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#[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
137pub(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
189pub(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}