From 6730123fe7e9a5620143d502199c68008a69e08c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 19:19:34 +0000 Subject: [PATCH 1/2] fix(parser): redirect and heredoc combos; function redirects stop streaming - redirected function calls no longer stream their output to the caller - `&>> file` parses as `>> file 2>&1` - several heredocs per line, `N< = ctx + .stdin + .map(|s| s.lines().map(|l| l.to_string()).collect()) + .unwrap_or_default(); + let dashes = files.iter().filter(|f| *f == "-").count(); + let mut dash_idx = 0; for file in &files { if file == "-" { - let lines = ctx - .stdin - .map(|s| s.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default(); + let lines = if opts.serial { + if dash_idx == 0 { + stdin_lines.clone() + } else { + Vec::new() + } + } else { + stdin_lines + .iter() + .skip(dash_idx) + .step_by(dashes) + .cloned() + .collect() + }; + dash_idx += 1; sources.push(lines); } else { let path = if file.starts_with('/') { @@ -422,6 +442,14 @@ mod tests { assert!(result.stderr.contains("paste: -d requires an argument")); } + #[tokio::test] + async fn test_paste_dashes_share_stdin() { + let result = run_paste(&["-", "-"], Some("1\n2\n3\n")).await; + assert_eq!(result.stdout, "1\t2\n3\t\n"); + let result = run_paste(&["-s", "-", "-"], Some("1\n2\n")).await; + assert_eq!(result.stdout, "1\t2\n\n"); + } + #[tokio::test] async fn test_paste_stdin_dash() { let result = diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 211622a42..ac9211d54 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -8699,8 +8699,25 @@ impl Interpreter { self.pending_fd_capture_depth += 1; } + // Output the call's redirects will route must not stream from the + // body first (`f > out` printed `out` too), as for compounds. + let has_output_redirect = redirects.iter().any(|r| { + !matches!( + r.kind, + RedirectKind::Input | RedirectKind::HereDoc | RedirectKind::HereString + ) + }); + let saved_callback = if has_output_redirect { + self.output_callback.take() + } else { + None + }; + // Execute function body. Always restore call state even on error. let result = self.execute_command(&func_def.body).await; + if let Some(cb) = saved_callback { + self.output_callback = Some(cb); + } if capture_pending_fd { self.pending_fd_capture_depth = self.pending_fd_capture_depth.saturating_sub(1); if result.is_err() { diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 682203420..b4f64fbb8 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -73,9 +73,12 @@ impl Interpreter { stdin = Some(format!("{}\n", content).into()); } RedirectKind::HereDoc | RedirectKind::HereDocStrip => { - // << EOF / <<- EOF - use the heredoc content as stdin - let content = self.expand_word(&redirect.target).await?; - stdin = Some(content.into()); + // << EOF / <<- EOF - use the heredoc content as stdin. + // `3< { // <&FD - if FD is a coproc read FD, consume next line diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index a5843c2f5..4b5ec816c 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -149,7 +149,12 @@ impl<'a> Lexer<'a> { Some(Token::And) } else if self.peek_char() == Some('>') { self.advance(); - Some(Token::RedirectBoth) + if self.peek_char() == Some('>') { + self.advance(); + Some(Token::RedirectBothAppend) + } else { + Some(Token::RedirectBoth) + } } else { Some(Token::Background) } @@ -347,7 +352,14 @@ impl<'a> Lexer<'a> { // 2-char redirect operator (e.g. ">>", "<&", "<<") matter, so bound the // lookahead — collecting all remaining input here made every // digit-initial word O(n) and the whole lex O(n^2) (TM-DOS-024). - let input_remaining: String = self.chars.clone().take(4).collect(); + // Re-injected text (the rest of a heredoc line) comes first. + let input_remaining: String = self + .reinject_buf + .iter() + .copied() + .chain(self.chars.clone()) + .take(4) + .collect(); // Check patterns: "N>" "N>>" "N>&" "N<" "N<&" if fd_str.len() == 1 @@ -425,6 +437,17 @@ impl<'a> Lexer<'a> { } let target_fd: i32 = target_str.parse().unwrap_or(0); return Some(Token::DupFdIn(fd, target_fd)); + } else if rest.starts_with("<<") && !rest.starts_with("<<<") { + // N< Lexer<'a> { // Re-inject saved rest-of-line so subsequent tokens (pipes, commands, etc.) // are visible to the parser. Add a newline so the tokenizer sees the line break. + // The line break always comes back: it ends the command even when + // nothing followed the delimiter (`cat < Parser<'a> { }); } } + Some(tokens::Token::RedirectBothAppend) => { + self.advance(); + if let Ok(target) = self.expect_word() { + redirects.extend(Self::append_both(None, target)); + } + } Some(tokens::Token::DupOutput) => { self.advance(); if let Ok(target) = self.expect_word() { @@ -688,9 +694,14 @@ impl<'a> Parser<'a> { }); } } - Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => { - let strip_tabs = - matches!(self.current_token, Some(tokens::Token::HereDocStrip)); + Some(tokens::Token::HereDoc) + | Some(tokens::Token::HereDocStrip) + | Some(tokens::Token::HereDocFd(..)) => { + let (fd, strip_tabs) = match self.current_token { + Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), + Some(tokens::Token::HereDocStrip) => (None, true), + _ => (None, false), + }; self.advance(); let (delimiter, quoted) = match &self.current_token { Some(tokens::Token::Word(w)) => (w.clone(), false), @@ -729,14 +740,13 @@ impl<'a> Parser<'a> { RedirectKind::HereDoc }; redirects.push(Redirect { - fd: None, + fd, fd_var: None, kind, target, }); - // Rest-of-line tokens re-injected by lexer; break so callers - // can see pipes/semicolons. - break; + // Rest-of-line tokens were re-injected by the lexer: more + // redirects may follow (`done <out`). } _ => break, } @@ -2483,6 +2493,7 @@ impl<'a> Parser<'a> { /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. fn parse_heredoc_redirect( &mut self, + fd: Option, strip_tabs: bool, redirects: &mut Vec, ) -> Result<()> { @@ -2531,7 +2542,7 @@ impl<'a> Parser<'a> { }; redirects.push(Redirect { - fd: None, + fd, fd_var: None, kind, target, @@ -2539,112 +2550,25 @@ impl<'a> Parser<'a> { // Advance so re-injected rest-of-line tokens are picked up self.advance(); - - // Consume any trailing redirects on the same line (e.g. `cat < file`) - self.collect_trailing_redirects(redirects); Ok(()) } - /// Consume redirect tokens that follow a heredoc on the same line. - fn collect_trailing_redirects(&mut self, redirects: &mut Vec) { - while let Some(tok) = &self.current_token { - match tok { - tokens::Token::RedirectOut | tokens::Token::Clobber => { - let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { - RedirectKind::Clobber - } else { - RedirectKind::Output - }; - self.advance(); - if let Ok(target) = self.expect_word() { - redirects.push(Redirect { - fd: None, - fd_var: None, - kind, - target, - }); - } - } - tokens::Token::RedirectAppend => { - self.advance(); - if let Ok(target) = self.expect_word() { - redirects.push(Redirect { - fd: None, - fd_var: None, - kind: RedirectKind::Append, - target, - }); - } - } - tokens::Token::RedirectFd(fd) => { - let fd = *fd; - self.advance(); - if let Ok(target) = self.expect_word() { - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Output, - target, - }); - } - } - tokens::Token::DupFdCloseOut(fd) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal("-"), - }); - } - tokens::Token::DupInput => { - self.advance(); - if let Ok(target) = self.expect_word() { - redirects.push(Redirect { - fd: Some(0), - fd_var: None, - kind: RedirectKind::DupInput, - target, - }); - } - } - tokens::Token::DupFdIn(src_fd, dst_fd) => { - let src_fd = *src_fd; - let dst_fd = *dst_fd; - self.advance(); - redirects.push(Redirect { - fd: Some(src_fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal(dst_fd.to_string()), - }); - } - tokens::Token::DupFdClose(fd) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal("-"), - }); - } - tokens::Token::RedirectFdIn(fd) => { - let fd = *fd; - self.advance(); - if let Ok(target) = self.expect_word() { - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Input, - target, - }); - } - } - _ => break, - } - } + /// `&>> file` is `>> file 2>&1` (bash documents it as that). + fn append_both(fd_var: Option, target: Word) -> [Redirect; 2] { + [ + Redirect { + fd: None, + fd_var, + kind: RedirectKind::Append, + target, + }, + Redirect { + fd: Some(2), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("1"), + }, + ] } /// Extract fd-variable name from `{varname}` pattern in the last word. @@ -2812,11 +2736,17 @@ impl<'a> Parser<'a> { target, }); } - Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => { - let strip_tabs = - matches!(self.current_token, Some(tokens::Token::HereDocStrip)); - self.parse_heredoc_redirect(strip_tabs, &mut redirects)?; - break; + Some(tokens::Token::HereDoc) + | Some(tokens::Token::HereDocStrip) + | Some(tokens::Token::HereDocFd(..)) => { + let (fd, strip_tabs) = match self.current_token { + Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), + Some(tokens::Token::HereDocStrip) => (None, true), + _ => (None, false), + }; + // Words and redirects may follow on the same line + // (`cat < { let word = self.expect_word()?; @@ -2833,6 +2763,12 @@ impl<'a> Parser<'a> { target, }); } + Some(tokens::Token::RedirectBothAppend) => { + let fd_var = Self::pop_fd_var(&mut words); + self.advance(); + let target = self.expect_word()?; + redirects.extend(Self::append_both(fd_var, target)); + } Some(tokens::Token::DupOutput) => { let fd_var = Self::pop_fd_var(&mut words); self.advance(); diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index 81ad385b2..a66e04e94 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -66,6 +66,10 @@ pub enum Token { /// Here document with tab stripping (<<-) HereDocStrip, + /// Here document on a numbered descriptor (`3<) RedirectBoth, + /// Append both stdout and stderr (&>>) + RedirectBothAppend, + /// Clobber redirect (>|) - force overwrite even with noclobber Clobber, diff --git a/crates/bashkit/tests/integration/pipeline_tests.rs b/crates/bashkit/tests/integration/pipeline_tests.rs index 066a3a08d..2ae3e4fab 100644 --- a/crates/bashkit/tests/integration/pipeline_tests.rs +++ b/crates/bashkit/tests/integration/pipeline_tests.rs @@ -141,3 +141,25 @@ async fn command_substitution_output_is_not_streamed() { assert_eq!(result.stdout, "cgi\n"); assert_eq!(*streamed.lock().unwrap(), "cgi\n"); } + +/// A function call's redirected output goes to the file only; the function +/// body must not stream it to the caller first (`f > out` printed `out`). +#[tokio::test] +async fn redirected_function_output_is_not_streamed() { + let streamed = Arc::new(Mutex::new(String::new())); + let sink = streamed.clone(); + let mut bash = Bash::new(); + let result = bash + .exec_streaming( + "f() { echo out; echo err >&2; }\nf > /tmp/o1 2> /tmp/e1\nf 2>/dev/null\ncat /tmp/o1 /tmp/e1", + Box::new(move |stdout, stderr| { + let mut s = sink.lock().unwrap(); + s.push_str(&stdout.to_string()); + s.push_str(&stderr.to_string()); + }), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "out\nout\nerr\n"); + assert_eq!(*streamed.lock().unwrap(), "out\nout\nerr\n"); +} diff --git a/crates/bashkit/tests/spec_cases/bash/redirect-heredoc-combos.test.sh b/crates/bashkit/tests/spec_cases/bash/redirect-heredoc-combos.test.sh new file mode 100644 index 000000000..75fd381f2 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/redirect-heredoc-combos.test.sh @@ -0,0 +1,73 @@ +### append_both_operator +echo a &>> /tmp/ab.txt +{ echo b; echo c >&2; } &>>/tmp/ab.txt +cat /tmp/ab.txt +### expect +a +b +c +### end + +### function_dup_before_file_redirect +f() { echo out; echo err >&2; } +f 2>&1 > /tmp/fo.txt | sed 's/^/pipe:/' +f > /tmp/f1.txt 2> /tmp/f2.txt +echo "$(cat /tmp/fo.txt) $(cat /tmp/f1.txt) $(cat /tmp/f2.txt)" +### expect +pipe:err +out out err +### end + +### two_heredocs_last_wins +cat </tmp/w.txt +w1 +A +ignored +B +cat /tmp/w.txt +### expect +[w1] +### end + +### heredoc_then_words +printf 'x\n' > /tmp/hw.txt +cat <out`. The fd-redirect lookahead reads re-injected text first. +`&>> file` parses as `>> file 2>&1`. + **`[[` lexing.** `[[` is the keyword only when followed by a word break (space, tab, newline, `;`, `&`, `|`, `(`, `)`, or end); `[[:digit:]]*` is a bracket-expression word. diff --git a/knowledge/log.md b/knowledge/log.md index 2e02c06b9..30392b4b7 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -10,6 +10,7 @@ * **Feature**: `grep` and `tr` stream through pipelines. In a pipeline stage they read their input in ~4 KiB pieces (record-aligned for `grep`, UTF-8-aligned for `tr`) and write results as they go, so `while :; do echo y; done | grep y | head -2`, `yes | tr y n | head -1` and `grep . big | head -1` end with SIGPIPE (`141`) like bash instead of running to a limit; `grep -m` stops reading at its budget. Summarizing options (`grep -c/-l/-L/-q`, context, `-b`, `-r`; `tr -s`) stay buffered. L-PIPE-001 narrowed. * **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path. * **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches. +* **Fix**: Redirect and heredoc gaps from the Debian-oracle cases. A function call's redirected output no longer also streams to the caller (`f > out` printed `out` under `exec_streaming` and the CLI; `f 2>/dev/null` leaked stderr). `&>> file` parses (as `>> file 2>&1`). Heredocs: several per line (`cat <out`). `paste - -` shares stdin between its `-` operands like GNU. * **Feature**: `fmt` builtin, on the uutils `fmt` engine (MIT, vendored: Knuth-Plass line breaking, paragraphs, `-c`/`-t`/`-s`/`-u`/`-p`, `-w`/`-g`, `-WIDTH`). Width rules and error messages follow GNU. Line breaks can differ from GNU `fmt` on long paragraphs (L-FMT-001); GNU's GPL code was not ported. Step 4 of the level-up roadmap (missing commands). Spec: `fmt.test.sh`. See foundations/builtins "fmt". * **Perf**: The CPython snapshot ships as xz (4.99 MB, was ~6.7 MB as gzip; decoded by `lzma-rs` in build.rs). The room went to preloading `urllib.request` (http.client, email), `tomllib`, `configparser` and `xml.etree.ElementTree`: their imports went from 0.1-0.47 s per call to ~0; `print(1)` unchanged at ~4.1 ms, first call ~20 ms. `doctest` works again (a `pdb` stand-in; `breakpoint()` prints a notice), `mailbox` dropped. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#startup-is-the-product-constraint). * **Feature**: `import requests`, `import httpx` and `import httpx2` work in CPython. They are bashkit's own compact implementations of the common API over the HTTP bridge, preloaded in the snapshot (import cost ~0 vs ~3.1 s for vendored upstream requests). http.client read timeouts now surface from `getresponse()`. L-CPY-003 updated, L-CPY-010 added. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#requests-and-httpx-bashkits-own-modules). From 72b78b48f8f0a2a0ae19b75cb272e24d9f02753b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 19:44:17 +0000 Subject: [PATCH 2/2] refactor(parser): keep heredoc and &>> parsing off the recursive frame The coverage build overflowed TM-DOS-044 depth-50 nested $( ). Move the new arms into out-of-line helpers, share one heredoc parser between simple and compound commands, and heap-allocate the nested $( ) parser. Debug stack need for the test: 2.05 MB -> 2.01 MB (main 2.02 MB). Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/parser/mod.rs | 148 +++++++++++++------------------ 1 file changed, 60 insertions(+), 88 deletions(-) diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 42613b933..6e8c3ba26 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -580,7 +580,7 @@ impl<'a> Parser<'a> { Some(tokens::Token::RedirectBothAppend) => { self.advance(); if let Ok(target) = self.expect_word() { - redirects.extend(Self::append_both(None, target)); + Self::push_append_both(&mut redirects, None, target); } } Some(tokens::Token::DupOutput) => { @@ -697,56 +697,9 @@ impl<'a> Parser<'a> { Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) | Some(tokens::Token::HereDocFd(..)) => { - let (fd, strip_tabs) = match self.current_token { - Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), - Some(tokens::Token::HereDocStrip) => (None, true), - _ => (None, false), - }; - self.advance(); - let (delimiter, quoted) = match &self.current_token { - Some(tokens::Token::Word(w)) => (w.clone(), false), - Some(tokens::Token::LiteralWord(w)) => (w.clone(), true), - Some(tokens::Token::QuotedWord(w)) - | Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), true), - _ => break, - }; - let (content, rest_of_line_chars) = self - .lexer - .read_heredoc_with_strip_metered(&delimiter, strip_tabs); - self.tick_units(rest_of_line_chars)?; - let content = if strip_tabs { - let had_trailing_newline = content.ends_with('\n'); - let mut stripped: String = content - .lines() - .map(|l| l.trim_start_matches('\t')) - .collect::>() - .join("\n"); - if had_trailing_newline { - stripped.push('\n'); - } - stripped - } else { - content - }; - self.advance(); - let target = if quoted { - Word::quoted_literal(content) - } else { - self.parse_word(heredoc_body_escapes(&content)) - }; - let kind = if strip_tabs { - RedirectKind::HereDocStrip - } else { - RedirectKind::HereDoc - }; - redirects.push(Redirect { - fd, - fd_var: None, - kind, - target, - }); - // Rest-of-line tokens were re-injected by the lexer: more + // Rest-of-line tokens are re-injected by the lexer: more // redirects may follow (`done <out`). + self.parse_heredoc_redirect(&mut redirects)?; } _ => break, } @@ -2491,12 +2444,15 @@ impl<'a> Parser<'a> { } /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. - fn parse_heredoc_redirect( - &mut self, - fd: Option, - strip_tabs: bool, - redirects: &mut Vec, - ) -> Result<()> { + // Out of line: its body-reading temporaries must not grow the frame + // of the recursive command parser. + #[inline(never)] + fn parse_heredoc_redirect(&mut self, redirects: &mut Vec) -> Result<()> { + let (fd, strip_tabs) = match self.current_token { + Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), + Some(tokens::Token::HereDocStrip) => (None, true), + _ => (None, false), + }; self.advance(); // Get the delimiter word and track if it was quoted let (delimiter, quoted) = match &self.current_token { @@ -2553,22 +2509,38 @@ impl<'a> Parser<'a> { Ok(()) } - /// `&>> file` is `>> file 2>&1` (bash documents it as that). - fn append_both(fd_var: Option, target: Word) -> [Redirect; 2] { - [ - Redirect { - fd: None, - fd_var, - kind: RedirectKind::Append, - target, - }, - Redirect { - fd: Some(2), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal("1"), - }, - ] + /// `{fd}&>> file` in a simple command. Out of line, like + /// [`Self::push_append_both`], to keep the recursive parser's frame small. + #[inline(never)] + fn parse_append_both( + &mut self, + words: &mut Vec, + redirects: &mut Vec, + ) -> Result<()> { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + Self::push_append_both(redirects, fd_var, target); + Ok(()) + } + + /// `&>> file` is `>> file 2>&1` (bash documents it as that). Out of + /// line so the two `Redirect` temporaries stay off the stack frame of + /// the recursive command parsers. + #[inline(never)] + fn push_append_both(redirects: &mut Vec, fd_var: Option, target: Word) { + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::Append, + target, + }); + redirects.push(Redirect { + fd: Some(2), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("1"), + }); } /// Extract fd-variable name from `{varname}` pattern in the last word. @@ -2739,14 +2711,9 @@ impl<'a> Parser<'a> { Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) | Some(tokens::Token::HereDocFd(..)) => { - let (fd, strip_tabs) = match self.current_token { - Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), - Some(tokens::Token::HereDocStrip) => (None, true), - _ => (None, false), - }; // Words and redirects may follow on the same line // (`cat < { let word = self.expect_word()?; @@ -2764,10 +2731,7 @@ impl<'a> Parser<'a> { }); } Some(tokens::Token::RedirectBothAppend) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.extend(Self::append_both(fd_var, target)); + self.parse_append_both(&mut words, &mut redirects)?; } Some(tokens::Token::DupOutput) => { let fd_var = Self::pop_fd_var(&mut words); @@ -3105,6 +3069,19 @@ impl<'a> Parser<'a> { } /// Parse a word string into a Word with proper parts (variables, literals) + /// Parser for a `$(...)` body, on the heap: one lives per nesting level + /// while the inner script parses, so keeping it out of `parse_word`'s + /// frame leaves room for deep nesting (TM-DOS-044). + // THREAT[TM-DOS-021]: Propagate parent parser limits to child parser + // to prevent depth limit bypass via nested command substitution. + #[inline(never)] + fn nested_parser<'b>(&self, src: &'b str) -> Box> { + let remaining_depth = self.max_depth.saturating_sub(self.current_depth); + let mut parser = Box::new(Parser::with_limits(src, remaining_depth, self.fuel)); + parser.execution_budget = self.execution_budget.clone(); + parser + } + fn parse_word(&self, s: String) -> Word { let mut parts = Vec::new(); let mut part_quoted = Vec::new(); @@ -3207,12 +3184,7 @@ impl<'a> Parser<'a> { } cmd_str.push(c); } - // THREAT[TM-DOS-021]: Propagate parent parser limits to child parser - // to prevent depth limit bypass via nested command substitution. - let remaining_depth = self.max_depth.saturating_sub(self.current_depth); - let mut inner_parser = - Parser::with_limits(&cmd_str, remaining_depth, self.fuel); - inner_parser.execution_budget = self.execution_budget.clone(); + let inner_parser = self.nested_parser(&cmd_str); // A failed inner parse must never make the part vanish: // dropping it splices the literals on either side into a // word that appears nowhere in the source (`a$(|)b` ->