Skip to main content

vault_core/
lib.rs

1//! vault-core — shared encryption, storage, and tooling for UnENVerse.
2//!
3//! Used by the Tauri desktop app, the HTTP server (`unv-server`), and the CLI
4//! (`unv-cli`).  Has no dependency on Tauri; accepts `&Path` for all I/O.
5
6use argon2::{Algorithm, Argon2, Params, Version};
7pub use rusqlite::Connection as SqlConnection;
8use rusqlite::{Connection, OpenFlags, OptionalExtension};
9use std::fs;
10use std::path::Path;
11pub use zeroize::Zeroize;
12
13pub mod generators;
14pub mod jwks;
15pub use generators::{generate_certificate, generate_ssh_keypair};
16
17// Phase 24.3: the one Rust builder for the .ics feed — moved here from
18// unv-cli so `unv-server` can serve it too. See the module doc for why the
19// TypeScript twin was deleted rather than kept as a second answer.
20pub mod calendar;
21// Phase 24.3: the ics_feeds table (token issuance/lookup/revocation). Storage
22// only — rate limiting and RBAC filtering live in unv-server, same split as
23// `users`.
24pub mod ics_feeds;
25// Phase 24.4: the unique-ID registry — a separate SQLCipher file keyed from a
26// secret stored in this vault's vault_meta. Storage and hashing only; rate
27// limiting lives in unv-server.
28pub mod uid_registry;
29
30// Phase 34: nodes. The protocol, the hub registry and the node config (`nodes`),
31// and the transactional file apply a node performs (`nodes_apply`).
32pub mod nodes;
33pub mod nodes_apply;
34
35// Phase 35: the config time machine, and the line diff it shares with the CLI,
36// the server and the app.
37pub mod blast;
38pub mod config_history;
39
40// Phase 38: stack integrations (Prometheus, Grafana, Homepage) as descriptors in
41// data/stack-adapters.json, interpreted here and in src/ts/stack.ts.
42pub mod stack;
43pub mod textdiff;
44// Phase 24.5: the secret-type registry — one JSON descriptor file, read here
45// and imported as plain JSON by the TypeScript side.
46pub mod secret_types;
47// Phase 24.5: FIDO CXF import/export.
48pub mod cxf;
49// Phase 24.1: bundle-local and sibling-value template resolution.
50pub mod bundle_import;
51pub mod bundle_scope;
52pub mod catalogue;
53pub mod config_check;
54pub mod oauth;
55pub mod pgp;
56pub mod php_config;
57pub mod session_import;
58pub mod storage;
59pub mod templates;
60pub mod toml_import;
61pub mod type_emit;
62
63pub mod permex;
64pub mod pool;
65
66// No outer `///` here either — same reason as `totp` and `totp_import` below:
67// this module's own `//!` block would merge with one and break intra-doc links.
68pub mod composite;
69
70// Both modules carry their own `//!` docs. Adding an outer `///` here as well
71// makes rustdoc merge the two and resolve the *combined* text in this file's
72// scope, so every intra-doc link written inside the module — `[`Source::Os`]`,
73// `[`TlsPolicy::Pin`]` — fails with "no item named … in scope" and
74// `-D warnings` turns that into a failed docs build.
75pub mod entropy;
76
77#[cfg(feature = "tls")]
78pub mod tls;
79
80#[cfg(feature = "telemetry")]
81pub mod telemetry;
82pub use permex::{
83    eval as eval_perm_expr, parse as parse_perm_expr, EntryView, Expr as PermExpr,
84    Field as PermField,
85};
86
87pub mod users;
88
89// `totp` carries its own `//!` docs. No outer `///` here, for the same reason
90// as `entropy` above: rustdoc merges the two and resolves the combined text in
91// *this* file's scope, so `[`verify`]` written inside the module fails with
92// "no item named `verify` in module `vault_core`" and `-D warnings` turns that
93// into a failed docs build.
94pub mod totp;
95
96// Reading and writing the export files other authenticator apps produce.
97// Documented inside the module: an outer `///` here would merge with its own
98// `//!` block and resolve every intra-doc link in *this* file's scope, which is
99// how `entropy` and `totp` have each broken the docs build before.
100pub mod totp_import;
101pub use users::{
102    assign_user_class, authority_tier, class_authority_tier, create_user, create_user_class,
103    create_user_token, delete_user, delete_user_class, effective_permission_expr,
104    ensure_owner_user, filter_vault_for_user, get_class_permissions, get_permission_expr,
105    get_user_capabilities, get_user_permissions, glob_matches, init_users_schema,
106    list_user_classes, list_user_tokens, list_users, merge_user_vault_write, rename_user,
107    revoke_user_token, seed_default_admin, set_class_permissions, set_permission_expr,
108    set_user_password, set_user_permissions, token_user_id, update_user_class, user_authority_tier,
109    verify_user_password, verify_user_token, AdminSeed, ClassPermission, PermissionRecord,
110    TokenRecord, UserClass, UserRecord,
111};
112
113// ── Constants ──────────────────────────────────────────────────────────────────
114
115pub const SALT_LEN: usize = 16;
116pub const KEY_LEN: usize = 32;
117
118const A2_M_COST: u32 = 65_536;
119const A2_T_COST: u32 = 3;
120const A2_P_COST: u32 = 1;
121
122/// In-memory AES-256 vault key.
123pub type VaultKey = [u8; KEY_LEN];
124
125// ── KDF ────────────────────────────────────────────────────────────────────────
126
127/// Derives a 32-byte AES-256 key from `password` and `salt` using Argon2id
128/// (m=65536 KiB, t=3, p=1 — OWASP 2023 recommendation).
129pub fn derive_key(password: &str, salt: &[u8]) -> Result<VaultKey, String> {
130    let params =
131        Params::new(A2_M_COST, A2_T_COST, A2_P_COST, Some(KEY_LEN)).map_err(|e| e.to_string())?;
132    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
133    let mut key = [0u8; KEY_LEN];
134    argon2
135        .hash_password_into(password.as_bytes(), salt, &mut key)
136        .map_err(|e| e.to_string())?;
137    Ok(key)
138}
139
140/// Restrict a file to its owner where the platform can express that.
141///
142/// Windows has no chmod equivalent — files inherit the directory ACL — so this
143/// is a no-op there and `unv doctor` reports the check as *not enforceable*
144/// rather than passing. A check that always passes proves nothing.
145pub fn restrict_to_owner(path: &Path) -> Result<(), String> {
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::PermissionsExt;
149        fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
150    }
151    #[cfg(not(unix))]
152    {
153        let _ = path;
154    }
155    Ok(())
156}
157
158/// Refuse to derive a key when a database exists but its salt does not.
159///
160/// [`read_or_create_salt`] generates a salt when the file is absent, which is
161/// right for a first run and catastrophic for an existing vault: the new salt
162/// derives a different key, every unlock reports **"Wrong master password"**,
163/// and the user spends the afternoon convinced they have forgotten it. The
164/// evidence that anything else happened is gone by then, because the missing
165/// file has been silently replaced.
166///
167/// Called before key derivation by every path that opens an existing vault.
168pub fn check_salt_pairing(db_path: &Path, salt_path: &Path) -> Result<(), String> {
169    let db_exists = fs::metadata(db_path).map(|m| m.len() > 0).unwrap_or(false);
170    if db_exists && !salt_path.exists() {
171        return Err(format!(
172            "{} exists but {} is missing.\n\
173             The salt is 16 random bytes written once and stored nowhere else — without \n\
174             it this database cannot be opened by anyone, and nothing can recompute it.\n\
175             Restore both from an archive (`unv backup restore-archive`), or restore the \n\
176             vault contents from a .vaultbak (`unv backup import`), which does not need \n\
177             the original salt.",
178            db_path.display(),
179            salt_path.display()
180        ));
181    }
182    Ok(())
183}
184
185/// Reads salt from `salt_path`; generates and writes a fresh 16-byte salt if absent.
186pub fn read_or_create_salt(salt_path: &Path) -> Result<[u8; SALT_LEN], String> {
187    if salt_path.exists() {
188        let raw = fs::read(salt_path).map_err(|e| e.to_string())?;
189        raw.try_into()
190            .map_err(|_| "vault.salt is corrupt (wrong length)".to_string())
191    } else {
192        use rand::RngCore;
193        let mut s = [0u8; SALT_LEN];
194        rand::thread_rng().fill_bytes(&mut s);
195        if let Some(parent) = salt_path.parent() {
196            fs::create_dir_all(parent).map_err(|e| e.to_string())?;
197        }
198        fs::write(salt_path, s).map_err(|e| e.to_string())?;
199        // The salt is half of what opens the vault. It was written with whatever
200        // the umask gave it — 0644 on a default Linux install, which `unv
201        // doctor` is what finally noticed.
202        restrict_to_owner(salt_path)?;
203        Ok(s)
204    }
205}
206
207// ── Database ───────────────────────────────────────────────────────────────────
208
209/// Opens (or creates) the SQLCipher database at `db_path` using the 32-byte `key`.
210///
211/// Executes a verification query; returns `Err("Wrong master password")` on
212/// decryption failure so callers can distinguish auth errors from I/O errors.
213pub fn open_db(db_path: &Path, key: &VaultKey) -> Result<Connection, String> {
214    if let Some(p) = db_path.parent() {
215        fs::create_dir_all(p).map_err(|e| e.to_string())?;
216    }
217    let conn = Connection::open_with_flags(
218        db_path,
219        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
220    )
221    .map_err(|e| e.to_string())?;
222    conn.execute_batch(&format!("PRAGMA key = \"x'{}'\";", hex::encode(key)))
223        .map_err(|e| e.to_string())?;
224    conn.execute_batch("SELECT count(*) FROM sqlite_master;")
225        .map_err(|_| "Wrong master password".to_string())?;
226    // WAL mode: allows concurrent reads + one writer, avoids full locks (item 17)
227    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
228        .map_err(|e| e.to_string())?;
229    // Both explicit rather than inherited: rusqlite's implicit 5 s busy timeout is
230    // a library default that could change under an upgrade, and without a size
231    // limit a long-lived reader lets the WAL grow without bound.
232    conn.busy_timeout(std::time::Duration::from_secs(5))
233        .map_err(|e| e.to_string())?;
234    conn.execute_batch("PRAGMA journal_size_limit=67108864;")
235        .map_err(|e| e.to_string())?;
236    // SQLCipher creates the file with the process umask — 0644 on a default
237    // Linux install. The contents are encrypted, so this is not a disclosure of
238    // secrets; it is a disclosure of the ciphertext to anyone with a login on
239    // the box, which is an offline-attack head start nobody asked to give.
240    // WAL mode means two sidecars carry the same data.
241    restrict_to_owner(db_path)?;
242    for suffix in ["-wal", "-shm"] {
243        let mut side = db_path.as_os_str().to_owned();
244        side.push(suffix);
245        let side = std::path::PathBuf::from(side);
246        if side.exists() {
247            restrict_to_owner(&side)?;
248        }
249    }
250    Ok(conn)
251}
252
253/// Creates the `vault` and `vault_audit` tables if absent; adds hash-chain
254/// columns to `vault_audit` via idempotent ALTER TABLE (errors silently ignored
255/// on existing columns).
256pub fn init_schema(conn: &Connection) -> Result<(), String> {
257    conn.execute_batch(
258        "CREATE TABLE IF NOT EXISTS vault (
259             id   INTEGER PRIMARY KEY CHECK (id = 1),
260             data TEXT    NOT NULL
261         );
262         CREATE TABLE IF NOT EXISTS vault_audit (
263             id             INTEGER PRIMARY KEY AUTOINCREMENT,
264             action         TEXT    NOT NULL,
265             entry_provider TEXT,
266             timestamp      TEXT    NOT NULL,
267             details        TEXT
268         );
269         CREATE TABLE IF NOT EXISTS vault_meta (
270             key   TEXT PRIMARY KEY,
271             value TEXT NOT NULL
272         );",
273    )
274    .map_err(|e| e.to_string())?;
275    // Idempotent migration: add hash-chain columns if absent.
276    let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN entry_hash TEXT;");
277    let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN prev_hash  TEXT;");
278    // Who performed the action. Rows written before this column exists stay NULL
279    // and verify against the v1 hash formula (see `compute_audit_hash`).
280    let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN actor TEXT;");
281    // Audit lookups by entry and by time were full scans of the only table in the
282    // schema that could have been indexed from the start.
283    let _ = conn.execute_batch(
284        "CREATE INDEX IF NOT EXISTS vault_audit_provider ON vault_audit (entry_provider);
285         CREATE INDEX IF NOT EXISTS vault_audit_time ON vault_audit (timestamp);",
286    );
287    // Row-per-entry storage (Phase 30).
288    storage::init_schema(conn)?;
289    // Multi-user tables (Phase 5)
290    users::init_users_schema(conn)?;
291    // Calendar feed tokens (Phase 24.3)
292    ics_feeds::init_schema(conn)?;
293    config_history::init_schema(conn)?;
294    Ok(())
295}
296
297// ── Entry identity ────────────────────────────────────────────────────────────
298
299/// Canonical identity key for a vault entry.
300///
301/// Prefers the stable `id` (a UUID the frontend assigns on creation and never
302/// mutates). Falls back to `provider|account_name|key_id` for entries written
303/// before `id` existed.
304///
305/// Every consumer must use this one function. `save_vault` and
306/// [`merge_user_vault_write`] previously disagreed — the former ignored
307/// `key_id`, so two entries sharing provider+account collapsed into one key and
308/// `version_history` / audit rows landed on the wrong entry, while the RBAC
309/// merge treated them as distinct.
310pub fn entry_ck(entry: &serde_json::Value) -> String {
311    if let Some(id) = entry.get("id").and_then(|v| v.as_str()) {
312        if !id.is_empty() {
313            return format!("id\u{1}{id}");
314        }
315    }
316    let field = |k: &str| entry.get(k).and_then(|v| v.as_str()).unwrap_or("");
317    format!(
318        "legacy\u{1}{}\u{1}{}\u{1}{}",
319        field("provider"),
320        field("account_name"),
321        field("key_id"),
322    )
323}
324
325// ── Schema versioning ─────────────────────────────────────────────────────────
326
327/// The vault-document schema this build writes.
328///
329/// Three binaries — the desktop app, `unv-server` and `unv` — read and write
330/// one untyped JSON blob, and until this existed nothing recorded which shape it
331/// was in. The problem had already been hit once and solved by convention: the
332/// legacy `rate_limit` string is dual-written so a vault edited by a current
333/// build stays readable to an older one. The next field that skips that
334/// convention breaks old readers with no way to detect it and no way to refuse.
335///
336/// Bump this when a change makes a document unreadable to the previous build —
337/// not for an added optional field, which older readers ignore harmlessly.
338///
339/// Version 1 is the document shape as of 0.8.1: the whole vault as one JSON
340/// string in one row. Vaults written before this constant existed carry no
341/// version at all; that is treated as 1, because it is.
342///
343/// **Version 2 (Phase 30) is row-per-entry storage** (see [`storage`]). A v1 vault
344/// is converted on first open, after a `vault.db.v1.bak` copy; a v1 build then
345/// refuses the file with [`SCHEMA_ERR`] instead of reading an empty blob.
346///
347/// **Version 3 (Phase 30.2) gives every chunk of a project a row of its own.** A v2
348/// vault loads unchanged (a project row with inline chunks is understood) and is
349/// rewritten into chunk rows by its first save; a v2 build then refuses the file,
350/// because it would read a project with no chunks and delete the chunk rows.
351pub const VAULT_SCHEMA_VERSION: u32 = 3;
352
353/// Marker prefix on the error returned when the stored vault was written by a
354/// newer build than this one. Callers match on it to tell "upgrade me" from a
355/// real failure.
356pub const SCHEMA_ERR: &str = "VAULT_SCHEMA_TOO_NEW";
357
358/// The schema version stamped on the stored vault, or `None` for a vault
359/// written before versioning existed (or an empty database).
360pub fn vault_schema_version(conn: &Connection) -> Result<Option<u32>, String> {
361    let raw: Option<String> = conn
362        .query_row(
363            "SELECT value FROM vault_meta WHERE key = 'schema_version'",
364            [],
365            |r| r.get(0),
366        )
367        .optional()
368        .map_err(|e| e.to_string())?;
369    match raw {
370        None => Ok(None),
371        // An unparseable stamp is not "no stamp": something wrote a value this
372        // build cannot interpret, which is the same situation as a future
373        // version and gets the same refusal.
374        Some(s) => s
375            .trim()
376            .parse::<u32>()
377            .map(Some)
378            .map_err(|_| format!("{SCHEMA_ERR}: unreadable schema_version {s:?}")),
379    }
380}
381
382/// Refuses to touch a vault written by a newer build.
383///
384/// Refuse-with-a-message beats corrupt-on-round-trip: an old binary that reads a
385/// future document, drops the fields it does not know and writes it back has
386/// silently destroyed data, which is this project's worst bug class.
387pub fn check_schema_version(conn: &Connection) -> Result<(), String> {
388    match vault_schema_version(conn)? {
389        Some(v) if v > VAULT_SCHEMA_VERSION => Err(format!(
390            "{SCHEMA_ERR}: this vault was written by a newer version of UnENVerse \
391             (vault schema v{v}, this build understands v{VAULT_SCHEMA_VERSION}). \
392             Upgrade UnENVerse to open it — writing it with this build would drop \
393             the fields it does not understand."
394        )),
395        _ => Ok(()),
396    }
397}
398
399// ── Vault I/O ─────────────────────────────────────────────────────────────────
400
401/// Loads the raw vault JSON from an open connection.
402///
403/// Refuses a vault stamped with a schema this build does not understand, rather
404/// than handing back a document it would silently truncate on the next save.
405pub fn load_vault(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
406    check_schema_version(conn)?;
407    // A v1 vault is converted the first time anything opens it.
408    storage::migrate_if_needed(conn, &iso_now())?;
409    storage::load(conn)
410}
411
412/// Appended to the version returned by a save that folded in other writers'
413/// changes (Phase 30). The part before it is the current version token; a writer
414/// that sends the whole thing back as `expect_version` is understood.
415pub const MERGED_SUFFIX: &str = "+merged";
416
417/// Refuse a newer schema and convert a v1 vault, so that a version read *after*
418/// this is a version of the converted vault. A caller that reads the version
419/// before the data (the safe order, see the server's PUT handler) must call this
420/// first, or it pairs a v1 hash with a v2 document and its first save conflicts.
421pub fn ensure_current_schema(conn: &Connection) -> Result<(), String> {
422    check_schema_version(conn)?;
423    storage::migrate_if_needed(conn, &iso_now())
424}
425
426/// The vault document without any entry's `version_history`: what a selective
427/// read needs, without the 50-revision secret trail per entry that a full read
428/// carries (Phase 30).
429pub fn load_vault_lite(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
430    check_schema_version(conn)?;
431    storage::migrate_if_needed(conn, &iso_now())?;
432    storage::load_lite(conn)
433}
434
435/// Marker prefix on the error returned when a compare-and-swap write is refused.
436/// Callers match on this to tell "someone else wrote first" from a real failure.
437pub const CONFLICT_ERR: &str = "VAULT_CONFLICT";
438
439/// Who is writing, and what they believe the vault currently is.
440///
441/// A struct rather than two positional `Option<&str>` arguments: silently
442/// swapping an actor id for a version hash would disable the concurrency check
443/// while still compiling and still passing tests.
444#[derive(Debug, Default, Clone, Copy)]
445pub struct SaveCtx<'a> {
446    /// User id responsible for the change, recorded in the audit log.
447    /// `None` for contexts where the owner is implicit.
448    pub actor: Option<&'a str>,
449    /// The version the caller last read. When set, the write is refused unless
450    /// the stored vault is *still* at that version. `None` writes unconditionally
451    /// — only correct when nothing else can be writing.
452    pub expect_version: Option<&'a str>,
453}
454
455/// Current version of the stored vault, or `None` when the vault is empty.
456///
457/// This is the `data_hash` that `save_vault` writes, so it is by construction
458/// the hash of exactly the bytes on disk — no re-serialisation, no assumptions
459/// about map ordering.
460pub fn vault_version(conn: &Connection) -> Result<Option<String>, String> {
461    storage::version(conn)
462}
463
464/// Entry fields whose change is worth a `version_history` snapshot.
465///
466/// The pair is (JSON field, the word the audit row uses). `api_key` is first and
467/// is the one that writes no `field` discriminator into the record — see
468/// `save_vault_with_actor`.
469const HISTORIED_SECRET_FIELDS: [(&str, &str); 3] = [
470    ("api_key", "api_key"),
471    ("api_secret", "api_secret"),
472    ("totp_secret", "totp_secret"),
473];
474
475/// Previous values of an entry's `extra_vars`, keyed by var name.
476///
477/// Phase 23, E8. A named variable is where the real payload of an `env_var`
478/// entry lives, and for an AWS or Twilio credential it is where *all* of it
479/// lives — so before this, the only entries whose secrets were versioned were
480/// the ones that happened to use the primary slot. An `env_var` entry had **no
481/// history at all**, which E8 itself calls the one unacceptable option.
482///
483/// A var marked `public` is skipped: it is a region or a client id by
484/// declaration, and filling a 50-record history with them evicts the values
485/// that cannot be recovered any other way.
486fn historied_extra_vars(entry: &serde_json::Value) -> Vec<(String, String)> {
487    entry
488        .get("extra_vars")
489        .and_then(|v| v.as_array())
490        .map(|arr| {
491            arr.iter()
492                .filter(|xv| !xv.get("public").and_then(|p| p.as_bool()).unwrap_or(false))
493                .filter_map(|xv| {
494                    let k = xv.get("key").and_then(|v| v.as_str())?;
495                    let v = xv.get("value").and_then(|v| v.as_str())?;
496                    if k.is_empty() {
497                        return None;
498                    }
499                    Some((k.to_string(), v.to_string()))
500                })
501                .collect()
502        })
503        .unwrap_or_default()
504}
505
506/// Phase 30.1: apply a delta to a stored vault document. Shared by `PATCH
507/// /api/vault` and the desktop's `save_vault_rows` so they cannot differ.
508///
509/// `{ put, delete }` change `api_keys` by `id`; `projects_put`/`projects_delete`
510/// change `projects` by `id`; `categories`, when present, replaces
511/// `user_categories` (a flat list of strings, small). Every put needs a
512/// non-empty string `id`. Unknown keys are ignored.
513pub fn apply_row_patch(
514    mut doc: serde_json::Value,
515    patch: &serde_json::Value,
516) -> Result<serde_json::Value, String> {
517    use serde_json::Value;
518    fn ids(v: Option<&Value>, what: &str) -> Result<Vec<String>, String> {
519        let mut out = Vec::new();
520        for d in v.and_then(Value::as_array).into_iter().flatten() {
521            match d.as_str() {
522                Some(id) if !id.is_empty() => out.push(id.to_string()),
523                _ => return Err(format!("{what} takes a list of ids")),
524            }
525        }
526        Ok(out)
527    }
528    fn puts(v: Option<&Value>, what: &str) -> Result<Vec<(String, Value)>, String> {
529        let mut out = Vec::new();
530        for e in v.and_then(Value::as_array).into_iter().flatten() {
531            match e.get("id").and_then(Value::as_str) {
532                Some(id) if !id.is_empty() => out.push((id.to_string(), e.clone())),
533                _ => return Err(format!("Every {what} needs a string id")),
534            }
535        }
536        Ok(out)
537    }
538    fn apply(
539        doc: &mut Value,
540        key: &str,
541        put: Vec<(String, Value)>,
542        del: Vec<String>,
543    ) -> Result<(), String> {
544        let list = doc
545            .get_mut(key)
546            .and_then(Value::as_array_mut)
547            .ok_or_else(|| format!("stored vault has no {key}"))?;
548        let id_of = |e: &Value| e.get("id").and_then(Value::as_str).map(str::to_string);
549        list.retain(|e| id_of(e).is_none_or(|id| !del.contains(&id)));
550        for (id, entry) in put {
551            match list
552                .iter()
553                .position(|e| id_of(e).as_deref() == Some(id.as_str()))
554            {
555                Some(i) => list[i] = entry,
556                None => list.push(entry),
557            }
558        }
559        Ok(())
560    }
561    let (ep, ed) = (
562        puts(patch.get("put"), "put entry")?,
563        ids(patch.get("delete"), "delete")?,
564    );
565    let (pp, pd) = (
566        puts(patch.get("projects_put"), "put project")?,
567        ids(patch.get("projects_delete"), "projects_delete")?,
568    );
569    let cats = match patch.get("categories") {
570        None | Some(Value::Null) => None,
571        Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(Value::Array(a.clone())),
572        Some(_) => return Err("categories takes a list of strings".into()),
573    };
574    apply(&mut doc, "api_keys", ep, ed)?;
575    if !pp.is_empty() || !pd.is_empty() {
576        if doc.get("projects").is_none() {
577            doc["projects"] = Value::Array(Vec::new());
578        }
579        apply(&mut doc, "projects", pp, pd)?;
580    }
581    if let Some(c) = cats {
582        doc["user_categories"] = c;
583    }
584    Ok(doc)
585}
586
587/// Serialises `data` to the vault, updating `version_history` on key changes
588/// and appending to the `vault_audit` hash chain. Returns the new version.
589///
590/// # Concurrency
591///
592/// When `ctx.expect_version` is set this is a **compare-and-swap**: the whole
593/// operation runs inside one `BEGIN IMMEDIATE` transaction, and if another
594/// writer has changed the vault since the caller read it, nothing is written and
595/// [`CONFLICT_ERR`] is returned.
596///
597/// Doing the check here rather than in each caller matters for two reasons.
598/// A caller that reads, compares, then writes has a race between the compare and
599/// the write — which is what the server's `If-Match` handling used to be. And a
600/// caller that simply forgets is silently unprotected, which is how the desktop
601/// could clobber a LAN peer's edit.
602///
603/// The audit appends are inside the same transaction. They used to run before it,
604/// so a rejected or failed write still left audit rows describing changes that
605/// never happened.
606pub fn save_vault(
607    conn: &Connection,
608    data: serde_json::Value,
609    ctx: SaveCtx<'_>,
610) -> Result<String, String> {
611    conn.execute_batch("BEGIN IMMEDIATE")
612        .map_err(|e| e.to_string())?;
613    match save_vault_txn(conn, data, ctx) {
614        Ok(hash) => {
615            conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
616            Ok(hash)
617        }
618        Err(e) => {
619            let _ = conn.execute_batch("ROLLBACK");
620            Err(e)
621        }
622    }
623}
624
625/// Body of [`save_vault`]. Must only be called inside a write transaction.
626///
627/// Phase 30: the document is split into rows, a stale writer is merged with what
628/// others saved since (see [`storage`]), and only rows whose content changed are
629/// written, audited and given history.
630fn save_vault_txn(
631    conn: &Connection,
632    data: serde_json::Value,
633    ctx: SaveCtx<'_>,
634) -> Result<String, String> {
635    let actor = ctx.actor;
636    let now_str = iso_now();
637
638    // Refuse before doing any work: a newer document read by this build would
639    // lose every field this build does not know about.
640    check_schema_version(conn)?;
641    // A v1 blob still waiting is converted inside this same transaction.
642    storage::migrate_in_txn(conn, &now_str)?;
643
644    // Compare-and-swap, now per row: inside the transaction, so no writer can slip
645    // between this check and the write below. An absent version means an empty
646    // vault; a caller expecting a specific version against one is out of date.
647    let snap = storage::snapshot(conn)?;
648    let (mut ents, merged) = storage::merge(conn, &snap, storage::split(data), ctx.expect_version)?;
649
650    let stored: std::collections::HashMap<&str, &str> = snap
651        .iter()
652        .filter(|((kind, _), _)| kind == "entry")
653        .map(|((_, key), (rev, _))| (key.as_str(), rev.as_str()))
654        .collect();
655    let kept: std::collections::HashSet<&str> = ents
656        .iter()
657        .filter(|e| e.kind == "entry")
658        .map(|e| e.key.as_str())
659        .collect();
660    for key in stored.keys() {
661        if !kept.contains(*key) {
662            if let Some(old) = storage::load_ent(conn, "entry", key)? {
663                append_audit(conn, "delete", &old.provider(), &now_str, None, actor)?;
664            }
665        }
666    }
667
668    for ent in ents.iter_mut().filter(|e| e.kind == "entry") {
669        match stored.get(ent.key.as_str()) {
670            // Content unchanged: no history, no audit, no write.
671            Some(rev) if *rev == ent.rev => continue,
672            Some(_) => {
673                let Some(old) = storage::load_ent(conn, "entry", &ent.key)? else {
674                    continue;
675                };
676                let old_e = old.full();
677                let mut entry = ent.full();
678                let provider = ent.provider();
679                let entry = &mut entry;
680                // Every secret-carrying value the entry holds, snapshot into one
681                // history. `api_key` writes no `field` discriminator so a vault
682                // stays readable to a build that predates the others — an
683                // absent `field` means `api_key`, and always has.
684                //
685                // `totp_secret` is here because a re-enrolled authenticator seed
686                // is exactly as unrecoverable as a replaced API key, and losing
687                // it silently is how a user finds out at the login screen.
688                for (field, label) in HISTORIED_SECRET_FIELDS {
689                    let new_val = entry.get(field).and_then(|v| v.as_str()).unwrap_or("");
690                    let old_val = old_e.get(field).and_then(|v| v.as_str()).unwrap_or("");
691                    if new_val == old_val || old_val.is_empty() {
692                        continue;
693                    }
694                    let mut history: Vec<serde_json::Value> = entry
695                        .get("version_history")
696                        .and_then(|v| v.as_array())
697                        .cloned()
698                        .unwrap_or_else(|| {
699                            old_e
700                                .get("version_history")
701                                .and_then(|v| v.as_array())
702                                .cloned()
703                                .unwrap_or_default()
704                        });
705                    let mut record = serde_json::json!({ "value": old_val, "saved_at": now_str });
706                    if field != "api_key" {
707                        record["field"] = serde_json::json!(field);
708                    }
709                    history.insert(0, record);
710                    // The cap is per entry, not per field, so a chatty seed
711                    // cannot evict an API key's history — which is why they all
712                    // share one list rather than getting one each.
713                    history.truncate(50);
714                    if let Some(obj) = entry.as_object_mut() {
715                        obj.insert(
716                            "version_history".to_string(),
717                            serde_json::Value::Array(history),
718                        );
719                    }
720                    append_audit(
721                        conn,
722                        "update",
723                        &provider,
724                        &now_str,
725                        Some(&format!("{label} rotated")),
726                        actor,
727                    )?;
728                }
729
730                // The same, for named variables (E8). Matched by **name**, not
731                // by position: `extra_vars` is an array the form rebuilds on
732                // every save, so an index captured across an edit points at
733                // whatever took its place — invariant 1, in the one place where
734                // getting it wrong writes the wrong secret into history.
735                //
736                // A var that is *removed* leaves its last value in history: the
737                // user deleting a row is exactly as unable to recover it as the
738                // user overwriting one, and the row's absence is not evidence
739                // that they meant to lose it.
740                let old_vars = historied_extra_vars(&old_e);
741                if !old_vars.is_empty() {
742                    let new_vars: std::collections::HashMap<String, String> =
743                        historied_extra_vars(entry).into_iter().collect();
744                    for (key, old_val) in old_vars {
745                        if old_val.is_empty() {
746                            continue;
747                        }
748                        if new_vars.get(&key).map(String::as_str) == Some(old_val.as_str()) {
749                            continue;
750                        }
751                        let mut history: Vec<serde_json::Value> = entry
752                            .get("version_history")
753                            .and_then(|v| v.as_array())
754                            .cloned()
755                            .unwrap_or_default();
756                        history.insert(
757                            0,
758                            serde_json::json!({
759                                "value": old_val,
760                                "saved_at": now_str,
761                                // Namespaced so a restore can tell a var called
762                                // `api_key` from the field of that name.
763                                "field": format!("extra_vars/{key}"),
764                            }),
765                        );
766                        history.truncate(50);
767                        if let Some(obj) = entry.as_object_mut() {
768                            obj.insert(
769                                "version_history".to_string(),
770                                serde_json::Value::Array(history),
771                            );
772                        }
773                        append_audit(
774                            conn,
775                            "update",
776                            &provider,
777                            &now_str,
778                            Some(&format!("{key} rotated")),
779                            actor,
780                        )?;
781                    }
782                }
783                // Split the (possibly extended) history back off the row.
784                if let Some(h) = entry
785                    .as_object_mut()
786                    .and_then(|o| o.remove("version_history"))
787                {
788                    ent.history = Some(h);
789                }
790            }
791            None => {
792                append_audit(conn, "add", &ent.provider(), &now_str, None, actor)?;
793            }
794        }
795    }
796
797    // Data and token move together, so the integrity check never sees a mismatch.
798    let token = storage::write(conn, &snap, &ents, &now_str)?;
799    // Stamp the shape alongside the data, in the same transaction. A vault that
800    // has been written by this build is by definition in this build's schema,
801    // so there is no separate migration step to forget to run.
802    conn.execute(
803        "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
804        rusqlite::params![VAULT_SCHEMA_VERSION.to_string()],
805    )
806    .map_err(|e| e.to_string())?;
807
808    // When other writers' changes were folded in, the caller's copy of the vault
809    // is *behind* what was just stored. Treating the new token as its base would
810    // let it overwrite those changes on its next save, and keeping its old base
811    // would make that save conflict with its own previous one. So the token comes
812    // back marked [`MERGED_SUFFIX`]: a client that holds a document reloads it; a
813    // one-shot client (the CLI) never looks.
814    if merged {
815        Ok(format!("{token}{MERGED_SUFFIX}"))
816    } else {
817        Ok(token)
818    }
819}
820
821/// Verifies the stored vault data against its SHA-256 integrity hash.
822/// Returns `Ok(true)` if hash matches, `Ok(false)` if tampered or hash absent, `Err` on I/O.
823pub fn verify_vault_integrity(conn: &Connection) -> Result<bool, String> {
824    storage::verify(conn)
825}
826
827/// Returns vault entries whose `expires_at` date falls within `within_days` days
828/// from today (inclusive of today, exclusive of entries already expired).
829///
830/// Uses lexicographic YYYY-MM-DD comparison — no parsing feature required.
831pub fn get_expiring_entries(
832    conn: &Connection,
833    within_days: u32,
834) -> Result<Vec<serde_json::Value>, String> {
835    let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
836    Ok(expiring_from_value(&data, within_days))
837}
838
839/// Like [`get_expiring_entries`] but first filters the vault to the entries the
840/// user is permitted to read.  Prevents non-owner sessions from learning about
841/// the expiry (and full contents) of secrets outside their RBAC scope.
842pub fn get_expiring_entries_for_user(
843    conn: &Connection,
844    within_days: u32,
845    read: Option<&permex::Expr>,
846) -> Result<Vec<serde_json::Value>, String> {
847    let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
848    let filtered = filter_vault_for_user(data, read);
849    Ok(expiring_from_value(&filtered, within_days))
850}
851
852/// Extracts the `api_keys` whose `expires_at` falls within `within_days` of today.
853fn expiring_from_value(data: &serde_json::Value, within_days: u32) -> Vec<serde_json::Value> {
854    let now = time::OffsetDateTime::now_utc();
855    let cutoff = now + time::Duration::days(within_days as i64);
856    let today_str = fmt_date(&now);
857    let cutoff_str = fmt_date(&cutoff);
858
859    data.get("api_keys")
860        .and_then(|k| k.as_array())
861        .cloned()
862        .unwrap_or_default()
863        .into_iter()
864        .filter(|entry| {
865            entry
866                .get("expires_at")
867                .and_then(|v| v.as_str())
868                .is_some_and(|s| {
869                    let d = &s[..s.len().min(10)];
870                    d >= today_str.as_str() && d <= cutoff_str.as_str()
871                })
872        })
873        .collect()
874}
875
876fn fmt_date(dt: &time::OffsetDateTime) -> String {
877    format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
878}
879
880// ── Audit log ─────────────────────────────────────────────────────────────────
881
882/// A single audit log row, including the hash-chain fields.
883#[derive(Debug, serde::Serialize, serde::Deserialize)]
884pub struct AuditRow {
885    pub id: i64,
886    pub action: String,
887    pub entry_provider: Option<String>,
888    pub timestamp: String,
889    pub details: Option<String>,
890    pub entry_hash: Option<String>,
891    pub prev_hash: Option<String>,
892    /// User id that performed the action. `None` for rows written before actor
893    /// tracking, and for local desktop edits where the owner is implicit.
894    pub actor: Option<String>,
895}
896
897/// Appends an audit entry and computes `entry_hash = SHA256(action|provider|ts|prev_hash)`.
898fn append_audit(
899    conn: &Connection,
900    action: &str,
901    provider: &str,
902    timestamp: &str,
903    details: Option<&str>,
904    actor: Option<&str>,
905) -> Result<(), String> {
906    let prev_hash: Option<String> = conn
907        .query_row(
908            "SELECT entry_hash FROM vault_audit ORDER BY id DESC LIMIT 1",
909            [],
910            |row| row.get(0),
911        )
912        .optional()
913        .map_err(|e| e.to_string())?
914        .flatten();
915
916    let entry_hash = compute_audit_hash(
917        action,
918        provider,
919        timestamp,
920        actor,
921        prev_hash.as_deref().unwrap_or("genesis"),
922    );
923
924    conn.execute(
925        "INSERT INTO vault_audit \
926         (action, entry_provider, timestamp, details, entry_hash, prev_hash, actor) \
927         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
928        rusqlite::params![action, provider, timestamp, details, entry_hash, prev_hash, actor],
929    )
930    .map_err(|e| e.to_string())?;
931    Ok(())
932}
933
934/// Records a hash-chained audit event with the current timestamp.
935pub fn record_event(
936    conn: &Connection,
937    action: &str,
938    provider: &str,
939    details: Option<&str>,
940    actor: Option<&str>,
941) -> Result<(), String> {
942    append_audit(conn, action, provider, &iso_now(), details, actor)
943}
944
945/// Hash for one audit row, binding it to its predecessor.
946///
947/// Two formats coexist:
948/// - **v1** `action|provider|timestamp|prev` — rows written before actor tracking.
949/// - **v2** `action|provider|timestamp|actor|prev` — includes the acting user, so
950///   attribution is covered by the chain and cannot be rewritten undetected.
951///
952/// A row with no actor keeps using v1 so existing chains stay verifiable; the
953/// verifier tries v2 first and falls back to v1.
954fn compute_audit_hash(
955    action: &str,
956    provider: &str,
957    timestamp: &str,
958    actor: Option<&str>,
959    prev_hash: &str,
960) -> String {
961    use sha2::{Digest, Sha256};
962    let mut h = Sha256::new();
963    match actor {
964        Some(a) => {
965            for part in [
966                action, "|", provider, "|", timestamp, "|", a, "|", prev_hash,
967            ] {
968                h.update(part.as_bytes());
969            }
970        }
971        None => {
972            for part in [action, "|", provider, "|", timestamp, "|", prev_hash] {
973                h.update(part.as_bytes());
974            }
975        }
976    }
977    hex::encode(h.finalize())
978}
979
980/// Returns all audit rows ordered newest-first.
981pub fn load_audit(conn: &Connection) -> Result<Vec<AuditRow>, String> {
982    let mut stmt = conn
983        .prepare(
984            "SELECT id, action, entry_provider, timestamp, details, entry_hash, prev_hash, actor \
985         FROM vault_audit ORDER BY id DESC",
986        )
987        .map_err(|e| e.to_string())?;
988
989    let rows: Vec<Result<AuditRow, _>> = stmt
990        .query_map([], |row| {
991            Ok(AuditRow {
992                id: row.get(0)?,
993                action: row.get(1)?,
994                entry_provider: row.get(2)?,
995                timestamp: row.get(3)?,
996                details: row.get(4)?,
997                entry_hash: row.get(5)?,
998                prev_hash: row.get(6)?,
999                actor: row.get(7)?,
1000            })
1001        })
1002        .map_err(|e| e.to_string())?
1003        .collect();
1004    rows.into_iter()
1005        .map(|r| r.map_err(|e| e.to_string()))
1006        .collect()
1007}
1008
1009// ── Migration helpers ─────────────────────────────────────────────────────────
1010
1011/// Inserts raw JSON from a legacy `vault.json` into the `vault` table.
1012/// Called once on first unlock after a Phase 1 → Phase 2 upgrade.
1013pub fn migrate_legacy_json(conn: &Connection, raw_json: &str) -> Result<(), String> {
1014    let doc: serde_json::Value = serde_json::from_str(raw_json).map_err(|e| e.to_string())?;
1015    save_vault(conn, doc, SaveCtx::default()).map(|_| ())
1016}
1017
1018// ── Helpers ────────────────────────────────────────────────────────────────────
1019
1020/// Returns the current UTC time as an ISO-8601 string (`YYYY-MM-DDTHH:MM:SSZ`).
1021/// A random UUID v4, with the version and variant bits set.
1022///
1023/// Hand-rolled rather than pulled in as a crate for the same reason base32 is:
1024/// it is eleven lines, and `rand` is already here. `users.rs` and the TOTP
1025/// importer both call it, so an entry created by the desktop app's import gets
1026/// an id shaped exactly like one created anywhere else — which matters because
1027/// `entry_ck` falls back to a legacy tuple for entries that have none.
1028pub fn new_uuid() -> String {
1029    use rand::RngCore;
1030    let mut b = [0u8; 16];
1031    rand::thread_rng().fill_bytes(&mut b);
1032    b[6] = (b[6] & 0x0f) | 0x40;
1033    b[8] = (b[8] & 0x3f) | 0x80;
1034    format!(
1035        "{}-{}-{}-{}-{}",
1036        hex::encode(&b[0..4]),
1037        hex::encode(&b[4..6]),
1038        hex::encode(&b[6..8]),
1039        hex::encode(&b[8..10]),
1040        hex::encode(&b[10..16]),
1041    )
1042}
1043
1044pub fn iso_now() -> String {
1045    let t = time::OffsetDateTime::now_utc();
1046    format!(
1047        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1048        t.year(),
1049        t.month() as u8,
1050        t.day(),
1051        t.hour(),
1052        t.minute(),
1053        t.second()
1054    )
1055}
1056
1057// ── Tests ──────────────────────────────────────────────────────────────────────
1058
1059#[cfg(test)]
1060mod tests {
1061    #[test]
1062    fn a_row_patch_replaces_by_id_appends_deletes_and_refuses_bad_input() {
1063        use serde_json::json;
1064        let doc = json!({
1065            "api_keys": [{"id":"a","provider":"A"},{"id":"b","provider":"B"}],
1066            "user_categories": ["x"],
1067            "projects": [{"id":"p","name":"P"}]
1068        });
1069        let out = apply_row_patch(
1070            doc.clone(),
1071            &json!({
1072                "put": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1073                "delete": ["a"],
1074                "projects_put": [{"id":"q","name":"Q"}],
1075                "projects_delete": ["p"],
1076                "categories": ["y","z"]
1077            }),
1078        )
1079        .unwrap();
1080        assert_eq!(
1081            out,
1082            json!({
1083                "api_keys": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1084                "user_categories": ["y","z"],
1085                "projects": [{"id":"q","name":"Q"}]
1086            })
1087        );
1088        // An empty patch changes nothing.
1089        assert_eq!(apply_row_patch(doc.clone(), &json!({})).unwrap(), doc);
1090        for bad in [
1091            json!({"put": [{"provider":"no id"}]}),
1092            json!({"delete": [1]}),
1093            json!({"projects_put": [{"name":"no id"}]}),
1094            json!({"categories": [1]}),
1095        ] {
1096            assert!(apply_row_patch(doc.clone(), &bad).is_err(), "{bad}");
1097        }
1098    }
1099
1100    use super::*;
1101    use serde_json::json;
1102
1103    /// Unique scratch path per test; SQLCipher needs a real file, not `:memory:`.
1104    fn scratch(tag: &str) -> std::path::PathBuf {
1105        let nanos = std::time::SystemTime::now()
1106            .duration_since(std::time::UNIX_EPOCH)
1107            .unwrap()
1108            .as_nanos();
1109        let dir = std::env::temp_dir().join(format!("unenverse-test-{tag}-{nanos}"));
1110        fs::create_dir_all(&dir).unwrap();
1111        dir
1112    }
1113
1114    fn open_scratch(tag: &str) -> (Connection, std::path::PathBuf) {
1115        let dir = scratch(tag);
1116        let key = derive_key("correct horse battery staple", b"0123456789abcdef").unwrap();
1117        let conn = open_db(&dir.join("vault.db"), &key).unwrap();
1118        init_schema(&conn).unwrap();
1119        (conn, dir)
1120    }
1121
1122    // ── version_history (Phase 23, E8) ─────────────────────────────────────────
1123
1124    fn history_of(conn: &Connection) -> Vec<serde_json::Value> {
1125        let raw = load_vault(conn).unwrap().unwrap_or(json!({}));
1126        raw["api_keys"][0]["version_history"]
1127            .as_array()
1128            .cloned()
1129            .unwrap_or_default()
1130    }
1131
1132    /// Every secret-carrying value is versioned, not just `api_key`.
1133    ///
1134    /// Before Phase 23 a refresh-token swap, a replaced client secret and every
1135    /// `extra_vars` edit left no history at all — and for an `env_var` entry,
1136    /// whose entire payload lives in named variables, *nothing* was versioned.
1137    /// E8 calls leaving a secret silently unversioned the one unacceptable
1138    /// option.
1139    #[test]
1140    fn every_secret_carrying_value_is_versioned() {
1141        let (conn, _d) = open_scratch("historyfields");
1142        let base = json!({ "api_keys": [{
1143            "id": "e1", "provider": "Aws",
1144            "api_key": "key-v1", "api_secret": "secret-v1",
1145            "extra_vars": [
1146                { "key": "SESSION_TOKEN", "value": "tok-v1" },
1147                { "key": "REGION", "value": "eu-west-1", "public": true },
1148            ],
1149        }]});
1150        save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1151        assert!(history_of(&conn).is_empty(), "nothing changed yet");
1152
1153        let mut next = base.clone();
1154        next["api_keys"][0]["api_key"] = json!("key-v2");
1155        next["api_keys"][0]["api_secret"] = json!("secret-v2");
1156        next["api_keys"][0]["extra_vars"][0]["value"] = json!("tok-v2");
1157        next["api_keys"][0]["extra_vars"][1]["value"] = json!("us-east-1");
1158        save_vault(&conn, next.clone(), SaveCtx::default()).unwrap();
1159
1160        let hist = history_of(&conn);
1161        let found: Vec<(String, String)> = hist
1162            .iter()
1163            .map(|h| {
1164                (
1165                    h.get("field")
1166                        .and_then(|v| v.as_str())
1167                        .unwrap_or("api_key")
1168                        .to_string(),
1169                    h["value"].as_str().unwrap_or("").to_string(),
1170                )
1171            })
1172            .collect();
1173
1174        assert!(
1175            found.contains(&("api_key".into(), "key-v1".into())),
1176            "api_key still writes no discriminator — every pre-Phase-22 vault relies on that: {found:?}"
1177        );
1178        assert!(
1179            found.contains(&("api_secret".into(), "secret-v1".into())),
1180            "a replaced client secret is as unrecoverable as a replaced key: {found:?}"
1181        );
1182        assert!(
1183            found.contains(&("extra_vars/SESSION_TOKEN".into(), "tok-v1".into())),
1184            "a named variable is where an env_var entry's whole payload lives: {found:?}"
1185        );
1186        assert!(
1187            !found.iter().any(|(f, _)| f == "extra_vars/REGION"),
1188            "a var marked public is a region by declaration; filling a 50-record \
1189             history with them evicts the values that cannot be recovered: {found:?}"
1190        );
1191    }
1192
1193    /// A deleted variable leaves its last value behind.
1194    ///
1195    /// Deleting a row makes its value exactly as unrecoverable as overwriting
1196    /// one, and the row's absence is not evidence that the user meant to lose it.
1197    #[test]
1198    fn deleting_a_variable_still_versions_it() {
1199        let (conn, _d) = open_scratch("historydelete");
1200        let base = json!({ "api_keys": [{
1201            "id": "e1", "provider": "Aws", "api_key": "k",
1202            "extra_vars": [{ "key": "SESSION_TOKEN", "value": "tok-v1" }],
1203        }]});
1204        save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1205
1206        let mut next = base.clone();
1207        next["api_keys"][0]["extra_vars"] = json!([]);
1208        save_vault(&conn, next, SaveCtx::default()).unwrap();
1209
1210        let hist = history_of(&conn);
1211        assert_eq!(hist.len(), 1, "{hist:?}");
1212        assert_eq!(hist[0]["field"], json!("extra_vars/SESSION_TOKEN"));
1213        assert_eq!(hist[0]["value"], json!("tok-v1"));
1214    }
1215
1216    /// Variables are matched by **name**, never by position.
1217    ///
1218    /// `extra_vars` is an array the form rebuilds on every save, so an index
1219    /// captured across an edit points at whatever took its place — invariant 1,
1220    /// in the one place where getting it wrong writes the wrong secret into
1221    /// history.
1222    #[test]
1223    fn variables_are_matched_by_name_not_position() {
1224        let (conn, _d) = open_scratch("historyreorder");
1225        let base = json!({ "api_keys": [{
1226            "id": "e1", "provider": "Aws", "api_key": "k",
1227            "extra_vars": [
1228                { "key": "A", "value": "a1" },
1229                { "key": "B", "value": "b1" },
1230            ],
1231        }]});
1232        save_vault(&conn, base, SaveCtx::default()).unwrap();
1233
1234        // Reordered, and only B changed.
1235        let next = json!({ "api_keys": [{
1236            "id": "e1", "provider": "Aws", "api_key": "k",
1237            "extra_vars": [
1238                { "key": "B", "value": "b2" },
1239                { "key": "A", "value": "a1" },
1240            ],
1241        }]});
1242        save_vault(&conn, next, SaveCtx::default()).unwrap();
1243
1244        let hist = history_of(&conn);
1245        assert_eq!(hist.len(), 1, "only B changed: {hist:?}");
1246        assert_eq!(hist[0]["field"], json!("extra_vars/B"));
1247        assert_eq!(hist[0]["value"], json!("b1"));
1248    }
1249
1250    // ── Schema version ─────────────────────────────────────────────────────────
1251
1252    #[test]
1253    fn saving_stamps_the_schema_version() {
1254        let (conn, _d) = open_scratch("schemastamp");
1255        assert_eq!(
1256            vault_schema_version(&conn).unwrap(),
1257            None,
1258            "a fresh database carries no stamp until something is written"
1259        );
1260        save_vault(
1261            &conn,
1262            serde_json::json!({ "api_keys": [] }),
1263            SaveCtx::default(),
1264        )
1265        .unwrap();
1266        assert_eq!(
1267            vault_schema_version(&conn).unwrap(),
1268            Some(VAULT_SCHEMA_VERSION)
1269        );
1270    }
1271
1272    #[test]
1273    fn an_unstamped_vault_still_opens() {
1274        // Every vault written before this constant existed has no stamp. Treating
1275        // "absent" as a failure would refuse to open every vault in the field.
1276        let (conn, _d) = open_scratch("schemalegacy");
1277        save_vault(
1278            &conn,
1279            serde_json::json!({ "api_keys": [] }),
1280            SaveCtx::default(),
1281        )
1282        .unwrap();
1283        conn.execute("DELETE FROM vault_meta WHERE key = 'schema_version'", [])
1284            .unwrap();
1285        assert!(load_vault(&conn).unwrap().is_some());
1286    }
1287
1288    #[test]
1289    fn a_future_schema_is_refused_for_both_read_and_write() {
1290        // Refuse-with-a-message beats corrupt-on-round-trip. An old binary that
1291        // reads a newer document, drops the fields it does not know and saves it
1292        // back has silently destroyed data — the exact failure mode this project
1293        // hunts everywhere else.
1294        let (conn, _d) = open_scratch("schemafuture");
1295        save_vault(
1296            &conn,
1297            serde_json::json!({ "api_keys": [], "projects": [] }),
1298            SaveCtx::default(),
1299        )
1300        .unwrap();
1301        conn.execute(
1302            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
1303            rusqlite::params![(VAULT_SCHEMA_VERSION + 1).to_string()],
1304        )
1305        .unwrap();
1306
1307        let read = load_vault(&conn).unwrap_err();
1308        assert!(read.starts_with(SCHEMA_ERR), "load said: {read}");
1309        let write = save_vault(
1310            &conn,
1311            serde_json::json!({ "api_keys": [] }),
1312            SaveCtx::default(),
1313        )
1314        .unwrap_err();
1315        assert!(write.starts_with(SCHEMA_ERR), "save said: {write}");
1316
1317        // And the refusal must not have been a partial write.
1318        conn.execute(
1319            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1320            [],
1321        )
1322        .unwrap();
1323        let v = load_vault(&conn).unwrap().unwrap();
1324        assert!(
1325            v.get("projects").is_some(),
1326            "the refused save wrote nothing"
1327        );
1328    }
1329
1330    #[test]
1331    fn an_unparseable_stamp_is_treated_as_unknown_not_as_absent() {
1332        let (conn, _d) = open_scratch("schemajunk");
1333        conn.execute(
1334            "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', 'tomorrow')",
1335            [],
1336        )
1337        .unwrap();
1338        let e = load_vault(&conn).unwrap_err();
1339        assert!(e.starts_with(SCHEMA_ERR), "said: {e}");
1340    }
1341
1342    // ── KDF ────────────────────────────────────────────────────────────────────
1343
1344    #[test]
1345    fn derive_key_is_deterministic_and_salt_sensitive() {
1346        let a = derive_key("hunter2", b"0123456789abcdef").unwrap();
1347        let b = derive_key("hunter2", b"0123456789abcdef").unwrap();
1348        let c = derive_key("hunter2", b"fedcba9876543210").unwrap();
1349        let d = derive_key("hunter3", b"0123456789abcdef").unwrap();
1350        assert_eq!(a, b, "same password + salt must derive the same key");
1351        assert_ne!(a, c, "different salt must derive a different key");
1352        assert_ne!(a, d, "different password must derive a different key");
1353    }
1354
1355    #[test]
1356    fn salt_is_persisted_and_reused() {
1357        let dir = scratch("salt");
1358        let path = dir.join("vault.salt");
1359        let first = read_or_create_salt(&path).unwrap();
1360        let second = read_or_create_salt(&path).unwrap();
1361        assert_eq!(first, second, "salt must be stable across reads");
1362        assert_eq!(first.len(), SALT_LEN);
1363    }
1364
1365    // ── Entry identity ─────────────────────────────────────────────────────────
1366
1367    #[test]
1368    fn entry_ck_prefers_stable_id() {
1369        let a = json!({ "id": "abc", "provider": "GitHub", "account_name": "x" });
1370        let b = json!({ "id": "abc", "provider": "Renamed", "account_name": "y" });
1371        assert_eq!(entry_ck(&a), entry_ck(&b), "id must dominate other fields");
1372    }
1373
1374    #[test]
1375    fn entry_ck_legacy_distinguishes_key_id() {
1376        // The historic save_vault key ignored key_id and collapsed these two into
1377        // one entry, misattributing version_history between them.
1378        let a = json!({ "provider": "AWS", "account_name": "prod", "key_id": "one" });
1379        let b = json!({ "provider": "AWS", "account_name": "prod", "key_id": "two" });
1380        assert_ne!(entry_ck(&a), entry_ck(&b));
1381    }
1382
1383    #[test]
1384    fn entry_ck_ignores_empty_id() {
1385        let with_empty = json!({ "id": "", "provider": "P" });
1386        let without = json!({ "provider": "P" });
1387        assert_eq!(entry_ck(&with_empty), entry_ck(&without));
1388    }
1389
1390    // ── Vault I/O ──────────────────────────────────────────────────────────────
1391
1392    #[test]
1393    fn save_then_load_roundtrips() {
1394        let (conn, _dir) = open_scratch("roundtrip");
1395        let data = json!({
1396            "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "ghp_aaa" }],
1397            "user_categories": ["dev"],
1398            "projects": [{ "id": "Universal", "name": "Universal" }],
1399        });
1400        save_vault(&conn, data.clone(), SaveCtx::default()).unwrap();
1401        let loaded = load_vault(&conn).unwrap().expect("vault should exist");
1402        assert_eq!(loaded["api_keys"][0]["provider"], "GitHub");
1403        assert_eq!(loaded["user_categories"][0], "dev");
1404    }
1405
1406    #[test]
1407    fn load_returns_none_for_fresh_vault() {
1408        let (conn, _dir) = open_scratch("fresh");
1409        assert!(load_vault(&conn).unwrap().is_none());
1410    }
1411
1412    #[test]
1413    fn changing_a_key_records_previous_value_in_history() {
1414        let (conn, _dir) = open_scratch("history");
1415        save_vault(
1416            &conn,
1417            json!({
1418                "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "old_value" }]
1419            }),
1420            SaveCtx::default(),
1421        )
1422        .unwrap();
1423        save_vault(
1424            &conn,
1425            json!({
1426                "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "new_value" }]
1427            }),
1428            SaveCtx::default(),
1429        )
1430        .unwrap();
1431
1432        let loaded = load_vault(&conn).unwrap().unwrap();
1433        let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1434        assert_eq!(
1435            history.len(),
1436            1,
1437            "one rotation should append one history entry"
1438        );
1439        assert_eq!(history[0]["value"], "old_value");
1440    }
1441
1442    #[test]
1443    fn a_replaced_totp_seed_is_versioned_and_labelled() {
1444        // A re-enrolled authenticator seed is as unrecoverable as a replaced API
1445        // key. Before Phase 22 only `api_key` was snapshot, so swapping a seed
1446        // left no record at all — and the user would find out at a login screen.
1447        let (conn, _dir) = open_scratch("totp-history");
1448        save_vault(
1449            &conn,
1450            json!({
1451                "api_keys": [{
1452                    "id": "1", "provider": "GitHub",
1453                    "api_key": "k1", "totp_secret": "JBSWY3DPEHPK3PXP",
1454                }]
1455            }),
1456            SaveCtx::default(),
1457        )
1458        .unwrap();
1459        save_vault(
1460            &conn,
1461            json!({
1462                "api_keys": [{
1463                    "id": "1", "provider": "GitHub",
1464                    "api_key": "k1", "totp_secret": "MZXW6YTBOI======",
1465                }]
1466            }),
1467            SaveCtx::default(),
1468        )
1469        .unwrap();
1470
1471        let loaded = load_vault(&conn).unwrap().unwrap();
1472        let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1473        assert_eq!(history.len(), 1, "the seed change is one revision");
1474        assert_eq!(history[0]["value"], "JBSWY3DPEHPK3PXP");
1475        // The discriminator is what tells a restore which field it is restoring.
1476        assert_eq!(history[0]["field"], "totp_secret");
1477    }
1478
1479    #[test]
1480    fn an_api_key_revision_still_carries_no_field_discriminator() {
1481        // Absent `field` means `api_key`, and every vault written before Phase 22
1482        // relies on that. Stamping it now would make an older build's history
1483        // viewer show a field name it has never heard of.
1484        let (conn, _dir) = open_scratch("legacy-history-shape");
1485        save_vault(
1486            &conn,
1487            json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v1" }] }),
1488            SaveCtx::default(),
1489        )
1490        .unwrap();
1491        save_vault(
1492            &conn,
1493            json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v2" }] }),
1494            SaveCtx::default(),
1495        )
1496        .unwrap();
1497
1498        let loaded = load_vault(&conn).unwrap().unwrap();
1499        let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1500        assert!(history[0].get("field").is_none(), "{:?}", history[0]);
1501    }
1502
1503    #[test]
1504    fn history_follows_the_id_not_the_provider_name() {
1505        // Renaming an entry must not look like "delete + create", which would
1506        // lose its history. This is exactly what the old provider|account key broke.
1507        let (conn, _dir) = open_scratch("rename");
1508        save_vault(
1509            &conn,
1510            json!({
1511                "api_keys": [{ "id": "1", "provider": "OldName", "api_key": "v1" }]
1512            }),
1513            SaveCtx::default(),
1514        )
1515        .unwrap();
1516        save_vault(
1517            &conn,
1518            json!({
1519                "api_keys": [{ "id": "1", "provider": "NewName", "api_key": "v2" }]
1520            }),
1521            SaveCtx::default(),
1522        )
1523        .unwrap();
1524
1525        let loaded = load_vault(&conn).unwrap().unwrap();
1526        let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1527        assert_eq!(history[0]["value"], "v1", "history must survive a rename");
1528    }
1529
1530    #[test]
1531    fn integrity_hash_matches_after_save() {
1532        let (conn, _dir) = open_scratch("integrity");
1533        save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1534        assert!(verify_vault_integrity(&conn).unwrap());
1535    }
1536
1537    #[test]
1538    fn integrity_check_detects_tampering() {
1539        let (conn, _dir) = open_scratch("tamper");
1540        save_vault(
1541            &conn,
1542            json!({
1543                "api_keys": [{ "id": "1", "provider": "P", "api_key": "k" }]
1544            }),
1545            SaveCtx::default(),
1546        )
1547        .unwrap();
1548        // Rewrite the row behind save_vault's back, leaving the stored hash stale.
1549        conn.execute(
1550            "UPDATE vault_rows SET data = ?1 WHERE kind = 'entry'",
1551            rusqlite::params![r#"{"id":"1","provider":"EVIL","api_key":"k"}"#],
1552        )
1553        .unwrap();
1554        assert!(
1555            !verify_vault_integrity(&conn).unwrap(),
1556            "tampered data must fail the hash check"
1557        );
1558    }
1559
1560    #[test]
1561    fn empty_vault_is_trivially_intact() {
1562        let (conn, _dir) = open_scratch("empty-integrity");
1563        assert!(verify_vault_integrity(&conn).unwrap());
1564    }
1565
1566    // ── Optimistic concurrency ────────────────────────────────────────────────
1567
1568    #[test]
1569    fn version_changes_with_every_write() {
1570        let (conn, _dir) = open_scratch("version");
1571        assert!(
1572            vault_version(&conn).unwrap().is_none(),
1573            "empty vault has no version"
1574        );
1575        let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1576        let v2 = save_vault(
1577            &conn,
1578            json!({
1579                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1580            }),
1581            SaveCtx::default(),
1582        )
1583        .unwrap();
1584        assert_ne!(v1, v2);
1585        assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(v2.as_str()));
1586    }
1587
1588    #[test]
1589    fn returned_version_is_the_stored_version() {
1590        // The value save_vault hands back must be exactly what a later
1591        // compare-and-swap will be checked against, or every write would conflict.
1592        let (conn, _dir) = open_scratch("version-match");
1593        let v = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1594        assert_eq!(vault_version(&conn).unwrap().unwrap(), v);
1595    }
1596
1597    #[test]
1598    fn writing_at_the_expected_version_succeeds() {
1599        let (conn, _dir) = open_scratch("cas-ok");
1600        let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1601        let res = save_vault(
1602            &conn,
1603            json!({
1604                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1605            }),
1606            SaveCtx {
1607                actor: None,
1608                expect_version: Some(&v1),
1609            },
1610        );
1611        assert!(res.is_ok());
1612    }
1613
1614    #[test]
1615    fn writing_at_a_stale_version_is_refused() {
1616        // The lost-update scenario: two writers read v1, one saves, the other
1617        // must not be allowed to overwrite it.
1618        let (conn, _dir) = open_scratch("cas-stale");
1619        let v1 = save_vault(
1620            &conn,
1621            json!({
1622                "api_keys": [{ "id": "1", "provider": "original", "api_key": "k" }]
1623            }),
1624            SaveCtx::default(),
1625        )
1626        .unwrap();
1627
1628        // Writer A lands first.
1629        save_vault(
1630            &conn,
1631            json!({
1632                "api_keys": [{ "id": "1", "provider": "written-by-A", "api_key": "k" }]
1633            }),
1634            SaveCtx {
1635                actor: None,
1636                expect_version: Some(&v1),
1637            },
1638        )
1639        .unwrap();
1640
1641        // Writer B still holds v1.
1642        let err = save_vault(
1643            &conn,
1644            json!({
1645                "api_keys": [{ "id": "1", "provider": "written-by-B", "api_key": "k" }]
1646            }),
1647            SaveCtx {
1648                actor: None,
1649                expect_version: Some(&v1),
1650            },
1651        )
1652        .expect_err("a stale write must be refused");
1653        assert!(
1654            err.starts_with(CONFLICT_ERR),
1655            "callers match on this prefix, got: {err}"
1656        );
1657
1658        // A's data survived intact.
1659        let stored = load_vault(&conn).unwrap().unwrap();
1660        assert_eq!(stored["api_keys"][0]["provider"], "written-by-A");
1661    }
1662
1663    #[test]
1664    fn a_refused_write_leaves_no_trace() {
1665        // The audit appends used to run before the transaction, so a rejected
1666        // write still logged changes that never happened.
1667        let (conn, _dir) = open_scratch("cas-clean");
1668        let v1 = save_vault(
1669            &conn,
1670            json!({
1671                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1672            }),
1673            SaveCtx::default(),
1674        )
1675        .unwrap();
1676        // Another writer edits the same entry.
1677        save_vault(
1678            &conn,
1679            json!({
1680                "api_keys": [{ "id": "1", "provider": "A-theirs", "api_key": "k" }]
1681            }),
1682            SaveCtx::default(),
1683        )
1684        .unwrap();
1685
1686        let audit_before = load_audit(&conn).unwrap().len();
1687        let version_before = vault_version(&conn).unwrap();
1688
1689        let err = save_vault(
1690            &conn,
1691            json!({
1692                "api_keys": [
1693                    { "id": "1", "provider": "A-mine", "api_key": "k" },
1694                    { "id": "9", "provider": "GHOST", "api_key": "k" }
1695                ]
1696            }),
1697            SaveCtx {
1698                actor: None,
1699                expect_version: Some(&v1),
1700            },
1701        )
1702        .expect_err("both sides edited entry 1");
1703        assert!(err.starts_with(CONFLICT_ERR), "{err}");
1704        assert!(
1705            err.contains("A-mine"),
1706            "the conflict names the entry: {err}"
1707        );
1708
1709        assert_eq!(
1710            load_audit(&conn).unwrap().len(),
1711            audit_before,
1712            "a refused write must not append audit rows"
1713        );
1714        assert_eq!(vault_version(&conn).unwrap(), version_before);
1715        assert!(!load_audit(&conn)
1716            .unwrap()
1717            .iter()
1718            .any(|r| r.entry_provider.as_deref() == Some("GHOST")));
1719        let stored = load_vault(&conn).unwrap().unwrap();
1720        assert_eq!(stored["api_keys"].as_array().unwrap().len(), 1);
1721        assert_eq!(stored["api_keys"][0]["provider"], "A-theirs");
1722    }
1723
1724    #[test]
1725    fn expecting_a_version_against_an_empty_vault_is_refused() {
1726        let (conn, _dir) = open_scratch("cas-empty");
1727        let err = save_vault(
1728            &conn,
1729            json!({ "api_keys": [] }),
1730            SaveCtx {
1731                actor: None,
1732                expect_version: Some("deadbeef"),
1733            },
1734        )
1735        .expect_err("nothing is stored, so no version can match");
1736        assert!(err.starts_with(CONFLICT_ERR));
1737    }
1738
1739    #[test]
1740    fn omitting_the_version_writes_unconditionally() {
1741        // The explicit escape hatch, used when the user chooses to overwrite.
1742        let (conn, _dir) = open_scratch("cas-force");
1743        save_vault(
1744            &conn,
1745            json!({
1746                "api_keys": [{ "id": "1", "provider": "first", "api_key": "k" }]
1747            }),
1748            SaveCtx::default(),
1749        )
1750        .unwrap();
1751        save_vault(
1752            &conn,
1753            json!({
1754                "api_keys": [{ "id": "1", "provider": "forced", "api_key": "k" }]
1755            }),
1756            SaveCtx::default(),
1757        )
1758        .unwrap();
1759        let stored = load_vault(&conn).unwrap().unwrap();
1760        assert_eq!(stored["api_keys"][0]["provider"], "forced");
1761    }
1762
1763    #[test]
1764    fn integrity_still_holds_after_a_refused_write() {
1765        let (conn, _dir) = open_scratch("cas-integrity");
1766        let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1767        save_vault(
1768            &conn,
1769            json!({
1770                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1771            }),
1772            SaveCtx::default(),
1773        )
1774        .unwrap();
1775        let _ = save_vault(
1776            &conn,
1777            json!({ "api_keys": [] }),
1778            SaveCtx {
1779                actor: None,
1780                expect_version: Some(&v1),
1781            },
1782        );
1783        assert!(
1784            verify_vault_integrity(&conn).unwrap(),
1785            "a rolled-back write must not desync data from its hash"
1786        );
1787    }
1788
1789    // ── Audit chain ────────────────────────────────────────────────────────────
1790
1791    #[test]
1792    fn audit_rows_form_a_hash_chain() {
1793        let (conn, _dir) = open_scratch("audit");
1794        save_vault(
1795            &conn,
1796            json!({
1797                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1798            }),
1799            SaveCtx::default(),
1800        )
1801        .unwrap();
1802        save_vault(
1803            &conn,
1804            json!({
1805                "api_keys": [
1806                    { "id": "1", "provider": "A", "api_key": "k" },
1807                    { "id": "2", "provider": "B", "api_key": "k2" }
1808                ]
1809            }),
1810            SaveCtx::default(),
1811        )
1812        .unwrap();
1813
1814        let mut rows = load_audit(&conn).unwrap();
1815        assert!(rows.len() >= 2, "expected an audit row per added entry");
1816        rows.sort_by_key(|r| r.id); // load_audit returns newest-first
1817        assert!(rows[0].entry_hash.is_some());
1818        // Each row must link to its predecessor.
1819        for pair in rows.windows(2) {
1820            assert_eq!(
1821                pair[1].prev_hash, pair[0].entry_hash,
1822                "row {} must chain to row {}",
1823                pair[1].id, pair[0].id
1824            );
1825        }
1826    }
1827
1828    #[test]
1829    fn deleting_an_entry_is_audited() {
1830        let (conn, _dir) = open_scratch("audit-delete");
1831        save_vault(
1832            &conn,
1833            json!({
1834                "api_keys": [{ "id": "1", "provider": "Doomed", "api_key": "k" }]
1835            }),
1836            SaveCtx::default(),
1837        )
1838        .unwrap();
1839        save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1840        let rows = load_audit(&conn).unwrap();
1841        assert!(rows
1842            .iter()
1843            .any(|r| r.action == "delete" && r.entry_provider.as_deref() == Some("Doomed")));
1844    }
1845
1846    #[test]
1847    fn audit_rows_record_the_acting_user() {
1848        let (conn, _dir) = open_scratch("audit-actor");
1849        save_vault(
1850            &conn,
1851            json!({
1852                "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1853            }),
1854            SaveCtx {
1855                actor: Some("user-123"),
1856                ..Default::default()
1857            },
1858        )
1859        .unwrap();
1860        let rows = load_audit(&conn).unwrap();
1861        let add = rows.iter().find(|r| r.action == "add").unwrap();
1862        assert_eq!(add.actor.as_deref(), Some("user-123"));
1863    }
1864
1865    #[test]
1866    fn actor_is_bound_into_the_hash_chain() {
1867        // Rewriting who did something must invalidate the row hash, otherwise
1868        // attribution would be forgeable while the chain still "verified".
1869        let with = compute_audit_hash("add", "P", "T", Some("alice"), "prev");
1870        let other = compute_audit_hash("add", "P", "T", Some("bob"), "prev");
1871        let without = compute_audit_hash("add", "P", "T", None, "prev");
1872        assert_ne!(with, other, "different actor must give a different hash");
1873        assert_ne!(with, without);
1874    }
1875
1876    #[test]
1877    fn actorless_rows_keep_the_v1_hash_format() {
1878        // Existing chains were written before the actor column; their hashes
1879        // must still reproduce or every old log would read as tampered.
1880        use sha2::{Digest, Sha256};
1881        let mut h = Sha256::new();
1882        for part in ["add", "|", "P", "|", "T", "|", "prev"] {
1883            h.update(part.as_bytes());
1884        }
1885        assert_eq!(
1886            compute_audit_hash("add", "P", "T", None, "prev"),
1887            hex::encode(h.finalize())
1888        );
1889    }
1890
1891    // ── Expiry ─────────────────────────────────────────────────────────────────
1892
1893    #[test]
1894    fn expiring_selects_only_the_window() {
1895        let now = time::OffsetDateTime::now_utc();
1896        let fmt = |d: i64| {
1897            let t = now + time::Duration::days(d);
1898            format!("{:04}-{:02}-{:02}", t.year(), t.month() as u8, t.day())
1899        };
1900        let data = json!({ "api_keys": [
1901            { "provider": "expired",  "expires_at": fmt(-5)  },
1902            { "provider": "soon",     "expires_at": fmt(3)   },
1903            { "provider": "far",      "expires_at": fmt(365) },
1904            { "provider": "no-expiry" },
1905        ]});
1906        let found = expiring_from_value(&data, 30);
1907        let names: Vec<&str> = found
1908            .iter()
1909            .map(|e| e["provider"].as_str().unwrap())
1910            .collect();
1911        assert_eq!(
1912            names,
1913            vec!["soon"],
1914            "already-expired, far-future and never-expiring entries are all excluded"
1915        );
1916    }
1917}
1918
1919#[cfg(test)]
1920mod salt_pairing_tests {
1921    use super::*;
1922
1923    fn tmp(name: &str) -> std::path::PathBuf {
1924        let d = std::env::temp_dir().join(format!("unv-salt-{name}-{}", std::process::id()));
1925        let _ = fs::remove_dir_all(&d);
1926        fs::create_dir_all(&d).unwrap();
1927        d
1928    }
1929
1930    /// The bug: a database whose salt vanished was silently given a new one, and
1931    /// every unlock then reported "Wrong master password" for a correct password.
1932    /// By the time anyone looked, the missing file had already been replaced.
1933    #[test]
1934    fn a_database_without_its_salt_is_refused_not_re_salted() {
1935        let d = tmp("orphan");
1936        let db = d.join("vault.db");
1937        let salt = d.join("vault.salt");
1938        fs::write(&db, b"pretend this is a SQLCipher file").unwrap();
1939
1940        let err = check_salt_pairing(&db, &salt).unwrap_err();
1941        assert!(err.contains("is missing"), "{err}");
1942        assert!(
1943            err.contains("nothing can recompute it"),
1944            "the message must not imply recovery is possible: {err}"
1945        );
1946        assert!(!salt.exists(), "the check must not create a salt");
1947        let _ = fs::remove_dir_all(&d);
1948    }
1949
1950    /// A first run has neither file, and must be allowed to create both.
1951    #[test]
1952    fn a_fresh_directory_is_fine() {
1953        let d = tmp("fresh");
1954        assert!(check_salt_pairing(&d.join("vault.db"), &d.join("vault.salt")).is_ok());
1955        let _ = fs::remove_dir_all(&d);
1956    }
1957
1958    /// An empty database file is a first run that got interrupted, not a vault.
1959    #[test]
1960    fn an_empty_database_file_is_not_treated_as_a_vault() {
1961        let d = tmp("empty");
1962        let db = d.join("vault.db");
1963        fs::write(&db, b"").unwrap();
1964        assert!(check_salt_pairing(&db, &d.join("vault.salt")).is_ok());
1965        let _ = fs::remove_dir_all(&d);
1966    }
1967
1968    /// New salts are owner-only. They were 0644 until `unv doctor` said so.
1969    #[test]
1970    #[cfg(unix)]
1971    fn a_generated_salt_is_owner_only() {
1972        use std::os::unix::fs::PermissionsExt;
1973        let d = tmp("mode");
1974        let salt = d.join("vault.salt");
1975        read_or_create_salt(&salt).unwrap();
1976        let mode = fs::metadata(&salt).unwrap().permissions().mode() & 0o777;
1977        assert_eq!(mode, 0o600, "salt was created world-readable");
1978        let _ = fs::remove_dir_all(&d);
1979    }
1980
1981    /// And so is a newly created database.
1982    #[test]
1983    #[cfg(unix)]
1984    fn a_created_database_is_owner_only() {
1985        use std::os::unix::fs::PermissionsExt;
1986        let d = tmp("dbmode");
1987        let db = d.join("vault.db");
1988        let key = derive_key(
1989            "correct-horse-battery",
1990            &read_or_create_salt(&d.join("vault.salt")).unwrap(),
1991        )
1992        .unwrap();
1993        let conn = open_db(&db, &key).unwrap();
1994        drop(conn);
1995        let mode = fs::metadata(&db).unwrap().permissions().mode() & 0o777;
1996        assert_eq!(mode, 0o600, "database was created world-readable");
1997        let _ = fs::remove_dir_all(&d);
1998    }
1999}
2000
2001/// True for a value safe to write bare in a `.env`. Deliberately narrow.
2002fn env_bare_ok(v: &str) -> bool {
2003    !v.is_empty()
2004        && v.chars()
2005            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | ':' | '@' | '-'))
2006}
2007
2008/// A value as it must appear after the `=` in a `.env` (Phase 23, E1).
2009///
2010/// The empty string quotes to `""` rather than to nothing, because a bare `KEY=`
2011/// is how "unset" is spelled and a deliberately empty value must not read as
2012/// one. A newline is escaped rather than emitted, so the parser's backslash
2013/// line-continuation can never see one. Twin of `quoteEnvValue` in
2014/// `src/ts/state.ts`, pinned by `parity/env-names.json`.
2015pub fn env_quote(value: &str) -> String {
2016    if env_bare_ok(value) {
2017        return value.to_string();
2018    }
2019    let mut out = String::with_capacity(value.len() + 2);
2020    out.push('"');
2021    for ch in value.chars() {
2022        match ch {
2023            '\\' | '"' | '$' | '`' => {
2024                out.push('\\');
2025                out.push(ch);
2026            }
2027            '\n' => out.push_str("\\n"),
2028            '\r' => out.push_str("\\r"),
2029            '\t' => out.push_str("\\t"),
2030            _ => out.push(ch),
2031        }
2032    }
2033    out.push('"');
2034    out
2035}