diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 7875d649b..61596bfca 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -100,7 +100,11 @@ impl Builtin for Mkdir { if let Err(e) = ctx.fs.mkdir(&path, recursive).await { return Ok(ExecResult::err( - format!("mkdir: cannot create directory '{}': {}\n", dir, e), + format!( + "mkdir: cannot create directory '{}': {}\n", + dir, + crate::error::io_error_reason(&e) + ), 1, )); } @@ -772,12 +776,20 @@ impl Builtin for Touch { continue; } if let Err(e) = ctx.fs.write_file(&path, &[]).await { - return fail(format!("touch: cannot touch '{}': {}\n", file, e)); + return fail(format!( + "touch: cannot touch '{}': {}\n", + file, + crate::error::io_error_reason(&e) + )); } } if let Err(e) = ctx.fs.set_modified_time(&path, target_time).await { - return fail(format!("touch: cannot touch '{}': {}\n", file, e)); + return fail(format!( + "touch: cannot touch '{}': {}\n", + file, + crate::error::io_error_reason(&e) + )); } } diff --git a/crates/bashkit/src/builtins/flow.rs b/crates/bashkit/src/builtins/flow.rs index b6369d308..499dd7246 100644 --- a/crates/bashkit/src/builtins/flow.rs +++ b/crates/bashkit/src/builtins/flow.rs @@ -44,17 +44,34 @@ impl Builtin for False { /// The exit builtin - exit the shell with a status code. /// Bash truncates exit codes to 8-bit unsigned range (0-255) via `& 0xFF`. +/// Without an argument it exits with `$?`; a non-numeric argument is +/// reported and exits 2; more than one argument is an error (status 1) that +/// abandons the rest of the line instead of exiting. pub struct Exit; #[async_trait] impl Builtin for Exit { async fn execute(&self, ctx: Context<'_>) -> Result { - let exit_code = ctx - .args - .first() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0) - & 0xFF; + let exit_code = match ctx.args.first() { + None => ctx.shell.as_ref().map_or(0, |s| s.last_exit_code), + Some(arg) => match arg.trim().parse::() { + Ok(n) => (n & 0xFF) as i32, + Err(_) => { + let mut result = ExecResult::with_control_flow(ControlFlow::Exit(2)); + result.exit_code = 2; + result.stderr = + format!("bash: exit: {arg}: numeric argument required\n").into(); + return Ok(result); + } + }, + }; + if ctx.args.len() > 1 { + // bash discards the rest of the line (`jump_to_top_level(DISCARD)`): + // the script resumes at the next line, a subshell ends. + let mut result = ExecResult::err("bash: exit: too many arguments\n", 1); + result.control_flow = ControlFlow::Abort; + return Ok(result); + } Ok(ExecResult { exit_code, @@ -286,13 +303,30 @@ mod tests { } #[tokio::test] - async fn exit_non_numeric_defaults_zero() { + async fn exit_non_numeric_is_usage_error() { let (fs, mut cwd, mut variables) = setup().await; let env = HashMap::new(); let args = vec!["abc".to_string()]; let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None); let result = Exit.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); + assert_eq!(result.exit_code, 2); + assert_eq!(result.control_flow, ControlFlow::Exit(2)); + assert!( + result + .stderr + .contains("exit: abc: numeric argument required") + ); + } + + #[tokio::test] + async fn exit_too_many_args_aborts_the_line() { + let (fs, mut cwd, mut variables) = setup().await; + let env = HashMap::new(); + let args = vec!["1".to_string(), "2".to_string()]; + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None); + let result = Exit.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!(result.control_flow, ControlFlow::Abort); } // ==================== break ==================== diff --git a/crates/bashkit/src/fs/rootfs.rs b/crates/bashkit/src/fs/rootfs.rs index 707e80e39..8affe9f93 100644 --- a/crates/bashkit/src/fs/rootfs.rs +++ b/crates/bashkit/src/fs/rootfs.rs @@ -14,6 +14,9 @@ //! modifying, removing, or renaming one fails with "Permission denied". New //! files may still be created under `/etc`, `/usr/bin`, ... (they go to the //! session filesystem), which keeps scripts that `mkdir -p /etc/app` working. +//! - `/proc` is the exception: like Linux procfs, nothing new can be created +//! in it (`echo x > /proc/new` fails with "No such file or directory"), +//! unless the session filesystem itself holds the parent directory. //! - `/bin` and `/usr/bin` hold a stub per registered command (not shell-only //! builtins) so `ls /usr/bin`, `[ -x /usr/bin/env ]`, `which`, and //! `#!/usr/bin/env` work. Executing a stub dispatches the builtin. @@ -141,6 +144,9 @@ fn is_ancestor(path: &Path) -> bool { SYS_ANCESTORS.iter().any(|a| path == Path::new(a)) } +/// The procfs tree: a pseudo-filesystem that refuses new entries. +const PROC_TREE: &str = "/proc"; + fn permission_denied() -> crate::Error { IoError::new(ErrorKind::PermissionDenied, "Permission denied").into() } @@ -359,9 +365,18 @@ impl RootFs { } /// Create a missing parent that the system layer provides, so new files - /// can be created under `/etc` and friends. + /// can be created under `/etc` and friends. `/proc` refuses new entries + /// like Linux procfs (`ENOENT`), unless the session filesystem holds the + /// parent (an embedder's own `/proc` tree). async fn ensure_parent(&self, path: &Path) -> Result<()> { let p = normalize_path(path); + if p.starts_with(PROC_TREE) + && let Some(parent) = p.parent() + && !self.inner.exists(parent).await.unwrap_or(false) + && self.served_by_sys(&p).await.is_none() + { + return Err(IoError::new(ErrorKind::NotFound, "No such file or directory").into()); + } if let Some(parent) = p.parent() && Self::sys_path(parent).is_some() && !self.inner.exists(parent).await.unwrap_or(false) @@ -404,9 +419,7 @@ impl FileSystem for RootFs { } return Err(IoError::new(ErrorKind::AlreadyExists, "File exists").into()); } - if !recursive { - self.ensure_parent(path).await?; - } + self.ensure_parent(path).await?; self.inner.mkdir(path, recursive).await } @@ -626,6 +639,47 @@ mod tests { ); } + #[tokio::test] + async fn proc_refuses_new_entries_like_procfs() { + let fs = rootfs(); + for path in ["/proc/naopode", "/proc/self/new"] { + let err = fs.write_file(Path::new(path), b"y").await.unwrap_err(); + assert_eq!( + crate::error::io_error_reason(&err), + "No such file or directory" + ); + assert!(fs.append_file(Path::new(path), b"y").await.is_err()); + assert!(fs.mkdir(Path::new(path), false).await.is_err()); + assert!(!fs.exists(Path::new(path)).await.unwrap()); + } + assert!(fs.mkdir(Path::new("/proc/a/b"), true).await.is_err()); + assert!( + fs.symlink(Path::new("/tmp"), Path::new("/proc/link")) + .await + .is_err() + ); + // Existing entries stay read-only, not missing. + assert!( + fs.write_file(Path::new("/proc/version"), b"y") + .await + .is_err() + ); + } + + #[tokio::test] + async fn embedder_proc_dir_accepts_new_files() { + let inner = Arc::new(InMemoryFs::new()); + inner.mkdir(Path::new("/proc/app"), true).await.unwrap(); + let fs = RootFs::new(inner, "sandbox", "bashkit-sandbox"); + fs.write_file(Path::new("/proc/app/state"), b"ok") + .await + .unwrap(); + assert_eq!( + fs.read_file(Path::new("/proc/app/state")).await.unwrap(), + b"ok" + ); + } + #[tokio::test] async fn session_files_shadow_system_files() { let inner = Arc::new(InMemoryFs::new()); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 9fcd3cd8a..bddfb7065 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1604,6 +1604,15 @@ pub struct Interpreter { cond_regex_error: bool, /// Diagnostics from `[[ ]]` leaves (arithmetic operand errors). cond_stderr: String, + /// Stderr written inside `$(...)` that has not reached a command result + /// yet. bash's substitution shares the outer stderr, so its diagnostics + /// (`x=$(nocmd)`) show; the command that expanded it emits them ahead of + /// its own stderr, outside its own redirects (bash expands words first). + subst_stderr: crate::StreamData, + /// `subst_stderr` values set aside by enclosing commands and + /// substitutions while an inner one runs. Kept here rather than in async + /// locals so the recursive `$(...)` futures stay small (TM-DOS-089). + subst_stderr_held: Vec, /// First arithmetic error raised by a read-only evaluation site (array /// subscripts, `declare -i` values, substring offsets). The command /// boundary turns it into a line abort, as bash does. @@ -2111,6 +2120,8 @@ impl Interpreter { return_depth: 0, cond_regex_error: false, cond_stderr: String::new(), + subst_stderr: crate::StreamData::new(), + subst_stderr_held: Vec::new(), arith_error: StdMutex::new(None), pending_compound_args: Vec::new(), assign_raw: false, @@ -2291,6 +2302,8 @@ impl Interpreter { return_depth: self.return_depth, cond_regex_error: false, cond_stderr: String::new(), + subst_stderr: crate::StreamData::new(), + subst_stderr_held: Vec::new(), arith_error: StdMutex::new(None), pending_compound_args: Vec::new(), assign_raw: false, @@ -2536,6 +2549,10 @@ impl Interpreter { self.err_trap_dormant = false; self.err_trap_skip_stage = false; self.line_base = 0; + // An exec that failed mid-command must not hand its queued `$(...)` + // stderr to the next exec. + self.subst_stderr = crate::StreamData::new(); + self.subst_stderr_held.clear(); self.condition_sequence_depth = 0; self.loop_depth = 0; self.return_depth = 0; @@ -3785,6 +3802,8 @@ impl Interpreter { Ok(ExecResult::ok(String::new())) } }; + let mut result = result; + self.settle_pending_subst_stderr(&mut result); let mut result = self.abort_line_on_error(result); if err_armed && let Ok(r) = &mut result @@ -3801,6 +3820,68 @@ impl Interpreter { }) } + /// Queue stderr written inside `$(...)` for the command that expanded it + /// (capped like any stderr: THREAT[TM-DOS-058]). + fn queue_subst_stderr(&mut self, stderr: &crate::StreamData) { + let room = self + .limits + .max_stderr_bytes + .saturating_sub(self.subst_stderr.len()); + if !stderr.is_empty() && room > 0 { + self.subst_stderr.append(&stderr.prefix(room)); + } + } + + /// Emit queued `$(...)` stderr ahead of the command's own. On error it + /// stays queued for the next command boundary. + fn settle_subst_stderr(&mut self, queued: crate::StreamData, result: &mut Result) { + if queued.is_empty() { + return; + } + match result { + Ok(r) => r.stderr = queued + &r.stderr, + Err(_) => { + let later = std::mem::take(&mut self.subst_stderr); + self.subst_stderr = queued + &later; + } + } + } + + /// Emit the pending `$(...)` stderr ahead of `result`'s own. + fn settle_pending_subst_stderr(&mut self, result: &mut Result) { + let queued = std::mem::take(&mut self.subst_stderr); + self.settle_subst_stderr(queued, result); + } + + /// Set the pending `$(...)` stderr aside while an inner command or + /// substitution runs; returns the slot to settle or release it from. + /// A slot left behind by an inner early `?` return is dropped when an + /// enclosing slot is taken back. + fn hold_subst_stderr(&mut self) -> usize { + let held = std::mem::take(&mut self.subst_stderr); + self.subst_stderr_held.push(held); + self.subst_stderr_held.len() - 1 + } + + fn take_held_subst_stderr(&mut self, slot: usize) -> crate::StreamData { + self.subst_stderr_held.truncate(slot + 1); + self.subst_stderr_held.pop().unwrap_or_default() + } + + /// Settle the stderr set aside by [`Self::hold_subst_stderr`] on `result`. + fn settle_held_subst_stderr(&mut self, slot: usize, result: &mut Result) { + let held = self.take_held_subst_stderr(slot); + self.settle_subst_stderr(held, result); + } + + /// End a substitution: the outer stderr set aside on entry comes back, + /// followed by whatever the substitution queued. + fn release_held_subst_stderr(&mut self, slot: usize) { + let inner = std::mem::take(&mut self.subst_stderr); + self.subst_stderr = self.take_held_subst_stderr(slot); + self.queue_subst_stderr(&inner); + } + /// An ERR trap is set and live (not dormant in a subshell). fn err_trap_armed(&self) -> bool { !self.err_trap_dormant && !self.in_trap && self.scoped.traps.contains_key("ERR") @@ -7317,12 +7398,12 @@ impl Interpreter { // Empty command handling if name.is_empty() { - if command.name.quoted && !name_vanished && command.assignments.is_empty() { - self.last_exit_code = 127; - self.discard_deferred_proc_subs_from(deferred_proc_sub_start); - return Ok(ExecResult::err(self.diag(": command not found\n"), 127)); - } - let exit_code = if !command.assignments.is_empty() + // `"" 2>/dev/null`: the not-found report follows the redirects. + let not_found = + command.name.quoted && !name_vanished && command.assignments.is_empty(); + let exit_code = if not_found { + 127 + } else if !command.assignments.is_empty() && self.subst_generation == pre_assign_subst_gen { 0 @@ -7337,11 +7418,13 @@ impl Interpreter { self.last_exit_code }; self.discard_deferred_proc_subs_from(deferred_proc_sub_start); - if !command.redirects.is_empty() { + if not_found || !command.redirects.is_empty() { // Null commands still perform their redirections: `> f` // truncates, `< missing` fails with status 1. + let own_error = + not_found.then(|| ExecResult::err(self.diag(": command not found\n"), 127)); return self - .execute_null_command_redirects(exit_code, &command.redirects) + .null_command_outcome(own_error, exit_code, &command.redirects) .await; } self.last_exit_code = exit_code; @@ -7549,10 +7632,14 @@ impl Interpreter { // (`f 3>&1` where `f` writes `>&3`). let scope_len = self.fd_redirect_scope.len(); redirection::push_redirect_scope_fds(&mut self.fd_redirect_scope, &command.redirects); - let result = self + // Stderr of this command's own `$(...)` words: claimed here so a + // function body cannot route it through the call's redirects. + let held = self.hold_subst_stderr(); + let mut result = self .execute_dispatched_command_inner(name, args, command, stdin) .await; self.fd_redirect_scope.truncate(scope_len); + self.settle_held_subst_stderr(held, &mut result); result }) } @@ -7596,12 +7683,14 @@ impl Interpreter { // top-level positional parameters (`bashkit script.sh a b`) does // not make `return` legal. let in_function = self.call_stack.iter().any(|f| f.keeps_arg0); + // Its own redirects apply to the report (`return 2>&1`). if name == "return" && !in_function && self.source_depth == 0 { self.last_exit_code = 2; - return Ok(ExecResult::err( + let result = ExecResult::err( self.diag("return: can only `return' from a function or sourced script\n"), 2, - )); + ); + return self.redirect_result(result, &command.redirects).await; } // Check for nounset error from argument expansion @@ -7969,7 +8058,7 @@ impl Interpreter { self.diag(format!("{name}: cancelled by before_tool hook\n")), 1, ); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } } } else { @@ -8218,7 +8307,7 @@ impl Interpreter { self.diag(format!("{name}: cancelled by before_tool hook\n")), 1, ); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } } } else { @@ -8454,7 +8543,10 @@ impl Interpreter { .chain(host_names.iter().map(|s| s.as_str())) .collect(); let msg = command_not_found_message(&self.diag_prefix(), name, &known); - Ok(ExecResult::err(msg, 127)) + // bash sets up the redirects before looking the name up, so + // `nocmd 2>/dev/null` is silent and `nocmd 2>&1` reports on stdout. + self.redirect_result(ExecResult::err(msg, 127), &command.redirects) + .await }) } @@ -8475,42 +8567,11 @@ impl Interpreter { ) -> Result { let path = self.resolve_path(name); - // stat the file - let meta = match self.fs.stat(&path).await { - Ok(m) => m, - Err(_) => { - return Ok(ExecResult::err( - self.diag(format!("{name}: No such file or directory\n")), - 127, - )); - } - }; - - // Directory check - if meta.file_type.is_dir() { - return Ok(ExecResult::err( - self.diag(format!("{name}: Is a directory\n")), - 126, - )); - } - - // Execute permission check - if meta.mode & 0o111 == 0 { - return Ok(ExecResult::err( - self.diag(format!("{name}: Permission denied\n")), - 126, - )); - } - - // Read file content - let raw = match self.fs.read_file(&path).await { - Ok(c) => c, - Err(_) => { - return Ok(ExecResult::err( - self.diag(format!("{name}: No such file or directory\n")), - 127, - )); - } + // bash opens the command's redirects before it tries to run the + // file, so `./missing 2>/dev/null` reports nothing. + let raw = match self.read_executable(name, &path).await { + 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) @@ -8527,6 +8588,46 @@ impl Interpreter { .await } + /// Apply `redirects` to a result the command produced without running + /// (its own error). THREAT[TM-DOS-089]: boxed so the command-dispatch + /// futures on the `$(...)` recursion path stay small. + fn redirect_result<'a>( + &'a mut self, + result: ExecResult, + redirects: &'a [Redirect], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(self.apply_redirections(result, redirects)) + } + + /// stat + permission checks + read for a command run by path. The error + /// is the command's result (bash's wording and 126/127 status). + async fn read_executable( + &self, + name: &str, + path: &Path, + ) -> std::result::Result, ExecResult> { + let missing = || { + ExecResult::err( + self.diag(format!("{name}: No such file or directory\n")), + 127, + ) + }; + let meta = self.fs.stat(path).await.map_err(|_| missing())?; + if meta.file_type.is_dir() { + return Err(ExecResult::err( + self.diag(format!("{name}: Is a directory\n")), + 126, + )); + } + if meta.mode & 0o111 == 0 { + return Err(ExecResult::err( + self.diag(format!("{name}: Permission denied\n")), + 126, + )); + } + self.fs.read_file(path).await.map_err(|_| missing()) + } + /// Search $PATH for an executable script and run it. /// /// Returns `Ok(None)` if no matching file found (caller emits "command not found"). @@ -8801,7 +8902,9 @@ impl Interpreter { "{name}: filename argument required\n\ {name}: usage: {name} filename [arguments]\n" )); - return Ok(ExecResult::err(msg, 2)); + return self + .redirect_result(ExecResult::err(msg, 2), redirects) + .await; } }; @@ -8813,10 +8916,11 @@ impl Interpreter { match self.fs.read_file(&path).await { Ok(c) => decode_file_bytes_for_path(&path, &c), Err(_) => { - return Ok(ExecResult::err( + let result = ExecResult::err( self.diag(format!("{filename}: No such file or directory\n")), 1, - )); + ); + return self.redirect_result(result, redirects).await; } } } else { @@ -8849,10 +8953,11 @@ impl Interpreter { match found { Some(c) => c, None => { - return Ok(ExecResult::err( + let result = ExecResult::err( self.diag(format!("{filename}: No such file or directory\n")), 1, - )); + ); + return self.redirect_result(result, redirects).await; } } }; @@ -8866,10 +8971,9 @@ impl Interpreter { } else { format!("syntax error: {message}") }; - return Ok(ExecResult::err( - format!("{filename}: line {}: {message}\n", line.max(1)), - 2, - )); + let result = + ExecResult::err(format!("{filename}: line {}: {message}\n", line.max(1)), 2); + return self.redirect_result(result, redirects).await; } Err(e) => return Err(e), }; @@ -8989,7 +9093,7 @@ impl Interpreter { format!("{who}: eval: line {at}: {message}\n{who}: eval: line {at}: `{src}'\n"), 2, ); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } Err(e) => return Err(e), }; @@ -9005,7 +9109,13 @@ impl Interpreter { // EXIT trap, which is wrong for eval and — because the top-level EXIT // trap has no re-entrancy guard — lets `trap 'eval :' EXIT` recurse one // command per level until the budget aborts, risking stack overflow. - let mut result = self.execute_script_body(&script, false, true).await?; + // Like bash, the eval'd text counts lines from the eval's own line + // (`$LINENO`, diagnostics), not from 1. + let saved_line_base = + std::mem::replace(&mut self.line_base, self.current_line.saturating_sub(1)); + let result = self.execute_script_body(&script, false, true).await; + self.line_base = saved_line_base; + let mut result = result?; self.pipeline_stdin = prev_pipeline_stdin; @@ -9476,7 +9586,7 @@ impl Interpreter { ) -> Result { if args.is_empty() { let result = ExecResult::err(self.diag("let: expression expected\n"), 1); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } let mut last_val = 0i64; for arg in args { @@ -9485,7 +9595,7 @@ impl Interpreter { // An error stops `let` at that expression with status 1. Err(msg) => { let result = ExecResult::err(self.arith_diag("let: ", &msg), 1); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } } } @@ -9530,7 +9640,7 @@ impl Interpreter { )), 2, ); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } } } @@ -9540,7 +9650,7 @@ impl Interpreter { self.diag("unset: cannot simultaneously unset a function and a variable\n"), 1, ); - return self.apply_redirections(result, redirects).await; + return self.redirect_result(result, redirects).await; } let var_args: Vec<&String> = args[operands_start..].iter().collect(); @@ -9713,7 +9823,7 @@ impl Interpreter { ) -> Result { if args.len() < 2 { let result = ExecResult::err("getopts: usage: getopts optstring name [arg ...]\n", 2); - return Ok(result); + return self.redirect_result(result, redirects).await; } let optstring = &args[0]; @@ -9905,10 +10015,11 @@ impl Interpreter { || builtins::BASH_BUILTIN_NAMES.contains(&name.as_str()) || self.resolve_command_path(name).await.is_none()); if !is_shell_builtin { - return Ok(ExecResult::err( + let result = ExecResult::err( self.diag(format!("builtin: {name}: not a shell builtin\n")), 1, - )); + ); + return self.redirect_result(result, redirects).await; } // `command NAME` already runs the builtin and skips functions; a // leading `--` keeps a `-v`-named builtin from becoming a flag. @@ -10095,10 +10206,11 @@ impl Interpreter { return Ok(result); } } - Ok(ExecResult::err( + let result = ExecResult::err( self.diag(format!("{}: command not found\n", remaining[0])), 127, - )) + ); + self.redirect_result(result, redirects).await } } } @@ -10683,6 +10795,30 @@ impl Interpreter { /// Perform the redirections of a null command (no command word) and /// return its result. Input redirects are opened and discarded. + /// A null command's outcome: its own error (`""`) under its redirects, + /// or just the redirects. Boxed, and a single await in + /// `execute_simple_command`, to keep that future's frame small on the + /// `$(...)` recursion path (TM-DOS-089). + fn null_command_outcome<'a>( + &'a mut self, + own_error: Option, + exit_code: i32, + redirects: &'a [Redirect], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + match own_error { + Some(result) => { + self.last_exit_code = result.exit_code; + self.apply_redirections(result, redirects).await + } + None => { + self.execute_null_command_redirects(exit_code, redirects) + .await + } + } + }) + } + async fn execute_null_command_redirects( &mut self, exit_code: i32, @@ -10870,6 +11006,9 @@ impl Interpreter { let snapshot = self.snapshot_subshell_state(); self.bash_subshell += 1; self.enter_subshell_err_scope(); + // The outer command's queued stderr must not pass through the + // substitution's own redirects (`$(cmd 2>&1)`). + let held_stderr = self.hold_subst_stderr(); // THREAT[TM-DOS-111]: Expansion happens before top-level output caps, // so reserve every byte before growing the substitution buffer. let mut stdout = BudgetedString::new(Some(&self.execution_budget))?; @@ -10896,7 +11035,9 @@ impl Interpreter { } self.last_exit_code = 0; } - Err(crate::error::Error::CommandFailure(_)) => { + Err(crate::error::Error::CommandFailure(msg)) => { + // `$(< missing)` reports like bash. + self.queue_subst_stderr(&msg.into()); self.last_exit_code = 1; } Err(e) => return Err(e), @@ -10931,6 +11072,9 @@ impl Interpreter { run = Err(e.into()); break; } + // Not captured: bash's substitution writes it to the outer + // stderr (`x=$(nocmd)` reports `nocmd: command not found`). + self.queue_subst_stderr(&cmd_result.stderr); self.last_exit_code = cmd_result.exit_code; if matches!( cmd_result.control_flow, @@ -10947,6 +11091,9 @@ impl Interpreter { } } self.output_callback = saved_callback; + if run.is_err() { + self.release_held_subst_stderr(held_stderr); + } run?; // Fire EXIT trap set inside the command substitution if let Some(trap_cmd) = self.scoped.traps.get("EXIT").cloned() @@ -10963,7 +11110,9 @@ impl Interpreter { .await { stdout.try_push_str(&trap_result.stdout.command_substitution_text())?; + self.queue_subst_stderr(&trap_result.stderr); } + self.release_held_subst_stderr(held_stderr); self.restore_subshell_state(snapshot); self.counters.pop_subst(); self.subst_generation += 1; diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index c09be2b64..4d2268bdf 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -143,6 +143,7 @@ pub mod sqlite_fuzz_tests; pub mod sqlite_integration_tests; pub mod sqlite_security_tests; pub mod stack_overflow_regression_tests; +pub mod stderr_fidelity_tests; pub mod subst_depth_limit_tests; pub mod symlink_overlay_security_tests; #[cfg(feature = "terminal")] diff --git a/crates/bashkit/tests/integration/stderr_fidelity_tests.rs b/crates/bashkit/tests/integration/stderr_fidelity_tests.rs new file mode 100644 index 000000000..e6fab2c7d --- /dev/null +++ b/crates/bashkit/tests/integration/stderr_fidelity_tests.rs @@ -0,0 +1,144 @@ +//! Where diagnostics go: a command's own redirects apply to its own error +//! (`nocmd 2>/dev/null`, `return 2>&1`), stderr written inside `$(...)` +//! reaches the outer stderr, `eval` keeps the caller's `$LINENO`, and +//! `/proc` refuses new files like procfs. +//! +//! Every expected stdout/stderr pair was checked against real bash 5.2 +//! running the same text as `bash SCRIPT` (`$0` is `SCRIPT`). + +use bashkit::{Bash, ExecOptions}; + +/// Run `script` the way `bash SCRIPT` would: `$0` is `SCRIPT`. +async fn run(script: &str) -> (String, String, i32) { + let mut bash = Bash::new(); + let r = bash + .exec_with_options(script, ExecOptions::new().arg0("SCRIPT")) + .await + .unwrap(); + (r.stdout.to_string(), r.stderr.to_string(), r.exit_code) +} + +#[tokio::test] +async fn command_not_found_honors_its_redirects() { + let (out, err, _) = run("nocmdq 2>/dev/null\necho \"rc=$?\"\n").await; + assert_eq!(out, "rc=127\n"); + assert_eq!(err, ""); + + let (out, err, _) = run("nocmdq 2>&1\n").await; + assert_eq!(out, "SCRIPT: line 1: nocmdq: command not found\n"); + assert_eq!(err, ""); +} + +#[tokio::test] +async fn missing_path_command_honors_its_redirects() { + let (out, err, _) = run("./missingq 2>/dev/null\necho \"rc=$?\"\n").await; + assert_eq!(out, "rc=127\n"); + assert_eq!(err, ""); +} + +#[tokio::test] +async fn toplevel_return_error_follows_its_redirects() { + let (out, err, _) = run("return 2>&1; echo \"rc=$?\"\n").await; + assert_eq!( + out, + "SCRIPT: line 1: return: can only `return' from a function or sourced script\nrc=2\n" + ); + assert_eq!(err, ""); +} + +#[tokio::test] +async fn special_builtin_errors_follow_their_redirects() { + let script = "source /nofileq 2>&1\n. 2>&1\ngetopts 2>&1\nbuiltin nocmdq 2>&1\n"; + let (out, err, _) = run(script).await; + assert_eq!( + out, + "SCRIPT: line 1: /nofileq: No such file or directory\n\ + SCRIPT: line 2: .: filename argument required\n\ + .: usage: . filename [arguments]\n\ + getopts: usage: getopts optstring name [arg ...]\n\ + SCRIPT: line 4: builtin: nocmdq: not a shell builtin\n" + ); + assert_eq!(err, ""); +} + +#[tokio::test] +async fn exit_reports_bad_arguments() { + let (out, err, code) = run("exit abc 2>&1\necho after\n").await; + assert_eq!( + out, + "SCRIPT: line 1: exit: abc: numeric argument required\n" + ); + assert_eq!(err, ""); + assert_eq!(code, 2); + + let (out, err, code) = run("false\nexit\n").await; + assert_eq!((out.as_str(), err.as_str(), code), ("", "", 1)); + + let (out, err, _) = run("exit 1 2\necho \"rc=$?\"\n").await; + assert_eq!(out, "rc=1\n"); + assert_eq!(err, "SCRIPT: line 1: exit: too many arguments\n"); +} + +#[tokio::test] +async fn command_substitution_stderr_reaches_outer_stderr() { + let (out, err, _) = run("x=$(nocmdq)\necho \"x=[$x]\"\n").await; + assert_eq!(out, "x=[]\n"); + assert_eq!(err, "SCRIPT: line 1: nocmdq: command not found\n"); + + // Words are expanded before the command's redirects apply. + let (out, err, _) = run("echo \"a$(nocmdq)b\" 2>/dev/null\n").await; + assert_eq!(out, "ab\n"); + assert_eq!(err, "SCRIPT: line 1: nocmdq: command not found\n"); + + // A function's call redirects do not cover its argument words either. + let (out, err, _) = run("f() { echo \"f:$1\"; }\nf \"$(nocmdq)\" 2>/dev/null\n").await; + assert_eq!(out, "f:\n"); + assert_eq!(err, "SCRIPT: line 2: nocmdq: command not found\n"); + + // The substitution's own redirects do. + let (out, err, _) = run("y=$(nocmdq 2>&1)\necho \"y=[$y]\"\n").await; + assert_eq!(out, "y=[SCRIPT: line 1: nocmdq: command not found]\n"); + assert_eq!(err, ""); + + // A compound command's redirects cover its own expansions. + let (out, err, _) = run("{ w=$(nocmdq); } 2>/dev/null\necho done\n").await; + assert_eq!(out, "done\n"); + assert_eq!(err, ""); + + let (out, err, _) = run("r=$(echo out; echo err >&2)\necho \"r=[$r]\"\n").await; + assert_eq!(out, "r=[out]\n"); + assert_eq!(err, "err\n"); + + let (out, err, _) = run("echo $(echo $(nocmdq))x\n").await; + assert_eq!(out, "x\n"); + assert_eq!(err, "SCRIPT: line 1: nocmdq: command not found\n"); + + let (out, err, _) = run("q=$(< /nofileq)\necho \"rc=$?\"\n").await; + assert_eq!(out, "rc=1\n"); + assert_eq!(err, "SCRIPT: line 1: /nofileq: No such file or directory\n"); +} + +#[tokio::test] +async fn eval_keeps_the_callers_lineno() { + let (out, err, _) = run("#\n#\n#\neval 'echo $LINENO'\neval 'nocmdq'\n").await; + assert_eq!(out, "4\n"); + assert_eq!(err, "SCRIPT: line 5: nocmdq: command not found\n"); +} + +#[tokio::test] +async fn proc_refuses_new_files() { + let (out, err, _) = run("echo y > /proc/naopode 2>/dev/null; echo \"rc=$?\"\n").await; + assert_eq!(out, "rc=1\n"); + assert_eq!( + err, + "SCRIPT: line 1: /proc/naopode: No such file or directory\n" + ); + + let (out, err, _) = run("mkdir /proc/d; touch /proc/t; [ -e /proc/t ] || echo absent\n").await; + assert_eq!(out, "absent\n"); + assert_eq!( + err, + "mkdir: cannot create directory '/proc/d': No such file or directory\n\ + touch: cannot touch '/proc/t': No such file or directory\n" + ); +} diff --git a/crates/bashkit/tests/spec_cases/bash/stderr-redirect-fidelity.test.sh b/crates/bashkit/tests/spec_cases/bash/stderr-redirect-fidelity.test.sh new file mode 100644 index 000000000..9e4a939fb --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/stderr-redirect-fidelity.test.sh @@ -0,0 +1,143 @@ +### command_not_found_honors_2_dev_null +# The command's redirects are set up before the name is looked up +nocmdq 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### command_not_found_report_follows_2_to_1 +out=$(nocmdq 2>&1) +echo "[$out]" +### expect +[bash: line 1: nocmdq: command not found] +### end + +### missing_path_command_honors_2_dev_null +./missingq 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### toplevel_return_report_follows_2_to_1 +return 2>&1; echo "rc=$?" +### expect +bash: line 1: return: can only `return' from a function or sourced script +rc=2 +### end + +### source_missing_file_report_follows_2_to_1 +out=$(source /nofileq 2>&1) +echo "[$out]" +### expect +[bash: line 1: /nofileq: No such file or directory] +### end + +### builtin_not_a_shell_builtin_follows_2_to_1 +out=$(builtin nocmdq 2>&1) +echo "[$out]" +### expect +[bash: line 1: builtin: nocmdq: not a shell builtin] +### end + +### exit_non_numeric_argument +(exit abc) 2>/dev/null +echo "rc=$?" +### expect +rc=2 +### end + +### exit_without_argument_uses_last_status +(false; exit) +echo "rc=$?" +### expect +rc=1 +### end + +### exit_too_many_arguments_abandons_the_line +# Status 1 and the rest of the line is skipped (a subshell ends) +(exit 1 2; echo "same line") 2>/dev/null +echo "rc=$?" +### expect +rc=1 +### end + +### cmdsub_stderr_reaches_outer_stderr +out=$( { x=$(nocmdq); } 2>&1 ) +echo "[$out]" +x=$(nocmdq 2>/dev/null; echo kept) +echo "x=[$x]" +### expect +[bash: line 1: nocmdq: command not found] +x=[kept] +### end + +### cmdsub_stderr_not_hidden_by_command_redirect +# Words are expanded before the command's own redirects apply +out=$( { echo "a$(nocmdq)b" 2>/dev/null; } 2>&1 >/dev/null ) +echo "[$out]" +### expect +[bash: line 1: nocmdq: command not found] +### end + +### cmdsub_stderr_not_hidden_by_function_call_redirect +f() { :; } +out=$( { f "$(nocmdq)" 2>/dev/null; } 2>&1 ) +echo "[$out]" +### expect +[bash: line 2: nocmdq: command not found] +### end + +### cmdsub_own_redirect_captures_stderr +y=$(nocmdq 2>&1) +echo "[$y]" +### expect +[bash: line 1: nocmdq: command not found] +### end + +### cmdsub_stderr_hidden_by_group_redirect +{ w=$(nocmdq); } 2>/dev/null +echo done +### expect +done +### end + +### cmdsub_file_read_reports_missing_file +out=$( { q=$(< /nofileq); } 2>&1 ) +echo "[$out]" +### expect +[bash: line 1: /nofileq: No such file or directory] +### end + +### eval_keeps_caller_lineno +echo one +eval 'echo $LINENO' +eval 'eval "echo \$LINENO"' +### expect +one +2 +3 +### end + +### eval_diagnostic_names_caller_line +echo one +out=$(eval 'nocmdq' 2>&1) +echo "[$out]" +### expect +one +[bash: line 2: nocmdq: command not found] +### end + +### proc_refuses_new_files +{ echo y > /proc/naopode 2>/dev/null; } 2>/dev/null +echo "rc=$?" +[ -e /proc/naopode ] || echo absent +mkdir /proc/newdir 2>/dev/null || echo "mkdir failed" +touch /proc/newfile 2>/dev/null || echo "touch failed" +### expect +rc=1 +absent +mkdir failed +touch failed +### end diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index fa10491e0..4d1b12b5c 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -187,7 +187,9 @@ mounts and custom filesystems always win. Rules: system-only files cannot be written or removed (`PermissionDenied`); creating a file in a system dir (`/etc/app.conf`) creates the parent in the -session fs first; `mkdir /bin` shadows the system dir. Listings of `/`, +session fs first, except under `/proc`, which refuses new entries like Linux +procfs (`echo x > /proc/new`, `mkdir`, `touch`, `ln -s` fail with `No such +file or directory`) unless the session fs itself holds the parent directory; `mkdir /bin` shadows the system dir. Listings of `/`, `/usr`, `/dev` merge both layers. Usage accounting, snapshots and `fs()` writes go to the session fs only, so the layer costs no quota and is never persisted. Restricted shells turn it off with `rootfs(false)` (the diff --git a/knowledge/log.md b/knowledge/log.md index b38c93c06..acc72fdb0 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Fix**: stderr goes where bash sends it. A command's own failure honors its redirects: `nocmd 2>/dev/null` and `./missing 2>/dev/null` are silent, `return 2>&1` at top level reports on stdout, and so do `source`/`.`/`getopts`/`builtin` usage errors. Stderr written inside `$(...)` is no longer dropped (`x=$(nocmd)` reports `nocmd: command not found`, `$(< missing)` reports the file): it is queued on the interpreter and emitted ahead of the expanding command's own stderr, outside that command's redirects (words are expanded first) and inside a compound command's. `eval` text counts `$LINENO` and diagnostics from the eval's own line (it restarted at 1). `exit` uses `$?` with no argument (it exited 0), reports `numeric argument required` and exits 2 for a non-number (it exited 0), and `exit 1 2` reports `too many arguments` and drops the rest of the line. `/proc` refuses new entries like procfs: `echo y > /proc/naopode` fails with `No such file or directory` (it created the file); `mkdir`/`touch` print the strerror text instead of `io error: ...`. See [VFS](foundations/vfs.md) and limitations "Stderr routing". bash-oracle match 98 to 100/106, strict 89 to 93/106. * **Fix**: `${v@a}` prints every attribute letter `declare -p` shows, in bash's order (`a`/`A`, `i`, `n`, `r`, `x`, `l`, `u`), follows namerefs, takes the array from a subscript, and repeats per element for `${arr[@]@a}`. It only knew `r` and `x`, so `declare -i n; ${n@a}` was empty. * **Fix**: `${#v}` counts bytes when the shell's locale is C/POSIX (`LC_ALL`, then `LC_CTYPE`, then `LANG`, first non-empty), like bash; `LC_ALL=C; s=ação; ${#s}` is 6. With no locale variable set bashkit stays UTF-8, where bash would fall back to C. `${#s}` inside a `${x:-...}` operand counted bytes in every locale and now counts characters. Other operators (`${s:0:2}`, case changes, globs) stay character-based under C (limitations, "Locale"). * **Fix**: shell diagnostics carry bash's non-interactive `$0: line N: ` prefix (`SCRIPT: line 2: x: command not found`, `bash: line 1: type: h: not found` under `-c`) instead of a bare `bash: `. `$0` is the file being read (`BASH_SOURCE[0]`, so a sourced file names itself), else `$0`; eval syntax errors read `$0: eval: line N:`; redirect failures on `while ...; done < f` report the closing line. Interpreter messages use `Interpreter::diag_prefix`; bundled builtins keep `bash: name:` / `name:` text and the builtin dispatch point rewrites the line start (usage lines, coreutils-style builtins and execution plans untouched). `source` of a missing file now ends with a newline and reads `$0: line N: ./lib.sh: No such file or directory`; `source` with no operand exits 2 with bash's usage line; `set -u` names a bare positional `$2`. Interactive shells (`Bash::set_interactive`, set by the CLI REPL and `Terminal`) keep `bash: msg` with no line, like `bash -i`. The sandbox "Did you mean" hint after `command not found` is kept. bash-oracle strict 71 to 84/108. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 2e24abafd..e61a161ae 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -111,7 +111,8 @@ Target: IEEE 1003.1-2024 Shell Command Language. | 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) | -| Diagnostic prefix | `$0: line N: ` on interpreter and shell-builtin messages, `$0` = sourced file inside `source` | `(( ))`, `[[ ]]`, `{ }` and `( )` carry no span, so an error raised by the construct itself (not a command inside it) reports a stale line (line 1 at the top level); a function defined in a sourced file and called from the script names the caller's file (bash names the defining file); `${3}` under `set -u` reports `$3` (bash: `3`, only bare `$3` gets the `$`); `command not found` keeps the sandbox "Did you mean" / unavailable-command hint bash does not print; parse-error wording differs from bash | +| 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 | `(( ))`, `[[ ]]`, `{ }` and `( )` carry no span, so an error raised by the construct itself (not a command inside it) reports a stale line (line 1 at the top level); a function defined in a sourced file and called from the script names the caller's file (bash names the defining file); `${3}` under `set -u` reports `$3` (bash: `3`, only bare `$3` gets the `$`); `command not found` keeps the sandbox "Did you mean" / unavailable-command hint bash does not print; parse-error wording differs from bash; 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) | | Brace expansion | Runs before other expansions on unquoted literal text; lists, nesting, ranges with step and zero padding, quoted/escaped braces and commas | Works on parsed words, not raw text: `$v{1,2}` expands `$v` then appends `1`/`2` (bash reads `$v1`, `$v2`); write `${v}` | ## Builtins diff --git a/scripts/bash-oracle/floor.txt b/scripts/bash-oracle/floor.txt index 4f319472e..87bb23b0b 100644 --- a/scripts/bash-oracle/floor.txt +++ b/scripts/bash-oracle/floor.txt @@ -1 +1 @@ -98 89 +101 94