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