Skip to main content

yarp_cli/shell/
mod.rs

1use std::collections::BTreeSet;
2
3use tree_sitter::{Node, Parser};
4
5const MAX_SOURCE_BYTES: usize = 256 * 1024;
6const MAX_AST_NODES: usize = 16_384;
7const MAX_NESTING_DEPTH: usize = 64;
8const MAX_STAGES: usize = 256;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub(crate) enum Connector {
12    Sequence,
13    And,
14    Or,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub(crate) struct ShellProgram {
19    pub(crate) items: Vec<ShellItem>,
20    pub(crate) connectors: Vec<Connector>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub(crate) enum ShellItem {
25    Simple(SimpleCommand),
26    Pipeline(Vec<SimpleCommand>),
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub(crate) struct SimpleCommand {
31    pub(crate) words: Vec<String>,
32    pub(crate) stream_merges: Vec<StreamMerge>,
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub(crate) enum StreamMerge {
37    StderrToStdout,
38    StdoutToStderr,
39}
40
41#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
42pub enum SyntaxFeature {
43    Pipeline,
44    AndChain,
45    OtherRedirection,
46    StreamMerge,
47    Semicolon,
48    Multiline,
49    ParameterExpansion,
50    OrChain,
51    CommandSubstitution,
52    PathInvocation,
53    ControlStructure,
54    UnsupportedWrapper,
55    Comment,
56    Background,
57    NestedShell,
58    SubshellGroup,
59    ShellFunction,
60    ParserFailure,
61}
62
63impl SyntaxFeature {
64    const fn label(self) -> &'static str {
65        match self {
66            Self::Pipeline => "pipeline",
67            Self::AndChain => "and_chain",
68            Self::OtherRedirection => "other_redirection",
69            Self::StreamMerge => "stream_merge",
70            Self::Semicolon => "semicolon",
71            Self::Multiline => "multiline",
72            Self::ParameterExpansion => "parameter_expansion",
73            Self::OrChain => "or_chain",
74            Self::CommandSubstitution => "command_substitution",
75            Self::PathInvocation => "path_invocation",
76            Self::ControlStructure => "control_structure",
77            Self::UnsupportedWrapper => "unsupported_wrapper",
78            Self::Comment => "comment",
79            Self::Background => "background",
80            Self::NestedShell => "nested_shell",
81            Self::SubshellGroup => "subshell_group",
82            Self::ShellFunction => "shell_function",
83            Self::ParserFailure => "parser_failure",
84        }
85    }
86}
87
88#[derive(Clone, Debug, Default, Eq, PartialEq)]
89pub struct SyntaxFeatures {
90    values: BTreeSet<SyntaxFeature>,
91}
92
93impl SyntaxFeatures {
94    #[must_use]
95    pub fn contains(&self, feature: SyntaxFeature) -> bool {
96        self.values.contains(&feature)
97    }
98
99    pub fn labels(&self) -> impl Iterator<Item = &'static str> + '_ {
100        self.values.iter().map(|feature| feature.label())
101    }
102
103    fn insert(&mut self, feature: SyntaxFeature) {
104        self.values.insert(feature);
105    }
106}
107
108/// Inspect fixed public shell syntax features without retaining source values.
109#[must_use]
110pub fn inspect_syntax(source: &str) -> SyntaxFeatures {
111    let mut features = inspect_lexical_syntax(source);
112    if source.len() > MAX_SOURCE_BYTES {
113        features.insert(SyntaxFeature::ParserFailure);
114        return features;
115    }
116    let mut parser = Parser::new();
117    if parser
118        .set_language(&tree_sitter_bash::LANGUAGE.into())
119        .is_err()
120    {
121        features.insert(SyntaxFeature::ParserFailure);
122        return features;
123    }
124    let Some(tree) = parser.parse(source, None) else {
125        features.insert(SyntaxFeature::ParserFailure);
126        return features;
127    };
128    let root = tree.root_node();
129    if root.has_error() {
130        features.insert(SyntaxFeature::ParserFailure);
131    }
132    let mut node_count = 0_usize;
133    inspect_syntax_tree(root, source, &mut features, 0, &mut node_count);
134    features
135}
136
137/// Parse one conservatively supported Bash command without executing or expanding it.
138///
139/// # Errors
140///
141/// Returns an error for unsupported syntax, dynamic words, unsafe redirects, or resource limits.
142pub(crate) fn parse(source: &str) -> Result<ShellProgram, String> {
143    if source.trim().is_empty() {
144        return Err("empty shell source".to_owned());
145    }
146    if source.len() > MAX_SOURCE_BYTES {
147        return Err("shell source exceeds the parser byte limit".to_owned());
148    }
149    let mut parser = Parser::new();
150    parser
151        .set_language(&tree_sitter_bash::LANGUAGE.into())
152        .map_err(|error| format!("could not load Bash grammar: {error}"))?;
153    let tree = parser
154        .parse(source, None)
155        .ok_or_else(|| "Bash parser returned no syntax tree".to_owned())?;
156    let root = tree.root_node();
157    if root.start_byte() != 0 || root.end_byte() != source.len() {
158        return Err("Bash syntax tree does not cover the full source".to_owned());
159    }
160    validate_tree_limits(root)?;
161    if root.has_error() {
162        return Err("Bash syntax tree contains an error or missing node".to_owned());
163    }
164    if root.kind() != "program" {
165        return Err("Bash syntax tree has an unsupported root".to_owned());
166    }
167
168    let mut program = ShellProgram {
169        items: Vec::new(),
170        connectors: Vec::new(),
171    };
172    let mut cursor = root.walk();
173    for child in root.named_children(&mut cursor) {
174        if child.kind() == "comment" {
175            return Err("shell comments are unsupported".to_owned());
176        }
177        let connector = (!program.items.is_empty()).then_some(Connector::Sequence);
178        append_statement(child, source, connector, &mut program)?;
179    }
180    if program.items.is_empty() || program.items.len() > MAX_STAGES {
181        return Err("shell command count is outside the supported range".to_owned());
182    }
183    if program.connectors.len().saturating_add(1) != program.items.len() {
184        return Err("shell connector count is inconsistent".to_owned());
185    }
186    Ok(program)
187}
188
189/// Parse exactly one simple command for pre-execution rewriting.
190///
191/// # Errors
192///
193/// Returns an error when the source is compound or contains stream redirects.
194pub(crate) fn parse_simple_words(source: &str) -> Result<Vec<String>, String> {
195    let program = parse(source)?;
196    let [ShellItem::Simple(command)] = program.items.as_slice() else {
197        return Err("shell source is not one simple command".to_owned());
198    };
199    if !program.connectors.is_empty() || !command.stream_merges.is_empty() {
200        return Err("simple command contains unsupported stream syntax".to_owned());
201    }
202    Ok(command.words.clone())
203}
204
205fn validate_tree_limits(root: Node<'_>) -> Result<(), String> {
206    let mut count = 0_usize;
207    let mut stack = vec![(root, 1_usize)];
208    while let Some((node, depth)) = stack.pop() {
209        count = count.saturating_add(1);
210        if count > MAX_AST_NODES {
211            return Err("Bash syntax tree exceeds the node limit".to_owned());
212        }
213        if depth > MAX_NESTING_DEPTH {
214            return Err("Bash syntax tree exceeds the nesting limit".to_owned());
215        }
216        if node.is_error() || node.is_missing() {
217            return Err("Bash syntax tree contains an error or missing node".to_owned());
218        }
219        if !node.is_named() && node.kind() == "&" {
220            return Err("background shell jobs are unsupported".to_owned());
221        }
222        let mut cursor = node.walk();
223        stack.extend(
224            node.children(&mut cursor)
225                .map(|child| (child, depth.saturating_add(1))),
226        );
227    }
228    Ok(())
229}
230
231fn append_statement(
232    node: Node<'_>,
233    source: &str,
234    preceding: Option<Connector>,
235    output: &mut ShellProgram,
236) -> Result<(), String> {
237    if node.kind() == "list" {
238        let mut cursor = node.walk();
239        let children = node.named_children(&mut cursor).collect::<Vec<_>>();
240        if children.len() != 2 {
241            return Err("shell list has an unsupported shape".to_owned());
242        }
243        let connector = list_connector(node)?;
244        append_statement(children[0], source, preceding, output)?;
245        append_statement(children[1], source, Some(connector), output)?;
246        return Ok(());
247    }
248
249    let item = parse_item(node, source)?;
250    if let Some(connector) = preceding {
251        if output.items.is_empty() {
252            return Err("shell connector has no left command".to_owned());
253        }
254        output.connectors.push(connector);
255    } else if !output.items.is_empty() {
256        return Err("shell command is missing a connector".to_owned());
257    }
258    output.items.push(item);
259    Ok(())
260}
261
262fn list_connector(node: Node<'_>) -> Result<Connector, String> {
263    let mut cursor = node.walk();
264    let operators = node
265        .children(&mut cursor)
266        .filter(|child| !child.is_named())
267        .filter_map(|child| match child.kind() {
268            "&&" => Some(Connector::And),
269            "||" => Some(Connector::Or),
270            _ => None,
271        })
272        .collect::<Vec<_>>();
273    let [connector] = operators.as_slice() else {
274        return Err("shell list has an unsupported connector".to_owned());
275    };
276    Ok(*connector)
277}
278
279fn parse_item(node: Node<'_>, source: &str) -> Result<ShellItem, String> {
280    match node.kind() {
281        "command" | "declaration_command" | "variable_assignment" | "variable_assignments" => {
282            Ok(ShellItem::Simple(parse_simple(node, source)?))
283        }
284        "redirected_statement" => parse_redirected(node, source),
285        "pipeline" => parse_pipeline(node, source),
286        kind => Err(format!("unsupported shell statement: {kind}")),
287    }
288}
289
290fn parse_pipeline(node: Node<'_>, source: &str) -> Result<ShellItem, String> {
291    let mut stage_cursor = node.walk();
292    let stage_nodes = node.named_children(&mut stage_cursor).collect::<Vec<_>>();
293    if stage_nodes.len() < 2 || stage_nodes.len() > MAX_STAGES {
294        return Err("pipeline stage count is outside the supported range".to_owned());
295    }
296    let mut token_cursor = node.walk();
297    if node
298        .children(&mut token_cursor)
299        .any(|child| !child.is_named() && child.kind() != "|")
300    {
301        return Err("pipeline uses an unsupported operator or prefix".to_owned());
302    }
303    let mut stages = Vec::with_capacity(stage_nodes.len());
304    for stage in stage_nodes {
305        let simple = match parse_item(stage, source)? {
306            ShellItem::Simple(simple) => simple,
307            ShellItem::Pipeline(_) => return Err("nested pipelines are unsupported".to_owned()),
308        };
309        stages.push(simple);
310    }
311    Ok(ShellItem::Pipeline(stages))
312}
313
314fn parse_redirected(node: Node<'_>, source: &str) -> Result<ShellItem, String> {
315    let body = node
316        .child_by_field_name("body")
317        .ok_or_else(|| "redirected shell statement has no body".to_owned())?;
318    let ShellItem::Simple(mut command) = parse_item(body, source)? else {
319        return Err("redirects around compound statements are unsupported".to_owned());
320    };
321    let mut cursor = node.walk();
322    for child in node.named_children(&mut cursor) {
323        if child.id() == body.id() {
324            continue;
325        }
326        command
327            .stream_merges
328            .push(parse_stream_merge(child, source)?);
329    }
330    Ok(ShellItem::Simple(command))
331}
332
333fn parse_simple(node: Node<'_>, source: &str) -> Result<SimpleCommand, String> {
334    if matches!(
335        node.kind(),
336        "declaration_command" | "variable_assignment" | "variable_assignments"
337    ) {
338        return Ok(SimpleCommand {
339            words: decode_fragment_words(node_text(node, source)?)?,
340            stream_merges: Vec::new(),
341        });
342    }
343    if node.kind() != "command" {
344        return Err("unsupported simple command node".to_owned());
345    }
346    let mut words = Vec::new();
347    let mut stream_merges = Vec::new();
348    let mut cursor = node.walk();
349    for child in node.named_children(&mut cursor) {
350        match child.kind() {
351            "file_redirect" => stream_merges.push(parse_stream_merge(child, source)?),
352            "herestring_redirect" | "subshell" => {
353                return Err("dynamic command input is unsupported".to_owned());
354            }
355            _ => words.push(decode_one_word(node_text(child, source)?)?),
356        }
357    }
358    if words.is_empty() {
359        return Err("simple command has no literal words".to_owned());
360    }
361    Ok(SimpleCommand {
362        words,
363        stream_merges,
364    })
365}
366
367fn parse_stream_merge(node: Node<'_>, source: &str) -> Result<StreamMerge, String> {
368    if node.kind() != "file_redirect" {
369        return Err("unsupported shell redirection".to_owned());
370    }
371    let compact = node_text(node, source)?
372        .bytes()
373        .filter(|byte| !byte.is_ascii_whitespace())
374        .collect::<Vec<_>>();
375    match compact.as_slice() {
376        b"2>&1" => Ok(StreamMerge::StderrToStdout),
377        b"1>&2" => Ok(StreamMerge::StdoutToStderr),
378        _ => Err("unsupported shell redirection".to_owned()),
379    }
380}
381
382fn node_text<'a>(node: Node<'_>, source: &'a str) -> Result<&'a str, String> {
383    source
384        .get(node.byte_range())
385        .ok_or_else(|| "Bash syntax node is outside the source".to_owned())
386}
387
388fn decode_one_word(source: &str) -> Result<String, String> {
389    let words = decode_fragment_words(source)?;
390    let [word] = words.as_slice() else {
391        return Err("shell word did not decode to one literal argument".to_owned());
392    };
393    Ok(word.clone())
394}
395
396fn decode_fragment_words(source: &str) -> Result<Vec<String>, String> {
397    #[derive(Clone, Copy, Eq, PartialEq)]
398    enum Quote {
399        None,
400        Single,
401        Double,
402    }
403
404    let mut words = Vec::new();
405    let mut word = String::new();
406    let mut started = false;
407    let mut quote = Quote::None;
408    let mut characters = source.chars();
409    while let Some(character) = characters.next() {
410        match quote {
411            Quote::None => match character {
412                '\'' => {
413                    quote = Quote::Single;
414                    started = true;
415                }
416                '"' => {
417                    quote = Quote::Double;
418                    started = true;
419                }
420                '\\' => {
421                    let escaped = characters
422                        .next()
423                        .ok_or_else(|| "shell word has a trailing escape".to_owned())?;
424                    if matches!(escaped, '\n' | '\r') {
425                        return Err("escaped newlines are unsupported".to_owned());
426                    }
427                    word.push(escaped);
428                    started = true;
429                }
430                '*' | '?' | '[' if word.is_empty() || word.starts_with('-') => {
431                    return Err("shell word contains unsafe pathname expansion".to_owned());
432                }
433                '{' | '}' => return Err("shell brace expansion is unsupported".to_owned()),
434                '\n' | '\r' | '|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' | '$' | '#' => {
435                    return Err("shell word contains dynamic or control syntax".to_owned());
436                }
437                value if value.is_whitespace() => finish_word(&mut words, &mut word, &mut started),
438                _ => {
439                    word.push(character);
440                    started = true;
441                }
442            },
443            Quote::Single => {
444                if character == '\'' {
445                    quote = Quote::None;
446                } else {
447                    word.push(character);
448                }
449            }
450            Quote::Double => match character {
451                '"' => quote = Quote::None,
452                '\\' => {
453                    let escaped = characters
454                        .next()
455                        .ok_or_else(|| "shell word has a trailing escape".to_owned())?;
456                    if matches!(escaped, '\n' | '\r') {
457                        return Err("escaped newlines are unsupported".to_owned());
458                    }
459                    word.push(escaped);
460                }
461                '`' | '$' => return Err("shell expansion is unsupported".to_owned()),
462                _ => word.push(character),
463            },
464        }
465    }
466    if quote != Quote::None {
467        return Err("shell word has an unterminated quote".to_owned());
468    }
469    finish_word(&mut words, &mut word, &mut started);
470    if words.is_empty() {
471        return Err("shell fragment has no literal words".to_owned());
472    }
473    Ok(words)
474}
475
476fn finish_word(words: &mut Vec<String>, word: &mut String, started: &mut bool) {
477    if *started {
478        words.push(std::mem::take(word));
479        *started = false;
480    }
481}
482
483fn inspect_syntax_tree(
484    node: Node<'_>,
485    source: &str,
486    features: &mut SyntaxFeatures,
487    depth: usize,
488    node_count: &mut usize,
489) {
490    *node_count = node_count.saturating_add(1);
491    if depth > MAX_NESTING_DEPTH || *node_count > MAX_AST_NODES {
492        features.insert(SyntaxFeature::ParserFailure);
493        return;
494    }
495    match node.kind() {
496        "for_statement"
497        | "c_style_for_statement"
498        | "while_statement"
499        | "if_statement"
500        | "case_statement" => features.insert(SyntaxFeature::ControlStructure),
501        "function_definition" => features.insert(SyntaxFeature::ShellFunction),
502        "subshell" => features.insert(SyntaxFeature::SubshellGroup),
503        "comment" => features.insert(SyntaxFeature::Comment),
504        "file_redirect" => {
505            if parse_stream_merge(node, source).is_ok() {
506                features.insert(SyntaxFeature::StreamMerge);
507            } else {
508                features.insert(SyntaxFeature::OtherRedirection);
509            }
510        }
511        "herestring_redirect" | "heredoc_redirect" | "process_substitution" => {
512            features.insert(SyntaxFeature::OtherRedirection);
513        }
514        "simple_expansion" | "expansion" => features.insert(SyntaxFeature::ParameterExpansion),
515        "command_substitution" | "arithmetic_expansion" => {
516            features.insert(SyntaxFeature::CommandSubstitution);
517        }
518        "command" => inspect_command_name(node, source, features),
519        _ => {}
520    }
521    let mut cursor = node.walk();
522    for child in node.children(&mut cursor) {
523        inspect_syntax_tree(child, source, features, depth + 1, node_count);
524    }
525}
526
527fn inspect_command_name(node: Node<'_>, source: &str, features: &mut SyntaxFeatures) {
528    let Some(name) = node.child_by_field_name("name") else {
529        return;
530    };
531    let Ok(name) = node_text(name, source).and_then(decode_one_word) else {
532        return;
533    };
534    if name.contains('/') {
535        features.insert(SyntaxFeature::PathInvocation);
536    }
537    let base = name.rsplit('/').next().unwrap_or(&name);
538    if matches!(
539        base,
540        "sudo" | "doas" | "nice" | "nohup" | "stdbuf" | "unbuffer" | "xargs" | "parallel" | "watch"
541    ) {
542        features.insert(SyntaxFeature::UnsupportedWrapper);
543    }
544    if matches!(base, "bash" | "sh" | "zsh" | "fish") {
545        features.insert(SyntaxFeature::NestedShell);
546    }
547}
548
549#[derive(Clone, Copy, Eq, PartialEq)]
550enum LexicalQuote {
551    None,
552    Single,
553    Double,
554}
555
556struct LexicalScanner<'a> {
557    bytes: &'a [u8],
558    features: SyntaxFeatures,
559    index: usize,
560    quote: LexicalQuote,
561    escaped: bool,
562    comment: bool,
563    boundary: bool,
564}
565
566impl<'a> LexicalScanner<'a> {
567    fn new(source: &'a str) -> Self {
568        Self {
569            bytes: source.as_bytes(),
570            features: SyntaxFeatures::default(),
571            index: 0,
572            quote: LexicalQuote::None,
573            escaped: false,
574            comment: false,
575            boundary: true,
576        }
577    }
578
579    fn finish(mut self) -> SyntaxFeatures {
580        while self.index < self.bytes.len() {
581            self.advance();
582        }
583        self.features
584    }
585
586    fn advance(&mut self) {
587        let byte = self.bytes[self.index];
588        if self.comment {
589            if matches!(byte, b'\n' | b'\r') {
590                self.comment = false;
591                self.features.insert(SyntaxFeature::Multiline);
592                self.boundary = true;
593            }
594            self.index += 1;
595            return;
596        }
597        if self.escaped {
598            self.escaped = false;
599            self.boundary = false;
600            self.index += 1;
601            return;
602        }
603        match self.quote {
604            LexicalQuote::Single => {
605                if byte == b'\'' {
606                    self.quote = LexicalQuote::None;
607                }
608            }
609            LexicalQuote::Double => self.observe_double_quoted(byte),
610            LexicalQuote::None => self.observe_unquoted(byte),
611        }
612        self.index += 1;
613    }
614
615    fn observe_double_quoted(&mut self, byte: u8) {
616        match byte {
617            b'\\' => self.escaped = true,
618            b'"' => self.quote = LexicalQuote::None,
619            b'`' => self.features.insert(SyntaxFeature::CommandSubstitution),
620            b'$' if self.bytes.get(self.index + 1) == Some(&b'(') => {
621                self.features.insert(SyntaxFeature::CommandSubstitution);
622            }
623            b'$' => self.features.insert(SyntaxFeature::ParameterExpansion),
624            b'\n' | b'\r' => self.features.insert(SyntaxFeature::Multiline),
625            _ => {}
626        }
627    }
628
629    fn observe_unquoted(&mut self, byte: u8) {
630        match byte {
631            b'\\' => self.escaped = true,
632            b'\'' => self.quote = LexicalQuote::Single,
633            b'"' => self.quote = LexicalQuote::Double,
634            b'#' if self.boundary => {
635                self.features.insert(SyntaxFeature::Comment);
636                self.comment = true;
637            }
638            b'\n' | b'\r' => self.features.insert(SyntaxFeature::Multiline),
639            b'`' => self.features.insert(SyntaxFeature::CommandSubstitution),
640            b'$' if self.bytes.get(self.index + 1) == Some(&b'(') => {
641                self.features.insert(SyntaxFeature::CommandSubstitution);
642            }
643            b'$' => self.features.insert(SyntaxFeature::ParameterExpansion),
644            b'|' if self.bytes.get(self.index + 1) == Some(&b'|') => {
645                self.features.insert(SyntaxFeature::OrChain);
646                self.index += 1;
647            }
648            b'|' => {
649                self.features.insert(SyntaxFeature::Pipeline);
650                if self.bytes.get(self.index + 1) == Some(&b'&') {
651                    self.index += 1;
652                }
653            }
654            b'&' if self.is_redirect_ampersand() => {}
655            b'&' if self.bytes.get(self.index + 1) == Some(&b'&') => {
656                self.features.insert(SyntaxFeature::AndChain);
657                self.index += 1;
658            }
659            b'&' => self.features.insert(SyntaxFeature::Background),
660            b';' => self.features.insert(SyntaxFeature::Semicolon),
661            b'<' | b'>' if !self.is_safe_stream_merge() => {
662                self.features.insert(SyntaxFeature::OtherRedirection);
663            }
664            _ => {}
665        }
666        self.boundary = byte.is_ascii_whitespace()
667            || matches!(byte, b';' | b'&' | b'|' | b'(' | b')' | b'<' | b'>');
668    }
669
670    fn is_redirect_ampersand(&self) -> bool {
671        self.bytes.get(self.index.saturating_sub(1)) == Some(&b'>')
672            || self.bytes.get(self.index + 1) == Some(&b'>')
673    }
674
675    fn is_safe_stream_merge(&self) -> bool {
676        let start = self.index.saturating_sub(1);
677        let end = self.index.saturating_add(3).min(self.bytes.len());
678        matches!(&self.bytes[start..end], b"2>&1" | b"1>&2")
679    }
680}
681
682fn inspect_lexical_syntax(source: &str) -> SyntaxFeatures {
683    LexicalScanner::new(source).finish()
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    fn parses_simple_commands_and_literal_quotes() {
692        assert_eq!(
693            parse_simple_words("git diff -- 'file name'").expect("simple"),
694            ["git", "diff", "--", "file name"]
695        );
696        assert_eq!(
697            parse_simple_words("GOWORK=off go test ./...").expect("assignment"),
698            ["GOWORK=off", "go", "test", "./..."]
699        );
700        assert_eq!(
701            parse_simple_words(r"printf escaped\|operator").expect("escaped operator"),
702            ["printf", "escaped|operator"]
703        );
704        assert_eq!(
705            parse_simple_words("echo src/*.rs ~/private").expect("safe path expansion"),
706            ["echo", "src/*.rs", "~/private"]
707        );
708    }
709
710    #[test]
711    fn parses_lists_and_pipelines_without_expanding_them() {
712        let program = parse("cd repo && rg TODO . | sort | head -50; true").expect("program");
713        assert_eq!(program.items.len(), 3);
714        assert_eq!(program.connectors, [Connector::And, Connector::Sequence]);
715        let ShellItem::Pipeline(stages) = &program.items[1] else {
716            panic!("pipeline");
717        };
718        assert_eq!(stages.len(), 3);
719        assert_eq!(stages[0].words, ["rg", "TODO", "."]);
720        assert_eq!(stages[2].words, ["head", "-50"]);
721    }
722
723    #[test]
724    fn accepts_only_reviewed_stream_merges() {
725        let program = parse("cargo test 2>&1 || true").expect("stream merge");
726        let ShellItem::Simple(command) = &program.items[0] else {
727            panic!("simple");
728        };
729        assert_eq!(command.stream_merges, [StreamMerge::StderrToStdout]);
730        let features = inspect_syntax("cargo test 2>&1");
731        assert!(!features.contains(SyntaxFeature::Background));
732        assert!(!features.contains(SyntaxFeature::OtherRedirection));
733        assert!(features.contains(SyntaxFeature::StreamMerge));
734        assert!(parse("cargo test > result.log").is_err());
735        assert!(parse("cargo test |& head").is_err());
736    }
737
738    #[test]
739    fn reports_fixed_syntax_features_without_source_values() {
740        let features = inspect_syntax(
741            "cd /repo && /usr/bin/rg $TERM . | head -50; printf '%s' \"$(pwd)\" > result &",
742        );
743        assert!(features.contains(SyntaxFeature::Pipeline));
744        assert!(features.contains(SyntaxFeature::AndChain));
745        assert!(features.contains(SyntaxFeature::Semicolon));
746        assert!(features.contains(SyntaxFeature::ParameterExpansion));
747        assert!(features.contains(SyntaxFeature::CommandSubstitution));
748        assert!(features.contains(SyntaxFeature::OtherRedirection));
749        assert!(features.contains(SyntaxFeature::Background));
750        assert!(features.contains(SyntaxFeature::PathInvocation));
751        assert!(!features.labels().any(|label| label.contains("repo")));
752    }
753
754    #[test]
755    fn rejects_dynamic_or_unbounded_shell_forms() {
756        for source in [
757            "echo $HOME",
758            "echo $(pwd)",
759            "echo `pwd`",
760            "echo *.rs",
761            "echo --j*",
762            "echo {one,two}",
763            "echo value # comment",
764            "cargo test &",
765            "cargo test | head &",
766            "(cargo test)",
767            "for x in a; do echo $x; done",
768            "cat <<EOF\nvalue\nEOF",
769            "cargo test |",
770            "cargo test &&",
771            "! cargo test | head -100",
772            "((((((((((((((((((((cargo test))))))))))))))))))))",
773        ] {
774            assert!(parse(source).is_err(), "accepted {source:?}");
775        }
776        let oversized = "x".repeat(MAX_SOURCE_BYTES + 1);
777        assert!(parse(&oversized).is_err());
778        assert!(inspect_syntax(&oversized).contains(SyntaxFeature::ParserFailure));
779        let too_many_stages = std::iter::repeat_n("cat", MAX_STAGES + 1)
780            .collect::<Vec<_>>()
781            .join(" | ");
782        assert!(parse(&too_many_stages).is_err());
783    }
784
785    #[test]
786    fn arbitrary_parse_failures_are_pure_and_fail_open_without_mutation() {
787        let alphabet = b"abc $'\"\\|&;()<>#\n`";
788        let mut state = 0x6a09_e667_f3bc_c909_u64;
789        for length in 0..128 {
790            let mut source = String::with_capacity(length);
791            for _ in 0..length {
792                state ^= state << 13;
793                state ^= state >> 7;
794                state ^= state << 17;
795                let modulus = u64::try_from(alphabet.len()).expect("alphabet length");
796                let index = usize::try_from(state % modulus).expect("alphabet index");
797                source.push(char::from(alphabet[index]));
798            }
799            let before = source.clone();
800            let _ = parse(&source);
801            assert_eq!(source, before);
802        }
803    }
804}