diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 18c2294f..430279b9 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -1227,8 +1227,6 @@ impl<'a> Lexer<'a> { self.advance(); // consume opening " let mut content = String::new(); let mut closed = false; - let mut has_quoted_expansion = false; - while let Some(ch) = self.peek_char() { match ch { '"' => { @@ -1267,13 +1265,6 @@ impl<'a> Lexer<'a> { '$' => { content.push('$'); self.advance(); - if self.peek_char().is_some_and(|nc| { - nc.is_ascii_alphanumeric() - || nc == '_' - || matches!(nc, '{' | '(' | '?' | '#' | '@' | '*' | '!' | '$' | '-') - }) { - has_quoted_expansion = true; - } if self.peek_char() == Some('(') { // $(...) command substitution — track paren depth content.push('('); @@ -1291,7 +1282,6 @@ impl<'a> Lexer<'a> { } '`' => { // Backtick command substitution inside double quotes - has_quoted_expansion = true; self.advance(); // consume opening ` content.push_str("$("); while let Some(c) = self.peek_char() { @@ -1343,13 +1333,10 @@ impl<'a> Lexer<'a> { let has_glob = content[before_len..] .chars() .any(|c| matches!(c, '*' | '?' | '[')); - if has_quoted_expansion && has_glob { + if has_glob { return Some(Token::QuotedGlobWord(content)); } - if has_quoted_expansion { - return Some(Token::QuotedWord(content)); - } - return Some(Token::Word(content)); + return Some(Token::QuotedWord(content)); } Some(Token::QuotedWord(content)) diff --git a/crates/bashkit/tests/integration/blackbox_security_tests.rs b/crates/bashkit/tests/integration/blackbox_security_tests.rs index e7cb493a..247b633f 100644 --- a/crates/bashkit/tests/integration/blackbox_security_tests.rs +++ b/crates/bashkit/tests/integration/blackbox_security_tests.rs @@ -1315,6 +1315,46 @@ mod parser_edge_cases_passing { "Single-quoted heredoc expanded command substitution" ); } + + /// Any quoted byte in the delimiter disables heredoc body expansion. + #[tokio::test] + async fn heredoc_partially_quoted_delimiter_no_expansion() { + let mut bash = tight_bash(); + let result = bash + .exec("cat <<\"EOF\"x\n$(echo INJECTED)\nEOFx\n") + .await + .unwrap(); + assert_eq!( + result.stdout, "$(echo INJECTED)\n", + "partially quoted heredoc delimiter expanded command substitution" + ); + } +} + +mod finding_mixed_quoted_word_quote_metadata { + use super::*; + + #[tokio::test] + async fn quoted_glob_metacharacter_with_unquoted_suffix_stays_literal() { + let mut bash = tight_bash(); + let result = bash.exec(r#"echo "*"x"#).await.unwrap(); + + assert_eq!( + result.stdout, "*x\n", + "glob metacharacter from quoted segment was expanded" + ); + } + + #[tokio::test] + async fn quoted_brace_expression_with_unquoted_suffix_stays_literal() { + let mut bash = tight_bash(); + let result = bash.exec(r#"echo "{1..3}"x"#).await.unwrap(); + + assert_eq!( + result.stdout, "{1..3}x\n", + "brace expression from quoted segment was expanded" + ); + } } mod state_isolation_passing {