Skip to main content

agentd/config/
envfile.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! `.env` files (`--env <FILE>`, repeatable): a dependency-free dotenv subset.
3//!
4//! Lines are `KEY=VALUE` with optional `export ` prefix; `#` starts a comment
5//! (a whole line, or trailing an *unquoted* value); single quotes are literal;
6//! double quotes understand `\n` `\t` `\r` `\\` `\"`. There is **no `$VAR`
7//! interpolation inside the file** — the config layer's `${VAR:-default}`
8//! expansion already exists for that, and two interpolation passes with
9//! different rules is how values get mangled silently.
10//!
11//! Precedence is the dotenv convention: the **real environment wins** over any
12//! file (a deployment override beats the checked-in defaults file), and among
13//! files the **later wins** for keys the environment does not pin. A malformed
14//! line or an unreadable file is a startup refusal naming file and line —
15//! fail-closed, like every other config error.
16
17/// Parse one file's content. Returns pairs in file order.
18pub fn parse(content: &str, file: &str) -> Result<Vec<(String, String)>, String> {
19    let mut out = Vec::new();
20    for (i, raw) in content.lines().enumerate() {
21        let line = raw.trim();
22        if line.is_empty() || line.starts_with('#') {
23            continue;
24        }
25        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
26        let Some(eq) = line.find('=') else {
27            return Err(format!("{file}:{}: expected KEY=VALUE, got {raw:?}", i + 1));
28        };
29        let key = line[..eq].trim();
30        if key.is_empty()
31            || !key
32                .chars()
33                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
34            || key.starts_with(|c: char| c.is_ascii_digit())
35        {
36            return Err(format!("{file}:{}: invalid key {key:?}", i + 1));
37        }
38        let rest = line[eq + 1..].trim();
39        let value = if let Some(q) = rest.strip_prefix('"') {
40            // Double-quoted: escapes, must close, nothing but a comment after.
41            let (v, after) = unescape_double(q)
42                .ok_or_else(|| format!("{file}:{}: unterminated \" quote", i + 1))?;
43            let after = after.trim();
44            if !after.is_empty() && !after.starts_with('#') {
45                return Err(format!(
46                    "{file}:{}: unexpected trailing content {after:?}",
47                    i + 1
48                ));
49            }
50            v
51        } else if let Some(q) = rest.strip_prefix('\'') {
52            let end = q
53                .find('\'')
54                .ok_or_else(|| format!("{file}:{}: unterminated ' quote", i + 1))?;
55            let after = q[end + 1..].trim();
56            if !after.is_empty() && !after.starts_with('#') {
57                return Err(format!(
58                    "{file}:{}: unexpected trailing content {after:?}",
59                    i + 1
60                ));
61            }
62            q[..end].to_string()
63        } else {
64            // Unquoted: runs to a trailing comment or end of line.
65            match rest.find(" #") {
66                Some(h) => rest[..h].trim().to_string(),
67                None => rest.to_string(),
68            }
69        };
70        out.push((key.to_string(), value));
71    }
72    Ok(out)
73}
74
75/// `"…"` body → (value, remainder-after-closing-quote).
76fn unescape_double(s: &str) -> Option<(String, &str)> {
77    let mut out = String::new();
78    let mut chars = s.char_indices();
79    while let Some((i, c)) = chars.next() {
80        match c {
81            '"' => return Some((out, &s[i + 1..])),
82            '\\' => match chars.next()?.1 {
83                'n' => out.push('\n'),
84                't' => out.push('\t'),
85                'r' => out.push('\r'),
86                '\\' => out.push('\\'),
87                '"' => out.push('"'),
88                other => {
89                    out.push('\\');
90                    out.push(other);
91                }
92            },
93            _ => out.push(c),
94        }
95    }
96    None
97}
98
99/// Load every `--env` file in order and fold them into one map — later files
100/// win. The caller applies the real-environment-wins rule (it knows the
101/// environment; this function deliberately does not read it, so tests can).
102pub fn load_files(paths: &[String]) -> Result<Vec<(String, String)>, String> {
103    let mut merged: Vec<(String, String)> = Vec::new();
104    for path in paths {
105        let content = std::fs::read_to_string(path).map_err(|e| format!("--env {path}: {e}"))?;
106        for (k, v) in parse(&content, path)? {
107            merged.retain(|(ek, _)| ek != &k);
108            merged.push((k, v));
109        }
110    }
111    Ok(merged)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn the_dotenv_subset_parses_and_bad_lines_say_where() {
120        let src = r#"
121# a comment
122FOO=bar
123export QUOTED="a b\nc"
124LIT='keep $THIS literal'
125TRAIL=value # trailing comment
126EMPTY=
127DOTTED.KEY=ok
128"#;
129        let v = parse(src, "x.env").unwrap();
130        let get = |k: &str| v.iter().find(|(ek, _)| ek == k).map(|(_, v)| v.as_str());
131        assert_eq!(get("FOO"), Some("bar"));
132        assert_eq!(get("QUOTED"), Some("a b\nc"));
133        assert_eq!(get("LIT"), Some("keep $THIS literal"));
134        assert_eq!(get("TRAIL"), Some("value"));
135        assert_eq!(get("EMPTY"), Some(""));
136        assert_eq!(get("DOTTED.KEY"), Some("ok"));
137
138        for (bad, what) in [
139            ("JUSTAWORD", "expected KEY=VALUE"),
140            ("2BAD=x", "invalid key"),
141            ("Q=\"unterminated", "unterminated"),
142            ("Q='unterminated", "unterminated"),
143            ("Q=\"x\" extra", "trailing content"),
144        ] {
145            let e = parse(bad, "y.env").unwrap_err();
146            assert!(e.contains(what), "{bad:?} → {e}");
147            assert!(e.contains("y.env:1"), "names the location: {e}");
148        }
149    }
150
151    #[test]
152    fn later_files_win_within_the_env_layer() {
153        let d = std::env::temp_dir().join(format!("agentd-envfile-{}", std::process::id()));
154        std::fs::create_dir_all(&d).unwrap();
155        let a = d.join("a.env");
156        let b = d.join("b.env");
157        std::fs::write(&a, "K=from_a\nONLY_A=1\n").unwrap();
158        std::fs::write(&b, "K=from_b\n").unwrap();
159        let merged = load_files(&[
160            a.to_string_lossy().into_owned(),
161            b.to_string_lossy().into_owned(),
162        ])
163        .unwrap();
164        let get = |k: &str| {
165            merged
166                .iter()
167                .find(|(ek, _)| ek == k)
168                .map(|(_, v)| v.as_str())
169        };
170        assert_eq!(get("K"), Some("from_b"));
171        assert_eq!(get("ONLY_A"), Some("1"));
172        let _ = std::fs::remove_dir_all(&d);
173    }
174}