返回 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)?;
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 fn container_json(session: &SavedSession) -> io::Result<String> {
303 let container: SessionImportContainer = session.export_container("session-archive");
304 serde_json::to_string_pretty(&container)
305 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
306 }
307
308 /// Collect regular artifact files as `(archive member name, source path)`,
309 /// sorted by member name for stable ordering. Symlinks and other
310 /// non-regular entries are skipped so an export cannot reach outside the
311 /// session directory through a link.
312 fn collect_artifact_files(
313 sessions_dir: &Path,
314 artifacts_dir: &Path,
315 ) -> io::Result<Vec<(String, PathBuf)>> {
316 let mut files = Vec::new();
317 let mut entries = 0;
318 collect_artifact_files_recursive(
319 sessions_dir,
320 artifacts_dir,
321 ARTIFACTS_DIR_NAME,
322 0,
323 &mut entries,
324 &mut files,
325 )?;
326 files.sort_by(|left, right| left.0.cmp(&right.0));
327 Ok(files)
328 }
329
330 fn collect_artifact_files_recursive(
331 sessions_dir: &Path,
332 dir: &Path,
333 prefix: &str,
334 depth: usize,
335 entries: &mut usize,
336 files: &mut Vec<(String, PathBuf)>,
337 ) -> io::Result<()> {
338 if depth > MAX_ARTIFACT_DEPTH {
339 return Err(io::Error::new(
340 io::ErrorKind::InvalidData,
341 "artifact tree exceeds export depth limit",
342 ));
343 }
344 for entry in fs::read_dir(dir)? {
345 let entry = entry?;
346 *entries += 1;
347 if *entries > MAX_ARTIFACT_ENTRIES {
348 return Err(io::Error::new(
349 io::ErrorKind::InvalidData,
350 "artifact tree exceeds export entry limit",
351 ));
352 }
353 let file_type = entry.file_type()?;
354 if file_type.is_symlink() {
355 continue;
356 }
357 let child = entry.file_name();
358 let child = child
359 .to_str()
360 .filter(|name| !name.contains(['\\', ':']))
361 .ok_or_else(|| {
362 io::Error::new(
363 io::ErrorKind::InvalidData,
364 "artifact name is not portable UTF-8",
365 )
366 })?;
367 let member = format!("{prefix}/{child}");
368 let path = entry.path();
369 let relative = path.strip_prefix(sessions_dir).map_err(io::Error::other)?;
370 if file_type.is_dir() {
371 // Reject substituted parent directories before descending. The file
372 // read repeats confinement checks, so directory races cannot leak bytes.
373 WorkspaceFile::open(sessions_dir, &relative.join(".export-root-check"), false)?;
374 collect_artifact_files_recursive(
375 sessions_dir,
376 &path,
377 &member,
378 depth + 1,
379 entries,
380 files,
381 )?;
382 } else if file_type.is_file() {
383 files.push((member, relative.to_path_buf()));
384 }
385 }
386 Ok(())
387 }
388
389 fn archive_header(size: u64) -> tar::Header {
390 let mut header = tar::Header::new_gnu();
391 header.set_size(size);
392 header.set_mode(0o600);
393 header.set_mtime(Utc::now().timestamp().max(0) as u64);
394 header.set_cksum();
395 header
396 }
397
398 /// Payload for one archive member: in-memory bytes or a filesystem file
399 /// streamed straight into the tar.
400 enum MemberContents<'a> {
401 Bytes(&'a [u8]),
402 File(&'a mut fs::File),
403 }
404
405 /// Append exactly `expected` bytes of `reader` under `name`. The bounded read
406 /// keeps the tar header and the member payload consistent when the source
407 /// file changes size mid-export: growth is capped at the snapshot size
408 /// (valid archive, prefix content), while shrinkage — which would otherwise
409 /// silently shift every following header and corrupt the archive — fails the
410 /// export instead.
411 fn append_sized<W: Write, R: Read>(
412 tar: &mut Builder<W>,
413 header: &mut tar::Header,
414 name: &str,
415 reader: R,
416 expected: u64,
417 ) -> io::Result<()> {
418 let mut limited = reader.take(expected);
419 tar.append_data(header, name, &mut limited)?;
420 if limited.limit() != 0 {
421 return Err(io::Error::new(
422 io::ErrorKind::UnexpectedEof,
423 format!(
424 "member {name} ended before its recorded size of {expected} bytes; the source changed during export"
425 ),
426 ));
427 }
428 Ok(())
429 }
430
431 fn append_member<W: Write>(
432 tar: &mut Builder<W>,
433 name: &str,
434 contents: MemberContents<'_>,
435 members: &mut Vec<SessionArchiveMember>,
436 ) -> io::Result<()> {
437 match contents {
438 MemberContents::Bytes(bytes) => {
439 let mut header = archive_header(bytes.len() as u64);
440 tar.append_data(&mut header, name, bytes)?;
441 members.push(SessionArchiveMember {
442 name: name.to_string(),
443 bytes: bytes.len() as u64,
444 });
445 }
446 MemberContents::File(file) => {
447 let size = file.metadata()?.len();
448 let mut header = archive_header(size);
449 append_sized(tar, &mut header, name, file, size)?;
450 members.push(SessionArchiveMember {
451 name: name.to_string(),
452 bytes: size,
453 });
454 }
455 }
456 Ok(())
457 }
458
459 #[cfg(test)]
460 mod tests {
461 use super::*;
462 use crate::session_manager::create_saved_session;
463 use crate::session_tree::SessionJournal;
464 use codewhale_models::{ContentBlock, Message, Role};
465 use std::io::Read;
466
467 fn fixture_session() -> SavedSession {
468 let messages = vec![
469 Message {
470 role: Role::User,
471 content: vec![ContentBlock::Text {
472 text: "list the files in src".to_string(),
473 cache_control: None,
474 }],
475 },
476 Message {
477 role: Role::Assistant,
478 content: vec![
479 ContentBlock::thinking("I should run ls first"),
480 ContentBlock::ToolUse {
481 id: "toolu_01".to_string(),
482 name: "shell".to_string(),
483 input: serde_json::json!({ "command": "ls src" }),
484 caller: None,
485 thought_signature: None,
486 },
487 ],
488 },
489 Message {
490 role: Role::User,
491 content: vec![ContentBlock::ToolResult {
492 tool_use_id: "toolu_01".to_string(),
493 content: "main.rs\nlib.rs".to_string(),
494 is_error: None,
495 content_blocks: None,
496 }],
497 },
498 ];
499 let mut session = create_saved_session(
500 &messages,
501 "test-model",
502 Path::new("/tmp/archive-fixture"),
503 128,
504 None,
505 );
506 session.system_prompt = Some("You are a careful coding agent.".to_string());
507 session.journal = Some(SessionJournal::from_messages(messages, 0));
508 session
509 }
510
511 fn read_archive_members(path: &Path) -> Vec<(String, Vec<u8>)> {
512 let file = fs::File::open(path).expect("archive opens");
513 let mut archive = tar::Archive::new(liblzma::read::XzDecoder::new(file));
514 archive
515 .entries()
516 .expect("archive entries")
517 .map(|entry| {
518 let mut entry = entry.expect("entry");
519 let name = entry.path().expect("path").to_string_lossy().into_owned();
520 let mut bytes = Vec::new();
521 entry.read_to_end(&mut bytes).expect("member bytes");
522 (name, bytes)
523 })
524 .collect()
525 }
526
527 #[test]
528 fn forkguard_session_archive_export_roundtrips_full_context() {
529 let session = fixture_session();
530 let dir = tempfile::tempdir().expect("tempdir");
531 let output = dir.path().join("session.tar.xz");
532
533 let summary =
534 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
535 .expect("archive export");
536
537 assert_eq!(summary.session_id, session.metadata.id);
538 let mut members = read_archive_members(&output);
539 members.sort_by(|left, right| left.0.cmp(&right.0));
540 let names: Vec<&str> = members.iter().map(|(name, _)| name.as_str()).collect();
541 assert_eq!(
542 names,
543 vec!["container.json", "manifest.json", "session.json"]
544 );
545
546 // The raw session record restores the complete context: system
547 // prompt, thinking, tool call, and tool result.
548 let (_, session_bytes) = members
549 .iter()
550 .find(|(name, _)| name == SESSION_RECORD_MEMBER)
551 .expect("session.json member");
552 let restored: SavedSession =
553 serde_json::from_slice(session_bytes).expect("session.json parses");
554 assert_eq!(
555 restored.system_prompt.as_deref(),
556 Some("You are a careful coding agent.")
557 );
558 let mut saw_tool_use = false;
559 let mut saw_tool_result = false;
560 let mut saw_thinking = false;
561 for message in &restored.messages {
562 for block in &message.content {
563 match block {
564 ContentBlock::ToolUse { id, .. } => {
565 saw_tool_use = true;
566 assert_eq!(id, "toolu_01");
567 }
568 ContentBlock::ToolResult { tool_use_id, .. } => {
569 saw_tool_result = true;
570 assert_eq!(tool_use_id, "toolu_01");
571 }
572 ContentBlock::Thinking { .. } => saw_thinking = true,
573 _ => {}
574 }
575 }
576 }
577 assert!(saw_tool_use && saw_tool_result && saw_thinking);
578
579 // The portable container feeds the exact import path `/resume` uses
580 // for exported session JSON.
581 let (_, container_bytes) = members
582 .iter()
583 .find(|(name, _)| name == SESSION_CONTAINER_MEMBER)
584 .expect("container.json member");
585 let container = SessionImportContainer::from_json(
586 std::str::from_utf8(container_bytes).expect("container utf8"),
587 )
588 .expect("container parses");
589 let imported = SavedSession::import_foreign(
590 container,
591 PathBuf::from("/tmp/archive-fixture"),
592 "test-model".to_string(),
593 )
594 .expect("container imports");
595 assert!(
596 imported
597 .messages
598 .iter()
599 .any(|message| message.content.iter().any(
600 |block| matches!(block, ContentBlock::ToolUse { id, .. } if id == "toolu_01")
601 )),
602 "imported session keeps the tool call"
603 );
604 let manifest: serde_json::Value = serde_json::from_slice(
605 &members
606 .iter()
607 .find(|(name, _)| name == ARCHIVE_MANIFEST_MEMBER)
608 .expect("manifest member")
609 .1,
610 )
611 .expect("manifest parses");
612 assert_eq!(
613 manifest["archive_format_version"],
614 SESSION_ARCHIVE_FORMAT_VERSION
615 );
616 assert_eq!(manifest["session"]["id"], session.metadata.id);
617 }
618
619 #[test]
620 fn forkguard_session_archive_includes_artifacts_and_respects_skip() {
621 let session = fixture_session();
622 let dir = tempfile::tempdir().expect("tempdir");
623 let sessions_dir = dir.path().join("sessions");
624 let artifacts_dir = sessions_dir
625 .join(&session.metadata.id)
626 .join(ARTIFACTS_DIR_NAME);
627 fs::create_dir_all(&artifacts_dir).expect("artifacts dir");
628 fs::write(artifacts_dir.join("art_call-1.txt"), b"artifact body").expect("artifact");
629 assert!(
630 session_artifacts_dir(&sessions_dir, &session.metadata.id)
631 .unwrap()
632 .is_some(),
633 "artifacts dir is discovered for a valid session id"
634 );
635 assert!(
636 session_artifacts_dir(&sessions_dir, "../escape").is_err(),
637 "path traversal ids never resolve to an artifacts dir"
638 );
639
640 let with_artifacts = write_session_archive(
641 &session,
642 Some(&sessions_dir),
643 &dir.path().join("full.tar.xz"),
644 SessionArchiveOptions::default(),
645 )
646 .expect("archive with artifacts");
647 assert!(with_artifacts.includes_artifacts);
648 let names: Vec<&str> = with_artifacts
649 .members
650 .iter()
651 .map(|member| member.name.as_str())
652 .collect();
653 assert_eq!(
654 names,
655 vec!["session.json", "container.json", "artifacts/art_call-1.txt"]
656 );
657 let members = read_archive_members(&dir.path().join("full.tar.xz"));
658 let (_, artifact_bytes) = members
659 .iter()
660 .find(|(name, _)| name == "artifacts/art_call-1.txt")
661 .expect("artifact member");
662 assert_eq!(artifact_bytes, b"artifact body");
663
664 let transcript_only = write_session_archive(
665 &session,
666 Some(&sessions_dir),
667 &dir.path().join("lean.tar.xz"),
668 SessionArchiveOptions {
669 include_artifacts: false,
670 ..SessionArchiveOptions::default()
671 },
672 )
673 .expect("archive without artifacts");
674 assert!(!transcript_only.includes_artifacts);
675 assert!(
676 !transcript_only
677 .members
678 .iter()
679 .any(|member| member.name.starts_with(ARTIFACTS_DIR_NAME))
680 );
681 }
682
683 #[test]
684 fn forkguard_session_archive_rejects_artifact_shorter_than_recorded_size() {
685 // A member whose source shrank between stat and copy must fail the
686 // export instead of being zero-padded into a silently shifted
687 // archive (the growth direction is capped to the snapshot size).
688 let mut tar = Builder::new(Vec::new());
689 let mut header = archive_header(16);
690 let error = append_sized(
691 &mut tar,
692 &mut header,
693 "artifacts/shrinking.bin",
694 &b"only-nine"[..],
695 16,
696 )
697 .expect_err("short member must fail the export");
698 assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
699
700 let mut tar = Builder::new(Vec::new());
701 let mut header = archive_header(9);
702 append_sized(
703 &mut tar,
704 &mut header,
705 "artifacts/stable.bin",
706 &b"only-nine"[..],
707 9,
708 )
709 .expect("exact-size member succeeds");
710 }
711
712 #[test]
713 fn session_archive_rejects_out_of_range_compression_level() {
714 let session = fixture_session();
715 let dir = tempfile::tempdir().expect("tempdir");
716 let error = write_session_archive(
717 &session,
718 None,
719 &dir.path().join("out.tar.xz"),
720 SessionArchiveOptions {
721 compression_level: 10,
722 ..SessionArchiveOptions::default()
723 },
724 )
725 .expect_err("level 10 must be rejected");
726 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
727 }
728
729 #[test]
730 fn session_archive_replaces_existing_output_atomically() {
731 let session = fixture_session();
732 let dir = tempfile::tempdir().expect("tempdir");
733 let output = dir.path().join("out.tar.xz");
734 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
735 .expect("first export");
736 write_session_archive(
737 &session,
738 None,
739 &output,
740 SessionArchiveOptions {
741 overwrite: true,
742 ..SessionArchiveOptions::default()
743 },
744 )
745 .expect("second export replaces");
746 let members = read_archive_members(&output);
747 assert_eq!(members.len(), 3);
748 assert!(default_archive_file_name(&session.metadata).ends_with(".tar.xz"));
749 }
750
751 #[test]
752 fn session_archive_preserves_existing_output_without_force() {
753 let session = fixture_session();
754 let dir = tempfile::tempdir().unwrap();
755 let output = dir.path().join("out.tar.xz");
756 fs::write(&output, b"existing archive").unwrap();
757 let error =
758 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
759 .unwrap_err();
760 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
761 assert_eq!(fs::read(&output).unwrap(), b"existing archive");
762 assert_eq!(
763 fs::read_dir(dir.path()).unwrap().count(),
764 1,
765 "temporary file cleaned up"
766 );
767 }
768
769 #[test]
770 fn session_archive_rejects_output_inside_store_even_with_force() {
771 let session = fixture_session();
772 let dir = tempfile::tempdir().unwrap();
773 let output = dir.path().join(format!("{}.json", session.metadata.id));
774 fs::write(&output, b"durable session").unwrap();
775 let error = write_session_archive(
776 &session,
777 Some(dir.path()),
778 &output,
779 SessionArchiveOptions {
780 overwrite: true,
781 ..SessionArchiveOptions::default()
782 },
783 )
784 .unwrap_err();
785 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
786 assert_eq!(fs::read(&output).unwrap(), b"durable session");
787 }
788
789 #[test]
790 fn session_archive_prefix_snapshot_keeps_unfinished_tool_call_and_journal() {
791 use crate::session_manager::SessionManager;
792 let mut session = fixture_session();
793 session.messages.pop();
794 session.journal = Some(SessionJournal::from_messages(session.messages.clone(), 0));
795 let dir = tempfile::tempdir().unwrap();
796 let manager = SessionManager::new(dir.path().join("sessions")).unwrap();
797 manager.save_session(&session).unwrap();
798 let durable_path = manager
799 .sessions_dir()
800 .join(format!("{}.json", session.metadata.id));
801 let before = fs::read(&durable_path).unwrap();
802 let id = manager
803 .resolve_session_id_prefix(&session.metadata.id[..8])
804 .unwrap();
805 let snapshot = manager.load_session_snapshot(&id).unwrap();
806 let output = dir.path().join("out.tar.xz");
807 write_session_archive(
808 &snapshot,
809 Some(manager.sessions_dir()),
810 &output,
811 SessionArchiveOptions::default(),
812 )
813 .unwrap();
814 let members = read_archive_members(&output);
815 let bytes = &members
816 .iter()
817 .find(|(name, _)| name == SESSION_RECORD_MEMBER)
818 .unwrap()
819 .1;
820 let restored: SavedSession = serde_json::from_slice(bytes).unwrap();
821 assert_eq!(restored.messages, session.messages);
822 assert_eq!(
823 serde_json::to_value(restored.journal).unwrap(),
824 serde_json::to_value(session.journal).unwrap()
825 );
826 assert_eq!(
827 fs::read(durable_path).unwrap(),
828 before,
829 "export must not rewrite the durable record"
830 );
831 }
832
833 #[test]
834 fn session_archive_hard_link_fails_without_replacing_output() {
835 let session = fixture_session();
836 let dir = tempfile::tempdir().unwrap();
837 let sessions = dir.path().join("sessions");
838 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
839 fs::create_dir_all(&artifacts).unwrap();
840 let outside = dir.path().join("outside.txt");
841 fs::write(&outside, b"outside content").unwrap();
842 fs::hard_link(&outside, artifacts.join("linked.txt")).unwrap();
843 let output = dir.path().join("out.tar.xz");
844 fs::write(&output, b"original").unwrap();
845 assert!(
846 write_session_archive(
847 &session,
848 Some(&sessions),
849 &output,
850 SessionArchiveOptions {
851 overwrite: true,
852 ..SessionArchiveOptions::default()
853 }
854 )
855 .is_err()
856 );
857 assert_eq!(fs::read(output).unwrap(), b"original");
858 }
859
860 #[cfg(unix)]
861 #[test]
862 fn session_archive_rejects_linked_roots_and_skips_linked_members() {
863 use std::os::unix::fs::symlink;
864 let session = fixture_session();
865 let dir = tempfile::tempdir().unwrap();
866 let sessions = dir.path().join("sessions");
867 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
868 let outside = dir.path().join("outside");
869 fs::create_dir_all(&outside).unwrap();
870 fs::write(outside.join("secret.txt"), b"outside content").unwrap();
871 fs::create_dir_all(artifacts.parent().unwrap()).unwrap();
872 symlink(&outside, &artifacts).unwrap();
873 let output = dir.path().join("out.tar.xz");
874 assert!(
875 write_session_archive(
876 &session,
877 Some(&sessions),
878 &output,
879 SessionArchiveOptions::default()
880 )
881 .is_err()
882 );
883 assert!(!output.exists());
884 fs::remove_file(&artifacts).unwrap();
885 fs::create_dir(&artifacts).unwrap();
886 symlink(&outside, artifacts.join("directory-link")).unwrap();
887 symlink(outside.join("secret.txt"), artifacts.join("file-link")).unwrap();
888 fs::write(artifacts.join("regular.txt"), b"owned content").unwrap();
889 let summary = write_session_archive(
890 &session,
891 Some(&sessions),
892 &output,
893 SessionArchiveOptions::default(),
894 )
895 .unwrap();
896 assert_eq!(summary.members.len(), 3);
897 assert_eq!(summary.members[2].name, "artifacts/regular.txt");
898 assert!(
899 !read_archive_members(&output)
900 .iter()
901 .any(|(_, bytes)| bytes == b"outside content")
902 );
903 }
904
905 #[cfg(unix)]
906 #[test]
907 fn session_archive_dangling_output_link_does_not_bypass_no_clobber() {
908 use std::os::unix::fs::symlink;
909 let session = fixture_session();
910 let dir = tempfile::tempdir().unwrap();
911 let output = dir.path().join("out.tar.xz");
912 let target = dir.path().join("missing");
913 symlink(&target, &output).unwrap();
914 assert!(!output.exists());
915 let error =
916 write_session_archive(&session, None, &output, SessionArchiveOptions::default())
917 .unwrap_err();
918 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
919 assert_eq!(fs::read_link(&output).unwrap(), target);
920 write_session_archive(
921 &session,
922 None,
923 &output,
924 SessionArchiveOptions {
925 overwrite: true,
926 ..SessionArchiveOptions::default()
927 },
928 )
929 .unwrap();
930 assert!(!target.exists());
931 assert_eq!(read_archive_members(&output).len(), 3);
932 }
933
934 // APFS rejects invalid UTF-8 at file creation; Linux filesystems admit it.
935 #[cfg(target_os = "linux")]
936 #[test]
937 fn session_archive_rejects_non_utf8_names_without_lossy_rename() {
938 use std::os::unix::ffi::OsStringExt;
939 let session = fixture_session();
940 let dir = tempfile::tempdir().unwrap();
941 let sessions = dir.path().join("sessions");
942 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
943 fs::create_dir_all(&artifacts).unwrap();
944 fs::write(
945 artifacts.join(std::ffi::OsString::from_vec(vec![0xff])),
946 b"owned content",
947 )
948 .unwrap();
949 let output = dir.path().join("out.tar.xz");
950 let error = write_session_archive(
951 &session,
952 Some(&sessions),
953 &output,
954 SessionArchiveOptions::default(),
955 )
956 .unwrap_err();
957 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
958 assert!(!output.exists());
959 }
960
961 #[cfg(unix)]
962 #[test]
963 fn session_archive_rejects_nonportable_member_names() {
964 let session = fixture_session();
965 let dir = tempfile::tempdir().unwrap();
966 let sessions = dir.path().join("sessions");
967 let artifacts = sessions.join(&session.metadata.id).join(ARTIFACTS_DIR_NAME);
968 fs::create_dir_all(&artifacts).unwrap();
969 fs::write(artifacts.join("report:private"), b"owned content").unwrap();
970 let output = dir.path().join("out.tar.xz");
971 let error = write_session_archive(
972 &session,
973 Some(&sessions),
974 &output,
975 SessionArchiveOptions::default(),
976 )
977 .unwrap_err();
978 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
979 assert!(!output.exists());
980 }
981
982 #[cfg(unix)]
983 #[test]
984 fn session_archive_and_extracted_members_are_owner_only() {
985 use std::os::unix::fs::PermissionsExt;
986 let session = fixture_session();
987 let dir = tempfile::tempdir().unwrap();
988 let output = dir.path().join("out.tar.xz");
989 write_session_archive(&session, None, &output, SessionArchiveOptions::default()).unwrap();
990 assert_eq!(
991 fs::metadata(&output).unwrap().permissions().mode() & 0o777,
992 0o600
993 );
994 let mut archive = tar::Archive::new(liblzma::read::XzDecoder::new(
995 fs::File::open(&output).unwrap(),
996 ));
997 let extracted = dir.path().join("extracted");
998 archive.unpack(&extracted).unwrap();
999 for name in [
1000 SESSION_RECORD_MEMBER,
1001 SESSION_CONTAINER_MEMBER,
1002 ARCHIVE_MANIFEST_MEMBER,
1003 ] {
1004 assert_eq!(
1005 fs::metadata(extracted.join(name))
1006 .unwrap()
1007 .permissions()
1008 .mode()
1009 & 0o777,
1010 0o600
1011 );
1012 }
1013 }
1014 }
1015
1015 lines RUST