Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions crates/bashkit/src/builtins/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,13 @@ impl Builtin for Read {
let mut fields: Vec<ReadField<'_>> = 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();
let (_, ch) = iter.next().expect("valid char boundary");
let ch_len = ch.len_utf8();
if !ifs.contains(ch) {
i += ch_len;
last_delim_was_non_ws = false;
continue;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> {
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<String> = 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,
Expand Down
75 changes: 61 additions & 14 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,9 @@ struct CallFrame {
local_assoc_arrays: HashMap<String, Option<HashMap<String, String>>>,
/// Positional parameters ($1, $2, etc.)
positional: Vec<String>,
/// 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).
Expand Down Expand Up @@ -1333,6 +1336,7 @@ struct SubshellSnapshot {
getopts_char_idx: usize,
last_bg_pid: Option<String>,
seconds_base: (crate::time_compat::Instant, i64),
bash_subshell: u32,
}

/// Interpreter state.
Expand Down Expand Up @@ -1425,6 +1429,8 @@ pub struct Interpreter {
builtin_stdout_pipe: Option<Arc<pipe::Pipe>>,
/// Input pipe handed to a streaming filter's `Context` (taken at dispatch).
builtin_stdin_pipe: Option<Arc<pipe::Pipe>>,
/// `$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".
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2512,6 +2520,7 @@ impl Interpreter {
local_arrays: HashMap::new(),
local_assoc_arrays: HashMap::new(),
positional,
keeps_arg0: false,
});
}

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, ...)
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -10116,6 +10150,7 @@ impl Interpreter {
local_arrays: HashMap::new(),
local_assoc_arrays: HashMap::new(),
positional: new_positional.clone(),
keeps_arg0: false,
});
}
}
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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))?;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -11278,8 +11320,9 @@ impl Interpreter {
// Check for numeric positional parameter ($1, $2, etc.)
if let Ok(n) = name.parse::<usize>() {
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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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());

Expand Down Expand Up @@ -11815,6 +11861,7 @@ mod tests {
local_arrays: HashMap::new(),
local_assoc_arrays: HashMap::new(),
positional: Vec::new(),
keeps_arg0: false,
});
interp
.memory_budget
Expand Down
Loading
Loading