Injection-proof interpolation without variadics

Continuing the discussion from How to allow arbitrary expressions in format strings:

So, one of my long-standing desires for rust is to allow generic a interpolation syntax, like "hello {name}" which doesn't just convert stuff to string using hard-coded Display, but allows the library to interpose custom escaping logic. Basically, I want API like JS or Java template literals, which allow user code to get separate arrays of string fragments and interpolated values. This sounds terribly confusing if you are not already familiar to the idea, but luckily JEP 430: String Templates (Preview) explains this much clearer than I could after staring at the screen for 12 hours :slight_smile:

I long believed that that isn't actually possible to express without either variadic generics or dynamic casting, but, after reading that jep, I've realized today that such a generic interpolation API is actually possible in today's rust!

Basically, a macro can expand "{x} + {y}" into Unnamable(("", " + ", ""), (x, y)) where Unnamable implements visitor pattern like this one:

pub trait TemplateString<V: TemplateVisitor> {
    fn accept(self) -> V::Output;
}

pub trait TemplateVisitor {
    type Output;
    fn new() -> Self;
    fn visit_str(&mut self, s: &'static str);
    fn finish(self) -> Self::Output;
}

pub trait TemplateVisit<T> {
    fn visit(&mut self, value: &T);
}

So, something like

#[test]
fn shell_escape() {
    let branch = "lol; echo pwned!";
    let s = interpol!("git switch {branch}" as ShellEscape);
    assert_eq!(s, r#"git switch "lol; echo pwned!""#);
}

gets expanded to

    struct TS<'a, T0>((&'static str, &'static str), (&'a T0,));

    impl<'a, T0, V: my_desire::TemplateVisitor + my_desire::TemplateVisit<T0>>
        my_desire::TemplateString<V> for TS<'a, T0>
    {
        fn accept(self) -> V::Output {
            let mut v = V::new();
            v.visit_str(self.0 .0);
            v.visit(self.1 .0);
            v.visit_str(self.0 .1);
            v.finish()
        }
    }
    let ts = TS(("git switch ", ""), (&branch,));
    my_desire::TemplateString::<ShellEscape>::accept(ts)
}

I've published a quick proof of concept at GitHub - matklad/my-desire / https://crates.io/crates/my-desire I don't intend to push this to production readiness, so feel free to run with the idea!

More examples here:

6 Likes

I'm not super familiar with the internals of format_args!, but isn't the expansion into the string literal and interpolated values already what's done? The only issue to my knowledge was the various modifiers supported by format_args! (which this wholly avoids).

format_args doesn’t support custom escaping logic. I can’t use format-args to construct a process::Command in a safe way (; echo "Shell Injection"). I also can’t use it to not convert to string (see the sql example my-desire/it.rs at 1871db5738007bf50ee2243d849312534d9c58e2 · matklad/my-desire · GitHub).

This would be nice for interpolating OsString.

True. I was thinking of it solely from the perspective of fitting arbitrary expressions into format strings.