1use std::borrow::Cow;
48
49pub 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
70pub 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
95pub 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 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 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 None => {
135 out.push('$');
136 i += 1;
137 }
138 }
139 }
140
141 Cow::Owned(out)
142}
143
144struct Reference {
146 name: String,
147 default: Option<String>,
149 length: usize,
151}
152
153fn 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 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 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
197fn 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
208fn resolve(reference: &Reference, source: &str) -> String {
214 match std::env::var(&reference.name) {
215 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 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 assert_eq!(
271 expanded("postgres://$APIPLANT_T_HOST:5432/db"),
272 "postgres://db.example.com:5432/db"
273 );
274 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 assert_eq!(expanded("${APIPLANT_T_EMPTY:-us-east-1}"), "us-east-1");
304 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 #[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 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 assert_eq!(document["port"].as_integer(), Some(5432));
390 assert_eq!(document["title"].as_str(), Some("no references here"));
391 }
392
393 #[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 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 #[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}