scythe_codegen/sql_literal.rs
1//! Escape SQL text for safe splicing into a target-language string literal.
2//!
3//! Every backend embeds the query's SQL text directly into the generated source as a
4//! string literal (`conn.prepare("SELECT ...")`, `sqlx::query!("SELECT ...")`, and so on).
5//! Before this module existed, only the TypeScript backends escaped that text
6//! ([`escape_ts_template_literal`](crate::backends::typescript_common::escape_ts_template_literal) and
7//! [`escape_ts_double_quoted_literal`](crate::backends::typescript_common::escape_ts_double_quoted_literal)); every
8//! other backend spliced the cleaned SQL straight into a host literal with no escaping at
9//! all. Two distinct failure classes follow from that:
10//!
11//! - **Injection.** Kotlin's non-raw `"..."` string, Ruby's `"..."`, and Elixir's `"..."`
12//! are all *interpolating* literals: `$name`, `#{name}`, and `#{name}` respectively splice
13//! a same-scope variable's runtime value into the string. When SQL text contains one of
14//! those sequences and it happens to name an in-scope parameter, the generated code
15//! compiles cleanly and substitutes caller-controlled data directly into the SQL text --
16//! bypassing the `?`/`$N` parameter binding entirely. This is the root cause of the
17//! Kotlin injection tracked as issue #176.
18//! - **Silent corruption or a hard compile break.** The remaining host forms (Go, Java, C#,
19//! PHP, Python, Rust, and the raw-string variants) do not interpolate, but an unescaped
20//! backslash or embedded quote either changes what the database receives (a `LIKE
21//! 'a\_b%'` backslash silently becomes a real escape) or breaks the literal outright (a
22//! quoted SQL identifier like `SELECT "type"` terminates the host string early).
23//!
24//! One function per **host literal form** lives here, not one per backend, because the
25//! literal form -- not the driver -- determines what needs escaping. Every function takes
26//! the already-cleaned SQL text (post `clean_sql`/`clean_sql_oneline`/placeholder rewriting)
27//! and returns text safe to splice verbatim between that host form's delimiters. None of
28//! them touch delimiters themselves except [`rust_raw_string_literal`], which must choose
29//! its own delimiter width.
30//!
31//! Only literal forms actually emitted by a backend in this crate are implemented here --
32//! see each function's doc comment for which backends call it.
33
34/// Escape SQL text for splicing into a Kotlin non-raw `"..."` string literal.
35///
36/// Kotlin non-raw strings interpolate on `$identifier` and `${expression}`; left
37/// unescaped, SQL text containing `$name` where `name` matches an in-scope parameter
38/// compiles cleanly and substitutes the parameter's runtime value directly into the SQL
39/// text, bypassing the `?` binding entirely (issue #176). `\$` is Kotlin's own escape for a
40/// literal dollar sign, so escaping it here is not a workaround -- it is how Kotlin spells
41/// "a dollar sign that is not a template".
42///
43/// Also escapes backslash, the closing quote, and the three control characters
44/// (newline, carriage return, tab) a non-raw Kotlin string cannot contain unescaped.
45/// Used by `kotlin-jdbc`, `kotlin-r2dbc`, and `kotlin-exposed`.
46pub fn escape_kotlin_string(sql: &str) -> String {
47 escape_char_by_char(sql, |ch| match ch {
48 '\\' => Some("\\\\"),
49 '"' => Some("\\\""),
50 '$' => Some("\\$"),
51 '\n' => Some("\\n"),
52 '\r' => Some("\\r"),
53 '\t' => Some("\\t"),
54 _ => None,
55 })
56}
57
58/// Escape SQL text for splicing into a Ruby double-quoted `"..."` string literal.
59///
60/// Ruby double-quoted strings interpolate on `#{expression}`; SQL text containing that
61/// sequence would otherwise splice an in-scope variable's value into the query the same
62/// way Kotlin's `$name` does. Ruby double-quoted strings freely contain literal newlines
63/// and tabs, so only backslash, the closing quote, and a literal `#{` need escaping.
64///
65/// Used by `ruby-pg`, `ruby-sqlite3`, `ruby-mysql2`, `ruby-oci8`, `ruby-trilogy`, and
66/// `ruby-tiny-tds` (on the raw SQL text, before that backend's own `#{client.escape(...)}`
67/// parameter interpolations are spliced in -- see that backend for why order matters there).
68pub fn escape_ruby_double_quoted(sql: &str) -> String {
69 escape_interpolating_double_quoted(sql, '#')
70}
71
72/// Escape SQL text for splicing into an Elixir double-quoted `"..."` string literal.
73///
74/// Elixir double-quoted strings interpolate on `#{expression}`, identically to Ruby.
75/// Used by `elixir-postgrex`, `elixir-ecto`, `elixir-myxql`, `elixir-exqlite`, `elixir-tds`,
76/// and `elixir-jamdb`.
77pub fn escape_elixir_double_quoted(sql: &str) -> String {
78 escape_interpolating_double_quoted(sql, '#')
79}
80
81/// Shared implementation for host literals that interpolate on `<marker>{expression}`
82/// (Ruby and Elixir both use `#{...}`). Escapes backslash, the closing double quote, and
83/// the two-character interpolation opener; leaves literal newlines and tabs untouched,
84/// since both hosts permit them unescaped inside a double-quoted string.
85fn escape_interpolating_double_quoted(sql: &str, marker: char) -> String {
86 let mut out = String::with_capacity(sql.len());
87 let mut chars = sql.chars().peekable();
88 while let Some(ch) = chars.next() {
89 match ch {
90 '\\' => out.push_str("\\\\"),
91 '"' => out.push_str("\\\""),
92 c if c == marker && chars.peek() == Some(&'{') => {
93 out.push('\\');
94 out.push(marker);
95 }
96 _ => out.push(ch),
97 }
98 }
99 out
100}
101
102/// Escape SQL text for splicing into a PHP single-quoted `'...'` string literal.
103///
104/// PHP backends (`php-pdo`, `php-amphp`) previously spliced SQL into a *double*-quoted
105/// PHP string, which interpolates on `$name` and `{$expr}` the same way Kotlin's non-raw
106/// strings do -- SQL text containing `$conn` or a same-named in-scope parameter would
107/// substitute that variable's runtime value into the query text. PHP single-quoted strings
108/// do not interpolate at all (the only two escapes they recognize are `\\` and `\'`), so
109/// switching the emitted literal to single-quoted and escaping only those two characters
110/// closes the injection class without needing to hunt for every interpolation trigger.
111/// Literal newlines and tabs pass through unescaped, since PHP single-quoted strings permit
112/// them.
113pub fn escape_php_single_quoted(sql: &str) -> String {
114 escape_char_by_char(sql, |ch| match ch {
115 '\\' => Some("\\\\"),
116 '\'' => Some("\\'"),
117 _ => None,
118 })
119}
120
121/// Escape SQL text for splicing into a Java plain `"..."` string literal.
122///
123/// Java string literals do not interpolate, but a raw newline, backslash, or double quote
124/// each either fails to compile or changes what the database receives. Used by `java-jdbc`
125/// and `java-r2dbc`, both of which pre-flatten SQL to one line via `clean_sql_oneline`, but
126/// the newline/tab/carriage-return escapes here make the function correct independent of
127/// that upstream choice.
128pub fn escape_java_string(sql: &str) -> String {
129 escape_char_by_char(sql, |ch| match ch {
130 '\\' => Some("\\\\"),
131 '"' => Some("\\\""),
132 '\n' => Some("\\n"),
133 '\r' => Some("\\r"),
134 '\t' => Some("\\t"),
135 _ => None,
136 })
137}
138
139/// Escape SQL text for splicing into a Go interpreted `"..."` string literal.
140///
141/// Identical escape set to [`escape_java_string`] -- Go interpreted strings share the same
142/// restrictions (no raw newline, backslash and quote must be escaped). Used by `go-pgx`,
143/// `go-database-sql`, `go-godror`, and `go-gosnowflake`.
144pub fn escape_go_interpreted_string(sql: &str) -> String {
145 escape_char_by_char(sql, |ch| match ch {
146 '\\' => Some("\\\\"),
147 '"' => Some("\\\""),
148 '\n' => Some("\\n"),
149 '\r' => Some("\\r"),
150 '\t' => Some("\\t"),
151 _ => None,
152 })
153}
154
155/// Escape SQL text for splicing into a C# verbatim `@"..."` string literal.
156///
157/// The C# backends previously used a *regular* `"..."` string with no escaping at all,
158/// which breaks on both an embedded quote and a backslash (`LIKE 'a\_b%'` fails to compile
159/// outright, since `\_` is not a recognized regular-string escape). Verbatim strings sidestep
160/// backslash entirely -- it is literal there, exactly matching what SQL text needs -- and
161/// permit raw newlines, so the only escape required is doubling an embedded `"` to `""`.
162/// Used by all six C# backends (`csharp-npgsql`, `csharp-sqlclient`, `csharp-mysqlconnector`,
163/// `csharp-microsoft-sqlite`, `csharp-oracle`, `csharp-snowflake`).
164pub fn escape_csharp_verbatim_string(sql: &str) -> String {
165 sql.replace('"', "\"\"")
166}
167
168/// Escape SQL text for splicing into a Python triple-double-quoted `"""..."""` string
169/// literal.
170///
171/// Triple-quoted strings permit raw newlines and tabs, so only backslash and `"` need
172/// escaping; escaping every `"` (not just runs of three) also means a lone or doubled
173/// quote can never accidentally combine with the literal's own delimiter. Used by
174/// `python-asyncpg`, `python-psycopg3`, `python-aiosqlite`, `python-aiomysql`,
175/// `python-duckdb`, and (via `python_common::write_execute_call`) `python-oracledb`,
176/// `python-snowflake`, and `python-pyodbc`.
177pub fn escape_python_triple_double(sql: &str) -> String {
178 escape_char_by_char(sql, |ch| match ch {
179 '\\' => Some("\\\\"),
180 '"' => Some("\\\""),
181 _ => None,
182 })
183}
184
185/// Double every literal `%` in SQL text destined for a Python `%`-paramstyle DB-API driver
186/// (psycopg3's `%(name)s`, aiomysql's/PyMySQL's `%s`).
187///
188/// Both drivers apply Python `%`-style string formatting to the *entire* SQL string at
189/// execute time, not just to the placeholder tokens the generator emits. A literal `%`
190/// anywhere in the query -- a `LIKE '100%'` pattern, a `to_char(..., 'HH24%')` format
191/// string -- is therefore read by the driver as the start of a conversion specifier: `%'`
192/// is not a valid one, so psycopg raises `ValueError: unsupported format character` at
193/// execute time, and PyMySQL either raises the same way or silently mis-binds (issue
194/// #201). Doubling `%` to `%%` is the driver's own escape for a literal percent, identical
195/// to Python's own `%` operator.
196///
197/// Callers MUST run this over the *raw* SQL text -- after `clean_sql`/
198/// `clean_sql_with_optional`, before [`crate::backends::rewrite_pg_placeholders`] rewrites
199/// placeholders to their `%`-paramstyle form. Doing it in the other order would double the
200/// `%` in every placeholder this function's caller is about to emit (`%(id)s` becomes
201/// `%%(id)s`, which the driver then reads as a literal percent followed by literal text
202/// `(id)s`, not a binding) and corrupt every parameter. Because the source SQL at this
203/// point still uses `$N` or bare `?` placeholders -- never `%` -- every `%` found here is
204/// unambiguously literal SQL text, so a single unconditional `replace` is correct with no
205/// span-awareness needed, unlike the interpolation escaping elsewhere in this module.
206///
207/// Callers MUST ALSO apply this **only to a query that actually binds parameters**. Both
208/// drivers do the `%`-formatting pass inside `execute(query, params)` and skip it entirely
209/// for `execute(query)` -- psycopg3 documents `%` as not special when no parameters are
210/// passed, and PyMySQL simply never reaches `query % escaped_args` with `args=None`. So
211/// doubling a parameterless query's `%` is not a no-op: the doubled text reaches the server
212/// verbatim, and `LIKE '100%%'` matches a literal percent sign instead of acting as a
213/// wildcard. That is the same silent wrong answer #201 is about, merely inverted, which is
214/// why the backends gate the call on a non-empty parameter list rather than applying it
215/// unconditionally.
216pub fn double_percent_for_percent_paramstyle(sql: &str) -> String {
217 sql.replace('%', "%%")
218}
219
220/// Escape SQL text for splicing into a Rust plain `"..."` string literal.
221///
222/// Rust string literals permit raw newlines, so only backslash and `"` need escaping.
223/// Used by `rust-sibyl` and `rust-sqlx` (the latter's `sqlx::query!`/`query_as!` macros
224/// receive the *decoded* string value at compile time, identical to the unescaped source
225/// text, so escaping here has no effect on the macro's compile-time query verification).
226pub fn escape_rust_string(sql: &str) -> String {
227 escape_char_by_char(sql, |ch| match ch {
228 '\\' => Some("\\\\"),
229 '"' => Some("\\\""),
230 _ => None,
231 })
232}
233
234/// Build a complete Rust raw string literal (`r#"..."#`, widened as needed) containing
235/// `sql` verbatim.
236///
237/// Raw strings cannot escape anything -- that is the point of using one -- so a SQL body
238/// containing the delimiter sequence `"#` breaks a fixed `r#"..."#` literal outright
239/// (issue #179's `rust-tokio-postgres`/`rust-tiberius` case: `"#` anywhere in the query,
240/// including inside an ordinary comment or string, ends the literal early and leaves the
241/// rest as malformed Rust). This widens the hash run only as far as the content requires:
242/// plain SQL gets the same `r#"..."#` these backends always emitted, and only a query that
243/// actually contains `"#`, `"##`, ... pays for a wider delimiter.
244///
245/// Returns the full literal, delimiters included, ready to splice directly in place of a
246/// hand-written `r#"{}"#`.
247pub fn rust_raw_string_literal(sql: &str) -> String {
248 let mut hashes = 1usize;
249 loop {
250 let marker = format!("\"{}", "#".repeat(hashes));
251 if !sql.contains(&marker) {
252 break;
253 }
254 hashes += 1;
255 }
256 let delim = "#".repeat(hashes);
257 format!("r{delim}\"{sql}\"{delim}")
258}
259
260/// Shared implementation for host literals whose escape set is a fixed per-character
261/// substitution table with no multi-character lookahead (i.e. everything except the
262/// `#{`/`${`-style interpolation escapes, which need [`escape_interpolating_double_quoted`]
263/// or their own bespoke pass).
264///
265/// `substitute` returns `Some(replacement)` when it handled `ch` itself, `None` to fall
266/// through to pushing `ch` unchanged. Iterating character-by-character rather than chaining
267/// `str::replace` calls sidesteps the classic bug where an earlier pass's *output*
268/// accidentally matches a later pass's pattern (e.g. escaping `"` before `\` would turn `"`
269/// into `\"`, and the backslash pass would then re-escape the very backslash the quote pass
270/// just inserted).
271fn escape_char_by_char(sql: &str, substitute: impl Fn(char) -> Option<&'static str>) -> String {
272 let mut out = String::with_capacity(sql.len());
273 for ch in sql.chars() {
274 match substitute(ch) {
275 Some(replacement) => out.push_str(replacement),
276 None => out.push(ch),
277 }
278 }
279 out
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 /// Adversarial corpus shared by every round-trip test below: interpolation markers for
287 /// every interpolating host, the raw-string-breaking sequence, quote/backslash pairs a
288 /// naive single-pass replace gets wrong, whitespace control characters, non-ASCII text,
289 /// and a full SQL fragment with a doubled-apostrophe string literal.
290 fn adversarial_corpus() -> Vec<&'static str> {
291 vec![
292 "",
293 "$var",
294 "${x}",
295 "#{x}",
296 "`backtick`",
297 "single'quote",
298 "double\"quote",
299 "back\\slash",
300 "double\\\\backslash",
301 "new\nline",
302 "tab\tchar",
303 "carriage\rreturn",
304 "quote\"then\\backslash",
305 "backslash\\then\"quote",
306 "\"#",
307 "\"##",
308 "café",
309 "日本語",
310 "SELECT * FROM t WHERE name = 'O''Brien'",
311 "SELECT \"type\", \"class\" FROM items WHERE note = 'a\\_b%' AND tag = '$name' AND memo = '#{x}'",
312 ]
313 }
314
315 // --- Kotlin ---------------------------------------------------------
316
317 /// Reference unescaper matching Kotlin's own string-literal grammar: `\\`, `\"`, `\$`,
318 /// `\n`, `\r`, `\t` are the only escapes [`escape_kotlin_string`] ever emits.
319 fn unescape_kotlin(s: &str) -> String {
320 let mut out = String::with_capacity(s.len());
321 let mut chars = s.chars();
322 while let Some(ch) = chars.next() {
323 if ch == '\\' {
324 match chars.next() {
325 Some('\\') => out.push('\\'),
326 Some('"') => out.push('"'),
327 Some('$') => out.push('$'),
328 Some('n') => out.push('\n'),
329 Some('r') => out.push('\r'),
330 Some('t') => out.push('\t'),
331 Some(other) => panic!("unexpected Kotlin escape \\{other}"),
332 None => panic!("dangling backslash"),
333 }
334 } else {
335 out.push(ch);
336 }
337 }
338 out
339 }
340
341 #[test]
342 fn kotlin_escape_round_trips_over_adversarial_corpus() {
343 for input in adversarial_corpus() {
344 let escaped = escape_kotlin_string(input);
345 assert_eq!(unescape_kotlin(&escaped), input, "input: {input:?} -> {escaped:?}");
346 }
347 }
348
349 /// Direct regression test for issue #176: SQL containing `$name`, where `name` is also
350 /// the emitted parameter's identifier, must not produce a Kotlin string that
351 /// interpolates it. This is the exact shape of the reported injection: a literal `$`
352 /// followed by an identifier equal to an in-scope variable name.
353 #[test]
354 fn kotlin_escape_neutralizes_the_176_injection_shape() {
355 let sql = "SELECT id, name FROM users WHERE name = $1 AND data::text = 'literal-$name-marker'";
356 let escaped = escape_kotlin_string(sql);
357 assert!(
358 !escaped.contains("$name") || escaped.contains("\\$name"),
359 "a bare, unescaped $name must never reach the Kotlin literal: {escaped:?}"
360 );
361 assert!(
362 escaped.contains("\\$name"),
363 "expected \\$name (Kotlin's escape for a literal dollar sign), got: {escaped:?}"
364 );
365 // Round-trips back to the exact original SQL, byte for byte.
366 assert_eq!(unescape_kotlin(&escaped), sql);
367 }
368
369 #[test]
370 fn kotlin_dollar_digit_is_not_kotlin_template_syntax_but_is_still_escaped() {
371 // $1 is never a valid Kotlin identifier start (digits cannot begin one), so Kotlin
372 // itself treats it literally either way -- but escaping it is still correct and
373 // keeps the function's behavior uniform regardless of what follows `$`.
374 let escaped = escape_kotlin_string("WHERE id = $1");
375 assert_eq!(escaped, "WHERE id = \\$1");
376 }
377
378 // --- Ruby / Elixir (#{...} interpolation) ---------------------------
379
380 fn unescape_hash_brace_interpolated(s: &str) -> String {
381 let mut out = String::with_capacity(s.len());
382 let mut chars = s.chars().peekable();
383 while let Some(ch) = chars.next() {
384 if ch == '\\' {
385 match chars.next() {
386 Some('\\') => out.push('\\'),
387 Some('"') => out.push('"'),
388 Some('#') => out.push('#'),
389 Some(other) => panic!("unexpected escape \\{other}"),
390 None => panic!("dangling backslash"),
391 }
392 } else {
393 out.push(ch);
394 }
395 }
396 out
397 }
398
399 #[test]
400 fn ruby_escape_round_trips_over_adversarial_corpus() {
401 for input in adversarial_corpus() {
402 let escaped = escape_ruby_double_quoted(input);
403 assert_eq!(
404 unescape_hash_brace_interpolated(&escaped),
405 input,
406 "input: {input:?} -> {escaped:?}"
407 );
408 }
409 }
410
411 #[test]
412 fn ruby_escape_neutralizes_hash_brace_interpolation() {
413 let escaped = escape_ruby_double_quoted("literal-#{name}-marker");
414 assert!(
415 escaped.contains("\\#{"),
416 "expected an escaped #{{ opener, got: {escaped:?}"
417 );
418 assert!(!escaped.contains("marker-#{") && escaped.starts_with("literal-\\#{"));
419 }
420
421 /// A `#` not immediately followed by `{` is not an interpolation opener and must pass
422 /// through unescaped, e.g. an ordinary `#` in a comment-like SQL fragment.
423 #[test]
424 fn ruby_escape_leaves_bare_hash_alone() {
425 assert_eq!(escape_ruby_double_quoted("a#b"), "a#b");
426 }
427
428 #[test]
429 fn elixir_escape_round_trips_over_adversarial_corpus() {
430 for input in adversarial_corpus() {
431 let escaped = escape_elixir_double_quoted(input);
432 assert_eq!(
433 unescape_hash_brace_interpolated(&escaped),
434 input,
435 "input: {input:?} -> {escaped:?}"
436 );
437 }
438 }
439
440 // --- PHP --------------------------------------------------------------
441
442 fn unescape_php_single_quoted(s: &str) -> String {
443 let mut out = String::with_capacity(s.len());
444 let mut chars = s.chars();
445 while let Some(ch) = chars.next() {
446 if ch == '\\' {
447 match chars.next() {
448 Some('\\') => out.push('\\'),
449 Some('\'') => out.push('\''),
450 // PHP single-quoted strings pass any other backslash through literally
451 // (e.g. `\n` stays the two characters backslash-n).
452 Some(other) => {
453 out.push('\\');
454 out.push(other);
455 }
456 None => out.push('\\'),
457 }
458 } else {
459 out.push(ch);
460 }
461 }
462 out
463 }
464
465 #[test]
466 fn php_escape_round_trips_over_adversarial_corpus() {
467 for input in adversarial_corpus() {
468 let escaped = escape_php_single_quoted(input);
469 assert_eq!(
470 unescape_php_single_quoted(&escaped),
471 input,
472 "input: {input:?} -> {escaped:?}"
473 );
474 }
475 }
476
477 #[test]
478 fn php_single_quoted_never_interpolates_a_dollar_variable() {
479 // The point of switching to single-quoted: `$conn`/`$name` must reach the literal
480 // as inert text, not a variable reference. Single-quoted PHP performs no
481 // interpolation at all, so no escaping of `$` is needed for that guarantee to hold
482 // -- this test documents that the chosen literal form, not a `$` escape, is what
483 // closes the PHP interpolation class.
484 let escaped = escape_php_single_quoted("literal-$conn-marker");
485 assert_eq!(escaped, "literal-$conn-marker");
486 }
487
488 // --- Java / Go (shared escape set) -------------------------------------
489
490 fn unescape_backslash_quote_control(s: &str) -> String {
491 let mut out = String::with_capacity(s.len());
492 let mut chars = s.chars();
493 while let Some(ch) = chars.next() {
494 if ch == '\\' {
495 match chars.next() {
496 Some('\\') => out.push('\\'),
497 Some('"') => out.push('"'),
498 Some('n') => out.push('\n'),
499 Some('r') => out.push('\r'),
500 Some('t') => out.push('\t'),
501 Some(other) => panic!("unexpected escape \\{other}"),
502 None => panic!("dangling backslash"),
503 }
504 } else {
505 out.push(ch);
506 }
507 }
508 out
509 }
510
511 #[test]
512 fn java_escape_round_trips_over_adversarial_corpus() {
513 for input in adversarial_corpus() {
514 let escaped = escape_java_string(input);
515 assert_eq!(
516 unescape_backslash_quote_control(&escaped),
517 input,
518 "input: {input:?} -> {escaped:?}"
519 );
520 }
521 }
522
523 #[test]
524 fn go_escape_round_trips_over_adversarial_corpus() {
525 for input in adversarial_corpus() {
526 let escaped = escape_go_interpreted_string(input);
527 assert_eq!(
528 unescape_backslash_quote_control(&escaped),
529 input,
530 "input: {input:?} -> {escaped:?}"
531 );
532 }
533 }
534
535 /// Direct regression test for the "loud case" in issue #150/#179: a quoted SQL
536 /// identifier must not terminate the host literal early.
537 #[test]
538 fn java_escape_handles_quoted_identifier() {
539 let escaped = escape_java_string("SELECT \"type\", \"class\" FROM items");
540 assert!(escaped.contains("\\\"type\\\""));
541 assert!(
542 !escaped.contains("\"type\","),
543 "the raw quote must not survive unescaped"
544 );
545 }
546
547 #[test]
548 fn go_escape_handles_backslash_in_like_pattern() {
549 let escaped = escape_go_interpreted_string("LIKE 'a\\_b%'");
550 assert_eq!(escaped, "LIKE 'a\\\\_b%'");
551 }
552
553 // --- C# -----------------------------------------------------------------
554
555 fn unescape_csharp_verbatim(s: &str) -> String {
556 s.replace("\"\"", "\"")
557 }
558
559 #[test]
560 fn csharp_verbatim_round_trips_over_adversarial_corpus() {
561 for input in adversarial_corpus() {
562 let escaped = escape_csharp_verbatim_string(input);
563 assert_eq!(
564 unescape_csharp_verbatim(&escaped),
565 input,
566 "input: {input:?} -> {escaped:?}"
567 );
568 }
569 }
570
571 #[test]
572 fn csharp_verbatim_leaves_backslash_literal() {
573 // Verbatim strings treat `\` as an ordinary character, exactly matching what a SQL
574 // `LIKE 'a\_b%'` pattern needs -- no escaping required, and the round-trip test
575 // above with `back\slash` confirms the value survives unchanged.
576 assert_eq!(escape_csharp_verbatim_string("a\\_b"), "a\\_b");
577 }
578
579 // --- Python ---------------------------------------------------------
580
581 #[test]
582 fn python_triple_double_round_trips_over_adversarial_corpus() {
583 for input in adversarial_corpus() {
584 let escaped = escape_python_triple_double(input);
585 assert_eq!(
586 unescape_backslash_quote_control_no_nrt(&escaped),
587 input,
588 "input: {input:?} -> {escaped:?}"
589 );
590 }
591 }
592
593 /// Like [`unescape_backslash_quote_control`], but only `\\` and `\"` are escapes --
594 /// [`escape_python_triple_double`] leaves real newlines/tabs untouched, so a decoder
595 /// must too.
596 fn unescape_backslash_quote_control_no_nrt(s: &str) -> String {
597 let mut out = String::with_capacity(s.len());
598 let mut chars = s.chars();
599 while let Some(ch) = chars.next() {
600 if ch == '\\' {
601 match chars.next() {
602 Some('\\') => out.push('\\'),
603 Some('"') => out.push('"'),
604 Some(other) => panic!("unexpected escape \\{other}"),
605 None => panic!("dangling backslash"),
606 }
607 } else {
608 out.push(ch);
609 }
610 }
611 out
612 }
613
614 #[test]
615 fn python_triple_double_preserves_raw_newline() {
616 let escaped = escape_python_triple_double("line1\nline2");
617 assert_eq!(
618 escaped, "line1\nline2",
619 "python triple-quoted strings permit raw newlines"
620 );
621 }
622
623 // --- Python %-paramstyle doubling (#201) -------------------------------
624
625 #[test]
626 fn double_percent_doubles_a_lone_percent() {
627 assert_eq!(double_percent_for_percent_paramstyle("LIKE '100%'"), "LIKE '100%%'");
628 }
629
630 #[test]
631 fn double_percent_doubles_every_occurrence() {
632 assert_eq!(
633 double_percent_for_percent_paramstyle("a%b%c"),
634 "a%%b%%c",
635 "every literal % must be doubled, not just the first"
636 );
637 }
638
639 #[test]
640 fn double_percent_is_a_no_op_when_no_percent_present() {
641 let sql = "SELECT id FROM users WHERE id = $1";
642 assert_eq!(double_percent_for_percent_paramstyle(sql), sql);
643 }
644
645 #[test]
646 fn double_percent_leaves_dollar_placeholders_untouched() {
647 // Regression guard for #201: doubling must run before placeholder rewriting, so at
648 // this stage the only placeholder syntax present is `$N` -- never `%`. This pins
649 // that a `$N` placeholder survives the pass unchanged.
650 let sql = "SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%'";
651 assert_eq!(
652 double_percent_for_percent_paramstyle(sql),
653 "SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%%'"
654 );
655 }
656
657 // --- Rust -------------------------------------------------------------
658
659 #[test]
660 fn rust_plain_round_trips_over_adversarial_corpus() {
661 for input in adversarial_corpus() {
662 let escaped = escape_rust_string(input);
663 assert_eq!(
664 unescape_backslash_quote_control_no_nrt(&escaped),
665 input,
666 "input: {input:?} -> {escaped:?}"
667 );
668 }
669 }
670
671 /// Extracts the SQL body back out of a full `rN#"..."N#` literal built by
672 /// [`rust_raw_string_literal`], for round-trip assertions.
673 fn unwrap_rust_raw_string_literal(literal: &str) -> &str {
674 let rest = literal.strip_prefix('r').expect("must start with r");
675 let hashes = rest.chars().take_while(|&c| c == '#').count();
676 let delim = &rest[..hashes];
677 let opened = &rest[hashes..];
678 let opened = opened.strip_prefix('"').expect("must open with a quote");
679 let closer = format!("\"{delim}");
680 opened
681 .strip_suffix(&closer)
682 .expect("must close with matching delimiter")
683 }
684
685 #[test]
686 fn rust_raw_string_round_trips_over_adversarial_corpus() {
687 for input in adversarial_corpus() {
688 let literal = rust_raw_string_literal(input);
689 assert_eq!(
690 unwrap_rust_raw_string_literal(&literal),
691 input,
692 "input: {input:?} -> {literal:?}"
693 );
694 }
695 }
696
697 /// Direct regression test for the raw-string-breaking case in issue #179: `"#` anywhere
698 /// in the SQL must not terminate a fixed `r#"..."#` literal early. The widened
699 /// delimiter must not appear inside the SQL body itself, or the same problem recurs one
700 /// level up.
701 #[test]
702 fn rust_raw_string_widens_delimiter_when_sql_contains_hash_quote() {
703 let sql = "a\"#b";
704 let literal = rust_raw_string_literal(sql);
705 assert_eq!(literal, "r##\"a\"#b\"##");
706 assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
707 }
708
709 #[test]
710 fn rust_raw_string_plain_sql_uses_single_hash() {
711 let literal = rust_raw_string_literal("SELECT 1");
712 assert_eq!(literal, "r#\"SELECT 1\"#");
713 }
714
715 #[test]
716 fn rust_raw_string_widens_again_for_double_hash() {
717 let sql = "a\"##b";
718 let literal = rust_raw_string_literal(sql);
719 assert_eq!(literal, "r###\"a\"##b\"###");
720 assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
721 }
722}