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