1use super::*;
2use super::budget::{self, DepthGuard};
3use winnow::ModalResult;
4use winnow::combinator::{alt, delimited, not, opt, preceded, repeat, separated, terminated};
5use winnow::error::{ContextError, ErrMode};
6use winnow::prelude::*;
7use winnow::token::{any, take_while};
8
9pub fn parse(input: &str) -> Option<Script> {
10 reset_heredoc_queue();
11 budget::reset(input.len());
12 let result = script.parse(input).ok().filter(|_| !budget::spent());
13 reset_heredoc_queue();
14 result
15}
16
17fn backtrack<T>() -> ModalResult<T> {
18 Err(ErrMode::Backtrack(ContextError::new()))
19}
20
21fn step() -> ModalResult<()> {
23 if budget::charge(1, 0) { Ok(()) } else { backtrack() }
24}
25
26fn scan(bytes: usize) -> ModalResult<()> {
28 if budget::charge(0, bytes) { Ok(()) } else { backtrack() }
29}
30
31fn consumed<'a, O>(
33 input: &mut &'a str,
34 p: impl FnOnce(&mut &'a str) -> ModalResult<O>,
35) -> ModalResult<O> {
36 let before = input.len();
37 let out = p(input);
38 scan(before - input.len())?;
39 out
40}
41
42fn comment(input: &mut &str) -> ModalResult<()> {
43 if input.starts_with('#') {
44 if let Some(pos) = input.find('\n') {
45 *input = &input[pos + 1..];
46 } else {
47 *input = "";
48 }
49 }
50 Ok(())
51}
52
53fn ws(input: &mut &str) -> ModalResult<()> {
54 consumed(input, ws_raw)
55}
56
57fn ws_raw(input: &mut &str) -> ModalResult<()> {
58 loop {
59 take_while(0.., [' ', '\t']).void().parse_next(input)?;
60 if input.starts_with('#') {
61 comment(input)?;
62 } else {
63 break;
64 }
65 }
66 Ok(())
67}
68
69fn sep(input: &mut &str) -> ModalResult<()> {
70 consumed(input, sep_raw)
71}
72
73fn sep_raw(input: &mut &str) -> ModalResult<()> {
74 loop {
75 while let Some(c) = input.chars().next() {
78 if input.starts_with(";;") {
79 return Ok(());
80 }
81 if !matches!(c, ' ' | '\t' | ';' | '\n') {
82 break;
83 }
84 *input = &input[c.len_utf8()..];
85 }
86 if input.starts_with('#') {
87 comment(input)?;
88 } else {
89 break;
90 }
91 }
92 Ok(())
93}
94
95fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
96 if !input.starts_with(kw) {
97 return backtrack();
98 }
99 if input
100 .as_bytes()
101 .get(kw.len())
102 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
103 {
104 return backtrack();
105 }
106 *input = &input[kw.len()..];
107 Ok(())
108}
109
110const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "esac", "fi", "then"];
111
112fn at_script_stop(input: &str) -> bool {
113 input.starts_with(')')
114 || input.starts_with('}')
115 || input.starts_with(";;")
118 || SCRIPT_STOPS.iter().any(|kw| {
119 input.starts_with(kw)
120 && !input
121 .as_bytes()
122 .get(kw.len())
123 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
124 })
125}
126
127fn is_word_boundary(c: char) -> bool {
128 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
129}
130
131fn is_word_literal(c: char) -> bool {
132 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
133}
134
135fn is_dq_literal(c: char) -> bool {
136 !matches!(c, '"' | '\\' | '`' | '$')
137}
138
139fn script(input: &mut &str) -> ModalResult<Script> {
142 let Some(_depth) = DepthGuard::enter() else {
145 return backtrack();
146 };
147 sep.parse_next(input)?;
148 let mut stmts = Vec::new();
149 while let Some(pl) = opt(pipeline).parse_next(input)? {
150 ws.parse_next(input)?;
151 let op = opt(list_op).parse_next(input)?;
152 stmts.push(Stmt { pipeline: pl, op });
153 drain_pending_heredocs(input);
158 if op.is_none() {
159 break;
160 }
161 sep.parse_next(input)?;
162 }
163 Ok(Script(stmts))
164}
165
166fn list_op(input: &mut &str) -> ModalResult<ListOp> {
167 ws.parse_next(input)?;
168 alt((
169 "&&".value(ListOp::And),
170 "||".value(ListOp::Or),
171 '\n'.value(ListOp::Semi),
172 (';', not(';')).value(ListOp::Semi),
175 ('&', not('>')).value(ListOp::Amp),
176 ))
177 .parse_next(input)
178}
179
180fn pipe_sep(input: &mut &str) -> ModalResult<()> {
184 (ws, alt(("|&".void(), ('|', not('|')).void())), ws).void().parse_next(input)
185}
186
187fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
190 ws.parse_next(input)?;
191 if at_script_stop(input) {
192 return backtrack();
193 }
194 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
195 opt_time_keyword_before_compound(input);
196 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
197 Ok(Pipeline { bang, commands })
198}
199
200fn opt_time_keyword_before_compound(input: &mut &str) -> bool {
223 let mut probe = *input;
224 if eat_keyword(&mut probe, "time").is_err() || !probe.starts_with([' ', '\t', '\n']) {
225 return false;
226 }
227 if ws.parse_next(&mut probe).is_err() {
228 return false;
229 }
230 if probe.starts_with("-p") {
232 let mut with_flag = &probe[2..];
233 if with_flag.starts_with([' ', '\t', '\n']) && ws.parse_next(&mut with_flag).is_ok() {
234 probe = with_flag;
235 }
236 }
237 if !probe.starts_with(['(', '{']) {
238 return false;
239 }
240 *input = probe;
241 true
242}
243
244fn command(input: &mut &str) -> ModalResult<Cmd> {
247 step()?;
248 ws.parse_next(input)?;
249 if at_script_stop(input) {
250 return backtrack();
251 }
252 let committed = super::reserved::opens_compound(input);
253 alt((
254 subshell, brace_group, for_cmd, while_cmd, until_cmd, if_cmd, case_cmd, double_bracket_cmd,
255 function_def,
256 move |i: &mut &str| {
257 if committed { backtrack() } else { simple_cmd.map(Cmd::Simple).parse_next(i) }
258 },
259 ))
260 .parse_next(input)
261}
262
263fn function_def(input: &mut &str) -> ModalResult<Cmd> {
267 let had_keyword = opt_function_keyword(input);
268 let name = function_name(input)?;
269 ws.parse_next(input)?;
270 let has_parens = opt_paren_pair(input);
271 if !had_keyword && !has_parens {
272 return backtrack();
273 }
274 blank(input)?;
276 let body = function_body(input)?;
277 Ok(Cmd::FunctionDef { name, body })
278}
279
280fn opt_function_keyword(input: &mut &str) -> bool {
283 let mut probe = *input;
284 if eat_keyword(&mut probe, "function").is_ok()
285 && probe.starts_with([' ', '\t', '\n'])
286 && ws.parse_next(&mut probe).is_ok()
287 {
288 *input = probe;
289 return true;
290 }
291 false
292}
293
294fn opt_paren_pair(input: &mut &str) -> bool {
296 let mut probe = *input;
297 if let Some(rest) = probe.strip_prefix('(') {
298 probe = rest;
299 if ws.parse_next(&mut probe).is_ok()
300 && let Some(rest) = probe.strip_prefix(')')
301 {
302 *input = rest;
303 return true;
304 }
305 }
306 false
307}
308
309fn function_name(input: &mut &str) -> ModalResult<String> {
310 run(input, |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+')).map(String::from)
311}
312
313fn run<'a>(input: &mut &'a str, pred: fn(char) -> bool) -> ModalResult<&'a str> {
315 consumed(input, |i| take_while(1.., pred).parse_next(i))
316}
317
318fn function_body(input: &mut &str) -> ModalResult<Script> {
321 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
322 return Ok(body);
323 }
324 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
325 return Ok(body);
326 }
327 backtrack()
328}
329
330fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
331 let mut redirs = Vec::new();
332 loop {
333 ws.parse_next(input)?;
334 if let Some(r) = opt(redirect).parse_next(input)? {
335 redirs.push(r);
336 } else {
337 break;
338 }
339 }
340 Ok(redirs)
341}
342
343fn subshell(input: &mut &str) -> ModalResult<Cmd> {
344 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
345 let redirs = trailing_redirs(input)?;
346 Ok(Cmd::Subshell { body, redirs })
347}
348
349fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
350 if !input.starts_with('{') {
351 return backtrack();
352 }
353 if !input
354 .as_bytes()
355 .get(1)
356 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
357 {
358 return backtrack();
359 }
360 *input = &input[1..];
361 sep.parse_next(input)?;
362 let body = script.parse_next(input)?;
363 if body.0.is_empty() {
364 return backtrack();
365 }
366 sep.parse_next(input)?;
367 if !input.starts_with('}') {
368 return backtrack();
369 }
370 let last_op = body.0.last().and_then(|s| s.op);
371 if last_op.is_none() {
372 return backtrack();
373 }
374 *input = &input[1..];
375 let redirs = trailing_redirs(input)?;
376 Ok(Cmd::BraceGroup { body, redirs })
377}
378
379fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
382 let env: Vec<(String, Word)> =
383 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
384 let mut words = Vec::new();
385 let mut redirs = Vec::new();
386
387 loop {
388 ws.parse_next(input)?;
389 if at_cmd_end(input) {
390 break;
391 }
392 if let Some(r) = opt(redirect).parse_next(input)? {
393 redirs.push(r);
394 } else if let Some(w) = opt(word).parse_next(input)? {
395 words.push(w);
396 } else {
397 break;
398 }
399 }
400
401 if env.is_empty() && words.is_empty() && redirs.is_empty() {
402 return backtrack();
403 }
404 Ok(SimpleCmd { env, words, redirs })
405}
406
407fn at_cmd_end(input: &str) -> bool {
408 if input.starts_with("&>") {
411 return false;
412 }
413 input.is_empty()
414 || matches!(
415 input.as_bytes().first(),
416 Some(b'\n' | b';' | b'|' | b'&' | b')')
417 )
418}
419
420fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
421 let n = run(input, |c| c.is_ascii_alphanumeric() || c == '_')?;
422 '='.parse_next(input)?;
423 let value = opt(word)
424 .parse_next(input)?
425 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
426 Ok((n.to_string(), value))
427}
428
429fn redirect(input: &mut &str) -> ModalResult<Redir> {
432 let fd = opt(fd_prefix).parse_next(input)?;
433 alt((
434 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
435 heredoc,
436 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
437 fd: fd.unwrap_or(1),
438 target,
439 mode: WriteMode::Append,
440 }),
441 preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write {
444 fd: 1,
445 target,
446 mode: WriteMode::AppendBoth,
447 }),
448 preceded("&>", (ws, word)).map(|(_, target)| Redir::Write {
449 fd: 1,
450 target,
451 mode: WriteMode::TruncateBoth,
452 }),
453 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
454 src: fd.unwrap_or(1),
455 dst,
456 }),
457 preceded(">&", (ws, word)).map(|(_, target)| Redir::Write {
461 fd: 1,
462 target,
463 mode: WriteMode::TruncateBoth,
464 }),
465 preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write {
469 fd: fd.unwrap_or(1),
470 target,
471 mode: WriteMode::Clobber,
472 }),
473 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
474 fd: fd.unwrap_or(1),
475 target,
476 mode: WriteMode::Truncate,
477 }),
478 preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite {
481 fd: fd.unwrap_or(0),
482 target,
483 }),
484 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
485 fd: fd.unwrap_or(0),
486 target,
487 }),
488 ))
489 .parse_next(input)
490}
491
492fn heredoc(input: &mut &str) -> ModalResult<Redir> {
493 "<<".parse_next(input)?;
494 let strip_tabs = opt('-').parse_next(input)?.is_some();
495 ws.parse_next(input)?;
496 let before = input.len();
497 let delimiter = heredoc_delimiter.parse_next(input);
498 scan(if delimiter.is_ok() { before - input.len() } else { before })?;
499 let (delimiter, expands) = delimiter?;
500 let body = if expands {
504 heredoc_body_word(input, &delimiter, strip_tabs)?
505 } else {
506 Word(Vec::new())
507 };
508 PENDING_HEREDOCS.with(|q| {
514 q.borrow_mut().push(PendingHeredoc {
515 delimiter: delimiter.clone(),
516 strip_tabs,
517 });
518 });
519 Ok(Redir::HereDoc { delimiter, strip_tabs, body })
520}
521
522fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
532 let Some(nl) = input.find('\n') else {
533 scan(input.len())?;
534 return Ok(Word(Vec::new())); };
536 let mut rest = &input[nl + 1..];
537 let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
538 for prior in &priors {
539 let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
540 scan(input.len())?;
541 return Ok(Word(Vec::new()));
542 };
543 rest = after;
544 }
545 let Some((body, after)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
546 scan(input.len())?;
547 return Ok(Word(Vec::new()));
548 };
549 scan(input.len() - after.len())?;
550 let mut text = body;
551 let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
552 if !text.is_empty() {
553 return backtrack();
554 }
555 Ok(Word(parts))
556}
557
558fn is_heredoc_literal(c: char) -> bool {
562 !matches!(c, '"' | '\\' | '`' | '$')
563}
564
565fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
566 step()?;
567 if input.is_empty() {
568 return backtrack();
569 }
570 if input.starts_with('"') {
571 *input = &input[1..];
572 return Ok(WordPart::Lit("\"".to_string()));
573 }
574 alt((
575 dq_escape,
576 arith_sub,
577 cmd_sub,
578 backtick_part,
579 dollar_lit(is_heredoc_literal),
580 lit(is_heredoc_literal),
581 ))
582 .parse_next(input)
583}
584
585#[derive(Debug, Clone)]
586struct PendingHeredoc {
587 delimiter: String,
588 strip_tabs: bool,
589}
590
591thread_local! {
592 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
593 const { std::cell::RefCell::new(Vec::new()) };
594}
595
596fn drain_pending_heredocs(input: &mut &str) {
597 let pending: Vec<PendingHeredoc> =
598 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
599 for h in pending {
600 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
601 return;
605 }
606 }
607}
608
609fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
610 let split = split_heredoc_body(input, delimiter, strip_tabs);
611 let _ = scan(split.map_or(input.len(), |(_, rest)| input.len() - rest.len()));
612 match split {
613 Some((_, rest)) => {
614 *input = rest;
615 true
616 }
617 None => false,
618 }
619}
620
621fn split_heredoc_body<'a>(
625 s: &'a str,
626 delimiter: &str,
627 strip_tabs: bool,
628) -> Option<(&'a str, &'a str)> {
629 let bytes = s.as_bytes();
630 let mut line_start = 0;
631 while line_start <= bytes.len() {
632 let line_end = match s[line_start..].find('\n') {
633 Some(rel) => line_start + rel,
634 None => bytes.len(),
635 };
636 let line = &s[line_start..line_end];
637 let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
638 if line == delimiter {
639 let advance = line_end + usize::from(line_end < bytes.len());
640 return Some((&s[..line_start], &s[advance..]));
641 }
642 if line_end >= bytes.len() {
643 return None;
644 }
645 line_start = line_end + 1;
646 }
647 None
648}
649
650fn reset_heredoc_queue() {
651 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
652}
653
654fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
659 alt((
660 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
661 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
662 escaped_delimiter,
663 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
664 .map(|s: &str| (s.to_string(), true)),
665 ))
666 .parse_next(input)
667}
668
669fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
674 let mut rest = *input;
675 let mut name = String::new();
676 let mut quoted = false;
677 loop {
678 let mut chars = rest.chars();
679 match chars.next() {
680 Some('\\') => match chars.next() {
681 Some(c) => {
682 name.push(c);
683 quoted = true;
684 rest = &rest[1 + c.len_utf8()..];
685 }
686 None => break,
687 },
688 Some(q @ ('\'' | '"')) => {
689 let inner_end = rest[1..].find(q).map(|i| i + 1);
690 let Some(end) = inner_end else { break };
691 name.push_str(&rest[1..end]);
692 quoted = true;
693 rest = &rest[end + 1..];
694 }
695 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
696 name.push(c);
697 rest = &rest[c.len_utf8()..];
698 }
699 _ => break,
700 }
701 }
702 if !quoted || name.is_empty() {
703 return backtrack();
704 }
705 *input = rest;
706 Ok((name, false))
707}
708
709fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
710 let b = input.as_bytes();
711 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
712 let d = (b[0] - b'0') as u32;
713 *input = &input[1..];
714 Ok(d)
715 } else {
716 backtrack()
717 }
718}
719
720fn fd_target(input: &mut &str) -> ModalResult<String> {
721 alt((
722 '-'.value("-".to_string()),
723 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
724 ))
725 .parse_next(input)
726}
727
728fn word(input: &mut &str) -> ModalResult<Word> {
731 repeat(1.., word_part)
732 .map(Word)
733 .parse_next(input)
734}
735
736fn word_part(input: &mut &str) -> ModalResult<WordPart> {
737 step()?;
738 if input.is_empty() {
739 return backtrack();
740 }
741 if input.starts_with("<(") || input.starts_with(">(") {
742 return proc_sub(input);
743 }
744 if is_word_boundary(input.as_bytes()[0] as char) {
745 return backtrack();
746 }
747 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
748 .parse_next(input)
749}
750
751fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
752 delimited_scan(input, '\'', |i| {
753 delimited('\'', take_while(0.., |c| c != '\''), '\'')
754 .map(|s: &str| WordPart::SQuote(s.to_string()))
755 .parse_next(i)
756 })
757}
758
759fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
760 delimited('"', repeat(0.., dq_part).map(Word), '"')
761 .map(WordPart::DQuote)
762 .parse_next(input)
763}
764
765fn find_sub_close(body: &str) -> Option<usize> {
773 let b = body.as_bytes();
774 let mut i = 0;
775 let mut depth: usize = 0;
776 while i < b.len() {
777 match b[i] {
778 b'\\' => i += 1, b'\'' => {
780 i += 1;
781 while i < b.len() && b[i] != b'\'' {
782 i += 1;
783 }
784 if i >= b.len() {
785 return None;
786 }
787 }
788 b'"' => {
789 i += 1;
790 while i < b.len() && b[i] != b'"' {
791 i += if b[i] == b'\\' { 2 } else { 1 };
792 }
793 if i >= b.len() {
794 return None;
795 }
796 }
797 b'`' => {
798 i += 1;
799 while i < b.len() && b[i] != b'`' {
800 i += if b[i] == b'\\' { 2 } else { 1 };
803 }
804 if i >= b.len() {
805 return None;
806 }
807 }
808 b'(' => depth += 1,
809 b')' => {
810 if depth == 0 {
811 return Some(i);
812 }
813 depth -= 1;
814 }
815 _ => {}
816 }
817 i += 1;
818 }
819 None
820}
821
822fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
840 let body = &input[open_len..];
841 let close = find_sub_close(body);
842 scan(close.map_or(2 * body.len(), |rel| 3 * rel))?;
843 let Some(rel) = close else {
844 return if body.contains("<<") {
845 sub_body_via_grammar(input, body)
846 } else {
847 backtrack()
848 };
849 };
850 let interior = &body[..rel];
856 if !interior.contains("<<") && !interior.contains("case") {
862 let mut fast: &str = interior;
863 let parsed = script.parse_next(&mut fast)?;
864 ws.parse_next(&mut fast)?;
865 if !fast.is_empty() {
866 return backtrack();
867 }
868 *input = &body[rel + 1..];
869 return Ok(parsed);
870 }
871 sub_body_via_grammar(input, body)
872}
873
874fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
877 let mut rest: &str = body;
878 ws.parse_next(&mut rest)?;
879 let parsed = script.parse_next(&mut rest)?;
880 ws.parse_next(&mut rest)?;
881 if !rest.starts_with(')') {
882 return backtrack();
883 }
884 *input = &rest[1..];
885 Ok(parsed)
886}
887
888fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
889 if !input.starts_with("$(") {
890 return backtrack();
891 }
892 sub_body(input, 2).map(WordPart::CmdSub)
893}
894
895fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
896 if !(input.starts_with("<(") || input.starts_with(">(")) {
897 return backtrack();
898 }
899 sub_body(input, 2).map(WordPart::ProcSub)
900}
901
902fn arith_body_part(input: &mut &str) -> ModalResult<WordPart> {
904 step()?;
905 if input.is_empty() {
906 return backtrack();
907 }
908 alt((
909 dq_escape,
910 arith_sub,
915 cmd_sub,
916 backtick_part,
917 dollar_lit(is_heredoc_literal),
918 lit(is_heredoc_literal),
919 ))
920 .parse_next(input)
921}
922
923fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
924 if !input.starts_with("$((") {
925 return backtrack();
926 }
927 let Some(_depth) = DepthGuard::enter() else {
934 return backtrack();
935 };
936 let body_start = 3;
937 let bytes = input.as_bytes();
938 let mut depth: i32 = 1;
939 let mut i = body_start;
940 while i < bytes.len() {
941 match bytes[i] {
942 b'(' => depth += 1,
943 b')' => {
944 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
945 scan(i)?;
957 let mut body = &input[body_start..i];
958 let parts: Vec<WordPart> = repeat(0.., arith_body_part).parse_next(&mut body)?;
959 if !body.is_empty() {
960 return backtrack();
961 }
962 *input = &input[i + 2..];
963 return Ok(WordPart::Arith(Word(parts)));
964 }
965 depth -= 1;
966 if depth < 0 {
967 scan(i)?;
968 return backtrack();
969 }
970 }
971 _ => {}
972 }
973 i += 1;
974 }
975 scan(i)?;
976 backtrack()
977}
978
979fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
980 delimited_scan(input, '`', |i| {
981 delimited('`', backtick_inner, '`').map(WordPart::Backtick).parse_next(i)
982 })
983}
984
985fn delimited_scan<O>(
988 input: &mut &str,
989 open: char,
990 p: impl FnOnce(&mut &str) -> ModalResult<O>,
991) -> ModalResult<O> {
992 if !input.starts_with(open) {
993 return backtrack();
994 }
995 let before = input.len();
996 let out = p(input);
997 scan(if out.is_ok() { before - input.len() } else { before })?;
998 out
999}
1000
1001fn escaped(input: &mut &str) -> ModalResult<WordPart> {
1002 preceded('\\', any).map(WordPart::Escape).parse_next(input)
1003}
1004
1005fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
1006 move |input: &mut &str| {
1007 let s: &str = consumed(input, |i| take_while(1.., pred).parse_next(i))?;
1008 Ok(WordPart::Lit(s.to_string()))
1009 }
1010}
1011
1012fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
1013 move |input: &mut &str| {
1014 ('$', not('(')).void().parse_next(input)?;
1015 let rest: &str = consumed(input, |i| take_while(0.., pred).parse_next(i))?;
1016 Ok(WordPart::Lit(format!("${rest}")))
1017 }
1018}
1019
1020fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
1023 step()?;
1024 if input.is_empty() || input.starts_with('"') {
1025 return backtrack();
1026 }
1027 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
1028 .parse_next(input)
1029}
1030
1031fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
1032 preceded('\\', any)
1033 .map(|c: char| match c {
1034 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
1035 _ => WordPart::Lit(format!("\\{c}")),
1036 })
1037 .parse_next(input)
1038}
1039
1040fn backtick_inner(input: &mut &str) -> ModalResult<String> {
1043 repeat(0.., alt((bt_escape, bt_literal)))
1044 .fold(String::new, |mut acc, chunk: &str| {
1045 acc.push_str(chunk);
1046 acc
1047 })
1048 .parse_next(input)
1049}
1050
1051fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1052 ('\\', any).take().parse_next(input)
1053}
1054
1055fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1056 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
1057}
1058
1059fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
1062 eat_keyword(input, "for")?;
1063 ws.parse_next(input)?;
1064 let var = name.parse_next(input)?;
1065 ws.parse_next(input)?;
1066
1067 let items = if eat_keyword(input, "in").is_ok() {
1068 ws.parse_next(input)?;
1069 repeat(0.., terminated(word, ws)).parse_next(input)?
1070 } else {
1071 vec![]
1072 };
1073
1074 let body = do_done_body.parse_next(input)?;
1075 let redirs = trailing_redirs(input)?;
1076 Ok(Cmd::For { var, items, body, redirs })
1077}
1078
1079fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
1080 eat_keyword(input, "while")?;
1081 ws.parse_next(input)?;
1082 let cond = script.parse_next(input)?;
1083 let body = do_done_body.parse_next(input)?;
1084 let redirs = trailing_redirs(input)?;
1085 Ok(Cmd::While { cond, body, redirs })
1086}
1087
1088fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
1089 eat_keyword(input, "until")?;
1090 ws.parse_next(input)?;
1091 let cond = script.parse_next(input)?;
1092 let body = do_done_body.parse_next(input)?;
1093 let redirs = trailing_redirs(input)?;
1094 Ok(Cmd::Until { cond, body, redirs })
1095}
1096
1097fn do_done_body(input: &mut &str) -> ModalResult<Script> {
1098 sep.parse_next(input)?;
1099 eat_keyword(input, "do")?;
1100 sep.parse_next(input)?;
1101 let body = script.parse_next(input)?;
1102 sep.parse_next(input)?;
1103 eat_keyword(input, "done")?;
1104 Ok(body)
1105}
1106
1107fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
1108 eat_keyword(input, "if")?;
1109 ws.parse_next(input)?;
1110 let mut branches = vec![cond_then_body.parse_next(input)?];
1111 let mut else_body = None;
1112
1113 loop {
1114 sep.parse_next(input)?;
1115 if eat_keyword(input, "elif").is_ok() {
1116 ws.parse_next(input)?;
1117 branches.push(cond_then_body.parse_next(input)?);
1118 } else if eat_keyword(input, "else").is_ok() {
1119 sep.parse_next(input)?;
1120 else_body = Some(script.parse_next(input)?);
1121 break;
1122 } else {
1123 break;
1124 }
1125 }
1126
1127 sep.parse_next(input)?;
1128 eat_keyword(input, "fi")?;
1129 let redirs = trailing_redirs(input)?;
1130 Ok(Cmd::If { branches, else_body, redirs })
1131}
1132
1133fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
1134 let cond = script.parse_next(input)?;
1135 sep.parse_next(input)?;
1136 eat_keyword(input, "then")?;
1137 sep.parse_next(input)?;
1138 let body = script.parse_next(input)?;
1139 Ok(Branch { cond, body })
1140}
1141
1142fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1144 eat_keyword(input, "case")?;
1145 ws.parse_next(input)?;
1146 let subject = word.parse_next(input)?;
1147 blank.parse_next(input)?;
1148 eat_keyword(input, "in")?;
1149
1150 let mut arms = Vec::new();
1151 loop {
1152 blank.parse_next(input)?;
1153 if eat_keyword(input, "esac").is_ok() {
1154 break;
1155 }
1156 let arm = case_arm.parse_next(input)?;
1157 let had_terminator = opt(";;").parse_next(input)?.is_some();
1158 arms.push(arm);
1159 if !had_terminator {
1162 blank.parse_next(input)?;
1163 eat_keyword(input, "esac")?;
1164 break;
1165 }
1166 }
1167
1168 let redirs = trailing_redirs(input)?;
1169 Ok(Cmd::Case { subject, arms, redirs })
1170}
1171
1172fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1173 blank.parse_next(input)?;
1174 opt('(').parse_next(input)?;
1175 let mut patterns = Vec::new();
1176 loop {
1177 ws.parse_next(input)?;
1178 patterns.push(word.parse_next(input)?);
1179 ws.parse_next(input)?;
1180 if opt('|').parse_next(input)?.is_none() {
1181 break;
1182 }
1183 }
1184 ')'.parse_next(input)?;
1185 let body = script.parse_next(input)?;
1186 blank.parse_next(input)?;
1187 Ok(CaseArm { patterns, body })
1188}
1189
1190fn blank(input: &mut &str) -> ModalResult<()> {
1192 consumed(input, |i| take_while(0.., [' ', '\t', '\n']).void().parse_next(i))
1193}
1194
1195fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1196 if !input.starts_with("[[") {
1197 return backtrack();
1198 }
1199 let bytes = input.as_bytes();
1200 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1201 return backtrack();
1202 }
1203 *input = &input[2..];
1204
1205 let mut words: Vec<Word> = Vec::new();
1206 loop {
1207 ws.parse_next(input)?;
1208 if at_double_bracket_end(input) {
1209 *input = &input[2..];
1210 let redirs = trailing_redirs(input)?;
1211 return Ok(Cmd::DoubleBracket { words, redirs });
1212 }
1213 if input.is_empty() {
1214 return backtrack();
1215 }
1216 let w = bracket_word.parse_next(input)?;
1217 words.push(w);
1218 }
1219}
1220
1221fn at_double_bracket_end(input: &str) -> bool {
1222 if !input.starts_with("]]") {
1223 return false;
1224 }
1225 let after = &input[2..];
1226 after.is_empty()
1227 || after.starts_with(|c: char| {
1228 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
1229 })
1230}
1231
1232fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1233 repeat(1.., bracket_word_part).map(Word).parse_next(input)
1234}
1235
1236fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1237 step()?;
1238 if input.is_empty() {
1239 return backtrack();
1240 }
1241 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1242 return backtrack();
1243 }
1244 if at_double_bracket_end(input) {
1245 return backtrack();
1246 }
1247 alt((
1248 single_quoted,
1249 double_quoted,
1250 arith_sub,
1251 cmd_sub,
1252 backtick_part,
1253 escaped,
1254 dollar_lit(is_bracket_literal),
1255 bracket_lit,
1256 ))
1257 .parse_next(input)
1258}
1259
1260fn is_bracket_literal(c: char) -> bool {
1261 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1262}
1263
1264fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1265 let bytes = input.as_bytes();
1270 let mut end = 0;
1271 while end < bytes.len() {
1272 let c = bytes[end] as char;
1273 if !is_bracket_literal(c) {
1274 break;
1275 }
1276 if c == ']' && at_double_bracket_end(&input[end..]) {
1277 break;
1278 }
1279 end += 1;
1280 }
1281 scan(end)?;
1282 if end == 0 {
1283 return backtrack();
1284 }
1285 let lit = input[..end].to_string();
1286 *input = &input[end..];
1287 Ok(WordPart::Lit(lit))
1288}
1289
1290fn name(input: &mut &str) -> ModalResult<String> {
1291 run(input, |c| c.is_ascii_alphanumeric() || c == '_').map(String::from)
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::budget::MAX_PARSE_WORK_CEILING;
1297 #[test]
1315 fn the_parse_work_budget_bounds_nesting_without_refusing_real_commands() {
1316 let worst_real = crate::registry::corpus_examples()
1317 .into_iter()
1318 .flat_map(|(_, safe, denied)| safe.iter().chain(denied.iter()).cloned().collect::<Vec<_>>())
1319 .map(|ex| {
1320 let _ = super::parse(&ex);
1321 budget::work()
1322 })
1323 .max()
1324 .expect("the registry ships examples");
1325 assert!(
1326 worst_real * 100 < MAX_PARSE_WORK_CEILING,
1327 "a real example needs {worst_real} entries against a {MAX_PARSE_WORK_CEILING} ceiling; \
1328 the margin that makes this constant safe is gone"
1329 );
1330
1331 let flat = format!("echo {}", "$((1)) ".repeat(400));
1337 let _ = super::parse(&flat);
1338 let flat_work = budget::work();
1339 assert!(
1340 flat_work < MAX_PARSE_WORK_CEILING / 10,
1341 "flat input cost {flat_work}, close to the {MAX_PARSE_WORK_CEILING} ceiling — a long \
1342 flat command is at risk of being refused"
1343 );
1344
1345 let work_at = |depth: usize| {
1351 let deep = format!("echo {}1{}", "$((1+".repeat(depth), "))".repeat(depth));
1352 let _ = super::parse(&deep);
1353 assert!(budget::spent(), "the nest should actually reach a bound, or this proves nothing");
1354 budget::work()
1355 };
1356 let (w2k, w4k) = (work_at(2000), work_at(4000));
1357 assert!(
1358 w4k <= w2k + w2k / 10,
1359 "work still scales with input length: depth 2000 used {w2k}, depth 4000 used {w4k}"
1360 );
1361 assert!(
1362 w4k < MAX_PARSE_WORK_CEILING + MAX_PARSE_WORK_CEILING / 10,
1363 "work {w4k} ran far past the {MAX_PARSE_WORK_CEILING} ceiling"
1364 );
1365 }
1366
1367
1368 use super::*;
1369
1370 #[test]
1379 fn unclosed_compound_nesting_costs_linear_work() {
1380 let unclosed = |opener: &str| match opener {
1381 "{" => "{\n",
1382 "[[" => "[[ a\n",
1383 "if" => "if a; then\n",
1384 "for" => "for x in a; do\n",
1385 "while" => "while a; do\n",
1386 "until" => "until a; do\n",
1387 "case" => "case x in a)\n",
1388 "function" => "function f {\n",
1389 other => panic!("no unclosed form for the reserved word {other:?}; add one here"),
1390 };
1391 let tail = "\"$([[ <<\"\"<\"$( [[ x ]] ) $(ls <<EOF\n)\nEOF\n)\" ls";
1392 let openers = super::super::reserved::BLANK_OPENERS
1393 .iter()
1394 .chain(super::super::reserved::KEYWORD_OPENERS.iter());
1395 for opener in openers {
1396 let unit = unclosed(opener);
1397 for prefix in [unit.to_string(), format!("{unit}{{\n")] {
1398 for depth in [10u64, 20, 40] {
1399 let text = format!("{}{tail}", prefix.repeat(depth as usize));
1400 let _ = parse(&text);
1401 let work = budget::work();
1402 assert!(
1403 work <= 4 * depth + 64,
1404 "{depth} nested unclosed {prefix:?} cost {work} parse entries, more than \
1405 linear in the nesting: a failed compound is being re-read another way"
1406 );
1407 }
1408 }
1409 }
1410 }
1411
1412 #[test]
1420 fn refused_substitution_nesting_costs_linear_work() {
1421 for refused in ["[[ a", "{ a", "if a", "a; }"] {
1422 for (open, close) in [("$(", ")"), ("\"$(", ")\""), ("<(", ")")] {
1423 for depth in [10u64, 20, 40] {
1424 let n = depth as usize;
1425 let text = format!("echo {}x{}", format!("{open}{refused} ").repeat(n), close.repeat(n));
1426 let _ = parse(&text);
1427 let work = budget::work();
1428 assert!(
1429 work <= 4 * depth + 64,
1430 "{depth} nested {open}{refused} cost {work} parse entries, more than linear: \
1431 a refused interior is being parsed a second time"
1432 );
1433 }
1434 }
1435 }
1436 }
1437
1438 #[test]
1446 fn a_backtracking_nest_over_a_long_tail_is_stopped_by_what_it_reads() {
1447 let tail = "a ".repeat(50_000);
1448 for (open, close) in [("$(( $({ ", ") ))"), ("\"$(( $( [[ ", ") ))\"")] {
1449 let text = format!("echo {}x {tail}{}", open.repeat(20), close.repeat(20));
1450 assert!(parse(&text).is_none(), "{open:?} nest parsed");
1451 assert!(budget::spent(), "{open:?} nest was refused, but not by the budget");
1452 let work = budget::work();
1453 assert!(work < 1_000, "{open:?} nest ran {work} entries before the step budget stopped it");
1454 }
1455 }
1456
1457 #[test]
1465 fn the_explain_render_timeout_seed_scales_linearly_in_its_braces() {
1466 let seed = include_bytes!("../../fuzz/corpus/explain_render/seed-unclosed-brace-nest");
1467 let seed = String::from_utf8_lossy(seed);
1468 let body = seed.split('`').nth(1).expect("the seed carries a backtick body");
1469 let deep = body.find("$([[").expect("the seed's tail opens a `$([[`");
1470 let tail = &body[body[..deep].rfind("\n{").expect("the nest ends before the tail") + 2..];
1471 let cost = |k: usize| {
1472 let _ = parse(&format!("{}{tail}", "{\n".repeat(k)));
1473 assert!(!budget::spent(), "{k} braces spent the budget; the nest is not linear");
1474 (budget::work(), budget::steps())
1475 };
1476 let [(w10, s10), (w20, s20), (w40, s40)] = [cost(10), cost(20), cost(40)];
1477 assert!(w40 - w20 <= 2 * (w20 - w10) + 4, "entries {w10}/{w20}/{w40} for 10/20/40 braces");
1478 assert!(s40 - s20 <= 2 * (s20 - s10) + 16, "steps {s10}/{s20}/{s40} for 10/20/40 braces");
1479 assert!(!crate::is_safe_command(&seed));
1480 }
1481
1482 proptest::proptest! {
1483 #![proptest_config(proptest::prelude::ProptestConfig::with_cases(300))]
1484
1485 #[test]
1498 fn a_random_unclosed_nest_parses_in_linear_work(
1499 opener in proptest::sample::select(OPENERS.to_vec()),
1500 depth in 0..24usize,
1501 mixed in proptest::collection::vec(proptest::sample::select(OPENERS.to_vec()), 0..8),
1502 tail in proptest::collection::vec(proptest::sample::select(vec![
1503 "}", " ]]", "fi", "done", ";;", "esac", ")", "\"", "<<", "<<E\n", "\n", ";", "a ",
1504 "$(", "[[ ", "`",
1505 ]), 0..24),
1506 closes in 0..24usize,
1507 ) {
1508 let input = format!(
1509 "{}{}{}{}", opener.repeat(depth), mixed.concat(), tail.concat(), ")".repeat(closes)
1510 );
1511 let _ = parse(&input);
1512 let work = budget::work();
1513 let tokens = (depth + mixed.len() + tail.len() + closes) as u64;
1514 proptest::prop_assert!(!budget::spent(), "spent the budget on {input:?}");
1515 proptest::prop_assert!(work <= 4 * tokens + 16, "{work} entries for {tokens} tokens: {input:?}");
1516 }
1517 }
1518
1519 const OPENERS: [&str; 14] = [
1520 "{\n", "{ ", "[[ a\n", "if a; then\n", "for x in a; do\n", "while a; do\n", "case x in a)\n",
1521 "function f {\n", "(\n", "$(", "\"$(", "<(", "$([[ ", "$({ ",
1522 ];
1523
1524 #[test]
1532 fn committed_command_seeds_parse_in_linear_work() {
1533 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/corpus");
1534 let mut checked = 0;
1535 for target in ["parse", "explain_render"] {
1536 let dir = std::fs::read_dir(root.join(target)).expect("the fuzz corpus is committed");
1537 for path in dir.flatten().map(|e| e.path()) {
1538 if !path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("seed-")) {
1539 continue;
1540 }
1541 let text = String::from_utf8_lossy(&std::fs::read(&path).expect("readable seed")).into_owned();
1542 for piece in std::iter::once(text.as_str()).chain(text.split('`').skip(1).step_by(2)) {
1545 let _ = parse(piece);
1546 let (work, len) = (budget::work(), piece.len() as u64);
1547 assert!(!budget::spent(), "{} spent the parse budget", path.display());
1548 assert!(work <= len + 16, "{} cost {work} entries for {len} bytes", path.display());
1549 }
1550 checked += 1;
1551 }
1552 }
1553 assert!(checked >= 16, "only {checked} seeds found; the corpus has moved");
1554 }
1555
1556 #[test]
1559 fn the_step_budget_refuses_nothing_real() {
1560 for (_, safe, denied) in crate::registry::corpus_examples() {
1561 for example in safe.iter().chain(denied.iter()) {
1562 let _ = parse(example);
1563 assert!(!budget::spent(), "a registry example spent the step budget: {example}");
1564 }
1565 }
1566 let message = "it's a line of prose (with parens) and $vars\n".repeat(5_000);
1567 for text in [
1568 format!("git commit -m \"$(cat <<'EOF'\n{message}EOF\n)\""),
1569 "ls -la foo/bar; ".repeat(20_000),
1570 ] {
1571 assert!(parse(&text).is_some(), "a long real command no longer parses");
1572 let (steps, len) = (budget::steps(), text.len() as u64);
1573 assert!(
1574 steps * 4 < budget::STEPS_PER_BYTE * len,
1575 "{len} bytes of ordinary input spent {steps} steps, within 4x of the allowance"
1576 );
1577 }
1578 }
1579
1580 fn p(input: &str) -> Script {
1581 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1582 }
1583
1584 fn words(script: &Script) -> Vec<String> {
1585 match &script.0[0].pipeline.commands[0] {
1586 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1587 _ => panic!("expected simple command"),
1588 }
1589 }
1590
1591 fn simple(script: &Script) -> &SimpleCmd {
1592 match &script.0[0].pipeline.commands[0] {
1593 Cmd::Simple(s) => s,
1594 _ => panic!("expected simple command"),
1595 }
1596 }
1597
1598 #[test]
1599 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
1600 #[test]
1601 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
1602 #[test]
1603 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
1604 #[test]
1605 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
1606 #[test]
1607 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
1608
1609 #[test]
1610 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
1611 #[test]
1612 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
1613 #[test]
1614 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
1615 #[test]
1616 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1617 #[test]
1618 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1619 #[test]
1620 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1621 #[test]
1622 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
1623 #[test]
1624 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1625 #[test]
1626 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1627 #[test]
1628 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1629
1630 #[test]
1631 fn brace_group_simple() {
1632 assert!(matches!(
1633 &p("{ echo hello; }").0[0].pipeline.commands[0],
1634 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1635 ));
1636 }
1637 #[test]
1638 fn brace_group_multiple_stmts() {
1639 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1640 assert_eq!(body.0.len(), 3);
1641 } else { panic!("expected BraceGroup"); }
1642 }
1643 #[test]
1644 fn brace_group_with_redirect() {
1645 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1646 assert_eq!(redirs.len(), 1);
1647 assert!(matches!(redirs[0], Redir::Write { .. }));
1648 } else { panic!("expected BraceGroup"); }
1649 }
1650 #[test]
1651 fn brace_group_with_append_redirect() {
1652 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1653 assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1654 } else { panic!("expected BraceGroup"); }
1655 }
1656 #[test]
1657 fn brace_group_with_stderr_redirect() {
1658 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1659 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1660 } else { panic!("expected BraceGroup"); }
1661 }
1662 #[test]
1663 fn brace_group_newline_separated() {
1664 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1665 assert_eq!(body.0.len(), 2);
1666 } else { panic!("expected BraceGroup"); }
1667 }
1668 #[test]
1675 fn time_keyword_prefixes_a_compound() {
1676 for (src, want_subshell) in
1677 [("time (ls)", true), ("time -p (ls)", true), ("time { ls; }", false)]
1678 {
1679 let pl = &p(src).0[0].pipeline;
1680 assert_eq!(pl.commands.len(), 1, "{src}: one compound command");
1681 if want_subshell {
1682 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "{src}: kept the subshell");
1683 } else {
1684 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }), "{src}: kept the group");
1685 }
1686 }
1687 let pl = &p("time (ls | head -5)").0[0].pipeline;
1689 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }));
1690
1691 let pl = &p("time ls").0[0].pipeline;
1694 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "time ls stays a simple command");
1695
1696 let pl = &p("timeout 5 ls").0[0].pipeline;
1698 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "timeout is not the time keyword");
1699
1700 let pl = &p("! time (ls)").0[0].pipeline;
1705 assert!(pl.bang, "the bang survives the time keyword");
1706 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "and the compound survives too");
1707 }
1708
1709 #[test]
1710 fn brace_group_in_pipeline() {
1711 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1712 assert_eq!(pl.commands.len(), 2);
1713 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1714 }
1715 #[test]
1716 fn brace_group_followed_by_other() {
1717 let stmts = &p("{ echo a; }; echo b").0;
1718 assert_eq!(stmts.len(), 2);
1719 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1720 }
1721 #[test]
1722 fn brace_group_nested() {
1723 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1724 assert_eq!(body.0.len(), 2);
1725 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1726 } else { panic!("expected outer BraceGroup"); }
1727 }
1728 #[test]
1729 fn brace_group_with_subshell_inside() {
1730 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1731 assert_eq!(body.0.len(), 2);
1732 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1733 } else { panic!("expected BraceGroup"); }
1734 }
1735 #[test]
1736 fn brace_open_requires_whitespace() {
1737 let cmds = &p("{echo a}").0;
1741 if !cmds.is_empty() {
1744 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1745 }
1746 }
1747 #[test]
1748 fn subshell_with_redirect() {
1749 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1750 assert_eq!(redirs.len(), 1);
1751 } else { panic!("expected Subshell with redir"); }
1752 }
1753 #[test]
1754 fn for_loop_with_redirect() {
1755 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1756 assert_eq!(redirs.len(), 1);
1757 } else { panic!("expected For with redir"); }
1758 }
1759 #[test]
1760 fn for_loop_redirect_then_pipe() {
1761 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1763 assert_eq!(pl.commands.len(), 2);
1764 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1765 }
1766 #[test]
1767 fn while_and_if_with_redirect() {
1768 assert!(matches!(
1769 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1770 Cmd::While { redirs, .. } if redirs.len() == 1
1771 ));
1772 assert!(matches!(
1773 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1774 Cmd::If { redirs, .. } if redirs.len() == 1
1775 ));
1776 }
1777 #[test]
1778 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1779
1780 #[test]
1781 fn redirect_dev_null() {
1782 let s = p("echo hello > /dev/null");
1783 let cmd = simple(&s);
1784 assert_eq!(cmd.words.len(), 2);
1785 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1786 }
1787 #[test]
1788 fn redirect_stderr() {
1789 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1790 }
1791 #[test]
1792 fn here_string() {
1793 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1794 }
1795 #[test]
1796 fn heredoc_bare() {
1797 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1798 }
1799 #[test]
1800 fn heredoc_with_content() {
1801 let s = p("cat <<EOF\nhello world\nEOF");
1802 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1803 }
1804 #[test]
1805 fn heredoc_quoted_delimiter() {
1806 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1807 }
1808 #[test]
1809 fn heredoc_strip_tabs() {
1810 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1811 }
1812 #[test]
1813 fn heredoc_pipe_on_command_line() {
1814 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1817 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1818 }
1819 #[test]
1820 fn heredoc_body_does_not_swallow_pipe() {
1821 let s = p("cat <<EOF | bash\nrm\nEOF");
1825 assert_eq!(
1826 s.0[0].pipeline.commands.len(),
1827 2,
1828 "pipeline must keep `bash` as a second command"
1829 );
1830 }
1831 #[test]
1832 fn heredoc_followed_by_next_statement() {
1833 let s = p("cat <<EOF\nhello\nEOF\nls");
1836 assert_eq!(s.0.len(), 2);
1837 }
1838
1839 #[test]
1840 fn env_prefix() {
1841 let s = p("FOO='bar baz' ls -la");
1842 let cmd = simple(&s);
1843 assert_eq!(cmd.env[0].0, "FOO");
1844 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1845 }
1846 #[test]
1847 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1848 #[test]
1849 fn backtick_substitution() {
1850 assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1853 assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1855 }
1856 #[test]
1857 fn nested_substitution() {
1858 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1859 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1860 } else { panic!("expected CmdSub"); }
1861 }
1862
1863 #[test]
1864 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1865 #[test]
1866 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1867
1868 #[test]
1869 fn for_loop() { assert!(matches!(&p("for x in 1 2 3; do echo $x; done").0[0].pipeline.commands[0], Cmd::For { var, .. } if var == "x")); }
1870 #[test]
1871 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1872 #[test]
1873 fn if_then_fi() {
1874 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1875 assert_eq!(branches.len(), 1);
1876 assert!(else_body.is_none());
1877 } else { panic!("expected If"); }
1878 }
1879 #[test]
1880 fn if_elif_else() {
1881 if let Cmd::If { branches, else_body, .. } = &p("if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi").0[0].pipeline.commands[0] {
1882 assert_eq!(branches.len(), 2);
1883 assert!(else_body.is_some());
1884 } else { panic!("expected If"); }
1885 }
1886
1887 #[test]
1888 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1889 #[test]
1890 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1891 #[test]
1892 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1893
1894 #[test]
1895 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1896 #[test]
1897 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1898 #[test]
1899 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1900 #[test]
1901 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1902 #[test]
1903 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1904 #[test]
1905 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1906
1907 #[test]
1908 fn subshell_for() {
1909 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1910 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1911 } else { panic!("expected Subshell"); }
1912 }
1913 #[test]
1914 fn proc_sub_input() {
1915 let s = p("diff <(sort a.txt) <(sort b.txt)");
1916 let cmd = simple(&s);
1917 assert_eq!(cmd.words.len(), 3);
1918 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1919 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1920 }
1921 #[test]
1922 fn proc_sub_output() {
1923 let s = p("tee >(grep error > /dev/null)");
1924 let cmd = simple(&s);
1925 assert_eq!(cmd.words.len(), 2);
1926 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1927 }
1928
1929 fn func_def(s: &Script) -> (&str, &Script) {
1931 match &s.0[0].pipeline.commands[0] {
1932 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1933 other => panic!("expected FunctionDef, got {other:?}"),
1934 }
1935 }
1936 #[test]
1937 fn function_def_posix_form() {
1938 let s = p("probe(){ echo hi; }");
1939 let (name, body) = func_def(&s);
1940 assert_eq!(name, "probe");
1941 assert_eq!(words(body), ["echo", "hi"]);
1942 }
1943 #[test]
1944 fn function_def_spaced_and_keyword_forms() {
1945 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1946 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1947 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1948 }
1949 #[test]
1950 fn function_def_subshell_body() {
1951 let s = p("foo() ( echo sub )");
1952 assert_eq!(func_def(&s).0, "foo");
1953 }
1954 #[test]
1955 fn function_def_body_on_next_line() {
1956 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1957 }
1958 #[test]
1959 fn function_def_name_with_dashes_and_dots() {
1960 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1961 }
1962 #[test]
1963 fn plain_command_is_not_a_function_def() {
1964 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1966 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1967 }
1968 #[test]
1969 fn function_def_roundtrips() {
1970 let rendered = p("greet(){ echo hi; }").to_string();
1971 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1972 assert_eq!(func_def(&p(&rendered)).0, "greet");
1973 }
1974 #[test]
1975 fn comment_only() {
1976 let s = p("# just a comment");
1977 assert!(s.0.is_empty());
1978 }
1979 #[test]
1980 fn comment_before_command() {
1981 let s = p("# comment\necho hello");
1982 assert_eq!(words(&s), ["echo", "hello"]);
1983 }
1984 #[test]
1985 fn inline_comment() {
1986 let s = p("echo hello # this is a comment");
1987 assert_eq!(words(&s), ["echo", "hello"]);
1988 }
1989 #[test]
1990 fn comment_between_commands() {
1991 let s = p("echo hello\n# middle comment\necho world");
1992 assert_eq!(s.0.len(), 2);
1993 }
1994 #[test]
1995 fn comment_after_semicolon() {
1996 let s = p("echo hello; # comment\necho world");
1997 assert_eq!(s.0.len(), 2);
1998 }
1999 #[test]
2000 fn comment_in_for_loop() {
2001 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
2002 }
2003 #[test]
2004 fn quoted_redirect_in_echo() {
2005 let s = p("echo 'greater > than' test");
2006 let cmd = simple(&s);
2007 assert_eq!(cmd.words.len(), 3);
2008 assert_eq!(cmd.redirs.len(), 0);
2009 }
2010
2011 #[test]
2012 fn parses_all_safe_commands() {
2013 let cmds = [
2014 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
2015 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
2016 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
2017 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
2018 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
2019 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
2020 "((echo hello))", "(for x in 1 2; do echo $x; done)",
2021 "echo 'greater > than' test", "echo '$(safe)' arg",
2022 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
2023 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
2024 "grep foo file.txt | head -5", "cat file | sort | uniq",
2025 "ls && echo done", "ls; echo done", "ls & echo done",
2026 "grep -c , <<< 'hello,world,test'",
2027 "cat <<EOF\nhello world\nEOF",
2028 "cat <<'MARKER'\nsome text\nMARKER",
2029 "cat <<-EOF\n\thello\nEOF",
2030 "echo foo\necho bar", "ls\ncat file.txt",
2031 "git log --oneline -20 | head -5",
2032 "echo hello > /dev/null", "echo hello 2> /dev/null",
2033 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
2034 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
2035 "for x in 1 2 3; do echo $x; done",
2036 "for f in *.txt; do cat $f | grep pattern; done",
2037 "for x in 1 2 3; do; done",
2038 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
2039 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
2040 "for x in 1 2; do echo $x; done && echo finished",
2041 "for x in $(seq 1 5); do echo $x; done",
2042 "while test -f /tmp/foo; do sleep 1; done",
2043 "while ! test -f /tmp/done; do sleep 1; done",
2044 "until test -f /tmp/ready; do sleep 1; done",
2045 "if test -f foo; then echo exists; fi",
2046 "if test -f foo; then echo yes; else echo no; fi",
2047 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
2048 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
2049 "if true; then for x in 1 2; do echo $x; done; fi",
2050 "diff <(sort a.txt) <(sort b.txt)",
2051 "comm -23 file.txt <(sort other.txt)",
2052 "cat <(echo hello)",
2053 "# comment only",
2054 "# comment\necho hello",
2055 "echo hello # inline comment",
2056 "echo one\n# between\necho two",
2057 "! echo hello", "! test -f foo",
2058 "echo for; echo done; echo if; echo fi",
2059 ];
2060 let mut failures = Vec::new();
2061 for cmd in &cmds {
2062 if parse(cmd).is_none() { failures.push(*cmd); }
2063 }
2064 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
2065 }
2066
2067 fn inner_sub(s: &Script) -> &Script {
2073 match &simple(s).words[1].0[0] {
2074 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
2075 other => panic!("expected a substitution, got {other:?}"),
2076 }
2077 }
2078
2079 #[test]
2080 fn cmd_sub_squote_paren_is_not_close() {
2081 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
2082 }
2083 #[test]
2084 fn cmd_sub_dquote_paren_is_not_close() {
2085 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
2086 }
2087 #[test]
2088 fn cmd_sub_escaped_paren_is_not_close() {
2089 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
2090 }
2091 #[test]
2092 fn cmd_sub_backtick_paren_is_not_close() {
2093 let s = p("echo $(x `)` y)");
2095 let inner = simple(inner_sub(&s));
2096 assert_eq!(inner.words.len(), 3);
2097 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
2098 }
2099 #[test]
2100 fn cmd_sub_escaped_backtick_does_not_end_span() {
2101 let s = p("echo $(`\\`)`)");
2105 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
2106 }
2107 #[test]
2108 fn proc_sub_squote_paren_is_not_close() {
2109 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
2110 }
2111 #[test]
2112 fn proc_sub_out_squote_paren_is_not_close() {
2113 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
2114 }
2115 #[test]
2116 fn cmd_sub_nested_picks_outer_close() {
2117 let s = p("echo $(a $(b) c)");
2118 let inner = simple(inner_sub(&s));
2119 assert_eq!(inner.words.len(), 3);
2120 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
2121 }
2122 #[test]
2123 fn cmd_sub_literal_after_close_stays_in_outer_word() {
2124 let s = p("echo $(ls)tail");
2125 let w = &simple(&s).words[1];
2126 assert_eq!(w.0.len(), 2);
2127 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
2128 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
2129 }
2130 #[test]
2131 fn cmd_sub_heredoc_body_paren_does_not_close() {
2132 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
2136 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
2137 }
2138 #[test]
2139 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
2140 assert!(parse("echo $(echo ')").is_none());
2142 }
2143 #[test]
2144 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
2145 assert!(parse("cat <(grep ')").is_none());
2146 }
2147}