1use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use age::secrecy::SecretString;
9
10pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
13 if path.is_symlink() {
14 return Err(format!(
15 "{label} is a symlink — refusing to follow for security"
16 ));
17 }
18 Ok(())
19}
20
21fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
24 reject_symlink(path, label)?;
25
26 #[cfg(unix)]
27 {
28 use std::os::unix::fs::MetadataExt;
29 if let Ok(meta) = fs::metadata(path) {
30 let mode = meta.mode();
31 if mode & WORLD_READABLE_MASK != 0 {
32 return Err(format!(
33 "{label} is readable by others (mode {:o}). Run: chmod 600 {}",
34 mode & 0o777,
35 path.display()
36 ));
37 }
38 }
39 }
40
41 fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
42}
43
44pub const ENV_MURK_KEY: &str = "MURK_KEY";
46pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
48pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
50
51const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
53
54#[cfg(unix)]
56const SECRET_FILE_MODE: u32 = 0o600;
57
58#[cfg(unix)]
60const WORLD_READABLE_MASK: u32 = 0o077;
61
62pub fn resolve_key() -> Result<SecretString, String> {
70 resolve_key_for_vault(".murk")
71}
72
73pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
79 if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
81 return Ok(SecretString::from(k));
82 }
83 if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
85 let p = std::path::Path::new(&path);
86 let contents = read_secret_file(p, "MURK_KEY_FILE")?;
87 return Ok(SecretString::from(contents.trim().to_string()));
88 }
89 if let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists()) {
91 let contents = read_secret_file(&path, "key file")?;
92 return Ok(SecretString::from(contents.trim().to_string()));
93 }
94 if let Some(key) = read_key_from_dotenv() {
96 eprintln!(
97 "\x1b[1;33mwarn\x1b[0m reading key from .env is deprecated — use MURK_KEY_FILE or `murk init` instead"
98 );
99 return Ok(SecretString::from(key));
100 }
101 Err(
102 "MURK_KEY not set — run `murk init` to generate a key, or ask a recipient to authorize you"
103 .into(),
104 )
105}
106
107pub fn parse_env(contents: &str) -> Vec<(String, String)> {
110 let mut pairs = Vec::new();
111
112 for line in contents.lines() {
113 let line = line.trim();
114
115 if line.is_empty() || line.starts_with('#') {
116 continue;
117 }
118
119 let line = line.strip_prefix("export ").unwrap_or(line);
120
121 let Some((key, value)) = line.split_once('=') else {
122 continue;
123 };
124
125 let key = key.trim();
126 let value = value.trim();
127
128 let value = value
130 .strip_prefix('"')
131 .and_then(|v| v.strip_suffix('"'))
132 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
133 .unwrap_or(value);
134
135 if key.is_empty() || IMPORT_SKIP.contains(&key) {
136 continue;
137 }
138
139 pairs.push((key.into(), value.into()));
140 }
141
142 pairs
143}
144
145pub fn warn_env_permissions() {
147 #[cfg(unix)]
148 {
149 use std::os::unix::fs::PermissionsExt;
150 let env_path = Path::new(".env");
151 if env_path.exists()
152 && let Ok(meta) = fs::metadata(env_path)
153 {
154 let mode = meta.permissions().mode();
155 if mode & WORLD_READABLE_MASK != 0 {
156 eprintln!(
157 "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
158 mode & 0o777
159 );
160 }
161 }
162 }
163}
164
165pub fn read_key_from_dotenv() -> Option<String> {
170 let env_path = Path::new(".env");
171 let contents = fs::read_to_string(env_path).ok()?;
172 for line in contents.lines() {
173 let trimmed = line.trim();
174 if let Some(path_str) = trimmed
176 .strip_prefix("export MURK_KEY_FILE=")
177 .or_else(|| trimmed.strip_prefix("MURK_KEY_FILE="))
178 {
179 let p = Path::new(path_str.trim());
180 if let Ok(contents) = read_secret_file(p, "MURK_KEY_FILE from .env") {
181 return Some(contents.trim().to_string());
182 }
183 }
184 if let Some(key) = trimmed.strip_prefix("export MURK_KEY=") {
186 eprintln!(
187 "\x1b[1;33mwarn\x1b[0m inline MURK_KEY in .env is deprecated — use MURK_KEY_FILE instead"
188 );
189 return Some(key.to_string());
190 }
191 if let Some(key) = trimmed.strip_prefix("MURK_KEY=") {
192 eprintln!(
193 "\x1b[1;33mwarn\x1b[0m inline MURK_KEY in .env is deprecated — use MURK_KEY_FILE instead"
194 );
195 return Some(key.to_string());
196 }
197 }
198 None
199}
200
201pub fn dotenv_has_murk_key() -> bool {
203 let env_path = Path::new(".env");
204 if !env_path.exists() {
205 return false;
206 }
207 let contents = fs::read_to_string(env_path).unwrap_or_default();
208 contents.lines().any(|l| {
209 l.starts_with("MURK_KEY=")
210 || l.starts_with("export MURK_KEY=")
211 || l.starts_with("MURK_KEY_FILE=")
212 || l.starts_with("export MURK_KEY_FILE=")
213 })
214}
215
216pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
221 let env_path = Path::new(".env");
222 reject_symlink(env_path, ".env")?;
223
224 let existing = if env_path.exists() {
226 let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
227 let filtered: Vec<&str> = contents
228 .lines()
229 .filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
230 .collect();
231 filtered.join("\n") + "\n"
232 } else {
233 String::new()
234 };
235
236 let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
237
238 #[cfg(unix)]
240 {
241 use std::os::unix::fs::OpenOptionsExt;
242 let mut file = fs::OpenOptions::new()
243 .create(true)
244 .write(true)
245 .truncate(true)
246 .mode(SECRET_FILE_MODE)
247 .open(env_path)
248 .map_err(|e| format!("opening .env: {e}"))?;
249 file.write_all(full_content.as_bytes())
250 .map_err(|e| format!("writing .env: {e}"))?;
251 }
252
253 #[cfg(not(unix))]
254 {
255 fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
256 }
257
258 Ok(())
259}
260
261pub fn key_file_path(vault_path: &str) -> Result<std::path::PathBuf, String> {
264 use sha2::{Digest, Sha256};
265
266 let abs_path = std::path::Path::new(vault_path)
267 .canonicalize()
268 .or_else(|_| {
269 std::env::current_dir().map(|cwd| cwd.join(vault_path))
271 })
272 .map_err(|e| format!("cannot resolve vault path: {e}"))?;
273
274 let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
275 let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
276 use std::fmt::Write;
277 let _ = write!(s, "{b:02x}");
278 s
279 });
280
281 let config_dir = dirs_path()?;
282 Ok(config_dir.join(&short_hash))
283}
284
285fn dirs_path() -> Result<std::path::PathBuf, String> {
287 let home = std::env::var("HOME")
288 .or_else(|_| std::env::var("USERPROFILE"))
289 .map_err(|_| "cannot determine home directory")?;
290 let dir = std::path::Path::new(&home)
291 .join(".config")
292 .join("murk")
293 .join("keys");
294 fs::create_dir_all(&dir).map_err(|e| format!("creating key directory: {e}"))?;
295
296 #[cfg(unix)]
297 {
298 use std::os::unix::fs::PermissionsExt;
299 let parent = dir.parent().unwrap(); fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).ok();
301 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok();
302 }
303
304 Ok(dir)
305}
306
307pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
309 reject_symlink(path, &path.display().to_string())?;
310 #[cfg(unix)]
311 {
312 use std::os::unix::fs::OpenOptionsExt;
313 let mut file = fs::OpenOptions::new()
314 .create(true)
315 .write(true)
316 .truncate(true)
317 .mode(SECRET_FILE_MODE)
318 .open(path)
319 .map_err(|e| format!("writing key file: {e}"))?;
320 file.write_all(secret_key.as_bytes())
321 .map_err(|e| format!("writing key file: {e}"))?;
322 }
323 #[cfg(not(unix))]
324 {
325 fs::write(path, secret_key).map_err(|e| format!("writing key file: {e}"))?;
326 }
327 Ok(())
328}
329
330pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
332 let env_path = Path::new(".env");
333 reject_symlink(env_path, ".env")?;
334
335 let existing = if env_path.exists() {
336 let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
337 let filtered: Vec<&str> = contents
338 .lines()
339 .filter(|l| {
340 !l.starts_with("MURK_KEY=")
341 && !l.starts_with("export MURK_KEY=")
342 && !l.starts_with("MURK_KEY_FILE=")
343 && !l.starts_with("export MURK_KEY_FILE=")
344 })
345 .collect();
346 filtered.join("\n") + "\n"
347 } else {
348 String::new()
349 };
350
351 let full_content = format!(
352 "{existing}export MURK_KEY_FILE={}\n",
353 key_file_path.display()
354 );
355
356 #[cfg(unix)]
357 {
358 use std::os::unix::fs::OpenOptionsExt;
359 let mut file = fs::OpenOptions::new()
360 .create(true)
361 .write(true)
362 .truncate(true)
363 .mode(SECRET_FILE_MODE)
364 .open(env_path)
365 .map_err(|e| format!("opening .env: {e}"))?;
366 file.write_all(full_content.as_bytes())
367 .map_err(|e| format!("writing .env: {e}"))?;
368 }
369 #[cfg(not(unix))]
370 {
371 fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
372 }
373
374 Ok(())
375}
376
377#[derive(Debug, PartialEq, Eq)]
379pub enum EnvrcStatus {
380 AlreadyPresent,
382 Appended,
384 Created,
386}
387
388pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
393 let envrc = Path::new(".envrc");
394 reject_symlink(envrc, ".envrc")?;
395 let murk_line = format!("eval \"$(murk export --vault {vault_name})\"");
396
397 if envrc.exists() {
398 let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
399 if contents.contains("murk export") {
400 return Ok(EnvrcStatus::AlreadyPresent);
401 }
402 let mut file = fs::OpenOptions::new()
403 .append(true)
404 .open(envrc)
405 .map_err(|e| format!("writing .envrc: {e}"))?;
406 writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
407 Ok(EnvrcStatus::Appended)
408 } else {
409 fs::write(envrc, format!("{murk_line}\n")).map_err(|e| format!("writing .envrc: {e}"))?;
410 Ok(EnvrcStatus::Created)
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 use crate::testutil::{CWD_LOCK, ENV_LOCK};
419
420 #[test]
421 fn parse_env_empty() {
422 assert!(parse_env("").is_empty());
423 }
424
425 #[test]
426 fn parse_env_comments_and_blanks() {
427 let input = "# comment\n\n # another\n";
428 assert!(parse_env(input).is_empty());
429 }
430
431 #[test]
432 fn parse_env_basic() {
433 let input = "FOO=bar\nBAZ=qux\n";
434 let pairs = parse_env(input);
435 assert_eq!(
436 pairs,
437 vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]
438 );
439 }
440
441 #[test]
442 fn parse_env_double_quotes() {
443 let pairs = parse_env("KEY=\"hello world\"\n");
444 assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
445 }
446
447 #[test]
448 fn parse_env_single_quotes() {
449 let pairs = parse_env("KEY='hello world'\n");
450 assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
451 }
452
453 #[test]
454 fn parse_env_export_prefix() {
455 let pairs = parse_env("export FOO=bar\n");
456 assert_eq!(pairs, vec![("FOO".into(), "bar".into())]);
457 }
458
459 #[test]
460 fn parse_env_skips_murk_keys() {
461 let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
462 let pairs = parse_env(input);
463 assert_eq!(pairs, vec![("KEEP".into(), "yes".into())]);
464 }
465
466 #[test]
467 fn parse_env_equals_in_value() {
468 let pairs = parse_env("URL=postgres://host?opt=1\n");
469 assert_eq!(pairs, vec![("URL".into(), "postgres://host?opt=1".into())]);
470 }
471
472 #[test]
473 fn parse_env_no_equals_skipped() {
474 let pairs = parse_env("not-a-valid-line\nKEY=val\n");
475 assert_eq!(pairs, vec![("KEY".into(), "val".into())]);
476 }
477
478 #[test]
481 fn parse_env_empty_value() {
482 let pairs = parse_env("KEY=\n");
483 assert_eq!(pairs, vec![("KEY".into(), String::new())]);
484 }
485
486 #[test]
487 fn parse_env_trailing_whitespace() {
488 let pairs = parse_env("KEY=value \n");
489 assert_eq!(pairs, vec![("KEY".into(), "value".into())]);
490 }
491
492 #[test]
493 fn parse_env_unicode_value() {
494 let pairs = parse_env("KEY=hello🔐world\n");
495 assert_eq!(pairs, vec![("KEY".into(), "hello🔐world".into())]);
496 }
497
498 #[test]
499 fn parse_env_empty_key_skipped() {
500 let pairs = parse_env("=value\n");
501 assert!(pairs.is_empty());
502 }
503
504 #[test]
505 fn parse_env_mixed_quotes_unmatched() {
506 let pairs = parse_env("KEY=\"hello'\n");
508 assert_eq!(pairs, vec![("KEY".into(), "\"hello'".into())]);
509 }
510
511 #[test]
512 fn parse_env_multiple_murk_vars() {
513 let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
515 let pairs = parse_env(input);
516 assert_eq!(
517 pairs,
518 vec![("A".into(), "1".into()), ("B".into(), "2".into())]
519 );
520 }
521
522 fn resolve_key_sandbox(
527 name: &str,
528 ) -> (
529 std::sync::MutexGuard<'static, ()>,
530 std::sync::MutexGuard<'static, ()>,
531 std::path::PathBuf,
532 std::path::PathBuf,
533 ) {
534 let env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
535 let cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
536 let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
537 let _ = std::fs::create_dir_all(&tmp);
538 let prev = std::env::current_dir().unwrap();
539 std::env::set_current_dir(&tmp).unwrap();
540 (env, cwd, tmp, prev)
541 }
542
543 fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
544 std::env::set_current_dir(prev).unwrap();
545 let _ = std::fs::remove_dir_all(tmp);
546 }
547
548 #[test]
549 fn resolve_key_from_env() {
550 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
551 let key = "AGE-SECRET-KEY-1TEST";
552 unsafe { env::set_var("MURK_KEY", key) };
553 let result = resolve_key();
554 unsafe { env::remove_var("MURK_KEY") };
555 resolve_key_sandbox_teardown(&tmp, &prev);
556
557 let secret = result.unwrap();
558 use age::secrecy::ExposeSecret;
559 assert_eq!(secret.expose_secret(), key);
560 }
561
562 #[test]
563 fn resolve_key_from_file() {
564 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
565 unsafe { env::remove_var("MURK_KEY") };
566
567 let path = std::env::temp_dir().join("murk_test_key_file");
568 {
569 #[cfg(unix)]
570 {
571 use std::os::unix::fs::OpenOptionsExt;
572 let mut f = std::fs::OpenOptions::new()
573 .create(true)
574 .write(true)
575 .truncate(true)
576 .mode(0o600)
577 .open(&path)
578 .unwrap();
579 std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
580 }
581 #[cfg(not(unix))]
582 std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
583 }
584
585 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
586 let result = resolve_key();
587 unsafe { env::remove_var("MURK_KEY_FILE") };
588 std::fs::remove_file(&path).ok();
589 resolve_key_sandbox_teardown(&tmp, &prev);
590
591 let secret = result.unwrap();
592 use age::secrecy::ExposeSecret;
593 assert_eq!(secret.expose_secret(), "AGE-SECRET-KEY-1FROMFILE");
594 }
595
596 #[test]
597 fn resolve_key_file_not_found() {
598 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
599 unsafe { env::remove_var("MURK_KEY") };
600 unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
601 let result = resolve_key();
602 unsafe { env::remove_var("MURK_KEY_FILE") };
603 resolve_key_sandbox_teardown(&tmp, &prev);
604
605 assert!(result.is_err());
606 assert!(result.unwrap_err().contains("cannot read"));
607 }
608
609 #[test]
610 fn resolve_key_neither_set() {
611 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
612 unsafe { env::remove_var("MURK_KEY") };
613 unsafe { env::remove_var("MURK_KEY_FILE") };
614 let result = resolve_key();
615 resolve_key_sandbox_teardown(&tmp, &prev);
616
617 assert!(result.is_err());
618 assert!(result.unwrap_err().contains("MURK_KEY not set"));
619 }
620
621 #[test]
622 fn resolve_key_empty_string_treated_as_unset() {
623 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
624 unsafe { env::set_var("MURK_KEY", "") };
625 unsafe { env::remove_var("MURK_KEY_FILE") };
626 let result = resolve_key();
627 unsafe { env::remove_var("MURK_KEY") };
628 resolve_key_sandbox_teardown(&tmp, &prev);
629
630 assert!(result.is_err());
631 assert!(result.unwrap_err().contains("MURK_KEY not set"));
632 }
633
634 #[test]
635 fn resolve_key_murk_key_takes_priority_over_file() {
636 let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
637 let direct_key = "AGE-SECRET-KEY-1DIRECT";
638 let file_key = "AGE-SECRET-KEY-1FILE";
639
640 let path = std::env::temp_dir().join("murk_test_key_priority");
641 std::fs::write(&path, format!("{file_key}\n")).unwrap();
642
643 unsafe { env::set_var("MURK_KEY", direct_key) };
644 unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
645 let result = resolve_key();
646 unsafe { env::remove_var("MURK_KEY") };
647 unsafe { env::remove_var("MURK_KEY_FILE") };
648 std::fs::remove_file(&path).ok();
649 resolve_key_sandbox_teardown(&tmp, &prev);
650
651 let secret = result.unwrap();
652 use age::secrecy::ExposeSecret;
653 assert_eq!(secret.expose_secret(), direct_key);
654 }
655
656 #[cfg(unix)]
657 #[test]
658 fn warn_env_permissions_no_warning_on_secure_file() {
659 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
660 use std::os::unix::fs::PermissionsExt;
661
662 let dir = std::env::temp_dir().join("murk_test_perms");
663 let _ = std::fs::remove_dir_all(&dir);
664 std::fs::create_dir_all(&dir).unwrap();
665 let env_path = dir.join(".env");
666 std::fs::write(&env_path, "KEY=val\n").unwrap();
667 std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
668
669 let original_dir = std::env::current_dir().unwrap();
671 std::env::set_current_dir(&dir).unwrap();
672 warn_env_permissions();
673 std::env::set_current_dir(original_dir).unwrap();
674
675 std::fs::remove_dir_all(&dir).unwrap();
676 }
677
678 #[test]
679 fn read_key_from_dotenv_export_form() {
680 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
681 let dir = std::env::temp_dir().join("murk_test_read_dotenv_export");
682 let _ = std::fs::remove_dir_all(&dir);
683 std::fs::create_dir_all(&dir).unwrap();
684 let env_path = dir.join(".env");
685 std::fs::write(&env_path, "export MURK_KEY=AGE-SECRET-KEY-1ABC\n").unwrap();
686
687 let original_dir = std::env::current_dir().unwrap();
688 std::env::set_current_dir(&dir).unwrap();
689 let result = read_key_from_dotenv();
690 std::env::set_current_dir(original_dir).unwrap();
691
692 assert_eq!(result, Some("AGE-SECRET-KEY-1ABC".into()));
693 std::fs::remove_dir_all(&dir).unwrap();
694 }
695
696 #[test]
697 fn read_key_from_dotenv_bare_form() {
698 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
699 let dir = std::env::temp_dir().join("murk_test_read_dotenv_bare");
700 let _ = std::fs::remove_dir_all(&dir);
701 std::fs::create_dir_all(&dir).unwrap();
702 let env_path = dir.join(".env");
703 std::fs::write(&env_path, "MURK_KEY=AGE-SECRET-KEY-1XYZ\n").unwrap();
704
705 let original_dir = std::env::current_dir().unwrap();
706 std::env::set_current_dir(&dir).unwrap();
707 let result = read_key_from_dotenv();
708 std::env::set_current_dir(original_dir).unwrap();
709
710 assert_eq!(result, Some("AGE-SECRET-KEY-1XYZ".into()));
711 std::fs::remove_dir_all(&dir).unwrap();
712 }
713
714 #[test]
715 fn read_key_from_dotenv_missing_file() {
716 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
717 let dir = std::env::temp_dir().join("murk_test_read_dotenv_missing");
718 let _ = std::fs::remove_dir_all(&dir);
719 std::fs::create_dir_all(&dir).unwrap();
720
721 let original_dir = std::env::current_dir().unwrap();
722 std::env::set_current_dir(&dir).unwrap();
723 let result = read_key_from_dotenv();
724 std::env::set_current_dir(original_dir).unwrap();
725
726 assert_eq!(result, None);
727 std::fs::remove_dir_all(&dir).unwrap();
728 }
729
730 #[test]
731 fn dotenv_has_murk_key_true() {
732 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
733 let dir = std::env::temp_dir().join("murk_test_has_key_true");
734 let _ = std::fs::remove_dir_all(&dir);
735 std::fs::create_dir_all(&dir).unwrap();
736 std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
737
738 let original_dir = std::env::current_dir().unwrap();
739 std::env::set_current_dir(&dir).unwrap();
740 assert!(dotenv_has_murk_key());
741 std::env::set_current_dir(original_dir).unwrap();
742
743 std::fs::remove_dir_all(&dir).unwrap();
744 }
745
746 #[test]
747 fn dotenv_has_murk_key_false() {
748 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
749 let dir = std::env::temp_dir().join("murk_test_has_key_false");
750 let _ = std::fs::remove_dir_all(&dir);
751 std::fs::create_dir_all(&dir).unwrap();
752 std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
753
754 let original_dir = std::env::current_dir().unwrap();
755 std::env::set_current_dir(&dir).unwrap();
756 assert!(!dotenv_has_murk_key());
757 std::env::set_current_dir(original_dir).unwrap();
758
759 std::fs::remove_dir_all(&dir).unwrap();
760 }
761
762 #[test]
763 fn dotenv_has_murk_key_no_file() {
764 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
765 let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
766 let _ = std::fs::remove_dir_all(&dir);
767 std::fs::create_dir_all(&dir).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 write_key_to_dotenv_creates_new() {
779 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
780 let dir = std::env::temp_dir().join("murk_test_write_key_new");
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 write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
787
788 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
789 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
790
791 std::env::set_current_dir(original_dir).unwrap();
792 std::fs::remove_dir_all(&dir).unwrap();
793 }
794
795 #[test]
796 fn write_key_to_dotenv_replaces_existing() {
797 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
798 let dir = std::env::temp_dir().join("murk_test_write_key_replace");
799 let _ = std::fs::remove_dir_all(&dir);
800 std::fs::create_dir_all(&dir).unwrap();
801 std::fs::write(
802 dir.join(".env"),
803 "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
804 )
805 .unwrap();
806
807 let original_dir = std::env::current_dir().unwrap();
808 std::env::set_current_dir(&dir).unwrap();
809 write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
810
811 let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
812 assert!(contents.contains("OTHER=keep"));
813 assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
814 assert!(!contents.contains("MURK_KEY=old"));
815 assert!(!contents.contains("also_old"));
816
817 std::env::set_current_dir(original_dir).unwrap();
818 std::fs::remove_dir_all(&dir).unwrap();
819 }
820
821 #[cfg(unix)]
822 #[test]
823 fn write_key_to_dotenv_permissions_are_600() {
824 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
825 use std::os::unix::fs::PermissionsExt;
826
827 let dir = std::env::temp_dir().join("murk_test_write_key_perms");
828 let _ = std::fs::remove_dir_all(&dir);
829 std::fs::create_dir_all(&dir).unwrap();
830
831 let original_dir = std::env::current_dir().unwrap();
832 std::env::set_current_dir(&dir).unwrap();
833
834 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
836 let meta = std::fs::metadata(dir.join(".env")).unwrap();
837 assert_eq!(
838 meta.permissions().mode() & 0o777,
839 SECRET_FILE_MODE,
840 "new .env should be created with mode 600"
841 );
842
843 write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
845 let meta = std::fs::metadata(dir.join(".env")).unwrap();
846 assert_eq!(
847 meta.permissions().mode() & 0o777,
848 SECRET_FILE_MODE,
849 "rewritten .env should maintain mode 600"
850 );
851
852 std::env::set_current_dir(original_dir).unwrap();
853 std::fs::remove_dir_all(&dir).unwrap();
854 }
855
856 #[test]
857 fn write_envrc_creates_new() {
858 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
859 let dir = std::env::temp_dir().join("murk_test_envrc_new");
860 let _ = std::fs::remove_dir_all(&dir);
861 std::fs::create_dir_all(&dir).unwrap();
862
863 let original_dir = std::env::current_dir().unwrap();
864 std::env::set_current_dir(&dir).unwrap();
865 let status = write_envrc(".murk").unwrap();
866 assert_eq!(status, EnvrcStatus::Created);
867
868 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
869 assert!(contents.contains("murk export --vault .murk"));
870
871 std::env::set_current_dir(original_dir).unwrap();
872 std::fs::remove_dir_all(&dir).unwrap();
873 }
874
875 #[test]
876 fn write_envrc_appends() {
877 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
878 let dir = std::env::temp_dir().join("murk_test_envrc_append");
879 let _ = std::fs::remove_dir_all(&dir);
880 std::fs::create_dir_all(&dir).unwrap();
881 std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
882
883 let original_dir = std::env::current_dir().unwrap();
884 std::env::set_current_dir(&dir).unwrap();
885 let status = write_envrc(".murk").unwrap();
886 assert_eq!(status, EnvrcStatus::Appended);
887
888 let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
889 assert!(contents.contains("existing content"));
890 assert!(contents.contains("murk export"));
891
892 std::env::set_current_dir(original_dir).unwrap();
893 std::fs::remove_dir_all(&dir).unwrap();
894 }
895
896 #[test]
897 fn write_envrc_already_present() {
898 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
899 let dir = std::env::temp_dir().join("murk_test_envrc_present");
900 let _ = std::fs::remove_dir_all(&dir);
901 std::fs::create_dir_all(&dir).unwrap();
902 std::fs::write(
903 dir.join(".envrc"),
904 "eval \"$(murk export --vault .murk)\"\n",
905 )
906 .unwrap();
907
908 let original_dir = std::env::current_dir().unwrap();
909 std::env::set_current_dir(&dir).unwrap();
910 let status = write_envrc(".murk").unwrap();
911 assert_eq!(status, EnvrcStatus::AlreadyPresent);
912
913 std::env::set_current_dir(original_dir).unwrap();
914 std::fs::remove_dir_all(&dir).unwrap();
915 }
916}