| 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 |