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