1use crate::error::CoreError;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Source {
23 Literal(String),
25 EnvPassthrough {
27 var: String,
29 fallback: Option<String>,
31 },
32 Uri {
34 uri: String,
36 fallback: Option<String>,
38 },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Default)]
43pub struct EnvRefs {
44 pub vars: Vec<(String, Source)>,
46 pub project: Option<String>,
48}
49
50impl EnvRefs {
51 pub fn parse(content: &str) -> Result<EnvRefs, CoreError> {
55 let mut refs = EnvRefs::default();
56 for (i, raw) in content.lines().enumerate() {
57 let line = strip_comment(raw).trim();
58 if line.is_empty() {
59 continue;
60 }
61 let lineno = i + 1;
62 let (key, rhs) = line
63 .split_once('=')
64 .ok_or_else(|| err(lineno, "expected `NAME=<source>` or `project = <name>`"))?;
65 let key = key.trim();
66 let rhs = rhs.trim();
67
68 if key == "project" {
70 if refs.project.is_some() {
71 return Err(err(lineno, "duplicate `project =` line"));
72 }
73 if rhs.is_empty() {
74 return Err(err(lineno, "`project =` requires a name"));
75 }
76 refs.project = Some(rhs.to_string());
77 continue;
78 }
79
80 if !is_identifier(key) {
81 return Err(err(lineno, "variable name is not a valid identifier"));
82 }
83 if refs.vars.iter().any(|(n, _)| n == key) {
84 return Err(err(lineno, "duplicate variable name"));
85 }
86 refs.vars.push((key.to_string(), classify(rhs, lineno)?));
87 }
88 Ok(refs)
89 }
90}
91
92fn classify(rhs: &str, lineno: usize) -> Result<Source, CoreError> {
94 if let Some(rest) = rhs.strip_prefix("secret:") {
95 let (uri_tail, fallback) = split_fallback(rest);
98 if let Some(fb) = &fallback {
99 reject_interpolation(fb, lineno)?;
100 }
101 Ok(Source::Uri {
102 uri: format!("secret:{uri_tail}"),
103 fallback,
104 })
105 } else if let Some(inner) = strip_env_passthrough(rhs) {
106 let (var, fallback) = split_fallback(inner);
108 if !is_identifier(var) {
109 return Err(err(lineno, "`${env:…}` requires a valid variable name"));
110 }
111 if let Some(fb) = &fallback {
112 reject_interpolation(fb, lineno)?;
113 }
114 Ok(Source::EnvPassthrough {
115 var: var.to_string(),
116 fallback,
117 })
118 } else {
119 reject_interpolation(rhs, lineno)?;
121 Ok(Source::Literal(rhs.to_string()))
122 }
123}
124
125fn strip_comment(line: &str) -> &str {
128 let bytes = line.as_bytes();
129 for (i, &b) in bytes.iter().enumerate() {
130 if b == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
131 return &line[..i];
132 }
133 }
134 line
135}
136
137fn split_fallback(body: &str) -> (&str, Option<String>) {
139 match body.split_once('|') {
140 Some((left, right)) => (left.trim(), Some(right.trim().to_string())),
141 None => (body.trim(), None),
142 }
143}
144
145fn strip_env_passthrough(s: &str) -> Option<&str> {
147 s.strip_prefix("${env:")?.strip_suffix('}')
148}
149
150fn reject_interpolation(text: &str, lineno: usize) -> Result<(), CoreError> {
152 if text.contains("${") {
153 return Err(err(
154 lineno,
155 "cross-variable interpolation is not allowed (only `${ENV}` in a URI path and `${env:NAME}` are valid)",
156 ));
157 }
158 Ok(())
159}
160
161fn is_identifier(s: &str) -> bool {
163 let mut chars = s.chars();
164 match chars.next() {
165 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
166 _ => return false,
167 }
168 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
169}
170
171fn err(lineno: usize, msg: &str) -> CoreError {
172 CoreError::EnvRefs(format!("line {lineno}: {msg}"))
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn parses_all_line_types() {
181 let src = "\
182# a comment
183DB_PASSWORD=secret:${ENV}/db/password
184DB_HOST=secret:${ENV}/db/host | localhost
185CI_TOKEN=${env:CI_TOKEN}
186LOG_LEVEL=${env:LOG_LEVEL | info}
187PORT=8080
188
189project = billing
190";
191 let refs = EnvRefs::parse(src).unwrap();
192 assert_eq!(refs.project.as_deref(), Some("billing"));
193 assert_eq!(refs.vars.len(), 5);
194 assert_eq!(
195 refs.vars[0],
196 (
197 "DB_PASSWORD".to_string(),
198 Source::Uri {
199 uri: "secret:${ENV}/db/password".to_string(),
200 fallback: None
201 }
202 )
203 );
204 assert_eq!(
205 refs.vars[1],
206 (
207 "DB_HOST".to_string(),
208 Source::Uri {
209 uri: "secret:${ENV}/db/host".to_string(),
210 fallback: Some("localhost".to_string())
211 }
212 )
213 );
214 assert_eq!(
215 refs.vars[2],
216 (
217 "CI_TOKEN".to_string(),
218 Source::EnvPassthrough {
219 var: "CI_TOKEN".to_string(),
220 fallback: None
221 }
222 )
223 );
224 assert_eq!(
225 refs.vars[3],
226 (
227 "LOG_LEVEL".to_string(),
228 Source::EnvPassthrough {
229 var: "LOG_LEVEL".to_string(),
230 fallback: Some("info".to_string())
231 }
232 )
233 );
234 assert_eq!(
235 refs.vars[4],
236 ("PORT".to_string(), Source::Literal("8080".to_string()))
237 );
238 }
239
240 #[test]
241 fn rejects_cross_variable_interpolation() {
242 assert!(matches!(
244 EnvRefs::parse("DSN=postgres://user:${DB_PASSWORD}@h/db"),
245 Err(CoreError::EnvRefs(_))
246 ));
247 assert!(matches!(
249 EnvRefs::parse("X=secret:${ENV}/a/b | ${OTHER}"),
250 Err(CoreError::EnvRefs(_))
251 ));
252 }
253
254 #[test]
255 fn rejects_bad_identifier_and_duplicates() {
256 assert!(matches!(
257 EnvRefs::parse("1BAD=8080"),
258 Err(CoreError::EnvRefs(_))
259 ));
260 assert!(matches!(
261 EnvRefs::parse("A=8080\nA=9090"),
262 Err(CoreError::EnvRefs(_))
263 ));
264 }
265
266 #[test]
267 fn rejects_line_without_equals() {
268 assert!(matches!(
269 EnvRefs::parse("just-a-word"),
270 Err(CoreError::EnvRefs(_))
271 ));
272 }
273
274 #[test]
275 fn comment_and_blank_lines_are_skipped() {
276 let refs = EnvRefs::parse("\n # hi\n\nPORT=8080 # trailing\n").unwrap();
277 assert_eq!(
278 refs.vars,
279 vec![("PORT".to_string(), Source::Literal("8080".to_string()))]
280 );
281 }
282
283 #[test]
284 fn hash_inside_value_is_kept() {
285 let refs = EnvRefs::parse("U=secret:dev/a/b#frag").unwrap();
287 assert_eq!(
288 refs.vars[0].1,
289 Source::Uri {
290 uri: "secret:dev/a/b#frag".to_string(),
291 fallback: None
292 }
293 );
294 }
295
296 #[test]
297 fn project_only_once() {
298 assert!(matches!(
299 EnvRefs::parse("project = a\nproject = b"),
300 Err(CoreError::EnvRefs(_))
301 ));
302 }
303
304 mod fuzz {
311 use super::*;
312 use proptest::prelude::*;
313
314 proptest! {
315 #[test]
318 fn parse_never_panics(content in ".*") {
319 let _ = EnvRefs::parse(&content);
320 }
321
322 #[test]
324 fn parse_multiline_never_panics(
325 lines in proptest::collection::vec(".*", 0..16)
326 ) {
327 let _ = EnvRefs::parse(&lines.join("\n"));
328 }
329
330 #[test]
334 fn no_interpolation_survives_in_literal_or_fallback(
335 name in "[A-Za-z_][A-Za-z0-9_]{0,12}",
336 body in ".*",
337 ) {
338 let sigil = "${";
341 if let Ok(refs) = EnvRefs::parse(&format!("{name}={body}")) {
342 for (_, src) in &refs.vars {
343 match src {
344 Source::Literal(v) => prop_assert!(!v.contains(sigil)),
345 Source::EnvPassthrough { fallback, .. }
346 | Source::Uri { fallback, .. } => {
347 if let Some(fb) = fallback {
348 prop_assert!(!fb.contains(sigil));
349 }
350 }
351 }
352 }
353 }
354 }
355
356 #[test]
360 fn well_formed_literal_round_trips(
361 name in "[A-Za-z_][A-Za-z0-9_]{0,12}",
362 value in "[A-Za-z0-9_./:-]{1,20}",
363 ) {
364 prop_assume!(!value.starts_with("secret:"));
367 let refs = EnvRefs::parse(&format!("{name}={value}")).unwrap();
368 prop_assert_eq!(&refs.vars, &vec![(name, Source::Literal(value))]);
369 }
370
371 #[test]
373 fn duplicate_names_always_error(name in "[A-Za-z_][A-Za-z0-9_]{0,8}") {
374 let content = format!("{name}=1\n{name}=2");
375 prop_assert!(EnvRefs::parse(&content).is_err());
376 }
377
378 #[test]
380 fn bad_identifier_always_errors(
381 bad in "[0-9][A-Za-z0-9_]{0,8}",
382 value in "[a-z]{1,8}",
383 ) {
384 let line = format!("{bad}={value}");
385 prop_assert!(EnvRefs::parse(&line).is_err());
386 }
387 }
388 }
389}