diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 641157527..9998842a1 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -300,7 +300,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and | Syntax | Status | Example | |--------|--------|---------| | `$(cmd)` | ✅ | `x=$(pwd)` | -| `` `cmd` `` | ✅ | Backticks (deprecated but supported) | +| `` `cmd` `` | ✅ | Backticks, including after quoted word segments; unquoted output splits and globs | ### Arithmetic diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index e95595047..cd526cfaa 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -12,6 +12,10 @@ //! operand contributes its own quoted/unquoted parts. A lone expansion //! keeps the cheaper whole-string `ifs_split` path. //! +//! Important decision: mixed glob words escape expansion output per part's +//! quote flag; unquoted parts stay active patterns. The substitution byte +//! charge uses the same decision before allocating the escaped output. +//! //! Important decision: tilde expansion is decided per unquoted literal part //! at expansion time (`tilde_expand_word_literal`): a prefix that runs into //! another part (`~"x"`, `~$v`) is not a tilde prefix. Assignment values @@ -290,8 +294,8 @@ impl Interpreter { /// How many bytes `append_expansion_for_word` will append for `value`, /// counted before anything is allocated so the budget is charged first. - fn expansion_appended_len(word: &Word, value: &str) -> usize { - if word.quoted && word.has_unquoted_glob { + fn expansion_appended_len(word: &Word, part_quoted: bool, value: &str) -> usize { + if word.has_unquoted_glob && part_quoted { value .chars() .map(|ch| ch.len_utf8() + usize::from(Self::needs_glob_escape(ch))) @@ -304,9 +308,10 @@ impl Interpreter { fn append_expansion_for_word( result: &mut BudgetedString, word: &Word, + part_quoted: bool, value: &str, ) -> Result<()> { - if word.quoted && word.has_unquoted_glob { + if word.has_unquoted_glob && part_quoted { for ch in value.chars() { if Self::needs_glob_escape(ch) { result.try_push('\\')?; @@ -393,7 +398,15 @@ impl Interpreter { // words own their buffer charge, so temporary output charges end at copy. let mut substitution_leases = Vec::new(); + // Check the metadata once: many unquoted expansions before a quoted + // suffix must not rescan the whole word for every output append. + let has_part_quotes = word.part_quoted.iter().any(|q| *q); for (idx, part) in word.parts.iter().enumerate() { + let part_quoted = if has_part_quotes { + word.part_quoted.get(idx).copied().unwrap_or(false) + } else { + word.quoted + }; match part { WordPart::BadSubstitution(text) => { return Err(crate::error::Error::LineAbort( @@ -416,7 +429,7 @@ impl Interpreter { WordPart::Literal(s) => { // Tilde expansion (`~`, `~/x`, `x=~:~`); quoted literals // keep their tilde. - match (!Self::part_is_quoted(word, idx) && s.contains('~')) + match (!part_quoted && s.contains('~')) .then(|| self.tilde_expand_word_literal(word, idx, s)) .flatten() { @@ -428,7 +441,7 @@ impl Interpreter { if self.is_nounset() && !self.is_variable_set(name) { self.nounset_error = Some(self.unbound_variable_diag(name)); } - if name == "*" && word.quoted { + if name == "*" && part_quoted { let positional = self .call_stack .last() @@ -442,11 +455,17 @@ impl Interpreter { .unwrap_or_default(), None => " ".to_string(), }; - Self::append_expansion_for_word(&mut result, word, &positional.join(&sep))?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &positional.join(&sep), + )?; } else { Self::append_expansion_for_word( &mut result, word, + part_quoted, &self.expand_variable_cow(name), )?; } @@ -464,10 +483,10 @@ impl Interpreter { let output_bytes = if leased { trimmed.capacity() } else { - Self::expansion_appended_len(word, &trimmed) + Self::expansion_appended_len(word, part_quoted, &trimmed) }; let output_lease = self.execution_budget.lease_bytes(output_bytes)?; - Self::append_expansion_for_word(&mut result, word, &trimmed)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &trimmed)?; if !leased { substitution_leases.push(output_lease); } @@ -482,7 +501,12 @@ impl Interpreter { let value = self .try_evaluate_arithmetic_with_assign(&expanded_expr) .map_err(|msg| crate::error::Error::LineAbort(self.arith_diag("", &msg)))?; - Self::append_expansion_for_word(&mut result, word, &value.to_string())?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &value.to_string(), + )?; } WordPart::Length(name) => { let value = if let Some(bracket_pos) = name.find('[') { @@ -566,7 +590,7 @@ impl Interpreter { // Delegate to sync helper to avoid bloating the async state // machine with Vec locals (causes stack overflow at // depth 32 in debug builds — see stack_overflow_regression_tests). - self.operand_outer_unquoted = !Self::part_is_quoted(word, idx); + self.operand_outer_unquoted = !part_quoted; let colon_variant = self.star_colon_variant( name, &value, @@ -582,7 +606,7 @@ impl Interpreter { is_set, ); self.operand_outer_unquoted = false; - Self::append_expansion_for_word(&mut result, word, &expanded)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &expanded)?; } WordPart::ArrayAccess { name, index } => { // `${a[$(cmd)]}` in the source: the substitution runs @@ -601,7 +625,7 @@ impl Interpreter { } else { self.expand_array_access_part(name, index) }; - Self::append_expansion_for_word(&mut result, word, &value)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?; } WordPart::ArrayIndices { name, star } => { let keys = self.array_keys(name); @@ -610,7 +634,12 @@ impl Interpreter { } else { " ".to_string() }; - Self::append_expansion_for_word(&mut result, word, &keys.join(&sep))?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &keys.join(&sep), + )?; } WordPart::Substring { name, @@ -621,7 +650,7 @@ impl Interpreter { let value = self .substring_part(name, offset, length.as_deref()) .map_err(crate::error::Error::LineAbort)?; - Self::append_expansion_for_word(&mut result, word, &value)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?; } WordPart::IndirectExpansion { name, @@ -636,7 +665,7 @@ impl Interpreter { // Nameref without operator: ${!ref} is the name // at the end of the nameref chain. let target = self.resolve_nameref(name).to_string(); - Self::append_expansion_for_word(&mut result, word, &target)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &target)?; } else { // Resolve the indirect target variable name let resolved_name = if let Some(target) = nameref_target { @@ -657,16 +686,31 @@ impl Interpreter { *colon_variant, is_set, ); - Self::append_expansion_for_word(&mut result, word, &expanded)?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &expanded, + )?; } else { // Plain indirect expansion (no operator) if let Some(arr) = self.scoped.arrays.get(&resolved_name) { if let Some(first) = arr.get(&0) { - Self::append_expansion_for_word(&mut result, word, first)?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + first, + )?; } } else { let value = self.expand_variable(&resolved_name); - Self::append_expansion_for_word(&mut result, word, &value)?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &value, + )?; } } } @@ -684,7 +728,12 @@ impl Interpreter { } else { " ".to_string() }; - Self::append_expansion_for_word(&mut result, word, &names.join(&sep))?; + Self::append_expansion_for_word( + &mut result, + word, + part_quoted, + &names.join(&sep), + )?; } WordPart::ArrayLength(name) => { let resolved = self.resolve_nameref(name); @@ -701,14 +750,14 @@ impl Interpreter { let expanded = self .expand_process_substitution(commands, *is_input) .await?; - Self::append_expansion_for_word(&mut result, word, &expanded)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &expanded)?; } WordPart::Transformation { name, operator } => { let value = match prompts.as_mut().and_then(Vec::pop) { Some(decoded) if *operator == 'P' => decoded, _ => self.transformation_part(name, *operator), }; - Self::append_expansion_for_word(&mut result, word, &value)?; + Self::append_expansion_for_word(&mut result, word, part_quoted, &value)?; } } } @@ -3716,16 +3765,31 @@ mod expansion_charge_tests { ]; for quoted in [false, true] { for has_unquoted_glob in [false, true] { - let w = word(quoted, has_unquoted_glob); - for value in values { - let mut appended = BudgetedString::new(None).unwrap(); - Interpreter::append_expansion_for_word(&mut appended, &w, value).unwrap(); - assert_eq!( - Interpreter::expansion_appended_len(&w, value), - appended.len(), - "charge != appended for {value:?} \ + for part_quoted in [Vec::new(), vec![true, false], vec![false, true]] { + let mut w = word(quoted, has_unquoted_glob); + w.part_quoted = part_quoted; + for idx in 0..2 { + for value in values { + let mut appended = BudgetedString::new(None).unwrap(); + Interpreter::append_expansion_for_word( + &mut appended, + &w, + Interpreter::part_is_quoted(&w, idx), + value, + ) + .unwrap(); + assert_eq!( + Interpreter::expansion_appended_len( + &w, + Interpreter::part_is_quoted(&w, idx), + value + ), + appended.len(), + "charge != appended for {value:?} \ (quoted={quoted}, has_unquoted_glob={has_unquoted_glob})" - ); + ); + } + } } } } diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index d8779f2fe..6a29bd1bc 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -1596,6 +1596,20 @@ impl<'a> Lexer<'a> { quoted_prefix_len == 0 || flags.quoted_ranges.iter().any(|(s, e)| s == e); let mut ranges = flags.quoted_ranges; ranges.push((0, quoted_prefix_len)); + if flags.has_unquoted_expansion + && ranges.iter().any(|&(start, end)| { + let quoted = &content[start..end]; + quoted.contains(['*', '?', '[', ']', '{', '}', ',', '(', ')', '|', '\\']) + || Self::has_unescaped_dollar(quoted) + }) + { + // Preserve quoted glob text as well as IFS boundaries; an + // empty unquoted substitution must not activate quoted `*`. + ranges.sort_unstable_by_key(|&(start, _)| start); + return Some(Token::QuotedGlobWord( + Self::escape_glob_metas_in_quoted_ranges(&content, &ranges), + )); + } Self::apply_quote_markers(&mut content, ranges); if !flags.has_unquoted_expansion && !flags.has_unquoted_glob && !has_empty_quoted { // Nothing in it splits or globs: a quoted word whose @@ -1696,6 +1710,14 @@ impl<'a> Lexer<'a> { } } } + Some('`') => { + // `"a"`cmd``: the backquoted form of `"a"$(cmd)`. + flags.has_unquoted_expansion = true; + if let Err(e) = self.read_backtick_into(content) { + flags.error = Some(e); + break; + } + } Some('\\') => { // Backslash escape after a quoted segment: `'a'\''b'`. self.advance(); @@ -2431,6 +2453,18 @@ impl<'a> Lexer<'a> { { let mut ranges = flags.quoted_ranges; ranges.push((0, quoted_prefix_len)); + if flags.has_unquoted_expansion + && ranges.iter().any(|&(start, end)| { + let quoted = &content[start..end]; + quoted.contains(['*', '?', '[', ']', '{', '}', ',', '(', ')', '|', '\\']) + || Self::has_unescaped_dollar(quoted) + }) + { + ranges.sort_unstable_by_key(|&(start, _)| start); + return Some(Token::QuotedGlobWord( + Self::escape_glob_metas_in_quoted_ranges(&content, &ranges), + )); + } // Build marker-delimited quoted spans in one pass so hostile // many-continuation words cannot trigger quadratic insertion work. Self::apply_quote_markers(&mut content, ranges); @@ -2471,20 +2505,12 @@ impl<'a> Lexer<'a> { if quoted_ranges.is_empty() { return s.to_string(); } - // Collect chars with their byte positions for range-boundary checks. - let char_vec: Vec<(usize, char)> = { - let mut pos = 0usize; - s.chars() - .map(|c| { - let p = pos; - pos += c.len_utf8(); - (p, c) - }) - .collect() - }; - - let mut result = String::with_capacity(s.len() + 8); - let mut range_idx = 0usize; + // Reuse the one-pass marker builder: adjacent spans still end variable + // names, and empty quoted spans retain a field without quadratic work. + let mut marked = s.to_string(); + Self::apply_quote_markers(&mut marked, quoted_ranges.to_vec()); + let char_vec: Vec = marked.chars().collect(); + let mut result = String::with_capacity(marked.len() + 8); // Stack of opening delimiters ('{' or '(') for active ${ } / $( ) constructs. // While non-empty we are inside an expansion and must NOT escape anything, // because the content is still unexpanded at parse time and characters like @@ -2495,25 +2521,32 @@ impl<'a> Lexer<'a> { let mut marked_quoted = false; while i < n { - let (byte_pos, ch) = char_vec[i]; - let ch_end = byte_pos + ch.len_utf8(); - - // Advance past quoted-range entries that ended before this char. - while range_idx < quoted_ranges.len() && quoted_ranges[range_idx].1 <= byte_pos { - range_idx += 1; - } - let in_quoted = range_idx < quoted_ranges.len() - && byte_pos >= quoted_ranges[range_idx].0 - && ch_end <= quoted_ranges[range_idx].1; - if in_quoted != marked_quoted { - result.push(if in_quoted { '\u{1e}' } else { '\u{1f}' }); - marked_quoted = in_quoted; + let ch = char_vec[i]; + // Sentinel pairs are indivisible; escaped NUL/marker bytes stay data. + if ch == '\x00' + && let Some(&next) = char_vec.get(i + 1) + && matches!( + next, + '\x00' | '$' | QUOTED_SEGMENT_START | QUOTED_SEGMENT_END + ) + { + result.push(ch); + result.push(next); + i += 2; + continue; } + if matches!(ch, QUOTED_SEGMENT_START | QUOTED_SEGMENT_END) { + marked_quoted = ch == QUOTED_SEGMENT_START; + result.push(ch); + i += 1; + continue; + } + let in_quoted = marked_quoted; if in_quoted { if expansion_stack.is_empty() && ch == '$' { // Peek at next char to detect ${ or $( - if let Some(&(_, next)) = char_vec.get(i + 1) + if let Some(&next) = char_vec.get(i + 1) && (next == '{' || next == '(') { expansion_stack.push(next); @@ -2524,9 +2557,8 @@ impl<'a> Lexer<'a> { } // `"$*"*`, `"$@"x*`, `"$?"`: a special parameter's // name is not a glob character. - if let Some(&(_, next)) = char_vec.get(i + 1) + if let Some(&next) = char_vec.get(i + 1) && matches!(next, '*' | '@' | '?' | '!' | '-') - && (i == 0 || char_vec[i - 1].1 != '\x00') { result.push(ch); result.push(next); @@ -2577,9 +2609,6 @@ impl<'a> Lexer<'a> { result.push(ch); i += 1; } - if marked_quoted { - result.push('\u{1f}'); - } result } @@ -3134,6 +3163,23 @@ impl<'a> Lexer<'a> { mod tests { use super::*; + #[test] + fn quoted_glob_markers_do_not_split_sentinel_pairs() { + assert_eq!( + Lexer::escape_glob_metas_in_quoted_ranges("*\x00\x00*", &[(0, 3)]), + "\x1e\\*\x00\x00\x1f*" + ); + assert_eq!( + Lexer::escape_glob_metas_in_quoted_ranges("*\x00\x1fx", &[(0, 3)]), + "\x1e\\*\x00\x1f\x1fx" + ); + let quoted = "\x00\x00$(printf '*')"; + assert_eq!( + Lexer::escape_glob_metas_in_quoted_ranges(&format!("{quoted}*"), &[(0, quoted.len())]), + format!("\x1e{quoted}\x1f*") + ); + } + #[test] fn test_simple_words() { let mut lexer = Lexer::new("echo hello world"); diff --git a/crates/bashkit/tests/integration/backtick_quote_tests.rs b/crates/bashkit/tests/integration/backtick_quote_tests.rs new file mode 100644 index 000000000..cfd0167a6 --- /dev/null +++ b/crates/bashkit/tests/integration/backtick_quote_tests.rs @@ -0,0 +1,187 @@ +//! Quote-adjacent backticks: Bash parity and permission/budget invariants. + +use bashkit::hooks::{HookAction, ToolEvent}; +use bashkit::{Bash, CommandContext, ExecutionLimits}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn backticks_after_quotes_match_bash() { + let cases = [ + r#"printf '[%s]\n' 'x'`printf '1 2'`; echo tail"#, + r#"printf '[%s]\n' "x"`printf '1 2'`"z"; echo tail"#, + r#"printf '[%s]\n' ''`printf ''`; echo tail"#, + r#"printf '[%s]\n' 'x'`printf ''`'y'"#, + r#"printf '[%s]\n' 'x'`printf 'A\n\n'`'y'"#, + r#"printf '[%s]\n' 'x'`printf A`'y'`printf B`'z'"#, + r#"printf '[%s]\n' 'λ='`printf 猫`'終'"#, + r#"v=outside; printf '[%s]\n' '$v'`printf Y`"#, + r#"v='a b'; printf '[%s]\n' "$v"`printf '1 2'`"#, + r#"IFS=:; printf '[%s]\n' 'x'`printf '1:2'`"#, + r#"printf '[%s]\n' '`literal`'`printf ok`"#, + r#"printf '[%s]\n' 'x'\`'y'"#, + r#"printf '[%s]\n' 'x'`printf '%s' \`printf Y\``"#, + "printf '[%s]\\n' 'x'`printf '\\nY'`\necho tail", + r#"printf '[%s]\n' '$()*'`printf ''`"#, + r#"printf '[%s]\n' $'*\x1f'`printf ''`"#, + r#"v=''; printf '<%s>\n' ''$v "$v"$v"#, + r#"v='*'; a=([0]="$v"`printf '?'`); printf '[%s]\n' "${a[@]}""#, + r#"a=([0]='*'`printf '1 2'`); printf '[%s]\n' "${a[@]}""#, + r#"declare -a a=([0]='*'`printf '?'`); printf '[%s]\n' "${a[@]}""#, + r#"f() { printf '[%s]\n' 'x'`printf Y`; }; f"#, + ]; + let host_available = std::process::Command::new("bash") + .arg("--version") + .output() + .is_ok(); + for script in cases { + if !host_available { + eprintln!("skip: Bash is not installed"); + return; + } + // All oracle scripts use shell builtins and variables only: no host I/O. + let host = std::process::Command::new("bash") + .args(["--noprofile", "--norc", "-c", script]) + .env_clear() + .env("LC_ALL", "C") + .output() + .unwrap(); + let actual = Bash::new().exec(script).await.unwrap(); + assert_eq!( + actual.exit_code, + host.status.code().unwrap_or(-1), + "{script}: {}", + actual.stderr + ); + assert_eq!(actual.stdout.as_bytes(), host.stdout, "{script}"); + assert!(actual.stderr.is_empty(), "{script}: {}", actual.stderr); + assert!(host.stderr.is_empty(), "{script}: {:?}", host.stderr); + } +} + +#[tokio::test] +async fn backticks_after_quotes_execute_once_and_glob_only_unquoted_output() { + let mut bash = Bash::new(); + let result = bash.exec(r#"touch /tmp/p-a /tmp/p-b; printf '[%s]\n' '/tmp/p-'`printf '*'`; printf '[%s]\n' '/tmp/p-*'`printf ''`; echo 'x'`echo ran >> /tmp/calls; printf Y`; cat /tmp/calls"#).await.unwrap(); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert_eq!( + result.stdout, + "[/tmp/p-a]\n[/tmp/p-b]\n[/tmp/p-*]\nxY\nran\n" + ); + assert!(result.stderr.is_empty()); +} + +#[test] +fn backticks_after_quotes_are_visible_to_analysis() { + for script in [ + r#"echo 'x'`rm /tmp/sentinel`"#, + r#"echo "x"`rm /tmp/sentinel`"#, + ] { + let a = Bash::new().analyze(script).unwrap(); + assert!(a.has_command_substitution, "{script}"); + let rm = a + .commands + .iter() + .find(|c| c.name.as_deref() == Some("rm")) + .expect("substitution must be reported"); + assert_eq!(rm.context, CommandContext::Substitution); + let echo = a + .commands + .iter() + .find(|c| c.name.as_deref() == Some("echo")) + .unwrap(); + assert_eq!(echo.args, vec![None]); + } + let a = Bash::new().analyze(r#"'ec'`printf ho` hello"#).unwrap(); + assert!(a.has_dynamic_commands); + assert!(a.is_opaque()); +} + +#[tokio::test] +async fn backticks_after_quotes_still_obey_dispatch_veto() { + let dispatched = Arc::new(Mutex::new(Vec::new())); + let sink = dispatched.clone(); + let mut bash = Bash::builder() + .before_tool(Box::new(move |event: ToolEvent| { + sink.lock().unwrap().push(event.name.clone()); + if event.name == "rm" { + HookAction::Cancel("rm denied".into()) + } else { + HookAction::Continue(event) + } + })) + .build(); + bash.exec("echo kept > /tmp/sentinel").await.unwrap(); + let a = bash.analyze(r#"echo 'x'`rm /tmp/sentinel`"#).unwrap(); + assert!(a.command_names().contains(&"rm")); + let _ = bash.exec(r#"echo 'x'`rm /tmp/sentinel`"#).await.unwrap(); + assert!(dispatched.lock().unwrap().contains(&"rm".to_string())); + let result = bash.exec("cat /tmp/sentinel").await.unwrap(); + assert_eq!(result.stdout, "kept\n"); +} + +#[test] +fn unterminated_backticks_after_quotes_fail_analysis() { + for script in [ + "echo 'x'`printf Y", + "echo \"x\"`printf Y", + "echo 'x'`printf Y`'unclosed", + ] { + assert!(Bash::new().analyze(script).is_err(), "{script}"); + } +} + +#[tokio::test] +async fn backticks_after_quotes_cannot_refresh_execution_budget() { + let limits = ExecutionLimits::new() + .max_commands(100) + .max_work_units(6) + .max_aggregate_input_bytes(1_000); + let mut bash = Bash::builder().limits(limits).build(); + let result = bash.exec(r#"echo 'x'`echo $(echo $(echo nested))`"#).await; + assert!(result.is_err(), "budget must abort execution: {result:?}"); + assert!(result.unwrap_err().to_string().contains("budget")); +} + +#[tokio::test] +async fn empty_substitutions_do_not_activate_quoted_globs() { + let mut bash = Bash::new(); + bash.exec("touch /tmp/p-a /tmp/p-b").await.unwrap(); + for word in [ + r#"'/tmp/p-*'`printf ''`"#, + r#""/tmp/p-*"`printf ''`"#, + r#"'/tmp/p-*'$(printf '')"#, + r#""/tmp/p-*"$(printf '')"#, + r#""$prefix"$empty"#, + ] { + let script = format!("prefix='/tmp/p-*'; empty=; printf '[%s]\\n' {word}"); + let r = bash.exec(&script).await.unwrap(); + assert_eq!(r.exit_code, 0, "{script}: {}", r.stderr); + assert_eq!(r.stdout, "[/tmp/p-*]\n", "{script}"); + assert!(r.stderr.is_empty()); + } +} + +#[tokio::test] +async fn mixed_quote_patterns_keep_unquoted_expansions_active() { + for script in [ + r#"v='*'; case aa in 'a'$v) echo yes;; *) echo no;; esac"#, + r#"v='*'; case '*abc' in '*'$v) echo yes;; *) echo no;; esac"#, + r#"v='*'; case '*abc' in '*'`printf '%s' "$v"`) echo yes;; *) echo no;; esac"#, + r#"v='*'; case '*abc' in "$v"$(printf '*')) echo yes;; *) echo no;; esac"#, + ] { + let r = Bash::new().exec(script).await.unwrap(); + assert_eq!(r.stdout, "yes\n", "{script}"); + assert_eq!(r.exit_code, 0, "{}", r.stderr); + } +} + +#[tokio::test] +async fn invalid_backtick_body_after_quotes_fails_at_execution_only() { + let r = Bash::new() + .exec(r#"v='x'`fi`; printf '[%s] %s\n' "$v" "$?"; echo tail"#) + .await + .unwrap(); + assert_eq!(r.stdout, "[x] 2\ntail\n"); + assert_eq!(r.exit_code, 0); + assert!(r.stderr.contains("syntax error"), "{}", r.stderr); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 76534a8ce..28019d11d 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -25,6 +25,7 @@ pub mod awk_pattern_tests; pub mod awk_printf_expr_test; pub mod awk_range_pattern_tests; pub mod background_exec_tests; +pub mod backtick_quote_tests; pub mod base64_binary_tests; pub mod bash_source_tests; pub mod blackbox_security_tests; diff --git a/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh index 6dfa927db..10c5aabc0 100644 --- a/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh @@ -317,3 +317,24 @@ echo "$#" ### expect 3 ### end + +### subst_backtick_after_quoted_prefix +# Backquotes glued to a quoted segment, like "a"$(cmd) +echo 'b='`echo X` +echo "b="`echo X` +for u in 'a' 'b='`echo 42`; do echo $u; done +echo 'a'`echo X`'c' +### expect +b=X +b=X +a +b=42 +aXc +### end + +### subst_backtick_after_quoted_prefix_splits +# The unquoted result still word-splits +printf '[%s]' 'x'`echo "1 2"`; echo +### expect +[x1][2] +### end diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index ad5c78989..fbbc16446 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -62,6 +62,17 @@ globbing uses the escaped text and drops the escapes when nothing matches; `expand_word` returns unescaped text for non-glob uses; assignments and script analysis unescape literal parts; `case` and `[[ == ]]` build patterns with `expand_pattern_word`, which escapes fully quoted words. +A quote-start word followed by an unquoted expansion also uses this encoding +when quoted text contains glob syntax or a quoted expansion could produce it: +`"$prefix"$empty` and `'literal*'$(printf '')` keep the quoted glob literal. +Expansion escaping and its byte-budget charge use each part's quote flag; +unquoted expansion output still participates in glob and pattern matching. +Compound-array expansion uses the same per-part escaping inside a budgeted +buffer, retaining pending-value capacity leases until subscript evaluation. +The quote-metadata presence check is cached once per expanded word, avoiding +quadratic scans when many unquoted expansions precede a quoted suffix. +The shared marker builder preserves empty quoted spans and ends adjacent +variable names; sentinel-escaped marker bytes remain literal data. **Quoted segments of mixed words.** A word that starts quoted and continues with an unquoted expansion (`'a b'$x`, `"$y"$x`) also wraps its quoted spans @@ -239,6 +250,11 @@ after a heredoc on the same line lex the same as anywhere else. backquotes to `$(...)`; a body that does not parse becomes `$(eval 'body')`, which reports the syntax error at run time and never runs anything else. `$(...)` keeps rejecting the script at parse time. +Quote continuations use the same backquote reader as ordinary words, so +`'x'` followed immediately by a backquoted command is one mixed word. +Unquoted substitution output still splits; static analysis reports nested +commands in substitution context, dispatch hooks still gate them, and nested +execution shares the parent's budget. Missing closing backquotes fail parsing. **Process substitution end.** `<(...)`/`>(...)` bodies end where the shared `subst_scan` scanner closes them (`Lexer::skip_subst_body`), so