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