tsafe-core 1.0.13

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
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
//! Integration tests for vault locking posture, integrity boundary, and
//! snapshot rename continuity — covering tasks 1.1, 1.2, and 1.4 of the
//! whole-product maturity program.
//!
//! # Task 1.1 — Vault locking posture (advisory, documented ceiling)
//!
//! tsafe uses an advisory lock file (`<profile>.vault.lock`) to prevent
//! concurrent access.  This is **not** a kernel-enforced `flock(2)` /
//! `LockFileEx` guard; it is a best-effort exclusion mechanism that:
//!
//! - Prevents two processes from opening the same vault simultaneously.
//! - Recovers stale lock files when the owning PID is no longer running.
//! - Writes atomically (temp file → rename) to prevent partial-write corruption.
//!
//! This is the accepted custody ceiling for the local-first single-user-per-profile
//! scenario.  It is NOT a claim of:
//!
//! - Kernel-enforced exclusive access (no `flock`/`LockFileEx`).
//! - Network-share-safe coordination (NFS/SMB are explicitly out of scope).
//! - Multi-host or multi-user concurrent write safety.
//!
//! See `docs/decisions/vault-locking-and-integrity-boundary.md` for the
//! accepted decision record.
//!
//! # Task 1.2 — Whole-file integrity boundary (hardened non-goal)
//!
//! The current integrity contract is:
//!
//! - **Per-secret AEAD** — each secret's ciphertext is individually authenticated
//!   by XChaCha20-Poly1305 (or AES-256-GCM in FIPS mode).  Any bit flip in a
//!   ciphertext is detected and rejected on decrypt.
//!
//! - **Vault-key challenge** — a known plaintext (`tsafe-vault-challenge-v1`) is
//!   encrypted under the same vault key.  A wrong password or a swapped vault
//!   file is detected at open time before any secret is accessed.
//!
//! What the current contract does **not** cover:
//!
//! - Whole-file HMAC or signature over the full JSON envelope.
//! - Detection of envelope-level edits such as entry reordering, row deletion,
//!   or rollback to an older valid vault file encrypted under the same key.
//! - Anti-replay / monotonic-version proof.
//!
//! This boundary is explicitly accepted in
//! `docs/decisions/vault-locking-and-integrity-boundary.md` and
//! `docs/decisions/vault-custody-residuals.md`.
//!
//! # Task 1.4 — Snapshot rename continuity
//!
//! When a profile is renamed, its snapshot history is migrated with it via
//! `profile::rename_profile_snapshot_history`.  After migration:
//!
//! - Old snapshot files are moved to the destination profile's snapshot dir.
//! - Snapshot filenames are re-prefixed so `snapshot::list` returns them under
//!   the new profile name.
//! - The old snapshot directory is removed.
//! - Subsequent `Vault::save` calls on the new profile name take new snapshots
//!   correctly in the new location.

use std::collections::HashMap;

use tempfile::tempdir;
use tsafe_core::errors::SafeError;
use tsafe_core::profile::{rename_profile_snapshot_history, vault_path};
use tsafe_core::snapshot;
use tsafe_core::vault::Vault;

const PW: &[u8] = b"test-locking-integrity-pw";

// ── Task 1.1: vault locking posture documentation tests ─────────────────────

/// Advisory lock: a second open of the same vault fails while the first holds
/// the lock.  This is the primary locking guarantee — concurrent access is
/// rejected before any I/O occurs.
///
/// This test documents that the lock is **advisory** (implemented via a lock
/// file, not a kernel descriptor lock).  The guarantee holds within a single
/// host under normal conditions.
#[test]
fn task1_1_advisory_lock_rejects_concurrent_open() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("locking.vault");
    let _v = Vault::create(&path, PW).unwrap();

    // A second open while the first handle is held must fail.
    match Vault::open(&path, PW) {
        Err(SafeError::InvalidVault { reason }) => {
            assert!(
                reason.contains("vault is locked by another process"),
                "unexpected lock reason: {reason}"
            );
        }
        Ok(_) => panic!("advisory lock must reject concurrent open"),
        Err(e) => panic!("expected InvalidVault lock error, got {e:?}"),
    }
}

