Skip to main content

murk_cli/
env.rs

1//! Environment and `.env` file handling.
2
3use std::env;
4use std::fs;
5use std::path::Path;
6
7use age::secrecy::SecretString;
8
9/// Keys to skip when importing from a .env file.
10const IMPORT_SKIP: &[&str] = &["MURK_KEY", "MURK_KEY_FILE", "MURK_VAULT"];
11
12/// Resolve the secret key from `MURK_KEY` or `MURK_KEY_FILE`.
13/// `MURK_KEY` takes priority; `MURK_KEY_FILE` reads the key from a file.
14/// Returns the key wrapped in `SecretString` so it is zeroized on drop.
15pub fn resolve_key() -> Result<SecretString, String> {
16    if let Ok(k) = env::var("MURK_KEY") {
17        if !k.is_empty() {
18            return Ok(SecretString::from(k));
19        }
20    }
21    if let Ok(path) = env::var("MURK_KEY_FILE") {
22        return fs::read_to_string(&path)
23            .map(|contents| SecretString::from(contents.trim().to_string()))
24            .map_err(|e| format!("cannot read MURK_KEY_FILE ({path}): {e}"));
25    }
26    Err("MURK_KEY not set. Add it to .env and load with direnv or `eval $(cat .env)`. Alternatively, set MURK_KEY_FILE to a path containing the key".into())
27}
28
29/// Parse a .env file into key-value pairs.
30/// Skips comments, blank lines, `MURK_*` keys, and strips quotes and `export` prefixes.
31pub fn parse_env(contents: &str) -> Vec<(String, String)> {
32    let mut pairs = Vec::new();
33
34    for line in contents.lines() {
35        let line = line.trim();
36
37        if line.is_empty() || line.starts_with('#') {
38            continue;
39        }
40
41        let line = line.strip_prefix("export ").unwrap_or(line);
42
43        let Some((key, value)) = line.split_once('=') else {
44            continue;
45        };
46
47        let key = key.trim();
48        let value = value.trim();
49
50        // Strip surrounding quotes.
51        let value = value
52            .strip_prefix('"')
53            .and_then(|v| v.strip_suffix('"'))
54            .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
55            .unwrap_or(value);
56
57        if key.is_empty() || IMPORT_SKIP.contains(&key) {
58            continue;
59        }
60
61        pairs.push((key.into(), value.into()));
62    }
63
64    pairs
65}
66
67/// Warn if `.env` has loose permissions (Unix only).
68pub fn warn_env_permissions() {
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::PermissionsExt;
72        let env_path = Path::new(".env");
73        if env_path.exists()
74            && let Ok(meta) = fs::metadata(env_path)
75        {
76            let mode = meta.permissions().mode();
77            if mode & 0o077 != 0 {
78                eprintln!(
79                    "\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
80                    mode & 0o777
81                );
82            }
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::sync::Mutex;
91
92    /// Tests that mutate MURK_KEY / MURK_KEY_FILE env vars must hold this lock
93    /// to avoid racing with each other (cargo test runs tests in parallel).
94    static ENV_LOCK: Mutex<()> = Mutex::new(());
95
96    #[test]
97    fn parse_env_empty() {
98        assert!(parse_env("").is_empty());
99    }
100
101    #[test]
102    fn parse_env_comments_and_blanks() {
103        let input = "# comment\n\n  # another\n";
104        assert!(parse_env(input).is_empty());
105    }
106
107    #[test]
108    fn parse_env_basic() {
109        let input = "FOO=bar\nBAZ=qux\n";
110        let pairs = parse_env(input);
111        assert_eq!(
112            pairs,
113            vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]
114        );
115    }
116
117    #[test]
118    fn parse_env_double_quotes() {
119        let pairs = parse_env("KEY=\"hello world\"\n");
120        assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
121    }
122
123    #[test]
124    fn parse_env_single_quotes() {
125        let pairs = parse_env("KEY='hello world'\n");
126        assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
127    }
128
129    #[test]
130    fn parse_env_export_prefix() {
131        let pairs = parse_env("export FOO=bar\n");
132        assert_eq!(pairs, vec![("FOO".into(), "bar".into())]);
133    }
134
135    #[test]
136    fn parse_env_skips_murk_keys() {
137        let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
138        let pairs = parse_env(input);
139        assert_eq!(pairs, vec![("KEEP".into(), "yes".into())]);
140    }
141
142    #[test]
143    fn parse_env_equals_in_value() {
144        let pairs = parse_env("URL=postgres://host?opt=1\n");
145        assert_eq!(pairs, vec![("URL".into(), "postgres://host?opt=1".into())]);
146    }
147
148    #[test]
149    fn parse_env_no_equals_skipped() {
150        let pairs = parse_env("not-a-valid-line\nKEY=val\n");
151        assert_eq!(pairs, vec![("KEY".into(), "val".into())]);
152    }
153
154    // ── New edge-case tests ──
155
156    #[test]
157    fn parse_env_empty_value() {
158        let pairs = parse_env("KEY=\n");
159        assert_eq!(pairs, vec![("KEY".into(), String::new())]);
160    }
161
162    #[test]
163    fn parse_env_trailing_whitespace() {
164        let pairs = parse_env("KEY=value   \n");
165        assert_eq!(pairs, vec![("KEY".into(), "value".into())]);
166    }
167
168    #[test]
169    fn parse_env_unicode_value() {
170        let pairs = parse_env("KEY=hello🔐world\n");
171        assert_eq!(pairs, vec![("KEY".into(), "hello🔐world".into())]);
172    }
173
174    #[test]
175    fn parse_env_empty_key_skipped() {
176        let pairs = parse_env("=value\n");
177        assert!(pairs.is_empty());
178    }
179
180    #[test]
181    fn parse_env_mixed_quotes_unmatched() {
182        // Mismatched quotes are not stripped.
183        let pairs = parse_env("KEY=\"hello'\n");
184        assert_eq!(pairs, vec![("KEY".into(), "\"hello'".into())]);
185    }
186
187    #[test]
188    fn parse_env_multiple_murk_vars() {
189        // All three MURK_ vars are skipped, other vars kept.
190        let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
191        let pairs = parse_env(input);
192        assert_eq!(
193            pairs,
194            vec![("A".into(), "1".into()), ("B".into(), "2".into())]
195        );
196    }
197
198    #[test]
199    fn resolve_key_from_env() {
200        let _lock = ENV_LOCK.lock().unwrap();
201        let key = "AGE-SECRET-KEY-1TEST";
202        unsafe { env::set_var("MURK_KEY", key) };
203        let result = resolve_key();
204        unsafe { env::remove_var("MURK_KEY") };
205
206        let secret = result.unwrap();
207        use age::secrecy::ExposeSecret;
208        assert_eq!(secret.expose_secret(), key);
209    }
210
211    #[test]
212    fn resolve_key_from_file() {
213        let _lock = ENV_LOCK.lock().unwrap();
214        unsafe { env::remove_var("MURK_KEY") };
215
216        let path = std::env::temp_dir().join("murk_test_key_file");
217        std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
218
219        unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
220        let result = resolve_key();
221        unsafe { env::remove_var("MURK_KEY_FILE") };
222        std::fs::remove_file(&path).ok();
223
224        let secret = result.unwrap();
225        use age::secrecy::ExposeSecret;
226        assert_eq!(secret.expose_secret(), "AGE-SECRET-KEY-1FROMFILE");
227    }
228
229    #[test]
230    fn resolve_key_file_not_found() {
231        let _lock = ENV_LOCK.lock().unwrap();
232        unsafe { env::remove_var("MURK_KEY") };
233        unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
234        let result = resolve_key();
235        unsafe { env::remove_var("MURK_KEY_FILE") };
236
237        assert!(result.is_err());
238        assert!(result.unwrap_err().contains("cannot read MURK_KEY_FILE"));
239    }
240
241    #[test]
242    fn resolve_key_neither_set() {
243        let _lock = ENV_LOCK.lock().unwrap();
244        unsafe { env::remove_var("MURK_KEY") };
245        unsafe { env::remove_var("MURK_KEY_FILE") };
246        let result = resolve_key();
247
248        assert!(result.is_err());
249        assert!(result.unwrap_err().contains("MURK_KEY not set"));
250    }
251
252    #[test]
253    fn resolve_key_empty_string_treated_as_unset() {
254        let _lock = ENV_LOCK.lock().unwrap();
255        unsafe { env::set_var("MURK_KEY", "") };
256        unsafe { env::remove_var("MURK_KEY_FILE") };
257        let result = resolve_key();
258        unsafe { env::remove_var("MURK_KEY") };
259
260        assert!(result.is_err());
261        assert!(result.unwrap_err().contains("MURK_KEY not set"));
262    }
263
264    #[cfg(unix)]
265    #[test]
266    fn warn_env_permissions_no_warning_on_secure_file() {
267        use std::os::unix::fs::PermissionsExt;
268
269        let dir = std::env::temp_dir().join("murk_test_perms");
270        std::fs::create_dir_all(&dir).unwrap();
271        let env_path = dir.join(".env");
272        std::fs::write(&env_path, "KEY=val\n").unwrap();
273        std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
274
275        // Just verify it doesn't panic — output goes to stderr.
276        let original_dir = std::env::current_dir().unwrap();
277        std::env::set_current_dir(&dir).unwrap();
278        warn_env_permissions();
279        std::env::set_current_dir(original_dir).unwrap();
280
281        std::fs::remove_dir_all(&dir).unwrap();
282    }
283}