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