Skip to main content

aft/
backup.rs

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