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