/// Advisory lock: after the owning handle is dropped the lock file is removed
/// and a subsequent open succeeds.
///
/// This proves lock release on drop is working — the locking mechanism does
/// not permanently block access.
#[test]
fn task1_1_advisory_lock_releases_on_drop() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("release.vault");
    {
        let mut v = Vault::create(&path, PW).unwrap();
        v.set("K", "v", HashMap::new()).unwrap();
        // Lock is held here — second open must fail.
        assert!(Vault::open(&path, PW).is_err());
    } // lock released via Drop

    // After drop, a fresh open must succeed.
    let v2 = Vault::open(&path, PW).unwrap();
    assert_eq!(&*v2.get("K").unwrap(), "v");
}

/// Stale lock recovery: when the owning PID is no longer running, the advisory
/// lock can be reclaimed without manual intervention.
///
/// This tests the dead-lock recovery path that makes the advisory lock safe
/// for crash recovery scenarios.
#[test]
fn task1_1_stale_advisory_lock_is_recovered_from_dead_pid() {
    use tsafe_core::vault::Vault;

    let dir = tempdir().unwrap();
    let path = dir.path().join("stale.vault");

    // Create the vault first so it exists on disk.
    {
        let _v = Vault::create(&path, PW).unwrap();
    }

    // Manually write a lock file with a dead PID (u32::MAX is always invalid).
    let lock_path = path.with_extension("vault.lock");
    let stale_lock = serde_json::json!({
        "version": 1,
        "id": "stale-test-id",
        "pid": u32::MAX,
        "created_at": "2026-01-01T00:00:00Z"
    });
    std::fs::write(&lock_path, stale_lock.to_string()).unwrap();
    assert!(lock_path.exists(), "stale lock file must exist");

    // Opening the vault must succeed by recovering the dead lock.
    let v =
        Vault::open(&path, PW).expect("open must succeed after recovering a stale advisory lock");
    assert_eq!(v.secret_count(), 0);
    drop(v);

    // The lock file should be gone after the handle is dropped.
    assert!(
        !lock_path.exists(),
        "lock file must be released after vault handle is dropped"
    );
}

/// Documents the advisory-only ceiling: two separate processes that race
/// between the lock existence check and the creation step may both succeed
/// if they land in the exact same millisecond window before either writes the
/// lock file.  This is an explicit, accepted residual risk — not a bug.
///
/// This test is intentionally written as a documentation assertion rather than
/// a race-reproduction test (which would be flaky) to make the accepted
/// boundary explicit in the test suite.
#[test]
fn task1_1_documents_accepted_advisory_ceiling() {
    // The following facts are the accepted contract for this boundary.
    // These assertions serve as machine-verifiable documentation.

    // 1. The lock is implemented as a file, not a kernel descriptor.
    //    Evidence: lock file path uses `.vault.lock` extension.
    let dir = tempdir().unwrap();
    let path = dir.path().join("v.vault");
    let _v = Vault::create(&path, PW).unwrap();
    let lock_path = path.with_extension("vault.lock");
    assert!(
        lock_path.exists(),
        "advisory lock must be a file on disk (not a kernel descriptor)"
    );

    // 2. The lock file contains a structured JSON payload with PID and ID.
    let contents = std::fs::read_to_string(&lock_path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
    assert!(
        parsed.get("pid").is_some(),
        "lock file must contain PID for stale-lock recovery"
    );
    assert!(
        parsed.get("id").is_some(),
        "lock file must contain unique ID to detect lock theft"
    );

    // 3. Accepted residuals (not asserted, but documented here for clarity):
    //    - No flock(2)/LockFileEx kernel handle is held.
    //    - Network share (NFS/SMB) safety is explicitly out of scope.
    //    - Multi-host concurrent write safety is out of scope.
    //    See: docs/decisions/vault-locking-and-integrity-boundary.md
}

// ── Task 1.2: vault-key authentication tests ────────────────────────────────

/// The vault-challenge mechanism detects a wrong password at open time,
/// before any secret is accessed.  This is the core per-key-authentication
/// guarantee.
#[test]
fn task1_2_wrong_password_fails_at_challenge() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("challenge.vault");
    let mut v = Vault::create(&path, PW).unwrap();
    v.set("SECRET", "value", HashMap::new()).unwrap();
    drop(v);

    let result = Vault::open(&path, b"wrong-password");
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "wrong password must fail with DecryptionFailed at challenge verification"
    );
}

