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 age::secrecy::ExposeSecret;
459
460    use crate::testutil::{CWD_LOCK, ENV_LOCK};
461
462    #[test]
463    fn parse_env_empty() {
464        assert!(parse_env("").is_empty());
465    }
466
467    #[test]
468    fn parse_env_comments_and_blanks() {
469        let input = "# comment\n\n  # another\n";
470        assert!(parse_env(input).is_empty());
471    }
472
473    /// Compare parsed pairs against plain `(key, value)` expectations by
474    /// unwrapping the `Zeroizing` value wrappers.
475    fn assert_pairs(pairs: Vec<(String, Zeroizing<String>)>, expected: &[(&str, &str)]) {
476        let actual: Vec<(String, String)> =
477            pairs.into_iter().map(|(k, v)| (k, (*v).clone())).collect();
478        let want: Vec<(String, String)> = expected
479            .iter()
480            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
481            .collect();
482        assert_eq!(actual, want);
483    }
484
485    #[test]
486    fn parse_env_basic() {
487        assert_pairs(
488            parse_env("FOO=bar\nBAZ=qux\n"),
489            &[("FOO", "bar"), ("BAZ", "qux")],
490        );
491    }
492
493    #[test]
494    fn parse_env_double_quotes() {
495        assert_pairs(
496            parse_env("KEY=\"hello world\"\n"),
497            &[("KEY", "hello world")],
498        );
499    }
500
501    #[test]
502    fn parse_env_single_quotes() {
503        assert_pairs(parse_env("KEY='hello world'\n"), &[("KEY", "hello world")]);
504    }
505
506    #[test]
507    fn parse_env_export_prefix() {
508        assert_pairs(parse_env("export FOO=bar\n"), &[("FOO", "bar")]);
509    }
510
511    #[test]
512    fn parse_env_skips_murk_keys() {
513        let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
514        assert_pairs(parse_env(input), &[("KEEP", "yes")]);
515    }
516
517    #[test]
518    fn parse_env_equals_in_value() {
519        assert_pairs(
520            parse_env("URL=postgres://host?opt=1\n"),
521            &[("URL", "postgres://host?opt=1")],
522        );
523    }
524
525    #[test]
526    fn parse_env_no_equals_skipped() {
527        assert_pairs(parse_env("not-a-valid-line\nKEY=val\n"), &[("KEY", "val")]);
528    }
529
530    // ── New edge-case tests ──
531
532    #[test]
533    fn parse_env_empty_value() {
534        assert_pairs(parse_env("KEY=\n"), &[("KEY", "")]);
535    }
536
537    #[test]
538    fn parse_env_trailing_whitespace() {
539        assert_pairs(parse_env("KEY=value   \n"), &[("KEY", "value")]);
540    }
541
542    #[test]
543    fn parse_env_unicode_value() {
544        assert_pairs(parse_env("KEY=hello🔐world\n"), &[("KEY", "hello🔐world")]);
545    }
546
547    #[test]
548    fn parse_env_empty_key_skipped() {
549        let pairs = parse_env("=value\n");
550        assert!(pairs.is_empty());
551    }
552
553    #[test]
554    fn parse_env_mixed_quotes_unmatched() {
555        // Mismatched quotes are not stripped.
556        assert_pairs(parse_env("KEY=\"hello'\n"), &[("KEY", "\"hello'")]);
557    }
558
559    #[test]
560    fn parse_env_multiple_murk_vars() {
561        // All three MURK_ vars are skipped, other vars kept.
562        let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
563        assert_pairs(parse_env(input), &[("A", "1"), ("B", "2")]);
564    }
565
566    /// Helper: acquire both locks and cd to a clean temp dir.
567    /// Returns guards and the previous cwd. The cwd is restored on drop
568    /// via the returned `prev` path — callers must restore manually before
569    /// asserting so panics don't leave cwd changed.
570    fn resolve_key_sandbox(
571        name: &str,
572    ) -> (
573        std::sync::MutexGuard<'static, ()>,
574        std::sync::MutexGuard<'static, ()>,
575        std::path::PathBuf,
576        std::path::PathBuf,
577    ) {
578        let env = ENV_LOCK
579            .lock()
580            .unwrap_or_else(std::sync::PoisonError::into_inner);
581        let cwd = CWD_LOCK
582            .lock()
583            .unwrap_or_else(std::sync::PoisonError::into_inner);
584        let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
585        let _ = std::fs::create_dir_all(&tmp);
586        let prev = std::env::current_dir().unwrap();
587        std::env::set_current_dir(&tmp).unwrap();
588        (env, cwd, tmp, prev)
589    }
590
591    fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
592        std::env::set_current_dir(prev).unwrap();
593        let _ = std::fs::remove_dir_all(tmp);
594    }
595
596    #[test]
597    fn resolve_key_from_env() {
598        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
599        let key = "AGE-SECRET-KEY-1TEST";
600        unsafe { env::set_var("MURK_KEY", key) };
601        let result = resolve_key();
602        unsafe { env::remove_var("MURK_KEY") };
603        resolve_key_sandbox_teardown(&tmp, &prev);
604
605        let secret = result.unwrap();
606        assert_eq!(secret.expose_secret(), key);
607    }
608
609    #[test]
610    fn resolve_key_from_file() {
611        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
612        unsafe { env::remove_var("MURK_KEY") };
613
614        let path = std::env::temp_dir().join("murk_test_key_file");
615        {
616            #[cfg(unix)]
617            {
618                use std::os::unix::fs::OpenOptionsExt;
619                let mut f = std::fs::OpenOptions::new()
620                    .create(true)
621                    .write(true)
622                    .truncate(true)
623                    .mode(0o600)
624                    .open(&path)
625                    .unwrap();
626                std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
627            }
628            #[cfg(not(unix))]
629            std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
630        }
631
632        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
633        let result = resolve_key();
634        unsafe { env::remove_var("MURK_KEY_FILE") };
635        std::fs::remove_file(&path).ok();
636        resolve_key_sandbox_teardown(&tmp, &prev);
637
638        let secret = result.unwrap();
639        // File contents pass through unmodified so plugin identity files
640        // (multi-line with `# public key:` header) round-trip intact.
641        assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE");
642    }
643
644    #[test]
645    fn resolve_key_file_not_found() {
646        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
647        unsafe { env::remove_var("MURK_KEY") };
648        unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
649        let result = resolve_key();
650        unsafe { env::remove_var("MURK_KEY_FILE") };
651        resolve_key_sandbox_teardown(&tmp, &prev);
652
653        assert!(result.is_err());
654        assert!(result.unwrap_err().contains("cannot read"));
655    }
656
657    #[test]
658    fn resolve_key_neither_set() {
659        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
660        unsafe { env::remove_var("MURK_KEY") };
661        unsafe { env::remove_var("MURK_KEY_FILE") };
662        let result = resolve_key();
663        resolve_key_sandbox_teardown(&tmp, &prev);
664
665        assert!(result.is_err());
666        assert!(result.unwrap_err().contains("MURK_KEY not set"));
667    }
668
669    #[test]
670    fn resolve_key_empty_string_treated_as_unset() {
671        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
672        unsafe { env::set_var("MURK_KEY", "") };
673        unsafe { env::remove_var("MURK_KEY_FILE") };
674        let result = resolve_key();
675        unsafe { env::remove_var("MURK_KEY") };
676        resolve_key_sandbox_teardown(&tmp, &prev);
677
678        assert!(result.is_err());
679        assert!(result.unwrap_err().contains("MURK_KEY not set"));
680    }
681
682    #[test]
683    fn resolve_key_murk_key_takes_priority_over_file() {
684        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
685        let direct_key = "AGE-SECRET-KEY-1DIRECT";
686        let file_key = "AGE-SECRET-KEY-1FILE";
687
688        let path = std::env::temp_dir().join("murk_test_key_priority");
689        std::fs::write(&path, format!("{file_key}\n")).unwrap();
690
691        unsafe { env::set_var("MURK_KEY", direct_key) };
692        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
693        let result = resolve_key();
694        unsafe { env::remove_var("MURK_KEY") };
695        unsafe { env::remove_var("MURK_KEY_FILE") };
696        std::fs::remove_file(&path).ok();
697        resolve_key_sandbox_teardown(&tmp, &prev);
698
699        let secret = result.unwrap();
700        assert_eq!(secret.expose_secret(), direct_key);
701    }
702
703    #[cfg(unix)]
704    #[test]
705    fn warn_env_permissions_no_warning_on_secure_file() {
706        use std::os::unix::fs::PermissionsExt;
707
708        let _cwd = CWD_LOCK
709            .lock()
710            .unwrap_or_else(std::sync::PoisonError::into_inner);
711
712        let dir = std::env::temp_dir().join("murk_test_perms");
713        let _ = std::fs::remove_dir_all(&dir);
714        std::fs::create_dir_all(&dir).unwrap();
715        let env_path = dir.join(".env");
716        std::fs::write(&env_path, "KEY=val\n").unwrap();
717        std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
718
719        // Just verify it doesn't panic — output goes to stderr.
720        let original_dir = std::env::current_dir().unwrap();
721        std::env::set_current_dir(&dir).unwrap();
722        warn_env_permissions();
723        std::env::set_current_dir(original_dir).unwrap();
724
725        std::fs::remove_dir_all(&dir).unwrap();
726    }
727
728    #[test]
729    fn resolve_key_does_not_read_dotenv() {
730        // Confirms the murk-82q fix: even if .env sits in CWD with an inline
731        // MURK_KEY, resolve_key_with_source must not pick it up. The runtime
732        // only trusts the environment and the vault-keyed auto lookup.
733        //
734        // Lock order: ENV_LOCK before CWD_LOCK, matching every other test
735        // that grabs both. Reversing the order deadlocks against parallel
736        // tests that hold ENV_LOCK while waiting for CWD_LOCK.
737        let _env_lock = ENV_LOCK
738            .lock()
739            .unwrap_or_else(std::sync::PoisonError::into_inner);
740        let _cwd = CWD_LOCK
741            .lock()
742            .unwrap_or_else(std::sync::PoisonError::into_inner);
743        let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
744        let _ = std::fs::remove_dir_all(&dir);
745        std::fs::create_dir_all(&dir).unwrap();
746        std::fs::write(
747            dir.join(".env"),
748            "MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
749        )
750        .unwrap();
751
752        // Preserve and clear any ambient key env so we see the true fallback.
753        let prev_key = env::var(ENV_MURK_KEY).ok();
754        let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
755        unsafe {
756            env::remove_var(ENV_MURK_KEY);
757            env::remove_var(ENV_MURK_KEY_FILE);
758        }
759
760        let original_dir = std::env::current_dir().unwrap();
761        std::env::set_current_dir(&dir).unwrap();
762        // Use a vault_path that won't match any auto key file on this machine.
763        let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
764        std::env::set_current_dir(original_dir).unwrap();
765
766        unsafe {
767            if let Some(v) = prev_key {
768                env::set_var(ENV_MURK_KEY, v);
769            }
770            if let Some(v) = prev_keyfile {
771                env::set_var(ENV_MURK_KEY_FILE, v);
772            }
773        }
774
775        assert!(
776            result.is_err(),
777            "resolve_key_with_source must not fall back to .env"
778        );
779        std::fs::remove_dir_all(&dir).unwrap();
780    }
781
782    #[test]
783    fn dotenv_has_murk_key_true() {
784        let _cwd = CWD_LOCK
785            .lock()
786            .unwrap_or_else(std::sync::PoisonError::into_inner);
787        let dir = std::env::temp_dir().join("murk_test_has_key_true");
788        let _ = std::fs::remove_dir_all(&dir);
789        std::fs::create_dir_all(&dir).unwrap();
790        std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
791
792        let original_dir = std::env::current_dir().unwrap();
793        std::env::set_current_dir(&dir).unwrap();
794        assert!(dotenv_has_murk_key());
795        std::env::set_current_dir(original_dir).unwrap();
796
797        std::fs::remove_dir_all(&dir).unwrap();
798    }
799
800    #[test]
801    fn dotenv_has_murk_key_false() {
802        let _cwd = CWD_LOCK
803            .lock()
804            .unwrap_or_else(std::sync::PoisonError::into_inner);
805        let dir = std::env::temp_dir().join("murk_test_has_key_false");
806        let _ = std::fs::remove_dir_all(&dir);
807        std::fs::create_dir_all(&dir).unwrap();
808        std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
809
810        let original_dir = std::env::current_dir().unwrap();
811        std::env::set_current_dir(&dir).unwrap();
812        assert!(!dotenv_has_murk_key());
813        std::env::set_current_dir(original_dir).unwrap();
814
815        std::fs::remove_dir_all(&dir).unwrap();
816    }
817
818    #[test]
819    fn dotenv_has_murk_key_no_file() {
820        let _cwd = CWD_LOCK
821            .lock()
822            .unwrap_or_else(std::sync::PoisonError::into_inner);
823        let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
824        let _ = std::fs::remove_dir_all(&dir);
825        std::fs::create_dir_all(&dir).unwrap();
826
827        let original_dir = std::env::current_dir().unwrap();
828        std::env::set_current_dir(&dir).unwrap();
829        assert!(!dotenv_has_murk_key());
830        std::env::set_current_dir(original_dir).unwrap();
831
832        std::fs::remove_dir_all(&dir).unwrap();
833    }
834
835    #[test]
836    fn write_key_to_dotenv_creates_new() {
837        let _cwd = CWD_LOCK
838            .lock()
839            .unwrap_or_else(std::sync::PoisonError::into_inner);
840        let dir = std::env::temp_dir().join("murk_test_write_key_new");
841        let _ = std::fs::remove_dir_all(&dir);
842        std::fs::create_dir_all(&dir).unwrap();
843
844        let original_dir = std::env::current_dir().unwrap();
845        std::env::set_current_dir(&dir).unwrap();
846        write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
847
848        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
849        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
850
851        std::env::set_current_dir(original_dir).unwrap();
852        std::fs::remove_dir_all(&dir).unwrap();
853    }
854
855    #[test]
856    fn write_key_to_dotenv_replaces_existing() {
857        let _cwd = CWD_LOCK
858            .lock()
859            .unwrap_or_else(std::sync::PoisonError::into_inner);
860        let dir = std::env::temp_dir().join("murk_test_write_key_replace");
861        let _ = std::fs::remove_dir_all(&dir);
862        std::fs::create_dir_all(&dir).unwrap();
863        std::fs::write(
864            dir.join(".env"),
865            "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
866        )
867        .unwrap();
868
869        let original_dir = std::env::current_dir().unwrap();
870        std::env::set_current_dir(&dir).unwrap();
871        write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
872
873        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
874        assert!(contents.contains("OTHER=keep"));
875        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
876        assert!(!contents.contains("MURK_KEY=old"));
877        assert!(!contents.contains("also_old"));
878
879        std::env::set_current_dir(original_dir).unwrap();
880        std::fs::remove_dir_all(&dir).unwrap();
881    }
882
883    #[cfg(unix)]
884    #[test]
885    fn write_key_to_dotenv_permissions_are_600() {
886        use std::os::unix::fs::PermissionsExt;
887
888        let _cwd = CWD_LOCK
889            .lock()
890            .unwrap_or_else(std::sync::PoisonError::into_inner);
891
892        let dir = std::env::temp_dir().join("murk_test_write_key_perms");
893        let _ = std::fs::remove_dir_all(&dir);
894        std::fs::create_dir_all(&dir).unwrap();
895
896        let original_dir = std::env::current_dir().unwrap();
897        std::env::set_current_dir(&dir).unwrap();
898
899        // Create new .env — should be 0o600 from the start.
900        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
901        let meta = std::fs::metadata(dir.join(".env")).unwrap();
902        assert_eq!(
903            meta.permissions().mode() & 0o777,
904            SECRET_FILE_MODE,
905            "new .env should be created with mode 600"
906        );
907
908        // Replace existing — should still be 0o600.
909        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
910        let meta = std::fs::metadata(dir.join(".env")).unwrap();
911        assert_eq!(
912            meta.permissions().mode() & 0o777,
913            SECRET_FILE_MODE,
914            "rewritten .env should maintain mode 600"
915        );
916
917        std::env::set_current_dir(original_dir).unwrap();
918        std::fs::remove_dir_all(&dir).unwrap();
919    }
920
921    #[test]
922    fn write_envrc_creates_new() {
923        let _cwd = CWD_LOCK
924            .lock()
925            .unwrap_or_else(std::sync::PoisonError::into_inner);
926        let dir = std::env::temp_dir().join("murk_test_envrc_new");
927        let _ = std::fs::remove_dir_all(&dir);
928        std::fs::create_dir_all(&dir).unwrap();
929
930        let original_dir = std::env::current_dir().unwrap();
931        std::env::set_current_dir(&dir).unwrap();
932        let status = write_envrc(".murk").unwrap();
933        assert_eq!(status, EnvrcStatus::Created);
934
935        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
936        assert!(contents.contains("murk export --vault .murk"));
937
938        std::env::set_current_dir(original_dir).unwrap();
939        std::fs::remove_dir_all(&dir).unwrap();
940    }
941
942    #[test]
943    fn write_envrc_appends() {
944        let _cwd = CWD_LOCK
945            .lock()
946            .unwrap_or_else(std::sync::PoisonError::into_inner);
947        let dir = std::env::temp_dir().join("murk_test_envrc_append");
948        let _ = std::fs::remove_dir_all(&dir);
949        std::fs::create_dir_all(&dir).unwrap();
950        std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
951
952        let original_dir = std::env::current_dir().unwrap();
953        std::env::set_current_dir(&dir).unwrap();
954        let status = write_envrc(".murk").unwrap();
955        assert_eq!(status, EnvrcStatus::Appended);
956
957        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
958        assert!(contents.contains("existing content"));
959        assert!(contents.contains("murk export"));
960
961        std::env::set_current_dir(original_dir).unwrap();
962        std::fs::remove_dir_all(&dir).unwrap();
963    }
964
965    #[test]
966    fn write_envrc_already_present() {
967        let _cwd = CWD_LOCK
968            .lock()
969            .unwrap_or_else(std::sync::PoisonError::into_inner);
970        let dir = std::env::temp_dir().join("murk_test_envrc_present");
971        let _ = std::fs::remove_dir_all(&dir);
972        std::fs::create_dir_all(&dir).unwrap();
973        std::fs::write(
974            dir.join(".envrc"),
975            "eval \"$(murk export --vault .murk)\"\n",
976        )
977        .unwrap();
978
979        let original_dir = std::env::current_dir().unwrap();
980        std::env::set_current_dir(&dir).unwrap();
981        let status = write_envrc(".murk").unwrap();
982        assert_eq!(status, EnvrcStatus::AlreadyPresent);
983
984        std::env::set_current_dir(original_dir).unwrap();
985        std::fs::remove_dir_all(&dir).unwrap();
986    }
987
988    #[test]
989    fn reject_symlink_ok_for_regular_file() {
990        let dir = tempfile::TempDir::new().unwrap();
991        let path = dir.path().join("regular.txt");
992        std::fs::write(&path, "content").unwrap();
993        assert!(reject_symlink(&path, "test").is_ok());
994    }
995
996    #[test]
997    fn reject_symlink_ok_for_nonexistent() {
998        let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
999        assert!(reject_symlink(path, "test").is_ok());
1000    }
1001
1002    #[cfg(unix)]
1003    #[test]
1004    fn reject_symlink_rejects_symlink() {
1005        let dir = tempfile::TempDir::new().unwrap();
1006        let link = dir.path().join("link");
1007        std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
1008        let result = reject_symlink(&link, "test");
1009        assert!(result.is_err());
1010        assert!(result.unwrap_err().contains("symlink"));
1011    }
1012
1013    #[cfg(unix)]
1014    #[test]
1015    fn read_secret_file_rejects_world_readable() {
1016        use std::os::unix::fs::PermissionsExt;
1017        let dir = tempfile::TempDir::new().unwrap();
1018        let path = dir.path().join("loose.key");
1019        std::fs::write(&path, "secret").unwrap();
1020        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1021        let result = read_secret_file(&path, "test");
1022        assert!(result.is_err());
1023        assert!(result.unwrap_err().contains("readable by others"));
1024    }
1025
1026    #[cfg(unix)]
1027    #[test]
1028    fn read_secret_file_accepts_600() {
1029        use std::os::unix::fs::OpenOptionsExt;
1030        let dir = tempfile::TempDir::new().unwrap();
1031        let path = dir.path().join("tight.key");
1032        let mut f = std::fs::OpenOptions::new()
1033            .create(true)
1034            .truncate(true)
1035            .write(true)
1036            .mode(0o600)
1037            .open(&path)
1038            .unwrap();
1039        std::io::Write::write_all(&mut f, b"secret").unwrap();
1040        let result = read_secret_file(&path, "test");
1041        assert!(result.is_ok());
1042        assert_eq!(result.unwrap(), "secret");
1043    }
1044
1045    #[test]
1046    fn shell_escape_bare_identifiers() {
1047        assert_eq!(shell_escape(".murk"), ".murk");
1048        assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
1049        assert_eq!(
1050            shell_escape("/home/user/.config/murk/key"),
1051            "/home/user/.config/murk/key"
1052        );
1053    }
1054
1055    #[test]
1056    fn shell_escape_quotes_special_chars() {
1057        assert_eq!(shell_escape("my vault"), "'my vault'");
1058        assert_eq!(shell_escape("it's"), "'it'\\''s'");
1059        assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
1060    }
1061
1062    #[test]
1063    fn write_envrc_escapes_vault_name() {
1064        let _cwd = CWD_LOCK
1065            .lock()
1066            .unwrap_or_else(std::sync::PoisonError::into_inner);
1067        let dir = std::env::temp_dir().join("murk_test_envrc_escape");
1068        let _ = std::fs::remove_dir_all(&dir);
1069        std::fs::create_dir_all(&dir).unwrap();
1070
1071        let original_dir = std::env::current_dir().unwrap();
1072        std::env::set_current_dir(&dir).unwrap();
1073        let status = write_envrc("my vault.murk").unwrap();
1074        assert_eq!(status, EnvrcStatus::Created);
1075
1076        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1077        assert!(contents.contains("'my vault.murk'"));
1078
1079        std::env::set_current_dir(original_dir).unwrap();
1080        std::fs::remove_dir_all(&dir).unwrap();
1081    }
1082}