Skip to main content

pipedash_core/infrastructure/config/
interpolation.rs

1use std::{
2    path::PathBuf,
3    sync::LazyLock,
4};
5
6use regex::Regex;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum InterpolationError {
11    #[error("Required environment variable not found: {0} (set {0} or {0}_FILE)")]
12    RequiredVarNotFound(String),
13
14    #[error("Failed to read {var} file {}: {source}", .path.display())]
15    SecretFile {
16        var: String,
17        path: PathBuf,
18        source: std::io::Error,
19    },
20
21    #[error("Invalid variable syntax: {0}")]
22    InvalidSyntax(String),
23
24    #[error("Recursive interpolation limit exceeded")]
25    RecursionLimit,
26}
27
28pub type InterpolationResult<T> = Result<T, InterpolationError>;
29
30const MAX_RECURSION_DEPTH: usize = 10;
31
32static VAR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
33    Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").expect("Invalid regex pattern")
34});
35
36pub fn interpolate(input: &str) -> InterpolationResult<String> {
37    interpolate_with_depth(input, 0)
38}
39
40fn interpolate_with_depth(input: &str, depth: usize) -> InterpolationResult<String> {
41    if depth > MAX_RECURSION_DEPTH {
42        return Err(InterpolationError::RecursionLimit);
43    }
44
45    let mut result = String::with_capacity(input.len());
46    let mut rest = input;
47
48    while let Some(start) = rest.find("${") {
49        result.push_str(&rest[..start]);
50        let body = &rest[start + 2..];
51
52        let Some((var_name, default, consumed)) = parse_reference(body) else {
53            result.push_str("${");
54            rest = body;
55            continue;
56        };
57
58        match lookup_var(var_name)? {
59            Some(value) => result.push_str(&value),
60            None => match default {
61                Some(def) => result.push_str(&interpolate_with_depth(def, depth + 1)?),
62                None => {
63                    return Err(InterpolationError::RequiredVarNotFound(
64                        var_name.to_string(),
65                    ));
66                }
67            },
68        }
69
70        rest = &body[consumed..];
71    }
72
73    result.push_str(rest);
74    Ok(result)
75}
76
77fn lookup_var(name: &str) -> InterpolationResult<Option<String>> {
78    if let Ok(value) = std::env::var(name) {
79        return Ok(Some(value));
80    }
81
82    let file_var = format!("{name}_FILE");
83    let Some(path) = std::env::var_os(&file_var).map(PathBuf::from) else {
84        return Ok(None);
85    };
86
87    match std::fs::read_to_string(&path) {
88        Ok(content) => Ok(Some(content.trim_end_matches(['\r', '\n']).to_string())),
89        Err(source) => Err(InterpolationError::SecretFile {
90            var: file_var,
91            path,
92            source,
93        }),
94    }
95}
96
97fn parse_reference(body: &str) -> Option<(&str, Option<&str>, usize)> {
98    let name_len = body
99        .char_indices()
100        .find(|&(i, c)| !(c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())))
101        .map_or(body.len(), |(i, _)| i);
102    if name_len == 0 {
103        return None;
104    }
105
106    let name = &body[..name_len];
107    let after_name = &body[name_len..];
108
109    if after_name.starts_with('}') {
110        return Some((name, None, name_len + 1));
111    }
112
113    let default_body = after_name.strip_prefix(":-")?;
114    let default_start = name_len + 2;
115    let mut open = 0usize;
116    let mut chars = default_body.char_indices().peekable();
117
118    while let Some((i, c)) = chars.next() {
119        match c {
120            '$' if chars.peek().is_some_and(|&(_, next)| next == '{') => {
121                chars.next();
122                open += 1;
123            }
124            '}' if open == 0 => {
125                return Some((name, Some(&default_body[..i]), default_start + i + 1));
126            }
127            '}' => open -= 1,
128            _ => {}
129        }
130    }
131
132    None
133}
134
135pub fn interpolate_toml(value: &mut toml::Value) -> InterpolationResult<()> {
136    match value {
137        toml::Value::String(s) => {
138            *s = interpolate(s)?;
139        }
140        toml::Value::Array(arr) => {
141            for item in arr {
142                interpolate_toml(item)?;
143            }
144        }
145        toml::Value::Table(table) => {
146            for (_, v) in table.iter_mut() {
147                interpolate_toml(v)?;
148            }
149        }
150        _ => {}
151    }
152    Ok(())
153}
154
155pub fn has_variables(input: &str) -> bool {
156    VAR_PATTERN.is_match(input)
157}
158
159pub fn extract_variable_names(input: &str) -> Vec<String> {
160    VAR_PATTERN
161        .captures_iter(input)
162        .map(|cap| cap.get(1).unwrap().as_str().to_string())
163        .collect()
164}
165
166#[derive(Debug, Clone)]
167pub struct InterpolatedValue {
168    pub value: String,
169    pub original: Option<String>,
170    pub resolved_vars: Vec<String>,
171    pub defaulted_vars: Vec<String>,
172}
173
174impl InterpolatedValue {
175    pub fn was_interpolated(&self) -> bool {
176        self.original.is_some()
177    }
178}
179
180pub fn interpolate_with_tracking(input: &str) -> InterpolationResult<InterpolatedValue> {
181    let mut resolved_vars = Vec::new();
182    let mut defaulted_vars = Vec::new();
183
184    for cap in VAR_PATTERN.captures_iter(input) {
185        let var_name = cap.get(1).unwrap().as_str().to_string();
186        let has_default = cap.get(2).is_some();
187
188        if std::env::var(&var_name).is_ok()
189            || std::env::var_os(format!("{var_name}_FILE")).is_some()
190        {
191            resolved_vars.push(var_name);
192        } else if has_default {
193            defaulted_vars.push(var_name);
194        }
195    }
196
197    let value = interpolate(input)?;
198    let original = if value != input {
199        Some(input.to_string())
200    } else {
201        None
202    };
203
204    Ok(InterpolatedValue {
205        value,
206        original,
207        resolved_vars,
208        defaulted_vars,
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::test_env::EnvGuard;
216
217    #[test]
218    fn test_simple_var() {
219        std::env::set_var("TEST_VAR_SIMPLE", "hello");
220        let result = interpolate("${TEST_VAR_SIMPLE}").unwrap();
221        assert_eq!(result, "hello");
222        std::env::remove_var("TEST_VAR_SIMPLE");
223    }
224
225    #[test]
226    fn test_var_with_text() {
227        std::env::set_var("TEST_VAR_TEXT", "world");
228        let result = interpolate("Hello ${TEST_VAR_TEXT}!").unwrap();
229        assert_eq!(result, "Hello world!");
230        std::env::remove_var("TEST_VAR_TEXT");
231    }
232
233    #[test]
234    fn test_multiple_vars() {
235        std::env::set_var("TEST_VAR_A", "foo");
236        std::env::set_var("TEST_VAR_B", "bar");
237        let result = interpolate("${TEST_VAR_A}-${TEST_VAR_B}").unwrap();
238        assert_eq!(result, "foo-bar");
239        std::env::remove_var("TEST_VAR_A");
240        std::env::remove_var("TEST_VAR_B");
241    }
242
243    #[test]
244    fn test_missing_var_error() {
245        let result = interpolate("${THIS_VAR_DOES_NOT_EXIST_12345}");
246        assert!(matches!(
247            result,
248            Err(InterpolationError::RequiredVarNotFound(_))
249        ));
250    }
251
252    #[test]
253    fn test_default_value() {
254        let result = interpolate("${NONEXISTENT_VAR_123:-default_value}").unwrap();
255        assert_eq!(result, "default_value");
256    }
257
258    #[test]
259    fn test_empty_default() {
260        let result = interpolate("prefix${NONEXISTENT_VAR_456:-}suffix").unwrap();
261        assert_eq!(result, "prefixsuffix");
262    }
263
264    #[test]
265    fn test_var_overrides_default() {
266        std::env::set_var("TEST_VAR_OVERRIDE", "actual");
267        let result = interpolate("${TEST_VAR_OVERRIDE:-default}").unwrap();
268        assert_eq!(result, "actual");
269        std::env::remove_var("TEST_VAR_OVERRIDE");
270    }
271
272    #[test]
273    fn test_nested_default() {
274        std::env::set_var("TEST_NESTED_INNER", "inner_value");
275        let result = interpolate("${NONEXISTENT:-${TEST_NESTED_INNER}}").unwrap();
276        assert_eq!(result, "inner_value");
277        std::env::remove_var("TEST_NESTED_INNER");
278    }
279
280    #[test]
281    fn test_no_interpolation() {
282        let result = interpolate("plain text").unwrap();
283        assert_eq!(result, "plain text");
284    }
285
286    #[test]
287    fn test_has_variables() {
288        assert!(has_variables("${VAR}"));
289        assert!(has_variables("prefix${VAR}suffix"));
290        assert!(has_variables("${VAR:-default}"));
291        assert!(!has_variables("plain text"));
292        assert!(!has_variables("$VAR")); // Missing braces
293    }
294
295    #[test]
296    fn test_extract_variable_names() {
297        let names = extract_variable_names("${FOO}-${BAR:-default}");
298        assert_eq!(names, vec!["FOO", "BAR"]);
299    }
300
301    #[test]
302    fn test_interpolate_with_tracking() {
303        std::env::set_var("TEST_TRACKED", "value");
304        let result = interpolate_with_tracking("${TEST_TRACKED} ${MISSING:-default}").unwrap();
305
306        assert!(result.was_interpolated());
307        assert_eq!(result.value, "value default");
308        assert!(result.resolved_vars.contains(&"TEST_TRACKED".to_string()));
309        assert!(result.defaulted_vars.contains(&"MISSING".to_string()));
310        std::env::remove_var("TEST_TRACKED");
311    }
312
313    #[test]
314    fn test_interpolate_toml() {
315        std::env::set_var("TEST_TOML_VAR", "toml_value");
316
317        let toml_str = r#"
318            key = "${TEST_TOML_VAR}"
319            nested = { inner = "${TEST_TOML_VAR:-fallback}" }
320            array = ["${TEST_TOML_VAR}", "static"]
321        "#;
322
323        let mut value: toml::Value = toml::from_str(toml_str).unwrap();
324        interpolate_toml(&mut value).unwrap();
325
326        assert_eq!(value["key"].as_str().unwrap(), "toml_value");
327        assert_eq!(value["nested"]["inner"].as_str().unwrap(), "toml_value");
328        assert_eq!(value["array"][0].as_str().unwrap(), "toml_value");
329        assert_eq!(value["array"][1].as_str().unwrap(), "static");
330
331        std::env::remove_var("TEST_TOML_VAR");
332    }
333
334    #[test]
335    fn test_complex_address() {
336        std::env::set_var("TEST_HOST", "localhost");
337        std::env::set_var("TEST_PORT", "9000");
338
339        let result = interpolate("${TEST_HOST:-127.0.0.1}:${TEST_PORT:-8080}").unwrap();
340        assert_eq!(result, "localhost:9000");
341
342        std::env::remove_var("TEST_HOST");
343        std::env::remove_var("TEST_PORT");
344
345        let result = interpolate("${TEST_HOST:-127.0.0.1}:${TEST_PORT:-8080}").unwrap();
346        assert_eq!(result, "127.0.0.1:8080");
347    }
348
349    #[test]
350    fn test_env_values_are_inserted_verbatim() {
351        std::env::set_var("TEST_VERBATIM_SET", "expanded");
352        std::env::set_var("TEST_VERBATIM_A", "tok${TEST_VERBATIM_SET}en");
353        std::env::set_var("TEST_VERBATIM_B", "pa${TEST_VERBATIM_UNSET}ss");
354        std::env::set_var("TEST_VERBATIM_SELF", "x${TEST_VERBATIM_SELF}");
355
356        assert_eq!(
357            interpolate("${TEST_VERBATIM_A}").unwrap(),
358            "tok${TEST_VERBATIM_SET}en"
359        );
360        assert_eq!(
361            interpolate("${TEST_VERBATIM_B}").unwrap(),
362            "pa${TEST_VERBATIM_UNSET}ss"
363        );
364        assert_eq!(
365            interpolate("${TEST_VERBATIM_SELF}").unwrap(),
366            "x${TEST_VERBATIM_SELF}"
367        );
368    }
369
370    #[test]
371    fn test_file_fallback_when_var_unset() {
372        let dir = tempfile::tempdir().unwrap();
373        let path = dir.path().join("postgres_url");
374        std::fs::write(
375            &path,
376            "postgres://app:pa${NOT_EXPANDED}ss@db/pipedash\r\n\n",
377        )
378        .unwrap();
379        let _env = EnvGuard::set(&[
380            ("TEST_SECRET_URL", None),
381            ("TEST_SECRET_URL_FILE", path.to_str()),
382        ]);
383
384        assert_eq!(
385            interpolate("${TEST_SECRET_URL:-sqlite}").unwrap(),
386            "postgres://app:pa${NOT_EXPANDED}ss@db/pipedash"
387        );
388    }
389
390    #[test]
391    fn test_env_var_wins_over_file() {
392        let dir = tempfile::tempdir().unwrap();
393        let path = dir.path().join("token");
394        std::fs::write(&path, "from-file\n").unwrap();
395        let _env = EnvGuard::set(&[
396            ("TEST_SECRET_TOKEN", Some("from-env")),
397            ("TEST_SECRET_TOKEN_FILE", path.to_str()),
398        ]);
399
400        assert_eq!(interpolate("${TEST_SECRET_TOKEN}").unwrap(), "from-env");
401    }
402
403    #[test]
404    fn test_unreadable_file_fails_instead_of_default() {
405        let dir = tempfile::tempdir().unwrap();
406        let missing = dir.path().join("missing");
407        let _env = EnvGuard::set(&[
408            ("TEST_SECRET_MISSING", None),
409            ("TEST_SECRET_MISSING_FILE", missing.to_str()),
410        ]);
411
412        let err = interpolate("${TEST_SECRET_MISSING:-fallback}").unwrap_err();
413        assert!(matches!(
414            &err,
415            InterpolationError::SecretFile { var, path, .. }
416                if var == "TEST_SECRET_MISSING_FILE" && path == &missing
417        ));
418    }
419}