From 27c31483ed4fcb7a41edb880a027b66633c7c6bd Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 02:11:07 +0000 Subject: [PATCH] fix(builtins): xargs and empty pipes give stdin at EOF; printf %q keeps UTF-8 text --- .../src/builtins/generated/format_support.rs | 19 ++++++++++++++----- crates/bashkit/src/builtins/pipeline.rs | 5 ++++- crates/bashkit/src/interpreter/mod.rs | 6 +++--- .../spec_cases/bash/bash-stdin-pipe.test.sh | 8 ++++++++ .../tests/spec_cases/bash/printf.test.sh | 7 +++++++ .../tests/spec_cases/bash/xargs.test.sh | 10 ++++++++++ knowledge/log.md | 1 + 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/bashkit/src/builtins/generated/format_support.rs b/crates/bashkit/src/builtins/generated/format_support.rs index 327f330f2..5c40e9670 100644 --- a/crates/bashkit/src/builtins/generated/format_support.rs +++ b/crates/bashkit/src/builtins/generated/format_support.rs @@ -104,14 +104,17 @@ fn shell_quote(s: &str) -> String { return "''".to_string(); } - let needs_quoting = s - .chars() - .any(|c| !c.is_ascii_alphanumeric() && !"_/.:-=+@,%^".contains(c)); + // Under UTF-8, bash leaves printable non-ASCII characters as they are + // (`printf %q 'com espaço'` is `com\ espaço`). + let safe = |c: char| { + c.is_ascii_alphanumeric() || "_/.:-=+@,%^".contains(c) || (!c.is_ascii() && !c.is_control()) + }; + let needs_quoting = s.chars().any(|c| !safe(c)); if !needs_quoting { return s.to_string(); } - let has_control = s.chars().any(|c| (c as u32) < 32 || c as u32 == 127); + let has_control = s.chars().any(char::is_control); if has_control { let mut out = String::from("$'"); for ch in s.chars() { @@ -124,6 +127,12 @@ fn shell_quote(s: &str) -> String { c if (c as u32) < 32 || c as u32 == 127 => { out.push_str(&format!("\\x{:02x}", c as u32)); } + c if c.is_control() => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("\\{b:03o}")); + } + } c => out.push(c), } } @@ -133,7 +142,7 @@ fn shell_quote(s: &str) -> String { let mut out = String::new(); for ch in s.chars() { - if ch.is_ascii_alphanumeric() || "_/.:-=+@,%^".contains(ch) { + if safe(ch) { out.push(ch); } else { out.push('\\'); diff --git a/crates/bashkit/src/builtins/pipeline.rs b/crates/bashkit/src/builtins/pipeline.rs index ebe865827..15750b11e 100644 --- a/crates/bashkit/src/builtins/pipeline.rs +++ b/crates/bashkit/src/builtins/pipeline.rs @@ -536,7 +536,10 @@ impl XargsOptions { SubCommand { name, args: cmd, - stdin: None, + // GNU xargs gives each command /dev/null as stdin: the + // items are already consumed (`printf '' | xargs wc -l` + // prints 0). + stdin: Some(crate::StreamData::new()), assignments: match self.process_slot_var { Some(ref var) => vec![(var.clone(), (idx % slot_count).to_string())], None => Vec::new(), diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index d578d360c..fa6424324 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -7227,7 +7227,9 @@ impl Interpreter { let stdin = if stdin.is_some() { stdin } else if let Some(ref ps) = self.pipeline_stdin { - if !ps.is_empty() { + // An empty pipe is still stdin at EOF: `printf '' | { wc -l; }` + // prints 0, not nothing. + { if name == "read" { // Consume one record (line, `-d` delimiter, `-n` count, // `\` continuation) from pipeline stdin. @@ -7239,8 +7241,6 @@ impl Interpreter { } else { Some(ps.clone()) } - } else { - None } } else { None diff --git a/crates/bashkit/tests/spec_cases/bash/bash-stdin-pipe.test.sh b/crates/bashkit/tests/spec_cases/bash/bash-stdin-pipe.test.sh index af6502e14..ec31d1c2c 100644 --- a/crates/bashkit/tests/spec_cases/bash/bash-stdin-pipe.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bash-stdin-pipe.test.sh @@ -20,3 +20,11 @@ echo "test input" | bash /tmp/read_test.sh ### expect line: test input ### end + +### empty_pipe_is_stdin_at_eof_for_compound_commands +printf '' | { wc -l; } +f() { wc -l; }; printf '' | f +### expect +0 +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index 13c2b4288..d5ecfaa27 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -282,3 +282,10 @@ printf '\xff\xfe' | od -An -tx1 ### expect ff fe ### end + +### printf_q_keeps_printable_non_ascii +printf '%q\n' 'com espaço' 'a€b' +### expect +com\ espaço +a€b +### end diff --git a/crates/bashkit/tests/spec_cases/bash/xargs.test.sh b/crates/bashkit/tests/spec_cases/bash/xargs.test.sh index b011ba326..3029ffef2 100644 --- a/crates/bashkit/tests/spec_cases/bash/xargs.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/xargs.test.sh @@ -95,3 +95,13 @@ printf "x\n" | xargs -r echo got ### expect got x ### end + +### xargs_empty_input_runs_command_once_with_empty_stdin +# GNU xargs without -r runs the command once; its stdin is at EOF +printf '' | xargs wc -l +printf '' | xargs -r wc -l +echo done +### expect +0 +done +### end diff --git a/knowledge/log.md b/knowledge/log.md index 0174db37a..73da52423 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -91,6 +91,7 @@ * **Fix**: Declaration builtins and local scope match bash. `declare`/`typeset`/`local`/`export`/`readonly` share one engine with bash's option sets, errors and `declare -p` format (quoting, assoc hash order); locals use shallow binding saved in the call frame, so nested functions, `unset` of a caller's local, `declare -g` past a local, and `local -n` behave as in bash. Compound operands stay one `CompoundAssignment` word part (keyed `[k]="v w"` and `` [k]=`cmd` `` elements lex as one word). Readonly assignment and circular namerefs abandon the line. See [Builtins](foundations/builtins.md#declaration-builtins-and-variable-scope). * **Fix**: Arithmetic follows bash's grammar and error model. New tokenizer + precedence-climbing evaluator (`interpreter/arithmetic.rs`): comma lists, chained/compound assignment, `**` right-assoc, `base#n` up to base 64, `noeval` short-circuit. `$((1/0))`, syntax errors and bad subscripts now print bash's message and abort the rest of the line (status 1) instead of yielding 0; `((...))` and `let` return 1. `((z <<= 65))` is read as raw text, not a heredoc. Bounds unchanged (`MAX_ARITHMETIC_DEPTH`, fuel; TM-DOS-026). See [Parser](foundations/parser.md#arithmetic-expressions). * **Fix**: Named file descriptors follow bash. `{var}>file` (and `<`, `>>`, `<>`, `<<<`, `<<`, `>&N`, `<&N`) allocates the lowest free fd >= 10 into `var` (local, array element; readonly fails) on any command, simple or compound, and the fd stays open as bash never undoes `{var}` redirections; `{var}>&-` closes it and a closed fd is reused. `>&N`/`<&N` to a closed fd is `Bad file descriptor` (status 1), a dup to a word is `ambiguous redirect`. The lexer accepts multi-digit fds (`10>x`), `<>` and `N<<f` in a loop is capped by `max_file_descriptors` (TM-DOS-063 tests updated: a prior `$v` value is ignored). Gaps in [Limitations](operations/limitations.md) ("Named fds / `<>`"). +* **Fix**: xargs runs its command with stdin at EOF (`printf '' | xargs wc -l` prints 0); an empty pipe now reaches compound commands and functions as stdin at EOF; `printf %q` leaves printable non-ASCII unescaped under UTF-8. ## 2026-10-06