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