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