Skip to main content

tsafe_core/
snapshot.rs

1//! Local snapshot management — keeps the last N vault file copies so secrets
2//! are never permanently lost due to corruption or accidental deletion.
3//!
4//! Snapshot file naming convention:
5//!   `<profile>.vault.<timestamp_utc_millis>.<sequence>.snap`
6//!
7//! Snapshots are stored in `<vault_dir>/snapshots/<profile>/`.
8//! They are encrypted identical copies of the vault file — no additional
9//! credentials are needed to restore them.
10//!
11//! # Snapshot lifecycle
12//!
13//! The three key lifecycle operations beyond basic take/restore are:
14//!
15//! - **Export** — copy a snapshot to an arbitrary destination path (e.g. an
16//!   external backup drive or a CI artifact directory).  The destination file
17//!   is an encrypted copy of the vault file and requires the same vault
18//!   password to use.
19//!
20//! - **Timestamp-targeted diff** — compare the current vault file (on-disk)
21//!   against the most-recent snapshot that was taken at or before a given UTC
22//!   millisecond timestamp.  Returns `Added`, `Removed`, and `Changed` key
23//!   sets by examining the raw JSON secret maps without decrypting values.
24//!
25//! - **Configurable retention** — callers pass an explicit `keep` count to
26//!   [`take`] so that different use-cases (CI ephemeral, long-running desktop)
27//!   can manage snapshot depth independently.  The default is
28//!   [`DEFAULT_SNAPSHOT_KEEP`].
29
30use std::path::{Path, PathBuf};
31
32use chrono::Utc;
33
34use crate::errors::{SafeError, SafeResult};
35use crate::profile::vault_dir;
36
37/// How many snapshots to retain per profile.
38pub const DEFAULT_SNAPSHOT_KEEP: usize = 10;
39
40/// Return the directory that holds snapshots for `profile`.
41pub fn snapshot_dir(profile: &str) -> PathBuf {
42    vault_dir().join("snapshots").join(profile)
43}
44
45/// Take a snapshot of `vault_path`. Prunes oldest snapshots beyond `keep`.
46pub 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    // Atomic copy: write to .tmp, then rename.
62    let tmp = snap_path.with_extension("snap.tmp");
63    std::fs::copy(vault_path, &tmp)?;
64    // Snapshots are encrypted full copies of the vault; restrict them to
65    // owner-only (0o600) on Unix, matching the vault and audit log. fs::copy
66    // generally inherits the source mode, but set it explicitly so a snapshot
67    // is never left group/world-readable regardless of the source's mode.
68    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
89/// Return all snapshot paths for `profile`, sorted oldest-first.
90pub 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
110/// Restore the most-recent snapshot over `vault_path`. Returns the snapshot used.
111pub 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
119/// Restore a specific snapshot file over `vault_path`. Atomic.
120pub 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
135// ── extended lifecycle ───────────────────────────────────────────────────────
136
137/// Export a snapshot to an arbitrary destination path (e.g. an external backup
138/// or a CI artifact directory).
139///
140/// The exported file is an encrypted byte-for-byte copy of the snapshot — it
141/// requires the same vault password to use and can be safely stored on
142/// untrusted media.
143///
144/// The copy is atomic: the data is written to `<dest>.snap.export.tmp` first
145/// and then renamed to `dest`.
146///
147/// Returns `SnapshotNotFound` if `snap_path` does not exist.
148pub 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/// A single key difference between the current vault file and a snapshot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum SnapDiffEntry {
168    /// The key exists in the snapshot but not in the current vault.
169    Removed(String),
170    /// The key exists in the current vault but not in the snapshot.
171    Added(String),
172    /// The key exists in both but the ciphertext differs (value was changed).
173    Changed(String),
174}
175
176/// Compare the on-disk vault file at `vault_path` against the most-recent
177/// snapshot taken at or before `at_millis` (UTC milliseconds since epoch).
178///
179/// The diff is computed by comparing the raw JSON `secrets` maps; secret values
180/// are **not** decrypted — only key presence and ciphertext equality are
181/// checked.
182///
183/// Returns `NoSnapshotAvailable` if no snapshot exists at or before
184/// `at_millis`.
185pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
186    let snaps = list(profile)?;
187    // Find the latest snapshot whose timestamp (milliseconds in filename) is ≤ at_millis.
188    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
202/// Compare the on-disk vault file at `vault_path` against the most-recent
203/// snapshot for `profile`.
204///
205/// Returns `NoSnapshotAvailable` if no snapshots exist.
206pub 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
214/// Parse the UTC millisecond timestamp embedded in a snapshot filename.
215///
216/// Expected format: `<profile>.vault.<ts_millis>.<seq>.snap`
217fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
218    let name = snap_path.file_name()?.to_str()?;
219    // Strip trailing ".snap"
220    let stem = name.strip_suffix(".snap")?;
221    // Split on '.' — expect last two segments to be <seq> and <ts>
222    let parts: Vec<&str> = stem.split('.').collect();
223    // Format: <profile>.vault.<ts>.<seq>  → parts[-2] is ts
224    if parts.len() < 4 {
225        return None;
226    }
227    let ts_part = parts[parts.len() - 2];
228    ts_part.parse::<i64>().ok()
229}
230
231/// Diff two vault files (current and snapshot) at the JSON level.
232///
233/// Compares only the `secrets` map keys and ciphertext values — no decryption
234/// is performed.
235fn 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(&current_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    // Keys in snapshot but not in current → Removed.
270    for key in snap_secrets.keys() {
271        if !current_secrets.contains_key(key) {
272            diff.push(SnapDiffEntry::Removed(key.clone()));
273        }
274    }
275
276    // Keys in current vault.
277    for (key, current_entry) in &current_secrets {
278        match snap_secrets.get(key) {
279            None => diff.push(SnapDiffEntry::Added(key.clone())),
280            Some(snap_entry) => {
281                // Compare ciphertext fields to detect value changes.
282                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
303/// Delete snapshots beyond `keep` (oldest removed first).
304fn 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); // best-effort
320        }
321    }
322    Ok(())
323}
324
325// ── tests ────────────────────────────────────────────────────────────────────
326
327#[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            // Corrupt the vault.
380            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                // Sleep 10ms to ensure distinct timestamps.
424                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    // ── Task 1.3: export ─────────────────────────────────────────────────────
448
449    #[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            // First export succeeds.
492            export(&snap, &dest).unwrap();
493            assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");
494
495            // Overwrite with updated content succeeds (rename is atomic).
496            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    // ── Task 1.3: configurable retention ────────────────────────────────────
504
505    #[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            // Write 7 snapshots with keep=3.
513            for i in 0..7_u64 {
514                std::thread::sleep(std::time::Duration::from_millis(5));
515                // Vary content so filenames differ (timestamps are distinct).
516                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            // The 3 most recent (highest index) must survive.
522            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            // After keep=0 the prune removes all existing snapshots including
543            // the one just written (the new snap is counted then pruned).
544            // Behaviour: the newly written snap itself may be pruned.
545            // We accept 0 or 1 (implementation-detail of whether the new snap
546            // is written before or after the prune call — both are valid).
547            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    // ── Task 1.3: timestamp-targeted diff ───────────────────────────────────
556
557    /// Build a minimal vault JSON string with the given secret keys.
558    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        // Snapshot state: A and B exist.
592        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        // Current vault: A (changed ciphertext), C added, B removed.
601        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, &current_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            // No changes to vault.
646            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 a snapshot at a "future" timestamp.
662            take_at_timestamp_millis(
663                &vault_path,
664                "early",
665                DEFAULT_SNAPSHOT_KEEP,
666                9_999_999_999_999,
667            )
668            .unwrap();
669
670            // Requesting diff before that timestamp should return NoSnapshotAvailable.
671            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            // Add a new key to the current vault.
689            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}