Skip to main content

murk_cli/
env.rs

1//! Environment and `.env` file handling.
2
3use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use age::secrecy::SecretString;
9use zeroize::Zeroizing;
10
11/// Shell-escape a string using single quotes, safe for embedding in shell scripts.
12/// If the value is a simple identifier (alphanumeric, `-`, `_`, `.`, `/`), returns it bare.
13fn shell_escape(s: &str) -> String {
14    if !s.is_empty()
15        && s.chars()
16            .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
17    {
18        s.to_string()
19    } else {
20        format!("'{}'", s.replace('\'', "'\\''"))
21    }
22}
23
24/// Reject symlinks at the given path to prevent symlink-clobber attacks.
25/// Returns Ok(()) if the path does not exist or is not a symlink.
26pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
27    if path.is_symlink() {
28        return Err(format!(
29            "{label} is a symlink — refusing to follow for security"
30        ));
31    }
32    Ok(())
33}
34
35/// Read a file, rejecting symlinks and (on Unix) group/world-readable permissions.
36/// Returns the file contents as a string.
37fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
38    reject_symlink(path, label)?;
39
40    #[cfg(unix)]
41    {
42        use std::os::unix::fs::MetadataExt;
43        if let Ok(meta) = fs::metadata(path) {
44            let mode = meta.mode();
45            if mode & WORLD_READABLE_MASK != 0 {
46                return Err(format!(
47                    "{label} is readable by others (mode {:o}). Run: chmod 600 {}",
48                    mode & 0o777,
49                    path.display()
50                ));
51            }
52        }
53    }
54
55    fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
56}
57
58/// Environment variable for the secret key.
59pub const ENV_MURK_KEY: &str = "MURK_KEY";
60/// Environment variable for the secret key file path.
61pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
62/// Environment variable for the vault filename.
63pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
64
65/// Keys to skip when importing from a .env file.
66const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
67
68/// File mode for `.env`: owner read/write only.
69#[cfg(unix)]
70const SECRET_FILE_MODE: u32 = 0o600;
71
72/// Bitmask for group/other permission bits.
73#[cfg(unix)]
74const WORLD_READABLE_MASK: u32 = 0o077;
75
76/// Resolve the secret key, checking in order:
77/// 1. `MURK_KEY` env var (explicit key)
78/// 2. `MURK_KEY_FILE` env var (path to key file)
79/// 3. `~/.config/murk/keys/<vault-hash>` (automatic lookup for default vault)
80/// 4. `.env` file in cwd (backward compat)
81///
82/// Returns the key wrapped in `SecretString` so it is zeroized on drop.
83pub fn resolve_key() -> Result<SecretString, String> {
84    resolve_key_for_vault(".murk")
85}
86
87/// Where the resolved key came from.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum KeySource {
90    /// From `MURK_KEY` environment variable.
91    EnvVar,
92    /// From `MURK_KEY_FILE` environment variable (path).
93    EnvFile(std::path::PathBuf),
94    /// Auto-discovered at `~/.config/murk/keys/<hash>`.
95    Auto(std::path::PathBuf),
96}
97
98impl KeySource {
99    /// Human-readable description for display.
100    pub fn describe(&self) -> String {
101        match self {
102            KeySource::EnvVar => "MURK_KEY environment variable".into(),
103            KeySource::EnvFile(p) => format!("MURK_KEY_FILE {}", p.display()),
104            KeySource::Auto(p) => p.display().to_string(),
105        }
106    }
107}
108
109/// Resolve the secret key and report where it came from.
110///
111/// Checks, in order:
112/// 1. `MURK_KEY` env var (explicit key)
113/// 2. `MURK_KEY_FILE` env var (path to a key file)
114/// 3. `~/.config/murk/keys/<hash-of-vault-path>` (automatic lookup)
115///
116/// `.env` is **not** consulted at runtime. It is a write-only convenience that
117/// `murk init` populates with a `MURK_KEY_FILE` reference for direnv to export.
118/// Reading `.env` at runtime would let a copied vault in another repo borrow
119/// whichever key happened to be referenced in the current working directory's
120/// `.env` — a confused-deputy path that defeats per-vault key isolation.
121pub fn resolve_key_with_source(vault_path: &str) -> Result<(SecretString, KeySource), String> {
122    if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
123        return Ok((SecretString::from(k), KeySource::EnvVar));
124    }
125    // File paths return full contents (not trimmed) so that plugin identity
126    // files — which contain a `# public key: age1...` header above an
127    // `AGE-PLUGIN-...-1...` pointer — round-trip intact through parse_identity.
128    if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
129        let p = std::path::Path::new(&path);
130        let contents = read_secret_file(p, "MURK_KEY_FILE")?;
131        return Ok((
132            SecretString::from(contents),
133            KeySource::EnvFile(p.to_path_buf()),
134        ));
135    }
136    if let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists()) {
137        let contents = read_secret_file(&path, "key file")?;
138        return Ok((SecretString::from(contents), KeySource::Auto(path)));
139    }
140    Err(
141        "MURK_KEY not set. Run `murk init` to generate a key, set MURK_KEY_FILE to point at one, or ask a recipient to authorize you. If your .env contains an inline MURK_KEY or MURK_KEY_FILE, run `direnv allow` (or `source .env`) so it is exported to the environment — murk no longer reads .env directly."
142            .into(),
143    )
144}
145
146/// Resolve the secret key for a specific vault.
147pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
148    resolve_key_with_source(vault_path).map(|(k, _)| k)
149}
150
151/// Parse a .env file into key-value pairs.
152/// Skips comments, blank lines, `MURK_*` keys, and strips quotes and `export` prefixes.
153///
154/// Values are wrapped in [`Zeroizing`] so that the plaintext is wiped from memory
155/// as soon as the caller drops them.
156pub fn parse_env(contents: &str) -> Vec<(String, Zeroizing<String>)> {
157    let mut pairs = Vec::new();
158
159    for line in contents.lines() {
160        let line = line.trim();
161
162        if line.is_empty() || line.starts_with('#') {
163            continue;
164        }
165
166        let line = line.strip_prefix("export ").unwrap_or(line);
167
168        let Some((key, value)) = line.split_once('=') else {
169            continue;
170        };
171
172        let key = key.trim();
173        let value = value.trim();
174
175        // Strip surrounding quotes.
176        let value = value
177            .strip_prefix('"')
178            .and_then(|v| v.strip_suffix('"'))
179            .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
180            .unwrap_or(value);
181
182        if key.is_empty() || IMPORT_SKIP.contains(&key) {
183            continue;
184        }
185
186        pairs.push((key.into(), Zeroizing::new(value.to_string())));
187    }
188
189    pairs
190}
191
192/// Warn if `.env` has loose permissions (Unix only).
193pub fn warn_env_permissions() {
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::PermissionsExt;
197        let env_path = Path::new(".env");
198        if env_path.exists()
199            && let Ok(meta) = fs::metadata(env_path)
200        {
201            let mode = meta.permissions().mode();
202            if mode & WORLD_READABLE_MASK != 0 {
203                eprintln!(
204                    "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
205                    mode & 0o777
206                );
207            }
208        }
209    }
210}
211
212/// Check whether `.env` already contains a `MURK_KEY` line.
213pub fn dotenv_has_murk_key() -> bool {
214    let env_path = Path::new(".env");
215    if !env_path.exists() {
216        return false;
217    }
218    let contents = fs::read_to_string(env_path).unwrap_or_default();
219    contents.lines().any(|l| {
220        l.starts_with("MURK_KEY=")
221            || l.starts_with("export MURK_KEY=")
222            || l.starts_with("MURK_KEY_FILE=")
223            || l.starts_with("export MURK_KEY_FILE=")
224    })
225}
226
227/// Write a MURK_KEY to `.env`, removing any existing MURK_KEY lines.
228/// On Unix, sets file permissions to 600 atomically at creation time to
229/// prevent a TOCTOU window where the secret key is world-readable.
230/// On non-Unix platforms, permissions are not hardened.
231pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
232    let env_path = Path::new(".env");
233    reject_symlink(env_path, ".env")?;
234
235    // Read existing content (minus any MURK_KEY lines).
236    let existing = if env_path.exists() {
237        let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
238        let filtered: Vec<&str> = contents
239            .lines()
240            .filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
241            .collect();
242        filtered.join("\n") + "\n"
243    } else {
244        String::new()
245    };
246
247    let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
248
249    // Write the file with restricted permissions from the start (Unix).
250    #[cfg(unix)]
251    {
252        use std::os::unix::fs::OpenOptionsExt;
253        let mut file = fs::OpenOptions::new()
254            .create(true)
255            .write(true)
256            .truncate(true)
257            .mode(SECRET_FILE_MODE)
258            .custom_flags(libc::O_NOFOLLOW)
259            .open(env_path)
260            .map_err(|e| format!("opening .env: {e}"))?;
261        file.write_all(full_content.as_bytes())
262            .map_err(|e| format!("writing .env: {e}"))?;
263    }
264
265    #[cfg(not(unix))]
266    {
267        fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
268    }
269
270    Ok(())
271}
272
273/// Compute the key file path for a vault: `~/.config/murk/keys/<hash>`.
274///
275/// The hash is a truncated SHA-256 of the *lexical* absolute vault path
276/// (cwd-joined if relative, but symlinks are NOT resolved). Using the
277/// literal path is important for security: a symlink `.murk` pointing at
278/// another project's vault must not resolve to that project's key file.
279pub fn key_file_path(vault_path: &str) -> Result<std::path::PathBuf, String> {
280    use sha2::{Digest, Sha256};
281
282    let p = std::path::Path::new(vault_path);
283    let abs_path = if p.is_absolute() {
284        p.to_path_buf()
285    } else {
286        std::env::current_dir()
287            .map_err(|e| format!("cannot resolve vault path: {e}"))?
288            .join(p)
289    };
290
291    let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
292    let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
293        use std::fmt::Write;
294        let _ = write!(s, "{b:02x}");
295        s
296    });
297
298    let config_dir = dirs_path()?;
299    Ok(config_dir.join(&short_hash))
300}
301
302/// Return `~/.config/murk/keys/`, creating it if needed.
303fn dirs_path() -> Result<std::path::PathBuf, String> {
304    let home = std::env::var("HOME")
305        .or_else(|_| std::env::var("USERPROFILE"))
306        .map_err(|_| "cannot determine home directory")?;
307    let dir = std::path::Path::new(&home)
308        .join(".config")
309        .join("murk")
310        .join("keys");
311    fs::create_dir_all(&dir).map_err(|e| format!("creating key directory: {e}"))?;
312
313    #[cfg(unix)]
314    {
315        use std::os::unix::fs::PermissionsExt;
316        let parent = dir.parent().unwrap(); // ~/.config/murk
317        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
318            .map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
319        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
320            .map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
321    }
322
323    Ok(dir)
324}
325
326/// Write a secret key to a file with restricted permissions.
327pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
328    reject_symlink(path, &path.display().to_string())?;
329    #[cfg(unix)]
330    {
331        use std::os::unix::fs::OpenOptionsExt;
332        let mut file = fs::OpenOptions::new()
333            .create(true)
334            .write(true)
335            .truncate(true)
336            .mode(SECRET_FILE_MODE)
337            .custom_flags(libc::O_NOFOLLOW)
338            .open(path)
339            .map_err(|e| format!("writing key file: {e}"))?;
340        file.write_all(secret_key.as_bytes())
341            .map_err(|e| format!("writing key file: {e}"))?;
342    }
343    #[cfg(not(unix))]
344    {
345        fs::write(path, secret_key).map_err(|e| format!("writing key file: {e}"))?;
346    }
347    Ok(())
348}
349
350/// Write a MURK_KEY_FILE reference to `.env`, removing any existing MURK_KEY/MURK_KEY_FILE lines.
351pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
352    let env_path = Path::new(".env");
353    reject_symlink(env_path, ".env")?;
354
355    let existing = if env_path.exists() {
356        let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
357        let filtered: Vec<&str> = contents
358            .lines()
359            .filter(|l| {
360                !l.starts_with("MURK_KEY=")
361                    && !l.starts_with("export MURK_KEY=")
362                    && !l.starts_with("MURK_KEY_FILE=")
363                    && !l.starts_with("export MURK_KEY_FILE=")
364            })
365            .collect();
366        filtered.join("\n") + "\n"
367    } else {
368        String::new()
369    };
370
371    let full_content = format!(
372        "{existing}export MURK_KEY_FILE='{}'\n",
373        key_file_path.display().to_string().replace('\'', "'\\''")
374    );
375
376    #[cfg(unix)]
377    {
378        use std::os::unix::fs::OpenOptionsExt;
379        let mut file = fs::OpenOptions::new()
380            .create(true)
381            .write(true)
382            .truncate(true)
383            .mode(SECRET_FILE_MODE)
384            .custom_flags(libc::O_NOFOLLOW)
385            .open(env_path)
386            .map_err(|e| format!("opening .env: {e}"))?;
387        file.write_all(full_content.as_bytes())
388            .map_err(|e| format!("writing .env: {e}"))?;
389    }
390    #[cfg(not(unix))]
391    {
392        fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
393    }
394
395    Ok(())
396}
397
398/// Status of `.envrc` after writing.
399#[derive(Debug, PartialEq, Eq)]
400pub enum EnvrcStatus {
401    /// `.envrc` already contained `murk export`.
402    AlreadyPresent,
403    /// Appended murk export line to existing `.envrc`.
404    Appended,
405    /// Created a new `.envrc` file.
406    Created,
407}
408
409/// Write a `.envrc` file for direnv integration.
410///
411/// If `.envrc` exists and already contains `murk export`, returns `AlreadyPresent`.
412/// If it exists but doesn't, appends the line. Otherwise creates the file.
413pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
414    let envrc = Path::new(".envrc");
415    reject_symlink(envrc, ".envrc")?;
416    let safe_vault_name = shell_escape(vault_name);
417    let murk_line = format!("eval \"$(murk export --vault {safe_vault_name})\"");
418
419    if envrc.exists() {
420        let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
421        if contents.contains("murk export") {
422            return Ok(EnvrcStatus::AlreadyPresent);
423        }
424        let mut file = fs::OpenOptions::new()
425            .append(true)
426            .open(envrc)
427            .map_err(|e| format!("writing .envrc: {e}"))?;
428        writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
429        Ok(EnvrcStatus::Appended)
430    } else {
431        #[cfg(unix)]
432        {
433            use std::os::unix::fs::OpenOptionsExt;
434            let mut file = fs::OpenOptions::new()
435                .create(true)
436                .write(true)
437                .truncate(true)
438                .mode(SECRET_FILE_MODE)
439                .custom_flags(libc::O_NOFOLLOW)
440                .open(envrc)
441                .map_err(|e| format!("writing .envrc: {e}"))?;
442            file.write_all(format!("{murk_line}\n").as_bytes())
443                .map_err(|e| format!("writing .envrc: {e}"))?;
444        }
445        #[cfg(not(unix))]
446        {
447            fs::write(envrc, format!("{murk_line}\n"))
448                .map_err(|e| format!("writing .envrc: {e}"))?;
449        }
450        Ok(EnvrcStatus::Created)
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    use crate::testutil::{CWD_LOCK, ENV_LOCK};
459
460    #[test]
461    fn parse_env_empty() {
462        assert!(parse_env("").is_empty());
463    }
464
465    #[test]
466    fn parse_env_comments_and_blanks() {
467        let input = "# comment\n\n  # another\n";
468        assert!(parse_env(input).is_empty());
469    }
470
471    /// Compare parsed pairs against plain `(key, value)` expectations by
472    /// unwrapping the `Zeroizing` value wrappers.
473    fn assert_pairs(pairs: Vec<(String, Zeroizing<String>)>, expected: &[(&str, &str)]) {
474        let actual: Vec<(String, String)> =
475            pairs.into_iter().map(|(k, v)| (k, (*v).clone())).collect();
476        let want: Vec<(String, String)> = expected
477            .iter()
478            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
479            .collect();
480        assert_eq!(actual, want);
481    }
482
483    #[test]
484    fn parse_env_basic() {
485        assert_pairs(
486            parse_env("FOO=bar\nBAZ=qux\n"),
487            &[("FOO", "bar"), ("BAZ", "qux")],
488        );
489    }
490
491    #[test]
492    fn parse_env_double_quotes() {
493        assert_pairs(
494            parse_env("KEY=\"hello world\"\n"),
495            &[("KEY", "hello world")],
496        );
497    }
498
499    #[test]
500    fn parse_env_single_quotes() {
501        assert_pairs(parse_env("KEY='hello world'\n"), &[("KEY", "hello world")]);
502    }
503
504    #[test]
505    fn parse_env_export_prefix() {
506        assert_pairs(parse_env("export FOO=bar\n"), &[("FOO", "bar")]);
507    }
508
509    #[test]
510    fn parse_env_skips_murk_keys() {
511        let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
512        assert_pairs(parse_env(input), &[("KEEP", "yes")]);
513    }
514
515    #[test]
516    fn parse_env_equals_in_value() {
517        assert_pairs(
518            parse_env("URL=postgres://host?opt=1\n"),
519            &[("URL", "postgres://host?opt=1")],
520        );
521    }
522
523    #[test]
524    fn parse_env_no_equals_skipped() {
525        assert_pairs(parse_env("not-a-valid-line\nKEY=val\n"), &[("KEY", "val")]);
526    }
527
528    // ── New edge-case tests ──
529
530    #[test]
531    fn parse_env_empty_value() {
532        assert_pairs(parse_env("KEY=\n"), &[("KEY", "")]);
533    }
534
535    #[test]
536    fn parse_env_trailing_whitespace() {
537        assert_pairs(parse_env("KEY=value   \n"), &[("KEY", "value")]);
538    }
539
540    #[test]
541    fn parse_env_unicode_value() {
542        assert_pairs(parse_env("KEY=hello🔐world\n"), &[("KEY", "hello🔐world")]);
543    }
544
545    #[test]
546    fn parse_env_empty_key_skipped() {
547        let pairs = parse_env("=value\n");
548        assert!(pairs.is_empty());
549    }
550
551    #[test]
552    fn parse_env_mixed_quotes_unmatched() {
553        // Mismatched quotes are not stripped.
554        assert_pairs(parse_env("KEY=\"hello'\n"), &[("KEY", "\"hello'")]);
555    }
556
557    #[test]
558    fn parse_env_multiple_murk_vars() {
559        // All three MURK_ vars are skipped, other vars kept.
560        let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
561        assert_pairs(parse_env(input), &[("A", "1"), ("B", "2")]);
562    }
563
564    /// Helper: acquire both locks and cd to a clean temp dir.
565    /// Returns guards and the previous cwd. The cwd is restored on drop
566    /// via the returned `prev` path — callers must restore manually before
567    /// asserting so panics don't leave cwd changed.
568    fn resolve_key_sandbox(
569        name: &str,
570    ) -> (
571        std::sync::MutexGuard<'static, ()>,
572        std::sync::MutexGuard<'static, ()>,
573        std::path::PathBuf,
574        std::path::PathBuf,
575    ) {
576        let env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
577        let cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
578        let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
579        let _ = std::fs::create_dir_all(&tmp);
580        let prev = std::env::current_dir().unwrap();
581        std::env::set_current_dir(&tmp).unwrap();
582        (env, cwd, tmp, prev)
583    }
584
585    fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
586        std::env::set_current_dir(prev).unwrap();
587        let _ = std::fs::remove_dir_all(tmp);
588    }
589
590    #[test]
591    fn resolve_key_from_env() {
592        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
593        let key = "AGE-SECRET-KEY-1TEST";
594        unsafe { env::set_var("MURK_KEY", key) };
595        let result = resolve_key();
596        unsafe { env::remove_var("MURK_KEY") };
597        resolve_key_sandbox_teardown(&tmp, &prev);
598
599        let secret = result.unwrap();
600        use age::secrecy::ExposeSecret;
601        assert_eq!(secret.expose_secret(), key);
602    }
603
604    #[test]
605    fn resolve_key_from_file() {
606        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
607        unsafe { env::remove_var("MURK_KEY") };
608
609        let path = std::env::temp_dir().join("murk_test_key_file");
610        {
611            #[cfg(unix)]
612            {
613                use std::os::unix::fs::OpenOptionsExt;
614                let mut f = std::fs::OpenOptions::new()
615                    .create(true)
616                    .write(true)
617                    .truncate(true)
618                    .mode(0o600)
619                    .open(&path)
620                    .unwrap();
621                std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
622            }
623            #[cfg(not(unix))]
624            std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
625        }
626
627        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
628        let result = resolve_key();
629        unsafe { env::remove_var("MURK_KEY_FILE") };
630        std::fs::remove_file(&path).ok();
631        resolve_key_sandbox_teardown(&tmp, &prev);
632
633        let secret = result.unwrap();
634        use age::secrecy::ExposeSecret;
635        // File contents pass through unmodified so plugin identity files
636        // (multi-line with `# public key:` header) round-trip intact.
637        assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE");
638    }
639
640    #[test]
641    fn resolve_key_file_not_found() {
642        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
643        unsafe { env::remove_var("MURK_KEY") };
644        unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
645        let result = resolve_key();
646        unsafe { env::remove_var("MURK_KEY_FILE") };
647        resolve_key_sandbox_teardown(&tmp, &prev);
648
649        assert!(result.is_err());
650        assert!(result.unwrap_err().contains("cannot read"));
651    }
652
653    #[test]
654    fn resolve_key_neither_set() {
655        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
656        unsafe { env::remove_var("MURK_KEY") };
657        unsafe { env::remove_var("MURK_KEY_FILE") };
658        let result = resolve_key();
659        resolve_key_sandbox_teardown(&tmp, &prev);
660
661        assert!(result.is_err());
662        assert!(result.unwrap_err().contains("MURK_KEY not set"));
663    }
664
665    #[test]
666    fn resolve_key_empty_string_treated_as_unset() {
667        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
668        unsafe { env::set_var("MURK_KEY", "") };
669        unsafe { env::remove_var("MURK_KEY_FILE") };
670        let result = resolve_key();
671        unsafe { env::remove_var("MURK_KEY") };
672        resolve_key_sandbox_teardown(&tmp, &prev);
673
674        assert!(result.is_err());
675        assert!(result.unwrap_err().contains("MURK_KEY not set"));
676    }
677
678    #[test]
679    fn resolve_key_murk_key_takes_priority_over_file() {
680        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
681        let direct_key = "AGE-SECRET-KEY-1DIRECT";
682        let file_key = "AGE-SECRET-KEY-1FILE";
683
684        let path = std::env::temp_dir().join("murk_test_key_priority");
685        std::fs::write(&path, format!("{file_key}\n")).unwrap();
686
687        unsafe { env::set_var("MURK_KEY", direct_key) };
688        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
689        let result = resolve_key();
690        unsafe { env::remove_var("MURK_KEY") };
691        unsafe { env::remove_var("MURK_KEY_FILE") };
692        std::fs::remove_file(&path).ok();
693        resolve_key_sandbox_teardown(&tmp, &prev);
694
695        let secret = result.unwrap();
696        use age::secrecy::ExposeSecret;
697        assert_eq!(secret.expose_secret(), direct_key);
698    }
699
700    #[cfg(unix)]
701    #[test]
702    fn warn_env_permissions_no_warning_on_secure_file() {
703        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
704        use std::os::unix::fs::PermissionsExt;
705
706        let dir = std::env::temp_dir().join("murk_test_perms");
707        let _ = std::fs::remove_dir_all(&dir);
708        std::fs::create_dir_all(&dir).unwrap();
709        let env_path = dir.join(".env");
710        std::fs::write(&env_path, "KEY=val\n").unwrap();
711        std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
712
713        // Just verify it doesn't panic — output goes to stderr.
714        let original_dir = std::env::current_dir().unwrap();
715        std::env::set_current_dir(&dir).unwrap();
716        warn_env_permissions();
717        std::env::set_current_dir(original_dir).unwrap();
718
719        std::fs::remove_dir_all(&dir).unwrap();
720    }
721
722    #[test]
723    fn resolve_key_does_not_read_dotenv() {
724        // Confirms the murk-82q fix: even if .env sits in CWD with an inline
725        // MURK_KEY, resolve_key_with_source must not pick it up. The runtime
726        // only trusts the environment and the vault-keyed auto lookup.
727        //
728        // Lock order: ENV_LOCK before CWD_LOCK, matching every other test
729        // that grabs both. Reversing the order deadlocks against parallel
730        // tests that hold ENV_LOCK while waiting for CWD_LOCK.
731        let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
732        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
733        let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
734        let _ = std::fs::remove_dir_all(&dir);
735        std::fs::create_dir_all(&dir).unwrap();
736        std::fs::write(
737            dir.join(".env"),
738            "MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
739        )
740        .unwrap();
741
742        // Preserve and clear any ambient key env so we see the true fallback.
743        let prev_key = env::var(ENV_MURK_KEY).ok();
744        let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
745        unsafe {
746            env::remove_var(ENV_MURK_KEY);
747            env::remove_var(ENV_MURK_KEY_FILE);
748        }
749
750        let original_dir = std::env::current_dir().unwrap();
751        std::env::set_current_dir(&dir).unwrap();
752        // Use a vault_path that won't match any auto key file on this machine.
753        let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
754        std::env::set_current_dir(original_dir).unwrap();
755
756        unsafe {
757            if let Some(v) = prev_key {
758                env::set_var(ENV_MURK_KEY, v);
759            }
760            if let Some(v) = prev_keyfile {
761                env::set_var(ENV_MURK_KEY_FILE, v);
762            }
763        }
764
765        assert!(
766            result.is_err(),
767            "resolve_key_with_source must not fall back to .env"
768        );
769        std::fs::remove_dir_all(&dir).unwrap();
770    }
771
772    #[test]
773    fn dotenv_has_murk_key_true() {
774        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
775        let dir = std::env::temp_dir().join("murk_test_has_key_true");
776        let _ = std::fs::remove_dir_all(&dir);
777        std::fs::create_dir_all(&dir).unwrap();
778        std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
779
780        let original_dir = std::env::current_dir().unwrap();
781        std::env::set_current_dir(&dir).unwrap();
782        assert!(dotenv_has_murk_key());
783        std::env::set_current_dir(original_dir).unwrap();
784
785        std::fs::remove_dir_all(&dir).unwrap();
786    }
787
788    #[test]
789    fn dotenv_has_murk_key_false() {
790        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
791        let dir = std::env::temp_dir().join("murk_test_has_key_false");
792        let _ = std::fs::remove_dir_all(&dir);
793        std::fs::create_dir_all(&dir).unwrap();
794        std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
795
796        let original_dir = std::env::current_dir().unwrap();
797        std::env::set_current_dir(&dir).unwrap();
798        assert!(!dotenv_has_murk_key());
799        std::env::set_current_dir(original_dir).unwrap();
800
801        std::fs::remove_dir_all(&dir).unwrap();
802    }
803
804    #[test]
805    fn dotenv_has_murk_key_no_file() {
806        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
807        let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
808        let _ = std::fs::remove_dir_all(&dir);
809        std::fs::create_dir_all(&dir).unwrap();
810
811        let original_dir = std::env::current_dir().unwrap();
812        std::env::set_current_dir(&dir).unwrap();
813        assert!(!dotenv_has_murk_key());
814        std::env::set_current_dir(original_dir).unwrap();
815
816        std::fs::remove_dir_all(&dir).unwrap();
817    }
818
819    #[test]
820    fn write_key_to_dotenv_creates_new() {
821        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
822        let dir = std::env::temp_dir().join("murk_test_write_key_new");
823        let _ = std::fs::remove_dir_all(&dir);
824        std::fs::create_dir_all(&dir).unwrap();
825
826        let original_dir = std::env::current_dir().unwrap();
827        std::env::set_current_dir(&dir).unwrap();
828        write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
829
830        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
831        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
832
833        std::env::set_current_dir(original_dir).unwrap();
834        std::fs::remove_dir_all(&dir).unwrap();
835    }
836
837    #[test]
838    fn write_key_to_dotenv_replaces_existing() {
839        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
840        let dir = std::env::temp_dir().join("murk_test_write_key_replace");
841        let _ = std::fs::remove_dir_all(&dir);
842        std::fs::create_dir_all(&dir).unwrap();
843        std::fs::write(
844            dir.join(".env"),
845            "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
846        )
847        .unwrap();
848
849        let original_dir = std::env::current_dir().unwrap();
850        std::env::set_current_dir(&dir).unwrap();
851        write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
852
853        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
854        assert!(contents.contains("OTHER=keep"));
855        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
856        assert!(!contents.contains("MURK_KEY=old"));
857        assert!(!contents.contains("also_old"));
858
859        std::env::set_current_dir(original_dir).unwrap();
860        std::fs::remove_dir_all(&dir).unwrap();
861    }
862
863    #[cfg(unix)]
864    #[test]
865    fn write_key_to_dotenv_permissions_are_600() {
866        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
867        use std::os::unix::fs::PermissionsExt;
868
869        let dir = std::env::temp_dir().join("murk_test_write_key_perms");
870        let _ = std::fs::remove_dir_all(&dir);
871        std::fs::create_dir_all(&dir).unwrap();
872
873        let original_dir = std::env::current_dir().unwrap();
874        std::env::set_current_dir(&dir).unwrap();
875
876        // Create new .env — should be 0o600 from the start.
877        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
878        let meta = std::fs::metadata(dir.join(".env")).unwrap();
879        assert_eq!(
880            meta.permissions().mode() & 0o777,
881            SECRET_FILE_MODE,
882            "new .env should be created with mode 600"
883        );
884
885        // Replace existing — should still be 0o600.
886        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
887        let meta = std::fs::metadata(dir.join(".env")).unwrap();
888        assert_eq!(
889            meta.permissions().mode() & 0o777,
890            SECRET_FILE_MODE,
891            "rewritten .env should maintain mode 600"
892        );
893
894        std::env::set_current_dir(original_dir).unwrap();
895        std::fs::remove_dir_all(&dir).unwrap();
896    }
897
898    #[test]
899    fn write_envrc_creates_new() {
900        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
901        let dir = std::env::temp_dir().join("murk_test_envrc_new");
902        let _ = std::fs::remove_dir_all(&dir);
903        std::fs::create_dir_all(&dir).unwrap();
904
905        let original_dir = std::env::current_dir().unwrap();
906        std::env::set_current_dir(&dir).unwrap();
907        let status = write_envrc(".murk").unwrap();
908        assert_eq!(status, EnvrcStatus::Created);
909
910        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
911        assert!(contents.contains("murk export --vault .murk"));
912
913        std::env::set_current_dir(original_dir).unwrap();
914        std::fs::remove_dir_all(&dir).unwrap();
915    }
916
917    #[test]
918    fn write_envrc_appends() {
919        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
920        let dir = std::env::temp_dir().join("murk_test_envrc_append");
921        let _ = std::fs::remove_dir_all(&dir);
922        std::fs::create_dir_all(&dir).unwrap();
923        std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
924
925        let original_dir = std::env::current_dir().unwrap();
926        std::env::set_current_dir(&dir).unwrap();
927        let status = write_envrc(".murk").unwrap();
928        assert_eq!(status, EnvrcStatus::Appended);
929
930        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
931        assert!(contents.contains("existing content"));
932        assert!(contents.contains("murk export"));
933
934        std::env::set_current_dir(original_dir).unwrap();
935        std::fs::remove_dir_all(&dir).unwrap();
936    }
937
938    #[test]
939    fn write_envrc_already_present() {
940        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
941        let dir = std::env::temp_dir().join("murk_test_envrc_present");
942        let _ = std::fs::remove_dir_all(&dir);
943        std::fs::create_dir_all(&dir).unwrap();
944        std::fs::write(
945            dir.join(".envrc"),
946            "eval \"$(murk export --vault .murk)\"\n",
947        )
948        .unwrap();
949
950        let original_dir = std::env::current_dir().unwrap();
951        std::env::set_current_dir(&dir).unwrap();
952        let status = write_envrc(".murk").unwrap();
953        assert_eq!(status, EnvrcStatus::AlreadyPresent);
954
955        std::env::set_current_dir(original_dir).unwrap();
956        std::fs::remove_dir_all(&dir).unwrap();
957    }
958
959    #[test]
960    fn reject_symlink_ok_for_regular_file() {
961        let dir = tempfile::TempDir::new().unwrap();
962        let path = dir.path().join("regular.txt");
963        std::fs::write(&path, "content").unwrap();
964        assert!(reject_symlink(&path, "test").is_ok());
965    }
966
967    #[test]
968    fn reject_symlink_ok_for_nonexistent() {
969        let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
970        assert!(reject_symlink(path, "test").is_ok());
971    }
972
973    #[cfg(unix)]
974    #[test]
975    fn reject_symlink_rejects_symlink() {
976        let dir = tempfile::TempDir::new().unwrap();
977        let link = dir.path().join("link");
978        std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
979        let result = reject_symlink(&link, "test");
980        assert!(result.is_err());
981        assert!(result.unwrap_err().contains("symlink"));
982    }
983
984    #[cfg(unix)]
985    #[test]
986    fn read_secret_file_rejects_world_readable() {
987        use std::os::unix::fs::PermissionsExt;
988        let dir = tempfile::TempDir::new().unwrap();
989        let path = dir.path().join("loose.key");
990        std::fs::write(&path, "secret").unwrap();
991        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
992        let result = read_secret_file(&path, "test");
993        assert!(result.is_err());
994        assert!(result.unwrap_err().contains("readable by others"));
995    }
996
997    #[cfg(unix)]
998    #[test]
999    fn read_secret_file_accepts_600() {
1000        use std::os::unix::fs::OpenOptionsExt;
1001        let dir = tempfile::TempDir::new().unwrap();
1002        let path = dir.path().join("tight.key");
1003        let mut f = std::fs::OpenOptions::new()
1004            .create(true)
1005            .write(true)
1006            .mode(0o600)
1007            .open(&path)
1008            .unwrap();
1009        std::io::Write::write_all(&mut f, b"secret").unwrap();
1010        let result = read_secret_file(&path, "test");
1011        assert!(result.is_ok());
1012        assert_eq!(result.unwrap(), "secret");
1013    }
1014
1015    #[test]
1016    fn shell_escape_bare_identifiers() {
1017        assert_eq!(shell_escape(".murk"), ".murk");
1018        assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
1019        assert_eq!(
1020            shell_escape("/home/user/.config/murk/key"),
1021            "/home/user/.config/murk/key"
1022        );
1023    }
1024
1025    #[test]
1026    fn shell_escape_quotes_special_chars() {
1027        assert_eq!(shell_escape("my vault"), "'my vault'");
1028        assert_eq!(shell_escape("it's"), "'it'\\''s'");
1029        assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
1030    }
1031
1032    #[test]
1033    fn write_envrc_escapes_vault_name() {
1034        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1035        let dir = std::env::temp_dir().join("murk_test_envrc_escape");
1036        let _ = std::fs::remove_dir_all(&dir);
1037        std::fs::create_dir_all(&dir).unwrap();
1038
1039        let original_dir = std::env::current_dir().unwrap();
1040        std::env::set_current_dir(&dir).unwrap();
1041        let status = write_envrc("my vault.murk").unwrap();
1042        assert_eq!(status, EnvrcStatus::Created);
1043
1044        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1045        assert!(contents.contains("'my vault.murk'"));
1046
1047        std::env::set_current_dir(original_dir).unwrap();
1048        std::fs::remove_dir_all(&dir).unwrap();
1049    }
1050}