1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use crate::backup::{BackupStore, CapturedRegularFile};
9use crate::error::AftError;
10use crate::fs_lock;
11
12const CHECKPOINT_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
13
14#[derive(Debug, Clone)]
16pub struct CheckpointInfo {
17 pub name: String,
18 pub file_count: usize,
19 pub created_at: u64,
20 pub skipped: Vec<(PathBuf, String)>,
25}
26
27#[derive(Debug, Clone)]
29struct Checkpoint {
30 name: String,
31 file_contents: HashMap<PathBuf, CheckpointFile>,
32 created_at: u64,
33}
34
35#[derive(Debug, Clone)]
36struct CheckpointFile {
37 metadata: fs::Metadata,
38 kind: CheckpointFileKind,
39}
40
41#[derive(Debug, Clone)]
42enum CheckpointFileKind {
43 Regular {
44 bytes: Arc<[u8]>,
45 },
46 Symlink {
47 target: PathBuf,
48 target_is_dir: bool,
49 },
50}
51
52impl CheckpointFile {
53 fn read(path: &Path) -> io::Result<Self> {
54 let metadata = fs::symlink_metadata(path)?;
55 let file_type = metadata.file_type();
56 if file_type.is_symlink() {
57 let target = fs::read_link(path)?;
58 let target_is_dir = fs::metadata(path)
59 .map(|target_metadata| target_metadata.is_dir())
60 .unwrap_or(false);
61 return Ok(Self {
62 metadata,
63 kind: CheckpointFileKind::Symlink {
64 target,
65 target_is_dir,
66 },
67 });
68 }
69
70 if metadata.is_file() {
71 let capture = CapturedRegularFile::read(path)?.ok_or_else(|| {
72 io::Error::new(
73 io::ErrorKind::InvalidInput,
74 "file changed while being captured",
75 )
76 })?;
77 return Ok(Self::from_fresh_capture(capture));
78 }
79
80 Err(io::Error::new(
81 io::ErrorKind::InvalidInput,
82 "not a regular file or symlink",
83 ))
84 }
85
86 fn from_captured(path: &Path, capture: &mut CapturedRegularFile) -> io::Result<Self> {
93 capture.refresh_if_stale(path)?;
94 Ok(Self {
95 metadata: capture.metadata().clone(),
96 kind: CheckpointFileKind::Regular {
97 bytes: capture.shared_bytes(),
98 },
99 })
100 }
101
102 fn from_fresh_capture(capture: CapturedRegularFile) -> Self {
103 Self {
104 metadata: capture.metadata().clone(),
105 kind: CheckpointFileKind::Regular {
106 bytes: capture.shared_bytes(),
107 },
108 }
109 }
110
111 fn read_optional(path: &Path) -> io::Result<Option<Self>> {
112 match Self::read(path) {
113 Ok(snapshot) => Ok(Some(snapshot)),
114 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
115 Err(error) => Err(error),
116 }
117 }
118}
119
120#[derive(Debug)]
129pub struct CheckpointStore {
130 checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
132 lock_path: PathBuf,
133 lock_timeout: Duration,
134}
135
136struct CheckpointLockGuard {
140 guard: Option<fs_lock::LockGuard>,
141 scope_dir: Option<PathBuf>,
142}
143
144impl Drop for CheckpointLockGuard {
145 fn drop(&mut self) {
146 if let Some(guard) = self.guard.take() {
150 drop(guard);
151 }
152 if let Some(scope_dir) = &self.scope_dir {
153 remove_empty_scope_dir(scope_dir);
154 }
155 }
156}
157
158impl CheckpointStore {
159 pub fn new() -> Self {
160 let project_root = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
161 let project_key = crate::path_identity::project_scope_key(&project_root);
162 let lock_path = crate::bash_background::storage_dir(None)
163 .join("checkpoints")
164 .join(project_key)
165 .join("checkpoint.lock");
166 Self::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT)
167 }
168
169 #[cfg(test)]
173 pub(crate) fn set_lock_path_for_test(&mut self, lock_path: PathBuf) {
174 self.lock_path = lock_path;
175 }
176
177 fn with_lock_path(lock_path: PathBuf, lock_timeout: Duration) -> Self {
178 CheckpointStore {
179 checkpoints: HashMap::new(),
180 lock_path,
181 lock_timeout,
182 }
183 }
184
185 fn acquire_mutation_lock(&self) -> Result<CheckpointLockGuard, AftError> {
186 let scope_dir = self.lock_path.parent().map(Path::to_path_buf);
187 if let Some(parent) = scope_dir.as_deref() {
188 fs::create_dir_all(parent).map_err(|error| AftError::IoError {
189 path: parent.display().to_string(),
190 message: format!("failed to create checkpoint lock directory: {error}"),
191 })?;
192 }
193
194 let guard =
195 fs_lock::try_acquire(&self.lock_path, self.lock_timeout).map_err(
196 |error| match error {
197 fs_lock::AcquireError::Timeout => AftError::IoError {
198 path: self.lock_path.display().to_string(),
199 message: "timed out acquiring checkpoint mutation lock".to_string(),
200 },
201 fs_lock::AcquireError::Io(error) => AftError::IoError {
202 path: self.lock_path.display().to_string(),
203 message: format!("failed to acquire checkpoint mutation lock: {error}"),
204 },
205 },
206 )?;
207
208 Ok(CheckpointLockGuard {
209 guard: Some(guard),
210 scope_dir,
211 })
212 }
213
214 pub fn create(
228 &mut self,
229 session: &str,
230 name: &str,
231 files: Vec<PathBuf>,
232 backup_store: &BackupStore,
233 ) -> Result<CheckpointInfo, AftError> {
234 self.create_impl(session, name, files, backup_store, None)
235 }
236
237 pub(crate) fn create_from_captures(
238 &mut self,
239 session: &str,
240 name: &str,
241 files: Vec<PathBuf>,
242 backup_store: &BackupStore,
243 captures: &mut HashMap<PathBuf, CapturedRegularFile>,
244 ) -> Result<CheckpointInfo, AftError> {
245 self.create_impl(session, name, files, backup_store, Some(captures))
246 }
247
248 fn create_impl(
249 &mut self,
250 session: &str,
251 name: &str,
252 files: Vec<PathBuf>,
253 backup_store: &BackupStore,
254 mut captures: Option<&mut HashMap<PathBuf, CapturedRegularFile>>,
255 ) -> Result<CheckpointInfo, AftError> {
256 let _mutation_lock = self.acquire_mutation_lock()?;
257 let explicit_request = !files.is_empty();
258 let file_list = if files.is_empty() {
259 backup_store.tracked_files(session)
260 } else {
261 files
262 };
263
264 let mut file_contents = HashMap::new();
265 let mut skipped: Vec<(PathBuf, String)> = Vec::new();
266 for path in &file_list {
267 let seeded = captures
268 .as_deref_mut()
269 .and_then(|captures| captures.get_mut(path))
270 .map(|capture| CheckpointFile::from_captured(path, capture));
271 let snapshot = match seeded {
272 Some(Err(error)) if error.kind() == io::ErrorKind::InvalidInput => {
273 if let Some(captures) = captures.as_deref_mut() {
274 captures.remove(path);
275 }
276 CheckpointFile::read(path)
277 }
278 Some(result) => result,
279 None => CheckpointFile::read(path),
280 };
281 match snapshot {
282 Ok(snapshot) => {
283 file_contents.insert(path.clone(), snapshot);
284 }
285 Err(e) => {
286 crate::slog_warn!(
287 "checkpoint {}: skipping unreadable file {}: {}",
288 name,
289 path.display(),
290 e
291 );
292 skipped.push((path.clone(), e.to_string()));
293 }
294 }
295 }
296
297 if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
303 let (path, err) = &skipped[0];
304 return Err(AftError::FileNotFound {
305 path: format!("{}: {}", path.display(), err),
306 });
307 }
308
309 let created_at = current_timestamp();
310 let file_count = file_contents.len();
311
312 let checkpoint = Checkpoint {
313 name: name.to_string(),
314 file_contents,
315 created_at,
316 };
317
318 self.checkpoints
319 .entry(session.to_string())
320 .or_default()
321 .insert(name.to_string(), checkpoint);
322
323 if skipped.is_empty() {
324 crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
325 } else {
326 crate::slog_info!(
327 "checkpoint created: {} ({} files, {} skipped)",
328 name,
329 file_count,
330 skipped.len()
331 );
332 }
333
334 Ok(CheckpointInfo {
335 name: name.to_string(),
336 file_count,
337 created_at,
338 skipped,
339 })
340 }
341
342 pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
344 let _mutation_lock = self.acquire_mutation_lock()?;
345 let checkpoint = self.get(session, name)?;
346 let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
347 paths.sort();
348
349 restore_paths_atomically(checkpoint, &paths)?;
350
351 crate::slog_info!("checkpoint restored: {}", name);
352
353 Ok(CheckpointInfo {
354 name: checkpoint.name.clone(),
355 file_count: checkpoint.file_contents.len(),
356 created_at: checkpoint.created_at,
357 skipped: Vec::new(),
358 })
359 }
360
361 pub fn restore_validated(
363 &self,
364 session: &str,
365 name: &str,
366 validated_paths: &[PathBuf],
367 ) -> Result<CheckpointInfo, AftError> {
368 let _mutation_lock = self.acquire_mutation_lock()?;
369 let checkpoint = self.get(session, name)?;
370
371 for path in validated_paths {
372 checkpoint
373 .file_contents
374 .get(path)
375 .ok_or_else(|| AftError::FileNotFound {
376 path: path.display().to_string(),
377 })?;
378 }
379 restore_paths_atomically(checkpoint, validated_paths)?;
380
381 crate::slog_info!("checkpoint restored: {}", name);
382
383 Ok(CheckpointInfo {
384 name: checkpoint.name.clone(),
385 file_count: checkpoint.file_contents.len(),
386 created_at: checkpoint.created_at,
387 skipped: Vec::new(),
388 })
389 }
390
391 pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
393 let checkpoint = self.get(session, name)?;
394 Ok(checkpoint.file_contents.keys().cloned().collect())
395 }
396
397 pub fn absolute_file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
399 let mut paths: Vec<PathBuf> = self
400 .file_paths(session, name)?
401 .into_iter()
402 .map(absolute_checkpoint_path)
403 .collect();
404 paths.sort();
405 Ok(paths)
406 }
407
408 pub fn delete(&mut self, session: &str, name: &str) -> bool {
410 let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
411 return false;
412 };
413 let removed = session_checkpoints.remove(name).is_some();
414 if session_checkpoints.is_empty() {
415 self.checkpoints.remove(session);
416 }
417 removed
418 }
419
420 pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
422 self.checkpoints
423 .get(session)
424 .map(|s| {
425 s.values()
426 .map(|cp| CheckpointInfo {
427 name: cp.name.clone(),
428 file_count: cp.file_contents.len(),
429 created_at: cp.created_at,
430 skipped: Vec::new(),
431 })
432 .collect()
433 })
434 .unwrap_or_default()
435 }
436
437 pub fn total_count(&self) -> usize {
439 self.checkpoints.values().map(|s| s.len()).sum()
440 }
441
442 pub fn cleanup(&mut self, ttl_hours: u32) {
446 let now = current_timestamp();
447 let ttl_secs = ttl_hours as u64 * 3600;
448 self.checkpoints.retain(|_, session_cps| {
449 session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
450 !session_cps.is_empty()
451 });
452
453 if let Some(checkpoints_root) = self.lock_path.parent().and_then(Path::parent) {
454 sweep_empty_scope_dirs(checkpoints_root);
455 }
456 }
457
458 fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
459 self.checkpoints
460 .get(session)
461 .and_then(|s| s.get(name))
462 .ok_or_else(|| AftError::CheckpointNotFound {
463 name: name.to_string(),
464 })
465 }
466}
467
468fn absolute_checkpoint_path(path: PathBuf) -> PathBuf {
469 if path.is_absolute() {
470 return normalize_checkpoint_path(&path);
471 }
472 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
473 normalize_checkpoint_path(&cwd.join(path))
474}
475
476fn normalize_checkpoint_path(path: &Path) -> PathBuf {
477 let mut normalized = PathBuf::new();
478 for component in path.components() {
479 match component {
480 std::path::Component::CurDir => {}
481 std::path::Component::ParentDir => {
482 if !normalized.pop() {
483 normalized.push(component.as_os_str());
484 }
485 }
486 other => normalized.push(other.as_os_str()),
487 }
488 }
489 normalized
490}
491
492fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
493 let mut pre_restore_snapshot: HashMap<PathBuf, Option<CheckpointFile>> = HashMap::new();
494 for path in paths {
495 let current = CheckpointFile::read_optional(path).map_err(|error| AftError::IoError {
496 path: path.display().to_string(),
497 message: format!("failed to snapshot pre-restore file metadata: {error}"),
498 })?;
499 pre_restore_snapshot.insert(path.clone(), current);
500 }
501
502 let mut restored_paths: Vec<PathBuf> = Vec::new();
503 let mut created_dirs: Vec<PathBuf> = Vec::new();
504 for path in paths {
505 let snapshot =
506 checkpoint
507 .file_contents
508 .get(path)
509 .ok_or_else(|| AftError::FileNotFound {
510 path: path.display().to_string(),
511 })?;
512 if let Err(e) = write_restored_file(path, snapshot, &mut created_dirs) {
513 let mut rollback_errors = Vec::new();
514 if let Some(snapshot) = pre_restore_snapshot.get(path) {
515 if let Err(rollback_error) = restore_snapshot_file(path, snapshot.as_ref()) {
516 rollback_errors.push(format!("{}: {}", path.display(), rollback_error));
517 }
518 }
519 for restored_path in restored_paths.iter().rev() {
520 if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
521 if let Err(rollback_error) =
522 restore_snapshot_file(restored_path, snapshot.as_ref())
523 {
524 rollback_errors.push(format!(
525 "{}: {}",
526 restored_path.display(),
527 rollback_error
528 ));
529 }
530 }
531 }
532 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
533 if rollback_errors.is_empty() && dirs_rollback_ok {
534 return Err(e);
535 }
536 return Err(AftError::IoError {
537 path: path.display().to_string(),
538 message: format!(
539 "{}; restore_checkpoint rollback_succeeded: {}; rollback_errors: {}",
540 e,
541 rollback_errors.is_empty() && dirs_rollback_ok,
542 if rollback_errors.is_empty() {
543 "none".to_string()
544 } else {
545 rollback_errors.join("; ")
546 }
547 ),
548 });
549 }
550 restored_paths.push(path.clone());
551 }
552
553 Ok(())
554}
555
556fn restore_snapshot_file(path: &Path, snapshot: Option<&CheckpointFile>) -> Result<(), AftError> {
557 match snapshot {
558 Some(snapshot) => write_restored_file(path, snapshot, &mut Vec::new()),
559 None => remove_file_if_exists(path).map_err(|error| AftError::IoError {
560 path: path.display().to_string(),
561 message: format!("failed to remove file during checkpoint restore rollback: {error}"),
562 }),
563 }
564}
565
566fn write_restored_file(
567 path: &Path,
568 snapshot: &CheckpointFile,
569 created_dirs: &mut Vec<PathBuf>,
570) -> Result<(), AftError> {
571 create_parent_dirs(path, created_dirs)?;
572
573 match &snapshot.kind {
574 CheckpointFileKind::Regular { bytes } => {
575 if path_is_symlink(path) {
576 remove_file_if_exists(path).map_err(|error| AftError::IoError {
577 path: path.display().to_string(),
578 message: format!("failed to replace symlink with regular file: {error}"),
579 })?;
580 }
581 fs::write(path, bytes).map_err(|error| AftError::IoError {
582 path: path.display().to_string(),
583 message: format!("failed to restore checkpoint file contents: {error}"),
584 })?;
585 fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| {
586 AftError::IoError {
587 path: path.display().to_string(),
588 message: format!("failed to restore checkpoint file permissions: {error}"),
589 }
590 })
591 }
592 CheckpointFileKind::Symlink {
593 target,
594 target_is_dir,
595 } => {
596 remove_file_if_exists(path).map_err(|error| AftError::IoError {
597 path: path.display().to_string(),
598 message: format!("failed to replace file with checkpoint symlink: {error}"),
599 })?;
600 create_symlink(target, path, *target_is_dir).map_err(|error| AftError::IoError {
601 path: path.display().to_string(),
602 message: format!("failed to restore checkpoint symlink: {error}"),
603 })
604 }
605 }
606}
607
608fn create_parent_dirs(path: &Path, created_dirs: &mut Vec<PathBuf>) -> Result<(), AftError> {
609 if let Some(parent) = path.parent() {
610 let missing_dirs = missing_parent_dirs(parent);
611 fs::create_dir_all(parent).map_err(|error| AftError::IoError {
612 path: parent.display().to_string(),
613 message: format!("failed to create checkpoint restore parent directories: {error}"),
614 })?;
615 created_dirs.extend(missing_dirs);
616 }
617 Ok(())
618}
619
620fn path_is_symlink(path: &Path) -> bool {
621 fs::symlink_metadata(path)
622 .map(|metadata| metadata.file_type().is_symlink())
623 .unwrap_or(false)
624}
625
626fn remove_file_if_exists(path: &Path) -> io::Result<()> {
627 match fs::remove_file(path) {
628 Ok(()) => Ok(()),
629 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
630 Err(error) => Err(error),
631 }
632}
633
634#[cfg(unix)]
635fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
636 let _ = target_is_dir;
637 std::os::unix::fs::symlink(target, link)
638}
639
640#[cfg(windows)]
641fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
642 if target_is_dir {
643 std::os::windows::fs::symlink_dir(target, link)
644 } else {
645 std::os::windows::fs::symlink_file(target, link)
646 }
647}
648
649#[cfg(not(any(unix, windows)))]
650fn create_symlink(_target: &Path, _link: &Path, _target_is_dir: bool) -> io::Result<()> {
651 Err(io::Error::new(
652 io::ErrorKind::Unsupported,
653 "checkpoint symlink restore is unsupported on this platform",
654 ))
655}
656
657fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
658 let mut dirs = Vec::new();
659 let mut current = Some(parent);
660
661 while let Some(dir) = current {
662 if dir.as_os_str().is_empty() || dir.exists() {
663 break;
664 }
665 dirs.push(dir.to_path_buf());
666 current = dir.parent();
667 }
668
669 dirs
670}
671
672fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
673 let mut dirs = dirs.to_vec();
674 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
675 dirs.dedup();
676
677 let mut ok = true;
678 for dir in dirs {
679 match std::fs::remove_dir(&dir) {
680 Ok(()) => {}
681 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
682 Err(_) => ok = false,
683 }
684 }
685 ok
686}
687
688fn remove_empty_scope_dir(scope_dir: &Path) {
692 let _ = fs::remove_dir(scope_dir);
693}
694
695fn sweep_empty_scope_dirs(checkpoints_root: &Path) {
699 let entries = match fs::read_dir(checkpoints_root) {
700 Ok(entries) => entries,
701 Err(_) => return,
702 };
703
704 for entry in entries.flatten() {
705 let Ok(file_type) = entry.file_type() else {
706 continue;
707 };
708 if file_type.is_dir() {
709 remove_empty_scope_dir(&entry.path());
710 }
711 }
712}
713
714fn current_timestamp() -> u64 {
715 std::time::SystemTime::now()
716 .duration_since(std::time::UNIX_EPOCH)
717 .unwrap_or_default()
718 .as_secs()
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::protocol::DEFAULT_SESSION_ID;
725 use std::fs;
726
727 fn temp_file(name: &str, content: &str) -> (PathBuf, tempfile::TempDir) {
728 let dir = tempfile::Builder::new()
729 .prefix("aft_checkpoint_tests_")
730 .tempdir()
731 .expect("create checkpoint temp dir");
732 let path = dir.path().join(name);
733 fs::write(&path, content).unwrap();
734 (path, dir)
735 }
736
737 fn checkpoint_store() -> (CheckpointStore, tempfile::TempDir) {
738 let dir = tempfile::tempdir().unwrap();
739 let lock_path = dir
740 .path()
741 .join("checkpoints")
742 .join("test-project")
743 .join("checkpoint.lock");
744 (
745 CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT),
746 dir,
747 )
748 }
749
750 fn checkpoint_file(content: &str) -> CheckpointFile {
751 let file = tempfile::NamedTempFile::new().unwrap();
752 fs::write(file.path(), content).unwrap();
753 CheckpointFile::read(file.path()).unwrap()
754 }
755
756 #[test]
757 fn create_and_restore_round_trip() {
758 let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");
759 let (path2, _dir2) = temp_file("cp_rt2.txt", "world");
760
761 let backup_store = BackupStore::new();
762 let (mut store, _store_dir) = checkpoint_store();
763
764 let info = store
765 .create(
766 DEFAULT_SESSION_ID,
767 "snap1",
768 vec![path1.clone(), path2.clone()],
769 &backup_store,
770 )
771 .unwrap();
772 assert_eq!(info.name, "snap1");
773 assert_eq!(info.file_count, 2);
774
775 fs::write(&path1, "changed1").unwrap();
777 fs::write(&path2, "changed2").unwrap();
778
779 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
781 assert_eq!(info.file_count, 2);
782 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
783 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
784 }
785
786 #[test]
787 fn overwrite_existing_name() {
788 let (path, _dir) = temp_file("cp_overwrite.txt", "v1");
789 let backup_store = BackupStore::new();
790 let (mut store, _store_dir) = checkpoint_store();
791
792 store
793 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
794 .unwrap();
795 fs::write(&path, "v2").unwrap();
796 store
797 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
798 .unwrap();
799
800 fs::write(&path, "v3").unwrap();
802 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
803 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
804 }
805
806 #[test]
807 fn list_returns_metadata_scoped_to_session() {
808 let (path, _dir) = temp_file("cp_list.txt", "data");
809 let backup_store = BackupStore::new();
810 let (mut store, _store_dir) = checkpoint_store();
811
812 store
813 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
814 .unwrap();
815 store
816 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
817 .unwrap();
818 store
819 .create("other_session", "c", vec![path.clone()], &backup_store)
820 .unwrap();
821
822 let default_list = store.list(DEFAULT_SESSION_ID);
823 assert_eq!(default_list.len(), 2);
824 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
825 assert!(names.contains(&"a"));
826 assert!(names.contains(&"b"));
827
828 let other_list = store.list("other_session");
829 assert_eq!(other_list.len(), 1);
830 assert_eq!(other_list[0].name, "c");
831 }
832
833 #[test]
834 fn sessions_isolate_checkpoint_names() {
835 let (path_a, _dir_a) = temp_file("cp_isolated_a.txt", "a-original");
837 let (path_b, _dir_b) = temp_file("cp_isolated_b.txt", "b-original");
838 let backup_store = BackupStore::new();
839 let (mut store, _store_dir) = checkpoint_store();
840
841 store
843 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
844 .unwrap();
845 store
846 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
847 .unwrap();
848
849 fs::write(&path_a, "a-modified").unwrap();
850 fs::write(&path_b, "b-modified").unwrap();
851
852 store.restore("session_a", "snap").unwrap();
854 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
855 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
856
857 fs::write(&path_a, "a-modified").unwrap();
859 store.restore("session_b", "snap").unwrap();
860 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
861 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
862 }
863
864 #[test]
865 fn checkpoint_lock_scope_is_removed_after_release() {
866 let dir = tempfile::tempdir().unwrap();
867 let scope_dir = dir.path().join("checkpoints").join("project-scope");
868 let lock_path = scope_dir.join("checkpoint.lock");
869 let path = dir.path().join("checkpoint.txt");
870 fs::write(&path, "data").unwrap();
871 let backup_store = BackupStore::new();
872 let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
873
874 store
875 .create(DEFAULT_SESSION_ID, "released", vec![path], &backup_store)
876 .unwrap();
877
878 assert!(!scope_dir.exists(), "released lock scope should be removed");
879 }
880
881 #[test]
882 fn cleanup_removes_expired_across_sessions() {
883 let (path, _dir) = temp_file("cp_cleanup.txt", "data");
884 let backup_store = BackupStore::new();
885 let (mut store, _store_dir) = checkpoint_store();
886
887 store
888 .create(
889 DEFAULT_SESSION_ID,
890 "recent",
891 vec![path.clone()],
892 &backup_store,
893 )
894 .unwrap();
895
896 store
898 .checkpoints
899 .entry("other".to_string())
900 .or_default()
901 .insert(
902 "old".to_string(),
903 Checkpoint {
904 name: "old".to_string(),
905 file_contents: HashMap::new(),
906 created_at: 1000, },
908 );
909
910 assert_eq!(store.total_count(), 2);
911 store.cleanup(24); assert_eq!(store.total_count(), 1);
913 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
914 assert!(store.list("other").is_empty());
915 }
916
917 #[test]
918 fn cleanup_sweeps_empty_scope_dirs_but_keeps_live_lock_scope() {
919 let dir = tempfile::tempdir().unwrap();
920 let checkpoints_root = dir.path().join("checkpoints");
921 let empty_a = checkpoints_root.join("empty-a");
922 let empty_b = checkpoints_root.join("empty-b");
923 let live_scope = checkpoints_root.join("live-scope");
924 fs::create_dir_all(&empty_a).unwrap();
925 fs::create_dir_all(&empty_b).unwrap();
926 fs::create_dir_all(&live_scope).unwrap();
927 fs::write(live_scope.join("checkpoint.lock"), "live lock").unwrap();
928
929 let lock_path = checkpoints_root
930 .join("current-scope")
931 .join("checkpoint.lock");
932 let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
933 store.cleanup(24);
934
935 assert!(!empty_a.exists());
936 assert!(!empty_b.exists());
937 assert!(live_scope.is_dir());
938 assert!(live_scope.join("checkpoint.lock").is_file());
939 }
940
941 #[test]
942 fn cleanup_ignores_non_empty_scope_dir_removal_failure() {
943 let dir = tempfile::tempdir().unwrap();
944 let checkpoints_root = dir.path().join("checkpoints");
945 let scope_dir = checkpoints_root.join("racing-scope");
946 fs::create_dir_all(&scope_dir).unwrap();
947 fs::write(scope_dir.join("checkpoint.lock"), "lock appeared").unwrap();
950
951 let lock_path = checkpoints_root
952 .join("current-scope")
953 .join("checkpoint.lock");
954 let mut store = CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT);
955 store.cleanup(24);
956
957 assert!(scope_dir.is_dir());
958 assert!(scope_dir.join("checkpoint.lock").is_file());
959 }
960
961 #[test]
962 fn restore_nonexistent_returns_error() {
963 let (store, _store_dir) = checkpoint_store();
964 let result = store.restore(DEFAULT_SESSION_ID, "nope");
965 assert!(result.is_err());
966 match result.unwrap_err() {
967 AftError::CheckpointNotFound { name } => {
968 assert_eq!(name, "nope");
969 }
970 other => panic!("expected CheckpointNotFound, got: {:?}", other),
971 }
972 }
973
974 #[test]
975 fn restore_nonexistent_in_other_session_returns_error() {
976 let (path, _dir) = temp_file("cp_cross_session.txt", "data");
978 let backup_store = BackupStore::new();
979 let (mut store, _store_dir) = checkpoint_store();
980 store
981 .create("session_a", "only_a", vec![path], &backup_store)
982 .unwrap();
983 assert!(store.restore("session_b", "only_a").is_err());
984 }
985
986 #[test]
987 fn create_skips_missing_files_from_backup_tracked_set() {
988 let (readable, _readable_dir) = temp_file("cp_skip_readable.txt", "still_here");
994 let (deleted, _deleted_dir) = temp_file("cp_skip_deleted.txt", "about_to_vanish");
995
996 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
999
1000 let mut backup_store = BackupStore::new();
1001 backup_store
1002 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
1003 .unwrap();
1004 backup_store
1005 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
1006 .unwrap();
1007
1008 fs::remove_file(&deleted).unwrap();
1009
1010 let (mut store, _store_dir) = checkpoint_store();
1011 let info = store
1012 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
1013 .expect("checkpoint should succeed despite one missing file");
1014 assert_eq!(info.file_count, 1);
1015 assert_eq!(info.skipped.len(), 1);
1016 assert_eq!(info.skipped[0].0, deleted_canonical);
1017 assert!(!info.skipped[0].1.is_empty());
1018 }
1019
1020 #[test]
1021 fn create_with_explicit_single_missing_file_errors() {
1022 let dir = tempfile::tempdir().unwrap();
1025 let missing = dir.path().join("cp_explicit_missing_does_not_exist.txt");
1026
1027 let backup_store = BackupStore::new();
1028 let (mut store, _store_dir) = checkpoint_store();
1029 let result = store.create(
1030 DEFAULT_SESSION_ID,
1031 "explicit",
1032 vec![missing.clone()],
1033 &backup_store,
1034 );
1035
1036 assert!(result.is_err());
1037 match result.unwrap_err() {
1038 AftError::FileNotFound { path } => {
1039 assert!(path.contains(&missing.display().to_string()));
1040 }
1041 other => panic!("expected FileNotFound, got: {:?}", other),
1042 }
1043 }
1044
1045 #[test]
1046 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
1047 let (good, _good_dir) = temp_file("cp_mixed_good.txt", "ok");
1051 let missing_dir = tempfile::tempdir().unwrap();
1052 let missing = missing_dir.path().join("cp_mixed_missing.txt");
1053
1054 let backup_store = BackupStore::new();
1055 let (mut store, _store_dir) = checkpoint_store();
1056 let info = store
1057 .create(
1058 DEFAULT_SESSION_ID,
1059 "mixed",
1060 vec![good.clone(), missing.clone()],
1061 &backup_store,
1062 )
1063 .expect("mixed checkpoint should succeed when any file is readable");
1064 assert_eq!(info.file_count, 1);
1065 assert_eq!(info.skipped.len(), 1);
1066 assert_eq!(info.skipped[0].0, missing);
1067 }
1068
1069 #[test]
1070 fn create_with_empty_files_uses_backup_tracked() {
1071 let (path, _dir) = temp_file("cp_tracked.txt", "tracked_content");
1072 let mut backup_store = BackupStore::new();
1073 backup_store
1074 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
1075 .unwrap();
1076
1077 let (mut store, _store_dir) = checkpoint_store();
1078 let info = store
1079 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
1080 .unwrap();
1081 assert!(info.file_count >= 1);
1082
1083 fs::write(&path, "modified").unwrap();
1085 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
1086 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
1087 }
1088
1089 #[test]
1090 fn restore_recreates_missing_parent_directories() {
1091 let dir = tempfile::tempdir().unwrap();
1092 let path = dir.path().join("nested").join("deeper").join("file.txt");
1093 fs::create_dir_all(path.parent().unwrap()).unwrap();
1094 fs::write(&path, "original nested content").unwrap();
1095
1096 let backup_store = BackupStore::new();
1097 let (mut store, _store_dir) = checkpoint_store();
1098 store
1099 .create(
1100 DEFAULT_SESSION_ID,
1101 "nested",
1102 vec![path.clone()],
1103 &backup_store,
1104 )
1105 .unwrap();
1106
1107 fs::remove_dir_all(dir.path().join("nested")).unwrap();
1108
1109 store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
1110 assert_eq!(
1111 fs::read_to_string(&path).unwrap(),
1112 "original nested content"
1113 );
1114 }
1115
1116 #[cfg(unix)]
1117 #[test]
1118 fn checkpoint_restore_rolls_back_on_partial_failure() {
1119 use std::os::unix::fs::PermissionsExt;
1120
1121 let dir = tempfile::tempdir().unwrap();
1122 let path_a = dir.path().join("a.txt");
1123 let path_b = dir.path().join("b.txt");
1124 fs::write(&path_a, "checkpoint-a").unwrap();
1125 fs::write(&path_b, "checkpoint-b").unwrap();
1126
1127 let backup_store = BackupStore::new();
1128 let (mut store, _store_dir) = checkpoint_store();
1129 store
1130 .create(
1131 DEFAULT_SESSION_ID,
1132 "partial_failure",
1133 vec![path_a.clone(), path_b.clone()],
1134 &backup_store,
1135 )
1136 .unwrap();
1137
1138 fs::write(&path_a, "pre-restore-a").unwrap();
1139 fs::write(&path_b, "pre-restore-b").unwrap();
1140 let mut readonly = fs::metadata(&path_b).unwrap().permissions();
1141 readonly.set_mode(0o444);
1142 fs::set_permissions(&path_b, readonly).unwrap();
1143
1144 let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
1145 let mut writable = fs::metadata(&path_b).unwrap().permissions();
1146 writable.set_mode(0o644);
1147 fs::set_permissions(&path_b, writable).unwrap();
1148
1149 assert!(result.is_err(), "restore should surface write failure");
1150 assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
1151 assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
1152 }
1153
1154 #[test]
1155 fn checkpoint_create_and_restore_use_mutation_lock() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let lock_path = dir.path().join("locks").join("checkpoint.lock");
1158 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1159 let mut store =
1160 CheckpointStore::with_lock_path(lock_path.clone(), Duration::from_millis(50));
1161 let backup_store = BackupStore::new();
1162 let path = dir.path().join("locked.txt");
1163 fs::write(&path, "original").unwrap();
1164
1165 let held_lock =
1166 fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
1167 let create_result = store.create(
1168 DEFAULT_SESSION_ID,
1169 "locked",
1170 vec![path.clone()],
1171 &backup_store,
1172 );
1173 assert!(matches!(create_result, Err(AftError::IoError { .. })));
1174 drop(held_lock);
1175
1176 store
1177 .create(
1178 DEFAULT_SESSION_ID,
1179 "locked",
1180 vec![path.clone()],
1181 &backup_store,
1182 )
1183 .unwrap();
1184 fs::write(&path, "changed").unwrap();
1185
1186 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1187 let held_lock =
1188 fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
1189 let restore_result = store.restore(DEFAULT_SESSION_ID, "locked");
1190 assert!(matches!(restore_result, Err(AftError::IoError { .. })));
1191 drop(held_lock);
1192
1193 store.restore(DEFAULT_SESSION_ID, "locked").unwrap();
1194 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1195 }
1196
1197 #[cfg(unix)]
1198 #[test]
1199 fn checkpoint_restore_preserves_regular_file_permissions() {
1200 use std::os::unix::fs::PermissionsExt;
1201
1202 let dir = tempfile::tempdir().unwrap();
1203 let path = dir.path().join("mode.txt");
1204 fs::write(&path, "original").unwrap();
1205 let mut original_permissions = fs::metadata(&path).unwrap().permissions();
1206 original_permissions.set_mode(0o600);
1207 fs::set_permissions(&path, original_permissions).unwrap();
1208
1209 let backup_store = BackupStore::new();
1210 let (mut store, _store_dir) = checkpoint_store();
1211 store
1212 .create(
1213 DEFAULT_SESSION_ID,
1214 "mode",
1215 vec![path.clone()],
1216 &backup_store,
1217 )
1218 .unwrap();
1219
1220 fs::write(&path, "changed").unwrap();
1221 let mut changed_permissions = fs::metadata(&path).unwrap().permissions();
1222 changed_permissions.set_mode(0o644);
1223 fs::set_permissions(&path, changed_permissions).unwrap();
1224
1225 store.restore(DEFAULT_SESSION_ID, "mode").unwrap();
1226
1227 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1228 let restored_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1229 assert_eq!(restored_mode, 0o600);
1230 }
1231
1232 #[cfg(unix)]
1233 #[test]
1234 fn checkpoint_restore_recreates_symlink() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let target = dir.path().join("target.txt");
1237 let link = dir.path().join("link.txt");
1238 fs::write(&target, "target content").unwrap();
1239 std::os::unix::fs::symlink(&target, &link).unwrap();
1240
1241 let backup_store = BackupStore::new();
1242 let (mut store, _store_dir) = checkpoint_store();
1243 store
1244 .create(
1245 DEFAULT_SESSION_ID,
1246 "symlink",
1247 vec![link.clone()],
1248 &backup_store,
1249 )
1250 .unwrap();
1251
1252 fs::remove_file(&link).unwrap();
1253 fs::write(&link, "plain file").unwrap();
1254
1255 store.restore(DEFAULT_SESSION_ID, "symlink").unwrap();
1256
1257 assert!(fs::symlink_metadata(&link)
1258 .unwrap()
1259 .file_type()
1260 .is_symlink());
1261 assert_eq!(fs::read_link(&link).unwrap(), target);
1262 assert_eq!(fs::read_to_string(&link).unwrap(), "target content");
1263 }
1264
1265 #[test]
1266 fn captured_regular_file_is_shared_by_checkpoint_and_backup() {
1267 let (path, _dir) = temp_file("shared-capture.txt", "original bytes");
1268 crate::backup::reset_capture_read_count(&path);
1269 let mut capture = CapturedRegularFile::read(&path).unwrap().unwrap();
1270 assert_eq!(crate::backup::capture_read_count(&path), 1);
1271
1272 let checkpoint = CheckpointFile::from_captured(&path, &mut capture).unwrap();
1273 let mut backup = BackupStore::new();
1274 backup
1275 .snapshot_with_op_from_capture(
1276 DEFAULT_SESSION_ID,
1277 &path,
1278 "shared capture",
1279 Some("shared-op"),
1280 &capture,
1281 )
1282 .unwrap();
1283
1284 let history = backup.history(DEFAULT_SESSION_ID, &path);
1285 let CheckpointFileKind::Regular { bytes } = checkpoint.kind else {
1286 panic!("regular capture must create a regular checkpoint");
1287 };
1288 assert_eq!(bytes.as_ref(), b"original bytes");
1289 assert_eq!(history[0].content_bytes.as_ref(), b"original bytes");
1290 assert!(Arc::ptr_eq(&bytes, &history[0].content_bytes));
1291 assert_eq!(crate::backup::capture_read_count(&path), 1);
1292 }
1293
1294 #[test]
1295 fn stale_capture_refreshes_before_checkpoint_and_backup() {
1296 let (path, _dir) = temp_file("stale-capture.txt", "old");
1297 crate::backup::reset_capture_read_count(&path);
1298 let mut capture = CapturedRegularFile::read(&path).unwrap().unwrap();
1299 fs::write(&path, "fresh disk truth").unwrap();
1300
1301 let checkpoint = CheckpointFile::from_captured(&path, &mut capture).unwrap();
1302 let mut backup = BackupStore::new();
1303 backup
1304 .snapshot_with_op_from_capture(
1305 DEFAULT_SESSION_ID,
1306 &path,
1307 "freshened capture",
1308 Some("fresh-op"),
1309 &capture,
1310 )
1311 .unwrap();
1312
1313 let history = backup.history(DEFAULT_SESSION_ID, &path);
1314 let CheckpointFileKind::Regular { bytes } = checkpoint.kind else {
1315 panic!("regular capture must create a regular checkpoint");
1316 };
1317 assert_eq!(bytes.as_ref(), b"fresh disk truth");
1318 assert_eq!(history[0].content_bytes.as_ref(), b"fresh disk truth");
1319 assert!(Arc::ptr_eq(&bytes, &history[0].content_bytes));
1320 assert_eq!(crate::backup::capture_read_count(&path), 2);
1321 }
1322
1323 #[test]
1324 fn checkpoint_restore_failure_removes_created_parent_dirs() {
1325 let dir = tempfile::tempdir().unwrap();
1326 let missing_root = dir.path().join("created");
1327 let path_a = missing_root.join("nested").join("a.txt");
1328 let path_b = dir.path().join("blocking-dir");
1329 fs::create_dir(&path_b).unwrap();
1330
1331 let checkpoint = Checkpoint {
1332 name: "dir-cleanup".to_string(),
1333 file_contents: HashMap::from([
1334 (path_a.clone(), checkpoint_file("checkpoint-a")),
1335 (path_b.clone(), checkpoint_file("checkpoint-b")),
1336 ]),
1337 created_at: current_timestamp(),
1338 };
1339
1340 let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
1341
1342 assert!(
1343 result.is_err(),
1344 "second restore write should fail on directory"
1345 );
1346 assert!(!path_a.exists(), "restored file should be rolled back");
1347 assert!(
1348 !missing_root.exists(),
1349 "new parent directories should be removed on rollback"
1350 );
1351 assert!(path_b.is_dir(), "pre-existing blocking directory remains");
1352 }
1353}