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