pub fn escape_kotlin_string(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'"' => Some("\\\""),
'$' => Some("\\$"),
'\n' => Some("\\n"),
'\r' => Some("\\r"),
'\t' => Some("\\t"),
_ => None,
})
}
pub fn escape_ruby_double_quoted(sql: &str) -> String {
escape_interpolating_double_quoted(sql, '#')
}
pub fn escape_elixir_double_quoted(sql: &str) -> String {
escape_interpolating_double_quoted(sql, '#')
}
fn escape_interpolating_double_quoted(sql: &str, marker: char) -> String {
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c if c == marker && chars.peek() == Some(&'{') => {
out.push('\\');
out.push(marker);
}
_ => out.push(ch),
}
}
out
}
pub fn escape_php_single_quoted(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'\'' => Some("\\'"),
_ => None,
})
}
pub fn escape_java_string(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'"' => Some("\\\""),
'\n' => Some("\\n"),
'\r' => Some("\\r"),
'\t' => Some("\\t"),
_ => None,
})
}
pub fn escape_go_interpreted_string(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'"' => Some("\\\""),
'\n' => Some("\\n"),
'\r' => Some("\\r"),
'\t' => Some("\\t"),
_ => None,
})
}
pub fn escape_csharp_verbatim_string(sql: &str) -> String {
sql.replace('"', "\"\"")
}
pub fn escape_python_triple_double(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'"' => Some("\\\""),
_ => None,
})
}
pub fn double_percent_for_percent_paramstyle(sql: &str) -> String {
sql.replace('%', "%%")
}
pub fn escape_rust_string(sql: &str) -> String {
escape_char_by_char(sql, |ch| match ch {
'\\' => Some("\\\\"),
'"' => Some("\\\""),
_ => None,
})
}
pub fn rust_raw_string_literal(sql: &str) -> String {
let mut hashes = 1usize;
loop {
let marker = format!("\"{}", "#".repeat(hashes));
if !sql.contains(&marker) {
break;
}
hashes += 1;
}
let delim = "#".repeat(hashes);
format!("r{delim}\"{sql}\"{delim}")
}
fn escape_char_by_char(sql: &str, substitute: impl Fn(char) -> Option<&'static str>) -> String {
let mut out = String::with_capacity(sql.len());
for ch in sql.chars() {
match substitute(ch) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn adversarial_corpus() -> Vec<&'static str> {
vec![
"",
"$var",
"${x}",
"#{x}",
"`backtick`",
"single'quote",
"double\"quote",
"back\\slash",
"double\\\\backslash",
"new\nline",
"tab\tchar",
"carriage\rreturn",
"quote\"then\\backslash",
"backslash\\then\"quote",
"\"#",
"\"##",
"café",
"日本語",
"SELECT * FROM t WHERE name = 'O''Brien'",
"SELECT \"type\", \"class\" FROM items WHERE note = 'a\\_b%' AND tag = '$name' AND memo = '#{x}'",
]
}
fn unescape_kotlin(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('$') => out.push('$'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some(other) => panic!("unexpected Kotlin escape \\{other}"),
None => panic!("dangling backslash"),
}
} else {
out.push(ch);
}
}
out
}
#[test]
fn kotlin_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_kotlin_string(input);
assert_eq!(unescape_kotlin(&escaped), input, "input: {input:?} -> {escaped:?}");
}
}
#[test]
fn kotlin_escape_neutralizes_the_176_injection_shape() {
let sql = "SELECT id, name FROM users WHERE name = $1 AND data::text = 'literal-$name-marker'";
let escaped = escape_kotlin_string(sql);
assert!(
!escaped.contains("$name") || escaped.contains("\\$name"),
"a bare, unescaped $name must never reach the Kotlin literal: {escaped:?}"
);
assert!(
escaped.contains("\\$name"),
"expected \\$name (Kotlin's escape for a literal dollar sign), got: {escaped:?}"
);
assert_eq!(unescape_kotlin(&escaped), sql);
}
#[test]
fn kotlin_dollar_digit_is_not_kotlin_template_syntax_but_is_still_escaped() {
let escaped = escape_kotlin_string("WHERE id = $1");
assert_eq!(escaped, "WHERE id = \\$1");
}
fn unescape_hash_brace_interpolated(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 == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('#') => out.push('#'),
Some(other) => panic!("unexpected escape \\{other}"),
None => panic!("dangling backslash"),
}
} else {
out.push(ch);
}
}
out
}
#[test]
fn ruby_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_ruby_double_quoted(input);
assert_eq!(
unescape_hash_brace_interpolated(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
#[test]
fn ruby_escape_neutralizes_hash_brace_interpolation() {
let escaped = escape_ruby_double_quoted("literal-#{name}-marker");
assert!(
escaped.contains("\\#{"),
"expected an escaped #{{ opener, got: {escaped:?}"
);
assert!(!escaped.contains("marker-#{") && escaped.starts_with("literal-\\#{"));
}
#[test]
fn ruby_escape_leaves_bare_hash_alone() {
assert_eq!(escape_ruby_double_quoted("a#b"), "a#b");
}
#[test]
fn elixir_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_elixir_double_quoted(input);
assert_eq!(
unescape_hash_brace_interpolated(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
fn unescape_php_single_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('\'') => out.push('\''),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
out
}
#[test]
fn php_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_php_single_quoted(input);
assert_eq!(
unescape_php_single_quoted(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
#[test]
fn php_single_quoted_never_interpolates_a_dollar_variable() {
let escaped = escape_php_single_quoted("literal-$conn-marker");
assert_eq!(escaped, "literal-$conn-marker");
}
fn unescape_backslash_quote_control(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some(other) => panic!("unexpected escape \\{other}"),
None => panic!("dangling backslash"),
}
} else {
out.push(ch);
}
}
out
}
#[test]
fn java_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_java_string(input);
assert_eq!(
unescape_backslash_quote_control(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
#[test]
fn go_escape_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_go_interpreted_string(input);
assert_eq!(
unescape_backslash_quote_control(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
#[test]
fn java_escape_handles_quoted_identifier() {
let escaped = escape_java_string("SELECT \"type\", \"class\" FROM items");
assert!(escaped.contains("\\\"type\\\""));
assert!(
!escaped.contains("\"type\","),
"the raw quote must not survive unescaped"
);
}
#[test]
fn go_escape_handles_backslash_in_like_pattern() {
let escaped = escape_go_interpreted_string("LIKE 'a\\_b%'");
assert_eq!(escaped, "LIKE 'a\\\\_b%'");
}
fn unescape_csharp_verbatim(s: &str) -> String {
s.replace("\"\"", "\"")
}
#[test]
fn csharp_verbatim_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_csharp_verbatim_string(input);
assert_eq!(
unescape_csharp_verbatim(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
#[test]
fn csharp_verbatim_leaves_backslash_literal() {
assert_eq!(escape_csharp_verbatim_string("a\\_b"), "a\\_b");
}
#[test]
fn python_triple_double_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_python_triple_double(input);
assert_eq!(
unescape_backslash_quote_control_no_nrt(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
fn unescape_backslash_quote_control_no_nrt(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => panic!("unexpected escape \\{other}"),
None => panic!("dangling backslash"),
}
} else {
out.push(ch);
}
}
out
}
#[test]
fn python_triple_double_preserves_raw_newline() {
let escaped = escape_python_triple_double("line1\nline2");
assert_eq!(
escaped, "line1\nline2",
"python triple-quoted strings permit raw newlines"
);
}
#[test]
fn double_percent_doubles_a_lone_percent() {
assert_eq!(double_percent_for_percent_paramstyle("LIKE '100%'"), "LIKE '100%%'");
}
#[test]
fn double_percent_doubles_every_occurrence() {
assert_eq!(
double_percent_for_percent_paramstyle("a%b%c"),
"a%%b%%c",
"every literal % must be doubled, not just the first"
);
}
#[test]
fn double_percent_is_a_no_op_when_no_percent_present() {
let sql = "SELECT id FROM users WHERE id = $1";
assert_eq!(double_percent_for_percent_paramstyle(sql), sql);
}
#[test]
fn double_percent_leaves_dollar_placeholders_untouched() {
let sql = "SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%'";
assert_eq!(
double_percent_for_percent_paramstyle(sql),
"SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%%'"
);
}
#[test]
fn rust_plain_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let escaped = escape_rust_string(input);
assert_eq!(
unescape_backslash_quote_control_no_nrt(&escaped),
input,
"input: {input:?} -> {escaped:?}"
);
}
}
fn unwrap_rust_raw_string_literal(literal: &str) -> &str {
let rest = literal.strip_prefix('r').expect("must start with r");
let hashes = rest.chars().take_while(|&c| c == '#').count();
let delim = &rest[..hashes];
let opened = &rest[hashes..];
let opened = opened.strip_prefix('"').expect("must open with a quote");
let closer = format!("\"{delim}");
opened
.strip_suffix(&closer)
.expect("must close with matching delimiter")
}
#[test]
fn rust_raw_string_round_trips_over_adversarial_corpus() {
for input in adversarial_corpus() {
let literal = rust_raw_string_literal(input);
assert_eq!(
unwrap_rust_raw_string_literal(&literal),
input,
"input: {input:?} -> {literal:?}"
);
}
}
#[test]
fn rust_raw_string_widens_delimiter_when_sql_contains_hash_quote() {
let sql = "a\"#b";
let literal = rust_raw_string_literal(sql);
assert_eq!(literal, "r##\"a\"#b\"##");
assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
}
#[test]
fn rust_raw_string_plain_sql_uses_single_hash() {
let literal = rust_raw_string_literal("SELECT 1");
assert_eq!(literal, "r#\"SELECT 1\"#");
}
#[test]
fn rust_raw_string_widens_again_for_double_hash() {
let sql = "a\"##b";
let literal = rust_raw_string_literal(sql);
assert_eq!(literal, "r###\"a\"##b\"###");
assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
}
}