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