lightshuttle_secrets/source/
env_file.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::SecretError;
7use crate::source::SecretSource;
8
9#[derive(Debug)]
35pub struct EnvFileSource {
36 path: PathBuf,
37 entries: HashMap<String, String>,
38}
39
40impl EnvFileSource {
41 pub fn load(path: impl Into<PathBuf>) -> Result<Self, SecretError> {
65 let path = path.into();
66 if !path.exists() {
67 return Err(SecretError::FileNotFound(path));
68 }
69 let entries = parse_env_file(&path)?;
70 Ok(Self { path, entries })
71 }
72
73 pub fn load_optional(path: impl Into<PathBuf>) -> Result<Option<Self>, SecretError> {
96 let path = path.into();
97 if !path.exists() {
98 return Ok(None);
99 }
100 let entries = parse_env_file(&path)?;
101 Ok(Some(Self { path, entries }))
102 }
103
104 #[must_use]
108 pub fn len(&self) -> usize {
109 self.entries.len()
110 }
111
112 #[must_use]
117 pub fn is_empty(&self) -> bool {
118 self.entries.is_empty()
119 }
120}
121
122impl SecretSource for EnvFileSource {
123 fn load(&self) -> Result<HashMap<String, String>, SecretError> {
124 Ok(self.entries.clone())
125 }
126
127 fn source_name(&self) -> &str {
128 self.path.to_str().unwrap_or(".env")
129 }
130}
131
132fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, SecretError> {
133 let content = std::fs::read_to_string(path).map_err(|source| SecretError::Io {
134 path: path.to_path_buf(),
135 source,
136 })?;
137
138 let content = content.strip_prefix('\u{feff}').unwrap_or(&content);
141
142 let mut map = HashMap::new();
143
144 for (idx, raw) in content.lines().enumerate() {
145 let line = raw.trim();
146
147 if line.is_empty() || line.starts_with('#') {
148 continue;
149 }
150
151 let line = strip_export_prefix(line);
152
153 let Some((key, raw_value)) = line.split_once('=') else {
154 return Err(SecretError::InvalidSyntax {
155 path: path.to_path_buf(),
156 line: idx + 1,
157 message: format!("expected KEY=VALUE, got `{line}`"),
158 });
159 };
160
161 let key = key.trim();
162 if key.is_empty() {
163 return Err(SecretError::InvalidSyntax {
164 path: path.to_path_buf(),
165 line: idx + 1,
166 message: "empty key".to_owned(),
167 });
168 }
169
170 let value = unescape_value(raw_value.trim());
171 map.insert(key.to_owned(), value);
172 }
173
174 Ok(map)
175}
176
177fn strip_export_prefix(line: &str) -> &str {
182 line.strip_prefix("export")
183 .filter(|rest| rest.starts_with([' ', '\t']))
184 .map_or(line, |rest| rest.trim_start_matches([' ', '\t']))
185}
186
187fn unescape_value(s: &str) -> String {
188 let s = s.trim();
189
190 if let Some(quote) = s.chars().next().filter(|c| *c == '"' || *c == '\'') {
194 if let Some(end) = s[1..].find(quote) {
195 return s[1..=end].to_owned();
196 }
197 }
199
200 if let Some((value, _comment)) = s.split_once(" #") {
202 value.trim_end().to_owned()
203 } else {
204 s.to_owned()
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use std::io::Write as _;
211
212 use super::*;
213
214 fn write_env(content: &str) -> (tempfile::NamedTempFile, PathBuf) {
215 let mut f = tempfile::NamedTempFile::new().unwrap();
216 f.write_all(content.as_bytes()).unwrap();
217 let path = f.path().to_path_buf();
218 (f, path)
219 }
220
221 #[test]
222 fn parse_plain_key_value() {
223 let (_f, path) = write_env("DB_URL=postgres://localhost/db\n");
224 let src = EnvFileSource::load(&path).unwrap();
225 let map = SecretSource::load(&src).unwrap();
226 assert_eq!(map["DB_URL"], "postgres://localhost/db");
227 }
228
229 #[test]
230 fn parse_double_quoted_value() {
231 let (_f, path) = write_env("SECRET=\"hello world\"\n");
232 let src = EnvFileSource::load(&path).unwrap();
233 let map = SecretSource::load(&src).unwrap();
234 assert_eq!(map["SECRET"], "hello world");
235 }
236
237 #[test]
238 fn parse_single_quoted_value() {
239 let (_f, path) = write_env("TOKEN='abc123'\n");
240 let src = EnvFileSource::load(&path).unwrap();
241 let map = SecretSource::load(&src).unwrap();
242 assert_eq!(map["TOKEN"], "abc123");
243 }
244
245 #[test]
246 fn skip_comments_and_blank_lines() {
247 let (_f, path) = write_env("# comment\n\nKEY=val\n");
248 let src = EnvFileSource::load(&path).unwrap();
249 assert_eq!(src.len(), 1);
250 }
251
252 #[test]
253 fn strip_export_prefix() {
254 let (_f, path) = write_env("export API_KEY=secret\n");
255 let src = EnvFileSource::load(&path).unwrap();
256 let map = SecretSource::load(&src).unwrap();
257 assert_eq!(map["API_KEY"], "secret");
258 }
259
260 #[test]
261 fn strip_inline_comment() {
262 let (_f, path) = write_env("PORT=8080 # default port\n");
263 let src = EnvFileSource::load(&path).unwrap();
264 let map = SecretSource::load(&src).unwrap();
265 assert_eq!(map["PORT"], "8080");
266 }
267
268 #[test]
269 fn load_optional_absent_returns_none() {
270 let result = EnvFileSource::load_optional("/nonexistent/.env").unwrap();
271 assert!(result.is_none());
272 }
273
274 #[test]
275 fn load_explicit_absent_returns_error() {
276 let err = EnvFileSource::load("/nonexistent/.env").unwrap_err();
277 assert!(matches!(err, SecretError::FileNotFound(_)));
278 }
279
280 #[test]
281 fn invalid_line_returns_error() {
282 let (_f, path) = write_env("NOT_A_VALID_LINE\n");
283 let err = EnvFileSource::load(&path).unwrap_err();
284 assert!(matches!(err, SecretError::InvalidSyntax { line: 1, .. }));
285 }
286
287 #[test]
288 fn strips_utf8_bom_from_first_key() {
289 let (_f, path) = write_env("\u{feff}FIRST=value\n");
290 let src = EnvFileSource::load(&path).unwrap();
291 let map = SecretSource::load(&src).unwrap();
292 assert_eq!(map["FIRST"], "value");
293 assert!(!map.contains_key("\u{feff}FIRST"));
294 }
295
296 #[test]
297 fn quoted_value_with_inline_comment_drops_the_comment_and_quotes() {
298 let (_f, path) = write_env("KEY=\"val\" # trailing comment\n");
299 let src = EnvFileSource::load(&path).unwrap();
300 let map = SecretSource::load(&src).unwrap();
301 assert_eq!(map["KEY"], "val");
302 }
303
304 #[test]
305 fn hash_inside_quotes_is_preserved() {
306 let (_f, path) = write_env("PASSWORD=\"a b#c #d\"\n");
307 let src = EnvFileSource::load(&path).unwrap();
308 let map = SecretSource::load(&src).unwrap();
309 assert_eq!(map["PASSWORD"], "a b#c #d");
310 }
311
312 #[test]
313 fn unquoted_value_without_space_hash_keeps_fragment() {
314 let (_f, path) = write_env("URL=https://example.com/p#frag\n");
315 let src = EnvFileSource::load(&path).unwrap();
316 let map = SecretSource::load(&src).unwrap();
317 assert_eq!(map["URL"], "https://example.com/p#frag");
318 }
319
320 #[test]
321 fn strip_export_prefix_with_tab() {
322 let (_f, path) = write_env("export\tAPI_KEY=secret\n");
323 let src = EnvFileSource::load(&path).unwrap();
324 let map = SecretSource::load(&src).unwrap();
325 assert_eq!(map["API_KEY"], "secret");
326 }
327
328 #[test]
329 fn export_glued_to_key_is_not_stripped() {
330 let (_f, path) = write_env("exportAPI_KEY=secret\n");
331 let src = EnvFileSource::load(&path).unwrap();
332 let map = SecretSource::load(&src).unwrap();
333 assert_eq!(map["exportAPI_KEY"], "secret");
334 }
335}