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    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
83/// Return all snapshot paths for `profile`, sorted oldest-first.
84pub 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
104/// Restore the most-recent snapshot over `vault_path`. Returns the snapshot used.
105pub 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
113/// Restore a specific snapshot file over `vault_path`. Atomic.
114pub 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
129// ── extended lifecycle ───────────────────────────────────────────────────────
130
131/// Export a snapshot to an arbitrary destination path (e.g. an external backup
132/// or a CI artifact directory).
133///
134/// The exported file is an encrypted byte-for-byte copy of the snapshot — it
135/// requires the same vault password to use and can be safely stored on
136/// untrusted media.
137///
138/// The copy is atomic: the data is written to `<dest>.snap.export.tmp` first
139/// and then renamed to `dest`.
140///
141/// Returns `SnapshotNotFound` if `snap_path` does not exist.
142pub 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/// A single key difference between the current vault file and a snapshot.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum SnapDiffEntry {
162    /// The key exists in the snapshot but not in the current vault.
163    Removed(String),
164    /// The key exists in the current vault but not in the snapshot.
165    Added(String),
166    /// The key exists in both but the ciphertext differs (value was changed).
167    Changed(String),
168}
169
170/// Compare the on-disk vault file at `vault_path` against the most-recent
171/// snapshot taken at or before `at_millis` (UTC milliseconds since epoch).
172///
173/// The diff is computed by comparing the raw JSON `secrets` maps; secret values
174/// are **not** decrypted — only key presence and ciphertext equality are
175/// checked.
176///
177/// Returns `NoSnapshotAvailable` if no snapshot exists at or before
178/// `at_millis`.
179pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
180    let snaps = list(profile)?;
181    // Find the latest snapshot whose timestamp (milliseconds in filename) is ≤ at_millis.
182    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
197/// Compare the on-disk vault file at `vault_path` against the most-recent
198/// snapshot for `profile`.
199///
200/// Returns `NoSnapshotAvailable` if no snapshots exist.
201pub 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
209/// Parse the UTC millisecond timestamp embedded in a snapshot filename.
210///
211/// Expected format: `<profile>.vault.<ts_millis>.<seq>.snap`
212fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
213    let name = snap_path.file_name()?.to_str()?;
214    // Strip trailing ".snap"
215    let stem = name.strip_suffix(".snap")?;
216    // Split on '.' — expect last two segments to be <seq> and <ts>
217    let parts: Vec<&str> = stem.split('.').collect();
218    // Format: <profile>.vault.<ts>.<seq>  → parts[-2] is ts
219    if parts.len() < 4 {
220        return None;
221    }
222    let ts_part = parts[parts.len() - 2];
223    ts_part.parse::<i64>().ok()
224}
225
226/// Diff two vault files (current and snapshot) at the JSON level.
227///
228/// Compares only the `secrets` map keys and ciphertext values — no decryption
229/// is performed.
230fn 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(&current_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    // Keys in snapshot but not in current → Removed.
265    for key in snap_secrets.keys() {
266        if !current_secrets.contains_key(key) {
267            diff.push(SnapDiffEntry::Removed(key.clone()));
268        }
269    }
270
271    // Keys in current vault.
272    for (key, current_entry) in &current_secrets {
273        match snap_secrets.get(key) {
274            None => diff.push(SnapDiffEntry::Added(key.clone())),
275            Some(snap_entry) => {
276                // Compare ciphertext fields to detect value changes.
277                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
298/// Delete snapshots beyond `keep` (oldest removed first).
299fn 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); // best-effort
315        }
316    }
317    Ok(())
318}
319
320// ── tests ────────────────────────────────────────────────────────────────────
321
322#[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            // Corrupt the vault.
375            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                // Sleep 10ms to ensure distinct timestamps.
419                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    // ── Task 1.3: export ─────────────────────────────────────────────────────
443
444    #[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            // First export succeeds.
487            export(&snap, &dest).unwrap();
488            assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");
489
490            // Overwrite with updated content succeeds (rename is atomic).
491            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    // ── Task 1.3: configurable retention ────────────────────────────────────
499
500    #[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            // Write 7 snapshots with keep=3.
508            for i in 0..7_u64 {
509                std::thread::sleep(std::time::Duration::from_millis(5));
510                // Vary content so filenames differ (timestamps are distinct).
511                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            // The 3 most recent (highest index) must survive.
517            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            // After keep=0 the prune removes all existing snapshots including
538            // the one just written (the new snap is counted then pruned).
539            // Behaviour: the newly written snap itself may be pruned.
540            // We accept 0 or 1 (implementation-detail of whether the new snap
541            // is written before or after the prune call — both are valid).
542            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    // ── Task 1.3: timestamp-targeted diff ───────────────────────────────────
551
552    /// Build a minimal vault JSON string with the given secret keys.
553    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        // Snapshot state: A and B exist.
587        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        // Current vault: A (changed ciphertext), C added, B removed.
596        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, &current_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            // No changes to vault.
641            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 a snapshot at a "future" timestamp.
657            take_at_timestamp_millis(
658                &vault_path,
659                "early",
660                DEFAULT_SNAPSHOT_KEEP,
661                9_999_999_999_999,
662            )
663            .unwrap();
664
665            // Requesting diff before that timestamp should return NoSnapshotAvailable.
666            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            // Add a new key to the current vault.
684            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}