chain_builder/ident.rs
1//! Dialect-aware SQL identifier escaping.
2//!
3//! Values are always sent to the database as bound parameters, but SQL
4//! *identifiers* (table names, column names, aliases) are interpolated directly
5//! into the generated SQL. If any of those identifiers can be influenced by
6//! untrusted input (e.g. a dynamic `ORDER BY` column coming from a request),
7//! interpolating them verbatim is a SQL-injection vector.
8//!
9//! [`escape_identifier`] quotes identifiers using the dialect's quote character
10//! and doubles any embedded quote character, which is the standard, injection-safe
11//! way to emit an identifier.
12
13/// Escape a SQL identifier so that attacker-controlled table/column/alias names
14/// cannot break out of the identifier context.
15///
16/// Behaviour:
17/// - The identifier is split on `.` so qualified names like `db.table.col` are
18/// quoted segment-by-segment (`` `db`.`table`.`col` ``).
19/// - A bare `*` segment (wildcard) is passed through unquoted, so `t.*` becomes
20/// `` `t`.* `` and `*` stays `*`.
21/// - Any occurrence of the quote character inside a segment is doubled, which
22/// neutralizes attempts to terminate the quoted identifier early.
23/// - Surrounding whitespace is trimmed; empty input yields an empty string.
24pub fn escape_identifier(ident: &str, quote: char) -> String {
25 let trimmed = ident.trim();
26 if trimmed.is_empty() {
27 return String::new();
28 }
29
30 let mut out = String::with_capacity(trimmed.len() + 4);
31 for (i, part) in trimmed.split('.').enumerate() {
32 if i > 0 {
33 out.push('.');
34 }
35 let part = part.trim();
36 if part == "*" {
37 out.push('*');
38 continue;
39 }
40 out.push(quote);
41 for ch in part.chars() {
42 if ch == quote {
43 // Double the quote char to embed it safely.
44 out.push(quote);
45 }
46 out.push(ch);
47 }
48 out.push(quote);
49 }
50 out
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 const BACKTICK: char = '\u{60}';
58 const DQUOTE: char = '"';
59
60 #[test]
61 fn plain_identifier_mysql() {
62 assert_eq!(escape_identifier("name", BACKTICK), "`name`");
63 }
64
65 #[test]
66 fn qualified_identifier_mysql() {
67 assert_eq!(escape_identifier("users.name", BACKTICK), "`users`.`name`");
68 }
69
70 #[test]
71 fn wildcard_passthrough() {
72 assert_eq!(escape_identifier("*", BACKTICK), "*");
73 assert_eq!(escape_identifier("users.*", BACKTICK), "`users`.*");
74 assert_eq!(escape_identifier("t.*", BACKTICK), "`t`.*");
75 }
76
77 #[test]
78 fn sqlite_uses_double_quotes() {
79 assert_eq!(escape_identifier("name", DQUOTE), "\"name\"");
80 }
81
82 #[test]
83 fn injection_attempt_is_neutralized_mysql() {
84 // A backtick in the input is doubled, keeping it inside one identifier.
85 assert_eq!(
86 escape_identifier("name` = 1; DROP TABLE users; -- ", BACKTICK),
87 "`name`` = 1; DROP TABLE users; --`"
88 );
89 }
90
91 #[test]
92 fn injection_attempt_is_neutralized_double_quote() {
93 assert_eq!(
94 escape_identifier("name\" OR \"1\"=\"1", DQUOTE),
95 "\"name\"\" OR \"\"1\"\"=\"\"1\""
96 );
97 }
98
99 #[test]
100 fn empty_input_yields_empty() {
101 assert_eq!(escape_identifier(" ", BACKTICK), "");
102 }
103}