summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-27 02:55:30 +0100
committertv <tv@krebsco.de>2026-10-27 02:55:30 +0100
commit5ae2f49509be196aec3d6de1ef558b046562c4ac (patch)
tree9847b9e2680eab6b80f30399070902c1a5721847 /src
parentd1926800dfdbfba213a0b5060777055d8b1dbae4 (diff)
allow complex interpolations in the config
Diffstat (limited to 'src')
-rw-r--r--src/config/import.rs72
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);