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
298 changes: 198 additions & 100 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6563,93 +6563,158 @@ impl Interpreter {
}
}
AssignmentValue::Array(words) => {
// Expand directly into the replacement array so word-split expansions cannot
// accumulate an unbounded temporary Vec before max_array_entries is enforced.
let arr_name = self.resolve_nameref(&assignment.name).to_string();
let old_entries = self.scoped.arrays.get(&arr_name).map_or(0, |arr| arr.len());
let remaining_entries = self
.memory_limits
.max_array_entries
.saturating_sub(self.memory_budget.array_entries);
let max_new_entries = old_entries.saturating_add(remaining_entries);
let mut next_arr = if assignment.append {
self.scoped
.arrays
.get(&arr_name)
.cloned()
.unwrap_or_default()
} else {
HashMap::new()
};
let mut idx = if assignment.append {
next_arr.keys().max().map(|k| k + 1).unwrap_or(0)
} else {
0
};
self.assign_array_literal(assignment, words).await?;
}
}
}
Ok(())
}

'array_words: for word in words.iter() {
let is_unquoted_expansion = !word.quoted
&& word.parts.iter().any(|p| {
matches!(
p,
WordPart::Variable(_)
| WordPart::CommandSubstitution(_)
| WordPart::ArithmeticExpansion(_)
| WordPart::ParameterExpansion { .. }
| WordPart::ArrayAccess { .. }
)
});
// "${arr[@]}" or "$@" in array context should splat
// individual elements, not join into a single string.
let is_quoted_splat = word.quoted
&& word.parts.len() == 1
&& matches!(
&word.parts[0],
WordPart::ArrayAccess { index, .. } if index == "@"
);
let is_quoted_positional_splat = word.quoted
&& word.parts.len() == 1
&& matches!(
&word.parts[0],
WordPart::Variable(name) if name == "@"
);
/// `name=(words...)` / `name+=(words...)`. Boxed so the per-word brace,
/// glob and `[i]=v` handling stays off the recursive poll stack of every
/// simple command (TM-DOS-020 two-MiB stack budget).
fn assign_array_literal<'a>(
&'a mut self,
assignment: &'a Assignment,
words: &'a [Word],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
// Expand directly into the replacement array so word-split expansions cannot
// accumulate an unbounded temporary Vec before max_array_entries is enforced.
let arr_name = self.resolve_nameref(&assignment.name).to_string();
let old_entries = self.scoped.arrays.get(&arr_name).map_or(0, |arr| arr.len());
let remaining_entries = self
.memory_limits
.max_array_entries
.saturating_sub(self.memory_budget.array_entries);
let max_new_entries = old_entries.saturating_add(remaining_entries);
let mut next_arr = if assignment.append {
self.scoped
.arrays
.get(&arr_name)
.cloned()
.unwrap_or_default()
} else {
HashMap::new()
};
let mut idx = if assignment.append {
next_arr.keys().max().map(|k| k + 1).unwrap_or(0)
} else {
0
};

