pipedash_core/infrastructure/config/
interpolation.rs1use 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")); }
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}