From fd450e1c1e0fba2fdb1b66ebe65e7cede17defbf Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 13:36:17 +0000 Subject: [PATCH] fix(parser): keep quoted and escaped glob characters literal Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/analysis.rs | 5 ++ crates/bashkit/src/interpreter/expansion.rs | 25 +++++- crates/bashkit/src/interpreter/glob.rs | 38 +++++++-- crates/bashkit/src/interpreter/mod.rs | 35 ++++++-- crates/bashkit/src/parser/lexer.rs | 85 ++++++++++++++++++- crates/bashkit/src/parser/mod.rs | 55 +++++++++++- .../spec_cases/bash/escaped-glob.test.sh | 77 +++++++++++++++++ knowledge/foundations/parser.md | 11 +++ knowledge/log.md | 1 + 9 files changed, 312 insertions(+), 20 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/escaped-glob.test.sh diff --git a/crates/bashkit/src/analysis.rs b/crates/bashkit/src/analysis.rs index 03852aa76..dbc0cdf3c 100644 --- a/crates/bashkit/src/analysis.rs +++ b/crates/bashkit/src/analysis.rs @@ -350,6 +350,11 @@ fn literal_word(word: &Word) -> Option { _ => return None, } } + // A `QuotedGlobWord` stores its quoted glob characters backslash-escaped; + // report the text the command actually sees. + if word.quoted && word.has_unquoted_glob { + out = crate::parser::unescape_glob_literal(&out); + } Some(out) } diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index a39bffe83..300140bec 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -112,7 +112,15 @@ impl Interpreter { ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let expanded = self.expand_word_inner(word).await?; - Ok(Self::strip_quote_markers(&expanded)) + let expanded = Self::strip_quote_markers(&expanded); + // A `QuotedGlobWord` carries glob escapes for its quoted text; + // contexts that do not glob (command names, redirect targets, + // operands) want the text itself. + if word.quoted && word.has_unquoted_glob { + Ok(Self::glob_path_unescape(&expanded)) + } else { + Ok(expanded) + } }) } @@ -164,6 +172,21 @@ impl Interpreter { } } + /// Expand a word used as a pattern (`case` item, `[[ == ]]` operand). + /// Quoted text must match literally, so a fully quoted word has every + /// glob metacharacter escaped. A mixed word (`QuotedGlobWord`) already + /// carries escapes for its quoted literals from the lexer and for its + /// quoted expansions from `append_expansion_for_word`. + pub(super) async fn expand_pattern_word(&mut self, word: &Word) -> Result { + let expanded = Box::pin(self.expand_word_inner(word)).await?; + let expanded = Self::strip_quote_markers(&expanded); + if word.quoted && !word.has_unquoted_glob { + Ok(Self::quote_expansion_for_quoted_glob(&expanded)) + } else { + Ok(expanded) + } + } + pub(super) async fn expand_word_inner(&mut self, word: &Word) -> Result { let mut result = String::new(); // Keep command-substitution bytes charged until the complete word has diff --git a/crates/bashkit/src/interpreter/glob.rs b/crates/bashkit/src/interpreter/glob.rs index fc467c260..5f42f5fb3 100644 --- a/crates/bashkit/src/interpreter/glob.rs +++ b/crates/bashkit/src/interpreter/glob.rs @@ -98,8 +98,22 @@ impl Interpreter { if self.contains_glob_chars(pattern) || self.contains_extglob(pattern) { self.glob_match(value, pattern) } else { - // Literal match - value == pattern + // Literal match; a backslash still escapes the next character + // (`\*` from quoted pattern text matches a literal `*`, and an + // unquoted `a\b` pattern matches `ab`, as in bash). + if pattern.contains('\\') { + let mut literal = String::with_capacity(pattern.len()); + let mut chars = pattern.chars(); + while let Some(ch) = chars.next() { + match ch { + '\\' => literal.push(chars.next().unwrap_or('\\')), + _ => literal.push(ch), + } + } + value == literal + } else { + value == pattern + } } } @@ -703,17 +717,29 @@ impl Interpreter { /// Expand glob for a single item, applying noglob/failglob/nullglob. /// Returns Err(pattern) if failglob triggers, Ok(items) otherwise. + /// Glob-expand one field. `escaped` marks a field from a word whose + /// quoted glob characters the lexer backslash-escaped (quoted && + /// has_unquoted_glob): when the field is not replaced by matches, those + /// escapes are removed so `"*"zz*` with no match prints `*zz*`. pub(crate) async fn expand_glob_item( &self, item: &str, + escaped: bool, ) -> std::result::Result, String> { + let literal = |s: &str| { + if escaped { + Self::glob_path_unescape(s) + } else { + s.to_string() + } + }; if !(self.contains_glob_chars(item) || self.contains_extglob(item)) || self.is_noglob() { - return Ok(vec![item.to_string()]); + return Ok(vec![literal(item)]); } let glob_matches = self.expand_glob(item).await.unwrap_or_default(); if glob_matches.is_empty() { if self.is_failglob() { - return Err(item.to_string()); + return Err(literal(item)); } let nullglob = self .scoped @@ -724,7 +750,7 @@ impl Interpreter { if nullglob { Ok(vec![]) } else { - Ok(vec![item.to_string()]) + Ok(vec![literal(item)]) } } else { Ok(glob_matches) @@ -743,7 +769,7 @@ impl Interpreter { /// The metacharacter set below must stay in sync with the escape set in /// `Interpreter::quote_expansion_for_quoted_glob` (interpreter/mod.rs); if /// one side adds or drops a character, lookups break or escapes leak. - fn glob_path_unescape(s: &str) -> String { + pub(super) fn glob_path_unescape(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(ch) = chars.next() { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 5a1c50abe..2d35878e7 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3652,7 +3652,10 @@ impl Interpreter { for expanded in fields { let brace_expanded = self.expand_braces(&expanded); for item in brace_expanded { - match self.expand_glob_item(&item).await { + match self + .expand_glob_item(&item, w.quoted && w.has_unquoted_glob) + .await + { Ok(items) => vals.extend(items), Err(pat) => { self.last_exit_code = 1; @@ -3778,7 +3781,10 @@ impl Interpreter { for expanded in fields { let brace_expanded = self.expand_braces(&expanded); for item in brace_expanded { - match self.expand_glob_item(&item).await { + match self + .expand_glob_item(&item, w.quoted && w.has_unquoted_glob) + .await + { Ok(items) => values.extend(items), Err(pat) => { self.last_exit_code = 1; @@ -4171,10 +4177,22 @@ impl Interpreter { return self.evaluate_conditional_words(&words[i + 1..]).await; } - // Leaf: expand words and evaluate as a simple condition + // Leaf: expand words and evaluate as a simple condition. The + // right side of `==`/`=`/`!=` is a pattern: its quoted parts + // match literally. let mut expanded = Vec::new(); - for word in words { - expanded.push(self.expand_word(word).await?); + for (i, word) in words.iter().enumerate() { + let is_pattern = i > 0 + && !words[i - 1].quoted + && matches!( + words[i - 1].parts.as_slice(), + [WordPart::Literal(op)] if matches!(op.as_str(), "==" | "=" | "!=") + ); + expanded.push(if is_pattern { + self.expand_pattern_word(word).await? + } else { + self.expand_word(word).await? + }); } Ok(self.evaluate_conditional(&expanded).await) }) @@ -4597,7 +4615,7 @@ impl Interpreter { } else { let mut m = false; for pattern in &case_item.patterns { - let pattern_str = self.expand_word(pattern).await?; + let pattern_str = self.expand_pattern_word(pattern).await?; if self.pattern_matches(&word_value, &pattern_str) { m = true; break; @@ -6898,7 +6916,10 @@ impl Interpreter { // Step 2: For each brace-expanded item, do glob expansion for item in brace_expanded { - match self.expand_glob_item(&item).await { + match self + .expand_glob_item(&item, word.quoted && word.has_unquoted_glob) + .await + { Ok(items) => args.extend(items), Err(pat) => { self.last_exit_code = 1; diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index b6c94e564..696a19055 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -588,6 +588,11 @@ impl<'a> Lexer<'a> { // this flag are true, the word needs IFS-splitting suppression (quoted) // *and* glob expansion on the unquoted portion — e.g. `"$var"*.ext`. let mut has_unquoted_glob = false; + // Byte ranges of quoted or backslash-escaped text, and whether an + // unquoted `$`/backtick expansion appears. Together they decide how + // quoted glob characters stay literal (see the end of this function). + let mut quoted_ranges: Vec<(usize, usize)> = Vec::new(); + let mut has_unquoted_expansion = false; while let Some(ch) = self.peek_char() { // Handle quoted strings within words (e.g., a="Hello" or VAR="value") @@ -605,6 +610,7 @@ impl<'a> Lexer<'a> { if quote_char == '"' { word.push('\u{1e}'); } + let seg_start = word.len(); while let Some(c) = self.peek_char() { if c == quote_char { self.advance(); // consume closing quote @@ -680,6 +686,7 @@ impl<'a> Lexer<'a> { } ))); } + quoted_ranges.push((seg_start, word.len())); if quote_char == '"' { word.push('\u{1f}'); } @@ -696,7 +703,9 @@ impl<'a> Lexer<'a> { if !closed { return Some(Token::Error("unterminated single quote".to_string())); } + let seg_start = word.len(); Self::push_literal_with_escaped_dollar(&mut word, &content); + quoted_ranges.push((seg_start, word.len())); word.push('\u{1f}'); // ANSI-C quotes are single-quote semantics: quoted context. has_quoted_expansion = true; @@ -709,6 +718,7 @@ impl<'a> Lexer<'a> { // Locale quotes are double-quote semantics: quoted context. has_quoted_expansion = true; word.push('\u{1e}'); + let seg_start = word.len(); let mut closed = false; while let Some(c) = self.peek_char() { if c == '"' { @@ -797,10 +807,12 @@ impl<'a> Lexer<'a> { if !closed { return Some(Token::Error("unterminated double quote".to_string())); } + quoted_ranges.push((seg_start, word.len())); word.push('\u{1f}'); continue; } + has_unquoted_expansion = true; word.push(ch); // push the '$' // Check for $( - command substitution or arithmetic @@ -866,7 +878,10 @@ impl<'a> Lexer<'a> { } } } else if ch == '{' { - // Brace expansion pattern - include entire {...} in word + // Brace expansion pattern - include entire {...} in word. + // Counts as unquoted glob syntax: a word mixing it with quoted + // text (`a{1,2}"*"`) must still reach brace expansion. + has_unquoted_glob = true; word.push(ch); self.advance(); let mut depth = 1; @@ -884,6 +899,7 @@ impl<'a> Lexer<'a> { } } else if ch == '`' { // Backtick command substitution: convert `cmd` to $(cmd) + has_unquoted_expansion = true; self.advance(); // consume opening ` word.push_str("$("); let mut closed = false; @@ -931,6 +947,9 @@ impl<'a> Lexer<'a> { if matches!(next, '$' | '`') { word.push('\x00'); } + if Self::is_glob_escape_char(next) { + quoted_ranges.push((word.len(), word.len() + next.len_utf8())); + } word.push(next); self.advance(); } @@ -1033,6 +1052,24 @@ impl<'a> Lexer<'a> { } } + // Quoted or escaped glob characters are literal (`a"*"`, `a\*`, + // `a'?'`). With an unquoted glob in the same word they are escaped + // in place, the QuotedGlobWord convention also used for words that + // start with a quote. Without one the word never globs, so it is + // simply quoted. A word with an unquoted expansion keeps the old + // classification: quoting it would suppress field splitting. + let quoted_glob = quoted_ranges + .iter() + .any(|&(start, end)| word[start..end].contains(['*', '?', '[', '{'])); + if quoted_glob && has_unquoted_glob { + return Some(Token::QuotedGlobWord( + Self::escape_glob_metas_in_quoted_ranges(&word, "ed_ranges), + )); + } + if quoted_glob && !has_unquoted_expansion { + return Some(Token::QuotedWord(word)); + } + if word.is_empty() { None } else if has_quoted_expansion && has_unquoted_glob { @@ -1070,11 +1107,33 @@ impl<'a> Lexer<'a> { } // If next char is another quote or word char, concatenate (e.g., 'EOF'"2" -> EOF2). - // Any quoting makes the whole token literal. + // An unquoted expansion or glob in the continuation keeps working + // (`'a'$x`, `'a'*`); otherwise the whole token is literal. The quoted + // prefix carries NUL sentinels so `parse_word` never expands its `$`. + let mut content = { + let mut escaped = String::with_capacity(content.len()); + Self::push_literal_with_escaped_dollar(&mut escaped, &content); + escaped + }; + let quoted_prefix_len = content.len(); let flags = self.read_continuation_into(&mut content); if let Some(error) = flags.error { return Some(Token::Error(error)); } + if flags.has_unquoted_expansion { + let mut ranges = flags.quoted_ranges; + ranges.push((0, quoted_prefix_len)); + Self::apply_quote_markers(&mut content, ranges); + return Some(Token::Word(content)); + } + if flags.has_unquoted_glob { + let mut ranges = flags.quoted_ranges; + ranges.push((0, quoted_prefix_len)); + ranges.sort_unstable_by_key(|&(s, _)| s); + return Some(Token::QuotedGlobWord( + Self::escape_glob_metas_in_quoted_ranges(&content, &ranges), + )); + } // Single-quoted strings are literal - no variable expansion. Quote-boundary // markers are only for parsed mixed words; LiteralWord keeps contents raw. @@ -1100,6 +1159,9 @@ impl<'a> Lexer<'a> { closed = true; break; } + if matches!(ch, '\x00' | '$') { + content.push('\x00'); + } content.push(ch); self.advance(); } @@ -1507,6 +1569,14 @@ impl<'a> Lexer<'a> { } } + /// Characters `escape_glob_metas_in_quoted_ranges` backslash-escapes. + fn is_glob_escape_char(ch: char) -> bool { + matches!( + ch, + '\\' | '*' | '?' | '[' | ']' | '{' | '}' | '@' | '!' | '+' | '(' | ')' | '|' + ) + } + /// Drop quote-boundary markers and decode NUL escape sentinels in one /// pass, so a NUL-escaped marker byte survives as data. fn strip_markers_decode_sentinels(segment: &str) -> String { @@ -1666,6 +1736,9 @@ impl<'a> Lexer<'a> { /// Escape glob metacharacters within quoted byte ranges so that the glob /// expander treats them as literal characters rather than active patterns. + /// Each range is also wrapped in quote-segment markers: `parse_word` then + /// ends a variable name at the closing quote (`"$x"zz*` reads `$x`, not + /// `$xzz`) and records which parts were quoted for field splitting. /// Ranges must be sorted and non-overlapping. fn escape_glob_metas_in_quoted_ranges(s: &str, quoted_ranges: &[(usize, usize)]) -> String { if quoted_ranges.is_empty() { @@ -1692,6 +1765,7 @@ impl<'a> Lexer<'a> { let mut expansion_stack: Vec = Vec::new(); let n = char_vec.len(); let mut i = 0usize; + let mut marked_quoted = false; while i < n { let (byte_pos, ch) = char_vec[i]; @@ -1704,6 +1778,10 @@ impl<'a> Lexer<'a> { 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; + } if in_quoted { if expansion_stack.is_empty() && ch == '$' { @@ -1759,6 +1837,9 @@ impl<'a> Lexer<'a> { result.push(ch); i += 1; } + if marked_quoted { + result.push('\u{1f}'); + } result } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 9bcac8a8e..c7c339b64 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -1352,7 +1352,20 @@ impl<'a> Parser<'a> { }, Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) - | Some(tokens::Token::QuotedGlobWord(w)) => self.parse_word(w.clone()), + | Some(tokens::Token::QuotedGlobWord(w)) => { + // Keep the quoting flags: quoted pattern text matches + // literally (`"$x")`, `a\*)`). + let mut word = self.parse_word(w.clone()); + match &self.current_token { + Some(tokens::Token::QuotedWord(_)) => word.quoted = true, + Some(tokens::Token::QuotedGlobWord(_)) => { + word.quoted = true; + word.has_unquoted_glob = true; + } + _ => {} + } + word + } _ => unreachable!(), }; patterns.push(pattern); @@ -2332,7 +2345,10 @@ impl<'a> Parser<'a> { /// Parse the value side of an assignment (`VAR=value`). /// Returns `Some((Assignment, needs_advance))` if the current word is an assignment. /// The bool indicates whether the caller must call `self.advance()` afterward. - fn try_parse_assignment(&mut self, w: &str) -> Option<(Assignment, bool)> { + /// `glob_escaped` marks a `QuotedGlobWord` token: the lexer backslash- + /// escaped its quoted glob characters, which an assignment (no globbing) + /// must drop again so `x="a*"b*` stores `a*b*`. + fn try_parse_assignment(&mut self, w: &str, glob_escaped: bool) -> Option<(Assignment, bool)> { let (name, index, value, is_append) = Self::is_assignment(w)?; let name = name.to_string(); let index = index.map(|s| s.to_string()); @@ -2407,7 +2423,15 @@ impl<'a> Parser<'a> { part_quoted: Vec::new(), } } else { - self.parse_word(value_str) + let mut w = self.parse_word(value_str); + if glob_escaped { + for part in &mut w.parts { + if let WordPart::Literal(s) = part { + *s = unescape_glob_literal(s); + } + } + } + w }; Some(( Assignment { @@ -2679,7 +2703,8 @@ impl<'a> Parser<'a> { // Check for assignment (only before the command name, not for literal words) if words.is_empty() && !is_literal - && let Some((assignment, needs_advance)) = self.try_parse_assignment(&w) + && let Some((assignment, needs_advance)) = + self.try_parse_assignment(&w, is_glob_quoted) { if needs_advance { self.advance(); @@ -4042,6 +4067,28 @@ fn cond_regex_literal(raw: &str) -> String { out } +/// Undo the lexer's glob escaping of quoted text (`\*` -> `*`, `\\` -> `\`) +/// for contexts that never glob. +pub(crate) fn unescape_glob_literal(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\\' + && let Some(&next) = chars.peek() + && matches!( + next, + '\\' | '*' | '?' | '[' | ']' | '{' | '}' | '@' | '!' | '+' | '(' | ')' | '|' + ) + { + out.push(next); + chars.next(); + continue; + } + out.push(ch); + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/tests/spec_cases/bash/escaped-glob.test.sh b/crates/bashkit/tests/spec_cases/bash/escaped-glob.test.sh new file mode 100644 index 000000000..7b1ca0e50 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/escaped-glob.test.sh @@ -0,0 +1,77 @@ +# Quoted or backslash-escaped glob characters are literal: in words +# (`a\*`, `a"*"`), case patterns and [[ == ]] patterns. Unquoted glob +# characters in the same word still glob. + +### escaped_star_not_globbed +mkdir -p /tmp/el && cd /tmp/el && touch ab ac +echo a\* +echo a"*" +echo a'?' +echo a* +### expect +a* +a* +a? +ab ac +### end + +### escaped_glob_in_case_pattern +case ab in a\*) echo glob ;; *) echo literal ;; esac +case 'a*' in a\*) echo star ;; *) echo no ;; esac +case '[s]' in \[s\]) echo bracket ;; *) echo no ;; esac +case ab in a"*") echo glob ;; *) echo literal ;; esac +### expect +literal +star +bracket +literal +### end + +### quoted_var_in_case_pattern_is_literal +x='a*' +case ab in "$x") echo glob ;; *) echo literal ;; esac +case ab in $x) echo glob ;; *) echo literal ;; esac +case 'a*' in "$x") echo exact ;; *) echo no ;; esac +### expect +literal +glob +exact +### end + +### escaped_glob_in_double_bracket +[[ ab == a\* ]] && echo glob || echo literal +[[ '[s]' == \[s\] ]] && echo bracket || echo no +x='a*' +[[ ab == "$x" ]] && echo glob || echo literal +[[ ab == $x ]] && echo glob || echo literal +[[ ab == a"*" ]] && echo glob || echo literal +### expect +literal +bracket +literal +glob +literal +### end + +### quoted_glob_prefix_then_unquoted_glob +mkdir -p /tmp/qg && cd /tmp/qg && touch 'a*b' axb +echo "*"zz* +echo "a*"* +x='a*'; echo "$x"zz* +echo a"*"* +echo a\** +### expect +*zz* +a*b +a*zz* +a*b +a*b +### end + +### quoted_glob_assignment_keeps_text +x="a*"b*; echo "$x" +y=a\*b?; echo "$y" +### expect +a*b* +a*b? +### end diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index 1c4f9f453..8c9927df2 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -52,6 +52,17 @@ Handles bash's context-sensitivity: - Brace expansion: `{a,b,c}` and `{1..5}` vs brace groups `{ cmd; }` - Tilde expansion: `~` at start of word expands to `$HOME` +**Quoted glob characters.** `"*"`, `'?'` and `\[` are literal; only +unquoted `*?[` (and brace syntax) glob. Token kinds encode this: a word whose +quoted text has glob characters but no unquoted glob is a `QuotedWord` (never +globs). A word with both is a `QuotedGlobWord`: the lexer backslash-escapes +the quoted glob characters in place and wraps quoted ranges in `\x1e`/`\x1f` +markers (so `"$x"zz*` ends the variable name at the quote). Consumers: +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. + **Metacharacters vs reserved words.** Only space, tab, newline, `|`, `&`, `;`, `(`, `)`, `<` and `>` delimit a word. `{` and `}` do not: they are reserved words, recognized as such only when they stand alone. So `echo a}b` prints diff --git a/knowledge/log.md b/knowledge/log.md index fe2655019..24e45428f 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches. * **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry. * **Fix**: `$'\x1e'` and `$'\x1f'` bytes (record/unit separators) are kept as data. They collided with the lexer's quote-boundary markers and were silently dropped, which broke the common `SEP=$'\x1f'` composite-key idiom for associative arrays (`v["$section"$'\x1f'"$key"]`). Decoded ANSI-C content now NUL-escapes those bytes like `$`. Found by gap telemetry (`config_ini_merge` replay printed nothing). * **Fix**: A usage error in a bundled builtin no longer aborts the script. `grep -E '('`, `grep -P` with a bad pattern, `rg` with a bad regex, an awk syntax error, `grep` with no pattern and `grep -f missing` now fail that one command with exit 2 and the message on stderr, and the script continues (bash semantics; `set -e` still stops). `Err` from custom builtins (`BashBuilder::builtin`, registry, resolver) keeps aborting, per the public contract. See foundations/builtins "Errors: Failed Command vs Aborted Execution".