Skip to main content

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}