Skip to main content

murk_cli/
lib.rs

1//! Encrypted secrets manager for developers — one file, age encryption, git-friendly.
2//!
3//! This library provides the core functionality for murk: vault I/O, age encryption,
4//! BIP39 key recovery, and secret management. The CLI binary wraps this library.
5
6#![warn(clippy::pedantic)]
7#![allow(
8    clippy::doc_markdown,
9    clippy::cast_possible_wrap,
10    clippy::missing_errors_doc,
11    clippy::missing_panics_doc,
12    clippy::must_use_candidate,
13    clippy::similar_names,
14    clippy::unreadable_literal,
15    clippy::too_many_arguments,
16    clippy::implicit_hasher
17)]
18
19// Domain modules — pub(crate) unless main.rs needs direct path access.
20pub(crate) mod codename;
21pub mod crypto;
22pub mod edit;
23pub(crate) mod env;
24pub mod error;
25pub(crate) mod export;
26pub(crate) mod git;
27pub mod github;
28pub(crate) mod info;
29pub(crate) mod init;
30pub(crate) mod merge;
31pub(crate) mod recipients;
32pub mod recovery;
33pub mod scan;
34pub(crate) mod secrets;
35pub mod types;
36pub mod vault;
37
38#[cfg(feature = "python")]
39mod python;
40
41// Shared test utilities
42#[cfg(test)]
43pub mod testutil;
44
45// Re-exports: keep the flat murk_cli::foo() API for main.rs
46pub use env::{
47    EnvrcStatus, KeySource, dotenv_has_murk_key, key_file_path, parse_env, read_key_from_dotenv,
48    resolve_key, resolve_key_for_vault, resolve_key_with_source, warn_env_permissions, write_envrc,
49    write_key_ref_to_dotenv, write_key_to_dotenv, write_key_to_file,
50};
51pub use error::MurkError;
52pub use export::{
53    DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
54    parse_and_decrypt_values, resolve_secrets,
55};
56pub use git::{MergeDriverSetupStep, setup_merge_driver};
57pub use github::{GitHubError, fetch_keys};
58pub use info::{InfoEntry, VaultInfo, format_info_lines, vault_info};
59pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
60pub use merge::{MergeDriverOutput, run_merge_driver};
61pub use recipients::{
62    RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
63    list_recipients, revoke_recipient, truncate_pubkey,
64};
65pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
66
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::Path;
69
70/// Check whether a key name is a valid shell identifier (safe for `export KEY=...`).
71/// Must start with a letter or underscore, and contain only `[A-Za-z0-9_]`.
72pub fn is_valid_key_name(key: &str) -> bool {
73    !key.is_empty()
74        && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
75        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
76}
77
78use age::secrecy::ExposeSecret;
79use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
80
81// Re-export polymorphic types for consumers.
82pub use crypto::{MurkIdentity, MurkRecipient};
83
84/// Decrypt the meta blob from a vault, returning the deserialized Meta if possible.
85pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
86    if vault.meta.is_empty() {
87        return None;
88    }
89    let plaintext = decrypt_value(&vault.meta, identity).ok()?;
90    serde_json::from_slice(&plaintext).ok()
91}
92
93/// Parse a list of pubkey strings into recipients (age or SSH).
94pub(crate) fn parse_recipients(
95    pubkeys: &[String],
96) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
97    pubkeys
98        .iter()
99        .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
100        .collect()
101}
102
103/// Encrypt a value and return base64-encoded ciphertext.
104pub fn encrypt_value(
105    plaintext: &[u8],
106    recipients: &[crypto::MurkRecipient],
107) -> Result<String, MurkError> {
108    let ciphertext = crypto::encrypt(plaintext, recipients)?;
109    Ok(BASE64.encode(&ciphertext))
110}
111
112/// Decrypt a base64-encoded ciphertext and return plaintext bytes.
113pub fn decrypt_value(encoded: &str, identity: &crypto::MurkIdentity) -> Result<Vec<u8>, MurkError> {
114    let ciphertext = BASE64.decode(encoded).map_err(|e| {
115        MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
116    })?;
117    Ok(crypto::decrypt(&ciphertext, identity)?)
118}
119
120/// Read a vault file from disk.
121///
122/// This is a thin wrapper around `vault::read` for a convenient string-path API.
123pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
124    Ok(vault::read(Path::new(vault_path))?)
125}
126
127/// Resolve a vault path argument, walking up parent directories to discover the vault.
128///
129/// Mirrors how git finds `.git` and cargo finds `Cargo.toml`: if the user passed a bare
130/// filename (no path separator, not absolute) and it does not exist in the current
131/// directory, walk up from CWD looking for a file of that name. Stops at:
132///
133/// - a directory containing `.git` (the git root — don't escape the repo)
134/// - `$HOME` (don't traverse into parents of the user's home)
135/// - the filesystem root
136///
137/// If a match is found, returns the absolute path. Otherwise returns the input unchanged,
138/// so downstream error messages still reference what the user asked for.
139///
140/// Explicit paths (absolute, or containing `/` or `\`) are returned unchanged — the user
141/// told us exactly where to look, so don't second-guess them.
142pub fn resolve_vault_path(arg: &str) -> String {
143    use std::path::PathBuf;
144
145    // Explicit path: no traversal.
146    if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
147        return arg.to_string();
148    }
149
150    let Ok(cwd) = std::env::current_dir() else {
151        return arg.to_string();
152    };
153
154    // Found in CWD — nothing to discover.
155    if cwd.join(arg).exists() {
156        return arg.to_string();
157    }
158
159    let home = std::env::var_os("HOME").map(PathBuf::from);
160    let mut dir = cwd.as_path();
161    loop {
162        let candidate = dir.join(arg);
163        if candidate.exists() {
164            return candidate.to_string_lossy().into_owned();
165        }
166        // Stop at git root after checking this directory.
167        if dir.join(".git").exists() {
168            break;
169        }
170        // Stop at $HOME boundary (don't traverse above the user's home).
171        if let Some(ref h) = home
172            && dir == h.as_path()
173        {
174            break;
175        }
176        match dir.parent() {
177            Some(parent) => dir = parent,
178            None => break,
179        }
180    }
181
182    arg.to_string()
183}
184
185/// Decrypt a vault using the given identity. Verifies integrity, decrypts all
186/// shared and scoped values, and returns the working state.
187///
188/// Use this when you already have a key (e.g. from a Python SDK or test harness).
189/// For the common CLI case where the key comes from the environment, use `load_vault`.
190pub fn decrypt_vault(
191    vault: &types::Vault,
192    identity: &crypto::MurkIdentity,
193) -> Result<types::Murk, MurkError> {
194    let pubkey = identity.pubkey_string()?;
195
196    // Verify integrity BEFORE decrypting secrets — a tampered vault should fail
197    // with an integrity error, not a misleading "you are not a recipient" message.
198    let (recipients, legacy_mac, github_pins) = match decrypt_meta(vault, identity) {
199        Some(meta) if !meta.mac.is_empty() => {
200            let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
201            if !verify_mac(vault, &meta.mac, mac_key.as_ref()) {
202                let expected = compute_mac(vault, mac_key.as_ref());
203                return Err(MurkError::Integrity(format!(
204                    "vault may have been tampered with (expected {expected}, got {})",
205                    meta.mac
206                )));
207            }
208            let legacy = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
209            (meta.recipients, legacy, meta.github_pins)
210        }
211        Some(meta) if vault.secrets.is_empty() => (meta.recipients, false, meta.github_pins),
212        Some(_) => {
213            return Err(MurkError::Integrity(
214                "vault has secrets but MAC is empty — vault may have been tampered with".into(),
215            ));
216        }
217        None if vault.secrets.is_empty() && vault.meta.is_empty() => {
218            (HashMap::new(), false, HashMap::new())
219        }
220        None => {
221            return Err(MurkError::Integrity(
222                "vault has secrets but no meta — vault may have been tampered with".into(),
223            ));
224        }
225    };
226
227    // Decrypt shared values (skip scoped-only entries with empty shared ciphertext).
228    let mut values = HashMap::new();
229    for (key, entry) in &vault.secrets {
230        if entry.shared.is_empty() {
231            continue;
232        }
233        let plaintext = decrypt_value(&entry.shared, identity).map_err(|_| {
234            MurkError::Crypto(crypto::CryptoError::Decrypt(
235                "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into()
236            ))
237        })?;
238        let value = String::from_utf8(plaintext)
239            .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
240        values.insert(key.clone(), value);
241    }
242
243    // Decrypt our scoped (mote) overrides.
244    let mut scoped = HashMap::new();
245    for (key, entry) in &vault.secrets {
246        if let Some(encoded) = entry.scoped.get(&pubkey)
247            && let Ok(value) = decrypt_value(encoded, identity)
248                .and_then(|pt| String::from_utf8(pt).map_err(|e| MurkError::Secret(e.to_string())))
249        {
250            scoped
251                .entry(key.clone())
252                .or_insert_with(HashMap::new)
253                .insert(pubkey.clone(), value);
254        }
255    }
256
257    Ok(types::Murk {
258        values,
259        recipients,
260        scoped,
261        legacy_mac,
262        github_pins,
263    })
264}
265
266/// Resolve the key from the environment, read the vault, and decrypt it.
267///
268/// Convenience wrapper combining `resolve_key` + `read_vault` + `decrypt_vault`.
269pub fn load_vault(
270    vault_path: &str,
271) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
272    let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
273
274    let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
275        MurkError::Key(format!(
276            "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
277        ))
278    })?;
279
280    let vault = read_vault(vault_path)?;
281    let murk = decrypt_vault(&vault, &identity)?;
282
283    Ok((vault, murk, identity))
284}
285
286/// Save the vault: compare against original state and only re-encrypt changed values.
287/// Unchanged values keep their original ciphertext for minimal git diffs.
288pub fn save_vault(
289    vault_path: &str,
290    vault: &mut types::Vault,
291    original: &types::Murk,
292    current: &types::Murk,
293) -> Result<(), MurkError> {
294    let recipients = parse_recipients(&vault.recipients)?;
295
296    // Check if recipient list changed — forces full re-encryption of shared values.
297    let recipients_changed = {
298        let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
299        let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
300        current_pks.sort_unstable();
301        original_pks.sort_unstable();
302        current_pks != original_pks
303    };
304
305    let mut new_secrets = BTreeMap::new();
306
307    // Collect all keys with a shared or scoped value.
308    let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
309    all_keys.extend(current.scoped.keys());
310
311    for key in all_keys {
312        let shared = if let Some(value) = current.values.get(key) {
313            if !recipients_changed && original.values.get(key) == Some(value) {
314                if let Some(existing) = vault.secrets.get(key) {
315                    existing.shared.clone()
316                } else {
317                    encrypt_value(value.as_bytes(), &recipients)?
318                }
319            } else {
320                encrypt_value(value.as_bytes(), &recipients)?
321            }
322        } else {
323            // Scoped-only key — no shared ciphertext.
324            String::new()
325        };
326
327        let mut scoped = vault
328            .secrets
329            .get(key)
330            .map(|e| e.scoped.clone())
331            .unwrap_or_default();
332
333        if let Some(key_scoped) = current.scoped.get(key) {
334            for (pk, val) in key_scoped {
335                let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
336                if original_val == Some(val) {
337                    // Unchanged — keep original ciphertext.
338                } else {
339                    let recipient = crypto::parse_recipient(pk)?;
340                    scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
341                }
342            }
343        }
344
345        if let Some(orig_key_scoped) = original.scoped.get(key) {
346            for pk in orig_key_scoped.keys() {
347                let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
348                if !still_present {
349                    scoped.remove(pk);
350                }
351            }
352        }
353
354        new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
355    }
356
357    vault.secrets = new_secrets;
358
359    // Update meta — always generate a fresh BLAKE3 key on save.
360    let mac_key_hex = generate_mac_key();
361    let mac_key = decode_mac_key(&mac_key_hex).unwrap();
362    let mac = compute_mac(vault, Some(&mac_key));
363    let meta = types::Meta {
364        recipients: current.recipients.clone(),
365        mac,
366        mac_key: Some(mac_key_hex),
367        github_pins: current.github_pins.clone(),
368    };
369    let meta_json =
370        serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
371    vault.meta = encrypt_value(&meta_json, &recipients)?;
372
373    Ok(vault::write(Path::new(vault_path), vault)?)
374}
375
376/// Compute an integrity MAC over the vault's secrets, scoped entries, recipients, and schema.
377///
378/// If an HMAC key is provided, uses BLAKE3 keyed hash v4 (written as `blake3v2:`),
379/// which includes schema in the MAC input. Otherwise falls back to unkeyed SHA-256 v2
380/// for legacy compatibility.
381pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String {
382    match mac_key {
383        Some(key) => compute_mac_v4(vault, key),
384        None => compute_mac_v2(vault),
385    }
386}
387
388/// Legacy MAC: covers key names, shared ciphertext, and recipients (no scoped).
389fn compute_mac_v1(vault: &types::Vault) -> String {
390    use sha2::{Digest, Sha256};
391
392    let mut hasher = Sha256::new();
393
394    for key in vault.secrets.keys() {
395        hasher.update(key.as_bytes());
396        hasher.update(b"\x00");
397    }
398
399    for entry in vault.secrets.values() {
400        hasher.update(entry.shared.as_bytes());
401        hasher.update(b"\x00");
402    }
403
404    let mut pks = vault.recipients.clone();
405    pks.sort();
406    for pk in &pks {
407        hasher.update(pk.as_bytes());
408        hasher.update(b"\x00");
409    }
410
411    let digest = hasher.finalize();
412    format!(
413        "sha256:{}",
414        digest.iter().fold(String::new(), |mut s, b| {
415            use std::fmt::Write;
416            let _ = write!(s, "{b:02x}");
417            s
418        })
419    )
420}
421
422/// V2 MAC: covers key names, shared ciphertext, scoped entries, and recipients.
423fn compute_mac_v2(vault: &types::Vault) -> String {
424    use sha2::{Digest, Sha256};
425
426    let mut hasher = Sha256::new();
427
428    // Hash sorted key names.
429    for key in vault.secrets.keys() {
430        hasher.update(key.as_bytes());
431        hasher.update(b"\x00");
432    }
433
434    // Hash encrypted shared values (as stored).
435    for entry in vault.secrets.values() {
436        hasher.update(entry.shared.as_bytes());
437        hasher.update(b"\x00");
438
439        // Hash scoped entries (sorted by pubkey for determinism).
440        let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
441        scoped_pks.sort();
442        for pk in scoped_pks {
443            hasher.update(pk.as_bytes());
444            hasher.update(b"\x01");
445            hasher.update(entry.scoped[pk].as_bytes());
446            hasher.update(b"\x00");
447        }
448    }
449
450    // Hash sorted recipient pubkeys.
451    let mut pks = vault.recipients.clone();
452    pks.sort();
453    for pk in &pks {
454        hasher.update(pk.as_bytes());
455        hasher.update(b"\x00");
456    }
457
458    let digest = hasher.finalize();
459    format!(
460        "sha256v2:{}",
461        digest.iter().fold(String::new(), |mut s, b| {
462            use std::fmt::Write;
463            let _ = write!(s, "{b:02x}");
464            s
465        })
466    )
467}
468
469/// V3 MAC: BLAKE3 keyed hash over the same inputs as v2.
470fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
471    let mut data = Vec::new();
472
473    for key_name in vault.secrets.keys() {
474        data.extend_from_slice(key_name.as_bytes());
475        data.push(0x00);
476    }
477
478    for entry in vault.secrets.values() {
479        data.extend_from_slice(entry.shared.as_bytes());
480        data.push(0x00);
481
482        let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
483        scoped_pks.sort();
484        for pk in scoped_pks {
485            data.extend_from_slice(pk.as_bytes());
486            data.push(0x01);
487            data.extend_from_slice(entry.scoped[pk].as_bytes());
488            data.push(0x00);
489        }
490    }
491
492    let mut pks = vault.recipients.clone();
493    pks.sort();
494    for pk in &pks {
495        data.extend_from_slice(pk.as_bytes());
496        data.push(0x00);
497    }
498
499    let hash = blake3::keyed_hash(key, &data);
500    format!("blake3:{hash}")
501}
502
503/// V4 MAC: BLAKE3 keyed hash over secrets, recipients, AND schema.
504/// Prefix `blake3v2:` distinguishes from v3 which omitted schema.
505fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
506    let mut data = Vec::new();
507
508    for key_name in vault.secrets.keys() {
509        data.extend_from_slice(key_name.as_bytes());
510        data.push(0x00);
511    }
512
513    for entry in vault.secrets.values() {
514        data.extend_from_slice(entry.shared.as_bytes());
515        data.push(0x00);
516
517        let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
518        scoped_pks.sort();
519        for pk in scoped_pks {
520            data.extend_from_slice(pk.as_bytes());
521            data.push(0x01);
522            data.extend_from_slice(entry.scoped[pk].as_bytes());
523            data.push(0x00);
524        }
525    }
526
527    let mut pks = vault.recipients.clone();
528    pks.sort();
529    for pk in &pks {
530        data.extend_from_slice(pk.as_bytes());
531        data.push(0x00);
532    }
533
534    // Schema: include descriptions, examples, and tags for each key.
535    // Uses 0x02 separator to distinguish from secrets/recipients data.
536    for (key_name, entry) in &vault.schema {
537        data.push(0x02);
538        data.extend_from_slice(key_name.as_bytes());
539        data.push(0x00);
540        data.extend_from_slice(entry.description.as_bytes());
541        data.push(0x00);
542        if let Some(example) = &entry.example {
543            data.extend_from_slice(example.as_bytes());
544        }
545        data.push(0x00);
546        for tag in &entry.tags {
547            data.extend_from_slice(tag.as_bytes());
548            data.push(0x00);
549        }
550    }
551
552    let hash = blake3::keyed_hash(key, &data);
553    format!("blake3v2:{hash}")
554}
555
556/// Verify a stored MAC against the vault, accepting v1, v2, blake3, and blake3v2 schemes.
557pub(crate) fn verify_mac(
558    vault: &types::Vault,
559    stored_mac: &str,
560    mac_key: Option<&[u8; 32]>,
561) -> bool {
562    use constant_time_eq::constant_time_eq;
563
564    let expected = if stored_mac.starts_with("blake3v2:") {
565        match mac_key {
566            Some(key) => compute_mac_v4(vault, key),
567            None => return false,
568        }
569    } else if stored_mac.starts_with("blake3:") {
570        match mac_key {
571            Some(key) => compute_mac_v3(vault, key),
572            None => return false,
573        }
574    } else if stored_mac.starts_with("sha256v2:") {
575        compute_mac_v2(vault)
576    } else if stored_mac.starts_with("sha256:") {
577        compute_mac_v1(vault)
578    } else {
579        return false;
580    };
581    constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
582}
583
584/// Generate a random 32-byte BLAKE3 MAC key, returned as hex.
585pub(crate) fn generate_mac_key() -> String {
586    let key: [u8; 32] = rand::random();
587    key.iter().fold(String::new(), |mut s, b| {
588        use std::fmt::Write;
589        let _ = write!(s, "{b:02x}");
590        s
591    })
592}
593
594/// Decode a hex-encoded 32-byte key.
595pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
596    if hex.len() != 64 {
597        return None;
598    }
599    let mut key = [0u8; 32];
600    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
601        key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
602    }
603    Some(key)
604}
605
606/// Generate an ISO-8601 UTC timestamp.
607pub(crate) fn now_utc() -> String {
608    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::testutil::*;
615    use std::collections::BTreeMap;
616    use std::fs;
617
618    use crate::testutil::ENV_LOCK;
619
620    #[test]
621    fn resolve_vault_path_finds_in_parent_dir() {
622        let _lock = ENV_LOCK
623            .lock()
624            .unwrap_or_else(std::sync::PoisonError::into_inner);
625        let dir = tempfile::tempdir().unwrap();
626        // Create a fake git repo with a vault at the root and a nested subdir.
627        fs::create_dir(dir.path().join(".git")).unwrap();
628        fs::write(dir.path().join(".murk"), "{}").unwrap();
629        let nested = dir.path().join("a").join("b");
630        fs::create_dir_all(&nested).unwrap();
631
632        let prev = std::env::current_dir().unwrap();
633        std::env::set_current_dir(&nested).unwrap();
634        let got = resolve_vault_path(".murk");
635        std::env::set_current_dir(prev).unwrap();
636
637        assert_eq!(
638            std::fs::canonicalize(&got).unwrap(),
639            std::fs::canonicalize(dir.path().join(".murk")).unwrap()
640        );
641    }
642
643    #[test]
644    fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
645        let _lock = ENV_LOCK
646            .lock()
647            .unwrap_or_else(std::sync::PoisonError::into_inner);
648        let dir = tempfile::tempdir().unwrap();
649        fs::write(dir.path().join(".murk"), "{}").unwrap();
650        let prev = std::env::current_dir().unwrap();
651        std::env::set_current_dir(dir.path()).unwrap();
652        let got = resolve_vault_path(".murk");
653        std::env::set_current_dir(prev).unwrap();
654        assert_eq!(got, ".murk");
655    }
656
657    #[test]
658    fn resolve_vault_path_passes_through_explicit_paths() {
659        assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
660        assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
661        assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
662    }
663
664    #[test]
665    fn resolve_vault_path_stops_at_git_root() {
666        let _lock = ENV_LOCK
667            .lock()
668            .unwrap_or_else(std::sync::PoisonError::into_inner);
669        let dir = tempfile::tempdir().unwrap();
670        // Vault lives OUTSIDE the git repo; traversal should not find it.
671        fs::write(dir.path().join(".murk"), "{}").unwrap();
672        let repo = dir.path().join("repo");
673        fs::create_dir(&repo).unwrap();
674        fs::create_dir(repo.join(".git")).unwrap();
675        let nested = repo.join("sub");
676        fs::create_dir(&nested).unwrap();
677
678        let prev = std::env::current_dir().unwrap();
679        std::env::set_current_dir(&nested).unwrap();
680        let got = resolve_vault_path(".murk");
681        std::env::set_current_dir(prev).unwrap();
682
683        // Unchanged — we stopped at the git root and never saw the outer vault.
684        assert_eq!(got, ".murk");
685    }
686
687    #[test]
688    fn encrypt_decrypt_value_roundtrip() {
689        let (secret, pubkey) = generate_keypair();
690        let recipient = make_recipient(&pubkey);
691        let identity = make_identity(&secret);
692
693        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
694        let decrypted = decrypt_value(&encoded, &identity).unwrap();
695        assert_eq!(decrypted, b"hello world");
696    }
697
698    #[test]
699    fn decrypt_value_invalid_base64() {
700        let (secret, _) = generate_keypair();
701        let identity = make_identity(&secret);
702
703        let result = decrypt_value("not!valid!base64!!!", &identity);
704        assert!(result.is_err());
705        assert!(result.unwrap_err().to_string().contains("invalid base64"));
706    }
707
708    #[test]
709    fn encrypt_value_multiple_recipients() {
710        let (secret_a, pubkey_a) = generate_keypair();
711        let (secret_b, pubkey_b) = generate_keypair();
712
713        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
714        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
715
716        // Both can decrypt.
717        let id_a = make_identity(&secret_a);
718        let id_b = make_identity(&secret_b);
719        assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
720        assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
721    }
722
723    #[test]
724    fn decrypt_value_wrong_key_fails() {
725        let (_, pubkey) = generate_keypair();
726        let (wrong_secret, _) = generate_keypair();
727
728        let recipient = make_recipient(&pubkey);
729        let wrong_identity = make_identity(&wrong_secret);
730
731        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
732        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
733    }
734
735    #[test]
736    fn compute_mac_deterministic() {
737        let vault = types::Vault {
738            version: types::VAULT_VERSION.into(),
739            created: "2026-02-28T00:00:00Z".into(),
740            vault_name: ".murk".into(),
741            repo: String::new(),
742            recipients: vec!["age1abc".into()],
743            schema: BTreeMap::new(),
744            secrets: BTreeMap::new(),
745            meta: String::new(),
746        };
747
748        let key = [0u8; 32];
749        let mac1 = compute_mac(&vault, Some(&key));
750        let mac2 = compute_mac(&vault, Some(&key));
751        assert_eq!(mac1, mac2);
752        assert!(mac1.starts_with("blake3v2:"));
753
754        // Without key, falls back to sha256v2
755        let mac_legacy = compute_mac(&vault, None);
756        assert!(mac_legacy.starts_with("sha256v2:"));
757    }
758
759    #[test]
760    fn compute_mac_changes_with_different_secrets() {
761        let mut vault = types::Vault {
762            version: types::VAULT_VERSION.into(),
763            created: "2026-02-28T00:00:00Z".into(),
764            vault_name: ".murk".into(),
765            repo: String::new(),
766            recipients: vec!["age1abc".into()],
767            schema: BTreeMap::new(),
768            secrets: BTreeMap::new(),
769            meta: String::new(),
770        };
771
772        let key = [0u8; 32];
773        let mac_empty = compute_mac(&vault, Some(&key));
774
775        vault.secrets.insert(
776            "KEY".into(),
777            types::SecretEntry {
778                shared: "ciphertext".into(),
779                scoped: BTreeMap::new(),
780            },
781        );
782
783        let mac_with_secret = compute_mac(&vault, Some(&key));
784        assert_ne!(mac_empty, mac_with_secret);
785    }
786
787    #[test]
788    fn compute_mac_changes_with_different_recipients() {
789        let mut vault = types::Vault {
790            version: types::VAULT_VERSION.into(),
791            created: "2026-02-28T00:00:00Z".into(),
792            vault_name: ".murk".into(),
793            repo: String::new(),
794            recipients: vec!["age1abc".into()],
795            schema: BTreeMap::new(),
796            secrets: BTreeMap::new(),
797            meta: String::new(),
798        };
799
800        let key = [0u8; 32];
801        let mac1 = compute_mac(&vault, Some(&key));
802        vault.recipients.push("age1xyz".into());
803        let mac2 = compute_mac(&vault, Some(&key));
804        assert_ne!(mac1, mac2);
805    }
806
807    #[test]
808    fn save_vault_preserves_unchanged_ciphertext() {
809        let (secret, pubkey) = generate_keypair();
810        let recipient = make_recipient(&pubkey);
811        let identity = make_identity(&secret);
812
813        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
814        fs::create_dir_all(&dir).unwrap();
815        let path = dir.join("test.murk");
816
817        let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
818        let mut vault = types::Vault {
819            version: types::VAULT_VERSION.into(),
820            created: "2026-02-28T00:00:00Z".into(),
821            vault_name: ".murk".into(),
822            repo: String::new(),
823            recipients: vec![pubkey.clone()],
824            schema: BTreeMap::new(),
825            secrets: BTreeMap::new(),
826            meta: String::new(),
827        };
828        vault.secrets.insert(
829            "KEY1".into(),
830            types::SecretEntry {
831                shared: shared.clone(),
832                scoped: BTreeMap::new(),
833            },
834        );
835
836        let mut recipients_map = HashMap::new();
837        recipients_map.insert(pubkey.clone(), "alice".into());
838        let original = types::Murk {
839            values: HashMap::from([("KEY1".into(), "original".into())]),
840            recipients: recipients_map.clone(),
841            scoped: HashMap::new(),
842            legacy_mac: false,
843            github_pins: HashMap::new(),
844        };
845
846        let current = original.clone();
847        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
848
849        assert_eq!(vault.secrets["KEY1"].shared, shared);
850
851        let mut changed = current.clone();
852        changed.values.insert("KEY1".into(), "modified".into());
853        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
854
855        assert_ne!(vault.secrets["KEY1"].shared, shared);
856
857        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
858        assert_eq!(decrypted, b"modified");
859
860        fs::remove_dir_all(&dir).unwrap();
861    }
862
863    #[test]
864    fn save_vault_adds_new_secret() {
865        let (_, pubkey) = generate_keypair();
866        let recipient = make_recipient(&pubkey);
867
868        let dir = std::env::temp_dir().join("murk_test_save_add");
869        fs::create_dir_all(&dir).unwrap();
870        let path = dir.join("test.murk");
871
872        let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
873        let mut vault = types::Vault {
874            version: types::VAULT_VERSION.into(),
875            created: "2026-02-28T00:00:00Z".into(),
876            vault_name: ".murk".into(),
877            repo: String::new(),
878            recipients: vec![pubkey.clone()],
879            schema: BTreeMap::new(),
880            secrets: BTreeMap::new(),
881            meta: String::new(),
882        };
883        vault.secrets.insert(
884            "KEY1".into(),
885            types::SecretEntry {
886                shared,
887                scoped: BTreeMap::new(),
888            },
889        );
890
891        let mut recipients_map = HashMap::new();
892        recipients_map.insert(pubkey.clone(), "alice".into());
893        let original = types::Murk {
894            values: HashMap::from([("KEY1".into(), "val1".into())]),
895            recipients: recipients_map.clone(),
896            scoped: HashMap::new(),
897            legacy_mac: false,
898            github_pins: HashMap::new(),
899        };
900
901        let mut current = original.clone();
902        current.values.insert("KEY2".into(), "val2".into());
903
904        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
905
906        assert!(vault.secrets.contains_key("KEY1"));
907        assert!(vault.secrets.contains_key("KEY2"));
908
909        fs::remove_dir_all(&dir).unwrap();
910    }
911
912    #[test]
913    fn save_vault_removes_deleted_secret() {
914        let (_, pubkey) = generate_keypair();
915        let recipient = make_recipient(&pubkey);
916
917        let dir = std::env::temp_dir().join("murk_test_save_remove");
918        fs::create_dir_all(&dir).unwrap();
919        let path = dir.join("test.murk");
920
921        let mut vault = types::Vault {
922            version: types::VAULT_VERSION.into(),
923            created: "2026-02-28T00:00:00Z".into(),
924            vault_name: ".murk".into(),
925            repo: String::new(),
926            recipients: vec![pubkey.clone()],
927            schema: BTreeMap::new(),
928            secrets: BTreeMap::new(),
929            meta: String::new(),
930        };
931        vault.secrets.insert(
932            "KEY1".into(),
933            types::SecretEntry {
934                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
935                scoped: BTreeMap::new(),
936            },
937        );
938        vault.secrets.insert(
939            "KEY2".into(),
940            types::SecretEntry {
941                shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
942                scoped: BTreeMap::new(),
943            },
944        );
945
946        let mut recipients_map = HashMap::new();
947        recipients_map.insert(pubkey.clone(), "alice".into());
948        let original = types::Murk {
949            values: HashMap::from([
950                ("KEY1".into(), "val1".into()),
951                ("KEY2".into(), "val2".into()),
952            ]),
953            recipients: recipients_map.clone(),
954            scoped: HashMap::new(),
955            legacy_mac: false,
956            github_pins: HashMap::new(),
957        };
958
959        let mut current = original.clone();
960        current.values.remove("KEY2");
961
962        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
963
964        assert!(vault.secrets.contains_key("KEY1"));
965        assert!(!vault.secrets.contains_key("KEY2"));
966
967        fs::remove_dir_all(&dir).unwrap();
968    }
969
970    #[test]
971    fn save_vault_reencrypts_all_on_recipient_change() {
972        let (secret1, pubkey1) = generate_keypair();
973        let (_, pubkey2) = generate_keypair();
974        let recipient1 = make_recipient(&pubkey1);
975
976        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
977        fs::create_dir_all(&dir).unwrap();
978        let path = dir.join("test.murk");
979
980        let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
981        let mut vault = types::Vault {
982            version: types::VAULT_VERSION.into(),
983            created: "2026-02-28T00:00:00Z".into(),
984            vault_name: ".murk".into(),
985            repo: String::new(),
986            recipients: vec![pubkey1.clone(), pubkey2.clone()],
987            schema: BTreeMap::new(),
988            secrets: BTreeMap::new(),
989            meta: String::new(),
990        };
991        vault.secrets.insert(
992            "KEY1".into(),
993            types::SecretEntry {
994                shared: shared.clone(),
995                scoped: BTreeMap::new(),
996            },
997        );
998
999        let mut recipients_map = HashMap::new();
1000        recipients_map.insert(pubkey1.clone(), "alice".into());
1001        let original = types::Murk {
1002            values: HashMap::from([("KEY1".into(), "val1".into())]),
1003            recipients: recipients_map,
1004            scoped: HashMap::new(),
1005            legacy_mac: false,
1006            github_pins: HashMap::new(),
1007        };
1008
1009        let mut current_recipients = HashMap::new();
1010        current_recipients.insert(pubkey1.clone(), "alice".into());
1011        current_recipients.insert(pubkey2.clone(), "bob".into());
1012        let current = types::Murk {
1013            values: HashMap::from([("KEY1".into(), "val1".into())]),
1014            recipients: current_recipients,
1015            scoped: HashMap::new(),
1016            legacy_mac: false,
1017            github_pins: HashMap::new(),
1018        };
1019
1020        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1021
1022        assert_ne!(vault.secrets["KEY1"].shared, shared);
1023
1024        let identity1 = make_identity(&secret1);
1025        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
1026        assert_eq!(decrypted, b"val1");
1027
1028        fs::remove_dir_all(&dir).unwrap();
1029    }
1030
1031    #[test]
1032    fn save_vault_scoped_entry_lifecycle() {
1033        let (secret, pubkey) = generate_keypair();
1034        let recipient = make_recipient(&pubkey);
1035        let identity = make_identity(&secret);
1036
1037        let dir = std::env::temp_dir().join("murk_test_save_scoped");
1038        fs::create_dir_all(&dir).unwrap();
1039        let path = dir.join("test.murk");
1040
1041        let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
1042        let mut vault = types::Vault {
1043            version: types::VAULT_VERSION.into(),
1044            created: "2026-02-28T00:00:00Z".into(),
1045            vault_name: ".murk".into(),
1046            repo: String::new(),
1047            recipients: vec![pubkey.clone()],
1048            schema: BTreeMap::new(),
1049            secrets: BTreeMap::new(),
1050            meta: String::new(),
1051        };
1052        vault.secrets.insert(
1053            "KEY1".into(),
1054            types::SecretEntry {
1055                shared,
1056                scoped: BTreeMap::new(),
1057            },
1058        );
1059
1060        let mut recipients_map = HashMap::new();
1061        recipients_map.insert(pubkey.clone(), "alice".into());
1062        let original = types::Murk {
1063            values: HashMap::from([("KEY1".into(), "shared_val".into())]),
1064            recipients: recipients_map.clone(),
1065            scoped: HashMap::new(),
1066            legacy_mac: false,
1067            github_pins: HashMap::new(),
1068        };
1069
1070        // Add a scoped override.
1071        let mut current = original.clone();
1072        let mut key_scoped = HashMap::new();
1073        key_scoped.insert(pubkey.clone(), "my_override".into());
1074        current.scoped.insert("KEY1".into(), key_scoped);
1075
1076        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1077
1078        assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
1079        let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
1080        assert_eq!(scoped_val, b"my_override");
1081
1082        // Now remove the scoped override.
1083        let original_with_scoped = current.clone();
1084        let mut current_no_scoped = original_with_scoped.clone();
1085        current_no_scoped.scoped.remove("KEY1");
1086
1087        save_vault(
1088            path.to_str().unwrap(),
1089            &mut vault,
1090            &original_with_scoped,
1091            &current_no_scoped,
1092        )
1093        .unwrap();
1094
1095        assert!(vault.secrets["KEY1"].scoped.is_empty());
1096
1097        fs::remove_dir_all(&dir).unwrap();
1098    }
1099
1100    #[test]
1101    fn load_vault_validates_mac() {
1102        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1103
1104        let (secret, pubkey) = generate_keypair();
1105        let recipient = make_recipient(&pubkey);
1106        let _identity = make_identity(&secret);
1107
1108        let dir = std::env::temp_dir().join("murk_test_load_mac");
1109        let _ = fs::remove_dir_all(&dir);
1110        fs::create_dir_all(&dir).unwrap();
1111        let path = dir.join("test.murk");
1112
1113        // Build a vault with one secret, save it (computes valid MAC).
1114        let mut vault = types::Vault {
1115            version: types::VAULT_VERSION.into(),
1116            created: "2026-02-28T00:00:00Z".into(),
1117            vault_name: ".murk".into(),
1118            repo: String::new(),
1119            recipients: vec![pubkey.clone()],
1120            schema: BTreeMap::new(),
1121            secrets: BTreeMap::new(),
1122            meta: String::new(),
1123        };
1124        vault.secrets.insert(
1125            "KEY1".into(),
1126            types::SecretEntry {
1127                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1128                scoped: BTreeMap::new(),
1129            },
1130        );
1131
1132        let mut recipients_map = HashMap::new();
1133        recipients_map.insert(pubkey.clone(), "alice".into());
1134        let original = types::Murk {
1135            values: HashMap::from([("KEY1".into(), "val1".into())]),
1136            recipients: recipients_map,
1137            scoped: HashMap::new(),
1138            legacy_mac: false,
1139            github_pins: HashMap::new(),
1140        };
1141
1142        // save_vault needs MURK_KEY set to encrypt meta.
1143        unsafe { std::env::set_var("MURK_KEY", &secret) };
1144        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1145        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1146
1147        // Now tamper: change the ciphertext in the saved vault file.
1148        let mut tampered: types::Vault =
1149            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1150        tampered.secrets.get_mut("KEY1").unwrap().shared =
1151            encrypt_value(b"tampered", &[recipient]).unwrap();
1152        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1153
1154        // Load should fail MAC validation.
1155        let result = load_vault(path.to_str().unwrap());
1156        unsafe { std::env::remove_var("MURK_KEY") };
1157
1158        let err = result.err().expect("expected MAC validation to fail");
1159        assert!(
1160            err.to_string().contains("integrity check failed"),
1161            "expected integrity check failure, got: {err}"
1162        );
1163
1164        fs::remove_dir_all(&dir).unwrap();
1165    }
1166
1167    #[test]
1168    fn load_vault_succeeds_with_valid_mac() {
1169        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1170
1171        let (secret, pubkey) = generate_keypair();
1172        let recipient = make_recipient(&pubkey);
1173
1174        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
1175        let _ = fs::remove_dir_all(&dir);
1176        fs::create_dir_all(&dir).unwrap();
1177        let path = dir.join("test.murk");
1178
1179        let mut vault = types::Vault {
1180            version: types::VAULT_VERSION.into(),
1181            created: "2026-02-28T00:00:00Z".into(),
1182            vault_name: ".murk".into(),
1183            repo: String::new(),
1184            recipients: vec![pubkey.clone()],
1185            schema: BTreeMap::new(),
1186            secrets: BTreeMap::new(),
1187            meta: String::new(),
1188        };
1189        vault.secrets.insert(
1190            "KEY1".into(),
1191            types::SecretEntry {
1192                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1193                scoped: BTreeMap::new(),
1194            },
1195        );
1196
1197        let mut recipients_map = HashMap::new();
1198        recipients_map.insert(pubkey.clone(), "alice".into());
1199        let original = types::Murk {
1200            values: HashMap::from([("KEY1".into(), "val1".into())]),
1201            recipients: recipients_map,
1202            scoped: HashMap::new(),
1203            legacy_mac: false,
1204            github_pins: HashMap::new(),
1205        };
1206
1207        unsafe { std::env::set_var("MURK_KEY", &secret) };
1208        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1209        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1210
1211        // Load should succeed.
1212        let result = load_vault(path.to_str().unwrap());
1213        unsafe { std::env::remove_var("MURK_KEY") };
1214
1215        assert!(result.is_ok());
1216        let (_, murk, _) = result.unwrap();
1217        assert_eq!(murk.values["KEY1"], "val1");
1218
1219        fs::remove_dir_all(&dir).unwrap();
1220    }
1221
1222    #[test]
1223    fn load_vault_not_a_recipient() {
1224        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1225
1226        let (secret, _pubkey) = generate_keypair();
1227        let (other_secret, other_pubkey) = generate_keypair();
1228        let other_recipient = make_recipient(&other_pubkey);
1229
1230        let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
1231        let _ = fs::remove_dir_all(&dir);
1232        fs::create_dir_all(&dir).unwrap();
1233        let path = dir.join("test.murk");
1234
1235        // Build a vault encrypted to `other`, not to `secret`.
1236        let mut vault = types::Vault {
1237            version: types::VAULT_VERSION.into(),
1238            created: "2026-02-28T00:00:00Z".into(),
1239            vault_name: ".murk".into(),
1240            repo: String::new(),
1241            recipients: vec![other_pubkey.clone()],
1242            schema: BTreeMap::new(),
1243            secrets: BTreeMap::new(),
1244            meta: String::new(),
1245        };
1246        vault.secrets.insert(
1247            "KEY1".into(),
1248            types::SecretEntry {
1249                shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
1250                scoped: BTreeMap::new(),
1251            },
1252        );
1253
1254        // Save via save_vault (needs the other key for re-encryption).
1255        let mut recipients_map = HashMap::new();
1256        recipients_map.insert(other_pubkey.clone(), "other".into());
1257        let original = types::Murk {
1258            values: HashMap::from([("KEY1".into(), "val1".into())]),
1259            recipients: recipients_map,
1260            scoped: HashMap::new(),
1261            legacy_mac: false,
1262            github_pins: HashMap::new(),
1263        };
1264
1265        unsafe { std::env::set_var("MURK_KEY", &other_secret) };
1266        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1267        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1268
1269        // Now try to load with a key that is NOT a recipient.
1270        unsafe { std::env::set_var("MURK_KEY", secret) };
1271        let result = load_vault(path.to_str().unwrap());
1272        unsafe { std::env::remove_var("MURK_KEY") };
1273
1274        let err = match result {
1275            Err(e) => e,
1276            Ok(_) => panic!("expected load_vault to fail for non-recipient"),
1277        };
1278        // Non-recipient can't decrypt meta, so integrity check fails first.
1279        let msg = err.to_string();
1280        assert!(
1281            msg.contains("decryption failed")
1282                || msg.contains("no meta")
1283                || msg.contains("tampered"),
1284            "expected decryption or integrity failure, got: {err}"
1285        );
1286
1287        fs::remove_dir_all(&dir).unwrap();
1288    }
1289
1290    #[test]
1291    fn load_vault_zero_secrets() {
1292        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1293
1294        let (secret, pubkey) = generate_keypair();
1295
1296        let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
1297        let _ = fs::remove_dir_all(&dir);
1298        fs::create_dir_all(&dir).unwrap();
1299        let path = dir.join("test.murk");
1300
1301        // Build a vault with no secrets at all.
1302        let mut vault = types::Vault {
1303            version: types::VAULT_VERSION.into(),
1304            created: "2026-02-28T00:00:00Z".into(),
1305            vault_name: ".murk".into(),
1306            repo: String::new(),
1307            recipients: vec![pubkey.clone()],
1308            schema: BTreeMap::new(),
1309            secrets: BTreeMap::new(),
1310            meta: String::new(),
1311        };
1312
1313        let mut recipients_map = HashMap::new();
1314        recipients_map.insert(pubkey.clone(), "alice".into());
1315        let original = types::Murk {
1316            values: HashMap::new(),
1317            recipients: recipients_map,
1318            scoped: HashMap::new(),
1319            legacy_mac: false,
1320            github_pins: HashMap::new(),
1321        };
1322
1323        unsafe { std::env::set_var("MURK_KEY", &secret) };
1324        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1325        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1326
1327        let result = load_vault(path.to_str().unwrap());
1328        unsafe { std::env::remove_var("MURK_KEY") };
1329
1330        assert!(result.is_ok());
1331        let (_, murk, _) = result.unwrap();
1332        assert!(murk.values.is_empty());
1333        assert!(murk.scoped.is_empty());
1334
1335        fs::remove_dir_all(&dir).unwrap();
1336    }
1337
1338    #[test]
1339    fn load_vault_stripped_meta_with_secrets_fails() {
1340        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1341
1342        let (secret, pubkey) = generate_keypair();
1343        let recipient = make_recipient(&pubkey);
1344
1345        let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
1346        let _ = fs::remove_dir_all(&dir);
1347        fs::create_dir_all(&dir).unwrap();
1348        let path = dir.join("test.murk");
1349
1350        // Build a vault with one secret and a valid MAC via save_vault.
1351        let mut vault = types::Vault {
1352            version: types::VAULT_VERSION.into(),
1353            created: "2026-02-28T00:00:00Z".into(),
1354            vault_name: ".murk".into(),
1355            repo: String::new(),
1356            recipients: vec![pubkey.clone()],
1357            schema: BTreeMap::new(),
1358            secrets: BTreeMap::new(),
1359            meta: String::new(),
1360        };
1361        vault.secrets.insert(
1362            "KEY1".into(),
1363            types::SecretEntry {
1364                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1365                scoped: BTreeMap::new(),
1366            },
1367        );
1368
1369        let mut recipients_map = HashMap::new();
1370        recipients_map.insert(pubkey.clone(), "alice".into());
1371        let original = types::Murk {
1372            values: HashMap::from([("KEY1".into(), "val1".into())]),
1373            recipients: recipients_map,
1374            scoped: HashMap::new(),
1375            legacy_mac: false,
1376            github_pins: HashMap::new(),
1377        };
1378
1379        unsafe { std::env::set_var("MURK_KEY", &secret) };
1380        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1381        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1382
1383        // Tamper: strip meta field entirely.
1384        let mut tampered: types::Vault =
1385            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1386        tampered.meta = String::new();
1387        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1388
1389        // Load should fail: secrets present but no meta.
1390        let result = load_vault(path.to_str().unwrap());
1391        unsafe { std::env::remove_var("MURK_KEY") };
1392
1393        let err = result.err().expect("expected MAC validation to fail");
1394        assert!(
1395            err.to_string().contains("integrity check failed"),
1396            "expected integrity check failure, got: {err}"
1397        );
1398
1399        fs::remove_dir_all(&dir).unwrap();
1400    }
1401
1402    #[test]
1403    fn load_vault_empty_mac_with_secrets_fails() {
1404        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1405
1406        let (secret, pubkey) = generate_keypair();
1407        let recipient = make_recipient(&pubkey);
1408
1409        let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
1410        let _ = fs::remove_dir_all(&dir);
1411        fs::create_dir_all(&dir).unwrap();
1412        let path = dir.join("test.murk");
1413
1414        // Build a vault with one secret.
1415        let mut vault = types::Vault {
1416            version: types::VAULT_VERSION.into(),
1417            created: "2026-02-28T00:00:00Z".into(),
1418            vault_name: ".murk".into(),
1419            repo: String::new(),
1420            recipients: vec![pubkey.clone()],
1421            schema: BTreeMap::new(),
1422            secrets: BTreeMap::new(),
1423            meta: String::new(),
1424        };
1425        vault.secrets.insert(
1426            "KEY1".into(),
1427            types::SecretEntry {
1428                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1429                scoped: BTreeMap::new(),
1430            },
1431        );
1432
1433        // Manually create meta with empty MAC and encrypt it.
1434        let mut recipients_map = HashMap::new();
1435        recipients_map.insert(pubkey.clone(), "alice".into());
1436        let meta = types::Meta {
1437            recipients: recipients_map,
1438            mac: String::new(),
1439            mac_key: None,
1440            github_pins: HashMap::new(),
1441        };
1442        let meta_json = serde_json::to_vec(&meta).unwrap();
1443        vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1444
1445        // Write the vault to disk.
1446        crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1447
1448        // Load should fail: secrets present but MAC is empty.
1449        unsafe { std::env::set_var("MURK_KEY", &secret) };
1450        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1451        let result = load_vault(path.to_str().unwrap());
1452        unsafe { std::env::remove_var("MURK_KEY") };
1453
1454        let err = result.err().expect("expected MAC validation to fail");
1455        assert!(
1456            err.to_string().contains("integrity check failed"),
1457            "expected integrity check failure, got: {err}"
1458        );
1459
1460        fs::remove_dir_all(&dir).unwrap();
1461    }
1462
1463    #[test]
1464    fn compute_mac_changes_with_scoped_entries() {
1465        let mut vault = types::Vault {
1466            version: types::VAULT_VERSION.into(),
1467            created: "2026-02-28T00:00:00Z".into(),
1468            vault_name: ".murk".into(),
1469            repo: String::new(),
1470            recipients: vec!["age1abc".into()],
1471            schema: BTreeMap::new(),
1472            secrets: BTreeMap::new(),
1473            meta: String::new(),
1474        };
1475
1476        vault.secrets.insert(
1477            "KEY".into(),
1478            types::SecretEntry {
1479                shared: "ciphertext".into(),
1480                scoped: BTreeMap::new(),
1481            },
1482        );
1483
1484        let key = [0u8; 32];
1485        let mac_no_scoped = compute_mac(&vault, Some(&key));
1486
1487        vault
1488            .secrets
1489            .get_mut("KEY")
1490            .unwrap()
1491            .scoped
1492            .insert("age1bob".into(), "scoped-ct".into());
1493
1494        let mac_with_scoped = compute_mac(&vault, Some(&key));
1495        assert_ne!(mac_no_scoped, mac_with_scoped);
1496    }
1497
1498    #[test]
1499    fn verify_mac_accepts_v1_prefix() {
1500        let vault = types::Vault {
1501            version: types::VAULT_VERSION.into(),
1502            created: "2026-02-28T00:00:00Z".into(),
1503            vault_name: ".murk".into(),
1504            repo: String::new(),
1505            recipients: vec!["age1abc".into()],
1506            schema: BTreeMap::new(),
1507            secrets: BTreeMap::new(),
1508            meta: String::new(),
1509        };
1510
1511        let key = [0u8; 32];
1512        let v1_mac = compute_mac_v1(&vault);
1513        let v2_mac = compute_mac_v2(&vault);
1514        let v3_mac = compute_mac_v3(&vault, &key);
1515        assert!(verify_mac(&vault, &v1_mac, None));
1516        assert!(verify_mac(&vault, &v2_mac, None));
1517        assert!(verify_mac(&vault, &v3_mac, Some(&key)));
1518        assert!(!verify_mac(&vault, "sha256:bogus", None));
1519        assert!(!verify_mac(&vault, "blake3:bogus", Some(&key)));
1520        assert!(!verify_mac(&vault, "blake3v2:bogus", Some(&key)));
1521        assert!(!verify_mac(&vault, "unknown:prefix", None));
1522
1523        // v4 (blake3v2) — includes schema
1524        let v4_mac = compute_mac_v4(&vault, &key);
1525        assert!(v4_mac.starts_with("blake3v2:"));
1526        assert!(verify_mac(&vault, &v4_mac, Some(&key)));
1527    }
1528
1529    #[test]
1530    fn compute_mac_changes_with_schema() {
1531        let mut vault = types::Vault {
1532            version: types::VAULT_VERSION.into(),
1533            created: "2026-02-28T00:00:00Z".into(),
1534            vault_name: ".murk".into(),
1535            repo: String::new(),
1536            recipients: vec!["age1abc".into()],
1537            schema: BTreeMap::new(),
1538            secrets: BTreeMap::new(),
1539            meta: String::new(),
1540        };
1541
1542        let key = [0u8; 32];
1543        let mac_no_schema = compute_mac(&vault, Some(&key));
1544
1545        vault.schema.insert(
1546            "API_KEY".into(),
1547            types::SchemaEntry {
1548                description: "Main API key".into(),
1549                tags: vec!["deploy".into()],
1550                ..Default::default()
1551            },
1552        );
1553
1554        let mac_with_schema = compute_mac(&vault, Some(&key));
1555        assert_ne!(mac_no_schema, mac_with_schema);
1556
1557        // Changing a tag changes the MAC
1558        let mac_before_retag = mac_with_schema;
1559        vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
1560        let mac_after_retag = compute_mac(&vault, Some(&key));
1561        assert_ne!(mac_before_retag, mac_after_retag);
1562    }
1563
1564    #[test]
1565    fn mac_key_roundtrip() {
1566        let hex = generate_mac_key();
1567        assert_eq!(hex.len(), 64);
1568        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1569
1570        let key = decode_mac_key(&hex).expect("valid hex should decode");
1571        // Re-encode and compare.
1572        let rehex = key.iter().fold(String::new(), |mut s, b| {
1573            use std::fmt::Write;
1574            let _ = write!(s, "{b:02x}");
1575            s
1576        });
1577        assert_eq!(hex, rehex);
1578    }
1579
1580    #[test]
1581    fn decode_mac_key_rejects_bad_input() {
1582        assert!(decode_mac_key("").is_none());
1583        assert!(decode_mac_key("tooshort").is_none());
1584        assert!(decode_mac_key(&"zz".repeat(32)).is_none()); // invalid hex
1585        assert!(decode_mac_key(&"aa".repeat(31)).is_none()); // 31 bytes
1586        assert!(decode_mac_key(&"aa".repeat(33)).is_none()); // 33 bytes
1587    }
1588
1589    #[test]
1590    fn blake3_mac_different_key_different_mac() {
1591        let vault = types::Vault {
1592            version: types::VAULT_VERSION.into(),
1593            created: "2026-02-28T00:00:00Z".into(),
1594            vault_name: ".murk".into(),
1595            repo: String::new(),
1596            recipients: vec!["age1abc".into()],
1597            schema: BTreeMap::new(),
1598            secrets: BTreeMap::new(),
1599            meta: String::new(),
1600        };
1601
1602        let key1 = [0u8; 32];
1603        let key2 = [1u8; 32];
1604        let mac1 = compute_mac(&vault, Some(&key1));
1605        let mac2 = compute_mac(&vault, Some(&key2));
1606        assert_ne!(mac1, mac2);
1607    }
1608
1609    #[test]
1610    fn valid_key_names() {
1611        assert!(is_valid_key_name("DATABASE_URL"));
1612        assert!(is_valid_key_name("_PRIVATE"));
1613        assert!(is_valid_key_name("A"));
1614        assert!(is_valid_key_name("key123"));
1615    }
1616
1617    #[test]
1618    fn invalid_key_names() {
1619        assert!(!is_valid_key_name(""));
1620        assert!(!is_valid_key_name("123_START"));
1621        assert!(!is_valid_key_name("KEY-NAME"));
1622        assert!(!is_valid_key_name("KEY NAME"));
1623        assert!(!is_valid_key_name("FOO$(bar)"));
1624        assert!(!is_valid_key_name("KEY=VAL"));
1625    }
1626
1627    #[test]
1628    fn now_utc_format() {
1629        let ts = now_utc();
1630        assert!(ts.ends_with('Z'));
1631        assert_eq!(ts.len(), 20);
1632        assert_eq!(&ts[4..5], "-");
1633        assert_eq!(&ts[7..8], "-");
1634        assert_eq!(&ts[10..11], "T");
1635    }
1636}