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