Skip to main content

aft/
backup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, RwLock};
5
6use rusqlite::Connection;
7
8use crate::db::backups::BackupRow;
9use crate::error::AftError;
10use sha2::{Digest, Sha256};
11
12const MAX_UNDO_DEPTH: usize = 20;
13
14/// Current on-disk backup metadata schema version.
15///
16/// Bump this when the `meta.json` shape changes. Readers check the field and
17/// refuse or migrate older versions instead of misinterpreting them.
18const SCHEMA_VERSION: u32 = 4;
19
20/// A single backup entry for a file.
21#[derive(Debug, Clone)]
22pub struct BackupEntry {
23    pub backup_id: String,
24    /// UTF-8 view of the captured regular-file bytes, kept for API/tests that
25    /// inspect text backups. Restore uses `content_bytes` so binary files round-trip.
26    pub content: String,
27    pub content_bytes: Vec<u8>,
28    pub timestamp: u64,
29    pub order: u128,
30    pub description: String,
31    pub op_id: Option<String>,
32    pub kind: BackupEntryKind,
33    pub mode: Option<u32>,
34    pub link_target: Option<PathBuf>,
35    pub created_dirs: Vec<PathBuf>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum BackupEntryKind {
40    Content,
41    Symlink,
42    Tombstone,
43}
44
45impl BackupEntry {
46    fn to_backup_row(
47        &self,
48        harness: &str,
49        session_id: &str,
50        project_key: &str,
51        file_path: &str,
52        path_hash: &str,
53        backup_path: Option<&str>,
54    ) -> BackupRow {
55        BackupRow {
56            backup_id: self.backup_id.clone(),
57            harness: harness.to_string(),
58            session_id: session_id.to_string(),
59            project_key: project_key.to_string(),
60            op_id: self.op_id.clone(),
61            order: self.order,
62            file_path: file_path.to_string(),
63            path_hash: path_hash.to_string(),
64            backup_path: backup_path.map(str::to_string),
65            kind: match self.kind {
66                BackupEntryKind::Content => "content".to_string(),
67                BackupEntryKind::Symlink => "symlink".to_string(),
68                BackupEntryKind::Tombstone => "tombstone".to_string(),
69            },
70            description: self.description.clone(),
71            created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
72            is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
73        }
74    }
75}
76
77impl TryFrom<BackupRow> for BackupEntry {
78    type Error = std::io::Error;
79
80    fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
81        let kind = if row.is_tombstone || row.kind == "tombstone" {
82            BackupEntryKind::Tombstone
83        } else if row.kind == "symlink" {
84            BackupEntryKind::Symlink
85        } else {
86            BackupEntryKind::Content
87        };
88        let backup_path = row.backup_path.clone();
89        let disk_metadata = backup_path
90            .as_deref()
91            .and_then(|path| read_entry_disk_metadata(Path::new(path), &row.backup_id));
92        let content_bytes = match kind {
93            BackupEntryKind::Content | BackupEntryKind::Symlink => {
94                let backup_path = backup_path.ok_or_else(|| {
95                    std::io::Error::new(
96                        std::io::ErrorKind::NotFound,
97                        format!("backup DB row {} has no backup_path", row.backup_id),
98                    )
99                })?;
100                std::fs::read(backup_path)?
101            }
102            BackupEntryKind::Tombstone => Vec::new(),
103        };
104        let link_target = if kind == BackupEntryKind::Symlink {
105            disk_metadata
106                .as_ref()
107                .and_then(|metadata| metadata.link_target.clone())
108                .or_else(|| {
109                    Some(PathBuf::from(
110                        String::from_utf8_lossy(&content_bytes).into_owned(),
111                    ))
112                })
113        } else {
114            None
115        };
116        let content = match kind {
117            BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
118            BackupEntryKind::Symlink => link_target
119                .as_ref()
120                .map(|target| target.display().to_string())
121                .unwrap_or_default(),
122            BackupEntryKind::Tombstone => String::new(),
123        };
124
125        Ok(BackupEntry {
126            backup_id: row.backup_id,
127            content,
128            content_bytes,
129            timestamp: u64::try_from(row.created_at).unwrap_or_default(),
130            order: row.order,
131            description: row.description,
132            op_id: row.op_id,
133            kind,
134            mode: disk_metadata.as_ref().and_then(|metadata| metadata.mode),
135            link_target,
136            created_dirs: disk_metadata
137                .map(|metadata| metadata.created_dirs)
138                .unwrap_or_default(),
139        })
140    }
141}
142
143#[derive(Debug, Clone)]
144pub struct RestoredOperation {
145    pub op_id: String,
146    pub restored: Vec<RestoredFile>,
147    pub warnings: Vec<String>,
148}
149
150#[derive(Debug, Clone)]
151pub struct RestoredFile {
152    pub path: PathBuf,
153    pub backup_id: String,
154}
155
156/// Per-(session, file) undo store with optional disk persistence.
157///
158/// Introduced alongside project-shared bridges (issue #14): one bridge can now
159/// serve many OpenCode sessions in the same project, so undo history must be
160/// partitioned by session to keep session A's edits invisible to session B.
161///
162/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
163/// per-file LRU would re-couple sessions and let one busy session evict
164/// another's history.
165///
166/// Disk layout (schema v2):
167///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
168///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
169///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
170///
171/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
172/// `backups/`) are migrated on first `set_storage_dir` call into the default
173/// session namespace.
174#[derive(Debug)]
175pub struct BackupStore {
176    /// session -> path -> entry stack
177    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
178    /// session -> path -> disk metadata
179    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
180    /// session -> metadata
181    session_meta: HashMap<String, SessionMeta>,
182    counter: AtomicU64,
183    storage_dir: Option<PathBuf>,
184    storage_harness: Option<String>,
185    db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
186    db_harness: RwLock<Option<String>>,
187    db_project_key: RwLock<Option<String>>,
188}
189
190#[derive(Debug, Clone)]
191struct DiskMeta {
192    dir: PathBuf,
193    count: usize,
194}
195
196#[derive(Debug, Clone, Default)]
197struct SessionMeta {
198    /// Unix timestamp of last read/write activity in this session namespace.
199    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
200    last_accessed: u64,
201}
202
203impl BackupStore {
204    pub fn new() -> Self {
205        BackupStore {
206            entries: HashMap::new(),
207            disk_index: HashMap::new(),
208            session_meta: HashMap::new(),
209            counter: AtomicU64::new(0),
210            storage_dir: None,
211            storage_harness: None,
212            db_pool: RwLock::new(None),
213            db_harness: RwLock::new(None),
214            db_project_key: RwLock::new(None),
215        }
216    }
217
218    pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
219        if let Ok(mut slot) = self.db_pool.write() {
220            *slot = Some(conn);
221        }
222    }
223
224    pub fn clear_db_pool(&self) {
225        if let Ok(mut slot) = self.db_pool.write() {
226            *slot = None;
227        }
228    }
229
230    pub fn set_db_harness(&self, harness: crate::harness::Harness) {
231        if let Ok(mut slot) = self.db_harness.write() {
232            *slot = Some(harness.as_str().to_string());
233        }
234    }
235
236    pub fn set_db_project_key(&self, project_key: String) {
237        if let Ok(mut slot) = self.db_project_key.write() {
238            *slot = Some(project_key);
239        }
240    }
241
242    /// Set storage directory for disk persistence (called during configure).
243    ///
244    /// Loads the disk index for all session namespaces, removes stale session
245    /// directories, and migrates any legacy pre-session (flat) layout into the
246    /// default namespace.
247    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
248        self.set_storage_dir_inner(dir, None, ttl_hours);
249    }
250
251    pub fn set_storage_dir_for_harness(
252        &mut self,
253        dir: PathBuf,
254        harness: crate::harness::Harness,
255        ttl_hours: u32,
256    ) {
257        self.set_storage_dir_inner(dir, Some(harness.as_str().to_string()), ttl_hours);
258    }
259
260    fn set_storage_dir_inner(&mut self, dir: PathBuf, harness: Option<String>, ttl_hours: u32) {
261        self.storage_dir = Some(dir);
262        self.storage_harness = harness;
263        self.entries.clear();
264        self.disk_index.clear();
265        self.session_meta.clear();
266        self.repair_root_backups_if_needed();
267        self.gc_stale_sessions(ttl_hours);
268        self.migrate_legacy_layout_if_needed();
269        self.load_disk_index();
270    }
271
272    /// Snapshot the current contents of `path` under the given session namespace.
273    pub fn snapshot(
274        &mut self,
275        session: &str,
276        path: &Path,
277        description: &str,
278    ) -> Result<String, AftError> {
279        self.snapshot_with_op(session, path, description, None)
280    }
281
282    /// Snapshot the current contents of `path` under the given session namespace,
283    /// optionally tagging it with an operation id shared by all files touched by
284    /// one mutating tool call.
285    pub fn snapshot_with_op(
286        &mut self,
287        session: &str,
288        path: &Path,
289        description: &str,
290        op_id: Option<&str>,
291    ) -> Result<String, AftError> {
292        let key = canonicalize_key(path);
293        let (id, order) = self.next_id_and_order();
294        let entry = backup_entry_from_path(path, id.clone(), order, description, op_id)?;
295
296        let session_entries = self.entries.entry(session.to_string()).or_default();
297        let stack = session_entries.entry(key.clone()).or_default();
298        if stack.len() >= MAX_UNDO_DEPTH {
299            stack.remove(0);
300        }
301        stack.push(entry);
302
303        // Persist to disk
304        let stack_clone = stack.clone();
305        self.write_snapshot_to_disk(session, &key, &stack_clone);
306        self.touch_session(session);
307
308        Ok(id)
309    }
310
311    /// Record that `path` was created by the operation and should be removed
312    /// if that operation is undone. No file content is captured.
313    pub fn snapshot_op_tombstone(
314        &mut self,
315        session: &str,
316        op_id: &str,
317        path: &Path,
318        description: &str,
319    ) -> Result<String, AftError> {
320        let key = canonicalize_key(path);
321        let created_dirs = path.parent().map(missing_parent_dirs).unwrap_or_default();
322        let (id, order) = self.next_id_and_order();
323        let entry = BackupEntry {
324            backup_id: id.clone(),
325            content: String::new(),
326            content_bytes: Vec::new(),
327            timestamp: current_timestamp(),
328            order,
329            description: description.to_string(),
330            op_id: Some(op_id.to_string()),
331            kind: BackupEntryKind::Tombstone,
332            mode: None,
333            link_target: None,
334            created_dirs,
335        };
336
337        let session_entries = self.entries.entry(session.to_string()).or_default();
338        let stack = session_entries.entry(key.clone()).or_default();
339        if stack.len() >= MAX_UNDO_DEPTH {
340            stack.remove(0);
341        }
342        stack.push(entry);
343
344        let stack_clone = stack.clone();
345        self.write_snapshot_to_disk(session, &key, &stack_clone);
346        self.touch_session(session);
347
348        Ok(id)
349    }
350
351    /// Restore every top-of-stack backup entry belonging to the most recent
352    /// operation in this session.
353    pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
354        match self.load_latest_operation_from_db(session) {
355            Some(Ok(true)) => {}
356            Some(Ok(false)) => {
357                crate::slog_info!(
358                    "backup latest operation DB miss for session {}; falling back to disk",
359                    session
360                );
361                self.load_all_disk_backups(session);
362            }
363            Some(Err(error)) => {
364                crate::slog_warn!(
365                    "backup latest operation DB lookup failed for session {}; falling back to disk: {}",
366                    session,
367                    error
368                );
369                self.load_all_disk_backups(session);
370            }
371            None => {
372                crate::slog_info!(
373                    "backup latest operation DB unavailable for session {}; falling back to disk",
374                    session
375                );
376                self.load_all_disk_backups(session);
377            }
378        }
379
380        let mut latest: Option<(u128, String)> = None;
381        if let Some(files) = self.entries.get(session) {
382            for stack in files.values() {
383                if let Some(entry) = stack.last() {
384                    if let Some(op_id) = &entry.op_id {
385                        let order = entry.order;
386                        if latest
387                            .as_ref()
388                            .map_or(true, |(latest_order, _)| order > *latest_order)
389                        {
390                            latest = Some((order, op_id.clone()));
391                        }
392                    }
393                }
394            }
395        }
396
397        let Some((_, op_id)) = latest else {
398            return Err(AftError::NoUndoHistory {
399                path: "operation".to_string(),
400            });
401        };
402
403        let mut keys_to_restore: Vec<PathBuf> = self
404            .entries
405            .get(session)
406            .map(|files| {
407                files
408                    .iter()
409                    .filter_map(|(key, stack)| {
410                        stack.last().and_then(|entry| {
411                            (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
412                        })
413                    })
414                    .collect()
415            })
416            .unwrap_or_default();
417        keys_to_restore.sort();
418
419        if keys_to_restore.is_empty() {
420            return Err(AftError::NoUndoHistory {
421                path: "operation".to_string(),
422            });
423        }
424
425        let mut content_targets = Vec::new();
426        let mut tombstone_targets = Vec::new();
427        for key in &keys_to_restore {
428            let entry = self
429                .entries
430                .get(session)
431                .and_then(|files| files.get(key))
432                .and_then(|stack| stack.last())
433                .cloned()
434                .ok_or_else(|| AftError::NoUndoHistory {
435                    path: key.display().to_string(),
436                })?;
437            match entry.kind {
438                BackupEntryKind::Content | BackupEntryKind::Symlink => {
439                    let existing_state = capture_path_state(key)?;
440                    let warning = self.check_external_modification(session, key, key);
441                    content_targets.push((key.clone(), entry, warning, existing_state));
442                }
443                BackupEntryKind::Tombstone => {
444                    let existing_state = capture_path_state(key)?;
445                    tombstone_targets.push((key.clone(), entry, existing_state));
446                }
447            }
448        }
449
450        let mut created_dirs = Vec::new();
451        for (key, _, _, _) in &content_targets {
452            if let Some(parent) = key.parent() {
453                if !parent.as_os_str().is_empty() {
454                    let missing_dirs = missing_parent_dirs(parent);
455                    if let Err(e) = std::fs::create_dir_all(parent) {
456                        let mut dirs_to_remove = created_dirs;
457                        dirs_to_remove.extend(missing_dirs);
458                        let rollback_ok = rollback_created_dirs(&dirs_to_remove);
459                        return Err(AftError::IoError {
460                            path: parent.display().to_string(),
461                            message: format!(
462                                "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
463                                e,
464                                !rollback_ok,
465                                rollback_ok
466                            ),
467                        });
468                    }
469                    created_dirs.extend(missing_dirs);
470                }
471            }
472        }
473
474        let mut written = Vec::new();
475        for (key, entry, _, existing_state) in &content_targets {
476            if let Err(e) = restore_entry_to_path(key, entry) {
477                let files_rollback_ok =
478                    rollback_transactional_restore(&written, Some((key, existing_state)));
479                let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
480                let rollback_ok = files_rollback_ok && dirs_rollback_ok;
481                return Err(AftError::IoError {
482                    path: key.display().to_string(),
483                    message: format!(
484                        "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
485                        e,
486                        !rollback_ok,
487                        rollback_ok
488                    ),
489                });
490            }
491            written.push((key.clone(), existing_state.clone()));
492        }
493
494        let mut deleted_tombstones = Vec::new();
495        for (key, _, existing_state) in &tombstone_targets {
496            match remove_tombstone_path(key) {
497                Ok(()) => deleted_tombstones.push((key.clone(), existing_state.clone())),
498                Err(e) => {
499                    let files_rollback_ok = rollback_transactional_restore(&written, None);
500                    let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
501                    let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
502                    let rollback_ok =
503                        files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
504                    return Err(AftError::IoError {
505                        path: key.display().to_string(),
506                        message: format!(
507                            "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
508                            e,
509                            !rollback_ok,
510                            rollback_ok
511                        ),
512                    });
513                }
514            }
515        }
516        let tombstone_created_dirs = tombstone_targets
517            .iter()
518            .flat_map(|(_, entry, _)| entry.created_dirs.iter().cloned())
519            .collect::<Vec<_>>();
520        remove_created_dirs_best_effort(&tombstone_created_dirs);
521
522        let mut restored = Vec::new();
523        let mut warnings = Vec::new();
524        for (key, entry, warning, _) in content_targets {
525            self.commit_restored_backup(session, &key);
526            if let Some(warning) = warning {
527                warnings.push(format!("{}: {}", key.display(), warning));
528            }
529            restored.push(RestoredFile {
530                path: key,
531                backup_id: entry.backup_id,
532            });
533        }
534        for (key, _, _) in tombstone_targets {
535            self.commit_restored_backup(session, &key);
536        }
537        self.touch_session(session);
538
539        Ok(RestoredOperation {
540            op_id,
541            restored,
542            warnings,
543        })
544    }
545
546    /// Pop the most recent backup for `(session, path)` and restore the file.
547    /// Returns `(entry, optional_warning)`.
548    pub fn restore_latest(
549        &mut self,
550        session: &str,
551        path: &Path,
552    ) -> Result<(BackupEntry, Option<String>), AftError> {
553        let key = canonicalize_key(path);
554
555        match self.load_from_db_if_present(session, &key) {
556            Some(Ok(true)) => {
557                let warning = self.check_external_modification(session, &key, path);
558                let result = self
559                    .do_restore(session, &key, path)
560                    .map(|(entry, _)| (entry, warning));
561                if result.is_ok() {
562                    self.touch_session(session);
563                }
564                return result;
565            }
566            Some(Ok(false)) => {
567                crate::slog_info!(
568                    "backup DB miss for session {} path {}; falling back to disk",
569                    session,
570                    key.display()
571                );
572            }
573            Some(Err(error)) => {
574                crate::slog_warn!(
575                    "backup DB lookup failed for session {} path {}; falling back to disk: {}",
576                    session,
577                    key.display(),
578                    error
579                );
580            }
581            None => {
582                crate::slog_info!(
583                    "backup DB unavailable for session {} path {}; falling back to disk",
584                    session,
585                    key.display()
586                );
587            }
588        }
589
590        // Try memory first
591        let in_memory = self
592            .entries
593            .get(session)
594            .and_then(|s| s.get(&key))
595            .map_or(false, |s| !s.is_empty());
596        if in_memory {
597            let warning = self.check_external_modification(session, &key, path);
598            let result = self
599                .do_restore(session, &key, path)
600                .map(|(entry, _)| (entry, warning));
601            if result.is_ok() {
602                self.touch_session(session);
603            }
604            return result;
605        }
606
607        // Try disk fallback
608        if self.load_from_disk_if_needed(session, &key) {
609            // Check for external modification
610            let warning = self.check_external_modification(session, &key, path);
611            let (entry, _) = self.do_restore(session, &key, path)?;
612            self.touch_session(session);
613            return Ok((entry, warning));
614        }
615
616        Err(AftError::NoUndoHistory {
617            path: path.display().to_string(),
618        })
619    }
620
621    /// Return the backup history for `(session, path)` (oldest first).
622    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
623        let key = canonicalize_key(path);
624        match self.read_stack_from_db(session, &key) {
625            Some(Ok(stack)) if !stack.is_empty() => return stack,
626            Some(Ok(_)) => {
627                crate::slog_info!(
628                    "backup history DB miss for session {} path {}; falling back to disk",
629                    session,
630                    key.display()
631                );
632            }
633            Some(Err(error)) => {
634                crate::slog_warn!(
635                    "backup history DB lookup failed for session {} path {}; falling back to disk: {}",
636                    session,
637                    key.display(),
638                    error
639                );
640            }
641            None => {
642                crate::slog_info!(
643                    "backup history DB unavailable for session {} path {}; falling back to disk",
644                    session,
645                    key.display()
646                );
647            }
648        }
649
650        self.entries
651            .get(session)
652            .and_then(|s| s.get(&key))
653            .cloned()
654            .or_else(|| self.read_stack_from_disk(session, &key))
655            .unwrap_or_default()
656    }
657
658    /// Return the number of on-disk backup entries for `(session, file)`.
659    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
660        let key = canonicalize_key(path);
661        self.disk_index
662            .get(session)
663            .and_then(|s| s.get(&key))
664            .map(|m| m.count)
665            .unwrap_or(0)
666    }
667
668    /// Return all files that have at least one backup entry in this session
669    /// (memory + disk). Other sessions' files are not visible.
670    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
671        let mut files: std::collections::HashSet<PathBuf> = self
672            .entries
673            .get(session)
674            .map(|s| s.keys().cloned().collect())
675            .unwrap_or_default();
676        if let Some(disk) = self.disk_index.get(session) {
677            for key in disk.keys() {
678                files.insert(key.clone());
679            }
680        }
681        files.into_iter().collect()
682    }
683
684    /// Return all session namespaces that currently have any backup state
685    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
686    pub fn sessions_with_backups(&self) -> Vec<String> {
687        let mut sessions: std::collections::HashSet<String> =
688            self.entries.keys().cloned().collect();
689        for s in self.disk_index.keys() {
690            sessions.insert(s.clone());
691        }
692        sessions.into_iter().collect()
693    }
694
695    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
696    /// Used by `/aft-status` to surface storage footprint.
697    pub fn total_disk_bytes(&self) -> u64 {
698        let mut total = 0u64;
699        for session_dirs in self.disk_index.values() {
700            for meta in session_dirs.values() {
701                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
702                    for entry in read_dir.flatten() {
703                        if let Ok(m) = entry.metadata() {
704                            if m.is_file() {
705                                total += m.len();
706                            }
707                        }
708                    }
709                }
710            }
711        }
712        total
713    }
714
715    fn next_id_and_order(&self) -> (String, u128) {
716        let n = self.counter.fetch_add(1, Ordering::Relaxed);
717        let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
718        (format!("backup-{}", n), order)
719    }
720
721    fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
722        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
723        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
724        Some((pool, harness))
725    }
726
727    fn read_stack_from_db(
728        &self,
729        session: &str,
730        key: &Path,
731    ) -> Option<Result<Vec<BackupEntry>, String>> {
732        let (pool, harness) = self.db_pool_and_harness()?;
733        let conn = match pool.lock() {
734            Ok(conn) => conn,
735            Err(_) => return Some(Err("db mutex poisoned".to_string())),
736        };
737        let path_hash = Self::path_hash(key);
738        Some(
739            crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
740                .map_err(|error| error.to_string())
741                .and_then(|rows| {
742                    rows.into_iter()
743                        .map(BackupEntry::try_from)
744                        .collect::<Result<Vec<_>, _>>()
745                        .map_err(|error| error.to_string())
746                }),
747        )
748    }
749
750    fn load_from_db_if_present(
751        &mut self,
752        session: &str,
753        key: &Path,
754    ) -> Option<Result<bool, String>> {
755        match self.read_stack_from_db(session, key) {
756            Some(Ok(stack)) if !stack.is_empty() => {
757                self.update_counter_from_entries(&stack);
758                self.entries
759                    .entry(session.to_string())
760                    .or_default()
761                    .insert(key.to_path_buf(), stack);
762                Some(Ok(true))
763            }
764            Some(Ok(_)) => Some(Ok(false)),
765            Some(Err(error)) => Some(Err(error)),
766            None => None,
767        }
768    }
769
770    fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
771        let (pool, harness) = self.db_pool_and_harness()?;
772        let conn = match pool.lock() {
773            Ok(conn) => conn,
774            Err(_) => return Some(Err("db mutex poisoned".to_string())),
775        };
776        let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
777        {
778            Ok(Some(row)) => row,
779            Ok(None) => return Some(Ok(false)),
780            Err(error) => return Some(Err(error.to_string())),
781        };
782        let Some(op_id) = latest.op_id else {
783            return Some(Ok(false));
784        };
785        let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
786            Ok(rows) => rows,
787            Err(error) => return Some(Err(error.to_string())),
788        };
789        if rows.is_empty() {
790            return Some(Ok(false));
791        }
792        let path_hashes: std::collections::HashSet<String> =
793            rows.into_iter().map(|row| row.path_hash).collect();
794        drop(conn);
795
796        let mut loaded_any = false;
797        for path_hash in path_hashes {
798            let conn = match pool.lock() {
799                Ok(conn) => conn,
800                Err(_) => return Some(Err("db mutex poisoned".to_string())),
801            };
802            let loaded =
803                match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
804                    Ok(rows) => {
805                        let file_path = rows.first().map(|row| row.file_path.clone());
806                        rows.into_iter()
807                            .map(BackupEntry::try_from)
808                            .collect::<Result<Vec<_>, _>>()
809                            .map(|stack| (file_path, stack))
810                            .map_err(|error| error.to_string())
811                    }
812                    Err(error) => Err(error.to_string()),
813                };
814            drop(conn);
815            let (file_path, stack) = match loaded {
816                Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
817                Ok(_) => continue,
818                Err(error) => return Some(Err(error)),
819            };
820            let Some(file_path) = file_path else {
821                return Some(Err(format!(
822                    "backup DB rows for path hash {path_hash} have no file path"
823                )));
824            };
825            let key = PathBuf::from(file_path);
826            self.update_counter_from_entries(&stack);
827            self.entries
828                .entry(session.to_string())
829                .or_default()
830                .insert(key, stack);
831            loaded_any = true;
832        }
833
834        Some(Ok(loaded_any))
835    }
836
837    fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
838        if let Some(next_counter) = entries
839            .iter()
840            .filter_map(|entry| backup_sequence(&entry.backup_id))
841            .max()
842            .and_then(|max| max.checked_add(1))
843        {
844            let _ = self
845                .counter
846                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
847                    (current < next_counter).then_some(next_counter)
848                });
849        }
850    }
851
852    pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
853        let keys: Vec<PathBuf> = self
854            .entries
855            .get(session)
856            .map(|files| files.keys().cloned().collect())
857            .unwrap_or_default();
858
859        for key in keys {
860            let mut remove_key = false;
861            let mut remaining_stack = None;
862            if let Some(session_entries) = self.entries.get_mut(session) {
863                if let Some(stack) = session_entries.get_mut(&key) {
864                    while stack
865                        .last()
866                        .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
867                    {
868                        stack.pop();
869                    }
870                    if stack.is_empty() {
871                        remove_key = true;
872                    } else {
873                        remaining_stack = Some(stack.clone());
874                    }
875                }
876                if remove_key {
877                    session_entries.remove(&key);
878                }
879            }
880
881            if remove_key {
882                self.remove_disk_backups(session, &key);
883            } else if let Some(stack) = remaining_stack {
884                self.write_snapshot_to_disk(session, &key, &stack);
885            }
886        }
887
888        if self
889            .entries
890            .get(session)
891            .is_some_and(|session_entries| session_entries.is_empty())
892        {
893            self.entries.remove(session);
894        }
895    }
896
897    fn touch_session(&mut self, session: &str) {
898        let now = current_timestamp();
899        self.session_meta
900            .entry(session.to_string())
901            .or_default()
902            .last_accessed = now;
903        self.write_session_marker(session, now);
904    }
905
906    // ---- Internal helpers ----
907
908    fn do_restore(
909        &mut self,
910        session: &str,
911        key: &Path,
912        path: &Path,
913    ) -> Result<(BackupEntry, Option<String>), AftError> {
914        let session_entries =
915            self.entries
916                .get_mut(session)
917                .ok_or_else(|| AftError::NoUndoHistory {
918                    path: path.display().to_string(),
919                })?;
920        let stack = session_entries
921            .get_mut(key)
922            .ok_or_else(|| AftError::NoUndoHistory {
923                path: path.display().to_string(),
924            })?;
925
926        let entry = stack
927            .last()
928            .cloned()
929            .ok_or_else(|| AftError::NoUndoHistory {
930                path: path.display().to_string(),
931            })?;
932
933        match entry.kind {
934            BackupEntryKind::Content | BackupEntryKind::Symlink => {
935                restore_entry_to_path(path, &entry).map_err(|e| AftError::IoError {
936                    path: path.display().to_string(),
937                    message: e.to_string(),
938                })?;
939            }
940            BackupEntryKind::Tombstone => {
941                remove_tombstone_path(path).map_err(|e| AftError::IoError {
942                    path: path.display().to_string(),
943                    message: e.to_string(),
944                })?;
945                remove_created_dirs_best_effort(&entry.created_dirs);
946            }
947        }
948
949        stack.pop();
950        if stack.is_empty() {
951            session_entries.remove(key);
952            // Also prune the session map when its last file is gone.
953            if session_entries.is_empty() {
954                self.entries.remove(session);
955            }
956            self.remove_disk_backups(session, key);
957        } else {
958            let stack_clone = self
959                .entries
960                .get(session)
961                .and_then(|s| s.get(key))
962                .cloned()
963                .unwrap_or_default();
964            self.write_snapshot_to_disk(session, key, &stack_clone);
965        }
966
967        Ok((entry, None))
968    }
969
970    fn commit_restored_backup(&mut self, session: &str, key: &Path) {
971        let mut remove_key = false;
972        let mut remove_session = false;
973        let mut remaining_stack = None;
974
975        if let Some(session_entries) = self.entries.get_mut(session) {
976            if let Some(stack) = session_entries.get_mut(key) {
977                stack.pop();
978                if stack.is_empty() {
979                    remove_key = true;
980                } else {
981                    remaining_stack = Some(stack.clone());
982                }
983            }
984
985            if remove_key {
986                session_entries.remove(key);
987                remove_session = session_entries.is_empty();
988            }
989        }
990
991        if remove_session {
992            self.entries.remove(session);
993        }
994
995        if remove_key {
996            self.remove_disk_backups(session, key);
997        } else if let Some(stack) = remaining_stack {
998            self.write_snapshot_to_disk(session, key, &stack);
999        }
1000    }
1001
1002    fn check_external_modification(
1003        &self,
1004        session: &str,
1005        key: &Path,
1006        path: &Path,
1007    ) -> Option<String> {
1008        let stack = self.entries.get(session).and_then(|s| s.get(key))?;
1009        let latest = stack.last()?;
1010        let modified = match latest.kind {
1011            BackupEntryKind::Content => std::fs::read(path)
1012                .map(|current| current != latest.content_bytes)
1013                .unwrap_or(true),
1014            BackupEntryKind::Symlink => std::fs::read_link(path)
1015                .map(|target| latest.link_target.as_ref() != Some(&target))
1016                .unwrap_or(true),
1017            BackupEntryKind::Tombstone => false,
1018        };
1019        modified.then(|| "file was modified externally since last backup".to_string())
1020    }
1021
1022    // ---- Disk persistence ----
1023
1024    fn backups_dir(&self) -> Option<PathBuf> {
1025        self.storage_dir
1026            .as_ref()
1027            .map(|dir| match &self.storage_harness {
1028                Some(harness) => dir.join(harness).join("backups"),
1029                None => dir.join("backups"),
1030            })
1031    }
1032
1033    fn session_dir(&self, session: &str) -> Option<PathBuf> {
1034        self.backups_dir()
1035            .map(|d| d.join(Self::session_hash(session)))
1036    }
1037
1038    fn session_hash(session: &str) -> String {
1039        hash_session(session)
1040    }
1041
1042    fn path_hash(key: &Path) -> String {
1043        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
1044        // stable on-disk names. Existing DefaultHasher backup directories are
1045        // not migrated: backups are short-lived/session-scoped, so one-time
1046        // loss of pre-upgrade undo history is acceptable.
1047        stable_hash_16(key.to_string_lossy().as_bytes())
1048    }
1049
1050    fn write_session_marker(&self, session: &str, last_accessed: u64) {
1051        let Some(session_dir) = self.session_dir(session) else {
1052            return;
1053        };
1054        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1055            crate::slog_warn!("failed to create session dir: {}", e);
1056            return;
1057        }
1058        let marker = session_dir.join("session.json");
1059        let json = serde_json::json!({
1060            "schema_version": SCHEMA_VERSION,
1061            "session_id": session,
1062            "last_accessed": last_accessed,
1063        });
1064        if let Ok(s) = serde_json::to_string_pretty(&json) {
1065            let tmp = session_dir.join("session.json.tmp");
1066            if std::fs::write(&tmp, s).is_ok() {
1067                let _ = std::fs::rename(&tmp, marker);
1068            }
1069        }
1070    }
1071
1072    fn repair_root_backups_if_needed(&self) {
1073        let (Some(storage_dir), Some(harness)) = (&self.storage_dir, &self.storage_harness) else {
1074            return;
1075        };
1076        let root_backups = storage_dir.join("backups");
1077        if !dir_has_entries(&root_backups) {
1078            return;
1079        }
1080        let harness_backups = storage_dir.join(harness).join("backups");
1081        if dir_has_entries(&harness_backups) {
1082            return;
1083        }
1084        if let Some(parent) = harness_backups.parent() {
1085            if let Err(error) = std::fs::create_dir_all(parent) {
1086                crate::slog_warn!(
1087                    "failed to create harness backup dir {}: {}",
1088                    parent.display(),
1089                    error
1090                );
1091                return;
1092            }
1093        }
1094        if harness_backups.exists() {
1095            let _ = std::fs::remove_dir(&harness_backups);
1096        }
1097        match std::fs::rename(&root_backups, &harness_backups) {
1098            Ok(()) => {
1099                crate::slog_info!(
1100                    "moved legacy root backups into harness namespace: {}",
1101                    harness_backups.display()
1102                );
1103            }
1104            Err(error) => {
1105                crate::slog_warn!(
1106                    "failed to move legacy root backups into {}: {}; trying child merge",
1107                    harness_backups.display(),
1108                    error
1109                );
1110                if std::fs::create_dir_all(&harness_backups).is_err() {
1111                    return;
1112                }
1113                if let Ok(entries) = std::fs::read_dir(&root_backups) {
1114                    for entry in entries.flatten() {
1115                        let source = entry.path();
1116                        let target = harness_backups.join(entry.file_name());
1117                        if !target.exists() {
1118                            let _ = std::fs::rename(source, target);
1119                        }
1120                    }
1121                }
1122                let _ = std::fs::remove_dir(&root_backups);
1123            }
1124        }
1125    }
1126
1127    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1128        let backups_dir = match self.backups_dir() {
1129            Some(d) if d.exists() => d,
1130            _ => return,
1131        };
1132        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1133        let cutoff = current_timestamp().saturating_sub(ttl_secs);
1134        let entries = match std::fs::read_dir(&backups_dir) {
1135            Ok(entries) => entries,
1136            Err(_) => return,
1137        };
1138
1139        for entry in entries.flatten() {
1140            let session_dir = entry.path();
1141            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1142                continue;
1143            }
1144            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1145                continue;
1146            };
1147            if last_accessed >= cutoff {
1148                continue;
1149            }
1150            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1151                crate::slog_warn!(
1152                    "failed to remove stale backup session {}: {}",
1153                    session_dir.display(),
1154                    e
1155                );
1156            } else {
1157                crate::slog_warn!(
1158                    "removed stale backup session {} (last_accessed={})",
1159                    session_dir.display(),
1160                    last_accessed
1161                );
1162            }
1163        }
1164    }
1165
1166    /// One-time migration: move pre-session flat layout into the default
1167    /// session namespace. Called from `set_storage_dir` so existing backups
1168    /// survive the upgrade.
1169    ///
1170    /// Detection: any directory directly under `backups/` that contains a
1171    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
1172    /// is treated as a legacy entry.
1173    fn migrate_legacy_layout_if_needed(&mut self) {
1174        let backups_dir = match self.backups_dir() {
1175            Some(d) if d.exists() => d,
1176            _ => return,
1177        };
1178        let default_session_dir =
1179            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1180
1181        let entries = match std::fs::read_dir(&backups_dir) {
1182            Ok(e) => e,
1183            Err(_) => return,
1184        };
1185        let mut migrated = 0usize;
1186        for entry in entries.flatten() {
1187            let entry_path = entry.path();
1188            // Skip non-directories and already-sessionized layouts.
1189            if !entry_path.is_dir() {
1190                continue;
1191            }
1192            if entry_path == default_session_dir {
1193                continue;
1194            }
1195            let meta_path = entry_path.join("meta.json");
1196            if !meta_path.exists() {
1197                continue; // Already a session-hash dir (contains per-path subdirs), skip
1198            }
1199            // This is a legacy flat-layout path-hash directory. Move it under
1200            // the default session namespace.
1201            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1202                crate::slog_warn!("failed to create default session dir: {}", e);
1203                return;
1204            }
1205            let leaf = match entry_path.file_name() {
1206                Some(n) => n,
1207                None => continue,
1208            };
1209            let target = default_session_dir.join(leaf);
1210            if target.exists() {
1211                // Already migrated on a prior run that was interrupted —
1212                // leave both and let the regular load pick up the target.
1213                continue;
1214            }
1215            match std::fs::rename(&entry_path, &target) {
1216                Ok(()) => {
1217                    // Bump meta.json to include session_id + schema_version.
1218                    Self::upgrade_meta_file(
1219                        &target.join("meta.json"),
1220                        crate::protocol::DEFAULT_SESSION_ID,
1221                    );
1222                    migrated += 1;
1223                }
1224                Err(e) => {
1225                    crate::slog_warn!(
1226                        "failed to migrate legacy backup {}: {}",
1227                        entry_path.display(),
1228                        e
1229                    );
1230                }
1231            }
1232        }
1233        if migrated > 0 {
1234            crate::slog_info!(
1235                "migrated {} legacy backup entries into default session namespace",
1236                migrated
1237            );
1238            // Write a session.json marker so future scans don't re-migrate.
1239            let marker = default_session_dir.join("session.json");
1240            let json = serde_json::json!({
1241                "schema_version": SCHEMA_VERSION,
1242                "session_id": crate::protocol::DEFAULT_SESSION_ID,
1243                "last_accessed": current_timestamp(),
1244            });
1245            if let Ok(s) = serde_json::to_string_pretty(&json) {
1246                let _ = std::fs::write(&marker, s);
1247            }
1248        }
1249    }
1250
1251    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1252        let content = match std::fs::read_to_string(meta_path) {
1253            Ok(c) => c,
1254            Err(_) => return,
1255        };
1256        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1257            Ok(v) => v,
1258            Err(_) => return,
1259        };
1260        if let Some(obj) = parsed.as_object_mut() {
1261            let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1262            obj.insert(
1263                "schema_version".to_string(),
1264                serde_json::json!(SCHEMA_VERSION),
1265            );
1266            obj.insert("session_id".to_string(), serde_json::json!(session_id));
1267            obj.entry("entries").or_insert_with(|| {
1268                serde_json::Value::Array(
1269                    (0..count)
1270                        .map(|i| {
1271                            serde_json::json!({
1272                                "backup_id": format!("disk-{}", i),
1273                                "timestamp": 0,
1274                                "description": "restored from disk",
1275                                "op_id": null,
1276                            })
1277                        })
1278                        .collect(),
1279                )
1280            });
1281        }
1282        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1283            let tmp = meta_path.with_extension("json.tmp");
1284            if std::fs::write(&tmp, &s).is_ok() {
1285                let _ = std::fs::rename(&tmp, meta_path);
1286            }
1287        }
1288    }
1289
1290    fn load_disk_index(&mut self) {
1291        let backups_dir = match self.backups_dir() {
1292            Some(d) if d.exists() => d,
1293            _ => return,
1294        };
1295        let session_dirs = match std::fs::read_dir(&backups_dir) {
1296            Ok(e) => e,
1297            Err(_) => return,
1298        };
1299        let mut total_entries = 0usize;
1300        for session_entry in session_dirs.flatten() {
1301            let session_dir = session_entry.path();
1302            if !session_dir.is_dir() {
1303                continue;
1304            }
1305            // Recover the session_id from session.json if present, otherwise skip
1306            // (can't invert the hash to recover the original).
1307            let session_id = match Self::read_session_marker(&session_dir) {
1308                Some(session_id) => session_id,
1309                None => {
1310                    crate::slog_warn!(
1311                        "skipping backup session dir without readable session marker: {}",
1312                        session_dir.display()
1313                    );
1314                    continue;
1315                }
1316            };
1317
1318            let path_dirs = match std::fs::read_dir(&session_dir) {
1319                Ok(e) => e,
1320                Err(_) => continue,
1321            };
1322            let per_session = self.disk_index.entry(session_id.clone()).or_default();
1323            for path_entry in path_dirs.flatten() {
1324                let path_dir = path_entry.path();
1325                if !path_dir.is_dir() {
1326                    continue;
1327                }
1328                let meta_path = path_dir.join("meta.json");
1329                if let Ok(content) = std::fs::read_to_string(&meta_path) {
1330                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1331                        if let (Some(path_str), Some(count)) = (
1332                            meta.get("path").and_then(|v| v.as_str()),
1333                            meta.get("count").and_then(|v| v.as_u64()),
1334                        ) {
1335                            let key = PathBuf::from(path_str);
1336                            if !is_loadable_backup_path(&key, &path_dir) {
1337                                crate::slog_warn!(
1338                                    "skipping backup entry with invalid path metadata: {}",
1339                                    meta_path.display()
1340                                );
1341                                continue;
1342                            }
1343                            per_session.insert(
1344                                key,
1345                                DiskMeta {
1346                                    dir: path_dir.clone(),
1347                                    count: count as usize,
1348                                },
1349                            );
1350                            total_entries += 1;
1351                        }
1352                    }
1353                }
1354            }
1355            if per_session.is_empty() {
1356                self.disk_index.remove(&session_id);
1357            }
1358        }
1359        if total_entries > 0 {
1360            crate::slog_info!(
1361                "loaded {} backup entries across {} session(s) from disk",
1362                total_entries,
1363                self.disk_index.len()
1364            );
1365        }
1366    }
1367
1368    fn read_session_marker(session_dir: &Path) -> Option<String> {
1369        let marker = session_dir.join("session.json");
1370        let content = std::fs::read_to_string(&marker).ok()?;
1371        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1372        parsed
1373            .get("session_id")
1374            .and_then(|v| v.as_str())
1375            .map(|s| s.to_string())
1376    }
1377
1378    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1379        let marker = session_dir.join("session.json");
1380        let content = std::fs::read_to_string(&marker).ok()?;
1381        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1382        parsed.get("last_accessed").and_then(|v| v.as_u64())
1383    }
1384
1385    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
1386        let Some(entries) = self.read_stack_from_disk(session, key) else {
1387            return false;
1388        };
1389
1390        self.update_counter_from_entries(&entries);
1391
1392        self.entries
1393            .entry(session.to_string())
1394            .or_default()
1395            .insert(key.to_path_buf(), entries);
1396        true
1397    }
1398
1399    fn load_all_disk_backups(&mut self, session: &str) {
1400        let disk_keys: Vec<PathBuf> = self
1401            .disk_index
1402            .get(session)
1403            .map(|files| files.keys().cloned().collect())
1404            .unwrap_or_default();
1405        for key in disk_keys {
1406            self.load_from_disk_if_needed(session, &key);
1407        }
1408    }
1409
1410    fn read_stack_from_disk(&self, session: &str, key: &Path) -> Option<Vec<BackupEntry>> {
1411        let disk_meta = match self
1412            .disk_index
1413            .get(session)
1414            .and_then(|s| s.get(key))
1415            .cloned()
1416        {
1417            Some(m) if m.count > 0 => m,
1418            _ => return None,
1419        };
1420
1421        let mut entries = Vec::new();
1422        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1423            .ok()
1424            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1425            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1426            .unwrap_or_default();
1427
1428        for i in 0..disk_meta.count {
1429            let meta = entry_meta.get(i);
1430            let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1431                Some("tombstone") => BackupEntryKind::Tombstone,
1432                Some("symlink") => BackupEntryKind::Symlink,
1433                _ => BackupEntryKind::Content,
1434            };
1435            let content_bytes = match kind {
1436                BackupEntryKind::Content | BackupEntryKind::Symlink => {
1437                    let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1438                    match std::fs::read(&bak_path) {
1439                        Ok(content) => content,
1440                        Err(_) => continue,
1441                    }
1442                }
1443                BackupEntryKind::Tombstone => Vec::new(),
1444            };
1445            let link_target = if kind == BackupEntryKind::Symlink {
1446                meta.and_then(|m| m.get("link_target"))
1447                    .and_then(|v| v.as_str())
1448                    .map(PathBuf::from)
1449                    .or_else(|| {
1450                        Some(PathBuf::from(
1451                            String::from_utf8_lossy(&content_bytes).into_owned(),
1452                        ))
1453                    })
1454            } else {
1455                None
1456            };
1457            let content = match kind {
1458                BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
1459                BackupEntryKind::Symlink => link_target
1460                    .as_ref()
1461                    .map(|target| target.display().to_string())
1462                    .unwrap_or_default(),
1463                BackupEntryKind::Tombstone => String::new(),
1464            };
1465            let backup_id = meta
1466                .and_then(|m| m.get("backup_id"))
1467                .and_then(|v| v.as_str())
1468                .map(str::to_string)
1469                .unwrap_or_else(|| format!("disk-{}", i));
1470            let timestamp = meta
1471                .and_then(|m| m.get("timestamp"))
1472                .and_then(|v| v.as_u64())
1473                .unwrap_or(0);
1474            let order = meta
1475                .and_then(|m| m.get("order"))
1476                .and_then(parse_order_value)
1477                .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1478            entries.push(BackupEntry {
1479                backup_id,
1480                content,
1481                content_bytes,
1482                timestamp,
1483                order,
1484                description: meta
1485                    .and_then(|m| m.get("description"))
1486                    .and_then(|v| v.as_str())
1487                    .unwrap_or("restored from disk")
1488                    .to_string(),
1489                op_id: meta
1490                    .and_then(|m| m.get("op_id"))
1491                    .and_then(|v| v.as_str())
1492                    .map(str::to_string),
1493                kind,
1494                mode: meta
1495                    .and_then(|m| m.get("mode"))
1496                    .and_then(|v| v.as_u64())
1497                    .and_then(|mode| u32::try_from(mode).ok()),
1498                link_target,
1499                created_dirs: meta
1500                    .and_then(|m| m.get("created_dirs"))
1501                    .and_then(|v| v.as_array())
1502                    .map(|dirs| {
1503                        dirs.iter()
1504                            .filter_map(|dir| dir.as_str())
1505                            .map(PathBuf::from)
1506                            .collect()
1507                    })
1508                    .unwrap_or_default(),
1509            });
1510        }
1511
1512        if entries.is_empty() {
1513            return None;
1514        }
1515        Some(entries)
1516    }
1517
1518    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1519        let session_dir = match self.session_dir(session) {
1520            Some(d) => d,
1521            None => return,
1522        };
1523
1524        // Ensure session dir + marker exist.
1525        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1526            crate::slog_warn!("failed to create session dir: {}", e);
1527            return;
1528        }
1529        let marker = session_dir.join("session.json");
1530        if !marker.exists() {
1531            let json = serde_json::json!({
1532                "schema_version": SCHEMA_VERSION,
1533                "session_id": session,
1534                "last_accessed": current_timestamp(),
1535            });
1536            if let Ok(s) = serde_json::to_string_pretty(&json) {
1537                let _ = std::fs::write(&marker, s);
1538            }
1539        }
1540
1541        let hash = Self::path_hash(key);
1542        let dir = session_dir.join(&hash);
1543        if let Err(e) = std::fs::create_dir_all(&dir) {
1544            crate::slog_warn!("failed to create backup dir: {}", e);
1545            return;
1546        }
1547
1548        for (i, entry) in stack.iter().enumerate() {
1549            let bak_path = dir.join(format!("{}.bak", i));
1550            let tmp_path = dir.join(format!("{}.bak.tmp", i));
1551            match entry.kind {
1552                BackupEntryKind::Content => {
1553                    if std::fs::write(&tmp_path, &entry.content_bytes).is_ok() {
1554                        let _ = std::fs::rename(&tmp_path, &bak_path);
1555                    }
1556                }
1557                BackupEntryKind::Symlink => {
1558                    let target = entry
1559                        .link_target
1560                        .as_ref()
1561                        .map(|target| target.as_os_str().to_string_lossy().as_bytes().to_vec())
1562                        .unwrap_or_default();
1563                    if std::fs::write(&tmp_path, target).is_ok() {
1564                        let _ = std::fs::rename(&tmp_path, &bak_path);
1565                    }
1566                }
1567                BackupEntryKind::Tombstone => {
1568                    let _ = std::fs::remove_file(&bak_path);
1569                    let _ = std::fs::remove_file(&tmp_path);
1570                }
1571            }
1572        }
1573
1574        // Clean up extra .bak files if stack shrank.
1575        for i in stack.len()..MAX_UNDO_DEPTH {
1576            let old = dir.join(format!("{}.bak", i));
1577            if old.exists() {
1578                let _ = std::fs::remove_file(&old);
1579            }
1580        }
1581
1582        let entries: Vec<serde_json::Value> = stack
1583            .iter()
1584            .map(|entry| {
1585                serde_json::json!({
1586                    "backup_id": entry.backup_id,
1587                    "timestamp": entry.timestamp,
1588                    "order": entry.order.to_string(),
1589                    "description": entry.description,
1590                    "op_id": entry.op_id,
1591                    "kind": match entry.kind {
1592                        BackupEntryKind::Content => "content",
1593                        BackupEntryKind::Symlink => "symlink",
1594                        BackupEntryKind::Tombstone => "tombstone",
1595                    },
1596                    "mode": entry.mode,
1597                    "link_target": entry.link_target.as_ref().map(|target| target.display().to_string()),
1598                    "created_dirs": entry
1599                        .created_dirs
1600                        .iter()
1601                        .map(|dir| dir.display().to_string())
1602                        .collect::<Vec<_>>(),
1603                })
1604            })
1605            .collect();
1606        let meta = serde_json::json!({
1607            "schema_version": SCHEMA_VERSION,
1608            "session_id": session,
1609            "path": key.display().to_string(),
1610            "count": stack.len(),
1611            "entries": entries,
1612        });
1613        let meta_path = dir.join("meta.json");
1614        let meta_tmp = dir.join("meta.json.tmp");
1615        if let Ok(content) = serde_json::to_string_pretty(&meta) {
1616            if std::fs::write(&meta_tmp, &content).is_ok() {
1617                let _ = std::fs::rename(&meta_tmp, &meta_path);
1618            }
1619        }
1620
1621        // Keep the in-memory disk_index in sync so tracked_files() and
1622        // disk_history_count() immediately reflect what we just wrote.
1623        self.disk_index
1624            .entry(session.to_string())
1625            .or_default()
1626            .insert(
1627                key.to_path_buf(),
1628                DiskMeta {
1629                    dir: dir.clone(),
1630                    count: stack.len(),
1631                },
1632            );
1633        self.dual_write_stack_to_db(session, key, &dir, stack);
1634    }
1635
1636    fn dual_write_stack_to_db(&self, session: &str, key: &Path, dir: &Path, stack: &[BackupEntry]) {
1637        let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
1638        let Some(pool) = pool else {
1639            return;
1640        };
1641        let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
1642        let Some(harness) = harness else {
1643            crate::slog_warn!(
1644                "dual-write backup to DB skipped for {}: harness not configured",
1645                key.display()
1646            );
1647            return;
1648        };
1649        let project_key = self
1650            .db_project_key
1651            .read()
1652            .ok()
1653            .and_then(|slot| slot.clone());
1654        let Some(project_key) = project_key else {
1655            crate::slog_warn!(
1656                "dual-write backup to DB skipped for {}: project key not configured",
1657                key.display()
1658            );
1659            return;
1660        };
1661
1662        let conn = match pool.lock() {
1663            Ok(conn) => conn,
1664            Err(_) => {
1665                crate::slog_warn!(
1666                    "dual-write backup to DB failed for {}: db mutex poisoned",
1667                    key.display()
1668                );
1669                return;
1670            }
1671        };
1672        let path_hash = Self::path_hash(key);
1673        let file_path = key.display().to_string();
1674        if let Err(error) =
1675            crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1676        {
1677            crate::slog_warn!(
1678                "delete old backup DB rows failed for {}: {}",
1679                key.display(),
1680                error
1681            );
1682            return;
1683        }
1684        for (index, entry) in stack.iter().enumerate() {
1685            let backup_path = match entry.kind {
1686                BackupEntryKind::Content | BackupEntryKind::Symlink => {
1687                    Some(dir.join(format!("{}.bak", index)).display().to_string())
1688                }
1689                BackupEntryKind::Tombstone => Some(dir.join("meta.json").display().to_string()),
1690            };
1691            let row = entry.to_backup_row(
1692                &harness,
1693                session,
1694                &project_key,
1695                &file_path,
1696                &path_hash,
1697                backup_path.as_deref(),
1698            );
1699            if let Err(error) = crate::db::backups::upsert_backup(&conn, &row) {
1700                crate::slog_warn!(
1701                    "dual-write backup to DB failed for {}: {}",
1702                    entry.backup_id,
1703                    error
1704                );
1705            }
1706        }
1707    }
1708
1709    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
1710        self.remove_db_backups(session, key);
1711        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
1712        if let Some(meta) = removed {
1713            let _ = std::fs::remove_dir_all(&meta.dir);
1714        } else if let Some(session_dir) = self.session_dir(session) {
1715            let hash = Self::path_hash(key);
1716            let dir = session_dir.join(&hash);
1717            if dir.exists() {
1718                let _ = std::fs::remove_dir_all(&dir);
1719            }
1720        }
1721
1722        // If this session has no more disk entries, drop the map slot (session
1723        // dir itself is kept so the marker survives future sessions).
1724        let empty = self
1725            .disk_index
1726            .get(session)
1727            .map(|s| s.is_empty())
1728            .unwrap_or(false);
1729        if empty {
1730            self.disk_index.remove(session);
1731        }
1732    }
1733
1734    fn remove_db_backups(&self, session: &str, key: &Path) {
1735        let Some((pool, harness)) = self.db_pool_and_harness() else {
1736            return;
1737        };
1738        let conn = match pool.lock() {
1739            Ok(conn) => conn,
1740            Err(_) => {
1741                crate::slog_warn!(
1742                    "delete backup DB rows failed for {}: db mutex poisoned",
1743                    key.display()
1744                );
1745                return;
1746            }
1747        };
1748        let path_hash = Self::path_hash(key);
1749        if let Err(error) =
1750            crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1751        {
1752            crate::slog_warn!(
1753                "delete backup DB rows failed for {}: {}",
1754                key.display(),
1755                error
1756            );
1757        }
1758    }
1759}
1760
1761pub fn hash_session(session: &str) -> String {
1762    stable_hash_16(session.as_bytes())
1763}
1764
1765pub fn new_op_id() -> String {
1766    let mut bytes = [0u8; 4];
1767    if getrandom::fill(&mut bytes).is_err() {
1768        bytes = current_timestamp().to_le_bytes()[..4]
1769            .try_into()
1770            .unwrap_or([0; 4]);
1771    }
1772    let rand = u32::from_le_bytes(bytes);
1773    format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1774}
1775
1776#[derive(Debug, Clone)]
1777struct BackupEntryDiskMetadata {
1778    mode: Option<u32>,
1779    link_target: Option<PathBuf>,
1780    created_dirs: Vec<PathBuf>,
1781}
1782
1783#[derive(Debug, Clone)]
1784enum RestorePathState {
1785    Missing,
1786    Regular {
1787        content_bytes: Vec<u8>,
1788        mode: Option<u32>,
1789    },
1790    Symlink {
1791        target: PathBuf,
1792    },
1793    Directory,
1794}
1795
1796fn backup_entry_from_path(
1797    path: &Path,
1798    backup_id: String,
1799    order: u128,
1800    description: &str,
1801    op_id: Option<&str>,
1802) -> Result<BackupEntry, AftError> {
1803    let metadata = std::fs::symlink_metadata(path).map_err(|error| match error.kind() {
1804        std::io::ErrorKind::NotFound => AftError::FileNotFound {
1805            path: path.display().to_string(),
1806        },
1807        _ => AftError::IoError {
1808            path: path.display().to_string(),
1809            message: error.to_string(),
1810        },
1811    })?;
1812    let mode = file_mode(&metadata);
1813
1814    let (kind, content, content_bytes, link_target) = if metadata.file_type().is_symlink() {
1815        let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
1816            path: path.display().to_string(),
1817            message: error.to_string(),
1818        })?;
1819        (
1820            BackupEntryKind::Symlink,
1821            target.display().to_string(),
1822            Vec::new(),
1823            Some(target),
1824        )
1825    } else if metadata.is_file() {
1826        let bytes = std::fs::read(path).map_err(|error| AftError::IoError {
1827            path: path.display().to_string(),
1828            message: error.to_string(),
1829        })?;
1830        (
1831            BackupEntryKind::Content,
1832            String::from_utf8_lossy(&bytes).into_owned(),
1833            bytes,
1834            None,
1835        )
1836    } else {
1837        return Err(AftError::InvalidRequest {
1838            message: format!(
1839                "backup: '{}' is not a regular file or symlink",
1840                path.display()
1841            ),
1842        });
1843    };
1844
1845    Ok(BackupEntry {
1846        backup_id,
1847        content,
1848        content_bytes,
1849        timestamp: current_timestamp(),
1850        order,
1851        description: description.to_string(),
1852        op_id: op_id.map(str::to_string),
1853        kind,
1854        mode,
1855        link_target,
1856        created_dirs: Vec::new(),
1857    })
1858}
1859
1860fn canonicalize_key(path: &Path) -> PathBuf {
1861    let absolute = if path.is_absolute() {
1862        path.to_path_buf()
1863    } else {
1864        std::env::current_dir()
1865            .unwrap_or_else(|_| PathBuf::from("."))
1866            .join(path)
1867    };
1868
1869    match std::fs::symlink_metadata(&absolute) {
1870        Ok(metadata) if metadata.file_type().is_symlink() => {
1871            canonicalize_parent_join_leaf(&absolute)
1872        }
1873        Ok(_) => std::fs::canonicalize(&absolute)
1874            .map(|path| normalize_absolute_key(&path))
1875            .unwrap_or_else(|_| canonicalize_existing_ancestor(&absolute)),
1876        Err(_) => canonicalize_existing_ancestor(&absolute),
1877    }
1878}
1879
1880fn canonicalize_parent_join_leaf(path: &Path) -> PathBuf {
1881    let Some(parent) = path.parent() else {
1882        return normalize_absolute_key(path);
1883    };
1884    let mut key = canonicalize_existing_ancestor(parent);
1885    if let Some(file_name) = path.file_name() {
1886        key.push(file_name);
1887    }
1888    key
1889}
1890
1891fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
1892    let mut suffix = Vec::new();
1893    let mut current = path;
1894
1895    loop {
1896        if let Ok(mut base) = std::fs::canonicalize(current) {
1897            for component in suffix.iter().rev() {
1898                base.push(Path::new(component));
1899            }
1900            return normalize_absolute_key(&base);
1901        }
1902        let Some(parent) = current.parent() else {
1903            return normalize_absolute_key(path);
1904        };
1905        if let Some(file_name) = current.file_name() {
1906            suffix.push(file_name.to_os_string());
1907        }
1908        current = parent;
1909    }
1910}
1911
1912fn normalize_absolute_key(path: &Path) -> PathBuf {
1913    let mut normalized = PathBuf::new();
1914
1915    for component in path.components() {
1916        match component {
1917            std::path::Component::CurDir => {}
1918            std::path::Component::ParentDir => {
1919                if !normalized.pop() {
1920                    normalized.push(component.as_os_str());
1921                }
1922            }
1923            other => normalized.push(other.as_os_str()),
1924        }
1925    }
1926
1927    normalized
1928}
1929
1930fn file_mode(metadata: &std::fs::Metadata) -> Option<u32> {
1931    #[cfg(unix)]
1932    {
1933        use std::os::unix::fs::PermissionsExt;
1934        Some(metadata.permissions().mode())
1935    }
1936    #[cfg(not(unix))]
1937    {
1938        let _ = metadata;
1939        None
1940    }
1941}
1942
1943fn set_file_mode(path: &Path, mode: Option<u32>) -> std::io::Result<()> {
1944    #[cfg(unix)]
1945    {
1946        use std::os::unix::fs::PermissionsExt;
1947        if let Some(mode) = mode {
1948            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
1949        }
1950    }
1951    #[cfg(not(unix))]
1952    {
1953        let _ = (path, mode);
1954    }
1955    Ok(())
1956}
1957
1958fn capture_path_state(path: &Path) -> Result<RestorePathState, AftError> {
1959    let metadata = match std::fs::symlink_metadata(path) {
1960        Ok(metadata) => metadata,
1961        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1962            return Ok(RestorePathState::Missing);
1963        }
1964        Err(error) => {
1965            return Err(AftError::IoError {
1966                path: path.display().to_string(),
1967                message: error.to_string(),
1968            });
1969        }
1970    };
1971
1972    if metadata.file_type().is_symlink() {
1973        let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
1974            path: path.display().to_string(),
1975            message: error.to_string(),
1976        })?;
1977        Ok(RestorePathState::Symlink { target })
1978    } else if metadata.is_file() {
1979        let content_bytes = std::fs::read(path).map_err(|error| AftError::IoError {
1980            path: path.display().to_string(),
1981            message: error.to_string(),
1982        })?;
1983        Ok(RestorePathState::Regular {
1984            content_bytes,
1985            mode: file_mode(&metadata),
1986        })
1987    } else {
1988        Ok(RestorePathState::Directory)
1989    }
1990}
1991
1992fn restore_entry_to_path(path: &Path, entry: &BackupEntry) -> std::io::Result<()> {
1993    match entry.kind {
1994        BackupEntryKind::Content => restore_regular_file(path, &entry.content_bytes, entry.mode),
1995        BackupEntryKind::Symlink => {
1996            let target = entry.link_target.as_ref().ok_or_else(|| {
1997                std::io::Error::new(
1998                    std::io::ErrorKind::InvalidData,
1999                    "symlink backup entry missing target",
2000                )
2001            })?;
2002            restore_symlink(path, target)
2003        }
2004        BackupEntryKind::Tombstone => remove_tombstone_path(path),
2005    }
2006}
2007
2008fn restore_path_state(path: &Path, state: &RestorePathState) -> bool {
2009    match state {
2010        RestorePathState::Missing => remove_file_or_symlink_if_present(path).is_ok(),
2011        RestorePathState::Regular {
2012            content_bytes,
2013            mode,
2014        } => restore_regular_file(path, content_bytes, *mode).is_ok(),
2015        RestorePathState::Symlink { target } => restore_symlink(path, target).is_ok(),
2016        RestorePathState::Directory => true,
2017    }
2018}
2019
2020fn restore_regular_file(
2021    path: &Path,
2022    content_bytes: &[u8],
2023    mode: Option<u32>,
2024) -> std::io::Result<()> {
2025    if let Some(parent) = path.parent() {
2026        if !parent.as_os_str().is_empty() {
2027            std::fs::create_dir_all(parent)?;
2028        }
2029    }
2030    if std::fs::symlink_metadata(path)
2031        .map(|metadata| metadata.file_type().is_symlink())
2032        .unwrap_or(false)
2033    {
2034        std::fs::remove_file(path)?;
2035    }
2036    std::fs::write(path, content_bytes)?;
2037    set_file_mode(path, mode)
2038}
2039
2040fn restore_symlink(path: &Path, target: &Path) -> std::io::Result<()> {
2041    if let Some(parent) = path.parent() {
2042        if !parent.as_os_str().is_empty() {
2043            std::fs::create_dir_all(parent)?;
2044        }
2045    }
2046    remove_file_or_symlink_if_present(path)?;
2047    create_symlink(target, path)
2048}
2049
2050#[cfg(unix)]
2051fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2052    std::os::unix::fs::symlink(target, link)
2053}
2054
2055#[cfg(windows)]
2056fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2057    if target.is_dir() {
2058        std::os::windows::fs::symlink_dir(target, link)
2059    } else {
2060        std::os::windows::fs::symlink_file(target, link)
2061    }
2062}
2063
2064fn remove_tombstone_path(path: &Path) -> std::io::Result<()> {
2065    match std::fs::symlink_metadata(path) {
2066        Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2067            std::fs::remove_file(path)
2068        }
2069        Ok(_) => Err(std::io::Error::new(
2070            std::io::ErrorKind::IsADirectory,
2071            "tombstone target is a directory",
2072        )),
2073        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2074        Err(error) => Err(error),
2075    }
2076}
2077
2078fn remove_file_or_symlink_if_present(path: &Path) -> std::io::Result<()> {
2079    match std::fs::symlink_metadata(path) {
2080        Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2081            std::fs::remove_file(path)
2082        }
2083        Ok(_) => Err(std::io::Error::new(
2084            std::io::ErrorKind::IsADirectory,
2085            "path is a directory",
2086        )),
2087        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2088        Err(error) => Err(error),
2089    }
2090}
2091
2092fn read_entry_disk_metadata(
2093    backup_path: &Path,
2094    backup_id: &str,
2095) -> Option<BackupEntryDiskMetadata> {
2096    let meta_path = if backup_path.file_name().and_then(|name| name.to_str()) == Some("meta.json") {
2097        backup_path.to_path_buf()
2098    } else {
2099        backup_path.parent()?.join("meta.json")
2100    };
2101    let content = std::fs::read_to_string(meta_path).ok()?;
2102    let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
2103    let entries = meta.get("entries")?.as_array()?;
2104    let entry = entries
2105        .iter()
2106        .find(|entry| entry.get("backup_id").and_then(|value| value.as_str()) == Some(backup_id))?;
2107    Some(BackupEntryDiskMetadata {
2108        mode: entry
2109            .get("mode")
2110            .and_then(|value| value.as_u64())
2111            .and_then(|mode| u32::try_from(mode).ok()),
2112        link_target: entry
2113            .get("link_target")
2114            .and_then(|value| value.as_str())
2115            .map(PathBuf::from),
2116        created_dirs: entry
2117            .get("created_dirs")
2118            .and_then(|value| value.as_array())
2119            .map(|dirs| {
2120                dirs.iter()
2121                    .filter_map(|dir| dir.as_str())
2122                    .map(PathBuf::from)
2123                    .collect()
2124            })
2125            .unwrap_or_default(),
2126    })
2127}
2128
2129fn rollback_transactional_restore(
2130    written: &[(PathBuf, RestorePathState)],
2131    attempted: Option<(&PathBuf, &RestorePathState)>,
2132) -> bool {
2133    let mut ok = true;
2134
2135    if let Some((path, state)) = attempted {
2136        ok &= restore_path_state(path, state);
2137    }
2138
2139    for (path, state) in written.iter().rev() {
2140        ok &= restore_path_state(path, state);
2141    }
2142
2143    ok
2144}
2145
2146fn rollback_deleted_tombstones(deleted: &[(PathBuf, RestorePathState)]) -> bool {
2147    let mut ok = true;
2148    for (path, state) in deleted.iter().rev() {
2149        ok &= restore_path_state(path, state);
2150    }
2151    ok
2152}
2153
2154fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
2155    let mut dirs = Vec::new();
2156    let mut current = Some(parent);
2157
2158    while let Some(dir) = current {
2159        if dir.as_os_str().is_empty() || dir.exists() {
2160            break;
2161        }
2162        dirs.push(dir.to_path_buf());
2163        current = dir.parent();
2164    }
2165
2166    dirs
2167}
2168
2169fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
2170    let mut dirs = dirs.to_vec();
2171    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2172    dirs.dedup();
2173
2174    let mut ok = true;
2175    for dir in dirs {
2176        match std::fs::remove_dir(&dir) {
2177            Ok(()) => {}
2178            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2179            Err(_) => ok = false,
2180        }
2181    }
2182
2183    ok
2184}
2185
2186fn remove_created_dirs_best_effort(dirs: &[PathBuf]) {
2187    let mut dirs = dirs.to_vec();
2188    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2189    dirs.dedup();
2190
2191    for dir in dirs {
2192        match std::fs::remove_dir(&dir) {
2193            Ok(()) => {}
2194            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2195            Err(_) => {}
2196        }
2197    }
2198}
2199
2200fn dir_has_entries(path: &Path) -> bool {
2201    std::fs::read_dir(path)
2202        .map(|mut entries| entries.next().is_some())
2203        .unwrap_or(false)
2204}
2205
2206fn current_timestamp() -> u64 {
2207    std::time::SystemTime::now()
2208        .duration_since(std::time::UNIX_EPOCH)
2209        .unwrap_or_default()
2210        .as_secs()
2211}
2212
2213fn current_timestamp_nanos() -> u64 {
2214    let nanos = std::time::SystemTime::now()
2215        .duration_since(std::time::UNIX_EPOCH)
2216        .unwrap_or_default()
2217        .as_nanos();
2218    nanos.min(u128::from(u64::MAX)) as u64
2219}
2220
2221fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
2222    let nanos = timestamp_secs.saturating_mul(1_000_000_000);
2223    ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
2224}
2225
2226fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
2227    value
2228        .as_str()
2229        .and_then(|s| s.parse::<u128>().ok())
2230        .or_else(|| value.as_u64().map(u128::from))
2231}
2232
2233fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
2234    if !key.is_absolute()
2235        || key
2236            .components()
2237            .any(|c| matches!(c, std::path::Component::ParentDir))
2238    {
2239        return false;
2240    }
2241    let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
2242        return false;
2243    };
2244    BackupStore::path_hash(key) == dir_name
2245}
2246
2247fn stable_hash_16(bytes: &[u8]) -> String {
2248    let digest = Sha256::digest(bytes);
2249    digest[..8]
2250        .iter()
2251        .map(|byte| format!("{:02x}", byte))
2252        .collect()
2253}
2254
2255fn backup_sequence(backup_id: &str) -> Option<u64> {
2256    backup_id
2257        .strip_prefix("backup-")
2258        .or_else(|| backup_id.strip_prefix("disk-"))
2259        .and_then(|s| s.parse().ok())
2260}
2261
2262#[cfg(test)]
2263mod tests {
2264    use super::*;
2265    use crate::protocol::DEFAULT_SESSION_ID;
2266    use std::fs;
2267    #[cfg(unix)]
2268    use std::os::unix::fs::PermissionsExt;
2269
2270    fn temp_file(name: &str, content: &str) -> PathBuf {
2271        let dir = std::env::temp_dir().join("aft_backup_tests");
2272        fs::create_dir_all(&dir).unwrap();
2273        let path = dir.join(name);
2274        fs::write(&path, content).unwrap();
2275        path
2276    }
2277
2278    #[test]
2279    fn snapshot_and_restore_round_trip() {
2280        let path = temp_file("round_trip.txt", "original");
2281        let mut store = BackupStore::new();
2282
2283        let id = store
2284            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
2285            .unwrap();
2286        assert!(id.starts_with("backup-"));
2287
2288        fs::write(&path, "modified").unwrap();
2289        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
2290
2291        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2292        assert_eq!(entry.content, "original");
2293        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2294    }
2295
2296    #[test]
2297    fn multiple_snapshots_preserve_order() {
2298        let path = temp_file("order.txt", "v1");
2299        let mut store = BackupStore::new();
2300
2301        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2302        fs::write(&path, "v2").unwrap();
2303        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2304        fs::write(&path, "v3").unwrap();
2305        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
2306
2307        let history = store.history(DEFAULT_SESSION_ID, &path);
2308        assert_eq!(history.len(), 3);
2309        assert_eq!(history[0].content, "v1");
2310        assert_eq!(history[1].content, "v2");
2311        assert_eq!(history[2].content, "v3");
2312    }
2313
2314    #[test]
2315    fn restore_pops_from_stack() {
2316        let path = temp_file("pop.txt", "v1");
2317        let mut store = BackupStore::new();
2318
2319        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2320        fs::write(&path, "v2").unwrap();
2321        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2322
2323        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2324        assert_eq!(entry.description, "second");
2325        assert_eq!(entry.content, "v2");
2326
2327        let history = store.history(DEFAULT_SESSION_ID, &path);
2328        assert_eq!(history.len(), 1);
2329    }
2330
2331    #[test]
2332    fn empty_history_returns_empty_vec() {
2333        let store = BackupStore::new();
2334        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
2335        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
2336    }
2337
2338    #[test]
2339    fn snapshot_nonexistent_file_returns_error() {
2340        let mut store = BackupStore::new();
2341        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
2342        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
2343    }
2344
2345    #[test]
2346    fn tracked_files_lists_snapshotted_paths() {
2347        let path1 = temp_file("tracked1.txt", "a");
2348        let path2 = temp_file("tracked2.txt", "b");
2349        let mut store = BackupStore::new();
2350
2351        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
2352        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
2353        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
2354    }
2355
2356    #[test]
2357    fn sessions_are_isolated() {
2358        let path = temp_file("isolated.txt", "original");
2359        let mut store = BackupStore::new();
2360
2361        store.snapshot("session_a", &path, "a's snapshot").unwrap();
2362
2363        // Session B sees no history for this file.
2364        assert!(store.history("session_b", &path).is_empty());
2365        assert_eq!(store.tracked_files("session_b").len(), 0);
2366
2367        // Session B's restore_latest fails with NoUndoHistory.
2368        let err = store.restore_latest("session_b", &path);
2369        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2370
2371        // Session A still sees its own snapshot.
2372        assert_eq!(store.history("session_a", &path).len(), 1);
2373        assert_eq!(store.tracked_files("session_a").len(), 1);
2374    }
2375
2376    #[test]
2377    fn per_session_per_file_cap_is_independent() {
2378        // Two sessions fill up their own stacks independently; hitting the cap
2379        // in session A does not evict anything from session B.
2380        let path = temp_file("cap_indep.txt", "v0");
2381        let mut store = BackupStore::new();
2382
2383        for i in 0..(MAX_UNDO_DEPTH + 5) {
2384            fs::write(&path, format!("a{}", i)).unwrap();
2385            store.snapshot("session_a", &path, "a").unwrap();
2386        }
2387        fs::write(&path, "b_initial").unwrap();
2388        store.snapshot("session_b", &path, "b").unwrap();
2389
2390        // Session A should be capped at MAX_UNDO_DEPTH.
2391        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
2392        // Session B should still have its single entry.
2393        assert_eq!(store.history("session_b", &path).len(), 1);
2394    }
2395
2396    #[test]
2397    fn sessions_with_backups_lists_all_namespaces() {
2398        let path_a = temp_file("sessions_list_a.txt", "a");
2399        let path_b = temp_file("sessions_list_b.txt", "b");
2400        let mut store = BackupStore::new();
2401
2402        store.snapshot("alice", &path_a, "from alice").unwrap();
2403        store.snapshot("bob", &path_b, "from bob").unwrap();
2404
2405        let sessions = store.sessions_with_backups();
2406        assert_eq!(sessions.len(), 2);
2407        assert!(sessions.iter().any(|s| s == "alice"));
2408        assert!(sessions.iter().any(|s| s == "bob"));
2409    }
2410
2411    #[test]
2412    fn disk_persistence_survives_reload() {
2413        let dir = std::env::temp_dir().join("aft_backup_disk_test");
2414        let _ = fs::remove_dir_all(&dir);
2415        fs::create_dir_all(&dir).unwrap();
2416
2417        let file_path = temp_file("disk_persist.txt", "original");
2418
2419        // Create store with storage, snapshot under default session, drop.
2420        {
2421            let mut store = BackupStore::new();
2422            store.set_storage_dir(dir.clone(), 72);
2423            store
2424                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
2425                .unwrap();
2426        }
2427
2428        // Modify the file externally.
2429        fs::write(&file_path, "externally modified").unwrap();
2430
2431        // Create new store, load from disk, restore.
2432        let mut store2 = BackupStore::new();
2433        store2.set_storage_dir(dir.clone(), 72);
2434
2435        let (entry, warning) = store2
2436            .restore_latest(DEFAULT_SESSION_ID, &file_path)
2437            .unwrap();
2438        assert_eq!(entry.content, "original");
2439        assert!(warning.is_some()); // modified externally
2440        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2441
2442        let _ = fs::remove_dir_all(&dir);
2443    }
2444
2445    #[test]
2446    fn legacy_flat_layout_migrates_to_default_session() {
2447        // Simulate a pre-session on-disk layout (schema v1) and verify it's
2448        // moved under the default session namespace on set_storage_dir.
2449        let dir = std::env::temp_dir().join("aft_backup_migration_test");
2450        let _ = fs::remove_dir_all(&dir);
2451        fs::create_dir_all(&dir).unwrap();
2452        let backups = dir.join("backups");
2453        fs::create_dir_all(&backups).unwrap();
2454
2455        // Fake legacy entry for some path hash.
2456        let legacy_hash = "deadbeefcafebabe";
2457        let legacy_dir = backups.join(legacy_hash);
2458        fs::create_dir_all(&legacy_dir).unwrap();
2459        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
2460        let legacy_meta = serde_json::json!({
2461            "path": "/tmp/migrated_file.txt",
2462            "count": 1,
2463        });
2464        fs::write(
2465            legacy_dir.join("meta.json"),
2466            serde_json::to_string_pretty(&legacy_meta).unwrap(),
2467        )
2468        .unwrap();
2469
2470        // Run migration.
2471        let mut store = BackupStore::new();
2472        store.set_storage_dir(dir.clone(), 72);
2473
2474        // After migration, the legacy dir should be gone from the top level,
2475        // and the entry should now live under the default-session hash dir.
2476        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2477        assert!(default_session_dir.exists());
2478        assert!(default_session_dir.join(legacy_hash).exists());
2479        assert!(!backups.join(legacy_hash).exists());
2480
2481        // The upgraded meta.json should now include session_id + schema_version.
2482        let meta_content =
2483            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
2484        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
2485        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
2486        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
2487
2488        let _ = fs::remove_dir_all(&dir);
2489    }
2490
2491    #[test]
2492    fn set_storage_dir_removes_stale_backup_sessions() {
2493        let dir = std::env::temp_dir().join("aft_backup_gc_test");
2494        let _ = fs::remove_dir_all(&dir);
2495        let backups = dir.join("backups");
2496        fs::create_dir_all(&backups).unwrap();
2497
2498        let stale_session_dir = backups.join("stale-session");
2499        fs::create_dir_all(&stale_session_dir).unwrap();
2500        let stale_marker = serde_json::json!({
2501            "schema_version": SCHEMA_VERSION,
2502            "session_id": "stale",
2503            "last_accessed": 1,
2504        });
2505        fs::write(
2506            stale_session_dir.join("session.json"),
2507            serde_json::to_string_pretty(&stale_marker).unwrap(),
2508        )
2509        .unwrap();
2510
2511        let mut store = BackupStore::new();
2512        store.set_storage_dir(dir.clone(), 1);
2513
2514        assert!(!stale_session_dir.exists());
2515        let _ = fs::remove_dir_all(&dir);
2516    }
2517
2518    #[test]
2519    fn markerless_session_dir_is_skipped_not_mapped_to_default() {
2520        let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
2521        let _ = fs::remove_dir_all(&dir);
2522        let file_path = temp_file("markerless.txt", "original");
2523        let key = canonicalize_key(&file_path);
2524        let path_dir = dir
2525            .join("backups")
2526            .join("corrupt-session")
2527            .join("path-entry");
2528        fs::create_dir_all(&path_dir).unwrap();
2529        fs::write(path_dir.join("0.bak"), "original").unwrap();
2530        fs::write(
2531            path_dir.join("meta.json"),
2532            serde_json::to_string_pretty(&serde_json::json!({
2533                "schema_version": SCHEMA_VERSION,
2534                "session_id": "lost-session",
2535                "path": key.display().to_string(),
2536                "count": 1,
2537                "entries": [{
2538                    "backup_id": "disk-0",
2539                    "timestamp": 0,
2540                    "description": "corrupt marker test",
2541                    "op_id": null,
2542                    "kind": "content",
2543                }]
2544            }))
2545            .unwrap(),
2546        )
2547        .unwrap();
2548
2549        let mut store = BackupStore::new();
2550        store.set_storage_dir(dir.clone(), 72);
2551
2552        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2553        assert!(store.sessions_with_backups().is_empty());
2554        let _ = fs::remove_dir_all(&dir);
2555    }
2556
2557    #[test]
2558    fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
2559        let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
2560        let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
2561        let _ = fs::remove_dir_all(&dir_a);
2562        let _ = fs::remove_dir_all(&dir_b);
2563        fs::create_dir_all(&dir_a).unwrap();
2564        fs::create_dir_all(&dir_b).unwrap();
2565        let file_path = temp_file("storage_reconfigure.txt", "original");
2566
2567        let mut store = BackupStore::new();
2568        store.set_storage_dir(dir_a.clone(), 72);
2569        store
2570            .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
2571            .unwrap();
2572        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
2573
2574        store.set_storage_dir(dir_b.clone(), 72);
2575
2576        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2577        assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
2578        let _ = fs::remove_dir_all(&dir_a);
2579        let _ = fs::remove_dir_all(&dir_b);
2580    }
2581
2582    #[test]
2583    fn restore_last_operation_restores_all_top_entries_for_same_op() {
2584        let path_a = temp_file("op_restore_a.txt", "a1");
2585        let path_b = temp_file("op_restore_b.txt", "b1");
2586        let mut store = BackupStore::new();
2587        let op_id = "op-test-00000001";
2588
2589        store
2590            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2591            .unwrap();
2592        store
2593            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2594            .unwrap();
2595        fs::write(&path_a, "a2").unwrap();
2596        fs::write(&path_b, "b2").unwrap();
2597
2598        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2599        assert_eq!(restored.op_id, op_id);
2600        assert_eq!(restored.restored.len(), 2);
2601        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
2602        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2603    }
2604
2605    #[test]
2606    fn restore_last_operation_deletes_tombstone_destination() {
2607        let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
2608        let _ = fs::remove_dir_all(&dir);
2609        fs::create_dir_all(&dir).unwrap();
2610        let source = dir.join("source.txt");
2611        let destination = dir.join("destination.txt");
2612        fs::write(&source, "original").unwrap();
2613
2614        let mut store = BackupStore::new();
2615        let op_id = "op-tombstone-delete";
2616        store
2617            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2618            .unwrap();
2619        fs::rename(&source, &destination).unwrap();
2620        store
2621            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2622            .unwrap();
2623
2624        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2625        assert_eq!(restored.op_id, op_id);
2626        assert_eq!(restored.restored.len(), 1);
2627        assert_eq!(fs::read_to_string(&source).unwrap(), "original");
2628        assert!(!destination.exists());
2629        let _ = fs::remove_dir_all(&dir);
2630    }
2631
2632    #[test]
2633    fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
2634        let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
2635        let _ = fs::remove_dir_all(&dir);
2636        fs::create_dir_all(&dir).unwrap();
2637        let source = dir.join("source.txt");
2638        let destination = dir.join("destination.txt");
2639        fs::write(&source, "original").unwrap();
2640
2641        let mut store = BackupStore::new();
2642        let op_id = "op-tombstone-atomic";
2643        store
2644            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2645            .unwrap();
2646        fs::rename(&source, &destination).unwrap();
2647        store
2648            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2649            .unwrap();
2650
2651        fs::remove_file(&destination).unwrap();
2652        fs::create_dir(&destination).unwrap();
2653        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2654
2655        assert!(result.is_err(), "directory tombstone target should fail");
2656        assert!(
2657            !source.exists(),
2658            "source restore must roll back when destination deletion fails"
2659        );
2660        assert!(
2661            destination.is_dir(),
2662            "failed tombstone target should remain"
2663        );
2664        let _ = fs::remove_dir_all(&dir);
2665    }
2666
2667    // Uses Unix-specific PermissionsExt::set_mode to make a target file
2668    // read-only and force the Phase 1 write to fail. The atomicity logic
2669    // it exercises is platform-independent — Windows has different
2670    // mechanisms for forcing write failures, covered separately.
2671    #[cfg(unix)]
2672    #[test]
2673    fn restore_last_operation_is_atomic_when_a_write_fails() {
2674        let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
2675        let _ = fs::remove_dir_all(&dir);
2676        fs::create_dir_all(&dir).unwrap();
2677        let path_a = dir.join("a.txt");
2678        let path_b = dir.join("b.txt");
2679        let path_c = dir.join("c.txt");
2680        fs::write(&path_a, "a-original").unwrap();
2681        fs::write(&path_b, "b-original").unwrap();
2682        fs::write(&path_c, "c-original").unwrap();
2683
2684        let mut store = BackupStore::new();
2685        let op_id = "op-atomic-restore-01";
2686        let id_a = store
2687            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2688            .unwrap();
2689        let id_b = store
2690            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2691            .unwrap();
2692        let id_c = store
2693            .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
2694            .unwrap();
2695        fs::write(&path_a, "a-modified").unwrap();
2696        fs::write(&path_b, "b-modified").unwrap();
2697        fs::write(&path_c, "c-modified").unwrap();
2698
2699        let original_permissions = fs::metadata(&path_b).unwrap().permissions();
2700        let mut readonly_permissions = original_permissions.clone();
2701        readonly_permissions.set_mode(0o444);
2702        fs::set_permissions(&path_b, readonly_permissions).unwrap();
2703
2704        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2705        fs::set_permissions(&path_b, original_permissions).unwrap();
2706
2707        assert!(result.is_err());
2708        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
2709        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
2710        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
2711
2712        let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
2713        let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
2714        let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
2715        assert_eq!(history_a.len(), 1);
2716        assert_eq!(history_b.len(), 1);
2717        assert_eq!(history_c.len(), 1);
2718        assert_eq!(history_a[0].backup_id, id_a);
2719        assert_eq!(history_b[0].backup_id, id_b);
2720        assert_eq!(history_c[0].backup_id, id_c);
2721        assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
2722        assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
2723        assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
2724
2725        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2726        assert_eq!(restored.op_id, op_id);
2727        assert_eq!(restored.restored.len(), 3);
2728        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
2729        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
2730        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
2731
2732        let _ = fs::remove_dir_all(&dir);
2733    }
2734
2735    #[test]
2736    fn restore_last_operation_restores_only_most_recent_op() {
2737        let path_a = temp_file("op_recent_a.txt", "a1");
2738        let path_b = temp_file("op_recent_b.txt", "b1");
2739        let mut store = BackupStore::new();
2740
2741        store
2742            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
2743            .unwrap();
2744        store
2745            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
2746            .unwrap();
2747        fs::write(&path_a, "a2").unwrap();
2748        fs::write(&path_b, "b2").unwrap();
2749
2750        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2751        assert_eq!(restored.op_id, "op-newer");
2752        assert_eq!(restored.restored.len(), 1);
2753        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2754        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2755    }
2756
2757    #[test]
2758    fn restore_recreates_missing_parent_directories() {
2759        // Simulate aft_delete files: [dir/] with recursive: true:
2760        // the parent directories are gone by the time we restore.
2761        let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
2762        let _ = fs::remove_dir_all(&dir);
2763        let nested = dir.join("nested");
2764        fs::create_dir_all(&nested).unwrap();
2765        let path = nested.join("inner.txt");
2766        fs::write(&path, "original").unwrap();
2767
2768        let mut store = BackupStore::new();
2769        let op_id = "op-recreate-parents-01";
2770        store
2771            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
2772            .unwrap();
2773
2774        // Real-world delete sequence: tree is wiped before undo runs.
2775        fs::remove_dir_all(&dir).unwrap();
2776        assert!(!path.exists());
2777        assert!(!nested.exists());
2778        assert!(!dir.exists());
2779
2780        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2781        assert_eq!(restored.op_id, op_id);
2782        assert_eq!(restored.restored.len(), 1);
2783        assert!(
2784            path.exists(),
2785            "file should be restored even though both nested/ and dir/ were missing"
2786        );
2787        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2788
2789        let _ = fs::remove_dir_all(&dir);
2790    }
2791
2792    #[test]
2793    fn restore_last_operation_ignores_legacy_entries_without_op_id() {
2794        let path = temp_file("op_legacy_none.txt", "v1");
2795        let mut store = BackupStore::new();
2796
2797        store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
2798        fs::write(&path, "v2").unwrap();
2799
2800        let err = store.restore_last_operation(DEFAULT_SESSION_ID);
2801        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2802        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
2803    }
2804
2805    #[test]
2806    fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
2807        let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
2808        let _ = fs::remove_dir_all(&dir);
2809        fs::create_dir_all(&dir).unwrap();
2810        let file_path = temp_file("v2_to_v3.txt", "original");
2811        let key = canonicalize_key(&file_path);
2812        let session_dir = dir
2813            .join("backups")
2814            .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2815        let path_dir = session_dir.join(BackupStore::path_hash(&key));
2816        fs::create_dir_all(&path_dir).unwrap();
2817        fs::write(path_dir.join("0.bak"), "original").unwrap();
2818        fs::write(
2819            session_dir.join("session.json"),
2820            serde_json::to_string_pretty(&serde_json::json!({
2821                "schema_version": 2,
2822                "session_id": DEFAULT_SESSION_ID,
2823                "last_accessed": current_timestamp(),
2824            }))
2825            .unwrap(),
2826        )
2827        .unwrap();
2828        fs::write(
2829            path_dir.join("meta.json"),
2830            serde_json::to_string_pretty(&serde_json::json!({
2831                "schema_version": 2,
2832                "session_id": DEFAULT_SESSION_ID,
2833                "path": key.display().to_string(),
2834                "count": 1,
2835            }))
2836            .unwrap(),
2837        )
2838        .unwrap();
2839
2840        let mut store = BackupStore::new();
2841        store.set_storage_dir(dir.clone(), 72);
2842        assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
2843        let history = store.history(DEFAULT_SESSION_ID, &file_path);
2844        assert_eq!(history.len(), 1);
2845        assert_eq!(history[0].op_id, None);
2846
2847        fs::write(&file_path, "second").unwrap();
2848        store
2849            .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
2850            .unwrap();
2851        let written: serde_json::Value =
2852            serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
2853        assert_eq!(written["schema_version"], SCHEMA_VERSION);
2854        assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
2855        assert_eq!(written["entries"][1]["op_id"], "op-v3");
2856        let _ = fs::remove_dir_all(&dir);
2857    }
2858
2859    #[test]
2860    fn per_file_restore_latest_still_works_with_op_ids() {
2861        let path = temp_file("op_per_file.txt", "v1");
2862        let mut store = BackupStore::new();
2863
2864        store
2865            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
2866            .unwrap();
2867        fs::write(&path, "v2").unwrap();
2868
2869        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2870        assert_eq!(entry.op_id.as_deref(), Some("op-file"));
2871        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
2872    }
2873
2874    #[test]
2875    fn per_file_restore_latest_deletes_tombstone() {
2876        let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
2877        let _ = fs::remove_dir_all(&dir);
2878        fs::create_dir_all(&dir).unwrap();
2879        let path = dir.join("created.txt");
2880        fs::write(&path, "created").unwrap();
2881
2882        let mut store = BackupStore::new();
2883        let id = store
2884            .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
2885            .unwrap();
2886
2887        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2888        assert_eq!(entry.backup_id, id);
2889        assert!(!path.exists(), "tombstone undo should delete the file");
2890        let _ = fs::remove_dir_all(&dir);
2891    }
2892
2893    #[test]
2894    fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
2895        let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
2896        let _ = fs::remove_dir_all(&dir);
2897        let backups = dir.join("backups");
2898        let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2899        let path_dir = session_dir.join("not-the-path-hash");
2900        fs::create_dir_all(&path_dir).unwrap();
2901        fs::write(
2902            session_dir.join("session.json"),
2903            serde_json::to_string_pretty(&serde_json::json!({
2904                "schema_version": SCHEMA_VERSION,
2905                "session_id": DEFAULT_SESSION_ID,
2906                "last_accessed": current_timestamp(),
2907            }))
2908            .unwrap(),
2909        )
2910        .unwrap();
2911        fs::write(path_dir.join("0.bak"), "outside").unwrap();
2912        fs::write(
2913            path_dir.join("meta.json"),
2914            serde_json::to_string_pretty(&serde_json::json!({
2915                "schema_version": SCHEMA_VERSION,
2916                "session_id": DEFAULT_SESSION_ID,
2917                "path": "/tmp/aft-malicious-overwrite-target.txt",
2918                "count": 1,
2919                "entries": [{
2920                    "backup_id": "backup-0",
2921                    "timestamp": current_timestamp(),
2922                    "order": "1",
2923                    "description": "tampered",
2924                    "op_id": "op-tampered",
2925                    "kind": "content",
2926                }]
2927            }))
2928            .unwrap(),
2929        )
2930        .unwrap();
2931
2932        let mut store = BackupStore::new();
2933        store.set_storage_dir(dir.clone(), 72);
2934
2935        assert!(store.sessions_with_backups().is_empty());
2936        let _ = fs::remove_dir_all(&dir);
2937    }
2938
2939    #[test]
2940    fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
2941        let path_a = temp_file("op_order_a.txt", "a1");
2942        let path_b = temp_file("op_order_b.txt", "b1");
2943        let mut store = BackupStore::new();
2944
2945        store
2946            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
2947            .unwrap();
2948        store
2949            .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
2950            .unwrap();
2951        store
2952            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
2953            .unwrap();
2954
2955        let key_a = canonicalize_key(&path_a);
2956        let key_b = canonicalize_key(&path_b);
2957        let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
2958        files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
2959        files.get_mut(&key_a).unwrap()[1].order = 1;
2960        files.get_mut(&key_b).unwrap()[0].order = 2;
2961
2962        fs::write(&path_a, "a2").unwrap();
2963        fs::write(&path_b, "b2").unwrap();
2964
2965        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2966        assert_eq!(restored.op_id, "op-top");
2967        assert_eq!(restored.restored.len(), 1);
2968        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2969        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2970    }
2971}