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