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