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