use std::collections::BTreeMap;
use std::fmt::Write as _;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TemplateError {
#[error("the declared command is empty; an action body must name a program to run")]
Empty,
#[error("the declared command has an unterminated {quote} quote")]
UnterminatedQuote {
quote: char,
},
#[error("`{{{{}}}}` interpolates nothing; write `{{{{name}}}}` around one declared parameter")]
EmptyParameterName,
#[error("`{{{{` is missing its closing `}}}}` in the declared command")]
UnterminatedParameterBrace,
#[error(
"the program to run must be a literal, not the parameter `{parameter}`; \
a caller-supplied value must never choose which program executes"
)]
ParameterizedProgram {
parameter: String,
},
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SubstitutionError {
#[error("the declared command references parameter `{parameter}`, which was not supplied")]
MissingParameter {
parameter: String,
},
#[error(
"parameter `{parameter}` is {kind}, which has no unambiguous command-argument form; \
supply a string, number, or boolean"
)]
UnrepresentableValue {
parameter: String,
kind: &'static str,
},
#[error(
"parameter `{parameter}` contains a NUL byte, which cannot appear in a command argument"
)]
InteriorNul {
parameter: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Piece {
Literal(String),
Parameter(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Word {
pieces: Vec<Piece>,
}
impl Word {
fn sole_parameter(&self) -> Option<&str> {
match self.pieces.as_slice() {
[Piece::Parameter(name)] => Some(name),
_ => None,
}
}
fn render(
&self,
values: &BTreeMap<String, serde_json::Value>,
) -> Result<String, SubstitutionError> {
let mut rendered = String::new();
for piece in &self.pieces {
match piece {
Piece::Literal(text) => rendered.push_str(text),
Piece::Parameter(name) => {
let value =
values
.get(name)
.ok_or_else(|| SubstitutionError::MissingParameter {
parameter: name.clone(),
})?;
render_value(name, value, &mut rendered)?;
}
}
}
Ok(rendered)
}
}
fn render_value(
parameter: &str,
value: &serde_json::Value,
out: &mut String,
) -> Result<(), SubstitutionError> {
let unrepresentable = |kind: &'static str| SubstitutionError::UnrepresentableValue {
parameter: parameter.to_owned(),
kind,
};
match value {
serde_json::Value::String(text) => {
if text.contains('\0') {
return Err(SubstitutionError::InteriorNul {
parameter: parameter.to_owned(),
});
}
out.push_str(text);
Ok(())
}
serde_json::Value::Number(number) => {
let _ = write!(out, "{number}");
Ok(())
}
serde_json::Value::Bool(flag) => {
out.push_str(if *flag { "true" } else { "false" });
Ok(())
}
serde_json::Value::Null => Err(unrepresentable("null")),
serde_json::Value::Array(_) => Err(unrepresentable("an array")),
serde_json::Value::Object(_) => Err(unrepresentable("an object")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandTemplate {
program: Word,
arguments: Vec<Word>,
}
impl CommandTemplate {
pub fn parse(command: &str) -> Result<Self, TemplateError> {
let mut words = Vec::new();
let mut pieces: Vec<Piece> = Vec::new();
let mut literal = String::new();
let mut started = false;
let mut chars = command.char_indices().peekable();
macro_rules! flush_literal {
() => {
if !literal.is_empty() {
pieces.push(Piece::Literal(std::mem::take(&mut literal)));
}
};
}
let mut quote: Option<char> = None;
while let Some(&(index, character)) = chars.peek() {
if command[index..].starts_with(HOLE_OPEN) {
started = true;
flush_literal!();
push_parameter(&mut chars, command, index, &mut pieces)?;
continue;
}
chars.next();
match character {
_ if quote.is_none() && character.is_whitespace() => {
flush_literal!();
if started {
words.push(Word {
pieces: std::mem::take(&mut pieces),
});
started = false;
}
}
'\'' | '"' => {
started = true;
match quote {
None => quote = Some(character),
Some(open) if open == character => quote = None,
Some(_) => literal.push(character),
}
}
other => {
started = true;
literal.push(other);
}
}
}
if let Some(open) = quote {
return Err(TemplateError::UnterminatedQuote { quote: open });
}
flush_literal!();
if started {
words.push(Word { pieces });
}
let mut words = words.into_iter();
let program = words.next().ok_or(TemplateError::Empty)?;
if let Some(parameter) = program.sole_parameter() {
return Err(TemplateError::ParameterizedProgram {
parameter: parameter.to_owned(),
});
}
Ok(Self {
program,
arguments: words.collect(),
})
}
#[must_use]
pub fn referenced_parameters(&self) -> Vec<String> {
let mut names: Vec<String> = std::iter::once(&self.program)
.chain(&self.arguments)
.flat_map(|word| &word.pieces)
.filter_map(|piece| match piece {
Piece::Parameter(name) => Some(name.clone()),
Piece::Literal(_) => None,
})
.collect();
names.sort_unstable();
names.dedup();
names
}
pub fn render(
&self,
values: &BTreeMap<String, serde_json::Value>,
) -> Result<Vec<String>, SubstitutionError> {
let mut argv = Vec::with_capacity(self.arguments.len() + 1);
argv.push(self.program.render(values)?);
for argument in &self.arguments {
argv.push(argument.render(values)?);
}
Ok(argv)
}
}
const HOLE_OPEN: &str = "{{";
const HOLE_CLOSE: &str = "}}";
fn push_parameter(
chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
command: &str,
open: usize,
pieces: &mut Vec<Piece>,
) -> Result<(), TemplateError> {
let after_open = &command[open + HOLE_OPEN.len()..];
let Some(close) = after_open.find(HOLE_CLOSE) else {
return Err(TemplateError::UnterminatedParameterBrace);
};
let end = open + HOLE_OPEN.len() + close + HOLE_CLOSE.len();
while matches!(chars.peek(), Some(&(index, _)) if index < end) {
chars.next();
}
let name = after_open[..close].trim();
if name.is_empty() {
return Err(TemplateError::EmptyParameterName);
}
pieces.push(Piece::Parameter(name.to_owned()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::{CommandTemplate, SubstitutionError, TemplateError};
use std::collections::BTreeMap;
fn values(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), value.clone()))
.collect()
}
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn render(
command: &str,
pairs: &[(&str, serde_json::Value)],
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(CommandTemplate::parse(command)?.render(&values(pairs))?)
}
#[test]
fn splits_a_plain_command_into_program_and_arguments() -> TestResult {
assert_eq!(render("echo hello world", &[])?, ["echo", "hello", "world"]);
Ok(())
}
#[test]
fn collapses_runs_of_whitespace_between_words() -> TestResult {
assert_eq!(render("echo a\t\tb", &[])?, ["echo", "a", "b"]);
Ok(())
}
#[test]
fn substitutes_a_bare_parameter_as_one_argument() -> TestResult {
assert_eq!(
render("echo {{name}}", &[("name", serde_json::json!("Ada"))])?,
["echo", "Ada"]
);
Ok(())
}
#[test]
fn a_parameter_abutting_more_text_makes_one_word() -> TestResult {
assert_eq!(
render("echo {{name}}!", &[("name", serde_json::json!("Ada"))])?,
["echo", "Ada!"]
);
Ok(())
}
#[test]
fn padding_inside_the_braces_is_not_part_of_the_name() -> TestResult {
assert_eq!(
render("echo {{ name }}", &[("name", serde_json::json!("Ada"))])?,
["echo", "Ada"]
);
Ok(())
}
#[test]
fn a_value_containing_spaces_stays_one_argument() -> TestResult {
assert_eq!(
render(
"echo {{name}}",
&[("name", serde_json::json!("Ada Lovelace"))]
)?,
["echo", "Ada Lovelace"]
);
Ok(())
}
#[test]
fn shell_metacharacters_in_a_value_are_inert_literal_text() -> TestResult {
for hostile in [
"; rm -rf /",
"$(rm -rf /)",
"`rm -rf /`",
"a | b",
"a && b",
"a > /etc/passwd",
"'; DROP TABLE users; --",
"$HOME",
"\n rm -rf /",
] {
assert_eq!(
render("echo {{value}}", &[("value", serde_json::json!(hostile))])?,
["echo", hostile],
"a value must never be re-parsed: {hostile}"
);
}
Ok(())
}
#[test]
fn a_substituted_value_is_never_rescanned_for_parameters() -> TestResult {
assert_eq!(
render(
"echo {{outer}}",
&[
("outer", serde_json::json!("{{inner}}")),
("inner", serde_json::json!("substituted twice")),
]
)?,
["echo", "{{inner}}"]
);
Ok(())
}
#[test]
fn double_quotes_group_text_and_still_substitute() -> TestResult {
assert_eq!(
render(
"echo \"hello {{name}}\"",
&[("name", serde_json::json!("Ada"))]
)?,
["echo", "hello Ada"]
);
Ok(())
}
#[test]
fn single_quotes_group_text_and_still_substitute() -> TestResult {
assert_eq!(
render(
"sh -c 'echo {{name}}'",
&[("name", serde_json::json!("Ada"))]
)?,
["sh", "-c", "echo Ada"]
);
Ok(())
}
#[test]
fn a_dollar_sign_is_ordinary_literal_text_in_every_region() -> TestResult {
assert_eq!(
render(
"echo $HOME '$1' $$ {{name}}",
&[("name", serde_json::json!("Ada"))]
)?,
["echo", "$HOME", "$1", "$$", "Ada"]
);
Ok(())
}
#[test]
fn a_single_brace_is_ordinary_literal_text() -> TestResult {
assert_eq!(
render(
"awk {print $1} {{path}}",
&[("path", serde_json::json!("/tmp/x"))]
)?,
["awk", "{print", "$1}", "/tmp/x"]
);
Ok(())
}
#[test]
fn an_empty_quoted_word_survives_as_an_empty_argument() -> TestResult {
assert_eq!(render("echo '' x", &[])?, ["echo", "", "x"]);
Ok(())
}
#[test]
fn numbers_and_booleans_render_without_quotes() -> TestResult {
assert_eq!(
render(
"run {{count}} {{flag}}",
&[
("count", serde_json::json!(42)),
("flag", serde_json::json!(true)),
]
)?,
["run", "42", "true"]
);
Ok(())
}
#[test]
fn an_empty_command_is_refused() {
assert_eq!(CommandTemplate::parse(" "), Err(TemplateError::Empty));
}
#[test]
fn an_unterminated_quote_is_refused() {
assert_eq!(
CommandTemplate::parse("echo 'oops"),
Err(TemplateError::UnterminatedQuote { quote: '\'' })
);
assert_eq!(
CommandTemplate::parse("echo \"oops"),
Err(TemplateError::UnterminatedQuote { quote: '"' })
);
}
#[test]
fn an_unterminated_interpolation_is_refused() {
assert_eq!(
CommandTemplate::parse("echo {{name"),
Err(TemplateError::UnterminatedParameterBrace)
);
}
#[test]
fn an_interpolation_naming_nothing_is_refused() {
assert_eq!(
CommandTemplate::parse("echo {{}}"),
Err(TemplateError::EmptyParameterName)
);
assert_eq!(
CommandTemplate::parse("echo {{ }}"),
Err(TemplateError::EmptyParameterName)
);
}
#[test]
fn a_parameterized_program_is_refused() {
assert_eq!(
CommandTemplate::parse("{{program}} arg"),
Err(TemplateError::ParameterizedProgram {
parameter: "program".to_owned()
})
);
}
#[test]
fn a_missing_parameter_is_refused_by_name() -> TestResult {
let Err(error) = CommandTemplate::parse("echo {{absent}}")?.render(&BTreeMap::new()) else {
return Err("a missing parameter must be refused, not rendered".into());
};
assert_eq!(
error,
SubstitutionError::MissingParameter {
parameter: "absent".to_owned()
}
);
Ok(())
}
#[test]
fn structured_and_null_values_are_refused_rather_than_guessed() -> TestResult {
for (value, kind) in [
(serde_json::json!(null), "null"),
(serde_json::json!([1, 2]), "an array"),
(serde_json::json!({"a": 1}), "an object"),
] {
let Err(error) =
CommandTemplate::parse("echo {{value}}")?.render(&values(&[("value", value)]))
else {
return Err(format!("a value that is {kind} must be refused, not guessed").into());
};
assert_eq!(
error,
SubstitutionError::UnrepresentableValue {
parameter: "value".to_owned(),
kind
}
);
}
Ok(())
}
#[test]
fn an_interior_nul_is_refused_rather_than_truncating_the_argument() -> TestResult {
let Err(error) = CommandTemplate::parse("echo {{value}}")?
.render(&values(&[("value", serde_json::json!("a\0b"))]))
else {
return Err("an interior NUL must be refused, not silently truncated".into());
};
assert_eq!(
error,
SubstitutionError::InteriorNul {
parameter: "value".to_owned()
}
);
Ok(())
}
#[test]
fn referenced_parameters_are_reported_sorted_and_deduplicated() -> TestResult {
let template = CommandTemplate::parse("run {{b}} {{a}} {{b}} literal")?;
assert_eq!(template.referenced_parameters(), ["a", "b"]);
Ok(())
}
}