返回 CodeWhale
session_export.rs
根目录 / crates / tui / src / session_export.rs
1 //! Full-fidelity session archive export (`tar.xz`).
2 //!
3 //! The interactive `/export` command renders a sanitized, lossy Markdown
4 //! transcript for humans. This module is the machine-facing counterpart: it
5 //! packs the durable session record into a compressed tar archive so a
6 //! complete session log — system prompt, every user and assistant message
7 //! including thinking blocks, tool calls and tool results, the branch
8 //! journal, approval receipts, and the session's artifacts — can be saved
9 //! with one command and restored later or attached to a bug report.
10 //!
11 //! Archive layout (format version 1):
12 //!
13 //! - `session.json` — the full [`SavedSession`] serialization, the same shape
14 //! as the on-disk session record. Extract it and open it with `/load` in
15 //! the TUI for a full-fidelity restore (system prompt included); note that
16 //! `/resume <file>` imports the conversation transcript only.
17 //! - `container.json` — the portable [`SessionImportContainer`] for
18 //! version-tolerant resume across schema changes.
19 //! - `artifacts/<...>` — the session-owned artifact directory, when present
20 //! and not excluded. Only regular files are archived; symlinks are skipped
21 //! and opened through the existing confined-file reader. Extracted files
22 //! remain separate; `/load` does not install them into the artifact store.
23 //! - `manifest.json` — archive format version, generator version, export
24 //! timestamp, session metadata, and the index of the preceding members.
25 //! Written last so its index covers everything above it.
26 //!
27 //! Contents are intentionally **not sanitized**: this is a full-fidelity log
28 //! for the session owner, unlike `/export`, which redacts secrets for
29 //! sharing. xz compression keeps verbose tool-heavy sessions small enough to
30 //! archive or attach.
31
32 use std::fs;
33 use std::io::{self, Read, Write};
34 use std::path::{Path, PathBuf};
35
36 use chrono::Utc;
37 use liblzma::write::XzEncoder;
38 use serde::Serialize;
39 use tar::Builder;
40
41 use crate::artifacts::{ARTIFACTS_DIR_NAME, is_valid_session_id};
42 use crate::fleet::files::WorkspaceFile;
43 use crate::session_manager::{SavedSession, SessionMetadata};
44 use crate::session_tree::SessionImportContainer;
45
46 /// Layout version of the exported archive. Bump when members change.
47 pub const SESSION_ARCHIVE_FORMAT_VERSION: u32 = 1;
48
49 /// Default xz compression preset (0–9), mirroring `xz -6`.
50 pub const DEFAULT_XZ_COMPRESSION_LEVEL: u32 = 6;
51
52 const SESSION_RECORD_MEMBER: &str = "session.json";
53 const SESSION_CONTAINER_MEMBER: &str = "container.json";
54 const ARCHIVE_MANIFEST_MEMBER: &str = "manifest.json";
55 const MAX_ARTIFACT_DEPTH: usize = 64;
56 const MAX_ARTIFACT_ENTRIES: usize = 100_000;
57
58 /// Options for [`write_session_archive`].
59 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60 pub struct SessionArchiveOptions {
61 /// Include the session-owned `artifacts/` directory when it exists.
62 pub include_artifacts: bool,
63 /// xz compression preset, 0 (fastest) through 9 (smallest).
64 pub compression_level: u32,
65 /// Replace the destination directory entry. Otherwise fail if it exists.
66 pub overwrite: bool,
67 }
68
69 impl Default for SessionArchiveOptions {
70 fn default() -> Self {
71 Self {
72 include_artifacts: true,
73 compression_level: DEFAULT_XZ_COMPRESSION_LEVEL,
74 overwrite: false,
75 }
76 }
77 }
78
79 /// One archive member reported in the manifest and [`SessionArchiveSummary`].
80 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
81 pub struct SessionArchiveMember {
82 /// Member path inside the archive, `/`-separated.
83 pub name: String,
84 /// Uncompressed member size in bytes.
85 pub bytes: u64,
86 }
87
88 /// Outcome of a successful [`write_session_archive`] call.
89 #[derive(Debug, Clone, Serialize)]
90 pub struct SessionArchiveSummary {
91 /// Path the `.tar.xz` archive was written to.
92 pub output: PathBuf,
93 /// Exported session id.
94 pub session_id: String,
95 /// [`SESSION_ARCHIVE_FORMAT_VERSION`] used for this archive.
96 pub archive_format_version: u32,
97 /// xz preset the archive was written with.
98 pub compression_level: u32,
99 /// Whether `artifacts/` members were included.
100 pub includes_artifacts: bool,
101 /// Member index in write order. `manifest.json` itself is excluded: it
102 /// is written last and indexes everything before it.
103 pub members: Vec<SessionArchiveMember>,
104 }
105
106 impl SessionArchiveSummary {
107 /// Sum of uncompressed member sizes.
108 pub fn total_member_bytes(&self) -> u64 {
109 self.members.iter().map(|member| member.bytes).sum()
110 }
111
112 /// Compressed archive size in bytes, or 0 if the file is unreadable.
113 pub fn compressed_bytes(&self) -> u64 {
114 fs::metadata(&self.output).map_or(0, |meta| meta.len())
115 }
116 }
117
118 #[derive(Serialize)]
119 struct ArchiveManifest {
120 archive_format_version: u32,
121 generator: &'static str,
122 generator_version: &'static str,
123 exported_at: String,
124 session: SessionMetadata,
125 members: Vec<SessionArchiveMember>,
126 }
127
128 /// Write one session as a full-fidelity `.tar.xz` archive.
129 ///
130 /// `session` is typically loaded with
131 /// [`SessionManager::load_session_snapshot`](crate::session_manager::SessionManager::load_session_snapshot)
132 /// so the archive reflects the durable record without applying resume-time
133 /// repair. `sessions_dir` is the trusted session store root; pass `None` (or clear
134 /// [`SessionArchiveOptions::include_artifacts`]) to export the transcript
135 /// only.
136 ///
137 /// The archive is streamed to a sibling temporary file and renamed into
138 /// place, so a failed export never leaves a truncated archive at `output`.
139 /// An existing `output` is replaced only when `options.overwrite` is set.
140 pub fn write_session_archive(
141 session: &SavedSession,
142 sessions_dir: Option<&Path>,
143 output: &Path,
144 options: SessionArchiveOptions,
145 ) -> io::Result<SessionArchiveSummary> {
146 if !is_valid_session_id(&session.metadata.id) {
147 return Err(io::Error::new(
148 io::ErrorKind::InvalidInput,
149 "invalid session id",
150 ));
151 }
152 if options.compression_level > 9 {
153 return Err(io::Error::new(
154 io::ErrorKind::InvalidInput,
155 format!(
156 "xz compression level {} is out of range 0-9",
157 options.compression_level
158 ),
159 ));
160 }
161 let session_json = session_json(session)?;
162 let container_json = container_json(session, sessions_dir)?;
163 let parent = output
164 .parent()
165 .filter(|parent| !parent.as_os_str().is_empty())
166 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
167 fs::create_dir_all(&parent)?;
168 let sessions_dir = sessions_dir.map(Path::canonicalize).transpose()?;
169 if let Some(root) = &sessions_dir
170 && parent.canonicalize()?.starts_with(root)
171 {
172 return Err(io::Error::new(
173 io::ErrorKind::InvalidInput,
174 "export output must be outside the session store",
175 ));
176 }
177 let artifact_files = match (options.include_artifacts, sessions_dir.as_deref()) {
178 (true, Some(root)) => match session_artifacts_dir(root, &session.metadata.id)? {
179 Some(dir) => collect_artifact_files(root, &dir)?,
180 None => Vec::new(),
181 },
182 _ => Vec::new(),
183 };
184 let mut temp = tempfile::Builder::new()
185 .prefix(".codewhale-session-export-")
186 .tempfile_in(&parent)?;
187
188 let mut summary = SessionArchiveSummary {
189 output: output.to_path_buf(),
190 session_id: session.metadata.id.clone(),
191 archive_format_version: SESSION_ARCHIVE_FORMAT_VERSION,
192 compression_level: options.compression_level,
193 includes_artifacts: !artifact_files.is_empty(),
194 members: Vec::new(),
195 };
196
197 {
198 let file = temp.as_file_mut();
199 let mut tar = Builder::new(XzEncoder::new(file, options.compression_level));
200 append_member(
201 &mut tar,
202 SESSION_RECORD_MEMBER,
203 MemberContents::Bytes(session_json.as_bytes()),
204 &mut summary.members,
205 )?;
206 append_member(
207 &mut tar,
208 SESSION_CONTAINER_MEMBER,
209 MemberContents::Bytes(container_json.as_bytes()),
210 &mut summary.members,
211 )?;
212 for (name, relative) in &artifact_files {
213 let root = sessions_dir
214 .as_deref()
215 .expect("artifact collection needs a store");
216 // Parent directories and the leaf are opened without following links.
217 // Stream this handle directly; never reopen a validated path.
218 let mut file = WorkspaceFile::open(root, relative, false)?.open_file()?;
219 append_member(
220 &mut tar,
221 name,
222 MemberContents::File(&mut file),
223 &mut summary.members,
224 )?;
225 }
226 let manifest_json = serde_json::to_string_pretty(&ArchiveManifest {
227 archive_format_version: SESSION_ARCHIVE_FORMAT_VERSION,
228 generator: env!("CARGO_PKG_NAME"),
229 generator_version: env!("CARGO_PKG_VERSION"),
230 exported_at: Utc::now().to_rfc3339(),
231 session: session.metadata.clone(),
232 members: summary.members.clone(),
233 })
234 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
235 append_member(
236 &mut tar,
237 ARCHIVE_MANIFEST_MEMBER,
238 MemberContents::Bytes(manifest_json.as_bytes()),
239 &mut Vec::new(),
240 )?;
241 let encoder = tar.into_inner()?;
242 let file = encoder.finish()?;
243 file.flush()?;
244 file.sync_all()?;
245 }
246 if options.overwrite {
247 temp.persist(output)
248 } else {
249 temp.persist_noclobber(output)
250 }
251 .map_err(|error| {
252 if error.error.kind() == io::ErrorKind::AlreadyExists {
253 io::Error::new(
254 io::ErrorKind::AlreadyExists,
255 format!(
256 "{} already exists; pass --force to overwrite it",
257 output.display()
258 ),
259 )
260 } else {
261 error.error
262 }
263 })?;
264
265 Ok(summary)
266 }
267
268 /// Directory holding this session's artifacts, when it exists. `session_id`
269 /// is re-checked against path traversal here because this helper is also the
270 /// boundary for callers that build the path from user-supplied ids.
271 pub fn session_artifacts_dir(sessions_dir: &Path, session_id: &str) -> io::Result<Option<PathBuf>> {
272 if !is_valid_session_id(session_id) {
273 return Err(io::Error::new(
274 io::ErrorKind::InvalidInput,
275 "invalid session id",
276 ));
277 }
278 let relative = Path::new(session_id).join(ARTIFACTS_DIR_NAME);
279 // Opening a confined file reference pins and validates its parent directory;
280 // this checks the artifact root without creating or opening the dummy leaf.
281 match WorkspaceFile::open(sessions_dir, &relative.join(".export-root-check"), false) {
282 Ok(_) => Ok(Some(sessions_dir.join(relative))),
283 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
284 Err(error) => Err(error),
285 }
286 }
287
288 /// Default archive file name for a session:
289 /// `codewhale-session-<short-id>.tar.xz`.
290 pub fn default_archive_file_name(metadata: &SessionMetadata) -> String {
291 format!(
292 "codewhale-session-{}.tar.xz",
293 crate::session_manager::truncate_id(&metadata.id)
294 )
295 }
296
297 fn session_json(session: &SavedSession) -> io::Result<String> {
298 serde_json::to_string_pretty(session)
299 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
300 }
301
302 /// The portable journal container. With a session store it also carries the
303 /// entries bounded saves moved into the journal archive (#6842), so an export
304 /// holds the full history, not only what the document still keeps.
305 fn container_json(session: &SavedSession, sessions_dir: Option<&Path>) -> io::Result<String> {
306 let mut container: SessionImportContainer = session.export_container("session-archive");
307 if let Some(root) = sessions_dir {
308 let archived = crate::session_manager::load_journal_archive(root, &session.metadata.id)?;
309 if !archived.is_empty() {
310 let present: std::collections::HashSet<String> =
311 container.entries.iter().map(|e| e.id.clone()).collect();
312 let mut entries: Vec<_> = archived
313 .into_iter()
314 .filter(|e| !present.contains(&e.id))
315 .collect();
316 entries.append(&mut container.entries);
317 container.entries = entries;
318 }
319 }
320 serde_json::to_string_pretty(&container)
321 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
322 }
323
324 /// Collect regular artifact files as `(archive member name, source path)`,
325 /// sorted by member name for stable ordering. Symlinks and other
326 /// non-regular entries are skipped so an export cannot reach outside the
327 /// session directory through a link.
328 fn collect_artifact_files(
329 sessions_dir: &Path,
330 artifacts_dir: &Path,
331 ) -> io::Result<Vec<(String, PathBuf)>> {
332 let mut files = Vec::new();
333 let mut entries = 0;
334 collect_artifact_files_recursive(
335 sessions_dir,
336 artifacts_dir,
337 ARTIFACTS_DIR_NAME,
338 0,
339 &mut entries,
340 &mut files,
341 )?;
342 files.sort_by(|left, right| left.0.cmp(&right.0));
343 Ok(files)
344 }
345
346 fn collect_artifact_files_recursive(
347 sessions_dir: &Path,
348 dir: &Path,
349 prefix: &str,
350 depth: usize,
351 entries: &mut usize,
352 files: &mut Vec<(String, PathBuf)>,
353 ) -> io::Result<()> {
354 if depth > MAX_ARTIFACT_DEPTH {
355 return Err(io::Error::new(
356 io::ErrorKind::InvalidData,
357 "artifact tree exceeds export depth limit",
358 ));
359 }
360 for entry in fs::read_dir(dir)? {
361 let entry = entry?;
362 *entries += 1;
363 if *entries > MAX_ARTIFACT_ENTRIES {
364 return Err(io::Error::new(
365 io::ErrorKind::InvalidData,
366 "artifact tree exceeds export entry limit",
367 ));
368 }
369 let file_type = entry.file_type()?;
370 if file_type.is_symlink() {
371 continue;
372 }
373 let child = entry.file_name();
374 let child = child
375 .to_str()
376 .filter(|name| !name.contains(['\\', ':']))
377 .ok_or_else(|| {
378 io::Error::new(
379 io::ErrorKind::InvalidData,
380 "artifact name is not portable UTF-8",
381 )
382 })?;
383 let member = format!("{prefix}/{child}");
384 let path = entry.path();
385 let relative = path.strip_prefix(sessions_dir).map_err(io::Error::other)?;
386 if file_type.is_dir() {
387 // Reject substituted parent directories before descending. The file
388 // read repeats confinement checks, so directory races cannot leak bytes.
389 WorkspaceFile::open(sessions_dir, &relative.join(".export-root-check"), false)?;
390 collect_artifact_files_recursive(
391 sessions_dir,
392 &path,
393 &member,
394 depth + 1,
395 entries,
396 files,
397 )?;
398 } else if file_type.is_file() {
399 files.push((member, relative.to_path_buf()));
400 }
401 }
402 Ok(())
403 }
404
405 fn archive_header(size: u64) -> tar::Header {
406 let mut header = tar::Header::new_gnu();
407 header.set_size(size);
408 header.set_mode(0o600);
409 header.set_mtime(Utc::now().timestamp().max(0) as u64);
410 header.set_cksum();
411 header
412 }
413
414 /// Payload for one archive member: in-memory bytes or a filesystem file
415 /// streamed straight into the tar.
416 enum MemberContents<'a> {
417 Bytes(&'a [u8]),
418 File(&'a mut fs::File),
419 }
420
421 /// Append exactly `expected` bytes of `reader` under `name`. The bounded read
422 /// keeps the tar header and the member payload consistent when the source
423 /// file changes size mid-export: growth is capped at the snapshot size
424 /// (valid archive, prefix content), while shrinkage — which would otherwise
425 /// silently shift every following header and corrupt the archive — fails the
426 /// export instead.
427 fn append_sized<W: Write, R: Read>(
428 tar: &mut Builder<W>,
429 header: &mut tar::Header,
430 name: &str,
431 reader: R,
432 expected: u64,
433 ) -> io::Result<()> {
434 let mut limited = reader.take(expected);
435 tar.append_data(header, name, &mut limited)?;
436 if limited.limit() != 0 {
437 return Err(io::Error::new(
438 io::ErrorKind::UnexpectedEof,
439 format!(
440 "member {name} ended before its recorded size of {expected} bytes; the source changed during export"
441 ),
442 ));
443 }
444 Ok(())
445 }
446
447 fn append_member<W: Write>(
448 tar: &mut Builder<W>,
449 name: &str,
450 contents: MemberContents<'_>,
451 members: &mut Vec<SessionArchiveMember>,
452 ) -> io::Result<()> {
453 match contents {
454 MemberContents::Bytes(bytes) => {
455 let mut header = archive_header(bytes.len() as u64);
456 tar.append_data(&mut header, name, bytes)?;
457 members.push(SessionArchiveMember {
458 name: name.to_string(),
459 bytes: bytes.len() as u64,
460 });
461 }
462 MemberContents::File(file) => {
463 let size = file.metadata()?.len();
464 let mut header = archive_header(size);
465 append_sized(tar, &mut header, name, file, size)?;
466 members.push(SessionArchiveMember {
467 name: name.to_string(),
468 bytes: size,
469 });
470 }
471 }
472 Ok(())
473 }
474
475 #[cfg(test)]
476 mod tests {
477 use super::*;
478 use crate::session_manager::create_saved_session;
479 use crate::session_tree::SessionJournal;
480 use codewhale_models::{ContentBlock, Message, Role};
481 use std::io::Read;
482
483 fn fixture_session() -> SavedSession {
484 let messages = vec![
485 Message {
486 role: Role::User,
487 content: vec![ContentBlock::Text {
488 text: "list the files in src".to_string(),
489 cache_control: None,
490 }],
491 },
492 Message {
493 role: Role::Assistant,
494 content: vec![
495 ContentBlock::thinking("I should run ls first"),
496 ContentBlock::ToolUse {
497 execution_id: None,
498 id: "toolu_01".to_string(),
499 name: "shell".to_string(),
500 input: serde_json::json!({ "command": "ls src" }),
501 caller: None,
502 thought_signature: None,
503 },
504 ],
505 },
506 Message {
507 role: Role::User,
508 content: vec![ContentBlock::ToolResult {
509 execution_id: None,
510 tool_use_id: "toolu_01".to_string(),
511 content: "main.rs\nlib.rs".to_string(),
512 is_error: None,
513 content_blocks: None,
514 }],
515 },
516 ];
517 let mut session = create_saved_session(
518 &messages,
519 "test-model",
520 Path::new("/tmp/archive-fixture"),
521 128,
522 None,
523 );
524 session.system_prompt = Some("You are a careful coding agent.".to_string());
525 session.journal = Some(SessionJournal::from_messages(messages, 0));
526 session
527 }
528
529 fn read_archive_members(path: &Path) -> Vec<(String, Vec<u8>)> {
530 let file = fs::File::open(path).expect("archive opens");
531 let mut archive = tar::Archive::new(liblzma::read::XzDecoder::new(file));
532 archive
533 .entries()
534 .expect("archive entries")
535 .map(|entry| {
536 let mut entry = entry.expect("entry");
537 let name = entry.path().expect("path").to_string_lossy().into_owned();
538 let mut bytes = Vec::new();
539 entry.read_to_end(&mut bytes).expect("member bytes");
540 (name, bytes)
541 })
542 .collect()
543 }
544
545 #[test]
546 fn forkguard_session_archive_export_roundtrips_full_context() {
547 let session = fixture_session();
548 let dir = tempfile::tempdir().expect("tempdir");
549 let output = dir.path().join("session.tar.xz");
550
551 let summary =
552 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
553 .expect("archive export");
554
555 assert_eq!(summary.session_id, session.metadata.id);
556 let mut members = read_archive_members(&output);
557 members.sort_by(|left, right| left.0.cmp(&right.0));
558 let names: Vec<&str> = members.iter().map(|(name, _)| name.as_str()).collect();
559 assert_eq!(
560 names,
561 vec!["container.json", "manifest.json", "session.json"]
562 );
563
564 // The raw session record restores the complete context: system
565 // prompt, thinking, tool call, and tool result.
566 let (_, session_bytes) = members
567 .iter()
568 .find(|(name, _)| name == SESSION_RECORD_MEMBER)
569 .expect("session.json member");
570 let restored: SavedSession =
571 serde_json::from_slice(session_bytes).expect("session.json parses");
572 assert_eq!(
573 restored.system_prompt.as_deref(),
574 Some("You are a careful coding agent.")
575 );
576 let mut saw_tool_use = false;
577 let mut saw_tool_result = false;
578 let mut saw_thinking = false;
579 for message in &restored.messages {
580 for block in &message.content {
581 match block {
582 ContentBlock::ToolUse { id, .. } => {
583 saw_tool_use = true;
584 assert_eq!(id, "toolu_01");
585 }
586 ContentBlock::ToolResult { tool_use_id, .. } => {
587 saw_tool_result = true;
588 assert_eq!(tool_use_id, "toolu_01");
589 }
590 ContentBlock::Thinking { .. } => saw_thinking = true,
591 _ => {}
592 }
593 }
594 }
595 assert!(saw_tool_use && saw_tool_result && saw_thinking);
596
597 // The portable container feeds the exact import path `/resume` uses
598 // for exported session JSON.
599 let (_, container_bytes) = members
600 .iter()
601 .find(|(name, _)| name == SESSION_CONTAINER_MEMBER)
602 .expect("container.json member");
603 let container = SessionImportContainer::from_json(
604 std::str::from_utf8(container_bytes).expect("container utf8"),
605 )
606 .expect("container parses");
607 let imported = SavedSession::import_foreign(
608 container,
609 PathBuf::from("/tmp/archive-fixture"),
610 "test-model".to_string(),
611 )
612 .expect("container imports");
613 assert!(
614 imported
615 .messages
616 .iter()
617 .any(|message| message.content.iter().any(
618 |block| matches!(block, ContentBlock::ToolUse { id, .. } if id == "toolu_01")
619 )),
620 "imported session keeps the tool call"
621 );
622 let manifest: serde_json::Value = serde_json::from_slice(
623 &members
624 .iter()
625 .find(|(name, _)| name == ARCHIVE_MANIFEST_MEMBER)
626 .expect("manifest member")
627 .1,
628 )
629 .expect("manifest parses");
630 assert_eq!(
631 manifest["archive_format_version"],
632 SESSION_ARCHIVE_FORMAT_VERSION
633 );
634 assert_eq!(manifest["session"]["id"], session.metadata.id);
635 }
636
637 #[test]
638 fn forkguard_session_archive_includes_artifacts_and_respects_skip() {
639 let session = fixture_session();
640 let dir = tempfile::tempdir().expect("tempdir");
641 let sessions_dir = dir.path().join("sessions");
642 let artifacts_dir = sessions_dir
643 .join(&session.metadata.id)
644 .join(ARTIFACTS_DIR_NAME);
645 fs::create_dir_all(&artifacts_dir).expect("artifacts dir");
646 fs::write(artifacts_dir.join("art_call-1.txt"), b"artifact body").expect("artifact");
647 assert!(
648 session_artifacts_dir(&sessions_dir, &session.metadata.id)
649 .unwrap()
650 .is_some(),
651 "artifacts dir is discovered for a valid session id"
652 );
653 assert!(
654 session_artifacts_dir(&sessions_dir, "../escape").is_err(),
655 "path traversal ids never resolve to an artifacts dir"
656 );
657
658 let with_artifacts = write_session_archive(
659 &session,
660 Some(&sessions_dir),
661 &dir.path().join("full.tar.xz"),
662 SessionArchiveOptions::default(),
663 )
664 .expect("archive with artifacts");
665 assert!(with_artifacts.includes_artifacts);
666 let names: Vec<&str> = with_artifacts
667 .members
668 .iter()
669 .map(|member| member.name.as_str())
670 .collect();
671 assert_eq!(
672 names,
673 vec!["session.json", "container.json", "artifacts/art_call-1.txt"]
674 );
675 let members = read_archive_members(&dir.path().join("full.tar.xz"));
676 let (_, artifact_bytes) = members
677 .iter()
678 .find(|(name, _)| name == "artifacts/art_call-1.txt")
679 .expect("artifact member");
680 assert_eq!(artifact_bytes, b"artifact body");
681
682 let transcript_only = write_session_archive(
683 &session,
684 Some(&sessions_dir),
685 &dir.path().join("lean.tar.xz"),
686 SessionArchiveOptions {
687 include_artifacts: false,
688 ..SessionArchiveOptions::default()
689 },
690 )
691 .expect("archive without artifacts");
692 assert!(!transcript_only.includes_artifacts);
693 assert!(
694 !transcript_only
695 .members
696 .iter()
697 .any(|member| member.name.starts_with(ARTIFACTS_DIR_NAME))
698 );
699 }
700
701 #[test]
702 fn forkguard_session_archive_rejects_artifact_shorter_than_recorded_size() {
703 // A member whose source shrank between stat and copy must fail the
704 // export instead of being zero-padded into a silently shifted
705 // archive (the growth direction is capped to the snapshot size).
706 let mut tar = Builder::new(Vec::new());
707 let mut header = archive_header(16);
708 let error = append_sized(
709 &mut tar,
710 &mut header,
711 "artifacts/shrinking.bin",
712 &b"only-nine"[..],
713 16,
714 )
715 .expect_err("short member must fail the export");
716 assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
717
718 let mut tar = Builder::new(Vec::new());
719 let mut header = archive_header(9);
720 append_sized(
721 &mut tar,
722 &mut header,
723 "artifacts/stable.bin",
724 &b"only-nine"[..],
725 9,
726 )
727 .expect("exact-size member succeeds");
728 }
729
730 #[test]
731 fn session_archive_rejects_out_of_range_compression_level() {
732 let session = fixture_session();
733 let dir = tempfile::tempdir().expect("tempdir");
734 let error = write_session_archive(
735 &session,
736 None,
737 &dir.path().join("out.tar.xz"),
738 SessionArchiveOptions {
739 compression_level: 10,
740 ..SessionArchiveOptions::default()
741 },
742 )
743 .expect_err("level 10 must be rejected");
744 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
745 }
746
747 #[test]
748 fn session_archive_replaces_existing_output_atomically() {
749 let session = fixture_session();
750 let dir = tempfile::tempdir().expect("tempdir");
751 let output = dir.path().join("out.tar.xz");
752 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
753 .expect("first export");
754 write_session_archive(
755 &session,
756 None,
757 &output,
758 SessionArchiveOptions {
759 overwrite: true,
760 ..SessionArchiveOptions::default()
761 },
762 )
763 .expect("second export replaces");
764 let members = read_archive_members(&output);
765 assert_eq!(members.len(), 3);
766 assert!(default_archive_file_name(&session.metadata).ends_with(".tar.xz"));
767 }
768
769 #[test]
770 fn session_archive_preserves_existing_output_without_force() {
771 let session = fixture_session();
772 let dir = tempfile::tempdir().unwrap();
773 let output = dir.path().join("out.tar.xz");
774 fs::write(&output, b"existing archive").unwrap();
775 let error =
776 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
777 .unwrap_err();
778 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
779 assert_eq!(fs::read(&output).unwrap(), b"existing archive");
780 assert_eq!(
781 fs::read_dir(dir.path()).unwrap().count(),
782 1,
783 "temporary file cleaned up"
784 );
785 }
786
787 #[test]
788 fn session_archive_rejects_output_inside_store_even_with_force() {
789 let session = fixture_session();
790 let dir = tempfile::tempdir().unwrap();
791 let output = dir.path().join(format!("{}.json", session.metadata.id));
792 fs::write(&output, b"durable session").unwrap();
793 let error = write_session_archive(
794 &session,
795 Some(dir.path()),
796 &output,
797 SessionArchiveOptions {
798 overwrite: true,
799 ..SessionArchiveOptions::default()
800 },
801 )
802 .unwrap_err();
803 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
804 assert_eq!(fs::read(&output).unwrap(), b"durable session");
805 }
806
807 #[test]
808 fn session_archive_prefix_snapshot_keeps_unfinished_tool_call_and_journal() {
809 use crate::session_manager::SessionManager;
810 let mut session = fixture_session();
811 session.messages.pop();
812 session.journal = Some(SessionJournal::from_messages(session.messages.clone(), 0));
813 let dir = tempfile::tempdir().unwrap();
814 let manager = SessionManager::new(dir.path().join("sessions")).unwrap();
815 manager.save_session(&session).unwrap();
816 let durable_path = manager
817 .sessions_dir()
818 .join(format!("{}.json", session.metadata.id));
819 let before = fs::read(&durable_path).unwrap();
820 let id = manager
821 .resolve_session_id_prefix(&session.metadata.id[..8])
822 .unwrap();
823 let snapshot = manager.load_session_snapshot(&id).unwrap();
824 let output = dir.path().join("out.tar.xz");
825 write_session_archive(
826 &snapshot,
827 Some(manager.sessions_dir()),
828 &output,
829 SessionArchiveOptions::default(),
830 )
831 .unwrap();
832 let members = read_archive_members(&output);
833 let bytes = &members
834 .iter()
835 .find(|(name, _)| name == SESSION_RECORD_MEMBER)
836 .unwrap()
837 .1;
838 let restored: SavedSession = serde_json::from_slice(bytes).unwrap();
839 assert_eq!(restored.messages, session.messages);
840 assert_eq!(
841 serde_json::to_value(restored.journal).unwrap(),
842 serde_json::to_value(session.journal).unwrap()
843 );
844 assert_eq!(
845 fs::read(durable_path).unwrap(),
846 before,
847 "export must not rewrite the durable record"
848 );
849 }
850
851 #[test]
852 fn session_archive_hard_link_fails_without_replacing_output() {
853 let session = fixture_session();
854 let dir = tempfile::tempdir().unwrap();
855 let sessions = dir.path().join("sessions");
856 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
857 fs::create_dir_all(&artifacts).unwrap();
858 let outside = dir.path().join("outside.txt");
859 fs::write(&outside, b"outside content").unwrap();
860 fs::hard_link(&outside, artifacts.join("linked.txt")).unwrap();
861 let output = dir.path().join("out.tar.xz");
862 fs::write(&output, b"original").unwrap();
863 assert!(
864 write_session_archive(
865 &session,
866 Some(&sessions),
867 &output,
868 SessionArchiveOptions {
869 overwrite: true,
870 ..SessionArchiveOptions::default()
871 }
872 )
873 .is_err()
874 );
875 assert_eq!(fs::read(output).unwrap(), b"original");
876 }
877
878 #[cfg(unix)]
879 #[test]
880 fn session_archive_rejects_linked_roots_and_skips_linked_members() {
881 use std::os::unix::fs::symlink;
882 let session = fixture_session();
883 let dir = tempfile::tempdir().unwrap();
884 let sessions = dir.path().join("sessions");
885 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
886 let outside = dir.path().join("outside");
887 fs::create_dir_all(&outside).unwrap();
888 fs::write(outside.join("secret.txt"), b"outside content").unwrap();
889 fs::create_dir_all(artifacts.parent().unwrap()).unwrap();
890 symlink(&outside, &artifacts).unwrap();
891 let output = dir.path().join("out.tar.xz");
892 assert!(
893 write_session_archive(
894 &session,
895 Some(&sessions),
896 &output,
897 SessionArchiveOptions::default()
898 )
899 .is_err()
900 );
901 assert!(!output.exists());
902 fs::remove_file(&artifacts).unwrap();
903 fs::create_dir(&artifacts).unwrap();
904 symlink(&outside, artifacts.join("directory-link")).unwrap();
905 symlink(outside.join("secret.txt"), artifacts.join("file-link")).unwrap();
906 fs::write(artifacts.join("regular.txt"), b"owned content").unwrap();
907 let summary = write_session_archive(
908 &session,
909 Some(&sessions),
910 &output,
911 SessionArchiveOptions::default(),
912 )
913 .unwrap();
914 assert_eq!(summary.members.len(), 3);
915 assert_eq!(summary.members[2].name, "artifacts/regular.txt");
916 assert!(
917 !read_archive_members(&output)
918 .iter()
919 .any(|(_, bytes)| bytes == b"outside content")
920 );
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn session_archive_dangling_output_link_does_not_bypass_no_clobber() {
926 use std::os::unix::fs::symlink;
927 let session = fixture_session();
928 let dir = tempfile::tempdir().unwrap();
929 let output = dir.path().join("out.tar.xz");
930 let target = dir.path().join("missing");
931 symlink(&target, &output).unwrap();
932 assert!(!output.exists());
933 let error =
934 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
935 .unwrap_err();
936 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
937 assert_eq!(fs::read_link(&output).unwrap(), target);
938 write_session_archive(
939 &session,
940 None,
941 &output,
942 SessionArchiveOptions {
943 overwrite: true,
944 ..SessionArchiveOptions::default()
945 },
946 )
947 .unwrap();
948 assert!(!target.exists());
949 assert_eq!(read_archive_members(&output).len(), 3);
950 }
951
952 // APFS rejects invalid UTF-8 at file creation; Linux filesystems admit it.
953 #[cfg(target_os = "linux")]
954 #[test]
955 fn session_archive_rejects_non_utf8_names_without_lossy_rename() {
956 use std::os::unix::ffi::OsStringExt;
957 let session = fixture_session();
958 let dir = tempfile::tempdir().unwrap();
959 let sessions = dir.path().join("sessions");
960 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
961 fs::create_dir_all(&artifacts).unwrap();
962 fs::write(
963 artifacts.join(std::ffi::OsString::from_vec(vec![0xff])),
964 b"owned content",
965 )
966 .unwrap();
967 let output = dir.path().join("out.tar.xz");
968 let error = write_session_archive(
969 &session,
970 Some(&sessions),
971 &output,
972 SessionArchiveOptions::default(),
973 )
974 .unwrap_err();
975 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
976 assert!(!output.exists());
977 }
978
979 #[cfg(unix)]
980 #[test]
981 fn session_archive_rejects_nonportable_member_names() {
982 let session = fixture_session();
983 let dir = tempfile::tempdir().unwrap();
984 let sessions = dir.path().join("sessions");
985 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
986 fs::create_dir_all(&artifacts).unwrap();
987 fs::write(artifacts.join("report:private"), b"owned content").unwrap();
988 let output = dir.path().join("out.tar.xz");
989 let error = write_session_archive(
990 &session,
991 Some(&sessions),
992 &output,
993 SessionArchiveOptions::default(),
994 )
995 .unwrap_err();
996 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
997 assert!(!output.exists());
998 }
999
1000 #[cfg(unix)]
1001 #[test]
1002 fn session_archive_and_extracted_members_are_owner_only() {
1003 use std::os::unix::fs::PermissionsExt;
1004 let session = fixture_session();
1005 let dir = tempfile::tempdir().unwrap();
1006 let output = dir.path().join("out.tar.xz");
1007 write_session_archive(&session, None, &output, SessionArchiveOptions::default()).unwrap();
1008 assert_eq!(
1009 fs::metadata(&output).unwrap().permissions().mode() & 0o777,
1010 0o600
1011 );
1012 let mut archive = tar::Archive::new(liblzma::read::XzDecoder::new(
1013 fs::File::open(&output).unwrap(),
1014 ));
1015 let extracted = dir.path().join("extracted");
1016 archive.unpack(&extracted).unwrap();
1017 for name in [
1018 SESSION_RECORD_MEMBER,
1019 SESSION_CONTAINER_MEMBER,
1020 ARCHIVE_MANIFEST_MEMBER,
1021 ] {
1022 assert_eq!(
1023 fs::metadata(extracted.join(name))
1024 .unwrap()
1025 .permissions()
1026 .mode()
1027 & 0o777,
1028 0o600
1029 );
1030 }
1031 }
1032
1033 /// #6842: entries a bounded save archived travel with the export.
1034 #[test]
1035 fn container_includes_journal_archive_entries() {
1036 let dir = tempfile::tempdir().expect("tempdir");
1037 let sessions = dir.path().join("sessions");
1038 let session = fixture_session();
1039 let journal = session.journal.clone().expect("journal");
1040 let root = journal.entries[0].id.clone();
1041 let mut archived = journal.clone();
1042 archived.branch_to(&root).expect("branch");
1043 let extra = archived.append_message(Message {
1044 role: Role::User,
1045 content: vec![ContentBlock::Text {
1046 text: "archived alternative".to_string(),
1047 cache_control: None,
1048 }],
1049 });
1050 let entry = archived.entries.last().expect("entry").clone();
1051 assert_eq!(entry.id, extra);
1052 let archive_dir = sessions.join(crate::session_manager::JOURNAL_ARCHIVE_DIR);
1053 fs::create_dir_all(&archive_dir).expect("archive dir");
1054 let path = archive_dir.join(format!("{}.jsonl", session.metadata.id));
1055 fs::write(
1056 &path,
1057 format!("{}\n", serde_json::to_string(&entry).unwrap()),
1058 )
1059 .unwrap();
1060 #[cfg(unix)]
1061 {
1062 use std::os::unix::fs::PermissionsExt as _;
1063 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1064 }
1065
1066 let without: SessionImportContainer =
1067 serde_json::from_str(&container_json(&session, None).unwrap()).unwrap();
1068 let with: SessionImportContainer =
1069 serde_json::from_str(&container_json(&session, Some(&sessions)).unwrap()).unwrap();
1070 assert_eq!(with.entries.len(), without.entries.len() + 1);
1071 assert!(with.entries.iter().any(|e| e.id == extra));
1072 with.into_journal().expect("merged container validates");
1073 }
1074 }
1075
1075 lines RUST