diff --git a/crates/aft/src/checkpoint.rs b/crates/aft/src/checkpoint.rs index 2205cc47..1062c638 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,41 @@ 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). The source of truth for + /// re-applying permissions on restore (see [`permission_from_mode`]). + #[serde(default)] + mode: u32, + /// 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 { + /// 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, +} + 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 +97,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 +130,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 +139,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 +163,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 +197,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 +348,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 +443,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 +497,238 @@ 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. + // 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; + } + 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; + } + 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()); + } + } + } + } + } + + // ------------------------------------------------------------------- + // Durable persistence + // ------------------------------------------------------------------- + + /// 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(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. + /// + /// 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}"), + })?; + + // 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(); + 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), + 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 { + session: session.to_string(), + 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 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; + } + // 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, + }, + ); + } + } } fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> { @@ -429,6 +741,88 @@ 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 +} + +/// 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; + 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 +940,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 +1081,132 @@ 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 = 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 + + // 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 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");