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