1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, RwLock};
5
6use rusqlite::Connection;
7
8use crate::db::backups::BackupRow;
9use crate::error::AftError;
10use sha2::{Digest, Sha256};
11
12const MAX_UNDO_DEPTH: usize = 20;
13
14const SCHEMA_VERSION: u32 = 3;
19
20#[derive(Debug, Clone)]
22pub struct BackupEntry {
23 pub backup_id: String,
24 pub content: String,
25 pub timestamp: u64,
26 pub order: u128,
27 pub description: String,
28 pub op_id: Option<String>,
29 pub kind: BackupEntryKind,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BackupEntryKind {
34 Content,
35 Tombstone,
36}
37
38impl BackupEntry {
39 fn to_backup_row(
40 &self,
41 harness: &str,
42 session_id: &str,
43 project_key: &str,
44 file_path: &str,
45 path_hash: &str,
46 backup_path: Option<&str>,
47 ) -> BackupRow {
48 BackupRow {
49 backup_id: self.backup_id.clone(),
50 harness: harness.to_string(),
51 session_id: session_id.to_string(),
52 project_key: project_key.to_string(),
53 op_id: self.op_id.clone(),
54 order: self.order,
55 file_path: file_path.to_string(),
56 path_hash: path_hash.to_string(),
57 backup_path: backup_path.map(str::to_string),
58 kind: match self.kind {
59 BackupEntryKind::Content => "content".to_string(),
60 BackupEntryKind::Tombstone => "tombstone".to_string(),
61 },
62 description: self.description.clone(),
63 created_at: i64::try_from(self.timestamp).unwrap_or(i64::MAX),
64 is_tombstone: matches!(self.kind, BackupEntryKind::Tombstone),
65 }
66 }
67}
68
69impl TryFrom<BackupRow> for BackupEntry {
70 type Error = std::io::Error;
71
72 fn try_from(row: BackupRow) -> Result<Self, Self::Error> {
73 let kind = if row.is_tombstone || row.kind == "tombstone" {
74 BackupEntryKind::Tombstone
75 } else {
76 BackupEntryKind::Content
77 };
78 let content = match kind {
79 BackupEntryKind::Content => {
80 let backup_path = row.backup_path.ok_or_else(|| {
81 std::io::Error::new(
82 std::io::ErrorKind::NotFound,
83 format!("backup DB row {} has no backup_path", row.backup_id),
84 )
85 })?;
86 std::fs::read_to_string(backup_path)?
87 }
88 BackupEntryKind::Tombstone => String::new(),
89 };
90
91 Ok(BackupEntry {
92 backup_id: row.backup_id,
93 content,
94 timestamp: u64::try_from(row.created_at).unwrap_or_default(),
95 order: row.order,
96 description: row.description,
97 op_id: row.op_id,
98 kind,
99 })
100 }
101}
102
103#[derive(Debug, Clone)]
104pub struct RestoredOperation {
105 pub op_id: String,
106 pub restored: Vec<RestoredFile>,
107 pub warnings: Vec<String>,
108}
109
110#[derive(Debug, Clone)]
111pub struct RestoredFile {
112 pub path: PathBuf,
113 pub backup_id: String,
114}
115
116#[derive(Debug)]
135pub struct BackupStore {
136 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
138 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
140 session_meta: HashMap<String, SessionMeta>,
142 counter: AtomicU64,
143 storage_dir: Option<PathBuf>,
144 db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
145 db_harness: RwLock<Option<String>>,
146 db_project_key: RwLock<Option<String>>,
147}
148
149#[derive(Debug, Clone)]
150struct DiskMeta {
151 dir: PathBuf,
152 count: usize,
153}
154
155#[derive(Debug, Clone, Default)]
156struct SessionMeta {
157 last_accessed: u64,
160}
161
162impl BackupStore {
163 pub fn new() -> Self {
164 BackupStore {
165 entries: HashMap::new(),
166 disk_index: HashMap::new(),
167 session_meta: HashMap::new(),
168 counter: AtomicU64::new(0),
169 storage_dir: None,
170 db_pool: RwLock::new(None),
171 db_harness: RwLock::new(None),
172 db_project_key: RwLock::new(None),
173 }
174 }
175
176 pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
177 if let Ok(mut slot) = self.db_pool.write() {
178 *slot = Some(conn);
179 }
180 }
181
182 pub fn clear_db_pool(&self) {
183 if let Ok(mut slot) = self.db_pool.write() {
184 *slot = None;
185 }
186 }
187
188 pub fn set_db_harness(&self, harness: crate::harness::Harness) {
189 if let Ok(mut slot) = self.db_harness.write() {
190 *slot = Some(harness.as_str().to_string());
191 }
192 }
193
194 pub fn set_db_project_key(&self, project_key: String) {
195 if let Ok(mut slot) = self.db_project_key.write() {
196 *slot = Some(project_key);
197 }
198 }
199
200 pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
206 self.storage_dir = Some(dir);
207 self.entries.clear();
208 self.disk_index.clear();
209 self.session_meta.clear();
210 self.gc_stale_sessions(ttl_hours);
211 self.migrate_legacy_layout_if_needed();
212 self.load_disk_index();
213 }
214
215 pub fn snapshot(
217 &mut self,
218 session: &str,
219 path: &Path,
220 description: &str,
221 ) -> Result<String, AftError> {
222 self.snapshot_with_op(session, path, description, None)
223 }
224
225 pub fn snapshot_with_op(
229 &mut self,
230 session: &str,
231 path: &Path,
232 description: &str,
233 op_id: Option<&str>,
234 ) -> Result<String, AftError> {
235 let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
236 path: path.display().to_string(),
237 })?;
238
239 let key = canonicalize_key(path);
240 let (id, order) = self.next_id_and_order();
241 let entry = BackupEntry {
242 backup_id: id.clone(),
243 content,
244 timestamp: current_timestamp(),
245 order,
246 description: description.to_string(),
247 op_id: op_id.map(str::to_string),
248 kind: BackupEntryKind::Content,
249 };
250
251 let session_entries = self.entries.entry(session.to_string()).or_default();
252 let stack = session_entries.entry(key.clone()).or_default();
253 if stack.len() >= MAX_UNDO_DEPTH {
254 stack.remove(0);
255 }
256 stack.push(entry);
257
258 let stack_clone = stack.clone();
260 self.write_snapshot_to_disk(session, &key, &stack_clone);
261 self.touch_session(session);
262
263 Ok(id)
264 }
265
266 pub fn snapshot_op_tombstone(
269 &mut self,
270 session: &str,
271 op_id: &str,
272 path: &Path,
273 description: &str,
274 ) -> Result<String, AftError> {
275 let key = canonicalize_key(path);
276 let (id, order) = self.next_id_and_order();
277 let entry = BackupEntry {
278 backup_id: id.clone(),
279 content: String::new(),
280 timestamp: current_timestamp(),
281 order,
282 description: description.to_string(),
283 op_id: Some(op_id.to_string()),
284 kind: BackupEntryKind::Tombstone,
285 };
286
287 let session_entries = self.entries.entry(session.to_string()).or_default();
288 let stack = session_entries.entry(key.clone()).or_default();
289 if stack.len() >= MAX_UNDO_DEPTH {
290 stack.remove(0);
291 }
292 stack.push(entry);
293
294 let stack_clone = stack.clone();
295 self.write_snapshot_to_disk(session, &key, &stack_clone);
296 self.touch_session(session);
297
298 Ok(id)
299 }
300
301 pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
304 match self.load_latest_operation_from_db(session) {
305 Some(Ok(true)) => {}
306 Some(Ok(false)) => {
307 crate::slog_info!(
308 "backup latest operation DB miss for session {}; falling back to disk",
309 session
310 );
311 self.load_all_disk_backups(session);
312 }
313 Some(Err(error)) => {
314 crate::slog_warn!(
315 "backup latest operation DB lookup failed for session {}; falling back to disk: {}",
316 session,
317 error
318 );
319 self.load_all_disk_backups(session);
320 }
321 None => {
322 crate::slog_info!(
323 "backup latest operation DB unavailable for session {}; falling back to disk",
324 session
325 );
326 self.load_all_disk_backups(session);
327 }
328 }
329
330 let mut latest: Option<(u128, String)> = None;
331 if let Some(files) = self.entries.get(session) {
332 for stack in files.values() {
333 if let Some(entry) = stack.last() {
334 if let Some(op_id) = &entry.op_id {
335 let order = entry.order;
336 if latest
337 .as_ref()
338 .map_or(true, |(latest_order, _)| order > *latest_order)
339 {
340 latest = Some((order, op_id.clone()));
341 }
342 }
343 }
344 }
345 }
346
347 let Some((_, op_id)) = latest else {
348 return Err(AftError::NoUndoHistory {
349 path: "operation".to_string(),
350 });
351 };
352
353 let mut keys_to_restore: Vec<PathBuf> = self
354 .entries
355 .get(session)
356 .map(|files| {
357 files
358 .iter()
359 .filter_map(|(key, stack)| {
360 stack.last().and_then(|entry| {
361 (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
362 })
363 })
364 .collect()
365 })
366 .unwrap_or_default();
367 keys_to_restore.sort();
368
369 if keys_to_restore.is_empty() {
370 return Err(AftError::NoUndoHistory {
371 path: "operation".to_string(),
372 });
373 }
374
375 let mut content_targets = Vec::new();
376 let mut tombstone_targets = Vec::new();
377 for key in &keys_to_restore {
378 let entry = self
379 .entries
380 .get(session)
381 .and_then(|files| files.get(key))
382 .and_then(|stack| stack.last())
383 .cloned()
384 .ok_or_else(|| AftError::NoUndoHistory {
385 path: key.display().to_string(),
386 })?;
387 match entry.kind {
388 BackupEntryKind::Content => {
389 let existing_content = match std::fs::read(key) {
390 Ok(content) => Some(content),
391 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
392 Err(e) => {
393 return Err(AftError::IoError {
394 path: key.display().to_string(),
395 message: e.to_string(),
396 });
397 }
398 };
399 let warning = self.check_external_modification(session, key, key);
400 content_targets.push((key.clone(), entry, warning, existing_content));
401 }
402 BackupEntryKind::Tombstone => {
403 let existing_content = if key.is_file() {
404 Some(std::fs::read(key).map_err(|e| AftError::IoError {
405 path: key.display().to_string(),
406 message: e.to_string(),
407 })?)
408 } else {
409 None
410 };
411 tombstone_targets.push((key.clone(), entry, existing_content));
412 }
413 }
414 }
415
416 let mut created_dirs = Vec::new();
417 for (key, _, _, _) in &content_targets {
418 if let Some(parent) = key.parent() {
419 if !parent.as_os_str().is_empty() {
420 let missing_dirs = missing_parent_dirs(parent);
421 if let Err(e) = std::fs::create_dir_all(parent) {
422 let mut dirs_to_remove = created_dirs;
423 dirs_to_remove.extend(missing_dirs);
424 let rollback_ok = rollback_created_dirs(&dirs_to_remove);
425 return Err(AftError::IoError {
426 path: parent.display().to_string(),
427 message: format!(
428 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
429 e,
430 !rollback_ok,
431 rollback_ok
432 ),
433 });
434 }
435 created_dirs.extend(missing_dirs);
436 }
437 }
438 }
439
440 let mut written = Vec::new();
441 for (key, entry, _, existing_content) in &content_targets {
442 if let Err(e) = std::fs::write(key, &entry.content) {
443 let files_rollback_ok =
444 rollback_transactional_restore(&written, Some((key, existing_content)));
445 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
446 let rollback_ok = files_rollback_ok && dirs_rollback_ok;
447 return Err(AftError::IoError {
448 path: key.display().to_string(),
449 message: format!(
450 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
451 e,
452 !rollback_ok,
453 rollback_ok
454 ),
455 });
456 }
457 written.push((key.clone(), existing_content.clone()));
458 }
459
460 let mut deleted_tombstones = Vec::new();
461 for (key, _, existing_content) in &tombstone_targets {
462 match std::fs::remove_file(key) {
463 Ok(()) => deleted_tombstones.push((key.clone(), existing_content.clone())),
464 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
465 deleted_tombstones.push((key.clone(), None));
466 }
467 Err(e) => {
468 let files_rollback_ok = rollback_transactional_restore(&written, None);
469 let tombstone_rollback_ok = rollback_deleted_tombstones(&deleted_tombstones);
470 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
471 let rollback_ok =
472 files_rollback_ok && tombstone_rollback_ok && dirs_rollback_ok;
473 return Err(AftError::IoError {
474 path: key.display().to_string(),
475 message: format!(
476 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
477 e,
478 !rollback_ok,
479 rollback_ok
480 ),
481 });
482 }
483 }
484 }
485
486 let mut restored = Vec::new();
487 let mut warnings = Vec::new();
488 for (key, entry, warning, _) in content_targets {
489 self.commit_restored_backup(session, &key);
490 if let Some(warning) = warning {
491 warnings.push(format!("{}: {}", key.display(), warning));
492 }
493 restored.push(RestoredFile {
494 path: key,
495 backup_id: entry.backup_id,
496 });
497 }
498 for (key, _, _) in tombstone_targets {
499 self.commit_restored_backup(session, &key);
500 }
501 self.touch_session(session);
502
503 Ok(RestoredOperation {
504 op_id,
505 restored,
506 warnings,
507 })
508 }
509
510 pub fn restore_latest(
513 &mut self,
514 session: &str,
515 path: &Path,
516 ) -> Result<(BackupEntry, Option<String>), AftError> {
517 let key = canonicalize_key(path);
518
519 match self.load_from_db_if_present(session, &key) {
520 Some(Ok(true)) => {
521 let warning = self.check_external_modification(session, &key, path);
522 let result = self
523 .do_restore(session, &key, path)
524 .map(|(entry, _)| (entry, warning));
525 if result.is_ok() {
526 self.touch_session(session);
527 }
528 return result;
529 }
530 Some(Ok(false)) => {
531 crate::slog_info!(
532 "backup DB miss for session {} path {}; falling back to disk",
533 session,
534 key.display()
535 );
536 }
537 Some(Err(error)) => {
538 crate::slog_warn!(
539 "backup DB lookup failed for session {} path {}; falling back to disk: {}",
540 session,
541 key.display(),
542 error
543 );
544 }
545 None => {
546 crate::slog_info!(
547 "backup DB unavailable for session {} path {}; falling back to disk",
548 session,
549 key.display()
550 );
551 }
552 }
553
554 let in_memory = self
556 .entries
557 .get(session)
558 .and_then(|s| s.get(&key))
559 .map_or(false, |s| !s.is_empty());
560 if in_memory {
561 let warning = self.check_external_modification(session, &key, path);
562 let result = self
563 .do_restore(session, &key, path)
564 .map(|(entry, _)| (entry, warning));
565 if result.is_ok() {
566 self.touch_session(session);
567 }
568 return result;
569 }
570
571 if self.load_from_disk_if_needed(session, &key) {
573 let warning = self.check_external_modification(session, &key, path);
575 let (entry, _) = self.do_restore(session, &key, path)?;
576 self.touch_session(session);
577 return Ok((entry, warning));
578 }
579
580 Err(AftError::NoUndoHistory {
581 path: path.display().to_string(),
582 })
583 }
584
585 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
587 let key = canonicalize_key(path);
588 match self.read_stack_from_db(session, &key) {
589 Some(Ok(stack)) if !stack.is_empty() => return stack,
590 Some(Ok(_)) => {
591 crate::slog_info!(
592 "backup history DB miss for session {} path {}; falling back to disk",
593 session,
594 key.display()
595 );
596 }
597 Some(Err(error)) => {
598 crate::slog_warn!(
599 "backup history DB lookup failed for session {} path {}; falling back to disk: {}",
600 session,
601 key.display(),
602 error
603 );
604 }
605 None => {
606 crate::slog_info!(
607 "backup history DB unavailable for session {} path {}; falling back to disk",
608 session,
609 key.display()
610 );
611 }
612 }
613
614 self.entries
615 .get(session)
616 .and_then(|s| s.get(&key))
617 .cloned()
618 .or_else(|| self.read_stack_from_disk(session, &key))
619 .unwrap_or_default()
620 }
621
622 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
624 let key = canonicalize_key(path);
625 self.disk_index
626 .get(session)
627 .and_then(|s| s.get(&key))
628 .map(|m| m.count)
629 .unwrap_or(0)
630 }
631
632 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
635 let mut files: std::collections::HashSet<PathBuf> = self
636 .entries
637 .get(session)
638 .map(|s| s.keys().cloned().collect())
639 .unwrap_or_default();
640 if let Some(disk) = self.disk_index.get(session) {
641 for key in disk.keys() {
642 files.insert(key.clone());
643 }
644 }
645 files.into_iter().collect()
646 }
647
648 pub fn sessions_with_backups(&self) -> Vec<String> {
651 let mut sessions: std::collections::HashSet<String> =
652 self.entries.keys().cloned().collect();
653 for s in self.disk_index.keys() {
654 sessions.insert(s.clone());
655 }
656 sessions.into_iter().collect()
657 }
658
659 pub fn total_disk_bytes(&self) -> u64 {
662 let mut total = 0u64;
663 for session_dirs in self.disk_index.values() {
664 for meta in session_dirs.values() {
665 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
666 for entry in read_dir.flatten() {
667 if let Ok(m) = entry.metadata() {
668 if m.is_file() {
669 total += m.len();
670 }
671 }
672 }
673 }
674 }
675 }
676 total
677 }
678
679 fn next_id_and_order(&self) -> (String, u128) {
680 let n = self.counter.fetch_add(1, Ordering::Relaxed);
681 let order = ((current_timestamp_nanos() as u128) << 32) | u128::from(n);
682 (format!("backup-{}", n), order)
683 }
684
685 fn db_pool_and_harness(&self) -> Option<(Arc<Mutex<Connection>>, String)> {
686 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone())?;
687 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone())?;
688 Some((pool, harness))
689 }
690
691 fn read_stack_from_db(
692 &self,
693 session: &str,
694 key: &Path,
695 ) -> Option<Result<Vec<BackupEntry>, String>> {
696 let (pool, harness) = self.db_pool_and_harness()?;
697 let conn = match pool.lock() {
698 Ok(conn) => conn,
699 Err(_) => return Some(Err("db mutex poisoned".to_string())),
700 };
701 let path_hash = Self::path_hash(key);
702 Some(
703 crate::db::backups::list_backups(&conn, &harness, session, &path_hash)
704 .map_err(|error| error.to_string())
705 .and_then(|rows| {
706 rows.into_iter()
707 .map(BackupEntry::try_from)
708 .collect::<Result<Vec<_>, _>>()
709 .map_err(|error| error.to_string())
710 }),
711 )
712 }
713
714 fn load_from_db_if_present(
715 &mut self,
716 session: &str,
717 key: &Path,
718 ) -> Option<Result<bool, String>> {
719 match self.read_stack_from_db(session, key) {
720 Some(Ok(stack)) if !stack.is_empty() => {
721 self.update_counter_from_entries(&stack);
722 self.entries
723 .entry(session.to_string())
724 .or_default()
725 .insert(key.to_path_buf(), stack);
726 Some(Ok(true))
727 }
728 Some(Ok(_)) => Some(Ok(false)),
729 Some(Err(error)) => Some(Err(error)),
730 None => None,
731 }
732 }
733
734 fn load_latest_operation_from_db(&mut self, session: &str) -> Option<Result<bool, String>> {
735 let (pool, harness) = self.db_pool_and_harness()?;
736 let conn = match pool.lock() {
737 Ok(conn) => conn,
738 Err(_) => return Some(Err("db mutex poisoned".to_string())),
739 };
740 let latest = match crate::db::backups::get_latest_operation_backup(&conn, &harness, session)
741 {
742 Ok(Some(row)) => row,
743 Ok(None) => return Some(Ok(false)),
744 Err(error) => return Some(Err(error.to_string())),
745 };
746 let Some(op_id) = latest.op_id else {
747 return Some(Ok(false));
748 };
749 let rows = match crate::db::backups::list_backups_by_op(&conn, &harness, session, &op_id) {
750 Ok(rows) => rows,
751 Err(error) => return Some(Err(error.to_string())),
752 };
753 if rows.is_empty() {
754 return Some(Ok(false));
755 }
756 let path_hashes: std::collections::HashSet<String> =
757 rows.into_iter().map(|row| row.path_hash).collect();
758 drop(conn);
759
760 let mut loaded_any = false;
761 for path_hash in path_hashes {
762 let conn = match pool.lock() {
763 Ok(conn) => conn,
764 Err(_) => return Some(Err("db mutex poisoned".to_string())),
765 };
766 let loaded =
767 match crate::db::backups::list_backups(&conn, &harness, session, &path_hash) {
768 Ok(rows) => {
769 let file_path = rows.first().map(|row| row.file_path.clone());
770 rows.into_iter()
771 .map(BackupEntry::try_from)
772 .collect::<Result<Vec<_>, _>>()
773 .map(|stack| (file_path, stack))
774 .map_err(|error| error.to_string())
775 }
776 Err(error) => Err(error.to_string()),
777 };
778 drop(conn);
779 let (file_path, stack) = match loaded {
780 Ok((file_path, stack)) if !stack.is_empty() => (file_path, stack),
781 Ok(_) => continue,
782 Err(error) => return Some(Err(error)),
783 };
784 let Some(file_path) = file_path else {
785 return Some(Err(format!(
786 "backup DB rows for path hash {path_hash} have no file path"
787 )));
788 };
789 let key = PathBuf::from(file_path);
790 self.update_counter_from_entries(&stack);
791 self.entries
792 .entry(session.to_string())
793 .or_default()
794 .insert(key, stack);
795 loaded_any = true;
796 }
797
798 Some(Ok(loaded_any))
799 }
800
801 fn update_counter_from_entries(&self, entries: &[BackupEntry]) {
802 if let Some(next_counter) = entries
803 .iter()
804 .filter_map(|entry| backup_sequence(&entry.backup_id))
805 .max()
806 .and_then(|max| max.checked_add(1))
807 {
808 let _ = self
809 .counter
810 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
811 (current < next_counter).then_some(next_counter)
812 });
813 }
814 }
815
816 pub fn discard_operation_entries(&mut self, session: &str, op_id: &str) {
817 let keys: Vec<PathBuf> = self
818 .entries
819 .get(session)
820 .map(|files| files.keys().cloned().collect())
821 .unwrap_or_default();
822
823 for key in keys {
824 let mut remove_key = false;
825 let mut remaining_stack = None;
826 if let Some(session_entries) = self.entries.get_mut(session) {
827 if let Some(stack) = session_entries.get_mut(&key) {
828 while stack
829 .last()
830 .is_some_and(|entry| entry.op_id.as_deref() == Some(op_id))
831 {
832 stack.pop();
833 }
834 if stack.is_empty() {
835 remove_key = true;
836 } else {
837 remaining_stack = Some(stack.clone());
838 }
839 }
840 if remove_key {
841 session_entries.remove(&key);
842 }
843 }
844
845 if remove_key {
846 self.remove_disk_backups(session, &key);
847 } else if let Some(stack) = remaining_stack {
848 self.write_snapshot_to_disk(session, &key, &stack);
849 }
850 }
851
852 if self
853 .entries
854 .get(session)
855 .is_some_and(|session_entries| session_entries.is_empty())
856 {
857 self.entries.remove(session);
858 }
859 }
860
861 fn touch_session(&mut self, session: &str) {
862 let now = current_timestamp();
863 self.session_meta
864 .entry(session.to_string())
865 .or_default()
866 .last_accessed = now;
867 self.write_session_marker(session, now);
868 }
869
870 fn do_restore(
873 &mut self,
874 session: &str,
875 key: &Path,
876 path: &Path,
877 ) -> Result<(BackupEntry, Option<String>), AftError> {
878 let session_entries =
879 self.entries
880 .get_mut(session)
881 .ok_or_else(|| AftError::NoUndoHistory {
882 path: path.display().to_string(),
883 })?;
884 let stack = session_entries
885 .get_mut(key)
886 .ok_or_else(|| AftError::NoUndoHistory {
887 path: path.display().to_string(),
888 })?;
889
890 let entry = stack
891 .last()
892 .cloned()
893 .ok_or_else(|| AftError::NoUndoHistory {
894 path: path.display().to_string(),
895 })?;
896
897 match entry.kind {
898 BackupEntryKind::Content => {
899 if let Some(parent) = path.parent() {
903 if !parent.as_os_str().is_empty() {
904 std::fs::create_dir_all(parent).map_err(|e| AftError::IoError {
905 path: parent.display().to_string(),
906 message: e.to_string(),
907 })?;
908 }
909 }
910 std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
911 path: path.display().to_string(),
912 message: e.to_string(),
913 })?;
914 }
915 BackupEntryKind::Tombstone => match std::fs::remove_file(path) {
916 Ok(()) => {}
917 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
918 Err(e) => {
919 return Err(AftError::IoError {
920 path: path.display().to_string(),
921 message: e.to_string(),
922 });
923 }
924 },
925 }
926
927 stack.pop();
928 if stack.is_empty() {
929 session_entries.remove(key);
930 if session_entries.is_empty() {
932 self.entries.remove(session);
933 }
934 self.remove_disk_backups(session, key);
935 } else {
936 let stack_clone = self
937 .entries
938 .get(session)
939 .and_then(|s| s.get(key))
940 .cloned()
941 .unwrap_or_default();
942 self.write_snapshot_to_disk(session, key, &stack_clone);
943 }
944
945 Ok((entry, None))
946 }
947
948 fn commit_restored_backup(&mut self, session: &str, key: &Path) {
949 let mut remove_key = false;
950 let mut remove_session = false;
951 let mut remaining_stack = None;
952
953 if let Some(session_entries) = self.entries.get_mut(session) {
954 if let Some(stack) = session_entries.get_mut(key) {
955 stack.pop();
956 if stack.is_empty() {
957 remove_key = true;
958 } else {
959 remaining_stack = Some(stack.clone());
960 }
961 }
962
963 if remove_key {
964 session_entries.remove(key);
965 remove_session = session_entries.is_empty();
966 }
967 }
968
969 if remove_session {
970 self.entries.remove(session);
971 }
972
973 if remove_key {
974 self.remove_disk_backups(session, key);
975 } else if let Some(stack) = remaining_stack {
976 self.write_snapshot_to_disk(session, key, &stack);
977 }
978 }
979
980 fn check_external_modification(
981 &self,
982 session: &str,
983 key: &Path,
984 path: &Path,
985 ) -> Option<String> {
986 if let (Some(stack), Ok(current)) = (
987 self.entries.get(session).and_then(|s| s.get(key)),
988 std::fs::read_to_string(path),
989 ) {
990 if let Some(latest) = stack.last() {
991 if latest.content != current {
992 return Some("file was modified externally since last backup".to_string());
993 }
994 }
995 }
996 None
997 }
998
999 fn backups_dir(&self) -> Option<PathBuf> {
1002 self.storage_dir.as_ref().map(|d| d.join("backups"))
1003 }
1004
1005 fn session_dir(&self, session: &str) -> Option<PathBuf> {
1006 self.backups_dir()
1007 .map(|d| d.join(Self::session_hash(session)))
1008 }
1009
1010 fn session_hash(session: &str) -> String {
1011 hash_session(session)
1012 }
1013
1014 fn path_hash(key: &Path) -> String {
1015 stable_hash_16(key.to_string_lossy().as_bytes())
1020 }
1021
1022 fn write_session_marker(&self, session: &str, last_accessed: u64) {
1023 let Some(session_dir) = self.session_dir(session) else {
1024 return;
1025 };
1026 if let Err(e) = std::fs::create_dir_all(&session_dir) {
1027 crate::slog_warn!("failed to create session dir: {}", e);
1028 return;
1029 }
1030 let marker = session_dir.join("session.json");
1031 let json = serde_json::json!({
1032 "schema_version": SCHEMA_VERSION,
1033 "session_id": session,
1034 "last_accessed": last_accessed,
1035 });
1036 if let Ok(s) = serde_json::to_string_pretty(&json) {
1037 let tmp = session_dir.join("session.json.tmp");
1038 if std::fs::write(&tmp, s).is_ok() {
1039 let _ = std::fs::rename(&tmp, marker);
1040 }
1041 }
1042 }
1043
1044 fn gc_stale_sessions(&mut self, ttl_hours: u32) {
1045 let backups_dir = match self.backups_dir() {
1046 Some(d) if d.exists() => d,
1047 _ => return,
1048 };
1049 let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
1050 let cutoff = current_timestamp().saturating_sub(ttl_secs);
1051 let entries = match std::fs::read_dir(&backups_dir) {
1052 Ok(entries) => entries,
1053 Err(_) => return,
1054 };
1055
1056 for entry in entries.flatten() {
1057 let session_dir = entry.path();
1058 if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
1059 continue;
1060 }
1061 let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
1062 continue;
1063 };
1064 if last_accessed >= cutoff {
1065 continue;
1066 }
1067 if let Err(e) = std::fs::remove_dir_all(&session_dir) {
1068 crate::slog_warn!(
1069 "failed to remove stale backup session {}: {}",
1070 session_dir.display(),
1071 e
1072 );
1073 } else {
1074 crate::slog_warn!(
1075 "removed stale backup session {} (last_accessed={})",
1076 session_dir.display(),
1077 last_accessed
1078 );
1079 }
1080 }
1081 }
1082
1083 fn migrate_legacy_layout_if_needed(&mut self) {
1091 let backups_dir = match self.backups_dir() {
1092 Some(d) if d.exists() => d,
1093 _ => return,
1094 };
1095 let default_session_dir =
1096 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
1097
1098 let entries = match std::fs::read_dir(&backups_dir) {
1099 Ok(e) => e,
1100 Err(_) => return,
1101 };
1102 let mut migrated = 0usize;
1103 for entry in entries.flatten() {
1104 let entry_path = entry.path();
1105 if !entry_path.is_dir() {
1107 continue;
1108 }
1109 if entry_path == default_session_dir {
1110 continue;
1111 }
1112 let meta_path = entry_path.join("meta.json");
1113 if !meta_path.exists() {
1114 continue; }
1116 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
1119 crate::slog_warn!("failed to create default session dir: {}", e);
1120 return;
1121 }
1122 let leaf = match entry_path.file_name() {
1123 Some(n) => n,
1124 None => continue,
1125 };
1126 let target = default_session_dir.join(leaf);
1127 if target.exists() {
1128 continue;
1131 }
1132 match std::fs::rename(&entry_path, &target) {
1133 Ok(()) => {
1134 Self::upgrade_meta_file(
1136 &target.join("meta.json"),
1137 crate::protocol::DEFAULT_SESSION_ID,
1138 );
1139 migrated += 1;
1140 }
1141 Err(e) => {
1142 crate::slog_warn!(
1143 "failed to migrate legacy backup {}: {}",
1144 entry_path.display(),
1145 e
1146 );
1147 }
1148 }
1149 }
1150 if migrated > 0 {
1151 crate::slog_info!(
1152 "migrated {} legacy backup entries into default session namespace",
1153 migrated
1154 );
1155 let marker = default_session_dir.join("session.json");
1157 let json = serde_json::json!({
1158 "schema_version": SCHEMA_VERSION,
1159 "session_id": crate::protocol::DEFAULT_SESSION_ID,
1160 "last_accessed": current_timestamp(),
1161 });
1162 if let Ok(s) = serde_json::to_string_pretty(&json) {
1163 let _ = std::fs::write(&marker, s);
1164 }
1165 }
1166 }
1167
1168 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
1169 let content = match std::fs::read_to_string(meta_path) {
1170 Ok(c) => c,
1171 Err(_) => return,
1172 };
1173 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
1174 Ok(v) => v,
1175 Err(_) => return,
1176 };
1177 if let Some(obj) = parsed.as_object_mut() {
1178 let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1179 obj.insert(
1180 "schema_version".to_string(),
1181 serde_json::json!(SCHEMA_VERSION),
1182 );
1183 obj.insert("session_id".to_string(), serde_json::json!(session_id));
1184 obj.entry("entries").or_insert_with(|| {
1185 serde_json::Value::Array(
1186 (0..count)
1187 .map(|i| {
1188 serde_json::json!({
1189 "backup_id": format!("disk-{}", i),
1190 "timestamp": 0,
1191 "description": "restored from disk",
1192 "op_id": null,
1193 })
1194 })
1195 .collect(),
1196 )
1197 });
1198 }
1199 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
1200 let tmp = meta_path.with_extension("json.tmp");
1201 if std::fs::write(&tmp, &s).is_ok() {
1202 let _ = std::fs::rename(&tmp, meta_path);
1203 }
1204 }
1205 }
1206
1207 fn load_disk_index(&mut self) {
1208 let backups_dir = match self.backups_dir() {
1209 Some(d) if d.exists() => d,
1210 _ => return,
1211 };
1212 let session_dirs = match std::fs::read_dir(&backups_dir) {
1213 Ok(e) => e,
1214 Err(_) => return,
1215 };
1216 let mut total_entries = 0usize;
1217 for session_entry in session_dirs.flatten() {
1218 let session_dir = session_entry.path();
1219 if !session_dir.is_dir() {
1220 continue;
1221 }
1222 let session_id = match Self::read_session_marker(&session_dir) {
1225 Some(session_id) => session_id,
1226 None => {
1227 crate::slog_warn!(
1228 "skipping backup session dir without readable session marker: {}",
1229 session_dir.display()
1230 );
1231 continue;
1232 }
1233 };
1234
1235 let path_dirs = match std::fs::read_dir(&session_dir) {
1236 Ok(e) => e,
1237 Err(_) => continue,
1238 };
1239 let per_session = self.disk_index.entry(session_id.clone()).or_default();
1240 for path_entry in path_dirs.flatten() {
1241 let path_dir = path_entry.path();
1242 if !path_dir.is_dir() {
1243 continue;
1244 }
1245 let meta_path = path_dir.join("meta.json");
1246 if let Ok(content) = std::fs::read_to_string(&meta_path) {
1247 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
1248 if let (Some(path_str), Some(count)) = (
1249 meta.get("path").and_then(|v| v.as_str()),
1250 meta.get("count").and_then(|v| v.as_u64()),
1251 ) {
1252 let key = PathBuf::from(path_str);
1253 if !is_loadable_backup_path(&key, &path_dir) {
1254 crate::slog_warn!(
1255 "skipping backup entry with invalid path metadata: {}",
1256 meta_path.display()
1257 );
1258 continue;
1259 }
1260 per_session.insert(
1261 key,
1262 DiskMeta {
1263 dir: path_dir.clone(),
1264 count: count as usize,
1265 },
1266 );
1267 total_entries += 1;
1268 }
1269 }
1270 }
1271 }
1272 if per_session.is_empty() {
1273 self.disk_index.remove(&session_id);
1274 }
1275 }
1276 if total_entries > 0 {
1277 crate::slog_info!(
1278 "loaded {} backup entries across {} session(s) from disk",
1279 total_entries,
1280 self.disk_index.len()
1281 );
1282 }
1283 }
1284
1285 fn read_session_marker(session_dir: &Path) -> Option<String> {
1286 let marker = session_dir.join("session.json");
1287 let content = std::fs::read_to_string(&marker).ok()?;
1288 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1289 parsed
1290 .get("session_id")
1291 .and_then(|v| v.as_str())
1292 .map(|s| s.to_string())
1293 }
1294
1295 fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
1296 let marker = session_dir.join("session.json");
1297 let content = std::fs::read_to_string(&marker).ok()?;
1298 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
1299 parsed.get("last_accessed").and_then(|v| v.as_u64())
1300 }
1301
1302 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
1303 let Some(entries) = self.read_stack_from_disk(session, key) else {
1304 return false;
1305 };
1306
1307 self.update_counter_from_entries(&entries);
1308
1309 self.entries
1310 .entry(session.to_string())
1311 .or_default()
1312 .insert(key.to_path_buf(), entries);
1313 true
1314 }
1315
1316 fn load_all_disk_backups(&mut self, session: &str) {
1317 let disk_keys: Vec<PathBuf> = self
1318 .disk_index
1319 .get(session)
1320 .map(|files| files.keys().cloned().collect())
1321 .unwrap_or_default();
1322 for key in disk_keys {
1323 self.load_from_disk_if_needed(session, &key);
1324 }
1325 }
1326
1327 fn read_stack_from_disk(&self, session: &str, key: &Path) -> Option<Vec<BackupEntry>> {
1328 let disk_meta = match self
1329 .disk_index
1330 .get(session)
1331 .and_then(|s| s.get(key))
1332 .cloned()
1333 {
1334 Some(m) if m.count > 0 => m,
1335 _ => return None,
1336 };
1337
1338 let mut entries = Vec::new();
1339 let entry_meta = std::fs::read_to_string(disk_meta.dir.join("meta.json"))
1340 .ok()
1341 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
1342 .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
1343 .unwrap_or_default();
1344
1345 for i in 0..disk_meta.count {
1346 let meta = entry_meta.get(i);
1347 let kind = match meta.and_then(|m| m.get("kind")).and_then(|v| v.as_str()) {
1348 Some("tombstone") => BackupEntryKind::Tombstone,
1349 _ => BackupEntryKind::Content,
1350 };
1351 let content = match kind {
1352 BackupEntryKind::Content => {
1353 let bak_path = disk_meta.dir.join(format!("{}.bak", i));
1354 match std::fs::read_to_string(&bak_path) {
1355 Ok(content) => content,
1356 Err(_) => continue,
1357 }
1358 }
1359 BackupEntryKind::Tombstone => String::new(),
1360 };
1361 let backup_id = meta
1362 .and_then(|m| m.get("backup_id"))
1363 .and_then(|v| v.as_str())
1364 .map(str::to_string)
1365 .unwrap_or_else(|| format!("disk-{}", i));
1366 let timestamp = meta
1367 .and_then(|m| m.get("timestamp"))
1368 .and_then(|v| v.as_u64())
1369 .unwrap_or(0);
1370 let order = meta
1371 .and_then(|m| m.get("order"))
1372 .and_then(parse_order_value)
1373 .unwrap_or_else(|| legacy_entry_order(timestamp, &backup_id));
1374 entries.push(BackupEntry {
1375 backup_id,
1376 content,
1377 timestamp,
1378 order,
1379 description: meta
1380 .and_then(|m| m.get("description"))
1381 .and_then(|v| v.as_str())
1382 .unwrap_or("restored from disk")
1383 .to_string(),
1384 op_id: meta
1385 .and_then(|m| m.get("op_id"))
1386 .and_then(|v| v.as_str())
1387 .map(str::to_string),
1388 kind,
1389 });
1390 }
1391
1392 if entries.is_empty() {
1393 return None;
1394 }
1395 Some(entries)
1396 }
1397
1398 fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
1399 let session_dir = match self.session_dir(session) {
1400 Some(d) => d,
1401 None => return,
1402 };
1403
1404 if let Err(e) = std::fs::create_dir_all(&session_dir) {
1406 crate::slog_warn!("failed to create session dir: {}", e);
1407 return;
1408 }
1409 let marker = session_dir.join("session.json");
1410 if !marker.exists() {
1411 let json = serde_json::json!({
1412 "schema_version": SCHEMA_VERSION,
1413 "session_id": session,
1414 "last_accessed": current_timestamp(),
1415 });
1416 if let Ok(s) = serde_json::to_string_pretty(&json) {
1417 let _ = std::fs::write(&marker, s);
1418 }
1419 }
1420
1421 let hash = Self::path_hash(key);
1422 let dir = session_dir.join(&hash);
1423 if let Err(e) = std::fs::create_dir_all(&dir) {
1424 crate::slog_warn!("failed to create backup dir: {}", e);
1425 return;
1426 }
1427
1428 for (i, entry) in stack.iter().enumerate() {
1429 let bak_path = dir.join(format!("{}.bak", i));
1430 let tmp_path = dir.join(format!("{}.bak.tmp", i));
1431 match entry.kind {
1432 BackupEntryKind::Content => {
1433 if std::fs::write(&tmp_path, &entry.content).is_ok() {
1434 let _ = std::fs::rename(&tmp_path, &bak_path);
1435 }
1436 }
1437 BackupEntryKind::Tombstone => {
1438 let _ = std::fs::remove_file(&bak_path);
1439 let _ = std::fs::remove_file(&tmp_path);
1440 }
1441 }
1442 }
1443
1444 for i in stack.len()..MAX_UNDO_DEPTH {
1446 let old = dir.join(format!("{}.bak", i));
1447 if old.exists() {
1448 let _ = std::fs::remove_file(&old);
1449 }
1450 }
1451
1452 let entries: Vec<serde_json::Value> = stack
1453 .iter()
1454 .map(|entry| {
1455 serde_json::json!({
1456 "backup_id": entry.backup_id,
1457 "timestamp": entry.timestamp,
1458 "order": entry.order.to_string(),
1459 "description": entry.description,
1460 "op_id": entry.op_id,
1461 "kind": match entry.kind {
1462 BackupEntryKind::Content => "content",
1463 BackupEntryKind::Tombstone => "tombstone",
1464 },
1465 })
1466 })
1467 .collect();
1468 let meta = serde_json::json!({
1469 "schema_version": SCHEMA_VERSION,
1470 "session_id": session,
1471 "path": key.display().to_string(),
1472 "count": stack.len(),
1473 "entries": entries,
1474 });
1475 let meta_path = dir.join("meta.json");
1476 let meta_tmp = dir.join("meta.json.tmp");
1477 if let Ok(content) = serde_json::to_string_pretty(&meta) {
1478 if std::fs::write(&meta_tmp, &content).is_ok() {
1479 let _ = std::fs::rename(&meta_tmp, &meta_path);
1480 }
1481 }
1482
1483 self.disk_index
1486 .entry(session.to_string())
1487 .or_default()
1488 .insert(
1489 key.to_path_buf(),
1490 DiskMeta {
1491 dir: dir.clone(),
1492 count: stack.len(),
1493 },
1494 );
1495 self.dual_write_stack_to_db(session, key, &dir, stack);
1496 }
1497
1498 fn dual_write_stack_to_db(&self, session: &str, key: &Path, dir: &Path, stack: &[BackupEntry]) {
1499 let pool = self.db_pool.read().ok().and_then(|slot| slot.clone());
1500 let Some(pool) = pool else {
1501 return;
1502 };
1503 let harness = self.db_harness.read().ok().and_then(|slot| slot.clone());
1504 let Some(harness) = harness else {
1505 crate::slog_warn!(
1506 "dual-write backup to DB skipped for {}: harness not configured",
1507 key.display()
1508 );
1509 return;
1510 };
1511 let project_key = self
1512 .db_project_key
1513 .read()
1514 .ok()
1515 .and_then(|slot| slot.clone());
1516 let Some(project_key) = project_key else {
1517 crate::slog_warn!(
1518 "dual-write backup to DB skipped for {}: project key not configured",
1519 key.display()
1520 );
1521 return;
1522 };
1523
1524 let conn = match pool.lock() {
1525 Ok(conn) => conn,
1526 Err(_) => {
1527 crate::slog_warn!(
1528 "dual-write backup to DB failed for {}: db mutex poisoned",
1529 key.display()
1530 );
1531 return;
1532 }
1533 };
1534 let path_hash = Self::path_hash(key);
1535 let file_path = key.display().to_string();
1536 if let Err(error) =
1537 crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1538 {
1539 crate::slog_warn!(
1540 "delete old backup DB rows failed for {}: {}",
1541 key.display(),
1542 error
1543 );
1544 return;
1545 }
1546 for (index, entry) in stack.iter().enumerate() {
1547 let backup_path = match entry.kind {
1548 BackupEntryKind::Content => {
1549 Some(dir.join(format!("{}.bak", index)).display().to_string())
1550 }
1551 BackupEntryKind::Tombstone => None,
1552 };
1553 let row = entry.to_backup_row(
1554 &harness,
1555 session,
1556 &project_key,
1557 &file_path,
1558 &path_hash,
1559 backup_path.as_deref(),
1560 );
1561 if let Err(error) = crate::db::backups::upsert_backup(&conn, &row) {
1562 crate::slog_warn!(
1563 "dual-write backup to DB failed for {}: {}",
1564 entry.backup_id,
1565 error
1566 );
1567 }
1568 }
1569 }
1570
1571 fn remove_disk_backups(&mut self, session: &str, key: &Path) {
1572 self.remove_db_backups(session, key);
1573 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
1574 if let Some(meta) = removed {
1575 let _ = std::fs::remove_dir_all(&meta.dir);
1576 } else if let Some(session_dir) = self.session_dir(session) {
1577 let hash = Self::path_hash(key);
1578 let dir = session_dir.join(&hash);
1579 if dir.exists() {
1580 let _ = std::fs::remove_dir_all(&dir);
1581 }
1582 }
1583
1584 let empty = self
1587 .disk_index
1588 .get(session)
1589 .map(|s| s.is_empty())
1590 .unwrap_or(false);
1591 if empty {
1592 self.disk_index.remove(session);
1593 }
1594 }
1595
1596 fn remove_db_backups(&self, session: &str, key: &Path) {
1597 let Some((pool, harness)) = self.db_pool_and_harness() else {
1598 return;
1599 };
1600 let conn = match pool.lock() {
1601 Ok(conn) => conn,
1602 Err(_) => {
1603 crate::slog_warn!(
1604 "delete backup DB rows failed for {}: db mutex poisoned",
1605 key.display()
1606 );
1607 return;
1608 }
1609 };
1610 let path_hash = Self::path_hash(key);
1611 if let Err(error) =
1612 crate::db::backups::delete_backups_for_path(&conn, &harness, session, &path_hash)
1613 {
1614 crate::slog_warn!(
1615 "delete backup DB rows failed for {}: {}",
1616 key.display(),
1617 error
1618 );
1619 }
1620 }
1621}
1622
1623pub fn hash_session(session: &str) -> String {
1624 stable_hash_16(session.as_bytes())
1625}
1626
1627pub fn new_op_id() -> String {
1628 let mut bytes = [0u8; 4];
1629 if getrandom::fill(&mut bytes).is_err() {
1630 bytes = current_timestamp().to_le_bytes()[..4]
1631 .try_into()
1632 .unwrap_or([0; 4]);
1633 }
1634 let rand = u32::from_le_bytes(bytes);
1635 format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1636}
1637
1638fn canonicalize_key(path: &Path) -> PathBuf {
1639 std::fs::canonicalize(path).unwrap_or_else(|err| {
1640 log::debug!(
1641 "backup canonicalize_key fallback for {}: {}",
1642 path.display(),
1643 err
1644 );
1645 path.to_path_buf()
1646 })
1647}
1648
1649fn rollback_transactional_restore(
1650 written: &[(PathBuf, Option<Vec<u8>>)],
1651 attempted: Option<(&PathBuf, &Option<Vec<u8>>)>,
1652) -> bool {
1653 let mut ok = true;
1654
1655 if let Some((path, content)) = attempted {
1656 ok &= rollback_one_restore_write(path, content);
1657 }
1658
1659 for (path, content) in written.iter().rev() {
1660 ok &= rollback_one_restore_write(path, content);
1661 }
1662
1663 ok
1664}
1665
1666fn rollback_one_restore_write(path: &Path, content: &Option<Vec<u8>>) -> bool {
1667 match content {
1668 Some(content) => std::fs::write(path, content).is_ok(),
1669 None => match std::fs::remove_file(path) {
1670 Ok(()) => true,
1671 Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1672 Err(_) => false,
1673 },
1674 }
1675}
1676
1677fn rollback_deleted_tombstones(deleted: &[(PathBuf, Option<Vec<u8>>)]) -> bool {
1678 let mut ok = true;
1679 for (path, content) in deleted.iter().rev() {
1680 if let Some(content) = content {
1681 if let Some(parent) = path.parent() {
1682 if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
1683 ok = false;
1684 continue;
1685 }
1686 }
1687 if std::fs::write(path, content).is_err() {
1688 ok = false;
1689 }
1690 }
1691 }
1692 ok
1693}
1694
1695fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
1696 let mut dirs = Vec::new();
1697 let mut current = Some(parent);
1698
1699 while let Some(dir) = current {
1700 if dir.as_os_str().is_empty() || dir.exists() {
1701 break;
1702 }
1703 dirs.push(dir.to_path_buf());
1704 current = dir.parent();
1705 }
1706
1707 dirs
1708}
1709
1710fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
1711 let mut dirs = dirs.to_vec();
1712 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1713 dirs.dedup();
1714
1715 let mut ok = true;
1716 for dir in dirs {
1717 match std::fs::remove_dir(&dir) {
1718 Ok(()) => {}
1719 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1720 Err(_) => ok = false,
1721 }
1722 }
1723
1724 ok
1725}
1726
1727fn current_timestamp() -> u64 {
1728 std::time::SystemTime::now()
1729 .duration_since(std::time::UNIX_EPOCH)
1730 .unwrap_or_default()
1731 .as_secs()
1732}
1733
1734fn current_timestamp_nanos() -> u64 {
1735 let nanos = std::time::SystemTime::now()
1736 .duration_since(std::time::UNIX_EPOCH)
1737 .unwrap_or_default()
1738 .as_nanos();
1739 nanos.min(u128::from(u64::MAX)) as u64
1740}
1741
1742fn legacy_entry_order(timestamp_secs: u64, backup_id: &str) -> u128 {
1743 let nanos = timestamp_secs.saturating_mul(1_000_000_000);
1744 ((nanos as u128) << 32) | u128::from(backup_sequence(backup_id).unwrap_or(0))
1745}
1746
1747fn parse_order_value(value: &serde_json::Value) -> Option<u128> {
1748 value
1749 .as_str()
1750 .and_then(|s| s.parse::<u128>().ok())
1751 .or_else(|| value.as_u64().map(u128::from))
1752}
1753
1754fn is_loadable_backup_path(key: &Path, path_dir: &Path) -> bool {
1755 if !key.is_absolute()
1756 || key
1757 .components()
1758 .any(|c| matches!(c, std::path::Component::ParentDir))
1759 {
1760 return false;
1761 }
1762 let Some(dir_name) = path_dir.file_name().and_then(|name| name.to_str()) else {
1763 return false;
1764 };
1765 BackupStore::path_hash(key) == dir_name
1766}
1767
1768fn stable_hash_16(bytes: &[u8]) -> String {
1769 let digest = Sha256::digest(bytes);
1770 digest[..8]
1771 .iter()
1772 .map(|byte| format!("{:02x}", byte))
1773 .collect()
1774}
1775
1776fn backup_sequence(backup_id: &str) -> Option<u64> {
1777 backup_id
1778 .strip_prefix("backup-")
1779 .or_else(|| backup_id.strip_prefix("disk-"))
1780 .and_then(|s| s.parse().ok())
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785 use super::*;
1786 use crate::protocol::DEFAULT_SESSION_ID;
1787 use std::fs;
1788 #[cfg(unix)]
1789 use std::os::unix::fs::PermissionsExt;
1790
1791 fn temp_file(name: &str, content: &str) -> PathBuf {
1792 let dir = std::env::temp_dir().join("aft_backup_tests");
1793 fs::create_dir_all(&dir).unwrap();
1794 let path = dir.join(name);
1795 fs::write(&path, content).unwrap();
1796 path
1797 }
1798
1799 #[test]
1800 fn snapshot_and_restore_round_trip() {
1801 let path = temp_file("round_trip.txt", "original");
1802 let mut store = BackupStore::new();
1803
1804 let id = store
1805 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
1806 .unwrap();
1807 assert!(id.starts_with("backup-"));
1808
1809 fs::write(&path, "modified").unwrap();
1810 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
1811
1812 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1813 assert_eq!(entry.content, "original");
1814 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1815 }
1816
1817 #[test]
1818 fn multiple_snapshots_preserve_order() {
1819 let path = temp_file("order.txt", "v1");
1820 let mut store = BackupStore::new();
1821
1822 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1823 fs::write(&path, "v2").unwrap();
1824 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1825 fs::write(&path, "v3").unwrap();
1826 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
1827
1828 let history = store.history(DEFAULT_SESSION_ID, &path);
1829 assert_eq!(history.len(), 3);
1830 assert_eq!(history[0].content, "v1");
1831 assert_eq!(history[1].content, "v2");
1832 assert_eq!(history[2].content, "v3");
1833 }
1834
1835 #[test]
1836 fn restore_pops_from_stack() {
1837 let path = temp_file("pop.txt", "v1");
1838 let mut store = BackupStore::new();
1839
1840 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1841 fs::write(&path, "v2").unwrap();
1842 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1843
1844 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1845 assert_eq!(entry.description, "second");
1846 assert_eq!(entry.content, "v2");
1847
1848 let history = store.history(DEFAULT_SESSION_ID, &path);
1849 assert_eq!(history.len(), 1);
1850 }
1851
1852 #[test]
1853 fn empty_history_returns_empty_vec() {
1854 let store = BackupStore::new();
1855 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
1856 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
1857 }
1858
1859 #[test]
1860 fn snapshot_nonexistent_file_returns_error() {
1861 let mut store = BackupStore::new();
1862 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
1863 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
1864 }
1865
1866 #[test]
1867 fn tracked_files_lists_snapshotted_paths() {
1868 let path1 = temp_file("tracked1.txt", "a");
1869 let path2 = temp_file("tracked2.txt", "b");
1870 let mut store = BackupStore::new();
1871
1872 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
1873 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
1874 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
1875 }
1876
1877 #[test]
1878 fn sessions_are_isolated() {
1879 let path = temp_file("isolated.txt", "original");
1880 let mut store = BackupStore::new();
1881
1882 store.snapshot("session_a", &path, "a's snapshot").unwrap();
1883
1884 assert!(store.history("session_b", &path).is_empty());
1886 assert_eq!(store.tracked_files("session_b").len(), 0);
1887
1888 let err = store.restore_latest("session_b", &path);
1890 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1891
1892 assert_eq!(store.history("session_a", &path).len(), 1);
1894 assert_eq!(store.tracked_files("session_a").len(), 1);
1895 }
1896
1897 #[test]
1898 fn per_session_per_file_cap_is_independent() {
1899 let path = temp_file("cap_indep.txt", "v0");
1902 let mut store = BackupStore::new();
1903
1904 for i in 0..(MAX_UNDO_DEPTH + 5) {
1905 fs::write(&path, format!("a{}", i)).unwrap();
1906 store.snapshot("session_a", &path, "a").unwrap();
1907 }
1908 fs::write(&path, "b_initial").unwrap();
1909 store.snapshot("session_b", &path, "b").unwrap();
1910
1911 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
1913 assert_eq!(store.history("session_b", &path).len(), 1);
1915 }
1916
1917 #[test]
1918 fn sessions_with_backups_lists_all_namespaces() {
1919 let path_a = temp_file("sessions_list_a.txt", "a");
1920 let path_b = temp_file("sessions_list_b.txt", "b");
1921 let mut store = BackupStore::new();
1922
1923 store.snapshot("alice", &path_a, "from alice").unwrap();
1924 store.snapshot("bob", &path_b, "from bob").unwrap();
1925
1926 let sessions = store.sessions_with_backups();
1927 assert_eq!(sessions.len(), 2);
1928 assert!(sessions.iter().any(|s| s == "alice"));
1929 assert!(sessions.iter().any(|s| s == "bob"));
1930 }
1931
1932 #[test]
1933 fn disk_persistence_survives_reload() {
1934 let dir = std::env::temp_dir().join("aft_backup_disk_test");
1935 let _ = fs::remove_dir_all(&dir);
1936 fs::create_dir_all(&dir).unwrap();
1937
1938 let file_path = temp_file("disk_persist.txt", "original");
1939
1940 {
1942 let mut store = BackupStore::new();
1943 store.set_storage_dir(dir.clone(), 72);
1944 store
1945 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
1946 .unwrap();
1947 }
1948
1949 fs::write(&file_path, "externally modified").unwrap();
1951
1952 let mut store2 = BackupStore::new();
1954 store2.set_storage_dir(dir.clone(), 72);
1955
1956 let (entry, warning) = store2
1957 .restore_latest(DEFAULT_SESSION_ID, &file_path)
1958 .unwrap();
1959 assert_eq!(entry.content, "original");
1960 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1962
1963 let _ = fs::remove_dir_all(&dir);
1964 }
1965
1966 #[test]
1967 fn legacy_flat_layout_migrates_to_default_session() {
1968 let dir = std::env::temp_dir().join("aft_backup_migration_test");
1971 let _ = fs::remove_dir_all(&dir);
1972 fs::create_dir_all(&dir).unwrap();
1973 let backups = dir.join("backups");
1974 fs::create_dir_all(&backups).unwrap();
1975
1976 let legacy_hash = "deadbeefcafebabe";
1978 let legacy_dir = backups.join(legacy_hash);
1979 fs::create_dir_all(&legacy_dir).unwrap();
1980 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
1981 let legacy_meta = serde_json::json!({
1982 "path": "/tmp/migrated_file.txt",
1983 "count": 1,
1984 });
1985 fs::write(
1986 legacy_dir.join("meta.json"),
1987 serde_json::to_string_pretty(&legacy_meta).unwrap(),
1988 )
1989 .unwrap();
1990
1991 let mut store = BackupStore::new();
1993 store.set_storage_dir(dir.clone(), 72);
1994
1995 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1998 assert!(default_session_dir.exists());
1999 assert!(default_session_dir.join(legacy_hash).exists());
2000 assert!(!backups.join(legacy_hash).exists());
2001
2002 let meta_content =
2004 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
2005 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
2006 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
2007 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
2008
2009 let _ = fs::remove_dir_all(&dir);
2010 }
2011
2012 #[test]
2013 fn set_storage_dir_removes_stale_backup_sessions() {
2014 let dir = std::env::temp_dir().join("aft_backup_gc_test");
2015 let _ = fs::remove_dir_all(&dir);
2016 let backups = dir.join("backups");
2017 fs::create_dir_all(&backups).unwrap();
2018
2019 let stale_session_dir = backups.join("stale-session");
2020 fs::create_dir_all(&stale_session_dir).unwrap();
2021 let stale_marker = serde_json::json!({
2022 "schema_version": SCHEMA_VERSION,
2023 "session_id": "stale",
2024 "last_accessed": 1,
2025 });
2026 fs::write(
2027 stale_session_dir.join("session.json"),
2028 serde_json::to_string_pretty(&stale_marker).unwrap(),
2029 )
2030 .unwrap();
2031
2032 let mut store = BackupStore::new();
2033 store.set_storage_dir(dir.clone(), 1);
2034
2035 assert!(!stale_session_dir.exists());
2036 let _ = fs::remove_dir_all(&dir);
2037 }
2038
2039 #[test]
2040 fn markerless_session_dir_is_skipped_not_mapped_to_default() {
2041 let dir = std::env::temp_dir().join("aft_backup_markerless_skip_test");
2042 let _ = fs::remove_dir_all(&dir);
2043 let file_path = temp_file("markerless.txt", "original");
2044 let key = canonicalize_key(&file_path);
2045 let path_dir = dir
2046 .join("backups")
2047 .join("corrupt-session")
2048 .join("path-entry");
2049 fs::create_dir_all(&path_dir).unwrap();
2050 fs::write(path_dir.join("0.bak"), "original").unwrap();
2051 fs::write(
2052 path_dir.join("meta.json"),
2053 serde_json::to_string_pretty(&serde_json::json!({
2054 "schema_version": SCHEMA_VERSION,
2055 "session_id": "lost-session",
2056 "path": key.display().to_string(),
2057 "count": 1,
2058 "entries": [{
2059 "backup_id": "disk-0",
2060 "timestamp": 0,
2061 "description": "corrupt marker test",
2062 "op_id": null,
2063 "kind": "content",
2064 }]
2065 }))
2066 .unwrap(),
2067 )
2068 .unwrap();
2069
2070 let mut store = BackupStore::new();
2071 store.set_storage_dir(dir.clone(), 72);
2072
2073 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2074 assert!(store.sessions_with_backups().is_empty());
2075 let _ = fs::remove_dir_all(&dir);
2076 }
2077
2078 #[test]
2079 fn set_storage_dir_reconfiguration_drops_previous_disk_index() {
2080 let dir_a = std::env::temp_dir().join("aft_backup_storage_a_test");
2081 let dir_b = std::env::temp_dir().join("aft_backup_storage_b_test");
2082 let _ = fs::remove_dir_all(&dir_a);
2083 let _ = fs::remove_dir_all(&dir_b);
2084 fs::create_dir_all(&dir_a).unwrap();
2085 fs::create_dir_all(&dir_b).unwrap();
2086 let file_path = temp_file("storage_reconfigure.txt", "original");
2087
2088 let mut store = BackupStore::new();
2089 store.set_storage_dir(dir_a.clone(), 72);
2090 store
2091 .snapshot(DEFAULT_SESSION_ID, &file_path, "stored in a")
2092 .unwrap();
2093 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 1);
2094
2095 store.set_storage_dir(dir_b.clone(), 72);
2096
2097 assert_eq!(store.disk_history_count(DEFAULT_SESSION_ID, &file_path), 0);
2098 assert!(store.tracked_files(DEFAULT_SESSION_ID).is_empty());
2099 let _ = fs::remove_dir_all(&dir_a);
2100 let _ = fs::remove_dir_all(&dir_b);
2101 }
2102
2103 #[test]
2104 fn restore_last_operation_restores_all_top_entries_for_same_op() {
2105 let path_a = temp_file("op_restore_a.txt", "a1");
2106 let path_b = temp_file("op_restore_b.txt", "b1");
2107 let mut store = BackupStore::new();
2108 let op_id = "op-test-00000001";
2109
2110 store
2111 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2112 .unwrap();
2113 store
2114 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2115 .unwrap();
2116 fs::write(&path_a, "a2").unwrap();
2117 fs::write(&path_b, "b2").unwrap();
2118
2119 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2120 assert_eq!(restored.op_id, op_id);
2121 assert_eq!(restored.restored.len(), 2);
2122 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
2123 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2124 }
2125
2126 #[test]
2127 fn restore_last_operation_deletes_tombstone_destination() {
2128 let dir = std::env::temp_dir().join("aft_backup_tombstone_delete_test");
2129 let _ = fs::remove_dir_all(&dir);
2130 fs::create_dir_all(&dir).unwrap();
2131 let source = dir.join("source.txt");
2132 let destination = dir.join("destination.txt");
2133 fs::write(&source, "original").unwrap();
2134
2135 let mut store = BackupStore::new();
2136 let op_id = "op-tombstone-delete";
2137 store
2138 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2139 .unwrap();
2140 fs::rename(&source, &destination).unwrap();
2141 store
2142 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2143 .unwrap();
2144
2145 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2146 assert_eq!(restored.op_id, op_id);
2147 assert_eq!(restored.restored.len(), 1);
2148 assert_eq!(fs::read_to_string(&source).unwrap(), "original");
2149 assert!(!destination.exists());
2150 let _ = fs::remove_dir_all(&dir);
2151 }
2152
2153 #[test]
2154 fn restore_last_operation_rolls_back_source_when_tombstone_delete_fails() {
2155 let dir = std::env::temp_dir().join("aft_backup_tombstone_atomic_test");
2156 let _ = fs::remove_dir_all(&dir);
2157 fs::create_dir_all(&dir).unwrap();
2158 let source = dir.join("source.txt");
2159 let destination = dir.join("destination.txt");
2160 fs::write(&source, "original").unwrap();
2161
2162 let mut store = BackupStore::new();
2163 let op_id = "op-tombstone-atomic";
2164 store
2165 .snapshot_with_op(DEFAULT_SESSION_ID, &source, "move source", Some(op_id))
2166 .unwrap();
2167 fs::rename(&source, &destination).unwrap();
2168 store
2169 .snapshot_op_tombstone(DEFAULT_SESSION_ID, op_id, &destination, "created dest")
2170 .unwrap();
2171
2172 fs::remove_file(&destination).unwrap();
2173 fs::create_dir(&destination).unwrap();
2174 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2175
2176 assert!(result.is_err(), "directory tombstone target should fail");
2177 assert!(
2178 !source.exists(),
2179 "source restore must roll back when destination deletion fails"
2180 );
2181 assert!(
2182 destination.is_dir(),
2183 "failed tombstone target should remain"
2184 );
2185 let _ = fs::remove_dir_all(&dir);
2186 }
2187
2188 #[cfg(unix)]
2193 #[test]
2194 fn restore_last_operation_is_atomic_when_a_write_fails() {
2195 let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
2196 let _ = fs::remove_dir_all(&dir);
2197 fs::create_dir_all(&dir).unwrap();
2198 let path_a = dir.join("a.txt");
2199 let path_b = dir.join("b.txt");
2200 let path_c = dir.join("c.txt");
2201 fs::write(&path_a, "a-original").unwrap();
2202 fs::write(&path_b, "b-original").unwrap();
2203 fs::write(&path_c, "c-original").unwrap();
2204
2205 let mut store = BackupStore::new();
2206 let op_id = "op-atomic-restore-01";
2207 let id_a = store
2208 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
2209 .unwrap();
2210 let id_b = store
2211 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
2212 .unwrap();
2213 let id_c = store
2214 .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
2215 .unwrap();
2216 fs::write(&path_a, "a-modified").unwrap();
2217 fs::write(&path_b, "b-modified").unwrap();
2218 fs::write(&path_c, "c-modified").unwrap();
2219
2220 let original_permissions = fs::metadata(&path_b).unwrap().permissions();
2221 let mut readonly_permissions = original_permissions.clone();
2222 readonly_permissions.set_mode(0o444);
2223 fs::set_permissions(&path_b, readonly_permissions).unwrap();
2224
2225 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
2226 fs::set_permissions(&path_b, original_permissions).unwrap();
2227
2228 assert!(result.is_err());
2229 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
2230 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
2231 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
2232
2233 let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
2234 let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
2235 let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
2236 assert_eq!(history_a.len(), 1);
2237 assert_eq!(history_b.len(), 1);
2238 assert_eq!(history_c.len(), 1);
2239 assert_eq!(history_a[0].backup_id, id_a);
2240 assert_eq!(history_b[0].backup_id, id_b);
2241 assert_eq!(history_c[0].backup_id, id_c);
2242 assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
2243 assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
2244 assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
2245
2246 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2247 assert_eq!(restored.op_id, op_id);
2248 assert_eq!(restored.restored.len(), 3);
2249 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
2250 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
2251 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
2252
2253 let _ = fs::remove_dir_all(&dir);
2254 }
2255
2256 #[test]
2257 fn restore_last_operation_restores_only_most_recent_op() {
2258 let path_a = temp_file("op_recent_a.txt", "a1");
2259 let path_b = temp_file("op_recent_b.txt", "b1");
2260 let mut store = BackupStore::new();
2261
2262 store
2263 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
2264 .unwrap();
2265 store
2266 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
2267 .unwrap();
2268 fs::write(&path_a, "a2").unwrap();
2269 fs::write(&path_b, "b2").unwrap();
2270
2271 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2272 assert_eq!(restored.op_id, "op-newer");
2273 assert_eq!(restored.restored.len(), 1);
2274 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2275 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2276 }
2277
2278 #[test]
2279 fn restore_recreates_missing_parent_directories() {
2280 let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
2283 let _ = fs::remove_dir_all(&dir);
2284 let nested = dir.join("nested");
2285 fs::create_dir_all(&nested).unwrap();
2286 let path = nested.join("inner.txt");
2287 fs::write(&path, "original").unwrap();
2288
2289 let mut store = BackupStore::new();
2290 let op_id = "op-recreate-parents-01";
2291 store
2292 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
2293 .unwrap();
2294
2295 fs::remove_dir_all(&dir).unwrap();
2297 assert!(!path.exists());
2298 assert!(!nested.exists());
2299 assert!(!dir.exists());
2300
2301 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2302 assert_eq!(restored.op_id, op_id);
2303 assert_eq!(restored.restored.len(), 1);
2304 assert!(
2305 path.exists(),
2306 "file should be restored even though both nested/ and dir/ were missing"
2307 );
2308 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
2309
2310 let _ = fs::remove_dir_all(&dir);
2311 }
2312
2313 #[test]
2314 fn restore_last_operation_ignores_legacy_entries_without_op_id() {
2315 let path = temp_file("op_legacy_none.txt", "v1");
2316 let mut store = BackupStore::new();
2317
2318 store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
2319 fs::write(&path, "v2").unwrap();
2320
2321 let err = store.restore_last_operation(DEFAULT_SESSION_ID);
2322 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
2323 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
2324 }
2325
2326 #[test]
2327 fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
2328 let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
2329 let _ = fs::remove_dir_all(&dir);
2330 fs::create_dir_all(&dir).unwrap();
2331 let file_path = temp_file("v2_to_v3.txt", "original");
2332 let key = canonicalize_key(&file_path);
2333 let session_dir = dir
2334 .join("backups")
2335 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2336 let path_dir = session_dir.join(BackupStore::path_hash(&key));
2337 fs::create_dir_all(&path_dir).unwrap();
2338 fs::write(path_dir.join("0.bak"), "original").unwrap();
2339 fs::write(
2340 session_dir.join("session.json"),
2341 serde_json::to_string_pretty(&serde_json::json!({
2342 "schema_version": 2,
2343 "session_id": DEFAULT_SESSION_ID,
2344 "last_accessed": current_timestamp(),
2345 }))
2346 .unwrap(),
2347 )
2348 .unwrap();
2349 fs::write(
2350 path_dir.join("meta.json"),
2351 serde_json::to_string_pretty(&serde_json::json!({
2352 "schema_version": 2,
2353 "session_id": DEFAULT_SESSION_ID,
2354 "path": key.display().to_string(),
2355 "count": 1,
2356 }))
2357 .unwrap(),
2358 )
2359 .unwrap();
2360
2361 let mut store = BackupStore::new();
2362 store.set_storage_dir(dir.clone(), 72);
2363 assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
2364 let history = store.history(DEFAULT_SESSION_ID, &file_path);
2365 assert_eq!(history.len(), 1);
2366 assert_eq!(history[0].op_id, None);
2367
2368 fs::write(&file_path, "second").unwrap();
2369 store
2370 .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
2371 .unwrap();
2372 let written: serde_json::Value =
2373 serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
2374 assert_eq!(written["schema_version"], SCHEMA_VERSION);
2375 assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
2376 assert_eq!(written["entries"][1]["op_id"], "op-v3");
2377 let _ = fs::remove_dir_all(&dir);
2378 }
2379
2380 #[test]
2381 fn per_file_restore_latest_still_works_with_op_ids() {
2382 let path = temp_file("op_per_file.txt", "v1");
2383 let mut store = BackupStore::new();
2384
2385 store
2386 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
2387 .unwrap();
2388 fs::write(&path, "v2").unwrap();
2389
2390 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2391 assert_eq!(entry.op_id.as_deref(), Some("op-file"));
2392 assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
2393 }
2394
2395 #[test]
2396 fn per_file_restore_latest_deletes_tombstone() {
2397 let dir = std::env::temp_dir().join("aft_backup_per_file_tombstone_test");
2398 let _ = fs::remove_dir_all(&dir);
2399 fs::create_dir_all(&dir).unwrap();
2400 let path = dir.join("created.txt");
2401 fs::write(&path, "created").unwrap();
2402
2403 let mut store = BackupStore::new();
2404 let id = store
2405 .snapshot_op_tombstone(DEFAULT_SESSION_ID, "op-create", &path, "created")
2406 .unwrap();
2407
2408 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
2409 assert_eq!(entry.backup_id, id);
2410 assert!(!path.exists(), "tombstone undo should delete the file");
2411 let _ = fs::remove_dir_all(&dir);
2412 }
2413
2414 #[test]
2415 fn load_disk_index_skips_tampered_meta_path_hash_mismatch() {
2416 let dir = std::env::temp_dir().join("aft_backup_tampered_meta_skip_test");
2417 let _ = fs::remove_dir_all(&dir);
2418 let backups = dir.join("backups");
2419 let session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
2420 let path_dir = session_dir.join("not-the-path-hash");
2421 fs::create_dir_all(&path_dir).unwrap();
2422 fs::write(
2423 session_dir.join("session.json"),
2424 serde_json::to_string_pretty(&serde_json::json!({
2425 "schema_version": SCHEMA_VERSION,
2426 "session_id": DEFAULT_SESSION_ID,
2427 "last_accessed": current_timestamp(),
2428 }))
2429 .unwrap(),
2430 )
2431 .unwrap();
2432 fs::write(path_dir.join("0.bak"), "outside").unwrap();
2433 fs::write(
2434 path_dir.join("meta.json"),
2435 serde_json::to_string_pretty(&serde_json::json!({
2436 "schema_version": SCHEMA_VERSION,
2437 "session_id": DEFAULT_SESSION_ID,
2438 "path": "/tmp/aft-malicious-overwrite-target.txt",
2439 "count": 1,
2440 "entries": [{
2441 "backup_id": "backup-0",
2442 "timestamp": current_timestamp(),
2443 "order": "1",
2444 "description": "tampered",
2445 "op_id": "op-tampered",
2446 "kind": "content",
2447 }]
2448 }))
2449 .unwrap(),
2450 )
2451 .unwrap();
2452
2453 let mut store = BackupStore::new();
2454 store.set_storage_dir(dir.clone(), 72);
2455
2456 assert!(store.sessions_with_backups().is_empty());
2457 let _ = fs::remove_dir_all(&dir);
2458 }
2459
2460 #[test]
2461 fn restore_last_operation_uses_only_top_entries_and_persisted_order() {
2462 let path_a = temp_file("op_order_a.txt", "a1");
2463 let path_b = temp_file("op_order_b.txt", "b1");
2464 let mut store = BackupStore::new();
2465
2466 store
2467 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "buried", Some("op-buried"))
2468 .unwrap();
2469 store
2470 .snapshot(DEFAULT_SESSION_ID, &path_a, "top without op")
2471 .unwrap();
2472 store
2473 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "top", Some("op-top"))
2474 .unwrap();
2475
2476 let key_a = canonicalize_key(&path_a);
2477 let key_b = canonicalize_key(&path_b);
2478 let files = store.entries.get_mut(DEFAULT_SESSION_ID).unwrap();
2479 files.get_mut(&key_a).unwrap()[0].order = u128::MAX;
2480 files.get_mut(&key_a).unwrap()[1].order = 1;
2481 files.get_mut(&key_b).unwrap()[0].order = 2;
2482
2483 fs::write(&path_a, "a2").unwrap();
2484 fs::write(&path_b, "b2").unwrap();
2485
2486 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
2487 assert_eq!(restored.op_id, "op-top");
2488 assert_eq!(restored.restored.len(), 1);
2489 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
2490 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
2491 }
2492}