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