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