diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 45cc1b64..1df99ef9 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -556,6 +556,92 @@ impl Builtin for Ln { } } +/// The chown builtin - change file ownership (no-op in VFS). +/// +/// Usage: chown [-R] OWNER[:GROUP] FILE... +/// +/// In the virtual filesystem there are no real UIDs/GIDs, so chown is a no-op +/// that simply validates arguments and succeeds silently. +pub struct Chown; + +#[async_trait] +impl Builtin for Chown { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut recursive = false; + let mut positional: Vec<&str> = Vec::new(); + + for arg in ctx.args { + match arg.as_str() { + "-R" | "--recursive" => recursive = true, + _ if arg.starts_with('-') => {} // ignore other flags + _ => positional.push(arg), + } + } + let _ = recursive; // accepted but irrelevant in VFS + + if positional.len() < 2 { + return Ok(ExecResult::err("chown: missing operand\n".to_string(), 1)); + } + + // Validate that target files exist + let _owner = positional[0]; // accepted but not applied + for file in &positional[1..] { + let path = resolve_path(ctx.cwd, file); + if !ctx.fs.exists(&path).await.unwrap_or(false) { + return Ok(ExecResult::err( + format!( + "chown: cannot access '{}': No such file or directory\n", + file + ), + 1, + )); + } + } + + Ok(ExecResult::ok(String::new())) + } +} + +/// The kill builtin - send signal to process (no-op in VFS). +/// +/// Usage: kill [-s SIGNAL] [-SIGNAL] PID... +/// +/// Since there are no real processes in the virtual environment, kill is a no-op +/// that accepts the command syntax for compatibility. +pub struct Kill; + +#[async_trait] +impl Builtin for Kill { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut pids: Vec<&str> = Vec::new(); + + for arg in ctx.args { + if arg == "-l" || arg == "-L" { + // List signal names + return Ok(ExecResult::ok( + "HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM\n" + .to_string(), + )); + } + if arg.starts_with('-') { + continue; // skip signal spec + } + pids.push(arg); + } + + if pids.is_empty() { + return Ok(ExecResult::err( + "kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ...\n" + .to_string(), + 2, + )); + } + + // In VFS, no real processes exist — just succeed silently + Ok(ExecResult::ok(String::new())) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 0aba4be0..fdd76129 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -81,7 +81,7 @@ pub use disk::{Df, Du}; pub use echo::Echo; pub use environ::{Env, History, Printenv}; pub use export::Export; -pub use fileops::{Chmod, Cp, Ln, Mkdir, Mv, Rm, Touch}; +pub use fileops::{Chmod, Chown, Cp, Kill, Ln, Mkdir, Mv, Rm, Touch}; pub use flow::{Break, Colon, Continue, Exit, False, Return, True}; pub use grep::Grep; pub use headtail::{Head, Tail}; diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 7663ff07..dc62b07b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -130,6 +130,8 @@ pub struct Interpreter { variables: HashMap, /// Arrays - stored as name -> index -> value arrays: HashMap>, + /// Associative arrays (declare -A) - stored as name -> key -> value + assoc_arrays: HashMap>, cwd: PathBuf, last_exit_code: i32, /// Built-in commands (default + custom) @@ -237,6 +239,8 @@ impl Interpreter { builtins.insert("touch".to_string(), Box::new(builtins::Touch)); builtins.insert("chmod".to_string(), Box::new(builtins::Chmod)); builtins.insert("ln".to_string(), Box::new(builtins::Ln)); + builtins.insert("chown".to_string(), Box::new(builtins::Chown)); + builtins.insert("kill".to_string(), Box::new(builtins::Kill)); builtins.insert("wc".to_string(), Box::new(builtins::Wc)); builtins.insert("nl".to_string(), Box::new(builtins::Nl)); builtins.insert("paste".to_string(), Box::new(builtins::Paste)); @@ -314,6 +318,7 @@ impl Interpreter { env: HashMap::new(), variables: HashMap::new(), arrays: HashMap::new(), + assoc_arrays: HashMap::new(), cwd: PathBuf::from("/home/user"), last_exit_code: 0, builtins, @@ -2084,16 +2089,30 @@ impl Interpreter { AssignmentValue::Scalar(word) => { let value = self.expand_word(word).await?; if let Some(index_str) = &assignment.index { - // arr[index]=value - set array element - let index: usize = - self.evaluate_arithmetic(index_str).try_into().unwrap_or(0); - let arr = self.arrays.entry(assignment.name.clone()).or_default(); - if assignment.append { - // Append to existing element - let existing = arr.get(&index).cloned().unwrap_or_default(); - arr.insert(index, existing + &value); + if self.assoc_arrays.contains_key(&assignment.name) { + // Associative array: use string key + let key = self.expand_variable_or_literal(index_str); + let arr = self + .assoc_arrays + .entry(assignment.name.clone()) + .or_default(); + if assignment.append { + let existing = arr.get(&key).cloned().unwrap_or_default(); + arr.insert(key, existing + &value); + } else { + arr.insert(key, value); + } } else { - arr.insert(index, value); + // Indexed array: use numeric index + let index: usize = + self.evaluate_arithmetic(index_str).try_into().unwrap_or(0); + let arr = self.arrays.entry(assignment.name.clone()).or_default(); + if assignment.append { + let existing = arr.get(&index).cloned().unwrap_or_default(); + arr.insert(index, existing + &value); + } else { + arr.insert(index, value); + } } } else if assignment.append { // VAR+=value - append to variable @@ -2423,6 +2442,34 @@ impl Interpreter { .await; } + // Handle `unset` with array element syntax: unset 'arr[key]' + if name == "unset" { + for arg in &args { + if let Some(bracket) = arg.find('[') { + if arg.ends_with(']') { + let arr_name = &arg[..bracket]; + let key = &arg[bracket + 1..arg.len() - 1]; + let expanded_key = self.expand_variable_or_literal(key); + if let Some(arr) = self.assoc_arrays.get_mut(arr_name) { + arr.remove(&expanded_key); + } else if let Some(arr) = self.arrays.get_mut(arr_name) { + if let Ok(idx) = key.parse::() { + arr.remove(&idx); + } + } + continue; + } + } + // Regular unset + self.variables.remove(arg.as_str()); + self.arrays.remove(arg.as_str()); + self.assoc_arrays.remove(arg.as_str()); + } + let mut result = ExecResult::ok(String::new()); + result = self.apply_redirections(result, &command.redirects).await?; + return Ok(result); + } + // Handle `getopts` builtin - needs to read/write shell variables (OPTIND, OPTARG) if name == "getopts" { return self.execute_getopts(&args, &command.redirects).await; @@ -3284,6 +3331,7 @@ impl Interpreter { let mut is_readonly = false; let mut is_export = false; let mut is_array = false; + let mut is_assoc = false; let mut is_integer = false; let mut names: Vec<&str> = Vec::new(); @@ -3296,10 +3344,7 @@ impl Interpreter { 'x' => is_export = true, 'a' => is_array = true, 'i' => is_integer = true, - 'A' => { - // Associative arrays not yet supported - is_array = true; - } + 'A' => is_assoc = true, 'g' | 'l' | 'n' | 'u' | 't' | 'f' | 'F' => {} // ignored _ => {} } @@ -3331,6 +3376,15 @@ impl Interpreter { attrs = String::from("-r"); } output.push_str(&format!("declare {} {}=\"{}\"\n", attrs, var_name, value)); + } else if let Some(arr) = self.assoc_arrays.get(var_name) { + let mut items: Vec<_> = arr.iter().collect(); + items.sort_by_key(|(k, _)| (*k).clone()); + let inner: String = items + .iter() + .map(|(k, v)| format!("[{}]=\"{}\"", k, v)) + .collect::>() + .join(" "); + output.push_str(&format!("declare -A {}=({})\n", var_name, inner)); } else if let Some(arr) = self.arrays.get(var_name) { let mut items: Vec<_> = arr.iter().collect(); items.sort_by_key(|(k, _)| *k); @@ -3353,13 +3407,89 @@ impl Interpreter { return Ok(result); } - // Set variables + // Reconstruct compound assignments: declare -A m=([a]="1" [b]="2") + // Args may be split across names: ["m=([a]=1", "[b]=2)"] + let mut merged_names: Vec = Vec::new(); + let mut pending: Option = None; for name in &names { + if let Some(ref mut p) = pending { + p.push(' '); + p.push_str(name); + if name.ends_with(')') { + merged_names.push(p.clone()); + pending = None; + } + } else if let Some(eq_pos) = name.find("=(") { + if name.ends_with(')') { + merged_names.push(name.to_string()); + } else { + pending = Some(name.to_string()); + let _ = eq_pos; // used above in find + } + } else { + merged_names.push(name.to_string()); + } + } + if let Some(p) = pending { + merged_names.push(p); + } + + // Set variables + for name in &merged_names { if let Some(eq_pos) = name.find('=') { let var_name = &name[..eq_pos]; let value = &name[eq_pos + 1..]; - if is_integer { + // Handle compound array assignment: declare -A m=([k]="v" ...) + if (is_assoc || is_array) && value.starts_with('(') && value.ends_with(')') { + let inner = &value[1..value.len() - 1]; + if is_assoc { + let arr = self.assoc_arrays.entry(var_name.to_string()).or_default(); + arr.clear(); + // Parse [key]="value" pairs + let mut rest = inner.trim(); + while let Some(bracket_start) = rest.find('[') { + if let Some(bracket_end) = rest[bracket_start..].find(']') { + let key = &rest[bracket_start + 1..bracket_start + bracket_end]; + let after = &rest[bracket_start + bracket_end + 1..]; + if let Some(eq_rest) = after.strip_prefix('=') { + let eq_rest = eq_rest.trim_start(); + let (val, remainder) = if let Some(stripped) = + eq_rest.strip_prefix('"') + { + // Quoted value + if let Some(end_q) = stripped.find('"') { + (&stripped[..end_q], stripped[end_q + 1..].trim_start()) + } else { + (stripped.trim_end_matches('"'), "") + } + } else { + // Unquoted value — up to next space or end + match eq_rest.find(char::is_whitespace) { + Some(sp) => { + (&eq_rest[..sp], eq_rest[sp..].trim_start()) + } + None => (eq_rest, ""), + } + }; + arr.insert(key.to_string(), val.to_string()); + rest = remainder; + } else { + break; + } + } else { + break; + } + } + } else { + // Indexed array: declare -a arr=(a b c) + let arr = self.arrays.entry(var_name.to_string()).or_default(); + arr.clear(); + for (idx, val) in inner.split_whitespace().enumerate() { + arr.insert(idx, val.trim_matches('"').to_string()); + } + } + } else if is_integer { // Try to evaluate as integer let int_val: i64 = value.parse().unwrap_or(0); self.variables @@ -3381,10 +3511,13 @@ impl Interpreter { } } else { // Declare without value - if is_array { - // Initialize empty array if not exists + if is_assoc { + // Initialize empty associative array + self.assoc_arrays.entry(name.to_string()).or_default(); + } else if is_array { + // Initialize empty indexed array self.arrays.entry(name.to_string()).or_default(); - } else if !self.variables.contains_key(*name) { + } else if !self.variables.contains_key(name.as_str()) { self.variables.insert(name.to_string(), String::new()); } if is_readonly { @@ -3394,7 +3527,10 @@ impl Interpreter { if is_export { self.env.insert( name.to_string(), - self.variables.get(*name).cloned().unwrap_or_default(), + self.variables + .get(name.as_str()) + .cloned() + .unwrap_or_default(), ); } } @@ -3700,7 +3836,13 @@ impl Interpreter { WordPart::ArrayAccess { name, index } => { if index == "@" || index == "*" { // ${arr[@]} or ${arr[*]} - expand to all elements - if let Some(arr) = self.arrays.get(name) { + if let Some(arr) = self.assoc_arrays.get(name) { + let mut keys: Vec<_> = arr.keys().collect(); + keys.sort(); + let values: Vec = + keys.iter().filter_map(|k| arr.get(*k).cloned()).collect(); + result.push_str(&values.join(" ")); + } else if let Some(arr) = self.arrays.get(name) { let mut indices: Vec<_> = arr.keys().collect(); indices.sort(); let values: Vec<_> = @@ -3709,6 +3851,12 @@ impl Interpreter { &values.into_iter().cloned().collect::>().join(" "), ); } + } else if let Some(arr) = self.assoc_arrays.get(name) { + // ${assoc[key]} - get by string key + let key = self.expand_variable_or_literal(index); + if let Some(value) = arr.get(&key) { + result.push_str(value); + } } else { // ${arr[n]} - get specific element let idx: usize = self.evaluate_arithmetic(index).try_into().unwrap_or(0); @@ -3720,8 +3868,12 @@ impl Interpreter { } } WordPart::ArrayIndices(name) => { - // ${!arr[@]} or ${!arr[*]} - expand to array indices - if let Some(arr) = self.arrays.get(name) { + // ${!arr[@]} or ${!arr[*]} - expand to array indices/keys + if let Some(arr) = self.assoc_arrays.get(name) { + let mut keys: Vec<_> = arr.keys().cloned().collect(); + keys.sort(); + result.push_str(&keys.join(" ")); + } else if let Some(arr) = self.arrays.get(name) { let mut indices: Vec<_> = arr.keys().collect(); indices.sort(); let index_strs: Vec = @@ -3734,23 +3886,22 @@ impl Interpreter { offset, length, } => { - // ${var:offset} or ${var:offset:length} + // ${var:offset} or ${var:offset:length} - character-based indexing let value = self.expand_variable(name); + let char_count = value.chars().count(); let offset_val: isize = self.evaluate_arithmetic(offset) as isize; let start = if offset_val < 0 { - // Negative offset counts from end - (value.len() as isize + offset_val).max(0) as usize + (char_count as isize + offset_val).max(0) as usize } else { - (offset_val as usize).min(value.len()) + (offset_val as usize).min(char_count) }; - let substr = if let Some(len_expr) = length { + let substr: String = if let Some(len_expr) = length { let len_val = self.evaluate_arithmetic(len_expr) as usize; - let end = (start + len_val).min(value.len()); - &value[start..end] + value.chars().skip(start).take(len_val).collect() } else { - &value[start..] + value.chars().skip(start).collect() }; - result.push_str(substr); + result.push_str(&substr); } WordPart::ArraySlice { name, @@ -3789,7 +3940,9 @@ impl Interpreter { } WordPart::ArrayLength(name) => { // ${#arr[@]} - number of elements - if let Some(arr) = self.arrays.get(name) { + if let Some(arr) = self.assoc_arrays.get(name) { + result.push_str(&arr.len().to_string()); + } else if let Some(arr) = self.arrays.get(name) { result.push_str(&arr.len().to_string()); } else { result.push('0'); @@ -3840,6 +3993,17 @@ impl Interpreter { if word.parts.len() == 1 { if let WordPart::ArrayAccess { name, index } = &word.parts[0] { if index == "@" || index == "*" { + // Check assoc arrays first + if let Some(arr) = self.assoc_arrays.get(name) { + let mut keys: Vec<_> = arr.keys().cloned().collect(); + keys.sort(); + let values: Vec = + keys.iter().filter_map(|k| arr.get(k).cloned()).collect(); + if word.quoted && index == "*" { + return Ok(vec![values.join(" ")]); + } + return Ok(values); + } if let Some(arr) = self.arrays.get(name) { let mut indices: Vec<_> = arr.keys().collect(); indices.sort(); @@ -3854,6 +4018,20 @@ impl Interpreter { return Ok(Vec::new()); } } + // "${!arr[@]}" - array keys/indices as separate fields + if let WordPart::ArrayIndices(name) = &word.parts[0] { + if let Some(arr) = self.assoc_arrays.get(name) { + let mut keys: Vec<_> = arr.keys().cloned().collect(); + keys.sort(); + return Ok(keys); + } + if let Some(arr) = self.arrays.get(name) { + let mut indices: Vec<_> = arr.keys().collect(); + indices.sort(); + return Ok(indices.iter().map(|i| i.to_string()).collect()); + } + return Ok(Vec::new()); + } } // For other words, expand to a single field @@ -4253,6 +4431,11 @@ impl Interpreter { return 0; } + // Non-ASCII chars can't be valid arithmetic; bail to avoid byte/char index mismatch + if !expr.is_ascii() { + return 0; + } + // THREAT[TM-DOS-025]: Bail out if arithmetic nesting is too deep if arith_depth >= Self::MAX_ARITHMETIC_DEPTH { return 0; @@ -4500,6 +4683,21 @@ impl Interpreter { } /// Expand a variable by name, checking local scope, positional params, shell vars, then env + /// Expand a string as a variable reference, or return as literal. + /// Used for associative array keys which may be variable refs or literals. + fn expand_variable_or_literal(&self, s: &str) -> String { + // Handle $var and ${var} references in assoc array keys + let trimmed = s.trim(); + if let Some(var_name) = trimmed.strip_prefix('$') { + let var_name = var_name.trim_start_matches('{').trim_end_matches('}'); + return self.expand_variable(var_name); + } + if let Some(val) = self.variables.get(s) { + return val.clone(); + } + s.to_string() + } + fn expand_variable(&self, name: &str) -> String { // Check for special parameters (POSIX required) match name { diff --git a/crates/bashkit/tests/proptest_security.rs b/crates/bashkit/tests/proptest_security.rs index f4aacc4b..4f6149b3 100644 --- a/crates/bashkit/tests/proptest_security.rs +++ b/crates/bashkit/tests/proptest_security.rs @@ -211,3 +211,31 @@ fn test_unicode_handling() { } }); } + +/// Regression: proptest found multi-byte char panic in variable expansion +/// Input "${:¡%" caused byte index panic in substring/parameter expansion +#[test] +fn test_multibyte_in_variable_expansion() { + let scripts = [ + "X='${:¡%'; echo $X", + "X='¡%'; echo ${X:1}", + "X='日本語'; echo ${X:1:2}", + "X='émoji'; echo ${X:0:3}", + "X='über'; echo ${#X}", + ]; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + for script in scripts { + let limits = ExecutionLimits::new() + .max_commands(10) + .timeout(Duration::from_millis(100)); + let mut bash = Bash::builder().limits(limits).build(); + let _ = bash.exec(script).await; + } + }); +} diff --git a/crates/bashkit/tests/spec_cases/bash/array-slicing.test.sh b/crates/bashkit/tests/spec_cases/bash/array-slicing.test.sh new file mode 100644 index 00000000..acab63de --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/array-slicing.test.sh @@ -0,0 +1,55 @@ +### array_slice_basic +arr=(a b c d e) +echo "${arr[@]:1:3}" +### expect +b c d +### end + +### array_slice_from_start +arr=(a b c d e) +echo "${arr[@]:0:2}" +### expect +a b +### end + +### array_slice_to_end +arr=(a b c d e) +echo "${arr[@]:2}" +### expect +c d e +### end + +### array_slice_negative_offset +arr=(a b c d e) +echo "${arr[@]: -2}" +### expect +d e +### end + +### array_slice_single +arr=(a b c d e) +echo "${arr[@]:3:1}" +### expect +d +### end + +### array_slice_zero_length +arr=(a b c d e) +echo ">${arr[@]:1:0}<" +### expect +>< +### end + +### array_slice_beyond_bounds +arr=(a b c) +echo "${arr[@]:1:10}" +### expect +b c +### end + +### array_slice_at_length +arr=(a b c) +echo ">${arr[@]:3}<" +### expect +>< +### end diff --git a/crates/bashkit/tests/spec_cases/bash/assoc-arrays.test.sh b/crates/bashkit/tests/spec_cases/bash/assoc-arrays.test.sh new file mode 100644 index 00000000..96431043 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/assoc-arrays.test.sh @@ -0,0 +1,122 @@ +### assoc_declare_and_access +declare -A mymap +mymap[name]="Alice" +mymap[age]="30" +echo "${mymap[name]}" +echo "${mymap[age]}" +### expect +Alice +30 +### end + +### assoc_length +declare -A m +m[a]="1" +m[b]="2" +m[c]="3" +echo "${#m[@]}" +### expect +3 +### end + +### assoc_keys +declare -A m +m[x]="10" +m[y]="20" +for k in "${!m[@]}"; do echo "$k"; done | sort +### expect +x +y +### end + +### assoc_all_values +declare -A m +m[a]="alpha" +m[b]="beta" +for v in "${m[@]}"; do echo "$v"; done | sort +### expect +alpha +beta +### end + +### assoc_overwrite +declare -A m +m[key]="old" +m[key]="new" +echo "${m[key]}" +### expect +new +### end + +### assoc_empty +declare -A m +echo ">${#m[@]}<" +### expect +>0< +### end + +### assoc_unset_key +declare -A m +m[a]="1" +m[b]="2" +unset 'm[a]' +echo "${#m[@]}" +echo "${m[b]}" +### expect +1 +2 +### end + +### assoc_declare_inline +### skip: compound array assignment parsing not yet implemented +declare -A m=([foo]="bar" [baz]="qux") +echo "${m[foo]}" +echo "${m[baz]}" +### expect +bar +qux +### end + +### assoc_numeric_string_key +declare -A m +m[1]="one" +m[2]="two" +echo "${m[1]}" +echo "${m[2]}" +### expect +one +two +### end + +### assoc_variable_key +declare -A m +key="mykey" +m[$key]="value" +echo "${m[$key]}" +echo "${m[mykey]}" +### expect +value +value +### end + +### assoc_special_chars_value +declare -A m +m[key]="hello world" +echo "${m[key]}" +### expect +hello world +### end + +### assoc_iteration +declare -A m +m[a]="1" +m[b]="2" +m[c]="3" +for key in "${!m[@]}"; do + echo "$key=${m[$key]}" +done | sort +### expect +a=1 +b=2 +c=3 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/chown-kill.test.sh b/crates/bashkit/tests/spec_cases/bash/chown-kill.test.sh new file mode 100644 index 00000000..62ea81da --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/chown-kill.test.sh @@ -0,0 +1,57 @@ +### chown_basic +### bash_diff: VFS chown is a no-op, no real ownership +# chown accepts owner:group syntax +echo hello > /tmp/chown_test.txt +chown root:root /tmp/chown_test.txt +echo $? +### expect +0 +### end + +### chown_recursive +### bash_diff: VFS chown is a no-op +# chown -R accepted +mkdir -p /tmp/chown_dir +echo a > /tmp/chown_dir/file.txt +chown -R user:user /tmp/chown_dir +echo $? +### expect +0 +### end + +### chown_missing_operand +### exit_code:1 +# chown with missing operand +chown root +### expect +### end + +### chown_nonexistent_file +### exit_code:1 +# chown on nonexistent file +chown root:root /tmp/nonexistent_chown_xyz +### expect +### end + +### kill_list_signals +# kill -l lists signal names +kill -l | grep -q HUP && echo "ok" +### expect +ok +### end + +### kill_no_args +### exit_code:2 +# kill with no PID +kill +### expect +### end + +### kill_noop +### bash_diff: VFS has no real processes +# kill accepts PID and succeeds (no-op in VFS) +kill -0 1 +echo $? +### expect +0 +### end diff --git a/specs/005-builtins.md b/specs/005-builtins.md index 0342a929..f9486916 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -74,6 +74,9 @@ execution → $PATH search → "command not found". - `mv` - Move/rename files - `touch` - Create empty files - `chmod` - Change permissions (octal mode) +- `chown` - Change ownership (no-op in VFS, validates file existence) +- `ln` - Create links (`-s` symbolic, `-f` force) +- `kill` - Send signals (no-op in VFS, `-l` lists signals) #### Text Processing - `cat` - Concatenate files (`-v`, `-n`, `-e`, `-t`) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 8f281fe9..63774117 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -107,11 +107,11 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1087 (1034 pass, 53 skip) +**Total spec test cases:** 1060 (1038 pass, 22 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 684 | Yes | 679 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 711 | Yes | 705 | 6 | `bash_spec_tests` in CI | | AWK | 90 | Yes | 73 | 17 | loops, arrays, -v, ternary, field assign | | Grep | 82 | Yes | 79 | 3 | now with -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude | | Sed | 65 | Yes | 53 | 12 | hold space, change, regex ranges, -E | @@ -179,7 +179,7 @@ Features that may be added in the future (not intentionally excluded): |---------|----------|-------| | Coprocesses `coproc` | Low | Rarely used | | Extended globs `@()` `!()` | Medium | Requires `shopt -s extglob` | -| Associative arrays `declare -A` | Medium | Bash 4+ feature | +| ~~Associative arrays `declare -A`~~ | ~~Medium~~ | Implemented: key-value access, iteration, unset, `${!m[@]}` | | ~~`[[ =~ ]]` regex matching~~ | ~~Medium~~ | Implemented: `[[ ]]` conditionals with `=~` and BASH_REMATCH | | ~~`getopts`~~ | ~~Medium~~ | Implemented: POSIX option parsing | | ~~`command` builtin~~ | ~~Medium~~ | Implemented: `-v`, `-V`, bypass functions | @@ -197,7 +197,7 @@ Features that may be added in the future (not intentionally excluded): | `local` | Declaration | Proper scoping in nested functions | | `return` | Basic usage | Return value propagation | | Heredocs | Basic | Variable expansion inside | -| Arrays | Indexing, `[@]`/`[*]` as separate args, `${!arr[@]}`, `+=` | Slice `${arr[@]:1:2}` | +| Arrays | Indexing, `[@]`/`[*]` as separate args, `${!arr[@]}`, `+=`, slice `${arr[@]:1:2}`, assoc `declare -A` | Compound init `declare -A m=([k]=v)` | | `echo -n` | Flag parsed | Trailing newline handling | | `time` | Wall-clock timing | User/sys CPU time (always 0) | | `timeout` | Basic usage | `-k` kill timeout | @@ -207,15 +207,15 @@ Features that may be added in the future (not intentionally excluded): ### Implemented -**90 core builtins + 3 feature-gated = 93 total** +**92 core builtins + 3 feature-gated = 95 total** `echo`, `printf`, `cat`, `nl`, `cd`, `pwd`, `true`, `false`, `exit`, `test`, `[`, `export`, `set`, `unset`, `local`, `source`, `.`, `read`, `shift`, `break`, `continue`, `return`, `grep`, `sed`, `awk`, `jq`, `sleep`, `head`, `tail`, -`basename`, `dirname`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `ln`, `wc`, +`basename`, `dirname`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `chown`, `ln`, `wc`, `sort`, `uniq`, `cut`, `tr`, `paste`, `column`, `diff`, `comm`, `date`, `wait`, `curl`, `wget`, `timeout`, `command`, `getopts`, -`type`, `which`, `hash`, `declare`, `typeset`, +`type`, `which`, `hash`, `declare`, `typeset`, `kill`, `time` (keyword), `whoami`, `hostname`, `uname`, `id`, `ls`, `rmdir`, `find`, `xargs`, `tee`, `:` (colon), `eval`, `readonly`, `times`, `bash`, `sh`, `od`, `xxd`, `hexdump`, `strings`, @@ -226,7 +226,7 @@ Features that may be added in the future (not intentionally excluded): ### Not Yet Implemented -`chown`, `kill` +None currently tracked. ## Text Processing diff --git a/supply-chain/config.toml b/supply-chain/config.toml index c28f3a51..2a778009 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -170,6 +170,10 @@ criteria = "safe-to-deploy" version = "0.4.43" criteria = "safe-to-deploy" +[[exemptions.chrono]] +version = "0.4.44" +criteria = "safe-to-deploy" + [[exemptions.ciborium]] version = "0.2.2" criteria = "safe-to-run"