1use std::env;
4use std::fs;
5use std::path::Path;
6
7use age::secrecy::SecretString;
8
9const IMPORT_SKIP: &[&str] = &["MURK_KEY", "MURK_KEY_FILE", "MURK_VAULT"];
11
12pub 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
29pub 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 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
67pub 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 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 #[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 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 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 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}