1use std::collections::{HashMap, HashSet};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5#[cfg(test)]
6use std::sync::LazyLock;
7use std::sync::{Arc, Mutex, RwLock};
8
9use rusqlite::Connection;
10
11use crate::db::backups::BackupRow;
12use crate::error::AftError;
13use sha2::{Digest, Sha256};
14
15pub const DEFAULT_MAX_UNDO_DEPTH: usize = 20;
16#[cfg(test)]
17const MAX_UNDO_DEPTH: usize = DEFAULT_MAX_UNDO_DEPTH;
18const V2_FORMAT_VERSION: &str = "v2";
19const MAX_RESTORE_OPERATION_LOCK_RETRIES: usize = 32;
20
21#[cfg(test)]
22type RestoreBeforeLockHook = Box<dyn FnMut(usize) -> bool + Send>;
23
24#[cfg(test)]
25static RESTORE_BEFORE_LOCK_HOOKS: LazyLock<Mutex<HashMap<String, RestoreBeforeLockHook>>> =
26 LazyLock::new(|| Mutex::new(HashMap::new()));
27
28#[cfg(test)]
29fn set_restore_before_lock_hook_for_tests(
30 session: &str,
31 hook: impl FnMut(usize) -> bool + Send + 'static,
32) {
33 RESTORE_BEFORE_LOCK_HOOKS
34 .lock()
35 .unwrap()
36 .insert(session.to_string(), Box::new(hook));
37}
38
39#[cfg(test)]
40fn run_restore_before_lock_hook_for_tests(session: &str, attempt: usize) {
41 let mut hooks = RESTORE_BEFORE_LOCK_HOOKS.lock().unwrap();
42 let Some(mut hook) = hooks.remove(session) else {
43 return;
44 };
45 drop(hooks);
46 let keep_hook = hook(attempt);
47 if keep_hook {
48 RESTORE_BEFORE_LOCK_HOOKS
49 .lock()
50 .unwrap()
51 .insert(session.to_string(), hook);
52 }
53}
54
55#[cfg(not(test))]
56fn run_restore_before_lock_hook_for_tests(_session: &str, _attempt: usize) {}
57
58const SCHEMA_VERSION: u32 = 4;
63
64#[derive(Debug, Clone)]
66pub struct BackupEntry {
67 pub backup_id: String,
68 pub content: String,
71 pub content_bytes: Vec<u8>,
72 pub timestamp: u64,
73 pub order: u128,
74 pub description: String,
75 pub op_id: Option<String>,
76 pub kind: BackupEntryKind,
77 pub mode: Option<u32>,
78 pub link_target: Option<PathBuf>,
79 pub created_dirs: Vec<PathBuf>,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum BackupEntryKind {
84 Content,
85 Symlink,
86 Tombstone,
87}
88
89#[derive(Debug, Clone)]
90struct BackupEntryHead {
91 order: u128,
92 op_id: Option<String>,
93}
94
95impl BackupEntryHead {
96 fn from_entry(entry: &BackupEntry) -> Self {
97 Self {
98 order: entry.order,
99 op_id: entry.op_id.clone(),
100 }
101 }
102
103 fn from_row(row: &BackupRow) -> Self {
104 Self {
105 order: row.order,
106 op_id: row.op_id.clone(),
107 }
108 }
109}
110
111impl BackupEntry {
112 fn to_backup_row(
113 &self,
114 harness: &str,
115 session_id: &str,
116 project_key: &str,
117 file_path: &str,
118 path_hash: &str,
119 backup_path: Option<&str>,
120 ) -> BackupRow {
121 BackupRow {
122 backup_id: self.backup_id.clone(),
123 harness: harness.to_string(),
124 session_id: session_id.to_string(),
125 project_key: project_key.to_string(),
126 op_id: self.op_id.clone(),
127 order: self.order,
128 file_path: file_path.to_string(),
129 path_hash: path_hash.to_string(),
130 backup_path: backup_path.map(str::to_string),
131 kind: match self.kind {
132 BackupEntryKind::Content => "content".to_string(),
133 BackupEntryKind::Symlink => "symlink".to_string(),
134 BackupEntryKind::Tombstone => "tombstone".to_string(),
135 },
136 description: self.description.clone(),
137 created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
138 is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
139 }
140 }
141}
142
143impl TryFrom<BackupRow> for BackupEntry {
144 type Error = std::io::Error;
145
146 fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
147 let kind = if row.is_tombstone || row.kind == "tombstone" {
148 BackupEntryKind::Tombstone
149 } else if row.kind == "symlink" {
150 BackupEntryKind::Symlink
151 } else {
152 BackupEntryKind::Content
153 };
154 let backup_path = row.backup_path.clone();
155 let disk_metadata = backup_path
156 .as_deref()
157 .and_then(|path| read_entry_disk_metadata(Path::new(path), &row.backup_id));
158 let content_bytes = match kind {
159 BackupEntryKind::Content | BackupEntryKind::Symlink => {
160 let backup_path = backup_path.ok_or_else(|| {
161 std::io::Error::new(
162 std::io::ErrorKind::NotFound,
163 format!("backup DB row {} has no backup_path", row.backup_id),
164 )
165 })?;
166 std::fs::read(backup_path)?
167 }
168 BackupEntryKind::Tombstone => Vec::new(),
169 };
170 let link_target = if kind == BackupEntryKind::Symlink {
171 disk_metadata
172 .as_ref()
173 .and_then(|metadata| metadata.link_target.clone())
174 .or_else(|| {
175 Some(PathBuf::from(
176 String::from_utf8_lossy(&content_bytes).into_owned(),
177 ))
178 })
179 } else {
180 None
181 };
182 let content = match kind {
183 BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
184 BackupEntryKind::Symlink => link_target
185 .as_ref()
186 .map(|target| target.display().to_string())
187 .unwrap_or_default(),
188 BackupEntryKind::Tombstone => String::new(),
189 };
190
191 Ok(BackupEntry {
192 backup_id: row.backup_id,
193 content,
194 content_bytes,
195 timestamp: u64::try_from(row.created_at).unwrap_or_default(),
196 order: row.order,
197 description: row.description,
198 op_id: row.op_id,
199 kind,
200 mode: disk_metadata.as_ref().and_then(|metadata| metadata.mode),
201 link_target,
202 created_dirs: disk_metadata
203 .map(|metadata| metadata.created_dirs)
204 .unwrap_or_default(),
205 })
206 }
207}
208
209#[derive(Debug, Clone)]
210pub struct RestoredOperation {
211 pub op_id: String,
212 pub restored: Vec<RestoredFile>,
213 pub warnings: Vec<String>,
214}
215
216#[derive(Debug, Clone)]
217pub struct RestoredFile {
218 pub path: PathBuf,
219 pub backup_id: String,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct BackupPolicy {
224 pub enabled: bool,
225 pub max_depth: usize,
226 pub max_file_size: Option<u64>,
227}
228
229impl Default for BackupPolicy {
230 fn default() -> Self {
231 Self {
232 enabled: true,
233 max_depth: DEFAULT_MAX_UNDO_DEPTH,
234 max_file_size: None,
235 }
236 }
237}
238
239#[derive(Debug)]
258pub struct BackupStore {
259 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
261 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
263 session_meta: HashMap<String, SessionMeta>,
265 counter: AtomicU64,
266 storage_dir: Option<PathBuf>,
267 storage_harness: Option<String>,
268 db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
269 db_harness: RwLock<Option<String>>,
270 db_project_key: RwLock<Option<String>>,
271 policy: BackupPolicy,
272 #[cfg(test)]
273 fail_next_disk_write: bool,
274}
275
276#[derive(Debug, Clone)]
277struct DiskMeta {
278 dir: PathBuf,
279 count: usize,
280}
281
282#[derive(Debug, Clone, Default)]
283struct SessionMeta {
284 last_accessed: u64,
287}
288
289impl BackupStore {
290 pub fn new() -> Self {
291 BackupStore {
292 entries: HashMap::new(),
293 disk_index: HashMap::new(),
294 session_meta: HashMap::new(),
295 counter: AtomicU64::new(0),
296 storage_dir: None,
297 storage_harness: None,
298 db_pool: RwLock::new(None),
299 db_harness: RwLock::new(None),
300 db_project_key: RwLock::new(None),
301 policy: BackupPolicy::default(),
302 #[cfg(test)]
303 fail_next_disk_write: false,
304 }
305 }
306
307 pub fn set_policy(&mut self, policy: BackupPolicy) {
308 let old_policy = self.policy;
309 self.policy = policy;
310
311 let failed_disk_prunes = if policy.max_depth < old_policy.max_depth {
312 self.prune_disk_stacks_to_depth(policy.max_depth)
313 } else {
314 HashSet::new()
315 };
316
317 for (session, files) in &mut self.entries {
318 for (key, stack) in files {
319 if failed_disk_prunes.contains(&(session.clone(), key.clone())) {
320 continue;
321 }
322 trim_stack_to_depth(stack, self.policy.max_depth);
323 }
324 }
325 self.entries.retain(|_, files| {
326 files.retain(|_, stack| !stack.is_empty());
327 !files.is_empty()
328 });
329 }
330
331 pub fn policy(&self) -> BackupPolicy {
332 self.policy
333 }
334
335 #[cfg(test)]
336 fn fail_next_disk_write_for_tests(&mut self) {
337 self.fail_next_disk_write = true;
338 }
339
340 pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
341 if let Ok(mut slot) = self.db_pool.write() {
342 *slot = Some(conn);
343 }
344 }
345
346 pub fn clear_db_pool(&self) {
347 if let Ok(mut slot) = self.db_pool.write() {
348 *slot = None;
349 }
350 }
351
352 pub fn set_db_harness(&self, harness: crate::harness::Harness) {
353 if let Ok(mut slot) = self.db_harness.write() {
354 *slot = Some(harness.storage_segment());
355 }
356 }
357
358 pub fn set_db_project_key(&self, project_key: String) {
359 if let Ok(mut slot) = self.db_project_key.write() {
360 *slot = Some(project_key);
361 }
362 }
363
364 pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
370 self.set_storage_dir_inner(dir, None, ttl_hours);
371 }
372
373 pub fn set_storage_dir_for_harness(
374 &mut self,
375 dir: PathBuf,
376 harness: crate::harness::Harness,
377 ttl_hours: u32,
378 ) {
379 self.set_storage_dir_inner(dir, Some(harness.storage_segment()), ttl_hours);
380 }
381
382 fn set_storage_dir_inner(&mut self, dir: PathBuf, harness: Option<String>, ttl_hours: u32) {
383 self.storage_dir = Some(dir);
384 self.storage_harness = harness;
385 self.entries.clear();
386 self.disk_index.clear();
387 self.session_meta.clear();
388 self.repair_root_backups_if_needed();
389 self.gc_stale_sessions(ttl_hours);
390 self.migrate_legacy_layout_if_needed();
391 self.load_disk_index();
392 }
393
394 pub fn snapshot(
396 &mut self,
397 session: &str,
398 path: &Path,
399 description: &str,
400 ) -> Result<Option<String>, AftError> {
401 self.snapshot_with_op(session, path, description, None)
402 }
403
404 pub fn snapshot_with_op(
408 &mut self,
409 session: &str,
410 path: &Path,
411 description: &str,
412 op_id: Option<&str>,
413 ) -> Result<Option<String>, AftError> {
414 if !self.should_snapshot_path(path)? {
415 return Ok(None);
416 }
417 let key = canonicalize_key(path);
418 let _disk_lock = self.acquire_stack_disk_lock(session, &key)?;
419 self.ensure_stack_hydrated_locked(session, &key)?;
424 let (id, order) = self.next_id_and_order();
425 let entry = backup_entry_from_path(path, id.clone(), order, description, op_id)?;
426
427 let max_depth = self.policy.max_depth;
428 let pre_mutation_stack = self
429 .entries
430 .get(session)
431 .and_then(|files| files.get(&key))
432 .cloned();
433 let session_entries = self.entries.entry(session.to_string()).or_default();
434 let stack = session_entries.entry(key.clone()).or_default();
435 trim_stack_to_depth(stack, max_depth.saturating_sub(1));
436 stack.push(entry);
437 trim_stack_to_depth(stack, max_depth);
438
439 let stack_clone = stack.clone();
441 if let Err(error) = self.write_snapshot_to_disk_locked(session, &key, &stack_clone) {
442 self.restore_in_memory_stack(session, &key, pre_mutation_stack);
443 return Err(error);
444 }
445 self.touch_session(session);
446
447 Ok(Some(id))
448 }
449
450 pub fn snapshot_op_tombstone(
453 &mut self,
454 session: &str,
455 op_id: &str,
456 path: &Path,
457 description: &str,
458 ) -> Result<Option<String>, AftError> {
459 if !self.policy.enabled {
460 return Ok(None);
461 }
462 let key = canonicalize_key(path);
463 let _disk_lock = self.acquire_stack_disk_lock(session, &key)?;
464 self.ensure_stack_hydrated_locked(session, &key)?;
465 let created_dirs = path.parent().map(missing_parent_dirs).unwrap_or_default();
466 let (id, order) = self.next_id_and_order();
467 let entry = BackupEntry {
468 backup_id: id.clone(),
469 content: String::new(),
470 content_bytes: Vec::new(),
471 timestamp: current_timestamp(),
472 order,
473 description: description.to_string(),
474 op_id: Some(op_id.to_string()),
475 kind: BackupEntryKind::Tombstone,
476 mode: None,
477 link_target: None,
478 created_dirs,
479 };
480
481 let max_depth = self.policy.max_depth;
482 let pre_mutation_stack = self
483 .entries
484 .get(session)
485 .and_then(|files| files.get(&key))
486 .cloned();
487 let session_entries = self.entries.entry(session.to_string()).or_default();
488 let stack = session_entries.entry(key.clone()).or_default();
489 trim_stack_to_depth(stack, max_depth.saturating_sub(1));
490 stack.push(entry);
491 trim_stack_to_depth(stack, max_depth);
492
493 let stack_clone = stack.clone();
494 if let Err(error) = self.write_snapshot_to_disk_locked(session, &key, &stack_clone) {
495 self.restore_in_memory_stack(session, &key, pre_mutation_stack);
496 return Err(error);
497 }
498 self.touch_session(session);
499
500 Ok(Some(id))
501 }
502
503 pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
506 let mut candidate_keys = self.restore_operation_candidate_keys(session)?;
507 if candidate_keys.is_empty() {
508 self.load_latest_operation_from_db_or_log(session);
509 candidate_keys = self.restore_operation_candidate_keys(session)?;
510 }
511
512 for attempt in 0..MAX_RESTORE_OPERATION_LOCK_RETRIES {
513 if candidate_keys.is_empty() {
514 return Err(AftError::NoUndoHistory {
515 path: "operation".to_string(),
516 });
517 }
518
519 run_restore_before_lock_hook_for_tests(session, attempt);
520
521 let disk_locks = self.acquire_stack_disk_locks(session, &candidate_keys)?;
522 let locked_keys: HashSet<PathBuf> = candidate_keys.iter().cloned().collect();
523 let current_keys = self.restore_operation_candidate_keys(session)?;
524 let current_key_set: HashSet<PathBuf> = current_keys.iter().cloned().collect();
525 if !current_key_set.is_subset(&locked_keys) {
526 drop(disk_locks);
527 candidate_keys.extend(current_key_set);
528 candidate_keys.sort();
529 candidate_keys.dedup();
530 continue;
531 }
532
533 for key in ¤t_keys {
534 self.load_from_disk_if_needed_locked(session, key)?;
535 }
536
537 if !self.has_in_memory_entries(session) {
538 self.load_latest_operation_from_db_or_log(session);
539 }
540
541 let Some(op_id) = self.latest_operation_id_from_memory(session) else {
542 return Err(AftError::NoUndoHistory {
543 path: "operation".to_string(),
544 });
545 };
546
547 let keys_to_restore = self.operation_keys_for_top_op(session, &op_id);
548 if keys_to_restore.is_empty() {
549 return Err(AftError::NoUndoHistory {
550 path: "operation".to_string(),
551 });
552 }
553 if !keys_to_restore.iter().all(|key| locked_keys.contains(key)) {
554 drop(disk_locks);
555 candidate_keys.extend(keys_to_restore);
556 candidate_keys.sort();
557 candidate_keys.dedup();
558 continue;
559 }
560
561 let mut content_targets = Vec::new();
562 let mut tombstone_targets = Vec::new();
563 for key in &keys_to_restore {
564 let entry = self
565 .entries
566 .get(session)
567 .and_then(|files| files.get(key))
568 .and_then(|stack| stack.last())
569 .cloned()
570 .ok_or_else(|| AftError::NoUndoHistory {
571 path: key.display().to_string(),
572 })?;
573 match entry.kind {
574 BackupEntryKind::Content | BackupEntryKind::Symlink => {
575 let existing_state = capture_path_state(key)?;
576 let warning = self.check_external_modification(session, key, key);
577 content_targets.push((key.clone(), entry, warning, existing_state));
578 }
579 BackupEntryKind::Tombstone => {
580 let existing_state = capture_path_state(key)?;
581 tombstone_targets.push((key.clone(), entry, existing_state));
582 }
583 }
584 }
585
586 let mut created_dirs = Vec::new();
587 for (key, _, _, _) in &content_targets {
588 if let Some(parent) = key.parent() {
589 if !parent.as_os_str().is_empty() {
590 let missing_dirs = missing_parent_dirs(parent);
591 if let Err(e) = std::fs::create_dir_all(parent) {
592 let mut dirs_to_remove = created_dirs;
593 dirs_to_remove.extend(missing_dirs);
594 let rollback_ok = rollback_created_dirs(&dirs_to_remove);
595 return Err(AftError::IoError {
596 path: parent.display().to_string(),
597 message: format!(
598 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
599 e,
600 !rollback_ok,
601 rollback_ok
602 ),
603 });
604 }
605 created_dirs.extend(missing_dirs);
606 }
607 }
608 }
609
610 let mut written = Vec::new();
611 for (key, entry, _, existing_state) in &content_targets {
612 if let Err(e) = restore_entry_to_path(key, entry) {
613 let files_rollback_ok =
614 rollback_transactional_restore(&written, Some((key, existing_state)));
615 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
616 let rollback_ok = files_rollback_ok && dirs_rollback_ok;
617 return Err(AftError::IoError {
618 path: key.display().to_string(),
619 message: format!(
620 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
621 e,
622 !rollback_ok,
623 rollback_ok
624 ),
625 });
626 }
627 written.push((key.clone(), existing_state.clone()));
628 }
629
630 let mut deleted_tombstones = Vec::new();
631 for (key, _, existing_state) in &tombstone_targets {
632 match remove_tombstone_path(key) {
633 Ok(()) => deleted_tombstones.push((key.clone(), existing_state.clone())),
634 Err(e) => {
635 let files_rollback_ok = rollback_transactional_restore(&written, None);
636 let tombstone_rollback_ok =
637 rollback_deleted_tombstones(&deleted_tombstones);
638 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
639 let rollback_ok =
640 files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
641 return Err(AftError::IoError {
642 path: key.display().to_string(),
643 message: format!(
644 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
645 e,
646 !rollback_ok,
647 rollback_ok
648 ),
649 });
650 }
651 }
652 }
653 let tombstone_created_dirs = tombstone_targets
654 .iter()
655 .flat_map(|(_, entry, _)| entry.created_dirs.iter().cloned())
656 .collect::<Vec<_>>();
657 remove_created_dirs_best_effort(&tombstone_created_dirs);
658
659 let mut restored = Vec::new();
660 let mut warnings = Vec::new();
661 for (key, entry, warning, _) in content_targets {
662 self.commit_restored_backup_locked(session, &key)?;
663 if let Some(warning) = warning {
664 warnings.push(format!("{}: {}", key.display(), warning));
665 }
666 restored.push(RestoredFile {
667 path: key,
668 backup_id: entry.backup_id,
669 });
670 }
671 for (key, _, _) in tombstone_targets {
672 self.commit_restored_backup_locked(session, &key)?;
673 }
674 self.touch_session(session);
675 drop(disk_locks);
676
677 return Ok(RestoredOperation {
678 op_id,
679 restored,
680 warnings,
681 });
682 }
683
684 Err(AftError::IoError {
685 path: "operation".to_string(),
686 message: "backup stack changing under concurrent activity; retry".to_string(),
687 })
688 }
689
690 pub fn restore_latest(
693 &mut self,
694 session: &str,
695 path: &Path,
696 ) -> Result<(BackupEntry, Option<String>), AftError> {
697 let key = canonicalize_key(path);
698 let _disk_lock = self.acquire_stack_disk_lock(session, &key)?;
699
700 match self.read_stack_from_disk_unlocked(session, &key) {
701 Ok(Some(entries)) if !entries.is_empty() => {
702 self.update_counter_from_entries(&entries);
703 self.entries
704 .entry(session.to_string())
705 .or_default()
706 .insert(key.to_path_buf(), entries);
707 }
708 Ok(_) => {
709 if self.session_dir(session).is_some() {
710 self.restore_in_memory_stack(session, &key, None);
711 }
712 }
713 Err(error) => {
714 return Err(AftError::IoError {
715 path: key.display().to_string(),
716 message: error,
717 });
718 }
719 }
720
721 if self
722 .entries
723 .get(session)
724 .and_then(|s| s.get(&key))
725 .is_none_or(|s| s.is_empty())
726 {
727 match self.load_from_db_if_present(session, &key) {
728 Some(Ok(true)) => {}
729 Some(Ok(false)) => {
730 crate::slog_info!(
731 "backup DB miss for session {} path {}; disk meta is authoritative",
732 session,
733 key.display()
734 );
735 }
736 Some(Err(error)) => {
737 crate::slog_warn!(
738 "backup DB lookup failed for session {} path {}: {}",
739 session,
740 key.display(),
741 error
742 );
743 }
744 None => {
745 crate::slog_info!(
746 "backup DB unavailable for session {} path {}",
747 session,
748 key.display()
749 );
750 }
751 }
752 }
753
754 let in_memory = self
756 .entries
757 .get(session)
758 .and_then(|s| s.get(&key))
759 .map_or(false, |s| !s.is_empty());
760 if in_memory {
761 let warning = self.check_external_modification(session, &key, path);
762 let result = self
763 .do_restore_locked(session, &key, path)
764 .map(|(entry, _)| (entry, warning));
765 if result.is_ok() {
766 self.touch_session(session);
767 }
768 return result;
769 }
770
771 Err(AftError::NoUndoHistory {
772 path: path.display().to_string(),
773 })
774 }
775
776 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
778 let key = canonicalize_key(path);
779 let _disk_lock = match self.acquire_stack_disk_lock(session, &key) {
780 Ok(lock) => lock,
781 Err(error) => {
782 crate::slog_warn!(
783 "backup disk read lock failed for {}: {}",
784 key.display(),
785 error
786 );
787 return Vec::new();
788 }
789 };
790
791 match self.read_stack_from_disk_unlocked(session, &key) {
792 Ok(Some(stack)) if !stack.is_empty() => return stack,
793 Ok(_) => {}
794 Err(error) => {
795 crate::slog_warn!("backup disk read failed for {}: {}", key.display(), error);
796 return Vec::new();
797 }
798 }
799
800 if let Some(stack) = self.entries.get(session).and_then(|s| s.get(&key)).cloned() {
801 if !stack.is_empty() {
802 return stack;
803 }
804 }
805
806 match self.read_stack_from_db(session, &key) {
807 Some(Ok(stack)) if !stack.is_empty() => stack,
808 Some(Ok(_)) => Vec::new(),
809 Some(Err(error)) => {
810 crate::slog_warn!(
811 "backup history DB lookup failed for session {} path {}: {}",
812 session,
813 key.display(),
814 error
815 );
816 Vec::new()
817 }
818 None => Vec::new(),
819 }
820 }
821
822 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
824 let key = canonicalize_key(path);
825 self.disk_index
826 .get(session)
827 .and_then(|s| s.get(&key))
828 .map(|m| m.count)
829 .unwrap_or(0)
830 }
831
832 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
835 let mut files: std::collections::HashSet<PathBuf> = self
836 .entries
837 .get(session)
838 .map(|s| s.keys().cloned().collect())
839 .unwrap_or_default();
840 if let Some(disk) = self.disk_index.get(session) {
841 for key in disk.keys() {
842 files.insert(key.clone());
843 }
844 }
845 files.into_iter().collect()
846 }
847
848 pub fn preview_latest_path(&self, session: &str, path: &Path) -> Result<PathBuf, AftError> {
853 let key = canonicalize_key(path);
854 if self.latest_head_for_key(session, &key).is_some() {
855 Ok(key)
856 } else {
857 Err(AftError::NoUndoHistory {
858 path: path.display().to_string(),
859 })
860 }
861 }
862
863 pub fn preview_last_operation_paths(&self, session: &str) -> Result<Vec<PathBuf>, AftError> {
869 let mut heads_by_path: HashMap<PathBuf, BackupEntryHead> = self
870 .entries
871 .get(session)
872 .map(|files| {
873 files
874 .iter()
875 .filter_map(|(key, stack)| {
876 stack
877 .last()
878 .map(|entry| (key.clone(), BackupEntryHead::from_entry(entry)))
879 })
880 .collect()
881 })
882 .unwrap_or_default();
883
884 match self.read_latest_operation_heads_from_db(session) {
885 Some(Ok(db_heads)) if !db_heads.is_empty() => {
886 for (key, head) in db_heads {
887 heads_by_path.insert(key, head);
888 }
889 self.merge_disk_stack_heads(session, &mut heads_by_path);
890 }
891 Some(Ok(_)) => {
892 crate::slog_info!(
893 "backup latest operation preview DB miss for session {}; falling back to disk",
894 session
895 );
896 self.merge_disk_stack_heads(session, &mut heads_by_path);
897 }
898 Some(Err(error)) => {
899 crate::slog_warn!(
900 "backup latest operation preview DB lookup failed for session {}; falling back to disk: {}",
901 session,
902 error
903 );
904 self.merge_disk_stack_heads(session, &mut heads_by_path);
905 }
906 None => {
907 crate::slog_info!(
908 "backup latest operation preview DB unavailable for session {}; falling back to disk",
909 session
910 );
911 self.merge_disk_stack_heads(session, &mut heads_by_path);
912 }
913 }
914
915 let mut latest: Option<(u128, String)> = None;
916 for head in heads_by_path.values() {
917 if let Some(op_id) = &head.op_id {
918 if latest
919 .as_ref()
920 .map_or(true, |(latest_order, _)| head.order > *latest_order)
921 {
922 latest = Some((head.order, op_id.clone()));
923 }
924 }
925 }
926
927 let Some((_, op_id)) = latest else {
928 return Err(AftError::NoUndoHistory {
929 path: "operation".to_string(),
930 });
931 };
932
933 let mut paths: Vec<PathBuf> = heads_by_path
934 .into_iter()
935 .filter_map(|(key, head)| {
936 (head.op_id.as_deref() == Some(op_id.as_str())).then_some(key)
937 })
938 .collect();
939 paths.sort();
940
941 if paths.is_empty() {
942 Err(AftError::NoUndoHistory {
943 path: "operation".to_string(),
944 })
945 } else {
946 Ok(paths)
947 }
948 }
949
950 pub fn sessions_with_backups(&self) -> Vec<String> {
953 let mut sessions: std::collections::HashSet<String> =
954 self.entries.keys().cloned().collect();
955 for s in self.disk_index.keys() {
956 sessions.insert(s.clone());
957 }
958 sessions.into_iter().collect()
959 }
960
961 pub fn total_disk_bytes(&self) -> u64 {
964 let mut total = 0u64;
965 for session_dirs in self.disk_index.values() {
966 for meta in session_dirs.values() {
967 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
968 for entry in read_dir.flatten() {
969 if let Ok(m) = entry.metadata() {
970 if m.is_file() {
971 total += m.len();
972 }
973 }
974 }
975 }
976 }
977 }
978 total
979 }
980
981 fn next_id_and_order(&self) -> (String, u128) {
982 let n = self.counter.fetch_add(1, Ordering::Relaxed);
983 let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
984 (format!("backup-{}", n), order)
985 }
986
987 fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
988 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
989 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
990 Some((pool, harness))
991 }
992
993 fn latest_head_for_key(&self, session: &str, key: &Path) -> Option<BackupEntryHead> {
994 self.entries
995 .get(session)
996 .and_then(|files| files.get(key))
997 .and_then(|stack| stack.last())
998 .map(BackupEntryHead::from_entry)
999 .or_else(|| {
1000 self.read_stack_heads_from_disk(session, key)
1001 .and_then(|stack| stack.last().cloned())
1002 })
1003 .or_else(|| match self.read_stack_heads_from_db(session, key) {
1004 Some(Ok(stack)) if !stack.is_empty() => stack.last().cloned(),
1005 Some(Err(error)) => {
1006 crate::slog_warn!(
1007 "backup preview DB lookup failed for session {} path {}: {}",
1008 session,
1009 key.display(),
1010 error
1011 );
1012 None
1013 }
1014 _ => None,
1015 })
1016 }
1017
1018 fn merge_disk_stack_heads(
1019 &self,
1020 session: &str,
1021 heads_by_path: &mut HashMap<PathBuf, BackupEntryHead>,
1022 ) {
1023 let disk_keys: Vec<PathBuf> = self
1024 .disk_index
1025 .get(session)
1026 .map(|files| files.keys().cloned().collect())
1027 .unwrap_or_default();
1028 for key in disk_keys {
1029 if let Some(head) = self
1030 .read_stack_heads_from_disk(session, &key)
1031 .and_then(|stack| stack.last().cloned())
1032 {
1033 heads_by_path.insert(key, head);
1034 }
1035 }
1036 }
1037
1038 fn read_stack_heads_from_db(
1039 &self,
1040 session: &str,
1041 key: &Path,
1042 ) -> Option<Result<Vec<BackupEntryHead>, String>> {
1043 let (pool, harness) = self.db_pool_and_harness()?;
1044 let conn = match pool.lock() {
1045 Ok(conn) => conn,
1046 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1047 };
1048 let path_hash = Self::path_hash(key);
1049 Some(
1050 crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
1051 .map_err(|error| error.to_string())
1052 .map(|rows| {
1053 rows.iter()
1054 .map(BackupEntryHead::from_row)
1055 .collect::<Vec<_>>()
1056 }),
1057 )
1058 }
1059
1060 fn read_latest_operation_heads_from_db(
1061 &self,
1062 session: &str,
1063 ) -> Option<Result<HashMap<PathBuf, BackupEntryHead>, String>> {
1064 let (pool, harness) = self.db_pool_and_harness()?;
1065 let conn = match pool.lock() {
1066 Ok(conn) => conn,
1067 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1068 };
1069 let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
1070 {
1071 Ok(Some(row)) => row,
1072 Ok(None) => return Some(Ok(HashMap::new())),
1073 Err(error) => return Some(Err(error.to_string())),
1074 };
1075 let Some(op_id) = latest.op_id else {
1076 return Some(Ok(HashMap::new()));
1077 };
1078 let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
1079 Ok(rows) => rows,
1080 Err(error) => return Some(Err(error.to_string())),
1081 };
1082 if rows.is_empty() {
1083 return Some(Ok(HashMap::new()));
1084 }
1085 let path_hashes: std::collections::HashSet<String> =
1086 rows.into_iter().map(|row| row.path_hash).collect();
1087 drop(conn);
1088
1089 let mut heads = HashMap::new();
1090 for path_hash in path_hashes {
1091 let conn = match pool.lock() {
1092 Ok(conn) => conn,
1093 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1094 };
1095 let rows = match crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
1096 {
1097 Ok(rows) => rows,
1098 Err(error) => return Some(Err(error.to_string())),
1099 };
1100 drop(conn);
1101
1102 let Some(file_path) = rows.first().map(|row| row.file_path.clone()) else {
1103 continue;
1104 };
1105 let Some(head) = rows.last().map(BackupEntryHead::from_row) else {
1106 continue;
1107 };
1108 heads.insert(PathBuf::from(file_path), head);
1109 }
1110
1111 Some(Ok(heads))
1112 }
1113
1114 fn read_stack_from_db(
1115 &self,
1116 session: &str,
1117 key: &Path,
1118 ) -> Option<Result<Vec<BackupEntry>, String>> {
1119 let (pool, harness) = self.db_pool_and_harness()?;
1120 let conn = match pool.lock() {
1121 Ok(conn) => conn,
1122 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1123 };
1124 let path_hash = Self::path_hash(key);
1125 Some(
1126 crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
1127 .map_err(|error| error.to_string())
1128 .and_then(|rows| {
1129 rows.into_iter()
1130 .map(|row| self.backup_entry_from_db_row(row))
1131 .collect::<Result<Vec<_>, _>>()
1132 .map_err(|error| error.to_string())
1133 }),
1134 )
1135 }
1136
1137 fn load_from_db_if_present(
1138 &mut self,
1139 session: &str,
1140 key: &Path,
1141 ) -> Option<Result<bool, String>> {
1142 match self.read_stack_from_db(session, key) {
1143 Some(Ok(stack)) if !stack.is_empty() => {
1144 self.update_counter_from_entries(&stack);
1145 self.entries
1146 .entry(session.to_string())
1147 .or_default()
1148 .insert(key.to_path_buf(), stack);
1149 Some(Ok(true))
1150 }
1151 Some(Ok(_)) => Some(Ok(false)),
1152 Some(Err(error)) => Some(Err(error)),
1153 None => None,
1154 }
1155 }
1156
1157 fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
1158 let (pool, harness) = self.db_pool_and_harness()?;
1159 let conn = match pool.lock() {
1160 Ok(conn) => conn,
1161 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1162 };
1163 let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
1164 {
1165 Ok(Some(row)) => row,
1166 Ok(None) => return Some(Ok(false)),
1167 Err(error) => return Some(Err(error.to_string())),
1168 };
1169 let Some(op_id) = latest.op_id else {
1170 return Some(Ok(false));
1171 };
1172 let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
1173 Ok(rows) => rows,
1174 Err(error) => return Some(Err(error.to_string())),
1175 };
1176 if rows.is_empty() {
1177 return Some(Ok(false));
1178 }
1179 let path_hashes: std::collections::HashSet<String> =
1180 rows.into_iter().map(|row| row.path_hash).collect();
1181 drop(conn);
1182
1183 let mut loaded_any = false;
1184 for path_hash in path_hashes {
1185 let conn = match pool.lock() {
1186 Ok(conn) => conn,
1187 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1188 };
1189 let loaded =
1190 match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
1191 Ok(rows) => {
1192 let file_path = rows.first().map(|row| row.file_path.clone());
1193 rows.into_iter()
1194 .map(|row| self.backup_entry_from_db_row(row))
1195 .collect::<Result<Vec<_>, _>>()
1196 .map(|stack| (file_path, stack))
1197 .map_err(|error| error.to_string())
1198 }
1199 Err(error) => Err(error.to_string()),
1200 };
1201 drop(conn);
1202 let (file_path, stack) = match loaded {
1203 Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
1204 Ok(_) => continue,
1205 Err(error) => return Some(Err(error)),
1206 };
1207 let Some(file_path) = file_path else {
1208 return Some(Err(format!(
1209 "backup DB rows for path hash {path_hash} have no file path"
1210 )));
1211 };
1212 let key = PathBuf::from(file_path);
1213 self.update_counter_from_entries(&stack);
1214 self.entries
1215 .entry(session.to_string())
1216 .or_default()
1217 .insert(key, stack);
1218 loaded_any = true;
1219 }
1220
1221 Some(Ok(loaded_any))
1222 }
1223
1224 fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
1225 if let Some(next_counter) = entries
1226 .iter()
1227 .filter_map(|entry| backup_sequence(&entry.backup_id))
1228 .max()
1229 .and_then(|max| max.checked_add(1))
1230 {
1231 let _ = self
1232 .counter
1233 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1234 (current < next_counter).then_some(next_counter)
1235 });
1236 }
1237 }
1238
1239 fn restore_in_memory_stack(
1240 &mut self,
1241 session: &str,
1242 key: &Path,
1243 stack: Option<Vec<BackupEntry>>,
1244 ) {
1245 match stack {
1246 Some(stack) if !stack.is_empty() => {
1247 self.entries
1248 .entry(session.to_string())
1249 .or_default()
1250 .insert(key.to_path_buf(), stack);
1251 }
1252 _ => {
1253 if let Some(files) = self.entries.get_mut(session) {
1254 files.remove(key);
1255 if files.is_empty() {
1256 self.entries.remove(session);
1257 }
1258 }
1259 }
1260 }
1261 }
1262
1263 fn has_in_memory_entries(&self, session: &str) -> bool {
1264 self.entries
1265 .get(session)
1266 .is_some_and(|files| files.values().any(|stack| !stack.is_empty()))
1267 }
1268
1269 fn latest_operation_id_from_memory(&self, session: &str) -> Option<String> {
1270 let mut latest: Option<(u128, String)> = None;
1271 if let Some(files) = self.entries.get(session) {
1272 for stack in files.values() {
1273 if let Some(entry) = stack.last() {
1274 if let Some(op_id) = &entry.op_id {
1275 if latest
1276 .as_ref()
1277 .is_none_or(|(latest_order, _)| entry.order > *latest_order)
1278 {
1279 latest = Some((entry.order, op_id.clone()));
1280 }
1281 }
1282 }
1283 }
1284 }
1285 latest.map(|(_, op_id)| op_id)
1286 }
1287
1288 fn operation_keys_for_top_op(&self, session: &str, op_id: &str) -> Vec<PathBuf> {
1289 let mut keys: Vec<PathBuf> = self
1290 .entries
1291 .get(session)
1292 .map(|files| {
1293 files
1294 .iter()
1295 .filter_map(|(key, stack)| {
1296 stack.last().and_then(|entry| {
1297 (entry.op_id.as_deref() == Some(op_id)).then(|| key.clone())
1298 })
1299 })
1300 .collect()
1301 })
1302 .unwrap_or_default();
1303 keys.sort();
1304 keys
1305 }
1306
1307 fn load_latest_operation_from_db_or_log(&mut self, session: &str) {
1308 match self.load_latest_operation_from_db(session) {
1309 Some(Ok(true)) => {}
1310 Some(Ok(false)) => {
1311 crate::slog_info!(
1312 "backup latest operation DB miss for session {}; disk meta is authoritative",
1313 session
1314 );
1315 }
1316 Some(Err(error)) => {
1317 crate::slog_warn!(
1318 "backup latest operation DB lookup failed for session {}: {}",
1319 session,
1320 error
1321 );
1322 }
1323 None => {
1324 crate::slog_info!(
1325 "backup latest operation DB unavailable for session {}",
1326 session
1327 );
1328 }
1329 }
1330 }
1331
1332 fn resolve_db_backup_row_path(&self, mut row: BackupRow) -> BackupRow {
1333 if let Some(backup_path) = row.backup_path.clone() {
1334 let path = PathBuf::from(&backup_path);
1335 if path.is_relative() {
1336 if let Some(session_dir) = self.session_dir(&row.session_id) {
1337 row.backup_path = Some(
1338 session_dir
1339 .join(&row.path_hash)
1340 .join(path)
1341 .display()
1342 .to_string(),
1343 );
1344 }
1345 }
1346 }
1347 row
1348 }
1349
1350 fn backup_entry_from_db_row(&self, row: BackupRow) -> Result<BackupEntry, std::io::Error> {
1351 BackupEntry::try_from(self.resolve_db_backup_row_path(row))
1352 }
1353
1354 pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
1355 let keys: Vec<PathBuf> = self
1356 .entries
1357 .get(session)
1358 .map(|files| files.keys().cloned().collect())
1359 .unwrap_or_default();
1360
1361 for key in keys {
1362 let mut remove_key = false;
1363 let mut remaining_stack = None;
1364 if let Some(session_entries) = self.entries.get_mut(session) {
1365 if let Some(stack) = session_entries.get_mut(&key) {
1366 while stack
1367 .last()
1368 .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
1369 {
1370 stack.pop();
1371 }
1372 if stack.is_empty() {
1373 remove_key = true;
1374 } else {
1375 remaining_stack = Some(stack.clone());
1376 }
1377 }
1378 if remove_key {
1379 session_entries.remove(&key);
1380 }
1381 }
1382
1383 if remove_key {
1384 if let Err(error) = self.remove_disk_backups(session, &key) {
1385 crate::slog_warn!(
1386 "failed to remove backup stack for {} during operation discard: {}",
1387 key.display(),
1388 error
1389 );
1390 }
1391 } else if let Some(stack) = remaining_stack {
1392 if let Err(error) = self.write_snapshot_to_disk(session, &key, &stack) {
1393 crate::slog_warn!(
1394 "failed to persist backup stack for {} during operation discard: {}",
1395 key.display(),
1396 error
1397 );
1398 }
1399 }
1400 }
1401
1402 if self
1403 .entries
1404 .get(session)
1405 .is_some_and(|session_entries| session_entries.is_empty())
1406 {
1407 self.entries.remove(session);
1408 }
1409 }
1410
1411 pub(crate) fn discard_latest_operation_entry_for_path(
1412 &mut self,
1413 session: &str,
1414 op_id: &str,
1415 path: &Path,
1416 ) {
1417 let key = canonicalize_key(path);
1418 let mut remove_key = false;
1419 let mut remaining_stack = None;
1420
1421 if let Some(session_entries) = self.entries.get_mut(session) {
1422 if let Some(stack) = session_entries.get_mut(&key) {
1423 if stack
1424 .last()
1425 .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
1426 {
1427 stack.pop();
1428 if stack.is_empty() {
1429 remove_key = true;
1430 } else {
1431 remaining_stack = Some(stack.clone());
1432 }
1433 }
1434 }
1435 if remove_key {
1436 session_entries.remove(&key);
1437 }
1438 }
1439
1440 if remove_key {
1441 if let Err(error) = self.remove_disk_backups(session, &key) {
1442 crate::slog_warn!(
1443 "failed to remove backup stack for {} during single-entry discard: {}",
1444 key.display(),
1445 error
1446 );
1447 }
1448 } else if let Some(stack) = remaining_stack {
1449 if let Err(error) = self.write_snapshot_to_disk(session, &key, &stack) {
1450 crate::slog_warn!(
1451 "failed to persist backup stack for {} during single-entry discard: {}",
1452 key.display(),
1453 error
1454 );
1455 }
1456 }
1457
1458 if self
1459 .entries
1460 .get(session)
1461 .is_some_and(|session_entries| session_entries.is_empty())
1462 {
1463 self.entries.remove(session);
1464 }
1465 }
1466
1467 fn touch_session(&mut self, session: &str) {
1468 let now = current_timestamp();
1469 self.session_meta
1470 .entry(session.to_string())
1471 .or_default()
1472 .last_accessed = now;
1473 self.write_session_marker(session, now);
1474 }
1475
1476 fn do_restore_locked(
1479 &mut self,
1480 session: &str,
1481 key: &Path,
1482 path: &Path,
1483 ) -> Result<(BackupEntry, Option<String>), AftError> {
1484 let session_entries =
1485 self.entries
1486 .get_mut(session)
1487 .ok_or_else(|| AftError::NoUndoHistory {
1488 path: path.display().to_string(),
1489 })?;
1490 let stack = session_entries
1491 .get_mut(key)
1492 .ok_or_else(|| AftError::NoUndoHistory {
1493 path: path.display().to_string(),
1494 })?;
1495
1496 let entry = stack
1497 .last()
1498 .cloned()
1499 .ok_or_else(|| AftError::NoUndoHistory {
1500 path: path.display().to_string(),
1501 })?;
1502
1503 match entry.kind {
1504 BackupEntryKind::Content | BackupEntryKind::Symlink => {
1505 restore_entry_to_path(path, &entry).map_err(|e| AftError::IoError {
1506 path: path.display().to_string(),
1507 message: e.to_string(),
1508 })?;
1509 }
1510 BackupEntryKind::Tombstone => {
1511 remove_tombstone_path(path).map_err(|e| AftError::IoError {
1512 path: path.display().to_string(),
1513 message: e.to_string(),
1514 })?;
1515 remove_created_dirs_best_effort(&entry.created_dirs);
1516 }
1517 }
1518
1519 stack.pop();
1520 if stack.is_empty() {
1521 session_entries.remove(key);
1522 if session_entries.is_empty() {
1524 self.entries.remove(session);
1525 }
1526 self.remove_disk_backups_locked(session, key)?;
1527 } else {
1528 let stack_clone = self
1529 .entries
1530 .get(session)
1531 .and_then(|s| s.get(key))
1532 .cloned()
1533 .unwrap_or_default();
1534 self.write_snapshot_to_disk_locked(session, key, &stack_clone)?;
1535 }
1536
1537 Ok((entry, None))
1538 }
1539
1540 fn commit_restored_backup_locked(&mut self, session: &str, key: &Path) -> Result<(), AftError> {
1541 let mut remove_key = false;
1542 let mut remove_session = false;
1543 let mut remaining_stack = None;
1544
1545 if let Some(session_entries) = self.entries.get_mut(session) {
1546 if let Some(stack) = session_entries.get_mut(key) {
1547 stack.pop();
1548 if stack.is_empty() {
1549 remove_key = true;
1550 } else {
1551 remaining_stack = Some(stack.clone());
1552 }
1553 }
1554
1555 if remove_key {
1556 session_entries.remove(key);
1557 remove_session = session_entries.is_empty();
1558 }
1559 }
1560
1561 if remove_session {
1562 self.entries.remove(session);
1563 }
1564
1565 if remove_key {
1566 self.remove_disk_backups_locked(session, key)?;
1567 } else if let Some(stack) = remaining_stack {
1568 self.write_snapshot_to_disk_locked(session, key, &stack)?;
1569 }
1570
1571 Ok(())
1572 }
1573
1574 fn check_external_modification(
1575 &self,
1576 session: &str,
1577 key: &Path,
1578 path: &Path,
1579 ) -> Option<String> {
1580 let stack = self.entries.get(session).and_then(|s| s.get(key))?;
1581 let latest = stack.last()?;
1582 let modified = match latest.kind {
1583 BackupEntryKind::Content => std::fs::read(path)
1584 .map(|current| current != latest.content_bytes)
1585 .unwrap_or(true),
1586 BackupEntryKind::Symlink => std::fs::read_link(path)
1587 .map(|target| latest.link_target.as_ref() != Some(&target))
1588 .unwrap_or(true),
1589 BackupEntryKind::Tombstone => false,
1590 };
1591 modified.then(|| "file was modified externally since last backup".to_string())
1592 }
1593
1594 fn backups_dir(&self) -> Option<PathBuf> {
1597 self.storage_dir
1598 .as_ref()
1599 .map(|dir| match &self.storage_harness {
1600 Some(harness) => dir.join(harness).join("backups"),
1601 None => dir.join("backups"),
1602 })
1603 }
1604
1605 fn session_dir(&self, session: &str) -> Option<PathBuf> {
1606 self.backups_dir()
1607 .map(|d| d.join(Self::session_hash(session)))
1608 }
1609
1610 fn session_hash(session: &str) -> String {
1611 hash_session(session)
1612 }
1613
1614 fn path_hash(key: &Path) -> String {
1615 stable_hash_16(key.to_string_lossy().as_bytes())
1620 }
1621
1622 fn write_session_marker(&self, session: &str, last_accessed: u64) {
1623 let Some(session_dir) = self.session_dir(session) else {
1624 return;
1625 };
1626 if let Err(e) = std::fs::create_dir_all(&session_dir) {
1627 crate::slog_warn!("failed to create session dir: {}", e);
1628 return;
1629 }
1630 let marker = session_dir.join("session.json");
1631 let json = serde_json::json!({
1632 "schema_version": SCHEMA_VERSION,
1633 "session_id": session,
1634 "last_accessed": last_accessed,
1635 });
1636 if let Ok(s) = serde_json::to_string_pretty(&json) {
1637 let tmp = session_dir.join("session.json.tmp");
1638 if std::fs::write(&tmp, s).is_ok() {
1639 let _ = std::fs::rename(&tmp, marker);
1640 }
1641 }
1642 }
1643
1644 fn repair_root_backups_if_needed(&self) {
1645 let (Some(storage_dir), Some(harness)) = (&self.storage_dir, &self.storage_harness) else {
1646 return;
1647 };
1648 let root_backups = storage_dir.join("backups");
1649 if !dir_has_entries(&root_backups) {
1650 return;
1651 }
1652 let harness_backups = storage_dir.join(harness).join("backups");
1653 if dir_has_entries(&harness_backups) {
1654 return;
1655 }
1656 if let Some(parent) = harness_backups.parent() {
1657 if let Err(error) = std::fs::create_dir_all(parent) {
1658 crate::slog_warn!(
1659 "failed to create harness backup dir {}: {}",
1660 parent.display(),
1661 error
1662 );
1663 return;
1664 }
1665 }
1666 if harness_backups.exists() {
1667 let _ = std::fs::remove_dir(&harness_backups);
1668 }
1669 match std::fs::rename(&root_backups, &harness_backups) {
1670 Ok(()) => {
1671 crate::slog_info!(
1672 "moved legacy root backups into harness namespace: {}",
1673 harness_backups.display()
1674 );
1675 }
1676 Err(error) => {
1677 crate::slog_warn!(
1678 "failed to move legacy root backups into {}: {}; trying child merge",
1679 harness_backups.display(),
1680 error
1681 );
1682 if std::fs::create_dir_all(&harness_backups).is_err() {
1683 return;
1684 }
1685 if let Ok(entries) = std::fs::read_dir(&root_backups) {
1686 for entry in entries.flatten() {
1687 let source = entry.path();
1688 let target = harness_backups.join(entry.file_name());
1689 if !target.exists() {
1690 let _ = std::fs::rename(source, target);
1691 }
1692 }
1693 }
1694 let _ = std::fs::remove_dir(&root_backups);
1695 }
1696 }
1697 }
1698
1699 fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1700 let backups_dir = match self.backups_dir() {
1701 Some(d) if d.exists() => d,
1702 _ => return,
1703 };
1704 let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1705 let cutoff = current_timestamp().saturating_sub(ttl_secs);
1706 let entries = match std::fs::read_dir(&backups_dir) {
1707 Ok(entries) => entries,
1708 Err(_) => return,
1709 };
1710
1711 for entry in entries.flatten() {
1712 let session_dir = entry.path();
1713 if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1714 continue;
1715 }
1716 let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1717 continue;
1718 };
1719 if last_accessed >= cutoff {
1720 continue;
1721 }
1722 if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1723 crate::slog_warn!(
1724 "failed to remove stale backup session {}: {}",
1725 session_dir.display(),
1726 e
1727 );
1728 } else {
1729 crate::slog_warn!(
1730 "removed stale backup session {} (last_accessed={})",
1731 session_dir.display(),
1732 last_accessed
1733 );
1734 }
1735 }
1736 }
1737
1738 fn migrate_legacy_layout_if_needed(&mut self) {
1746 let backups_dir = match self.backups_dir() {
1747 Some(d) if d.exists() => d,
1748 _ => return,
1749 };
1750 let default_session_dir =
1751 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1752
1753 let entries = match std::fs::read_dir(&backups_dir) {
1754 Ok(e) => e,
1755 Err(_) => return,
1756 };
1757 let mut migrated = 0usize;
1758 for entry in entries.flatten() {
1759 let entry_path = entry.path();
1760 if !entry_path.is_dir() {
1762 continue;
1763 }
1764 if entry_path == default_session_dir {
1765 continue;
1766 }
1767 let meta_path = entry_path.join("meta.json");
1768 if !meta_path.exists() {
1769 continue; }
1771 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1774 crate::slog_warn!("failed to create default session dir: {}", e);
1775 return;
1776 }
1777 let leaf = match entry_path.file_name() {
1778 Some(n) => n,
1779 None => continue,
1780 };
1781 let target = default_session_dir.join(leaf);
1782 if target.exists() {
1783 continue;
1786 }
1787 match std::fs::rename(&entry_path, &target) {
1788 Ok(()) => {
1789 Self::upgrade_meta_file(
1791 &target.join("meta.json"),
1792 crate::protocol::DEFAULT_SESSION_ID,
1793 );
1794 migrated += 1;
1795 }
1796 Err(e) => {
1797 crate::slog_warn!(
1798 "failed to migrate legacy backup {}: {}",
1799 entry_path.display(),
1800 e
1801 );
1802 }
1803 }
1804 }
1805 if migrated > 0 {
1806 crate::slog_info!(
1807 "migrated {} legacy backup entries into default session namespace",
1808 migrated
1809 );
1810 let marker = default_session_dir.join("session.json");
1812 let json = serde_json::json!({
1813 "schema_version": SCHEMA_VERSION,
1814 "session_id": crate::protocol::DEFAULT_SESSION_ID,
1815 "last_accessed": current_timestamp(),
1816 });
1817 if let Ok(s) = serde_json::to_string_pretty(&json) {
1818 let _ = std::fs::write(&marker, s);
1819 }
1820 }
1821 }
1822
1823 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1824 let content = match std::fs::read_to_string(meta_path) {
1825 Ok(c) => c,
1826 Err(_) => return,
1827 };
1828 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1829 Ok(v) => v,
1830 Err(_) => return,
1831 };
1832 if let Some(obj) = parsed.as_object_mut() {
1833 let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1834 obj.insert(
1835 "schema_version".to_string(),
1836 serde_json::json!(SCHEMA_VERSION),
1837 );
1838 obj.insert("session_id".to_string(), serde_json::json!(session_id));
1839 obj.entry("entries").or_insert_with(|| {
1840 serde_json::Value::Array(
1841 (0..count)
1842 .map(|i| {
1843 serde_json::json!({
1844 "backup_id": format!("disk-{}", i),
1845 "timestamp": 0,
1846 "description": "restored from disk",
1847 "op_id": null,
1848 })
1849 })
1850 .collect(),
1851 )
1852 });
1853 }
1854 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1855 let tmp = meta_path.with_extension("json.tmp");
1856 if std::fs::write(&tmp, &s).is_ok() {
1857 let _ = std::fs::rename(&tmp, meta_path);
1858 }
1859 }
1860 }
1861
1862 fn load_disk_index(&mut self) {
1863 let backups_dir = match self.backups_dir() {
1864 Some(d) if d.exists() => d,
1865 _ => return,
1866 };
1867 let session_dirs = match std::fs::read_dir(&backups_dir) {
1868 Ok(e) => e,
1869 Err(_) => return,
1870 };
1871 let mut total_entries = 0usize;
1872 let mut skipped_legacy = 0usize;
1873 for session_entry in session_dirs.flatten() {
1874 let session_dir = session_entry.path();
1875 if !session_dir.is_dir() {
1876 continue;
1877 }
1878 let session_id = match Self::read_session_marker(&session_dir) {
1881 Some(session_id) => session_id,
1882 None => {
1883 crate::slog_warn!(
1884 "skipping backup session dir without readable session marker: {}",
1885 session_dir.display()
1886 );
1887 continue;
1888 }
1889 };
1890
1891 let path_dirs = match std::fs::read_dir(&session_dir) {
1892 Ok(e) => e,
1893 Err(_) => continue,
1894 };
1895 let per_session = self.disk_index.entry(session_id.clone()).or_default();
1896 for path_entry in path_dirs.flatten() {
1897 let path_dir = path_entry.path();
1898 if !path_dir.is_dir() {
1899 continue;
1900 }
1901 let meta_path = path_dir.join("meta.json");
1902 if let Ok(content) = std::fs::read_to_string(&meta_path) {
1903 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1904 if let (Some(path_str), Some(count)) = (
1905 meta.get("path").and_then(|v| v.as_str()),
1906 meta_entry_count(&meta).map(|count| count as u64),
1907 ) {
1908 let key = PathBuf::from(path_str);
1909 if !is_loadable_backup_path(&key, &path_dir) {
1910 skipped_legacy += 1;
1916 crate::slog_debug!(
1917 "skipping backup entry with invalid path metadata: {}",
1918 meta_path.display()
1919 );
1920 continue;
1921 }
1922 per_session.insert(
1923 key,
1924 DiskMeta {
1925 dir: path_dir.clone(),
1926 count: count as usize,
1927 },
1928 );
1929 total_entries += 1;
1930 }
1931 }
1932 }
1933 }
1934 if per_session.is_empty() {
1935 self.disk_index.remove(&session_id);
1936 }
1937 }
1938 if skipped_legacy > 0 {
1939 crate::slog_debug!(
1940 "skipped {} legacy backup entries with mismatched path-hash directories",
1941 skipped_legacy
1942 );
1943 }
1944 if total_entries > 0 {
1945 crate::slog_info!(
1946 "loaded {} backup entries across {} session(s) from disk",
1947 total_entries,
1948 self.disk_index.len()
1949 );
1950 }
1951 }
1952
1953 fn read_session_marker(session_dir: &Path) -> Option<String> {
1954 let marker = session_dir.join("session.json");
1955 let content = std::fs::read_to_string(&marker).ok()?;
1956 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1957 parsed
1958 .get("session_id")
1959 .and_then(|v| v.as_str())
1960 .map(|s| s.to_string())
1961 }
1962
1963 fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1964 let marker = session_dir.join("session.json");
1965 let content = std::fs::read_to_string(&marker).ok()?;
1966 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1967 parsed.get("last_accessed").and_then(|v| v.as_u64())
1968 }
1969
1970 fn should_snapshot_path(&self, path: &Path) -> Result<bool, AftError> {
1971 if !self.policy.enabled {
1972 return Ok(false);
1973 }
1974 let Some(max_file_size) = self.policy.max_file_size else {
1975 return Ok(true);
1976 };
1977 match std::fs::symlink_metadata(path) {
1978 Ok(metadata) if metadata.is_file() && metadata.len() > max_file_size => Ok(false),
1979 Ok(_) => Ok(true),
1980 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1981 Err(AftError::FileNotFound {
1982 path: path.display().to_string(),
1983 })
1984 }
1985 Err(error) => Err(AftError::IoError {
1986 path: path.display().to_string(),
1987 message: error.to_string(),
1988 }),
1989 }
1990 }
1991
1992 fn ensure_session_marker(&self, session_dir: &Path, session: &str) -> Result<(), AftError> {
1993 let marker = session_dir.join("session.json");
1994 if marker.exists() {
1995 return Ok(());
1996 }
1997 let json = serde_json::json!({
1998 "schema_version": SCHEMA_VERSION,
1999 "session_id": session,
2000 "last_accessed": current_timestamp(),
2001 });
2002 let content = serde_json::to_string_pretty(&json).map_err(|error| AftError::IoError {
2003 path: marker.display().to_string(),
2004 message: error.to_string(),
2005 })?;
2006 write_temp_fsync_rename(session_dir, "session.json", content.as_bytes()).map_err(
2007 |error| AftError::IoError {
2008 path: marker.display().to_string(),
2009 message: error.to_string(),
2010 },
2011 )?;
2012 let _ = fsync_dir(session_dir);
2013 Ok(())
2014 }
2015
2016 fn acquire_stack_disk_lock(
2017 &self,
2018 session: &str,
2019 key: &Path,
2020 ) -> Result<Option<crate::fs_lock::LockGuard>, AftError> {
2021 let Some(session_dir) = self.session_dir(session) else {
2022 return Ok(None);
2023 };
2024 let lock_dir = session_dir.join(".locks");
2025 std::fs::create_dir_all(&lock_dir).map_err(|error| AftError::IoError {
2026 path: lock_dir.display().to_string(),
2027 message: error.to_string(),
2028 })?;
2029 let lock_path = lock_dir.join(format!("{}.lock", Self::path_hash(key)));
2030 crate::fs_lock::acquire(&lock_path)
2031 .map(Some)
2032 .map_err(|error| AftError::IoError {
2033 path: lock_path.display().to_string(),
2034 message: error.to_string(),
2035 })
2036 }
2037
2038 fn acquire_stack_disk_locks(
2039 &self,
2040 session: &str,
2041 keys: &[PathBuf],
2042 ) -> Result<Vec<crate::fs_lock::LockGuard>, AftError> {
2043 let mut keys = keys.to_vec();
2044 keys.sort();
2045 keys.dedup();
2046 let mut guards = Vec::with_capacity(keys.len());
2047 for key in keys {
2048 if let Some(guard) = self.acquire_stack_disk_lock(session, &key)? {
2049 guards.push(guard);
2050 }
2051 }
2052 Ok(guards)
2053 }
2054
2055 #[cfg(test)]
2056 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> Result<bool, AftError> {
2057 let _disk_lock = self.acquire_stack_disk_lock(session, key)?;
2058 self.load_from_disk_if_needed_locked(session, key)
2059 }
2060
2061 fn load_from_disk_if_needed_locked(
2062 &mut self,
2063 session: &str,
2064 key: &Path,
2065 ) -> Result<bool, AftError> {
2066 let entries = match self.read_stack_from_disk_unlocked(session, key) {
2067 Ok(Some(entries)) => entries,
2068 Ok(None) => {
2069 if self.session_dir(session).is_some() {
2070 self.restore_in_memory_stack(session, key, None);
2071 }
2072 if let Some(files) = self.disk_index.get_mut(session) {
2073 files.remove(key);
2074 if files.is_empty() {
2075 self.disk_index.remove(session);
2076 }
2077 }
2078 return Ok(false);
2079 }
2080 Err(error) => {
2081 return Err(AftError::IoError {
2082 path: key.display().to_string(),
2083 message: error,
2084 });
2085 }
2086 };
2087
2088 self.update_counter_from_entries(&entries);
2089 if let Ok(Some((disk_meta, _))) = self.read_disk_meta_value(session, key) {
2090 self.disk_index
2091 .entry(session.to_string())
2092 .or_default()
2093 .insert(key.to_path_buf(), disk_meta);
2094 }
2095 self.entries
2096 .entry(session.to_string())
2097 .or_default()
2098 .insert(key.to_path_buf(), entries);
2099 Ok(true)
2100 }
2101
2102 fn ensure_stack_hydrated_locked(&mut self, session: &str, key: &Path) -> Result<(), AftError> {
2109 self.load_from_disk_if_needed_locked(session, key)?;
2110 Ok(())
2111 }
2112
2113 fn refresh_disk_index_for_session(&mut self, session: &str) -> Result<Vec<PathBuf>, AftError> {
2114 let Some(session_dir) = self.session_dir(session) else {
2115 self.disk_index.remove(session);
2116 return Ok(Vec::new());
2117 };
2118 if !session_dir.exists() {
2119 self.disk_index.remove(session);
2120 return Ok(Vec::new());
2121 }
2122
2123 let path_dirs = std::fs::read_dir(&session_dir).map_err(|error| AftError::IoError {
2124 path: session_dir.display().to_string(),
2125 message: error.to_string(),
2126 })?;
2127 let mut per_session = HashMap::new();
2128 for path_entry in path_dirs {
2129 let path_entry = path_entry.map_err(|error| AftError::IoError {
2130 path: session_dir.display().to_string(),
2131 message: error.to_string(),
2132 })?;
2133 let path_dir = path_entry.path();
2134 if !path_dir.is_dir() {
2135 continue;
2136 }
2137 let meta_path = path_dir.join("meta.json");
2138 if !meta_path.exists() {
2139 continue;
2140 }
2141 let content =
2142 std::fs::read_to_string(&meta_path).map_err(|error| AftError::IoError {
2143 path: meta_path.display().to_string(),
2144 message: error.to_string(),
2145 })?;
2146 let meta = serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
2147 AftError::IoError {
2148 path: meta_path.display().to_string(),
2149 message: error.to_string(),
2150 }
2151 })?;
2152 let path_str = meta
2153 .get("path")
2154 .and_then(|value| value.as_str())
2155 .ok_or_else(|| AftError::IoError {
2156 path: meta_path.display().to_string(),
2157 message: "backup meta missing path".to_string(),
2158 })?;
2159 let key = PathBuf::from(path_str);
2160 if !is_loadable_backup_path(&key, &path_dir) {
2161 continue;
2162 }
2163 let count = meta_entry_count(&meta).ok_or_else(|| AftError::IoError {
2164 path: meta_path.display().to_string(),
2165 message: "backup meta missing entry count".to_string(),
2166 })?;
2167 if count > 0 {
2168 per_session.insert(
2169 key,
2170 DiskMeta {
2171 dir: path_dir,
2172 count,
2173 },
2174 );
2175 }
2176 }
2177
2178 let keys = per_session.keys().cloned().collect::<Vec<_>>();
2179 if per_session.is_empty() {
2180 self.disk_index.remove(session);
2181 } else {
2182 self.disk_index.insert(session.to_string(), per_session);
2183 }
2184 Ok(keys)
2185 }
2186
2187 fn restore_operation_candidate_keys(
2188 &mut self,
2189 session: &str,
2190 ) -> Result<Vec<PathBuf>, AftError> {
2191 let mut keys: HashSet<PathBuf> = self
2192 .refresh_disk_index_for_session(session)?
2193 .into_iter()
2194 .collect();
2195 if let Some(files) = self.entries.get(session) {
2196 keys.extend(files.keys().cloned());
2197 }
2198 let mut keys = keys.into_iter().collect::<Vec<_>>();
2199 keys.sort();
2200 Ok(keys)
2201 }
2202
2203 fn read_stack_heads_from_disk(
2204 &self,
2205 session: &str,
2206 key: &Path,
2207 ) -> Option<Vec<BackupEntryHead>> {
2208 let _disk_lock = match self.acquire_stack_disk_lock(session, key) {
2209 Ok(lock) => lock,
2210 Err(error) => {
2211 crate::slog_warn!(
2212 "backup disk head read lock failed for {}: {}",
2213 key.display(),
2214 error
2215 );
2216 return None;
2217 }
2218 };
2219 match self.read_stack_heads_from_disk_unlocked(session, key) {
2220 Ok(heads) => heads,
2221 Err(error) => {
2222 crate::slog_warn!(
2223 "backup disk head read failed for {}: {}",
2224 key.display(),
2225 error
2226 );
2227 None
2228 }
2229 }
2230 }
2231
2232 fn read_stack_heads_from_disk_unlocked(
2233 &self,
2234 session: &str,
2235 key: &Path,
2236 ) -> Result<Option<Vec<BackupEntryHead>>, String> {
2237 let Some((disk_meta, meta)) = self.read_disk_meta_value(session, key)? else {
2238 return Ok(None);
2239 };
2240 if disk_meta.count == 0 {
2241 return Ok(None);
2242 }
2243
2244 let heads = if is_v2_meta(&meta) {
2245 let entries = meta_entries(&meta)?;
2246 for entry in entries {
2247 self.validate_v2_content_reference(&disk_meta.dir, entry)?;
2248 }
2249 entries
2250 .iter()
2251 .enumerate()
2252 .map(|(i, entry)| backup_head_from_meta(Some(entry), i))
2253 .collect::<Vec<_>>()
2254 } else {
2255 let entries = meta.get("entries").and_then(|value| value.as_array());
2256 (0..disk_meta.count)
2257 .map(|i| backup_head_from_meta(entries.and_then(|entries| entries.get(i)), i))
2258 .collect::<Vec<_>>()
2259 };
2260
2261 Ok((!heads.is_empty()).then_some(heads))
2262 }
2263
2264 fn read_stack_from_disk_unlocked(
2265 &self,
2266 session: &str,
2267 key: &Path,
2268 ) -> Result<Option<Vec<BackupEntry>>, String> {
2269 let Some((disk_meta, meta)) = self.read_disk_meta_value(session, key)? else {
2270 return Ok(None);
2271 };
2272 if disk_meta.count == 0 {
2273 return Ok(None);
2274 }
2275
2276 let entries = if is_v2_meta(&meta) {
2277 meta_entries(&meta)?
2278 .iter()
2279 .enumerate()
2280 .map(|(i, entry_meta)| self.entry_from_v2_meta(&disk_meta.dir, entry_meta, i))
2281 .collect::<Result<Vec<_>, _>>()?
2282 } else {
2283 let entries = meta.get("entries").and_then(|value| value.as_array());
2284 let mut loaded = Vec::new();
2285 for i in 0..disk_meta.count {
2286 let entry_meta = entries.and_then(|entries| entries.get(i));
2287 if let Some(entry) = legacy_entry_from_meta(&disk_meta.dir, entry_meta, i) {
2288 loaded.push(entry);
2289 }
2290 }
2291 loaded
2292 };
2293
2294 Ok((!entries.is_empty()).then_some(entries))
2295 }
2296
2297 fn read_disk_meta_value(
2298 &self,
2299 session: &str,
2300 key: &Path,
2301 ) -> Result<Option<(DiskMeta, serde_json::Value)>, String> {
2302 let Some(session_dir) = self.session_dir(session) else {
2303 return Ok(None);
2304 };
2305 let dir = session_dir.join(Self::path_hash(key));
2306 let meta_path = dir.join("meta.json");
2307 if !meta_path.exists() {
2308 return Ok(None);
2309 }
2310 let content = std::fs::read_to_string(&meta_path)
2311 .map_err(|error| format!("failed to read {}: {}", meta_path.display(), error))?;
2312 let meta = serde_json::from_str::<serde_json::Value>(&content)
2313 .map_err(|error| format!("failed to parse {}: {}", meta_path.display(), error))?;
2314 let path_str = meta
2315 .get("path")
2316 .and_then(|value| value.as_str())
2317 .ok_or_else(|| format!("backup meta {} missing path", meta_path.display()))?;
2318 let stored_key = PathBuf::from(path_str);
2319 if stored_key != key || !is_loadable_backup_path(&stored_key, &dir) {
2320 return Ok(None);
2321 }
2322 let count = meta_entry_count(&meta)
2323 .ok_or_else(|| format!("backup meta {} missing entry count", meta_path.display()))?;
2324 Ok(Some((DiskMeta { dir, count }, meta)))
2325 }
2326
2327 fn validate_v2_content_reference(
2328 &self,
2329 dir: &Path,
2330 entry_meta: &serde_json::Value,
2331 ) -> Result<(), String> {
2332 let kind = entry_kind_from_meta(Some(entry_meta));
2333 if matches!(kind, BackupEntryKind::Tombstone) {
2334 return Ok(());
2335 }
2336 let content_path = content_path_from_meta(entry_meta)?;
2337 let path = dir.join(content_path);
2338 if !path.is_file() {
2339 return Err(format!(
2340 "v2 backup meta references missing content file {}",
2341 path.display()
2342 ));
2343 }
2344 Ok(())
2345 }
2346
2347 fn entry_from_v2_meta(
2348 &self,
2349 dir: &Path,
2350 entry_meta: &serde_json::Value,
2351 index: usize,
2352 ) -> Result<BackupEntry, String> {
2353 let kind = entry_kind_from_meta(Some(entry_meta));
2354 let content_bytes = match kind {
2355 BackupEntryKind::Content | BackupEntryKind::Symlink => {
2356 let content_path = content_path_from_meta(entry_meta)?;
2357 let path = dir.join(content_path);
2358 std::fs::read(&path).map_err(|error| {
2359 format!(
2360 "failed to read v2 backup content {}: {}",
2361 path.display(),
2362 error
2363 )
2364 })?
2365 }
2366 BackupEntryKind::Tombstone => Vec::new(),
2367 };
2368 Ok(entry_from_meta(
2369 Some(entry_meta),
2370 index,
2371 kind,
2372 content_bytes,
2373 ))
2374 }
2375
2376 fn write_snapshot_to_disk(
2377 &mut self,
2378 session: &str,
2379 key: &Path,
2380 stack: &[BackupEntry],
2381 ) -> Result<(), AftError> {
2382 let _disk_lock = self.acquire_stack_disk_lock(session, key)?;
2383 self.write_snapshot_to_disk_locked(session, key, stack)
2384 }
2385
2386 fn write_snapshot_to_disk_locked(
2387 &mut self,
2388 session: &str,
2389 key: &Path,
2390 stack: &[BackupEntry],
2391 ) -> Result<(), AftError> {
2392 #[cfg(test)]
2393 if self.fail_next_disk_write {
2394 self.fail_next_disk_write = false;
2395 return Err(AftError::IoError {
2396 path: key.display().to_string(),
2397 message: "injected backup disk write failure".to_string(),
2398 });
2399 }
2400
2401 let Some(session_dir) = self.session_dir(session) else {
2402 return Ok(());
2403 };
2404
2405 std::fs::create_dir_all(&session_dir).map_err(|error| AftError::IoError {
2406 path: session_dir.display().to_string(),
2407 message: error.to_string(),
2408 })?;
2409 self.ensure_session_marker(&session_dir, session)?;
2410
2411 let hash = Self::path_hash(key);
2412 let dir = session_dir.join(&hash);
2413 std::fs::create_dir_all(&dir).map_err(|error| AftError::IoError {
2414 path: dir.display().to_string(),
2415 message: error.to_string(),
2416 })?;
2417
2418 let max_depth = self.policy.max_depth;
2419 let retained_start = stack.len().saturating_sub(max_depth);
2420 let retained = &stack[retained_start..];
2421 let mut referenced_content = HashSet::new();
2422 let mut wrote_content = false;
2423
2424 for entry in retained {
2425 if let Some(content_path) = content_filename_for_entry(entry) {
2426 referenced_content.insert(content_path.clone());
2427 let final_path = dir.join(&content_path);
2428 if final_path.exists() {
2429 continue;
2430 }
2431 let bytes = content_bytes_for_disk(entry);
2432 write_temp_fsync_rename(&dir, &content_path, &bytes).map_err(|error| {
2433 AftError::IoError {
2434 path: final_path.display().to_string(),
2435 message: error.to_string(),
2436 }
2437 })?;
2438 wrote_content = true;
2439 }
2440 }
2441 if wrote_content {
2442 fsync_dir(&dir).map_err(|error| AftError::IoError {
2443 path: dir.display().to_string(),
2444 message: error.to_string(),
2445 })?;
2446 }
2447
2448 let entries: Vec<serde_json::Value> = retained.iter().map(entry_meta_json).collect();
2449 let meta = serde_json::json!({
2450 "schema_version": SCHEMA_VERSION,
2451 "format_version": V2_FORMAT_VERSION,
2452 "session_id": session,
2453 "path": key.display().to_string(),
2454 "count": retained.len(),
2455 "entries": entries,
2456 });
2457 let meta_content =
2458 serde_json::to_string_pretty(&meta).map_err(|error| AftError::IoError {
2459 path: dir.join("meta.json").display().to_string(),
2460 message: error.to_string(),
2461 })?;
2462 write_temp_fsync_rename(&dir, "meta.json", meta_content.as_bytes()).map_err(|error| {
2463 AftError::IoError {
2464 path: dir.join("meta.json").display().to_string(),
2465 message: error.to_string(),
2466 }
2467 })?;
2468 fsync_dir(&dir).map_err(|error| AftError::IoError {
2469 path: dir.display().to_string(),
2470 message: error.to_string(),
2471 })?;
2472
2473 prune_unreferenced_backup_files(&dir, &referenced_content).map_err(|error| {
2474 AftError::IoError {
2475 path: dir.display().to_string(),
2476 message: error.to_string(),
2477 }
2478 })?;
2479 let _ = fsync_dir(&dir);
2480
2481 self.disk_index
2484 .entry(session.to_string())
2485 .or_default()
2486 .insert(
2487 key.to_path_buf(),
2488 DiskMeta {
2489 dir: dir.clone(),
2490 count: retained.len(),
2491 },
2492 );
2493 self.dual_write_stack_to_db(session, key, retained);
2494 Ok(())
2495 }
2496
2497 fn dual_write_stack_to_db(&self, session: &str, key: &Path, stack: &[BackupEntry]) {
2498 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
2499 let Some(pool) = pool else {
2500 return;
2501 };
2502 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
2503 let Some(harness) = harness else {
2504 crate::slog_warn!(
2505 "dual-write backup to DB skipped for {}: harness not configured",
2506 key.display()
2507 );
2508 return;
2509 };
2510 let project_key = self
2511 .db_project_key
2512 .read()
2513 .ok()
2514 .and_then(|slot| slot.clone());
2515 let Some(project_key) = project_key else {
2516 crate::slog_warn!(
2517 "dual-write backup to DB skipped for {}: project key not configured",
2518 key.display()
2519 );
2520 return;
2521 };
2522
2523 let conn = match pool.lock() {
2524 Ok(conn) => conn,
2525 Err(_) => {
2526 crate::slog_warn!(
2527 "dual-write backup to DB failed for {}: db mutex poisoned",
2528 key.display()
2529 );
2530 return;
2531 }
2532 };
2533 let path_hash = Self::path_hash(key);
2534 let file_path = key.display().to_string();
2535
2536 let write_result = (|| -> rusqlite::Result<()> {
2544 let tx = conn.unchecked_transaction()?;
2545 crate::db::backups::delete_backups_for_path(&tx, &harness, session, &path_hash)?;
2546 for entry in stack {
2547 let backup_path = content_filename_for_entry(entry);
2548 let row = entry.to_backup_row(
2549 &harness,
2550 session,
2551 &project_key,
2552 &file_path,
2553 &path_hash,
2554 backup_path.as_deref(),
2555 );
2556 crate::db::backups::upsert_backup(&tx, &row)?;
2557 }
2558 tx.commit()
2559 })();
2560 if let Err(error) = write_result {
2561 crate::slog_warn!(
2562 "dual-write backup stack to DB failed for {} (rolled back, prior stack kept): {}",
2563 key.display(),
2564 error
2565 );
2566 }
2567 }
2568
2569 fn prune_disk_stacks_to_depth(&mut self, max_depth: usize) -> HashSet<(String, PathBuf)> {
2570 self.disk_index.clear();
2571 self.load_disk_index();
2572 let disk_keys = self
2573 .disk_index
2574 .iter()
2575 .flat_map(|(session, files)| {
2576 files
2577 .keys()
2578 .cloned()
2579 .map(|key| (session.clone(), key))
2580 .collect::<Vec<_>>()
2581 })
2582 .collect::<Vec<_>>();
2583 let mut failed = HashSet::new();
2584
2585 for (session, key) in disk_keys {
2586 let disk_lock = match self.acquire_stack_disk_lock(&session, &key) {
2587 Ok(lock) => lock,
2588 Err(error) => {
2589 crate::slog_warn!(
2590 "failed to lock backup stack for {} while applying max_depth: {}",
2591 key.display(),
2592 error
2593 );
2594 failed.insert((session, key));
2595 continue;
2596 }
2597 };
2598
2599 let mut stack = match self.read_stack_from_disk_unlocked(&session, &key) {
2600 Ok(Some(stack)) => stack,
2601 Ok(None) => Vec::new(),
2602 Err(error) => {
2603 crate::slog_warn!(
2604 "failed to read backup stack for {} while applying max_depth: {}",
2605 key.display(),
2606 error
2607 );
2608 failed.insert((session, key));
2609 drop(disk_lock);
2610 continue;
2611 }
2612 };
2613 trim_stack_to_depth(&mut stack, max_depth);
2614 if let Err(error) = self.write_snapshot_to_disk_locked(&session, &key, &stack) {
2615 crate::slog_warn!(
2616 "failed to prune backup stack for {} while applying max_depth: {}",
2617 key.display(),
2618 error
2619 );
2620 failed.insert((session, key));
2621 drop(disk_lock);
2622 continue;
2623 }
2624 if stack.is_empty() {
2625 if let Some(files) = self.entries.get_mut(&session) {
2626 files.remove(&key);
2627 if files.is_empty() {
2628 self.entries.remove(&session);
2629 }
2630 }
2631 } else {
2632 self.entries
2633 .entry(session.clone())
2634 .or_default()
2635 .insert(key.clone(), stack);
2636 }
2637 drop(disk_lock);
2638 }
2639
2640 failed
2641 }
2642
2643 fn remove_disk_backups(&mut self, session: &str, key: &Path) -> Result<(), AftError> {
2644 let _disk_lock = self.acquire_stack_disk_lock(session, key)?;
2645 self.remove_disk_backups_locked(session, key)
2646 }
2647
2648 fn remove_disk_backups_locked(&mut self, session: &str, key: &Path) -> Result<(), AftError> {
2649 self.remove_db_backups(session, key);
2650 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
2651 if let Some(meta) = removed {
2652 if let Err(error) = std::fs::remove_dir_all(&meta.dir) {
2653 return Err(AftError::IoError {
2654 path: meta.dir.display().to_string(),
2655 message: error.to_string(),
2656 });
2657 }
2658 } else if let Some(session_dir) = self.session_dir(session) {
2659 let hash = Self::path_hash(key);
2660 let dir = session_dir.join(&hash);
2661 if dir.exists() {
2662 if let Err(error) = std::fs::remove_dir_all(&dir) {
2663 return Err(AftError::IoError {
2664 path: dir.display().to_string(),
2665 message: error.to_string(),
2666 });
2667 }
2668 }
2669 }
2670
2671 let empty = self
2674 .disk_index
2675 .get(session)
2676 .map(|s| s.is_empty())
2677 .unwrap_or(false);
2678 if empty {
2679 self.disk_index.remove(session);
2680 }
2681 Ok(())
2682 }
2683
2684 fn remove_db_backups(&self, session: &str, key: &Path) {
2685 let Some((pool, harness)) = self.db_pool_and_harness() else {
2686 return;
2687 };
2688 let conn = match pool.lock() {
2689 Ok(conn) => conn,
2690 Err(_) => {
2691 crate::slog_warn!(
2692 "delete backup DB rows failed for {}: db mutex poisoned",
2693 key.display()
2694 );
2695 return;
2696 }
2697 };
2698 let path_hash = Self::path_hash(key);
2699 if let Err(error) =
2700 crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
2701 {
2702 crate::slog_warn!(
2703 "delete backup DB rows failed for {}: {}",
2704 key.display(),
2705 error
2706 );
2707 }
2708 }
2709}
2710
2711pub fn hash_session(session: &str) -> String {
2712 stable_hash_16(session.as_bytes())
2713}
2714
2715pub fn new_op_id() -> String {
2716 let mut bytes = [0u8; 4];
2717 if getrandom::fill(&mut bytes).is_err() {
2718 bytes = current_timestamp().to_le_bytes()[..4]
2719 .try_into()
2720 .unwrap_or([0; 4]);
2721 }
2722 let rand = u32::from_le_bytes(bytes);
2723 format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
2724}
2725
2726#[derive(Debug, Clone)]
2727struct BackupEntryDiskMetadata {
2728 mode: Option<u32>,
2729 link_target: Option<PathBuf>,
2730 created_dirs: Vec<PathBuf>,
2731}
2732
2733#[derive(Debug, Clone)]
2734enum RestorePathState {
2735 Missing,
2736 Regular {
2737 content_bytes: Vec<u8>,
2738 mode: Option<u32>,
2739 },
2740 Symlink {
2741 target: PathBuf,
2742 },
2743 Directory,
2744}
2745
2746fn backup_entry_from_path(
2747 path: &Path,
2748 backup_id: String,
2749 order: u128,
2750 description: &str,
2751 op_id: Option<&str>,
2752) -> Result<BackupEntry, AftError> {
2753 let metadata = std::fs::symlink_metadata(path).map_err(|error| match error.kind() {
2754 std::io::ErrorKind::NotFound => AftError::FileNotFound {
2755 path: path.display().to_string(),
2756 },
2757 _ => AftError::IoError {
2758 path: path.display().to_string(),
2759 message: error.to_string(),
2760 },
2761 })?;
2762 let mode = file_mode(&metadata);
2763
2764 let (kind, content, content_bytes, link_target) = if metadata.file_type().is_symlink() {
2765 let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2766 path: path.display().to_string(),
2767 message: error.to_string(),
2768 })?;
2769 (
2770 BackupEntryKind::Symlink,
2771 target.display().to_string(),
2772 Vec::new(),
2773 Some(target),
2774 )
2775 } else if metadata.is_file() {
2776 let bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2777 path: path.display().to_string(),
2778 message: error.to_string(),
2779 })?;
2780 (
2781 BackupEntryKind::Content,
2782 String::from_utf8_lossy(&bytes).into_owned(),
2783 bytes,
2784 None,
2785 )
2786 } else {
2787 return Err(AftError::InvalidRequest {
2788 message: format!(
2789 "backup: '{}' is not a regular file or symlink",
2790 path.display()
2791 ),
2792 });
2793 };
2794
2795 Ok(BackupEntry {
2796 backup_id,
2797 content,
2798 content_bytes,
2799 timestamp: current_timestamp(),
2800 order,
2801 description: description.to_string(),
2802 op_id: op_id.map(str::to_string),
2803 kind,
2804 mode,
2805 link_target,
2806 created_dirs: Vec::new(),
2807 })
2808}
2809
2810fn canonicalize_key(path: &Path) -> PathBuf {
2811 let absolute = if path.is_absolute() {
2812 path.to_path_buf()
2813 } else {
2814 std::env::current_dir()
2815 .unwrap_or_else(|_| PathBuf::from("."))
2816 .join(path)
2817 };
2818
2819 match std::fs::symlink_metadata(&absolute) {
2820 Ok(metadata) if metadata.file_type().is_symlink() => {
2821 canonicalize_parent_join_leaf(&absolute)
2822 }
2823 Ok(_) => std::fs::canonicalize(&absolute)
2824 .map(|path| normalize_absolute_key(&path))
2825 .unwrap_or_else(|_| canonicalize_existing_ancestor(&absolute)),
2826 Err(_) => canonicalize_existing_ancestor(&absolute),
2827 }
2828}
2829
2830fn canonicalize_parent_join_leaf(path: &Path) -> PathBuf {
2831 let Some(parent) = path.parent() else {
2832 return normalize_absolute_key(path);
2833 };
2834 let mut key = canonicalize_existing_ancestor(parent);
2835 if let Some(file_name) = path.file_name() {
2836 key.push(file_name);
2837 }
2838 key
2839}
2840
2841fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
2842 let mut suffix = Vec::new();
2843 let mut current = path;
2844
2845 loop {
2846 if let Ok(mut base) = std::fs::canonicalize(current) {
2847 for component in suffix.iter().rev() {
2848 base.push(Path::new(component));
2849 }
2850 return normalize_absolute_key(&base);
2851 }
2852 let Some(parent) = current.parent() else {
2853 return normalize_absolute_key(path);
2854 };
2855 if let Some(file_name) = current.file_name() {
2856 suffix.push(file_name.to_os_string());
2857 }
2858 current = parent;
2859 }
2860}
2861
2862fn normalize_absolute_key(path: &Path) -> PathBuf {
2863 let mut normalized = PathBuf::new();
2864
2865 for component in path.components() {
2866 match component {
2867 std::path::Component::CurDir => {}
2868 std::path::Component::ParentDir => {
2869 if !normalized.pop() {
2870 normalized.push(component.as_os_str());
2871 }
2872 }
2873 other => normalized.push(other.as_os_str()),
2874 }
2875 }
2876
2877 normalized
2878}
2879
2880fn file_mode(metadata: &std::fs::Metadata) -> Option<u32> {
2881 #[cfg(unix)]
2882 {
2883 use std::os::unix::fs::PermissionsExt;
2884 Some(metadata.permissions().mode())
2885 }
2886 #[cfg(not(unix))]
2887 {
2888 let _ = metadata;
2889 None
2890 }
2891}
2892
2893fn set_file_mode(path: &Path, mode: Option<u32>) -> std::io::Result<()> {
2894 #[cfg(unix)]
2895 {
2896 use std::os::unix::fs::PermissionsExt;
2897 if let Some(mode) = mode {
2898 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
2899 }
2900 }
2901 #[cfg(not(unix))]
2902 {
2903 let _ = (path, mode);
2904 }
2905 Ok(())
2906}
2907
2908fn capture_path_state(path: &Path) -> Result<RestorePathState, AftError> {
2909 let metadata = match std::fs::symlink_metadata(path) {
2910 Ok(metadata) => metadata,
2911 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2912 return Ok(RestorePathState::Missing);
2913 }
2914 Err(error) => {
2915 return Err(AftError::IoError {
2916 path: path.display().to_string(),
2917 message: error.to_string(),
2918 });
2919 }
2920 };
2921
2922 if metadata.file_type().is_symlink() {
2923 let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2924 path: path.display().to_string(),
2925 message: error.to_string(),
2926 })?;
2927 Ok(RestorePathState::Symlink { target })
2928 } else if metadata.is_file() {
2929 let content_bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2930 path: path.display().to_string(),
2931 message: error.to_string(),
2932 })?;
2933 Ok(RestorePathState::Regular {
2934 content_bytes,
2935 mode: file_mode(&metadata),
2936 })
2937 } else {
2938 Ok(RestorePathState::Directory)
2939 }
2940}
2941
2942fn restore_entry_to_path(path: &Path, entry: &BackupEntry) -> std::io::Result<()> {
2943 match entry.kind {
2944 BackupEntryKind::Content => restore_regular_file(path, &entry.content_bytes, entry.mode),
2945 BackupEntryKind::Symlink => {
2946 let target = entry.link_target.as_ref().ok_or_else(|| {
2947 std::io::Error::new(
2948 std::io::ErrorKind::InvalidData,
2949 "symlink backup entry missing target",
2950 )
2951 })?;
2952 restore_symlink(path, target)
2953 }
2954 BackupEntryKind::Tombstone => remove_tombstone_path(path),
2955 }
2956}
2957
2958fn restore_path_state(path: &Path, state: &RestorePathState) -> bool {
2959 match state {
2960 RestorePathState::Missing => remove_file_or_symlink_if_present(path).is_ok(),
2961 RestorePathState::Regular {
2962 content_bytes,
2963 mode,
2964 } => restore_regular_file(path, content_bytes, *mode).is_ok(),
2965 RestorePathState::Symlink { target } => restore_symlink(path, target).is_ok(),
2966 RestorePathState::Directory => true,
2967 }
2968}
2969
2970fn restore_regular_file(
2971 path: &Path,
2972 content_bytes: &[u8],
2973 mode: Option<u32>,
2974) -> std::io::Result<()> {
2975 if let Some(parent) = path.parent() {
2976 if !parent.as_os_str().is_empty() {
2977 std::fs::create_dir_all(parent)?;
2978 }
2979 }
2980 if std::fs::symlink_metadata(path)
2981 .map(|metadata| metadata.file_type().is_symlink())
2982 .unwrap_or(false)
2983 {
2984 std::fs::remove_file(path)?;
2985 }
2986 std::fs::write(path, content_bytes)?;
2987 set_file_mode(path, mode)
2988}
2989
2990fn restore_symlink(path: &Path, target: &Path) -> std::io::Result<()> {
2991 if let Some(parent) = path.parent() {
2992 if !parent.as_os_str().is_empty() {
2993 std::fs::create_dir_all(parent)?;
2994 }
2995 }
2996 remove_file_or_symlink_if_present(path)?;
2997 create_symlink(target, path)
2998}
2999
3000#[cfg(unix)]
3001fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
3002 std::os::unix::fs::symlink(target, link)
3003}
3004
3005#[cfg(windows)]
3006fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
3007 if target.is_dir() {
3008 std::os::windows::fs::symlink_dir(target, link)
3009 } else {
3010 std::os::windows::fs::symlink_file(target, link)
3011 }
3012}
3013
3014fn remove_tombstone_path(path: &Path) -> std::io::Result<()> {
3015 match std::fs::symlink_metadata(path) {
3016 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
3017 std::fs::remove_file(path)
3018 }
3019 Ok(_) => Err(std::io::Error::new(
3020 std::io::ErrorKind::IsADirectory,
3021 "tombstone target is a directory",
3022 )),
3023 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3024 Err(error) => Err(error),
3025 }
3026}
3027
3028fn remove_file_or_symlink_if_present(path: &Path) -> std::io::Result<()> {
3029 match std::fs::symlink_metadata(path) {
3030 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
3031 std::fs::remove_file(path)
3032 }
3033 Ok(_) => Err(std::io::Error::new(
3034 std::io::ErrorKind::IsADirectory,
3035 "path is a directory",
3036 )),
3037 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3038 Err(error) => Err(error),
3039 }
3040}
3041
3042fn read_entry_disk_metadata(
3043 backup_path: &Path,
3044 backup_id: &str,
3045) -> Option<BackupEntryDiskMetadata> {
3046 let meta_path = if backup_path.file_name().and_then(|name| name.to_str()) == Some("meta.json") {
3047 backup_path.to_path_buf()
3048 } else {
3049 backup_path.parent()?.join("meta.json")
3050 };
3051 let content = std::fs::read_to_string(meta_path).ok()?;
3052 let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
3053 let entries = meta.get("entries")?.as_array()?;
3054 let entry = entries
3055 .iter()
3056 .find(|entry| entry.get("backup_id").and_then(|value| value.as_str()) == Some(backup_id))?;
3057 Some(BackupEntryDiskMetadata {
3058 mode: entry
3059 .get("mode")
3060 .and_then(|value| value.as_u64())
3061 .and_then(|mode| u32::try_from(mode).ok()),
3062 link_target: entry
3063 .get("link_target")
3064 .and_then(|value| value.as_str())
3065 .map(PathBuf::from),
3066 created_dirs: entry
3067 .get("created_dirs")
3068 .and_then(|value| value.as_array())
3069 .map(|dirs| {
3070 dirs.iter()
3071 .filter_map(|dir| dir.as_str())
3072 .map(PathBuf::from)
3073 .collect()
3074 })
3075 .unwrap_or_default(),
3076 })
3077}
3078
3079fn rollback_transactional_restore(
3080 written: &[(PathBuf, RestorePathState)],
3081 attempted: Option<(&PathBuf, &RestorePathState)>,
3082) -> bool {
3083 let mut ok = true;
3084
3085 if let Some((path, state)) = attempted {
3086 ok &= restore_path_state(path, state);
3087 }
3088
3089 for (path, state) in written.iter().rev() {
3090 ok &= restore_path_state(path, state);
3091 }
3092
3093 ok
3094}
3095
3096fn rollback_deleted_tombstones(deleted: &[(PathBuf, RestorePathState)]) -> bool {
3097 let mut ok = true;
3098 for (path, state) in deleted.iter().rev() {
3099 ok &= restore_path_state(path, state);
3100 }
3101 ok
3102}
3103
3104fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
3105 let mut dirs = Vec::new();
3106 let mut current = Some(parent);
3107
3108 while let Some(dir) = current {
3109 if dir.as_os_str().is_empty() || dir.exists() {
3110 break;
3111 }
3112 dirs.push(dir.to_path_buf());
3113 current = dir.parent();
3114 }
3115
3116 dirs
3117}
3118
3119fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
3120 let mut dirs = dirs.to_vec();
3121 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
3122 dirs.dedup();
3123
3124 let mut ok = true;
3125 for dir in dirs {
3126 match std::fs::remove_dir(&dir) {
3127 Ok(()) => {}
3128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
3129 Err(_) => ok = false,
3130 }
3131 }
3132
3133 ok
3134}
3135
3136fn remove_created_dirs_best_effort(dirs: &[PathBuf]) {
3137 let mut dirs = dirs.to_vec();
3138 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
3139 dirs.dedup();
3140
3141 for dir in dirs {
3142 match std::fs::remove_dir(&dir) {
3143 Ok(()) => {}
3144 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3145 Err(_) => {}
3146 }
3147 }
3148}
3149
3150fn dir_has_entries(path: &Path) -> bool {
3151 std::fs::read_dir(path)
3152 .map(|mut entries| entries.next().is_some())
3153 .unwrap_or(false)
3154}
3155
3156fn current_timestamp() -> u64 {
3157 std::time::SystemTime::now()
3158 .duration_since(std::time::UNIX_EPOCH)
3159 .unwrap_or_default()
3160 .as_secs()
3161}
3162
3163fn current_timestamp_nanos() -> u64 {
3164 let nanos = std::time::SystemTime::now()
3165 .duration_since(std::time::UNIX_EPOCH)
3166 .unwrap_or_default()
3167 .as_nanos();
3168 nanos.min(u128::from(u64::MAX)) as u64
3169}
3170
3171fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
3172 let nanos = timestamp_secs.saturating_mul(1_000_000_000);
3173 ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
3174}
3175
3176fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
3177 value
3178 .as_str()
3179 .and_then(|s| s.parse::<u128>().ok())
3180 .or_else(|| value.as_u64().map(u128::from))
3181}
3182
3183fn is_v2_meta(meta: &serde_json::Value) -> bool {
3184 meta.get("format_version").and_then(|value| value.as_str()) == Some(V2_FORMAT_VERSION)
3185}
3186
3187fn meta_entries(meta: &serde_json::Value) -> Result<&Vec<serde_json::Value>, String> {
3188 meta.get("entries")
3189 .and_then(|value| value.as_array())
3190 .ok_or_else(|| "backup meta missing entries array".to_string())
3191}
3192
3193fn meta_entry_count(meta: &serde_json::Value) -> Option<usize> {
3194 if is_v2_meta(meta) {
3195 return meta
3196 .get("entries")
3197 .and_then(|value| value.as_array())
3198 .map(Vec::len);
3199 }
3200 meta.get("count")
3201 .and_then(|value| value.as_u64())
3202 .and_then(|count| usize::try_from(count).ok())
3203 .or_else(|| {
3204 meta.get("entries")
3205 .and_then(|value| value.as_array())
3206 .map(Vec::len)
3207 })
3208}
3209
3210fn entry_kind_from_meta(entry_meta: Option<&serde_json::Value>) -> BackupEntryKind {
3211 match entry_meta
3212 .and_then(|meta| meta.get("kind"))
3213 .and_then(|value| value.as_str())
3214 {
3215 Some("tombstone") => BackupEntryKind::Tombstone,
3216 Some("symlink") => BackupEntryKind::Symlink,
3217 _ => BackupEntryKind::Content,
3218 }
3219}
3220
3221fn backup_head_from_meta(entry_meta: Option<&serde_json::Value>, index: usize) -> BackupEntryHead {
3222 let backup_id = entry_backup_id(entry_meta, index);
3223 let timestamp = entry_meta
3224 .and_then(|meta| meta.get("timestamp"))
3225 .and_then(|value| value.as_u64())
3226 .unwrap_or(0);
3227 let order = entry_meta
3228 .and_then(|meta| meta.get("order"))
3229 .and_then(parse_order_value)
3230 .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
3231 BackupEntryHead {
3232 order,
3233 op_id: entry_meta
3234 .and_then(|meta| meta.get("op_id"))
3235 .and_then(|value| value.as_str())
3236 .map(str::to_string),
3237 }
3238}
3239
3240fn entry_backup_id(entry_meta: Option<&serde_json::Value>, index: usize) -> String {
3241 entry_meta
3242 .and_then(|meta| meta.get("backup_id"))
3243 .and_then(|value| value.as_str())
3244 .map(str::to_string)
3245 .unwrap_or_else(|| format!("disk-{}", index))
3246}
3247
3248fn entry_from_meta(
3249 entry_meta: Option<&serde_json::Value>,
3250 index: usize,
3251 kind: BackupEntryKind,
3252 content_bytes: Vec<u8>,
3253) -> BackupEntry {
3254 let backup_id = entry_backup_id(entry_meta, index);
3255 let timestamp = entry_meta
3256 .and_then(|meta| meta.get("timestamp"))
3257 .and_then(|value| value.as_u64())
3258 .unwrap_or(0);
3259 let order = entry_meta
3260 .and_then(|meta| meta.get("order"))
3261 .and_then(parse_order_value)
3262 .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
3263 let link_target = if kind == BackupEntryKind::Symlink {
3264 entry_meta
3265 .and_then(|meta| meta.get("link_target"))
3266 .and_then(|value| value.as_str())
3267 .map(PathBuf::from)
3268 .or_else(|| {
3269 Some(PathBuf::from(
3270 String::from_utf8_lossy(&content_bytes).into_owned(),
3271 ))
3272 })
3273 } else {
3274 None
3275 };
3276 let content = match kind {
3277 BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
3278 BackupEntryKind::Symlink => link_target
3279 .as_ref()
3280 .map(|target| target.display().to_string())
3281 .unwrap_or_default(),
3282 BackupEntryKind::Tombstone => String::new(),
3283 };
3284 BackupEntry {
3285 backup_id,
3286 content,
3287 content_bytes,
3288 timestamp,
3289 order,
3290 description: entry_meta
3291 .and_then(|meta| meta.get("description"))
3292 .and_then(|value| value.as_str())
3293 .unwrap_or("restored from disk")
3294 .to_string(),
3295 op_id: entry_meta
3296 .and_then(|meta| meta.get("op_id"))
3297 .and_then(|value| value.as_str())
3298 .map(str::to_string),
3299 kind,
3300 mode: entry_meta
3301 .and_then(|meta| meta.get("mode"))
3302 .and_then(|value| value.as_u64())
3303 .and_then(|mode| u32::try_from(mode).ok()),
3304 link_target,
3305 created_dirs: entry_meta
3306 .and_then(|meta| meta.get("created_dirs"))
3307 .and_then(|value| value.as_array())
3308 .map(|dirs| {
3309 dirs.iter()
3310 .filter_map(|dir| dir.as_str())
3311 .map(PathBuf::from)
3312 .collect()
3313 })
3314 .unwrap_or_default(),
3315 }
3316}
3317
3318fn legacy_entry_from_meta(
3319 dir: &Path,
3320 entry_meta: Option<&serde_json::Value>,
3321 index: usize,
3322) -> Option<BackupEntry> {
3323 let kind = entry_kind_from_meta(entry_meta);
3324 let content_bytes = match kind {
3325 BackupEntryKind::Content | BackupEntryKind::Symlink => {
3326 std::fs::read(dir.join(format!("{}.bak", index))).ok()?
3327 }
3328 BackupEntryKind::Tombstone => Vec::new(),
3329 };
3330 Some(entry_from_meta(entry_meta, index, kind, content_bytes))
3331}
3332
3333fn content_path_from_meta(entry_meta: &serde_json::Value) -> Result<&str, String> {
3334 let value = entry_meta
3335 .get("content_path")
3336 .and_then(|value| value.as_str())
3337 .ok_or_else(|| "v2 backup entry missing content_path".to_string())?;
3338 let path = Path::new(value);
3339 let mut components = path.components();
3340 match (components.next(), components.next()) {
3341 (Some(std::path::Component::Normal(_)), None) => Ok(value),
3342 _ => Err(format!("invalid backup content_path '{value}'")),
3343 }
3344}
3345
3346fn sanitize_backup_id(value: &str) -> String {
3347 value
3348 .chars()
3349 .map(|ch| {
3350 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
3351 ch
3352 } else {
3353 '_'
3354 }
3355 })
3356 .collect()
3357}
3358
3359fn content_filename_for_entry(entry: &BackupEntry) -> Option<String> {
3360 match entry.kind {
3361 BackupEntryKind::Content | BackupEntryKind::Symlink => Some(format!(
3362 "bak_{}_{}.bak",
3363 entry.order,
3364 sanitize_backup_id(&entry.backup_id)
3365 )),
3366 BackupEntryKind::Tombstone => None,
3367 }
3368}
3369
3370fn content_bytes_for_disk(entry: &BackupEntry) -> Vec<u8> {
3371 match entry.kind {
3372 BackupEntryKind::Content => entry.content_bytes.clone(),
3373 BackupEntryKind::Symlink => entry
3374 .link_target
3375 .as_ref()
3376 .map(|target| target.as_os_str().to_string_lossy().as_bytes().to_vec())
3377 .unwrap_or_default(),
3378 BackupEntryKind::Tombstone => Vec::new(),
3379 }
3380}
3381
3382fn entry_meta_json(entry: &BackupEntry) -> serde_json::Value {
3383 serde_json::json!({
3384 "backup_id": entry.backup_id,
3385 "timestamp": entry.timestamp,
3386 "order": entry.order.to_string(),
3387 "description": entry.description,
3388 "op_id": entry.op_id,
3389 "kind": match entry.kind {
3390 BackupEntryKind::Content => "content",
3391 BackupEntryKind::Symlink => "symlink",
3392 BackupEntryKind::Tombstone => "tombstone",
3393 },
3394 "content_path": content_filename_for_entry(entry),
3395 "mode": entry.mode,
3396 "link_target": entry.link_target.as_ref().map(|target| target.display().to_string()),
3397 "created_dirs": entry
3398 .created_dirs
3399 .iter()
3400 .map(|dir| dir.display().to_string())
3401 .collect::<Vec<_>>(),
3402 })
3403}
3404
3405fn trim_stack_to_depth(stack: &mut Vec<BackupEntry>, max_depth: usize) {
3406 if max_depth == 0 {
3407 stack.clear();
3408 return;
3409 }
3410 while stack.len() > max_depth {
3411 stack.remove(0);
3412 }
3413}
3414
3415fn write_temp_fsync_rename(dir: &Path, final_name: &str, content: &[u8]) -> std::io::Result<()> {
3416 let tmp_name = format!(
3417 ".{}.{}.{}.tmp",
3418 final_name,
3419 std::process::id(),
3420 current_timestamp_nanos()
3421 );
3422 let tmp_path = dir.join(tmp_name);
3423 let final_path = dir.join(final_name);
3424 {
3425 let mut file = std::fs::OpenOptions::new()
3426 .write(true)
3427 .create_new(true)
3428 .open(&tmp_path)?;
3429 file.write_all(content)?;
3430 file.sync_all()?;
3431 }
3432 replace_file(&tmp_path, &final_path)
3433}
3434
3435fn replace_file(from: &Path, to: &Path) -> std::io::Result<()> {
3436 std::fs::rename(from, to)
3439}
3440
3441#[cfg(unix)]
3442fn fsync_dir(path: &Path) -> std::io::Result<()> {
3443 std::fs::File::open(path)?.sync_all()
3444}
3445
3446#[cfg(not(unix))]
3447fn fsync_dir(_path: &Path) -> std::io::Result<()> {
3448 Ok(())
3456}
3457
3458fn prune_unreferenced_backup_files(
3459 dir: &Path,
3460 referenced: &HashSet<String>,
3461) -> std::io::Result<()> {
3462 for entry in std::fs::read_dir(dir)? {
3463 let entry = entry?;
3464 let path = entry.path();
3465 if !path.is_file() {
3466 continue;
3467 }
3468 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
3469 continue;
3470 };
3471 let is_backup_content = (name.starts_with("bak_") && name.ends_with(".bak"))
3472 || legacy_numeric_backup_name(name);
3473 let is_temp = name.ends_with(".tmp") || name.contains(".tmp.");
3474 if is_temp || (is_backup_content && !referenced.contains(name)) {
3475 let _ = std::fs::remove_file(path);
3476 }
3477 }
3478 Ok(())
3479}
3480
3481fn legacy_numeric_backup_name(name: &str) -> bool {
3482 name.strip_suffix(".bak")
3483 .is_some_and(|stem| !stem.is_empty() && stem.chars().all(|ch| ch.is_ascii_digit()))
3484}
3485
3486fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
3487 if !key.is_absolute()
3488 || key
3489 .components()
3490 .any(|c| matches!(c, std::path::Component::ParentDir))
3491 {
3492 return false;
3493 }
3494 let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
3495 return false;
3496 };
3497 BackupStore::path_hash(key) == dir_name
3498}
3499
3500fn stable_hash_16(bytes: &[u8]) -> String {
3501 let digest = Sha256::digest(bytes);
3502 digest[..8]
3503 .iter()
3504 .map(|byte| format!("{:02x}", byte))
3505 .collect()
3506}
3507
3508fn backup_sequence(backup_id: &str) -> Option<u64> {
3509 backup_id
3510 .strip_prefix("backup-")
3511 .or_else(|| backup_id.strip_prefix("disk-"))
3512 .and_then(|s| s.parse().ok())
3513}
3514
3515#[cfg(test)]
3516mod tests {
3517 use super::*;
3518 use crate::harness::Harness;
3519 use crate::protocol::DEFAULT_SESSION_ID;
3520 use std::fs;
3521 #[cfg(unix)]
3522 use std::os::unix::fs::PermissionsExt;
3523 use std::sync::{Arc, Mutex};
3524
3525 fn temp_file(name: &str, content: &str) -> PathBuf {
3526 let dir = std::env::temp_dir().join("aft_backup_tests");
3527 fs::create_dir_all(&dir).unwrap();
3528 let path = dir.join(name);
3529 fs::write(&path, content).unwrap();
3530 path
3531 }
3532
3533 #[test]
3534 fn snapshot_and_restore_round_trip() {
3535 let path = temp_file("round_trip.txt", "original");
3536 let mut store = BackupStore::new();
3537
3538 let id = store
3539 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
3540 .unwrap()
3541 .unwrap();
3542 assert!(id.starts_with("backup-"));
3543
3544 fs::write(&path, "modified").unwrap();
3545 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
3546
3547 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3548 assert_eq!(entry.content, "original");
3549 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
3550 }
3551
3552 #[test]
3553 fn multiple_snapshots_preserve_order() {
3554 let path = temp_file("order.txt", "v1");
3555 let mut store = BackupStore::new();
3556
3557 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
3558 fs::write(&path, "v2").unwrap();
3559 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
3560 fs::write(&path, "v3").unwrap();
3561 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
3562
3563 let history = store.history(DEFAULT_SESSION_ID, &path);
3564 assert_eq!(history.len(), 3);
3565 assert_eq!(history[0].content, "v1");
3566 assert_eq!(history[1].content, "v2");
3567 assert_eq!(history[2].content, "v3");
3568 }
3569
3570 #[test]
3571 fn restore_pops_from_stack() {
3572 let path = temp_file("pop.txt", "v1");
3573 let mut store = BackupStore::new();
3574
3575 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
3576 fs::write(&path, "v2").unwrap();
3577 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
3578
3579 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3580 assert_eq!(entry.description, "second");
3581 assert_eq!(entry.content, "v2");
3582
3583 let history = store.history(DEFAULT_SESSION_ID, &path);
3584 assert_eq!(history.len(), 1);
3585 }
3586
3587 #[test]
3588 fn empty_history_returns_empty_vec() {
3589 let store = BackupStore::new();
3590 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
3591 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
3592 }
3593
3594 #[test]
3595 fn snapshot_nonexistent_file_returns_error() {
3596 let mut store = BackupStore::new();
3597 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
3598 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
3599 }
3600
3601 #[test]
3602 fn tracked_files_lists_snapshotted_paths() {
3603 let path1 = temp_file("tracked1.txt", "a");
3604 let path2 = temp_file("tracked2.txt", "b");
3605 let mut store = BackupStore::new();
3606
3607 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
3608 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
3609 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
3610 }
3611
3612 #[test]
3613 fn sessions_are_isolated() {
3614 let path = temp_file("isolated.txt", "original");
3615 let mut store = BackupStore::new();
3616
3617 store.snapshot("session_a", &path, "a's snapshot").unwrap();
3618
3619 assert!(store.history("session_b", &path).is_empty());
3621 assert_eq!(store.tracked_files("session_b").len(), 0);
3622
3623 let err = store.restore_latest("session_b", &path);
3625 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
3626
3627 assert_eq!(store.history("session_a", &path).len(), 1);
3629 assert_eq!(store.tracked_files("session_a").len(), 1);
3630 }
3631
3632 #[test]
3633 fn per_session_per_file_cap_is_independent() {
3634 let path = temp_file("cap_indep.txt", "v0");
3637 let mut store = BackupStore::new();
3638
3639 for i in 0..(MAX_UNDO_DEPTH + 5) {
3640 fs::write(&path, format!("a{}", i)).unwrap();
3641 store.snapshot("session_a", &path, "a").unwrap();
3642 }
3643 fs::write(&path, "b_initial").unwrap();
3644 store.snapshot("session_b", &path, "b").unwrap();
3645
3646 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
3648 assert_eq!(store.history("session_b", &path).len(), 1);
3650 }
3651
3652 #[test]
3653 fn sessions_with_backups_lists_all_namespaces() {
3654 let path_a = temp_file("sessions_list_a.txt", "a");
3655 let path_b = temp_file("sessions_list_b.txt", "b");
3656 let mut store = BackupStore::new();
3657
3658 store.snapshot("alice", &path_a, "from alice").unwrap();
3659 store.snapshot("bob", &path_b, "from bob").unwrap();
3660
3661 let sessions = store.sessions_with_backups();
3662 assert_eq!(sessions.len(), 2);
3663 assert!(sessions.iter().any(|s| s == "alice"));
3664 assert!(sessions.iter().any(|s| s == "bob"));
3665 }
3666
3667 #[test]
3668 fn disk_persistence_survives_reload() {
3669 let dir = std::env::temp_dir().join("aft_backup_disk_test");
3670 let _ = fs::remove_dir_all(&dir);
3671 fs::create_dir_all(&dir).unwrap();
3672
3673 let file_path = temp_file("disk_persist.txt", "original");
3674
3675 {
3677 let mut store = BackupStore::new();
3678 store.set_storage_dir(dir.clone(), 72);
3679 store
3680 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
3681 .unwrap();
3682 }
3683
3684 fs::write(&file_path, "externally modified").unwrap();
3686
3687 let mut store2 = BackupStore::new();
3689 store2.set_storage_dir(dir.clone(), 72);
3690
3691 let (entry, warning) = store2
3692 .restore_latest(DEFAULT_SESSION_ID, &file_path)
3693 .unwrap();
3694 assert_eq!(entry.content, "original");
3695 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
3697
3698 let _ = fs::remove_dir_all(&dir);
3699 }
3700
3701 #[test]
3702 fn snapshot_after_restart_preserves_history_and_unique_ids() {
3703 let dir = std::env::temp_dir().join("aft_backup_restart_history_test");
3709 let _ = fs::remove_dir_all(&dir);
3710 fs::create_dir_all(&dir).unwrap();
3711 let file_path = temp_file("restart_history.txt", "v0");
3712
3713 let first_id = {
3715 let mut store = BackupStore::new();
3716 store.set_storage_dir(dir.clone(), 72);
3717 let id = store
3718 .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 1")
3719 .unwrap()
3720 .unwrap();
3721 fs::write(&file_path, "v1").unwrap();
3722 id
3723 };
3724
3725 let second_id = {
3728 let mut store = BackupStore::new();
3729 store.set_storage_dir(dir.clone(), 72);
3730 let id = store
3731 .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 2")
3732 .unwrap()
3733 .unwrap();
3734 fs::write(&file_path, "v2").unwrap();
3735 id
3736 };
3737
3738 assert_ne!(
3741 first_id, second_id,
3742 "post-restart snapshot reused backup id {first_id}"
3743 );
3744
3745 let mut store = BackupStore::new();
3748 store.set_storage_dir(dir.clone(), 72);
3749 assert_eq!(
3750 store.history(DEFAULT_SESSION_ID, &file_path).len(),
3751 2,
3752 "prior history was overwritten by the post-restart snapshot"
3753 );
3754
3755 let (entry1, _) = store
3756 .restore_latest(DEFAULT_SESSION_ID, &file_path)
3757 .unwrap();
3758 assert_eq!(entry1.content, "v1", "first undo should restore v1");
3759 let (entry0, _) = store
3760 .restore_latest(DEFAULT_SESSION_ID, &file_path)
3761 .unwrap();
3762 assert_eq!(entry0.content, "v0", "second undo should restore v0");
3763
3764 let _ = fs::remove_dir_all(&dir);
3765 }
3766
3767 #[test]
3768 fn legacy_flat_layout_migrates_to_default_session() {
3769 let dir = std::env::temp_dir().join("aft_backup_migration_test");
3772 let _ = fs::remove_dir_all(&dir);
3773 fs::create_dir_all(&dir).unwrap();
3774 let backups = dir.join("backups");
3775 fs::create_dir_all(&backups).unwrap();
3776
3777 let legacy_hash = "deadbeefcafebabe";
3779 let legacy_dir = backups.join(legacy_hash);
3780 fs::create_dir_all(&legacy_dir).unwrap();
3781 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
3782 let legacy_meta = serde_json::json!({
3783 "path": "/tmp/migrated_file.txt",
3784 "count": 1,
3785 });
3786 fs::write(
3787 legacy_dir.join("meta.json"),
3788 serde_json::to_string_pretty(&legacy_meta).unwrap(),
3789 )
3790 .unwrap();
3791
3792 let mut store = BackupStore::new();
3794 store.set_storage_dir(dir.clone(), 72);
3795
3796 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
3799 assert!(default_session_dir.exists());
3800 assert!(default_session_dir.join(legacy_hash).exists());
3801 assert!(!backups.join(legacy_hash).exists());
3802
3803 let meta_content =
3805 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
3806 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
3807 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
3808 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
3809
3810 let _ = fs::remove_dir_all(&dir);
3811 }
3812
3813 #[test]
3814 fn set_storage_dir_removes_stale_backup_sessions() {
3815 let dir = std::env::temp_dir().join("aft_backup_gc_test");
3816 let _ = fs::remove_dir_all(&dir);
3817 let backups = dir.join("backups");
3818 fs::create_dir_all(&backups).unwrap();
3819
3820 let stale_session_dir = backups.join("stale-session");
3821 fs::create_dir_all(&stale_session_dir).unwrap();
3822 let stale_marker = serde_json::json!({
3823 "schema_version": SCHEMA_VERSION,
3824 "session_id": "stale",
3825 "last_accessed": 1,
3826 });
3827 fs::write(
3828 stale_session_dir.join("session.json"),
3829 serde_json::to_string_pretty(&stale_marker).unwrap(),
3830 )
3831 .unwrap();
3832
3833 let mut store = BackupStore::new();
3834 store.set_storage_dir(dir.clone(), 1);
3835
3836 assert!(!stale_session_dir.exists());
3837 let _ = fs::remove_dir_all(&dir);
3838 }
3839
3840 #[test]
3841 fn markerless_session_dir_is_skipped_not_mapped_to_default() {
3842 let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
3843 let _ = fs::remove_dir_all(&dir);
3844 let file_path = temp_file("markerless.txt", "original");
3845 let key = canonicalize_key(&file_path);
3846 let path_dir = dir
3847 .join("backups")
3848 .join("corrupt-session")
3849 .join("path-entry");
3850 fs::create_dir_all(&path_dir).unwrap();
3851 fs::write(path_dir.join("0.bak"), "original").unwrap();
3852 fs::write(
3853 path_dir.join("meta.json"),
3854 serde_json::to_string_pretty(&serde_json::json!({
3855 "schema_version": SCHEMA_VERSION,
3856 "session_id": "lost-session",
3857 "path": key.display().to_string(),
3858 "count": 1,
3859 "entries": [{
3860 "backup_id": "disk-0",
3861 "timestamp": 0,
3862 "description": "corrupt marker test",
3863 "op_id": null,
3864 "kind": "content",
3865 }]
3866 }))
3867 .unwrap(),
3868 )
3869 .unwrap();
3870
3871 let mut store = BackupStore::new();
3872 store.set_storage_dir(dir.clone(), 72);
3873
3874 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
3875 assert!(store.sessions_with_backups().is_empty());
3876 let _ = fs::remove_dir_all(&dir);
3877 }
3878
3879 #[test]
3880 fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
3881 let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
3882 let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
3883 let _ = fs::remove_dir_all(&dir_a);
3884 let _ = fs::remove_dir_all(&dir_b);
3885 fs::create_dir_all(&dir_a).unwrap();
3886 fs::create_dir_all(&dir_b).unwrap();
3887 let file_path = temp_file("storage_reconfigure.txt", "original");
3888
3889 let mut store = BackupStore::new();
3890 store.set_storage_dir(dir_a.clone(), 72);
3891 store
3892 .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
3893 .unwrap();
3894 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
3895
3896 store.set_storage_dir(dir_b.clone(), 72);
3897
3898 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
3899 assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
3900 let _ = fs::remove_dir_all(&dir_a);
3901 let _ = fs::remove_dir_all(&dir_b);
3902 }
3903
3904 #[test]
3905 fn restore_last_operation_restores_all_top_entries_for_same_op() {
3906 let path_a = temp_file("op_restore_a.txt", "a1");
3907 let path_b = temp_file("op_restore_b.txt", "b1");
3908 let mut store = BackupStore::new();
3909 let op_id = "op-test-00000001";
3910
3911 store
3912 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
3913 .unwrap();
3914 store
3915 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
3916 .unwrap();
3917 fs::write(&path_a, "a2").unwrap();
3918 fs::write(&path_b, "b2").unwrap();
3919
3920 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3921 assert_eq!(restored.op_id, op_id);
3922 assert_eq!(restored.restored.len(), 2);
3923 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
3924 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3925 }
3926
3927 #[test]
3928 fn restore_last_operation_deletes_tombstone_destination() {
3929 let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
3930 let _ = fs::remove_dir_all(&dir);
3931 fs::create_dir_all(&dir).unwrap();
3932 let source = dir.join("source.txt");
3933 let destination = dir.join("destination.txt");
3934 fs::write(&source, "original").unwrap();
3935
3936 let mut store = BackupStore::new();
3937 let op_id = "op-tombstone-delete";
3938 store
3939 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3940 .unwrap();
3941 fs::rename(&source, &destination).unwrap();
3942 store
3943 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3944 .unwrap();
3945
3946 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3947 assert_eq!(restored.op_id, op_id);
3948 assert_eq!(restored.restored.len(), 1);
3949 assert_eq!(fs::read_to_string(&source).unwrap(), "original");
3950 assert!(!destination.exists());
3951 let _ = fs::remove_dir_all(&dir);
3952 }
3953
3954 #[test]
3955 fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
3956 let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
3957 let _ = fs::remove_dir_all(&dir);
3958 fs::create_dir_all(&dir).unwrap();
3959 let source = dir.join("source.txt");
3960 let destination = dir.join("destination.txt");
3961 fs::write(&source, "original").unwrap();
3962
3963 let mut store = BackupStore::new();
3964 let op_id = "op-tombstone-atomic";
3965 store
3966 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3967 .unwrap();
3968 fs::rename(&source, &destination).unwrap();
3969 store
3970 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3971 .unwrap();
3972
3973 fs::remove_file(&destination).unwrap();
3974 fs::create_dir(&destination).unwrap();
3975 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
3976
3977 assert!(result.is_err(), "directory tombstone target should fail");
3978 assert!(
3979 !source.exists(),
3980 "source restore must roll back when destination deletion fails"
3981 );
3982 assert!(
3983 destination.is_dir(),
3984 "failed tombstone target should remain"
3985 );
3986 let _ = fs::remove_dir_all(&dir);
3987 }
3988
3989 #[cfg(unix)]
3995 #[test]
3996 fn restore_last_operation_is_atomic_when_a_write_fails() {
3997 let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
3998 let _ = fs::remove_dir_all(&dir);
3999 fs::create_dir_all(&dir).unwrap();
4000 let path_a = dir.join("a.txt");
4001 let path_b = dir.join("b.txt");
4002 let path_c = dir.join("c.txt");
4003 fs::write(&path_a, "a-original").unwrap();
4004 fs::write(&path_b, "b-original").unwrap();
4005 fs::write(&path_c, "c-original").unwrap();
4006
4007 let mut store = BackupStore::new();
4008 let op_id = "op-atomic-restore-01";
4009 let id_a = store
4010 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
4011 .unwrap()
4012 .unwrap();
4013 let id_b = store
4014 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
4015 .unwrap()
4016 .unwrap();
4017 let id_c = store
4018 .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
4019 .unwrap()
4020 .unwrap();
4021 fs::write(&path_a, "a-modified").unwrap();
4022 fs::write(&path_b, "b-modified").unwrap();
4023 fs::write(&path_c, "c-modified").unwrap();
4024
4025 let original_permissions = fs::metadata(&path_b).unwrap().permissions();
4026 let mut readonly_permissions = original_permissions.clone();
4027 readonly_permissions.set_mode(0o444);
4028 fs::set_permissions(&path_b, readonly_permissions).unwrap();
4029
4030 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
4031 fs::set_permissions(&path_b, original_permissions).unwrap();
4032
4033 assert!(result.is_err());
4034 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
4035 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
4036 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
4037
4038 let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
4039 let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
4040 let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
4041 assert_eq!(history_a.len(), 1);
4042 assert_eq!(history_b.len(), 1);
4043 assert_eq!(history_c.len(), 1);
4044 assert_eq!(history_a[0].backup_id, id_a);
4045 assert_eq!(history_b[0].backup_id, id_b);
4046 assert_eq!(history_c[0].backup_id, id_c);
4047 assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
4048 assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
4049 assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
4050
4051 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
4052 assert_eq!(restored.op_id, op_id);
4053 assert_eq!(restored.restored.len(), 3);
4054 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
4055 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
4056 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
4057
4058 let _ = fs::remove_dir_all(&dir);
4059 }
4060
4061 #[test]
4062 fn restore_last_operation_restores_only_most_recent_op() {
4063 let path_a = temp_file("op_recent_a.txt", "a1");
4064 let path_b = temp_file("op_recent_b.txt", "b1");
4065 let mut store = BackupStore::new();
4066
4067 store
4068 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
4069 .unwrap();
4070 store
4071 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
4072 .unwrap();
4073 fs::write(&path_a, "a2").unwrap();
4074 fs::write(&path_b, "b2").unwrap();
4075
4076 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
4077 assert_eq!(restored.op_id, "op-newer");
4078 assert_eq!(restored.restored.len(), 1);
4079 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
4080 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
4081 }
4082
4083 #[test]
4084 fn restore_recreates_missing_parent_directories() {
4085 let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
4088 let _ = fs::remove_dir_all(&dir);
4089 let nested = dir.join("nested");
4090 fs::create_dir_all(&nested).unwrap();
4091 let path = nested.join("inner.txt");
4092 fs::write(&path, "original").unwrap();
4093
4094 let mut store = BackupStore::new();
4095 let op_id = "op-recreate-parents-01";
4096 store
4097 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
4098 .unwrap();
4099
4100 fs::remove_dir_all(&dir).unwrap();
4102 assert!(!path.exists());
4103 assert!(!nested.exists());
4104 assert!(!dir.exists());
4105
4106 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
4107 assert_eq!(restored.op_id, op_id);
4108 assert_eq!(restored.restored.len(), 1);
4109 assert!(
4110 path.exists(),
4111 "file should be restored even though both nested/ and dir/ were missing"
4112 );
4113 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
4114
4115 let _ = fs::remove_dir_all(&dir);
4116 }
4117
4118 #[test]
4119 fn restore_last_operation_ignores_legacy_entries_without_op_id() {
4120 let path = temp_file("op_legacy_none.txt", "v1");
4121 let mut store = BackupStore::new();
4122
4123 store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
4124 fs::write(&path, "v2").unwrap();
4125
4126 let err = store.restore_last_operation(DEFAULT_SESSION_ID);
4127 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
4128 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
4129 }
4130
4131 #[test]
4132 fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
4133 let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
4134 let _ = fs::remove_dir_all(&dir);
4135 fs::create_dir_all(&dir).unwrap();
4136 let file_path = temp_file("v2_to_v3.txt", "original");
4137 let key = canonicalize_key(&file_path);
4138 let session_dir = dir
4139 .join("backups")
4140 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
4141 let path_dir = session_dir.join(BackupStore::path_hash(&key));
4142 fs::create_dir_all(&path_dir).unwrap();
4143 fs::write(path_dir.join("0.bak"), "original").unwrap();
4144 fs::write(
4145 session_dir.join("session.json"),
4146 serde_json::to_string_pretty(&serde_json::json!({
4147 "schema_version": 2,
4148 "session_id": DEFAULT_SESSION_ID,
4149 "last_accessed": current_timestamp(),
4150 }))
4151 .unwrap(),
4152 )
4153 .unwrap();
4154 fs::write(
4155 path_dir.join("meta.json"),
4156 serde_json::to_string_pretty(&serde_json::json!({
4157 "schema_version": 2,
4158 "session_id": DEFAULT_SESSION_ID,
4159 "path": key.display().to_string(),
4160 "count": 1,
4161 }))
4162 .unwrap(),
4163 )
4164 .unwrap();
4165
4166 let mut store = BackupStore::new();
4167 store.set_storage_dir(dir.clone(), 72);
4168 assert!(store
4169 .load_from_disk_if_needed(DEFAULT_SESSION_ID, &key)
4170 .unwrap());
4171 let history = store.history(DEFAULT_SESSION_ID, &file_path);
4172 assert_eq!(history.len(), 1);
4173 assert_eq!(history[0].op_id, None);
4174
4175 fs::write(&file_path, "second").unwrap();
4176 store
4177 .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
4178 .unwrap();
4179 let written: serde_json::Value =
4180 serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
4181 assert_eq!(written["schema_version"], SCHEMA_VERSION);
4182 assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
4183 assert_eq!(written["entries"][1]["op_id"], "op-v3");
4184 let _ = fs::remove_dir_all(&dir);
4185 }
4186
4187 #[test]
4188 fn per_file_restore_latest_still_works_with_op_ids() {
4189 let path = temp_file("op_per_file.txt", "v1");
4190 let mut store = BackupStore::new();
4191
4192 store
4193 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
4194 .unwrap();
4195 fs::write(&path, "v2").unwrap();
4196
4197 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
4198 assert_eq!(entry.op_id.as_deref(), Some("op-file"));
4199 assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
4200 }
4201
4202 #[test]
4203 fn per_file_restore_latest_deletes_tombstone() {
4204 let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
4205 let _ = fs::remove_dir_all(&dir);
4206 fs::create_dir_all(&dir).unwrap();
4207 let path = dir.join("created.txt");
4208 fs::write(&path, "created").unwrap();
4209
4210 let mut store = BackupStore::new();
4211 let id = store
4212 .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
4213 .unwrap()
4214 .unwrap();
4215
4216 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
4217 assert_eq!(entry.backup_id, id);
4218 assert!(!path.exists(), "tombstone undo should delete the file");
4219 let _ = fs::remove_dir_all(&dir);
4220 }
4221
4222 #[test]
4223 fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
4224 let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
4225 let _ = fs::remove_dir_all(&dir);
4226 let backups = dir.join("backups");
4227 let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
4228 let path_dir = session_dir.join("not-the-path-hash");
4229 fs::create_dir_all(&path_dir).unwrap();
4230 fs::write(
4231 session_dir.join("session.json"),
4232 serde_json::to_string_pretty(&serde_json::json!({
4233 "schema_version": SCHEMA_VERSION,
4234 "session_id": DEFAULT_SESSION_ID,
4235 "last_accessed": current_timestamp(),
4236 }))
4237 .unwrap(),
4238 )
4239 .unwrap();
4240 fs::write(path_dir.join("0.bak"), "outside").unwrap();
4241 fs::write(
4242 path_dir.join("meta.json"),
4243 serde_json::to_string_pretty(&serde_json::json!({
4244 "schema_version": SCHEMA_VERSION,
4245 "session_id": DEFAULT_SESSION_ID,
4246 "path": "/tmp/aft-malicious-overwrite-target.txt",
4247 "count": 1,
4248 "entries": [{
4249 "backup_id": "backup-0",
4250 "timestamp": current_timestamp(),
4251 "order": "1",
4252 "description": "tampered",
4253 "op_id": "op-tampered",
4254 "kind": "content",
4255 }]
4256 }))
4257 .unwrap(),
4258 )
4259 .unwrap();
4260
4261 let mut store = BackupStore::new();
4262 store.set_storage_dir(dir.clone(), 72);
4263
4264 assert!(store.sessions_with_backups().is_empty());
4265 let _ = fs::remove_dir_all(&dir);
4266 }
4267
4268 #[test]
4269 fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
4270 let path_a = temp_file("op_order_a.txt", "a1");
4271 let path_b = temp_file("op_order_b.txt", "b1");
4272 let mut store = BackupStore::new();
4273
4274 store
4275 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
4276 .unwrap();
4277 store
4278 .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
4279 .unwrap();
4280 store
4281 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
4282 .unwrap();
4283
4284 let key_a = canonicalize_key(&path_a);
4285 let key_b = canonicalize_key(&path_b);
4286 let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
4287 files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
4288 files.get_mut(&key_a).unwrap()[1].order = 1;
4289 files.get_mut(&key_b).unwrap()[0].order = 2;
4290
4291 fs::write(&path_a, "a2").unwrap();
4292 fs::write(&path_b, "b2").unwrap();
4293
4294 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
4295 assert_eq!(restored.op_id, "op-top");
4296 assert_eq!(restored.restored.len(), 1);
4297 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
4298 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
4299 }
4300
4301 #[test]
4302 fn append_only_v2_adds_one_content_file_at_steady_depth() {
4303 let dir = tempfile::tempdir().unwrap();
4304 let path = dir.path().join("append_only.txt");
4305 fs::write(&path, "v0").unwrap();
4306 let mut store = BackupStore::new();
4307 store.set_storage_dir(dir.path().to_path_buf(), 72);
4308
4309 for i in 0..MAX_UNDO_DEPTH {
4310 store
4311 .snapshot(DEFAULT_SESSION_ID, &path, "push")
4312 .unwrap()
4313 .unwrap();
4314 fs::write(&path, format!("v{}", i + 1)).unwrap();
4315 }
4316
4317 let key = canonicalize_key(&path);
4318 let stack_dir = store
4319 .session_dir(DEFAULT_SESSION_ID)
4320 .unwrap()
4321 .join(BackupStore::path_hash(&key));
4322 let before = backup_content_names(&stack_dir);
4323 assert_eq!(before.len(), MAX_UNDO_DEPTH);
4324
4325 store
4326 .snapshot(DEFAULT_SESSION_ID, &path, "steady push")
4327 .unwrap()
4328 .unwrap();
4329 let after = backup_content_names(&stack_dir);
4330 assert_eq!(after.len(), MAX_UNDO_DEPTH);
4331 assert_eq!(after.difference(&before).count(), 1);
4332 assert_eq!(before.difference(&after).count(), 1);
4333
4334 let meta: serde_json::Value =
4335 serde_json::from_str(&fs::read_to_string(stack_dir.join("meta.json")).unwrap())
4336 .unwrap();
4337 assert_eq!(
4338 meta.get("format_version").and_then(|v| v.as_str()),
4339 Some("v2")
4340 );
4341 assert!(meta_entries(&meta)
4342 .unwrap()
4343 .iter()
4344 .all(|entry| entry.get("content_path").and_then(|v| v.as_str()).is_some()));
4345 }
4346
4347 #[test]
4348 fn legacy_stack_migrates_to_v2_on_next_write() {
4349 let dir = tempfile::tempdir().unwrap();
4350 let path = dir.path().join("legacy.txt");
4351 fs::write(&path, "current").unwrap();
4352 let key = canonicalize_key(&path);
4353 let session_dir = dir
4354 .path()
4355 .join("backups")
4356 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
4357 let stack_dir = session_dir.join(BackupStore::path_hash(&key));
4358 fs::create_dir_all(&stack_dir).unwrap();
4359 fs::write(
4360 session_dir.join("session.json"),
4361 serde_json::to_string_pretty(&serde_json::json!({
4362 "schema_version": SCHEMA_VERSION,
4363 "session_id": DEFAULT_SESSION_ID,
4364 "last_accessed": current_timestamp(),
4365 }))
4366 .unwrap(),
4367 )
4368 .unwrap();
4369 fs::write(stack_dir.join("0.bak"), "legacy").unwrap();
4370 fs::write(
4371 stack_dir.join("meta.json"),
4372 serde_json::to_string_pretty(&serde_json::json!({
4373 "schema_version": SCHEMA_VERSION,
4374 "session_id": DEFAULT_SESSION_ID,
4375 "path": key.display().to_string(),
4376 "count": 1,
4377 "entries": [{
4378 "backup_id": "backup-0",
4379 "timestamp": current_timestamp(),
4380 "order": "1",
4381 "description": "legacy",
4382 "kind": "content",
4383 }]
4384 }))
4385 .unwrap(),
4386 )
4387 .unwrap();
4388
4389 let mut store = BackupStore::new();
4390 store.set_storage_dir(dir.path().to_path_buf(), 72);
4391 assert_eq!(
4392 store.history(DEFAULT_SESSION_ID, &path)[0].content,
4393 "legacy"
4394 );
4395
4396 store
4397 .snapshot(DEFAULT_SESSION_ID, &path, "migrate")
4398 .unwrap()
4399 .unwrap();
4400 let meta: serde_json::Value =
4401 serde_json::from_str(&fs::read_to_string(stack_dir.join("meta.json")).unwrap())
4402 .unwrap();
4403 assert_eq!(
4404 meta.get("format_version").and_then(|v| v.as_str()),
4405 Some("v2")
4406 );
4407 assert!(!stack_dir.join("0.bak").exists());
4408 assert_eq!(backup_content_names(&stack_dir).len(), 2);
4409 }
4410
4411 #[test]
4412 fn snapshot_reloads_non_empty_stale_stack_before_append() {
4413 let project = tempfile::tempdir().unwrap();
4414 let storage = tempfile::tempdir().unwrap();
4415 let path = project.path().join("stale-memory.txt");
4416 fs::write(&path, "v0").unwrap();
4417 let policy = BackupPolicy {
4418 enabled: true,
4419 max_depth: 2,
4420 max_file_size: None,
4421 };
4422
4423 let mut store_a = BackupStore::new();
4424 store_a.set_storage_dir(storage.path().to_path_buf(), 72);
4425 store_a.set_policy(policy);
4426 store_a
4427 .snapshot(DEFAULT_SESSION_ID, &path, "a captures v0")
4428 .unwrap();
4429 fs::write(&path, "v1").unwrap();
4430
4431 let mut store_b = BackupStore::new();
4432 store_b.set_storage_dir(storage.path().to_path_buf(), 72);
4433 store_b.set_policy(policy);
4434 store_b
4435 .snapshot(DEFAULT_SESSION_ID, &path, "b captures v1")
4436 .unwrap();
4437 fs::write(&path, "v2").unwrap();
4438
4439 store_a
4440 .snapshot(DEFAULT_SESSION_ID, &path, "a captures v2")
4441 .unwrap();
4442
4443 let mut fresh = BackupStore::new();
4444 fresh.set_storage_dir(storage.path().to_path_buf(), 72);
4445 let contents = fresh
4446 .history(DEFAULT_SESSION_ID, &path)
4447 .into_iter()
4448 .map(|entry| entry.content)
4449 .collect::<Vec<_>>();
4450 assert_eq!(contents, vec!["v1".to_string(), "v2".to_string()]);
4451 }
4452
4453 #[test]
4454 fn restore_latest_clears_stale_memory_when_disk_stack_disappears() {
4455 let project = tempfile::tempdir().unwrap();
4456 let storage = tempfile::tempdir().unwrap();
4457 let session = "stale-resurrection-session";
4458 let path = project.path().join("stale-resurrection.txt");
4459 fs::write(&path, "v0").unwrap();
4460
4461 let mut store_a = BackupStore::new();
4462 store_a.set_storage_dir(storage.path().to_path_buf(), 72);
4463 store_a.snapshot(session, &path, "a captures v0").unwrap();
4464 fs::write(&path, "v1").unwrap();
4465
4466 let mut store_b = BackupStore::new();
4467 store_b.set_storage_dir(storage.path().to_path_buf(), 72);
4468 let (restored, _) = store_b.restore_latest(session, &path).unwrap();
4469 assert_eq!(restored.content, "v0");
4470
4471 fs::write(&path, "current after other restore").unwrap();
4472 let error = store_a.restore_latest(session, &path).unwrap_err();
4473
4474 assert_eq!(error.code(), "no_undo_history");
4475 assert_eq!(
4476 fs::read_to_string(&path).unwrap(),
4477 "current after other restore"
4478 );
4479 let key = canonicalize_key(&path);
4480 assert!(store_a
4481 .entries
4482 .get(session)
4483 .and_then(|files| files.get(&key))
4484 .is_none());
4485
4486 let snapshot_path = project.path().join("stale-snapshot.txt");
4487 fs::write(&snapshot_path, "snapshot v0").unwrap();
4488 let mut store_c = BackupStore::new();
4489 store_c.set_storage_dir(storage.path().to_path_buf(), 72);
4490 store_c
4491 .snapshot(session, &snapshot_path, "c captures v0")
4492 .unwrap();
4493 fs::write(&snapshot_path, "snapshot v1").unwrap();
4494 let mut store_d = BackupStore::new();
4495 store_d.set_storage_dir(storage.path().to_path_buf(), 72);
4496 store_d.restore_latest(session, &snapshot_path).unwrap();
4497
4498 fs::write(&snapshot_path, "snapshot current").unwrap();
4499 store_c
4500 .snapshot(session, &snapshot_path, "c captures current")
4501 .unwrap();
4502 let mut fresh = BackupStore::new();
4503 fresh.set_storage_dir(storage.path().to_path_buf(), 72);
4504 let contents = fresh
4505 .history(session, &snapshot_path)
4506 .into_iter()
4507 .map(|entry| entry.content)
4508 .collect::<Vec<_>>();
4509 assert_eq!(contents, vec!["snapshot current".to_string()]);
4510 }
4511
4512 #[test]
4513 fn restore_last_operation_returns_retry_error_under_unbounded_key_churn() {
4514 let project = tempfile::tempdir().unwrap();
4515 let storage = tempfile::tempdir().unwrap();
4516 let session = "restore-churn-session";
4517 let base_path = project.path().join("base.txt");
4518 fs::write(&base_path, "base before").unwrap();
4519 let mut base_store = BackupStore::new();
4520 base_store.set_storage_dir(storage.path().to_path_buf(), 72);
4521 base_store
4522 .snapshot_with_op(session, &base_path, "base op", Some("op-base"))
4523 .unwrap();
4524 fs::write(&base_path, "base after").unwrap();
4525
4526 let churn_count = Arc::new(Mutex::new(0usize));
4527 let hook_count = churn_count.clone();
4528 let hook_project = project.path().to_path_buf();
4529 let hook_storage = storage.path().to_path_buf();
4530 set_restore_before_lock_hook_for_tests(session, move |_| {
4531 let mut count = hook_count.lock().unwrap();
4532 let churn_path = hook_project.join(format!("churn-{}.txt", *count));
4533 fs::write(&churn_path, format!("churn before {}", *count)).unwrap();
4534 let mut churn_store = BackupStore::new();
4535 churn_store.set_storage_dir(hook_storage.clone(), 72);
4536 let op_id = format!("op-churn-{}", *count);
4537 churn_store
4538 .snapshot_with_op(session, &churn_path, "churn op", Some(&op_id))
4539 .unwrap();
4540 fs::write(&churn_path, format!("churn after {}", *count)).unwrap();
4541 *count += 1;
4542 *count < MAX_RESTORE_OPERATION_LOCK_RETRIES
4543 });
4544
4545 let mut restore_store = BackupStore::new();
4546 restore_store.set_storage_dir(storage.path().to_path_buf(), 72);
4547 let error = restore_store.restore_last_operation(session).unwrap_err();
4548
4549 assert_eq!(error.code(), "io_error");
4550 assert!(error
4551 .to_string()
4552 .contains("backup stack changing under concurrent activity; retry"));
4553 assert_eq!(
4554 *churn_count.lock().unwrap(),
4555 MAX_RESTORE_OPERATION_LOCK_RETRIES
4556 );
4557 }
4558
4559 #[test]
4560 fn restore_last_operation_test_hooks_are_isolated_per_session() {
4561 let project = tempfile::tempdir().unwrap();
4562 let storage = tempfile::tempdir().unwrap();
4563 let session_a = "restore-hook-session-a";
4564 let session_b = "restore-hook-session-b";
4565 let path_a = project.path().join("restore-hook-a.txt");
4566 fs::write(&path_a, "v0").unwrap();
4567
4568 let mut store_a = BackupStore::new();
4569 store_a.set_storage_dir(storage.path().to_path_buf(), 72);
4570 store_a
4571 .snapshot_with_op(session_a, &path_a, "old op", Some("op-old-a"))
4572 .unwrap();
4573 fs::write(&path_a, "v1").unwrap();
4574
4575 let hook_storage = storage.path().to_path_buf();
4576 let hook_path_a = path_a.clone();
4577 set_restore_before_lock_hook_for_tests(session_a, move |_| {
4578 let mut hook_store = BackupStore::new();
4579 hook_store.set_storage_dir(hook_storage.clone(), 72);
4580 hook_store
4581 .snapshot_with_op(session_a, &hook_path_a, "new op", Some("op-new-a"))
4582 .unwrap();
4583 fs::write(&hook_path_a, "v2").unwrap();
4584 false
4585 });
4586 set_restore_before_lock_hook_for_tests(session_b, |_| false);
4587
4588 let restored = store_a.restore_last_operation(session_a).unwrap();
4589
4590 assert_eq!(restored.op_id, "op-new-a");
4591 assert_eq!(fs::read_to_string(&path_a).unwrap(), "v1");
4592 run_restore_before_lock_hook_for_tests(session_b, 0);
4593 }
4594
4595 #[test]
4596 fn restore_last_operation_rescans_stack_after_locking() {
4597 let project = tempfile::tempdir().unwrap();
4598 let storage = tempfile::tempdir().unwrap();
4599 let session = "restore-toctou-session";
4600 let path = project.path().join("restore-toctou.txt");
4601 fs::write(&path, "v0").unwrap();
4602
4603 let mut store_a = BackupStore::new();
4604 store_a.set_storage_dir(storage.path().to_path_buf(), 72);
4605 store_a
4606 .snapshot_with_op(session, &path, "old op", Some("op-old"))
4607 .unwrap();
4608 fs::write(&path, "v1").unwrap();
4609
4610 let hook_storage = storage.path().to_path_buf();
4611 let hook_path = path.clone();
4612 set_restore_before_lock_hook_for_tests(session, move |_| {
4613 let mut store_b = BackupStore::new();
4614 store_b.set_storage_dir(hook_storage.clone(), 72);
4615 store_b
4616 .snapshot_with_op(session, &hook_path, "new op", Some("op-new"))
4617 .unwrap();
4618 fs::write(&hook_path, "v2").unwrap();
4619 false
4620 });
4621
4622 let restored = store_a.restore_last_operation(session).unwrap();
4623
4624 assert_eq!(restored.op_id, "op-new");
4625 assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
4626 }
4627
4628 #[test]
4629 fn corrupt_v2_meta_fails_closed_for_operation_and_single_restore() {
4630 let project = tempfile::tempdir().unwrap();
4631 let storage = tempfile::tempdir().unwrap();
4632 let session = "corrupt-v2-session";
4633 let path = project.path().join("corrupt-v2.txt");
4634 fs::write(&path, "current").unwrap();
4635 let key = canonicalize_key(&path);
4636 let session_dir = storage
4637 .path()
4638 .join("backups")
4639 .join(BackupStore::session_hash(session));
4640 let stack_dir = session_dir.join(BackupStore::path_hash(&key));
4641 fs::create_dir_all(&stack_dir).unwrap();
4642 fs::write(
4643 session_dir.join("session.json"),
4644 serde_json::to_string_pretty(&serde_json::json!({
4645 "schema_version": SCHEMA_VERSION,
4646 "session_id": session,
4647 "last_accessed": current_timestamp(),
4648 }))
4649 .unwrap(),
4650 )
4651 .unwrap();
4652 fs::write(
4653 stack_dir.join("meta.json"),
4654 serde_json::to_string_pretty(&serde_json::json!({
4655 "schema_version": SCHEMA_VERSION,
4656 "format_version": "v2",
4657 "session_id": session,
4658 "path": key.display().to_string(),
4659 "count": 1,
4660 "entries": [{
4661 "backup_id": "backup-corrupt",
4662 "timestamp": current_timestamp(),
4663 "order": "9",
4664 "description": "corrupt disk should win over DB fallback",
4665 "op_id": "op-corrupt",
4666 "kind": "content",
4667 "content_path": "bak_9_backup-corrupt.bak",
4668 }]
4669 }))
4670 .unwrap(),
4671 )
4672 .unwrap();
4673
4674 let conn = crate::db::open(&storage.path().join("aft.db")).unwrap();
4675 let fallback_path = stack_dir.join("db-fallback.bak");
4676 fs::write(&fallback_path, "db fallback").unwrap();
4677 crate::db::backups::upsert_backup(
4678 &conn,
4679 &BackupRow {
4680 backup_id: "backup-db".to_string(),
4681 harness: "opencode".to_string(),
4682 session_id: session.to_string(),
4683 project_key: "project".to_string(),
4684 op_id: Some("op-corrupt".to_string()),
4685 order: 9,
4686 file_path: key.display().to_string(),
4687 path_hash: BackupStore::path_hash(&key),
4688 backup_path: Some(fallback_path.display().to_string()),
4689 kind: "content".to_string(),
4690 description: "db fallback".to_string(),
4691 created_at: i64::try_from(current_timestamp()).unwrap(),
4692 is_tombstone: false,
4693 },
4694 )
4695 .unwrap();
4696 let shared = Arc::new(Mutex::new(conn));
4697
4698 let mut single = BackupStore::new();
4699 single.set_storage_dir(storage.path().to_path_buf(), 72);
4700 single.set_db_harness(Harness::Opencode);
4701 single.set_db_project_key("project".to_string());
4702 single.set_db_pool(shared.clone());
4703 let single_error = single.restore_latest(session, &path).unwrap_err();
4704 assert_eq!(single_error.code(), "io_error");
4705 assert_eq!(fs::read_to_string(&path).unwrap(), "current");
4706
4707 let mut operation = BackupStore::new();
4708 operation.set_storage_dir(storage.path().to_path_buf(), 72);
4709 operation.set_db_harness(Harness::Opencode);
4710 operation.set_db_project_key("project".to_string());
4711 operation.set_db_pool(shared);
4712 let operation_error = operation.restore_last_operation(session).unwrap_err();
4713 assert_eq!(operation_error.code(), "io_error");
4714 assert_eq!(fs::read_to_string(&path).unwrap(), "current");
4715 }
4716
4717 #[test]
4718 fn replace_file_replaces_existing_meta_with_single_rename_path() {
4719 let dir = tempfile::tempdir().unwrap();
4720 let meta_path = dir.path().join("meta.json");
4721 let temp_path = dir.path().join("meta.tmp");
4722 fs::write(&meta_path, "old").unwrap();
4723 fs::write(&temp_path, "new").unwrap();
4724
4725 replace_file(&temp_path, &meta_path).unwrap();
4726
4727 assert_eq!(fs::read_to_string(&meta_path).unwrap(), "new");
4728 assert!(!temp_path.exists());
4729 }
4730
4731 #[test]
4732 fn snapshot_write_failure_restores_full_pre_trim_stack() {
4733 let project = tempfile::tempdir().unwrap();
4734 let storage = tempfile::tempdir().unwrap();
4735 let session = "rollback-pretrim-session";
4736 let path = project.path().join("rollback.txt");
4737 fs::write(&path, "v0").unwrap();
4738 let mut store = BackupStore::new();
4739 store.set_storage_dir(storage.path().to_path_buf(), 72);
4740 store.set_policy(BackupPolicy {
4741 enabled: true,
4742 max_depth: 2,
4743 max_file_size: None,
4744 });
4745
4746 store.snapshot(session, &path, "first").unwrap();
4747 fs::write(&path, "v1").unwrap();
4748 store.snapshot(session, &path, "second").unwrap();
4749 fs::write(&path, "v2").unwrap();
4750 let key = canonicalize_key(&path);
4751 let before_file_stack = store
4752 .entries
4753 .get(session)
4754 .unwrap()
4755 .get(&key)
4756 .unwrap()
4757 .clone();
4758
4759 store.fail_next_disk_write_for_tests();
4760 let error = store.snapshot(session, &path, "third").unwrap_err();
4761 assert_eq!(error.code(), "io_error");
4762 let after_file_stack = store.entries.get(session).unwrap().get(&key).unwrap();
4763 assert_eq!(
4764 after_file_stack
4765 .iter()
4766 .map(|entry| entry.description.as_str())
4767 .collect::<Vec<_>>(),
4768 before_file_stack
4769 .iter()
4770 .map(|entry| entry.description.as_str())
4771 .collect::<Vec<_>>()
4772 );
4773
4774 let tombstone = project.path().join("created-by-op.txt");
4775 store
4776 .snapshot_op_tombstone(session, "op-one", &tombstone, "created one")
4777 .unwrap();
4778 store
4779 .snapshot_op_tombstone(session, "op-two", &tombstone, "created two")
4780 .unwrap();
4781 let tombstone_key = canonicalize_key(&tombstone);
4782 let before_tombstone_stack = store
4783 .entries
4784 .get(session)
4785 .unwrap()
4786 .get(&tombstone_key)
4787 .unwrap()
4788 .clone();
4789
4790 store.fail_next_disk_write_for_tests();
4791 let error = store
4792 .snapshot_op_tombstone(session, "op-three", &tombstone, "created three")
4793 .unwrap_err();
4794 assert_eq!(error.code(), "io_error");
4795 let after_tombstone_stack = store
4796 .entries
4797 .get(session)
4798 .unwrap()
4799 .get(&tombstone_key)
4800 .unwrap();
4801 assert_eq!(
4802 after_tombstone_stack
4803 .iter()
4804 .map(|entry| entry.op_id.as_deref())
4805 .collect::<Vec<_>>(),
4806 before_tombstone_stack
4807 .iter()
4808 .map(|entry| entry.op_id.as_deref())
4809 .collect::<Vec<_>>()
4810 );
4811 }
4812
4813 #[test]
4814 fn lowering_max_depth_prunes_disk_content_immediately() {
4815 let project = tempfile::tempdir().unwrap();
4816 let storage = tempfile::tempdir().unwrap();
4817 let path = project.path().join("policy-prune.txt");
4818 fs::write(&path, "v0").unwrap();
4819 let mut store = BackupStore::new();
4820 store.set_storage_dir(storage.path().to_path_buf(), 72);
4821
4822 for i in 0..3 {
4823 store
4824 .snapshot(DEFAULT_SESSION_ID, &path, &format!("snapshot {i}"))
4825 .unwrap();
4826 fs::write(&path, format!("v{}", i + 1)).unwrap();
4827 }
4828
4829 let key = canonicalize_key(&path);
4830 let stack_dir = store
4831 .session_dir(DEFAULT_SESSION_ID)
4832 .unwrap()
4833 .join(BackupStore::path_hash(&key));
4834 assert_eq!(backup_content_names(&stack_dir).len(), 3);
4835
4836 store.set_policy(BackupPolicy {
4837 enabled: true,
4838 max_depth: 1,
4839 max_file_size: None,
4840 });
4841
4842 assert_eq!(backup_content_names(&stack_dir).len(), 1);
4843 let meta: serde_json::Value =
4844 serde_json::from_str(&fs::read_to_string(stack_dir.join("meta.json")).unwrap())
4845 .unwrap();
4846 assert_eq!(meta_entry_count(&meta), Some(1));
4847 let mut fresh = BackupStore::new();
4848 fresh.set_storage_dir(storage.path().to_path_buf(), 72);
4849 assert_eq!(fresh.history(DEFAULT_SESSION_ID, &path).len(), 1);
4850 }
4851
4852 #[test]
4853 fn v2_missing_content_fails_closed() {
4854 let dir = tempfile::tempdir().unwrap();
4855 let path = dir.path().join("missing-content.txt");
4856 fs::write(&path, "current").unwrap();
4857 let key = canonicalize_key(&path);
4858 let session_dir = dir
4859 .path()
4860 .join("backups")
4861 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
4862 let stack_dir = session_dir.join(BackupStore::path_hash(&key));
4863 fs::create_dir_all(&stack_dir).unwrap();
4864 fs::write(
4865 session_dir.join("session.json"),
4866 serde_json::to_string_pretty(&serde_json::json!({
4867 "schema_version": SCHEMA_VERSION,
4868 "session_id": DEFAULT_SESSION_ID,
4869 "last_accessed": current_timestamp(),
4870 }))
4871 .unwrap(),
4872 )
4873 .unwrap();
4874 fs::write(
4875 stack_dir.join("meta.json"),
4876 serde_json::to_string_pretty(&serde_json::json!({
4877 "schema_version": SCHEMA_VERSION,
4878 "format_version": "v2",
4879 "session_id": DEFAULT_SESSION_ID,
4880 "path": key.display().to_string(),
4881 "count": 1,
4882 "entries": [{
4883 "backup_id": "backup-0",
4884 "timestamp": current_timestamp(),
4885 "order": "1",
4886 "description": "missing",
4887 "kind": "content",
4888 "content_path": "bak_1_backup-0.bak",
4889 }]
4890 }))
4891 .unwrap(),
4892 )
4893 .unwrap();
4894
4895 let mut store = BackupStore::new();
4896 store.set_storage_dir(dir.path().to_path_buf(), 72);
4897 let error = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap_err();
4898 assert_eq!(error.code(), "io_error");
4899 }
4900
4901 #[test]
4902 fn v2_orphan_files_are_ignored_then_pruned() {
4903 let dir = tempfile::tempdir().unwrap();
4904 let path = dir.path().join("orphan.txt");
4905 fs::write(&path, "v0").unwrap();
4906 let mut store = BackupStore::new();
4907 store.set_storage_dir(dir.path().to_path_buf(), 72);
4908 store
4909 .snapshot(DEFAULT_SESSION_ID, &path, "first")
4910 .unwrap()
4911 .unwrap();
4912 let key = canonicalize_key(&path);
4913 let stack_dir = store
4914 .session_dir(DEFAULT_SESSION_ID)
4915 .unwrap()
4916 .join(BackupStore::path_hash(&key));
4917 fs::write(stack_dir.join("bak_999_orphan.bak"), "orphan").unwrap();
4918
4919 assert_eq!(store.history(DEFAULT_SESSION_ID, &path).len(), 1);
4920 fs::write(&path, "v1").unwrap();
4921 store
4922 .snapshot(DEFAULT_SESSION_ID, &path, "second")
4923 .unwrap()
4924 .unwrap();
4925 assert!(!stack_dir.join("bak_999_orphan.bak").exists());
4926 }
4927
4928 fn backup_content_names(dir: &Path) -> HashSet<String> {
4929 fs::read_dir(dir)
4930 .unwrap()
4931 .filter_map(|entry| entry.ok())
4932 .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
4933 .filter(|name| name.starts_with("bak_") && name.ends_with(".bak"))
4934 .collect()
4935 }
4936}