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;
9use zeroize::Zeroizing;
10
11/// Shell-escape a string using single quotes, safe for embedding in shell scripts.
12/// If the value is a simple identifier (alphanumeric, `-`, `_`, `.`, `/`), returns it bare.
13fn shell_escape(s: &str) -> String {
14    if !s.is_empty()
15        && s.chars()
16            .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
17    {
18        s.to_string()
19    } else {
20        format!("'{}'", s.replace('\'', "'\\''"))
21    }
22}
23
24/// Reject symlinks at the given path to prevent symlink-clobber attacks.
25/// Returns Ok(()) if the path does not exist or is not a symlink.
26pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
27    if path.is_symlink() {
28        return Err(format!(
29            "{label} is a symlink — refusing to follow for security"
30        ));
31    }
32    Ok(())
33}
34
35/// Read a file, rejecting symlinks and (on Unix) group/world-readable permissions.
36/// Returns the file contents as a string.
37fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
38    reject_symlink(path, label)?;
39
40    #[cfg(unix)]
41    {
42        use std::os::unix::fs::MetadataExt;
43        if let Ok(meta) = fs::metadata(path) {
44            let mode = meta.mode();
45            if mode & WORLD_READABLE_MASK != 0 {
46                return Err(format!(
47                    "{label} is readable by others (mode {:o}). Run: chmod 600 {}",
48                    mode & 0o777,
49                    path.display()
50                ));
51            }
52        }
53    }
54
55    fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
56}
57
58/// Environment variable for the secret key.
59pub const ENV_MURK_KEY: &str = "MURK_KEY";
60/// Environment variable for the secret key file path.
61pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
62/// Environment variable for the vault filename.
63pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
64
65/// Keys to skip when importing from a .env file.
66const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
67
68/// File mode for `.env`: owner read/write only.
69#[cfg(unix)]
70const SECRET_FILE_MODE: u32 = 0o600;
71
72/// Bitmask for group/other permission bits.
73#[cfg(unix)]
74const WORLD_READABLE_MASK: u32 = 0o077;
75
76/// Resolve the secret key, checking in order:
77/// 1. `MURK_KEY` env var (explicit key)
78/// 2. `MURK_KEY_FILE` env var (path to key file)
79/// 3. `~/.config/murk/keys/<vault-hash>` (automatic lookup for default vault)
80/// 4. `.env` file in cwd (backward compat)
81///
82/// Returns the key wrapped in `SecretString` so it is zeroized on drop.
83pub fn resolve_key() -> Result<SecretString, String> {
84    resolve_key_for_vault(".murk")
85}
86
87/// Where the resolved key came from.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum KeySource {
90    /// From `MURK_KEY` environment variable.
91    EnvVar,
92    /// From `MURK_KEY_FILE` environment variable (path).
93    EnvFile(std::path::PathBuf),
94    /// Auto-discovered at `~/.config/murk/keys/<hash>`.
95    Auto(std::path::PathBuf),
96}
97
98impl KeySource {
99    /// Human-readable description for display.
100    pub fn describe(&self) -> String {
101        match self {
102            KeySource::EnvVar => "MURK_KEY environment variable".into(),
103            KeySource::EnvFile(p) => format!("MURK_KEY_FILE {}", p.display()),
104            KeySource::Auto(p) => p.display().to_string(),
105        }
106    }
107}
108
109/// Resolve the secret key and report where it came from.
110///
111/// Checks, in order:
112/// 1. `MURK_KEY` env var (explicit key)
113/// 2. `MURK_KEY_FILE` env var (path to a key file)
114/// 3. `~/.config/murk/keys/<hash-of-vault-path>` (automatic lookup)
115///
116/// `.env` is **not** consulted at runtime. It is a write-only convenience that
117/// `murk init` populates with a `MURK_KEY_FILE` reference for direnv to export.
118/// Reading `.env` at runtime would let a copied vault in another repo borrow
119/// whichever key happened to be referenced in the current working directory's
120/// `.env` — a confused-deputy path that defeats per-vault key isolation.
121pub fn resolve_key_with_source(vault_path: &str) -> Result<(SecretString, KeySource), String> {
122    if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
123        return Ok((SecretString::from(k), KeySource::EnvVar));
124    }
125    // File paths return full contents (not trimmed) so that plugin identity
126    // files — which contain a `# public key: age1...` header above an
127    // `AGE-PLUGIN-...-1...` pointer — round-trip intact through parse_identity.
128    if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
129        let p = std::path::Path::new(&path);
130        let contents = read_secret_file(p, "MURK_KEY_FILE")?;
131        return Ok((
132            SecretString::from(contents),
133            KeySource::EnvFile(p.to_path_buf()),
134        ));
135    }
136    // Auto-discovery of the operator's stored key. Disabled under MURK_STRICT so
137    // an agent context never silently falls back to the operator's personal key
138    // in ~/.config/murk/keys — it must present an explicit MURK_KEY/MURK_KEY_FILE
139    // (its grant key) or fail closed. `murk agent exec` sets MURK_STRICT for the
140    // agent so this holds without the agent having to opt in.
141    if !crate::hardening::strict_mode()
142        && let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists())
143    {
144        let contents = read_secret_file(&path, "key file")?;
145        return Ok((SecretString::from(contents), KeySource::Auto(path)));
146    }
147    Err(
148        "MURK_KEY not set. Run `murk init` to generate a key, set MURK_KEY_FILE to point at one, or ask a recipient to authorize you. If your .env contains an inline MURK_KEY or MURK_KEY_FILE, run `direnv allow` (or `source .env`) so it is exported to the environment — murk no longer reads .env directly."
149            .into(),
150    )
151}
152
153/// Resolve the secret key for a specific vault.
154pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
155    resolve_key_with_source(vault_path).map(|(k, _)| k)
156}
157
158/// Read a key the environment supplied directly — `MURK_KEY`, or the file at
159/// `MURK_KEY_FILE` — and nothing else. Unlike [`resolve_key_with_source`] this
160/// does NOT fall back to auto-discovery in `~/.config/murk/keys`: it answers only
161/// "did the current environment hand us a key?". `murk init` uses it to reuse an
162/// already-present identity instead of generating a new one, which is why it must
163/// ignore the stored key it would otherwise be about to create.
164///
165/// Returns `Ok(None)` when neither variable is set. The result is trimmed (init
166/// stores it as a plain key string); the runtime path deliberately does not trim,
167/// to keep plugin identity files intact.
168///
169/// This is the single place, alongside [`resolve_key_with_source`], that reads
170/// the key environment variables — see `tests/invariants.rs`.
171pub fn key_from_env_only() -> Result<Option<String>, String> {
172    if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
173        return Ok(Some(k));
174    }
175    if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
176        let p = std::path::Path::new(&path);
177        reject_symlink(p, "MURK_KEY_FILE")?;
178        let key = std::fs::read_to_string(p)
179            .map_err(|e| format!("cannot read MURK_KEY_FILE: {e}"))?
180            .trim()
181            .to_string();
182        return Ok(Some(key));
183    }
184    Ok(None)
185}
186
187/// Parse a .env file into key-value pairs.
188/// Skips comments, blank lines, `MURK_*` keys, and strips quotes and `export` prefixes.
189///
190/// Values are wrapped in [`Zeroizing`] so that the plaintext is wiped from memory
191/// as soon as the caller drops them.
192pub fn parse_env(contents: &str) -> Vec<(String, Zeroizing<String>)> {
193    let mut pairs = Vec::new();
194
195    for line in contents.lines() {
196        let line = line.trim();
197
198        if line.is_empty() || line.starts_with('#') {
199            continue;
200        }
201
202        let line = line.strip_prefix("export ").unwrap_or(line);
203
204        let Some((key, value)) = line.split_once('=') else {
205            continue;
206        };
207
208        let key = key.trim();
209        let value = value.trim();
210
211        // Strip surrounding quotes.
212        let value = value
213            .strip_prefix('"')
214            .and_then(|v| v.strip_suffix('"'))
215            .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
216            .unwrap_or(value);
217
218        if key.is_empty() || IMPORT_SKIP.contains(&key) {
219            continue;
220        }
221
222        pairs.push((key.into(), Zeroizing::new(value.to_string())));
223    }
224
225    pairs
226}
227
228/// Warn if `.env` has loose permissions (Unix only).
229pub fn warn_env_permissions() {
230    #[cfg(unix)]
231    {
232        use std::os::unix::fs::PermissionsExt;
233        let env_path = Path::new(".env");
234        if env_path.exists()
235            && let Ok(meta) = fs::metadata(env_path)
236        {
237            let mode = meta.permissions().mode();
238            if mode & WORLD_READABLE_MASK != 0 {
239                eprintln!(
240                    "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
241                    mode & 0o777
242                );
243            }
244        }
245    }
246}
247
248/// Check whether `.env` already contains a `MURK_KEY` line.
249pub fn dotenv_has_murk_key() -> bool {
250    let env_path = Path::new(".env");
251    if !env_path.exists() {
252        return false;
253    }
254    let contents = fs::read_to_string(env_path).unwrap_or_default();
255    contents.lines().any(|l| {
256        l.starts_with("MURK_KEY=")
257            || l.starts_with("export MURK_KEY=")
258            || l.starts_with("MURK_KEY_FILE=")
259            || l.starts_with("export MURK_KEY_FILE=")
260    })
261}
262
263/// Write a MURK_KEY to `.env`, removing any existing MURK_KEY lines.
264/// On Unix, sets file permissions to 600 atomically at creation time to
265/// prevent a TOCTOU window where the secret key is world-readable.
266/// On non-Unix platforms, permissions are not hardened.
267pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
268    let env_path = Path::new(".env");
269    reject_symlink(env_path, ".env")?;
270
271    // Read existing content (minus any MURK_KEY lines).
272    let existing = if env_path.exists() {
273        let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
274        let filtered: Vec<&str> = contents
275            .lines()
276            .filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
277            .collect();
278        filtered.join("\n") + "\n"
279    } else {
280        String::new()
281    };
282
283    let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
284
285    // Write the file with restricted permissions from the start (Unix).
286    #[cfg(unix)]
287    {
288        use std::os::unix::fs::OpenOptionsExt;
289        let mut file = fs::OpenOptions::new()
290            .create(true)
291            .write(true)
292            .truncate(true)
293            .mode(SECRET_FILE_MODE)
294            .custom_flags(libc::O_NOFOLLOW)
295            .open(env_path)
296            .map_err(|e| format!("opening .env: {e}"))?;
297        file.write_all(full_content.as_bytes())
298            .map_err(|e| format!("writing .env: {e}"))?;
299    }
300
301    #[cfg(not(unix))]
302    {
303        fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
304    }
305
306    Ok(())
307}
308
309/// Compute the key file path for a vault: `~/.config/murk/keys/<hash>`.
310///
311/// The hash is a truncated SHA-256 of the *lexical* absolute vault path
312/// (cwd-joined if relative, but symlinks are NOT resolved). Using the
313/// literal path is important for security: a symlink `.murk` pointing at
314/// another project's vault must not resolve to that project's key file.
315pub fn key_file_path(vault_path: &str) -> Result<std::path::PathBuf, String> {
316    use sha2::{Digest, Sha256};
317
318    let p = std::path::Path::new(vault_path);
319    let abs_path = if p.is_absolute() {
320        p.to_path_buf()
321    } else {
322        std::env::current_dir()
323            .map_err(|e| format!("cannot resolve vault path: {e}"))?
324            .join(p)
325    };
326
327    let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
328    let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
329        use std::fmt::Write;
330        let _ = write!(s, "{b:02x}");
331        s
332    });
333
334    let config_dir = dirs_path()?;
335    Ok(config_dir.join(&short_hash))
336}
337
338/// Compute the file path for an agent grant key:
339/// `~/.config/murk/agent-keys/<vault-hash>-<name>`.
340///
341/// Grant keys live in a separate `agent-keys/` directory, not in
342/// `~/.config/murk/keys/`, so they are never surfaced by key auto-discovery
343/// (which only looks up `keys/<vault-hash>`). The vault hash prefix keeps a
344/// grant named the same across two vaults from colliding.
345pub fn agent_key_file_path(vault_path: &str, name: &str) -> Result<std::path::PathBuf, String> {
346    use sha2::{Digest, Sha256};
347
348    let p = std::path::Path::new(vault_path);
349    let abs_path = if p.is_absolute() {
350        p.to_path_buf()
351    } else {
352        std::env::current_dir()
353            .map_err(|e| format!("cannot resolve vault path: {e}"))?
354            .join(p)
355    };
356    let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
357    let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
358        use std::fmt::Write;
359        let _ = write!(s, "{b:02x}");
360        s
361    });
362
363    Ok(agent_keys_dir()?.join(format!("{short_hash}-{name}")))
364}
365
366/// Return `~/.config/murk/agent-keys/`, creating it if needed (dir `0700`).
367pub fn agent_keys_dir() -> Result<std::path::PathBuf, String> {
368    let home = std::env::var("HOME")
369        .or_else(|_| std::env::var("USERPROFILE"))
370        .map_err(|_| "cannot determine home directory")?;
371    let dir = std::path::Path::new(&home)
372        .join(".config")
373        .join("murk")
374        .join("agent-keys");
375    fs::create_dir_all(&dir).map_err(|e| format!("creating agent key directory: {e}"))?;
376
377    #[cfg(unix)]
378    {
379        use std::os::unix::fs::PermissionsExt;
380        let parent = dir.parent().unwrap(); // ~/.config/murk
381        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
382            .map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
383        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
384            .map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
385    }
386
387    Ok(dir)
388}
389
390/// Return `~/.config/murk/keys/`, creating it if needed.
391fn dirs_path() -> Result<std::path::PathBuf, String> {
392    let home = std::env::var("HOME")
393        .or_else(|_| std::env::var("USERPROFILE"))
394        .map_err(|_| "cannot determine home directory")?;
395    let dir = std::path::Path::new(&home)
396        .join(".config")
397        .join("murk")
398        .join("keys");
399    fs::create_dir_all(&dir).map_err(|e| format!("creating key directory: {e}"))?;
400
401    #[cfg(unix)]
402    {
403        use std::os::unix::fs::PermissionsExt;
404        let parent = dir.parent().unwrap(); // ~/.config/murk
405        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
406            .map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
407        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
408            .map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
409    }
410
411    Ok(dir)
412}
413
414/// Write a secret key to a file with restricted permissions.
415pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
416    reject_symlink(path, &path.display().to_string())?;
417    #[cfg(unix)]
418    {
419        use std::os::unix::fs::OpenOptionsExt;
420        let mut file = fs::OpenOptions::new()
421            .create(true)
422            .write(true)
423            .truncate(true)
424            .mode(SECRET_FILE_MODE)
425            .custom_flags(libc::O_NOFOLLOW)
426            .open(path)
427            .map_err(|e| format!("writing key file: {e}"))?;
428        file.write_all(secret_key.as_bytes())
429            .map_err(|e| format!("writing key file: {e}"))?;
430    }
431    #[cfg(not(unix))]
432    {
433        fs::write(path, secret_key).map_err(|e| format!("writing key file: {e}"))?;
434    }
435    Ok(())
436}
437
438/// Write a MURK_KEY_FILE reference to `.env`, removing any existing MURK_KEY/MURK_KEY_FILE lines.
439pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
440    let env_path = Path::new(".env");
441    reject_symlink(env_path, ".env")?;
442
443    let existing = if env_path.exists() {
444        let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
445        let filtered: Vec<&str> = contents
446            .lines()
447            .filter(|l| {
448                !l.starts_with("MURK_KEY=")
449                    && !l.starts_with("export MURK_KEY=")
450                    && !l.starts_with("MURK_KEY_FILE=")
451                    && !l.starts_with("export MURK_KEY_FILE=")
452            })
453            .collect();
454        filtered.join("\n") + "\n"
455    } else {
456        String::new()
457    };
458
459    let full_content = format!(
460        "{existing}export MURK_KEY_FILE='{}'\n",
461        key_file_path.display().to_string().replace('\'', "'\\''")
462    );
463
464    #[cfg(unix)]
465    {
466        use std::os::unix::fs::OpenOptionsExt;
467        let mut file = fs::OpenOptions::new()
468            .create(true)
469            .write(true)
470            .truncate(true)
471            .mode(SECRET_FILE_MODE)
472            .custom_flags(libc::O_NOFOLLOW)
473            .open(env_path)
474            .map_err(|e| format!("opening .env: {e}"))?;
475        file.write_all(full_content.as_bytes())
476            .map_err(|e| format!("writing .env: {e}"))?;
477    }
478    #[cfg(not(unix))]
479    {
480        fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
481    }
482
483    Ok(())
484}
485
486/// Status of `.envrc` after writing.
487#[derive(Debug, PartialEq, Eq)]
488pub enum EnvrcStatus {
489    /// `.envrc` already contained `murk export`.
490    AlreadyPresent,
491    /// Appended murk export line to existing `.envrc`.
492    Appended,
493    /// Created a new `.envrc` file.
494    Created,
495}
496
497/// Write a `.envrc` file for direnv integration.
498///
499/// If `.envrc` exists and already contains `murk export`, returns `AlreadyPresent`.
500/// If it exists but doesn't, appends the line. Otherwise creates the file.
501pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
502    let envrc = Path::new(".envrc");
503    reject_symlink(envrc, ".envrc")?;
504    let safe_vault_name = shell_escape(vault_name);
505    let murk_line = format!("eval \"$(murk export --vault {safe_vault_name})\"");
506
507    if envrc.exists() {
508        let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
509        if contents.contains("murk export") {
510            return Ok(EnvrcStatus::AlreadyPresent);
511        }
512        let mut file = fs::OpenOptions::new()
513            .append(true)
514            .open(envrc)
515            .map_err(|e| format!("writing .envrc: {e}"))?;
516        writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
517        Ok(EnvrcStatus::Appended)
518    } else {
519        #[cfg(unix)]
520        {
521            use std::os::unix::fs::OpenOptionsExt;
522            let mut file = fs::OpenOptions::new()
523                .create(true)
524                .write(true)
525                .truncate(true)
526                .mode(SECRET_FILE_MODE)
527                .custom_flags(libc::O_NOFOLLOW)
528                .open(envrc)
529                .map_err(|e| format!("writing .envrc: {e}"))?;
530            file.write_all(format!("{murk_line}\n").as_bytes())
531                .map_err(|e| format!("writing .envrc: {e}"))?;
532        }
533        #[cfg(not(unix))]
534        {
535            fs::write(envrc, format!("{murk_line}\n"))
536                .map_err(|e| format!("writing .envrc: {e}"))?;
537        }
538        Ok(EnvrcStatus::Created)
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    use age::secrecy::ExposeSecret;
547
548    use crate::testutil::{CWD_LOCK, ENV_LOCK};
549
550    #[test]
551    fn parse_env_empty() {
552        assert!(parse_env("").is_empty());
553    }
554
555    #[test]
556    fn parse_env_comments_and_blanks() {
557        let input = "# comment\n\n  # another\n";
558        assert!(parse_env(input).is_empty());
559    }
560
561    /// Compare parsed pairs against plain `(key, value)` expectations by
562    /// unwrapping the `Zeroizing` value wrappers.
563    fn assert_pairs(pairs: Vec<(String, Zeroizing<String>)>, expected: &[(&str, &str)]) {
564        let actual: Vec<(String, String)> =
565            pairs.into_iter().map(|(k, v)| (k, (*v).clone())).collect();
566        let want: Vec<(String, String)> = expected
567            .iter()
568            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
569            .collect();
570        assert_eq!(actual, want);
571    }
572
573    #[test]
574    fn parse_env_basic() {
575        assert_pairs(
576            parse_env("FOO=bar\nBAZ=qux\n"),
577            &[("FOO", "bar"), ("BAZ", "qux")],
578        );
579    }
580
581    #[test]
582    fn parse_env_double_quotes() {
583        assert_pairs(
584            parse_env("KEY=\"hello world\"\n"),
585            &[("KEY", "hello world")],
586        );
587    }
588
589    #[test]
590    fn parse_env_single_quotes() {
591        assert_pairs(parse_env("KEY='hello world'\n"), &[("KEY", "hello world")]);
592    }
593
594    #[test]
595    fn parse_env_export_prefix() {
596        assert_pairs(parse_env("export FOO=bar\n"), &[("FOO", "bar")]);
597    }
598
599    #[test]
600    fn parse_env_skips_murk_keys() {
601        let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
602        assert_pairs(parse_env(input), &[("KEEP", "yes")]);
603    }
604
605    #[test]
606    fn parse_env_equals_in_value() {
607        assert_pairs(
608            parse_env("URL=postgres://host?opt=1\n"),
609            &[("URL", "postgres://host?opt=1")],
610        );
611    }
612
613    #[test]
614    fn parse_env_no_equals_skipped() {
615        assert_pairs(parse_env("not-a-valid-line\nKEY=val\n"), &[("KEY", "val")]);
616    }
617
618    // ── New edge-case tests ──
619
620    #[test]
621    fn parse_env_empty_value() {
622        assert_pairs(parse_env("KEY=\n"), &[("KEY", "")]);
623    }
624
625    #[test]
626    fn parse_env_trailing_whitespace() {
627        assert_pairs(parse_env("KEY=value   \n"), &[("KEY", "value")]);
628    }
629
630    #[test]
631    fn parse_env_unicode_value() {
632        assert_pairs(parse_env("KEY=hello🔐world\n"), &[("KEY", "hello🔐world")]);
633    }
634
635    #[test]
636    fn parse_env_empty_key_skipped() {
637        let pairs = parse_env("=value\n");
638        assert!(pairs.is_empty());
639    }
640
641    #[test]
642    fn parse_env_mixed_quotes_unmatched() {
643        // Mismatched quotes are not stripped.
644        assert_pairs(parse_env("KEY=\"hello'\n"), &[("KEY", "\"hello'")]);
645    }
646
647    #[test]
648    fn parse_env_multiple_murk_vars() {
649        // All three MURK_ vars are skipped, other vars kept.
650        let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
651        assert_pairs(parse_env(input), &[("A", "1"), ("B", "2")]);
652    }
653
654    /// Helper: acquire both locks and cd to a clean temp dir.
655    /// Returns guards and the previous cwd. The cwd is restored on drop
656    /// via the returned `prev` path — callers must restore manually before
657    /// asserting so panics don't leave cwd changed.
658    fn resolve_key_sandbox(
659        name: &str,
660    ) -> (
661        std::sync::MutexGuard<'static, ()>,
662        std::sync::MutexGuard<'static, ()>,
663        std::path::PathBuf,
664        std::path::PathBuf,
665    ) {
666        let env = ENV_LOCK
667            .lock()
668            .unwrap_or_else(std::sync::PoisonError::into_inner);
669        let cwd = CWD_LOCK
670            .lock()
671            .unwrap_or_else(std::sync::PoisonError::into_inner);
672        let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
673        let _ = std::fs::create_dir_all(&tmp);
674        let prev = std::env::current_dir().unwrap();
675        std::env::set_current_dir(&tmp).unwrap();
676        (env, cwd, tmp, prev)
677    }
678
679    fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
680        std::env::set_current_dir(prev).unwrap();
681        let _ = std::fs::remove_dir_all(tmp);
682    }
683
684    #[test]
685    fn resolve_key_from_env() {
686        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
687        let key = "AGE-SECRET-KEY-1TEST";
688        unsafe { env::set_var("MURK_KEY", key) };
689        let result = resolve_key();
690        unsafe { env::remove_var("MURK_KEY") };
691        resolve_key_sandbox_teardown(&tmp, &prev);
692
693        let secret = result.unwrap();
694        assert_eq!(secret.expose_secret(), key);
695    }
696
697    #[test]
698    fn resolve_key_from_file() {
699        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
700        unsafe { env::remove_var("MURK_KEY") };
701
702        let path = std::env::temp_dir().join("murk_test_key_file");
703        {
704            #[cfg(unix)]
705            {
706                use std::os::unix::fs::OpenOptionsExt;
707                let mut f = std::fs::OpenOptions::new()
708                    .create(true)
709                    .write(true)
710                    .truncate(true)
711                    .mode(0o600)
712                    .open(&path)
713                    .unwrap();
714                std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
715            }
716            #[cfg(not(unix))]
717            std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
718        }
719
720        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
721        let result = resolve_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        // File contents pass through unmodified so plugin identity files
728        // (multi-line with `# public key:` header) round-trip intact.
729        assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE");
730    }
731
732    #[test]
733    fn resolve_key_file_not_found() {
734        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
735        unsafe { env::remove_var("MURK_KEY") };
736        unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
737        let result = resolve_key();
738        unsafe { env::remove_var("MURK_KEY_FILE") };
739        resolve_key_sandbox_teardown(&tmp, &prev);
740
741        assert!(result.is_err());
742        assert!(result.unwrap_err().contains("cannot read"));
743    }
744
745    #[test]
746    fn resolve_key_neither_set() {
747        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
748        unsafe { env::remove_var("MURK_KEY") };
749        unsafe { env::remove_var("MURK_KEY_FILE") };
750        let result = resolve_key();
751        resolve_key_sandbox_teardown(&tmp, &prev);
752
753        assert!(result.is_err());
754        assert!(result.unwrap_err().contains("MURK_KEY not set"));
755    }
756
757    #[test]
758    fn resolve_key_empty_string_treated_as_unset() {
759        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
760        unsafe { env::set_var("MURK_KEY", "") };
761        unsafe { env::remove_var("MURK_KEY_FILE") };
762        let result = resolve_key();
763        unsafe { env::remove_var("MURK_KEY") };
764        resolve_key_sandbox_teardown(&tmp, &prev);
765
766        assert!(result.is_err());
767        assert!(result.unwrap_err().contains("MURK_KEY not set"));
768    }
769
770    #[test]
771    fn resolve_key_murk_key_takes_priority_over_file() {
772        let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
773        let direct_key = "AGE-SECRET-KEY-1DIRECT";
774        let file_key = "AGE-SECRET-KEY-1FILE";
775
776        let path = std::env::temp_dir().join("murk_test_key_priority");
777        std::fs::write(&path, format!("{file_key}\n")).unwrap();
778
779        unsafe { env::set_var("MURK_KEY", direct_key) };
780        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
781        let result = resolve_key();
782        unsafe { env::remove_var("MURK_KEY") };
783        unsafe { env::remove_var("MURK_KEY_FILE") };
784        std::fs::remove_file(&path).ok();
785        resolve_key_sandbox_teardown(&tmp, &prev);
786
787        let secret = result.unwrap();
788        assert_eq!(secret.expose_secret(), direct_key);
789    }
790
791    #[cfg(unix)]
792    #[test]
793    fn warn_env_permissions_no_warning_on_secure_file() {
794        use std::os::unix::fs::PermissionsExt;
795
796        let _cwd = CWD_LOCK
797            .lock()
798            .unwrap_or_else(std::sync::PoisonError::into_inner);
799
800        let dir = std::env::temp_dir().join("murk_test_perms");
801        let _ = std::fs::remove_dir_all(&dir);
802        std::fs::create_dir_all(&dir).unwrap();
803        let env_path = dir.join(".env");
804        std::fs::write(&env_path, "KEY=val\n").unwrap();
805        std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
806
807        // Just verify it doesn't panic — output goes to stderr.
808        let original_dir = std::env::current_dir().unwrap();
809        std::env::set_current_dir(&dir).unwrap();
810        warn_env_permissions();
811        std::env::set_current_dir(original_dir).unwrap();
812
813        std::fs::remove_dir_all(&dir).unwrap();
814    }
815
816    #[test]
817    fn resolve_key_does_not_read_dotenv() {
818        // Confirms the murk-82q fix: even if .env sits in CWD with an inline
819        // MURK_KEY, resolve_key_with_source must not pick it up. The runtime
820        // only trusts the environment and the vault-keyed auto lookup.
821        //
822        // Lock order: ENV_LOCK before CWD_LOCK, matching every other test
823        // that grabs both. Reversing the order deadlocks against parallel
824        // tests that hold ENV_LOCK while waiting for CWD_LOCK.
825        let _env_lock = ENV_LOCK
826            .lock()
827            .unwrap_or_else(std::sync::PoisonError::into_inner);
828        let _cwd = CWD_LOCK
829            .lock()
830            .unwrap_or_else(std::sync::PoisonError::into_inner);
831        let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
832        let _ = std::fs::remove_dir_all(&dir);
833        std::fs::create_dir_all(&dir).unwrap();
834        std::fs::write(
835            dir.join(".env"),
836            "MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
837        )
838        .unwrap();
839
840        // Preserve and clear any ambient key env so we see the true fallback.
841        let prev_key = env::var(ENV_MURK_KEY).ok();
842        let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
843        unsafe {
844            env::remove_var(ENV_MURK_KEY);
845            env::remove_var(ENV_MURK_KEY_FILE);
846        }
847
848        let original_dir = std::env::current_dir().unwrap();
849        std::env::set_current_dir(&dir).unwrap();
850        // Use a vault_path that won't match any auto key file on this machine.
851        let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
852        std::env::set_current_dir(original_dir).unwrap();
853
854        unsafe {
855            if let Some(v) = prev_key {
856                env::set_var(ENV_MURK_KEY, v);
857            }
858            if let Some(v) = prev_keyfile {
859                env::set_var(ENV_MURK_KEY_FILE, v);
860            }
861        }
862
863        assert!(
864            result.is_err(),
865            "resolve_key_with_source must not fall back to .env"
866        );
867        std::fs::remove_dir_all(&dir).unwrap();
868    }
869
870    #[test]
871    fn dotenv_has_murk_key_true() {
872        let _cwd = CWD_LOCK
873            .lock()
874            .unwrap_or_else(std::sync::PoisonError::into_inner);
875        let dir = std::env::temp_dir().join("murk_test_has_key_true");
876        let _ = std::fs::remove_dir_all(&dir);
877        std::fs::create_dir_all(&dir).unwrap();
878        std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
879
880        let original_dir = std::env::current_dir().unwrap();
881        std::env::set_current_dir(&dir).unwrap();
882        assert!(dotenv_has_murk_key());
883        std::env::set_current_dir(original_dir).unwrap();
884
885        std::fs::remove_dir_all(&dir).unwrap();
886    }
887
888    #[test]
889    fn dotenv_has_murk_key_false() {
890        let _cwd = CWD_LOCK
891            .lock()
892            .unwrap_or_else(std::sync::PoisonError::into_inner);
893        let dir = std::env::temp_dir().join("murk_test_has_key_false");
894        let _ = std::fs::remove_dir_all(&dir);
895        std::fs::create_dir_all(&dir).unwrap();
896        std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
897
898        let original_dir = std::env::current_dir().unwrap();
899        std::env::set_current_dir(&dir).unwrap();
900        assert!(!dotenv_has_murk_key());
901        std::env::set_current_dir(original_dir).unwrap();
902
903        std::fs::remove_dir_all(&dir).unwrap();
904    }
905
906    #[test]
907    fn dotenv_has_murk_key_no_file() {
908        let _cwd = CWD_LOCK
909            .lock()
910            .unwrap_or_else(std::sync::PoisonError::into_inner);
911        let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
912        let _ = std::fs::remove_dir_all(&dir);
913        std::fs::create_dir_all(&dir).unwrap();
914
915        let original_dir = std::env::current_dir().unwrap();
916        std::env::set_current_dir(&dir).unwrap();
917        assert!(!dotenv_has_murk_key());
918        std::env::set_current_dir(original_dir).unwrap();
919
920        std::fs::remove_dir_all(&dir).unwrap();
921    }
922
923    #[test]
924    fn write_key_to_dotenv_creates_new() {
925        let _cwd = CWD_LOCK
926            .lock()
927            .unwrap_or_else(std::sync::PoisonError::into_inner);
928        let dir = std::env::temp_dir().join("murk_test_write_key_new");
929        let _ = std::fs::remove_dir_all(&dir);
930        std::fs::create_dir_all(&dir).unwrap();
931
932        let original_dir = std::env::current_dir().unwrap();
933        std::env::set_current_dir(&dir).unwrap();
934        write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
935
936        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
937        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
938
939        std::env::set_current_dir(original_dir).unwrap();
940        std::fs::remove_dir_all(&dir).unwrap();
941    }
942
943    #[test]
944    fn write_key_to_dotenv_replaces_existing() {
945        let _cwd = CWD_LOCK
946            .lock()
947            .unwrap_or_else(std::sync::PoisonError::into_inner);
948        let dir = std::env::temp_dir().join("murk_test_write_key_replace");
949        let _ = std::fs::remove_dir_all(&dir);
950        std::fs::create_dir_all(&dir).unwrap();
951        std::fs::write(
952            dir.join(".env"),
953            "OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
954        )
955        .unwrap();
956
957        let original_dir = std::env::current_dir().unwrap();
958        std::env::set_current_dir(&dir).unwrap();
959        write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
960
961        let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
962        assert!(contents.contains("OTHER=keep"));
963        assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
964        assert!(!contents.contains("MURK_KEY=old"));
965        assert!(!contents.contains("also_old"));
966
967        std::env::set_current_dir(original_dir).unwrap();
968        std::fs::remove_dir_all(&dir).unwrap();
969    }
970
971    #[cfg(unix)]
972    #[test]
973    fn write_key_to_dotenv_permissions_are_600() {
974        use std::os::unix::fs::PermissionsExt;
975
976        let _cwd = CWD_LOCK
977            .lock()
978            .unwrap_or_else(std::sync::PoisonError::into_inner);
979
980        let dir = std::env::temp_dir().join("murk_test_write_key_perms");
981        let _ = std::fs::remove_dir_all(&dir);
982        std::fs::create_dir_all(&dir).unwrap();
983
984        let original_dir = std::env::current_dir().unwrap();
985        std::env::set_current_dir(&dir).unwrap();
986
987        // Create new .env — should be 0o600 from the start.
988        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
989        let meta = std::fs::metadata(dir.join(".env")).unwrap();
990        assert_eq!(
991            meta.permissions().mode() & 0o777,
992            SECRET_FILE_MODE,
993            "new .env should be created with mode 600"
994        );
995
996        // Replace existing — should still be 0o600.
997        write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
998        let meta = std::fs::metadata(dir.join(".env")).unwrap();
999        assert_eq!(
1000            meta.permissions().mode() & 0o777,
1001            SECRET_FILE_MODE,
1002            "rewritten .env should maintain mode 600"
1003        );
1004
1005        std::env::set_current_dir(original_dir).unwrap();
1006        std::fs::remove_dir_all(&dir).unwrap();
1007    }
1008
1009    #[test]
1010    fn write_envrc_creates_new() {
1011        let _cwd = CWD_LOCK
1012            .lock()
1013            .unwrap_or_else(std::sync::PoisonError::into_inner);
1014        let dir = std::env::temp_dir().join("murk_test_envrc_new");
1015        let _ = std::fs::remove_dir_all(&dir);
1016        std::fs::create_dir_all(&dir).unwrap();
1017
1018        let original_dir = std::env::current_dir().unwrap();
1019        std::env::set_current_dir(&dir).unwrap();
1020        let status = write_envrc(".murk").unwrap();
1021        assert_eq!(status, EnvrcStatus::Created);
1022
1023        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1024        assert!(contents.contains("murk export --vault .murk"));
1025
1026        std::env::set_current_dir(original_dir).unwrap();
1027        std::fs::remove_dir_all(&dir).unwrap();
1028    }
1029
1030    #[test]
1031    fn write_envrc_appends() {
1032        let _cwd = CWD_LOCK
1033            .lock()
1034            .unwrap_or_else(std::sync::PoisonError::into_inner);
1035        let dir = std::env::temp_dir().join("murk_test_envrc_append");
1036        let _ = std::fs::remove_dir_all(&dir);
1037        std::fs::create_dir_all(&dir).unwrap();
1038        std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
1039
1040        let original_dir = std::env::current_dir().unwrap();
1041        std::env::set_current_dir(&dir).unwrap();
1042        let status = write_envrc(".murk").unwrap();
1043        assert_eq!(status, EnvrcStatus::Appended);
1044
1045        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1046        assert!(contents.contains("existing content"));
1047        assert!(contents.contains("murk export"));
1048
1049        std::env::set_current_dir(original_dir).unwrap();
1050        std::fs::remove_dir_all(&dir).unwrap();
1051    }
1052
1053    #[test]
1054    fn write_envrc_already_present() {
1055        let _cwd = CWD_LOCK
1056            .lock()
1057            .unwrap_or_else(std::sync::PoisonError::into_inner);
1058        let dir = std::env::temp_dir().join("murk_test_envrc_present");
1059        let _ = std::fs::remove_dir_all(&dir);
1060        std::fs::create_dir_all(&dir).unwrap();
1061        std::fs::write(
1062            dir.join(".envrc"),
1063            "eval \"$(murk export --vault .murk)\"\n",
1064        )
1065        .unwrap();
1066
1067        let original_dir = std::env::current_dir().unwrap();
1068        std::env::set_current_dir(&dir).unwrap();
1069        let status = write_envrc(".murk").unwrap();
1070        assert_eq!(status, EnvrcStatus::AlreadyPresent);
1071
1072        std::env::set_current_dir(original_dir).unwrap();
1073        std::fs::remove_dir_all(&dir).unwrap();
1074    }
1075
1076    #[test]
1077    fn reject_symlink_ok_for_regular_file() {
1078        let dir = tempfile::TempDir::new().unwrap();
1079        let path = dir.path().join("regular.txt");
1080        std::fs::write(&path, "content").unwrap();
1081        assert!(reject_symlink(&path, "test").is_ok());
1082    }
1083
1084    #[test]
1085    fn reject_symlink_ok_for_nonexistent() {
1086        let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
1087        assert!(reject_symlink(path, "test").is_ok());
1088    }
1089
1090    #[cfg(unix)]
1091    #[test]
1092    fn reject_symlink_rejects_symlink() {
1093        let dir = tempfile::TempDir::new().unwrap();
1094        let link = dir.path().join("link");
1095        std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
1096        let result = reject_symlink(&link, "test");
1097        assert!(result.is_err());
1098        assert!(result.unwrap_err().contains("symlink"));
1099    }
1100
1101    #[cfg(unix)]
1102    #[test]
1103    fn read_secret_file_rejects_world_readable() {
1104        use std::os::unix::fs::PermissionsExt;
1105        let dir = tempfile::TempDir::new().unwrap();
1106        let path = dir.path().join("loose.key");
1107        std::fs::write(&path, "secret").unwrap();
1108        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1109        let result = read_secret_file(&path, "test");
1110        assert!(result.is_err());
1111        assert!(result.unwrap_err().contains("readable by others"));
1112    }
1113
1114    #[cfg(unix)]
1115    #[test]
1116    fn read_secret_file_accepts_600() {
1117        use std::os::unix::fs::OpenOptionsExt;
1118        let dir = tempfile::TempDir::new().unwrap();
1119        let path = dir.path().join("tight.key");
1120        let mut f = std::fs::OpenOptions::new()
1121            .create(true)
1122            .truncate(true)
1123            .write(true)
1124            .mode(0o600)
1125            .open(&path)
1126            .unwrap();
1127        std::io::Write::write_all(&mut f, b"secret").unwrap();
1128        let result = read_secret_file(&path, "test");
1129        assert!(result.is_ok());
1130        assert_eq!(result.unwrap(), "secret");
1131    }
1132
1133    #[test]
1134    fn shell_escape_bare_identifiers() {
1135        assert_eq!(shell_escape(".murk"), ".murk");
1136        assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
1137        assert_eq!(
1138            shell_escape("/home/user/.config/murk/key"),
1139            "/home/user/.config/murk/key"
1140        );
1141    }
1142
1143    #[test]
1144    fn shell_escape_quotes_special_chars() {
1145        assert_eq!(shell_escape("my vault"), "'my vault'");
1146        assert_eq!(shell_escape("it's"), "'it'\\''s'");
1147        assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
1148    }
1149
1150    #[test]
1151    fn write_envrc_escapes_vault_name() {
1152        let _cwd = CWD_LOCK
1153            .lock()
1154            .unwrap_or_else(std::sync::PoisonError::into_inner);
1155        let dir = std::env::temp_dir().join("murk_test_envrc_escape");
1156        let _ = std::fs::remove_dir_all(&dir);
1157        std::fs::create_dir_all(&dir).unwrap();
1158
1159        let original_dir = std::env::current_dir().unwrap();
1160        std::env::set_current_dir(&dir).unwrap();
1161        let status = write_envrc("my vault.murk").unwrap();
1162        assert_eq!(status, EnvrcStatus::Created);
1163
1164        let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
1165        assert!(contents.contains("'my vault.murk'"));
1166
1167        std::env::set_current_dir(original_dir).unwrap();
1168        std::fs::remove_dir_all(&dir).unwrap();
1169    }
1170}