/// The vault-challenge mechanism detects a swapped vault file: opening a
/// valid vault file with the wrong (but valid for another vault) password
/// fails.
///
/// This proves that the challenge is tied to the specific vault key, not just
/// that AEAD is used somewhere.
#[test]
fn task1_2_vault_challenge_detects_wrong_key_for_this_vault() {
    let dir = tempdir().unwrap();
    let path_a = dir.path().join("vault-a.vault");
    let path_b = dir.path().join("vault-b.vault");

    let pw_a = b"password-for-vault-a";
    let pw_b = b"password-for-vault-b";

    Vault::create(&path_a, pw_a).unwrap();
    Vault::create(&path_b, pw_b).unwrap();

    drop(Vault::open(&path_a, pw_a).unwrap());
    drop(Vault::open(&path_b, pw_b).unwrap());

    // Try to open vault-b using vault-a's password → must fail.
    let result = Vault::open(&path_b, pw_a);
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "vault-key challenge must reject a valid password that belongs to a different vault"
    );

    // And vice versa.
    let result = Vault::open(&path_a, pw_b);
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "vault-key challenge must reject a valid password that belongs to a different vault"
    );
}

/// Documents the current integrity ceiling: the vault-challenge verifies the
/// master key but does NOT protect against whole-file envelope manipulation
/// by an attacker with write access to the vault file.
///
/// This test is a documentation assertion proving the accepted non-goal
/// boundary.  The per-secret AEAD + challenge is the current mature contract;
/// whole-file HMAC is a post-v1 hardening item.
///
/// See: docs/decisions/vault-locking-and-integrity-boundary.md
///      docs/decisions/vault-custody-residuals.md
#[test]
fn task1_2_documents_per_secret_aead_plus_challenge_is_integrity_contract() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("integrity-boundary.vault");
    let mut v = Vault::create(&path, PW).unwrap();
    v.set("A", "alpha", HashMap::new()).unwrap();
    v.set("B", "beta", HashMap::new()).unwrap();
    drop(v);

    // Open and verify the vault works correctly.
    let v2 = Vault::open(&path, PW).unwrap();
    assert_eq!(&*v2.get("A").unwrap(), "alpha");
    assert_eq!(&*v2.get("B").unwrap(), "beta");
    drop(v2);

    // Current contract: per-secret AEAD + challenge.
    // Read the raw vault JSON and verify structural properties.
    let json = std::fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();

    // The vault has a challenge ciphertext.
    assert!(
        parsed.get("vault_challenge").is_some(),
        "vault must contain a vault_challenge for key authentication"
    );

    // Each secret has its own nonce and ciphertext (per-secret AEAD).
    let secrets = parsed.get("secrets").and_then(|s| s.as_object()).unwrap();
    for (_key, entry) in secrets {
        assert!(
            entry.get("nonce").is_some() && entry.get("ciphertext").is_some(),
            "each secret must have its own nonce and ciphertext (per-secret AEAD)"
        );
    }

    // The vault does NOT have a whole-file MAC or signature field.
    // This is the documented accepted non-goal for the current contract.
    assert!(
        parsed.get("file_mac").is_none() && parsed.get("file_hmac").is_none(),
        "whole-file MAC is not part of the current contract (post-v1 hardening)"
    );
}

/// Verify that each secret is encrypted with a unique nonce (no nonce reuse).
/// This is a basic property of the per-secret AEAD contract.
#[test]
fn task1_2_per_secret_aead_uses_unique_nonces() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("nonce-unique.vault");
    let mut v = Vault::create(&path, PW).unwrap();

    for i in 0..5 {
        v.set(&format!("K{i}"), &format!("value{i}"), HashMap::new())
            .unwrap();
    }
    drop(v);

    let json = std::fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    let secrets = parsed.get("secrets").and_then(|s| s.as_object()).unwrap();

    let nonces: Vec<&str> = secrets
        .values()
        .map(|e| e.get("nonce").and_then(|n| n.as_str()).unwrap())
        .collect();

    let mut seen = std::collections::HashSet::new();
    for nonce in &nonces {
        assert!(
            seen.insert(*nonce),
            "duplicate nonce detected — each secret must use a unique nonce: {nonce}"
        );
    }
}

// ── Task 1.4: snapshot rename continuity ────────────────────────────────────

