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