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