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