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