if is_unquoted_expansion {
let remaining = max_new_entries.saturating_sub(next_arr.len());
if remaining == 0 {
break;
}
let expanded = self.expand_word(word).await?;
for field in self.ifs_split_limited(&expanded, remaining)? {
next_arr.insert(idx, field);
idx += 1;
}
// Brace expansion runs first, on the unexpanded word, as for
// command arguments; pathname expansion runs on the fields below.
let mut braced_words: Vec<Word> = Vec::with_capacity(words.len());
for w in words.iter() {
match self.brace_expand_word(w) {
Some(ws) => braced_words.extend(ws),
None => braced_words.push(w.clone()),
}
}

'array_words: for word in braced_words.iter() {
// `[i]=value` sets one element; the value gets no field
// splitting or pathname expansion.
if let Some((index, value_word)) = split_indexed_element(word) {
let i = self.evaluate_arithmetic_with_assign(&index);
let value = self.expand_word(&value_word).await?;
if i < 0 {
continue;
}
let i = i as usize;
if !next_arr.contains_key(&i) && next_arr.len() >= max_new_entries {
break 'array_words;
}
next_arr.insert(i, value);
idx = i + 1;
continue;
}
let globs = !word.quoted || word.has_unquoted_glob;
let is_unquoted_expansion = !word.quoted
&& word.parts.iter().any(|p| {
matches!(
p,
WordPart::Variable(_)
| WordPart::CommandSubstitution(_)
| WordPart::ArithmeticExpansion(_)
| WordPart::ParameterExpansion { .. }
| WordPart::ArrayAccess { .. }
)
});
// "${arr[@]}" or "$@" in array context should splat
// individual elements, not join into a single string.
let is_quoted_splat = word.quoted
&& word.parts.len() == 1
&& matches!(
&word.parts[0],
WordPart::ArrayAccess { index, .. } if index == "@"
);
let is_quoted_positional_splat = word.quoted
&& word.parts.len() == 1
&& matches!(
&word.parts[0],
WordPart::Variable(name) if name == "@"
);

if is_unquoted_expansion {
let remaining = max_new_entries.saturating_sub(next_arr.len());
if remaining == 0 {
break;
}
let expanded = self.expand_word(word).await?;
for field in self.ifs_split_limited(&expanded, remaining)? {
for item in self.glob_array_field(field, word, globs).await {
if next_arr.len() >= max_new_entries {
break 'array_words;
}
} else if is_quoted_splat || is_quoted_positional_splat {
for field in self.expand_word_to_fields(word).await? {
if next_arr.len() >= max_new_entries {
break 'array_words;
}
next_arr.insert(idx, field);
idx += 1;
}
} else {
let value = self.expand_word(word).await?;
if next_arr.len() >= max_new_entries {
break;
}
next_arr.insert(idx, value);
next_arr.insert(idx, item);
idx += 1;
}
}

let _ = self.insert_array_checked(arr_name, next_arr);
if next_arr.len() >= max_new_entries {
break 'array_words;
}
} else if is_quoted_splat || is_quoted_positional_splat {
for field in self.expand_word_to_fields(word).await? {
if next_arr.len() >= max_new_entries {
break 'array_words;
}
next_arr.insert(idx, field);
idx += 1;
}
} else {
let value = self.expand_word(word).await?;
for item in self.glob_array_field(value, word, globs).await {
if next_arr.len() >= max_new_entries {
break 'array_words;
}
next_arr.insert(idx, item);
idx += 1;
}
}
}

let _ = self.insert_array_checked(arr_name, next_arr);
Ok(())
})
}

/// Pathname expansion for one array-literal field. A failglob miss keeps the
/// literal field.
// WTF: bash aborts the assignment with "no match" under failglob; assignment
// expansion has no error channel here, so the pattern is stored as-is.
async fn glob_array_field(&mut self, field: String, word: &Word, globs: bool) -> Vec<String> {
if !globs {
return vec![field];
}
match self
.expand_glob_item(&field, word.quoted && word.has_unquoted_glob)
.await
{
Ok(items) => items,
Err(_) => vec![field],
}
Ok(())
}

