Skip to main content

isb_apps/app/
env.rs

1//! An app's environment as `.env` text, the way Dokploy's environment tab
2//! edits it: `KEY=value` lines, comments and blank lines kept in place.
3//!
4//! A value is plain text, or a reference to a secret in the org's store,
5//! written `KEY=${{secret.NAME}}`. A reference is shown as that reference,
6//! never as the value it points at.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13
14/// One variable's value.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum EnvValue {
18    Plain(String),
19    /// `{secret: NAME}`: a secret in the org's store, delivered as the
20    /// variable (`environment: {KEY: {secret: ...}}` in the rendered stack).
21    Secret {
22        secret: String,
23    },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27enum Line {
28    /// A comment or blank line, verbatim.
29    Text(String),
30    Var(String, EnvValue),
31}
32
33/// An ordered `.env` file. Serialized as its text; deserialized from text
34/// or from a `{KEY: value | {secret: NAME}}` map.
35#[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    /// Parse `.env` text. `export KEY=v` is accepted; a later duplicate
59    /// replaces the earlier value in place.
60    #[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                // Double quotes: escapes, and may span lines.
82                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                // Unquoted: a ` #` starts a comment, as in docker compose.
114                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        // Trailing blank lines are noise from editors.
136        while matches!(f.lines.last(), Some(Line::Text(t)) if t.is_empty()) {
137            f.lines.pop();
138        }
139        Ok(f)
140    }
141
142    /// The text: comments kept, values quoted where they need it.
143    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(&quote(s)),
158                    }
159                }
160            }
161            out.push('\n');
162        }
163        out
164    }
165
166    /// Set a variable: in place if present, else appended.
167    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    /// The names of the store secrets it references.
216    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
230/// The index of the first unescaped `"`.
231fn 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        // The rendered text parses back to the same file.
333        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        // A duplicate key: the later value, at the first position.
345        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        // A plain value that looks like a reference stays plain through a
372        // round trip.
373        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}