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
14const SCHEMA_VERSION: u32 = 4;
19
20#[derive(Debug, Clone)]
22pub struct BackupEntry {
23 pub backup_id: String,
24 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#[derive(Debug)]
175pub struct BackupStore {
176 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
178 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
180 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 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 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 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 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 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 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 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 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 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 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 if self.load_from_disk_if_needed(session, &key) {
615 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 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 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 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 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 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 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 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 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 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 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 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; }
1205 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 continue;
1220 }
1221 match std::fs::rename(&entry_path, &target) {
1222 Ok(()) => {
1223 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 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 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 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 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 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 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 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 assert!(store.history("session_b", &path).is_empty());
2396 assert_eq!(store.tracked_files("session_b").len(), 0);
2397
2398 let err = store.restore_latest("session_b", &path);
2400 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2401
2402 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 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 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
2423 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 {
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 fs::write(&file_path, "externally modified").unwrap();
2461
2462 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()); 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 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 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 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 assert_ne!(
2514 first_id, second_id,
2515 "post-restart snapshot reused backup id {first_id}"
2516 );
2517
2518 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 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 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 let mut store = BackupStore::new();
2567 store.set_storage_dir(dir.clone(), 72);
2568
2569 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 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 #[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 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 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}