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