1use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use age::secrecy::SecretString;
9use zeroize::Zeroizing;
10
11fn shell_escape(s: &str) -> String {
14 if !s.is_empty()
15 && s.chars()
16 .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
17 {
18 s.to_string()
19 } else {
20 format!("'{}'", s.replace('\'', "'\\''"))
21 }
22}
23
24pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
27 if path.is_symlink() {
28 return Err(format!(
29 "{label} is a symlink — refusing to follow for security"
30 ));
31 }
32 Ok(())
33}
34
35fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
38 reject_symlink(path, label)?;
39
40 #[cfg(unix)]
41 {
42 use std::os::unix::fs::MetadataExt;
43 if let Ok(meta) = fs::metadata(path) {
44 let mode = meta.mode();
45 if mode & WORLD_READABLE_MASK != 0 {
46 return Err(format!(
47 "{label} is readable by others (mode {:o}). Run: chmod 600 {}",
48 mode & 0o777,
49 path.display()
50 ));
51 }
52 }
53 }
54
55 fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
56}
57
58pub const ENV_MURK_KEY: &str = "MURK_KEY";
60pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
62pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
64
65const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
67
68#[cfg(unix)]
70const SECRET_FILE_MODE: u32 = 0o600;
71
72#[cfg(unix)]
74const WORLD_READABLE_MASK: u32 = 0o077;
75
76pub fn resolve_key() -> Result<SecretString, String> {
84 resolve_key_for_vault(".murk")
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum KeySource {
90 EnvVar,
92 EnvFile(std::path::PathBuf),
94 Auto(std::path::PathBuf),
96}
97
98impl KeySource {
99 pub fn describe(&self) -> String {
101 match self {
102 KeySource::EnvVar => "MURK_KEY environment variable".into(),
103 KeySource::EnvFile(p) => format!("MURK_KEY_FILE {}", p.display()),
104 KeySource::Auto(p) => p.display().to_string(),
105 }
106 }
107}
108
109pub fn resolve_key_with_source(vault_path: &str) -> Result<(SecretString, KeySource), String> {
122 if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
123 return Ok((SecretString::from(k), KeySource::EnvVar));
124 }
125 if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
129 let p = std::path::Path::new(&path);
130 let contents = read_secret_file(p, "MURK_KEY_FILE")?;
131 return Ok((
132 SecretString::from(contents),
133 KeySource::EnvFile(p.to_path_buf()),
134 ));
135 }
136 if !crate::hardening::strict_mode()
143 && let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists())
144 {
145 let contents = read_secret_file(&path, "key file")?;
146 return Ok((SecretString::from(contents), KeySource::Auto(path)));
147 }
148 Err(
149 "MURK_KEY not set. Run `murk init` to generate a key, set MURK_KEY_FILE to point at one, or ask a recipient to authorize you. If your .env contains an inline MURK_KEY or MURK_KEY_FILE, run `direnv allow` (or `source .env`) so it is exported to the environment — murk no longer reads .env directly."
150 .into(),
151 )
152}
153
154pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
156 resolve_key_with_source(vault_path).map(|(k, _)| k)
157}
158
159pub fn key_from_env_only() -> Result<Option<String>, String> {
173 if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
174 return Ok(Some(k));
175 }
176 if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
177 let p = std::path::Path::new(&path);
178 reject_symlink(p, "MURK_KEY_FILE")?;
179 let key = std::fs::read_to_string(p)
180 .map_err(|e| format!("cannot read MURK_KEY_FILE: {e}"))?
181 .trim()
182 .to_string();
183 return Ok(Some(key));
184 }
185 Ok(None)
186}
187
188pub fn parse_env(contents: &str) -> Vec<(String, Zeroizing<String>)> {
194 let mut pairs = Vec::new();
195
196 for line in contents.lines() {
197 let line = line.trim();
198
199 if line.is_empty() || line.starts_with('#') {
200 continue;
201 }
202
203 let line = line.strip_prefix("export ").unwrap_or(line);
204
205 let Some((key, value)) = line.split_once('=') else {
206 continue;
207 };
208
209 let key = key.trim();
210 let value = value.trim();
211
212 let value = value
214 .strip_prefix('"')
215 .and_then(|v| v.strip_suffix('"'))
216 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
217 .unwrap_or(value);
218
219 if key.is_empty() || IMPORT_SKIP.contains(&key) {
220 continue;
221 }
222
223 pairs.push((key.into(), Zeroizing::new(value.to_string())));
224 }
225
226 pairs
227}
228
229pub fn warn_env_permissions() {
231 #[cfg(unix)]
232 {
233 use std::os::unix::fs::PermissionsExt;
234 let env_path = Path::new(".env");
235 if env_path.exists()
236 && let Ok(meta) = fs::metadata(env_path)
237 {
238 let mode = meta.permissions().mode();
239 if mode & WORLD_READABLE_MASK != 0 {
240 eprintln!(
241 "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
242 mode & 0o777
243 );
244 }
245 }
246 }
247}
248
249pub fn dotenv_has_murk_key() -> bool {
251 let env_path = Path::new(".env");
252 if !env_path.exists() {
253 return false;
254 }
255 let contents = fs::read_to_string(env_path).unwrap_or_default();
256 contents.lines().any(|l| {
257 l.starts_with("MURK_KEY=")
258 || l.starts_with("export MURK_KEY=")
259 || l.starts_with("MURK_KEY_FILE=")
260 || l.starts_with("export MURK_KEY_FILE=")
261 })
262}
263
264pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
269 let env_path = Path::new(".env");
270 reject_symlink(env_path, ".env")?;
271
272 let existing = if env_path.exists() {
274 let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
275 let filtered: Vec<&str> = contents
276 .lines()
277 .filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
278 .collect();
279 filtered.join("\n") + "\n"
280 } else {
281 String::new()
282 };
283
284 let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
285
286 #[cfg(unix)]
288 {
289 use std::os::unix::fs::OpenOptionsExt;
290 let mut file = fs::OpenOptions::new()
291 .create(true)
292 .write(true)
293 .truncate(true)
294 .mode(SECRET_FILE_MODE)
295 .custom_flags(libc::O_NOFOLLOW)
296 .open(env_path)
297 .map_err(|e| format!("opening .env: {e}"))?;
298 file.write_all(full_content.as_bytes())
299 .map_err(|e| format!("writing .env: {e}"))?;
300 }
301
302 #[cfg(not(unix))]
303 {
304 fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
305 }
306
307 Ok(())
308}
309
310pub fn key_file_path(vault_path: &str) -> Result<std::path::PathBuf, String> {
317 use sha2::{Digest, Sha256};
318
319 let p = std::path::Path::new(vault_path);
320 let abs_path = if p.is_absolute() {
321 p.to_path_buf()
322 } else {
323 std::env::current_dir()
324 .map_err(|e| format!("cannot resolve vault path: {e}"))?
325 .join(p)
326 };
327
328 let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
329 let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
330 use std::fmt::Write;
331 let _ = write!(s, "{b:02x}");
332 s
333 });
334
335 let config_dir = dirs_path()?;
336 Ok(config_dir.join(&short_hash))
337}
338
339pub fn agent_key_file_path(vault_path: &str, name: &str) -> Result<std::path::PathBuf, String> {
347 use sha2::{Digest, Sha256};
348
349 let p = std::path::Path::new(vault_path);
350 let abs_path = if p.is_absolute() {
351 p.to_path_buf()
352 } else {
353 std::env::current_dir()
354 .map_err(|e| format!("cannot resolve vault path: {e}"))?
355 .join(p)
356 };
357 let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
358 let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
359 use std::fmt::Write;
360 let _ = write!(s, "{b:02x}");
361 s
362 });
363
364 Ok(agent_keys_dir()?.join(format!("{short_hash}-{name}")))
365}
366
367pub fn agent_keys_dir() -> Result<std::path::PathBuf, String> {
369 let home = std::env::var("HOME")
370 .or_else(|_| std::env::var("USERPROFILE"))
371 .map_err(|_| "cannot determine home directory")?;
372 let dir = std::path::Path::new(&home)
373 .join(".config")
374 .join("murk")
375 .join("agent-keys");
376 fs::create_dir_all(&dir).map_err(|e| format!("creating agent key directory: {e}"))?;
377
378 #[cfg(unix)]
379 {
380 use std::os::unix::fs::PermissionsExt;
381 let parent = dir.parent().unwrap(); fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
383 .map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
384 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
385 .map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
386 }
387
388 Ok(dir)
389}
390
391fn dirs_path() -> Result<std::path::PathBuf, String> {
393 let home = std::env::var("HOME")
394 .or_else(|_| std::env::var("USERPROFILE"))
395 .map_err(|_| "cannot determine home directory")?;
396 let dir = std::path::Path::new(&home)
397 .join(".config")
398 .join("murk")
399 .join("keys");
400 fs::create_dir_all(&dir).map_err(|e| format!("creating key directory: {e}"))?;
401
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::PermissionsExt;
405 let parent = dir.parent().unwrap(); fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
407 .map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
408 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
409 .map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
410 }
411
412 Ok(dir)
413}
414
415pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
417 reject_symlink(path, &path.display().to_string())?;
418 #[cfg(unix)]
419 {
420 use std::os::unix::fs::OpenOptionsExt;
421 let mut file = fs::OpenOptions::new()
422 .create(true)
423 .write(true)
424 .truncate(true)
425 .mode(SECRET_FILE_MODE)
426 .custom_flags(libc::O_NOFOLLOW)
427 .open(path)
428 .map_err(|e| format!("writing key file: {e}"))?;
429 file.write_all(secret_key.as_bytes())
430 .map_err(|e| format!("writing key file: {e}"))?;
431 }
432 #[cfg(not(unix))]
433 {
434 fs::write(path, secret_key).map_err(|e| format!("writing key file: {e}"))?;
435 }
436 Ok(())
437}
438
439pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
441 let env_path = Path::new(".env");
442 reject_symlink(env_path, ".env")?;
443
444 let existing = if env_path.exists() {
445 let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
446 let filtered: Vec<&str> = contents
447 .lines()
448 .filter(|l| {
449 !l.starts_with("MURK_KEY=")
450 && !l.starts_with("export MURK_KEY=")
451 && !l.starts_with("MURK_KEY_FILE=")
452 && !l.starts_with("export MURK_KEY_FILE=")
453 })
454 .collect();
455 filtered.join("\n") + "\n"
456 } else {
457 String::new()
458 };
459
460 let full_content = format!(
461 "{existing}export MURK_KEY_FILE='{}'\n",
462 key_file_path.display().to_string().replace('\'', "'\\''")
463 );
464
465 #[cfg(unix)]
466 {
467 use std::os::unix::fs::OpenOptionsExt;
468 let mut file = fs::OpenOptions::new()
469 .create(true)
470 .write(true)
471 .truncate(true)
472 .mode(SECRET_FILE_MODE)
473 .custom_flags(libc::O_NOFOLLOW)
474 .open(env_path)
475 .map_err(|e| format!("opening .env: {e}"))?;
476 file.write_all(full_content.as_bytes())
477 .map_err(|e| format!("writing .env: {e}"))?;
478 }
479 #[cfg(not(unix))]
480 {
481 fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
482 }
483
484 Ok(())
485}
486
487#[derive(Debug, PartialEq, Eq)]
489pub enum EnvrcStatus {
490 AlreadyPresent,
492 Appended,
494 Created,
496}
497
498pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
503 let envrc = Path::new(".envrc");
504 reject_symlink(envrc, ".envrc")?;
505 let safe_vault_name = shell_escape(vault_name);
506 let murk_line = format!("eval \"$(murk export --vault {safe_vault_name})\"");
507
508 if envrc.exists() {
509 let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
510 if contents.contains("murk export") {
511 return Ok(EnvrcStatus::AlreadyPresent);
512 }
513 let mut file = fs::OpenOptions::new()
514 .append(true)
515 .open(envrc)
516 .map_err(|e| format!("writing .envrc: {e}"))?;
517 writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
518 Ok(EnvrcStatus::Appended)
519 } else {
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::OpenOptionsExt;
523 let mut file = fs::OpenOptions::new()
524 .create(true)
525 .write(true)
526 .truncate(true)
527 .mode(SECRET_FILE_MODE)
528 .custom_flags(libc::O_NOFOLLOW)
529 .open(envrc)
530 .map_err(|e| format!("writing .envrc: {e}"))?;
531 file.write_all(format!("{murk_line}\n").as_bytes())
532 .map_err(|e| format!("writing .envrc: {e}"))?;
533 }
534 #[cfg(not(unix))]
535 {
536 fs::write(envrc, format!("{murk_line}\n"))
537 .map_err(|e| format!("writing .envrc: {e}"))?;
538 }
539 Ok(EnvrcStatus::Created)
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 use age::secrecy::ExposeSecret;
548
549 use crate::testutil::{CWD_LOCK, ENV_LOCK};
550
551 #[test]
552 fn parse_env_empty() {
553 assert!(parse_env("").is_empty());
554 }
555
556 #[test]
557 fn parse_env_comments_and_blanks() {
558 let input = "# comment\n\n # another\n";
559 assert!(parse_env(input).is_empty());
560 }
561
562 fn assert_pairs(pairs: Vec<(String, Zeroizing<String>)>, expected: &[(&str, &str)]) {
565 let actual: Vec<(String, String)> =
566 pairs.into_iter().map(|(k, v)| (k, (*v).clone())).collect();
567 let want: Vec<(String, String)> = expected
568 .iter()
569 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
570 .collect();
571 assert_eq!(actual, want);
572 }
573
574 #[test]
575 fn parse_env_basic() {
576 assert_pairs(
577 parse_env("FOO=bar\nBAZ=qux\n"),
578 &[("FOO", "bar"), ("BAZ", "qux")],
579 );
580 }
581
582 #[test]
583 fn parse_env_double_quotes() {
584 assert_pairs(
585 parse_env("KEY=\"hello world\"\n"),
586 &[("KEY", "hello world")],
587 );
588 }
589
590 #[test]
591 fn parse_env_single_quotes() {
592 assert_pairs(parse_env("KEY='hello world'\n"), &[("KEY", "hello world")]);
593 }
594
595 #[test]
596 fn parse_env_export_prefix() {
597 assert_pairs(parse_env("export FOO=bar\n"), &[("FOO", "bar")]);
598 }
599
600 #[test]
601 fn parse_env_skips_murk_keys() {
602 let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
603 assert_pairs(parse_env(input), &[("KEEP", "yes")]);
604 }
605
606 #[test]
607 fn parse_env_equals_in_value() {
608 assert_pairs(
609 parse_env("URL=postgres://host?opt=1\n"),
610 &[("URL", "postgres://host?opt=1")],
611 );
612 }
613
614 #[test]
615 fn parse_env_no_equals_skipped() {
616 assert_pairs(parse_env("not-a-valid-line\nKEY=val\n"), &[("KEY", "val")]);
617 }
618
619 #[test]
622 fn parse_env_empty_value() {
623 assert_pairs(parse_env("KEY=\n"), &[("KEY", "")]);
624 }
625
626 #[test]
627 fn parse_env_trailing_whitespace() {
628 assert_pairs(parse_env("KEY=value \n"), &[("KEY", "value")]);
629 }
630
631 #[test]
632 fn parse_env_unicode_value() {
633 assert_pairs(parse_env("KEY=hello🔐world\n"), &[("KEY", "hello🔐world")]);
634 }
635
636 #[test]
637 fn parse_env_empty_key_skipped() {
638 let pairs = parse_env("=value\n");
639 assert!(pairs.is_empty());
640 }
641
642 #[test]
643 fn parse_env_mixed_quotes_unmatched() {
644 assert_pairs(parse_env("KEY=\"hello'\n"), &[("KEY", "\"hello'")]);
646 }
647
648 #[test]
649 fn parse_env_multiple_murk_vars() {
650 let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
652 assert_pairs(parse_env(input), &[("A", "1"), ("B", "2")]);
653 }
654
655 fn resolve_key_sandbox(
660 name: &str,
661 ) -> (
662 std::sync::MutexGuard<'static, ()>,
663 std::sync::MutexGuard<'static, ()>,
664 std::path::PathBuf,
665 std::path::PathBuf,
666 ) {
667 let env = ENV_LOCK
668 .lock()
669 .unwrap_or_else(std::sync::PoisonError::into_inner);
670 let cwd = CWD_LOCK
671 .lock()
672 .unwrap_or_else(std::sync::PoisonError::into_inner);
673 let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
674 let _ = std::fs::create_dir_all(&tmp);
675 let prev = std::env::current_dir().unwrap();
676 std::env::set_current_dir(&tmp).unwrap();
677 (env, cwd, tmp, prev)
678 }
679
680 fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
681 std::env::set_current_dir(prev).unwrap();
682 let _ = std::fs::remove_dir_all(tmp);
683 }
684
685 #[test]
686 fn resolve_key_from_env() {
687 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
688 let key = "AGE-SECRET-KEY-1TEST";
689 unsafe { env::set_var("MURK_KEY", key) };
690 let result = resolve_key();
691 unsafe { env::remove_var("MURK_KEY") };
692 resolve_key_sandbox_teardown(&tmp, &prev);
693
694 let secret = result.unwrap();
695 assert_eq!(secret.expose_secret(), key);
696 }
697
698 #[test]
699 fn resolve_key_from_file() {
700 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
701 unsafe { env::remove_var("MURK_KEY") };
702
703 let path = std::env::temp_dir().join("murk_test_key_file");
704 {
705 #[cfg(unix)]
706 {
707 use std::os::unix::fs::OpenOptionsExt;
708 let mut f = std::fs::OpenOptions::new()
709 .create(true)
710 .write(true)
711 .truncate(true)
712 .mode(0o600)
713 .open(&path)
714 .unwrap();
715 std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
716 }
717 #[cfg(not(unix))]
718 std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
719 }
720
721 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
722 let result = resolve_key();
723 unsafe { env::remove_var("MURK_KEY_FILE") };
724 std::fs::remove_file(&path).ok();
725 resolve_key_sandbox_teardown(&tmp, &prev);
726
727 let secret = result.unwrap();
728 assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE");
731 }
732
733 #[test]
734 fn resolve_key_file_not_found() {
735 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
736 unsafe { env::remove_var("MURK_KEY") };
737 unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
738 let result = resolve_key();
739 unsafe { env::remove_var("MURK_KEY_FILE") };
740 resolve_key_sandbox_teardown(&tmp, &prev);
741
742 assert!(result.is_err());
743 assert!(result.unwrap_err().contains("cannot read"));
744 }
745
746 #[test]
747 fn resolve_key_neither_set() {
748 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
749 unsafe { env::remove_var("MURK_KEY") };
750 unsafe { env::remove_var("MURK_KEY_FILE") };
751 let result = resolve_key();
752 resolve_key_sandbox_teardown(&tmp, &prev);
753
754 assert!(result.is_err());
755 assert!(result.unwrap_err().contains("MURK_KEY not set"));
756 }
757
758 #[test]
759 fn resolve_key_empty_string_treated_as_unset() {
760 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
761 unsafe { env::set_var("MURK_KEY", "") };
762 unsafe { env::remove_var("MURK_KEY_FILE") };
763 let result = resolve_key();
764 unsafe { env::remove_var("MURK_KEY") };
765 resolve_key_sandbox_teardown(&tmp, &prev);
766
767 assert!(result.is_err());
768 assert!(result.unwrap_err().contains("MURK_KEY not set"));
769 }
770
771 #[test]
772 fn resolve_key_murk_key_takes_priority_over_file() {
773 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
774 let direct_key = "AGE-SECRET-KEY-1DIRECT";
775 let file_key = "AGE-SECRET-KEY-1FILE";
776
777 let path = std::env::temp_dir().join("murk_test_key_priority");
778 std::fs::write(&path, format!("{file_key}\n")).unwrap();
779
780 unsafe { env::set_var("MURK_KEY", direct_key) };
781 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
782 let result = resolve_key();
783 unsafe { env::remove_var("MURK_KEY") };
784 unsafe { env::remove_var("MURK_KEY_FILE") };
785 std::fs::remove_file(&path).ok();
786 resolve_key_sandbox_teardown(&tmp, &prev);
787
788 let secret = result.unwrap();
789 assert_eq!(secret.expose_secret(), direct_key);
790 }
791
792 #[cfg(unix)]
793 #[test]
794 fn warn_env_permissions_no_warning_on_secure_file() {
795 use std::os::unix::fs::PermissionsExt;
796
797 let _cwd = CWD_LOCK
798 .lock()
799 .unwrap_or_else(std::sync::PoisonError::into_inner);
800
801 let dir = std::env::temp_dir().join("murk_test_perms");
802 let _ = std::fs::remove_dir_all(&dir);
803 std::fs::create_dir_all(&dir).unwrap();
804 let env_path = dir.join(".env");
805 std::fs::write(&env_path, "KEY=val\n").unwrap();
806 std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
807
808 let original_dir = std::env::current_dir().unwrap();
810 std::env::set_current_dir(&dir).unwrap();
811 warn_env_permissions();
812 std::env::set_current_dir(original_dir).unwrap();
813
814 std::fs::remove_dir_all(&dir).unwrap();
815 }
816
817 #[test]
818 fn resolve_key_does_not_read_dotenv() {
819 let _env_lock = ENV_LOCK
827 .lock()
828 .unwrap_or_else(std::sync::PoisonError::into_inner);
829 let _cwd = CWD_LOCK
830 .lock()
831 .unwrap_or_else(std::sync::PoisonError::into_inner);
832 let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
833 let _ = std::fs::remove_dir_all(&dir);
834 std::fs::create_dir_all(&dir).unwrap();
835 std::fs::write(
836 dir.join(".env"),
837 "MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
838 )
839 .unwrap();
840
841 let prev_key = env::var(ENV_MURK_KEY).ok();
843 let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
844 unsafe {
845 env::remove_var(ENV_MURK_KEY);
846 env::remove_var(ENV_MURK_KEY_FILE);
847 }
848
849 let original_dir = std::env::current_dir().unwrap();
850 std::env::set_current_dir(&dir).unwrap();
851 let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
853 std::env::set_current_dir(original_dir).unwrap();
854
855 unsafe {
856 if let Some(v) = prev_key {
857 env::set_var(ENV_MURK_KEY, v);
858 }
859 if let Some(v) = prev_keyfile {
860 env::set_var(ENV_MURK_KEY_FILE, v);
861 }
862 }
863
864 assert!(
865 result.is_err(),
866 "resolve_key_with_source must not fall back to .env"
867 );
868 std::fs::remove_dir_all(&dir).unwrap();
869 }
870
871 #[test]
872 fn resolve_key_agent_context_disables_auto_fallback() {
873 let _env_lock = ENV_LOCK
883 .lock()
884 .unwrap_or_else(std::sync::PoisonError::into_inner);
885 let _cwd = CWD_LOCK
886 .lock()
887 .unwrap_or_else(std::sync::PoisonError::into_inner);
888
889 let prev_home = env::var_os("HOME");
890 let prev_key = env::var(ENV_MURK_KEY).ok();
891 let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
892 let prev_agent = env::var("MURK_AGENT").ok();
893 let prev_strict = env::var("MURK_STRICT").ok();
894
895 let home = std::env::temp_dir().join("murk_test_agent_fallback_home");
896 let vault_dir = std::env::temp_dir().join("murk_test_agent_fallback_vault");
897 let _ = std::fs::remove_dir_all(&home);
898 let _ = std::fs::remove_dir_all(&vault_dir);
899 std::fs::create_dir_all(&home).unwrap();
900 std::fs::create_dir_all(&vault_dir).unwrap();
901
902 let original_dir = std::env::current_dir().unwrap();
903 unsafe {
904 env::remove_var(ENV_MURK_KEY);
905 env::remove_var(ENV_MURK_KEY_FILE);
906 env::remove_var("MURK_AGENT");
907 env::remove_var("MURK_STRICT");
908 env::set_var("HOME", &home);
909 }
910 std::env::set_current_dir(&vault_dir).unwrap();
911
912 let vault_path = "auto-fallback-test.murk";
913 let key = "AGE-SECRET-KEY-1AUTOFALLBACK";
914 let auto_path = key_file_path(vault_path).unwrap();
915 write_key_to_file(&auto_path, key).unwrap();
916
917 let plain = resolve_key_with_source(vault_path);
919
920 unsafe { env::set_var("MURK_AGENT", "1") };
922 let agent = resolve_key_with_source(vault_path);
923
924 unsafe { env::set_var("MURK_STRICT", "0") };
926 let agent_with_explicit_off = resolve_key_with_source(vault_path);
927
928 std::env::set_current_dir(&original_dir).unwrap();
929 unsafe {
930 match prev_home {
931 Some(v) => env::set_var("HOME", v),
932 None => env::remove_var("HOME"),
933 }
934 match prev_key {
935 Some(v) => env::set_var(ENV_MURK_KEY, v),
936 None => env::remove_var(ENV_MURK_KEY),
937 }
938 match prev_keyfile {
939 Some(v) => env::set_var(ENV_MURK_KEY_FILE, v),
940 None => env::remove_var(ENV_MURK_KEY_FILE),
941 }
942 match prev_agent {
943 Some(v) => env::set_var("MURK_AGENT", v),
944 None => env::remove_var("MURK_AGENT"),
945 }
946 match prev_strict {
947 Some(v) => env::set_var("MURK_STRICT", v),
948 None => env::remove_var("MURK_STRICT"),
949 }
950 }
951 std::fs::remove_dir_all(&home).ok();
952 std::fs::remove_dir_all(&vault_dir).ok();
953
954 let (_, source) = plain.expect("auto fallback should succeed without agent/strict context");
955 assert_eq!(source, KeySource::Auto(auto_path.clone()));
956
957 assert!(
958 agent.is_err(),
959 "MURK_AGENT=1 must disable the stored-key auto fallback"
960 );
961
962 assert!(
963 agent_with_explicit_off.is_err(),
964 "MURK_STRICT=0 must not re-enable auto fallback while MURK_AGENT is set"
965 );
966 }
967
968 #[test]
969 fn dotenv_has_murk_key_true() {
970 let _cwd = CWD_LOCK
971 .lock()
972 .unwrap_or_else(std::sync::PoisonError::into_inner);
973 let dir = std::env::temp_dir().join("murk_test_has_key_true");
974 let _ = std::fs::remove_dir_all(&dir);
975 std::fs::create_dir_all(&dir).unwrap();
976 std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
977
978 let original_dir = std::env::current_dir().unwrap();
979 std::env::set_current_dir(&dir).unwrap();
980 assert!(dotenv_has_murk_key());
981 std::env::set_current_dir(original_dir).unwrap();
982
983 std::fs::remove_dir_all(&dir).unwrap();
984 }
985
986 #[test]
987 fn dotenv_has_murk_key_false() {
988 let _cwd = CWD_LOCK
989 .lock()
990 .unwrap_or_else(std::sync::PoisonError::into_inner);
991 let dir = std::env::temp_dir().join("murk_test_has_key_false");
992 let _ = std::fs::remove_dir_all(&dir);
993 std::fs::create_dir_all(&dir).unwrap();
994 std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
995
996 let original_dir = std::env::current_dir().unwrap();
997 std::env::set_current_dir(&dir).unwrap();
998 assert!(!dotenv_has_murk_key());
999 std::env::set_current_dir(original_dir).unwrap();
1000
1001 std::fs::remove_dir_all(&dir).unwrap();
1002 }
1003
1004 #[test]
1005 fn dotenv_has_murk_key_no_file() {
1006 let _cwd = CWD_LOCK
1007 .lock()
1008 .unwrap_or_else(std::sync::PoisonError::into_inner);
1009 let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
1010 let _ = std::fs::remove_dir_all(&dir);
1011 std::fs::create_dir_all(&dir).unwrap();
1012
1013 let original_dir = std::env::current_dir().unwrap();
1014 std::env::set_current_dir(&dir).unwrap();
1015 assert!(!dotenv_has_murk_key());
1016 std::env::set_current_dir(original_dir).unwrap();
1017
1018 std::fs::remove_dir_all(&dir).unwrap();
1019 }
1020
1021 #[test]
1022 fn write_key_to_dotenv_creates_new() {
1023 let _cwd = CWD_LOCK
1024 .lock()
1025 .unwrap_or_else(std::sync::PoisonError::into_inner);
1026 let dir = std::env::temp_dir().join("murk_test_write_key_new");
1027 let _ = std::fs::remove_dir_all(&dir);
1028 std::fs::create_dir_all(&dir).unwrap();
1029
1030 let original_dir = std::env::current_dir().unwrap();
1031 std::env::set_current_dir(&dir).unwrap();
1032 write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
1033
1034 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
1035 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
1036
1037 std::env::set_current_dir(original_dir).unwrap();
1038 std::fs::remove_dir_all(&dir).unwrap();
1039 }
1040
1041 #[test]
1042 fn write_key_to_dotenv_replaces_existing() {
1043 let _cwd = CWD_LOCK
1044 .lock()
1045 .unwrap_or_else(std::sync::PoisonError::into_inner);
1046 let dir = std::env::temp_dir().join("murk_test_write_key_replace");
1047 let _ = std::fs::remove_dir_all(&dir);
1048 std::fs::create_dir_all(&dir).unwrap();
1049 std::fs::write(
1050 dir.join(".env"),
1051 "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
1052 )
1053 .unwrap();
1054
1055 let original_dir = std::env::current_dir().unwrap();
1056 std::env::set_current_dir(&dir).unwrap();
1057 write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
1058
1059 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
1060 assert!(contents.contains("OTHER=keep"));
1061 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
1062 assert!(!contents.contains("MURK_KEY=old"));
1063 assert!(!contents.contains("also_old"));
1064
1065 std::env::set_current_dir(original_dir).unwrap();
1066 std::fs::remove_dir_all(&dir).unwrap();
1067 }
1068
1069 #[cfg(unix)]
1070 #[test]
1071 fn write_key_to_dotenv_permissions_are_600() {
1072 use std::os::unix::fs::PermissionsExt;
1073
1074 let _cwd = CWD_LOCK
1075 .lock()
1076 .unwrap_or_else(std::sync::PoisonError::into_inner);
1077
1078 let dir = std::env::temp_dir().join("murk_test_write_key_perms");
1079 let _ = std::fs::remove_dir_all(&dir);
1080 std::fs::create_dir_all(&dir).unwrap();
1081
1082 let original_dir = std::env::current_dir().unwrap();
1083 std::env::set_current_dir(&dir).unwrap();
1084
1085 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
1087 let meta = std::fs::metadata(dir.join(".env")).unwrap();
1088 assert_eq!(
1089 meta.permissions().mode() & 0o777,
1090 SECRET_FILE_MODE,
1091 "new .env should be created with mode 600"
1092 );
1093
1094 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
1096 let meta = std::fs::metadata(dir.join(".env")).unwrap();
1097 assert_eq!(
1098 meta.permissions().mode() & 0o777,
1099 SECRET_FILE_MODE,
1100 "rewritten .env should maintain mode 600"
1101 );
1102
1103 std::env::set_current_dir(original_dir).unwrap();
1104 std::fs::remove_dir_all(&dir).unwrap();
1105 }
1106
1107 #[test]
1108 fn write_envrc_creates_new() {
1109 let _cwd = CWD_LOCK
1110 .lock()
1111 .unwrap_or_else(std::sync::PoisonError::into_inner);
1112 let dir = std::env::temp_dir().join("murk_test_envrc_new");
1113 let _ = std::fs::remove_dir_all(&dir);
1114 std::fs::create_dir_all(&dir).unwrap();
1115
1116 let original_dir = std::env::current_dir().unwrap();
1117 std::env::set_current_dir(&dir).unwrap();
1118 let status = write_envrc(".murk").unwrap();
1119 assert_eq!(status, EnvrcStatus::Created);
1120
1121 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1122 assert!(contents.contains("murk export --vault .murk"));
1123
1124 std::env::set_current_dir(original_dir).unwrap();
1125 std::fs::remove_dir_all(&dir).unwrap();
1126 }
1127
1128 #[test]
1129 fn write_envrc_appends() {
1130 let _cwd = CWD_LOCK
1131 .lock()
1132 .unwrap_or_else(std::sync::PoisonError::into_inner);
1133 let dir = std::env::temp_dir().join("murk_test_envrc_append");
1134 let _ = std::fs::remove_dir_all(&dir);
1135 std::fs::create_dir_all(&dir).unwrap();
1136 std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
1137
1138 let original_dir = std::env::current_dir().unwrap();
1139 std::env::set_current_dir(&dir).unwrap();
1140 let status = write_envrc(".murk").unwrap();
1141 assert_eq!(status, EnvrcStatus::Appended);
1142
1143 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1144 assert!(contents.contains("existing content"));
1145 assert!(contents.contains("murk export"));
1146
1147 std::env::set_current_dir(original_dir).unwrap();
1148 std::fs::remove_dir_all(&dir).unwrap();
1149 }
1150
1151 #[test]
1152 fn write_envrc_already_present() {
1153 let _cwd = CWD_LOCK
1154 .lock()
1155 .unwrap_or_else(std::sync::PoisonError::into_inner);
1156 let dir = std::env::temp_dir().join("murk_test_envrc_present");
1157 let _ = std::fs::remove_dir_all(&dir);
1158 std::fs::create_dir_all(&dir).unwrap();
1159 std::fs::write(
1160 dir.join(".envrc"),
1161 "eval \"$(murk export --vault .murk)\"\n",
1162 )
1163 .unwrap();
1164
1165 let original_dir = std::env::current_dir().unwrap();
1166 std::env::set_current_dir(&dir).unwrap();
1167 let status = write_envrc(".murk").unwrap();
1168 assert_eq!(status, EnvrcStatus::AlreadyPresent);
1169
1170 std::env::set_current_dir(original_dir).unwrap();
1171 std::fs::remove_dir_all(&dir).unwrap();
1172 }
1173
1174 #[test]
1175 fn reject_symlink_ok_for_regular_file() {
1176 let dir = tempfile::TempDir::new().unwrap();
1177 let path = dir.path().join("regular.txt");
1178 std::fs::write(&path, "content").unwrap();
1179 assert!(reject_symlink(&path, "test").is_ok());
1180 }
1181
1182 #[test]
1183 fn reject_symlink_ok_for_nonexistent() {
1184 let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
1185 assert!(reject_symlink(path, "test").is_ok());
1186 }
1187
1188 #[cfg(unix)]
1189 #[test]
1190 fn reject_symlink_rejects_symlink() {
1191 let dir = tempfile::TempDir::new().unwrap();
1192 let link = dir.path().join("link");
1193 std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
1194 let result = reject_symlink(&link, "test");
1195 assert!(result.is_err());
1196 assert!(result.unwrap_err().contains("symlink"));
1197 }
1198
1199 #[cfg(unix)]
1200 #[test]
1201 fn read_secret_file_rejects_world_readable() {
1202 use std::os::unix::fs::PermissionsExt;
1203 let dir = tempfile::TempDir::new().unwrap();
1204 let path = dir.path().join("loose.key");
1205 std::fs::write(&path, "secret").unwrap();
1206 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1207 let result = read_secret_file(&path, "test");
1208 assert!(result.is_err());
1209 assert!(result.unwrap_err().contains("readable by others"));
1210 }
1211
1212 #[cfg(unix)]
1213 #[test]
1214 fn read_secret_file_accepts_600() {
1215 use std::os::unix::fs::OpenOptionsExt;
1216 let dir = tempfile::TempDir::new().unwrap();
1217 let path = dir.path().join("tight.key");
1218 let mut f = std::fs::OpenOptions::new()
1219 .create(true)
1220 .truncate(true)
1221 .write(true)
1222 .mode(0o600)
1223 .open(&path)
1224 .unwrap();
1225 std::io::Write::write_all(&mut f, b"secret").unwrap();
1226 let result = read_secret_file(&path, "test");
1227 assert!(result.is_ok());
1228 assert_eq!(result.unwrap(), "secret");
1229 }
1230
1231 #[test]
1232 fn shell_escape_bare_identifiers() {
1233 assert_eq!(shell_escape(".murk"), ".murk");
1234 assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
1235 assert_eq!(
1236 shell_escape("/home/user/.config/murk/key"),
1237 "/home/user/.config/murk/key"
1238 );
1239 }
1240
1241 #[test]
1242 fn shell_escape_quotes_special_chars() {
1243 assert_eq!(shell_escape("my vault"), "'my vault'");
1244 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1245 assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
1246 }
1247
1248 #[test]
1249 fn write_envrc_escapes_vault_name() {
1250 let _cwd = CWD_LOCK
1251 .lock()
1252 .unwrap_or_else(std::sync::PoisonError::into_inner);
1253 let dir = std::env::temp_dir().join("murk_test_envrc_escape");
1254 let _ = std::fs::remove_dir_all(&dir);
1255 std::fs::create_dir_all(&dir).unwrap();
1256
1257 let original_dir = std::env::current_dir().unwrap();
1258 std::env::set_current_dir(&dir).unwrap();
1259 let status = write_envrc("my vault.murk").unwrap();
1260 assert_eq!(status, EnvrcStatus::Created);
1261
1262 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1263 assert!(contents.contains("'my vault.murk'"));
1264
1265 std::env::set_current_dir(original_dir).unwrap();
1266 std::fs::remove_dir_all(&dir).unwrap();
1267 }
1268}