Skip to main content

aft/
backup.rs

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