use std::path::{Path, PathBuf};
use chrono::Utc;
use crate::errors::{SafeError, SafeResult};
use crate::profile::vault_dir;
pub const DEFAULT_SNAPSHOT_KEEP: usize = 10;
pub fn snapshot_dir(profile: &str) -> PathBuf {
vault_dir().join("snapshots").join(profile)
}
pub fn take(vault_path: &Path, profile: &str, keep: usize) -> SafeResult<PathBuf> {
take_at_timestamp_millis(vault_path, profile, keep, Utc::now().timestamp_millis())
}
fn take_at_timestamp_millis(
vault_path: &Path,
profile: &str,
keep: usize,
ts_millis: i64,
) -> SafeResult<PathBuf> {
let dir = snapshot_dir(profile);
std::fs::create_dir_all(&dir)?;
let snap_path = next_snapshot_path(&dir, profile, ts_millis);
let tmp = snap_path.with_extension("snap.tmp");
std::fs::copy(vault_path, &tmp)?;
std::fs::rename(&tmp, &snap_path)?;
prune(&dir, profile, keep)?;
Ok(snap_path)
}
fn next_snapshot_path(dir: &Path, profile: &str, ts_millis: i64) -> PathBuf {
for seq in 0_u32.. {
let snap_name = format!("{profile}.vault.{ts_millis}.{seq:04}.snap");
let snap_path = dir.join(&snap_name);
if !snap_path.exists() {
return snap_path;
}
}
unreachable!("u32 sequence exhausted while generating snapshot path")
}
pub fn list(profile: &str) -> SafeResult<Vec<PathBuf>> {
let dir = snapshot_dir(profile);
if !dir.exists() {
return Ok(Vec::new());
}
let suffix = format!("{profile}.vault.");
let mut snaps: Vec<PathBuf> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
.unwrap_or(false)
})
.collect();
snaps.sort();
Ok(snaps)
}
pub fn restore_latest(vault_path: &Path, profile: &str) -> SafeResult<PathBuf> {
let snaps = list(profile)?;
let latest = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
profile: profile.to_string(),
})?;
restore(vault_path, latest)
}
pub fn restore(vault_path: &Path, snap_path: &Path) -> SafeResult<PathBuf> {
if !snap_path.exists() {
return Err(SafeError::SnapshotNotFound {
path: snap_path.display().to_string(),
});
}
if let Some(parent) = vault_path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = vault_path.with_extension("vault.restore.tmp");
std::fs::copy(snap_path, &tmp)?;
std::fs::rename(&tmp, vault_path)?;
Ok(snap_path.to_path_buf())
}
pub fn export(snap_path: &Path, dest: &Path) -> SafeResult<PathBuf> {
if !snap_path.exists() {
return Err(SafeError::SnapshotNotFound {
path: snap_path.display().to_string(),
});
}
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let tmp = dest.with_extension("snap.export.tmp");
std::fs::copy(snap_path, &tmp)?;
std::fs::rename(&tmp, dest)?;
Ok(dest.to_path_buf())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapDiffEntry {
Removed(String),
Added(String),
Changed(String),
}
pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
let snaps = list(profile)?;
let snap = snaps
.iter()
.filter(|p| {
snapshot_ts_millis(p)
.map(|ts| ts <= at_millis)
.unwrap_or(false)
})
.next_back()
.ok_or_else(|| SafeError::NoSnapshotAvailable {
profile: profile.to_string(),
})?;
diff_files(vault_path, snap)
}
pub fn diff_latest(vault_path: &Path, profile: &str) -> SafeResult<Vec<SnapDiffEntry>> {
let snaps = list(profile)?;
let snap = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
profile: profile.to_string(),
})?;
diff_files(vault_path, snap)
}
fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
let name = snap_path.file_name()?.to_str()?;
let stem = name.strip_suffix(".snap")?;
let parts: Vec<&str> = stem.split('.').collect();
if parts.len() < 4 {
return None;
}
let ts_part = parts[parts.len() - 2];
ts_part.parse::<i64>().ok()
}
fn diff_files(current_path: &Path, snap_path: &Path) -> SafeResult<Vec<SnapDiffEntry>> {
let current_json = std::fs::read_to_string(current_path)?;
let snap_json = std::fs::read_to_string(snap_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SafeError::SnapshotNotFound {
path: snap_path.display().to_string(),
}
} else {
SafeError::Io(e)
}
})?;
let current_val: serde_json::Value =
serde_json::from_str(¤t_json).map_err(|e| SafeError::VaultCorrupted {
reason: format!("current vault JSON parse error: {e}"),
})?;
let snap_val: serde_json::Value =
serde_json::from_str(&snap_json).map_err(|e| SafeError::VaultCorrupted {
reason: format!("snapshot JSON parse error: {e}"),
})?;
let current_secrets = current_val
.get("secrets")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let snap_secrets = snap_val
.get("secrets")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut diff = Vec::new();
for key in snap_secrets.keys() {
if !current_secrets.contains_key(key) {
diff.push(SnapDiffEntry::Removed(key.clone()));
}
}
for (key, current_entry) in ¤t_secrets {
match snap_secrets.get(key) {
None => diff.push(SnapDiffEntry::Added(key.clone())),
Some(snap_entry) => {
let current_ct = current_entry.get("ciphertext");
let snap_ct = snap_entry.get("ciphertext");
if current_ct != snap_ct {
diff.push(SnapDiffEntry::Changed(key.clone()));
}
}
}
}
diff.sort_by(|a, b| {
let key = |e: &SnapDiffEntry| match e {
SnapDiffEntry::Removed(k) | SnapDiffEntry::Added(k) | SnapDiffEntry::Changed(k) => {
k.clone()
}
};
key(a).cmp(&key(b))
});
Ok(diff)
}
fn prune(dir: &Path, profile: &str, keep: usize) -> SafeResult<()> {
let suffix = format!("{profile}.vault.");
let mut snaps: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
.unwrap_or(false)
})
.collect();
snaps.sort();
if snaps.len() > keep {
for old in &snaps[..snaps.len() - keep] {
let _ = std::fs::remove_file(old); }
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn take_and_list_snapshots() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("test.vault");
std::fs::write(&vault_path, b"vault-content-v1").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
take(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP).unwrap();
let snaps = list("test").unwrap();
assert_eq!(snaps.len(), 1);
});
}
#[test]
fn same_timestamp_creates_distinct_snapshot_names() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("test.vault");
std::fs::write(&vault_path, b"vault-content-v1").unwrap();
let ts = 1_744_000_000_000;
let (first, second) = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let first =
take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
let second =
take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
(first, second)
});
assert_ne!(first, second);
assert_eq!(
first.file_name().and_then(|n| n.to_str()),
Some("test.vault.1744000000000.0000.snap")
);
assert_eq!(
second.file_name().and_then(|n| n.to_str()),
Some("test.vault.1744000000000.0001.snap")
);
}
#[test]
fn restore_latest_roundtrip() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("restore.vault");
std::fs::write(&vault_path, b"original-content").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
take(&vault_path, "restore", DEFAULT_SNAPSHOT_KEEP).unwrap();
std::fs::write(&vault_path, b"corrupted!").unwrap();
restore_latest(&vault_path, "restore").unwrap();
let recovered = std::fs::read(&vault_path).unwrap();
assert_eq!(recovered, b"original-content");
});
}
#[test]
fn restore_latest_prefers_highest_sequence_for_same_timestamp() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("restore-seq.vault");
let ts = 1_744_000_000_000;
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
std::fs::write(&vault_path, b"snapshot-a").unwrap();
let first =
take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
.unwrap();
std::fs::write(&vault_path, b"snapshot-b").unwrap();
let second =
take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
.unwrap();
assert!(first < second);
std::fs::write(&vault_path, b"corrupted").unwrap();
let restored = restore_latest(&vault_path, "restore-seq").unwrap();
let recovered = std::fs::read(&vault_path).unwrap();
assert_eq!(restored, second);
assert_eq!(recovered, b"snapshot-b");
});
}
#[test]
fn prune_keeps_n_snapshots() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("prune.vault");
std::fs::write(&vault_path, b"data").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
for _ in 0..5 {
std::thread::sleep(std::time::Duration::from_millis(10));
take(&vault_path, "prune", 3).unwrap();
}
let snaps = list("prune").unwrap();
assert!(
snaps.len() <= 3,
"expected ≤3 snapshots, got {}",
snaps.len()
);
});
}
#[test]
fn restore_missing_snapshot_errors() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("v.vault");
let snap_path = tmp.path().join("ghost.snap");
let err = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
restore(&vault_path, &snap_path).unwrap_err()
});
assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
}
#[test]
fn export_copies_snapshot_to_destination() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("export.vault");
std::fs::write(&vault_path, b"original-vault-bytes").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let snap = take(&vault_path, "export", DEFAULT_SNAPSHOT_KEEP).unwrap();
let dest = tmp.path().join("backups").join("export-backup.snap");
let exported = export(&snap, &dest).unwrap();
assert_eq!(exported, dest);
assert!(dest.exists());
assert_eq!(
std::fs::read(&dest).unwrap(),
b"original-vault-bytes",
"exported content must match the original snapshot"
);
});
}
#[test]
fn export_missing_snapshot_returns_error() {
let tmp = tempdir().unwrap();
let ghost = tmp.path().join("ghost.snap");
let dest = tmp.path().join("out.snap");
let err = export(&ghost, &dest).unwrap_err();
assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
assert!(!dest.exists());
}
#[test]
fn export_is_atomic_and_does_not_leave_partial_files_on_simulated_overwrite() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("atomic-export.vault");
std::fs::write(&vault_path, b"vault-v1").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let snap = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
let dest = tmp.path().join("dest.snap");
export(&snap, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");
std::fs::write(&vault_path, b"vault-v2").unwrap();
let snap2 = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
export(&snap2, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v2");
});
}
#[test]
fn configurable_retention_keeps_exactly_n_snapshots() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("ret.vault");
std::fs::write(&vault_path, b"data").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
for i in 0..7_u64 {
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&vault_path, format!("v{i}").as_bytes()).unwrap();
take(&vault_path, "ret", 3).unwrap();
}
let snaps = list("ret").unwrap();
assert_eq!(snaps.len(), 3, "expected exactly 3 snapshots after keep=3");
for snap in &snaps {
let content = std::fs::read_to_string(snap).unwrap();
assert!(
content.as_str() >= "v4",
"only the 3 most recent snapshots should survive, got: {content}"
);
}
});
}
#[test]
fn retention_of_zero_removes_all_snapshots() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("zero-ret.vault");
std::fs::write(&vault_path, b"data").unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
take(&vault_path, "zero-ret", DEFAULT_SNAPSHOT_KEEP).unwrap();
take(&vault_path, "zero-ret", 0).unwrap();
let snaps = list("zero-ret").unwrap();
assert!(
snaps.len() <= 1,
"keep=0 should remove all but at most the just-written snap; got {}",
snaps.len()
);
});
}
fn make_vault_json(keys: &[&str]) -> String {
let secrets: serde_json::Value = serde_json::Value::Object(
keys.iter()
.map(|k| {
(
k.to_string(),
serde_json::json!({
"nonce": "abc",
"ciphertext": format!("ct-{k}"),
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}),
)
})
.collect(),
);
serde_json::json!({
"_schema": "tsafe/vault/v1",
"kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
"cipher": "xchacha20poly1305",
"vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"secrets": secrets
})
.to_string()
}
#[test]
fn diff_at_detects_added_removed_and_changed_keys() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("diff.vault");
std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();
let ts_snap = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let ts = Utc::now().timestamp_millis();
take_at_timestamp_millis(&vault_path, "diff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
ts
});
let current_json = serde_json::json!({
"_schema": "tsafe/vault/v1",
"kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
"cipher": "xchacha20poly1305",
"vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:01:00Z",
"secrets": {
"A": { "nonce": "abc", "ciphertext": "ct-A-updated", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:01:00Z" },
"C": { "nonce": "abc", "ciphertext": "ct-C", "created_at": "2026-01-01T00:01:00Z", "updated_at": "2026-01-01T00:01:00Z" }
}
})
.to_string();
std::fs::write(&vault_path, ¤t_json).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let diff = diff_at(&vault_path, "diff", ts_snap).unwrap();
assert!(
diff.contains(&SnapDiffEntry::Removed("B".to_string())),
"B should be removed: {diff:?}"
);
assert!(
diff.contains(&SnapDiffEntry::Added("C".to_string())),
"C should be added: {diff:?}"
);
assert!(
diff.contains(&SnapDiffEntry::Changed("A".to_string())),
"A should be changed: {diff:?}"
);
assert_eq!(diff.len(), 3, "expected exactly 3 diff entries: {diff:?}");
});
}
#[test]
fn diff_at_no_changes_returns_empty() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("nodiff.vault");
std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
let ts = Utc::now().timestamp_millis();
take_at_timestamp_millis(&vault_path, "nodiff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
let diff = diff_at(&vault_path, "nodiff", ts).unwrap();
assert!(
diff.is_empty(),
"identical vault should have no diff: {diff:?}"
);
});
}
#[test]
fn diff_at_returns_error_when_no_snapshot_at_or_before_timestamp() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("early.vault");
std::fs::write(&vault_path, make_vault_json(&["A"])).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
take_at_timestamp_millis(
&vault_path,
"early",
DEFAULT_SNAPSHOT_KEEP,
9_999_999_999_999,
)
.unwrap();
let err = diff_at(&vault_path, "early", 0).unwrap_err();
assert!(
matches!(err, SafeError::NoSnapshotAvailable { .. }),
"expected NoSnapshotAvailable, got {err:?}"
);
});
}
#[test]
fn diff_latest_returns_added_keys() {
let tmp = tempdir().unwrap();
let vault_path = tmp.path().join("dl.vault");
std::fs::write(&vault_path, make_vault_json(&["EXISTING"])).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
take(&vault_path, "dl", DEFAULT_SNAPSHOT_KEEP).unwrap();
std::fs::write(&vault_path, make_vault_json(&["EXISTING", "NEW"])).unwrap();
let diff = diff_latest(&vault_path, "dl").unwrap();
assert!(
diff.contains(&SnapDiffEntry::Added("NEW".to_string())),
"NEW should be in diff: {diff:?}"
);
assert_eq!(diff.len(), 1);
});
}
#[test]
fn snapshot_ts_millis_parses_correctly() {
use std::path::PathBuf;
let p = PathBuf::from("myprofile.vault.1744000000000.0000.snap");
assert_eq!(snapshot_ts_millis(&p), Some(1_744_000_000_000));
let p2 = PathBuf::from("myprofile.vault.99.0001.snap");
assert_eq!(snapshot_ts_millis(&p2), Some(99));
let bad = PathBuf::from("notasnap.txt");
assert_eq!(snapshot_ts_millis(&bad), None);
}
}