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/// Where the resolved key came from.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum KeySource {
89    /// From `MURK_KEY` environment variable.
90    EnvVar,
91    /// From `MURK_KEY_FILE` environment variable (path).
92    EnvFile(std::path::PathBuf),
93    /// Auto-discovered at `~/.config/murk/keys/<hash>`.
94    Auto(std::path::PathBuf),
95    /// Legacy `.env` file in cwd.
96    Dotenv,
97}
98
99impl KeySource {
100    /// Human-readable description for display.
101    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
111/// Resolve the secret key and report where it came from.
112pub 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
143/// Resolve the secret key for a specific vault, checking in order:
144/// 1. `MURK_KEY` env var (explicit key)
145/// 2. `MURK_KEY_FILE` env var (path to key file)
146/// 3. `~/.config/murk/keys/<vault-hash>` (automatic lookup keyed to the given vault path)
147/// 4. `.env` file in cwd (backward compat)
148pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
149    resolve_key_with_source(vault_path).map(|(k, _)| k)
150}
151
152/// Parse a .env file into key-value pairs.
153/// Skips comments, blank lines, `MURK_*` keys, and strips quotes and `export` prefixes.
154pub 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        // Strip surrounding quotes.
174        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
190/// Warn if `.env` has loose permissions (Unix only).
191pub 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
210/// Read MURK_KEY from `.env` file if present.
211///
212/// Supports `MURK_KEY_FILE=path` references (preferred) and legacy inline
213/// `MURK_KEY=value` (deprecated — logs a warning).
214pub 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        // Key file reference (preferred path).
220        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        // Inline key (deprecated — still accepted for backward compat).
231        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
247/// Check whether `.env` already contains a `MURK_KEY` line.
248pub 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
262/// Write a MURK_KEY to `.env`, removing any existing MURK_KEY lines.
263/// On Unix, sets file permissions to 600 atomically at creation time to
264/// prevent a TOCTOU window where the secret key is world-readable.
265/// On non-Unix platforms, permissions are not hardened.
266pub 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    // Read existing content (minus any MURK_KEY lines).
271    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    // Write the file with restricted permissions from the start (Unix).
285    #[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
308/// Compute the key file path for a vault: `~/.config/murk/keys/<hash>`.
309///
310/// The hash is a truncated SHA-256 of the *lexical* absolute vault path
311/// (cwd-joined if relative, but symlinks are NOT resolved). Using the
312/// literal path is important for security: a symlink `.murk` pointing at
313/// another project's vault must not resolve to that project's key file.
314pub 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
337/// Return `~/.config/murk/keys/`, creating it if needed.
338fn 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(); // ~/.config/murk
352        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
361/// Write a secret key to a file with restricted permissions.
362pub 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
385/// Write a MURK_KEY_FILE reference to `.env`, removing any existing MURK_KEY/MURK_KEY_FILE lines.
386pub 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/// Status of `.envrc` after writing.
434#[derive(Debug, PartialEq, Eq)]
435pub enum EnvrcStatus {
436    /// `.envrc` already contained `murk export`.
437    AlreadyPresent,
438    /// Appended murk export line to existing `.envrc`.
439    Appended,
440    /// Created a new `.envrc` file.
441    Created,
442}
443
444/// Write a `.envrc` file for direnv integration.
445///
446/// If `.envrc` exists and already contains `murk export`, returns `AlreadyPresent`.
447/// If it exists but doesn't, appends the line. Otherwise creates the file.
448pub 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    // ── New edge-case tests ──
554
555    #[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        // Mismatched quotes are not stripped.
582        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        // All three MURK_ vars are skipped, other vars kept.
589        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    /// Helper: acquire both locks and cd to a clean temp dir.
598    /// Returns guards and the previous cwd. The cwd is restored on drop
599    /// via the returned `prev` path — callers must restore manually before
600    /// asserting so panics don't leave cwd changed.
601    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        // Just verify it doesn't panic — output goes to stderr.
745        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        // Create new .env — should be 0o600 from the start.
910        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        // Replace existing — should still be 0o600.
919        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}