Skip to main content

lightshuttle_secrets/source/
env_file.rs

1//! `.env` file source implementation.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::SecretError;
7use crate::source::SecretSource;
8
9/// Loads secrets from a `.env` file.
10///
11/// This struct parses a `.env` file at construction time and caches the key-value pairs.
12/// Once constructed, the source can be cloned and reused; each call to [`load`] returns
13/// a copy of the cached map.
14///
15/// # Supported syntax
16///
17/// - `KEY=VALUE`: plain value
18/// - `KEY="quoted value"`: double-quoted values with quotes stripped
19/// - `KEY='quoted value'`: single-quoted values with quotes stripped
20/// - `export KEY=VALUE`: optional `export` prefix followed by whitespace (ignored)
21/// - `# comment`: lines starting with `#` are skipped
22/// - Blank lines: ignored
23/// - Inline comments: `KEY=VALUE # comment` (trailing comments on unquoted values only;
24///   quoted values preserve all characters including `#`)
25/// - UTF-8 BOM: stripped from the start of the file if present
26///
27/// # Errors
28///
29/// Construction fails if the file does not exist, is unreadable, or contains invalid syntax.
30/// Use [`load_optional`] for paths that may not exist.
31///
32/// [`load`]: SecretSource::load
33/// [`load_optional`]: EnvFileSource::load_optional
34#[derive(Debug)]
35pub struct EnvFileSource {
36    path: PathBuf,
37    entries: HashMap<String, String>,
38}
39
40impl EnvFileSource {
41    /// Load from `path`, requiring the file to exist.
42    ///
43    /// Returns [`SecretError::FileNotFound`] if the file does not exist, or
44    /// [`SecretError::Io`] if the file is unreadable. Use this when the path
45    /// was explicitly provided by the user (e.g. via `--env-file` CLI flag).
46    ///
47    /// For optional files (like the default `.env`), use [`load_optional`] instead.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if the file does not exist, cannot be read, or contains invalid syntax.
52    ///
53    /// # Examples
54    ///
55    /// ```no_run
56    /// use lightshuttle_secrets::EnvFileSource;
57    ///
58    /// let source = EnvFileSource::load(".env")?;
59    /// println!("Loaded {} secrets", source.len());
60    /// # Ok::<(), lightshuttle_secrets::SecretError>(())
61    /// ```
62    ///
63    /// [`load_optional`]: EnvFileSource::load_optional
64    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    /// Load from `path` if it exists, returning `None` if the file is absent.
74    ///
75    /// This is useful for optional configuration files like the default `.env` path.
76    /// If the file is absent, no error is raised. If the file exists but is malformed,
77    /// an error is returned.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error only if the file exists but cannot be read or contains invalid syntax.
82    ///
83    /// # Examples
84    ///
85    /// ```no_run
86    /// use lightshuttle_secrets::EnvFileSource;
87    ///
88    /// if let Some(source) = EnvFileSource::load_optional(".env")? {
89    ///     println!("Using {} secrets", source.len());
90    /// } else {
91    ///     println!("No .env file; using defaults");
92    /// }
93    /// # Ok::<(), lightshuttle_secrets::SecretError>(())
94    /// ```
95    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    /// Number of entries loaded from the file.
105    ///
106    /// Returns the count of successfully parsed `KEY=VALUE` pairs.
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    /// Returns `true` if the file contained no entries.
113    ///
114    /// This is `true` if the file was empty, contained only comments and blank lines,
115    /// or was otherwise parsed to zero key-value pairs.
116    #[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    // Editors on Windows frequently prepend a UTF-8 byte-order mark; strip it
139    // so the first key is not silently misnamed with a leading `\u{feff}`.
140    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
177/// Strip an optional `export` keyword followed by horizontal whitespace.
178///
179/// `export KEY=VALUE` and `export<TAB>KEY=VALUE` both yield `KEY=VALUE`, while
180/// `exportKEY=VALUE` is left untouched because `export` is part of the key.
181fn 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    // Quoted value: return the span between the opening quote and its first
191    // matching closing quote, discarding any trailing inline comment. This
192    // lets a quoted value contain ` #` without being truncated.
193    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        // No closing quote: fall through and treat the value literally.
198    }
199
200    // Unquoted value: strip a trailing ` #` inline comment.
201    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}