Skip to main content

aft/
backup.rs

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