From 02417b3baade20c1c6034068e76be90387ca4477 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 2 Apr 2026 05:59:50 +0000 Subject: [PATCH] fix(parser): treat escaped dollar \$ in double quotes as literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lexer correctly handles \$ → $ but parse_word() re-interprets the resulting $ as a variable expansion. Use a NUL sentinel to distinguish escaped dollars from genuine expansion markers. Closes #948 --- crates/bashkit/src/parser/lexer.rs | 48 ++++++++++++++++--- crates/bashkit/src/parser/mod.rs | 7 ++- .../bash/blackbox-exploration.test.sh | 5 +- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index bcaa6bb8..ae24f2c5 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -416,7 +416,14 @@ impl<'a> Lexer<'a> { '\n' => { self.advance(); } - '"' | '\\' | '$' | '`' => { + '$' => { + // Use NUL sentinel so parse_word() treats this + // as a literal '$' rather than a variable expansion. + word.push('\x00'); + word.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { word.push(next); self.advance(); } @@ -512,7 +519,14 @@ impl<'a> Lexer<'a> { // \ is line continuation: discard both self.advance(); } - '"' | '\\' | '$' | '`' => { + '$' => { + // Use NUL sentinel so parse_word() treats this + // as a literal '$' rather than a variable expansion. + word.push('\x00'); + word.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { word.push(next); self.advance(); } @@ -568,7 +582,12 @@ impl<'a> Lexer<'a> { '\n' => { self.advance(); } - '"' | '\\' | '$' | '`' => { + '$' => { + word.push('\x00'); + word.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { word.push(next); self.advance(); } @@ -937,7 +956,12 @@ impl<'a> Lexer<'a> { self.advance(); if let Some(next) = self.peek_char() { match next { - '"' | '\\' | '$' | '`' => { + '$' => { + content.push('\x00'); + content.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { content.push(next); self.advance(); } @@ -1108,7 +1132,14 @@ impl<'a> Lexer<'a> { // \ is line continuation: discard both self.advance(); } - '"' | '\\' | '$' | '`' => { + '$' => { + // Use NUL sentinel so parse_word() treats this + // as a literal '$' rather than a variable expansion. + content.push('\x00'); + content.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { content.push(next); self.advance(); } @@ -1332,7 +1363,12 @@ impl<'a> Lexer<'a> { self.advance(); if let Some(esc) = self.peek_char() { match esc { - '"' | '\\' | '$' | '`' => { + '$' => { + content.push('\x00'); + content.push('$'); + self.advance(); + } + '"' | '\\' | '`' => { content.push(esc); self.advance(); } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index d26e61a8..46d1cc5e 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2539,7 +2539,12 @@ impl<'a> Parser<'a> { let mut current = String::new(); while let Some(ch) = chars.next() { - if ch == '$' { + if ch == '\x00' { + // NUL sentinel from lexer: next char is a literal (escaped in source). + if let Some(literal_ch) = chars.next() { + current.push(literal_ch); + } + } else if ch == '$' { // Flush current literal if !current.is_empty() { parts.push(WordPart::Literal(std::mem::take(&mut current))); diff --git a/crates/bashkit/tests/spec_cases/bash/blackbox-exploration.test.sh b/crates/bashkit/tests/spec_cases/bash/blackbox-exploration.test.sh index 11d36968..16926418 100644 --- a/crates/bashkit/tests/spec_cases/bash/blackbox-exploration.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/blackbox-exploration.test.sh @@ -1370,11 +1370,10 @@ say "hello" ### end ### escaped_dollar -### bash_diff: echo "\$HOME" — bashkit expands the variable instead of treating \$ as literal (#668) -# Escaped dollar sign — bash: "$HOME", bashkit: "/home/sandbox" +# Escaped dollar sign — bash treats \$ in double quotes as literal $ echo "\$HOME" ### expect -/home/sandbox +$HOME ### end ### escaped_backtick