Skip to main content

contextual_encoder/
sql.rs

1//! SQL string literal encoders.
2//!
3//! encodes untrusted strings for safe embedding in SQL string literals.
4//!
5//! - [`for_sql`] — safe for standard SQL string literals (`'...'`)
6//! - [`for_sql_backslash`] — safe for MySQL/MariaDB string literals with
7//!   backslash escaping enabled (`'...'`)
8//!
9//! # encoding rules
10//!
11//! ## standard SQL (`for_sql`)
12//!
13//! standard SQL escapes single quotes by doubling them:
14//!
15//! | character | encoded as |
16//! |-----------|-----------|
17//! | `'` | `''` |
18//! | NUL (`\0`) | removed |
19//! | unicode non-characters | space |
20//!
21//! all other characters (including backslash) pass through unchanged — they
22//! have no special meaning in standard SQL string literals.
23//!
24//! ## MySQL/MariaDB backslash escaping (`for_sql_backslash`)
25//!
26//! MySQL and MariaDB (when `NO_BACKSLASH_ESCAPES` is not set) use C-style
27//! backslash escape sequences:
28//!
29//! | character | encoded as |
30//! |-----------|-----------|
31//! | `'` | `\'` |
32//! | `\` | `\\` |
33//! | NUL (`\0`) | `\0` |
34//! | newline (`\n`) | `\n` |
35//! | carriage return (`\r`) | `\r` |
36//! | tab (`\t`) | `\t` |
37//! | backspace (`\x08`) | `\b` |
38//! | Control-Z (`\x1A`) | `\Z` |
39//! | unicode non-characters | space |
40//!
41//! # security notes
42//!
43//! - **parameterized queries are always preferred.** these encoders exist for
44//!   cases where parameterized queries are not possible (e.g. string literals
45//!   in DDL, legacy code).
46//! - **identifiers cannot be encoded.** these encoders are for string literal
47//!   values. a table or column name is delimited by `"..."` (standard) or
48//!   `` `...` `` (MySQL), and neither delimiter is escaped — both pass through
49//!   unchanged. validate an untrusted identifier against a whitelist instead.
50//! - **know your dialect.** use `for_sql` for databases that follow the SQL
51//!   standard (PostgreSQL, SQLite, SQL Server, Oracle). use
52//!   `for_sql_backslash` for MySQL/MariaDB when `NO_BACKSLASH_ESCAPES` is
53//!   not enabled.
54//! - **do not use `for_sql` with MySQL** unless `NO_BACKSLASH_ESCAPES` is
55//!   set — a backslash can be used to escape the closing quote.
56
57use std::fmt;
58
59use crate::engine::{encode_loop, is_unicode_noncharacter};
60
61/// encodes `input` for safe embedding in a standard SQL string literal
62/// (`'...'`).
63///
64/// escapes single quotes by doubling them (`'` → `''`). NUL bytes are
65/// removed (they can cause truncation in many SQL implementations).
66/// unicode non-characters are replaced with space.
67///
68/// suitable for PostgreSQL, SQLite, SQL Server, Oracle, and MySQL/MariaDB
69/// with `NO_BACKSLASH_ESCAPES` enabled.
70///
71/// # examples
72///
73/// ```
74/// use contextual_encoder::for_sql;
75///
76/// assert_eq!(for_sql("it's"), "it''s");
77/// assert_eq!(for_sql("hello"), "hello");
78/// assert_eq!(for_sql(r"back\slash"), r"back\slash");
79/// ```
80pub fn for_sql(input: &str) -> String {
81    let mut out = String::with_capacity(input.len());
82    write_sql(&mut out, input).expect("writing to string cannot fail");
83    out
84}
85
86/// writes the standard-SQL-encoded form of `input` to `out`.
87///
88/// see [`for_sql`] for encoding rules.
89pub fn write_sql<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
90    encode_loop(out, input, needs_sql_encoding, write_sql_encoded)
91}
92
93fn needs_sql_encoding(c: char) -> bool {
94    c == '\'' || c == '\0' || is_unicode_noncharacter(c as u32)
95}
96
97fn write_sql_encoded<W: fmt::Write>(out: &mut W, c: char, _next: Option<char>) -> fmt::Result {
98    match c {
99        '\'' => out.write_str("''"),
100        '\0' => Ok(()), // remove NUL bytes
101        _ if is_unicode_noncharacter(c as u32) => out.write_char(' '),
102        _ => out.write_char(c),
103    }
104}
105
106/// encodes `input` for safe embedding in a MySQL/MariaDB string literal
107/// (`'...'`) when backslash escaping is active (the default).
108///
109/// escapes single quotes, backslashes, NUL bytes, and control characters
110/// using MySQL's backslash escape sequences. unicode non-characters are
111/// replaced with space.
112///
113/// # examples
114///
115/// ```
116/// use contextual_encoder::for_sql_backslash;
117///
118/// assert_eq!(for_sql_backslash("it's"), r"it\'s");
119/// assert_eq!(for_sql_backslash(r"back\slash"), r"back\\slash");
120/// assert_eq!(for_sql_backslash("line\nbreak"), r"line\nbreak");
121/// assert_eq!(for_sql_backslash("null\x00byte"), r"null\0byte");
122/// ```
123pub fn for_sql_backslash(input: &str) -> String {
124    let mut out = String::with_capacity(input.len());
125    write_sql_backslash(&mut out, input).expect("writing to string cannot fail");
126    out
127}
128
129/// writes the MySQL-backslash-encoded form of `input` to `out`.
130///
131/// see [`for_sql_backslash`] for encoding rules.
132pub fn write_sql_backslash<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
133    encode_loop(
134        out,
135        input,
136        needs_sql_backslash_encoding,
137        write_sql_backslash_encoded,
138    )
139}
140
141fn needs_sql_backslash_encoding(c: char) -> bool {
142    matches!(c, '\0' | '\x08' | '\t' | '\n' | '\r' | '\x1A' | '\'' | '\\')
143        || is_unicode_noncharacter(c as u32)
144}
145
146fn write_sql_backslash_encoded<W: fmt::Write>(
147    out: &mut W,
148    c: char,
149    _next: Option<char>,
150) -> fmt::Result {
151    match c {
152        '\0' => out.write_str("\\0"),
153        '\x08' => out.write_str("\\b"),
154        '\t' => out.write_str("\\t"),
155        '\n' => out.write_str("\\n"),
156        '\r' => out.write_str("\\r"),
157        '\x1A' => out.write_str("\\Z"),
158        '\'' => out.write_str("\\'"),
159        '\\' => out.write_str("\\\\"),
160        _ if is_unicode_noncharacter(c as u32) => out.write_char(' '),
161        _ => out.write_char(c),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // -- for_sql --
170
171    #[test]
172    fn sql_passthrough() {
173        assert_eq!(for_sql("hello world"), "hello world");
174        assert_eq!(for_sql(""), "");
175        assert_eq!(for_sql("SELECT 1"), "SELECT 1");
176        assert_eq!(for_sql("café"), "café");
177        assert_eq!(for_sql("日本語"), "日本語");
178        assert_eq!(for_sql("\u{1F600}"), "\u{1F600}");
179    }
180
181    #[test]
182    fn sql_doubles_single_quote() {
183        assert_eq!(for_sql("it's"), "it''s");
184        assert_eq!(for_sql("'quoted'"), "''quoted''");
185        assert_eq!(for_sql("a''b"), "a''''b");
186    }
187
188    #[test]
189    fn sql_backslash_passes_through() {
190        assert_eq!(for_sql(r"back\slash"), r"back\slash");
191        assert_eq!(for_sql(r"a\\b"), r"a\\b");
192    }
193
194    #[test]
195    fn sql_double_quote_passes_through() {
196        assert_eq!(for_sql(r#"a"b"#), r#"a"b"#);
197    }
198
199    #[test]
200    fn sql_backtick_passes_through() {
201        assert_eq!(for_sql("a`b"), "a`b");
202    }
203
204    #[test]
205    fn sql_removes_nul() {
206        assert_eq!(for_sql("before\x00after"), "beforeafter");
207        assert_eq!(for_sql("\x00"), "");
208        assert_eq!(for_sql("\x00\x00"), "");
209    }
210
211    #[test]
212    fn sql_control_chars_pass_through() {
213        // standard SQL has no escape sequences for control characters —
214        // they are valid string content
215        assert_eq!(for_sql("\t"), "\t");
216        assert_eq!(for_sql("\n"), "\n");
217        assert_eq!(for_sql("\r"), "\r");
218        assert_eq!(for_sql("\x08"), "\x08");
219    }
220
221    #[test]
222    fn sql_nonchars_replaced() {
223        assert_eq!(for_sql("\u{FDD0}"), " ");
224        assert_eq!(for_sql("\u{FFFE}"), " ");
225        assert_eq!(for_sql("\u{1FFFE}"), " ");
226    }
227
228    #[test]
229    fn sql_injection_attempt() {
230        assert_eq!(
231            for_sql("'; DROP TABLE users; --"),
232            "''; DROP TABLE users; --"
233        );
234    }
235
236    #[test]
237    fn sql_writer_matches() {
238        let input = "test\x00'escape' café\u{FDD0}";
239        let mut w = String::new();
240        write_sql(&mut w, input).unwrap();
241        assert_eq!(for_sql(input), w);
242    }
243
244    // -- for_sql_backslash --
245
246    #[test]
247    fn backslash_passthrough() {
248        assert_eq!(for_sql_backslash("hello world"), "hello world");
249        assert_eq!(for_sql_backslash(""), "");
250        assert_eq!(for_sql_backslash("SELECT 1"), "SELECT 1");
251        assert_eq!(for_sql_backslash("café"), "café");
252        assert_eq!(for_sql_backslash("日本語"), "日本語");
253        assert_eq!(for_sql_backslash("\u{1F600}"), "\u{1F600}");
254    }
255
256    #[test]
257    fn backslash_escapes_single_quote() {
258        assert_eq!(for_sql_backslash("it's"), r"it\'s");
259        assert_eq!(for_sql_backslash("'quoted'"), r"\'quoted\'");
260    }
261
262    #[test]
263    fn backslash_escapes_backslash() {
264        assert_eq!(for_sql_backslash(r"a\b"), r"a\\b");
265        assert_eq!(for_sql_backslash(r"a\\b"), r"a\\\\b");
266    }
267
268    #[test]
269    fn backslash_escapes_nul() {
270        assert_eq!(for_sql_backslash("before\x00after"), r"before\0after");
271        assert_eq!(for_sql_backslash("\x00"), r"\0");
272    }
273
274    #[test]
275    fn backslash_escapes_newline() {
276        assert_eq!(for_sql_backslash("line\nbreak"), r"line\nbreak");
277    }
278
279    #[test]
280    fn backslash_escapes_carriage_return() {
281        assert_eq!(for_sql_backslash("line\rbreak"), r"line\rbreak");
282    }
283
284    #[test]
285    fn backslash_escapes_tab() {
286        assert_eq!(for_sql_backslash("col\tcol"), r"col\tcol");
287    }
288
289    #[test]
290    fn backslash_escapes_backspace() {
291        assert_eq!(for_sql_backslash("a\x08b"), r"a\bb");
292    }
293
294    #[test]
295    fn backslash_escapes_control_z() {
296        assert_eq!(for_sql_backslash("a\x1Ab"), r"a\Zb");
297    }
298
299    #[test]
300    fn backslash_double_quote_passes_through() {
301        assert_eq!(for_sql_backslash(r#"a"b"#), r#"a"b"#);
302    }
303
304    #[test]
305    fn backslash_backtick_passes_through() {
306        assert_eq!(for_sql_backslash("a`b"), "a`b");
307    }
308
309    #[test]
310    fn backslash_other_controls_pass_through() {
311        // controls not in MySQL's escape list pass through
312        assert_eq!(for_sql_backslash("\x01"), "\x01");
313        assert_eq!(for_sql_backslash("\x7F"), "\x7F");
314    }
315
316    #[test]
317    fn backslash_nonchars_replaced() {
318        assert_eq!(for_sql_backslash("\u{FDD0}"), " ");
319        assert_eq!(for_sql_backslash("\u{FFFE}"), " ");
320    }
321
322    #[test]
323    fn backslash_injection_attempt() {
324        assert_eq!(
325            for_sql_backslash("'; DROP TABLE users; --"),
326            r"\'; DROP TABLE users; --"
327        );
328    }
329
330    #[test]
331    fn backslash_injection_via_backslash() {
332        // attacker tries: \' to escape the quote — both get escaped
333        assert_eq!(for_sql_backslash("\\'"), r"\\\'");
334    }
335
336    #[test]
337    fn backslash_writer_matches() {
338        let input = "test\x00\x08\t\n\r\x1A'\\café\u{FDD0}";
339        let mut w = String::new();
340        write_sql_backslash(&mut w, input).unwrap();
341        assert_eq!(for_sql_backslash(input), w);
342    }
343}