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