Skip to main content

aft/
backup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6use sha2::{Digest, Sha256};
7
8const MAX_UNDO_DEPTH: usize = 20;
9
10/// Current on-disk backup metadata schema version.
11///
12/// Bump this when the `meta.json` shape changes. Readers check the field and
13/// refuse or migrate older versions instead of misinterpreting them.
14const SCHEMA_VERSION: u32 = 3;
15
16/// A single backup entry for a file.
17#[derive(Debug, Clone)]
18pub struct BackupEntry {
19    pub backup_id: String,
20    pub content: String,
21    pub timestamp: u64,
22    pub order: u128,
23    pub description: String,
24    pub op_id: Option<String>,
25    pub kind: BackupEntryKind,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BackupEntryKind {
30    Content,
31    Tombstone,
32}
33
34#[derive(Debug, Clone)]
35pub struct RestoredOperation {
36    pub op_id: String,
37    pub restored: Vec<RestoredFile>,
38    pub warnings: Vec<String>,
39}
40
41#[derive(Debug, Clone)]
42pub struct RestoredFile {
43    pub path: PathBuf,
44    pub backup_id: String,
45}
46
47/// Per-(session, file) undo store with optional disk persistence.
48///
49/// Introduced alongside project-shared bridges (issue #14): one bridge can now
50/// serve many OpenCode sessions in the same project, so undo history must be
51/// partitioned by session to keep session A's edits invisible to session B.
52///
53/// The 20-entry cap is enforced **per (session, file)** deliberately — a global
54/// per-file LRU would re-couple sessions and let one busy session evict
55/// another's history.
56///
57/// Disk layout (schema v2):
58///   `<storage_dir>/backups/<session_hash>/session.json` — session metadata
59///   `<storage_dir>/backups/<session_hash>/<path_hash>/meta.json` — file path + count + session
60///   `<storage_dir>/backups/<session_hash>/<path_hash>/0.bak` … `19.bak` — snapshots
61///
62/// Legacy layouts from before sessionization (flat `<path_hash>/` directly under
63/// `backups/`) are migrated on first `set_storage_dir` call into the default
64/// session namespace.
65#[derive(Debug)]
66pub struct BackupStore {
67    /// session -> path -> entry stack
68    entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
69    /// session -> path -> disk metadata
70    disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
71    /// session -> metadata
72    session_meta: HashMap<String, SessionMeta>,
73    counter: AtomicU64,
74    storage_dir: Option<PathBuf>,
75}
76
77#[derive(Debug, Clone)]
78struct DiskMeta {
79    dir: PathBuf,
80    count: usize,
81}
82
83#[derive(Debug, Clone, Default)]
84struct SessionMeta {
85    /// Unix timestamp of last read/write activity in this session namespace.
86    /// Maintained in-memory now, reserved for future inactivity-TTL cleanup.
87    last_accessed: u64,
88}
89
90impl BackupStore {
91    pub fn new() -> Self {
92        BackupStore {
93            entries: HashMap::new(),
94            disk_index: HashMap::new(),
95            session_meta: HashMap::new(),
96            counter: AtomicU64::new(0),
97            storage_dir: None,
98        }
99    }
100
101    /// Set storage directory for disk persistence (called during configure).
102    ///
103    /// Loads the disk index for all session namespaces, removes stale session
104    /// directories, and migrates any legacy pre-session (flat) layout into the
105    /// default namespace.
106    pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
107        self.storage_dir = Some(dir);
108        self.entries.clear();
109        self.disk_index.clear();
110        self.session_meta.clear();
111        self.gc_stale_sessions(ttl_hours);
112        self.migrate_legacy_layout_if_needed();
113        self.load_disk_index();
114    }
115
116    /// Snapshot the current contents of `path` under the given session namespace.
117    pub fn snapshot(
118        &mut self,
119        session: &str,
120        path: &Path,
121        description: &str,
122    ) -> Result<String, AftError> {
123        self.snapshot_with_op(session, path, description, None)
124    }
125
126    /// Snapshot the current contents of `path` under the given session namespace,
127    /// optionally tagging it with an operation id shared by all files touched by
128    /// one mutating tool call.
129    pub fn snapshot_with_op(
130        &mut self,
131        session: &str,
132        path: &Path,
133        description: &str,
134        op_id: Option<&str>,
135    ) -> Result<String, AftError> {
136        let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
137            path: path.display().to_string(),
138        })?;
139
140        let key = canonicalize_key(path);
141        let (id, order) = self.next_id_and_order();
142        let entry = BackupEntry {
143            backup_id: id.clone(),
144            content,
145            timestamp: current_timestamp(),
146            order,
147            description: description.to_string(),
148            op_id: op_id.map(str::to_string),
149            kind: BackupEntryKind::Content,
150        };
151
152        let session_entries = self.entries.entry(session.to_string()).or_default();
153        let stack = session_entries.entry(key.clone()).or_default();
154        if stack.len() >= MAX_UNDO_DEPTH {
155            stack.remove(0);
156        }
157        stack.push(entry);
158
159        // Persist to disk
160        let stack_clone = stack.clone();
161        self.write_snapshot_to_disk(session, &key, &stack_clone);
162        self.touch_session(session);
163
164        Ok(id)
165    }
166
167    /// Record that `path` was created by the operation and should be removed
168    /// if that operation is undone. No file content is captured.
169    pub fn snapshot_op_tombstone(
170        &mut self,
171        session: &str,
172        op_id: &str,
173        path: &Path,
174        description: &str,
175    ) -> Result<String, AftError> {
176        let key = canonicalize_key(path);
177        let (id, order) = self.next_id_and_order();
178        let entry = BackupEntry {
179            backup_id: id.clone(),
180            content: String::new(),
181            timestamp: current_timestamp(),
182            order,
183            description: description.to_string(),
184            op_id: Some(op_id.to_string()),
185            kind: BackupEntryKind::Tombstone,
186        };
187
188        let session_entries = self.entries.entry(session.to_string()).or_default();
189        let stack = session_entries.entry(key.clone()).or_default();
190        if stack.len() >= MAX_UNDO_DEPTH {
191            stack.remove(0);
192        }
193        stack.push(entry);
194
195        let stack_clone = stack.clone();
196        self.write_snapshot_to_disk(session, &key, &stack_clone);
197        self.touch_session(session);
198
199        Ok(id)
200    }
201
202    /// Restore every top-of-stack backup entry belonging to the most recent
203    /// operation in this session.
204    pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
205        let disk_keys: Vec<PathBuf> = self
206            .disk_index
207            .get(session)
208            .map(|files| files.keys().cloned().collect())
209            .unwrap_or_default();
210        for key in disk_keys {
211            self.load_from_disk_if_needed(session, &key);
212        }
213
214        let mut latest: Option<(u128, String)> = None;
215        if let Some(files) = self.entries.get(session) {
216            for stack in files.values() {
217                if let Some(entry) = stack.last() {
218                    if let Some(op_id) = &entry.op_id {
219                        let order = entry.order;
220                        if latest
221                            .as_ref()
222                            .map_or(true, |(latest_order, _)| order > *latest_order)
223                        {
224                            latest = Some((order, op_id.clone()));
225                        }
226                    }
227                }
228            }
229        }
230
231        let Some((_, op_id)) = latest else {
232            return Err(AftError::NoUndoHistory {
233                path: "operation".to_string(),
234            });
235        };
236
237        let mut keys_to_restore: Vec<PathBuf> = self
238            .entries
239            .get(session)
240            .map(|files| {
241                files
242                    .iter()
243                    .filter_map(|(key, stack)| {
244                        stack.last().and_then(|entry| {
245                            (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
246                        })
247                    })
248                    .collect()
249            })
250            .unwrap_or_default();
251        keys_to_restore.sort();
252
253        if keys_to_restore.is_empty() {
254            return Err(AftError::NoUndoHistory {
255                path: "operation".to_string(),
256            });
257        }
258
259        let mut content_targets = Vec::new();
260        let mut tombstone_targets = Vec::new();
261        for key in &keys_to_restore {
262            let entry = self
263                .entries
264                .get(session)
265                .and_then(|files| files.get(key))
266                .and_then(|stack| stack.last())
267                .cloned()
268                .ok_or_else(|| AftError::NoUndoHistory {
269                    path: key.display().to_string(),
270                })?;
271            match entry.kind {
272                BackupEntryKind::Content => {
273                    let existing_content = match std::fs::read(key) {
274                        Ok(content) => Some(content),
275                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
276                        Err(e) => {
277                            return Err(AftError::IoError {
278                                path: key.display().to_string(),
279                                message: e.to_string(),
280                            });
281                        }
282                    };
283                    let warning = self.check_external_modification(session, key, key);
284                    content_targets.push((key.clone(), entry, warning, existing_content));
285                }
286                BackupEntryKind::Tombstone => {
287                    let existing_content = if key.is_file() {
288                        Some(std::fs::read(key).map_err(|e| AftError::IoError {
289                            path: key.display().to_string(),
290                            message: e.to_string(),
291                        })?)
292                    } else {
293                        None
294                    };
295                    tombstone_targets.push((key.clone(), entry, existing_content));
296                }
297            }
298        }
299
300        let mut created_dirs = Vec::new();
301        for (key, _, _, _) in &content_targets {
302            if let Some(parent) = key.parent() {
303                if !parent.as_os_str().is_empty() {
304                    let missing_dirs = missing_parent_dirs(parent);
305                    if let Err(e) = std::fs::create_dir_all(parent) {
306                        let mut dirs_to_remove = created_dirs;
307                        dirs_to_remove.extend(missing_dirs);
308                        let rollback_ok = rollback_created_dirs(&dirs_to_remove);
309                        return Err(AftError::IoError {
310                            path: parent.display().to_string(),
311                            message: format!(
312                                "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
313                                e,
314                                !rollback_ok,
315                                rollback_ok
316                            ),
317                        });
318                    }
319                    created_dirs.extend(missing_dirs);
320                }
321            }
322        }
323
324        let mut written = Vec::new();
325        for (key, entry, _, existing_content) in &content_targets {
326            if let Err(e) = std::fs::write(key, &entry.content) {
327                let files_rollback_ok =
328                    rollback_transactional_restore(&written, Some((key, existing_content)));
329                let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
330                let rollback_ok = files_rollback_ok && dirs_rollback_ok;
331                return Err(AftError::IoError {
332                    path: key.display().to_string(),
333                    message: format!(
334                        "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
335                        e,
336                        !rollback_ok,
337                        rollback_ok
338                    ),
339                });
340            }
341            written.push((key.clone(), existing_content.clone()));
342        }
343
344        let mut deleted_tombstones = Vec::new();
345        for (key, _, existing_content) in &tombstone_targets {
346            match std::fs::remove_file(key) {
347                Ok(()) => deleted_tombstones.push((key.clone(), existing_content.clone())),
348                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
349                    deleted_tombstones.push((key.clone(), None));
350                }
351                Err(e) => {
352                    let files_rollback_ok = rollback_transactional_restore(&written, None);
353                    let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
354                    let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
355                    let rollback_ok =
356                        files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
357                    return Err(AftError::IoError {
358                        path: key.display().to_string(),
359                        message: format!(
360                            "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
361                            e,
362                            !rollback_ok,
363                            rollback_ok
364                        ),
365                    });
366                }
367            }
368        }
369
370        let mut restored = Vec::new();
371        let mut warnings = Vec::new();
372        for (key, entry, warning, _) in content_targets {
373            self.commit_restored_backup(session, &key);
374            if let Some(warning) = warning {
375                warnings.push(format!("{}: {}", key.display(), warning));
376            }
377            restored.push(RestoredFile {
378                path: key,
379                backup_id: entry.backup_id,
380            });
381        }
382        for (key, _, _) in tombstone_targets {
383            self.commit_restored_backup(session, &key);
384        }
385        self.touch_session(session);
386
387        Ok(RestoredOperation {
388            op_id,
389            restored,
390            warnings,
391        })
392    }
393
394    /// Pop the most recent backup for `(session, path)` and restore the file.
395    /// Returns `(entry, optional_warning)`.
396    pub fn restore_latest(
397        &mut self,
398        session: &str,
399        path: &Path,
400    ) -> Result<(BackupEntry, Option<String>), AftError> {
401        let key = canonicalize_key(path);
402
403        // Try memory first
404        let in_memory = self
405            .entries
406            .get(session)
407            .and_then(|s| s.get(&key))
408            .map_or(false, |s| !s.is_empty());
409        if in_memory {
410            let warning = self.check_external_modification(session, &key, path);
411            let result = self
412                .do_restore(session, &key, path)
413                .map(|(entry, _)| (entry, warning));
414            if result.is_ok() {
415                self.touch_session(session);
416            }
417            return result;
418        }
419
420        // Try disk fallback
421        if self.load_from_disk_if_needed(session, &key) {
422            // Check for external modification
423            let warning = self.check_external_modification(session, &key, path);
424            let (entry, _) = self.do_restore(session, &key, path)?;
425            self.touch_session(session);
426            return Ok((entry, warning));
427        }
428
429        Err(AftError::NoUndoHistory {
430            path: path.display().to_string(),
431        })
432    }
433
434    /// Return the backup history for `(session, path)` (oldest first).
435    pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
436        let key = canonicalize_key(path);
437        self.entries
438            .get(session)
439            .and_then(|s| s.get(&key))
440            .cloned()
441            .unwrap_or_default()
442    }
443
444    /// Return the number of on-disk backup entries for `(session, file)`.
445    pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
446        let key = canonicalize_key(path);
447        self.disk_index
448            .get(session)
449            .and_then(|s| s.get(&key))
450            .map(|m| m.count)
451            .unwrap_or(0)
452    }
453
454    /// Return all files that have at least one backup entry in this session
455    /// (memory + disk). Other sessions' files are not visible.
456    pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
457        let mut files: std::collections::HashSet<PathBuf> = self
458            .entries
459            .get(session)
460            .map(|s| s.keys().cloned().collect())
461            .unwrap_or_default();
462        if let Some(disk) = self.disk_index.get(session) {
463            for key in disk.keys() {
464                files.insert(key.clone());
465            }
466        }
467        files.into_iter().collect()
468    }
469
470    /// Return all session namespaces that currently have any backup state
471    /// (memory or disk). Exposed for `/aft-status` aggregate reporting.
472    pub fn sessions_with_backups(&self) -> Vec<String> {
473        let mut sessions: std::collections::HashSet<String> =
474            self.entries.keys().cloned().collect();
475        for s in self.disk_index.keys() {
476            sessions.insert(s.clone());
477        }
478        sessions.into_iter().collect()
479    }
480
481    /// Total on-disk bytes across all sessions (best-effort, reads metadata only).
482    /// Used by `/aft-status` to surface storage footprint.
483    pub fn total_disk_bytes(&self) -> u64 {
484        let mut total = 0u64;
485        for session_dirs in self.disk_index.values() {
486            for meta in session_dirs.values() {
487                if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
488                    for entry in read_dir.flatten() {
489                        if let Ok(m) = entry.metadata() {
490                            if m.is_file() {
491                                total += m.len();
492                            }
493                        }
494                    }
495                }
496            }
497        }
498        total
499    }
500
501    fn next_id_and_order(&self) -> (String, u128) {
502        let n = self.counter.fetch_add(1, Ordering::Relaxed);
503        let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
504        (format!("backup-{}", n), order)
505    }
506
507    pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
508        let keys: Vec<PathBuf> = self
509            .entries
510            .get(session)
511            .map(|files| files.keys().cloned().collect())
512            .unwrap_or_default();
513
514        for key in keys {
515            let mut remove_key = false;
516            let mut remaining_stack = None;
517            if let Some(session_entries) = self.entries.get_mut(session) {
518                if let Some(stack) = session_entries.get_mut(&key) {
519                    while stack
520                        .last()
521                        .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
522                    {
523                        stack.pop();
524                    }
525                    if stack.is_empty() {
526                        remove_key = true;
527                    } else {
528                        remaining_stack = Some(stack.clone());
529                    }
530                }
531                if remove_key {
532                    session_entries.remove(&key);
533                }
534            }
535
536            if remove_key {
537                self.remove_disk_backups(session, &key);
538            } else if let Some(stack) = remaining_stack {
539                self.write_snapshot_to_disk(session, &key, &stack);
540            }
541        }
542
543        if self
544            .entries
545            .get(session)
546            .is_some_and(|session_entries| session_entries.is_empty())
547        {
548            self.entries.remove(session);
549        }
550    }
551
552    fn touch_session(&mut self, session: &str) {
553        let now = current_timestamp();
554        self.session_meta
555            .entry(session.to_string())
556            .or_default()
557            .last_accessed = now;
558        self.write_session_marker(session, now);
559    }
560
561    // ---- Internal helpers ----
562
563    fn do_restore(
564        &mut self,
565        session: &str,
566        key: &Path,
567        path: &Path,
568    ) -> Result<(BackupEntry, Option<String>), AftError> {
569        let session_entries =
570            self.entries
571                .get_mut(session)
572                .ok_or_else(|| AftError::NoUndoHistory {
573                    path: path.display().to_string(),
574                })?;
575        let stack = session_entries
576            .get_mut(key)
577            .ok_or_else(|| AftError::NoUndoHistory {
578                path: path.display().to_string(),
579            })?;
580
581        let entry = stack
582            .last()
583            .cloned()
584            .ok_or_else(|| AftError::NoUndoHistory {
585                path: path.display().to_string(),
586            })?;
587
588        match entry.kind {
589            BackupEntryKind::Content => {
590                // Ensure parent directory exists. This matters when restoring an
591                // operation that deleted a directory tree — the parent directories
592                // are gone by the time we try to write the file content back.
593                if let Some(parent) = path.parent() {
594                    if !parent.as_os_str().is_empty() {
595                        std::fs::create_dir_all(parent).map_err(|e| AftError::IoError {
596                            path: parent.display().to_string(),
597                            message: e.to_string(),
598                        })?;
599                    }
600                }
601                std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
602                    path: path.display().to_string(),
603                    message: e.to_string(),
604                })?;
605            }
606            BackupEntryKind::Tombstone => match std::fs::remove_file(path) {
607                Ok(()) => {}
608                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
609                Err(e) => {
610                    return Err(AftError::IoError {
611                        path: path.display().to_string(),
612                        message: e.to_string(),
613                    });
614                }
615            },
616        }
617
618        stack.pop();
619        if stack.is_empty() {
620            session_entries.remove(key);
621            // Also prune the session map when its last file is gone.
622            if session_entries.is_empty() {
623                self.entries.remove(session);
624            }
625            self.remove_disk_backups(session, key);
626        } else {
627            let stack_clone = self
628                .entries
629                .get(session)
630                .and_then(|s| s.get(key))
631                .cloned()
632                .unwrap_or_default();
633            self.write_snapshot_to_disk(session, key, &stack_clone);
634        }
635
636        Ok((entry, None))
637    }
638
639    fn commit_restored_backup(&mut self, session: &str, key: &Path) {
640        let mut remove_key = false;
641        let mut remove_session = false;
642        let mut remaining_stack = None;
643
644        if let Some(session_entries) = self.entries.get_mut(session) {
645            if let Some(stack) = session_entries.get_mut(key) {
646                stack.pop();
647                if stack.is_empty() {
648                    remove_key = true;
649                } else {
650                    remaining_stack = Some(stack.clone());
651                }
652            }
653
654            if remove_key {
655                session_entries.remove(key);
656                remove_session = session_entries.is_empty();
657            }
658        }
659
660        if remove_session {
661            self.entries.remove(session);
662        }
663
664        if remove_key {
665            self.remove_disk_backups(session, key);
666        } else if let Some(stack) = remaining_stack {
667            self.write_snapshot_to_disk(session, key, &stack);
668        }
669    }
670
671    fn check_external_modification(
672        &self,
673        session: &str,
674        key: &Path,
675        path: &Path,
676    ) -> Option<String> {
677        if let (Some(stack), Ok(current)) = (
678            self.entries.get(session).and_then(|s| s.get(key)),
679            std::fs::read_to_string(path),
680        ) {
681            if let Some(latest) = stack.last() {
682                if latest.content != current {
683                    return Some("file was modified externally since last backup".to_string());
684                }
685            }
686        }
687        None
688    }
689
690    // ---- Disk persistence ----
691
692    fn backups_dir(&self) -> Option<PathBuf> {
693        self.storage_dir.as_ref().map(|d| d.join("backups"))
694    }
695
696    fn session_dir(&self, session: &str) -> Option<PathBuf> {
697        self.backups_dir()
698            .map(|d| d.join(Self::session_hash(session)))
699    }
700
701    fn session_hash(session: &str) -> String {
702        hash_session(session)
703    }
704
705    fn path_hash(key: &Path) -> String {
706        // v0.16.0 intentionally switched from DefaultHasher to SHA-256 for
707        // stable on-disk names. Existing DefaultHasher backup directories are
708        // not migrated: backups are short-lived/session-scoped, so one-time
709        // loss of pre-upgrade undo history is acceptable.
710        stable_hash_16(key.to_string_lossy().as_bytes())
711    }
712
713    fn write_session_marker(&self, session: &str, last_accessed: u64) {
714        let Some(session_dir) = self.session_dir(session) else {
715            return;
716        };
717        if let Err(e) = std::fs::create_dir_all(&session_dir) {
718            crate::slog_warn!("failed to create session dir: {}", e);
719            return;
720        }
721        let marker = session_dir.join("session.json");
722        let json = serde_json::json!({
723            "schema_version": SCHEMA_VERSION,
724            "session_id": session,
725            "last_accessed": last_accessed,
726        });
727        if let Ok(s) = serde_json::to_string_pretty(&json) {
728            let tmp = session_dir.join("session.json.tmp");
729            if std::fs::write(&tmp, s).is_ok() {
730                let _ = std::fs::rename(&tmp, marker);
731            }
732        }
733    }
734
735    fn gc_stale_sessions(&mut self, ttl_hours: u32) {
736        let backups_dir = match self.backups_dir() {
737            Some(d) if d.exists() => d,
738            _ => return,
739        };
740        let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
741        let cutoff = current_timestamp().saturating_sub(ttl_secs);
742        let entries = match std::fs::read_dir(&backups_dir) {
743            Ok(entries) => entries,
744            Err(_) => return,
745        };
746
747        for entry in entries.flatten() {
748            let session_dir = entry.path();
749            if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
750                continue;
751            }
752            let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
753                continue;
754            };
755            if last_accessed >= cutoff {
756                continue;
757            }
758            if let Err(e) = std::fs::remove_dir_all(&session_dir) {
759                crate::slog_warn!(
760                    "failed to remove stale backup session {}: {}",
761                    session_dir.display(),
762                    e
763                );
764            } else {
765                crate::slog_warn!(
766                    "removed stale backup session {} (last_accessed={})",
767                    session_dir.display(),
768                    last_accessed
769                );
770            }
771        }
772    }
773
774    /// One-time migration: move pre-session flat layout into the default
775    /// session namespace. Called from `set_storage_dir` so existing backups
776    /// survive the upgrade.
777    ///
778    /// Detection: any directory directly under `backups/` that contains a
779    /// `meta.json` (as opposed to a `session.json` marker or subdirectories)
780    /// is treated as a legacy entry.
781    fn migrate_legacy_layout_if_needed(&mut self) {
782        let backups_dir = match self.backups_dir() {
783            Some(d) if d.exists() => d,
784            _ => return,
785        };
786        let default_session_dir =
787            backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
788
789        let entries = match std::fs::read_dir(&backups_dir) {
790            Ok(e) => e,
791            Err(_) => return,
792        };
793        let mut migrated = 0usize;
794        for entry in entries.flatten() {
795            let entry_path = entry.path();
796            // Skip non-directories and already-sessionized layouts.
797            if !entry_path.is_dir() {
798                continue;
799            }
800            if entry_path == default_session_dir {
801                continue;
802            }
803            let meta_path = entry_path.join("meta.json");
804            if !meta_path.exists() {
805                continue; // Already a session-hash dir (contains per-path subdirs), skip
806            }
807            // This is a legacy flat-layout path-hash directory. Move it under
808            // the default session namespace.
809            if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
810                crate::slog_warn!("failed to create default session dir: {}", e);
811                return;
812            }
813            let leaf = match entry_path.file_name() {
814                Some(n) => n,
815                None => continue,
816            };
817            let target = default_session_dir.join(leaf);
818            if target.exists() {
819                // Already migrated on a prior run that was interrupted —
820                // leave both and let the regular load pick up the target.
821                continue;
822            }
823            match std::fs::rename(&entry_path, &target) {
824                Ok(()) => {
825                    // Bump meta.json to include session_id + schema_version.
826                    Self::upgrade_meta_file(
827                        &target.join("meta.json"),
828                        crate::protocol::DEFAULT_SESSION_ID,
829                    );
830                    migrated += 1;
831                }
832                Err(e) => {
833                    crate::slog_warn!(
834                        "failed to migrate legacy backup {}: {}",
835                        entry_path.display(),
836                        e
837                    );
838                }
839            }
840        }
841        if migrated > 0 {
842            crate::slog_info!(
843                "migrated {} legacy backup entries into default session namespace",
844                migrated
845            );
846            // Write a session.json marker so future scans don't re-migrate.
847            let marker = default_session_dir.join("session.json");
848            let json = serde_json::json!({
849                "schema_version": SCHEMA_VERSION,
850                "session_id": crate::protocol::DEFAULT_SESSION_ID,
851                "last_accessed": current_timestamp(),
852            });
853            if let Ok(s) = serde_json::to_string_pretty(&json) {
854                let _ = std::fs::write(&marker, s);
855            }
856        }
857    }
858
859    fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
860        let content = match std::fs::read_to_string(meta_path) {
861            Ok(c) => c,
862            Err(_) => return,
863        };
864        let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
865            Ok(v) => v,
866            Err(_) => return,
867        };
868        if let Some(obj) = parsed.as_object_mut() {
869            let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
870            obj.insert(
871                "schema_version".to_string(),
872                serde_json::json!(SCHEMA_VERSION),
873            );
874            obj.insert("session_id".to_string(), serde_json::json!(session_id));
875            obj.entry("entries").or_insert_with(|| {
876                serde_json::Value::Array(
877                    (0..count)
878                        .map(|i| {
879                            serde_json::json!({
880                                "backup_id": format!("disk-{}", i),
881                                "timestamp": 0,
882                                "description": "restored from disk",
883                                "op_id": null,
884                            })
885                        })
886                        .collect(),
887                )
888            });
889        }
890        if let Ok(s) = serde_json::to_string_pretty(&parsed) {
891            let tmp = meta_path.with_extension("json.tmp");
892            if std::fs::write(&tmp, &s).is_ok() {
893                let _ = std::fs::rename(&tmp, meta_path);
894            }
895        }
896    }
897
898    fn load_disk_index(&mut self) {
899        let backups_dir = match self.backups_dir() {
900            Some(d) if d.exists() => d,
901            _ => return,
902        };
903        let session_dirs = match std::fs::read_dir(&backups_dir) {
904            Ok(e) => e,
905            Err(_) => return,
906        };
907        let mut total_entries = 0usize;
908        for session_entry in session_dirs.flatten() {
909            let session_dir = session_entry.path();
910            if !session_dir.is_dir() {
911                continue;
912            }
913            // Recover the session_id from session.json if present, otherwise skip
914            // (can't invert the hash to recover the original).
915            let session_id = match Self::read_session_marker(&session_dir) {
916                Some(session_id) => session_id,
917                None => {
918                    crate::slog_warn!(
919                        "skipping backup session dir without readable session marker: {}",
920                        session_dir.display()
921                    );
922                    continue;
923                }
924            };
925
926            let path_dirs = match std::fs::read_dir(&session_dir) {
927                Ok(e) => e,
928                Err(_) => continue,
929            };
930            let per_session = self.disk_index.entry(session_id.clone()).or_default();
931            for path_entry in path_dirs.flatten() {
932                let path_dir = path_entry.path();
933                if !path_dir.is_dir() {
934                    continue;
935                }
936                let meta_path = path_dir.join("meta.json");
937                if let Ok(content) = std::fs::read_to_string(&meta_path) {
938                    if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
939                        if let (Some(path_str), Some(count)) = (
940                            meta.get("path").and_then(|v| v.as_str()),
941                            meta.get("count").and_then(|v| v.as_u64()),
942                        ) {
943                            let key = PathBuf::from(path_str);
944                            if !is_loadable_backup_path(&key, &path_dir) {
945                                crate::slog_warn!(
946                                    "skipping backup entry with invalid path metadata: {}",
947                                    meta_path.display()
948                                );
949                                continue;
950                            }
951                            per_session.insert(
952                                key,
953                                DiskMeta {
954                                    dir: path_dir.clone(),
955                                    count: count as usize,
956                                },
957                            );
958                            total_entries += 1;
959                        }
960                    }
961                }
962            }
963            if per_session.is_empty() {
964                self.disk_index.remove(&session_id);
965            }
966        }
967        if total_entries > 0 {
968            crate::slog_info!(
969                "loaded {} backup entries across {} session(s) from disk",
970                total_entries,
971                self.disk_index.len()
972            );
973        }
974    }
975
976    fn read_session_marker(session_dir: &Path) -> Option<String> {
977        let marker = session_dir.join("session.json");
978        let content = std::fs::read_to_string(&marker).ok()?;
979        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
980        parsed
981            .get("session_id")
982            .and_then(|v| v.as_str())
983            .map(|s| s.to_string())
984    }
985
986    fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
987        let marker = session_dir.join("session.json");
988        let content = std::fs::read_to_string(&marker).ok()?;
989        let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
990        parsed.get("last_accessed").and_then(|v| v.as_u64())
991    }
992
993    fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
994        let disk_meta = match self
995            .disk_index
996            .get(session)
997            .and_then(|s| s.get(key))
998            .cloned()
999        {
1000            Some(m) if m.count > 0 => m,
1001            _ => return false,
1002        };
1003
1004        let mut entries = Vec::new();
1005        let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1006            .ok()
1007            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1008            .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1009            .unwrap_or_default();
1010
1011        for i in 0..disk_meta.count {
1012            let meta = entry_meta.get(i);
1013            let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1014                Some("tombstone") => BackupEntryKind::Tombstone,
1015                _ => BackupEntryKind::Content,
1016            };
1017            let content = match kind {
1018                BackupEntryKind::Content => {
1019                    let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1020                    match std::fs::read_to_string(&bak_path) {
1021                        Ok(content) => content,
1022                        Err(_) => continue,
1023                    }
1024                }
1025                BackupEntryKind::Tombstone => String::new(),
1026            };
1027            let backup_id = meta
1028                .and_then(|m| m.get("backup_id"))
1029                .and_then(|v| v.as_str())
1030                .map(str::to_string)
1031                .unwrap_or_else(|| format!("disk-{}", i));
1032            let timestamp = meta
1033                .and_then(|m| m.get("timestamp"))
1034                .and_then(|v| v.as_u64())
1035                .unwrap_or(0);
1036            let order = meta
1037                .and_then(|m| m.get("order"))
1038                .and_then(parse_order_value)
1039                .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1040            entries.push(BackupEntry {
1041                backup_id,
1042                content,
1043                timestamp,
1044                order,
1045                description: meta
1046                    .and_then(|m| m.get("description"))
1047                    .and_then(|v| v.as_str())
1048                    .unwrap_or("restored from disk")
1049                    .to_string(),
1050                op_id: meta
1051                    .and_then(|m| m.get("op_id"))
1052                    .and_then(|v| v.as_str())
1053                    .map(str::to_string),
1054                kind,
1055            });
1056        }
1057
1058        if entries.is_empty() {
1059            return false;
1060        }
1061
1062        if let Some(next_counter) = entries
1063            .iter()
1064            .filter_map(|entry| backup_sequence(&entry.backup_id))
1065            .max()
1066            .and_then(|max| max.checked_add(1))
1067        {
1068            let _ = self
1069                .counter
1070                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1071                    (current < next_counter).then_some(next_counter)
1072                });
1073        }
1074
1075        self.entries
1076            .entry(session.to_string())
1077            .or_default()
1078            .insert(key.to_path_buf(), entries);
1079        true
1080    }
1081
1082    fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1083        let session_dir = match self.session_dir(session) {
1084            Some(d) => d,
1085            None => return,
1086        };
1087
1088        // Ensure session dir + marker exist.
1089        if let Err(e) = std::fs::create_dir_all(&session_dir) {
1090            crate::slog_warn!("failed to create session dir: {}", e);
1091            return;
1092        }
1093        let marker = session_dir.join("session.json");
1094        if !marker.exists() {
1095            let json = serde_json::json!({
1096                "schema_version": SCHEMA_VERSION,
1097                "session_id": session,
1098                "last_accessed": current_timestamp(),
1099            });
1100            if let Ok(s) = serde_json::to_string_pretty(&json) {
1101                let _ = std::fs::write(&marker, s);
1102            }
1103        }
1104
1105        let hash = Self::path_hash(key);
1106        let dir = session_dir.join(&hash);
1107        if let Err(e) = std::fs::create_dir_all(&dir) {
1108            crate::slog_warn!("failed to create backup dir: {}", e);
1109            return;
1110        }
1111
1112        for (i, entry) in stack.iter().enumerate() {
1113            let bak_path = dir.join(format!("{}.bak", i));
1114            let tmp_path = dir.join(format!("{}.bak.tmp", i));
1115            match entry.kind {
1116                BackupEntryKind::Content => {
1117                    if std::fs::write(&tmp_path, &entry.content).is_ok() {
1118                        let _ = std::fs::rename(&tmp_path, &bak_path);
1119                    }
1120                }
1121                BackupEntryKind::Tombstone => {
1122                    let _ = std::fs::remove_file(&bak_path);
1123                    let _ = std::fs::remove_file(&tmp_path);
1124                }
1125            }
1126        }
1127
1128        // Clean up extra .bak files if stack shrank.
1129        for i in stack.len()..MAX_UNDO_DEPTH {
1130            let old = dir.join(format!("{}.bak", i));
1131            if old.exists() {
1132                let _ = std::fs::remove_file(&old);
1133            }
1134        }
1135
1136        let entries: Vec<serde_json::Value> = stack
1137            .iter()
1138            .map(|entry| {
1139                serde_json::json!({
1140                    "backup_id": entry.backup_id,
1141                    "timestamp": entry.timestamp,
1142                    "order": entry.order.to_string(),
1143                    "description": entry.description,
1144                    "op_id": entry.op_id,
1145                    "kind": match entry.kind {
1146                        BackupEntryKind::Content => "content",
1147                        BackupEntryKind::Tombstone => "tombstone",
1148                    },
1149                })
1150            })
1151            .collect();
1152        let meta = serde_json::json!({
1153            "schema_version": SCHEMA_VERSION,
1154            "session_id": session,
1155            "path": key.display().to_string(),
1156            "count": stack.len(),
1157            "entries": entries,
1158        });
1159        let meta_path = dir.join("meta.json");
1160        let meta_tmp = dir.join("meta.json.tmp");
1161        if let Ok(content) = serde_json::to_string_pretty(&meta) {
1162            if std::fs::write(&meta_tmp, &content).is_ok() {
1163                let _ = std::fs::rename(&meta_tmp, &meta_path);
1164            }
1165        }
1166
1167        // Keep the in-memory disk_index in sync so tracked_files() and
1168        // disk_history_count() immediately reflect what we just wrote.
1169        self.disk_index
1170            .entry(session.to_string())
1171            .or_default()
1172            .insert(
1173                key.to_path_buf(),
1174                DiskMeta {
1175                    dir,
1176                    count: stack.len(),
1177                },
1178            );
1179    }
1180
1181    fn remove_disk_backups(&mut self, session: &str, key: &Path) {
1182        let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
1183        if let Some(meta) = removed {
1184            let _ = std::fs::remove_dir_all(&meta.dir);
1185        } else if let Some(session_dir) = self.session_dir(session) {
1186            let hash = Self::path_hash(key);
1187            let dir = session_dir.join(&hash);
1188            if dir.exists() {
1189                let _ = std::fs::remove_dir_all(&dir);
1190            }
1191        }
1192
1193        // If this session has no more disk entries, drop the map slot (session
1194        // dir itself is kept so the marker survives future sessions).
1195        let empty = self
1196            .disk_index
1197            .get(session)
1198            .map(|s| s.is_empty())
1199            .unwrap_or(false);
1200        if empty {
1201            self.disk_index.remove(session);
1202        }
1203    }
1204}
1205
1206pub fn hash_session(session: &str) -> String {
1207    stable_hash_16(session.as_bytes())
1208}
1209
1210pub fn new_op_id() -> String {
1211    let mut bytes = [0u8; 4];
1212    if getrandom::fill(&mut bytes).is_err() {
1213        bytes = current_timestamp().to_le_bytes()[..4]
1214            .try_into()
1215            .unwrap_or([0; 4]);
1216    }
1217    let rand = u32::from_le_bytes(bytes);
1218    format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1219}
1220
1221fn canonicalize_key(path: &Path) -> PathBuf {
1222    std::fs::canonicalize(path).unwrap_or_else(|err| {
1223        log::debug!(
1224            "backup canonicalize_key fallback for {}: {}",
1225            path.display(),
1226            err
1227        );
1228        path.to_path_buf()
1229    })
1230}
1231
1232fn rollback_transactional_restore(
1233    written: &[(PathBuf, Option<Vec<u8>>)],
1234    attempted: Option<(&PathBuf, &Option<Vec<u8>>)>,
1235) -> bool {
1236    let mut ok = true;
1237
1238    if let Some((path, content)) = attempted {
1239        ok &= rollback_one_restore_write(path, content);
1240    }
1241
1242    for (path, content) in written.iter().rev() {
1243        ok &= rollback_one_restore_write(path, content);
1244    }
1245
1246    ok
1247}
1248
1249fn rollback_one_restore_write(path: &Path, content: &Option<Vec<u8>>) -> bool {
1250    match content {
1251        Some(content) => std::fs::write(path, content).is_ok(),
1252        None => match std::fs::remove_file(path) {
1253            Ok(()) => true,
1254            Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1255            Err(_) => false,
1256        },
1257    }
1258}
1259
1260fn rollback_deleted_tombstones(deleted: &[(PathBuf, Option<Vec<u8>>)]) -> bool {
1261    let mut ok = true;
1262    for (path, content) in deleted.iter().rev() {
1263        if let Some(content) = content {
1264            if let Some(parent) = path.parent() {
1265                if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
1266                    ok = false;
1267                    continue;
1268                }
1269            }
1270            if std::fs::write(path, content).is_err() {
1271                ok = false;
1272            }
1273        }
1274    }
1275    ok
1276}
1277
1278fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
1279    let mut dirs = Vec::new();
1280    let mut current = Some(parent);
1281
1282    while let Some(dir) = current {
1283        if dir.as_os_str().is_empty() || dir.exists() {
1284            break;
1285        }
1286        dirs.push(dir.to_path_buf());
1287        current = dir.parent();
1288    }
1289
1290    dirs
1291}
1292
1293fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
1294    let mut dirs = dirs.to_vec();
1295    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1296    dirs.dedup();
1297
1298    let mut ok = true;
1299    for dir in dirs {
1300        match std::fs::remove_dir(&dir) {
1301            Ok(()) => {}
1302            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1303            Err(_) => ok = false,
1304        }
1305    }
1306
1307    ok
1308}
1309
1310fn current_timestamp() -> u64 {
1311    std::time::SystemTime::now()
1312        .duration_since(std::time::UNIX_EPOCH)
1313        .unwrap_or_default()
1314        .as_secs()
1315}
1316
1317fn current_timestamp_nanos() -> u64 {
1318    let nanos = std::time::SystemTime::now()
1319        .duration_since(std::time::UNIX_EPOCH)
1320        .unwrap_or_default()
1321        .as_nanos();
1322    nanos.min(u128::from(u64::MAX)) as u64
1323}
1324
1325fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
1326    let nanos = timestamp_secs.saturating_mul(1_000_000_000);
1327    ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
1328}
1329
1330fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
1331    value
1332        .as_str()
1333        .and_then(|s| s.parse::<u128>().ok())
1334        .or_else(|| value.as_u64().map(u128::from))
1335}
1336
1337fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
1338    if !key.is_absolute()
1339        || key
1340            .components()
1341            .any(|c| matches!(c, std::path::Component::ParentDir))
1342    {
1343        return false;
1344    }
1345    let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
1346        return false;
1347    };
1348    BackupStore::path_hash(key) == dir_name
1349}
1350
1351fn stable_hash_16(bytes: &[u8]) -> String {
1352    let digest = Sha256::digest(bytes);
1353    digest[..8]
1354        .iter()
1355        .map(|byte| format!("{:02x}", byte))
1356        .collect()
1357}
1358
1359fn backup_sequence(backup_id: &str) -> Option<u64> {
1360    backup_id
1361        .strip_prefix("backup-")
1362        .or_else(|| backup_id.strip_prefix("disk-"))
1363        .and_then(|s| s.parse().ok())
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368    use super::*;
1369    use crate::protocol::DEFAULT_SESSION_ID;
1370    use std::fs;
1371    #[cfg(unix)]
1372    use std::os::unix::fs::PermissionsExt;
1373
1374    fn temp_file(name: &str, content: &str) -> PathBuf {
1375        let dir = std::env::temp_dir().join("aft_backup_tests");
1376        fs::create_dir_all(&dir).unwrap();
1377        let path = dir.join(name);
1378        fs::write(&path, content).unwrap();
1379        path
1380    }
1381
1382    #[test]
1383    fn snapshot_and_restore_round_trip() {
1384        let path = temp_file("round_trip.txt", "original");
1385        let mut store = BackupStore::new();
1386
1387        let id = store
1388            .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
1389            .unwrap();
1390        assert!(id.starts_with("backup-"));
1391
1392        fs::write(&path, "modified").unwrap();
1393        assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
1394
1395        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1396        assert_eq!(entry.content, "original");
1397        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1398    }
1399
1400    #[test]
1401    fn multiple_snapshots_preserve_order() {
1402        let path = temp_file("order.txt", "v1");
1403        let mut store = BackupStore::new();
1404
1405        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1406        fs::write(&path, "v2").unwrap();
1407        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1408        fs::write(&path, "v3").unwrap();
1409        store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
1410
1411        let history = store.history(DEFAULT_SESSION_ID, &path);
1412        assert_eq!(history.len(), 3);
1413        assert_eq!(history[0].content, "v1");
1414        assert_eq!(history[1].content, "v2");
1415        assert_eq!(history[2].content, "v3");
1416    }
1417
1418    #[test]
1419    fn restore_pops_from_stack() {
1420        let path = temp_file("pop.txt", "v1");
1421        let mut store = BackupStore::new();
1422
1423        store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1424        fs::write(&path, "v2").unwrap();
1425        store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1426
1427        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1428        assert_eq!(entry.description, "second");
1429        assert_eq!(entry.content, "v2");
1430
1431        let history = store.history(DEFAULT_SESSION_ID, &path);
1432        assert_eq!(history.len(), 1);
1433    }
1434
1435    #[test]
1436    fn empty_history_returns_empty_vec() {
1437        let store = BackupStore::new();
1438        let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
1439        assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
1440    }
1441
1442    #[test]
1443    fn snapshot_nonexistent_file_returns_error() {
1444        let mut store = BackupStore::new();
1445        let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
1446        assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
1447    }
1448
1449    #[test]
1450    fn tracked_files_lists_snapshotted_paths() {
1451        let path1 = temp_file("tracked1.txt", "a");
1452        let path2 = temp_file("tracked2.txt", "b");
1453        let mut store = BackupStore::new();
1454
1455        store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
1456        store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
1457        assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
1458    }
1459
1460    #[test]
1461    fn sessions_are_isolated() {
1462        let path = temp_file("isolated.txt", "original");
1463        let mut store = BackupStore::new();
1464
1465        store.snapshot("session_a", &path, "a's snapshot").unwrap();
1466
1467        // Session B sees no history for this file.
1468        assert!(store.history("session_b", &path).is_empty());
1469        assert_eq!(store.tracked_files("session_b").len(), 0);
1470
1471        // Session B's restore_latest fails with NoUndoHistory.
1472        let err = store.restore_latest("session_b", &path);
1473        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1474
1475        // Session A still sees its own snapshot.
1476        assert_eq!(store.history("session_a", &path).len(), 1);
1477        assert_eq!(store.tracked_files("session_a").len(), 1);
1478    }
1479
1480    #[test]
1481    fn per_session_per_file_cap_is_independent() {
1482        // Two sessions fill up their own stacks independently; hitting the cap
1483        // in session A does not evict anything from session B.
1484        let path = temp_file("cap_indep.txt", "v0");
1485        let mut store = BackupStore::new();
1486
1487        for i in 0..(MAX_UNDO_DEPTH + 5) {
1488            fs::write(&path, format!("a{}", i)).unwrap();
1489            store.snapshot("session_a", &path, "a").unwrap();
1490        }
1491        fs::write(&path, "b_initial").unwrap();
1492        store.snapshot("session_b", &path, "b").unwrap();
1493
1494        // Session A should be capped at MAX_UNDO_DEPTH.
1495        assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
1496        // Session B should still have its single entry.
1497        assert_eq!(store.history("session_b", &path).len(), 1);
1498    }
1499
1500    #[test]
1501    fn sessions_with_backups_lists_all_namespaces() {
1502        let path_a = temp_file("sessions_list_a.txt", "a");
1503        let path_b = temp_file("sessions_list_b.txt", "b");
1504        let mut store = BackupStore::new();
1505
1506        store.snapshot("alice", &path_a, "from alice").unwrap();
1507        store.snapshot("bob", &path_b, "from bob").unwrap();
1508
1509        let sessions = store.sessions_with_backups();
1510        assert_eq!(sessions.len(), 2);
1511        assert!(sessions.iter().any(|s| s == "alice"));
1512        assert!(sessions.iter().any(|s| s == "bob"));
1513    }
1514
1515    #[test]
1516    fn disk_persistence_survives_reload() {
1517        let dir = std::env::temp_dir().join("aft_backup_disk_test");
1518        let _ = fs::remove_dir_all(&dir);
1519        fs::create_dir_all(&dir).unwrap();
1520
1521        let file_path = temp_file("disk_persist.txt", "original");
1522
1523        // Create store with storage, snapshot under default session, drop.
1524        {
1525            let mut store = BackupStore::new();
1526            store.set_storage_dir(dir.clone(), 72);
1527            store
1528                .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
1529                .unwrap();
1530        }
1531
1532        // Modify the file externally.
1533        fs::write(&file_path, "externally modified").unwrap();
1534
1535        // Create new store, load from disk, restore.
1536        let mut store2 = BackupStore::new();
1537        store2.set_storage_dir(dir.clone(), 72);
1538
1539        let (entry, warning) = store2
1540            .restore_latest(DEFAULT_SESSION_ID, &file_path)
1541            .unwrap();
1542        assert_eq!(entry.content, "original");
1543        assert!(warning.is_some()); // modified externally
1544        assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1545
1546        let _ = fs::remove_dir_all(&dir);
1547    }
1548
1549    #[test]
1550    fn legacy_flat_layout_migrates_to_default_session() {
1551        // Simulate a pre-session on-disk layout (schema v1) and verify it's
1552        // moved under the default session namespace on set_storage_dir.
1553        let dir = std::env::temp_dir().join("aft_backup_migration_test");
1554        let _ = fs::remove_dir_all(&dir);
1555        fs::create_dir_all(&dir).unwrap();
1556        let backups = dir.join("backups");
1557        fs::create_dir_all(&backups).unwrap();
1558
1559        // Fake legacy entry for some path hash.
1560        let legacy_hash = "deadbeefcafebabe";
1561        let legacy_dir = backups.join(legacy_hash);
1562        fs::create_dir_all(&legacy_dir).unwrap();
1563        fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
1564        let legacy_meta = serde_json::json!({
1565            "path": "/tmp/migrated_file.txt",
1566            "count": 1,
1567        });
1568        fs::write(
1569            legacy_dir.join("meta.json"),
1570            serde_json::to_string_pretty(&legacy_meta).unwrap(),
1571        )
1572        .unwrap();
1573
1574        // Run migration.
1575        let mut store = BackupStore::new();
1576        store.set_storage_dir(dir.clone(), 72);
1577
1578        // After migration, the legacy dir should be gone from the top level,
1579        // and the entry should now live under the default-session hash dir.
1580        let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1581        assert!(default_session_dir.exists());
1582        assert!(default_session_dir.join(legacy_hash).exists());
1583        assert!(!backups.join(legacy_hash).exists());
1584
1585        // The upgraded meta.json should now include session_id + schema_version.
1586        let meta_content =
1587            fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
1588        let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
1589        assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
1590        assert_eq!(meta["schema_version"], SCHEMA_VERSION);
1591
1592        let _ = fs::remove_dir_all(&dir);
1593    }
1594
1595    #[test]
1596    fn set_storage_dir_removes_stale_backup_sessions() {
1597        let dir = std::env::temp_dir().join("aft_backup_gc_test");
1598        let _ = fs::remove_dir_all(&dir);
1599        let backups = dir.join("backups");
1600        fs::create_dir_all(&backups).unwrap();
1601
1602        let stale_session_dir = backups.join("stale-session");
1603        fs::create_dir_all(&stale_session_dir).unwrap();
1604        let stale_marker = serde_json::json!({
1605            "schema_version": SCHEMA_VERSION,
1606            "session_id": "stale",
1607            "last_accessed": 1,
1608        });
1609        fs::write(
1610            stale_session_dir.join("session.json"),
1611            serde_json::to_string_pretty(&stale_marker).unwrap(),
1612        )
1613        .unwrap();
1614
1615        let mut store = BackupStore::new();
1616        store.set_storage_dir(dir.clone(), 1);
1617
1618        assert!(!stale_session_dir.exists());
1619        let _ = fs::remove_dir_all(&dir);
1620    }
1621
1622    #[test]
1623    fn markerless_session_dir_is_skipped_not_mapped_to_default() {
1624        let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
1625        let _ = fs::remove_dir_all(&dir);
1626        let file_path = temp_file("markerless.txt", "original");
1627        let key = canonicalize_key(&file_path);
1628        let path_dir = dir
1629            .join("backups")
1630            .join("corrupt-session")
1631            .join("path-entry");
1632        fs::create_dir_all(&path_dir).unwrap();
1633        fs::write(path_dir.join("0.bak"), "original").unwrap();
1634        fs::write(
1635            path_dir.join("meta.json"),
1636            serde_json::to_string_pretty(&serde_json::json!({
1637                "schema_version": SCHEMA_VERSION,
1638                "session_id": "lost-session",
1639                "path": key.display().to_string(),
1640                "count": 1,
1641                "entries": [{
1642                    "backup_id": "disk-0",
1643                    "timestamp": 0,
1644                    "description": "corrupt marker test",
1645                    "op_id": null,
1646                    "kind": "content",
1647                }]
1648            }))
1649            .unwrap(),
1650        )
1651        .unwrap();
1652
1653        let mut store = BackupStore::new();
1654        store.set_storage_dir(dir.clone(), 72);
1655
1656        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
1657        assert!(store.sessions_with_backups().is_empty());
1658        let _ = fs::remove_dir_all(&dir);
1659    }
1660
1661    #[test]
1662    fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
1663        let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
1664        let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
1665        let _ = fs::remove_dir_all(&dir_a);
1666        let _ = fs::remove_dir_all(&dir_b);
1667        fs::create_dir_all(&dir_a).unwrap();
1668        fs::create_dir_all(&dir_b).unwrap();
1669        let file_path = temp_file("storage_reconfigure.txt", "original");
1670
1671        let mut store = BackupStore::new();
1672        store.set_storage_dir(dir_a.clone(), 72);
1673        store
1674            .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
1675            .unwrap();
1676        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
1677
1678        store.set_storage_dir(dir_b.clone(), 72);
1679
1680        assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
1681        assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
1682        let _ = fs::remove_dir_all(&dir_a);
1683        let _ = fs::remove_dir_all(&dir_b);
1684    }
1685
1686    #[test]
1687    fn restore_last_operation_restores_all_top_entries_for_same_op() {
1688        let path_a = temp_file("op_restore_a.txt", "a1");
1689        let path_b = temp_file("op_restore_b.txt", "b1");
1690        let mut store = BackupStore::new();
1691        let op_id = "op-test-00000001";
1692
1693        store
1694            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1695            .unwrap();
1696        store
1697            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1698            .unwrap();
1699        fs::write(&path_a, "a2").unwrap();
1700        fs::write(&path_b, "b2").unwrap();
1701
1702        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1703        assert_eq!(restored.op_id, op_id);
1704        assert_eq!(restored.restored.len(), 2);
1705        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
1706        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1707    }
1708
1709    #[test]
1710    fn restore_last_operation_deletes_tombstone_destination() {
1711        let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
1712        let _ = fs::remove_dir_all(&dir);
1713        fs::create_dir_all(&dir).unwrap();
1714        let source = dir.join("source.txt");
1715        let destination = dir.join("destination.txt");
1716        fs::write(&source, "original").unwrap();
1717
1718        let mut store = BackupStore::new();
1719        let op_id = "op-tombstone-delete";
1720        store
1721            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
1722            .unwrap();
1723        fs::rename(&source, &destination).unwrap();
1724        store
1725            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
1726            .unwrap();
1727
1728        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1729        assert_eq!(restored.op_id, op_id);
1730        assert_eq!(restored.restored.len(), 1);
1731        assert_eq!(fs::read_to_string(&source).unwrap(), "original");
1732        assert!(!destination.exists());
1733        let _ = fs::remove_dir_all(&dir);
1734    }
1735
1736    #[test]
1737    fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
1738        let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
1739        let _ = fs::remove_dir_all(&dir);
1740        fs::create_dir_all(&dir).unwrap();
1741        let source = dir.join("source.txt");
1742        let destination = dir.join("destination.txt");
1743        fs::write(&source, "original").unwrap();
1744
1745        let mut store = BackupStore::new();
1746        let op_id = "op-tombstone-atomic";
1747        store
1748            .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
1749            .unwrap();
1750        fs::rename(&source, &destination).unwrap();
1751        store
1752            .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
1753            .unwrap();
1754
1755        fs::remove_file(&destination).unwrap();
1756        fs::create_dir(&destination).unwrap();
1757        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
1758
1759        assert!(result.is_err(), "directory tombstone target should fail");
1760        assert!(
1761            !source.exists(),
1762            "source restore must roll back when destination deletion fails"
1763        );
1764        assert!(
1765            destination.is_dir(),
1766            "failed tombstone target should remain"
1767        );
1768        let _ = fs::remove_dir_all(&dir);
1769    }
1770
1771    // Uses Unix-specific PermissionsExt::set_mode to make a target file
1772    // read-only and force the Phase 1 write to fail. The atomicity logic
1773    // it exercises is platform-independent — Windows has different
1774    // mechanisms for forcing write failures, covered separately.
1775    #[cfg(unix)]
1776    #[test]
1777    fn restore_last_operation_is_atomic_when_a_write_fails() {
1778        let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
1779        let _ = fs::remove_dir_all(&dir);
1780        fs::create_dir_all(&dir).unwrap();
1781        let path_a = dir.join("a.txt");
1782        let path_b = dir.join("b.txt");
1783        let path_c = dir.join("c.txt");
1784        fs::write(&path_a, "a-original").unwrap();
1785        fs::write(&path_b, "b-original").unwrap();
1786        fs::write(&path_c, "c-original").unwrap();
1787
1788        let mut store = BackupStore::new();
1789        let op_id = "op-atomic-restore-01";
1790        let id_a = store
1791            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1792            .unwrap();
1793        let id_b = store
1794            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1795            .unwrap();
1796        let id_c = store
1797            .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
1798            .unwrap();
1799        fs::write(&path_a, "a-modified").unwrap();
1800        fs::write(&path_b, "b-modified").unwrap();
1801        fs::write(&path_c, "c-modified").unwrap();
1802
1803        let original_permissions = fs::metadata(&path_b).unwrap().permissions();
1804        let mut readonly_permissions = original_permissions.clone();
1805        readonly_permissions.set_mode(0o444);
1806        fs::set_permissions(&path_b, readonly_permissions).unwrap();
1807
1808        let result = store.restore_last_operation(DEFAULT_SESSION_ID);
1809        fs::set_permissions(&path_b, original_permissions).unwrap();
1810
1811        assert!(result.is_err());
1812        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
1813        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
1814        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
1815
1816        let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
1817        let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
1818        let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
1819        assert_eq!(history_a.len(), 1);
1820        assert_eq!(history_b.len(), 1);
1821        assert_eq!(history_c.len(), 1);
1822        assert_eq!(history_a[0].backup_id, id_a);
1823        assert_eq!(history_b[0].backup_id, id_b);
1824        assert_eq!(history_c[0].backup_id, id_c);
1825        assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
1826        assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
1827        assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
1828
1829        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1830        assert_eq!(restored.op_id, op_id);
1831        assert_eq!(restored.restored.len(), 3);
1832        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
1833        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
1834        assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
1835
1836        let _ = fs::remove_dir_all(&dir);
1837    }
1838
1839    #[test]
1840    fn restore_last_operation_restores_only_most_recent_op() {
1841        let path_a = temp_file("op_recent_a.txt", "a1");
1842        let path_b = temp_file("op_recent_b.txt", "b1");
1843        let mut store = BackupStore::new();
1844
1845        store
1846            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
1847            .unwrap();
1848        store
1849            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
1850            .unwrap();
1851        fs::write(&path_a, "a2").unwrap();
1852        fs::write(&path_b, "b2").unwrap();
1853
1854        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1855        assert_eq!(restored.op_id, "op-newer");
1856        assert_eq!(restored.restored.len(), 1);
1857        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
1858        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1859    }
1860
1861    #[test]
1862    fn restore_recreates_missing_parent_directories() {
1863        // Simulate aft_delete files: [dir/] with recursive: true:
1864        // the parent directories are gone by the time we restore.
1865        let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
1866        let _ = fs::remove_dir_all(&dir);
1867        let nested = dir.join("nested");
1868        fs::create_dir_all(&nested).unwrap();
1869        let path = nested.join("inner.txt");
1870        fs::write(&path, "original").unwrap();
1871
1872        let mut store = BackupStore::new();
1873        let op_id = "op-recreate-parents-01";
1874        store
1875            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
1876            .unwrap();
1877
1878        // Real-world delete sequence: tree is wiped before undo runs.
1879        fs::remove_dir_all(&dir).unwrap();
1880        assert!(!path.exists());
1881        assert!(!nested.exists());
1882        assert!(!dir.exists());
1883
1884        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1885        assert_eq!(restored.op_id, op_id);
1886        assert_eq!(restored.restored.len(), 1);
1887        assert!(
1888            path.exists(),
1889            "file should be restored even though both nested/ and dir/ were missing"
1890        );
1891        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1892
1893        let _ = fs::remove_dir_all(&dir);
1894    }
1895
1896    #[test]
1897    fn restore_last_operation_ignores_legacy_entries_without_op_id() {
1898        let path = temp_file("op_legacy_none.txt", "v1");
1899        let mut store = BackupStore::new();
1900
1901        store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
1902        fs::write(&path, "v2").unwrap();
1903
1904        let err = store.restore_last_operation(DEFAULT_SESSION_ID);
1905        assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1906        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
1907    }
1908
1909    #[test]
1910    fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
1911        let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
1912        let _ = fs::remove_dir_all(&dir);
1913        fs::create_dir_all(&dir).unwrap();
1914        let file_path = temp_file("v2_to_v3.txt", "original");
1915        let key = canonicalize_key(&file_path);
1916        let session_dir = dir
1917            .join("backups")
1918            .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1919        let path_dir = session_dir.join(BackupStore::path_hash(&key));
1920        fs::create_dir_all(&path_dir).unwrap();
1921        fs::write(path_dir.join("0.bak"), "original").unwrap();
1922        fs::write(
1923            session_dir.join("session.json"),
1924            serde_json::to_string_pretty(&serde_json::json!({
1925                "schema_version": 2,
1926                "session_id": DEFAULT_SESSION_ID,
1927                "last_accessed": current_timestamp(),
1928            }))
1929            .unwrap(),
1930        )
1931        .unwrap();
1932        fs::write(
1933            path_dir.join("meta.json"),
1934            serde_json::to_string_pretty(&serde_json::json!({
1935                "schema_version": 2,
1936                "session_id": DEFAULT_SESSION_ID,
1937                "path": key.display().to_string(),
1938                "count": 1,
1939            }))
1940            .unwrap(),
1941        )
1942        .unwrap();
1943
1944        let mut store = BackupStore::new();
1945        store.set_storage_dir(dir.clone(), 72);
1946        assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
1947        let history = store.history(DEFAULT_SESSION_ID, &file_path);
1948        assert_eq!(history.len(), 1);
1949        assert_eq!(history[0].op_id, None);
1950
1951        fs::write(&file_path, "second").unwrap();
1952        store
1953            .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
1954            .unwrap();
1955        let written: serde_json::Value =
1956            serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
1957        assert_eq!(written["schema_version"], SCHEMA_VERSION);
1958        assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
1959        assert_eq!(written["entries"][1]["op_id"], "op-v3");
1960        let _ = fs::remove_dir_all(&dir);
1961    }
1962
1963    #[test]
1964    fn per_file_restore_latest_still_works_with_op_ids() {
1965        let path = temp_file("op_per_file.txt", "v1");
1966        let mut store = BackupStore::new();
1967
1968        store
1969            .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
1970            .unwrap();
1971        fs::write(&path, "v2").unwrap();
1972
1973        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1974        assert_eq!(entry.op_id.as_deref(), Some("op-file"));
1975        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
1976    }
1977
1978    #[test]
1979    fn per_file_restore_latest_deletes_tombstone() {
1980        let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
1981        let _ = fs::remove_dir_all(&dir);
1982        fs::create_dir_all(&dir).unwrap();
1983        let path = dir.join("created.txt");
1984        fs::write(&path, "created").unwrap();
1985
1986        let mut store = BackupStore::new();
1987        let id = store
1988            .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
1989            .unwrap();
1990
1991        let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1992        assert_eq!(entry.backup_id, id);
1993        assert!(!path.exists(), "tombstone undo should delete the file");
1994        let _ = fs::remove_dir_all(&dir);
1995    }
1996
1997    #[test]
1998    fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
1999        let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
2000        let _ = fs::remove_dir_all(&dir);
2001        let backups = dir.join("backups");
2002        let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2003        let path_dir = session_dir.join("not-the-path-hash");
2004        fs::create_dir_all(&path_dir).unwrap();
2005        fs::write(
2006            session_dir.join("session.json"),
2007            serde_json::to_string_pretty(&serde_json::json!({
2008                "schema_version": SCHEMA_VERSION,
2009                "session_id": DEFAULT_SESSION_ID,
2010                "last_accessed": current_timestamp(),
2011            }))
2012            .unwrap(),
2013        )
2014        .unwrap();
2015        fs::write(path_dir.join("0.bak"), "outside").unwrap();
2016        fs::write(
2017            path_dir.join("meta.json"),
2018            serde_json::to_string_pretty(&serde_json::json!({
2019                "schema_version": SCHEMA_VERSION,
2020                "session_id": DEFAULT_SESSION_ID,
2021                "path": "/tmp/aft-malicious-overwrite-target.txt",
2022                "count": 1,
2023                "entries": [{
2024                    "backup_id": "backup-0",
2025                    "timestamp": current_timestamp(),
2026                    "order": "1",
2027                    "description": "tampered",
2028                    "op_id": "op-tampered",
2029                    "kind": "content",
2030                }]
2031            }))
2032            .unwrap(),
2033        )
2034        .unwrap();
2035
2036        let mut store = BackupStore::new();
2037        store.set_storage_dir(dir.clone(), 72);
2038
2039        assert!(store.sessions_with_backups().is_empty());
2040        let _ = fs::remove_dir_all(&dir);
2041    }
2042
2043    #[test]
2044    fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
2045        let path_a = temp_file("op_order_a.txt", "a1");
2046        let path_b = temp_file("op_order_b.txt", "b1");
2047        let mut store = BackupStore::new();
2048
2049        store
2050            .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
2051            .unwrap();
2052        store
2053            .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
2054            .unwrap();
2055        store
2056            .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
2057            .unwrap();
2058
2059        let key_a = canonicalize_key(&path_a);
2060        let key_b = canonicalize_key(&path_b);
2061        let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
2062        files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
2063        files.get_mut(&key_a).unwrap()[1].order = 1;
2064        files.get_mut(&key_b).unwrap()[0].order = 2;
2065
2066        fs::write(&path_a, "a2").unwrap();
2067        fs::write(&path_b, "b2").unwrap();
2068
2069        let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2070        assert_eq!(restored.op_id, "op-top");
2071        assert_eq!(restored.restored.len(), 1);
2072        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2073        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2074    }
2075}