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