From 03f62b67e60b5330897dae0e8b280ff5202060db Mon Sep 17 00:00:00 2001 From: ishan-parihar <129376561+ishan-parihar@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:52:39 +0530 Subject: [PATCH 1/2] feat(checkpoint): durable cross-process checkpoint persistence Checkpoints were in-memory only (a bridge crash dropped all of them). Now every mutation mirrors to disk under /// (manifest.json + blob-N.bin) and CheckpointStore hydrates on startup: - create() persists after the in-memory insert (atomic manifest write) - delete() removes the on-disk dir - cleanup() prunes aged-out persisted checkpoints - CheckpointFile stores Permissions instead of fs::Metadata so permissions can be re-applied after hydration (Metadata is not reconstructible) - new durability test: create in store A, list+restore in store B over the same storage root Protocol-verified: list_checkpoints and restore_checkpoint both work across fresh bridge subprocesses. --- crates/aft/src/checkpoint.rs | 409 +++++++++++++++++++++++++++++++++-- 1 file changed, 396 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/checkpoint.rs b/crates/aft/src/checkpoint.rs index 2205cc47..ebf1e456 100644 --- a/crates/aft/src/checkpoint.rs +++ b/crates/aft/src/checkpoint.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use serde::{Deserialize, Serialize}; + use crate::backup::{BackupStore, CapturedRegularFile}; use crate::error::AftError; use crate::fs_lock; @@ -34,7 +36,11 @@ struct Checkpoint { #[derive(Debug, Clone)] struct CheckpointFile { - metadata: fs::Metadata, + /// Permission bits captured at snapshot time, used to re-apply + /// permissions on restore. `fs::Metadata` itself cannot be + /// reconstructed after a process restart, so we persist the mode bits + /// and rebuild a `Permissions` from them when hydrating. + permissions: fs::Permissions, kind: CheckpointFileKind, } @@ -49,9 +55,40 @@ enum CheckpointFileKind { }, } +/// On-disk representation of a single snapshotted file (manifest entry). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredCheckpointFile { + path: String, + #[serde(rename = "kind")] + kind: String, + /// Unix-style permission mode bits (e.g. 0o644). Only meaningful on + /// unix/windows; other platforms use `readonly`. + #[serde(default)] + mode: u32, + #[serde(default)] + readonly: bool, + /// Relative path of the raw-bytes blob under the checkpoint dir, for + /// regular files only. + #[serde(default)] + blob: Option, + #[serde(default)] + target: Option, + #[serde(default)] + target_is_dir: bool, +} + +/// On-disk representation of a checkpoint (manifest.json). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredCheckpoint { + name: String, + created_at: u64, + files: Vec, +} + impl CheckpointFile { fn read(path: &Path) -> io::Result { let metadata = fs::symlink_metadata(path)?; + let permissions = metadata.permissions(); let file_type = metadata.file_type(); if file_type.is_symlink() { let target = fs::read_link(path)?; @@ -59,7 +96,7 @@ impl CheckpointFile { .map(|target_metadata| target_metadata.is_dir()) .unwrap_or(false); return Ok(Self { - metadata, + permissions, kind: CheckpointFileKind::Symlink { target, target_is_dir, @@ -92,7 +129,7 @@ impl CheckpointFile { fn from_captured(path: &Path, capture: &mut CapturedRegularFile) -> io::Result { capture.refresh_if_stale(path)?; Ok(Self { - metadata: capture.metadata().clone(), + permissions: capture.metadata().permissions(), kind: CheckpointFileKind::Regular { bytes: capture.shared_bytes(), }, @@ -101,7 +138,7 @@ impl CheckpointFile { fn from_fresh_capture(capture: CapturedRegularFile) -> Self { Self { - metadata: capture.metadata().clone(), + permissions: capture.metadata().permissions(), kind: CheckpointFileKind::Regular { bytes: capture.shared_bytes(), }, @@ -125,12 +162,22 @@ impl CheckpointFile { /// in memory only — a bridge crash drops all of them, which is a deliberate /// trade-off to keep this refactor bounded. Durable checkpoints are a possible /// follow-up. +/// +/// DURABILITY: since v0.49.4-operant this store additionally mirrors every +/// mutation to disk under `///` and hydrates on +/// startup, so checkpoints survive bridge restarts (the "in memory only" +/// limitation above no longer applies). `list`/`restore` fall back to the +/// hydrated in-memory map, which is kept in sync on every mutation. #[derive(Debug)] pub struct CheckpointStore { /// session -> name -> checkpoint checkpoints: HashMap>, lock_path: PathBuf, lock_timeout: Duration, + /// Root directory for durable checkpoint storage. Lock file lives at + /// `/checkpoint.lock`; each checkpoint is persisted at + /// `///`. + storage_root: PathBuf, } impl CheckpointStore { @@ -149,15 +196,26 @@ impl CheckpointStore { /// var, which races parallel lib tests that resolve storage paths. #[cfg(test)] pub(crate) fn set_lock_path_for_test(&mut self, lock_path: PathBuf) { + self.storage_root = lock_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); self.lock_path = lock_path; } fn with_lock_path(lock_path: PathBuf, lock_timeout: Duration) -> Self { - CheckpointStore { + let storage_root = lock_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let mut store = CheckpointStore { checkpoints: HashMap::new(), lock_path, lock_timeout, - } + storage_root, + }; + store.hydrate_from_disk(); + store } fn acquire_mutation_lock(&self) -> Result { @@ -289,6 +347,14 @@ impl CheckpointStore { .or_default() .insert(name.to_string(), checkpoint); + if let Err(e) = self.persist_checkpoint(session, name) { + crate::slog_warn!( + "checkpoint {}: persisted in memory but failed to write to disk: {}", + name, + e + ); + } + if skipped.is_empty() { crate::slog_info!("checkpoint created: {} ({} files)", name, file_count); } else { @@ -376,12 +442,25 @@ impl CheckpointStore { /// Delete a checkpoint from a session. Returns true when a checkpoint was removed. pub fn delete(&mut self, session: &str, name: &str) -> bool { - let Some(session_checkpoints) = self.checkpoints.get_mut(session) else { - return false; - }; - let removed = session_checkpoints.remove(name).is_some(); - if session_checkpoints.is_empty() { - self.checkpoints.remove(session); + let dir = self.checkpoint_dir(session, name); + let removed = self + .checkpoints + .get_mut(session) + .map(|session_checkpoints| session_checkpoints.remove(name).is_some()) + .unwrap_or(false); + if removed { + if let Err(e) = fs::remove_dir_all(&dir) { + crate::slog_warn!( + "checkpoint {}: removed from memory but failed to delete on disk: {}", + name, + e + ); + } + } + if let Some(session_checkpoints) = self.checkpoints.get(session) { + if session_checkpoints.is_empty() { + self.checkpoints.remove(session); + } } removed } @@ -417,6 +496,206 @@ impl CheckpointStore { session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs); !session_cps.is_empty() }); + // Mirror the on-disk store: drop persisted checkpoints that aged out. + if let Ok(session_entries) = fs::read_dir(&self.storage_root) { + for session_entry in session_entries.flatten() { + if !session_entry.path().is_dir() { + continue; + } + let session = session_entry.file_name().to_string_lossy().to_string(); + let alive: std::collections::HashSet = self + .checkpoints + .get(&session) + .map(|cps| cps.keys().cloned().collect()) + .unwrap_or_default(); + if let Ok(cp_entries) = fs::read_dir(session_entry.path()) { + for cp_entry in cp_entries.flatten() { + let cp_path = cp_entry.path(); + if !cp_path.is_dir() { + continue; + } + let cp_name = cp_entry.file_name().to_string_lossy().to_string(); + if !alive.contains(&cp_name) { + let _ = fs::remove_dir_all(&cp_path); + } + } + } + } + } + } + + // ------------------------------------------------------------------- + // Durable persistence + // ------------------------------------------------------------------- + + /// Directory where a checkpoint's durable payload lives. + fn checkpoint_dir(&self, session: &str, name: &str) -> PathBuf { + self.storage_root + .join(sanitize_for_path(session)) + .join(sanitize_for_path(name)) + } + + /// Persist one checkpoint (already in the in-memory map) to disk. + /// + /// Layout: `///manifest.json` plus one + /// `blob-.bin` per regular file. Symlinks are recorded in the + /// manifest only. The manifest is written atomically (tmp + rename). + fn persist_checkpoint(&self, session: &str, name: &str) -> Result<(), AftError> { + let checkpoint = self.get(session, name)?; + let dir = self.checkpoint_dir(session, name); + fs::create_dir_all(&dir).map_err(|error| AftError::IoError { + path: dir.display().to_string(), + message: format!("failed to create checkpoint dir: {error}"), + })?; + + let mut stored_files: Vec = Vec::new(); + let mut blob_index = 0usize; + let mut paths: Vec<&PathBuf> = checkpoint.file_contents.keys().collect(); + paths.sort(); + for path in paths { + let file = &checkpoint.file_contents[path]; + let mut stored = StoredCheckpointFile { + path: path.display().to_string(), + kind: String::new(), + mode: permission_mode(&file.permissions), + readonly: file.permissions.readonly(), + blob: None, + target: None, + target_is_dir: false, + }; + match &file.kind { + CheckpointFileKind::Regular { bytes } => { + let blob = format!("blob-{}.bin", blob_index); + blob_index += 1; + fs::write(dir.join(&blob), &bytes[..]).map_err(|error| AftError::IoError { + path: dir.join(&blob).display().to_string(), + message: format!("failed to write checkpoint blob: {error}"), + })?; + stored.kind = "regular".to_string(); + stored.blob = Some(blob); + } + CheckpointFileKind::Symlink { + target, + target_is_dir, + } => { + stored.kind = "symlink".to_string(); + stored.target = Some(target.display().to_string()); + stored.target_is_dir = *target_is_dir; + } + } + stored_files.push(stored); + } + + let stored = StoredCheckpoint { + name: checkpoint.name.clone(), + created_at: checkpoint.created_at, + files: stored_files, + }; + let raw = serde_json::to_vec(&stored).map_err(|error| AftError::IoError { + path: dir.display().to_string(), + message: format!("failed to serialize checkpoint manifest: {error}"), + })?; + let tmp = dir.join("manifest.json.tmp"); + fs::write(&tmp, &raw).map_err(|error| AftError::IoError { + path: tmp.display().to_string(), + message: format!("failed to write checkpoint manifest: {error}"), + })?; + fs::rename(&tmp, dir.join("manifest.json")).map_err(|error| AftError::IoError { + path: dir.join("manifest.json").display().to_string(), + message: format!("failed to finalize checkpoint manifest: {error}"), + }) + } + + /// Load all persisted checkpoints for this project into memory. + /// + /// Non-fatal: unreadable/corrupt entries are skipped with a warning so + /// a bad manifest never blocks bridge startup. + fn hydrate_from_disk(&mut self) { + let Ok(session_entries) = fs::read_dir(&self.storage_root) else { + return; + }; + for session_entry in session_entries.flatten() { + if !session_entry.path().is_dir() { + continue; + } + let session = session_entry.file_name().to_string_lossy().to_string(); + let Ok(cp_entries) = fs::read_dir(session_entry.path()) else { + continue; + }; + for cp_entry in cp_entries.flatten() { + let cp_path = cp_entry.path(); + if !cp_path.is_dir() { + continue; + } + let manifest_path = cp_path.join("manifest.json"); + let raw = match fs::read(&manifest_path) { + Ok(raw) => raw, + Err(_) => continue, + }; + let stored: StoredCheckpoint = match serde_json::from_slice(&raw) { + Ok(s) => s, + Err(e) => { + crate::slog_warn!( + "checkpoint hydration: skipping corrupt manifest {}: {}", + manifest_path.display(), + e + ); + continue; + } + }; + + let mut file_contents: HashMap = HashMap::new(); + let mut ok = true; + for f in stored.files { + let file = match f.kind.as_str() { + "regular" => { + let Some(blob) = &f.blob else { + ok = false; + break; + }; + let bytes = match fs::read(cp_path.join(blob)) { + Ok(b) => Arc::<[u8]>::from(b), + Err(_) => { + ok = false; + break; + } + }; + CheckpointFile { + permissions: permission_from_mode(f.mode), + kind: CheckpointFileKind::Regular { bytes }, + } + } + "symlink" => CheckpointFile { + permissions: permission_from_mode(f.mode), + kind: CheckpointFileKind::Symlink { + target: PathBuf::from(f.target.unwrap_or_default()), + target_is_dir: f.target_is_dir, + }, + }, + _ => { + ok = false; + break; + } + }; + file_contents.insert(PathBuf::from(f.path), file); + } + if !ok { + crate::slog_warn!( + "checkpoint hydration: skipping incomplete checkpoint {} (missing blobs)", + cp_path.display() + ); + continue; + } + self.checkpoints.entry(session.clone()).or_default().insert( + stored.name.clone(), + Checkpoint { + name: stored.name, + file_contents, + created_at: stored.created_at, + }, + ); + } + } } fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> { @@ -429,6 +708,70 @@ impl CheckpointStore { } } +/// Sanitize a session/name into a safe single path component. +/// +/// Session ids and checkpoint names are caller-controlled strings; they must +/// never be able to escape the storage root via `../` or path separators. +fn sanitize_for_path(component: &str) -> String { + let mut out = String::with_capacity(component.len()); + let mut chars = component.chars().peekable(); + while let Some(c) = chars.next() { + match c { + // Separators and NUL would allow escaping the storage root. + '/' | '\\' | '\0' => out.push('_'), + // Leading dots could produce "." / ".." path components. + '.' if out.is_empty() => { + out.push('_'); + // Consume a second dot so ".." never survives. + if chars.peek() == Some(&'.') { + let _ = chars.next(); + out.push('_'); + } + } + c if c.is_ascii_control() => out.push('_'), + c => out.push(c), + } + } + if out.is_empty() { + out.push('_'); + } + out +} + +#[cfg(unix)] +fn permission_mode(permissions: &fs::Permissions) -> u32 { + use std::os::unix::fs::PermissionsExt; + permissions.mode() & 0o7777 +} + +#[cfg(windows)] +fn permission_mode(permissions: &fs::Permissions) -> u32 { + use std::os::windows::fs::PermissionsExt; + permissions.mode() & 0o7777 +} + +#[cfg(not(any(unix, windows)))] +fn permission_mode(permissions: &fs::Permissions) -> u32 { + if permissions.readonly() { 0o444 } else { 0o644 } +} + +#[cfg(unix)] +fn permission_from_mode(mode: u32) -> fs::Permissions { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(mode & 0o7777) +} + +#[cfg(windows)] +fn permission_from_mode(mode: u32) -> fs::Permissions { + use std::os::windows::fs::PermissionsExt; + fs::Permissions::from_mode(mode & 0o7777) +} + +#[cfg(not(any(unix, windows)))] +fn permission_from_mode(mode: u32) -> fs::Permissions { + fs::Permissions::from_readonly(mode & 0o444 == 0) +} + fn absolute_checkpoint_path(path: PathBuf) -> PathBuf { if path.is_absolute() { return normalize_checkpoint_path(&path); @@ -546,7 +889,7 @@ fn write_restored_file( path: path.display().to_string(), message: format!("failed to restore checkpoint file contents: {error}"), })?; - fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| { + fs::set_permissions(path, snapshot.permissions.clone()).map_err(|error| { AftError::IoError { path: path.display().to_string(), message: format!("failed to restore checkpoint file permissions: {error}"), @@ -687,6 +1030,46 @@ mod tests { CheckpointFile::read(file.path()).unwrap() } + #[test] + fn checkpoints_survive_store_recreation() { + // Durability: a checkpoint persisted by one store instance (simulating + // one bridge process) must be listable + restorable by a fresh store + // over the same storage root (simulating a new bridge process). + let (path, _dir) = temp_file("cp_durable.txt", "durable-original"); + let storage = tempfile::tempdir().unwrap(); + let lock_path = storage.path().join("checkpoint.lock"); + let backup_store = BackupStore::new(); + + { + let mut store = + CheckpointStore::with_lock_path(lock_path.clone(), CHECKPOINT_LOCK_TIMEOUT); + store + .create( + DEFAULT_SESSION_ID, + "durable", + vec![path.clone()], + &backup_store, + ) + .unwrap(); + // On-disk layout: ///manifest.json + blob-0.bin + let cp_dir = storage.path().join(DEFAULT_SESSION_ID).join("durable"); + assert!(cp_dir.join("manifest.json").exists(), "manifest must be written"); + assert!(cp_dir.join("blob-0.bin").exists(), "blob must be written"); + } // drop store — simulates bridge shutdown + + // Fresh store over the same storage root — hydrates from disk. + let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT); + let list = store.list(DEFAULT_SESSION_ID); + assert_eq!(list.len(), 1, "hydrated checkpoint should be listable"); + assert_eq!(list[0].name, "durable"); + assert_eq!(list[0].file_count, 1); + + // Modify the file, then restore from the hydrated checkpoint. + fs::write(&path, "durable-modified").unwrap(); + store.restore(DEFAULT_SESSION_ID, "durable").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "durable-original"); + } + #[test] fn create_and_restore_round_trip() { let (path1, _dir1) = temp_file("cp_rt1.txt", "hello"); From e6a022b568da03b4dac6a52f02d17bad7db991e2 Mon Sep 17 00:00:00 2001 From: ishan-parihar <129376561+ishan-parihar@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:10:39 +0530 Subject: [PATCH 2/2] fix(checkpoint): review fixes for durable persistence - Store the raw session id in the manifest; hydrate by it instead of the sanitized+hashed on-dir dir name (which broke session scoping) - Sanitize+hash both dir components so names that sanitize identically (a/b vs a_b) never collide on disk or silently overwrite on hydration - cleanup() now compares on-disk dirs against the computed alive set (handles hash-suffixed dir names); prunes empty session dirs - Drop orphaned blobs on overwrite; drop dead readonly manifest field - New tests: sanitize traversal/collision, delete mirrors to disk --- crates/aft/src/checkpoint.rs | 193 ++++++++++++++++++++++++++++++----- 1 file changed, 165 insertions(+), 28 deletions(-) diff --git a/crates/aft/src/checkpoint.rs b/crates/aft/src/checkpoint.rs index ebf1e456..1062c638 100644 --- a/crates/aft/src/checkpoint.rs +++ b/crates/aft/src/checkpoint.rs @@ -61,12 +61,10 @@ struct StoredCheckpointFile { path: String, #[serde(rename = "kind")] kind: String, - /// Unix-style permission mode bits (e.g. 0o644). Only meaningful on - /// unix/windows; other platforms use `readonly`. + /// Unix-style permission mode bits (e.g. 0o644). The source of truth for + /// re-applying permissions on restore (see [`permission_from_mode`]). #[serde(default)] mode: u32, - #[serde(default)] - readonly: bool, /// Relative path of the raw-bytes blob under the checkpoint dir, for /// regular files only. #[serde(default)] @@ -80,6 +78,9 @@ struct StoredCheckpointFile { /// On-disk representation of a checkpoint (manifest.json). #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredCheckpoint { + /// Raw session id — on-disk dir names are sanitized+hashed, so the + /// manifest carries the authoritative session key for hydration. + session: String, name: String, created_at: u64, files: Vec, @@ -497,28 +498,34 @@ impl CheckpointStore { !session_cps.is_empty() }); // Mirror the on-disk store: drop persisted checkpoints that aged out. + // Compute the set of still-alive on-disk dirs first (no borrow of + // `self` inside the traversal below). + let mut alive_dirs: std::collections::HashSet = std::collections::HashSet::new(); + for (session, cps) in &self.checkpoints { + for name in cps.keys() { + alive_dirs.insert(self.checkpoint_dir(session, name)); + } + } if let Ok(session_entries) = fs::read_dir(&self.storage_root) { for session_entry in session_entries.flatten() { if !session_entry.path().is_dir() { continue; } - let session = session_entry.file_name().to_string_lossy().to_string(); - let alive: std::collections::HashSet = self - .checkpoints - .get(&session) - .map(|cps| cps.keys().cloned().collect()) - .unwrap_or_default(); if let Ok(cp_entries) = fs::read_dir(session_entry.path()) { + let mut session_empty = true; for cp_entry in cp_entries.flatten() { let cp_path = cp_entry.path(); if !cp_path.is_dir() { continue; } - let cp_name = cp_entry.file_name().to_string_lossy().to_string(); - if !alive.contains(&cp_name) { + session_empty = false; + if !alive_dirs.contains(&cp_path) { let _ = fs::remove_dir_all(&cp_path); } } + if session_empty { + let _ = fs::remove_dir_all(session_entry.path()); + } } } } @@ -529,10 +536,20 @@ impl CheckpointStore { // ------------------------------------------------------------------- /// Directory where a checkpoint's durable payload lives. + /// + /// The dir components are `sanitize(name)-`: sanitizing + /// guards path traversal while the hash suffix disambiguates names that + /// sanitize to the same string (e.g. `a/b` vs `a_b`), so two distinct + /// checkpoints can never map to the same on-disk dir and silently + /// overwrite each other on hydration. fn checkpoint_dir(&self, session: &str, name: &str) -> PathBuf { self.storage_root - .join(sanitize_for_path(session)) - .join(sanitize_for_path(name)) + .join(format!( + "{}-{}", + sanitize_for_path(session), + short_hash(session) + )) + .join(format!("{}-{}", sanitize_for_path(name), short_hash(name))) } /// Persist one checkpoint (already in the in-memory map) to disk. @@ -548,6 +565,18 @@ impl CheckpointStore { message: format!("failed to create checkpoint dir: {error}"), })?; + // Drop orphaned blobs from a previous snapshot with the same name + // (an overwrite with fewer files would otherwise leave stale blob-N + // files behind). We are under the mutation lock, so this is safe. + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with("blob-") && file_name.ends_with(".bin") { + let _ = fs::remove_file(entry.path()); + } + } + } + let mut stored_files: Vec = Vec::new(); let mut blob_index = 0usize; let mut paths: Vec<&PathBuf> = checkpoint.file_contents.keys().collect(); @@ -558,7 +587,6 @@ impl CheckpointStore { path: path.display().to_string(), kind: String::new(), mode: permission_mode(&file.permissions), - readonly: file.permissions.readonly(), blob: None, target: None, target_is_dir: false, @@ -587,6 +615,7 @@ impl CheckpointStore { } let stored = StoredCheckpoint { + session: session.to_string(), name: checkpoint.name.clone(), created_at: checkpoint.created_at, files: stored_files, @@ -618,7 +647,6 @@ impl CheckpointStore { if !session_entry.path().is_dir() { continue; } - let session = session_entry.file_name().to_string_lossy().to_string(); let Ok(cp_entries) = fs::read_dir(session_entry.path()) else { continue; }; @@ -686,14 +714,19 @@ impl CheckpointStore { ); continue; } - self.checkpoints.entry(session.clone()).or_default().insert( - stored.name.clone(), - Checkpoint { - name: stored.name, - file_contents, - created_at: stored.created_at, - }, - ); + // Use the manifest's authoritative session id, not the + // (sanitized+hashed) on-disk dir name. + self.checkpoints + .entry(stored.session.clone()) + .or_default() + .insert( + stored.name.clone(), + Checkpoint { + name: stored.name, + file_contents, + created_at: stored.created_at, + }, + ); } } } @@ -738,6 +771,20 @@ fn sanitize_for_path(component: &str) -> String { out } +/// Deterministic 64-bit FNV-1a hash, hex-encoded (8 chars). +/// +/// Used to disambiguate on-disk dir names after sanitization. Implemented +/// inline (no `DefaultHasher`, whose algorithm is not stable across Rust +/// releases) so hydrated dir names always match. +fn short_hash(s: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis + for byte in s.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime + } + format!("{:08x}", hash) +} + #[cfg(unix)] fn permission_mode(permissions: &fs::Permissions) -> u32 { use std::os::unix::fs::PermissionsExt; @@ -752,7 +799,11 @@ fn permission_mode(permissions: &fs::Permissions) -> u32 { #[cfg(not(any(unix, windows)))] fn permission_mode(permissions: &fs::Permissions) -> u32 { - if permissions.readonly() { 0o444 } else { 0o644 } + if permissions.readonly() { + 0o444 + } else { + 0o644 + } } #[cfg(unix)] @@ -1051,9 +1102,12 @@ mod tests { &backup_store, ) .unwrap(); - // On-disk layout: ///manifest.json + blob-0.bin - let cp_dir = storage.path().join(DEFAULT_SESSION_ID).join("durable"); - assert!(cp_dir.join("manifest.json").exists(), "manifest must be written"); + // On-disk layout: /-/-/{manifest.json, blob-0.bin} + let cp_dir = store.checkpoint_dir(DEFAULT_SESSION_ID, "durable"); + assert!( + cp_dir.join("manifest.json").exists(), + "manifest must be written" + ); assert!(cp_dir.join("blob-0.bin").exists(), "blob must be written"); } // drop store — simulates bridge shutdown @@ -1070,6 +1124,89 @@ mod tests { assert_eq!(fs::read_to_string(&path).unwrap(), "durable-original"); } + #[test] + fn sanitize_blocks_traversal_and_collides_disambiguated() { + // Every separator/NUL is replaced with '_' — no component can ever + // contain a path separator, so traversal is impossible regardless of + // embedded ".." (which survives only as harmless text inside a name). + assert_eq!(sanitize_for_path("../../etc/passwd"), "___.._etc_passwd"); + assert_eq!(sanitize_for_path(".."), "__"); + assert_eq!(sanitize_for_path("/abs/path"), "_abs_path"); + assert_eq!(sanitize_for_path("\\win\\path"), "_win_path"); + assert_eq!(sanitize_for_path("a/b"), "a_b"); + assert_eq!(sanitize_for_path("a\\b"), "a_b"); + assert_eq!(sanitize_for_path(""), "_"); + // Sanitized output must never contain a separator. + for probe in [ + "../../etc/passwd", + "..", + "/abs/path", + "\\win\\path", + "a/b", + "a\\b", + ] { + let out = sanitize_for_path(probe); + assert!( + !out.contains('/') && !out.contains('\\'), + "unsafe output {out}" + ); + } + + // Distinct names that sanitize identically get distinct dirs via hash. + let (p, _d) = temp_file("cp_collision.txt", "x"); + let storage = tempfile::tempdir().unwrap(); + let mut store = CheckpointStore::with_lock_path( + storage.path().join("checkpoint.lock"), + CHECKPOINT_LOCK_TIMEOUT, + ); + let backup = BackupStore::new(); + store + .create(DEFAULT_SESSION_ID, "a/b", vec![p.clone()], &backup) + .unwrap(); + store + .create(DEFAULT_SESSION_ID, "a_b", vec![p.clone()], &backup) + .unwrap(); + assert_ne!( + store.checkpoint_dir(DEFAULT_SESSION_ID, "a/b"), + store.checkpoint_dir(DEFAULT_SESSION_ID, "a_b") + ); + assert_eq!(store.list(DEFAULT_SESSION_ID).len(), 2); + } + + #[test] + fn delete_and_cleanup_mirror_to_disk() { + let (p, _d) = temp_file("cp_mirror.txt", "x"); + let storage = tempfile::tempdir().unwrap(); + let lock_path = storage.path().join("checkpoint.lock"); + let backup = BackupStore::new(); + + { + let mut store = + CheckpointStore::with_lock_path(lock_path.clone(), CHECKPOINT_LOCK_TIMEOUT); + store + .create(DEFAULT_SESSION_ID, "keep", vec![p.clone()], &backup) + .unwrap(); + store + .create(DEFAULT_SESSION_ID, "drop", vec![p.clone()], &backup) + .unwrap(); + assert!(store.checkpoint_dir(DEFAULT_SESSION_ID, "drop").exists()); + + // delete mirrors to disk + assert!(store.delete(DEFAULT_SESSION_ID, "drop")); + assert!(!store.checkpoint_dir(DEFAULT_SESSION_ID, "drop").exists()); + assert!(store.checkpoint_dir(DEFAULT_SESSION_ID, "keep").exists()); + } + + // Fresh store over the same root: only "keep" hydrates. + let store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT); + let names: Vec = store + .list(DEFAULT_SESSION_ID) + .into_iter() + .map(|i| i.name) + .collect(); + assert_eq!(names, vec!["keep".to_string()]); + } + #[test] fn create_and_restore_round_trip() { let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");