Skip to main content

kovra_core/
envrefs.rs

1//! The `.env.refs` project contract (spec §4.1/§4.2).
2//!
3//! Maps a local variable name to a **source** — a vault URI, an environment
4//! passthrough, or a literal — plus an optional `project =` link. It holds
5//! **no secret values**, only addresses, which is why it is committed to the
6//! repo (the vault is not).
7//!
8//! This parser is **filename-agnostic**: it parses content, never a path. The
9//! CLI/scaffold (L12) decides which file to read; the same grammar can back a
10//! language-native "dotenv that resolves vault references" via the L9 binding.
11//!
12//! The only legal interpolation is `${ENV}` inside a URI path (substituted at
13//! resolution, §4.3) and the `${env:NAME}` passthrough form. Any other `${…}`
14//! — i.e. cross-variable interpolation — is rejected (§4.2): composing one
15//! secret inside another string is the app's job, not the contract's, because
16//! the composed string would get logged and nullify policy.
17
18use crate::error::CoreError;
19
20/// The source a variable resolves from (§4.1 line types 1–5).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Source {
23    /// A direct literal passthrough (`PORT=8080`).
24    Literal(String),
25    /// Read from the execution environment (`${env:NAME}`), with optional fallback.
26    EnvPassthrough {
27        /// The environment variable to read.
28        var: String,
29        /// Fallback applied when the variable is absent.
30        fallback: Option<String>,
31    },
32    /// A vault coordinate URI (`secret:…`), with optional fallback.
33    Uri {
34        /// The `secret:` URI (may contain the `${ENV}` placeholder).
35        uri: String,
36        /// Fallback applied when the coordinate does not resolve.
37        fallback: Option<String>,
38    },
39}
40
41/// A parsed `.env.refs`: ordered variable bindings plus the optional project link.
42#[derive(Debug, Clone, PartialEq, Eq, Default)]
43pub struct EnvRefs {
44    /// Variable bindings, in file order (resolution is a single ordered pass).
45    pub vars: Vec<(String, Source)>,
46    /// The `project = <name>` link (§4.1 line type 6), consumed by the Wrapper.
47    pub project: Option<String>,
48}
49
50impl EnvRefs {
51    /// Parse `.env.refs` content. Skips blank lines and `#` comments; errors on a
52    /// malformed line, a bad identifier, a duplicate name, or any illegal
53    /// (cross-variable) interpolation.
54    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            // The `project = <name>` metadata line (lowercase sentinel).
69            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
92/// Classify a right-hand side into a [`Source`].
93fn classify(rhs: &str, lineno: usize) -> Result<Source, CoreError> {
94    if let Some(rest) = rhs.strip_prefix("secret:") {
95        // URI (+ optional fallback). The URI keeps its `secret:` scheme; the
96        // `${ENV}` placeholder is validated later by the coordinate parser.
97        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        // ${env:VAR} or ${env:VAR | fallback}
107        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        // Literal — must not contain any interpolation (cross-variable footgun).
120        reject_interpolation(rhs, lineno)?;
121        Ok(Source::Literal(rhs.to_string()))
122    }
123}
124
125/// Strip a trailing `# comment`. A `#` only starts a comment at the line start
126/// or after whitespace, so `secret:dev/a#b` keeps the `#`.
127fn 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
137/// Split `body [| fallback]` on the first ` | `-style pipe; trims both sides.
138fn 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
145/// If `s` is exactly a `${env:…}` form, return its inner text.
146fn strip_env_passthrough(s: &str) -> Option<&str> {
147    s.strip_prefix("${env:")?.strip_suffix('}')
148}
149
150/// Reject any `${…}` interpolation in text that must be taken verbatim (§4.2).
151fn 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
161/// A valid env-var identifier: `[A-Za-z_][A-Za-z0-9_]*`.
162fn 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        // a literal composing another variable
243        assert!(matches!(
244            EnvRefs::parse("DSN=postgres://user:${DB_PASSWORD}@h/db"),
245            Err(CoreError::EnvRefs(_))
246        ));
247        // a fallback that interpolates
248        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        // `#` only starts a comment at line start or after whitespace.
286        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    // ---- KOV-28 hardening: `.env.refs` parser fuzzing ----
305    //
306    // The `.env.refs` grammar previously had only example-based unit tests. These
307    // proptest harnesses pin the structural guarantees against arbitrary input:
308    // the parser never panics, and no cross-variable interpolation (§4.2) ever
309    // survives into a parsed `Source`.
310    mod fuzz {
311        use super::*;
312        use proptest::prelude::*;
313
314        proptest! {
315            // Arbitrary single-line content never panics — a malformed line is a
316            // `CoreError::EnvRefs`, never an unwind.
317            #[test]
318            fn parse_never_panics(content in ".*") {
319                let _ = EnvRefs::parse(&content);
320            }
321
322            // Arbitrary multi-line content (the real input shape) never panics.
323            #[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            // §4.2: no cross-variable interpolation ever survives parsing. The
331            // only legal `${…}` is the `${ENV}` placeholder inside a URI path; a
332            // literal value or any fallback must be free of `${`.
333            #[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                // A `${` literal inside `prop_assert!` confuses its internal
339                // format string; keeping the sigil in a variable avoids that.
340                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            // A plain `NAME=value` literal (no scheme prefix, no interpolation,
357            // no comment/fallback metacharacters) always parses and preserves the
358            // value verbatim.
359            #[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                // exclude the `secret:` prefix, which would (correctly) classify
365                // as a URI rather than a literal.
366                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            // A duplicate variable name is always rejected, whatever the values.
372            #[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            // A leading-digit identifier is never a valid variable name.
379            #[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}