1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum EnvValue {
18 Plain(String),
19 Secret {
22 secret: String,
23 },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27enum Line {
28 Text(String),
30 Var(String, EnvValue),
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct EnvFile {
37 lines: Vec<Line>,
38}
39
40const SECRET_OPEN: &str = "${{secret.";
41const SECRET_CLOSE: &str = "}}";
42
43pub fn validate_key(k: &str) -> Result<()> {
44 let ok = !k.is_empty()
45 && k.len() <= 256
46 && !k.starts_with(|c: char| c.is_ascii_digit())
47 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
48 if ok {
49 Ok(())
50 } else {
51 Err(Error::invalid(format!(
52 "environment variable {k:?}: letters, digits and _, not starting with a digit"
53 )))
54 }
55}
56
57impl EnvFile {
58 #[expect(
61 clippy::excessive_nesting,
62 reason = "predates the lint ratchet; split it when next changed"
63 )]
64 pub fn parse(text: &str) -> Result<EnvFile> {
65 let mut f = EnvFile::default();
66 let mut lines = text.lines().enumerate().peekable();
67 while let Some((n, raw)) = lines.next() {
68 let t = raw.trim();
69 if t.is_empty() || t.starts_with('#') {
70 f.lines.push(Line::Text(raw.trim_end().to_string()));
71 continue;
72 }
73 let t = t.strip_prefix("export ").map(str::trim_start).unwrap_or(t);
74 let (k, v) = t
75 .split_once('=')
76 .ok_or_else(|| Error::invalid(format!("line {}: expected KEY=value", n + 1)))?;
77 let k = k.trim();
78 validate_key(k).map_err(|e| Error::invalid(format!("line {}: {e}", n + 1)))?;
79 let v = v.trim_start();
80 let value = if let Some(rest) = v.strip_prefix('"') {
81 let mut s = rest.to_string();
83 loop {
84 if let Some(end) = closing_quote(&s) {
85 let tail = s[end + 1..].trim();
86 if !(tail.is_empty() || tail.starts_with('#')) {
87 return Err(Error::invalid(format!(
88 "line {}: text after the closing quote",
89 n + 1
90 )));
91 }
92 break EnvValue::Plain(unescape(&s[..end]));
93 }
94 match lines.next() {
95 Some((_, more)) => {
96 s.push('\n');
97 s.push_str(more);
98 }
99 None => {
100 return Err(Error::invalid(format!(
101 "line {}: unterminated quote",
102 n + 1
103 )));
104 }
105 }
106 }
107 } else if let Some(rest) = v.strip_prefix('\'') {
108 let end = rest
109 .find('\'')
110 .ok_or_else(|| Error::invalid(format!("line {}: unterminated quote", n + 1)))?;
111 EnvValue::Plain(rest[..end].to_string())
112 } else {
113 let v = match v.find(" #") {
115 Some(i) => &v[..i],
116 None => v,
117 }
118 .trim_end();
119 match v
120 .strip_prefix(SECRET_OPEN)
121 .and_then(|r| r.strip_suffix(SECRET_CLOSE))
122 {
123 Some(name) => {
124 crate::secrets::validate_name(name)
125 .map_err(|e| Error::invalid(format!("line {}: {e}", n + 1)))?;
126 EnvValue::Secret {
127 secret: name.to_string(),
128 }
129 }
130 None => EnvValue::Plain(v.to_string()),
131 }
132 };
133 f.set(k, value);
134 }
135 while matches!(f.lines.last(), Some(Line::Text(t)) if t.is_empty()) {
137 f.lines.pop();
138 }
139 Ok(f)
140 }
141
142 pub fn render(&self) -> String {
144 let mut out = String::new();
145 for l in &self.lines {
146 match l {
147 Line::Text(t) => out.push_str(t),
148 Line::Var(k, v) => {
149 out.push_str(k);
150 out.push('=');
151 match v {
152 EnvValue::Secret { secret } => {
153 out.push_str(SECRET_OPEN);
154 out.push_str(secret);
155 out.push_str(SECRET_CLOSE);
156 }
157 EnvValue::Plain(s) => out.push_str("e(s)),
158 }
159 }
160 }
161 out.push('\n');
162 }
163 out
164 }
165
166 pub fn set(&mut self, key: &str, value: EnvValue) {
168 for l in &mut self.lines {
169 if let Line::Var(k, v) = l {
170 if k == key {
171 *v = value;
172 return;
173 }
174 }
175 }
176 self.lines.push(Line::Var(key.to_string(), value));
177 }
178
179 pub fn remove(&mut self, key: &str) -> bool {
180 let before = self.lines.len();
181 self.lines
182 .retain(|l| !matches!(l, Line::Var(k, _) if k == key));
183 before != self.lines.len()
184 }
185
186 pub fn get(&self, key: &str) -> Option<&EnvValue> {
187 self.vars().find(|(k, _)| *k == key).map(|(_, v)| v)
188 }
189
190 pub fn vars(&self) -> impl Iterator<Item = (&str, &EnvValue)> {
191 self.lines.iter().filter_map(|l| match l {
192 Line::Var(k, v) => Some((k.as_str(), v)),
193 Line::Text(_) => None,
194 })
195 }
196
197 pub fn to_map(&self) -> BTreeMap<String, EnvValue> {
198 self.vars()
199 .map(|(k, v)| (k.to_string(), v.clone()))
200 .collect()
201 }
202
203 pub fn from_map(m: &BTreeMap<String, EnvValue>) -> Result<EnvFile> {
204 let mut f = EnvFile::default();
205 for (k, v) in m {
206 validate_key(k)?;
207 if let EnvValue::Secret { secret } = v {
208 crate::secrets::validate_name(secret)?;
209 }
210 f.set(k, v.clone());
211 }
212 Ok(f)
213 }
214
215 pub fn secret_names(&self) -> Vec<String> {
217 let mut v: Vec<String> = self
218 .vars()
219 .filter_map(|(_, v)| match v {
220 EnvValue::Secret { secret } => Some(secret.clone()),
221 EnvValue::Plain(_) => None,
222 })
223 .collect();
224 v.sort();
225 v.dedup();
226 v
227 }
228}
229
230fn closing_quote(s: &str) -> Option<usize> {
232 let mut esc = false;
233 for (i, c) in s.char_indices() {
234 match c {
235 _ if esc => esc = false,
236 '\\' => esc = true,
237 '"' => return Some(i),
238 _ => {}
239 }
240 }
241 None
242}
243
244fn unescape(s: &str) -> String {
245 let mut out = String::with_capacity(s.len());
246 let mut it = s.chars();
247 while let Some(c) = it.next() {
248 if c != '\\' {
249 out.push(c);
250 continue;
251 }
252 match it.next() {
253 Some('n') => out.push('\n'),
254 Some('t') => out.push('\t'),
255 Some('r') => out.push('\r'),
256 Some(o) => out.push(o),
257 None => out.push('\\'),
258 }
259 }
260 out
261}
262
263fn quote(s: &str) -> String {
264 let bare = !s.is_empty()
265 && s.bytes()
266 .all(|b| b.is_ascii_alphanumeric() || b"_./:@%+,=-".contains(&b))
267 && !s.starts_with(SECRET_OPEN);
268 if bare {
269 return s.to_string();
270 }
271 let mut out = String::from("\"");
272 for c in s.chars() {
273 match c {
274 '"' => out.push_str("\\\""),
275 '\\' => out.push_str("\\\\"),
276 '\n' => out.push_str("\\n"),
277 '\t' => out.push_str("\\t"),
278 '\r' => out.push_str("\\r"),
279 c => out.push(c),
280 }
281 }
282 out.push('"');
283 out
284}
285
286impl Serialize for EnvFile {
287 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
288 s.serialize_str(&self.render())
289 }
290}
291
292impl<'de> Deserialize<'de> for EnvFile {
293 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
294 #[derive(Deserialize)]
295 #[serde(untagged)]
296 enum Repr {
297 Text(String),
298 Map(BTreeMap<String, EnvValue>),
299 }
300 match Repr::deserialize(d)? {
301 Repr::Text(t) => EnvFile::parse(&t),
302 Repr::Map(m) => EnvFile::from_map(&m),
303 }
304 .map_err(serde::de::Error::custom)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn round_trip_keeps_comments_and_order() {
314 let text = "# database\nDB_HOST=db.shop\nexport PORT=8080\n\n# keys\nTOKEN=${{secret.api_token}}\nGREETING=\"hello world\"\nQ='single # kept'\nINLINE=x # dropped\nMULTI=\"a\nb\"\nEMPTY=\n";
315 let f = EnvFile::parse(text).unwrap();
316 assert_eq!(
317 f.get("TOKEN"),
318 Some(&EnvValue::Secret {
319 secret: "api_token".into()
320 })
321 );
322 assert_eq!(f.get("PORT"), Some(&EnvValue::Plain("8080".into())));
323 assert_eq!(f.get("Q"), Some(&EnvValue::Plain("single # kept".into())));
324 assert_eq!(f.get("INLINE"), Some(&EnvValue::Plain("x".into())));
325 assert_eq!(f.get("MULTI"), Some(&EnvValue::Plain("a\nb".into())));
326 assert_eq!(f.get("EMPTY"), Some(&EnvValue::Plain("".into())));
327 let out = f.render();
328 assert_eq!(
329 out,
330 "# database\nDB_HOST=db.shop\nPORT=8080\n\n# keys\nTOKEN=${{secret.api_token}}\nGREETING=\"hello world\"\nQ=\"single # kept\"\nINLINE=x\nMULTI=\"a\\nb\"\nEMPTY=\"\"\n"
331 );
332 assert_eq!(EnvFile::parse(&out).unwrap(), f);
334 assert_eq!(f.secret_names(), ["api_token"]);
335 }
336
337 #[test]
338 fn edits_in_place() {
339 let mut f = EnvFile::parse("A=1\n# c\nB=2\n").unwrap();
340 f.set("A", EnvValue::Plain("9".into()));
341 f.set("C", EnvValue::Plain("3".into()));
342 assert!(f.remove("B"));
343 assert_eq!(f.render(), "A=9\n# c\nC=3\n");
344 let g = EnvFile::parse("A=1\nB=2\nA=3\n").unwrap();
346 assert_eq!(g.render(), "A=3\nB=2\n");
347 }
348
349 #[test]
350 fn refuses_bad_input() {
351 assert!(EnvFile::parse("no equals\n").is_err());
352 assert!(EnvFile::parse("1A=x\n").is_err());
353 assert!(EnvFile::parse("A-B=x\n").is_err());
354 assert!(EnvFile::parse("A=\"open\n").is_err());
355 assert!(EnvFile::parse("A=\"x\" y\n").is_err());
356 assert!(EnvFile::parse("A=${{secret.bad name}}\n").is_err());
357 }
358
359 #[test]
360 fn serde_forms() {
361 let f: EnvFile = serde_json::from_value(serde_json::json!({
362 "A": "1", "T": {"secret": "tok"}
363 }))
364 .unwrap();
365 assert_eq!(f.render(), "A=1\nT=${{secret.tok}}\n");
366 let g: EnvFile = serde_json::from_value(serde_json::json!("A=1\n")).unwrap();
367 assert_eq!(
368 serde_json::to_value(&g).unwrap(),
369 serde_json::json!("A=1\n")
370 );
371 let mut h = EnvFile::default();
374 h.set("X", EnvValue::Plain("${{secret.x}}".into()));
375 assert_eq!(EnvFile::parse(&h.render()).unwrap(), h);
376 }
377}