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/// The file is parsed once at construction time. Supported syntax:
12///
13/// - `KEY=VALUE` — plain value
14/// - `KEY="quoted value"` — double-quoted (quotes stripped)
15/// - `KEY='quoted value'` — single-quoted (quotes stripped)
16/// - `export KEY=VALUE` — optional `export` prefix, followed by spaces or tabs (ignored)
17/// - `# comment` — ignored
18/// - Blank lines — ignored
19/// - Inline comments: `KEY=VALUE # comment` (unquoted values only)
20#[derive(Debug)]
21pub struct EnvFileSource {
22    path: PathBuf,
23    entries: HashMap<String, String>,
24}
25
26impl EnvFileSource {
27    /// Load from `path`.
28    ///
29    /// Returns [`SecretError::FileNotFound`] if the file does not exist.
30    /// Use this when the path was explicitly provided by the user (e.g. via
31    /// `--env-file`).
32    pub fn load(path: impl Into<PathBuf>) -> Result<Self, SecretError> {
33        let path = path.into();
34        if !path.exists() {
35            return Err(SecretError::FileNotFound(path));
36        }
37        let entries = parse_env_file(&path)?;
38        Ok(Self { path, entries })
39    }
40
41    /// Load from `path` if it exists, or return `None` if the file is absent.
42    ///
43    /// Use this for the default `.env` path so that projects without a `.env`
44    /// file are not forced to create one.
45    pub fn load_optional(path: impl Into<PathBuf>) -> Result<Option<Self>, SecretError> {
46        let path = path.into();
47        if !path.exists() {
48            return Ok(None);
49        }
50        let entries = parse_env_file(&path)?;
51        Ok(Some(Self { path, entries }))
52    }
53
54    /// Number of entries loaded from the file.
55    #[must_use]
56    pub fn len(&self) -> usize {
57        self.entries.len()
58    }
59
60    /// Returns `true` if the file contained no entries.
61    #[must_use]
62    pub fn is_empty(&self) -> bool {
63        self.entries.is_empty()
64    }
65}
66
67impl SecretSource for EnvFileSource {
68    fn load(&self) -> Result<HashMap<String, String>, SecretError> {
69        Ok(self.entries.clone())
70    }
71
72    fn source_name(&self) -> &str {
73        self.path.to_str().unwrap_or(".env")
74    }
75}
76
77fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, SecretError> {
78    let content = std::fs::read_to_string(path).map_err(|source| SecretError::Io {
79        path: path.to_path_buf(),
80        source,
81    })?;
82
83    // Editors on Windows frequently prepend a UTF-8 byte-order mark; strip it
84    // so the first key is not silently misnamed with a leading `\u{feff}`.
85    let content = content.strip_prefix('\u{feff}').unwrap_or(&content);
86
87    let mut map = HashMap::new();
88
89    for (idx, raw) in content.lines().enumerate() {
90        let line = raw.trim();
91
92        if line.is_empty() || line.starts_with('#') {
93            continue;
94        }
95
96        let line = strip_export_prefix(line);
97
98        let Some((key, raw_value)) = line.split_once('=') else {
99            return Err(SecretError::InvalidSyntax {
100                path: path.to_path_buf(),
101                line: idx + 1,
102                message: format!("expected KEY=VALUE, got `{line}`"),
103            });
104        };
105
106        let key = key.trim();
107        if key.is_empty() {
108            return Err(SecretError::InvalidSyntax {
109                path: path.to_path_buf(),
110                line: idx + 1,
111                message: "empty key".to_owned(),
112            });
113        }
114
115        let value = unescape_value(raw_value.trim());
116        map.insert(key.to_owned(), value);
117    }
118
119    Ok(map)
120}
121
122/// Strip an optional `export` keyword followed by horizontal whitespace.
123///
124/// `export KEY=VALUE` and `export<TAB>KEY=VALUE` both yield `KEY=VALUE`, while
125/// `exportKEY=VALUE` is left untouched because `export` is part of the key.
126fn strip_export_prefix(line: &str) -> &str {
127    line.strip_prefix("export")
128        .filter(|rest| rest.starts_with([' ', '\t']))
129        .map_or(line, |rest| rest.trim_start_matches([' ', '\t']))
130}
131
132fn unescape_value(s: &str) -> String {
133    let s = s.trim();
134
135    // Quoted value: return the span between the opening quote and its first
136    // matching closing quote, discarding any trailing inline comment. This
137    // lets a quoted value contain ` #` without being truncated.
138    if let Some(quote) = s.chars().next().filter(|c| *c == '"' || *c == '\'') {
139        if let Some(end) = s[1..].find(quote) {
140            return s[1..=end].to_owned();
141        }
142        // No closing quote: fall through and treat the value literally.
143    }
144
145    // Unquoted value: strip a trailing ` #` inline comment.
146    if let Some((value, _comment)) = s.split_once(" #") {
147        value.trim_end().to_owned()
148    } else {
149        s.to_owned()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use std::io::Write as _;
156
157    use super::*;
158
159    fn write_env(content: &str) -> (tempfile::NamedTempFile, PathBuf) {
160        let mut f = tempfile::NamedTempFile::new().unwrap();
161        f.write_all(content.as_bytes()).unwrap();
162        let path = f.path().to_path_buf();
163        (f, path)
164    }
165
166    #[test]
167    fn parse_plain_key_value() {
168        let (_f, path) = write_env("DB_URL=postgres://localhost/db\n");
169        let src = EnvFileSource::load(&path).unwrap();
170        let map = SecretSource::load(&src).unwrap();
171        assert_eq!(map["DB_URL"], "postgres://localhost/db");
172    }
173
174    #[test]
175    fn parse_double_quoted_value() {
176        let (_f, path) = write_env("SECRET=\"hello world\"\n");
177        let src = EnvFileSource::load(&path).unwrap();
178        let map = SecretSource::load(&src).unwrap();
179        assert_eq!(map["SECRET"], "hello world");
180    }
181
182    #[test]
183    fn parse_single_quoted_value() {
184        let (_f, path) = write_env("TOKEN='abc123'\n");
185        let src = EnvFileSource::load(&path).unwrap();
186        let map = SecretSource::load(&src).unwrap();
187        assert_eq!(map["TOKEN"], "abc123");
188    }
189
190    #[test]
191    fn skip_comments_and_blank_lines() {
192        let (_f, path) = write_env("# comment\n\nKEY=val\n");
193        let src = EnvFileSource::load(&path).unwrap();
194        assert_eq!(src.len(), 1);
195    }
196
197    #[test]
198    fn strip_export_prefix() {
199        let (_f, path) = write_env("export API_KEY=secret\n");
200        let src = EnvFileSource::load(&path).unwrap();
201        let map = SecretSource::load(&src).unwrap();
202        assert_eq!(map["API_KEY"], "secret");
203    }
204
205    #[test]
206    fn strip_inline_comment() {
207        let (_f, path) = write_env("PORT=8080 # default port\n");
208        let src = EnvFileSource::load(&path).unwrap();
209        let map = SecretSource::load(&src).unwrap();
210        assert_eq!(map["PORT"], "8080");
211    }
212
213    #[test]
214    fn load_optional_absent_returns_none() {
215        let result = EnvFileSource::load_optional("/nonexistent/.env").unwrap();
216        assert!(result.is_none());
217    }
218
219    #[test]
220    fn load_explicit_absent_returns_error() {
221        let err = EnvFileSource::load("/nonexistent/.env").unwrap_err();
222        assert!(matches!(err, SecretError::FileNotFound(_)));
223    }
224
225    #[test]
226    fn invalid_line_returns_error() {
227        let (_f, path) = write_env("NOT_A_VALID_LINE\n");
228        let err = EnvFileSource::load(&path).unwrap_err();
229        assert!(matches!(err, SecretError::InvalidSyntax { line: 1, .. }));
230    }
231
232    #[test]
233    fn strips_utf8_bom_from_first_key() {
234        let (_f, path) = write_env("\u{feff}FIRST=value\n");
235        let src = EnvFileSource::load(&path).unwrap();
236        let map = SecretSource::load(&src).unwrap();
237        assert_eq!(map["FIRST"], "value");
238        assert!(!map.contains_key("\u{feff}FIRST"));
239    }
240
241    #[test]
242    fn quoted_value_with_inline_comment_drops_the_comment_and_quotes() {
243        let (_f, path) = write_env("KEY=\"val\" # trailing comment\n");
244        let src = EnvFileSource::load(&path).unwrap();
245        let map = SecretSource::load(&src).unwrap();
246        assert_eq!(map["KEY"], "val");
247    }
248
249    #[test]
250    fn hash_inside_quotes_is_preserved() {
251        let (_f, path) = write_env("PASSWORD=\"a b#c #d\"\n");
252        let src = EnvFileSource::load(&path).unwrap();
253        let map = SecretSource::load(&src).unwrap();
254        assert_eq!(map["PASSWORD"], "a b#c #d");
255    }
256
257    #[test]
258    fn unquoted_value_without_space_hash_keeps_fragment() {
259        let (_f, path) = write_env("URL=https://example.com/p#frag\n");
260        let src = EnvFileSource::load(&path).unwrap();
261        let map = SecretSource::load(&src).unwrap();
262        assert_eq!(map["URL"], "https://example.com/p#frag");
263    }
264
265    #[test]
266    fn strip_export_prefix_with_tab() {
267        let (_f, path) = write_env("export\tAPI_KEY=secret\n");
268        let src = EnvFileSource::load(&path).unwrap();
269        let map = SecretSource::load(&src).unwrap();
270        assert_eq!(map["API_KEY"], "secret");
271    }
272
273    #[test]
274    fn export_glued_to_key_is_not_stripped() {
275        let (_f, path) = write_env("exportAPI_KEY=secret\n");
276        let src = EnvFileSource::load(&path).unwrap();
277        let map = SecretSource::load(&src).unwrap();
278        assert_eq!(map["exportAPI_KEY"], "secret");
279    }
280}