diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 13215e7e2..7875d649b 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -232,8 +232,11 @@ impl Builtin for Cp { // Reject unknown options like GNU cp. `-r`/`-R`/`-a` (and the long // forms) copy directories recursively; the remaining flags are - // accepted and ignored. '--' ends options. + // accepted and ignored. '--' ends options. `-n` (`--no-clobber`, + // `--update=none`) leaves existing destinations alone, silently and + // with status 0 like GNU cp; it wins over `-f`. let mut recursive = false; + let mut no_clobber = false; let mut files: Vec<&String> = Vec::new(); let mut opts_done = false; for arg in ctx.args { @@ -246,6 +249,9 @@ impl Builtin for Cp { if matches!(name, "archive" | "recursive") { recursive = true; } + if name == "no-clobber" || long == "update=none" { + no_clobber = true; + } match name { "archive" | "attributes-only" @@ -278,8 +284,9 @@ impl Builtin for Cp { for c in arg[1..].chars() { match c { 'a' | 'r' | 'R' => recursive = true, - 'd' | 'f' | 'H' | 'i' | 'l' | 'L' | 'n' | 'P' | 'p' | 's' | 't' | 'T' - | 'u' | 'v' | 'x' | 'Z' => {} + 'n' => no_clobber = true, + 'd' | 'f' | 'H' | 'i' | 'l' | 'L' | 'P' | 'p' | 's' | 't' | 'T' | 'u' + | 'v' | 'x' | 'Z' => {} _ => return Ok(super::invalid_option("cp", &format!("-{c}"), 1)), } } @@ -360,12 +367,15 @@ impl Builtin for Cp { )); continue; } - if let Err(msg) = copy_tree(&ctx, &src_path, &final_dest).await? { + if let Err(msg) = copy_tree(&ctx, &src_path, &final_dest, no_clobber).await? { stderr.push_str(&format!("cp: {msg}\n")); } continue; } + if no_clobber && ctx.fs.lstat(&final_dest).await.is_ok() { + continue; + } if let Err(e) = ctx.fs.copy(&src_path, &final_dest).await { stderr.push_str(&format!("cp: cannot copy '{}': {}\n", source, e)); } @@ -388,7 +398,12 @@ type CopyTreeFuture<'a> = std::pin::Pin< /// Recursively copy `src` to `dst` (GNU `cp -R` without `-L`: symlinks are /// recreated, not followed). The outer error is cancellation/budget; the /// inner one is a user-facing message for the first failed entry. -fn copy_tree<'a>(ctx: &'a Context<'_>, src: &'a Path, dst: &'a Path) -> CopyTreeFuture<'a> { +fn copy_tree<'a>( + ctx: &'a Context<'_>, + src: &'a Path, + dst: &'a Path, + no_clobber: bool, +) -> CopyTreeFuture<'a> { Box::pin(async move { ctx.consume_budget_work(1)?; let meta = match ctx.fs.lstat(src).await { @@ -422,12 +437,15 @@ fn copy_tree<'a>(ctx: &'a Context<'_>, src: &'a Path, dst: &'a Path) -> CopyTree for entry in entries { let from = vfs_join(src, &entry.name); let to = vfs_join(dst, &entry.name); - if let Err(msg) = copy_tree(ctx, &from, &to).await? { + if let Err(msg) = copy_tree(ctx, &from, &to, no_clobber).await? { return Ok(Err(msg)); } } return Ok(Ok(())); } + if no_clobber && ctx.fs.lstat(dst).await.is_ok() { + return Ok(Ok(())); + } if meta.file_type.is_symlink() { let target = match ctx.fs.read_link(src).await { Ok(t) => t, diff --git a/crates/bashkit/src/builtins/generated/format_support.rs b/crates/bashkit/src/builtins/generated/format_support.rs index 5c40e9670..f25063ba8 100644 --- a/crates/bashkit/src/builtins/generated/format_support.rs +++ b/crates/bashkit/src/builtins/generated/format_support.rs @@ -3,6 +3,8 @@ //! Keep uucore runtime hooks local and side-effect free: bashkit builtins //! return structured `ExecResult`s, so generated formatting code must not //! write diagnostics directly to host stderr or depend on uucore exit state. +//! `show_error!` messages are collected per call ([`collect_diagnostics`]) +//! so `printf` can report bad numeric arguments the way bash does. use std::ffi::{OsStr, OsString}; @@ -84,9 +86,42 @@ pub fn locale_aware_escape_name(input: &OsStr, style: QuotingStyle) -> OsString } } +/// Most diagnostics one [`collect_diagnostics`] call keeps (one per bad +/// argument; the cap bounds memory for huge argument lists). +const MAX_COLLECTED_DIAGNOSTICS: usize = 64; + +std::thread_local! { + /// `Some` while [`collect_diagnostics`] runs: uucore's `show_error!` + /// messages land here instead of host stderr. + static DIAGNOSTICS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Run `f` (synchronous, so the thread-local cannot leak across tasks) and +/// return the `show_error!` messages it produced, in order. +pub fn collect_diagnostics(f: impl FnOnce() -> R) -> (R, Vec) { + DIAGNOSTICS.with(|d| *d.borrow_mut() = Some(Vec::new())); + let result = f(); + let diags = DIAGNOSTICS + .with(|d| d.borrow_mut().take()) + .unwrap_or_default(); + (result, diags) +} + +/// Record one `show_error!` message when a collector is active. +pub fn record_diagnostic(msg: std::fmt::Arguments<'_>) { + DIAGNOSTICS.with(|d| { + if let Some(diags) = d.borrow_mut().as_mut() + && diags.len() < MAX_COLLECTED_DIAGNOSTICS + { + diags.push(msg.to_string()); + } + }); +} + macro_rules! show_error { ($($arg:tt)*) => {{ - let _ = format_args!($($arg)*); + $crate::builtins::generated::format_support::record_diagnostic(format_args!($($arg)*)); }}; } diff --git a/crates/bashkit/src/builtins/printf.rs b/crates/bashkit/src/builtins/printf.rs index 9059d96b8..c97d0f22e 100644 --- a/crates/bashkit/src/builtins/printf.rs +++ b/crates/bashkit/src/builtins/printf.rs @@ -5,18 +5,23 @@ //! outside generated code so regenerating `format/` cannot erase the DoS guard. use std::borrow::Cow; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::ops::ControlFlow; use async_trait::async_trait; +use os_display::Quotable; + use super::generated::format::{ FormatArgument, FormatArguments, FormatError, FormatItem, parse_spec_and_escape, }; +use super::generated::format_support::{ + QuotingStyle, collect_diagnostics, locale_aware_escape_name, +}; use super::limits::PRINTF_MAX_DIAG_CHARS as MAX_PRINTF_DIAG_CHARS; -use super::{Builtin, Context, Date, MAX_FORMAT_WIDTH}; +use super::{Builtin, BuiltinSideEffect, Context, Date, MAX_FORMAT_WIDTH}; use crate::error::Result; -use crate::interpreter::{ExecResult, is_internal_variable}; +use crate::interpreter::{ExecResult, is_internal_variable, is_valid_var_name}; /// printf builtin - formatted string output /// @@ -43,29 +48,47 @@ impl Builtin for Printf { ) { return Ok(r); } - if ctx.args.is_empty() { - return Ok(ExecResult::ok(String::new())); - } - - let mut args_iter = ctx.args.iter(); + // Options as bash's getopt("v:") reads them: `-v NAME`, `-vNAME`, + // repeated `-v` (last wins), `--` ends them. A lone `-` is the format. + let mut args_iter = ctx.args.iter().peekable(); let mut var_name: Option = None; - - let format = loop { - match args_iter.next() { - Some(arg) if arg == "-v" => { - if let Some(vname) = args_iter.next() { - var_name = Some(vname.clone()); - } + while let Some(arg) = args_iter.peek() { + if arg.as_str() == "--" { + args_iter.next(); + break; + } + let Some(rest) = arg.strip_prefix('-').filter(|r| !r.is_empty()) else { + break; + }; + if let Some(name) = rest.strip_prefix('v') { + args_iter.next(); + if !name.is_empty() { + var_name = Some(name.to_string()); + } else if let Some(name) = args_iter.next() { + var_name = Some(name.clone()); + } else { + return Ok(usage_error( + "bash: printf: -v: option requires an argument\n", + )); } - // `--` ends options; the next word is the format. - Some(arg) if arg == "--" => match args_iter.next() { - Some(f) => break f.clone(), - None => return Ok(ExecResult::ok(String::new())), - }, - Some(arg) => break arg.clone(), - None => return Ok(ExecResult::ok(String::new())), + } else { + let opt: String = rest.chars().take(1).collect(); + return Ok(usage_error(&format!( + "bash: printf: -{opt}: invalid option\n" + ))); } + } + let Some(format) = args_iter.next().cloned() else { + return Ok(usage_error("")); }; + if let Some(name) = &var_name + && !valid_var_target(name) + { + return Ok(ExecResult::err( + format!("bash: printf: `{name}': not a valid identifier\n"), + 2, + )); + } let args: Vec = args_iter.cloned().collect(); let format = escape_format_backslash_c(&format).into_owned(); @@ -79,26 +102,90 @@ impl Builtin for Printf { Ok(v) => v, Err(err) => return Ok(ExecResult::err(format!("{err}\n"), 1)), }; - let output = match render_printf_bytes(&format, &args) { + let (rendered, diags) = collect_diagnostics(|| render_printf_bytes(&format, &args)); + let output = match rendered { Ok(output) => output, Err(err) => return Ok(ExecResult::err(err, 1)), }; + let (number_stderr, number_status) = numeric_argument_diagnostics(&diags, &args); if let Some(name) = var_name { // THREAT[TM-INJ-009]: Block internal variable prefix injection via printf -v if is_internal_variable(&name) { return Ok(ExecResult::ok(String::new())); } + // Assigned by the interpreter like `read`: locals, namerefs, + // `a[i]` targets, `declare -i` arithmetic and readonly all apply. // Variables are text; a non-UTF-8 byte is decoded lossily here. - ctx.variables - .insert(name, String::from_utf8_lossy(&output).into_owned()); - Ok(ExecResult::ok(String::new())) + let mut result = ExecResult::with_code(String::new(), number_status); + result.stderr = number_stderr.into(); + result.side_effects.push(BuiltinSideEffect::SetVariable { + name, + value: String::from_utf8_lossy(&output).into_owned(), + }); + Ok(result) } else { - Ok(ExecResult::ok_bytes(output)) + let mut result = ExecResult::ok_bytes(output); + result.exit_code = number_status; + result.stderr = number_stderr.into(); + Ok(result) } } } +/// bash's report for numeric conversions uucore flagged: `abc`/`12abc` +/// are `invalid number` (status 1, the parsed prefix is still printed), +/// out of range is only a warning (status 0). An empty argument is 0 and a +/// leading quote is a character code (`"'"` alone is 0), both without +/// complaint in bash. +fn numeric_argument_diagnostics(diags: &[String], args: &[String]) -> (String, i32) { + let mut stderr = String::new(); + let mut status = 0; + for diag in diags { + // uucore writes `: `; recover the raw argument by + // quoting each candidate the same way. + let Some(arg) = args.iter().find(|a| { + let quoted = + locale_aware_escape_name(OsStr::new(a.as_str()), QuotingStyle::C_NO_QUOTES) + .quote() + .to_string(); + diag.strip_prefix(quoted.as_str()) + .is_some_and(|rest| rest.starts_with(": ")) + }) else { + continue; + }; + let shown = truncate_text(arg, MAX_PRINTF_DIAG_CHARS / 2); + if diag.ends_with("Numerical result out of range") { + stderr.push_str(&format!( + "bash: printf: warning: {shown}: Numerical result out of range\n" + )); + } else if !arg.is_empty() && !arg.starts_with(['\'', '"']) { + stderr.push_str(&format!("bash: printf: {shown}: invalid number\n")); + status = 1; + } + if stderr.len() > MAX_PRINTF_DIAG_CHARS { + break; + } + } + (stderr, status) +} + +/// bash's usage failure: optional diagnostic, then the usage line, status 2. +fn usage_error(diag: &str) -> ExecResult { + ExecResult::err( + format!("{diag}printf: usage: printf [-v var] format [arguments]\n"), + 2, + ) +} + +/// A `-v` target: an identifier, or `name[subscript]`. +fn valid_var_target(name: &str) -> bool { + match name.find('[') { + Some(b) => is_valid_var_name(&name[..b]) && name.ends_with(']') && name.len() > b + 2, + None => is_valid_var_name(name), + } +} + #[cfg(test)] fn render_printf(format: &str, args: &[String]) -> std::result::Result { render_printf_bytes(format, args).map(|b| String::from_utf8_lossy(&b).into_owned()) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index c445441c2..42f16659b 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -752,105 +752,25 @@ impl Interpreter { } } + // "x$@y", x"${a[@]}"y, "${1+"$@"}": a quoted `@` expansion yields + // one field per element even inside a larger word; the text + // before it joins the first element and the text after the last. + if let Some(fields) = self.expand_at_word_fields(word).await? { + return Ok(fields); + } + let has_mixed_part_quotes = word.part_quoted.iter().any(|q| *q) && word.part_quoted.iter().any(|q| !*q); if has_mixed_part_quotes { let mut segments = Vec::new(); - let mut sentinel_haystack = self - .scoped - .variables - .get("IFS") - .cloned() - .unwrap_or_default(); for (idx, part) in word.parts.iter().enumerate() { let part_is_quoted = word.part_quoted.get(idx).copied().unwrap_or(word.quoted); - let part_has_expansion = Self::is_field_split_expansion(part); - let value = if idx > 0 - && let WordPart::Literal(s) = part - { - s.clone() - } else { - let single = Word { - parts: vec![part.clone()], - quoted: part_is_quoted, - has_unquoted_glob: false, - part_quoted: vec![part_is_quoted], - raw: None, - }; - self.expand_word(&single).await? - }; - - if part_has_expansion && !part_is_quoted { - sentinel_haystack.push_str(&value); - segments.push((value, false, false)); - } else { - let value = - if part_is_quoted && part_has_expansion && word.has_unquoted_glob { - Self::quote_expansion_for_quoted_glob(&value) - } else { - value - }; - let preserves_empty_field = part_is_quoted && value.is_empty(); - sentinel_haystack.push_str(&value); - segments.push((value, true, preserves_empty_field)); - } - } - - // Pick a sentinel char absent from the data so empty quoted - // fields survive splitting and can be stripped afterward. If no - // candidate is free (astronomically unlikely), skip the sentinel - // rather than fall back to NUL, which is a valid data byte. - let empty_field_sentinel = segments - .iter() - .any(|(_, _, preserves_empty_field)| *preserves_empty_field) - .then(|| { - OPERAND_QUOTE_MARK_CANDIDATES - .iter() - .copied() - .find(|candidate| !sentinel_haystack.contains(*candidate)) - }) - .flatten(); - - // Protected-span markers, likewise chosen absent from the - // data so a value holding any byte (`\x01` included) stays data. - let mut free_marks = OPERAND_QUOTE_MARK_CANDIDATES.iter().copied().filter(|c| { - Some(*c) != empty_field_sentinel && !sentinel_haystack.contains(*c) - }); - let marks = free_marks.next().zip(free_marks.next()); - - let mut expanded_for_split = String::new(); - for (value, is_protected, preserves_empty_field) in segments { - if is_protected { - // Field splitting scans the whole expanded word. Mark literal and - // quoted segments as protected so unquoted expansion delimiters can - // still create boundaries between adjacent protected segments. - if let Some((start, _)) = marks { - expanded_for_split.push(start); - } - if preserves_empty_field - && let Some(empty_field_sentinel) = empty_field_sentinel - { - expanded_for_split.push(empty_field_sentinel); - } - expanded_for_split.push_str(&value); - if let Some((_, end)) = marks { - expanded_for_split.push(end); - } - } else { - expanded_for_split.push_str(&value); - } - } - let mut fields = self.ifs_split_marked( - &expanded_for_split, - self.limits.max_word_split_fields, - marks, - )?; - if let Some(empty_field_sentinel) = empty_field_sentinel { - for field in &mut fields { - field.retain(|ch| ch != empty_field_sentinel); - } + segments.push( + self.mixed_word_segment(word, idx, part, part_is_quoted) + .await?, + ); } - return Ok(fields); + return self.split_marked_segments(segments); } // For other words, expand to a single field then apply IFS word splitting @@ -877,6 +797,304 @@ impl Interpreter { }) } + /// One part of a mixed-quote word as `(value, protected, keeps_empty)`: + /// quoted parts and literals are protected from IFS splitting; an empty + /// quoted part still makes a field. + async fn mixed_word_segment( + &mut self, + word: &Word, + idx: usize, + part: &WordPart, + part_is_quoted: bool, + ) -> Result<(String, bool, bool)> { + let part_has_expansion = Self::is_field_split_expansion(part); + let value = if idx > 0 + && let WordPart::Literal(s) = part + { + s.clone() + } else { + let single = Word { + parts: vec![part.clone()], + quoted: part_is_quoted, + has_unquoted_glob: false, + part_quoted: vec![part_is_quoted], + raw: None, + }; + self.expand_word(&single).await? + }; + if part_has_expansion && !part_is_quoted { + return Ok((value, false, false)); + } + let value = if part_is_quoted && part_has_expansion && word.has_unquoted_glob { + Self::quote_expansion_for_quoted_glob(&value) + } else { + value + }; + let preserves_empty_field = part_is_quoted && value.is_empty(); + Ok((value, true, preserves_empty_field)) + } + + /// Join `(value, protected, keeps_empty)` segments into one string and + /// IFS-split it: only unprotected segments split, and protected + /// boundaries still separate fields created by unquoted expansions. + fn split_marked_segments(&self, segments: Vec<(String, bool, bool)>) -> Result> { + let mut sentinel_haystack = self + .scoped + .variables + .get("IFS") + .cloned() + .unwrap_or_default(); + for (value, _, _) in &segments { + sentinel_haystack.push_str(value); + } + // Pick a sentinel char absent from the data so empty quoted + // fields survive splitting and can be stripped afterward. If no + // candidate is free (astronomically unlikely), skip the sentinel + // rather than fall back to NUL, which is a valid data byte. + let empty_field_sentinel = segments + .iter() + .any(|(_, _, preserves_empty_field)| *preserves_empty_field) + .then(|| { + OPERAND_QUOTE_MARK_CANDIDATES + .iter() + .copied() + .find(|candidate| !sentinel_haystack.contains(*candidate)) + }) + .flatten(); + + // Protected-span markers, likewise chosen absent from the + // data so a value holding any byte (`\x01` included) stays data. + let mut free_marks = OPERAND_QUOTE_MARK_CANDIDATES + .iter() + .copied() + .filter(|c| Some(*c) != empty_field_sentinel && !sentinel_haystack.contains(*c)); + let marks = free_marks.next().zip(free_marks.next()); + + let mut expanded_for_split = String::new(); + for (value, is_protected, preserves_empty_field) in segments { + if is_protected { + // Field splitting scans the whole expanded word. Mark literal and + // quoted segments as protected so unquoted expansion delimiters can + // still create boundaries between adjacent protected segments. + if let Some((start, _)) = marks { + expanded_for_split.push(start); + } + if preserves_empty_field && let Some(empty_field_sentinel) = empty_field_sentinel { + expanded_for_split.push(empty_field_sentinel); + } + expanded_for_split.push_str(&value); + if let Some((_, end)) = marks { + expanded_for_split.push(end); + } + } else { + expanded_for_split.push_str(&value); + } + } + let mut fields = self.ifs_split_marked( + &expanded_for_split, + self.limits.max_word_split_fields, + marks, + )?; + if let Some(empty_field_sentinel) = empty_field_sentinel { + for field in &mut fields { + field.retain(|ch| ch != empty_field_sentinel); + } + } + Ok(fields) + } + + /// Fields of a word holding a quoted `@` expansion (`"x$@y"`, + /// `"${a[@]}"z`, `"${1+"$@"}"`), or `None` when it has none and the + /// ordinary single-field path applies. + async fn expand_at_word_fields(&mut self, word: &Word) -> Result>> { + // Per-part flags are only recorded for mixed words; otherwise the + // whole word's quoting applies to every part. + let mixed = word.part_quoted.iter().any(|q| *q); + let part_quoted = |idx: usize| { + if mixed { + word.part_quoted.get(idx).copied().unwrap_or(false) + } else { + word.quoted + } + }; + let quoted_at = |idx: usize, part: &WordPart| { + (part_quoted(idx) && Self::is_at_part(part)) || Self::at_operand_part(part).is_some() + }; + if !word + .parts + .iter() + .enumerate() + .any(|(idx, part)| quoted_at(idx, part)) + { + return Ok(None); + } + // Each field under construction is a list of segments. + let mut fields: Vec> = vec![Vec::new()]; + for (idx, part) in word.parts.iter().enumerate() { + let q = part_quoted(idx); + if let Some(elems) = self.part_at_fields(part, q).await? { + for (i, elem) in elems.into_iter().enumerate() { + if i > 0 { + fields.push(Vec::new()); + } + let elem = if word.has_unquoted_glob { + Self::quote_expansion_for_quoted_glob(&elem) + } else { + elem + }; + if let Some(field) = fields.last_mut() { + field.push((elem, true, true)); + } + } + continue; + } + let segment = self.mixed_word_segment(word, idx, part, q).await?; + if let Some(field) = fields.last_mut() { + field.push(segment); + } + } + let mut out = Vec::new(); + for segments in fields { + if segments.is_empty() { + continue; + } + out.extend(self.split_marked_segments(segments)?); + if out.len() > self.limits.max_word_split_fields { + out.truncate(self.limits.max_word_split_fields); + break; + } + } + Ok(Some(out)) + } + + /// `$@`, `${a[@]}`, and per-element operators on them (`${@^}`, + /// `${a[@]/x/y}`): one field per element when quoted. + fn is_at_part(part: &WordPart) -> bool { + match part { + WordPart::Variable(name) => name == "@", + WordPart::ArrayAccess { index, .. } => index == "@", + WordPart::ParameterExpansion { name, operator, .. } => { + Self::is_elementwise_op(operator) && (name == "@" || name.ends_with("[@]")) + } + WordPart::Transformation { name, .. } => name == "@" || name.ends_with("[@]"), + WordPart::Substring { name, .. } => name == "@", + _ => false, + } + } + + /// `${x-word}` / `${x+word}` whose operand holds an `@` expansion + /// (`${1+"$@"}`): `(name, operand, colon, use_when_set)`. + fn at_operand_part(part: &WordPart) -> Option<(&str, &str, bool, bool)> { + let WordPart::ParameterExpansion { + name, + operator, + operand, + colon_variant, + } = part + else { + return None; + }; + let use_when_set = match operator { + ParameterOp::UseDefault => false, + ParameterOp::UseReplacement => true, + _ => return None, + }; + (operand.contains("$@") || operand.contains("${@") || operand.contains("[@]}")).then_some(( + name.as_str(), + operand.as_str(), + *colon_variant, + use_when_set, + )) + } + + /// Elements of a part that expands to one field per element here, or + /// `None` when it expands to a single string. + async fn part_at_fields( + &mut self, + part: &WordPart, + quoted: bool, + ) -> Result>> { + if let Some((name, operand, colon, use_when_set)) = Self::at_operand_part(part) { + let (is_set, value) = self.resolve_param_expansion_name(name); + let unset_or_null = !is_set || (colon && value.is_empty()); + if use_when_set == unset_or_null { + return Ok(None); + } + let inner = self.operand_word(operand, quoted); + let fields = self.expand_word_to_fields(&inner).await?; + // bash: inside double quotes an operand whose `"$@"` is empty + // still leaves one empty field (`"${x-"$@"}"`). + if quoted && fields.is_empty() { + return Ok(Some(vec![String::new()])); + } + return Ok(Some(fields)); + } + if !quoted || !Self::is_at_part(part) { + return Ok(None); + } + Ok(Some(match part { + WordPart::Variable(_) => self + .call_stack + .last() + .map(|f| f.positional.clone()) + .unwrap_or_default(), + WordPart::ArrayAccess { name, .. } => self + .array_view(name) + .map(|items| items.into_iter().map(|(_, v)| v).collect()) + .unwrap_or_default(), + _ => match self.elementwise_fields(part) { + Some((elems, _)) => elems, + None => return Ok(None), + }, + })) + } + + /// A `${x-word}` operand as a word of its own: its double-quoted spans + /// (and all of it, inside an outer `"..."`) are quoted parts. + fn operand_word(&self, operand: &str, outer_quoted: bool) -> Word { + let (parsed, quote_mark, force_quoted) = Self::parse_marked_operand( + operand, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + let mut parts = Vec::new(); + let mut part_quoted = Vec::new(); + let mut in_marked = false; + for part in parsed.parts { + match part { + WordPart::Literal(s) => { + let mut lit = String::new(); + for ch in s.chars() { + if Some(ch) == quote_mark { + if !lit.is_empty() { + parts.push(WordPart::Literal(std::mem::take(&mut lit))); + part_quoted.push(outer_quoted || in_marked || force_quoted); + } + in_marked = !in_marked; + } else { + lit.push(ch); + } + } + if !lit.is_empty() { + parts.push(WordPart::Literal(lit)); + part_quoted.push(outer_quoted || in_marked || force_quoted); + } + } + other => { + parts.push(other); + part_quoted.push(outer_quoted || in_marked || force_quoted); + } + } + } + Word { + quoted: !part_quoted.is_empty() && part_quoted.iter().all(|q| *q), + parts, + has_unquoted_glob: false, + part_quoted, + raw: None, + } + } + /// Expansion parts whose unquoted result undergoes IFS field splitting. fn is_field_split_expansion(part: &WordPart) -> bool { matches!( diff --git a/crates/bashkit/src/interpreter/glob.rs b/crates/bashkit/src/interpreter/glob.rs index 01c0a19ab..d7d9641cc 100644 --- a/crates/bashkit/src/interpreter/glob.rs +++ b/crates/bashkit/src/interpreter/glob.rs @@ -994,12 +994,22 @@ impl Interpreter { if entry.name.starts_with('.') && !dotglob && !component_starts_with_dot { continue; } - if dirs_only && !entry.metadata.file_type.is_dir() { + if !self.glob_match_impl(&entry.name, component, opts, 0) { continue; } - if self.glob_match_impl(&entry.name, component, opts, 0) { - matched.push(entry.name); + // A symlink to a directory counts as one (bash's `*/`). + if dirs_only + && !entry.metadata.file_type.is_dir() + && !(entry.metadata.file_type.is_symlink() + && self + .fs + .stat(&vfs_join(dir, &entry.name)) + .await + .is_ok_and(|m| m.file_type.is_dir())) + { + continue; } + matched.push(entry.name); } // Looks redundant next to the final sort, but is not: `read_dir` @@ -1023,6 +1033,16 @@ impl Interpreter { if is_last && !self.fs.exists(&path).await.unwrap_or(false) { continue; } + if is_last + && trailing_slash + && !self + .fs + .stat(&path) + .await + .is_ok_and(|m| m.file_type.is_dir()) + { + continue; + } let output = Self::glob_join_output(out, &literal, is_absolute); next.push((path, output)); } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 4a8f7931a..9fcd3cd8a 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -10421,6 +10421,7 @@ impl Interpreter { self.refresh_shopt_flags(); let effects = std::mem::take(&mut result.side_effects); let mut shift_failed = false; + let mut readonly_failed = false; let mut pending_self_signal = None; for effect in &effects { match effect { @@ -10482,15 +10483,30 @@ impl Interpreter { self.last_exit_code = *code; } builtins::BuiltinSideEffect::SetVariable { name, value } => { + if readonly_failed { + continue; + } // A builtin (read, getopts, ...) assigning through a // circular nameref fails that command, as in bash, // instead of abandoning the line like an assignment. - if self.resolve_nameref_strict(name).is_err() { + let Ok(target) = self.resolve_nameref_strict(name) else { result.stderr.push_str( &self.diag(format!("warning: {name}: circular name reference\n")), ); result.exit_code = 1; continue; + }; + // THREAT[TM-INJ-019]: a readonly target fails the builtin + // visibly (status 1) and, as in bash's `read`, the later + // names are left alone. + let base = target.split('[').next().unwrap_or(&target); + if self.is_var_readonly(base) { + result + .stderr + .push_str(&self.diag(format!("{base}: readonly variable\n"))); + result.exit_code = 1; + readonly_failed = true; + continue; } self.set_variable(name.clone(), value.clone()); } diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 32ed0c718..0fdf85fc9 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -1584,8 +1584,16 @@ impl<'a> Lexer<'a> { fn read_dollar_single_quoted_content(&mut self) -> (String, bool) { let mut out = String::new(); + // `\xHH` / `\NNN` give bytes: a run that is valid UTF-8 is that + // text (`$'\xc3\xa9'` is `é`, as bash prints it); other bytes keep + // their one-char-per-byte decoding (L-STREAM-001). + let mut bytes: Vec = Vec::new(); let mut closed = false; while let Some(ch) = self.peek_char() { + let continues_bytes = ch == '\\' && self.next_is_byte_escape(); + if !bytes.is_empty() && !continues_bytes { + Self::flush_escape_bytes(&mut out, &mut bytes); + } if ch == '\'' { self.advance(); closed = true; @@ -1620,8 +1628,11 @@ impl<'a> Lexer<'a> { } } } - if let Ok(val) = u8::from_str_radix(&hex, 16) { - out.push(val as char); + if hex.is_empty() { + // bash keeps a `\x` with no hex digits as is. + out.push_str("\\x"); + } else if let Ok(val) = u8::from_str_radix(&hex, 16) { + bytes.push(val); } } 'u' => { @@ -1674,7 +1685,7 @@ impl<'a> Lexer<'a> { } } if let Ok(val) = u8::from_str_radix(&oct, 8) { - out.push(val as char); + bytes.push(val); } } _ => { @@ -1690,9 +1701,29 @@ impl<'a> Lexer<'a> { out.push(ch); self.advance(); } + Self::flush_escape_bytes(&mut out, &mut bytes); (out, closed) } + /// After a `\` at the current position: is it `\xH` or `\N` (octal)? + fn next_is_byte_escape(&self) -> bool { + let mut it = self.reinject_buf.iter().copied().chain(self.chars.clone()); + it.next(); + match it.next() { + Some('x') => it.next().is_some_and(|h| h.is_ascii_hexdigit()), + Some(c) => ('0'..='7').contains(&c), + None => false, + } + } + + fn flush_escape_bytes(out: &mut String, bytes: &mut Vec) { + match std::str::from_utf8(bytes) { + Ok(text) => out.push_str(text), + Err(_) => out.extend(bytes.iter().map(|&b| b as char)), + } + bytes.clear(); + } + /// Append a literal segment while protecting sentinel-sensitive bytes from parse_word expansion. fn push_literal_with_escaped_dollar(dst: &mut String, segment: &str) { for ch in segment.chars() { diff --git a/crates/bashkit/tests/integration/glob_intermediate_component_tests.rs b/crates/bashkit/tests/integration/glob_intermediate_component_tests.rs index 28022a509..e11d5f988 100644 --- a/crates/bashkit/tests/integration/glob_intermediate_component_tests.rs +++ b/crates/bashkit/tests/integration/glob_intermediate_component_tests.rs @@ -165,6 +165,7 @@ async fn quoted_intermediate_glob_is_literal() { // and already affects the trailing component (`echo /skills/\*`), so it is // tracked separately rather than pinned to the wrong behaviour here. +/// bash keeps the trailing `/` on each match and matches directories only. #[tokio::test] async fn trailing_slash_glob_still_lists_directories() { let mut bash = skills_shell().await; diff --git a/crates/bashkit/tests/spec_cases/bash/ansi-c-control-bytes.test.sh b/crates/bashkit/tests/spec_cases/bash/ansi-c-control-bytes.test.sh index a44707bab..1376abc68 100644 --- a/crates/bashkit/tests/spec_cases/bash/ansi-c-control-bytes.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/ansi-c-control-bytes.test.sh @@ -32,3 +32,11 @@ x='a'$'\x1f''b'; echo "${#x}" ### expect 3 ### end + +### ansi_c_hex_bytes_decode_as_utf8 +# $'\xc3\xa9' is the UTF-8 text é; a lone \x stays literal +printf '%s|%s\n' $'\xc3\xa9' $'\303\251'; echo $'a\x' +### expect +é|é +a\x +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh index 10c20ce92..a343d26e8 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -30,7 +30,6 @@ declare -i x; let x=4; ((x=x+1)); echo $x; read x <<< 2+2; echo $x ### bashbox_declare_read_and_printf_name_themselves_too # read and printf name themselves too -### skip: TODO printf -v into an integer variable does not evaluate arithmetic (printf builtin gap) declare -i x; (read x <<< 1/0); (printf -v x 1/0); echo $? ### expect 1 @@ -301,7 +300,7 @@ declare -ar b=([0]="3") ### bashbox_declare_declare_p_quotes_control_characters_and_bytes_that_aren_t_ut # declare -p quotes control characters and bytes that aren't UTF-8 like bash -### skip: TODO $'\x..' decodes each byte as a char (\x01 dropped, \xc3\xa9 not UTF-8) and non-UTF-8 bytes are not representable (L-STREAM-001) +### skip: L-STREAM-001: a word cannot hold the non-UTF-8 byte \xff, so declare -p prints "ÿ" instead of $'\377' x=$'a\x01b\e\'"\\c$`d\a\b\f\v\r\t\n\x7f'; declare -p x; y=$'\xc3\xa9\n'; z=$'\xff'; declare -p y z; a=($'x\ny' z); declare -p a ### expect declare -- x=$'a\001b\E\'"\\c$`d\a\b\f\v\r\t\n\177' @@ -335,7 +334,6 @@ x=1 ### bashbox_declare_printf_v_assigns_the_output_newlines_and_all # printf -v assigns the output, newlines and all -### skip: TODO printf -v gaps (-vNAME, element targets) belong to the printf builtin printf -v x '%s-%s\n' a b; echo "[$x]"; printf -vy %s a; echo $y; printf -v 'a[1+1]' %s z; declare -p a ### expect [a-b @@ -346,7 +344,6 @@ declare -a a=([2]="z") ### bashbox_declare_printf_v_takes_and_a_local # printf -v takes -- and a local -### skip: TODO printf -v gaps (-- after -v) belong to the printf builtin printf -v x -- %s a; echo $x; f() { local y; printf -v y %s in; echo $y; }; f; echo ${y-unset} ### expect a @@ -356,7 +353,6 @@ unset ### bashbox_declare_printf_v_errors # printf -v errors -### skip: TODO printf -v gaps (usage errors, invalid names) belong to the printf builtin printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; printf -v 'a[' %s x; echo $? ### expect 2 @@ -367,7 +363,6 @@ printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; ### bashbox_declare_printf_v_into_a_readonly_variable_fails # printf -v into a readonly variable fails -### skip: TODO printf -v gaps (readonly target) belong to the printf builtin readonly r; printf -v r %s a; echo $? ### expect 1 @@ -375,7 +370,6 @@ readonly r; printf -v r %s a; echo $? ### bashbox_declare_printf_without_v_is_the_printf_command # printf without -v is the printf command -### skip: TODO printf -- handling belongs to the printf builtin printf -- -v; echo ### expect -v diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh index b892ee413..a37dbc5aa 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh @@ -27,7 +27,6 @@ printf "1\n2\n" | { { read a; }; read b; echo $a$b; } ### bashbox_just_bash_compat_read_returns_1_on_unterminated_last_line # read returns 1 on unterminated last line -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf "x" | { read v; echo "$?:$v"; } ### expect 1:x @@ -128,7 +127,6 @@ set --; printf "[%s]" "$@" x; echo ### bashbox_just_bash_compat_joins_with_surrounding_text # $@ joins with surrounding text -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -- a b; printf "[%s]" "x$@y"; echo ### expect [xa][by] @@ -143,7 +141,6 @@ a=("x y" z); printf "[%s]" "${a[@]}" "${a[*]}"; echo ### bashbox_just_bash_compat_1_idiom # ${1+"$@"} idiom -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -- "a b" c; for w in "${1+"$@"}"; do echo "[$w]"; done ### expect [a b] @@ -201,14 +198,13 @@ unset u; e=; echo "[${u-d}][${u+a}][${e-d}][${e+a}][${e:-d}]" ### bashbox_just_bash_compat_cannot_assign_to_positional # cannot assign to positional -### skip: TODO bashbox corpus gap, bashkit output differs from real bash : ${1:=x}; echo after ### expect ### end ### bashbox_just_bash_compat_error_if_unset_stops_the_script # error-if-unset stops the script -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### bash_diff: only `bash -c` exits 127 on a fatal expansion error (run_one_command FORCE_EOF); a script file exits 1 like bashkit echo a; echo ${zz:?boom}; echo after ### expect a @@ -216,7 +212,6 @@ a ### bashbox_just_bash_compat_glob_matches_directories_only_with # glob matches directories only with */ -### skip: TODO bashbox corpus gap, bashkit output differs from real bash mkdir d1 d2; touch f1; for d in */; do echo $d; done ### expect d1/ @@ -325,7 +320,6 @@ printf "1\n2\n3\n" | head -2; printf "1\n2\n3\n" | tail -2 ### bashbox_just_bash_compat_tr_octal_escapes # tr octal escapes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a | tr a "\101" ### expect A @@ -333,7 +327,6 @@ A ### bashbox_just_bash_compat_cp_f_and_n # cp -f and -n -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a > f; echo b > g; cp -f f h && cat h; cp -n g f; cat f ### expect a @@ -381,7 +374,6 @@ f(){ local -A m=([a]=1 [b]="2 3"); echo "${m[a]}${m[b]}"; }; f ### bashbox_just_bash_compat_keyed_elements_in_an_indexed_array # keyed elements in an indexed array -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(x [5]=y z); echo ${!a[@]} ${a[6]} ### expect 0 5 6 z @@ -396,7 +388,6 @@ v w ### bashbox_just_bash_compat_readonly_array_declaration # readonly array declaration -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly -a r=(1 2); echo ${r[1]} ### expect 2 diff --git a/crates/bashkit/tests/spec_cases/bash/fileops.test.sh b/crates/bashkit/tests/spec_cases/bash/fileops.test.sh index b6aa384a9..60333916c 100644 --- a/crates/bashkit/tests/spec_cases/bash/fileops.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/fileops.test.sh @@ -319,3 +319,13 @@ mktemp -q -p /nonexist fooXXX; echo "exit=$?" exit=1 exit=1 ### end + +### cp_no_clobber_skips_existing +# cp -n leaves an existing destination alone (status 0); --no-clobber too +echo a > f; echo b > g; cp -n g f; echo $?; cat f; cp --no-clobber g f; cat f; cp -n g new; cat new +### expect +0 +a +a +b +### end diff --git a/crates/bashkit/tests/spec_cases/bash/globs.test.sh b/crates/bashkit/tests/spec_cases/bash/globs.test.sh index 75cdb3a75..a57ee5020 100644 --- a/crates/bashkit/tests/spec_cases/bash/globs.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/globs.test.sh @@ -101,3 +101,12 @@ echo /nope/*/SKILL.md ### expect /nope/*/SKILL.md ### end + +### glob_trailing_slash_directories_only +# */ matches directories (and symlinks to them) and keeps the slash +mkdir -p gd/d1 gd/d2/s; touch gd/f1; ln -s d1 gd/l; cd gd; echo */; echo d*/ f1/; echo */*/ +### expect +d1/ d2/ l/ +d1/ d2/ f1/ +d2/s/ +### end diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index d5ecfaa27..111ef855c 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -289,3 +289,43 @@ printf '%q\n' 'com espaço' 'a€b' com\ espaço a€b ### end + +### printf_v_into_integer_and_element +# printf -v assigns like read: declare -i evaluates, a[i] sets an element +declare -i n; printf -v n '%s' '2+3'; echo $n; printf -v 'arr[1+1]' %s z; declare -p arr +### expect +5 +declare -a arr=([2]="z") +### end + +### printf_usage_errors +# bash's getopt: invalid option, missing -v argument, missing format +printf -x 2>/dev/null; echo $?; printf -v 2>/dev/null; echo $?; printf 2>/dev/null; echo $?; printf - ; echo +### expect +2 +2 +2 +- +### end + +### printf_invalid_number_fails +# bash: a non-numeric %d argument prints its parsed prefix and fails (status 1) +printf '%d\n' abc; echo rc=$?; printf '%d\n' 12abc; echo rc=$?; printf -v v %d 3x; echo "$? $v" +printf '%d\n' 99999999999999999999 2>/dev/null; echo rc=$?; printf '%d|' '' "'x"; echo rc=$? +### expect +0 +rc=1 +12 +rc=1 +1 3 +9223372036854775807 +rc=0 +0|120|rc=0 +### end + +### printf_invalid_number_stderr +# the diagnostic names the argument like bash +printf '%d' abc 2>&1 >/dev/null | sed 's/^.*printf: //' +### expect +abc: invalid number +### end diff --git a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh index 4170ea1e2..923f9dd36 100644 --- a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh @@ -169,3 +169,10 @@ read -r <<< "secret "; printf 'reply=<%s> len=%d\n' "$REPLY" "${#REPLY}" ### expect reply= len=8 ### end + +### read_into_readonly_fails +# a readonly name fails read with status 1; later names are left alone +readonly r=x; read a r b <<< "1 2 3"; echo $? $a $r ${b-unset} +### expect +1 1 x unset +### end diff --git a/crates/bashkit/tests/spec_cases/bash/word-split.test.sh b/crates/bashkit/tests/spec_cases/bash/word-split.test.sh index ec2e5f232..5d0b8c53d 100644 --- a/crates/bashkit/tests/spec_cases/bash/word-split.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/word-split.test.sh @@ -477,3 +477,17 @@ echo "[$4]" [def] [] ### end + +### quoted_at_inside_word_splits_per_element +# "x$@y" and x"${a[@]}"y: prefix joins the first element, suffix the last +set -- "a b" c; a=(1 2); printf '[%s]' "x$@y" x"${a[@]}"y "${a[@]}z"; echo +### expect +[xa b][cy][x1][2y][1][2z] +### end + +### alternate_operand_at_keeps_fields +# ${1+"$@"} expands to the positional parameters, one field each +set -- "a b" c; printf '[%s]' "${1+"$@"}" ${1+"$@"} "${zz-$@}"; echo +### expect +[a b][c][a b][c][a b][c] +### end diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 08be3b4f9..12d5e8a9c 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -318,6 +318,25 @@ a reserved-word wrapper around a complete pipeline, so the interpreter measures the AST directly. This preserves groups, functions, pipeline status, redirects, errexit, cancellation, and the shared request budget. +### Builtin variable assignment + +Builtins that assign shell variables (`read`, `printf -v`, `getopts`) +return a `BuiltinSideEffect::SetVariable` instead of writing +`ctx.variables`, so the interpreter applies the same rules as an +assignment: namerefs, locals (shallow binding), `name[subscript]` targets, +`declare -i` arithmetic (an arithmetic error aborts the line with status 1) +and `-l`/`-u`. A readonly target fails the builtin with +`bash: NAME: readonly variable` and status 1 (TM-INJ-019); the remaining +`SetVariable` effects of that builtin are dropped, matching `read a r b` +leaving `b` alone. `printf` reads options like bash's getopt (`v:`): +`-v NAME`, `-vNAME`, `--`; a missing `-v` argument, missing format, invalid +option or invalid `-v` name exits 2 with the usage line. +A numeric conversion of a non-number (`%d abc`, `%d 12abc`, `%f x`) +prints the parsed prefix, reports `bash: printf: ARG: invalid number` and +exits 1; the vendored uucore formatter's `show_error!` messages are +collected per call (`format_support::collect_diagnostics`, capped) and +mapped to bash's wording. + ### Execution Plans (Sub-Command Delegation) Builtins cannot access the interpreter directly. When a builtin needs to run diff --git a/knowledge/log.md b/knowledge/log.md index c0c7b03ee..b38c93c06 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -104,6 +104,7 @@ * **Fix**: Interpreter-core parity from the BashBox corpus (bash-verified cases unskipped). `break`/`continue` never leave a caller's loop and warn outside one; `return` outside a function fails with 2, `return` with no argument returns `$?`, and `return` ends a sourced file; `${x!}`/`${}` report "bad substitution" and `${1:=x}` "cannot assign in this way", abandoning the line; ERR/DEBUG traps keep `$?`; `trap` canonicalizes specs and lists in bash order with re-readable quoting (as do `alias` and a bare `set`); `getopts` always writes `OPTIND` (initialized to 1); `unset` parses options only before operands and refuses readonly elements; `a[-5]=x` past the start is a bad subscript; `[[ -eq ]]` operands are arithmetic; an invalid `=~` regex returns 2; `set -k`; `read -d ''`; `dirs`/`pushd` abbreviate `$HOME` as `~`; `hash`, `builtin`, `source` error statuses. `<>` and ENOTDIR under a regular file stay open. See [Builtins](foundations/builtins.md#control-flow-and-shell-state-builtins). * **Fix**: Pattern, expansion and quoting parity from the BashBox corpus (24 bash-verified cases unskipped). `[[ == ]]` always matches with extglob and `shopt -s nocasematch` folds case for `case`, `[[ ==/=~ ]]` and `${v/pat/rep}` (not removal); `!(...)` may match an empty prefix, quotes and `\|` inside an extglob group stay literal, an unbalanced group is plain text, and extglob backtracking spends a shared step budget (TM-DOS-031). `${s:o:-n}` counts back from the end, out-of-range offsets select nothing, negative array/positional lengths are `substring expression < 0`, `${#@}` counts parameters. `shopt` reports a bad name and still applies the rest. `globstar` `**` is a per-component match with bash's output spelling, and `d/*/` keeps only directories with the slash. `"$'q'"` and here-documents keep `$'...'` literal; function listings print `$'...'` single-quoted. Values holding `\x01`/`\x02` survive expansion (mixed-word quote markers moved to `part_quoted`). `<(...)`/`>(...)` run in a subshell. Remaining gaps recorded in [Limitations](operations/limitations.md): execution-time aliases, `/dev/fd/proc_sub_` naming and proc-sub word joining. * **Fix**: ERR and RETURN traps follow bash's scoping. ERR fires once, after the failing simple command, multi-command pipeline, `( )`, `[[ ]]` or `(( ))` (not after lists or compound commands, not for a function call that set the trap itself); functions inherit ERR only under `set -E` and RETURN only under `set -T`; subshells, `$( )` and pipeline stages list ERR but do not run it without `set -E`; RETURN runs after a function or sourced file; handler `$LINENO` counts from the failing line. `N>&word` resolves at expansion: `N>&-` closes the fd for the command, `>&file`/`1>&file` mean `&>file`, `2>&file` is an ambiguous redirect, `2>&$fd` dups the expanded fd (it wrote a file named after it). `for ((...))` headers run their `$(...)`. All 27 skipped `bashbox-interpreter-builtins`/`bashbox-err-trap` cases pass; new `trap-scope.test.sh`. See [Builtins](foundations/builtins.md#control-flow-and-shell-state-builtins). +* **Fix**: BashBox `declare`/just-bash-compat corpus gaps (16 of 17 skipped cases now pass). `printf` parses options like bash's getopt (`-vNAME`, repeated `-v`, `--`; missing `-v` argument, missing format, an invalid option or an invalid `-v` name fail with status 2 and the usage line) and assigns `-v` through the interpreter, so `declare -i` targets evaluate (an arithmetic error aborts the line), `a[i]` targets set elements and locals stay local. A readonly target now fails `read`/`printf -v`/`getopts` visibly (status 1, later `read` names untouched; it was a silent success). A quoted `@` expansion inside a larger word yields one field per element (`"x$@y"` is `xa` `by`, as is `x"${a[@]}"y`), and `${1+"$@"}`/`"${x-$@}"` keep the fields. A glob with a trailing `/` matches directories (and symlinks to them) only and keeps the slash (`*/` is `d1/ d2/`). `cp -n`/`--no-clobber`/`--update=none` leave existing destinations alone. `$'\xc3\xa9'` byte runs that are valid UTF-8 decode as text and a bare `$'\x'` stays literal. The `declare -p` control-byte case stays skipped only for `$'\xff'` (non-UTF-8, L-STREAM-001). `printf` reports bad numeric arguments like bash: `printf %d abc` prints `0`, `12abc` prints `12`, each with `bash: printf: ARG: invalid number` and status 1; out of range is a warning with status 0 (uucore's `show_error!` messages are collected per call in `format_support` and mapped). See [Builtins](foundations/builtins.md#builtin-variable-assignment). ## 2026-10-06