Skip to main content

aft/
backup.rs

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