Skip to main content

apiplant_core/
env.rs

1//! Environment-variable references in an app's TOML files.
2//!
3//! Every TOML file apiplant reads from an app directory — `main.toml`, each
4//! `models/*.toml`, each `functions/*.toml` — goes through [`expand_document`]
5//! before it is deserialized, so any string value may name variables:
6//!
7//! ```toml
8//! [database]
9//! url = "$DATABASE_URL"
10//!
11//! [email]
12//! api_key = "${SENDGRID_API_KEY}"
13//! ```
14//!
15//! This is what lets a `main.toml` you commit hold no credentials: the file
16//! describes *where* the secret comes from, and the deployment supplies it.
17//!
18//! ## The syntax
19//!
20//! | Written | Means |
21//! |---------|-------|
22//! | `$VAR`, `${VAR}` | the variable's value, or `""` (with a warning) when unset |
23//! | `${VAR:-default}` | the variable's value, or `default` when unset or empty |
24//! | `$$` | a literal `$` |
25//! | `$` followed by anything else | itself, unchanged |
26//!
27//! A name is a letter or `_` followed by letters, digits or `_`. Anything that
28//! isn't one — `$19.99`, `US$`, `a$b` — is left exactly as written, so text that
29//! merely contains a dollar sign needs no escaping and only a genuine ambiguity
30//! (`$$USD`) calls for `$$`.
31//!
32//! Several references can appear in one string, which is the case that makes
33//! this worth having over a whole-value substitution:
34//!
35//! ```toml
36//! url = "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:${DB_PORT:-5432}/$DB_NAME"
37//! ```
38//!
39//! ## Where it happens
40//!
41//! On the **parsed document**, not the raw text: a value is substituted into a
42//! string that TOML has already produced, so a password containing `"` or `\`
43//! can't turn into a syntax error or, worse, into extra TOML. Keys are never
44//! expanded — a table named by the environment would make a file's shape
45//! depend on the deployment, which is not what this is for.
46
47use std::borrow::Cow;
48
49/// Parse a TOML file, expanding environment references in its string values.
50///
51/// This is how every app-directory file is read, so `$VAR` works the same in
52/// `main.toml`, in a model and in a function's config.
53///
54/// A file with no `$` in it is deserialized straight from its text rather than
55/// through [`toml::Value`], which keeps the line and column in a parse error.
56/// That is the overwhelmingly common case, so the diagnostics an author sees
57/// for a typo are unchanged by this feature existing.
58pub fn parse_toml<T: serde::de::DeserializeOwned>(
59    text: &str,
60    source: &str,
61) -> Result<T, toml::de::Error> {
62    if !text.contains('$') {
63        return toml::from_str(text);
64    }
65    let mut document: toml::Value = toml::from_str(text)?;
66    expand_document(&mut document, source);
67    document.try_into()
68}
69
70/// Expand every string value in a parsed TOML document, in place.
71///
72/// Walks tables and arrays recursively. Non-string scalars are untouched, and
73/// so are keys.
74pub fn expand_document(value: &mut toml::Value, source: &str) {
75    match value {
76        toml::Value::String(text) => {
77            if let Cow::Owned(expanded) = expand(text, source) {
78                *text = expanded;
79            }
80        }
81        toml::Value::Array(items) => {
82            for item in items {
83                expand_document(item, source);
84            }
85        }
86        toml::Value::Table(table) => {
87            for (_key, item) in table.iter_mut() {
88                expand_document(item, source);
89            }
90        }
91        _ => {}
92    }
93}
94
95/// Expand `$VAR`, `${VAR}`, `${VAR:-default}` and `$$` in one string.
96///
97/// `source` names the file for the warning an unset variable produces; it has
98/// no effect on the result.
99///
100/// Borrows when there is nothing to do, which is the overwhelmingly common
101/// case — most strings in a TOML file contain no `$` at all.
102pub fn expand<'a>(text: &'a str, source: &str) -> Cow<'a, str> {
103    if !text.contains('$') {
104        return Cow::Borrowed(text);
105    }
106
107    let bytes = text.as_bytes();
108    let mut out = String::with_capacity(text.len());
109    let mut i = 0;
110
111    while i < bytes.len() {
112        if bytes[i] != b'$' {
113            // Copy the whole run up to the next `$` at once.
114            let next = text[i..].find('$').map(|n| i + n).unwrap_or(bytes.len());
115            out.push_str(&text[i..next]);
116            i = next;
117            continue;
118        }
119
120        // `$$` is the escape, and the only reason a literal dollar ever needs
121        // one: every other unrecognised `$` is already left alone below.
122        if bytes.get(i + 1) == Some(&b'$') {
123            out.push('$');
124            i += 2;
125            continue;
126        }
127
128        match parse_reference(&text[i..]) {
129            Some(reference) => {
130                out.push_str(&resolve(&reference, source));
131                i += reference.length;
132            }
133            // Not a reference: `$19.99`, a trailing `$`, `${` with no `}`.
134            None => {
135                out.push('$');
136                i += 1;
137            }
138        }
139    }
140
141    Cow::Owned(out)
142}
143
144/// One `$…` reference and how much of the input it occupied.
145struct Reference {
146    name: String,
147    /// The `${VAR:-default}` fallback, when the reference gave one.
148    default: Option<String>,
149    /// Bytes consumed, including the leading `$`.
150    length: usize,
151}
152
153/// Read a reference at the start of `text`, which begins with `$`.
154///
155/// `None` means "this `$` doesn't introduce one" — an unterminated `${`, or a
156/// name that doesn't start with a letter or `_`. Both are left as written
157/// rather than reported: a `$` in prose is far more likely than a typo, and a
158/// config file that refuses to load over a price is a worse trade.
159fn parse_reference(text: &str) -> Option<Reference> {
160    let rest = &text[1..];
161
162    if let Some(body) = rest.strip_prefix('{') {
163        let end = body.find('}')?;
164        let inner = &body[..end];
165        // `${VAR:-default}`; the default may itself be empty (`${VAR:-}`) and
166        // may contain anything but the closing brace.
167        let (name, default) = match inner.split_once(":-") {
168            Some((name, default)) => (name, Some(default.to_string())),
169            None => (inner, None),
170        };
171        if !is_name(name) {
172            return None;
173        }
174        return Some(Reference {
175            name: name.to_string(),
176            default,
177            // `$` + `{` + inner + `}`
178            length: 2 + end + 1,
179        });
180    }
181
182    let name: String = rest
183        .chars()
184        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
185        .collect();
186    if !is_name(&name) {
187        return None;
188    }
189    let length = 1 + name.len();
190    Some(Reference {
191        name,
192        default: None,
193        length,
194    })
195}
196
197/// Whether `name` is a usable variable name: non-empty, starting with a letter
198/// or `_`, and otherwise letters, digits and `_`.
199fn is_name(name: &str) -> bool {
200    let mut chars = name.chars();
201    match chars.next() {
202        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
203        _ => return false,
204    }
205    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
206}
207
208/// The value a reference stands for.
209///
210/// An unset variable with no default expands to the empty string and warns.
211/// The alternative — leaving `$DATABASE_URL` in place — hands the literal text
212/// to whatever consumes it, and fails much later and much less clearly.
213fn resolve(reference: &Reference, source: &str) -> String {
214    match std::env::var(&reference.name) {
215        // An empty variable takes the default too: `${PORT:-5432}` with
216        // `PORT=""` means the same thing as `PORT` being unset, and this is the
217        // reading that makes `export PORT=` harmless.
218        Ok(value) if !value.is_empty() => value,
219        _ => match &reference.default {
220            Some(default) => default.clone(),
221            None => {
222                tracing::warn!(
223                    variable = %reference.name,
224                    file = source,
225                    "environment variable is not set; using an empty string \
226                     (write ${{{}:-…}} to give a default)",
227                    reference.name
228                );
229                String::new()
230            }
231        },
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// Set variables for one test, and remove them afterwards even if it fails.
240    struct Vars(&'static [(&'static str, &'static str)]);
241
242    impl Vars {
243        fn set(pairs: &'static [(&'static str, &'static str)]) -> Vars {
244            for (name, value) in pairs {
245                std::env::set_var(name, value);
246            }
247            Vars(pairs)
248        }
249    }
250
251    impl Drop for Vars {
252        fn drop(&mut self) {
253            for (name, _) in self.0 {
254                std::env::remove_var(name);
255            }
256        }
257    }
258
259    fn expanded(text: &str) -> String {
260        expand(text, "test.toml").into_owned()
261    }
262
263    #[test]
264    fn both_spellings_of_a_reference_expand() {
265        let _vars = Vars::set(&[("APIPLANT_T_HOST", "db.example.com")]);
266
267        assert_eq!(expanded("$APIPLANT_T_HOST"), "db.example.com");
268        assert_eq!(expanded("${APIPLANT_T_HOST}"), "db.example.com");
269        // A bare `$VAR` ends where the name does, so it can be embedded.
270        assert_eq!(
271            expanded("postgres://$APIPLANT_T_HOST:5432/db"),
272            "postgres://db.example.com:5432/db"
273        );
274        // …and braces are how you butt a reference against a name character.
275        assert_eq!(expanded("${APIPLANT_T_HOST}_1"), "db.example.com_1");
276    }
277
278    #[test]
279    fn several_references_expand_in_one_string() {
280        let _vars = Vars::set(&[
281            ("APIPLANT_T_USER", "user01"),
282            ("APIPLANT_T_PASS", "veryToughPas$w0rd"),
283            ("APIPLANT_T_HOST", "some-host.tld"),
284            ("APIPLANT_T_NAME", "my_database"),
285        ]);
286
287        assert_eq!(
288            expanded(
289                "mysql://$APIPLANT_T_USER:$APIPLANT_T_PASS@$APIPLANT_T_HOST:\
290                 ${APIPLANT_T_PORT:-3306}/$APIPLANT_T_NAME"
291            ),
292            "mysql://user01:veryToughPas$w0rd@some-host.tld:3306/my_database"
293        );
294    }
295
296    #[test]
297    fn a_default_covers_an_unset_or_empty_variable() {
298        let _vars = Vars::set(&[("APIPLANT_T_EMPTY", "")]);
299
300        assert_eq!(expanded("${APIPLANT_T_UNSET:-us-east-1}"), "us-east-1");
301        // `export VAR=` should behave like "not set", or a blank in the
302        // environment would silently defeat the default.
303        assert_eq!(expanded("${APIPLANT_T_EMPTY:-us-east-1}"), "us-east-1");
304        // A default may be empty, and may contain anything but `}`.
305        assert_eq!(expanded("${APIPLANT_T_UNSET:-}"), "");
306        assert_eq!(
307            expanded("${APIPLANT_T_UNSET:-postgres://a:b@c/d}"),
308            "postgres://a:b@c/d"
309        );
310
311        let _set = Vars::set(&[("APIPLANT_T_REGION", "eu-west-1")]);
312        assert_eq!(expanded("${APIPLANT_T_REGION:-us-east-1}"), "eu-west-1");
313    }
314
315    #[test]
316    fn an_unset_variable_without_a_default_expands_to_nothing() {
317        assert_eq!(expanded("$APIPLANT_T_MISSING"), "");
318        assert_eq!(expanded("a${APIPLANT_T_MISSING}b"), "ab");
319    }
320
321    /// The escape, and — more importantly — the far more common case of a `$`
322    /// that was never meant as a reference at all.
323    #[test]
324    fn dollars_that_are_not_references_survive() {
325        assert_eq!(expanded("$$19.99"), "$19.99");
326        assert_eq!(expanded("$$"), "$");
327        assert_eq!(expanded("$$$$"), "$$");
328
329        // No escape needed: none of these can be read as a name.
330        assert_eq!(expanded("$19.99"), "$19.99");
331        assert_eq!(expanded("100 US$"), "100 US$");
332        assert_eq!(expanded("a $ b"), "a $ b");
333        assert_eq!(expanded("${unterminated"), "${unterminated");
334        assert_eq!(expanded("${}"), "${}");
335        assert_eq!(expanded("${1BAD}"), "${1BAD}");
336    }
337
338    #[test]
339    fn text_without_a_dollar_is_returned_untouched() {
340        assert!(matches!(
341            expand("postgres://localhost/db", "test.toml"),
342            Cow::Borrowed(_)
343        ));
344        assert_eq!(expanded(""), "");
345    }
346
347    #[test]
348    fn a_document_is_expanded_through_tables_and_arrays() {
349        let _vars = Vars::set(&[
350            ("APIPLANT_T_DOC_URL", "postgres://db/app"),
351            ("APIPLANT_T_DOC_ORIGIN", "https://example.com"),
352        ]);
353
354        let mut document: toml::Value = toml::from_str(
355            r#"
356            title = "no references here"
357            port = 5432
358
359            [database]
360            url = "$APIPLANT_T_DOC_URL"
361
362            [server]
363            origins = ["$APIPLANT_T_DOC_ORIGIN", "http://localhost:3000"]
364
365            [[hooks]]
366            target = "${APIPLANT_T_DOC_ORIGIN}/hook"
367            "#,
368        )
369        .unwrap();
370        expand_document(&mut document, "main.toml");
371
372        assert_eq!(
373            document["database"]["url"].as_str(),
374            Some("postgres://db/app")
375        );
376        assert_eq!(
377            document["server"]["origins"][0].as_str(),
378            Some("https://example.com")
379        );
380        assert_eq!(
381            document["server"]["origins"][1].as_str(),
382            Some("http://localhost:3000")
383        );
384        assert_eq!(
385            document["hooks"][0]["target"].as_str(),
386            Some("https://example.com/hook")
387        );
388        // Non-strings are left alone, and so is a string with nothing to do.
389        assert_eq!(document["port"].as_integer(), Some(5432));
390        assert_eq!(document["title"].as_str(), Some("no references here"));
391    }
392
393    /// The reason expansion happens on the parsed document rather than on the
394    /// file's text: a value that looks like TOML must stay a value.
395    #[test]
396    fn an_expanded_value_cannot_inject_toml() {
397        let _vars = Vars::set(&[("APIPLANT_T_EVIL", "\"\nadmin = true\n[x]\ny = \"")]);
398
399        let mut document: toml::Value = toml::from_str(r#"password = "$APIPLANT_T_EVIL""#).unwrap();
400        expand_document(&mut document, "main.toml");
401
402        // One string value, exactly as the variable had it — no new keys.
403        assert_eq!(
404            document["password"].as_str(),
405            Some("\"\nadmin = true\n[x]\ny = \"")
406        );
407        assert_eq!(document.as_table().unwrap().len(), 1);
408    }
409
410    /// Keys name the shape of the file, and the shape is the author's, not the
411    /// deployment's.
412    #[test]
413    fn keys_are_not_expanded() {
414        let _vars = Vars::set(&[("APIPLANT_T_KEY", "surprise")]);
415
416        let mut document: toml::Value = toml::from_str(r#"'$APIPLANT_T_KEY' = "value""#).unwrap();
417        expand_document(&mut document, "main.toml");
418
419        assert!(document.get("$APIPLANT_T_KEY").is_some());
420        assert!(document.get("surprise").is_none());
421    }
422}