tsafe-core 1.0.10

Cryptographic vault engine for tsafe — consume this crate to build tools on top.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! Local snapshot management — keeps the last N vault file copies so secrets
//! are never permanently lost due to corruption or accidental deletion.
//!
//! Snapshot file naming convention:
//!   `<profile>.vault.<timestamp_utc_millis>.<sequence>.snap`
//!
//! Snapshots are stored in `<vault_dir>/snapshots/<profile>/`.
//! They are encrypted identical copies of the vault file — no additional
//! credentials are needed to restore them.
//!
//! # Snapshot lifecycle
//!
//! The three key lifecycle operations beyond basic take/restore are:
//!
//! - **Export** — copy a snapshot to an arbitrary destination path (e.g. an
//!   external backup drive or a CI artifact directory).  The destination file
//!   is an encrypted copy of the vault file and requires the same vault
//!   password to use.
//!
//! - **Timestamp-targeted diff** — compare the current vault file (on-disk)
//!   against the most-recent snapshot that was taken at or before a given UTC
//!   millisecond timestamp.  Returns `Added`, `Removed`, and `Changed` key
//!   sets by examining the raw JSON secret maps without decrypting values.
//!
//! - **Configurable retention** — callers pass an explicit `keep` count to
//!   [`take`] so that different use-cases (CI ephemeral, long-running desktop)
//!   can manage snapshot depth independently.  The default is
//!   [`DEFAULT_SNAPSHOT_KEEP`].

use std::path::{Path, PathBuf};

use chrono::Utc;

use crate::errors::{SafeError, SafeResult};
use crate::profile::vault_dir;

/// How many snapshots to retain per profile.
pub const DEFAULT_SNAPSHOT_KEEP: usize = 10;

/// Return the directory that holds snapshots for `profile`.
pub fn snapshot_dir(profile: &str) -> PathBuf {
    vault_dir().join("snapshots").join(profile)
}

/// Take a snapshot of `vault_path`. Prunes oldest snapshots beyond `keep`.
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);

    // Atomic copy: write to .tmp, then rename.
    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")
}

/// Return all snapshot paths for `profile`, sorted oldest-first.
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)
}

/// Restore the most-recent snapshot over `vault_path`. Returns the snapshot used.
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)
}

/// Restore a specific snapshot file over `vault_path`. Atomic.
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())
}

// ── extended lifecycle ───────────────────────────────────────────────────────

/// Export a snapshot to an arbitrary destination path (e.g. an external backup
/// or a CI artifact directory).
///
/// The exported file is an encrypted byte-for-byte copy of the snapshot — it
/// requires the same vault password to use and can be safely stored on
/// untrusted media.
///
/// The copy is atomic: the data is written to `<dest>.snap.export.tmp` first
/// and then renamed to `dest`.
///
/// Returns `SnapshotNotFound` if `snap_path` does not exist.
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())
}

/// A single key difference between the current vault file and a snapshot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapDiffEntry {
    /// The key exists in the snapshot but not in the current vault.
    Removed(String),
    /// The key exists in the current vault but not in the snapshot.
    Added(String),
    /// The key exists in both but the ciphertext differs (value was changed).
    Changed(String),
}

/// Compare the on-disk vault file at `vault_path` against the most-recent
/// snapshot taken at or before `at_millis` (UTC milliseconds since epoch).
///
/// The diff is computed by comparing the raw JSON `secrets` maps; secret values
/// are **not** decrypted — only key presence and ciphertext equality are
/// checked.
///
/// Returns `NoSnapshotAvailable` if no snapshot exists at or before
/// `at_millis`.
pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
    let snaps = list(profile)?;
    // Find the latest snapshot whose timestamp (milliseconds in filename) is ≤ at_millis.
    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)
}

/// Compare the on-disk vault file at `vault_path` against the most-recent
/// snapshot for `profile`.
///
/// Returns `NoSnapshotAvailable` if no snapshots exist.
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)
}

/// Parse the UTC millisecond timestamp embedded in a snapshot filename.
///
/// Expected format: `<profile>.vault.<ts_millis>.<seq>.snap`
fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
    let name = snap_path.file_name()?.to_str()?;
    // Strip trailing ".snap"
    let stem = name.strip_suffix(".snap")?;
    // Split on '.' — expect last two segments to be <seq> and <ts>
    let parts: Vec<&str> = stem.split('.').collect();
    // Format: <profile>.vault.<ts>.<seq>  → parts[-2] is ts
    if parts.len() < 4 {
        return None;
    }
    let ts_part = parts[parts.len() - 2];
    ts_part.parse::<i64>().ok()
}

/// Diff two vault files (current and snapshot) at the JSON level.
///
/// Compares only the `secrets` map keys and ciphertext values — no decryption
/// is performed.
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(&current_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();

    // Keys in snapshot but not in current → Removed.
    for key in snap_secrets.keys() {
        if !current_secrets.contains_key(key) {
            diff.push(SnapDiffEntry::Removed(key.clone()));
        }
    }

    // Keys in current vault.
    for (key, current_entry) in &current_secrets {
        match snap_secrets.get(key) {
            None => diff.push(SnapDiffEntry::Added(key.clone())),
            Some(snap_entry) => {
                // Compare ciphertext fields to detect value changes.
                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)
}

/// Delete snapshots beyond `keep` (oldest removed first).
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); // best-effort
        }
    }
    Ok(())
}

// ── tests ────────────────────────────────────────────────────────────────────

#[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();
            // Corrupt the vault.
            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 {
                // Sleep 10ms to ensure distinct timestamps.
                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 { .. }));
    }

    // ── Task 1.3: export ─────────────────────────────────────────────────────

    #[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");

            // First export succeeds.
            export(&snap, &dest).unwrap();
            assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");

            // Overwrite with updated content succeeds (rename is atomic).
            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");
        });
    }

    // ── Task 1.3: configurable retention ────────────────────────────────────

    #[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(), || {
            // Write 7 snapshots with keep=3.
            for i in 0..7_u64 {
                std::thread::sleep(std::time::Duration::from_millis(5));
                // Vary content so filenames differ (timestamps are distinct).
                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");
            // The 3 most recent (highest index) must survive.
            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();
            // After keep=0 the prune removes all existing snapshots including
            // the one just written (the new snap is counted then pruned).
            // Behaviour: the newly written snap itself may be pruned.
            // We accept 0 or 1 (implementation-detail of whether the new snap
            // is written before or after the prune call — both are valid).
            assert!(
                snaps.len() <= 1,
                "keep=0 should remove all but at most the just-written snap; got {}",
                snaps.len()
            );
        });
    }

    // ── Task 1.3: timestamp-targeted diff ───────────────────────────────────

    /// Build a minimal vault JSON string with the given secret keys.
    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");

        // Snapshot state: A and B exist.
        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
        });

        // Current vault: A (changed ciphertext), C added, B removed.
        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, &current_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();

            // No changes to vault.
            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 a snapshot at a "future" timestamp.
            take_at_timestamp_millis(
                &vault_path,
                "early",
                DEFAULT_SNAPSHOT_KEEP,
                9_999_999_999_999,
            )
            .unwrap();

            // Requesting diff before that timestamp should return NoSnapshotAvailable.
            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();

            // Add a new key to the current vault.
            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);
    }
}