diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index 35bdce2d9..5deb7fa9d 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -213,7 +213,6 @@ impl Builtin for Read { let mut fields: Vec> = Vec::new(); let mut field_start = 0usize; let mut i = 0usize; - let mut last_delim_was_non_ws = false; while i < line.len() { let mut iter = line[i..].char_indices(); @@ -221,7 +220,6 @@ impl Builtin for Read { let ch_len = ch.len_utf8(); if !ifs.contains(ch) { i += ch_len; - last_delim_was_non_ws = false; continue; } @@ -241,7 +239,6 @@ impl Builtin for Read { } } field_start = i; - last_delim_was_non_ws = true; } else { let pushed_field = field_start != i; if pushed_field { @@ -281,11 +278,12 @@ impl Builtin for Read { } field_start = i; - last_delim_was_non_ws = false; } } - if field_start < line.len() || last_delim_was_non_ws { + // A single trailing delimiter ends the last field; it does + // not start an empty one (`a,b,` is two fields). + if field_start < line.len() { fields.push(ReadField { text: &line[field_start..], start: field_start, @@ -331,7 +329,11 @@ impl Builtin for Read { if is_internal_variable(var_name) { continue; } - let value = if i == var_names.len() - 1 { + let value = if i == var_names.len() - 1 && words.len() == i + 1 { + // Only one field left: it is the value, without the + // delimiter that ended it (`IFS=, read x y <<< a,b,` gives `b`). + unprotect(words[i].text) + } else if i == var_names.len() - 1 { // Bash gives the final variable the unsplit remaining input, // then strips trailing IFS whitespace. Preserve original // separators without keeping whitespace Bash trims. diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index cfb7ac4b4..2f3a8e4f9 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -404,6 +404,19 @@ impl Interpreter { Self::append_expansion_for_word(&mut result, word, &index_strs.join(" ")); } } + WordPart::Substring { + name, + offset, + length, + } if name == "@" || name == "*" => { + let items = self.positional_slice(offset, length.as_deref()); + let sep = if name == "*" { + self.get_ifs_separator() + } else { + " ".to_string() + }; + Self::append_expansion_for_word(&mut result, word, &items.join(&sep)); + } WordPart::Substring { name, offset, @@ -1389,11 +1402,48 @@ impl Interpreter { } (name, out) } + WordPart::Substring { + name, + offset, + length, + } if name == "@" || name == "*" => { + let items = self.positional_slice(offset, length.as_deref()); + (name, items) + } _ => return None, }; Some((results, name == "*" || name.ends_with("[*]"))) } + /// `${@:offset:length}`: positional parameters counted from `$0` + /// (offset 0 includes it); a negative offset counts back from the last. + fn positional_slice(&mut self, offset: &str, length: Option<&str>) -> Vec { + let positional = self + .call_stack + .last() + .map(|f| f.positional.clone()) + .unwrap_or_default(); + let mut all = Vec::with_capacity(positional.len() + 1); + all.push(self.expand_variable("0")); + all.extend(positional); + let off = self.evaluate_arithmetic(offset); + let start = if off < 0 { + let back = usize::try_from(off.unsigned_abs()).unwrap_or(usize::MAX); + match all.len().checked_sub(back) { + Some(s) => s, + None => return Vec::new(), + } + } else { + usize::try_from(off).unwrap_or(usize::MAX).min(all.len()) + }; + let mut items: Vec = all.into_iter().skip(start).collect(); + if let Some(len) = length { + let n = self.evaluate_arithmetic(len); + items.truncate(usize::try_from(n.max(0)).unwrap_or(usize::MAX)); + } + items + } + fn is_elementwise_op(operator: &ParameterOp) -> bool { matches!( operator, diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 93e2f8d6f..effb321f5 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -992,6 +992,9 @@ struct CallFrame { local_assoc_arrays: HashMap>>, /// Positional parameters ($1, $2, etc.) positional: Vec, + /// A function or `source` frame: `$0` comes from the nearest frame below + /// it that is not (the script or shell), as in bash. + keeps_arg0: bool, } /// A snapshot of shell state (variables, env, cwd, options). @@ -1333,6 +1336,7 @@ struct SubshellSnapshot { getopts_char_idx: usize, last_bg_pid: Option, seconds_base: (crate::time_compat::Instant, i64), + bash_subshell: u32, } /// Interpreter state. @@ -1425,6 +1429,8 @@ pub struct Interpreter { builtin_stdout_pipe: Option>, /// Input pipe handed to a streaming filter's `Context` (taken at dispatch). builtin_stdin_pipe: Option>, + /// `$BASH_SUBSHELL`: subshell nesting (`( )`, `$( )`, pipeline stages, jobs). + bash_subshell: u32, /// Position within the current argument while `getopts` walks a clustered /// short-option group (e.g. `-abc`). Interpreter-internal working state for /// `execute_getopts`; `0` means "at the start of the next option group". @@ -2002,6 +2008,7 @@ impl Interpreter { stream_stdout_command: None, builtin_stdout_pipe: None, builtin_stdin_pipe: None, + bash_subshell: 0, getopts_char_idx: 0, last_bg_pid: None, output_callback: None, @@ -2160,6 +2167,7 @@ impl Interpreter { stream_stdout_command: None, builtin_stdout_pipe: None, builtin_stdin_pipe: None, + bash_subshell: self.bash_subshell + 1, getopts_char_idx: self.getopts_char_idx, last_bg_pid: self.last_bg_pid.clone(), output_callback: None, @@ -2512,6 +2520,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional, + keeps_arg0: false, }); } @@ -3525,6 +3534,7 @@ impl Interpreter { // bump; only mutations inside the subshell pay a clone. let snap = self.snapshot_subshell_state(); let saved_call_stack = self.call_stack.clone(); + self.bash_subshell += 1; let saved_exit = self.last_exit_code; let saved_coproc = self.coproc_buffers.clone(); @@ -5357,6 +5367,8 @@ impl Interpreter { } self.child_shell_depth += 1; let child_snapshot = self.snapshot_subshell_state(); + // A new shell starts at subshell level 0. + self.bash_subshell = 0; self.reset_state_for_child_shell(); // Push call frame, apply options, execute, restore, pop @@ -5366,6 +5378,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: positional_args, + keeps_arg0: false, }); let mut saved_opt_names: HashSet<&'static str> = HashSet::new(); @@ -5909,6 +5922,14 @@ impl Interpreter { let write_end = pipe::WriteEnd(Arc::clone(&pipe)); let read_end = input.take().map(pipe::ReadEnd); let command = command.clone(); + if matches!( + command, + Command::Simple(_) | Command::Compound(CompoundCommand::Subshell(_), _) + ) { + // Simple-command stages do not count as a subshell level; a + // `( )` stage counts itself once. + child.bash_subshell = self.bash_subshell; + } stages.push(Box::pin(async move { let _read_end = read_end; if let Command::Simple(simple) = &command @@ -6073,6 +6094,17 @@ impl Interpreter { self.coproc_buffers.clone(), ) }); + // bash counts compound stages, not simple commands (`echo + // $BASH_SUBSHELL | cat` prints 0, `{ echo $BASH_SUBSHELL; } | cat` 1). + // A `( )` stage reuses the stage fork, so it is counted once. + if subshell + && !matches!( + command, + Command::Simple(_) | Command::Compound(CompoundCommand::Subshell(_), _) + ) + { + self.bash_subshell += 1; + } let mut scope = PipelineStageScope { saved, prev_pipeline_stdin: None, @@ -8079,6 +8111,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: args.to_vec(), + keeps_arg0: false, }]; // Set up BASH_SOURCE for the subprocess @@ -8221,6 +8254,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: source_args, + keeps_arg0: true, }); } else if let Some(frame) = self.call_stack.last_mut() { frame.positional = source_args; @@ -8624,6 +8658,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: args, + keeps_arg0: true, }); // Set FUNCNAME array from call stack (index 0 = current, 1 = caller, ...) @@ -10077,10 +10112,9 @@ impl Interpreter { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { let mut arr = HashMap::new(); + // Empty fields are elements too (`IFS=, read -ra a <<< 'a,,b'`). for (i, word) in elements.iter().enumerate() { - if !word.is_empty() { - arr.insert(i, word.clone()); - } + arr.insert(i, word.clone()); } self.insert_array_checked(name.clone(), arr); } @@ -10116,6 +10150,7 @@ impl Interpreter { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: new_positional.clone(), + keeps_arg0: false, }); } } @@ -10207,6 +10242,7 @@ impl Interpreter { getopts_char_idx: self.getopts_char_idx, last_bg_pid: self.last_bg_pid.clone(), seconds_base: self.seconds_base, + bash_subshell: self.bash_subshell, } } @@ -10221,6 +10257,7 @@ impl Interpreter { self.getopts_char_idx = snap.getopts_char_idx; self.last_bg_pid = snap.last_bg_pid; self.seconds_base = snap.seconds_base; + self.bash_subshell = snap.bash_subshell; } /// Perform the redirections of a null command (no command word) and @@ -10364,6 +10401,7 @@ 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(); + self.bash_subshell += 1; // 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))?; @@ -11214,16 +11252,17 @@ impl Interpreter { "-" => { // $- - Current option flags as a string // Build from SHOPT_* variables + // In bash's order; `h` (hashall) and `B` (braceexpand) are + // on by default and cannot be turned off here. let mut flags = String::new(); - for opt in ['e', 'x', 'u', 'f', 'n', 'v', 'a', 'b', 'h', 'm'] { - let opt_name = format!("SHOPT_{}", opt); - if self - .scoped - .variables - .get(&opt_name) - .map(|v| v == "1") - .unwrap_or(false) - { + for opt in ['a', 'b', 'e', 'f', 'h', 'm', 'n', 'u', 'v', 'x', 'B', 'C'] { + let on = matches!(opt, 'h' | 'B') + || self + .scoped + .variables + .get(&format!("SHOPT_{opt}")) + .is_some_and(|v| v == "1"); + if on { flags.push(opt); } } @@ -11267,6 +11306,9 @@ impl Interpreter { "BASH_VERSION" => { return COMPAT_BASH_VERSION.to_string(); } + "BASH_SUBSHELL" => { + return self.bash_subshell.to_string(); + } "SECONDS" => { let (start, base) = self.seconds_base; let elapsed = i64::try_from(start.elapsed().as_secs()).unwrap_or(i64::MAX); @@ -11278,8 +11320,9 @@ impl Interpreter { // Check for numeric positional parameter ($1, $2, etc.) if let Ok(n) = name.parse::() { if n == 0 { - // $0 is the script/function name - if let Some(frame) = self.call_stack.last() { + // $0 is the script/shell name; functions and `source` + // do not change it. + if let Some(frame) = self.call_stack.iter().rev().find(|f| !f.keeps_arg0) { return frame.name.clone(); } return Self::DEFAULT_ARG0.to_string(); @@ -11519,6 +11562,7 @@ mod tests { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: vec!["x".to_string(); 6000], + keeps_arg0: false, }); let value = interp.resolve_param_expansion_name("@").1; @@ -11636,6 +11680,7 @@ mod tests { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), + keeps_arg0: false, }); let baseline_call_stack_len = interp.call_stack.len(); let baseline_bash_source_len = interp.bash_source_stack.len(); @@ -11647,6 +11692,7 @@ mod tests { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), + keeps_arg0: false, }); interp.bash_source_stack.push("script.sh".to_string()); @@ -11815,6 +11861,7 @@ mod tests { local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), + keeps_arg0: false, }); interp .memory_budget diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index ff9339351..b3a9ab8e5 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -3336,7 +3336,12 @@ impl<'a> Parser<'a> { if chars.peek() == Some(&'}') { chars.next(); } - push_part!(WordPart::Length(var_name)); + if var_name.is_empty() { + // `${#}` is `$#`, not a length. + push_part!(WordPart::Variable("#".to_string())); + } else { + push_part!(WordPart::Length(var_name)); + } } } else if chars.peek() == Some(&'!') { // Check for ${!arr[@]} or ${!arr[*]} - array indices @@ -3474,10 +3479,11 @@ impl<'a> Parser<'a> { } } - // Handle special parameters: ${@...}, ${*...} + // Handle special parameters: ${@...}, ${*...}, ${-...}, + // ${?...}, ${$...} if var_name.is_empty() && let Some(&c) = chars.peek() - && matches!(c, '@' | '*') + && matches!(c, '@' | '*' | '-' | '?' | '$') { var_name.push(chars.next().unwrap()); } diff --git a/crates/bashkit/tests/integration/blackbox_security_tests.rs b/crates/bashkit/tests/integration/blackbox_security_tests.rs index 7fcab0980..8c9b338da 100644 --- a/crates/bashkit/tests/integration/blackbox_security_tests.rs +++ b/crates/bashkit/tests/integration/blackbox_security_tests.rs @@ -594,10 +594,11 @@ mod finding_shell_options_leak { let result = bash.exec("set -bhm").await.unwrap(); assert_eq!(result.exit_code, 0, "set -bhm should succeed"); let result = bash.exec("echo \"$-\"").await.unwrap(); + // Only the always-on defaults (hashall, braceexpand) remain. assert_eq!( result.stdout.trim(), - "", - "set -b/-h/-m leaked across exec() calls through $-" + "hB", + "set -b/-m leaked across exec() calls through $-" ); } diff --git a/crates/bashkit/tests/spec_cases/bash/special-vars-subshell.test.sh b/crates/bashkit/tests/spec_cases/bash/special-vars-subshell.test.sh new file mode 100644 index 000000000..0495247a3 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/special-vars-subshell.test.sh @@ -0,0 +1,64 @@ +### arg0_unchanged_in_function +f() { echo "fn=${FUNCNAME[0]} same=$([[ $0 == "$top" ]] && echo y)"; } +top=$0 +f +### expect +fn=f same=y +### end + +### dollar_dash_flags +# `bash -c` adds `c`; drop it so script and -c runs agree +echo "${-//c/}" +set -eu; echo "${-//c/}"; set +eu +### expect +hB +ehuB +### end + +### bash_subshell_levels +echo "$BASH_SUBSHELL $(echo $BASH_SUBSHELL) $( (echo $BASH_SUBSHELL) )" +( echo "p $BASH_SUBSHELL" ) +echo "s $BASH_SUBSHELL" | cat +{ echo "g $BASH_SUBSHELL"; } | cat +( echo "ps $BASH_SUBSHELL" ) | cat +echo "end $BASH_SUBSHELL" +### expect +0 1 2 +p 1 +s 0 +g 1 +ps 1 +end 0 +### end + +### read_array_keeps_empty_fields +for s in "," "a,," ",a" "a,b,,d,"; do IFS=, read -ra p <<< "$s"; echo "${#p[@]} $(printf '[%s]' "${p[@]}")"; done +### expect +1 [] +2 [a][] +2 [][a] +4 [a][b][][d] +### end + +### read_last_var_trailing_delimiter +IFS=, read x y <<< "a,b,"; echo "[$y]" +IFS=, read x y <<< "a,b,,"; echo "[$y]" +IFS=, read x y <<< "a,b,c,"; echo "[$y]" +### expect +[b] +[b,,] +[b,c,] +### end + +### positional_slices +set -- a b c d +echo "${@:1}" "|" "${@:2}" "|" "${*:1:2}" "|" "${@: -1}" "|" "${@: -2:1}" "|" "${@:9}" "|" +printf '<%s>' "${@:2:2}"; echo +printf '<%s>' "${*:2}"; echo +echo "${#}" "${?:-unset}" +### expect +a b c d | b c d | a b | d | c | | + + +4 0 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 613d4cc64..519067339 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -13,6 +13,7 @@ * **Feature**: `import requests`, `import httpx` and `import httpx2` work in CPython. They are bashkit's own compact implementations of the common API over the HTTP bridge, preloaded in the snapshot (import cost ~0 vs ~3.1 s for vendored upstream requests). http.client read timeouts now surface from `getresponse()`. L-CPY-003 updated, L-CPY-010 added. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#requests-and-httpx-bashkits-own-modules). * **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry. * **Fix**: `$'\x1e'` and `$'\x1f'` bytes (record/unit separators) are kept as data. They collided with the lexer's quote-boundary markers and were silently dropped, which broke the common `SEP=$'\x1f'` composite-key idiom for associative arrays (`v["$section"$'\x1f'"$key"]`). Decoded ANSI-C content now NUL-escapes those bytes like `$`. Found by gap telemetry (`config_ini_merge` replay printed nothing). +* **Fix**: Special parameters. `$0` inside a function or sourced file is the script/shell name (it was the function name). `$-` lists flags in bash order and includes the always-on `h` and `B`. `$BASH_SUBSHELL` counts `( )`, `$( )`, compound pipeline stages and jobs (simple-command stages stay at the parent level, as in bash). `${-…}`, `${?…}` and `${$…}` take operators, `${#}` is `$#`, and `${@:off:len}`/`${*:off:len}` slice the positional parameters (offset 0 is `$0`, negative counts from the end). `read -a` keeps empty fields (`IFS=, read -ra p <<< a,,b`), a single trailing delimiter does not add an empty field, and the last variable drops it when only one field is left. From the pseudo-linus oracle list. * **Feature**: `/proc/self` and `/proc/1` in the rootfs (static `status`, `comm`, `cmdline`, `stat`, `cgroup`, `mounts`, `exe` -> `/bin/bash`, `fd/0..2`), plus `/proc/uptime` and `/proc/mounts`. `$BASHPID` is set (1, same as `$$`; it was empty). Never host data (TM-INF-003). Step 3 of the level-up roadmap. Spec: `proc-self.test.sh`. * **Fix**: A usage error in a bundled builtin no longer aborts the script. `grep -E '('`, `grep -P` with a bad pattern, `rg` with a bad regex, an awk syntax error, `grep` with no pattern and `grep -f missing` now fail that one command with exit 2 and the message on stderr, and the script continues (bash semantics; `set -e` still stops). `Err` from custom builtins (`BashBuilder::builtin`, registry, resolver) keeps aborting, per the public contract. See foundations/builtins "Errors: Failed Command vs Aborted Execution". * **Feature**: CPython `urllib.request`/`http.client` reach the network through bashkit's egress pipeline. New host import `bashkit.http_request` runs each request through `HttpClient` like `curl` (allowlist, SSRF, credential injection, signing, transport, response cap); TLS on the host, no sockets in the guest. The stdlib adapter swaps only http.client's socket for a bridge socket, so the real request/response code runs. Host validates the request record and drops host-owned headers (TM-PY-CPY-012, `cpython_http_fuzz`); `CPythonLimits::max_http_requests` caps requests per call. L-CPY-002 narrowed. Next: `requests` and `httpx2` (pydantic/httpx2, with `import httpx` aliased) in a separate packages crate behind opt-in `cpython-http`. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#http-bridge).