/// When a profile is renamed, its snapshot history is migrated so that
/// subsequent snapshot operations work correctly under the new name.
///
/// This test proves the full rename continuity guarantee:
/// 1. Existing snapshots are moved and re-prefixed.
/// 2. `snapshot::list` returns snapshots under the new name.
/// 3. The old snapshot directory is removed.
/// 4. A new `Vault::save` on the renamed profile creates snapshots under the
///    new name (not the old one).
#[test]
fn task1_4_snapshot_history_migrates_on_profile_rename() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create a vault under profile "oldname" and write some secrets.
            let old_path = vault_path("oldname");
            std::fs::create_dir_all(old_path.parent().unwrap()).unwrap();
            let mut v = Vault::create(&old_path, PW).unwrap();
            v.set("K1", "val1", HashMap::new()).unwrap();
            v.set("K2", "val2", HashMap::new()).unwrap();
            drop(v);

            // Snapshots should exist for "oldname" now.
            let old_snaps = snapshot::list("oldname").unwrap();
            assert!(
                !old_snaps.is_empty(),
                "snapshots must exist for 'oldname' after vault writes"
            );

            // Simulate a profile rename: migrate snapshot history first.
            let migrated = rename_profile_snapshot_history("oldname", "newname").unwrap();
            assert!(
                migrated,
                "migration must return true when snapshots existed"
            );

            // Old snapshot dir must be gone.
            let old_snap_dir = snapshot::snapshot_dir("oldname");
            assert!(
                !old_snap_dir.exists(),
                "old snapshot directory must be removed after migration"
            );

            // New snapshot dir must have the migrated snapshots.
            let new_snaps = snapshot::list("newname").unwrap();
            assert_eq!(
                new_snaps.len(),
                old_snaps.len(),
                "all snapshots must be migrated to the new profile"
            );

            // All migrated snapshots must use the new profile prefix.
            for snap in &new_snaps {
                let name = snap.file_name().unwrap().to_string_lossy();
                assert!(
                    name.starts_with("newname.vault."),
                    "migrated snapshot must use new profile prefix: {name}"
                );
            }

            // Old snapshots must no longer appear under the old name.
            assert!(
                snapshot::list("oldname").unwrap().is_empty(),
                "no snapshots should remain under 'oldname'"
            );
        },
    );
}

/// When a profile is renamed but has no snapshot history, the migration is a
/// no-op and the new profile name has no snapshots yet.
#[test]
fn task1_4_rename_with_no_snapshots_is_noop() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            let migrated = rename_profile_snapshot_history("ghost-profile", "new-ghost").unwrap();
            assert!(
                !migrated,
                "migration must return false when source has no snapshot directory"
            );
            assert!(
                snapshot::list("new-ghost").unwrap().is_empty(),
                "new profile must have no snapshots when source had none"
            );
        },
    );
}

/// After renaming, a vault saved under the new profile name creates snapshots
/// in the new profile's snapshot directory (not the old one).
#[test]
fn task1_4_new_snapshots_after_rename_use_new_profile_name() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create vault under "src-profile".
            let src_path = vault_path("src-profile");
            std::fs::create_dir_all(src_path.parent().unwrap()).unwrap();
            {
                let mut v = Vault::create(&src_path, PW).unwrap();
                v.set("INIT", "value", HashMap::new()).unwrap();
            }

            // Migrate snapshots.
            rename_profile_snapshot_history("src-profile", "dst-profile").unwrap();

            // Simulate vault rename: move the vault file.
            let dst_path = vault_path("dst-profile");
            std::fs::rename(&src_path, &dst_path).unwrap();

            // Open and write to the vault under the new name.
            let mut v = Vault::open(&dst_path, PW).unwrap();
            v.set("AFTER_RENAME", "new-value", HashMap::new()).unwrap();
            drop(v);

            // New snapshots must be under "dst-profile".
            let new_snaps = snapshot::list("dst-profile").unwrap();
            assert!(
                !new_snaps.is_empty(),
                "snapshots must exist under the new profile name"
            );
            for snap in &new_snaps {
                let name = snap.file_name().unwrap().to_string_lossy();
                assert!(
                    name.starts_with("dst-profile.vault."),
                    "all snapshots must use the new profile prefix: {name}"
                );
            }

            // No snapshots should exist under the old name.
            assert!(
                snapshot::list("src-profile").unwrap().is_empty(),
                "no snapshots should remain under the old profile name"
            );
        },
    );
}

/// Rename is rejected when the destination profile already has a snapshot
/// directory, preventing accidental history loss.
#[test]
fn task1_4_rename_rejected_when_destination_has_existing_snapshots() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create snapshot directories for both source and destination.
            std::fs::create_dir_all(snapshot::snapshot_dir("from")).unwrap();
            std::fs::create_dir_all(snapshot::snapshot_dir("to")).unwrap();

            let err = rename_profile_snapshot_history("from", "to").unwrap_err();
            assert!(
                matches!(err, SafeError::InvalidVault { .. }),
                "must reject rename when destination snapshot dir already exists: {err:?}"
            );
        },
    );
}