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