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