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 crate::testutil::{CWD_LOCK, ENV_LOCK};
459
460 #[test]
461 fn parse_env_empty() {
462 assert!(parse_env("").is_empty());
463 }
464
465 #[test]
466 fn parse_env_comments_and_blanks() {
467 let input = "# comment\n\n # another\n";
468 assert!(parse_env(input).is_empty());
469 }
470
471 fn assert_pairs(pairs: Vec<(String, Zeroizing<String>)>, expected: &[(&str, &str)]) {
474 let actual: Vec<(String, String)> =
475 pairs.into_iter().map(|(k, v)| (k, (*v).clone())).collect();
476 let want: Vec<(String, String)> = expected
477 .iter()
478 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
479 .collect();
480 assert_eq!(actual, want);
481 }
482
483 #[test]
484 fn parse_env_basic() {
485 assert_pairs(
486 parse_env("FOO=bar\nBAZ=qux\n"),
487 &[("FOO", "bar"), ("BAZ", "qux")],
488 );
489 }
490
491 #[test]
492 fn parse_env_double_quotes() {
493 assert_pairs(
494 parse_env("KEY=\"hello world\"\n"),
495 &[("KEY", "hello world")],
496 );
497 }
498
499 #[test]
500 fn parse_env_single_quotes() {
501 assert_pairs(parse_env("KEY='hello world'\n"), &[("KEY", "hello world")]);
502 }
503
504 #[test]
505 fn parse_env_export_prefix() {
506 assert_pairs(parse_env("export FOO=bar\n"), &[("FOO", "bar")]);
507 }
508
509 #[test]
510 fn parse_env_skips_murk_keys() {
511 let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
512 assert_pairs(parse_env(input), &[("KEEP", "yes")]);
513 }
514
515 #[test]
516 fn parse_env_equals_in_value() {
517 assert_pairs(
518 parse_env("URL=postgres://host?opt=1\n"),
519 &[("URL", "postgres://host?opt=1")],
520 );
521 }
522
523 #[test]
524 fn parse_env_no_equals_skipped() {
525 assert_pairs(parse_env("not-a-valid-line\nKEY=val\n"), &[("KEY", "val")]);
526 }
527
528 #[test]
531 fn parse_env_empty_value() {
532 assert_pairs(parse_env("KEY=\n"), &[("KEY", "")]);
533 }
534
535 #[test]
536 fn parse_env_trailing_whitespace() {
537 assert_pairs(parse_env("KEY=value \n"), &[("KEY", "value")]);
538 }
539
540 #[test]
541 fn parse_env_unicode_value() {
542 assert_pairs(parse_env("KEY=hello🔐world\n"), &[("KEY", "hello🔐world")]);
543 }
544
545 #[test]
546 fn parse_env_empty_key_skipped() {
547 let pairs = parse_env("=value\n");
548 assert!(pairs.is_empty());
549 }
550
551 #[test]
552 fn parse_env_mixed_quotes_unmatched() {
553 assert_pairs(parse_env("KEY=\"hello'\n"), &[("KEY", "\"hello'")]);
555 }
556
557 #[test]
558 fn parse_env_multiple_murk_vars() {
559 let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
561 assert_pairs(parse_env(input), &[("A", "1"), ("B", "2")]);
562 }
563
564 fn resolve_key_sandbox(
569 name: &str,
570 ) -> (
571 std::sync::MutexGuard<'static, ()>,
572 std::sync::MutexGuard<'static, ()>,
573 std::path::PathBuf,
574 std::path::PathBuf,
575 ) {
576 let env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
577 let cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
578 let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
579 let _ = std::fs::create_dir_all(&tmp);
580 let prev = std::env::current_dir().unwrap();
581 std::env::set_current_dir(&tmp).unwrap();
582 (env, cwd, tmp, prev)
583 }
584
585 fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
586 std::env::set_current_dir(prev).unwrap();
587 let _ = std::fs::remove_dir_all(tmp);
588 }
589
590 #[test]
591 fn resolve_key_from_env() {
592 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
593 let key = "AGE-SECRET-KEY-1TEST";
594 unsafe { env::set_var("MURK_KEY", key) };
595 let result = resolve_key();
596 unsafe { env::remove_var("MURK_KEY") };
597 resolve_key_sandbox_teardown(&tmp, &prev);
598
599 let secret = result.unwrap();
600 use age::secrecy::ExposeSecret;
601 assert_eq!(secret.expose_secret(), key);
602 }
603
604 #[test]
605 fn resolve_key_from_file() {
606 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
607 unsafe { env::remove_var("MURK_KEY") };
608
609 let path = std::env::temp_dir().join("murk_test_key_file");
610 {
611 #[cfg(unix)]
612 {
613 use std::os::unix::fs::OpenOptionsExt;
614 let mut f = std::fs::OpenOptions::new()
615 .create(true)
616 .write(true)
617 .truncate(true)
618 .mode(0o600)
619 .open(&path)
620 .unwrap();
621 std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
622 }
623 #[cfg(not(unix))]
624 std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
625 }
626
627 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
628 let result = resolve_key();
629 unsafe { env::remove_var("MURK_KEY_FILE") };
630 std::fs::remove_file(&path).ok();
631 resolve_key_sandbox_teardown(&tmp, &prev);
632
633 let secret = result.unwrap();
634 use age::secrecy::ExposeSecret;
635 assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE");
638 }
639
640 #[test]
641 fn resolve_key_file_not_found() {
642 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
643 unsafe { env::remove_var("MURK_KEY") };
644 unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
645 let result = resolve_key();
646 unsafe { env::remove_var("MURK_KEY_FILE") };
647 resolve_key_sandbox_teardown(&tmp, &prev);
648
649 assert!(result.is_err());
650 assert!(result.unwrap_err().contains("cannot read"));
651 }
652
653 #[test]
654 fn resolve_key_neither_set() {
655 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
656 unsafe { env::remove_var("MURK_KEY") };
657 unsafe { env::remove_var("MURK_KEY_FILE") };
658 let result = resolve_key();
659 resolve_key_sandbox_teardown(&tmp, &prev);
660
661 assert!(result.is_err());
662 assert!(result.unwrap_err().contains("MURK_KEY not set"));
663 }
664
665 #[test]
666 fn resolve_key_empty_string_treated_as_unset() {
667 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
668 unsafe { env::set_var("MURK_KEY", "") };
669 unsafe { env::remove_var("MURK_KEY_FILE") };
670 let result = resolve_key();
671 unsafe { env::remove_var("MURK_KEY") };
672 resolve_key_sandbox_teardown(&tmp, &prev);
673
674 assert!(result.is_err());
675 assert!(result.unwrap_err().contains("MURK_KEY not set"));
676 }
677
678 #[test]
679 fn resolve_key_murk_key_takes_priority_over_file() {
680 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
681 let direct_key = "AGE-SECRET-KEY-1DIRECT";
682 let file_key = "AGE-SECRET-KEY-1FILE";
683
684 let path = std::env::temp_dir().join("murk_test_key_priority");
685 std::fs::write(&path, format!("{file_key}\n")).unwrap();
686
687 unsafe { env::set_var("MURK_KEY", direct_key) };
688 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
689 let result = resolve_key();
690 unsafe { env::remove_var("MURK_KEY") };
691 unsafe { env::remove_var("MURK_KEY_FILE") };
692 std::fs::remove_file(&path).ok();
693 resolve_key_sandbox_teardown(&tmp, &prev);
694
695 let secret = result.unwrap();
696 use age::secrecy::ExposeSecret;
697 assert_eq!(secret.expose_secret(), direct_key);
698 }
699
700 #[cfg(unix)]
701 #[test]
702 fn warn_env_permissions_no_warning_on_secure_file() {
703 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
704 use std::os::unix::fs::PermissionsExt;
705
706 let dir = std::env::temp_dir().join("murk_test_perms");
707 let _ = std::fs::remove_dir_all(&dir);
708 std::fs::create_dir_all(&dir).unwrap();
709 let env_path = dir.join(".env");
710 std::fs::write(&env_path, "KEY=val\n").unwrap();
711 std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
712
713 let original_dir = std::env::current_dir().unwrap();
715 std::env::set_current_dir(&dir).unwrap();
716 warn_env_permissions();
717 std::env::set_current_dir(original_dir).unwrap();
718
719 std::fs::remove_dir_all(&dir).unwrap();
720 }
721
722 #[test]
723 fn resolve_key_does_not_read_dotenv() {
724 let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
732 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
733 let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
734 let _ = std::fs::remove_dir_all(&dir);
735 std::fs::create_dir_all(&dir).unwrap();
736 std::fs::write(
737 dir.join(".env"),
738 "MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
739 )
740 .unwrap();
741
742 let prev_key = env::var(ENV_MURK_KEY).ok();
744 let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
745 unsafe {
746 env::remove_var(ENV_MURK_KEY);
747 env::remove_var(ENV_MURK_KEY_FILE);
748 }
749
750 let original_dir = std::env::current_dir().unwrap();
751 std::env::set_current_dir(&dir).unwrap();
752 let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
754 std::env::set_current_dir(original_dir).unwrap();
755
756 unsafe {
757 if let Some(v) = prev_key {
758 env::set_var(ENV_MURK_KEY, v);
759 }
760 if let Some(v) = prev_keyfile {
761 env::set_var(ENV_MURK_KEY_FILE, v);
762 }
763 }
764
765 assert!(
766 result.is_err(),
767 "resolve_key_with_source must not fall back to .env"
768 );
769 std::fs::remove_dir_all(&dir).unwrap();
770 }
771
772 #[test]
773 fn dotenv_has_murk_key_true() {
774 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
775 let dir = std::env::temp_dir().join("murk_test_has_key_true");
776 let _ = std::fs::remove_dir_all(&dir);
777 std::fs::create_dir_all(&dir).unwrap();
778 std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
779
780 let original_dir = std::env::current_dir().unwrap();
781 std::env::set_current_dir(&dir).unwrap();
782 assert!(dotenv_has_murk_key());
783 std::env::set_current_dir(original_dir).unwrap();
784
785 std::fs::remove_dir_all(&dir).unwrap();
786 }
787
788 #[test]
789 fn dotenv_has_murk_key_false() {
790 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
791 let dir = std::env::temp_dir().join("murk_test_has_key_false");
792 let _ = std::fs::remove_dir_all(&dir);
793 std::fs::create_dir_all(&dir).unwrap();
794 std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
795
796 let original_dir = std::env::current_dir().unwrap();
797 std::env::set_current_dir(&dir).unwrap();
798 assert!(!dotenv_has_murk_key());
799 std::env::set_current_dir(original_dir).unwrap();
800
801 std::fs::remove_dir_all(&dir).unwrap();
802 }
803
804 #[test]
805 fn dotenv_has_murk_key_no_file() {
806 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
807 let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
808 let _ = std::fs::remove_dir_all(&dir);
809 std::fs::create_dir_all(&dir).unwrap();
810
811 let original_dir = std::env::current_dir().unwrap();
812 std::env::set_current_dir(&dir).unwrap();
813 assert!(!dotenv_has_murk_key());
814 std::env::set_current_dir(original_dir).unwrap();
815
816 std::fs::remove_dir_all(&dir).unwrap();
817 }
818
819 #[test]
820 fn write_key_to_dotenv_creates_new() {
821 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
822 let dir = std::env::temp_dir().join("murk_test_write_key_new");
823 let _ = std::fs::remove_dir_all(&dir);
824 std::fs::create_dir_all(&dir).unwrap();
825
826 let original_dir = std::env::current_dir().unwrap();
827 std::env::set_current_dir(&dir).unwrap();
828 write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
829
830 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
831 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
832
833 std::env::set_current_dir(original_dir).unwrap();
834 std::fs::remove_dir_all(&dir).unwrap();
835 }
836
837 #[test]
838 fn write_key_to_dotenv_replaces_existing() {
839 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
840 let dir = std::env::temp_dir().join("murk_test_write_key_replace");
841 let _ = std::fs::remove_dir_all(&dir);
842 std::fs::create_dir_all(&dir).unwrap();
843 std::fs::write(
844 dir.join(".env"),
845 "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
846 )
847 .unwrap();
848
849 let original_dir = std::env::current_dir().unwrap();
850 std::env::set_current_dir(&dir).unwrap();
851 write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
852
853 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
854 assert!(contents.contains("OTHER=keep"));
855 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
856 assert!(!contents.contains("MURK_KEY=old"));
857 assert!(!contents.contains("also_old"));
858
859 std::env::set_current_dir(original_dir).unwrap();
860 std::fs::remove_dir_all(&dir).unwrap();
861 }
862
863 #[cfg(unix)]
864 #[test]
865 fn write_key_to_dotenv_permissions_are_600() {
866 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
867 use std::os::unix::fs::PermissionsExt;
868
869 let dir = std::env::temp_dir().join("murk_test_write_key_perms");
870 let _ = std::fs::remove_dir_all(&dir);
871 std::fs::create_dir_all(&dir).unwrap();
872
873 let original_dir = std::env::current_dir().unwrap();
874 std::env::set_current_dir(&dir).unwrap();
875
876 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
878 let meta = std::fs::metadata(dir.join(".env")).unwrap();
879 assert_eq!(
880 meta.permissions().mode() & 0o777,
881 SECRET_FILE_MODE,
882 "new .env should be created with mode 600"
883 );
884
885 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
887 let meta = std::fs::metadata(dir.join(".env")).unwrap();
888 assert_eq!(
889 meta.permissions().mode() & 0o777,
890 SECRET_FILE_MODE,
891 "rewritten .env should maintain mode 600"
892 );
893
894 std::env::set_current_dir(original_dir).unwrap();
895 std::fs::remove_dir_all(&dir).unwrap();
896 }
897
898 #[test]
899 fn write_envrc_creates_new() {
900 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
901 let dir = std::env::temp_dir().join("murk_test_envrc_new");
902 let _ = std::fs::remove_dir_all(&dir);
903 std::fs::create_dir_all(&dir).unwrap();
904
905 let original_dir = std::env::current_dir().unwrap();
906 std::env::set_current_dir(&dir).unwrap();
907 let status = write_envrc(".murk").unwrap();
908 assert_eq!(status, EnvrcStatus::Created);
909
910 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
911 assert!(contents.contains("murk export --vault .murk"));
912
913 std::env::set_current_dir(original_dir).unwrap();
914 std::fs::remove_dir_all(&dir).unwrap();
915 }
916
917 #[test]
918 fn write_envrc_appends() {
919 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
920 let dir = std::env::temp_dir().join("murk_test_envrc_append");
921 let _ = std::fs::remove_dir_all(&dir);
922 std::fs::create_dir_all(&dir).unwrap();
923 std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
924
925 let original_dir = std::env::current_dir().unwrap();
926 std::env::set_current_dir(&dir).unwrap();
927 let status = write_envrc(".murk").unwrap();
928 assert_eq!(status, EnvrcStatus::Appended);
929
930 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
931 assert!(contents.contains("existing content"));
932 assert!(contents.contains("murk export"));
933
934 std::env::set_current_dir(original_dir).unwrap();
935 std::fs::remove_dir_all(&dir).unwrap();
936 }
937
938 #[test]
939 fn write_envrc_already_present() {
940 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
941 let dir = std::env::temp_dir().join("murk_test_envrc_present");
942 let _ = std::fs::remove_dir_all(&dir);
943 std::fs::create_dir_all(&dir).unwrap();
944 std::fs::write(
945 dir.join(".envrc"),
946 "eval \"$(murk export --vault .murk)\"\n",
947 )
948 .unwrap();
949
950 let original_dir = std::env::current_dir().unwrap();
951 std::env::set_current_dir(&dir).unwrap();
952 let status = write_envrc(".murk").unwrap();
953 assert_eq!(status, EnvrcStatus::AlreadyPresent);
954
955 std::env::set_current_dir(original_dir).unwrap();
956 std::fs::remove_dir_all(&dir).unwrap();
957 }
958
959 #[test]
960 fn reject_symlink_ok_for_regular_file() {
961 let dir = tempfile::TempDir::new().unwrap();
962 let path = dir.path().join("regular.txt");
963 std::fs::write(&path, "content").unwrap();
964 assert!(reject_symlink(&path, "test").is_ok());
965 }
966
967 #[test]
968 fn reject_symlink_ok_for_nonexistent() {
969 let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
970 assert!(reject_symlink(path, "test").is_ok());
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn reject_symlink_rejects_symlink() {
976 let dir = tempfile::TempDir::new().unwrap();
977 let link = dir.path().join("link");
978 std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
979 let result = reject_symlink(&link, "test");
980 assert!(result.is_err());
981 assert!(result.unwrap_err().contains("symlink"));
982 }
983
984 #[cfg(unix)]
985 #[test]
986 fn read_secret_file_rejects_world_readable() {
987 use std::os::unix::fs::PermissionsExt;
988 let dir = tempfile::TempDir::new().unwrap();
989 let path = dir.path().join("loose.key");
990 std::fs::write(&path, "secret").unwrap();
991 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
992 let result = read_secret_file(&path, "test");
993 assert!(result.is_err());
994 assert!(result.unwrap_err().contains("readable by others"));
995 }
996
997 #[cfg(unix)]
998 #[test]
999 fn read_secret_file_accepts_600() {
1000 use std::os::unix::fs::OpenOptionsExt;
1001 let dir = tempfile::TempDir::new().unwrap();
1002 let path = dir.path().join("tight.key");
1003 let mut f = std::fs::OpenOptions::new()
1004 .create(true)
1005 .write(true)
1006 .mode(0o600)
1007 .open(&path)
1008 .unwrap();
1009 std::io::Write::write_all(&mut f, b"secret").unwrap();
1010 let result = read_secret_file(&path, "test");
1011 assert!(result.is_ok());
1012 assert_eq!(result.unwrap(), "secret");
1013 }
1014
1015 #[test]
1016 fn shell_escape_bare_identifiers() {
1017 assert_eq!(shell_escape(".murk"), ".murk");
1018 assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
1019 assert_eq!(
1020 shell_escape("/home/user/.config/murk/key"),
1021 "/home/user/.config/murk/key"
1022 );
1023 }
1024
1025 #[test]
1026 fn shell_escape_quotes_special_chars() {
1027 assert_eq!(shell_escape("my vault"), "'my vault'");
1028 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1029 assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
1030 }
1031
1032 #[test]
1033 fn write_envrc_escapes_vault_name() {
1034 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1035 let dir = std::env::temp_dir().join("murk_test_envrc_escape");
1036 let _ = std::fs::remove_dir_all(&dir);
1037 std::fs::create_dir_all(&dir).unwrap();
1038
1039 let original_dir = std::env::current_dir().unwrap();
1040 std::env::set_current_dir(&dir).unwrap();
1041 let status = write_envrc("my vault.murk").unwrap();
1042 assert_eq!(status, EnvrcStatus::Created);
1043
1044 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1045 assert!(contents.contains("'my vault.murk'"));
1046
1047 std::env::set_current_dir(original_dir).unwrap();
1048 std::fs::remove_dir_all(&dir).unwrap();
1049 }
1050}