Skip to main content

murk_cli/
env.rs

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