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
45#[derive(Debug, Clone)]
46struct BackupEntryHead {
47 order: u128,
48 op_id: Option<String>,
49}
50
51impl BackupEntryHead {
52 fn from_entry(entry: &BackupEntry) -> Self {
53 Self {
54 order: entry.order,
55 op_id: entry.op_id.clone(),
56 }
57 }
58
59 fn from_row(row: &BackupRow) -> Self {
60 Self {
61 order: row.order,
62 op_id: row.op_id.clone(),
63 }
64 }
65}
66
67impl BackupEntry {
68 fn to_backup_row(
69 &self,
70 harness: &str,
71 session_id: &str,
72 project_key: &str,
73 file_path: &str,
74 path_hash: &str,
75 backup_path: Option<&str>,
76 ) -> BackupRow {
77 BackupRow {
78 backup_id: self.backup_id.clone(),
79 harness: harness.to_string(),
80 session_id: session_id.to_string(),
81 project_key: project_key.to_string(),
82 op_id: self.op_id.clone(),
83 order: self.order,
84 file_path: file_path.to_string(),
85 path_hash: path_hash.to_string(),
86 backup_path: backup_path.map(str::to_string),
87 kind: match self.kind {
88 BackupEntryKind::Content => "content".to_string(),
89 BackupEntryKind::Symlink => "symlink".to_string(),
90 BackupEntryKind::Tombstone => "tombstone".to_string(),
91 },
92 description: self.description.clone(),
93 created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
94 is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
95 }
96 }
97}
98
99impl TryFrom<BackupRow> for BackupEntry {
100 type Error = std::io::Error;
101
102 fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
103 let kind = if row.is_tombstone || row.kind == "tombstone" {
104 BackupEntryKind::Tombstone
105 } else if row.kind == "symlink" {
106 BackupEntryKind::Symlink
107 } else {
108 BackupEntryKind::Content
109 };
110 let backup_path = row.backup_path.clone();
111 let disk_metadata = backup_path
112 .as_deref()
113 .and_then(|path| read_entry_disk_metadata(Path::new(path), &row.backup_id));
114 let content_bytes = match kind {
115 BackupEntryKind::Content | BackupEntryKind::Symlink => {
116 let backup_path = backup_path.ok_or_else(|| {
117 std::io::Error::new(
118 std::io::ErrorKind::NotFound,
119 format!("backup DB row {} has no backup_path", row.backup_id),
120 )
121 })?;
122 std::fs::read(backup_path)?
123 }
124 BackupEntryKind::Tombstone => Vec::new(),
125 };
126 let link_target = if kind == BackupEntryKind::Symlink {
127 disk_metadata
128 .as_ref()
129 .and_then(|metadata| metadata.link_target.clone())
130 .or_else(|| {
131 Some(PathBuf::from(
132 String::from_utf8_lossy(&content_bytes).into_owned(),
133 ))
134 })
135 } else {
136 None
137 };
138 let content = match kind {
139 BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
140 BackupEntryKind::Symlink => link_target
141 .as_ref()
142 .map(|target| target.display().to_string())
143 .unwrap_or_default(),
144 BackupEntryKind::Tombstone => String::new(),
145 };
146
147 Ok(BackupEntry {
148 backup_id: row.backup_id,
149 content,
150 content_bytes,
151 timestamp: u64::try_from(row.created_at).unwrap_or_default(),
152 order: row.order,
153 description: row.description,
154 op_id: row.op_id,
155 kind,
156 mode: disk_metadata.as_ref().and_then(|metadata| metadata.mode),
157 link_target,
158 created_dirs: disk_metadata
159 .map(|metadata| metadata.created_dirs)
160 .unwrap_or_default(),
161 })
162 }
163}
164
165#[derive(Debug, Clone)]
166pub struct RestoredOperation {
167 pub op_id: String,
168 pub restored: Vec<RestoredFile>,
169 pub warnings: Vec<String>,
170}
171
172#[derive(Debug, Clone)]
173pub struct RestoredFile {
174 pub path: PathBuf,
175 pub backup_id: String,
176}
177
178#[derive(Debug)]
197pub struct BackupStore {
198 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
200 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
202 session_meta: HashMap<String, SessionMeta>,
204 counter: AtomicU64,
205 storage_dir: Option<PathBuf>,
206 storage_harness: Option<String>,
207 db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
208 db_harness: RwLock<Option<String>>,
209 db_project_key: RwLock<Option<String>>,
210}
211
212#[derive(Debug, Clone)]
213struct DiskMeta {
214 dir: PathBuf,
215 count: usize,
216}
217
218#[derive(Debug, Clone, Default)]
219struct SessionMeta {
220 last_accessed: u64,
223}
224
225impl BackupStore {
226 pub fn new() -> Self {
227 BackupStore {
228 entries: HashMap::new(),
229 disk_index: HashMap::new(),
230 session_meta: HashMap::new(),
231 counter: AtomicU64::new(0),
232 storage_dir: None,
233 storage_harness: None,
234 db_pool: RwLock::new(None),
235 db_harness: RwLock::new(None),
236 db_project_key: RwLock::new(None),
237 }
238 }
239
240 pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
241 if let Ok(mut slot) = self.db_pool.write() {
242 *slot = Some(conn);
243 }
244 }
245
246 pub fn clear_db_pool(&self) {
247 if let Ok(mut slot) = self.db_pool.write() {
248 *slot = None;
249 }
250 }
251
252 pub fn set_db_harness(&self, harness: crate::harness::Harness) {
253 if let Ok(mut slot) = self.db_harness.write() {
254 *slot = Some(harness.as_str().to_string());
255 }
256 }
257
258 pub fn set_db_project_key(&self, project_key: String) {
259 if let Ok(mut slot) = self.db_project_key.write() {
260 *slot = Some(project_key);
261 }
262 }
263
264 pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
270 self.set_storage_dir_inner(dir, None, ttl_hours);
271 }
272
273 pub fn set_storage_dir_for_harness(
274 &mut self,
275 dir: PathBuf,
276 harness: crate::harness::Harness,
277 ttl_hours: u32,
278 ) {
279 self.set_storage_dir_inner(dir, Some(harness.as_str().to_string()), ttl_hours);
280 }
281
282 fn set_storage_dir_inner(&mut self, dir: PathBuf, harness: Option<String>, ttl_hours: u32) {
283 self.storage_dir = Some(dir);
284 self.storage_harness = harness;
285 self.entries.clear();
286 self.disk_index.clear();
287 self.session_meta.clear();
288 self.repair_root_backups_if_needed();
289 self.gc_stale_sessions(ttl_hours);
290 self.migrate_legacy_layout_if_needed();
291 self.load_disk_index();
292 }
293
294 pub fn snapshot(
296 &mut self,
297 session: &str,
298 path: &Path,
299 description: &str,
300 ) -> Result<String, AftError> {
301 self.snapshot_with_op(session, path, description, None)
302 }
303
304 pub fn snapshot_with_op(
308 &mut self,
309 session: &str,
310 path: &Path,
311 description: &str,
312 op_id: Option<&str>,
313 ) -> Result<String, AftError> {
314 let key = canonicalize_key(path);
315 self.ensure_stack_hydrated(session, &key);
320 let (id, order) = self.next_id_and_order();
321 let entry = backup_entry_from_path(path, id.clone(), order, description, op_id)?;
322
323 let session_entries = self.entries.entry(session.to_string()).or_default();
324 let stack = session_entries.entry(key.clone()).or_default();
325 if stack.len() >= MAX_UNDO_DEPTH {
326 stack.remove(0);
327 }
328 stack.push(entry);
329
330 let stack_clone = stack.clone();
332 self.write_snapshot_to_disk(session, &key, &stack_clone);
333 self.touch_session(session);
334
335 Ok(id)
336 }
337
338 pub fn snapshot_op_tombstone(
341 &mut self,
342 session: &str,
343 op_id: &str,
344 path: &Path,
345 description: &str,
346 ) -> Result<String, AftError> {
347 let key = canonicalize_key(path);
348 self.ensure_stack_hydrated(session, &key);
349 let created_dirs = path.parent().map(missing_parent_dirs).unwrap_or_default();
350 let (id, order) = self.next_id_and_order();
351 let entry = BackupEntry {
352 backup_id: id.clone(),
353 content: String::new(),
354 content_bytes: Vec::new(),
355 timestamp: current_timestamp(),
356 order,
357 description: description.to_string(),
358 op_id: Some(op_id.to_string()),
359 kind: BackupEntryKind::Tombstone,
360 mode: None,
361 link_target: None,
362 created_dirs,
363 };
364
365 let session_entries = self.entries.entry(session.to_string()).or_default();
366 let stack = session_entries.entry(key.clone()).or_default();
367 if stack.len() >= MAX_UNDO_DEPTH {
368 stack.remove(0);
369 }
370 stack.push(entry);
371
372 let stack_clone = stack.clone();
373 self.write_snapshot_to_disk(session, &key, &stack_clone);
374 self.touch_session(session);
375
376 Ok(id)
377 }
378
379 pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
382 match self.load_latest_operation_from_db(session) {
383 Some(Ok(true)) => {}
384 Some(Ok(false)) => {
385 crate::slog_info!(
386 "backup latest operation DB miss for session {}; falling back to disk",
387 session
388 );
389 self.load_all_disk_backups(session);
390 }
391 Some(Err(error)) => {
392 crate::slog_warn!(
393 "backup latest operation DB lookup failed for session {}; falling back to disk: {}",
394 session,
395 error
396 );
397 self.load_all_disk_backups(session);
398 }
399 None => {
400 crate::slog_info!(
401 "backup latest operation DB unavailable for session {}; falling back to disk",
402 session
403 );
404 self.load_all_disk_backups(session);
405 }
406 }
407
408 let mut latest: Option<(u128, String)> = None;
409 if let Some(files) = self.entries.get(session) {
410 for stack in files.values() {
411 if let Some(entry) = stack.last() {
412 if let Some(op_id) = &entry.op_id {
413 let order = entry.order;
414 if latest
415 .as_ref()
416 .map_or(true, |(latest_order, _)| order > *latest_order)
417 {
418 latest = Some((order, op_id.clone()));
419 }
420 }
421 }
422 }
423 }
424
425 let Some((_, op_id)) = latest else {
426 return Err(AftError::NoUndoHistory {
427 path: "operation".to_string(),
428 });
429 };
430
431 let mut keys_to_restore: Vec<PathBuf> = self
432 .entries
433 .get(session)
434 .map(|files| {
435 files
436 .iter()
437 .filter_map(|(key, stack)| {
438 stack.last().and_then(|entry| {
439 (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
440 })
441 })
442 .collect()
443 })
444 .unwrap_or_default();
445 keys_to_restore.sort();
446
447 if keys_to_restore.is_empty() {
448 return Err(AftError::NoUndoHistory {
449 path: "operation".to_string(),
450 });
451 }
452
453 let mut content_targets = Vec::new();
454 let mut tombstone_targets = Vec::new();
455 for key in &keys_to_restore {
456 let entry = self
457 .entries
458 .get(session)
459 .and_then(|files| files.get(key))
460 .and_then(|stack| stack.last())
461 .cloned()
462 .ok_or_else(|| AftError::NoUndoHistory {
463 path: key.display().to_string(),
464 })?;
465 match entry.kind {
466 BackupEntryKind::Content | BackupEntryKind::Symlink => {
467 let existing_state = capture_path_state(key)?;
468 let warning = self.check_external_modification(session, key, key);
469 content_targets.push((key.clone(), entry, warning, existing_state));
470 }
471 BackupEntryKind::Tombstone => {
472 let existing_state = capture_path_state(key)?;
473 tombstone_targets.push((key.clone(), entry, existing_state));
474 }
475 }
476 }
477
478 let mut created_dirs = Vec::new();
479 for (key, _, _, _) in &content_targets {
480 if let Some(parent) = key.parent() {
481 if !parent.as_os_str().is_empty() {
482 let missing_dirs = missing_parent_dirs(parent);
483 if let Err(e) = std::fs::create_dir_all(parent) {
484 let mut dirs_to_remove = created_dirs;
485 dirs_to_remove.extend(missing_dirs);
486 let rollback_ok = rollback_created_dirs(&dirs_to_remove);
487 return Err(AftError::IoError {
488 path: parent.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 created_dirs.extend(missing_dirs);
498 }
499 }
500 }
501
502 let mut written = Vec::new();
503 for (key, entry, _, existing_state) in &content_targets {
504 if let Err(e) = restore_entry_to_path(key, entry) {
505 let files_rollback_ok =
506 rollback_transactional_restore(&written, Some((key, existing_state)));
507 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
508 let rollback_ok = files_rollback_ok && dirs_rollback_ok;
509 return Err(AftError::IoError {
510 path: key.display().to_string(),
511 message: format!(
512 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
513 e,
514 !rollback_ok,
515 rollback_ok
516 ),
517 });
518 }
519 written.push((key.clone(), existing_state.clone()));
520 }
521
522 let mut deleted_tombstones = Vec::new();
523 for (key, _, existing_state) in &tombstone_targets {
524 match remove_tombstone_path(key) {
525 Ok(()) => deleted_tombstones.push((key.clone(), existing_state.clone())),
526 Err(e) => {
527 let files_rollback_ok = rollback_transactional_restore(&written, None);
528 let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
529 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
530 let rollback_ok =
531 files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
532 return Err(AftError::IoError {
533 path: key.display().to_string(),
534 message: format!(
535 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
536 e,
537 !rollback_ok,
538 rollback_ok
539 ),
540 });
541 }
542 }
543 }
544 let tombstone_created_dirs = tombstone_targets
545 .iter()
546 .flat_map(|(_, entry, _)| entry.created_dirs.iter().cloned())
547 .collect::<Vec<_>>();
548 remove_created_dirs_best_effort(&tombstone_created_dirs);
549
550 let mut restored = Vec::new();
551 let mut warnings = Vec::new();
552 for (key, entry, warning, _) in content_targets {
553 self.commit_restored_backup(session, &key);
554 if let Some(warning) = warning {
555 warnings.push(format!("{}: {}", key.display(), warning));
556 }
557 restored.push(RestoredFile {
558 path: key,
559 backup_id: entry.backup_id,
560 });
561 }
562 for (key, _, _) in tombstone_targets {
563 self.commit_restored_backup(session, &key);
564 }
565 self.touch_session(session);
566
567 Ok(RestoredOperation {
568 op_id,
569 restored,
570 warnings,
571 })
572 }
573
574 pub fn restore_latest(
577 &mut self,
578 session: &str,
579 path: &Path,
580 ) -> Result<(BackupEntry, Option<String>), AftError> {
581 let key = canonicalize_key(path);
582
583 match self.load_from_db_if_present(session, &key) {
584 Some(Ok(true)) => {
585 let warning = self.check_external_modification(session, &key, path);
586 let result = self
587 .do_restore(session, &key, path)
588 .map(|(entry, _)| (entry, warning));
589 if result.is_ok() {
590 self.touch_session(session);
591 }
592 return result;
593 }
594 Some(Ok(false)) => {
595 crate::slog_info!(
596 "backup DB miss for session {} path {}; falling back to disk",
597 session,
598 key.display()
599 );
600 }
601 Some(Err(error)) => {
602 crate::slog_warn!(
603 "backup DB lookup failed for session {} path {}; falling back to disk: {}",
604 session,
605 key.display(),
606 error
607 );
608 }
609 None => {
610 crate::slog_info!(
611 "backup DB unavailable for session {} path {}; falling back to disk",
612 session,
613 key.display()
614 );
615 }
616 }
617
618 let in_memory = self
620 .entries
621 .get(session)
622 .and_then(|s| s.get(&key))
623 .map_or(false, |s| !s.is_empty());
624 if in_memory {
625 let warning = self.check_external_modification(session, &key, path);
626 let result = self
627 .do_restore(session, &key, path)
628 .map(|(entry, _)| (entry, warning));
629 if result.is_ok() {
630 self.touch_session(session);
631 }
632 return result;
633 }
634
635 if self.load_from_disk_if_needed(session, &key) {
637 let warning = self.check_external_modification(session, &key, path);
639 let (entry, _) = self.do_restore(session, &key, path)?;
640 self.touch_session(session);
641 return Ok((entry, warning));
642 }
643
644 Err(AftError::NoUndoHistory {
645 path: path.display().to_string(),
646 })
647 }
648
649 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
651 let key = canonicalize_key(path);
652 match self.read_stack_from_db(session, &key) {
653 Some(Ok(stack)) if !stack.is_empty() => return stack,
654 Some(Ok(_)) => {
655 crate::slog_info!(
656 "backup history DB miss for session {} path {}; falling back to disk",
657 session,
658 key.display()
659 );
660 }
661 Some(Err(error)) => {
662 crate::slog_warn!(
663 "backup history DB lookup failed for session {} path {}; falling back to disk: {}",
664 session,
665 key.display(),
666 error
667 );
668 }
669 None => {
670 crate::slog_info!(
671 "backup history DB unavailable for session {} path {}; falling back to disk",
672 session,
673 key.display()
674 );
675 }
676 }
677
678 self.entries
679 .get(session)
680 .and_then(|s| s.get(&key))
681 .cloned()
682 .or_else(|| self.read_stack_from_disk(session, &key))
683 .unwrap_or_default()
684 }
685
686 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
688 let key = canonicalize_key(path);
689 self.disk_index
690 .get(session)
691 .and_then(|s| s.get(&key))
692 .map(|m| m.count)
693 .unwrap_or(0)
694 }
695
696 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
699 let mut files: std::collections::HashSet<PathBuf> = self
700 .entries
701 .get(session)
702 .map(|s| s.keys().cloned().collect())
703 .unwrap_or_default();
704 if let Some(disk) = self.disk_index.get(session) {
705 for key in disk.keys() {
706 files.insert(key.clone());
707 }
708 }
709 files.into_iter().collect()
710 }
711
712 pub fn preview_latest_path(&self, session: &str, path: &Path) -> Result<PathBuf, AftError> {
717 let key = canonicalize_key(path);
718 if self.latest_head_for_key(session, &key).is_some() {
719 Ok(key)
720 } else {
721 Err(AftError::NoUndoHistory {
722 path: path.display().to_string(),
723 })
724 }
725 }
726
727 pub fn preview_last_operation_paths(&self, session: &str) -> Result<Vec<PathBuf>, AftError> {
733 let mut heads_by_path: HashMap<PathBuf, BackupEntryHead> = self
734 .entries
735 .get(session)
736 .map(|files| {
737 files
738 .iter()
739 .filter_map(|(key, stack)| {
740 stack
741 .last()
742 .map(|entry| (key.clone(), BackupEntryHead::from_entry(entry)))
743 })
744 .collect()
745 })
746 .unwrap_or_default();
747
748 match self.read_latest_operation_heads_from_db(session) {
749 Some(Ok(db_heads)) if !db_heads.is_empty() => {
750 for (key, head) in db_heads {
751 heads_by_path.insert(key, head);
752 }
753 }
754 Some(Ok(_)) => {
755 crate::slog_info!(
756 "backup latest operation preview DB miss for session {}; falling back to disk",
757 session
758 );
759 self.merge_disk_stack_heads(session, &mut heads_by_path);
760 }
761 Some(Err(error)) => {
762 crate::slog_warn!(
763 "backup latest operation preview DB lookup failed for session {}; falling back to disk: {}",
764 session,
765 error
766 );
767 self.merge_disk_stack_heads(session, &mut heads_by_path);
768 }
769 None => {
770 crate::slog_info!(
771 "backup latest operation preview DB unavailable for session {}; falling back to disk",
772 session
773 );
774 self.merge_disk_stack_heads(session, &mut heads_by_path);
775 }
776 }
777
778 let mut latest: Option<(u128, String)> = None;
779 for head in heads_by_path.values() {
780 if let Some(op_id) = &head.op_id {
781 if latest
782 .as_ref()
783 .map_or(true, |(latest_order, _)| head.order > *latest_order)
784 {
785 latest = Some((head.order, op_id.clone()));
786 }
787 }
788 }
789
790 let Some((_, op_id)) = latest else {
791 return Err(AftError::NoUndoHistory {
792 path: "operation".to_string(),
793 });
794 };
795
796 let mut paths: Vec<PathBuf> = heads_by_path
797 .into_iter()
798 .filter_map(|(key, head)| {
799 (head.op_id.as_deref() == Some(op_id.as_str())).then_some(key)
800 })
801 .collect();
802 paths.sort();
803
804 if paths.is_empty() {
805 Err(AftError::NoUndoHistory {
806 path: "operation".to_string(),
807 })
808 } else {
809 Ok(paths)
810 }
811 }
812
813 pub fn sessions_with_backups(&self) -> Vec<String> {
816 let mut sessions: std::collections::HashSet<String> =
817 self.entries.keys().cloned().collect();
818 for s in self.disk_index.keys() {
819 sessions.insert(s.clone());
820 }
821 sessions.into_iter().collect()
822 }
823
824 pub fn total_disk_bytes(&self) -> u64 {
827 let mut total = 0u64;
828 for session_dirs in self.disk_index.values() {
829 for meta in session_dirs.values() {
830 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
831 for entry in read_dir.flatten() {
832 if let Ok(m) = entry.metadata() {
833 if m.is_file() {
834 total += m.len();
835 }
836 }
837 }
838 }
839 }
840 }
841 total
842 }
843
844 fn next_id_and_order(&self) -> (String, u128) {
845 let n = self.counter.fetch_add(1, Ordering::Relaxed);
846 let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
847 (format!("backup-{}", n), order)
848 }
849
850 fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
851 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
852 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
853 Some((pool, harness))
854 }
855
856 fn latest_head_for_key(&self, session: &str, key: &Path) -> Option<BackupEntryHead> {
857 match self.read_stack_heads_from_db(session, key) {
858 Some(Ok(stack)) if !stack.is_empty() => return stack.last().cloned(),
859 Some(Ok(_)) => {
860 crate::slog_info!(
861 "backup preview DB miss for session {} path {}; falling back to disk",
862 session,
863 key.display()
864 );
865 }
866 Some(Err(error)) => {
867 crate::slog_warn!(
868 "backup preview DB lookup failed for session {} path {}; falling back to disk: {}",
869 session,
870 key.display(),
871 error
872 );
873 }
874 None => {
875 crate::slog_info!(
876 "backup preview DB unavailable for session {} path {}; falling back to disk",
877 session,
878 key.display()
879 );
880 }
881 }
882
883 self.entries
884 .get(session)
885 .and_then(|files| files.get(key))
886 .and_then(|stack| stack.last())
887 .map(BackupEntryHead::from_entry)
888 .or_else(|| {
889 self.read_stack_heads_from_disk(session, key)
890 .and_then(|stack| stack.last().cloned())
891 })
892 }
893
894 fn merge_disk_stack_heads(
895 &self,
896 session: &str,
897 heads_by_path: &mut HashMap<PathBuf, BackupEntryHead>,
898 ) {
899 let disk_keys: Vec<PathBuf> = self
900 .disk_index
901 .get(session)
902 .map(|files| files.keys().cloned().collect())
903 .unwrap_or_default();
904 for key in disk_keys {
905 if let Some(head) = self
906 .read_stack_heads_from_disk(session, &key)
907 .and_then(|stack| stack.last().cloned())
908 {
909 heads_by_path.insert(key, head);
910 }
911 }
912 }
913
914 fn read_stack_heads_from_db(
915 &self,
916 session: &str,
917 key: &Path,
918 ) -> Option<Result<Vec<BackupEntryHead>, String>> {
919 let (pool, harness) = self.db_pool_and_harness()?;
920 let conn = match pool.lock() {
921 Ok(conn) => conn,
922 Err(_) => return Some(Err("db mutex poisoned".to_string())),
923 };
924 let path_hash = Self::path_hash(key);
925 Some(
926 crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
927 .map_err(|error| error.to_string())
928 .map(|rows| {
929 rows.iter()
930 .map(BackupEntryHead::from_row)
931 .collect::<Vec<_>>()
932 }),
933 )
934 }
935
936 fn read_latest_operation_heads_from_db(
937 &self,
938 session: &str,
939 ) -> Option<Result<HashMap<PathBuf, BackupEntryHead>, String>> {
940 let (pool, harness) = self.db_pool_and_harness()?;
941 let conn = match pool.lock() {
942 Ok(conn) => conn,
943 Err(_) => return Some(Err("db mutex poisoned".to_string())),
944 };
945 let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
946 {
947 Ok(Some(row)) => row,
948 Ok(None) => return Some(Ok(HashMap::new())),
949 Err(error) => return Some(Err(error.to_string())),
950 };
951 let Some(op_id) = latest.op_id else {
952 return Some(Ok(HashMap::new()));
953 };
954 let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
955 Ok(rows) => rows,
956 Err(error) => return Some(Err(error.to_string())),
957 };
958 if rows.is_empty() {
959 return Some(Ok(HashMap::new()));
960 }
961 let path_hashes: std::collections::HashSet<String> =
962 rows.into_iter().map(|row| row.path_hash).collect();
963 drop(conn);
964
965 let mut heads = HashMap::new();
966 for path_hash in path_hashes {
967 let conn = match pool.lock() {
968 Ok(conn) => conn,
969 Err(_) => return Some(Err("db mutex poisoned".to_string())),
970 };
971 let rows = match crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
972 {
973 Ok(rows) => rows,
974 Err(error) => return Some(Err(error.to_string())),
975 };
976 drop(conn);
977
978 let Some(file_path) = rows.first().map(|row| row.file_path.clone()) else {
979 continue;
980 };
981 let Some(head) = rows.last().map(BackupEntryHead::from_row) else {
982 continue;
983 };
984 heads.insert(PathBuf::from(file_path), head);
985 }
986
987 Some(Ok(heads))
988 }
989
990 fn read_stack_from_db(
991 &self,
992 session: &str,
993 key: &Path,
994 ) -> Option<Result<Vec<BackupEntry>, String>> {
995 let (pool, harness) = self.db_pool_and_harness()?;
996 let conn = match pool.lock() {
997 Ok(conn) => conn,
998 Err(_) => return Some(Err("db mutex poisoned".to_string())),
999 };
1000 let path_hash = Self::path_hash(key);
1001 Some(
1002 crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
1003 .map_err(|error| error.to_string())
1004 .and_then(|rows| {
1005 rows.into_iter()
1006 .map(BackupEntry::try_from)
1007 .collect::<Result<Vec<_>, _>>()
1008 .map_err(|error| error.to_string())
1009 }),
1010 )
1011 }
1012
1013 fn load_from_db_if_present(
1014 &mut self,
1015 session: &str,
1016 key: &Path,
1017 ) -> Option<Result<bool, String>> {
1018 match self.read_stack_from_db(session, key) {
1019 Some(Ok(stack)) if !stack.is_empty() => {
1020 self.update_counter_from_entries(&stack);
1021 self.entries
1022 .entry(session.to_string())
1023 .or_default()
1024 .insert(key.to_path_buf(), stack);
1025 Some(Ok(true))
1026 }
1027 Some(Ok(_)) => Some(Ok(false)),
1028 Some(Err(error)) => Some(Err(error)),
1029 None => None,
1030 }
1031 }
1032
1033 fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
1034 let (pool, harness) = self.db_pool_and_harness()?;
1035 let conn = match pool.lock() {
1036 Ok(conn) => conn,
1037 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1038 };
1039 let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
1040 {
1041 Ok(Some(row)) => row,
1042 Ok(None) => return Some(Ok(false)),
1043 Err(error) => return Some(Err(error.to_string())),
1044 };
1045 let Some(op_id) = latest.op_id else {
1046 return Some(Ok(false));
1047 };
1048 let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
1049 Ok(rows) => rows,
1050 Err(error) => return Some(Err(error.to_string())),
1051 };
1052 if rows.is_empty() {
1053 return Some(Ok(false));
1054 }
1055 let path_hashes: std::collections::HashSet<String> =
1056 rows.into_iter().map(|row| row.path_hash).collect();
1057 drop(conn);
1058
1059 let mut loaded_any = false;
1060 for path_hash in path_hashes {
1061 let conn = match pool.lock() {
1062 Ok(conn) => conn,
1063 Err(_) => return Some(Err("db mutex poisoned".to_string())),
1064 };
1065 let loaded =
1066 match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
1067 Ok(rows) => {
1068 let file_path = rows.first().map(|row| row.file_path.clone());
1069 rows.into_iter()
1070 .map(BackupEntry::try_from)
1071 .collect::<Result<Vec<_>, _>>()
1072 .map(|stack| (file_path, stack))
1073 .map_err(|error| error.to_string())
1074 }
1075 Err(error) => Err(error.to_string()),
1076 };
1077 drop(conn);
1078 let (file_path, stack) = match loaded {
1079 Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
1080 Ok(_) => continue,
1081 Err(error) => return Some(Err(error)),
1082 };
1083 let Some(file_path) = file_path else {
1084 return Some(Err(format!(
1085 "backup DB rows for path hash {path_hash} have no file path"
1086 )));
1087 };
1088 let key = PathBuf::from(file_path);
1089 self.update_counter_from_entries(&stack);
1090 self.entries
1091 .entry(session.to_string())
1092 .or_default()
1093 .insert(key, stack);
1094 loaded_any = true;
1095 }
1096
1097 Some(Ok(loaded_any))
1098 }
1099
1100 fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
1101 if let Some(next_counter) = entries
1102 .iter()
1103 .filter_map(|entry| backup_sequence(&entry.backup_id))
1104 .max()
1105 .and_then(|max| max.checked_add(1))
1106 {
1107 let _ = self
1108 .counter
1109 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1110 (current < next_counter).then_some(next_counter)
1111 });
1112 }
1113 }
1114
1115 pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
1116 let keys: Vec<PathBuf> = self
1117 .entries
1118 .get(session)
1119 .map(|files| files.keys().cloned().collect())
1120 .unwrap_or_default();
1121
1122 for key in keys {
1123 let mut remove_key = false;
1124 let mut remaining_stack = None;
1125 if let Some(session_entries) = self.entries.get_mut(session) {
1126 if let Some(stack) = session_entries.get_mut(&key) {
1127 while stack
1128 .last()
1129 .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
1130 {
1131 stack.pop();
1132 }
1133 if stack.is_empty() {
1134 remove_key = true;
1135 } else {
1136 remaining_stack = Some(stack.clone());
1137 }
1138 }
1139 if remove_key {
1140 session_entries.remove(&key);
1141 }
1142 }
1143
1144 if remove_key {
1145 self.remove_disk_backups(session, &key);
1146 } else if let Some(stack) = remaining_stack {
1147 self.write_snapshot_to_disk(session, &key, &stack);
1148 }
1149 }
1150
1151 if self
1152 .entries
1153 .get(session)
1154 .is_some_and(|session_entries| session_entries.is_empty())
1155 {
1156 self.entries.remove(session);
1157 }
1158 }
1159
1160 fn touch_session(&mut self, session: &str) {
1161 let now = current_timestamp();
1162 self.session_meta
1163 .entry(session.to_string())
1164 .or_default()
1165 .last_accessed = now;
1166 self.write_session_marker(session, now);
1167 }
1168
1169 fn do_restore(
1172 &mut self,
1173 session: &str,
1174 key: &Path,
1175 path: &Path,
1176 ) -> Result<(BackupEntry, Option<String>), AftError> {
1177 let session_entries =
1178 self.entries
1179 .get_mut(session)
1180 .ok_or_else(|| AftError::NoUndoHistory {
1181 path: path.display().to_string(),
1182 })?;
1183 let stack = session_entries
1184 .get_mut(key)
1185 .ok_or_else(|| AftError::NoUndoHistory {
1186 path: path.display().to_string(),
1187 })?;
1188
1189 let entry = stack
1190 .last()
1191 .cloned()
1192 .ok_or_else(|| AftError::NoUndoHistory {
1193 path: path.display().to_string(),
1194 })?;
1195
1196 match entry.kind {
1197 BackupEntryKind::Content | BackupEntryKind::Symlink => {
1198 restore_entry_to_path(path, &entry).map_err(|e| AftError::IoError {
1199 path: path.display().to_string(),
1200 message: e.to_string(),
1201 })?;
1202 }
1203 BackupEntryKind::Tombstone => {
1204 remove_tombstone_path(path).map_err(|e| AftError::IoError {
1205 path: path.display().to_string(),
1206 message: e.to_string(),
1207 })?;
1208 remove_created_dirs_best_effort(&entry.created_dirs);
1209 }
1210 }
1211
1212 stack.pop();
1213 if stack.is_empty() {
1214 session_entries.remove(key);
1215 if session_entries.is_empty() {
1217 self.entries.remove(session);
1218 }
1219 self.remove_disk_backups(session, key);
1220 } else {
1221 let stack_clone = self
1222 .entries
1223 .get(session)
1224 .and_then(|s| s.get(key))
1225 .cloned()
1226 .unwrap_or_default();
1227 self.write_snapshot_to_disk(session, key, &stack_clone);
1228 }
1229
1230 Ok((entry, None))
1231 }
1232
1233 fn commit_restored_backup(&mut self, session: &str, key: &Path) {
1234 let mut remove_key = false;
1235 let mut remove_session = false;
1236 let mut remaining_stack = None;
1237
1238 if let Some(session_entries) = self.entries.get_mut(session) {
1239 if let Some(stack) = session_entries.get_mut(key) {
1240 stack.pop();
1241 if stack.is_empty() {
1242 remove_key = true;
1243 } else {
1244 remaining_stack = Some(stack.clone());
1245 }
1246 }
1247
1248 if remove_key {
1249 session_entries.remove(key);
1250 remove_session = session_entries.is_empty();
1251 }
1252 }
1253
1254 if remove_session {
1255 self.entries.remove(session);
1256 }
1257
1258 if remove_key {
1259 self.remove_disk_backups(session, key);
1260 } else if let Some(stack) = remaining_stack {
1261 self.write_snapshot_to_disk(session, key, &stack);
1262 }
1263 }
1264
1265 fn check_external_modification(
1266 &self,
1267 session: &str,
1268 key: &Path,
1269 path: &Path,
1270 ) -> Option<String> {
1271 let stack = self.entries.get(session).and_then(|s| s.get(key))?;
1272 let latest = stack.last()?;
1273 let modified = match latest.kind {
1274 BackupEntryKind::Content => std::fs::read(path)
1275 .map(|current| current != latest.content_bytes)
1276 .unwrap_or(true),
1277 BackupEntryKind::Symlink => std::fs::read_link(path)
1278 .map(|target| latest.link_target.as_ref() != Some(&target))
1279 .unwrap_or(true),
1280 BackupEntryKind::Tombstone => false,
1281 };
1282 modified.then(|| "file was modified externally since last backup".to_string())
1283 }
1284
1285 fn backups_dir(&self) -> Option<PathBuf> {
1288 self.storage_dir
1289 .as_ref()
1290 .map(|dir| match &self.storage_harness {
1291 Some(harness) => dir.join(harness).join("backups"),
1292 None => dir.join("backups"),
1293 })
1294 }
1295
1296 fn session_dir(&self, session: &str) -> Option<PathBuf> {
1297 self.backups_dir()
1298 .map(|d| d.join(Self::session_hash(session)))
1299 }
1300
1301 fn session_hash(session: &str) -> String {
1302 hash_session(session)
1303 }
1304
1305 fn path_hash(key: &Path) -> String {
1306 stable_hash_16(key.to_string_lossy().as_bytes())
1311 }
1312
1313 fn write_session_marker(&self, session: &str, last_accessed: u64) {
1314 let Some(session_dir) = self.session_dir(session) else {
1315 return;
1316 };
1317 if let Err(e) = std::fs::create_dir_all(&session_dir) {
1318 crate::slog_warn!("failed to create session dir: {}", e);
1319 return;
1320 }
1321 let marker = session_dir.join("session.json");
1322 let json = serde_json::json!({
1323 "schema_version": SCHEMA_VERSION,
1324 "session_id": session,
1325 "last_accessed": last_accessed,
1326 });
1327 if let Ok(s) = serde_json::to_string_pretty(&json) {
1328 let tmp = session_dir.join("session.json.tmp");
1329 if std::fs::write(&tmp, s).is_ok() {
1330 let _ = std::fs::rename(&tmp, marker);
1331 }
1332 }
1333 }
1334
1335 fn repair_root_backups_if_needed(&self) {
1336 let (Some(storage_dir), Some(harness)) = (&self.storage_dir, &self.storage_harness) else {
1337 return;
1338 };
1339 let root_backups = storage_dir.join("backups");
1340 if !dir_has_entries(&root_backups) {
1341 return;
1342 }
1343 let harness_backups = storage_dir.join(harness).join("backups");
1344 if dir_has_entries(&harness_backups) {
1345 return;
1346 }
1347 if let Some(parent) = harness_backups.parent() {
1348 if let Err(error) = std::fs::create_dir_all(parent) {
1349 crate::slog_warn!(
1350 "failed to create harness backup dir {}: {}",
1351 parent.display(),
1352 error
1353 );
1354 return;
1355 }
1356 }
1357 if harness_backups.exists() {
1358 let _ = std::fs::remove_dir(&harness_backups);
1359 }
1360 match std::fs::rename(&root_backups, &harness_backups) {
1361 Ok(()) => {
1362 crate::slog_info!(
1363 "moved legacy root backups into harness namespace: {}",
1364 harness_backups.display()
1365 );
1366 }
1367 Err(error) => {
1368 crate::slog_warn!(
1369 "failed to move legacy root backups into {}: {}; trying child merge",
1370 harness_backups.display(),
1371 error
1372 );
1373 if std::fs::create_dir_all(&harness_backups).is_err() {
1374 return;
1375 }
1376 if let Ok(entries) = std::fs::read_dir(&root_backups) {
1377 for entry in entries.flatten() {
1378 let source = entry.path();
1379 let target = harness_backups.join(entry.file_name());
1380 if !target.exists() {
1381 let _ = std::fs::rename(source, target);
1382 }
1383 }
1384 }
1385 let _ = std::fs::remove_dir(&root_backups);
1386 }
1387 }
1388 }
1389
1390 fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1391 let backups_dir = match self.backups_dir() {
1392 Some(d) if d.exists() => d,
1393 _ => return,
1394 };
1395 let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1396 let cutoff = current_timestamp().saturating_sub(ttl_secs);
1397 let entries = match std::fs::read_dir(&backups_dir) {
1398 Ok(entries) => entries,
1399 Err(_) => return,
1400 };
1401
1402 for entry in entries.flatten() {
1403 let session_dir = entry.path();
1404 if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1405 continue;
1406 }
1407 let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1408 continue;
1409 };
1410 if last_accessed >= cutoff {
1411 continue;
1412 }
1413 if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1414 crate::slog_warn!(
1415 "failed to remove stale backup session {}: {}",
1416 session_dir.display(),
1417 e
1418 );
1419 } else {
1420 crate::slog_warn!(
1421 "removed stale backup session {} (last_accessed={})",
1422 session_dir.display(),
1423 last_accessed
1424 );
1425 }
1426 }
1427 }
1428
1429 fn migrate_legacy_layout_if_needed(&mut self) {
1437 let backups_dir = match self.backups_dir() {
1438 Some(d) if d.exists() => d,
1439 _ => return,
1440 };
1441 let default_session_dir =
1442 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1443
1444 let entries = match std::fs::read_dir(&backups_dir) {
1445 Ok(e) => e,
1446 Err(_) => return,
1447 };
1448 let mut migrated = 0usize;
1449 for entry in entries.flatten() {
1450 let entry_path = entry.path();
1451 if !entry_path.is_dir() {
1453 continue;
1454 }
1455 if entry_path == default_session_dir {
1456 continue;
1457 }
1458 let meta_path = entry_path.join("meta.json");
1459 if !meta_path.exists() {
1460 continue; }
1462 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1465 crate::slog_warn!("failed to create default session dir: {}", e);
1466 return;
1467 }
1468 let leaf = match entry_path.file_name() {
1469 Some(n) => n,
1470 None => continue,
1471 };
1472 let target = default_session_dir.join(leaf);
1473 if target.exists() {
1474 continue;
1477 }
1478 match std::fs::rename(&entry_path, &target) {
1479 Ok(()) => {
1480 Self::upgrade_meta_file(
1482 &target.join("meta.json"),
1483 crate::protocol::DEFAULT_SESSION_ID,
1484 );
1485 migrated += 1;
1486 }
1487 Err(e) => {
1488 crate::slog_warn!(
1489 "failed to migrate legacy backup {}: {}",
1490 entry_path.display(),
1491 e
1492 );
1493 }
1494 }
1495 }
1496 if migrated > 0 {
1497 crate::slog_info!(
1498 "migrated {} legacy backup entries into default session namespace",
1499 migrated
1500 );
1501 let marker = default_session_dir.join("session.json");
1503 let json = serde_json::json!({
1504 "schema_version": SCHEMA_VERSION,
1505 "session_id": crate::protocol::DEFAULT_SESSION_ID,
1506 "last_accessed": current_timestamp(),
1507 });
1508 if let Ok(s) = serde_json::to_string_pretty(&json) {
1509 let _ = std::fs::write(&marker, s);
1510 }
1511 }
1512 }
1513
1514 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1515 let content = match std::fs::read_to_string(meta_path) {
1516 Ok(c) => c,
1517 Err(_) => return,
1518 };
1519 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1520 Ok(v) => v,
1521 Err(_) => return,
1522 };
1523 if let Some(obj) = parsed.as_object_mut() {
1524 let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1525 obj.insert(
1526 "schema_version".to_string(),
1527 serde_json::json!(SCHEMA_VERSION),
1528 );
1529 obj.insert("session_id".to_string(), serde_json::json!(session_id));
1530 obj.entry("entries").or_insert_with(|| {
1531 serde_json::Value::Array(
1532 (0..count)
1533 .map(|i| {
1534 serde_json::json!({
1535 "backup_id": format!("disk-{}", i),
1536 "timestamp": 0,
1537 "description": "restored from disk",
1538 "op_id": null,
1539 })
1540 })
1541 .collect(),
1542 )
1543 });
1544 }
1545 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1546 let tmp = meta_path.with_extension("json.tmp");
1547 if std::fs::write(&tmp, &s).is_ok() {
1548 let _ = std::fs::rename(&tmp, meta_path);
1549 }
1550 }
1551 }
1552
1553 fn load_disk_index(&mut self) {
1554 let backups_dir = match self.backups_dir() {
1555 Some(d) if d.exists() => d,
1556 _ => return,
1557 };
1558 let session_dirs = match std::fs::read_dir(&backups_dir) {
1559 Ok(e) => e,
1560 Err(_) => return,
1561 };
1562 let mut total_entries = 0usize;
1563 let mut skipped_legacy = 0usize;
1564 for session_entry in session_dirs.flatten() {
1565 let session_dir = session_entry.path();
1566 if !session_dir.is_dir() {
1567 continue;
1568 }
1569 let session_id = match Self::read_session_marker(&session_dir) {
1572 Some(session_id) => session_id,
1573 None => {
1574 crate::slog_warn!(
1575 "skipping backup session dir without readable session marker: {}",
1576 session_dir.display()
1577 );
1578 continue;
1579 }
1580 };
1581
1582 let path_dirs = match std::fs::read_dir(&session_dir) {
1583 Ok(e) => e,
1584 Err(_) => continue,
1585 };
1586 let per_session = self.disk_index.entry(session_id.clone()).or_default();
1587 for path_entry in path_dirs.flatten() {
1588 let path_dir = path_entry.path();
1589 if !path_dir.is_dir() {
1590 continue;
1591 }
1592 let meta_path = path_dir.join("meta.json");
1593 if let Ok(content) = std::fs::read_to_string(&meta_path) {
1594 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1595 if let (Some(path_str), Some(count)) = (
1596 meta.get("path").and_then(|v| v.as_str()),
1597 meta.get("count").and_then(|v| v.as_u64()),
1598 ) {
1599 let key = PathBuf::from(path_str);
1600 if !is_loadable_backup_path(&key, &path_dir) {
1601 skipped_legacy += 1;
1607 crate::slog_debug!(
1608 "skipping backup entry with invalid path metadata: {}",
1609 meta_path.display()
1610 );
1611 continue;
1612 }
1613 per_session.insert(
1614 key,
1615 DiskMeta {
1616 dir: path_dir.clone(),
1617 count: count as usize,
1618 },
1619 );
1620 total_entries += 1;
1621 }
1622 }
1623 }
1624 }
1625 if per_session.is_empty() {
1626 self.disk_index.remove(&session_id);
1627 }
1628 }
1629 if skipped_legacy > 0 {
1630 crate::slog_debug!(
1631 "skipped {} legacy backup entries with mismatched path-hash directories",
1632 skipped_legacy
1633 );
1634 }
1635 if total_entries > 0 {
1636 crate::slog_info!(
1637 "loaded {} backup entries across {} session(s) from disk",
1638 total_entries,
1639 self.disk_index.len()
1640 );
1641 }
1642 }
1643
1644 fn read_session_marker(session_dir: &Path) -> Option<String> {
1645 let marker = session_dir.join("session.json");
1646 let content = std::fs::read_to_string(&marker).ok()?;
1647 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1648 parsed
1649 .get("session_id")
1650 .and_then(|v| v.as_str())
1651 .map(|s| s.to_string())
1652 }
1653
1654 fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1655 let marker = session_dir.join("session.json");
1656 let content = std::fs::read_to_string(&marker).ok()?;
1657 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1658 parsed.get("last_accessed").and_then(|v| v.as_u64())
1659 }
1660
1661 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
1662 let Some(entries) = self.read_stack_from_disk(session, key) else {
1663 return false;
1664 };
1665
1666 self.update_counter_from_entries(&entries);
1667
1668 self.entries
1669 .entry(session.to_string())
1670 .or_default()
1671 .insert(key.to_path_buf(), entries);
1672 true
1673 }
1674
1675 fn ensure_stack_hydrated(&mut self, session: &str, key: &Path) {
1690 let already_in_memory = self
1691 .entries
1692 .get(session)
1693 .and_then(|files| files.get(key))
1694 .is_some_and(|stack| !stack.is_empty());
1695 if !already_in_memory {
1696 self.load_from_disk_if_needed(session, key);
1697 }
1698 }
1699
1700 fn load_all_disk_backups(&mut self, session: &str) {
1701 let disk_keys: Vec<PathBuf> = self
1702 .disk_index
1703 .get(session)
1704 .map(|files| files.keys().cloned().collect())
1705 .unwrap_or_default();
1706 for key in disk_keys {
1707 self.load_from_disk_if_needed(session, &key);
1708 }
1709 }
1710
1711 fn read_stack_heads_from_disk(
1712 &self,
1713 session: &str,
1714 key: &Path,
1715 ) -> Option<Vec<BackupEntryHead>> {
1716 let disk_meta = match self
1717 .disk_index
1718 .get(session)
1719 .and_then(|s| s.get(key))
1720 .cloned()
1721 {
1722 Some(m) if m.count > 0 => m,
1723 _ => return None,
1724 };
1725
1726 let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1727 .ok()
1728 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1729 .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1730 .unwrap_or_default();
1731
1732 let mut heads = Vec::new();
1733 for i in 0..disk_meta.count {
1734 let meta = entry_meta.get(i);
1735 let backup_id = meta
1736 .and_then(|m| m.get("backup_id"))
1737 .and_then(|v| v.as_str())
1738 .map(str::to_string)
1739 .unwrap_or_else(|| format!("disk-{}", i));
1740 let timestamp = meta
1741 .and_then(|m| m.get("timestamp"))
1742 .and_then(|v| v.as_u64())
1743 .unwrap_or(0);
1744 let order = meta
1745 .and_then(|m| m.get("order"))
1746 .and_then(parse_order_value)
1747 .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1748 heads.push(BackupEntryHead {
1749 order,
1750 op_id: meta
1751 .and_then(|m| m.get("op_id"))
1752 .and_then(|v| v.as_str())
1753 .map(str::to_string),
1754 });
1755 }
1756
1757 if heads.is_empty() {
1758 return None;
1759 }
1760 Some(heads)
1761 }
1762
1763 fn read_stack_from_disk(&self, session: &str, key: &Path) -> Option<Vec<BackupEntry>> {
1764 let disk_meta = match self
1765 .disk_index
1766 .get(session)
1767 .and_then(|s| s.get(key))
1768 .cloned()
1769 {
1770 Some(m) if m.count > 0 => m,
1771 _ => return None,
1772 };
1773
1774 let mut entries = Vec::new();
1775 let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1776 .ok()
1777 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1778 .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1779 .unwrap_or_default();
1780
1781 for i in 0..disk_meta.count {
1782 let meta = entry_meta.get(i);
1783 let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1784 Some("tombstone") => BackupEntryKind::Tombstone,
1785 Some("symlink") => BackupEntryKind::Symlink,
1786 _ => BackupEntryKind::Content,
1787 };
1788 let content_bytes = match kind {
1789 BackupEntryKind::Content | BackupEntryKind::Symlink => {
1790 let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1791 match std::fs::read(&bak_path) {
1792 Ok(content) => content,
1793 Err(_) => continue,
1794 }
1795 }
1796 BackupEntryKind::Tombstone => Vec::new(),
1797 };
1798 let link_target = if kind == BackupEntryKind::Symlink {
1799 meta.and_then(|m| m.get("link_target"))
1800 .and_then(|v| v.as_str())
1801 .map(PathBuf::from)
1802 .or_else(|| {
1803 Some(PathBuf::from(
1804 String::from_utf8_lossy(&content_bytes).into_owned(),
1805 ))
1806 })
1807 } else {
1808 None
1809 };
1810 let content = match kind {
1811 BackupEntryKind::Content => String::from_utf8_lossy(&content_bytes).into_owned(),
1812 BackupEntryKind::Symlink => link_target
1813 .as_ref()
1814 .map(|target| target.display().to_string())
1815 .unwrap_or_default(),
1816 BackupEntryKind::Tombstone => String::new(),
1817 };
1818 let backup_id = meta
1819 .and_then(|m| m.get("backup_id"))
1820 .and_then(|v| v.as_str())
1821 .map(str::to_string)
1822 .unwrap_or_else(|| format!("disk-{}", i));
1823 let timestamp = meta
1824 .and_then(|m| m.get("timestamp"))
1825 .and_then(|v| v.as_u64())
1826 .unwrap_or(0);
1827 let order = meta
1828 .and_then(|m| m.get("order"))
1829 .and_then(parse_order_value)
1830 .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1831 entries.push(BackupEntry {
1832 backup_id,
1833 content,
1834 content_bytes,
1835 timestamp,
1836 order,
1837 description: meta
1838 .and_then(|m| m.get("description"))
1839 .and_then(|v| v.as_str())
1840 .unwrap_or("restored from disk")
1841 .to_string(),
1842 op_id: meta
1843 .and_then(|m| m.get("op_id"))
1844 .and_then(|v| v.as_str())
1845 .map(str::to_string),
1846 kind,
1847 mode: meta
1848 .and_then(|m| m.get("mode"))
1849 .and_then(|v| v.as_u64())
1850 .and_then(|mode| u32::try_from(mode).ok()),
1851 link_target,
1852 created_dirs: meta
1853 .and_then(|m| m.get("created_dirs"))
1854 .and_then(|v| v.as_array())
1855 .map(|dirs| {
1856 dirs.iter()
1857 .filter_map(|dir| dir.as_str())
1858 .map(PathBuf::from)
1859 .collect()
1860 })
1861 .unwrap_or_default(),
1862 });
1863 }
1864
1865 if entries.is_empty() {
1866 return None;
1867 }
1868 Some(entries)
1869 }
1870
1871 fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1872 let session_dir = match self.session_dir(session) {
1873 Some(d) => d,
1874 None => return,
1875 };
1876
1877 if let Err(e) = std::fs::create_dir_all(&session_dir) {
1879 crate::slog_warn!("failed to create session dir: {}", e);
1880 return;
1881 }
1882 let marker = session_dir.join("session.json");
1883 if !marker.exists() {
1884 let json = serde_json::json!({
1885 "schema_version": SCHEMA_VERSION,
1886 "session_id": session,
1887 "last_accessed": current_timestamp(),
1888 });
1889 if let Ok(s) = serde_json::to_string_pretty(&json) {
1890 let _ = std::fs::write(&marker, s);
1891 }
1892 }
1893
1894 let hash = Self::path_hash(key);
1895 let dir = session_dir.join(&hash);
1896 if let Err(e) = std::fs::create_dir_all(&dir) {
1897 crate::slog_warn!("failed to create backup dir: {}", e);
1898 return;
1899 }
1900
1901 for (i, entry) in stack.iter().enumerate() {
1902 let bak_path = dir.join(format!("{}.bak", i));
1903 let tmp_path = dir.join(format!("{}.bak.tmp", i));
1904 match entry.kind {
1905 BackupEntryKind::Content => {
1906 if std::fs::write(&tmp_path, &entry.content_bytes).is_ok() {
1907 let _ = std::fs::rename(&tmp_path, &bak_path);
1908 }
1909 }
1910 BackupEntryKind::Symlink => {
1911 let target = entry
1912 .link_target
1913 .as_ref()
1914 .map(|target| target.as_os_str().to_string_lossy().as_bytes().to_vec())
1915 .unwrap_or_default();
1916 if std::fs::write(&tmp_path, target).is_ok() {
1917 let _ = std::fs::rename(&tmp_path, &bak_path);
1918 }
1919 }
1920 BackupEntryKind::Tombstone => {
1921 let _ = std::fs::remove_file(&bak_path);
1922 let _ = std::fs::remove_file(&tmp_path);
1923 }
1924 }
1925 }
1926
1927 for i in stack.len()..MAX_UNDO_DEPTH {
1929 let old = dir.join(format!("{}.bak", i));
1930 if old.exists() {
1931 let _ = std::fs::remove_file(&old);
1932 }
1933 }
1934
1935 let entries: Vec<serde_json::Value> = stack
1936 .iter()
1937 .map(|entry| {
1938 serde_json::json!({
1939 "backup_id": entry.backup_id,
1940 "timestamp": entry.timestamp,
1941 "order": entry.order.to_string(),
1942 "description": entry.description,
1943 "op_id": entry.op_id,
1944 "kind": match entry.kind {
1945 BackupEntryKind::Content => "content",
1946 BackupEntryKind::Symlink => "symlink",
1947 BackupEntryKind::Tombstone => "tombstone",
1948 },
1949 "mode": entry.mode,
1950 "link_target": entry.link_target.as_ref().map(|target| target.display().to_string()),
1951 "created_dirs": entry
1952 .created_dirs
1953 .iter()
1954 .map(|dir| dir.display().to_string())
1955 .collect::<Vec<_>>(),
1956 })
1957 })
1958 .collect();
1959 let meta = serde_json::json!({
1960 "schema_version": SCHEMA_VERSION,
1961 "session_id": session,
1962 "path": key.display().to_string(),
1963 "count": stack.len(),
1964 "entries": entries,
1965 });
1966 let meta_path = dir.join("meta.json");
1967 let meta_tmp = dir.join("meta.json.tmp");
1968 if let Ok(content) = serde_json::to_string_pretty(&meta) {
1969 if std::fs::write(&meta_tmp, &content).is_ok() {
1970 let _ = std::fs::rename(&meta_tmp, &meta_path);
1971 }
1972 }
1973
1974 self.disk_index
1977 .entry(session.to_string())
1978 .or_default()
1979 .insert(
1980 key.to_path_buf(),
1981 DiskMeta {
1982 dir: dir.clone(),
1983 count: stack.len(),
1984 },
1985 );
1986 self.dual_write_stack_to_db(session, key, &dir, stack);
1987 }
1988
1989 fn dual_write_stack_to_db(&self, session: &str, key: &Path, dir: &Path, stack: &[BackupEntry]) {
1990 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
1991 let Some(pool) = pool else {
1992 return;
1993 };
1994 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
1995 let Some(harness) = harness else {
1996 crate::slog_warn!(
1997 "dual-write backup to DB skipped for {}: harness not configured",
1998 key.display()
1999 );
2000 return;
2001 };
2002 let project_key = self
2003 .db_project_key
2004 .read()
2005 .ok()
2006 .and_then(|slot| slot.clone());
2007 let Some(project_key) = project_key else {
2008 crate::slog_warn!(
2009 "dual-write backup to DB skipped for {}: project key not configured",
2010 key.display()
2011 );
2012 return;
2013 };
2014
2015 let conn = match pool.lock() {
2016 Ok(conn) => conn,
2017 Err(_) => {
2018 crate::slog_warn!(
2019 "dual-write backup to DB failed for {}: db mutex poisoned",
2020 key.display()
2021 );
2022 return;
2023 }
2024 };
2025 let path_hash = Self::path_hash(key);
2026 let file_path = key.display().to_string();
2027 if let Err(error) =
2028 crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
2029 {
2030 crate::slog_warn!(
2031 "delete old backup DB rows failed for {}: {}",
2032 key.display(),
2033 error
2034 );
2035 return;
2036 }
2037 for (index, entry) in stack.iter().enumerate() {
2038 let backup_path = match entry.kind {
2039 BackupEntryKind::Content | BackupEntryKind::Symlink => {
2040 Some(dir.join(format!("{}.bak", index)).display().to_string())
2041 }
2042 BackupEntryKind::Tombstone => Some(dir.join("meta.json").display().to_string()),
2043 };
2044 let row = entry.to_backup_row(
2045 &harness,
2046 session,
2047 &project_key,
2048 &file_path,
2049 &path_hash,
2050 backup_path.as_deref(),
2051 );
2052 if let Err(error) = crate::db::backups::upsert_backup(&conn, &row) {
2053 crate::slog_warn!(
2054 "dual-write backup to DB failed for {}: {}",
2055 entry.backup_id,
2056 error
2057 );
2058 }
2059 }
2060 }
2061
2062 fn remove_disk_backups(&mut self, session: &str, key: &Path) {
2063 self.remove_db_backups(session, key);
2064 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
2065 if let Some(meta) = removed {
2066 let _ = std::fs::remove_dir_all(&meta.dir);
2067 } else if let Some(session_dir) = self.session_dir(session) {
2068 let hash = Self::path_hash(key);
2069 let dir = session_dir.join(&hash);
2070 if dir.exists() {
2071 let _ = std::fs::remove_dir_all(&dir);
2072 }
2073 }
2074
2075 let empty = self
2078 .disk_index
2079 .get(session)
2080 .map(|s| s.is_empty())
2081 .unwrap_or(false);
2082 if empty {
2083 self.disk_index.remove(session);
2084 }
2085 }
2086
2087 fn remove_db_backups(&self, session: &str, key: &Path) {
2088 let Some((pool, harness)) = self.db_pool_and_harness() else {
2089 return;
2090 };
2091 let conn = match pool.lock() {
2092 Ok(conn) => conn,
2093 Err(_) => {
2094 crate::slog_warn!(
2095 "delete backup DB rows failed for {}: db mutex poisoned",
2096 key.display()
2097 );
2098 return;
2099 }
2100 };
2101 let path_hash = Self::path_hash(key);
2102 if let Err(error) =
2103 crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
2104 {
2105 crate::slog_warn!(
2106 "delete backup DB rows failed for {}: {}",
2107 key.display(),
2108 error
2109 );
2110 }
2111 }
2112}
2113
2114pub fn hash_session(session: &str) -> String {
2115 stable_hash_16(session.as_bytes())
2116}
2117
2118pub fn new_op_id() -> String {
2119 let mut bytes = [0u8; 4];
2120 if getrandom::fill(&mut bytes).is_err() {
2121 bytes = current_timestamp().to_le_bytes()[..4]
2122 .try_into()
2123 .unwrap_or([0; 4]);
2124 }
2125 let rand = u32::from_le_bytes(bytes);
2126 format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
2127}
2128
2129#[derive(Debug, Clone)]
2130struct BackupEntryDiskMetadata {
2131 mode: Option<u32>,
2132 link_target: Option<PathBuf>,
2133 created_dirs: Vec<PathBuf>,
2134}
2135
2136#[derive(Debug, Clone)]
2137enum RestorePathState {
2138 Missing,
2139 Regular {
2140 content_bytes: Vec<u8>,
2141 mode: Option<u32>,
2142 },
2143 Symlink {
2144 target: PathBuf,
2145 },
2146 Directory,
2147}
2148
2149fn backup_entry_from_path(
2150 path: &Path,
2151 backup_id: String,
2152 order: u128,
2153 description: &str,
2154 op_id: Option<&str>,
2155) -> Result<BackupEntry, AftError> {
2156 let metadata = std::fs::symlink_metadata(path).map_err(|error| match error.kind() {
2157 std::io::ErrorKind::NotFound => AftError::FileNotFound {
2158 path: path.display().to_string(),
2159 },
2160 _ => AftError::IoError {
2161 path: path.display().to_string(),
2162 message: error.to_string(),
2163 },
2164 })?;
2165 let mode = file_mode(&metadata);
2166
2167 let (kind, content, content_bytes, link_target) = if metadata.file_type().is_symlink() {
2168 let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2169 path: path.display().to_string(),
2170 message: error.to_string(),
2171 })?;
2172 (
2173 BackupEntryKind::Symlink,
2174 target.display().to_string(),
2175 Vec::new(),
2176 Some(target),
2177 )
2178 } else if metadata.is_file() {
2179 let bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2180 path: path.display().to_string(),
2181 message: error.to_string(),
2182 })?;
2183 (
2184 BackupEntryKind::Content,
2185 String::from_utf8_lossy(&bytes).into_owned(),
2186 bytes,
2187 None,
2188 )
2189 } else {
2190 return Err(AftError::InvalidRequest {
2191 message: format!(
2192 "backup: '{}' is not a regular file or symlink",
2193 path.display()
2194 ),
2195 });
2196 };
2197
2198 Ok(BackupEntry {
2199 backup_id,
2200 content,
2201 content_bytes,
2202 timestamp: current_timestamp(),
2203 order,
2204 description: description.to_string(),
2205 op_id: op_id.map(str::to_string),
2206 kind,
2207 mode,
2208 link_target,
2209 created_dirs: Vec::new(),
2210 })
2211}
2212
2213fn canonicalize_key(path: &Path) -> PathBuf {
2214 let absolute = if path.is_absolute() {
2215 path.to_path_buf()
2216 } else {
2217 std::env::current_dir()
2218 .unwrap_or_else(|_| PathBuf::from("."))
2219 .join(path)
2220 };
2221
2222 match std::fs::symlink_metadata(&absolute) {
2223 Ok(metadata) if metadata.file_type().is_symlink() => {
2224 canonicalize_parent_join_leaf(&absolute)
2225 }
2226 Ok(_) => std::fs::canonicalize(&absolute)
2227 .map(|path| normalize_absolute_key(&path))
2228 .unwrap_or_else(|_| canonicalize_existing_ancestor(&absolute)),
2229 Err(_) => canonicalize_existing_ancestor(&absolute),
2230 }
2231}
2232
2233fn canonicalize_parent_join_leaf(path: &Path) -> PathBuf {
2234 let Some(parent) = path.parent() else {
2235 return normalize_absolute_key(path);
2236 };
2237 let mut key = canonicalize_existing_ancestor(parent);
2238 if let Some(file_name) = path.file_name() {
2239 key.push(file_name);
2240 }
2241 key
2242}
2243
2244fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
2245 let mut suffix = Vec::new();
2246 let mut current = path;
2247
2248 loop {
2249 if let Ok(mut base) = std::fs::canonicalize(current) {
2250 for component in suffix.iter().rev() {
2251 base.push(Path::new(component));
2252 }
2253 return normalize_absolute_key(&base);
2254 }
2255 let Some(parent) = current.parent() else {
2256 return normalize_absolute_key(path);
2257 };
2258 if let Some(file_name) = current.file_name() {
2259 suffix.push(file_name.to_os_string());
2260 }
2261 current = parent;
2262 }
2263}
2264
2265fn normalize_absolute_key(path: &Path) -> PathBuf {
2266 let mut normalized = PathBuf::new();
2267
2268 for component in path.components() {
2269 match component {
2270 std::path::Component::CurDir => {}
2271 std::path::Component::ParentDir => {
2272 if !normalized.pop() {
2273 normalized.push(component.as_os_str());
2274 }
2275 }
2276 other => normalized.push(other.as_os_str()),
2277 }
2278 }
2279
2280 normalized
2281}
2282
2283fn file_mode(metadata: &std::fs::Metadata) -> Option<u32> {
2284 #[cfg(unix)]
2285 {
2286 use std::os::unix::fs::PermissionsExt;
2287 Some(metadata.permissions().mode())
2288 }
2289 #[cfg(not(unix))]
2290 {
2291 let _ = metadata;
2292 None
2293 }
2294}
2295
2296fn set_file_mode(path: &Path, mode: Option<u32>) -> std::io::Result<()> {
2297 #[cfg(unix)]
2298 {
2299 use std::os::unix::fs::PermissionsExt;
2300 if let Some(mode) = mode {
2301 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
2302 }
2303 }
2304 #[cfg(not(unix))]
2305 {
2306 let _ = (path, mode);
2307 }
2308 Ok(())
2309}
2310
2311fn capture_path_state(path: &Path) -> Result<RestorePathState, AftError> {
2312 let metadata = match std::fs::symlink_metadata(path) {
2313 Ok(metadata) => metadata,
2314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2315 return Ok(RestorePathState::Missing);
2316 }
2317 Err(error) => {
2318 return Err(AftError::IoError {
2319 path: path.display().to_string(),
2320 message: error.to_string(),
2321 });
2322 }
2323 };
2324
2325 if metadata.file_type().is_symlink() {
2326 let target = std::fs::read_link(path).map_err(|error| AftError::IoError {
2327 path: path.display().to_string(),
2328 message: error.to_string(),
2329 })?;
2330 Ok(RestorePathState::Symlink { target })
2331 } else if metadata.is_file() {
2332 let content_bytes = std::fs::read(path).map_err(|error| AftError::IoError {
2333 path: path.display().to_string(),
2334 message: error.to_string(),
2335 })?;
2336 Ok(RestorePathState::Regular {
2337 content_bytes,
2338 mode: file_mode(&metadata),
2339 })
2340 } else {
2341 Ok(RestorePathState::Directory)
2342 }
2343}
2344
2345fn restore_entry_to_path(path: &Path, entry: &BackupEntry) -> std::io::Result<()> {
2346 match entry.kind {
2347 BackupEntryKind::Content => restore_regular_file(path, &entry.content_bytes, entry.mode),
2348 BackupEntryKind::Symlink => {
2349 let target = entry.link_target.as_ref().ok_or_else(|| {
2350 std::io::Error::new(
2351 std::io::ErrorKind::InvalidData,
2352 "symlink backup entry missing target",
2353 )
2354 })?;
2355 restore_symlink(path, target)
2356 }
2357 BackupEntryKind::Tombstone => remove_tombstone_path(path),
2358 }
2359}
2360
2361fn restore_path_state(path: &Path, state: &RestorePathState) -> bool {
2362 match state {
2363 RestorePathState::Missing => remove_file_or_symlink_if_present(path).is_ok(),
2364 RestorePathState::Regular {
2365 content_bytes,
2366 mode,
2367 } => restore_regular_file(path, content_bytes, *mode).is_ok(),
2368 RestorePathState::Symlink { target } => restore_symlink(path, target).is_ok(),
2369 RestorePathState::Directory => true,
2370 }
2371}
2372
2373fn restore_regular_file(
2374 path: &Path,
2375 content_bytes: &[u8],
2376 mode: Option<u32>,
2377) -> std::io::Result<()> {
2378 if let Some(parent) = path.parent() {
2379 if !parent.as_os_str().is_empty() {
2380 std::fs::create_dir_all(parent)?;
2381 }
2382 }
2383 if std::fs::symlink_metadata(path)
2384 .map(|metadata| metadata.file_type().is_symlink())
2385 .unwrap_or(false)
2386 {
2387 std::fs::remove_file(path)?;
2388 }
2389 std::fs::write(path, content_bytes)?;
2390 set_file_mode(path, mode)
2391}
2392
2393fn restore_symlink(path: &Path, target: &Path) -> std::io::Result<()> {
2394 if let Some(parent) = path.parent() {
2395 if !parent.as_os_str().is_empty() {
2396 std::fs::create_dir_all(parent)?;
2397 }
2398 }
2399 remove_file_or_symlink_if_present(path)?;
2400 create_symlink(target, path)
2401}
2402
2403#[cfg(unix)]
2404fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2405 std::os::unix::fs::symlink(target, link)
2406}
2407
2408#[cfg(windows)]
2409fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2410 if target.is_dir() {
2411 std::os::windows::fs::symlink_dir(target, link)
2412 } else {
2413 std::os::windows::fs::symlink_file(target, link)
2414 }
2415}
2416
2417fn remove_tombstone_path(path: &Path) -> std::io::Result<()> {
2418 match std::fs::symlink_metadata(path) {
2419 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2420 std::fs::remove_file(path)
2421 }
2422 Ok(_) => Err(std::io::Error::new(
2423 std::io::ErrorKind::IsADirectory,
2424 "tombstone target is a directory",
2425 )),
2426 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2427 Err(error) => Err(error),
2428 }
2429}
2430
2431fn remove_file_or_symlink_if_present(path: &Path) -> std::io::Result<()> {
2432 match std::fs::symlink_metadata(path) {
2433 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
2434 std::fs::remove_file(path)
2435 }
2436 Ok(_) => Err(std::io::Error::new(
2437 std::io::ErrorKind::IsADirectory,
2438 "path is a directory",
2439 )),
2440 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2441 Err(error) => Err(error),
2442 }
2443}
2444
2445fn read_entry_disk_metadata(
2446 backup_path: &Path,
2447 backup_id: &str,
2448) -> Option<BackupEntryDiskMetadata> {
2449 let meta_path = if backup_path.file_name().and_then(|name| name.to_str()) == Some("meta.json") {
2450 backup_path.to_path_buf()
2451 } else {
2452 backup_path.parent()?.join("meta.json")
2453 };
2454 let content = std::fs::read_to_string(meta_path).ok()?;
2455 let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
2456 let entries = meta.get("entries")?.as_array()?;
2457 let entry = entries
2458 .iter()
2459 .find(|entry| entry.get("backup_id").and_then(|value| value.as_str()) == Some(backup_id))?;
2460 Some(BackupEntryDiskMetadata {
2461 mode: entry
2462 .get("mode")
2463 .and_then(|value| value.as_u64())
2464 .and_then(|mode| u32::try_from(mode).ok()),
2465 link_target: entry
2466 .get("link_target")
2467 .and_then(|value| value.as_str())
2468 .map(PathBuf::from),
2469 created_dirs: entry
2470 .get("created_dirs")
2471 .and_then(|value| value.as_array())
2472 .map(|dirs| {
2473 dirs.iter()
2474 .filter_map(|dir| dir.as_str())
2475 .map(PathBuf::from)
2476 .collect()
2477 })
2478 .unwrap_or_default(),
2479 })
2480}
2481
2482fn rollback_transactional_restore(
2483 written: &[(PathBuf, RestorePathState)],
2484 attempted: Option<(&PathBuf, &RestorePathState)>,
2485) -> bool {
2486 let mut ok = true;
2487
2488 if let Some((path, state)) = attempted {
2489 ok &= restore_path_state(path, state);
2490 }
2491
2492 for (path, state) in written.iter().rev() {
2493 ok &= restore_path_state(path, state);
2494 }
2495
2496 ok
2497}
2498
2499fn rollback_deleted_tombstones(deleted: &[(PathBuf, RestorePathState)]) -> bool {
2500 let mut ok = true;
2501 for (path, state) in deleted.iter().rev() {
2502 ok &= restore_path_state(path, state);
2503 }
2504 ok
2505}
2506
2507fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
2508 let mut dirs = Vec::new();
2509 let mut current = Some(parent);
2510
2511 while let Some(dir) = current {
2512 if dir.as_os_str().is_empty() || dir.exists() {
2513 break;
2514 }
2515 dirs.push(dir.to_path_buf());
2516 current = dir.parent();
2517 }
2518
2519 dirs
2520}
2521
2522fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
2523 let mut dirs = dirs.to_vec();
2524 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2525 dirs.dedup();
2526
2527 let mut ok = true;
2528 for dir in dirs {
2529 match std::fs::remove_dir(&dir) {
2530 Ok(()) => {}
2531 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2532 Err(_) => ok = false,
2533 }
2534 }
2535
2536 ok
2537}
2538
2539fn remove_created_dirs_best_effort(dirs: &[PathBuf]) {
2540 let mut dirs = dirs.to_vec();
2541 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
2542 dirs.dedup();
2543
2544 for dir in dirs {
2545 match std::fs::remove_dir(&dir) {
2546 Ok(()) => {}
2547 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2548 Err(_) => {}
2549 }
2550 }
2551}
2552
2553fn dir_has_entries(path: &Path) -> bool {
2554 std::fs::read_dir(path)
2555 .map(|mut entries| entries.next().is_some())
2556 .unwrap_or(false)
2557}
2558
2559fn current_timestamp() -> u64 {
2560 std::time::SystemTime::now()
2561 .duration_since(std::time::UNIX_EPOCH)
2562 .unwrap_or_default()
2563 .as_secs()
2564}
2565
2566fn current_timestamp_nanos() -> u64 {
2567 let nanos = std::time::SystemTime::now()
2568 .duration_since(std::time::UNIX_EPOCH)
2569 .unwrap_or_default()
2570 .as_nanos();
2571 nanos.min(u128::from(u64::MAX)) as u64
2572}
2573
2574fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
2575 let nanos = timestamp_secs.saturating_mul(1_000_000_000);
2576 ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
2577}
2578
2579fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
2580 value
2581 .as_str()
2582 .and_then(|s| s.parse::<u128>().ok())
2583 .or_else(|| value.as_u64().map(u128::from))
2584}
2585
2586fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
2587 if !key.is_absolute()
2588 || key
2589 .components()
2590 .any(|c| matches!(c, std::path::Component::ParentDir))
2591 {
2592 return false;
2593 }
2594 let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
2595 return false;
2596 };
2597 BackupStore::path_hash(key) == dir_name
2598}
2599
2600fn stable_hash_16(bytes: &[u8]) -> String {
2601 let digest = Sha256::digest(bytes);
2602 digest[..8]
2603 .iter()
2604 .map(|byte| format!("{:02x}", byte))
2605 .collect()
2606}
2607
2608fn backup_sequence(backup_id: &str) -> Option<u64> {
2609 backup_id
2610 .strip_prefix("backup-")
2611 .or_else(|| backup_id.strip_prefix("disk-"))
2612 .and_then(|s| s.parse().ok())
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617 use super::*;
2618 use crate::protocol::DEFAULT_SESSION_ID;
2619 use std::fs;
2620 #[cfg(unix)]
2621 use std::os::unix::fs::PermissionsExt;
2622
2623 fn temp_file(name: &str, content: &str) -> PathBuf {
2624 let dir = std::env::temp_dir().join("aft_backup_tests");
2625 fs::create_dir_all(&dir).unwrap();
2626 let path = dir.join(name);
2627 fs::write(&path, content).unwrap();
2628 path
2629 }
2630
2631 #[test]
2632 fn snapshot_and_restore_round_trip() {
2633 let path = temp_file("round_trip.txt", "original");
2634 let mut store = BackupStore::new();
2635
2636 let id = store
2637 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
2638 .unwrap();
2639 assert!(id.starts_with("backup-"));
2640
2641 fs::write(&path, "modified").unwrap();
2642 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
2643
2644 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2645 assert_eq!(entry.content, "original");
2646 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2647 }
2648
2649 #[test]
2650 fn multiple_snapshots_preserve_order() {
2651 let path = temp_file("order.txt", "v1");
2652 let mut store = BackupStore::new();
2653
2654 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2655 fs::write(&path, "v2").unwrap();
2656 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2657 fs::write(&path, "v3").unwrap();
2658 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
2659
2660 let history = store.history(DEFAULT_SESSION_ID, &path);
2661 assert_eq!(history.len(), 3);
2662 assert_eq!(history[0].content, "v1");
2663 assert_eq!(history[1].content, "v2");
2664 assert_eq!(history[2].content, "v3");
2665 }
2666
2667 #[test]
2668 fn restore_pops_from_stack() {
2669 let path = temp_file("pop.txt", "v1");
2670 let mut store = BackupStore::new();
2671
2672 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
2673 fs::write(&path, "v2").unwrap();
2674 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
2675
2676 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2677 assert_eq!(entry.description, "second");
2678 assert_eq!(entry.content, "v2");
2679
2680 let history = store.history(DEFAULT_SESSION_ID, &path);
2681 assert_eq!(history.len(), 1);
2682 }
2683
2684 #[test]
2685 fn empty_history_returns_empty_vec() {
2686 let store = BackupStore::new();
2687 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
2688 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
2689 }
2690
2691 #[test]
2692 fn snapshot_nonexistent_file_returns_error() {
2693 let mut store = BackupStore::new();
2694 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
2695 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
2696 }
2697
2698 #[test]
2699 fn tracked_files_lists_snapshotted_paths() {
2700 let path1 = temp_file("tracked1.txt", "a");
2701 let path2 = temp_file("tracked2.txt", "b");
2702 let mut store = BackupStore::new();
2703
2704 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
2705 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
2706 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
2707 }
2708
2709 #[test]
2710 fn sessions_are_isolated() {
2711 let path = temp_file("isolated.txt", "original");
2712 let mut store = BackupStore::new();
2713
2714 store.snapshot("session_a", &path, "a's snapshot").unwrap();
2715
2716 assert!(store.history("session_b", &path).is_empty());
2718 assert_eq!(store.tracked_files("session_b").len(), 0);
2719
2720 let err = store.restore_latest("session_b", &path);
2722 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2723
2724 assert_eq!(store.history("session_a", &path).len(), 1);
2726 assert_eq!(store.tracked_files("session_a").len(), 1);
2727 }
2728
2729 #[test]
2730 fn per_session_per_file_cap_is_independent() {
2731 let path = temp_file("cap_indep.txt", "v0");
2734 let mut store = BackupStore::new();
2735
2736 for i in 0..(MAX_UNDO_DEPTH + 5) {
2737 fs::write(&path, format!("a{}", i)).unwrap();
2738 store.snapshot("session_a", &path, "a").unwrap();
2739 }
2740 fs::write(&path, "b_initial").unwrap();
2741 store.snapshot("session_b", &path, "b").unwrap();
2742
2743 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
2745 assert_eq!(store.history("session_b", &path).len(), 1);
2747 }
2748
2749 #[test]
2750 fn sessions_with_backups_lists_all_namespaces() {
2751 let path_a = temp_file("sessions_list_a.txt", "a");
2752 let path_b = temp_file("sessions_list_b.txt", "b");
2753 let mut store = BackupStore::new();
2754
2755 store.snapshot("alice", &path_a, "from alice").unwrap();
2756 store.snapshot("bob", &path_b, "from bob").unwrap();
2757
2758 let sessions = store.sessions_with_backups();
2759 assert_eq!(sessions.len(), 2);
2760 assert!(sessions.iter().any(|s| s == "alice"));
2761 assert!(sessions.iter().any(|s| s == "bob"));
2762 }
2763
2764 #[test]
2765 fn disk_persistence_survives_reload() {
2766 let dir = std::env::temp_dir().join("aft_backup_disk_test");
2767 let _ = fs::remove_dir_all(&dir);
2768 fs::create_dir_all(&dir).unwrap();
2769
2770 let file_path = temp_file("disk_persist.txt", "original");
2771
2772 {
2774 let mut store = BackupStore::new();
2775 store.set_storage_dir(dir.clone(), 72);
2776 store
2777 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
2778 .unwrap();
2779 }
2780
2781 fs::write(&file_path, "externally modified").unwrap();
2783
2784 let mut store2 = BackupStore::new();
2786 store2.set_storage_dir(dir.clone(), 72);
2787
2788 let (entry, warning) = store2
2789 .restore_latest(DEFAULT_SESSION_ID, &file_path)
2790 .unwrap();
2791 assert_eq!(entry.content, "original");
2792 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2794
2795 let _ = fs::remove_dir_all(&dir);
2796 }
2797
2798 #[test]
2799 fn snapshot_after_restart_preserves_history_and_unique_ids() {
2800 let dir = std::env::temp_dir().join("aft_backup_restart_history_test");
2806 let _ = fs::remove_dir_all(&dir);
2807 fs::create_dir_all(&dir).unwrap();
2808 let file_path = temp_file("restart_history.txt", "v0");
2809
2810 let first_id = {
2812 let mut store = BackupStore::new();
2813 store.set_storage_dir(dir.clone(), 72);
2814 let id = store
2815 .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 1")
2816 .unwrap();
2817 fs::write(&file_path, "v1").unwrap();
2818 id
2819 };
2820
2821 let second_id = {
2824 let mut store = BackupStore::new();
2825 store.set_storage_dir(dir.clone(), 72);
2826 let id = store
2827 .snapshot(DEFAULT_SESSION_ID, &file_path, "edit 2")
2828 .unwrap();
2829 fs::write(&file_path, "v2").unwrap();
2830 id
2831 };
2832
2833 assert_ne!(
2836 first_id, second_id,
2837 "post-restart snapshot reused backup id {first_id}"
2838 );
2839
2840 let mut store = BackupStore::new();
2843 store.set_storage_dir(dir.clone(), 72);
2844 assert_eq!(
2845 store.history(DEFAULT_SESSION_ID, &file_path).len(),
2846 2,
2847 "prior history was overwritten by the post-restart snapshot"
2848 );
2849
2850 let (entry1, _) = store
2851 .restore_latest(DEFAULT_SESSION_ID, &file_path)
2852 .unwrap();
2853 assert_eq!(entry1.content, "v1", "first undo should restore v1");
2854 let (entry0, _) = store
2855 .restore_latest(DEFAULT_SESSION_ID, &file_path)
2856 .unwrap();
2857 assert_eq!(entry0.content, "v0", "second undo should restore v0");
2858
2859 let _ = fs::remove_dir_all(&dir);
2860 }
2861
2862 #[test]
2863 fn legacy_flat_layout_migrates_to_default_session() {
2864 let dir = std::env::temp_dir().join("aft_backup_migration_test");
2867 let _ = fs::remove_dir_all(&dir);
2868 fs::create_dir_all(&dir).unwrap();
2869 let backups = dir.join("backups");
2870 fs::create_dir_all(&backups).unwrap();
2871
2872 let legacy_hash = "deadbeefcafebabe";
2874 let legacy_dir = backups.join(legacy_hash);
2875 fs::create_dir_all(&legacy_dir).unwrap();
2876 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
2877 let legacy_meta = serde_json::json!({
2878 "path": "/tmp/migrated_file.txt",
2879 "count": 1,
2880 });
2881 fs::write(
2882 legacy_dir.join("meta.json"),
2883 serde_json::to_string_pretty(&legacy_meta).unwrap(),
2884 )
2885 .unwrap();
2886
2887 let mut store = BackupStore::new();
2889 store.set_storage_dir(dir.clone(), 72);
2890
2891 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2894 assert!(default_session_dir.exists());
2895 assert!(default_session_dir.join(legacy_hash).exists());
2896 assert!(!backups.join(legacy_hash).exists());
2897
2898 let meta_content =
2900 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
2901 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
2902 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
2903 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
2904
2905 let _ = fs::remove_dir_all(&dir);
2906 }
2907
2908 #[test]
2909 fn set_storage_dir_removes_stale_backup_sessions() {
2910 let dir = std::env::temp_dir().join("aft_backup_gc_test");
2911 let _ = fs::remove_dir_all(&dir);
2912 let backups = dir.join("backups");
2913 fs::create_dir_all(&backups).unwrap();
2914
2915 let stale_session_dir = backups.join("stale-session");
2916 fs::create_dir_all(&stale_session_dir).unwrap();
2917 let stale_marker = serde_json::json!({
2918 "schema_version": SCHEMA_VERSION,
2919 "session_id": "stale",
2920 "last_accessed": 1,
2921 });
2922 fs::write(
2923 stale_session_dir.join("session.json"),
2924 serde_json::to_string_pretty(&stale_marker).unwrap(),
2925 )
2926 .unwrap();
2927
2928 let mut store = BackupStore::new();
2929 store.set_storage_dir(dir.clone(), 1);
2930
2931 assert!(!stale_session_dir.exists());
2932 let _ = fs::remove_dir_all(&dir);
2933 }
2934
2935 #[test]
2936 fn markerless_session_dir_is_skipped_not_mapped_to_default() {
2937 let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
2938 let _ = fs::remove_dir_all(&dir);
2939 let file_path = temp_file("markerless.txt", "original");
2940 let key = canonicalize_key(&file_path);
2941 let path_dir = dir
2942 .join("backups")
2943 .join("corrupt-session")
2944 .join("path-entry");
2945 fs::create_dir_all(&path_dir).unwrap();
2946 fs::write(path_dir.join("0.bak"), "original").unwrap();
2947 fs::write(
2948 path_dir.join("meta.json"),
2949 serde_json::to_string_pretty(&serde_json::json!({
2950 "schema_version": SCHEMA_VERSION,
2951 "session_id": "lost-session",
2952 "path": key.display().to_string(),
2953 "count": 1,
2954 "entries": [{
2955 "backup_id": "disk-0",
2956 "timestamp": 0,
2957 "description": "corrupt marker test",
2958 "op_id": null,
2959 "kind": "content",
2960 }]
2961 }))
2962 .unwrap(),
2963 )
2964 .unwrap();
2965
2966 let mut store = BackupStore::new();
2967 store.set_storage_dir(dir.clone(), 72);
2968
2969 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2970 assert!(store.sessions_with_backups().is_empty());
2971 let _ = fs::remove_dir_all(&dir);
2972 }
2973
2974 #[test]
2975 fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
2976 let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
2977 let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
2978 let _ = fs::remove_dir_all(&dir_a);
2979 let _ = fs::remove_dir_all(&dir_b);
2980 fs::create_dir_all(&dir_a).unwrap();
2981 fs::create_dir_all(&dir_b).unwrap();
2982 let file_path = temp_file("storage_reconfigure.txt", "original");
2983
2984 let mut store = BackupStore::new();
2985 store.set_storage_dir(dir_a.clone(), 72);
2986 store
2987 .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
2988 .unwrap();
2989 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
2990
2991 store.set_storage_dir(dir_b.clone(), 72);
2992
2993 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2994 assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
2995 let _ = fs::remove_dir_all(&dir_a);
2996 let _ = fs::remove_dir_all(&dir_b);
2997 }
2998
2999 #[test]
3000 fn restore_last_operation_restores_all_top_entries_for_same_op() {
3001 let path_a = temp_file("op_restore_a.txt", "a1");
3002 let path_b = temp_file("op_restore_b.txt", "b1");
3003 let mut store = BackupStore::new();
3004 let op_id = "op-test-00000001";
3005
3006 store
3007 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
3008 .unwrap();
3009 store
3010 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
3011 .unwrap();
3012 fs::write(&path_a, "a2").unwrap();
3013 fs::write(&path_b, "b2").unwrap();
3014
3015 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3016 assert_eq!(restored.op_id, op_id);
3017 assert_eq!(restored.restored.len(), 2);
3018 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
3019 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3020 }
3021
3022 #[test]
3023 fn restore_last_operation_deletes_tombstone_destination() {
3024 let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
3025 let _ = fs::remove_dir_all(&dir);
3026 fs::create_dir_all(&dir).unwrap();
3027 let source = dir.join("source.txt");
3028 let destination = dir.join("destination.txt");
3029 fs::write(&source, "original").unwrap();
3030
3031 let mut store = BackupStore::new();
3032 let op_id = "op-tombstone-delete";
3033 store
3034 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3035 .unwrap();
3036 fs::rename(&source, &destination).unwrap();
3037 store
3038 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3039 .unwrap();
3040
3041 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3042 assert_eq!(restored.op_id, op_id);
3043 assert_eq!(restored.restored.len(), 1);
3044 assert_eq!(fs::read_to_string(&source).unwrap(), "original");
3045 assert!(!destination.exists());
3046 let _ = fs::remove_dir_all(&dir);
3047 }
3048
3049 #[test]
3050 fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
3051 let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
3052 let _ = fs::remove_dir_all(&dir);
3053 fs::create_dir_all(&dir).unwrap();
3054 let source = dir.join("source.txt");
3055 let destination = dir.join("destination.txt");
3056 fs::write(&source, "original").unwrap();
3057
3058 let mut store = BackupStore::new();
3059 let op_id = "op-tombstone-atomic";
3060 store
3061 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
3062 .unwrap();
3063 fs::rename(&source, &destination).unwrap();
3064 store
3065 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
3066 .unwrap();
3067
3068 fs::remove_file(&destination).unwrap();
3069 fs::create_dir(&destination).unwrap();
3070 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
3071
3072 assert!(result.is_err(), "directory tombstone target should fail");
3073 assert!(
3074 !source.exists(),
3075 "source restore must roll back when destination deletion fails"
3076 );
3077 assert!(
3078 destination.is_dir(),
3079 "failed tombstone target should remain"
3080 );
3081 let _ = fs::remove_dir_all(&dir);
3082 }
3083
3084 #[cfg(unix)]
3089 #[test]
3090 fn restore_last_operation_is_atomic_when_a_write_fails() {
3091 let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
3092 let _ = fs::remove_dir_all(&dir);
3093 fs::create_dir_all(&dir).unwrap();
3094 let path_a = dir.join("a.txt");
3095 let path_b = dir.join("b.txt");
3096 let path_c = dir.join("c.txt");
3097 fs::write(&path_a, "a-original").unwrap();
3098 fs::write(&path_b, "b-original").unwrap();
3099 fs::write(&path_c, "c-original").unwrap();
3100
3101 let mut store = BackupStore::new();
3102 let op_id = "op-atomic-restore-01";
3103 let id_a = store
3104 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
3105 .unwrap();
3106 let id_b = store
3107 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
3108 .unwrap();
3109 let id_c = store
3110 .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
3111 .unwrap();
3112 fs::write(&path_a, "a-modified").unwrap();
3113 fs::write(&path_b, "b-modified").unwrap();
3114 fs::write(&path_c, "c-modified").unwrap();
3115
3116 let original_permissions = fs::metadata(&path_b).unwrap().permissions();
3117 let mut readonly_permissions = original_permissions.clone();
3118 readonly_permissions.set_mode(0o444);
3119 fs::set_permissions(&path_b, readonly_permissions).unwrap();
3120
3121 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
3122 fs::set_permissions(&path_b, original_permissions).unwrap();
3123
3124 assert!(result.is_err());
3125 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
3126 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
3127 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
3128
3129 let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
3130 let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
3131 let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
3132 assert_eq!(history_a.len(), 1);
3133 assert_eq!(history_b.len(), 1);
3134 assert_eq!(history_c.len(), 1);
3135 assert_eq!(history_a[0].backup_id, id_a);
3136 assert_eq!(history_b[0].backup_id, id_b);
3137 assert_eq!(history_c[0].backup_id, id_c);
3138 assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
3139 assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
3140 assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
3141
3142 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3143 assert_eq!(restored.op_id, op_id);
3144 assert_eq!(restored.restored.len(), 3);
3145 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
3146 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
3147 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
3148
3149 let _ = fs::remove_dir_all(&dir);
3150 }
3151
3152 #[test]
3153 fn restore_last_operation_restores_only_most_recent_op() {
3154 let path_a = temp_file("op_recent_a.txt", "a1");
3155 let path_b = temp_file("op_recent_b.txt", "b1");
3156 let mut store = BackupStore::new();
3157
3158 store
3159 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
3160 .unwrap();
3161 store
3162 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
3163 .unwrap();
3164 fs::write(&path_a, "a2").unwrap();
3165 fs::write(&path_b, "b2").unwrap();
3166
3167 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3168 assert_eq!(restored.op_id, "op-newer");
3169 assert_eq!(restored.restored.len(), 1);
3170 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
3171 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3172 }
3173
3174 #[test]
3175 fn restore_recreates_missing_parent_directories() {
3176 let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
3179 let _ = fs::remove_dir_all(&dir);
3180 let nested = dir.join("nested");
3181 fs::create_dir_all(&nested).unwrap();
3182 let path = nested.join("inner.txt");
3183 fs::write(&path, "original").unwrap();
3184
3185 let mut store = BackupStore::new();
3186 let op_id = "op-recreate-parents-01";
3187 store
3188 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
3189 .unwrap();
3190
3191 fs::remove_dir_all(&dir).unwrap();
3193 assert!(!path.exists());
3194 assert!(!nested.exists());
3195 assert!(!dir.exists());
3196
3197 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3198 assert_eq!(restored.op_id, op_id);
3199 assert_eq!(restored.restored.len(), 1);
3200 assert!(
3201 path.exists(),
3202 "file should be restored even though both nested/ and dir/ were missing"
3203 );
3204 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
3205
3206 let _ = fs::remove_dir_all(&dir);
3207 }
3208
3209 #[test]
3210 fn restore_last_operation_ignores_legacy_entries_without_op_id() {
3211 let path = temp_file("op_legacy_none.txt", "v1");
3212 let mut store = BackupStore::new();
3213
3214 store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
3215 fs::write(&path, "v2").unwrap();
3216
3217 let err = store.restore_last_operation(DEFAULT_SESSION_ID);
3218 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
3219 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
3220 }
3221
3222 #[test]
3223 fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
3224 let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
3225 let _ = fs::remove_dir_all(&dir);
3226 fs::create_dir_all(&dir).unwrap();
3227 let file_path = temp_file("v2_to_v3.txt", "original");
3228 let key = canonicalize_key(&file_path);
3229 let session_dir = dir
3230 .join("backups")
3231 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
3232 let path_dir = session_dir.join(BackupStore::path_hash(&key));
3233 fs::create_dir_all(&path_dir).unwrap();
3234 fs::write(path_dir.join("0.bak"), "original").unwrap();
3235 fs::write(
3236 session_dir.join("session.json"),
3237 serde_json::to_string_pretty(&serde_json::json!({
3238 "schema_version": 2,
3239 "session_id": DEFAULT_SESSION_ID,
3240 "last_accessed": current_timestamp(),
3241 }))
3242 .unwrap(),
3243 )
3244 .unwrap();
3245 fs::write(
3246 path_dir.join("meta.json"),
3247 serde_json::to_string_pretty(&serde_json::json!({
3248 "schema_version": 2,
3249 "session_id": DEFAULT_SESSION_ID,
3250 "path": key.display().to_string(),
3251 "count": 1,
3252 }))
3253 .unwrap(),
3254 )
3255 .unwrap();
3256
3257 let mut store = BackupStore::new();
3258 store.set_storage_dir(dir.clone(), 72);
3259 assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
3260 let history = store.history(DEFAULT_SESSION_ID, &file_path);
3261 assert_eq!(history.len(), 1);
3262 assert_eq!(history[0].op_id, None);
3263
3264 fs::write(&file_path, "second").unwrap();
3265 store
3266 .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
3267 .unwrap();
3268 let written: serde_json::Value =
3269 serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
3270 assert_eq!(written["schema_version"], SCHEMA_VERSION);
3271 assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
3272 assert_eq!(written["entries"][1]["op_id"], "op-v3");
3273 let _ = fs::remove_dir_all(&dir);
3274 }
3275
3276 #[test]
3277 fn per_file_restore_latest_still_works_with_op_ids() {
3278 let path = temp_file("op_per_file.txt", "v1");
3279 let mut store = BackupStore::new();
3280
3281 store
3282 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
3283 .unwrap();
3284 fs::write(&path, "v2").unwrap();
3285
3286 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3287 assert_eq!(entry.op_id.as_deref(), Some("op-file"));
3288 assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
3289 }
3290
3291 #[test]
3292 fn per_file_restore_latest_deletes_tombstone() {
3293 let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
3294 let _ = fs::remove_dir_all(&dir);
3295 fs::create_dir_all(&dir).unwrap();
3296 let path = dir.join("created.txt");
3297 fs::write(&path, "created").unwrap();
3298
3299 let mut store = BackupStore::new();
3300 let id = store
3301 .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
3302 .unwrap();
3303
3304 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
3305 assert_eq!(entry.backup_id, id);
3306 assert!(!path.exists(), "tombstone undo should delete the file");
3307 let _ = fs::remove_dir_all(&dir);
3308 }
3309
3310 #[test]
3311 fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
3312 let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
3313 let _ = fs::remove_dir_all(&dir);
3314 let backups = dir.join("backups");
3315 let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
3316 let path_dir = session_dir.join("not-the-path-hash");
3317 fs::create_dir_all(&path_dir).unwrap();
3318 fs::write(
3319 session_dir.join("session.json"),
3320 serde_json::to_string_pretty(&serde_json::json!({
3321 "schema_version": SCHEMA_VERSION,
3322 "session_id": DEFAULT_SESSION_ID,
3323 "last_accessed": current_timestamp(),
3324 }))
3325 .unwrap(),
3326 )
3327 .unwrap();
3328 fs::write(path_dir.join("0.bak"), "outside").unwrap();
3329 fs::write(
3330 path_dir.join("meta.json"),
3331 serde_json::to_string_pretty(&serde_json::json!({
3332 "schema_version": SCHEMA_VERSION,
3333 "session_id": DEFAULT_SESSION_ID,
3334 "path": "/tmp/aft-malicious-overwrite-target.txt",
3335 "count": 1,
3336 "entries": [{
3337 "backup_id": "backup-0",
3338 "timestamp": current_timestamp(),
3339 "order": "1",
3340 "description": "tampered",
3341 "op_id": "op-tampered",
3342 "kind": "content",
3343 }]
3344 }))
3345 .unwrap(),
3346 )
3347 .unwrap();
3348
3349 let mut store = BackupStore::new();
3350 store.set_storage_dir(dir.clone(), 72);
3351
3352 assert!(store.sessions_with_backups().is_empty());
3353 let _ = fs::remove_dir_all(&dir);
3354 }
3355
3356 #[test]
3357 fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
3358 let path_a = temp_file("op_order_a.txt", "a1");
3359 let path_b = temp_file("op_order_b.txt", "b1");
3360 let mut store = BackupStore::new();
3361
3362 store
3363 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
3364 .unwrap();
3365 store
3366 .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
3367 .unwrap();
3368 store
3369 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
3370 .unwrap();
3371
3372 let key_a = canonicalize_key(&path_a);
3373 let key_b = canonicalize_key(&path_b);
3374 let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
3375 files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
3376 files.get_mut(&key_a).unwrap()[1].order = 1;
3377 files.get_mut(&key_b).unwrap()[0].order = 2;
3378
3379 fs::write(&path_a, "a2").unwrap();
3380 fs::write(&path_b, "b2").unwrap();
3381
3382 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
3383 assert_eq!(restored.op_id, "op-top");
3384 assert_eq!(restored.restored.len(), 1);
3385 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
3386 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
3387 }
3388}