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