From 9ec4ba85ea8bf71d92c6e4faf5bdd77171db029e Mon Sep 17 00:00:00 2001 From: pagedmov Date: Thu, 19 Feb 2026 00:33:02 -0500 Subject: [PATCH] fixed backslashes not being stripped for special characters in double quotes --- src/expand.rs | 11 +++++-- src/tests/expand.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/expand.rs b/src/expand.rs index 90ef098..42f74a0 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -909,9 +909,16 @@ pub fn unescape_str(raw: &str) -> String { while let Some(q_ch) = chars.next() { match q_ch { '\\' => { - result.push(q_ch); if let Some(next_ch) = chars.next() { - result.push(next_ch) + match next_ch { + '"' | '\\' | '`' | '$' => { + // discard the backslash + } + _ => { + result.push(q_ch); + } + } + result.push(next_ch); } } '$' => { diff --git a/src/tests/expand.rs b/src/tests/expand.rs index 88f845d..2d79480 100644 --- a/src/tests/expand.rs +++ b/src/tests/expand.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::expand::perform_param_expansion; +use crate::expand::{perform_param_expansion, DUB_QUOTE, VAR_SUB}; use crate::state::VarFlags; use super::*; @@ -286,3 +286,77 @@ fn param_expansion_replacesuffix() { let result = perform_param_expansion("foo/%o/X").unwrap(); assert_eq!(result, "foX"); } + +// ============================================================================ +// Double-Quote Escape Tests (POSIX) +// ============================================================================ + +#[test] +fn dquote_escape_dollar() { + // "\$foo" should strip backslash, produce literal $foo (no expansion) + let result = unescape_str(r#""\$foo""#); + assert!(!result.contains(VAR_SUB), "Escaped $ should not become VAR_SUB"); + assert!(result.contains('$'), "Literal $ should be preserved"); + assert!(!result.contains('\\'), "Backslash should be stripped"); +} + +#[test] +fn dquote_escape_backslash() { + // "\\" in double quotes should produce a single backslash + let result = unescape_str(r#""\\""#); + let inner: String = result.chars() + .filter(|&c| c != DUB_QUOTE) + .collect(); + assert_eq!(inner, "\\", "Double backslash should produce single backslash"); +} + +#[test] +fn dquote_escape_quote() { + // "\"" should produce a literal double quote + let result = unescape_str(r#""\"""#); + let inner: String = result.chars() + .filter(|&c| c != DUB_QUOTE) + .collect(); + assert!(inner.contains('"'), "Escaped quote should produce literal quote"); +} + +#[test] +fn dquote_escape_backtick() { + // "\`" should strip backslash, produce literal backtick + let result = unescape_str(r#""\`""#); + let inner: String = result.chars() + .filter(|&c| c != DUB_QUOTE) + .collect(); + assert_eq!(inner, "`", "Escaped backtick should produce literal backtick"); +} + +#[test] +fn dquote_escape_nonspecial_preserves_backslash() { + // "\a" inside double quotes should preserve the backslash (a is not special) + let result = unescape_str(r#""\a""#); + let inner: String = result.chars() + .filter(|&c| c != DUB_QUOTE) + .collect(); + assert_eq!(inner, "\\a", "Backslash before non-special char should be preserved"); +} + +#[test] +fn dquote_unescaped_dollar_expands() { + // "$foo" inside double quotes should produce VAR_SUB (expansion marker) + let result = unescape_str(r#""$foo""#); + assert!(result.contains(VAR_SUB), "Unescaped $ should become VAR_SUB"); +} + +#[test] +fn dquote_mixed_escapes() { + // "hello \$world \\end" should have literal $, single backslash + let result = unescape_str(r#""hello \$world \\end""#); + assert!(!result.contains(VAR_SUB), "Escaped $ should not expand"); + assert!(result.contains('$'), "Literal $ should be in output"); + // Should have exactly one backslash (from \\) + let inner: String = result.chars() + .filter(|&c| c != DUB_QUOTE) + .collect(); + let backslash_count = inner.chars().filter(|&c| c == '\\').count(); + assert_eq!(backslash_count, 1, "\\\\ should produce one backslash"); +}