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