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