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