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//! That structure is the security property, not a convenience. A parameter
9//! value never re-enters a parser, so it cannot introduce a word boundary, a
10//! quote, a pipe, a redirect, a `;`, or a `$(…)`. A value of `; rm -rf /` is
11//! passed to the program as the single literal argument `; rm -rf /`. There is
12//! no shell between the declaration and `execve`, so there is nothing for a
13//! value to escape into. Splicing values into a command STRING and handing it
14//! to `sh -c` would make every parameter an injection site; this crate never
15//! does that.
16
17use std::collections::BTreeMap;
18use std::fmt::Write as _;
19
20use thiserror::Error;
21
22/// A declared command could not be parsed into an executable argv.
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum TemplateError {
25    /// The command was empty or contained only whitespace, so it names no
26    /// program to run.
27    #[error("the declared command is empty; an action body must name a program to run")]
28    Empty,
29    /// A quote was opened and never closed.
30    #[error("the declared command has an unterminated {quote} quote")]
31    UnterminatedQuote {
32        /// The quote character that was opened and never closed.
33        quote: char,
34    },
35    /// A `$` introduced no parameter name.
36    #[error("`$` must be followed by a parameter name in the declared command")]
37    EmptyParameterName,
38    /// A `${` was opened and never closed.
39    #[error("`${{` is missing its closing `}}` in the declared command")]
40    UnterminatedParameterBrace,
41    /// The command's program name is itself a parameter reference.
42    ///
43    /// Refused because it would let a caller-supplied value choose which
44    /// program executes.
45    #[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        /// The parameter that was found in program position.
51        parameter: String,
52    },
53}
54
55/// A value supplied for a parameter could not be rendered into an argument.
56#[derive(Debug, Error, PartialEq, Eq)]
57pub enum SubstitutionError {
58    /// The command references a parameter that the call did not supply.
59    #[error("the declared command references parameter `{parameter}`, which was not supplied")]
60    MissingParameter {
61        /// The referenced parameter name.
62        parameter: String,
63    },
64    /// A supplied value has no single unambiguous argument form.
65    #[error(
66        "parameter `{parameter}` is {kind}, which has no unambiguous command-argument form; \
67         supply a string, number, or boolean"
68    )]
69    UnrepresentableValue {
70        /// The referenced parameter name.
71        parameter: String,
72        /// The JSON kind that was supplied.
73        kind: &'static str,
74    },
75    /// A supplied value contains a NUL byte.
76    ///
77    /// Refused rather than truncated: an argument is delivered to the kernel
78    /// as a NUL-terminated string, so a NUL inside a value would silently cut
79    /// the argument short and change what the program receives.
80    #[error(
81        "parameter `{parameter}` contains a NUL byte, which cannot appear in a command argument"
82    )]
83    InteriorNul {
84        /// The referenced parameter name.
85        parameter: String,
86    },
87}
88
89/// One piece of a single argv element.
90#[derive(Debug, Clone, PartialEq, Eq)]
91enum Piece {
92    /// Literal text, used exactly as written.
93    Literal(String),
94    /// A parameter reference, replaced by that parameter's value.
95    Parameter(String),
96}
97
98/// One argv element: its pieces are concatenated to produce exactly one
99/// argument, however many parameters it references.
100#[derive(Debug, Clone, PartialEq, Eq)]
101struct Word {
102    pieces: Vec<Piece>,
103}
104
105impl Word {
106    /// Whether this word is a single bare parameter reference.
107    fn sole_parameter(&self) -> Option<&str> {
108        match self.pieces.as_slice() {
109            [Piece::Parameter(name)] => Some(name),
110            _ => None,
111        }
112    }
113
114    /// Render this word into one argument using `values`.
115    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
138/// Append `value`'s argument form to `out`, refusing anything ambiguous.
139fn 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            // `write!` to a String is infallible; the result is consumed so no
160            // formatting error can be silently dropped.
161            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        // A null, array, or object has no single obvious argument spelling.
169        // Guessing one (empty string? JSON text? space-joined?) would make the
170        // command mean something the author never wrote, so each is refused by
171        // name instead.
172        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/// A declared command parsed into an executable argv with named holes.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct CommandTemplate {
181    program: Word,
182    arguments: Vec<Word>,
183}
184
185impl CommandTemplate {
186    /// Parse a declared command into a program and its arguments.
187    ///
188    /// Words are split on unquoted whitespace. Single quotes take their
189    /// contents literally, including `$`. Double quotes group text into one
190    /// word while still substituting parameters. A parameter is written `$name`
191    /// or `${name}`; `$$` is a literal `$`.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`TemplateError`] when the command is empty, a quote or `${` is
196    /// unterminated, a `$` names no parameter, or the program position is a
197    /// parameter reference.
198    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        // Close the literal run in progress, if any, into `pieces`.
206        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                    // Single quotes are literal throughout: no substitution.
227                    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    /// The parameter names this command references, in sorted order.
285    #[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    /// Render the full argv, substituting `values` for parameter references.
301    ///
302    /// The first element is the program; the rest are its arguments. Each
303    /// element is one whole argument regardless of what a value contains.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`SubstitutionError`] when a referenced parameter is absent or
308    /// its value has no unambiguous argument form.
309    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
322/// Consume a parameter reference that follows a `$`.
323///
324/// `$$` is a literal `$` and is pushed onto `literal` rather than becoming a
325/// reference.
326fn 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    /// What a test returns. Every fallible step is carried rather than
376    /// unwrapped, because the workspace denies panicking accessors in test
377    /// code as firmly as in library code.
378    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        // The whole point: no word splitting after substitution.
420        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        // A value can never introduce a command, a pipe, or a redirect: it is
430        // handed to the program as one argument and never re-parsed.
431        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        // A caller-supplied value must never choose which program runs.
547        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}