diff options
| author | tv <tv@krebsco.de> | 2026-10-27 02:55:30 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-27 02:55:30 +0100 |
| commit | 5ae2f49509be196aec3d6de1ef558b046562c4ac (patch) | |
| tree | 9847b9e2680eab6b80f30399070902c1a5721847 /src | |
| parent | d1926800dfdbfba213a0b5060777055d8b1dbae4 (diff) | |
allow complex interpolations in the config
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/import.rs | 72 |
1 files changed, 70 insertions, 2 deletions
diff --git a/src/config/import.rs b/src/config/import.rs index 9f315f3..b50521a 100644 --- a/src/config/import.rs +++ b/src/config/import.rs @@ -228,6 +228,45 @@ pub(crate) enum Imported { Json(JsonValue), } +/// Where the placeholder that `text` starts (after its `${`) ends: at the `}` that +/// matches its `{`. Braces inside balance, so a command may hold `{...}` (a jq object, a +/// shell group); in a command (`cmd:`), braces in shell quotes (`'...'`, `"..."`) or +/// escaped with a backslash don't count either. +fn placeholder_end(text: &str) -> Option<usize> { + let shell = text.starts_with("cmd:") || text.starts_with("cmd+"); + let mut depth = 0usize; + let mut quote: Option<char> = None; + let mut escaped = false; + for (at, char) in text.char_indices() { + if shell { + if escaped { + escaped = false; + continue; + } + match (quote, char) { + // Nothing escapes in single quotes. + (Some('\''), '\'') => quote = None, + (Some('\''), _) => {} + (_, '\\') => escaped = true, + (Some('"'), '"') => quote = None, + (Some('"'), _) => {} + (None, '\'' | '"') => quote = Some(char), + _ => {} + } + if quote.is_some() || char == '\'' || char == '"' || char == '\\' { + continue; + } + } + match char { + '{' => depth += 1, + '}' if depth == 0 => return Some(at), + '}' => depth -= 1, + _ => {} + } + } + None +} + /// Text with its placeholders replaced by their content. fn expand(template: &str, dir: Option<&Path>) -> Result<String> { let mut out = String::new(); @@ -239,8 +278,7 @@ fn expand(template: &str, dir: Option<&Path>) -> Result<String> { out.push('$'); rest = after; } else if let Some(after) = after.strip_prefix('{') { - let end = after - .find('}') + let end = placeholder_end(after) .ok_or_else(|| anyhow!("unterminated placeholder in template {template:?}"))?; let placeholder = &after[..end]; let Raw::Import(mut importer) = parse_argument(placeholder) else { @@ -366,6 +404,36 @@ mod tests { } #[test] + fn placeholders_end_at_their_matching_brace() { + fn end(text: &str) -> Option<&str> { + placeholder_end(text).map(|at| &text[..at]) + } + assert_eq!(end("env:HOST}-x"), Some("env:HOST")); + // Balanced braces, as a jq object has them. + assert_eq!(end("cmd:jq -r '{a: .b}'}/tcp"), Some("cmd:jq -r '{a: .b}'")); + assert_eq!(end("cmd:sh -c { true; }}"), Some("cmd:sh -c { true; }")); + // In a command, braces in quotes or escaped don't count. + assert_eq!(end("cmd:printf '}'}x"), Some("cmd:printf '}'")); + assert_eq!(end(r#"cmd:printf "}\""}x"#), Some(r#"cmd:printf "}\"""#)); + assert_eq!(end(r"cmd:printf \}}x"), Some(r"cmd:printf \}")); + assert_eq!(end("cmd:printf '}"), None); + assert_eq!(end("env:X"), None); + // The kind of command this is for: quotes of both kinds, and `$` inside. + let command = r#"cmd:ip -j addr show wireguard | jq -r '.[].addr_info[]|select(.family=="inet6" and .scope=="global").local'"#; + assert_eq!(end(&format!("{command}}}/tcp/9661")), Some(command)); + } + + #[test] + fn commands_with_braces_and_quotes() { + let expanded = |template: &str| expand(template, None); + assert_eq!( + expanded(r#"/ip6/${cmd:printf '%s' "$(echo '::1')" | tr -d '{}'}/tcp/9661"#).unwrap(), + "/ip6/::1/tcp/9661" + ); + assert_eq!(expanded("x${cmd:printf '{%s}' a}y").unwrap(), "x{a}y"); + } + + #[test] fn tables() { let dir = Path::new("/etc/continuum"); let table = |value: JsonValue| parse_table(value.as_object().unwrap(), dir); |
