1use std::path::{Path, PathBuf};
31
32use chrono::Utc;
33
34use crate::errors::{SafeError, SafeResult};
35use crate::profile::vault_dir;
36
37pub const DEFAULT_SNAPSHOT_KEEP: usize = 10;
39
40pub fn snapshot_dir(profile: &str) -> PathBuf {
42 vault_dir().join("snapshots").join(profile)
43}
44
45pub fn take(vault_path: &Path, profile: &str, keep: usize) -> SafeResult<PathBuf> {
47 take_at_timestamp_millis(vault_path, profile, keep, Utc::now().timestamp_millis())
48}
49
50fn take_at_timestamp_millis(
51 vault_path: &Path,
52 profile: &str,
53 keep: usize,
54 ts_millis: i64,
55) -> SafeResult<PathBuf> {
56 let dir = snapshot_dir(profile);
57 std::fs::create_dir_all(&dir)?;
58
59 let snap_path = next_snapshot_path(&dir, profile, ts_millis);
60
61 let tmp = snap_path.with_extension("snap.tmp");
63 std::fs::copy(vault_path, &tmp)?;
64 crate::fsperm::set_owner_only(&tmp)?;
69 std::fs::rename(&tmp, &snap_path)?;
70 crate::fsperm::set_owner_only(&snap_path)?;
71
72 prune(&dir, profile, keep)?;
73
74 Ok(snap_path)
75}
76
77fn next_snapshot_path(dir: &Path, profile: &str, ts_millis: i64) -> PathBuf {
78 for seq in 0_u32.. {
79 let snap_name = format!("{profile}.vault.{ts_millis}.{seq:04}.snap");
80 let snap_path = dir.join(&snap_name);
81 if !snap_path.exists() {
82 return snap_path;
83 }
84 }
85
86 unreachable!("u32 sequence exhausted while generating snapshot path")
87}
88
89pub fn list(profile: &str) -> SafeResult<Vec<PathBuf>> {
91 let dir = snapshot_dir(profile);
92 if !dir.exists() {
93 return Ok(Vec::new());
94 }
95 let suffix = format!("{profile}.vault.");
96 let mut snaps: Vec<PathBuf> = std::fs::read_dir(&dir)?
97 .filter_map(|e| e.ok())
98 .map(|e| e.path())
99 .filter(|p| {
100 p.file_name()
101 .and_then(|n| n.to_str())
102 .map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
103 .unwrap_or(false)
104 })
105 .collect();
106 snaps.sort();
107 Ok(snaps)
108}
109
110pub fn restore_latest(vault_path: &Path, profile: &str) -> SafeResult<PathBuf> {
112 let snaps = list(profile)?;
113 let latest = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
114 profile: profile.to_string(),
115 })?;
116 restore(vault_path, latest)
117}
118
119pub fn restore(vault_path: &Path, snap_path: &Path) -> SafeResult<PathBuf> {
121 if !snap_path.exists() {
122 return Err(SafeError::SnapshotNotFound {
123 path: snap_path.display().to_string(),
124 });
125 }
126 if let Some(parent) = vault_path.parent() {
127 std::fs::create_dir_all(parent)?;
128 }
129 let tmp = vault_path.with_extension("vault.restore.tmp");
130 std::fs::copy(snap_path, &tmp)?;
131 std::fs::rename(&tmp, vault_path)?;
132 Ok(snap_path.to_path_buf())
133}
134
135pub fn export(snap_path: &Path, dest: &Path) -> SafeResult<PathBuf> {
149 if !snap_path.exists() {
150 return Err(SafeError::SnapshotNotFound {
151 path: snap_path.display().to_string(),
152 });
153 }
154 if let Some(parent) = dest.parent() {
155 if !parent.as_os_str().is_empty() {
156 std::fs::create_dir_all(parent)?;
157 }
158 }
159 let tmp = dest.with_extension("snap.export.tmp");
160 std::fs::copy(snap_path, &tmp)?;
161 std::fs::rename(&tmp, dest)?;
162 Ok(dest.to_path_buf())
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum SnapDiffEntry {
168 Removed(String),
170 Added(String),
172 Changed(String),
174}
175
176pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
186 let snaps = list(profile)?;
187 let snap = snaps
189 .iter()
190 .rfind(|p| {
191 snapshot_ts_millis(p)
192 .map(|ts| ts <= at_millis)
193 .unwrap_or(false)
194 })
195 .ok_or_else(|| SafeError::NoSnapshotAvailable {
196 profile: profile.to_string(),
197 })?;
198
199 diff_files(vault_path, snap)
200}
201
202pub fn diff_latest(vault_path: &Path, profile: &str) -> SafeResult<Vec<SnapDiffEntry>> {
207 let snaps = list(profile)?;
208 let snap = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
209 profile: profile.to_string(),
210 })?;
211 diff_files(vault_path, snap)
212}
213
214fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
218 let name = snap_path.file_name()?.to_str()?;
219 let stem = name.strip_suffix(".snap")?;
221 let parts: Vec<&str> = stem.split('.').collect();
223 if parts.len() < 4 {
225 return None;
226 }
227 let ts_part = parts[parts.len() - 2];
228 ts_part.parse::<i64>().ok()
229}
230
231fn diff_files(current_path: &Path, snap_path: &Path) -> SafeResult<Vec<SnapDiffEntry>> {
236 let current_json = std::fs::read_to_string(current_path)?;
237 let snap_json = std::fs::read_to_string(snap_path).map_err(|e| {
238 if e.kind() == std::io::ErrorKind::NotFound {
239 SafeError::SnapshotNotFound {
240 path: snap_path.display().to_string(),
241 }
242 } else {
243 SafeError::Io(e)
244 }
245 })?;
246
247 let current_val: serde_json::Value =
248 serde_json::from_str(¤t_json).map_err(|e| SafeError::VaultCorrupted {
249 reason: format!("current vault JSON parse error: {e}"),
250 })?;
251 let snap_val: serde_json::Value =
252 serde_json::from_str(&snap_json).map_err(|e| SafeError::VaultCorrupted {
253 reason: format!("snapshot JSON parse error: {e}"),
254 })?;
255
256 let current_secrets = current_val
257 .get("secrets")
258 .and_then(|v| v.as_object())
259 .cloned()
260 .unwrap_or_default();
261 let snap_secrets = snap_val
262 .get("secrets")
263 .and_then(|v| v.as_object())
264 .cloned()
265 .unwrap_or_default();
266
267 let mut diff = Vec::new();
268
269 for key in snap_secrets.keys() {
271 if !current_secrets.contains_key(key) {
272 diff.push(SnapDiffEntry::Removed(key.clone()));
273 }
274 }
275
276 for (key, current_entry) in ¤t_secrets {
278 match snap_secrets.get(key) {
279 None => diff.push(SnapDiffEntry::Added(key.clone())),
280 Some(snap_entry) => {
281 let current_ct = current_entry.get("ciphertext");
283 let snap_ct = snap_entry.get("ciphertext");
284 if current_ct != snap_ct {
285 diff.push(SnapDiffEntry::Changed(key.clone()));
286 }
287 }
288 }
289 }
290
291 diff.sort_by(|a, b| {
292 let key = |e: &SnapDiffEntry| match e {
293 SnapDiffEntry::Removed(k) | SnapDiffEntry::Added(k) | SnapDiffEntry::Changed(k) => {
294 k.clone()
295 }
296 };
297 key(a).cmp(&key(b))
298 });
299
300 Ok(diff)
301}
302
303fn prune(dir: &Path, profile: &str, keep: usize) -> SafeResult<()> {
305 let suffix = format!("{profile}.vault.");
306 let mut snaps: Vec<PathBuf> = std::fs::read_dir(dir)?
307 .filter_map(|e| e.ok())
308 .map(|e| e.path())
309 .filter(|p| {
310 p.file_name()
311 .and_then(|n| n.to_str())
312 .map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
313 .unwrap_or(false)
314 })
315 .collect();
316 snaps.sort();
317 if snaps.len() > keep {
318 for old in &snaps[..snaps.len() - keep] {
319 let _ = std::fs::remove_file(old); }
321 }
322 Ok(())
323}
324
325#[cfg(test)]
328mod tests {
329 use super::*;
330 use tempfile::tempdir;
331
332 #[test]
333 fn take_and_list_snapshots() {
334 let tmp = tempdir().unwrap();
335 let vault_path = tmp.path().join("test.vault");
336 std::fs::write(&vault_path, b"vault-content-v1").unwrap();
337
338 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
339 take(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP).unwrap();
340 let snaps = list("test").unwrap();
341 assert_eq!(snaps.len(), 1);
342 });
343 }
344
345 #[test]
346 fn same_timestamp_creates_distinct_snapshot_names() {
347 let tmp = tempdir().unwrap();
348 let vault_path = tmp.path().join("test.vault");
349 std::fs::write(&vault_path, b"vault-content-v1").unwrap();
350
351 let ts = 1_744_000_000_000;
352 let (first, second) = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
353 let first =
354 take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
355 let second =
356 take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
357 (first, second)
358 });
359
360 assert_ne!(first, second);
361 assert_eq!(
362 first.file_name().and_then(|n| n.to_str()),
363 Some("test.vault.1744000000000.0000.snap")
364 );
365 assert_eq!(
366 second.file_name().and_then(|n| n.to_str()),
367 Some("test.vault.1744000000000.0001.snap")
368 );
369 }
370
371 #[test]
372 fn restore_latest_roundtrip() {
373 let tmp = tempdir().unwrap();
374 let vault_path = tmp.path().join("restore.vault");
375 std::fs::write(&vault_path, b"original-content").unwrap();
376
377 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
378 take(&vault_path, "restore", DEFAULT_SNAPSHOT_KEEP).unwrap();
379 std::fs::write(&vault_path, b"corrupted!").unwrap();
381 restore_latest(&vault_path, "restore").unwrap();
382 let recovered = std::fs::read(&vault_path).unwrap();
383 assert_eq!(recovered, b"original-content");
384 });
385 }
386
387 #[test]
388 fn restore_latest_prefers_highest_sequence_for_same_timestamp() {
389 let tmp = tempdir().unwrap();
390 let vault_path = tmp.path().join("restore-seq.vault");
391 let ts = 1_744_000_000_000;
392
393 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
394 std::fs::write(&vault_path, b"snapshot-a").unwrap();
395 let first =
396 take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
397 .unwrap();
398
399 std::fs::write(&vault_path, b"snapshot-b").unwrap();
400 let second =
401 take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
402 .unwrap();
403
404 assert!(first < second);
405
406 std::fs::write(&vault_path, b"corrupted").unwrap();
407 let restored = restore_latest(&vault_path, "restore-seq").unwrap();
408 let recovered = std::fs::read(&vault_path).unwrap();
409
410 assert_eq!(restored, second);
411 assert_eq!(recovered, b"snapshot-b");
412 });
413 }
414
415 #[test]
416 fn prune_keeps_n_snapshots() {
417 let tmp = tempdir().unwrap();
418 let vault_path = tmp.path().join("prune.vault");
419 std::fs::write(&vault_path, b"data").unwrap();
420
421 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
422 for _ in 0..5 {
423 std::thread::sleep(std::time::Duration::from_millis(10));
425 take(&vault_path, "prune", 3).unwrap();
426 }
427 let snaps = list("prune").unwrap();
428 assert!(
429 snaps.len() <= 3,
430 "expected ≤3 snapshots, got {}",
431 snaps.len()
432 );
433 });
434 }
435
436 #[test]
437 fn restore_missing_snapshot_errors() {
438 let tmp = tempdir().unwrap();
439 let vault_path = tmp.path().join("v.vault");
440 let snap_path = tmp.path().join("ghost.snap");
441 let err = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
442 restore(&vault_path, &snap_path).unwrap_err()
443 });
444 assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
445 }
446
447 #[test]
450 fn export_copies_snapshot_to_destination() {
451 let tmp = tempdir().unwrap();
452 let vault_path = tmp.path().join("export.vault");
453 std::fs::write(&vault_path, b"original-vault-bytes").unwrap();
454
455 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
456 let snap = take(&vault_path, "export", DEFAULT_SNAPSHOT_KEEP).unwrap();
457
458 let dest = tmp.path().join("backups").join("export-backup.snap");
459 let exported = export(&snap, &dest).unwrap();
460
461 assert_eq!(exported, dest);
462 assert!(dest.exists());
463 assert_eq!(
464 std::fs::read(&dest).unwrap(),
465 b"original-vault-bytes",
466 "exported content must match the original snapshot"
467 );
468 });
469 }
470
471 #[test]
472 fn export_missing_snapshot_returns_error() {
473 let tmp = tempdir().unwrap();
474 let ghost = tmp.path().join("ghost.snap");
475 let dest = tmp.path().join("out.snap");
476 let err = export(&ghost, &dest).unwrap_err();
477 assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
478 assert!(!dest.exists());
479 }
480
481 #[test]
482 fn export_is_atomic_and_does_not_leave_partial_files_on_simulated_overwrite() {
483 let tmp = tempdir().unwrap();
484 let vault_path = tmp.path().join("atomic-export.vault");
485 std::fs::write(&vault_path, b"vault-v1").unwrap();
486
487 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
488 let snap = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
489 let dest = tmp.path().join("dest.snap");
490
491 export(&snap, &dest).unwrap();
493 assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");
494
495 std::fs::write(&vault_path, b"vault-v2").unwrap();
497 let snap2 = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
498 export(&snap2, &dest).unwrap();
499 assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v2");
500 });
501 }
502
503 #[test]
506 fn configurable_retention_keeps_exactly_n_snapshots() {
507 let tmp = tempdir().unwrap();
508 let vault_path = tmp.path().join("ret.vault");
509 std::fs::write(&vault_path, b"data").unwrap();
510
511 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
512 for i in 0..7_u64 {
514 std::thread::sleep(std::time::Duration::from_millis(5));
515 std::fs::write(&vault_path, format!("v{i}").as_bytes()).unwrap();
517 take(&vault_path, "ret", 3).unwrap();
518 }
519 let snaps = list("ret").unwrap();
520 assert_eq!(snaps.len(), 3, "expected exactly 3 snapshots after keep=3");
521 for snap in &snaps {
523 let content = std::fs::read_to_string(snap).unwrap();
524 assert!(
525 content.as_str() >= "v4",
526 "only the 3 most recent snapshots should survive, got: {content}"
527 );
528 }
529 });
530 }
531
532 #[test]
533 fn retention_of_zero_removes_all_snapshots() {
534 let tmp = tempdir().unwrap();
535 let vault_path = tmp.path().join("zero-ret.vault");
536 std::fs::write(&vault_path, b"data").unwrap();
537
538 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
539 take(&vault_path, "zero-ret", DEFAULT_SNAPSHOT_KEEP).unwrap();
540 take(&vault_path, "zero-ret", 0).unwrap();
541 let snaps = list("zero-ret").unwrap();
542 assert!(
548 snaps.len() <= 1,
549 "keep=0 should remove all but at most the just-written snap; got {}",
550 snaps.len()
551 );
552 });
553 }
554
555 fn make_vault_json(keys: &[&str]) -> String {
559 let secrets: serde_json::Value = serde_json::Value::Object(
560 keys.iter()
561 .map(|k| {
562 (
563 k.to_string(),
564 serde_json::json!({
565 "nonce": "abc",
566 "ciphertext": format!("ct-{k}"),
567 "created_at": "2026-01-01T00:00:00Z",
568 "updated_at": "2026-01-01T00:00:00Z"
569 }),
570 )
571 })
572 .collect(),
573 );
574 serde_json::json!({
575 "_schema": "tsafe/vault/v1",
576 "kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
577 "cipher": "xchacha20poly1305",
578 "vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
579 "created_at": "2026-01-01T00:00:00Z",
580 "updated_at": "2026-01-01T00:00:00Z",
581 "secrets": secrets
582 })
583 .to_string()
584 }
585
586 #[test]
587 fn diff_at_detects_added_removed_and_changed_keys() {
588 let tmp = tempdir().unwrap();
589 let vault_path = tmp.path().join("diff.vault");
590
591 std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();
593
594 let ts_snap = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
595 let ts = Utc::now().timestamp_millis();
596 take_at_timestamp_millis(&vault_path, "diff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
597 ts
598 });
599
600 let current_json = serde_json::json!({
602 "_schema": "tsafe/vault/v1",
603 "kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
604 "cipher": "xchacha20poly1305",
605 "vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
606 "created_at": "2026-01-01T00:00:00Z",
607 "updated_at": "2026-01-01T00:01:00Z",
608 "secrets": {
609 "A": { "nonce": "abc", "ciphertext": "ct-A-updated", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:01:00Z" },
610 "C": { "nonce": "abc", "ciphertext": "ct-C", "created_at": "2026-01-01T00:01:00Z", "updated_at": "2026-01-01T00:01:00Z" }
611 }
612 })
613 .to_string();
614 std::fs::write(&vault_path, ¤t_json).unwrap();
615
616 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
617 let diff = diff_at(&vault_path, "diff", ts_snap).unwrap();
618
619 assert!(
620 diff.contains(&SnapDiffEntry::Removed("B".to_string())),
621 "B should be removed: {diff:?}"
622 );
623 assert!(
624 diff.contains(&SnapDiffEntry::Added("C".to_string())),
625 "C should be added: {diff:?}"
626 );
627 assert!(
628 diff.contains(&SnapDiffEntry::Changed("A".to_string())),
629 "A should be changed: {diff:?}"
630 );
631 assert_eq!(diff.len(), 3, "expected exactly 3 diff entries: {diff:?}");
632 });
633 }
634
635 #[test]
636 fn diff_at_no_changes_returns_empty() {
637 let tmp = tempdir().unwrap();
638 let vault_path = tmp.path().join("nodiff.vault");
639 std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();
640
641 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
642 let ts = Utc::now().timestamp_millis();
643 take_at_timestamp_millis(&vault_path, "nodiff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
644
645 let diff = diff_at(&vault_path, "nodiff", ts).unwrap();
647 assert!(
648 diff.is_empty(),
649 "identical vault should have no diff: {diff:?}"
650 );
651 });
652 }
653
654 #[test]
655 fn diff_at_returns_error_when_no_snapshot_at_or_before_timestamp() {
656 let tmp = tempdir().unwrap();
657 let vault_path = tmp.path().join("early.vault");
658 std::fs::write(&vault_path, make_vault_json(&["A"])).unwrap();
659
660 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
661 take_at_timestamp_millis(
663 &vault_path,
664 "early",
665 DEFAULT_SNAPSHOT_KEEP,
666 9_999_999_999_999,
667 )
668 .unwrap();
669
670 let err = diff_at(&vault_path, "early", 0).unwrap_err();
672 assert!(
673 matches!(err, SafeError::NoSnapshotAvailable { .. }),
674 "expected NoSnapshotAvailable, got {err:?}"
675 );
676 });
677 }
678
679 #[test]
680 fn diff_latest_returns_added_keys() {
681 let tmp = tempdir().unwrap();
682 let vault_path = tmp.path().join("dl.vault");
683 std::fs::write(&vault_path, make_vault_json(&["EXISTING"])).unwrap();
684
685 temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
686 take(&vault_path, "dl", DEFAULT_SNAPSHOT_KEEP).unwrap();
687
688 std::fs::write(&vault_path, make_vault_json(&["EXISTING", "NEW"])).unwrap();
690
691 let diff = diff_latest(&vault_path, "dl").unwrap();
692 assert!(
693 diff.contains(&SnapDiffEntry::Added("NEW".to_string())),
694 "NEW should be in diff: {diff:?}"
695 );
696 assert_eq!(diff.len(), 1);
697 });
698 }
699
700 #[test]
701 fn snapshot_ts_millis_parses_correctly() {
702 use std::path::PathBuf;
703 let p = PathBuf::from("myprofile.vault.1744000000000.0000.snap");
704 assert_eq!(snapshot_ts_millis(&p), Some(1_744_000_000_000));
705 let p2 = PathBuf::from("myprofile.vault.99.0001.snap");
706 assert_eq!(snapshot_ts_millis(&p2), Some(99));
707 let bad = PathBuf::from("notasnap.txt");
708 assert_eq!(snapshot_ts_millis(&bad), None);
709 }
710}