Skip to main content

sui_castore/
env_expand.rs

1//! `${VAR}` environment-variable expansion for config-file text.
2//!
3//! Lets a secret-sourced connection string — e.g. a CloudNativePG password
4//! injected into the pod as a `secretKeyRef` env var — live inside a
5//! ConfigMap-rendered `backend.json` as a `${SUI_CACHE_PG_PASSWORD}` token, so
6//! the secret value itself never appears in the ConfigMap. Applied to the whole
7//! config-file text *before* it is parsed as TOML/JSON, so it expands a
8//! top-level DSN and every nested tier's DSN alike.
9//!
10//! Semantics are deliberately narrow, so a file with no valid `${VAR}` token
11//! round-trips byte-identical — existing password-free (trust-auth) configs and
12//! every existing parse test are unchanged:
13//!
14//! - `${NAME}`, where `NAME` matches `[A-Za-z_][A-Za-z0-9_]*`, is replaced by
15//!   `std::env::var("NAME")`. A **missing** variable is a hard
16//!   [`ExpandEnvError::Missing`], never a silent empty string — a missing
17//!   password must fail loudly, not degrade a DSN to no-auth (which the network
18//!   would then either reject noisily or, worse, accept silently).
19//! - `$${` is an escape producing a literal `${`.
20//! - Any other `$` (bare `$`, or a `${` that does not close on a valid name) is
21//!   left exactly as written.
22
23use std::env;
24
25/// A `${VAR}` token referenced an environment variable that is not set.
26#[derive(Debug, thiserror::Error)]
27pub enum ExpandEnvError {
28    /// The named variable was referenced by a `${VAR}` token but is unset.
29    #[error("config references ${{{name}}} but environment variable `{name}` is not set")]
30    Missing {
31        /// The referenced variable name.
32        name: String,
33    },
34}
35
36/// Expand `${VAR}` tokens in `text` against the process environment.
37///
38/// See the [module docs](self) for the exact semantics. Returns the expanded
39/// string, or [`ExpandEnvError::Missing`] on the first unresolved `${VAR}`.
40pub fn expand_env_vars(text: &str) -> Result<String, ExpandEnvError> {
41    expand_with(text, |name| {
42        env::var(name).map_err(|_| ExpandEnvError::Missing {
43            name: name.to_string(),
44        })
45    })
46}
47
48/// Core expansion, parameterized by a resolver so tests exercise the parser
49/// without mutating the real (process-global, test-race-prone) environment.
50fn expand_with<F>(text: &str, mut lookup: F) -> Result<String, ExpandEnvError>
51where
52    F: FnMut(&str) -> Result<String, ExpandEnvError>,
53{
54    let mut out = String::with_capacity(text.len());
55    let bytes = text.as_bytes();
56    let mut i = 0;
57    while i < bytes.len() {
58        if bytes[i] == b'$' {
59            // `$${` → literal `${`
60            if bytes.get(i + 1) == Some(&b'$') && bytes.get(i + 2) == Some(&b'{') {
61                out.push_str("${");
62                i += 3;
63                continue;
64            }
65            // `${NAME}` → env value
66            if bytes.get(i + 1) == Some(&b'{') {
67                if let Some(close) = text[i + 2..].bytes().position(|b| b == b'}') {
68                    let name = &text[i + 2..i + 2 + close];
69                    if is_valid_name(name) {
70                        out.push_str(&lookup(name)?);
71                        i = i + 2 + close + 1; // resume past the '}'
72                        continue;
73                    }
74                }
75            }
76        }
77        // Default: copy one whole UTF-8 char. `i` is always on a char boundary
78        // here — every special-case branch above advances past ASCII bytes only.
79        let ch = text[i..].chars().next().expect("i on a char boundary");
80        out.push(ch);
81        i += ch.len_utf8();
82    }
83    Ok(out)
84}
85
86fn is_valid_name(name: &str) -> bool {
87    let mut chars = name.chars();
88    match chars.next() {
89        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
90        _ => return false,
91    }
92    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    /// A closure resolver over a fixed table — never touches the real env.
100    fn table<'a>(
101        pairs: &'a [(&'a str, &'a str)],
102    ) -> impl FnMut(&str) -> Result<String, ExpandEnvError> + 'a {
103        move |name| {
104            pairs
105                .iter()
106                .find(|(k, _)| *k == name)
107                .map(|(_, v)| (*v).to_string())
108                .ok_or_else(|| ExpandEnvError::Missing {
109                    name: name.to_string(),
110                })
111        }
112    }
113
114    #[test]
115    fn no_tokens_roundtrips_byte_identical() {
116        // The current trust-auth DSN — must be returned unchanged.
117        let dsn = r#"{"type":"pg","url":"postgres://sui@sui-cache-pg:5432/suicache","max_conns":8}"#;
118        assert_eq!(expand_with(dsn, table(&[])).unwrap(), dsn);
119    }
120
121    #[test]
122    fn expands_a_dsn_user_and_password() {
123        let tmpl = "postgres://${U}:${P}@h:5432/db";
124        let got = expand_with(tmpl, table(&[("U", "sui"), ("P", "s3cr3t")])).unwrap();
125        assert_eq!(got, "postgres://sui:s3cr3t@h:5432/db");
126    }
127
128    #[test]
129    fn missing_var_is_a_hard_error_never_empty() {
130        // A missing password must fail loudly, not produce `postgres://sui:@h/db`.
131        let err = expand_with("postgres://${U}:${P}@h/db", table(&[("U", "sui")])).unwrap_err();
132        let ExpandEnvError::Missing { name } = err;
133        assert_eq!(name, "P");
134    }
135
136    #[test]
137    fn double_dollar_brace_is_a_literal() {
138        assert_eq!(
139            expand_with("$${NOT_A_VAR}", table(&[])).unwrap(),
140            "${NOT_A_VAR}"
141        );
142    }
143
144    #[test]
145    fn bare_dollar_and_invalid_ref_left_untouched() {
146        let s = "cost is $5 and ${bad name} and ${}";
147        assert_eq!(expand_with(s, table(&[])).unwrap(), s);
148    }
149
150    #[test]
151    fn expands_inside_a_tiered_json_l2_url() {
152        let json = r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://${U}:${P}@sui-cache-pg-rw:5432/suicache","max_conns":8}}"#;
153        let got = expand_with(json, table(&[("U", "sui"), ("P", "pw")])).unwrap();
154        assert!(got.contains("postgres://sui:pw@sui-cache-pg-rw:5432/suicache"));
155        // Structure (the JSON scaffold) is otherwise untouched.
156        assert!(got.starts_with(r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://"#));
157    }
158
159    #[test]
160    fn redis_password_only_dsn() {
161        let got = expand_with("redis://:${RP}@sui-cache-redis:6379", table(&[("RP", "rpw")])).unwrap();
162        assert_eq!(got, "redis://:rpw@sui-cache-redis:6379");
163    }
164}