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