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