Skip to main content

aion_worker/shell/
template.rs

1//! The parsed form of a declared command and its parameter substitution.
2//!
3//! A declared command is parsed ONCE, at registration, into a fixed argv
4//! whose elements are either literal text or a named parameter reference.
5//! Substitution then replaces a reference with a parameter's value **as one
6//! whole argv element**.
7//!
8//! A parameter is written `{{name}}`, in one syntax, read the same way in
9//! every quoting region. There is no second spelling and no escape, because
10//! `$` carries no meaning here for an escape to protect.
11//!
12//! That structure is the security property, not a convenience. A parameter
13//! value never re-enters a parser, so it cannot introduce a word boundary, a
14//! quote, a pipe, a redirect, a `;`, or a `$(…)`. A value of `; rm -rf /` is
15//! passed to the program as the single literal argument `; rm -rf /`. There is
16//! no shell between the declaration and `execve`, so there is nothing for a
17//! value to escape into. Splicing values into a command STRING and handing it
18//! to `sh -c` would make every parameter an injection site; this crate never
19//! does that.
20
21use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use thiserror::Error;
25
26/// A declared command could not be parsed into an executable argv.
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum TemplateError {
29    /// The command was empty or contained only whitespace, so it names no
30    /// program to run.
31    #[error("the declared command is empty; an action body must name a program to run")]
32    Empty,
33    /// A quote was opened and never closed.
34    #[error("the declared command has an unterminated {quote} quote")]
35    UnterminatedQuote {
36        /// The quote character that was opened and never closed.
37        quote: char,
38    },
39    /// A `{{}}` interpolated nothing.
40    #[error("`{{{{}}}}` interpolates nothing; write `{{{{name}}}}` around one declared parameter")]
41    EmptyParameterName,
42    /// A `{{` was opened and never closed.
43    #[error("`{{{{` is missing its closing `}}}}` in the declared command")]
44    UnterminatedParameterBrace,
45    /// The command's program name is itself a parameter reference.
46    ///
47    /// Refused because it would let a caller-supplied value choose which
48    /// program executes.
49    #[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        /// The parameter that was found in program position.
55        parameter: String,
56    },
57}
58
59/// A value supplied for a parameter could not be rendered into an argument.
60#[derive(Debug, Error, PartialEq, Eq)]
61pub enum SubstitutionError {
62    /// The command references a parameter that the call did not supply.
63    #[error("the declared command references parameter `{parameter}`, which was not supplied")]
64    MissingParameter {
65        /// The referenced parameter name.
66        parameter: String,
67    },
68    /// A supplied value has no single unambiguous argument form.
69    #[error(
70        "parameter `{parameter}` is {kind}, which has no unambiguous command-argument form; \
71         supply a string, number, or boolean"
72    )]
73    UnrepresentableValue {
74        /// The referenced parameter name.
75        parameter: String,
76        /// The JSON kind that was supplied.
77        kind: &'static str,
78    },
79    /// A supplied value contains a NUL byte.
80    ///
81    /// Refused rather than truncated: an argument is delivered to the kernel
82    /// as a NUL-terminated string, so a NUL inside a value would silently cut
83    /// the argument short and change what the program receives.
84    #[error(
85        "parameter `{parameter}` contains a NUL byte, which cannot appear in a command argument"
86    )]
87    InteriorNul {
88        /// The referenced parameter name.
89        parameter: String,
90    },
91}
92
93/// One piece of a single argv element.
94#[derive(Debug, Clone, PartialEq, Eq)]
95enum Piece {
96    /// Literal text, used exactly as written.
97    Literal(String),
98    /// A parameter reference, replaced by that parameter's value.
99    Parameter(String),
100}
101
102/// One argv element: its pieces are concatenated to produce exactly one
103/// argument, however many parameters it references.
104#[derive(Debug, Clone, PartialEq, Eq)]
105struct Word {
106    pieces: Vec<Piece>,
107}
108
109impl Word {
110    /// Whether this word is a single bare parameter reference.
111    fn sole_parameter(&self) -> Option<&str> {
112        match self.pieces.as_slice() {
113            [Piece::Parameter(name)] => Some(name),
114            _ => None,
115        }
116    }
117
118    /// Render this word into one argument using `values`.
119    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
142/// Append `value`'s argument form to `out`, refusing anything ambiguous.
143fn 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            // `write!` to a String is infallible; the result is consumed so no
164            // formatting error can be silently dropped.
165            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        // A null, array, or object has no single obvious argument spelling.
173        // Guessing one (empty string? JSON text? space-joined?) would make the
174        // command mean something the author never wrote, so each is refused by
175        // name instead.
176        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/// A declared command parsed into an executable argv with named holes.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct CommandTemplate {
185    program: Word,
186    arguments: Vec<Word>,
187}
188
189impl CommandTemplate {
190    /// Parse a declared command into a program and its arguments.
191    ///
192    /// Words are split on unquoted whitespace. Single and double quotes both
193    /// group text into one word. A parameter is written `{{name}}`, and it is
194    /// read in EVERY region — a quote groups, it never changes what an
195    /// interpolation means. `$` is an ordinary character with no meaning of
196    /// its own.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`TemplateError`] when the command is empty, a quote or `{{`
201    /// is unterminated, an interpolation names nothing, or the program
202    /// position is a parameter reference.
203    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        // Close the literal run in progress, if any, into `pieces`.
211        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        // Which quote, if any, is open. A quote decides word GROUPING and
220        // nothing else: an interpolation means the same thing inside one as
221        // outside, which is what removes the trap where `\'{{x}}\'` and
222        // `"{{x}}"` looked alike and meant different things.
223        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    /// The parameter names this command references, in sorted order.
281    #[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    /// Render the full argv, substituting `values` for parameter references.
297    ///
298    /// The first element is the program; the rest are its arguments. Each
299    /// element is one whole argument regardless of what a value contains.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`SubstitutionError`] when a referenced parameter is absent or
304    /// its value has no unambiguous argument form.
305    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
318/// The opening delimiter of an interpolation.
319const HOLE_OPEN: &str = "{{";
320/// The closing delimiter of an interpolation.
321const HOLE_CLOSE: &str = "}}";
322
323/// Consume a `{{name}}` interpolation. The cursor stands on the `{{`.
324fn 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    //! `{{name}}` is the one interpolation this executor knows, and these pin
349    //! that it means the same thing in every quoting region. `$` carries no
350    //! meaning at all on this side — the compiler only ever hands the executor
351    //! the canonical spelling, so a `$` that reaches here is a literal `$` the
352    //! author wrote and nothing else.
353
354    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    /// What a test returns. Every fallible step is carried rather than
365    /// unwrapped, because the workspace denies panicking accessors in test
366    /// code as firmly as in library code.
367    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        // The whole point: no word splitting after substitution.
418        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        // A value can never introduce a command, a pipe, or a redirect: it is
431        // handed to the program as one argument and never re-parsed.
432        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        // THE TRAP THAT IS GONE. `'…'` used to be literal for the retired `$`
482        // spelling while `"…"` substituted, the two looked identical in an
483        // editor, and getting it backwards produced a vacuous success rather
484        // than a failure. A quote groups; it never decides what an
485        // interpolation means.
486        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        // There is no shell here and no second spelling, so nothing on this
499        // side reads a `$`: it reaches the program exactly as written,
500        // quoted or not.
501        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        // Only the doubled brace opens an interpolation, so an `awk` program
514        // or a shell brace expansion passes through untouched.
515        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        // A caller-supplied value must never choose which program runs.
586        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}