1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use crate::backup::BackupStore;
8use crate::error::AftError;
9use crate::fs_lock;
10
11const CHECKPOINT_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
12
13#[derive(Debug, Clone)]
15pub struct CheckpointInfo {
16 pub name: String,
17 pub file_count: usize,
18 pub created_at: u64,
19 pub skipped: Vec<(PathBuf, String)>,
24}
25
26#[derive(Debug, Clone)]
28struct Checkpoint {
29 name: String,
30 file_contents: HashMap<PathBuf, CheckpointFile>,
31 created_at: u64,
32}
33
34#[derive(Debug, Clone)]
35struct CheckpointFile {
36 metadata: fs::Metadata,
37 kind: CheckpointFileKind,
38}
39
40#[derive(Debug, Clone)]
41enum CheckpointFileKind {
42 Regular {
43 bytes: Vec<u8>,
44 },
45 Symlink {
46 target: PathBuf,
47 target_is_dir: bool,
48 },
49}
50
51impl CheckpointFile {
52 fn read(path: &Path) -> io::Result<Self> {
53 let metadata = fs::symlink_metadata(path)?;
54 let file_type = metadata.file_type();
55 if file_type.is_symlink() {
56 let target = fs::read_link(path)?;
57 let target_is_dir = fs::metadata(path)
58 .map(|target_metadata| target_metadata.is_dir())
59 .unwrap_or(false);
60 return Ok(Self {
61 metadata,
62 kind: CheckpointFileKind::Symlink {
63 target,
64 target_is_dir,
65 },
66 });
67 }
68
69 if metadata.is_file() {
70 let bytes = fs::read(path)?;
71 return Ok(Self {
72 metadata,
73 kind: CheckpointFileKind::Regular { bytes },
74 });
75 }
76
77 Err(io::Error::new(
78 io::ErrorKind::InvalidInput,
79 "not a regular file or symlink",
80 ))
81 }
82
83 fn read_optional(path: &Path) -> io::Result<Option<Self>> {
84 match Self::read(path) {
85 Ok(snapshot) => Ok(Some(snapshot)),
86 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
87 Err(error) => Err(error),
88 }
89 }
90}
91
92#[derive(Debug)]
101pub struct CheckpointStore {
102 checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
104 lock_path: PathBuf,
105 lock_timeout: Duration,
106}
107
108impl CheckpointStore {
109 pub fn new() -> Self {
110 let project_root = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
111 let project_key = crate::search_index::project_cache_key(&project_root);
112 let lock_path = crate::bash_background::storage_dir(None)
113 .join("checkpoints")
114 .join(project_key)
115 .join("checkpoint.lock");
116 Self::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT)
117 }
118
119 fn with_lock_path(lock_path: PathBuf, lock_timeout: Duration) -> Self {
120 CheckpointStore {
121 checkpoints: HashMap::new(),
122 lock_path,
123 lock_timeout,
124 }
125 }
126
127 fn acquire_mutation_lock(&self) -> Result<fs_lock::LockGuard, AftError> {
128 if let Some(parent) = self.lock_path.parent() {
129 fs::create_dir_all(parent).map_err(|error| AftError::IoError {
130 path: parent.display().to_string(),
131 message: format!("failed to create checkpoint lock directory: {error}"),
132 })?;
133 }
134
135 fs_lock::try_acquire(&self.lock_path, self.lock_timeout).map_err(|error| match error {
136 fs_lock::AcquireError::Timeout => AftError::IoError {
137 path: self.lock_path.display().to_string(),
138 message: "timed out acquiring checkpoint mutation lock".to_string(),
139 },
140 fs_lock::AcquireError::Io(error) => AftError::IoError {
141 path: self.lock_path.display().to_string(),
142 message: format!("failed to acquire checkpoint mutation lock: {error}"),
143 },
144 })
145 }
146
147 pub fn create(
161 &mut self,
162 session: &str,
163 name: &str,
164 files: Vec<PathBuf>,
165 backup_store: &BackupStore,
166 ) -> Result<CheckpointInfo, AftError> {
167 let _mutation_lock = self.acquire_mutation_lock()?;
168 let explicit_request = !files.is_empty();
169 let file_list = if files.is_empty() {
170 backup_store.tracked_files(session)
171 } else {
172 files
173 };
174
175 let mut file_contents = HashMap::new();
176 let mut skipped: Vec<(PathBuf, String)> = Vec::new();
177 for path in &file_list {
178 match CheckpointFile::read(path) {
179 Ok(snapshot) => {
180 file_contents.insert(path.clone(), snapshot);
181 }
182 Err(e) => {
183 crate::slog_warn!(
184 "checkpoint {}: skipping unreadable file {}: {}",
185 name,
186 path.display(),
187 e
188 );
189 skipped.push((path.clone(), e.to_string()));
190 }
191 }
192 }
193
194 if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
200 let (path, err) = &skipped[0];
201 return Err(AftError::FileNotFound {
202 path: format!("{}: {}", path.display(), err),
203 });
204 }
205
206 let created_at = current_timestamp();
207 let file_count = file_contents.len();
208
209 let checkpoint = Checkpoint {
210 name: name.to_string(),
211 file_contents,
212 created_at,
213 };
214
215 self.checkpoints
216 .entry(session.to_string())
217 .or_default()
218 .insert(name.to_string(), checkpoint);
219
220 if skipped.is_empty() {
221 crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
222 } else {
223 crate::slog_info!(
224 "checkpoint created: {} ({} files, {} skipped)",
225 name,
226 file_count,
227 skipped.len()
228 );
229 }
230
231 Ok(CheckpointInfo {
232 name: name.to_string(),
233 file_count,
234 created_at,
235 skipped,
236 })
237 }
238
239 pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
241 let _mutation_lock = self.acquire_mutation_lock()?;
242 let checkpoint = self.get(session, name)?;
243 let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
244 paths.sort();
245
246 restore_paths_atomically(checkpoint, &paths)?;
247
248 crate::slog_info!("checkpoint restored: {}", name);
249
250 Ok(CheckpointInfo {
251 name: checkpoint.name.clone(),
252 file_count: checkpoint.file_contents.len(),
253 created_at: checkpoint.created_at,
254 skipped: Vec::new(),
255 })
256 }
257
258 pub fn restore_validated(
260 &self,
261 session: &str,
262 name: &str,
263 validated_paths: &[PathBuf],
264 ) -> Result<CheckpointInfo, AftError> {
265 let _mutation_lock = self.acquire_mutation_lock()?;
266 let checkpoint = self.get(session, name)?;
267
268 for path in validated_paths {
269 checkpoint
270 .file_contents
271 .get(path)
272 .ok_or_else(|| AftError::FileNotFound {
273 path: path.display().to_string(),
274 })?;
275 }
276 restore_paths_atomically(checkpoint, validated_paths)?;
277
278 crate::slog_info!("checkpoint restored: {}", name);
279
280 Ok(CheckpointInfo {
281 name: checkpoint.name.clone(),
282 file_count: checkpoint.file_contents.len(),
283 created_at: checkpoint.created_at,
284 skipped: Vec::new(),
285 })
286 }
287
288 pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
290 let checkpoint = self.get(session, name)?;
291 Ok(checkpoint.file_contents.keys().cloned().collect())
292 }
293
294 pub fn delete(&mut self, session: &str, name: &str) -> bool {
296 let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
297 return false;
298 };
299 let removed = session_checkpoints.remove(name).is_some();
300 if session_checkpoints.is_empty() {
301 self.checkpoints.remove(session);
302 }
303 removed
304 }
305
306 pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
308 self.checkpoints
309 .get(session)
310 .map(|s| {
311 s.values()
312 .map(|cp| CheckpointInfo {
313 name: cp.name.clone(),
314 file_count: cp.file_contents.len(),
315 created_at: cp.created_at,
316 skipped: Vec::new(),
317 })
318 .collect()
319 })
320 .unwrap_or_default()
321 }
322
323 pub fn total_count(&self) -> usize {
325 self.checkpoints.values().map(|s| s.len()).sum()
326 }
327
328 pub fn cleanup(&mut self, ttl_hours: u32) {
331 let now = current_timestamp();
332 let ttl_secs = ttl_hours as u64 * 3600;
333 self.checkpoints.retain(|_, session_cps| {
334 session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
335 !session_cps.is_empty()
336 });
337 }
338
339 fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
340 self.checkpoints
341 .get(session)
342 .and_then(|s| s.get(name))
343 .ok_or_else(|| AftError::CheckpointNotFound {
344 name: name.to_string(),
345 })
346 }
347}
348
349fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
350 let mut pre_restore_snapshot: HashMap<PathBuf, Option<CheckpointFile>> = HashMap::new();
351 for path in paths {
352 let current = CheckpointFile::read_optional(path).map_err(|error| AftError::IoError {
353 path: path.display().to_string(),
354 message: format!("failed to snapshot pre-restore file metadata: {error}"),
355 })?;
356 pre_restore_snapshot.insert(path.clone(), current);
357 }
358
359 let mut restored_paths: Vec<PathBuf> = Vec::new();
360 let mut created_dirs: Vec<PathBuf> = Vec::new();
361 for path in paths {
362 let snapshot =
363 checkpoint
364 .file_contents
365 .get(path)
366 .ok_or_else(|| AftError::FileNotFound {
367 path: path.display().to_string(),
368 })?;
369 if let Err(e) = write_restored_file(path, snapshot, &mut created_dirs) {
370 let mut rollback_errors = Vec::new();
371 if let Some(snapshot) = pre_restore_snapshot.get(path) {
372 if let Err(rollback_error) = restore_snapshot_file(path, snapshot.as_ref()) {
373 rollback_errors.push(format!("{}: {}", path.display(), rollback_error));
374 }
375 }
376 for restored_path in restored_paths.iter().rev() {
377 if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
378 if let Err(rollback_error) =
379 restore_snapshot_file(restored_path, snapshot.as_ref())
380 {
381 rollback_errors.push(format!(
382 "{}: {}",
383 restored_path.display(),
384 rollback_error
385 ));
386 }
387 }
388 }
389 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
390 if rollback_errors.is_empty() && dirs_rollback_ok {
391 return Err(e);
392 }
393 return Err(AftError::IoError {
394 path: path.display().to_string(),
395 message: format!(
396 "{}; restore_checkpoint rollback_succeeded: {}; rollback_errors: {}",
397 e,
398 rollback_errors.is_empty() && dirs_rollback_ok,
399 if rollback_errors.is_empty() {
400 "none".to_string()
401 } else {
402 rollback_errors.join("; ")
403 }
404 ),
405 });
406 }
407 restored_paths.push(path.clone());
408 }
409
410 Ok(())
411}
412
413fn restore_snapshot_file(path: &Path, snapshot: Option<&CheckpointFile>) -> Result<(), AftError> {
414 match snapshot {
415 Some(snapshot) => write_restored_file(path, snapshot, &mut Vec::new()),
416 None => remove_file_if_exists(path).map_err(|error| AftError::IoError {
417 path: path.display().to_string(),
418 message: format!("failed to remove file during checkpoint restore rollback: {error}"),
419 }),
420 }
421}
422
423fn write_restored_file(
424 path: &Path,
425 snapshot: &CheckpointFile,
426 created_dirs: &mut Vec<PathBuf>,
427) -> Result<(), AftError> {
428 create_parent_dirs(path, created_dirs)?;
429
430 match &snapshot.kind {
431 CheckpointFileKind::Regular { bytes } => {
432 if path_is_symlink(path) {
433 remove_file_if_exists(path).map_err(|error| AftError::IoError {
434 path: path.display().to_string(),
435 message: format!("failed to replace symlink with regular file: {error}"),
436 })?;
437 }
438 fs::write(path, bytes).map_err(|error| AftError::IoError {
439 path: path.display().to_string(),
440 message: format!("failed to restore checkpoint file contents: {error}"),
441 })?;
442 fs::set_permissions(path, snapshot.metadata.permissions()).map_err(|error| {
443 AftError::IoError {
444 path: path.display().to_string(),
445 message: format!("failed to restore checkpoint file permissions: {error}"),
446 }
447 })
448 }
449 CheckpointFileKind::Symlink {
450 target,
451 target_is_dir,
452 } => {
453 remove_file_if_exists(path).map_err(|error| AftError::IoError {
454 path: path.display().to_string(),
455 message: format!("failed to replace file with checkpoint symlink: {error}"),
456 })?;
457 create_symlink(target, path, *target_is_dir).map_err(|error| AftError::IoError {
458 path: path.display().to_string(),
459 message: format!("failed to restore checkpoint symlink: {error}"),
460 })
461 }
462 }
463}
464
465fn create_parent_dirs(path: &Path, created_dirs: &mut Vec<PathBuf>) -> Result<(), AftError> {
466 if let Some(parent) = path.parent() {
467 let missing_dirs = missing_parent_dirs(parent);
468 fs::create_dir_all(parent).map_err(|error| AftError::IoError {
469 path: parent.display().to_string(),
470 message: format!("failed to create checkpoint restore parent directories: {error}"),
471 })?;
472 created_dirs.extend(missing_dirs);
473 }
474 Ok(())
475}
476
477fn path_is_symlink(path: &Path) -> bool {
478 fs::symlink_metadata(path)
479 .map(|metadata| metadata.file_type().is_symlink())
480 .unwrap_or(false)
481}
482
483fn remove_file_if_exists(path: &Path) -> io::Result<()> {
484 match fs::remove_file(path) {
485 Ok(()) => Ok(()),
486 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
487 Err(error) => Err(error),
488 }
489}
490
491#[cfg(unix)]
492fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
493 let _ = target_is_dir;
494 std::os::unix::fs::symlink(target, link)
495}
496
497#[cfg(windows)]
498fn create_symlink(target: &Path, link: &Path, target_is_dir: bool) -> io::Result<()> {
499 if target_is_dir {
500 std::os::windows::fs::symlink_dir(target, link)
501 } else {
502 std::os::windows::fs::symlink_file(target, link)
503 }
504}
505
506#[cfg(not(any(unix, windows)))]
507fn create_symlink(_target: &Path, _link: &Path, _target_is_dir: bool) -> io::Result<()> {
508 Err(io::Error::new(
509 io::ErrorKind::Unsupported,
510 "checkpoint symlink restore is unsupported on this platform",
511 ))
512}
513
514fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
515 let mut dirs = Vec::new();
516 let mut current = Some(parent);
517
518 while let Some(dir) = current {
519 if dir.as_os_str().is_empty() || dir.exists() {
520 break;
521 }
522 dirs.push(dir.to_path_buf());
523 current = dir.parent();
524 }
525
526 dirs
527}
528
529fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
530 let mut dirs = dirs.to_vec();
531 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
532 dirs.dedup();
533
534 let mut ok = true;
535 for dir in dirs {
536 match std::fs::remove_dir(&dir) {
537 Ok(()) => {}
538 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
539 Err(_) => ok = false,
540 }
541 }
542 ok
543}
544
545fn current_timestamp() -> u64 {
546 std::time::SystemTime::now()
547 .duration_since(std::time::UNIX_EPOCH)
548 .unwrap_or_default()
549 .as_secs()
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::protocol::DEFAULT_SESSION_ID;
556 use std::fs;
557
558 fn temp_file(name: &str, content: &str) -> (PathBuf, tempfile::TempDir) {
559 let dir = tempfile::Builder::new()
560 .prefix("aft_checkpoint_tests_")
561 .tempdir()
562 .expect("create checkpoint temp dir");
563 let path = dir.path().join(name);
564 fs::write(&path, content).unwrap();
565 (path, dir)
566 }
567
568 fn checkpoint_store() -> (CheckpointStore, tempfile::TempDir) {
569 let dir = tempfile::tempdir().unwrap();
570 let lock_path = dir.path().join("checkpoint.lock");
571 (
572 CheckpointStore::with_lock_path(lock_path, CHECKPOINT_LOCK_TIMEOUT),
573 dir,
574 )
575 }
576
577 fn checkpoint_file(content: &str) -> CheckpointFile {
578 let file = tempfile::NamedTempFile::new().unwrap();
579 fs::write(file.path(), content).unwrap();
580 CheckpointFile::read(file.path()).unwrap()
581 }
582
583 #[test]
584 fn create_and_restore_round_trip() {
585 let (path1, _dir1) = temp_file("cp_rt1.txt", "hello");
586 let (path2, _dir2) = temp_file("cp_rt2.txt", "world");
587
588 let backup_store = BackupStore::new();
589 let (mut store, _store_dir) = checkpoint_store();
590
591 let info = store
592 .create(
593 DEFAULT_SESSION_ID,
594 "snap1",
595 vec![path1.clone(), path2.clone()],
596 &backup_store,
597 )
598 .unwrap();
599 assert_eq!(info.name, "snap1");
600 assert_eq!(info.file_count, 2);
601
602 fs::write(&path1, "changed1").unwrap();
604 fs::write(&path2, "changed2").unwrap();
605
606 let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
608 assert_eq!(info.file_count, 2);
609 assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
610 assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
611 }
612
613 #[test]
614 fn overwrite_existing_name() {
615 let (path, _dir) = temp_file("cp_overwrite.txt", "v1");
616 let backup_store = BackupStore::new();
617 let (mut store, _store_dir) = checkpoint_store();
618
619 store
620 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
621 .unwrap();
622 fs::write(&path, "v2").unwrap();
623 store
624 .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
625 .unwrap();
626
627 fs::write(&path, "v3").unwrap();
629 store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
630 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
631 }
632
633 #[test]
634 fn list_returns_metadata_scoped_to_session() {
635 let (path, _dir) = temp_file("cp_list.txt", "data");
636 let backup_store = BackupStore::new();
637 let (mut store, _store_dir) = checkpoint_store();
638
639 store
640 .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
641 .unwrap();
642 store
643 .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
644 .unwrap();
645 store
646 .create("other_session", "c", vec![path.clone()], &backup_store)
647 .unwrap();
648
649 let default_list = store.list(DEFAULT_SESSION_ID);
650 assert_eq!(default_list.len(), 2);
651 let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
652 assert!(names.contains(&"a"));
653 assert!(names.contains(&"b"));
654
655 let other_list = store.list("other_session");
656 assert_eq!(other_list.len(), 1);
657 assert_eq!(other_list[0].name, "c");
658 }
659
660 #[test]
661 fn sessions_isolate_checkpoint_names() {
662 let (path_a, _dir_a) = temp_file("cp_isolated_a.txt", "a-original");
664 let (path_b, _dir_b) = temp_file("cp_isolated_b.txt", "b-original");
665 let backup_store = BackupStore::new();
666 let (mut store, _store_dir) = checkpoint_store();
667
668 store
670 .create("session_a", "snap", vec![path_a.clone()], &backup_store)
671 .unwrap();
672 store
673 .create("session_b", "snap", vec![path_b.clone()], &backup_store)
674 .unwrap();
675
676 fs::write(&path_a, "a-modified").unwrap();
677 fs::write(&path_b, "b-modified").unwrap();
678
679 store.restore("session_a", "snap").unwrap();
681 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
682 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
683
684 fs::write(&path_a, "a-modified").unwrap();
686 store.restore("session_b", "snap").unwrap();
687 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
688 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
689 }
690
691 #[test]
692 fn cleanup_removes_expired_across_sessions() {
693 let (path, _dir) = temp_file("cp_cleanup.txt", "data");
694 let backup_store = BackupStore::new();
695 let (mut store, _store_dir) = checkpoint_store();
696
697 store
698 .create(
699 DEFAULT_SESSION_ID,
700 "recent",
701 vec![path.clone()],
702 &backup_store,
703 )
704 .unwrap();
705
706 store
708 .checkpoints
709 .entry("other".to_string())
710 .or_default()
711 .insert(
712 "old".to_string(),
713 Checkpoint {
714 name: "old".to_string(),
715 file_contents: HashMap::new(),
716 created_at: 1000, },
718 );
719
720 assert_eq!(store.total_count(), 2);
721 store.cleanup(24); assert_eq!(store.total_count(), 1);
723 assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
724 assert!(store.list("other").is_empty());
725 }
726
727 #[test]
728 fn restore_nonexistent_returns_error() {
729 let (store, _store_dir) = checkpoint_store();
730 let result = store.restore(DEFAULT_SESSION_ID, "nope");
731 assert!(result.is_err());
732 match result.unwrap_err() {
733 AftError::CheckpointNotFound { name } => {
734 assert_eq!(name, "nope");
735 }
736 other => panic!("expected CheckpointNotFound, got: {:?}", other),
737 }
738 }
739
740 #[test]
741 fn restore_nonexistent_in_other_session_returns_error() {
742 let (path, _dir) = temp_file("cp_cross_session.txt", "data");
744 let backup_store = BackupStore::new();
745 let (mut store, _store_dir) = checkpoint_store();
746 store
747 .create("session_a", "only_a", vec![path], &backup_store)
748 .unwrap();
749 assert!(store.restore("session_b", "only_a").is_err());
750 }
751
752 #[test]
753 fn create_skips_missing_files_from_backup_tracked_set() {
754 let (readable, _readable_dir) = temp_file("cp_skip_readable.txt", "still_here");
760 let (deleted, _deleted_dir) = temp_file("cp_skip_deleted.txt", "about_to_vanish");
761
762 let deleted_canonical = fs::canonicalize(&deleted).unwrap();
765
766 let mut backup_store = BackupStore::new();
767 backup_store
768 .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
769 .unwrap();
770 backup_store
771 .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
772 .unwrap();
773
774 fs::remove_file(&deleted).unwrap();
775
776 let (mut store, _store_dir) = checkpoint_store();
777 let info = store
778 .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
779 .expect("checkpoint should succeed despite one missing file");
780 assert_eq!(info.file_count, 1);
781 assert_eq!(info.skipped.len(), 1);
782 assert_eq!(info.skipped[0].0, deleted_canonical);
783 assert!(!info.skipped[0].1.is_empty());
784 }
785
786 #[test]
787 fn create_with_explicit_single_missing_file_errors() {
788 let dir = tempfile::tempdir().unwrap();
791 let missing = dir.path().join("cp_explicit_missing_does_not_exist.txt");
792
793 let backup_store = BackupStore::new();
794 let (mut store, _store_dir) = checkpoint_store();
795 let result = store.create(
796 DEFAULT_SESSION_ID,
797 "explicit",
798 vec![missing.clone()],
799 &backup_store,
800 );
801
802 assert!(result.is_err());
803 match result.unwrap_err() {
804 AftError::FileNotFound { path } => {
805 assert!(path.contains(&missing.display().to_string()));
806 }
807 other => panic!("expected FileNotFound, got: {:?}", other),
808 }
809 }
810
811 #[test]
812 fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
813 let (good, _good_dir) = temp_file("cp_mixed_good.txt", "ok");
817 let missing_dir = tempfile::tempdir().unwrap();
818 let missing = missing_dir.path().join("cp_mixed_missing.txt");
819
820 let backup_store = BackupStore::new();
821 let (mut store, _store_dir) = checkpoint_store();
822 let info = store
823 .create(
824 DEFAULT_SESSION_ID,
825 "mixed",
826 vec![good.clone(), missing.clone()],
827 &backup_store,
828 )
829 .expect("mixed checkpoint should succeed when any file is readable");
830 assert_eq!(info.file_count, 1);
831 assert_eq!(info.skipped.len(), 1);
832 assert_eq!(info.skipped[0].0, missing);
833 }
834
835 #[test]
836 fn create_with_empty_files_uses_backup_tracked() {
837 let (path, _dir) = temp_file("cp_tracked.txt", "tracked_content");
838 let mut backup_store = BackupStore::new();
839 backup_store
840 .snapshot(DEFAULT_SESSION_ID, &path, "auto")
841 .unwrap();
842
843 let (mut store, _store_dir) = checkpoint_store();
844 let info = store
845 .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
846 .unwrap();
847 assert!(info.file_count >= 1);
848
849 fs::write(&path, "modified").unwrap();
851 store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
852 assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
853 }
854
855 #[test]
856 fn restore_recreates_missing_parent_directories() {
857 let dir = tempfile::tempdir().unwrap();
858 let path = dir.path().join("nested").join("deeper").join("file.txt");
859 fs::create_dir_all(path.parent().unwrap()).unwrap();
860 fs::write(&path, "original nested content").unwrap();
861
862 let backup_store = BackupStore::new();
863 let (mut store, _store_dir) = checkpoint_store();
864 store
865 .create(
866 DEFAULT_SESSION_ID,
867 "nested",
868 vec![path.clone()],
869 &backup_store,
870 )
871 .unwrap();
872
873 fs::remove_dir_all(dir.path().join("nested")).unwrap();
874
875 store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
876 assert_eq!(
877 fs::read_to_string(&path).unwrap(),
878 "original nested content"
879 );
880 }
881
882 #[cfg(unix)]
883 #[test]
884 fn checkpoint_restore_rolls_back_on_partial_failure() {
885 use std::os::unix::fs::PermissionsExt;
886
887 let dir = tempfile::tempdir().unwrap();
888 let path_a = dir.path().join("a.txt");
889 let path_b = dir.path().join("b.txt");
890 fs::write(&path_a, "checkpoint-a").unwrap();
891 fs::write(&path_b, "checkpoint-b").unwrap();
892
893 let backup_store = BackupStore::new();
894 let (mut store, _store_dir) = checkpoint_store();
895 store
896 .create(
897 DEFAULT_SESSION_ID,
898 "partial_failure",
899 vec![path_a.clone(), path_b.clone()],
900 &backup_store,
901 )
902 .unwrap();
903
904 fs::write(&path_a, "pre-restore-a").unwrap();
905 fs::write(&path_b, "pre-restore-b").unwrap();
906 let mut readonly = fs::metadata(&path_b).unwrap().permissions();
907 readonly.set_mode(0o444);
908 fs::set_permissions(&path_b, readonly).unwrap();
909
910 let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
911 let mut writable = fs::metadata(&path_b).unwrap().permissions();
912 writable.set_mode(0o644);
913 fs::set_permissions(&path_b, writable).unwrap();
914
915 assert!(result.is_err(), "restore should surface write failure");
916 assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
917 assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
918 }
919
920 #[test]
921 fn checkpoint_create_and_restore_use_mutation_lock() {
922 let dir = tempfile::tempdir().unwrap();
923 let lock_path = dir.path().join("locks").join("checkpoint.lock");
924 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
925 let mut store =
926 CheckpointStore::with_lock_path(lock_path.clone(), Duration::from_millis(50));
927 let backup_store = BackupStore::new();
928 let path = dir.path().join("locked.txt");
929 fs::write(&path, "original").unwrap();
930
931 let held_lock =
932 fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
933 let create_result = store.create(
934 DEFAULT_SESSION_ID,
935 "locked",
936 vec![path.clone()],
937 &backup_store,
938 );
939 assert!(matches!(create_result, Err(AftError::IoError { .. })));
940 drop(held_lock);
941
942 store
943 .create(
944 DEFAULT_SESSION_ID,
945 "locked",
946 vec![path.clone()],
947 &backup_store,
948 )
949 .unwrap();
950 fs::write(&path, "changed").unwrap();
951
952 let held_lock =
953 fs_lock::try_acquire(&lock_path, Duration::from_secs(1)).expect("hold checkpoint lock");
954 let restore_result = store.restore(DEFAULT_SESSION_ID, "locked");
955 assert!(matches!(restore_result, Err(AftError::IoError { .. })));
956 drop(held_lock);
957
958 store.restore(DEFAULT_SESSION_ID, "locked").unwrap();
959 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
960 }
961
962 #[cfg(unix)]
963 #[test]
964 fn checkpoint_restore_preserves_regular_file_permissions() {
965 use std::os::unix::fs::PermissionsExt;
966
967 let dir = tempfile::tempdir().unwrap();
968 let path = dir.path().join("mode.txt");
969 fs::write(&path, "original").unwrap();
970 let mut original_permissions = fs::metadata(&path).unwrap().permissions();
971 original_permissions.set_mode(0o600);
972 fs::set_permissions(&path, original_permissions).unwrap();
973
974 let backup_store = BackupStore::new();
975 let (mut store, _store_dir) = checkpoint_store();
976 store
977 .create(
978 DEFAULT_SESSION_ID,
979 "mode",
980 vec![path.clone()],
981 &backup_store,
982 )
983 .unwrap();
984
985 fs::write(&path, "changed").unwrap();
986 let mut changed_permissions = fs::metadata(&path).unwrap().permissions();
987 changed_permissions.set_mode(0o644);
988 fs::set_permissions(&path, changed_permissions).unwrap();
989
990 store.restore(DEFAULT_SESSION_ID, "mode").unwrap();
991
992 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
993 let restored_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
994 assert_eq!(restored_mode, 0o600);
995 }
996
997 #[cfg(unix)]
998 #[test]
999 fn checkpoint_restore_recreates_symlink() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let target = dir.path().join("target.txt");
1002 let link = dir.path().join("link.txt");
1003 fs::write(&target, "target content").unwrap();
1004 std::os::unix::fs::symlink(&target, &link).unwrap();
1005
1006 let backup_store = BackupStore::new();
1007 let (mut store, _store_dir) = checkpoint_store();
1008 store
1009 .create(
1010 DEFAULT_SESSION_ID,
1011 "symlink",
1012 vec![link.clone()],
1013 &backup_store,
1014 )
1015 .unwrap();
1016
1017 fs::remove_file(&link).unwrap();
1018 fs::write(&link, "plain file").unwrap();
1019
1020 store.restore(DEFAULT_SESSION_ID, "symlink").unwrap();
1021
1022 assert!(fs::symlink_metadata(&link)
1023 .unwrap()
1024 .file_type()
1025 .is_symlink());
1026 assert_eq!(fs::read_link(&link).unwrap(), target);
1027 assert_eq!(fs::read_to_string(&link).unwrap(), "target content");
1028 }
1029
1030 #[test]
1031 fn checkpoint_restore_failure_removes_created_parent_dirs() {
1032 let dir = tempfile::tempdir().unwrap();
1033 let missing_root = dir.path().join("created");
1034 let path_a = missing_root.join("nested").join("a.txt");
1035 let path_b = dir.path().join("blocking-dir");
1036 fs::create_dir(&path_b).unwrap();
1037
1038 let checkpoint = Checkpoint {
1039 name: "dir-cleanup".to_string(),
1040 file_contents: HashMap::from([
1041 (path_a.clone(), checkpoint_file("checkpoint-a")),
1042 (path_b.clone(), checkpoint_file("checkpoint-b")),
1043 ]),
1044 created_at: current_timestamp(),
1045 };
1046
1047 let result = restore_paths_atomically(&checkpoint, &[path_a.clone(), path_b.clone()]);
1048
1049 assert!(
1050 result.is_err(),
1051 "second restore write should fail on directory"
1052 );
1053 assert!(!path_a.exists(), "restored file should be rolled back");
1054 assert!(
1055 !missing_root.exists(),
1056 "new parent directories should be removed on rollback"
1057 );
1058 assert!(path_b.is_dir(), "pre-existing blocking directory remains");
1059 }
1060}