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