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)]
21pub struct EnvFileSource {
22 path: PathBuf,
23 entries: HashMap<String, String>,
24}
25
26impl EnvFileSource {
27 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 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 #[must_use]
56 pub fn len(&self) -> usize {
57 self.entries.len()
58 }
59
60 #[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 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
122fn 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 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 }
144
145 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}