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