/// Try alias expansion. Returns `Some(result)` if alias was expanded, `None` otherwise.
Expand Down Expand Up @@ -9557,31 +9622,30 @@ impl Interpreter {

match mode {
'v' => {
// command -v: print name/path if it's a known command
let registered = self.builtins.contains_key(cmd_name.as_str())
|| is_dispatch_only_builtin(cmd_name)
|| self.has_host_builtin(cmd_name);
let output = if self.scoped.functions.contains_key(cmd_name.as_str())
|| is_keyword(cmd_name)
|| (registered && builtins::BASH_BUILTIN_NAMES.contains(&cmd_name.as_str()))
{
Some(cmd_name.to_string())
} else if let Some(path) = self.resolve_command_path(cmd_name).await {
Some(path)
} else {
registered.then(|| cmd_name.to_string())
};
let mut result = if let Some(name) = output {
ExecResult::ok(format!("{}\n", name))
} else {
ExecResult {
stdout: crate::StreamData::new(),
stderr: crate::StreamData::new(),
exit_code: 1,
control_flow: crate::interpreter::ControlFlow::None,
..Default::default()
// command -v: print the name/path of each known command;
// status 0 when any was found (bash).
let mut out = String::new();
for cmd_name in &args[cmd_args_start..] {
let registered = self.builtins.contains_key(cmd_name.as_str())
|| is_dispatch_only_builtin(cmd_name)
|| self.has_host_builtin(cmd_name);
let found = if self.scoped.functions.contains_key(cmd_name.as_str())
|| is_keyword(cmd_name)
|| (registered && builtins::BASH_BUILTIN_NAMES.contains(&cmd_name.as_str()))
{
Some(cmd_name.to_string())
} else if let Some(path) = self.resolve_command_path(cmd_name).await {
Some(path)
} else {
registered.then(|| cmd_name.to_string())
};
if let Some(name) = found {
out.push_str(&name);
out.push('\n');
}
};
}
let code = if out.is_empty() { 1 } else { 0 };
let mut result = ExecResult::with_code(out, code);
result = self.apply_redirections(result, redirects).await?;
Ok(result)
}
Expand Down Expand Up @@ -11720,6 +11784,40 @@ fn builtin_usage_error(msg: &str) -> ExecResult {
ExecResult::err(msg, 2)
}

/// Split an unquoted `[index]=value` array-literal element into its index text
/// and a word for the value.
fn split_indexed_element(word: &Word) -> Option<(String, Word)> {
let WordPart::Literal(first) = word.parts.first()? else {
return None;
};
let first_quoted = word.part_quoted.first().copied().unwrap_or(word.quoted);
if first_quoted || !first.starts_with('[') {
return None;
}
let close = first.find("]=")?;
let index = first[1..close].to_string();
let tail = &first[close + 2..];
let mut parts = Vec::with_capacity(word.parts.len());
let mut part_quoted = Vec::with_capacity(word.parts.len());
if !tail.is_empty() {
parts.push(WordPart::Literal(tail.to_string()));
part_quoted.push(false);
}
for (i, p) in word.parts.iter().enumerate().skip(1) {
parts.push(p.clone());
part_quoted.push(word.part_quoted.get(i).copied().unwrap_or(word.quoted));
}
Some((
index,
Word {
parts,
quoted: true,
has_unquoted_glob: false,
part_quoted,
},
))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
### array_literal_glob
cd /tmp && mkdir -p alg && cd alg && touch b.txt a.txt c.log
arr=(*.txt)
echo "${#arr[@]} ${arr[*]}"
### expect
2 a.txt b.txt
### end

### array_literal_brace
arr=(x{1,2} {a..c})
echo "${#arr[@]} ${arr[*]}"
### expect
5 x1 x2 a b c
### end

### array_literal_quoted_kept
cd /tmp && mkdir -p alq && cd alq && touch a.txt
arr=("*.txt" '{1,2}' "x"*.txt)
printf '<%s>' "${arr[@]}"; echo
### expect
<*.txt><{1,2}><x*.txt>
### end

### array_literal_nullglob
cd /tmp && mkdir -p aln && cd aln
shopt -s nullglob
arr=(*.zz); a=(x*.zz y); b=("*.zz")
echo "${#arr[@]} ${#a[@]} ${#b[@]}"
### expect
0 1 1
### end

### array_literal_unquoted_var_globs
cd /tmp && mkdir -p alv && cd alv && touch p1 p2
pat='p*'
arr=($pat)
echo "${arr[*]}"
### expect
p1 p2
### end

### array_literal_indexed_unchanged
arr=([2]=x y [0]=z)
declare -p arr
### expect
declare -a arr=([0]="z" [2]="x" [3]="y")
### end

### command_v_multiple_names
command -v echo cd
echo "rc=$?"
command -v nope_xyz echo
echo "rc=$?"
command -v nope_xyz nope2_xyz
echo "rc=$?"
### expect
echo
cd
rc=0
echo
rc=0
rc=1
### end
Loading
Loading