1use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use age::secrecy::SecretString;
9
10pub const ENV_MURK_KEY: &str = "MURK_KEY";
12pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
14pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
16
17const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
19
20#[cfg(unix)]
22const SECRET_FILE_MODE: u32 = 0o600;
23
24#[cfg(unix)]
26const WORLD_READABLE_MASK: u32 = 0o077;
27
28pub fn resolve_key() -> Result<SecretString, String> {
36 if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
38 return Ok(SecretString::from(k));
39 }
40 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 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 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
62pub 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 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
100pub 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
120pub 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 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 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
147pub 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
162pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
167 let env_path = Path::new(".env");
168
169 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 #[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
206pub 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 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
230fn 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(); 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
252pub 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
274pub 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#[derive(Debug, PartialEq, Eq)]
322pub enum EnvrcStatus {
323 AlreadyPresent,
325 Appended,
327 Created,
329}
330
331pub 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 static ENV_LOCK: Mutex<()> = Mutex::new(());
364
365 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 #[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 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 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 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 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 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}