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