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