From 37bef7f393fc842b0fc737d9d0b2a79fe30219eb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 08:57:41 +0000 Subject: [PATCH] fix(interpreter): model bash fork suppression for $SHLVL bash execs the last simple command of a -c string, $( ) body, ( ) or <( ) body, and any simple command run with &, in place; a shell started there lowers SHLVL first and keeps the level. exec bash does too, except directly inside ( ). Child shells no longer inherit trap handlers. --- crates/bashkit/src/interpreter/mod.rs | 283 ++++++++++++++++-- .../tests/spec_cases/bash/shlvl.test.sh | 194 +++++++++++- knowledge/log.md | 4 + knowledge/operations/limitations.md | 2 +- scripts/bash-oracle/floor.txt | 2 +- 5 files changed, 462 insertions(+), 23 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index e0f7816ab..d12f0340b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1354,6 +1354,41 @@ struct SubshellSnapshot { line_base: usize, } +/// Bits of `NoforkScope::subshell_env`, after bash's `subshell_environment`. +/// Inside `( )`: `exec` does not lower `$SHLVL`. +const SUBSHELL_PAREN: u8 = 1; +/// Inside a pipeline stage: a command run in place does not lower `$SHLVL`. +const SUBSHELL_PIPE: u8 = 2; + +/// Fork suppression ("nofork"), modeled for `$SHLVL` only. +/// +/// Important decision: bash execs some simple commands in place instead of +/// forking: the last one of a `bash -c` string or of a `$( )` body (both +/// parsed by `parse_and_execute`), the last one of a `( )` / `<( )` body, +/// and any simple command run with `&`. A disk command run that way first +/// lowers `$SHLVL` (`adjust_shell_level(-1)`), so a `bash` started there +/// ends at the same level. bashkit never forks; the only observable effect +/// is that level, so this records which command bash would run in place and +/// `execute_shell` applies the decrement. `target` is that command's +/// address: the AST outlives the scope that names it. Scopes form a chain +/// (`outer`) entered and left at each boundary instead of riding in +/// `SubshellSnapshot`: a snapshot field cost ~100 bytes of stack per +/// `$(...)` level in debug builds (TM-DOS-089). `reset_transient_state` +/// drops the chain, so a scope an error skipped leaving cannot outlive its +/// AST into the next exec. +#[derive(Clone, Default)] +struct NoforkScope { + /// Address of the `SimpleCommand` bash would run in place, 0 for none. + target: usize, + /// Traps when this shell context began. A forked child resets the + /// handlers it inherits, so only traps set since then keep the fork. + trap_base: Option>>, + /// `SUBSHELL_PAREN` / `SUBSHELL_PIPE` bits. + subshell_env: u8, + /// The enclosing context's scope, restored by `leave_nofork_scope`. + outer: Option>, +} + /// Interpreter state. pub struct Interpreter { fs: Arc, @@ -1464,6 +1499,12 @@ pub struct Interpreter { /// builtin (`yes`, `seq`): only that command writes straight into /// `pipe_out`, never a command run from its argument expansions. stream_stdout_command: Option, + /// Where this shell context would run a simple command without a fork + /// (see `NoforkScope`). + nofork: Arc, + /// Set just before a simple command dispatches: bash runs it in place + /// (no fork), so a `bash`/`sh` it starts keeps this shell's `$SHLVL`. + nofork_now: bool, /// Pipe handed to the next builtin's `Context` (taken at dispatch). builtin_stdout_pipe: Option>, /// Input pipe handed to a streaming filter's `Context` (taken at dispatch). @@ -2081,6 +2122,8 @@ impl Interpreter { pipe_in: None, pipe_out: None, stream_stdout_command: None, + nofork: Arc::default(), + nofork_now: false, builtin_stdout_pipe: None, builtin_stdin_pipe: None, bash_subshell: 0, @@ -2263,6 +2306,12 @@ impl Interpreter { pipe_in: None, pipe_out: None, stream_stdout_command: None, + // A job runs no command in place until told so (`spawn_in_background`). + nofork: Arc::new(NoforkScope { + subshell_env: self.nofork.subshell_env, + ..NoforkScope::default() + }), + nofork_now: false, builtin_stdout_pipe: None, builtin_stdin_pipe: None, bash_subshell: self.bash_subshell + 1, @@ -2555,6 +2604,8 @@ impl Interpreter { /// preserved — they are persistent session configuration. pub fn reset_transient_state(&mut self) { self.memory_limit_error = None; + self.nofork = Arc::default(); + self.nofork_now = false; self.traps_mut().clear(); self.last_exit_code = 0; // THREAT[TM-DOS-035/057]: A timeout can drop execution while a trap @@ -4025,6 +4076,7 @@ impl Interpreter { let snap = self.snapshot_subshell_state(); let saved_call_stack = self.call_stack.clone(); self.bash_subshell += 1; + self.enter_nofork_scope(commands, true, SUBSHELL_PAREN); self.enter_subshell_err_scope(); let saved_exit = self.last_exit_code; let saved_coproc = self.coproc_buffers.clone(); @@ -4034,6 +4086,8 @@ impl Interpreter { self.output_stream_stderr_bytes, ); let mut result = self.execute_command_sequence(commands).await; + // Left here, before anything can return early. + self.leave_nofork_scope(); // An `exec` redirect set inside the subshell applies to the // subshell's own output. It has to be routed here, while the // subshell's fd table is still current: the outermost loop @@ -5926,7 +5980,18 @@ impl Interpreter { // A new shell starts at subshell level 0. self.bash_subshell = 0; self.xtrace_depth = 0; - let shlvl_warning = self.reset_state_for_child_shell(); + // Read, not taken: a builtin that starts several shells (`xargs`) + // starts each one in place; restored below. + let in_place = self.nofork_now; + let shlvl_warning = self.reset_state_for_child_shell(in_place); + // Only a `-c` string runs its last command in place (bash's + // `parse_and_execute`), and only when nothing follows it. + let target = if is_command_mode && script.trailing_error.is_none() { + Self::nofork_candidate(&script.commands, false) + } else { + 0 + }; + self.push_nofork_scope(target, 0); // 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. @@ -6014,8 +6079,10 @@ impl Interpreter { // Restore parent state — full revert of the snapshot since the child // is process-isolated. This also undoes OPTIND/SHOPT_* writes above. self.restore_subshell_state(child_snapshot); + self.leave_nofork_scope(); self.counters.pop_function(); self.child_shell_depth -= 1; + self.nofork_now = in_place; match result { Ok(mut exec_result) => { @@ -6034,28 +6101,170 @@ impl Interpreter { /// negative one lands on 0 rather than counting up from below, and a level /// that would pass 999 restarts at 1 with a warning (so a script that runs /// itself cannot drive the number up forever). - fn child_shell_level(parent: Option<&str>) -> (String, Option) { + /// With `in_place` the parent runs the child without a fork (see + /// `NoforkScope`) and lowers its own level first, as bash does. + fn child_shell_level(parent: Option<&str>, in_place: bool) -> (String, Option) { let parent: i64 = parent .map(str::trim) .and_then(|text| text.parse().ok()) .unwrap_or(0); - let next = if parent < 0 { 0 } else { parent + 1 }; - if next > 999 { - return ( - "1".to_string(), - Some(format!( - "bash: warning: shell level ({next}) too high, resetting to 1\n" - )), - ); + let mut warning = None; + let parent = if in_place { + Self::adjust_shell_level(parent, -1, &mut warning) + } else { + parent + }; + let level = Self::adjust_shell_level(parent, 1, &mut warning); + (level.to_string(), warning) + } + + /// bash's `adjust_shell_level`: below 0 lands on 0, past 999 restarts at + /// 1 with a warning. + fn adjust_shell_level(level: i64, change: i64, warning: &mut Option) -> i64 { + let next = level.saturating_add(change); + if next < 0 { + 0 + } else if next > 999 { + warning.get_or_insert_with(String::new).push_str(&format!( + "bash: warning: shell level ({next}) too high, resetting to 1\n" + )); + 1 + } else { + next + } + } + + /// The simple command bash would run in place at the end of `commands` + /// (see `NoforkScope`), as an address; 0 for none. In `( )` and `<( )` a + /// sole command qualifies even with redirections (`sole_may_redirect`); + /// otherwise, and for the last of a list, it must have none. + fn nofork_candidate(commands: &[Command], sole_may_redirect: bool) -> usize { + let simple = match commands { + [Command::Simple(simple)] if sole_may_redirect => Some(simple), + [.., last] => { + Self::last_list_command(last).filter(|simple| simple.redirects.is_empty()) + } + [] => None, + }; + simple.map_or(0, |simple| simple as *const SimpleCommand as usize) + } + + /// `command` itself when simple, else the simple command that ends it as + /// the right side of a trailing `&&`, `||` or `;`. None after `&` or when + /// run with `&`. + fn last_list_command(command: &Command) -> Option<&SimpleCommand> { + match command { + Command::Simple(simple) => Some(simple), + Command::List(list) => { + let mut rest = list.rest.iter().rev().peekable(); + // `a;` and `a &` end in an empty sentinel after the terminator. + if let Some((op, cmd)) = rest.peek() + && Self::is_empty_sentinel(cmd) + { + if *op == ListOperator::Background { + return None; + } + rest.next(); + } + match rest.next() { + Some((ListOperator::Background, _)) => None, + Some((_, cmd)) => Self::last_list_command(cmd), + None => Self::last_list_command(&list.first), + } + } + _ => None, } - (next.to_string(), None) + } + + /// Start a shell context (`( )`, `$( )`, `<( )`) whose last simple + /// command bash runs in place. A nonzero `subshell_env` replaces the + /// current `SUBSHELL_*` bits. Pair with `leave_nofork_scope`. Out of + /// line: it runs on every `$(...)` level. + #[inline(never)] + fn enter_nofork_scope( + &mut self, + commands: &[Command], + sole_may_redirect: bool, + subshell_env: u8, + ) { + let target = Self::nofork_candidate(commands, sole_may_redirect); + let subshell_env = if subshell_env == 0 { + self.nofork.subshell_env + } else { + subshell_env + }; + self.push_nofork_scope(target, subshell_env); + } + + /// Enter a scope with this target and `SUBSHELL_*` bits; traps set so + /// far count as inherited. + fn push_nofork_scope(&mut self, target: usize, subshell_env: u8) { + let outer = std::mem::take(&mut self.nofork); + self.nofork = Arc::new(NoforkScope { + target, + trap_base: Some(Arc::clone(&self.scoped.traps)), + subshell_env, + outer: Some(outer), + }); + } + + /// Back to the scope that was current before the matching enter. + #[inline(never)] + fn leave_nofork_scope(&mut self) { + if let Some(outer) = self.nofork.outer.clone() { + self.nofork = outer; + } + } + + /// A job (`cmd &`) runs a simple command in place in its forked child. + fn enter_background_nofork(&mut self, command: &Command) { + self.nofork = Arc::new(NoforkScope { + target: match command { + Command::Simple(simple) => simple as *const SimpleCommand as usize, + _ => 0, + }, + trap_base: Some(Arc::clone(&self.scoped.traps)), + subshell_env: 0, + outer: None, + }); + } + + /// Whether bash runs `command`, about to dispatch, without a fork and + /// lowers `$SHLVL` for it: it is this context's in-place command, not in + /// a pipeline stage, and no trap set here keeps the fork. + #[inline(never)] + fn nofork_eligible(&self, command: &SimpleCommand) -> bool { + self.nofork.target == command as *const SimpleCommand as usize + && self.nofork.subshell_env & SUBSHELL_PIPE == 0 + && !self.traps_keep_fork() + } + + /// A trap set in this shell context that makes bash fork anyway: any + /// EXIT or ERR trap, or a signal trap with a command. Ignored signals, + /// DEBUG and RETURN do not count, nor handlers inherited from a parent + /// (a forked child resets them). + fn traps_keep_fork(&self) -> bool { + let base = self.nofork.trap_base.as_ref(); + if base.is_some_and(|base| Arc::ptr_eq(base, &self.scoped.traps)) { + return false; + } + self.scoped.traps.iter().any(|(name, action)| { + if base.and_then(|base| base.get(name)) == Some(action) { + return false; + } + match name.as_str() { + "EXIT" | "ERR" => true, + "DEBUG" | "RETURN" => false, + _ => !action.is_empty(), + } + }) } /// Reset interpreter state to what a freshly-forked `bash`/`sh` child /// would see: drop arrays/assoc_arrays/functions/aliases/namerefs, and /// keep only exported scalars in `variables`. The caller is expected to /// have just taken a snapshot to undo this on return. See issue #1777. - fn reset_state_for_child_shell(&mut self) -> Option { + fn reset_state_for_child_shell(&mut self, in_place: bool) -> Option { self.line_base = 0; let exported_names: Vec = self .scoped @@ -6108,12 +6317,17 @@ impl Interpreter { // the parent's own level. bash exports SHLVL at startup; bashkit's // synthetic startup variables are not marked exported, so the parent's // plain variable stands in for that export. - let inherited = next_vars - .get("SHLVL") - .or_else(|| self.env.get("SHLVL")) - .or_else(|| self.scoped.variables.get("SHLVL")) - .map(String::as_str); - let (level, warning) = Self::child_shell_level(inherited); + // Run in place, bash lowers the shell's own variable, exported or not. + let inherited = if in_place { + self.scoped.variables.get("SHLVL") + } else { + next_vars + .get("SHLVL") + .or_else(|| self.env.get("SHLVL")) + .or_else(|| self.scoped.variables.get("SHLVL")) + } + .map(String::as_str); + let (level, warning) = Self::child_shell_level(inherited, in_place); next_vars.insert("SHLVL".to_string(), level.clone()); *self.vars_mut() = next_vars; self.add_var_attr("SHLVL", VarAttrs::EXPORT); @@ -6125,6 +6339,18 @@ impl Interpreter { // Aliases are parse-time anyway, but a fresh `bash -c` would not have // user-defined aliases — drop them for consistency. self.scoped.aliases = Arc::new(HashMap::new()); + // A new shell has no trap handlers; signals ignored in the parent + // stay ignored (`trap '' INT` is inherited, `trap 'x' EXIT` is not). + let ignored: HashMap = self + .scoped + .traps + .iter() + .filter(|(name, action)| { + action.is_empty() && !matches!(name.as_str(), "EXIT" | "ERR" | "DEBUG" | "RETURN") + }) + .map(|(name, action)| (name.clone(), action.clone())) + .collect(); + self.scoped.traps = Arc::new(ignored); // Reset SHOPT_* flag bitfield so options from the parent don't leak. self.flags = BashFlags::empty(); warning @@ -6573,6 +6799,7 @@ impl Interpreter { child.pipe_out = Some(Arc::clone(&pipe)); // The pipeline as a whole fires ERR, not its stages. child.err_trap_skip_stage = true; + Arc::make_mut(&mut child.nofork).subshell_env = SUBSHELL_PIPE; let write_end = pipe::WriteEnd(Arc::clone(&pipe)); let read_end = input.take().map(pipe::ReadEnd); let command = command.clone(); @@ -6764,6 +6991,7 @@ impl Interpreter { } if subshell { self.enter_subshell_err_scope(); + self.push_nofork_scope(self.nofork.target, SUBSHELL_PIPE); } let mut scope = PipelineStageScope { saved, @@ -6790,6 +7018,7 @@ impl Interpreter { return result; }; self.restore_subshell_state(snap); + self.leave_nofork_scope(); self.call_stack = call_stack; self.coproc_buffers = coproc; let mut result = self.abort_line_on_error(result)?; @@ -6856,6 +7085,7 @@ impl Interpreter { let job_diag = self.diag_prefix(); let mut fut: jobs::JobFuture = Box::pin(async move { let _slot = slot; + child.enter_background_nofork(&cmd); let jobs = Arc::clone(&child.jobs); let result = jobs::with_jobs(&jobs, child.execute_command(&cmd)).await; jobs.finish_all().await; @@ -6882,7 +7112,11 @@ impl Interpreter { } } } else { - Some(self.execute_command(cmd).await?) + let saved_nofork = std::mem::take(&mut self.nofork); + self.enter_background_nofork(cmd); + let result = self.execute_command(cmd).await; + self.nofork = saved_nofork; + Some(result?) }; if let Some(result) = finished { @@ -7328,6 +7562,7 @@ impl Interpreter { let last_exit_code = self.last_exit_code; self.bash_subshell += 1; self.xtrace_depth += 1; + self.enter_nofork_scope(&commands, true, 0); let mut run = Ok(()); for cmd in &commands { let prev_stdin = self.pipeline_stdin.take(); @@ -7353,6 +7588,7 @@ impl Interpreter { } } self.restore_subshell_state(*snapshot); + self.leave_nofork_scope(); self.last_exit_code = last_exit_code; run?; } @@ -7671,6 +7907,7 @@ impl Interpreter { self.trace_simple_command(&name, &args).await; } + self.nofork_now = self.nofork_eligible(command); let result = self .execute_dispatched_command(&name, args, command, stdin) .await; @@ -7990,6 +8227,8 @@ impl Interpreter { assignments: Vec::new(), span: Span::new(), }; + // bash's `exec` lowers `$SHLVL` first, except directly in `( )`. + self.nofork_now = self.nofork.subshell_env & SUBSHELL_PAREN == 0; let result = self .execute_dispatched_command(&target_name, target_args, &target_command, None) .await?; @@ -10884,6 +11123,7 @@ impl Interpreter { let last_exit_code = self.last_exit_code; self.bash_subshell += 1; self.xtrace_depth += 1; + self.enter_nofork_scope(commands, true, 0); let mut failed = None; for cmd in commands { match self.execute_command(cmd).await { @@ -10903,6 +11143,7 @@ impl Interpreter { } } self.restore_subshell_state(*snapshot); + self.leave_nofork_scope(); self.last_exit_code = last_exit_code; if let Some(e) = failed { return Err(e); @@ -11245,6 +11486,9 @@ impl Interpreter { // commands (`case`, `{ }`, `if`) emit through it as they run. let saved_callback = self.output_callback.take(); let mut run: Result<()> = Ok(()); + // Entered and left around the loop alone, which never returns + // early, so an error cannot leave the scope current. + self.enter_nofork_scope(commands, false, 0); for cmd in commands { let cmd_result = match self.execute_command(cmd).await { Ok(r) => r, @@ -11276,6 +11520,7 @@ impl Interpreter { break; } } + self.leave_nofork_scope(); self.output_callback = saved_callback; if run.is_err() { self.release_held_subst_stderr(held_stderr); diff --git a/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh b/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh index 29cae11ba..1527f7fa1 100644 --- a/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh @@ -1,6 +1,7 @@ ### shlvl_starts_at_one -# Cases end in `true` so real bash under the comparison harness's `bash -c` -# does not exec the last command in place (L-PROC-003). +# Cases end in `true`: the comparison harness runs real bash as `bash -c`, +# which execs its last simple command in place, while a bashkit script is a +# top-level script file, where nothing runs in place. echo "$SHLVL" ### expect 1 @@ -60,3 +61,192 @@ true ### expect SHLVL=2 ### end + +### shlvl_nofork_last_command_of_c_string +# bash runs the last simple command of a `bash -c` string in place (no +# fork) and lowers SHLVL first, so a child shell started there keeps the +# parent's level: the right side of a trailing `&&`/`||`/`;` too. +unset SHLVL +bash -c 'bash -c "echo a=\$SHLVL"' +bash -c 'true && bash -c "echo b=\$SHLVL"' +bash -c 'false || bash -c "echo c=\$SHLVL"' +bash -c 'echo x >/dev/null; bash -c "echo d=\$SHLVL"' +bash -c 'true +bash -c "echo e=\$SHLVL"' +bash -c 'bash -c "echo f=\$SHLVL";' +bash -c 'command bash -c "echo g=\$SHLVL"' +bash -c 'X=1 bash -c "echo h=\$SHLVL"' +bash -c 'bash -c "bash -c \"echo i=\\\$SHLVL\""' +bash -c '{ true; } && bash -c "echo j=\$SHLVL"' +bash -c 'set -e; bash -c "echo k=\$SHLVL"' +true +### expect +a=1 +b=1 +c=1 +d=1 +e=1 +f=1 +g=1 +h=1 +i=1 +j=1 +k=1 +### end + +### shlvl_fork_kept_when_not_last_simple_command +# Only that last simple command is run in place: not one followed by more +# commands, a `{ }` group, a pipeline, a compound, a function, `!`, `eval`, +# one with redirections, or one after `&`. +unset SHLVL +bash -c 'bash -c "echo a=\$SHLVL"; true' +bash -c '{ bash -c "echo b=\$SHLVL"; }' +bash -c 'bash -c "echo c=\$SHLVL" | cat' +bash -c 'if true; then bash -c "echo d=\$SHLVL"; fi' +bash -c 'f() { bash -c "echo e=\$SHLVL"; }; f' +bash -c '! bash -c "echo f=\$SHLVL"' +bash -c 'bash -c "echo g=\$SHLVL" 2>/dev/null' +bash -c 'eval "bash -c \"echo h=\\\$SHLVL\""' +bash -c 'true & bash -c "echo i=\$SHLVL"; wait' +bash -c 'bash -c "echo j=\$SHLVL" +true' +bash -c 'bash -c "echo k=\$SHLVL" && true' +true +### expect +a=2 +b=2 +c=2 +d=2 +e=2 +f=2 +g=2 +h=2 +i=2 +j=2 +k=2 +### end + +### shlvl_fork_kept_when_traps_set +# A trap on EXIT or ERR, or a signal trap with a command, keeps the fork; +# an ignored signal, DEBUG, or a cleared trap does not. +unset SHLVL +bash -c 'trap "echo bye" EXIT; bash -c "echo a=\$SHLVL"' +bash -c 'trap "" EXIT; bash -c "echo b=\$SHLVL"' +bash -c 'trap "echo err" ERR; bash -c "echo c=\$SHLVL"' +bash -c 'trap "echo usr" USR1; bash -c "echo d=\$SHLVL"' +bash -c 'trap "" INT; bash -c "echo e=\$SHLVL"' +bash -c 'trap ":" DEBUG; bash -c "echo f=\$SHLVL"' +bash -c 'trap "echo t" EXIT; trap - EXIT; bash -c "echo g=\$SHLVL"' +true +### expect +a=2 +bye +b=2 +c=2 +d=2 +e=1 +f=1 +g=1 +### end + +### shlvl_nofork_in_subshells +# `( )`, `$( )`, backquotes and `<( )` run their last simple command in +# place as well. In `( )` and `<( )` a sole command may carry redirections; +# a substitution's may not, nor may the last of a list. A substitution +# expanded inside a pipeline keeps the level it was given. +export SHLVL=3 +(bash -c 'echo a=$SHLVL') +(bash -c 'echo b=$SHLVL' 2>/dev/null) +(true; bash -c 'echo c=$SHLVL') +(true && bash -c 'echo d=$SHLVL') +(true; bash -c 'echo e=$SHLVL' 2>/dev/null) +(bash -c 'echo f=$SHLVL'; true) +echo "$(bash -c 'echo g=$SHLVL')" +echo "$(true; bash -c 'echo h=$SHLVL')" +echo "$(bash -c 'echo i=$SHLVL' 2>/dev/null)" +echo "`bash -c 'echo j=$SHLVL'`" +cat <(bash -c 'echo k=$SHLVL' 2>/dev/null) +(trap 'echo t' USR1; bash -c 'echo l=$SHLVL') +(bash -c 'echo m=$SHLVL') | cat +echo "$(bash -c 'echo n=$SHLVL')" | cat +trap 'echo bye' EXIT +(bash -c 'echo o=$SHLVL') +trap - EXIT +true +### expect +a=3 +b=3 +c=3 +d=3 +e=4 +f=4 +g=3 +h=3 +i=4 +j=3 +k=3 +l=4 +m=3 +n=4 +o=3 +### end + +### shlvl_exec_keeps_level +# `exec bash` lowers SHLVL first wherever it runs, except directly inside +# `( )` (a pipeline stage inside it lowers again). +export SHLVL=3 +bash -c 'exec bash -c "echo a=\$SHLVL"' +bash -c 'echo x >/dev/null; exec bash -c "echo b=\$SHLVL"; true' +bash -c '{ exec bash -c "echo c=\$SHLVL"; }' +bash -c '(exec bash -c "echo d=\$SHLVL")' +(exec bash -c 'echo e=$SHLVL') +(exec bash -c 'echo f=$SHLVL' | cat) +echo "$(exec bash -c 'echo g=$SHLVL')" +(echo "$(exec bash -c 'echo h=$SHLVL')") +bash -c 'f() { exec bash -c "echo i=\$SHLVL"; }; f' +true +### expect +a=4 +b=4 +c=4 +d=5 +e=4 +f=3 +g=3 +h=4 +i=4 +### end + +### shlvl_background_simple_command_keeps_level +# A simple command run with `&` is run in place in its forked child. +export SHLVL=3 +bash -c 'echo a=$SHLVL' & wait +bash -c 'echo b=$SHLVL' 2>/dev/null & wait +{ bash -c 'echo c=$SHLVL'; } & wait +(bash -c 'echo d=$SHLVL') & wait +bash -c 'bash -c "echo e=\$SHLVL" & wait' +true +### expect +a=3 +b=3 +c=4 +d=3 +e=4 +### end + +### shlvl_nofork_level_bounds +# The in-place command lowers the shell's own SHLVL, exported or not, and +# clamps at 0 before the child counts up. +bash -c 'SHLVL=-5; bash -c "echo a=\$SHLVL"' +bash -c 'SHLVL=abc; bash -c "echo b=\$SHLVL"' +bash -c 'export -n SHLVL; SHLVL=7; bash -c "echo c=\$SHLVL"' +bash -c 'unset SHLVL; bash -c "echo d=\$SHLVL"' +bash -c 'SHLVL=0; bash -c "echo e=\$SHLVL"' +true +### expect +a=1 +b=1 +c=7 +d=1 +e=1 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 782756ae3..592515b0d 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-08 + +* **Fix**: `$SHLVL` follows bash's fork suppression. bash execs some simple commands in place: the last one of a `bash -c` string or `$( )` body (also the right side of a trailing `&&`/`||`/`;`; no redirections, no EXIT/ERR or signal trap set), the last one of a `( )`/`<( )` body (a sole one even with redirections), and any simple command run with `&`; a shell started there lowers `$SHLVL` first and so keeps the level. `exec bash` lowers it too, except directly inside `( )`; a substitution inside a pipeline stage does not. `bash -c 'bash -c "echo \$SHLVL"'` printed 3 in a script started at 1, now 2. A `bash`/`sh` child no longer inherits trap handlers (an ignored signal stays ignored), so `bash -c 'trap "echo bye" EXIT; bash -c true'` prints `bye` once. L-PROC-003 narrowed. bash-oracle match 101 to 102, strict 99 to 100. + ## 2026-10-07 * **Fix**: stderr text matches bash for `set -x`, syntax errors and `TIMEFORMAT`. `set -x` quotes traced words like bash (`''`, `'a b'`, `\'`, `$'\001'`), traces assignments (`+ a=1`, `+ v=`, `+ arr=(1 $(x))` as written), function calls (the line used to vanish when streaming), `eval`/`source` and each `[[ ]]` primary (`+ [[ ! -z x ]]`); PS4 is expanded (`$LINENO`, `$(...)`, with tracing off) and its first character repeats per `$(...)`/`eval`/`source`/trap level (`++ echo sub`). Trace lines queue with the pending `$(...)` stderr, so they come after the substitutions that built them and before the command's redirects (`echo x 2>/dev/null` still traces). Grammar errors are worded by the token the parser stopped at: `syntax error near unexpected token `then'` plus the offending line, or `syntax error: unexpected end of file` on the line after the last; unterminated quotes read `unexpected EOF while looking for matching `"'`, and `[[ x =~ ( ]]` gives bash's two diagnostics. Reports are named `bash: -c`, the script path, `$0: eval` or the sourced file (`Error::syntax_report`); `if then`/`while do` with an empty condition are now errors at `then`/`do`. `bash`/`sh` children apply their redirects to their own errors (`bash -c 'if then fi' 2>/dev/null` printed the error). `time` honors `TIMEFORMAT` (`%[p][l]R`, `%%`; CPU fields `unavailable`). `[[ ! a || b ]]` negates only `a` (it negated the whole list), and `$LINENO` inside `[[ ]]`/`(( ))` keeps the enclosing list's line instead of 1. See limitations "`set -x`" and "Diagnostic prefix". bash-oracle strict 94 to 99/106. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 1d0bc1195..1790a7217 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -44,7 +44,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` |----|------------|-----|----------| | L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell applies to the subshell's own output, routed at the subshell boundary; output the subshell wrote before the `exec` still reaches the caller. Inside a `bash -c` child it still does not take effect. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` | | L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` | -| L-PROC-003 | No process spawning; external commands run as builtins. So bash's fork suppression has no equivalent: a `bash -c` that is the last command of its shell raises `$SHLVL` like any other child, where bash execs it in place and keeps the level (`bash -c 'bash -c "echo \$SHLVL"'` in a top-level script prints 3, bash prints 2) | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | +| L-PROC-003 | No process spawning; external commands run as builtins. bash's fork suppression is modeled only for `$SHLVL` (see `NoforkScope` in the interpreter): a `bash`/`sh` child bash would exec in place keeps the level, but another command run there still sees the unlowered value (bash's `bash -c 'printenv SHLVL'` prints one less than the child's level), nor do `env`/`timeout`/`xargs` starting a shell, and a `SHLVL=n` prefix on the in-place command counts | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | | L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` | | L-PIPE-001 | Pipeline stages stream only from the first stage that runs shell code (loop, group, function, `eval`). Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE (`sort big \| head -1` gives `PIPESTATUS` `0 0`, bash `141 0`); `yes`, `seq`, plain `cat`, `grep` and `tr` are the exception and stream. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head`, `read`, plain `cat`, `grep` (not `-c/-l/-L/-q`, context, `-b`) and `tr` (not `-s`): `while :; do echo; done \| sed p \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` | | L-SUDO-001 | `sudo` runs its command as the one sandbox user: `-u`, `-g`, `-i` change nothing, `whoami` still prints `sandbox`, and there is never a password prompt. `busybox APPLET` runs bashkit's builtin of that name, not BusyBox's own applet (flags and messages are bashkit's) | The sandbox has no privilege boundary to cross; the VFS has no permission enforcement (L-FS-002) | `sudo-busybox.test.sh` | diff --git a/scripts/bash-oracle/floor.txt b/scripts/bash-oracle/floor.txt index 3e222a75b..2102eb532 100644 --- a/scripts/bash-oracle/floor.txt +++ b/scripts/bash-oracle/floor.txt @@ -1 +1 @@ -101 99 +102 100