1use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use thiserror::Error;
25
26#[derive(Debug, Error, PartialEq, Eq)]
28pub enum TemplateError {
29 #[error("the declared command is empty; an action body must name a program to run")]
32 Empty,
33 #[error("the declared command has an unterminated {quote} quote")]
35 UnterminatedQuote {
36 quote: char,
38 },
39 #[error("`{{{{}}}}` interpolates nothing; write `{{{{name}}}}` around one declared parameter")]
41 EmptyParameterName,
42 #[error("`{{{{` is missing its closing `}}}}` in the declared command")]
44 UnterminatedParameterBrace,
45 #[error(
50 "the program to run must be a literal, not the parameter `{parameter}`; \
51 a caller-supplied value must never choose which program executes"
52 )]
53 ParameterizedProgram {
54 parameter: String,
56 },
57}
58
59#[derive(Debug, Error, PartialEq, Eq)]
61pub enum SubstitutionError {
62 #[error("the declared command references parameter `{parameter}`, which was not supplied")]
64 MissingParameter {
65 parameter: String,
67 },
68 #[error(
70 "parameter `{parameter}` is {kind}, which has no unambiguous command-argument form; \
71 supply a string, number, or boolean"
72 )]
73 UnrepresentableValue {
74 parameter: String,
76 kind: &'static str,
78 },
79 #[error(
85 "parameter `{parameter}` contains a NUL byte, which cannot appear in a command argument"
86 )]
87 InteriorNul {
88 parameter: String,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95enum Piece {
96 Literal(String),
98 Parameter(String),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
105struct Word {
106 pieces: Vec<Piece>,
107}
108
109impl Word {
110 fn sole_parameter(&self) -> Option<&str> {
112 match self.pieces.as_slice() {
113 [Piece::Parameter(name)] => Some(name),
114 _ => None,
115 }
116 }
117
118 fn render(
120 &self,
121 values: &BTreeMap<String, serde_json::Value>,
122 ) -> Result<String, SubstitutionError> {
123 let mut rendered = String::new();
124 for piece in &self.pieces {
125 match piece {
126 Piece::Literal(text) => rendered.push_str(text),
127 Piece::Parameter(name) => {
128 let value =
129 values
130 .get(name)
131 .ok_or_else(|| SubstitutionError::MissingParameter {
132 parameter: name.clone(),
133 })?;
134 render_value(name, value, &mut rendered)?;
135 }
136 }
137 }
138 Ok(rendered)
139 }
140}
141
142fn render_value(
144 parameter: &str,
145 value: &serde_json::Value,
146 out: &mut String,
147) -> Result<(), SubstitutionError> {
148 let unrepresentable = |kind: &'static str| SubstitutionError::UnrepresentableValue {
149 parameter: parameter.to_owned(),
150 kind,
151 };
152 match value {
153 serde_json::Value::String(text) => {
154 if text.contains('\0') {
155 return Err(SubstitutionError::InteriorNul {
156 parameter: parameter.to_owned(),
157 });
158 }
159 out.push_str(text);
160 Ok(())
161 }
162 serde_json::Value::Number(number) => {
163 let _ = write!(out, "{number}");
166 Ok(())
167 }
168 serde_json::Value::Bool(flag) => {
169 out.push_str(if *flag { "true" } else { "false" });
170 Ok(())
171 }
172 serde_json::Value::Null => Err(unrepresentable("null")),
177 serde_json::Value::Array(_) => Err(unrepresentable("an array")),
178 serde_json::Value::Object(_) => Err(unrepresentable("an object")),
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct CommandTemplate {
185 program: Word,
186 arguments: Vec<Word>,
187}
188
189impl CommandTemplate {
190 pub fn parse(command: &str) -> Result<Self, TemplateError> {
204 let mut words = Vec::new();
205 let mut pieces: Vec<Piece> = Vec::new();
206 let mut literal = String::new();
207 let mut started = false;
208 let mut chars = command.char_indices().peekable();
209
210 macro_rules! flush_literal {
212 () => {
213 if !literal.is_empty() {
214 pieces.push(Piece::Literal(std::mem::take(&mut literal)));
215 }
216 };
217 }
218
219 let mut quote: Option<char> = None;
224
225 while let Some(&(index, character)) = chars.peek() {
226 if command[index..].starts_with(HOLE_OPEN) {
227 started = true;
228 flush_literal!();
229 push_parameter(&mut chars, command, index, &mut pieces)?;
230 continue;
231 }
232 chars.next();
233 match character {
234 _ if quote.is_none() && character.is_whitespace() => {
235 flush_literal!();
236 if started {
237 words.push(Word {
238 pieces: std::mem::take(&mut pieces),
239 });
240 started = false;
241 }
242 }
243 '\'' | '"' => {
244 started = true;
245 match quote {
246 None => quote = Some(character),
247 Some(open) if open == character => quote = None,
248 Some(_) => literal.push(character),
249 }
250 }
251 other => {
252 started = true;
253 literal.push(other);
254 }
255 }
256 }
257
258 if let Some(open) = quote {
259 return Err(TemplateError::UnterminatedQuote { quote: open });
260 }
261
262 flush_literal!();
263 if started {
264 words.push(Word { pieces });
265 }
266
267 let mut words = words.into_iter();
268 let program = words.next().ok_or(TemplateError::Empty)?;
269 if let Some(parameter) = program.sole_parameter() {
270 return Err(TemplateError::ParameterizedProgram {
271 parameter: parameter.to_owned(),
272 });
273 }
274 Ok(Self {
275 program,
276 arguments: words.collect(),
277 })
278 }
279
280 #[must_use]
282 pub fn referenced_parameters(&self) -> Vec<String> {
283 let mut names: Vec<String> = std::iter::once(&self.program)
284 .chain(&self.arguments)
285 .flat_map(|word| &word.pieces)
286 .filter_map(|piece| match piece {
287 Piece::Parameter(name) => Some(name.clone()),
288 Piece::Literal(_) => None,
289 })
290 .collect();
291 names.sort_unstable();
292 names.dedup();
293 names
294 }
295
296 pub fn render(
306 &self,
307 values: &BTreeMap<String, serde_json::Value>,
308 ) -> Result<Vec<String>, SubstitutionError> {
309 let mut argv = Vec::with_capacity(self.arguments.len() + 1);
310 argv.push(self.program.render(values)?);
311 for argument in &self.arguments {
312 argv.push(argument.render(values)?);
313 }
314 Ok(argv)
315 }
316}
317
318const HOLE_OPEN: &str = "{{";
320const HOLE_CLOSE: &str = "}}";
322
323fn push_parameter(
325 chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
326 command: &str,
327 open: usize,
328 pieces: &mut Vec<Piece>,
329) -> Result<(), TemplateError> {
330 let after_open = &command[open + HOLE_OPEN.len()..];
331 let Some(close) = after_open.find(HOLE_CLOSE) else {
332 return Err(TemplateError::UnterminatedParameterBrace);
333 };
334 let end = open + HOLE_OPEN.len() + close + HOLE_CLOSE.len();
335 while matches!(chars.peek(), Some(&(index, _)) if index < end) {
336 chars.next();
337 }
338 let name = after_open[..close].trim();
339 if name.is_empty() {
340 return Err(TemplateError::EmptyParameterName);
341 }
342 pieces.push(Piece::Parameter(name.to_owned()));
343 Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::{CommandTemplate, SubstitutionError, TemplateError};
355 use std::collections::BTreeMap;
356
357 fn values(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
358 pairs
359 .iter()
360 .map(|(name, value)| ((*name).to_owned(), value.clone()))
361 .collect()
362 }
363
364 type TestResult = Result<(), Box<dyn std::error::Error>>;
368
369 fn render(
370 command: &str,
371 pairs: &[(&str, serde_json::Value)],
372 ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
373 Ok(CommandTemplate::parse(command)?.render(&values(pairs))?)
374 }
375
376 #[test]
377 fn splits_a_plain_command_into_program_and_arguments() -> TestResult {
378 assert_eq!(render("echo hello world", &[])?, ["echo", "hello", "world"]);
379 Ok(())
380 }
381
382 #[test]
383 fn collapses_runs_of_whitespace_between_words() -> TestResult {
384 assert_eq!(render("echo a\t\tb", &[])?, ["echo", "a", "b"]);
385 Ok(())
386 }
387
388 #[test]
389 fn substitutes_a_bare_parameter_as_one_argument() -> TestResult {
390 assert_eq!(
391 render("echo {{name}}", &[("name", serde_json::json!("Ada"))])?,
392 ["echo", "Ada"]
393 );
394 Ok(())
395 }
396
397 #[test]
398 fn a_parameter_abutting_more_text_makes_one_word() -> TestResult {
399 assert_eq!(
400 render("echo {{name}}!", &[("name", serde_json::json!("Ada"))])?,
401 ["echo", "Ada!"]
402 );
403 Ok(())
404 }
405
406 #[test]
407 fn padding_inside_the_braces_is_not_part_of_the_name() -> TestResult {
408 assert_eq!(
409 render("echo {{ name }}", &[("name", serde_json::json!("Ada"))])?,
410 ["echo", "Ada"]
411 );
412 Ok(())
413 }
414
415 #[test]
416 fn a_value_containing_spaces_stays_one_argument() -> TestResult {
417 assert_eq!(
419 render(
420 "echo {{name}}",
421 &[("name", serde_json::json!("Ada Lovelace"))]
422 )?,
423 ["echo", "Ada Lovelace"]
424 );
425 Ok(())
426 }
427
428 #[test]
429 fn shell_metacharacters_in_a_value_are_inert_literal_text() -> TestResult {
430 for hostile in [
433 "; rm -rf /",
434 "$(rm -rf /)",
435 "`rm -rf /`",
436 "a | b",
437 "a && b",
438 "a > /etc/passwd",
439 "'; DROP TABLE users; --",
440 "$HOME",
441 "\n rm -rf /",
442 ] {
443 assert_eq!(
444 render("echo {{value}}", &[("value", serde_json::json!(hostile))])?,
445 ["echo", hostile],
446 "a value must never be re-parsed: {hostile}"
447 );
448 }
449 Ok(())
450 }
451
452 #[test]
453 fn a_substituted_value_is_never_rescanned_for_parameters() -> TestResult {
454 assert_eq!(
455 render(
456 "echo {{outer}}",
457 &[
458 ("outer", serde_json::json!("{{inner}}")),
459 ("inner", serde_json::json!("substituted twice")),
460 ]
461 )?,
462 ["echo", "{{inner}}"]
463 );
464 Ok(())
465 }
466
467 #[test]
468 fn double_quotes_group_text_and_still_substitute() -> TestResult {
469 assert_eq!(
470 render(
471 "echo \"hello {{name}}\"",
472 &[("name", serde_json::json!("Ada"))]
473 )?,
474 ["echo", "hello Ada"]
475 );
476 Ok(())
477 }
478
479 #[test]
480 fn single_quotes_group_text_and_still_substitute() -> TestResult {
481 assert_eq!(
487 render(
488 "sh -c 'echo {{name}}'",
489 &[("name", serde_json::json!("Ada"))]
490 )?,
491 ["sh", "-c", "echo Ada"]
492 );
493 Ok(())
494 }
495
496 #[test]
497 fn a_dollar_sign_is_ordinary_literal_text_in_every_region() -> TestResult {
498 assert_eq!(
502 render(
503 "echo $HOME '$1' $$ {{name}}",
504 &[("name", serde_json::json!("Ada"))]
505 )?,
506 ["echo", "$HOME", "$1", "$$", "Ada"]
507 );
508 Ok(())
509 }
510
511 #[test]
512 fn a_single_brace_is_ordinary_literal_text() -> TestResult {
513 assert_eq!(
516 render(
517 "awk {print $1} {{path}}",
518 &[("path", serde_json::json!("/tmp/x"))]
519 )?,
520 ["awk", "{print", "$1}", "/tmp/x"]
521 );
522 Ok(())
523 }
524
525 #[test]
526 fn an_empty_quoted_word_survives_as_an_empty_argument() -> TestResult {
527 assert_eq!(render("echo '' x", &[])?, ["echo", "", "x"]);
528 Ok(())
529 }
530
531 #[test]
532 fn numbers_and_booleans_render_without_quotes() -> TestResult {
533 assert_eq!(
534 render(
535 "run {{count}} {{flag}}",
536 &[
537 ("count", serde_json::json!(42)),
538 ("flag", serde_json::json!(true)),
539 ]
540 )?,
541 ["run", "42", "true"]
542 );
543 Ok(())
544 }
545
546 #[test]
547 fn an_empty_command_is_refused() {
548 assert_eq!(CommandTemplate::parse(" "), Err(TemplateError::Empty));
549 }
550
551 #[test]
552 fn an_unterminated_quote_is_refused() {
553 assert_eq!(
554 CommandTemplate::parse("echo 'oops"),
555 Err(TemplateError::UnterminatedQuote { quote: '\'' })
556 );
557 assert_eq!(
558 CommandTemplate::parse("echo \"oops"),
559 Err(TemplateError::UnterminatedQuote { quote: '"' })
560 );
561 }
562
563 #[test]
564 fn an_unterminated_interpolation_is_refused() {
565 assert_eq!(
566 CommandTemplate::parse("echo {{name"),
567 Err(TemplateError::UnterminatedParameterBrace)
568 );
569 }
570
571 #[test]
572 fn an_interpolation_naming_nothing_is_refused() {
573 assert_eq!(
574 CommandTemplate::parse("echo {{}}"),
575 Err(TemplateError::EmptyParameterName)
576 );
577 assert_eq!(
578 CommandTemplate::parse("echo {{ }}"),
579 Err(TemplateError::EmptyParameterName)
580 );
581 }
582
583 #[test]
584 fn a_parameterized_program_is_refused() {
585 assert_eq!(
587 CommandTemplate::parse("{{program}} arg"),
588 Err(TemplateError::ParameterizedProgram {
589 parameter: "program".to_owned()
590 })
591 );
592 }
593
594 #[test]
595 fn a_missing_parameter_is_refused_by_name() -> TestResult {
596 let Err(error) = CommandTemplate::parse("echo {{absent}}")?.render(&BTreeMap::new()) else {
597 return Err("a missing parameter must be refused, not rendered".into());
598 };
599 assert_eq!(
600 error,
601 SubstitutionError::MissingParameter {
602 parameter: "absent".to_owned()
603 }
604 );
605 Ok(())
606 }
607
608 #[test]
609 fn structured_and_null_values_are_refused_rather_than_guessed() -> TestResult {
610 for (value, kind) in [
611 (serde_json::json!(null), "null"),
612 (serde_json::json!([1, 2]), "an array"),
613 (serde_json::json!({"a": 1}), "an object"),
614 ] {
615 let Err(error) =
616 CommandTemplate::parse("echo {{value}}")?.render(&values(&[("value", value)]))
617 else {
618 return Err(format!("a value that is {kind} must be refused, not guessed").into());
619 };
620 assert_eq!(
621 error,
622 SubstitutionError::UnrepresentableValue {
623 parameter: "value".to_owned(),
624 kind
625 }
626 );
627 }
628 Ok(())
629 }
630
631 #[test]
632 fn an_interior_nul_is_refused_rather_than_truncating_the_argument() -> TestResult {
633 let Err(error) = CommandTemplate::parse("echo {{value}}")?
634 .render(&values(&[("value", serde_json::json!("a\0b"))]))
635 else {
636 return Err("an interior NUL must be refused, not silently truncated".into());
637 };
638 assert_eq!(
639 error,
640 SubstitutionError::InteriorNul {
641 parameter: "value".to_owned()
642 }
643 );
644 Ok(())
645 }
646
647 #[test]
648 fn referenced_parameters_are_reported_sorted_and_deduplicated() -> TestResult {
649 let template = CommandTemplate::parse("run {{b}} {{a}} {{b}} literal")?;
650 assert_eq!(template.referenced_parameters(), ["a", "b"]);
651 Ok(())
652 }
653}