Skip to main content

safe_chains/cst/
parse.rs

1use super::*;
2use winnow::ModalResult;
3use winnow::combinator::{alt, delimited, not, opt, preceded, repeat, separated, terminated};
4use winnow::error::{ContextError, ErrMode};
5use winnow::prelude::*;
6use winnow::token::{any, take_while};
7
8pub fn parse(input: &str) -> Option<Script> {
9    reset_heredoc_queue();
10    PARSE_DEPTH.with(|d| d.set(0));
11    let result = script.parse(input).ok();
12    reset_heredoc_queue();
13    result
14}
15
16fn backtrack<T>() -> ModalResult<T> {
17    Err(ErrMode::Backtrack(ContextError::new()))
18}
19
20thread_local! {
21    static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
22}
23
24/// Nesting depth beyond which the parser bails instead of recursing further. EVERY recursion source
25/// — subshells `( )`, brace groups `{ }`, command/process substitutions `$( )`/`<( )`, and
26/// double-quote-nested subs — funnels through `script()`, so bounding it there caps stack depth. A
27/// deeply-nested adversarial input (`"$("` × 100 000) would otherwise overflow the stack and ABORT
28/// the process — a fail-open CRASH of the hook that `catch_unwind` cannot recover (a stack overflow
29/// is not an unwindable panic). 200 is far beyond any real command; past it the parse fails and the
30/// command is denied (fail closed). Found by `classifier_terminates_on_adversarial_input`. Kept
31/// LOW (winnow's combinator frames are fat — ~200 levels alone overflowed a 2 MB stack), yet still
32/// far beyond any real command, which nests a handful of levels at most.
33const MAX_PARSE_DEPTH: u32 = 48;
34
35/// RAII depth counter for the recursive descent — increments on `enter`, decrements on drop (winnow
36/// returns errors rather than panicking, so drops balance even on the bail path).
37struct DepthGuard;
38
39impl DepthGuard {
40    fn enter() -> Option<Self> {
41        PARSE_DEPTH.with(|d| {
42            if d.get() >= MAX_PARSE_DEPTH {
43                None
44            } else {
45                d.set(d.get() + 1);
46                Some(DepthGuard)
47            }
48        })
49    }
50}
51
52impl Drop for DepthGuard {
53    fn drop(&mut self) {
54        PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
55    }
56}
57
58fn comment(input: &mut &str) -> ModalResult<()> {
59    if input.starts_with('#') {
60        if let Some(pos) = input.find('\n') {
61            *input = &input[pos + 1..];
62        } else {
63            *input = "";
64        }
65    }
66    Ok(())
67}
68
69fn ws(input: &mut &str) -> ModalResult<()> {
70    loop {
71        take_while(0.., [' ', '\t']).void().parse_next(input)?;
72        if input.starts_with('#') {
73            comment(input)?;
74        } else {
75            break;
76        }
77    }
78    Ok(())
79}
80
81fn sep(input: &mut &str) -> ModalResult<()> {
82    loop {
83        take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
84        if input.starts_with('#') {
85            comment(input)?;
86        } else {
87            break;
88        }
89    }
90    Ok(())
91}
92
93fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
94    if !input.starts_with(kw) {
95        return backtrack();
96    }
97    if input
98        .as_bytes()
99        .get(kw.len())
100        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
101    {
102        return backtrack();
103    }
104    *input = &input[kw.len()..];
105    Ok(())
106}
107
108const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
109
110fn at_script_stop(input: &str) -> bool {
111    input.starts_with(')')
112        || input.starts_with('}')
113        || SCRIPT_STOPS.iter().any(|kw| {
114            input.starts_with(kw)
115                && !input
116                    .as_bytes()
117                    .get(kw.len())
118                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
119        })
120}
121
122fn is_word_boundary(c: char) -> bool {
123    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
124}
125
126fn is_word_literal(c: char) -> bool {
127    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
128}
129
130fn is_dq_literal(c: char) -> bool {
131    !matches!(c, '"' | '\\' | '`' | '$')
132}
133
134// === Script ===
135
136fn script(input: &mut &str) -> ModalResult<Script> {
137    // Bound recursion depth: every nested `(`/`{`/`$(`/`<(`/`` ` `` funnels back through `script`,
138    // so this one guard caps stack depth against deeply-nested adversarial input (see MAX_PARSE_DEPTH).
139    let Some(_depth) = DepthGuard::enter() else {
140        return backtrack();
141    };
142    sep.parse_next(input)?;
143    let mut stmts = Vec::new();
144    while let Some(pl) = opt(pipeline).parse_next(input)? {
145        ws.parse_next(input)?;
146        let op = opt(list_op).parse_next(input)?;
147        stmts.push(Stmt { pipeline: pl, op });
148        // Drain any heredoc bodies pending from this statement before
149        // the next pipeline starts; otherwise the body would be parsed
150        // as the next statement (which would either misvalidate or
151        // misalign the line counter).
152        drain_pending_heredocs(input);
153        if op.is_none() {
154            break;
155        }
156        sep.parse_next(input)?;
157    }
158    Ok(Script(stmts))
159}
160
161fn list_op(input: &mut &str) -> ModalResult<ListOp> {
162    ws.parse_next(input)?;
163    alt((
164        "&&".value(ListOp::And),
165        "||".value(ListOp::Or),
166        '\n'.value(ListOp::Semi),
167        ';'.value(ListOp::Semi),
168        ('&', not('>')).value(ListOp::Amp),
169    ))
170    .parse_next(input)
171}
172
173fn pipe_sep(input: &mut &str) -> ModalResult<()> {
174    (ws, '|', not('|'), ws).void().parse_next(input)
175}
176
177// === Pipeline ===
178
179fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
180    ws.parse_next(input)?;
181    if at_script_stop(input) {
182        return backtrack();
183    }
184    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
185    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
186    Ok(Pipeline { bang, commands })
187}
188
189// === Command ===
190
191fn command(input: &mut &str) -> ModalResult<Cmd> {
192    ws.parse_next(input)?;
193    if at_script_stop(input) {
194        return backtrack();
195    }
196    alt((
197        subshell,
198        brace_group,
199        for_cmd,
200        while_cmd,
201        until_cmd,
202        if_cmd,
203        double_bracket_cmd,
204        simple_cmd.map(Cmd::Simple),
205    ))
206    .parse_next(input)
207}
208
209fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
210    let mut redirs = Vec::new();
211    loop {
212        ws.parse_next(input)?;
213        if let Some(r) = opt(redirect).parse_next(input)? {
214            redirs.push(r);
215        } else {
216            break;
217        }
218    }
219    Ok(redirs)
220}
221
222fn subshell(input: &mut &str) -> ModalResult<Cmd> {
223    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
224    let redirs = trailing_redirs(input)?;
225    Ok(Cmd::Subshell { body, redirs })
226}
227
228fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
229    if !input.starts_with('{') {
230        return backtrack();
231    }
232    if !input
233        .as_bytes()
234        .get(1)
235        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
236    {
237        return backtrack();
238    }
239    *input = &input[1..];
240    sep.parse_next(input)?;
241    let body = script.parse_next(input)?;
242    if body.0.is_empty() {
243        return backtrack();
244    }
245    sep.parse_next(input)?;
246    if !input.starts_with('}') {
247        return backtrack();
248    }
249    let last_op = body.0.last().and_then(|s| s.op);
250    if last_op.is_none() {
251        return backtrack();
252    }
253    *input = &input[1..];
254    let redirs = trailing_redirs(input)?;
255    Ok(Cmd::BraceGroup { body, redirs })
256}
257
258// === Simple Command ===
259
260fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
261    let env: Vec<(String, Word)> =
262        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
263    let mut words = Vec::new();
264    let mut redirs = Vec::new();
265
266    loop {
267        ws.parse_next(input)?;
268        if at_cmd_end(input) {
269            break;
270        }
271        if let Some(r) = opt(redirect).parse_next(input)? {
272            redirs.push(r);
273        } else if let Some(w) = opt(word).parse_next(input)? {
274            words.push(w);
275        } else {
276            break;
277        }
278    }
279
280    if env.is_empty() && words.is_empty() && redirs.is_empty() {
281        return backtrack();
282    }
283    Ok(SimpleCmd { env, words, redirs })
284}
285
286fn at_cmd_end(input: &str) -> bool {
287    input.is_empty()
288        || matches!(
289            input.as_bytes().first(),
290            Some(b'\n' | b';' | b'|' | b'&' | b')')
291        )
292}
293
294fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
295    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
296        .parse_next(input)?;
297    '='.parse_next(input)?;
298    let value = opt(word)
299        .parse_next(input)?
300        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
301    Ok((n.to_string(), value))
302}
303
304// === Redirect ===
305
306fn redirect(input: &mut &str) -> ModalResult<Redir> {
307    let fd = opt(fd_prefix).parse_next(input)?;
308    alt((
309        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
310        heredoc,
311        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
312            fd: fd.unwrap_or(1),
313            target,
314            append: true,
315        }),
316        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
317            src: fd.unwrap_or(1),
318            dst,
319        }),
320        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
321            fd: fd.unwrap_or(1),
322            target,
323            append: false,
324        }),
325        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
326            fd: fd.unwrap_or(0),
327            target,
328        }),
329    ))
330    .parse_next(input)
331}
332
333fn heredoc(input: &mut &str) -> ModalResult<Redir> {
334    "<<".parse_next(input)?;
335    let strip_tabs = opt('-').parse_next(input)?.is_some();
336    ws.parse_next(input)?;
337    let delimiter = heredoc_delimiter.parse_next(input)?;
338    // Bash semantics: the heredoc body lives on lines AFTER the
339    // command line is finished, not immediately after `<<DELIM`. The
340    // command line can continue with more redirects, a pipe, etc.
341    // Push the delimiter onto a thread-local queue; the body is
342    // drained at the next `\n`/`;` separator by drain_pending_heredocs.
343    PENDING_HEREDOCS.with(|q| {
344        q.borrow_mut().push(PendingHeredoc {
345            delimiter: delimiter.clone(),
346            strip_tabs,
347        });
348    });
349    Ok(Redir::HereDoc { delimiter, strip_tabs })
350}
351
352#[derive(Debug, Clone)]
353struct PendingHeredoc {
354    delimiter: String,
355    strip_tabs: bool,
356}
357
358thread_local! {
359    static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
360        const { std::cell::RefCell::new(Vec::new()) };
361}
362
363fn drain_pending_heredocs(input: &mut &str) {
364    let pending: Vec<PendingHeredoc> =
365        PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
366    for h in pending {
367        if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
368            // Couldn't find the matching delimiter line. Leave input
369            // as-is; the parser will likely fail on the leftover body
370            // text, which is the safe outcome (we deny on parse fail).
371            return;
372        }
373    }
374}
375
376fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
377    let s = *input;
378    let bytes = s.as_bytes();
379    let mut line_start = 0;
380    while line_start <= bytes.len() {
381        let line_end = match s[line_start..].find('\n') {
382            Some(rel) => line_start + rel,
383            None => bytes.len(),
384        };
385        let line_bytes = &bytes[line_start..line_end];
386        let line = if strip_tabs {
387            std::str::from_utf8(line_bytes)
388                .unwrap_or("")
389                .trim_start_matches('\t')
390        } else {
391            std::str::from_utf8(line_bytes).unwrap_or("")
392        };
393        if line == delimiter {
394            // Advance past the delimiter line + its newline.
395            let advance = line_end + usize::from(line_end < bytes.len());
396            *input = &s[advance..];
397            return true;
398        }
399        if line_end >= bytes.len() {
400            return false;
401        }
402        line_start = line_end + 1;
403    }
404    false
405}
406
407fn reset_heredoc_queue() {
408    PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
409}
410
411fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
412    alt((
413        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
414        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
415        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
416    ))
417    .parse_next(input)
418}
419
420fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
421    let b = input.as_bytes();
422    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
423        let d = (b[0] - b'0') as u32;
424        *input = &input[1..];
425        Ok(d)
426    } else {
427        backtrack()
428    }
429}
430
431fn fd_target(input: &mut &str) -> ModalResult<String> {
432    alt((
433        '-'.value("-".to_string()),
434        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
435    ))
436    .parse_next(input)
437}
438
439// === Word ===
440
441fn word(input: &mut &str) -> ModalResult<Word> {
442    repeat(1.., word_part)
443        .map(Word)
444        .parse_next(input)
445}
446
447fn word_part(input: &mut &str) -> ModalResult<WordPart> {
448    if input.is_empty() {
449        return backtrack();
450    }
451    if input.starts_with("<(") || input.starts_with(">(") {
452        return proc_sub(input);
453    }
454    if is_word_boundary(input.as_bytes()[0] as char) {
455        return backtrack();
456    }
457    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
458        .parse_next(input)
459}
460
461fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
462    delimited('\'', take_while(0.., |c| c != '\''), '\'')
463        .map(|s: &str| WordPart::SQuote(s.to_string()))
464        .parse_next(input)
465}
466
467fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
468    delimited('"', repeat(0.., dq_part).map(Word), '"')
469        .map(WordPart::DQuote)
470        .parse_next(input)
471}
472
473fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
474    delimited(("$(", ws), script, (ws, ')'))
475        .map(WordPart::CmdSub)
476        .parse_next(input)
477}
478
479fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
480    if !(input.starts_with("<(") || input.starts_with(">(")) {
481        return backtrack();
482    }
483    *input = &input[1..];
484    delimited(('(', ws), script, (ws, ')'))
485        .map(WordPart::ProcSub)
486        .parse_next(input)
487}
488
489fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
490    if !input.starts_with("$((") {
491        return backtrack();
492    }
493    let body_start = 3;
494    let bytes = input.as_bytes();
495    let mut depth: i32 = 1;
496    let mut i = body_start;
497    while i < bytes.len() {
498        match bytes[i] {
499            b'(' => depth += 1,
500            b')' => {
501                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
502                    let body = input[body_start..i].to_string();
503                    if body.contains("$(") || body.contains('`') {
504                        return backtrack();
505                    }
506                    *input = &input[i + 2..];
507                    return Ok(WordPart::Arith(body));
508                }
509                depth -= 1;
510                if depth < 0 {
511                    return backtrack();
512                }
513            }
514            _ => {}
515        }
516        i += 1;
517    }
518    backtrack()
519}
520
521fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
522    delimited('`', backtick_inner, '`')
523        .map(WordPart::Backtick)
524        .parse_next(input)
525}
526
527fn escaped(input: &mut &str) -> ModalResult<WordPart> {
528    preceded('\\', any).map(WordPart::Escape).parse_next(input)
529}
530
531fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
532    move |input: &mut &str| {
533        take_while(1.., pred)
534            .map(|s: &str| WordPart::Lit(s.to_string()))
535            .parse_next(input)
536    }
537}
538
539fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
540    move |input: &mut &str| {
541        ('$', not('(')).void().parse_next(input)?;
542        let rest: &str = take_while(0.., pred).parse_next(input)?;
543        Ok(WordPart::Lit(format!("${rest}")))
544    }
545}
546
547// === Double-quoted parts ===
548
549fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
550    if input.is_empty() || input.starts_with('"') {
551        return backtrack();
552    }
553    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
554        .parse_next(input)
555}
556
557fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
558    preceded('\\', any)
559        .map(|c: char| match c {
560            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
561            _ => WordPart::Lit(format!("\\{c}")),
562        })
563        .parse_next(input)
564}
565
566// === Backtick inner content ===
567
568fn backtick_inner(input: &mut &str) -> ModalResult<String> {
569    repeat(0.., alt((bt_escape, bt_literal)))
570        .fold(String::new, |mut acc, chunk: &str| {
571            acc.push_str(chunk);
572            acc
573        })
574        .parse_next(input)
575}
576
577fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
578    ('\\', any).take().parse_next(input)
579}
580
581fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
582    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
583}
584
585// === Compound Commands ===
586
587fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
588    eat_keyword(input, "for")?;
589    ws.parse_next(input)?;
590    let var = name.parse_next(input)?;
591    ws.parse_next(input)?;
592
593    let items = if eat_keyword(input, "in").is_ok() {
594        ws.parse_next(input)?;
595        repeat(0.., terminated(word, ws)).parse_next(input)?
596    } else {
597        vec![]
598    };
599
600    let body = do_done_body.parse_next(input)?;
601    let redirs = trailing_redirs(input)?;
602    Ok(Cmd::For { var, items, body, redirs })
603}
604
605fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
606    eat_keyword(input, "while")?;
607    ws.parse_next(input)?;
608    let cond = script.parse_next(input)?;
609    let body = do_done_body.parse_next(input)?;
610    let redirs = trailing_redirs(input)?;
611    Ok(Cmd::While { cond, body, redirs })
612}
613
614fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
615    eat_keyword(input, "until")?;
616    ws.parse_next(input)?;
617    let cond = script.parse_next(input)?;
618    let body = do_done_body.parse_next(input)?;
619    let redirs = trailing_redirs(input)?;
620    Ok(Cmd::Until { cond, body, redirs })
621}
622
623fn do_done_body(input: &mut &str) -> ModalResult<Script> {
624    sep.parse_next(input)?;
625    eat_keyword(input, "do")?;
626    sep.parse_next(input)?;
627    let body = script.parse_next(input)?;
628    sep.parse_next(input)?;
629    eat_keyword(input, "done")?;
630    Ok(body)
631}
632
633fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
634    eat_keyword(input, "if")?;
635    ws.parse_next(input)?;
636    let mut branches = vec![cond_then_body.parse_next(input)?];
637    let mut else_body = None;
638
639    loop {
640        sep.parse_next(input)?;
641        if eat_keyword(input, "elif").is_ok() {
642            ws.parse_next(input)?;
643            branches.push(cond_then_body.parse_next(input)?);
644        } else if eat_keyword(input, "else").is_ok() {
645            sep.parse_next(input)?;
646            else_body = Some(script.parse_next(input)?);
647            break;
648        } else {
649            break;
650        }
651    }
652
653    sep.parse_next(input)?;
654    eat_keyword(input, "fi")?;
655    let redirs = trailing_redirs(input)?;
656    Ok(Cmd::If { branches, else_body, redirs })
657}
658
659fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
660    let cond = script.parse_next(input)?;
661    sep.parse_next(input)?;
662    eat_keyword(input, "then")?;
663    sep.parse_next(input)?;
664    let body = script.parse_next(input)?;
665    Ok(Branch { cond, body })
666}
667
668fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
669    if !input.starts_with("[[") {
670        return backtrack();
671    }
672    let bytes = input.as_bytes();
673    if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
674        return backtrack();
675    }
676    *input = &input[2..];
677
678    let mut words: Vec<Word> = Vec::new();
679    loop {
680        ws.parse_next(input)?;
681        if at_double_bracket_end(input) {
682            *input = &input[2..];
683            let redirs = trailing_redirs(input)?;
684            return Ok(Cmd::DoubleBracket { words, redirs });
685        }
686        if input.is_empty() {
687            return backtrack();
688        }
689        let w = bracket_word.parse_next(input)?;
690        words.push(w);
691    }
692}
693
694fn at_double_bracket_end(input: &str) -> bool {
695    if !input.starts_with("]]") {
696        return false;
697    }
698    let after = &input[2..];
699    after.is_empty()
700        || after.starts_with(|c: char| {
701            matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
702        })
703}
704
705fn bracket_word(input: &mut &str) -> ModalResult<Word> {
706    repeat(1.., bracket_word_part).map(Word).parse_next(input)
707}
708
709fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
710    if input.is_empty() {
711        return backtrack();
712    }
713    if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
714        return backtrack();
715    }
716    if at_double_bracket_end(input) {
717        return backtrack();
718    }
719    alt((
720        single_quoted,
721        double_quoted,
722        arith_sub,
723        cmd_sub,
724        backtick_part,
725        escaped,
726        dollar_lit(is_bracket_literal),
727        bracket_lit,
728    ))
729    .parse_next(input)
730}
731
732fn is_bracket_literal(c: char) -> bool {
733    !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
734}
735
736fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
737    // Byte-by-byte scan relies on every stop char being single-byte ASCII —
738    // multibyte UTF-8 continuation bytes always pass `is_bracket_literal` and
739    // get consumed as part of the same `Lit`, so `end` only lands on a char
740    // boundary.
741    let bytes = input.as_bytes();
742    let mut end = 0;
743    while end < bytes.len() {
744        let c = bytes[end] as char;
745        if !is_bracket_literal(c) {
746            break;
747        }
748        if c == ']' && at_double_bracket_end(&input[end..]) {
749            break;
750        }
751        end += 1;
752    }
753    if end == 0 {
754        return backtrack();
755    }
756    let lit = input[..end].to_string();
757    *input = &input[end..];
758    Ok(WordPart::Lit(lit))
759}
760
761fn name(input: &mut &str) -> ModalResult<String> {
762    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
763        .map(|s: &str| s.to_string())
764        .parse_next(input)
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    fn p(input: &str) -> Script {
772        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
773    }
774
775    fn words(script: &Script) -> Vec<String> {
776        match &script.0[0].pipeline.commands[0] {
777            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
778            _ => panic!("expected simple command"),
779        }
780    }
781
782    fn simple(script: &Script) -> &SimpleCmd {
783        match &script.0[0].pipeline.commands[0] {
784            Cmd::Simple(s) => s,
785            _ => panic!("expected simple command"),
786        }
787    }
788
789    #[test]
790    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
791    #[test]
792    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
793    #[test]
794    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
795    #[test]
796    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
797    #[test]
798    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
799
800    #[test]
801    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
802    #[test]
803    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
804    #[test]
805    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
806    #[test]
807    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
808    #[test]
809    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
810    #[test]
811    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
812    #[test]
813    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
814    #[test]
815    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
816    #[test]
817    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
818    #[test]
819    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
820
821    #[test]
822    fn brace_group_simple() {
823        assert!(matches!(
824            &p("{ echo hello; }").0[0].pipeline.commands[0],
825            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
826        ));
827    }
828    #[test]
829    fn brace_group_multiple_stmts() {
830        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
831            assert_eq!(body.0.len(), 3);
832        } else { panic!("expected BraceGroup"); }
833    }
834    #[test]
835    fn brace_group_with_redirect() {
836        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
837            assert_eq!(redirs.len(), 1);
838            assert!(matches!(redirs[0], Redir::Write { .. }));
839        } else { panic!("expected BraceGroup"); }
840    }
841    #[test]
842    fn brace_group_with_append_redirect() {
843        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
844            assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
845        } else { panic!("expected BraceGroup"); }
846    }
847    #[test]
848    fn brace_group_with_stderr_redirect() {
849        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
850            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
851        } else { panic!("expected BraceGroup"); }
852    }
853    #[test]
854    fn brace_group_newline_separated() {
855        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
856            assert_eq!(body.0.len(), 2);
857        } else { panic!("expected BraceGroup"); }
858    }
859    #[test]
860    fn brace_group_in_pipeline() {
861        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
862        assert_eq!(pl.commands.len(), 2);
863        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
864    }
865    #[test]
866    fn brace_group_followed_by_other() {
867        let stmts = &p("{ echo a; }; echo b").0;
868        assert_eq!(stmts.len(), 2);
869        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
870    }
871    #[test]
872    fn brace_group_nested() {
873        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
874            assert_eq!(body.0.len(), 2);
875            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
876        } else { panic!("expected outer BraceGroup"); }
877    }
878    #[test]
879    fn brace_group_with_subshell_inside() {
880        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
881            assert_eq!(body.0.len(), 2);
882            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
883        } else { panic!("expected BraceGroup"); }
884    }
885    #[test]
886    fn brace_open_requires_whitespace() {
887        // {echo (no space) is NOT a brace group; it's a literal word
888        // that becomes part of a simple command. Parser should not
889        // treat it as a brace group.
890        let cmds = &p("{echo a}").0;
891        // Either parsed as a simple_cmd with a literal `{echo` token,
892        // or fails. Either way, it should NOT be a BraceGroup.
893        if !cmds.is_empty() {
894            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
895        }
896    }
897    #[test]
898    fn subshell_with_redirect() {
899        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
900            assert_eq!(redirs.len(), 1);
901        } else { panic!("expected Subshell with redir"); }
902    }
903    #[test]
904    fn for_loop_with_redirect() {
905        if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
906            assert_eq!(redirs.len(), 1);
907        } else { panic!("expected For with redir"); }
908    }
909    #[test]
910    fn for_loop_redirect_then_pipe() {
911        // `done 2>&1 | head` — redirect on the loop, then a pipe.
912        let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
913        assert_eq!(pl.commands.len(), 2);
914        assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
915    }
916    #[test]
917    fn while_and_if_with_redirect() {
918        assert!(matches!(
919            &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
920            Cmd::While { redirs, .. } if redirs.len() == 1
921        ));
922        assert!(matches!(
923            &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
924            Cmd::If { redirs, .. } if redirs.len() == 1
925        ));
926    }
927    #[test]
928    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
929
930    #[test]
931    fn redirect_dev_null() {
932        let s = p("echo hello > /dev/null");
933        let cmd = simple(&s);
934        assert_eq!(cmd.words.len(), 2);
935        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
936    }
937    #[test]
938    fn redirect_stderr() {
939        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
940    }
941    #[test]
942    fn here_string() {
943        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
944    }
945    #[test]
946    fn heredoc_bare() {
947        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
948    }
949    #[test]
950    fn heredoc_with_content() {
951        let s = p("cat <<EOF\nhello world\nEOF");
952        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
953    }
954    #[test]
955    fn heredoc_quoted_delimiter() {
956        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
957    }
958    #[test]
959    fn heredoc_strip_tabs() {
960        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
961    }
962    #[test]
963    fn heredoc_pipe_on_command_line() {
964        // Correct bash: pipe is on the command line BEFORE the body,
965        // body terminator is on its own line.
966        let s = p("cat <<EOF | grep hello\nhello\nEOF");
967        assert_eq!(s.0[0].pipeline.commands.len(), 2);
968    }
969    #[test]
970    fn heredoc_body_does_not_swallow_pipe() {
971        // Regression for the `cat <<EOF | bash\n...\nEOF` bypass: the
972        // heredoc parser must NOT consume the pipe + downstream
973        // commands as part of the body.
974        let s = p("cat <<EOF | bash\nrm\nEOF");
975        assert_eq!(
976            s.0[0].pipeline.commands.len(),
977            2,
978            "pipeline must keep `bash` as a second command"
979        );
980    }
981    #[test]
982    fn heredoc_followed_by_next_statement() {
983        // After the heredoc body terminator, the script can continue
984        // with another statement.
985        let s = p("cat <<EOF\nhello\nEOF\nls");
986        assert_eq!(s.0.len(), 2);
987    }
988
989    #[test]
990    fn env_prefix() {
991        let s = p("FOO='bar baz' ls -la");
992        let cmd = simple(&s);
993        assert_eq!(cmd.env[0].0, "FOO");
994        assert_eq!(cmd.env[0].1.eval(), "bar baz");
995    }
996    #[test]
997    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
998    #[test]
999    fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1000    #[test]
1001    fn nested_substitution() {
1002        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1003            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1004        } else { panic!("expected CmdSub"); }
1005    }
1006
1007    #[test]
1008    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1009    #[test]
1010    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1011
1012    #[test]
1013    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")); }
1014    #[test]
1015    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1016    #[test]
1017    fn if_then_fi() {
1018        if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1019            assert_eq!(branches.len(), 1);
1020            assert!(else_body.is_none());
1021        } else { panic!("expected If"); }
1022    }
1023    #[test]
1024    fn if_elif_else() {
1025        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] {
1026            assert_eq!(branches.len(), 2);
1027            assert!(else_body.is_some());
1028        } else { panic!("expected If"); }
1029    }
1030
1031    #[test]
1032    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1033    #[test]
1034    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1035    #[test]
1036    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1037
1038    #[test]
1039    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1040    #[test]
1041    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1042    #[test]
1043    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1044    #[test]
1045    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1046    #[test]
1047    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1048    #[test]
1049    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1050
1051    #[test]
1052    fn subshell_for() {
1053        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1054            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1055        } else { panic!("expected Subshell"); }
1056    }
1057    #[test]
1058    fn proc_sub_input() {
1059        let s = p("diff <(sort a.txt) <(sort b.txt)");
1060        let cmd = simple(&s);
1061        assert_eq!(cmd.words.len(), 3);
1062        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1063        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1064    }
1065    #[test]
1066    fn proc_sub_output() {
1067        let s = p("tee >(grep error > /dev/null)");
1068        let cmd = simple(&s);
1069        assert_eq!(cmd.words.len(), 2);
1070        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1071    }
1072    #[test]
1073    fn comment_only() {
1074        let s = p("# just a comment");
1075        assert!(s.0.is_empty());
1076    }
1077    #[test]
1078    fn comment_before_command() {
1079        let s = p("# comment\necho hello");
1080        assert_eq!(words(&s), ["echo", "hello"]);
1081    }
1082    #[test]
1083    fn inline_comment() {
1084        let s = p("echo hello # this is a comment");
1085        assert_eq!(words(&s), ["echo", "hello"]);
1086    }
1087    #[test]
1088    fn comment_between_commands() {
1089        let s = p("echo hello\n# middle comment\necho world");
1090        assert_eq!(s.0.len(), 2);
1091    }
1092    #[test]
1093    fn comment_after_semicolon() {
1094        let s = p("echo hello; # comment\necho world");
1095        assert_eq!(s.0.len(), 2);
1096    }
1097    #[test]
1098    fn comment_in_for_loop() {
1099        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1100    }
1101    #[test]
1102    fn quoted_redirect_in_echo() {
1103        let s = p("echo 'greater > than' test");
1104        let cmd = simple(&s);
1105        assert_eq!(cmd.words.len(), 3);
1106        assert_eq!(cmd.redirs.len(), 0);
1107    }
1108
1109    #[test]
1110    fn parses_all_safe_commands() {
1111        let cmds = [
1112            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1113            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1114            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1115            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1116            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1117            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1118            "((echo hello))", "(for x in 1 2; do echo $x; done)",
1119            "echo 'greater > than' test", "echo '$(safe)' arg",
1120            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1121            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1122            "grep foo file.txt | head -5", "cat file | sort | uniq",
1123            "ls && echo done", "ls; echo done", "ls & echo done",
1124            "grep -c , <<< 'hello,world,test'",
1125            "cat <<EOF\nhello world\nEOF",
1126            "cat <<'MARKER'\nsome text\nMARKER",
1127            "cat <<-EOF\n\thello\nEOF",
1128            "echo foo\necho bar", "ls\ncat file.txt",
1129            "git log --oneline -20 | head -5",
1130            "echo hello > /dev/null", "echo hello 2> /dev/null",
1131            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1132            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1133            "for x in 1 2 3; do echo $x; done",
1134            "for f in *.txt; do cat $f | grep pattern; done",
1135            "for x in 1 2 3; do; done",
1136            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1137            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1138            "for x in 1 2; do echo $x; done && echo finished",
1139            "for x in $(seq 1 5); do echo $x; done",
1140            "while test -f /tmp/foo; do sleep 1; done",
1141            "while ! test -f /tmp/done; do sleep 1; done",
1142            "until test -f /tmp/ready; do sleep 1; done",
1143            "if test -f foo; then echo exists; fi",
1144            "if test -f foo; then echo yes; else echo no; fi",
1145            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1146            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1147            "if true; then for x in 1 2; do echo $x; done; fi",
1148            "diff <(sort a.txt) <(sort b.txt)",
1149            "comm -23 file.txt <(sort other.txt)",
1150            "cat <(echo hello)",
1151            "# comment only",
1152            "# comment\necho hello",
1153            "echo hello # inline comment",
1154            "echo one\n# between\necho two",
1155            "! echo hello", "! test -f foo",
1156            "echo for; echo done; echo if; echo fi",
1157        ];
1158        let mut failures = Vec::new();
1159        for cmd in &cmds {
1160            if parse(cmd).is_none() { failures.push(*cmd); }
1161        }
1162        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1163    }
1164}