1use std::collections::BTreeMap;
18use std::fmt::Write as _;
19
20use thiserror::Error;
21
22#[derive(Debug, Error, PartialEq, Eq)]
24pub enum TemplateError {
25 #[error("the declared command is empty; an action body must name a program to run")]
28 Empty,
29 #[error("the declared command has an unterminated {quote} quote")]
31 UnterminatedQuote {
32 quote: char,
34 },
35 #[error("`$` must be followed by a parameter name in the declared command")]
37 EmptyParameterName,
38 #[error("`${{` is missing its closing `}}` in the declared command")]
40 UnterminatedParameterBrace,
41 #[error(
46 "the program to run must be a literal, not the parameter `{parameter}`; \
47 a caller-supplied value must never choose which program executes"
48 )]
49 ParameterizedProgram {
50 parameter: String,
52 },
53}
54
55#[derive(Debug, Error, PartialEq, Eq)]
57pub enum SubstitutionError {
58 #[error("the declared command references parameter `{parameter}`, which was not supplied")]
60 MissingParameter {
61 parameter: String,
63 },
64 #[error(
66 "parameter `{parameter}` is {kind}, which has no unambiguous command-argument form; \
67 supply a string, number, or boolean"
68 )]
69 UnrepresentableValue {
70 parameter: String,
72 kind: &'static str,
74 },
75 #[error(
81 "parameter `{parameter}` contains a NUL byte, which cannot appear in a command argument"
82 )]
83 InteriorNul {
84 parameter: String,
86 },
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91enum Piece {
92 Literal(String),
94 Parameter(String),
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
101struct Word {
102 pieces: Vec<Piece>,
103}
104
105impl Word {
106 fn sole_parameter(&self) -> Option<&str> {
108 match self.pieces.as_slice() {
109 [Piece::Parameter(name)] => Some(name),
110 _ => None,
111 }
112 }
113
114 fn render(
116 &self,
117 values: &BTreeMap<String, serde_json::Value>,
118 ) -> Result<String, SubstitutionError> {
119 let mut rendered = String::new();
120 for piece in &self.pieces {
121 match piece {
122 Piece::Literal(text) => rendered.push_str(text),
123 Piece::Parameter(name) => {
124 let value =
125 values
126 .get(name)
127 .ok_or_else(|| SubstitutionError::MissingParameter {
128 parameter: name.clone(),
129 })?;
130 render_value(name, value, &mut rendered)?;
131 }
132 }
133 }
134 Ok(rendered)
135 }
136}
137
138fn render_value(
140 parameter: &str,
141 value: &serde_json::Value,
142 out: &mut String,
143) -> Result<(), SubstitutionError> {
144 let unrepresentable = |kind: &'static str| SubstitutionError::UnrepresentableValue {
145 parameter: parameter.to_owned(),
146 kind,
147 };
148 match value {
149 serde_json::Value::String(text) => {
150 if text.contains('\0') {
151 return Err(SubstitutionError::InteriorNul {
152 parameter: parameter.to_owned(),
153 });
154 }
155 out.push_str(text);
156 Ok(())
157 }
158 serde_json::Value::Number(number) => {
159 let _ = write!(out, "{number}");
162 Ok(())
163 }
164 serde_json::Value::Bool(flag) => {
165 out.push_str(if *flag { "true" } else { "false" });
166 Ok(())
167 }
168 serde_json::Value::Null => Err(unrepresentable("null")),
173 serde_json::Value::Array(_) => Err(unrepresentable("an array")),
174 serde_json::Value::Object(_) => Err(unrepresentable("an object")),
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct CommandTemplate {
181 program: Word,
182 arguments: Vec<Word>,
183}
184
185impl CommandTemplate {
186 pub fn parse(command: &str) -> Result<Self, TemplateError> {
199 let mut words = Vec::new();
200 let mut pieces: Vec<Piece> = Vec::new();
201 let mut literal = String::new();
202 let mut started = false;
203 let mut chars = command.chars().peekable();
204
205 macro_rules! flush_literal {
207 () => {
208 if !literal.is_empty() {
209 pieces.push(Piece::Literal(std::mem::take(&mut literal)));
210 }
211 };
212 }
213
214 while let Some(character) = chars.next() {
215 match character {
216 whitespace if whitespace.is_whitespace() => {
217 flush_literal!();
218 if started {
219 words.push(Word {
220 pieces: std::mem::take(&mut pieces),
221 });
222 started = false;
223 }
224 }
225 '\'' => {
226 started = true;
228 loop {
229 match chars.next() {
230 Some('\'') => break,
231 Some(inner) => literal.push(inner),
232 None => {
233 return Err(TemplateError::UnterminatedQuote { quote: '\'' });
234 }
235 }
236 }
237 }
238 '"' => {
239 started = true;
240 loop {
241 match chars.next() {
242 Some('"') => break,
243 Some('$') => {
244 flush_literal!();
245 push_parameter(&mut chars, &mut pieces, &mut literal)?;
246 }
247 Some(inner) => literal.push(inner),
248 None => {
249 return Err(TemplateError::UnterminatedQuote { quote: '"' });
250 }
251 }
252 }
253 }
254 '$' => {
255 started = true;
256 flush_literal!();
257 push_parameter(&mut chars, &mut pieces, &mut literal)?;
258 }
259 other => {
260 started = true;
261 literal.push(other);
262 }
263 }
264 }
265
266 flush_literal!();
267 if started {
268 words.push(Word { pieces });
269 }
270
271 let mut words = words.into_iter();
272 let program = words.next().ok_or(TemplateError::Empty)?;
273 if let Some(parameter) = program.sole_parameter() {
274 return Err(TemplateError::ParameterizedProgram {
275 parameter: parameter.to_owned(),
276 });
277 }
278 Ok(Self {
279 program,
280 arguments: words.collect(),
281 })
282 }
283
284 #[must_use]
286 pub fn referenced_parameters(&self) -> Vec<String> {
287 let mut names: Vec<String> = std::iter::once(&self.program)
288 .chain(&self.arguments)
289 .flat_map(|word| &word.pieces)
290 .filter_map(|piece| match piece {
291 Piece::Parameter(name) => Some(name.clone()),
292 Piece::Literal(_) => None,
293 })
294 .collect();
295 names.sort_unstable();
296 names.dedup();
297 names
298 }
299
300 pub fn render(
310 &self,
311 values: &BTreeMap<String, serde_json::Value>,
312 ) -> Result<Vec<String>, SubstitutionError> {
313 let mut argv = Vec::with_capacity(self.arguments.len() + 1);
314 argv.push(self.program.render(values)?);
315 for argument in &self.arguments {
316 argv.push(argument.render(values)?);
317 }
318 Ok(argv)
319 }
320}
321
322fn push_parameter(
327 chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
328 pieces: &mut Vec<Piece>,
329 literal: &mut String,
330) -> Result<(), TemplateError> {
331 if chars.peek() == Some(&'$') {
332 chars.next();
333 literal.push('$');
334 return Ok(());
335 }
336 let mut name = String::new();
337 if chars.peek() == Some(&'{') {
338 chars.next();
339 loop {
340 match chars.next() {
341 Some('}') => break,
342 Some(inner) => name.push(inner),
343 None => return Err(TemplateError::UnterminatedParameterBrace),
344 }
345 }
346 } else {
347 while let Some(&next) = chars.peek() {
348 if next.is_alphanumeric() || next == '_' {
349 name.push(next);
350 chars.next();
351 } else {
352 break;
353 }
354 }
355 }
356 if name.is_empty() {
357 return Err(TemplateError::EmptyParameterName);
358 }
359 pieces.push(Piece::Parameter(name));
360 Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365 use super::{CommandTemplate, SubstitutionError, TemplateError};
366 use std::collections::BTreeMap;
367
368 fn values(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
369 pairs
370 .iter()
371 .map(|(name, value)| ((*name).to_owned(), value.clone()))
372 .collect()
373 }
374
375 type TestResult = Result<(), Box<dyn std::error::Error>>;
379
380 fn render(
381 command: &str,
382 pairs: &[(&str, serde_json::Value)],
383 ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
384 Ok(CommandTemplate::parse(command)?.render(&values(pairs))?)
385 }
386
387 #[test]
388 fn splits_a_plain_command_into_program_and_arguments() -> TestResult {
389 assert_eq!(render("echo hello world", &[])?, ["echo", "hello", "world"]);
390 Ok(())
391 }
392
393 #[test]
394 fn collapses_runs_of_whitespace_between_words() -> TestResult {
395 assert_eq!(render("echo a\t\tb", &[])?, ["echo", "a", "b"]);
396 Ok(())
397 }
398
399 #[test]
400 fn substitutes_a_bare_parameter_as_one_argument() -> TestResult {
401 assert_eq!(
402 render("echo $name", &[("name", serde_json::json!("Ada"))])?,
403 ["echo", "Ada"]
404 );
405 Ok(())
406 }
407
408 #[test]
409 fn substitutes_a_braced_parameter() -> TestResult {
410 assert_eq!(
411 render("echo ${name}!", &[("name", serde_json::json!("Ada"))])?,
412 ["echo", "Ada!"]
413 );
414 Ok(())
415 }
416
417 #[test]
418 fn a_value_containing_spaces_stays_one_argument() -> TestResult {
419 assert_eq!(
421 render("echo $name", &[("name", serde_json::json!("Ada Lovelace"))])?,
422 ["echo", "Ada Lovelace"]
423 );
424 Ok(())
425 }
426
427 #[test]
428 fn shell_metacharacters_in_a_value_are_inert_literal_text() -> TestResult {
429 for hostile in [
432 "; rm -rf /",
433 "$(rm -rf /)",
434 "`rm -rf /`",
435 "a | b",
436 "a && b",
437 "a > /etc/passwd",
438 "'; DROP TABLE users; --",
439 "$HOME",
440 "\n rm -rf /",
441 ] {
442 assert_eq!(
443 render("echo $value", &[("value", serde_json::json!(hostile))])?,
444 ["echo", hostile],
445 "a value must never be re-parsed: {hostile}"
446 );
447 }
448 Ok(())
449 }
450
451 #[test]
452 fn a_substituted_value_is_never_rescanned_for_parameters() -> TestResult {
453 assert_eq!(
454 render(
455 "echo $outer",
456 &[
457 ("outer", serde_json::json!("$inner")),
458 ("inner", serde_json::json!("substituted twice")),
459 ]
460 )?,
461 ["echo", "$inner"]
462 );
463 Ok(())
464 }
465
466 #[test]
467 fn double_quotes_group_text_and_still_substitute() -> TestResult {
468 assert_eq!(
469 render(
470 "echo \"hello $name\"",
471 &[("name", serde_json::json!("Ada"))]
472 )?,
473 ["echo", "hello Ada"]
474 );
475 Ok(())
476 }
477
478 #[test]
479 fn single_quotes_are_literal_including_dollar_signs() -> TestResult {
480 assert_eq!(render("echo '$name'", &[])?, ["echo", "$name"]);
481 Ok(())
482 }
483
484 #[test]
485 fn an_empty_quoted_word_survives_as_an_empty_argument() -> TestResult {
486 assert_eq!(render("echo '' x", &[])?, ["echo", "", "x"]);
487 Ok(())
488 }
489
490 #[test]
491 fn a_doubled_dollar_is_a_literal_dollar() -> TestResult {
492 assert_eq!(render("echo $$name", &[])?, ["echo", "$name"]);
493 Ok(())
494 }
495
496 #[test]
497 fn numbers_and_booleans_render_without_quotes() -> TestResult {
498 assert_eq!(
499 render(
500 "run $count $flag",
501 &[
502 ("count", serde_json::json!(42)),
503 ("flag", serde_json::json!(true)),
504 ]
505 )?,
506 ["run", "42", "true"]
507 );
508 Ok(())
509 }
510
511 #[test]
512 fn an_empty_command_is_refused() {
513 assert_eq!(CommandTemplate::parse(" "), Err(TemplateError::Empty));
514 }
515
516 #[test]
517 fn an_unterminated_quote_is_refused() {
518 assert_eq!(
519 CommandTemplate::parse("echo 'oops"),
520 Err(TemplateError::UnterminatedQuote { quote: '\'' })
521 );
522 assert_eq!(
523 CommandTemplate::parse("echo \"oops"),
524 Err(TemplateError::UnterminatedQuote { quote: '"' })
525 );
526 }
527
528 #[test]
529 fn an_unterminated_brace_is_refused() {
530 assert_eq!(
531 CommandTemplate::parse("echo ${name"),
532 Err(TemplateError::UnterminatedParameterBrace)
533 );
534 }
535
536 #[test]
537 fn a_dollar_naming_nothing_is_refused() {
538 assert_eq!(
539 CommandTemplate::parse("echo $ x"),
540 Err(TemplateError::EmptyParameterName)
541 );
542 }
543
544 #[test]
545 fn a_parameterized_program_is_refused() {
546 assert_eq!(
548 CommandTemplate::parse("$program arg"),
549 Err(TemplateError::ParameterizedProgram {
550 parameter: "program".to_owned()
551 })
552 );
553 }
554
555 #[test]
556 fn a_missing_parameter_is_refused_by_name() -> TestResult {
557 let Err(error) = CommandTemplate::parse("echo $absent")?.render(&BTreeMap::new()) else {
558 return Err("a missing parameter must be refused, not rendered".into());
559 };
560 assert_eq!(
561 error,
562 SubstitutionError::MissingParameter {
563 parameter: "absent".to_owned()
564 }
565 );
566 Ok(())
567 }
568
569 #[test]
570 fn structured_and_null_values_are_refused_rather_than_guessed() -> TestResult {
571 for (value, kind) in [
572 (serde_json::json!(null), "null"),
573 (serde_json::json!([1, 2]), "an array"),
574 (serde_json::json!({"a": 1}), "an object"),
575 ] {
576 let Err(error) =
577 CommandTemplate::parse("echo $value")?.render(&values(&[("value", value)]))
578 else {
579 return Err(format!("a value that is {kind} must be refused, not guessed").into());
580 };
581 assert_eq!(
582 error,
583 SubstitutionError::UnrepresentableValue {
584 parameter: "value".to_owned(),
585 kind
586 }
587 );
588 }
589 Ok(())
590 }
591
592 #[test]
593 fn an_interior_nul_is_refused_rather_than_truncating_the_argument() -> TestResult {
594 let Err(error) = CommandTemplate::parse("echo $value")?
595 .render(&values(&[("value", serde_json::json!("a\0b"))]))
596 else {
597 return Err("an interior NUL must be refused, not silently truncated".into());
598 };
599 assert_eq!(
600 error,
601 SubstitutionError::InteriorNul {
602 parameter: "value".to_owned()
603 }
604 );
605 Ok(())
606 }
607
608 #[test]
609 fn referenced_parameters_are_reported_sorted_and_deduplicated() -> TestResult {
610 let template = CommandTemplate::parse("run $b $a ${b} literal")?;
611 assert_eq!(template.referenced_parameters(), ["a", "b"]);
612 Ok(())
613 }
614}