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