From 8a800d13ab3dc4e2d1c74accf0b36768580423c7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 02:58:14 +0000 Subject: [PATCH] fix(interpreter): diagnostics carry bash's "$0: line N:" prefix Shell diagnostics now read like non-interactive bash: `SCRIPT: line 2: x: command not found`, `bash: line 1: type: h: not found` under -c, instead of a bare `bash: `. The name is BASH_SOURCE[0] when a file is being read (a sourced file names itself), else $0. - Interpreter messages go through Interpreter::diag_prefix / diag. - Bundled builtins keep their `bash: name:` / `name:` text; the builtin dispatch point rewrites the line start before redirects or streaming see it. Usage lines, coreutils-style builtins and execution plans are untouched. - eval syntax errors read `$0: eval: line N:`; failglob, arithmetic and write-error messages honor $0. - `source missing` ends with a newline and reports the missing path; `source` with no operand exits 2 with bash's usage line. - Redirect failures on while/for/if/case report the closing line. - `set -u` names a bare positional `$2` like bash. - Child shells (bash -c, scripts by path) get their own name and lines. - Bash::set_interactive (used by the CLI REPL and Terminal) keeps the `bash: msg` form of `bash -i`. bash-oracle: match 92 -> 98, strict 71 -> 89 of 106 (with #2622) (floors raised). --- crates/bashkit-cli/src/interactive.rs | 3 + crates/bashkit/src/interpreter/arithmetic.rs | 2 +- crates/bashkit/src/interpreter/declare.rs | 78 ++-- crates/bashkit/src/interpreter/expansion.rs | 22 +- crates/bashkit/src/interpreter/mod.rs | 422 ++++++++++++++---- crates/bashkit/src/interpreter/redirection.rs | 74 ++- crates/bashkit/src/lib.rs | 20 + crates/bashkit/src/terminal/mod.rs | 2 + .../integration/builtin_validation_tests.rs | 11 +- .../integration/command_resolver_tests.rs | 2 +- .../integration/diagnostic_prefix_tests.rs | 233 ++++++++++ .../integration/glob_fuzz_scaffold_tests.rs | 14 +- crates/bashkit/tests/integration/main.rs | 1 + .../tests/integration/threat_model_tests.rs | 6 +- knowledge/log.md | 1 + knowledge/operations/limitations.md | 1 + scripts/bash-oracle/README.md | 4 +- scripts/bash-oracle/floor.txt | 2 +- scripts/bash-oracle/run.py | 3 +- 19 files changed, 698 insertions(+), 203 deletions(-) create mode 100644 crates/bashkit/tests/integration/diagnostic_prefix_tests.rs diff --git a/crates/bashkit-cli/src/interactive.rs b/crates/bashkit-cli/src/interactive.rs index b2d00bb7d..d3a318df9 100644 --- a/crates/bashkit-cli/src/interactive.rs +++ b/crates/bashkit-cli/src/interactive.rs @@ -439,6 +439,9 @@ impl ExitState { } pub async fn run(mut bash: bashkit::Bash, exit_state: Arc) -> Result { + // Diagnostics as `bash -i` prints them: `bash: x: ...`, no `line N:`. + bash.set_interactive(true); + // Set up interactive environment set_interactive_env(&mut bash).await; diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index 43b6d4b94..88b2aa48a 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -831,7 +831,7 @@ impl Interpreter { /// `prefix` names the reporting builtin (`((: `, `let: `), empty for an /// expansion. pub(super) fn arith_diag(&self, prefix: &str, msg: &str) -> String { - format!("bash: line {}: {prefix}{msg}\n", self.current_line) + self.diag(format!("{prefix}{msg}\n")) } pub(super) fn take_arith_error(&self) -> Option { diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index 09f0d2f9b..bca160ad1 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -586,10 +586,10 @@ impl Interpreter { if !kind.allowed_options().contains(c) { let sign = if set { '-' } else { '+' }; return Ok(ExecResult::err( - format!( - "bash: {cmd}: {sign}{c}: invalid option\n{}", + self.diag(format!( + "{cmd}: {sign}{c}: invalid option\n{}", kind.usage(cmd) - ), + )), 2, )); } @@ -641,7 +641,7 @@ impl Interpreter { } } return Ok(ExecResult::err( - "bash: local: can only be used in a function\n".to_string(), + self.diag("local: can only be used in a function\n"), 1, )); } @@ -703,7 +703,7 @@ impl Interpreter { if !self.scoped.functions.contains_key(name.as_str()) { status = 1; if matches!(kind, DeclKind::Export | DeclKind::Readonly) { - err.push_str(&format!("bash: {cmd}: {name}: not a function\n")); + err.push_str(&self.diag(format!("{cmd}: {name}: not a function\n"))); } } else if matches!(kind, DeclKind::Declare | DeclKind::Local) { out.push_str(&show(name, false)); @@ -733,7 +733,7 @@ impl Interpreter { match self.format_declare_line(name) { Some(line) => out.push_str(&line), None => { - err.push_str(&format!("bash: {cmd}: {name}: not found\n")); + err.push_str(&self.diag(format!("{cmd}: {name}: not found\n"))); status = 1; } } @@ -832,10 +832,10 @@ impl Interpreter { _ => (lhs, None), }; if !is_valid_var_name(base) { - return Ok(Some(format!( - "bash: {cmd}: `{}': not a valid identifier\n", + return Ok(Some(self.diag(format!( + "{cmd}: `{}': not a valid identifier\n", display_arg() - ))); + )))); } // THREAT[TM-INJ-012/014/015]: internal marker names are never declared. if is_internal_variable(base) { @@ -870,9 +870,9 @@ impl Interpreter { match self.resolve_nameref_strict(lhs) { Ok(t) => t, Err(()) => { - return Ok(Some(format!( - "bash: {cmd}: warning: {base}: circular name reference\n" - ))); + return Ok(Some(self.diag(format!( + "{cmd}: warning: {base}: circular name reference\n" + )))); } } }; @@ -901,7 +901,9 @@ impl Interpreter { .cloned() .unwrap_or_default(); if saved.attrs.is_some_and(|a| a.contains(VarAttrs::READONLY)) { - return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + return Ok(Some( + self.diag(format!("{cmd}: {name}: readonly variable\n")), + )); } let new_value = if append { saved.value.clone().unwrap_or_default() + v @@ -939,7 +941,9 @@ impl Interpreter { if make_local && !self.is_local_in_current_frame(name) { if self.is_var_readonly(name) { - return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + return Ok(Some( + self.diag(format!("{cmd}: {name}: readonly variable\n")), + )); } self.make_local(name); } @@ -949,7 +953,9 @@ impl Interpreter { if self.is_var_readonly(name) && (value.is_some() || opts.off.readonly || opts.off.export && kind == DeclKind::Export) { - return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + return Ok(Some( + self.diag(format!("{cmd}: {name}: readonly variable\n")), + )); } let is_compound = matches!(value, Some(DeclValue::Compound(_))); @@ -971,7 +977,7 @@ impl Interpreter { None }; if let Some(what) = conversion { - let msg = format!("bash: {cmd}: {name}: cannot convert {what} array\n"); + let msg = self.diag(format!("{cmd}: {name}: cannot convert {what} array\n")); if is_compound { // With an array value bash abandons the whole command line. return Err(crate::error::Error::LineAbort(msg)); @@ -1015,9 +1021,9 @@ impl Interpreter { None => {} Some(DeclValue::Compound(words)) => { if subscript.is_some() { - return Ok(Some(format!( - "bash: {cmd}: {name}: cannot assign list to array member\n" - ))); + return Ok(Some(self.diag(format!( + "{cmd}: {name}: cannot assign list to array member\n" + )))); } let assoc = self.var_kind(name) == VarKind::Assoc; self.assign_array_words(name, &words, append, assoc).await?; @@ -1106,32 +1112,32 @@ impl Interpreter { let target = match value { Some(DeclValue::Str(t)) => Some(t), Some(DeclValue::Compound(_)) => { - return Some(format!( - "bash: {cmd}: {name}: reference variable cannot be an array\n" - )); + return Some(self.diag(format!( + "{cmd}: {name}: reference variable cannot be an array\n" + ))); } None => None, }; if let Some(t) = &target { if !valid_nameref_target(t) { - return Some(format!( - "bash: {cmd}: `{t}': invalid variable name for name reference\n" - )); + return Some(self.diag(format!( + "{cmd}: `{t}': invalid variable name for name reference\n" + ))); } if t == name && !make_local { - return Some(format!( - "bash: {cmd}: {name}: nameref variable self references not allowed\n" - )); + return Some(self.diag(format!( + "{cmd}: {name}: nameref variable self references not allowed\n" + ))); } } if make_local && !self.is_local_in_current_frame(name) { if self.is_var_readonly(name) { - return Some(format!("bash: {cmd}: {name}: readonly variable\n")); + return Some(self.diag(format!("{cmd}: {name}: readonly variable\n"))); } self.make_local(name); } if target.is_some() && self.is_var_readonly(name) { - return Some(format!("bash: {cmd}: {name}: readonly variable\n")); + return Some(self.diag(format!("{cmd}: {name}: readonly variable\n"))); } let target = match target { Some(t) => t, @@ -1144,9 +1150,9 @@ impl Interpreter { v } Some(v) => { - return Some(format!( - "bash: {cmd}: `{v}': invalid variable name for name reference\n" - )); + return Some(self.diag(format!( + "{cmd}: `{v}': invalid variable name for name reference\n" + ))); } None => String::new(), }, @@ -1389,9 +1395,9 @@ impl Interpreter { let len = map.keys().max().map_or(0, |m| m + 1) as i64; let i = len + raw; if i < 0 { - return Err(crate::error::Error::LineAbort(format!( - "bash: {name}[{key_text}]: bad array subscript\n" - ))); + return Err(crate::error::Error::LineAbort( + self.diag(format!("{name}[{key_text}]: bad array subscript\n")), + )); } i as usize } else { diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 8ae400e54..e3096941b 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -301,9 +301,9 @@ impl Interpreter { for part in &word.parts { match part { WordPart::BadSubstitution(text) => { - return Err(crate::error::Error::LineAbort(format!( - "bash: {text}: bad substitution\n" - ))); + return Err(crate::error::Error::LineAbort( + self.diag(format!("{text}: bad substitution\n")), + )); } WordPart::CompoundAssignment { .. } => { // Only declaration builtins take `name=(...)`; elsewhere @@ -364,7 +364,7 @@ impl Interpreter { } WordPart::Variable(name) => { if self.is_nounset() && !self.is_variable_set(name) { - self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); + self.nounset_error = Some(self.unbound_variable_diag(name)); } if name == "*" && word.quoted { let positional = self @@ -446,7 +446,7 @@ impl Interpreter { | ParameterOp::Error ) { - self.nounset_error = Some("bash: ${}: bad substitution\n".to_string()); + self.nounset_error = Some(self.diag("${}: bad substitution\n")); continue; } @@ -465,13 +465,13 @@ impl Interpreter { && !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && (!is_set || (*colon_variant && value.is_empty())) { - return Err(crate::error::Error::LineAbort(format!( - "bash: ${name}: cannot assign in this way\n" - ))); + return Err(crate::error::Error::LineAbort( + self.diag(format!("${name}: cannot assign in this way\n")), + )); } if self.is_nounset() && !suppress_nounset && !is_set { - self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); + self.nounset_error = Some(self.diag(format!("{name}: unbound variable\n"))); } if operand.contains("$(") { @@ -1659,9 +1659,9 @@ impl Interpreter { if use_default { let expanded = self.expand_operand(operand); let msg = if expanded.is_empty() { - format!("bash: {}: parameter null or not set\n", name) + self.diag(format!("{}: parameter null or not set\n", name)) } else { - format!("bash: {}: {}\n", name, expanded) + self.diag(format!("{}: {}\n", name, expanded)) }; self.nounset_error = Some(msg); String::new() diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f1f2948dc..04619b6d2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -10,6 +10,21 @@ // validating string contents. This is safe because we check for non-empty strings. #![allow(clippy::unwrap_used)] +// Decision: shell diagnostics carry bash's non-interactive prefix +// `$0: line N: ` (`$0` is BASH_SOURCE[0] when a file is being read, so a +// sourced file names itself), built by `Interpreter::diag_prefix`. Every +// interpreter-generated message uses it. Bundled builtins have no +// interpreter handle, so they keep writing `bash: name: msg` (or bare +// `name: msg` for bash shell builtins) and `prefix_builtin_diagnostics` +// rewrites the line start at the single dispatch point +// (`execute_builtin_arc`), before redirects or streaming see the text. +// Execution plans and `/dev/stderr` operand data are not rewritten; usage +// lines (`name: usage:`) and coreutils-style builtins (cat, grep, ...: +// external programs in bash) stay unprefixed. Interactive shells +// (`set_interactive`) print `$0: msg` with no line, like `bash -i`. The +// sandbox "Did you mean" / unavailable-command hints after +// `command not found` are kept on purpose. + mod arithmetic; mod brace_expansion; mod declare; @@ -785,8 +800,8 @@ fn unavailable_command_hint(name: &str) -> Option<&'static str> { /// The message is newline-terminated: it goes straight into stderr, and real /// bash ends every diagnostic with a newline. Without it consecutive failures /// run together on one line (`...command not foundbash: next: ...`). -fn command_not_found_message(name: &str, known_commands: &[&str]) -> String { - let mut msg = format!("bash: {}: command not found", name); +fn command_not_found_message(prefix: &str, name: &str, known_commands: &[&str]) -> String { + let mut msg = format!("{prefix}{name}: command not found"); // Check for unavailable command hints first if let Some(hint) = unavailable_command_hint(name) { @@ -1401,6 +1416,9 @@ pub struct Interpreter { /// Added to command line numbers: a trap handler counts its lines from /// the line of the command that triggered it (bash). line_base: usize, + /// Interactive shell (REPL / terminal): diagnostics name the shell but + /// carry no `line N:`, like `bash -i`. See [`Self::diag_prefix`]. + interactive: bool, /// `unset LINENO` makes it an ordinary variable for the rest of the /// shell's life, like bash. lineno_unset: bool, @@ -2027,6 +2045,7 @@ impl Interpreter { jobs: jobs::new_shared_job_table(), current_line: 1, line_base: 0, + interactive: false, lineno_unset: false, source_depth: 0, last_command_name: String::new(), @@ -2209,6 +2228,7 @@ impl Interpreter { jobs: self.jobs.fork(), current_line: self.current_line, line_base: self.line_base, + interactive: self.interactive, lineno_unset: self.lineno_unset, #[cfg(feature = "http_client")] http_client: self.http_client.clone(), @@ -2572,6 +2592,54 @@ impl Interpreter { /// Name `$0` expands to when no real script or function frame is active. pub(crate) const DEFAULT_ARG0: &'static str = "bash"; + /// Mark the shell interactive (REPL / terminal): diagnostics then read + /// `bash: msg` instead of `bash: line N: msg`, as with `bash -i`. + pub(crate) fn set_interactive(&mut self, interactive: bool) { + self.interactive = interactive; + } + + /// The name bash puts in front of a diagnostic: the file being read + /// (`BASH_SOURCE[0]`, so a sourced file names itself), else `$0`. Mirrors + /// bash's `get_name_for_error`. + pub(crate) fn diag_name(&self) -> String { + if !self.interactive + && let Some(src) = self.bash_source_stack.last().filter(|s| !s.is_empty()) + { + return src.clone(); + } + if let Some(frame) = self.call_stack.iter().rev().find(|f| !f.keeps_arg0) { + return frame.name.clone(); + } + Self::DEFAULT_ARG0.to_string() + } + + /// Prefix of a shell diagnostic: `$0: line N: ` (non-interactive bash), + /// or `$0: ` in an interactive shell. Every interpreter-generated error + /// goes through this; bundled builtins get it via + /// [`prefix_builtin_diagnostics`]. + /// `msg` as a shell diagnostic: [`Self::diag_prefix`] followed by `msg`. + pub(crate) fn diag(&self, msg: impl std::fmt::Display) -> String { + format!("{}{msg}", self.diag_prefix()) + } + + /// `set -u` diagnostic for a bare `$name`: bash names a positional + /// parameter `$N` there (but `3` in `${3}` and other braced forms). + pub(crate) fn unbound_variable_diag(&self, name: &str) -> String { + if !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) { + self.diag(format!("${name}: unbound variable\n")) + } else { + self.diag(format!("{name}: unbound variable\n")) + } + } + + pub(crate) fn diag_prefix(&self) -> String { + if self.interactive { + format!("{}: ", self.diag_name()) + } else { + format!("{}: line {}: ", self.diag_name(), self.current_line) + } + } + /// Current call-stack depth, used by the host `exec` boundary to restore /// the stack after installing per-invocation positional parameters. pub(crate) fn call_stack_len(&self) -> usize { @@ -3544,6 +3612,23 @@ impl Interpreter { } } + /// Line a compound command ends on (`done`, `fi`, `esac`): bash reports + /// a failing redirect of `while ...; done < file` there. `None` for the + /// forms without a span. + fn compound_end_line(compound: &CompoundCommand) -> Option { + let span = match compound { + CompoundCommand::If(cmd) => cmd.span, + CompoundCommand::For(cmd) => cmd.span, + CompoundCommand::ArithmeticFor(cmd) => cmd.span, + CompoundCommand::While(cmd) => cmd.span, + CompoundCommand::Until(cmd) => cmd.span, + CompoundCommand::Case(cmd) => cmd.span, + CompoundCommand::Select(cmd) => cmd.span, + _ => return None, + }; + Some(span.end.line.max(span.start.line)) + } + fn command_line(command: &Command) -> usize { match command { Command::Simple(c) => c.span.line(), @@ -3979,7 +4064,7 @@ impl Interpreter { // Validate for-loop variable name (bash rejects invalid names at runtime, exit 1) if !is_valid_var_name(&for_cmd.variable) { return Ok(ExecResult::err( - format!("bash: `{}': not a valid identifier\n", for_cmd.variable), + self.diag(format!("`{}': not a valid identifier\n", for_cmd.variable)), 1, )); } @@ -4012,7 +4097,7 @@ impl Interpreter { Err(pat) => { self.last_exit_code = 1; return Ok(ExecResult::err( - format!("-bash: no match: {}\n", pat), + self.diag(format!("no match: {pat}\n")), 1, )); } @@ -4152,7 +4237,7 @@ impl Interpreter { Err(pat) => { self.last_exit_code = 1; return Ok(ExecResult::err( - format!("-bash: no match: {}\n", pat), + self.diag(format!("no match: {pat}\n")), 1, )); } @@ -5688,11 +5773,16 @@ impl Interpreter { self.pipeline_stdin = stdin.clone(); } - // Set BASH_SOURCE for script file execution - if let Some(ref file) = script_file { - self.bash_source_stack.push(file.clone()); - self.update_bash_source(); - } + // The child is a fresh non-interactive shell: BASH_SOURCE holds only + // its script file (none for `-c`), so its diagnostics name `$0` or + // that file and count lines from its own text. + let saved_source_stack = std::mem::replace( + &mut self.bash_source_stack, + script_file.iter().cloned().collect(), + ); + self.update_bash_source(); + let saved_line = self.current_line; + let saved_interactive = std::mem::replace(&mut self.interactive, false); // A new shell is outside any loop, function or sourced file. let saved_loop_depth = std::mem::replace(&mut self.loop_depth, 0); @@ -5701,11 +5791,10 @@ impl Interpreter { self.loop_depth = saved_loop_depth; self.return_depth = saved_return_depth; - // Restore BASH_SOURCE - if script_file.is_some() { - self.bash_source_stack.pop(); - self.update_bash_source(); - } + self.interactive = saved_interactive; + self.current_line = saved_line; + self.bash_source_stack = saved_source_stack; + self.update_bash_source(); // Restore stdin self.pipeline_stdin = saved_stdin; @@ -6545,15 +6634,19 @@ impl Interpreter { let mut child = self.fork_for_job(); let depth_ok = child.counters.push_subshell(&child.limits).is_ok(); let Some(slot) = slot.filter(|_| depth_ok) else { + // bash reports fork failures with the shell name only (no line). let msg = format!( - "bash: fork: retry: Resource temporarily unavailable (max {} background jobs, {} nested)\n", - self.limits.max_background_jobs, self.limits.max_subshell_depth + "{}: fork: retry: Resource temporarily unavailable (max {} background jobs, {} nested)\n", + self.diag_name(), + self.limits.max_background_jobs, + self.limits.max_subshell_depth ); parent_stderr.append(&crate::StreamData::from(msg)); self.last_exit_code = 1; return Ok(()); }; let cmd = cmd.clone(); + let job_diag = self.diag_prefix(); let mut fut: jobs::JobFuture = Box::pin(async move { let _slot = slot; let jobs = Arc::clone(&child.jobs); @@ -6570,7 +6663,7 @@ impl Interpreter { r.control_flow = ControlFlow::None; r } - Err(e) => ExecResult::err(format!("bash: {e}\n"), 1), + Err(e) => ExecResult::err(format!("{job_diag}{e}\n"), 1), } }); match std::future::poll_fn(|cx| std::task::Poll::Ready(fut.as_mut().poll(cx))).await { @@ -6799,17 +6892,17 @@ impl Interpreter { let target = match self.resolve_nameref_strict(&assignment.name) { Ok(t) => t, Err(()) => { - return Err(crate::error::Error::LineAbort(format!( - "bash: warning: {}: circular name reference\n", + return Err(crate::error::Error::LineAbort(self.diag(format!( + "warning: {}: circular name reference\n", assignment.name - ))); + )))); } }; let base = target.split('[').next().unwrap_or(&target).to_string(); // THREAT[TM-INJ-019]: assignments to readonly variables fail // visibly, as in bash. if self.is_var_readonly(&base) { - let msg = format!("bash: {base}: readonly variable\n"); + let msg = self.diag(format!("{base}: readonly variable\n")); if has_command { stderr.push_str(&msg); continue; @@ -6838,17 +6931,17 @@ impl Interpreter { let arr_name = match self.resolve_nameref_strict(&assignment.name) { Ok(n) => n, Err(()) => { - return Err(crate::error::Error::LineAbort(format!( - "bash: warning: {}: circular name reference\n", + return Err(crate::error::Error::LineAbort(self.diag(format!( + "warning: {}: circular name reference\n", assignment.name - ))); + )))); } }; // THREAT[TM-INJ-019]: arrays honour readonly like scalars. if self.is_var_readonly(&arr_name) { - return Err(crate::error::Error::LineAbort(format!( - "bash: {arr_name}: readonly variable\n" - ))); + return Err(crate::error::Error::LineAbort( + self.diag(format!("{arr_name}: readonly variable\n")), + )); } let assoc = self.scoped.assoc_arrays.contains_key(&arr_name); self.assign_array_words(&arr_name, words, assignment.append, assoc) @@ -6935,7 +7028,7 @@ impl Interpreter { // to prevent bypassing static budget checks via alias expansion. if let Err(e) = crate::parser::validate_budget(&s, &self.limits) { Ok(ExecResult::err( - format!("bash: alias expansion: budget validation failed: {e}\n"), + self.diag(format!("alias expansion: budget validation failed: {e}\n")), 1, )) } else { @@ -6946,7 +7039,7 @@ impl Interpreter { } } Err(e) => Ok(ExecResult::err( - format!("bash: alias expansion: parse error: {}\n", e), + self.diag(format!("alias expansion: parse error: {e}\n")), 1, )), }; @@ -7184,10 +7277,7 @@ impl Interpreter { if command.name.quoted && !name_vanished && command.assignments.is_empty() { self.last_exit_code = 127; self.discard_deferred_proc_subs_from(deferred_proc_sub_start); - return Ok(ExecResult::err( - "bash: : command not found\n".to_string(), - 127, - )); + return Ok(ExecResult::err(self.diag(": command not found\n"), 127)); } let exit_code = if !command.assignments.is_empty() && self.subst_generation == pre_assign_subst_gen @@ -7366,7 +7456,8 @@ impl Interpreter { Err(pat) => { self.last_exit_code = 1; return Ok(vec![format!( - "\x00ERR\x00-bash: no match: {}\n", + "\x00ERR\x00{}no match: {}\n", + self.diag_prefix(), pat )]); } @@ -7465,8 +7556,7 @@ impl Interpreter { if name == "return" && !in_function && self.source_depth == 0 { self.last_exit_code = 2; return Ok(ExecResult::err( - "bash: return: can only `return' from a function or sourced script\n" - .to_string(), + self.diag("return: can only `return' from a function or sourced script\n"), 2, )); } @@ -7664,10 +7754,7 @@ impl Interpreter { } else { format!("{who}: ") }; - let msg = format!( - "bash: line {}: {who}write error: Bad file descriptor\n", - self.current_line - ); + let msg = self.diag(format!("{who}write error: Bad file descriptor\n")); if !matches!(self.exec_fd_table.get(&2), Some(FdTarget::Closed)) { result .stderr @@ -7836,7 +7923,7 @@ impl Interpreter { Some(modified) => std::borrow::Cow::Owned(modified.args), None => { let result = ExecResult::err( - format!("bash: {name}: cancelled by before_tool hook\n"), + self.diag(format!("{name}: cancelled by before_tool hook\n")), 1, ); return self.apply_redirections(result, redirects).await; @@ -7916,7 +8003,7 @@ impl Interpreter { Ok(Err(e)) => return Err(e), Err(_panic) => { let result = ExecResult::err( - format!("bash: {}: builtin failed unexpectedly\n", name), + self.diag(format!("{name}: builtin failed unexpectedly\n")), 1, ); let result = self.apply_redirections(result, redirects).await?; @@ -7990,10 +8077,19 @@ impl Interpreter { builtin_usage_error(&format!("{name}: {e}")) } Ok(Err(e)) => return Err(e), - Err(_panic) => { - ExecResult::err(format!("bash: {}: builtin failed unexpectedly\n", name), 1) - } + Err(_panic) => ExecResult::err( + self.diag(format!("{name}: builtin failed unexpectedly\n")), + 1, + ), }; + // Before `/dev/stderr` operand data is appended and before any + // redirect or streaming emission sees the text. + if bundled + && let Some(stderr) = + prefix_builtin_diagnostics(&result.stderr, name, &self.diag_prefix()) + { + result.stderr = stderr; + } if let Some(capture) = std_capture { let capture = std::mem::take(&mut *capture.lock().unwrap_or_else(|e| e.into_inner())); @@ -8050,7 +8146,7 @@ impl Interpreter { }) } None => Ok(ExecResult::err( - format!("bash: {name}: cancelled by after_tool hook\n"), + self.diag(format!("{name}: cancelled by after_tool hook\n")), 1, )), } @@ -8076,7 +8172,7 @@ impl Interpreter { Some(modified) => std::borrow::Cow::Owned(modified.args), None => { let result = ExecResult::err( - format!("bash: {name}: cancelled by before_tool hook\n"), + self.diag(format!("{name}: cancelled by before_tool hook\n")), 1, ); return self.apply_redirections(result, redirects).await; @@ -8117,7 +8213,7 @@ impl Interpreter { && matches!(name, "exec" | "bash" | "sh" | "source" | ".") { return Some(Ok(ExecResult::err( - format!("bash: {}: command not found\n", name), + self.diag(format!("{name}: command not found\n")), 127, ))); } @@ -8142,7 +8238,7 @@ impl Interpreter { .await, ), "bash" | "sh" => Some(self.execute_shell(name, args, stdin, redirects).await), - "source" | "." => Some(self.execute_source(args, redirects).await), + "source" | "." => Some(self.execute_source(name, args, redirects).await), "eval" => Some(self.execute_eval(args, stdin, redirects).await), "command" => Some(self.execute_command_builtin(args, stdin, redirects).await), "builtin" => Some(self.execute_builtin_builtin(args, stdin, redirects).await), @@ -8241,7 +8337,7 @@ impl Interpreter { if name.contains('/') { if !self.shell_features.has_script_execution() { return Ok(ExecResult::err( - format!("bash: {}: command not found\n", name), + self.diag(format!("{name}: command not found\n")), 127, )); } @@ -8279,7 +8375,7 @@ impl Interpreter { Ok(builtin) => builtin, Err(_panic) => { return Ok(ExecResult::err( - format!("bash: {}: resolver failed unexpectedly\n", name), + self.diag(format!("{name}: resolver failed unexpectedly\n")), 1, )); } @@ -8314,7 +8410,7 @@ impl Interpreter { .chain(self.scoped.aliases.keys().map(|s| s.as_str())) .chain(host_names.iter().map(|s| s.as_str())) .collect(); - let msg = command_not_found_message(name, &known); + let msg = command_not_found_message(&self.diag_prefix(), name, &known); Ok(ExecResult::err(msg, 127)) }) } @@ -8341,7 +8437,7 @@ impl Interpreter { Ok(m) => m, Err(_) => { return Ok(ExecResult::err( - format!("bash: {}: No such file or directory\n", name), + self.diag(format!("{name}: No such file or directory\n")), 127, )); } @@ -8350,7 +8446,7 @@ impl Interpreter { // Directory check if meta.file_type.is_dir() { return Ok(ExecResult::err( - format!("bash: {}: Is a directory\n", name), + self.diag(format!("{name}: Is a directory\n")), 126, )); } @@ -8358,7 +8454,7 @@ impl Interpreter { // Execute permission check if meta.mode & 0o111 == 0 { return Ok(ExecResult::err( - format!("bash: {}: Permission denied\n", name), + self.diag(format!("{name}: Permission denied\n")), 126, )); } @@ -8368,7 +8464,7 @@ impl Interpreter { Ok(c) => c, Err(_) => { return Ok(ExecResult::err( - format!("bash: {}: No such file or directory\n", name), + self.diag(format!("{name}: No such file or directory\n")), 127, )); } @@ -8516,7 +8612,7 @@ impl Interpreter { let script = match parser.parse() { Ok(s) => s, Err(e) => { - return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2)); + return Ok(ExecResult::err(self.diag(format!("{name}: {e}\n")), 2)); } }; @@ -8591,10 +8687,15 @@ impl Interpreter { let shares_caller_stdin = stdin.is_some() && stdin == prev_pipeline_stdin; self.pipeline_stdin = stdin; - // A new process is outside any loop, function or sourced file. + // A script run by path is a non-interactive child shell with its + // own line numbers, outside any loop, function or sourced file. let saved_loop_depth = std::mem::replace(&mut self.loop_depth, 0); let saved_return_depth = std::mem::replace(&mut self.return_depth, 0); + let saved_line = self.current_line; + let saved_interactive = std::mem::replace(&mut self.interactive, false); let result = self.execute_script_body(&script, true, false).await; + self.interactive = saved_interactive; + self.current_line = saved_line; self.loop_depth = saved_loop_depth; self.return_depth = saved_return_depth; let child_stdin_left = self.pipeline_stdin.take(); @@ -8646,16 +8747,18 @@ impl Interpreter { /// - Original positional parameters are restored after sourcing completes async fn execute_source( &mut self, + name: &str, args: &[String], redirects: &[Redirect], ) -> Result { let filename = match args.first() { Some(f) => f, None => { - return Ok(ExecResult::err( - "bash: source: filename argument required\nsource: usage: source filename [arguments]\n", - 2, + let msg = self.diag(format!( + "{name}: filename argument required\n\ + {name}: usage: {name} filename [arguments]\n" )); + return Ok(ExecResult::err(msg, 2)); } }; @@ -8668,7 +8771,7 @@ impl Interpreter { Ok(c) => decode_file_bytes_for_path(&path, &c), Err(_) => { return Ok(ExecResult::err( - format!("source: {}: No such file or directory", filename), + self.diag(format!("{filename}: No such file or directory\n")), 1, )); } @@ -8704,7 +8807,7 @@ impl Interpreter { Some(c) => c, None => { return Ok(ExecResult::err( - format!("source: {}: No such file or directory", filename), + self.diag(format!("{filename}: No such file or directory\n")), 1, )); } @@ -8713,9 +8816,15 @@ impl Interpreter { let script = match self.parse_embedded_script(&content).await { Ok(script) => script, - Err(crate::error::Error::Parse { message, .. }) => { + Err(crate::error::Error::Parse { message, line, .. }) => { + // Like bash: the sourced file names itself, with the line. + let message = if message.starts_with("syntax error") { + message + } else { + format!("syntax error: {message}") + }; return Ok(ExecResult::err( - format!("source: {}: parse error: {}", filename, message), + format!("{filename}: line {}: {message}\n", line.max(1)), 2, )); } @@ -8832,8 +8941,9 @@ impl Interpreter { } else { format!("syntax error: {message}") }; + let who = self.diag_name(); let result = ExecResult::err( - format!("bash: eval: line {at}: {message}\nbash: eval: line {at}: `{src}'\n"), + format!("{who}: eval: line {at}: {message}\n{who}: eval: line {at}: `{src}'\n"), 2, ); return self.apply_redirections(result, redirects).await; @@ -9322,7 +9432,7 @@ impl Interpreter { redirects: &[Redirect], ) -> Result { if args.is_empty() { - let result = ExecResult::err("bash: let: expression expected\n", 1); + let result = ExecResult::err(self.diag("let: expression expected\n"), 1); return self.apply_redirections(result, redirects).await; } let mut last_val = 0i64; @@ -9372,9 +9482,9 @@ impl Interpreter { 'v' => unset_var = true, _ => { let result = ExecResult::err( - format!( - "bash: unset: -{flag}: invalid option\nunset: usage: unset [-f] [-v] [-n] [name ...]\n" - ), + self.diag(format!( + "unset: -{flag}: invalid option\nunset: usage: unset [-f] [-v] [-n] [name ...]\n" + )), 2, ); return self.apply_redirections(result, redirects).await; @@ -9384,7 +9494,7 @@ impl Interpreter { } if unset_function && unset_var { let result = ExecResult::err( - "bash: unset: cannot simultaneously unset a function and a variable\n", + self.diag("unset: cannot simultaneously unset a function and a variable\n"), 1, ); return self.apply_redirections(result, redirects).await; @@ -9401,7 +9511,7 @@ impl Interpreter { } // Only an explicit `-v` checks the name (bash). if unset_var && !is_valid_var_name(arg) && !Self::is_array_reference(arg) { - stderr.push_str(&format!("bash: unset: `{arg}': not a valid identifier\n")); + stderr.push_str(&self.diag(format!("unset: `{arg}': not a valid identifier\n"))); exit_code = 1; continue; } @@ -9413,9 +9523,9 @@ impl Interpreter { let expanded_key = self.expand_variable_or_literal(key); let resolved_name = self.resolve_nameref(arr_name).to_string(); if self.is_var_readonly(&resolved_name) { - stderr.push_str(&format!( - "bash: unset: {resolved_name}: cannot unset: readonly variable\n" - )); + stderr.push_str(&self.diag(format!( + "unset: {resolved_name}: cannot unset: readonly variable\n" + ))); exit_code = 1; continue; } @@ -9450,18 +9560,18 @@ impl Interpreter { }; // THREAT[TM-INJ-009]: Block unset of internal marker variables if is_internal_variable(&resolved) { - stderr.push_str(&format!( - "bash: unset: {resolved}: cannot unset: readonly variable\n" - )); + stderr.push_str(&self.diag(format!( + "unset: {resolved}: cannot unset: readonly variable\n" + ))); exit_code = 1; continue; } // THREAT[TM-INJ-019]: Refuse to unset readonly variables and surface // the error so callers cannot mistake a silent skip for success. if self.is_var_readonly(&resolved) { - stderr.push_str(&format!( - "bash: unset: {resolved}: cannot unset: readonly variable\n" - )); + stderr.push_str(&self.diag(format!( + "unset: {resolved}: cannot unset: readonly variable\n" + ))); exit_code = 1; continue; } @@ -9675,11 +9785,11 @@ impl Interpreter { } else { self.set_variable(varname.clone(), "?".to_string()); let mut result = ExecResult::ok(String::new()); - result.stderr = format!( - "bash: getopts: option requires an argument -- '{}'\n", - opt_char - ) - .into(); + result.stderr = self + .diag(format!( + "getopts: option requires an argument -- '{opt_char}'\n" + )) + .into(); result = self.apply_redirections(result, redirects).await?; return Ok(result); } @@ -9714,7 +9824,9 @@ impl Interpreter { } else { self.set_variable(varname.clone(), "?".to_string()); let mut result = ExecResult::ok(String::new()); - result.stderr = format!("bash: getopts: illegal option -- '{}'\n", opt_char).into(); + result.stderr = self + .diag(format!("getopts: illegal option -- '{opt_char}'\n")) + .into(); result = self.apply_redirections(result, redirects).await?; return Ok(result); } @@ -9751,7 +9863,7 @@ impl Interpreter { || self.resolve_command_path(name).await.is_none()); if !is_shell_builtin { return Ok(ExecResult::err( - format!("bash: builtin: {name}: not a shell builtin\n"), + self.diag(format!("builtin: {name}: not a shell builtin\n")), 1, )); } @@ -9860,7 +9972,7 @@ impl Interpreter { format!("{} is a shell builtin\n", cmd_name) } else { return Ok(ExecResult::err( - format!("bash: command: {}: not found\n", cmd_name), + self.diag(format!("command: {cmd_name}: not found\n")), 1, )); }; @@ -9941,7 +10053,7 @@ impl Interpreter { } } Ok(ExecResult::err( - format!("bash: {}: command not found\n", remaining[0]), + self.diag(format!("{}: command not found\n", remaining[0])), 127, )) } @@ -10331,9 +10443,9 @@ impl Interpreter { // circular nameref fails that command, as in bash, // instead of abandoning the line like an assignment. if self.resolve_nameref_strict(name).is_err() { - result - .stderr - .push_str(&format!("bash: warning: {name}: circular name reference\n")); + result.stderr.push_str( + &self.diag(format!("warning: {name}: circular name reference\n")), + ); result.exit_code = 1; continue; } @@ -10595,6 +10707,12 @@ impl Interpreter { redirects: &'a [Redirect], ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { + let end_line = (!redirects.is_empty()) + .then(|| Self::compound_end_line(compound)) + .flatten(); + if let Some(line) = end_line { + self.current_line = line; + } // Process input redirections before executing compound let stdin = match self.process_input_redirections(None, redirects).await { Ok(s) => s, @@ -10664,6 +10782,9 @@ impl Interpreter { if redirects.is_empty() { Ok(result) } else { + if let Some(line) = end_line { + self.current_line = line; + } self.apply_redirections(result, redirects).await } }) @@ -10962,7 +11083,7 @@ impl Interpreter { .unwrap_or(0); let idx = len + raw_idx as i128; if idx < 0 { - return Err(format!("bash: {arr_name}[{key}]: bad array subscript\n")); + return Err(self.diag(format!("{arr_name}[{key}]: bad array subscript\n"))); } Ok(idx as usize) } @@ -12000,6 +12121,115 @@ fn ere_has_invalid_char_class(pattern: &str) -> bool { /// Turn a builtin's usage error into a failed command (exit 2) instead of /// aborting the script. THREAT[TM-INF-022]: the message is capped at 1 KB. +/// Names real bash runs as shell builtins (`enable -a`). Their diagnostics +/// carry the shell's `$0: line N: ` prefix; everything else bashkit bundles +/// (cat, grep, sort, ...) is an external program in bash and keeps its own +/// `cat: ...` form. +const BASH_SHELL_BUILTINS: &[&str] = &[ + ".", + ":", + "[", + "alias", + "bg", + "bind", + "break", + "builtin", + "caller", + "cd", + "command", + "compgen", + "complete", + "compopt", + "continue", + "declare", + "dirs", + "disown", + "echo", + "enable", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "getopts", + "hash", + "help", + "history", + "jobs", + "kill", + "let", + "local", + "logout", + "mapfile", + "popd", + "printf", + "pushd", + "pwd", + "read", + "readarray", + "readonly", + "return", + "set", + "shift", + "shopt", + "source", + "suspend", + "test", + "times", + "trap", + "true", + "type", + "typeset", + "ulimit", + "umask", + "unalias", + "unset", + "wait", +]; + +/// Give a bundled builtin's own stderr bash's diagnostic prefix. +/// +/// Builtins have no interpreter handle, so they write `bash: name: msg` (or, +/// for shell builtins, bare `name: msg`) and the one dispatch point that +/// returns their result rewrites the line start to `prefix` (`$0: line N: `). +/// Usage lines (`name: usage: ...`) stay unprefixed, as in bash. Only the +/// builtin's own diagnostics reach here: execution plans (nested commands) +/// and `/dev/stderr` operand data are excluded by the caller. +fn prefix_builtin_diagnostics( + stderr: &crate::StreamData, + name: &str, + prefix: &str, +) -> Option { + let bytes = stderr.as_bytes(); + if bytes.is_empty() { + return None; + } + let shell_builtin = BASH_SHELL_BUILTINS.contains(&name); + let own = format!("{name}: "); + let usage = format!("{name}: usage:"); + let mut out = Vec::with_capacity(bytes.len() + prefix.len()); + let mut changed = false; + for line in bytes.split_inclusive(|b| *b == b'\n') { + if let Some(rest) = line.strip_prefix(b"bash: ".as_slice()) { + out.extend_from_slice(prefix.as_bytes()); + out.extend_from_slice(rest); + changed = true; + } else if shell_builtin + && line.starts_with(own.as_bytes()) + && !line.starts_with(usage.as_bytes()) + { + out.extend_from_slice(prefix.as_bytes()); + out.extend_from_slice(line); + changed = true; + } else { + out.extend_from_slice(line); + } + } + changed.then(|| crate::StreamData::from(out)) +} + fn builtin_usage_error(msg: &str) -> ExecResult { let mut msg = msg.trim_end().to_string(); if msg.len() > 1024 { @@ -14501,7 +14731,7 @@ echo "count=$COUNT" #[test] fn test_command_not_found_suggestions_use_stable_tie_break() { - let msg = command_not_found_message("grpe", &["type", "true", "tree", "grep"]); + let msg = command_not_found_message("bash: ", "grpe", &["type", "true", "tree", "grep"]); assert_eq!( msg, "bash: grpe: command not found. Did you mean: grep, tree, true?\n" diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 5e8d109e9..465398e9b 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -35,11 +35,10 @@ impl Interpreter { true } - /// `bash: line N: PATH: reason` — the shape bash uses for a redirection - /// that could not be opened. Bash names the script instead of `bash` - /// when running a file; the interpreter does not track a script path. + /// `$0: line N: PATH: reason` — the shape bash uses for a redirection + /// that could not be opened. fn redirect_error(&self, path: &str, reason: &str) -> String { - format!("bash: line {}: {path}: {reason}\n", self.current_line) + self.diag(format!("{path}: {reason}\n")) } /// Process input redirections (< file, <<< string) @@ -63,10 +62,9 @@ impl Interpreter { let content = if is_dev_null(&path) { crate::StreamData::new() } else if !self.shell_features.has_file_redirects() { - return Err(crate::error::Error::Execution(format!( - "bash: {}: filesystem redirection disabled", - target_path - ))); + return Err(crate::error::Error::Execution( + self.diag(format!("{}: filesystem redirection disabled", target_path)), + )); } else { let opened = match self.fs.append_file(&path, b"").await { Ok(()) => self.fs.read_file(&path).await, @@ -75,10 +73,9 @@ impl Interpreter { match opened { Ok(content) => content.into(), Err(e) => { - return Err(crate::error::Error::CommandFailure(format!( - "bash: {target_path}: {}\n", - io_error_reason(&e) - ))); + return Err(crate::error::Error::CommandFailure( + self.diag(format!("{target_path}: {}\n", io_error_reason(&e))), + )); } } }; @@ -91,10 +88,10 @@ impl Interpreter { if !self.shell_features.has_file_redirects() && !word_is_literal_dev_null(&redirect.target) { - return Err(crate::error::Error::Execution(format!( - "bash: {}: filesystem redirection disabled", + return Err(crate::error::Error::Execution(self.diag(format!( + "{}: filesystem redirection disabled", redirect_target_label(&redirect.target) - ))); + )))); } let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); @@ -104,10 +101,9 @@ impl Interpreter { } else if is_dev_null(&path) { stdin = Some(crate::StreamData::new()); // EOF } else if !self.shell_features.has_file_redirects() { - return Err(crate::error::Error::Execution(format!( - "bash: {}: filesystem redirection disabled", - target_path - ))); + return Err(crate::error::Error::Execution( + self.diag(format!("{}: filesystem redirection disabled", target_path)), + )); } else { match self.fs.read_file(&path).await { Ok(content) => { @@ -233,7 +229,7 @@ impl Interpreter { { self.clear_pending_fd_redirect_state(); result.stdout = crate::StreamData::new(); - result.stderr = format!("bash: {target}: Bad file descriptor\n").into(); + result.stderr = self.diag(format!("{target}: Bad file descriptor\n")).into(); result.exit_code = 1; return Ok(result); } @@ -460,10 +456,7 @@ impl Interpreter { } fn closed_stdout_error(&self) -> String { - format!( - "bash: line {}: write error: Bad file descriptor\n", - self.current_line - ) + self.diag("write error: Bad file descriptor\n") } /// `N>&word` with N other than 1 and a word that is neither a number @@ -544,7 +537,7 @@ impl Interpreter { if !self.shell_features.has_process_substitution() && word_has_process_substitution(&redirect.target) { - return Some("bash: process substitution disabled\n".to_string()); + return Some(self.diag("process substitution disabled\n")); } if !self.shell_features.has_file_redirects() @@ -558,10 +551,10 @@ impl Interpreter { ) && !word_is_literal_dev_null(&redirect.target) { - return Some(format!( - "bash: {}: filesystem redirection disabled\n", + return Some(self.diag(format!( + "{}: filesystem redirection disabled\n", redirect_target_label(&redirect.target) - )); + ))); } } None @@ -857,12 +850,12 @@ impl DupTarget { } } -fn bad_fd_error(word: &str) -> ExecResult { - ExecResult::err(format!("bash: {word}: Bad file descriptor\n"), 1) +fn bad_fd_error(prefix: &str, word: &str) -> ExecResult { + ExecResult::err(format!("{prefix}{word}: Bad file descriptor\n"), 1) } -fn ambiguous_redirect_error(word: &str) -> ExecResult { - ExecResult::err(format!("bash: {word}: ambiguous redirect\n"), 1) +fn ambiguous_redirect_error(prefix: &str, word: &str) -> ExecResult { + ExecResult::err(format!("{prefix}{word}: ambiguous redirect\n"), 1) } /// Remaining coproc-buffer lines (stored reversed) as stdin text. @@ -972,7 +965,7 @@ impl Interpreter { // `{var}>&-` closes the fd named by `$var`. let value = self.expand_name_or_array_element(var); if value.is_empty() { - return Ok(Some(ambiguous_redirect_error(var))); + return Ok(Some(ambiguous_redirect_error(&self.diag_prefix(), var))); } if let Ok(fd) = value.trim().parse::() && fd >= 0 @@ -982,7 +975,7 @@ impl Interpreter { return Ok(None); } if target.parse::().is_err() { - return Ok(Some(ambiguous_redirect_error(var))); + return Ok(Some(ambiguous_redirect_error(&self.diag_prefix(), var))); } dup = Redirect { target: Word::quoted_literal(target), @@ -1000,7 +993,8 @@ impl Interpreter { self.close_fd(fd); return Ok(Some(ExecResult::err( format!( - "bash: {base}: readonly variable\nbash: {var}: cannot assign fd to variable\n" + "{p}{base}: readonly variable\n{p}{var}: cannot assign fd to variable\n", + p = self.diag_prefix() ), 1, ))); @@ -1039,7 +1033,7 @@ impl Interpreter { } if read_write && let Err(e) = self.fs.append_file(&path, b"").await { return Ok(Some(ExecResult::err( - format!("bash: {target_path}: {}\n", io_error_reason(&e)), + self.diag(format!("{target_path}: {}\n", io_error_reason(&e))), 1, ))); } @@ -1047,7 +1041,7 @@ impl Interpreter { Ok(c) => c, Err(e) => { return Ok(Some(ExecResult::err( - format!("bash: {target_path}: {}\n", io_error_reason(&e)), + self.diag(format!("{target_path}: {}\n", io_error_reason(&e))), 1, ))); } @@ -1099,14 +1093,14 @@ impl Interpreter { } let Ok(m) = target.parse::() else { if redirect.kind == RedirectKind::DupInput { - return Ok(Some(ambiguous_redirect_error(&target))); + return Ok(Some(ambiguous_redirect_error(&self.diag_prefix(), &target))); } // WTF: `exec >&file` (bash: `exec &>file`) is not // implemented; it is ignored. return Ok(None); }; if !self.fd_is_open(m) { - return Ok(Some(bad_fd_error(&target))); + return Ok(Some(bad_fd_error(&self.diag_prefix(), &target))); } match n { 0 => { @@ -1146,7 +1140,7 @@ impl Interpreter { }; if let Err(e) = opened { return Ok(Some(ExecResult::err( - format!("bash: {target_path}: {}\n", io_error_reason(&e)), + self.diag(format!("{target_path}: {}\n", io_error_reason(&e))), 1, ))); } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 2ede11725..5a7cd3cba 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1489,6 +1489,26 @@ impl Bash { Arc::clone(&self.fs) } + /// Mark this shell interactive, as a REPL or terminal front end does. + /// + /// Shell diagnostics then read `bash: nocmd: command not found`, like + /// `bash -i`, instead of the non-interactive `bash: line 1: nocmd: ...` + /// (`$0: line N: `) form scripts get. Off by default. + /// + /// ```rust + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = bashkit::Bash::new(); + /// assert_eq!(bash.exec("nocmd").await?.stderr, "bash: line 1: nocmd: command not found\n"); + /// bash.set_interactive(true); + /// assert_eq!(bash.exec("nocmd").await?.stderr, "bash: nocmd: command not found\n"); + /// # Ok(()) + /// # } + /// ``` + pub fn set_interactive(&mut self, interactive: bool) { + self.interpreter.set_interactive(interactive); + } + /// Mount a filesystem at `vfs_path` on a live interpreter. /// /// Unlike [`BashBuilder`] mount methods which configure mounts before build, diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index 9aac33591..32b2dcec9 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -253,6 +253,8 @@ impl Terminal { HookAction::Continue(event) })) .build(); + // An interactive shell: diagnostics read `bash: x: ...` (no line). + bash.set_interactive(true); let clock = InputWaitClock::default(); bash.input_wait_clock = Some(clock.clone()); let interrupt = Arc::new(tokio::sync::Notify::new()); diff --git a/crates/bashkit/tests/integration/builtin_validation_tests.rs b/crates/bashkit/tests/integration/builtin_validation_tests.rs index 8415520e9..17e124c77 100644 --- a/crates/bashkit/tests/integration/builtin_validation_tests.rs +++ b/crates/bashkit/tests/integration/builtin_validation_tests.rs @@ -15,7 +15,7 @@ async fn return_outside_a_function_reports_and_keeps_going() { let r = run("return\necho \"rc=$?\"\necho after").await; assert_eq!( r.stderr.to_string(), - "bash: return: can only `return' from a function or sourced script\n" + "bash: line 1: return: can only `return' from a function or sourced script\n" ); assert_eq!(r.stdout.to_string(), "rc=2\nafter\n"); } @@ -40,7 +40,7 @@ async fn unset_v_insists_on_an_identifier() { let r = run(&format!("unset -v '{name}'\necho \"rc=$?\"")).await; assert_eq!( r.stderr.to_string(), - format!("bash: unset: `{name}': not a valid identifier\n") + format!("bash: line 1: unset: `{name}': not a valid identifier\n") ); assert_eq!(r.stdout.to_string(), "rc=1\n"); } @@ -67,7 +67,7 @@ async fn read_rejects_a_non_numeric_timeout() { let r = run("read -t abc x <<< ''\necho \"rc=$?\"").await; assert_eq!( r.stderr.to_string(), - "read: abc: invalid timeout specification\n" + "bash: line 1: read: abc: invalid timeout specification\n" ); assert_eq!(r.stdout.to_string(), "rc=1\n"); } @@ -75,7 +75,10 @@ async fn read_rejects_a_non_numeric_timeout() { #[tokio::test] async fn read_rejects_a_non_numeric_count() { let r = run("read -n abc x <<< ''\necho \"rc=$?\"").await; - assert_eq!(r.stderr.to_string(), "read: abc: invalid number\n"); + assert_eq!( + r.stderr.to_string(), + "bash: line 1: read: abc: invalid number\n" + ); assert_eq!(r.stdout.to_string(), "rc=1\n"); } diff --git a/crates/bashkit/tests/integration/command_resolver_tests.rs b/crates/bashkit/tests/integration/command_resolver_tests.rs index 00b9a3c82..d8e78c7f2 100644 --- a/crates/bashkit/tests/integration/command_resolver_tests.rs +++ b/crates/bashkit/tests/integration/command_resolver_tests.rs @@ -166,7 +166,7 @@ async fn resolver_panic_becomes_a_sanitized_shell_error() { assert_eq!(result.exit_code, 1); assert_eq!( result.stderr, - "bash: attacker-controlled-name: resolver failed unexpectedly\n" + "bash: line 1: attacker-controlled-name: resolver failed unexpectedly\n" ); assert!(!result.stderr.contains("sensitive host resolver detail")); } diff --git a/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs b/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs new file mode 100644 index 000000000..6f3abd0f3 --- /dev/null +++ b/crates/bashkit/tests/integration/diagnostic_prefix_tests.rs @@ -0,0 +1,233 @@ +//! Shell diagnostics carry bash's non-interactive `$0: line N: ` prefix. +//! +//! Every expected string below was checked against real bash 5.2 running the +//! same text as `bash SCRIPT` (so `$0` is `SCRIPT`) or `bash -c '...'` (so +//! `$0` is `bash`). Coreutils-style builtins are external programs in bash and +//! keep their own `cat: ...` form; usage lines stay unprefixed. + +use bashkit::{Bash, ExecOptions}; +use std::sync::{Arc, Mutex}; + +/// Run `script` the way `bash SCRIPT` would: `$0` is `SCRIPT`. +async fn run_script(script: &str) -> (String, i32) { + let mut bash = Bash::new(); + let r = bash + .exec_with_options(script, ExecOptions::new().arg0("SCRIPT")) + .await + .unwrap(); + (r.stderr.to_string(), r.exit_code) +} + +/// Run `script` the way `bash -c` would: `$0` is the default `bash`. +async fn run_c(script: &str) -> (String, i32) { + let mut bash = Bash::new(); + let r = bash.exec(script).await.unwrap(); + (r.stderr.to_string(), r.exit_code) +} + +#[tokio::test] +async fn command_not_found_in_script_file() { + let (err, code) = run_script("echo a >/dev/null\ncomando\n").await; + assert_eq!(err, "SCRIPT: line 2: comando: command not found\n"); + assert_eq!(code, 127); +} + +#[tokio::test] +async fn command_not_found_in_dash_c() { + let (err, code) = run_c("comando").await; + assert_eq!(err, "bash: line 1: comando: command not found\n"); + assert_eq!(code, 127); +} + +#[tokio::test] +async fn missing_absolute_path_command() { + let (err, code) = run_script("\n/abs/missing\n").await; + assert_eq!( + err, + "SCRIPT: line 2: /abs/missing: No such file or directory\n" + ); + assert_eq!(code, 127); +} + +#[tokio::test] +async fn input_redirect_failure() { + let (err, code) = run_script("cat < dados.txt\n").await; + assert_eq!( + err, + "SCRIPT: line 1: dados.txt: No such file or directory\n" + ); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn mapfile_redirect_failure() { + let (err, _) = run_script("mapfile -t a < dados.txt\n").await; + assert_eq!( + err, + "SCRIPT: line 1: dados.txt: No such file or directory\n" + ); +} + +#[tokio::test] +async fn readonly_assignment() { + let (err, code) = run_script("readonly ro=1\nro=2\n").await; + assert_eq!(err, "SCRIPT: line 2: ro: readonly variable\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn declare_p_missing() { + let (err, code) = run_script("declare -p naoexiste\n").await; + assert_eq!(err, "SCRIPT: line 1: declare: naoexiste: not found\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn source_missing_file_has_prefix_and_newline() { + let (err, code) = run_script("echo 1 >/dev/null\n. ./lib.sh\necho after >&2\n").await; + assert_eq!( + err, + "SCRIPT: line 2: ./lib.sh: No such file or directory\nafter\n" + ); + assert_eq!(code, 0); + let (err, _) = run_script("source ./lib.sh\n").await; + assert_eq!(err, "SCRIPT: line 1: ./lib.sh: No such file or directory\n"); +} + +#[tokio::test] +async fn source_without_argument() { + let (err, code) = run_script("source\n").await; + assert_eq!( + err, + "SCRIPT: line 1: source: filename argument required\n\ + source: usage: source filename [arguments]\n" + ); + assert_eq!(code, 2); +} + +#[tokio::test] +async fn nounset_error() { + let (err, code) = run_script("set -u\necho $naodefinida\n").await; + assert_eq!(err, "SCRIPT: line 2: naodefinida: unbound variable\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn param_error_if_unset_message() { + let (err, code) = run_script("\necho ${obrigatorio:?precisa definir}\n").await; + assert_eq!(err, "SCRIPT: line 2: obrigatorio: precisa definir\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn error_inside_function_reports_line_of_failing_command() { + let (err, code) = run_script("f() {\n echo in >/dev/null\n nocmd\n}\nf\n").await; + assert_eq!(err, "SCRIPT: line 3: nocmd: command not found\n"); + assert_eq!(code, 127); +} + +#[tokio::test] +async fn eval_syntax_error_uses_dollar_zero() { + let (err, code) = run_script("\neval 'echo \"x'\n").await; + assert!(err.starts_with("SCRIPT: eval: line 2: "), "{err}"); + for line in err.lines() { + assert!(line.starts_with("SCRIPT: eval: line 2: "), "{err}"); + } + assert_eq!(code, 2); +} + +#[tokio::test] +async fn type_not_found_in_dash_c() { + let (err, code) = run_c("type h").await; + assert_eq!(err, "bash: line 1: type: h: not found\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn builtin_usage_line_stays_unprefixed() { + let (err, code) = run_c("umask -x").await; + assert_eq!( + err, + "bash: line 1: umask: -x: invalid option\numask: usage: umask [-p] [-S] [mode]\n" + ); + assert_eq!(code, 2); +} + +#[tokio::test] +async fn shell_builtin_without_bash_tag_gets_prefix() { + let (err, code) = run_script("\ncd /nonexist\n").await; + assert_eq!( + err, + "SCRIPT: line 2: cd: /nonexist: No such file or directory\n" + ); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn arithmetic_error_uses_dollar_zero() { + let (err, code) = run_script("echo $((1/0))\n").await; + assert_eq!( + err, + "SCRIPT: line 1: 1/0: division by 0 (error token is \"0\")\n" + ); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn coreutils_builtin_stays_unprefixed() { + let (err, code) = run_script("cat missing\n").await; + assert_eq!(err, "cat: missing: No such file or directory\n"); + assert_eq!(code, 1); +} + +#[tokio::test] +async fn child_shell_reports_its_own_name_and_line() { + let (err, code) = run_script("\nbash -c 'nocmd'\n").await; + assert_eq!(err, "bash: line 1: nocmd: command not found\n"); + assert_eq!(code, 127); +} + +#[tokio::test] +async fn sourced_file_reports_its_own_path_and_line() { + let mut bash = Bash::new(); + bash.exec("printf 'echo in >/dev/null\\nnocmd\\n' > /tmp/lib.sh") + .await + .unwrap(); + let r = bash + .exec_with_options( + "\n\nsource /tmp/lib.sh\n", + ExecOptions::new().arg0("SCRIPT"), + ) + .await + .unwrap(); + assert_eq!(r.stderr, "/tmp/lib.sh: line 2: nocmd: command not found\n"); +} + +#[tokio::test] +async fn streaming_callback_sees_prefixed_text() { + let seen: Arc> = Arc::new(Mutex::new(String::new())); + let seen_cb = seen.clone(); + let mut bash = Bash::new(); + bash.exec_with_options( + "echo a\nnocmd\n", + ExecOptions::new() + .arg0("SCRIPT") + .streaming(Box::new(move |_stdout, stderr| { + seen_cb.lock().unwrap().push_str(stderr); + })), + ) + .await + .unwrap(); + assert_eq!( + *seen.lock().unwrap(), + "SCRIPT: line 2: nocmd: command not found\n" + ); +} + +#[tokio::test] +async fn interactive_mode_keeps_bare_prefix() { + let mut bash = Bash::new(); + bash.set_interactive(true); + let r = bash.exec("nocmd").await.unwrap(); + assert_eq!(r.stderr, "bash: nocmd: command not found\n"); +} diff --git a/crates/bashkit/tests/integration/glob_fuzz_scaffold_tests.rs b/crates/bashkit/tests/integration/glob_fuzz_scaffold_tests.rs index 8fc498f98..fff39e833 100644 --- a/crates/bashkit/tests/integration/glob_fuzz_scaffold_tests.rs +++ b/crates/bashkit/tests/integration/glob_fuzz_scaffold_tests.rs @@ -98,7 +98,7 @@ async fn glob_fuzz_crash_nul_stripped_newline_command_name() { } /// The missing-input-redirect diagnostic must match real bash byte for byte: -/// `bash: : No such file or directory`. +/// `bash: line 1: : No such file or directory` (`bash -c`). #[tokio::test] async fn missing_input_redirect_matches_bash_wording() { let mut bash = fuzz_bash(); @@ -252,7 +252,7 @@ async fn consecutive_command_failures_are_separate_lines() { let result = bash.exec("nosuchcmd\nalsomissing").await.unwrap(); assert_eq!( result.stderr.to_string(), - "bash: nosuchcmd: command not found\nbash: alsomissing: command not found\n" + "bash: line 1: nosuchcmd: command not found\nbash: line 2: alsomissing: command not found\n" ); } @@ -263,11 +263,11 @@ async fn consecutive_command_failures_are_separate_lines() { #[tokio::test] async fn command_resolution_diagnostics_end_with_newline() { let cases: &[(&str, &str)] = &[ - ("nosuchcmd", "bash: nosuchcmd: command not found\n"), - ("/tmp", "bash: /tmp: Is a directory\n"), + ("nosuchcmd", "bash: line 1: nosuchcmd: command not found\n"), + ("/tmp", "bash: line 1: /tmp: Is a directory\n"), ( "/nope/missing", - "bash: /nope/missing: No such file or directory\n", + "bash: line 1: /nope/missing: No such file or directory\n", ), ]; for (script, expected) in cases { @@ -284,7 +284,7 @@ async fn command_not_found_suggestion_ends_with_newline() { let result = bash.exec("grpe").await.unwrap(); let stderr = result.stderr.to_string(); assert!( - stderr.starts_with("bash: grpe: command not found. Did you mean: "), + stderr.starts_with("bash: line 1: grpe: command not found. Did you mean: "), "unexpected wording: {stderr:?}" ); assert!(stderr.ends_with("\n"), "not terminated: {stderr:?}"); @@ -300,7 +300,7 @@ async fn non_executable_script_denied_ends_with_newline() { let result = bash.exec("/tmp/noexec.sh").await.unwrap(); assert_eq!( result.stderr.to_string(), - "bash: /tmp/noexec.sh: Permission denied\n" + "bash: line 1: /tmp/noexec.sh: Permission denied\n" ); assert_eq!(result.exit_code, 126); } diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 2eedbf8cd..c09be2b64 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -62,6 +62,7 @@ pub mod date_timezone_no_tzdata_tests; pub mod date_timezone_tests; pub mod dev_fd_path_tests; pub mod dev_null_tests; +pub mod diagnostic_prefix_tests; pub mod exec_options_tests; pub mod execution_budget_tests; pub mod execution_capability_tests; diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 854ea19d8..c8e541222 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -1275,9 +1275,11 @@ mod edge_cases { // Use rsync which is never a builtin (ssh may be with ssh feature) let result = bash.exec("rsync").await.unwrap(); assert_eq!(result.exit_code, 127); - // Should match bash format: "bash: cmd: command not found" + // Should match bash format: "bash: line 1: cmd: command not found" assert!( - result.stderr.starts_with("bash: rsync: command not found"), + result + .stderr + .starts_with("bash: line 1: rsync: command not found"), "stderr should match bash format, got: {}", result.stderr ); diff --git a/knowledge/log.md b/knowledge/log.md index 3c8698061..b7cd37f70 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -4,6 +4,7 @@ * **Fix**: `${v@a}` prints every attribute letter `declare -p` shows, in bash's order (`a`/`A`, `i`, `n`, `r`, `x`, `l`, `u`), follows namerefs, takes the array from a subscript, and repeats per element for `${arr[@]@a}`. It only knew `r` and `x`, so `declare -i n; ${n@a}` was empty. * **Fix**: `${#v}` counts bytes when the shell's locale is C/POSIX (`LC_ALL`, then `LC_CTYPE`, then `LANG`, first non-empty), like bash; `LC_ALL=C; s=ação; ${#s}` is 6. With no locale variable set bashkit stays UTF-8, where bash would fall back to C. `${#s}` inside a `${x:-...}` operand counted bytes in every locale and now counts characters. Other operators (`${s:0:2}`, case changes, globs) stay character-based under C (limitations, "Locale"). +* **Fix**: shell diagnostics carry bash's non-interactive `$0: line N: ` prefix (`SCRIPT: line 2: x: command not found`, `bash: line 1: type: h: not found` under `-c`) instead of a bare `bash: `. `$0` is the file being read (`BASH_SOURCE[0]`, so a sourced file names itself), else `$0`; eval syntax errors read `$0: eval: line N:`; redirect failures on `while ...; done < f` report the closing line. Interpreter messages use `Interpreter::diag_prefix`; bundled builtins keep `bash: name:` / `name:` text and the builtin dispatch point rewrites the line start (usage lines, coreutils-style builtins and execution plans untouched). `source` of a missing file now ends with a newline and reads `$0: line N: ./lib.sh: No such file or directory`; `source` with no operand exits 2 with bash's usage line; `set -u` names a bare positional `$2`. Interactive shells (`Bash::set_interactive`, set by the CLI REPL and `Terminal`) keep `bash: msg` with no line, like `bash -i`. The sandbox "Did you mean" hint after `command not found` is kept. bash-oracle strict 71 to 84/108. - Added the bash-oracle scoreboard (`scripts/bash-oracle`, `just bash-oracle`): 106 whole-script cases replayed against output recorded from real bash 5.2, scored as `match` (status + stdout, 90/106) and `strict` (also stderr, diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 6493dbcda..50328afe4 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -110,6 +110,7 @@ Target: IEEE 1003.1-2024 Shell Command Language. | Process substitution | `<(cmd)` and `>(cmd)` as filenames, nesting, redirections | The path is `/dev/fd/proc_sub_`, not bash's `/dev/fd/63`. Several interpreters can share one filesystem, so a per-shell descending number would let one tenant's substitution overwrite another's. A script that parses the number sees a different one (`scripts/bash-oracle/cases/process-substitution-dev-fd.sh`) | | Named fds / `<>` | `{var}>file`, `{var}file`, `{var}>>file`, `{var}<<&N`/`<&N` allocate the lowest free fd >= 10 on any command (persisting, as in bash); `{var}>&-` closes; `>&N` with N closed is `Bad file descriptor`; `N<>file`, `N<<file` has no shared read/write offset (writes append); `3&1`, `( ... ) 3>&1`) is dropped; a `Bad file descriptor` message ignores redirect order (`2>&1 >&7`); output to a stdout closed by the command's own `>&-` reports `write error: Bad file descriptor` without naming the command, since the redirect is applied after it ran | | Locale | UTF-8 text everywhere; `${#v}` counts bytes when `LC_ALL`/`LC_CTYPE`/`LANG` (first non-empty) is `C` or `POSIX` | With no locale variable set bashkit is UTF-8, where bash falls back to C. Under C, `${s:off:len}`, `${s^^}`, `${s#?}` and glob `?` still work on characters, not bytes: shell words are Rust strings and cannot hold a split UTF-8 sequence (L-STREAM-001) | +| Diagnostic prefix | `$0: line N: ` on interpreter and shell-builtin messages, `$0` = sourced file inside `source` | `(( ))`, `[[ ]]`, `{ }` and `( )` carry no span, so an error raised by the construct itself (not a command inside it) reports a stale line (line 1 at the top level); a function defined in a sourced file and called from the script names the caller's file (bash names the defining file); `${3}` under `set -u` reports `$3` (bash: `3`, only bare `$3` gets the `$`); `command not found` keeps the sandbox "Did you mean" / unavailable-command hint bash does not print; parse-error wording differs from bash | | Brace expansion | Runs before other expansions on unquoted literal text; lists, nesting, ranges with step and zero padding, quoted/escaped braces and commas | Works on parsed words, not raw text: `$v{1,2}` expands `$v` then appends `1`/`2` (bash reads `$v1`, `$v2`); write `${v}` | ## Builtins diff --git a/scripts/bash-oracle/README.md b/scripts/bash-oracle/README.md index 833834d8b..6678264be 100644 --- a/scripts/bash-oracle/README.md +++ b/scripts/bash-oracle/README.md @@ -15,8 +15,8 @@ python3 scripts/bash-oracle/run.py --bin path/to/bashkit - **match** — status plus stdout, what a script actually observes. This is the number the bash-parity work tracks. -- **strict** — also requires bash's stderr text. It trails well behind, because - most bashkit builtin diagnostics still lack bash's `bash: line N:` prefix. +- **strict** — also requires bash's stderr text. It trails behind, because + some wording (parser errors, xtrace details) still differs from bash. `floor.txt` holds both floors as ` `. `run.py` exits 1 when either score falls below its floor, and prints the new number to put in diff --git a/scripts/bash-oracle/floor.txt b/scripts/bash-oracle/floor.txt index 9f0f5566a..4f319472e 100644 --- a/scripts/bash-oracle/floor.txt +++ b/scripts/bash-oracle/floor.txt @@ -1 +1 @@ -90 69 +98 89 diff --git a/scripts/bash-oracle/run.py b/scripts/bash-oracle/run.py index b5280d4aa..d61185727 100755 --- a/scripts/bash-oracle/run.py +++ b/scripts/bash-oracle/run.py @@ -8,8 +8,7 @@ Two scores, because they move at different speeds. `match` is status plus stdout: what a script observes, and the number the roadmap tracks. `strict` also requires the stderr text, which still carries bash wording bashkit does -not reproduce (no `bash: line N:` prefix on most builtin diagnostics), so it -trails well behind. +not reproduce (parser error texts, xtrace details), so it trails behind. `floor.txt` holds both floors, ` `. Exits 1 when either score drops below its floor, so a regression fails CI; it also says when a floor is