mik_sql/validate/
column.rs

1//! Column/identifier validation logic for SQL injection prevention.
2
3/// Maximum length for SQL identifiers (`PostgreSQL` limit is 63).
4const MAX_IDENTIFIER_LENGTH: usize = 63;
5
6/// Validate that a string is a safe SQL identifier.
7///
8/// A valid SQL identifier:
9/// - Starts with a letter (a-z, A-Z) or underscore
10/// - Contains only letters, digits (0-9), and underscores
11/// - Is not empty and not longer than 63 characters
12///
13/// This prevents SQL injection attacks by rejecting:
14/// - Special characters (quotes, semicolons, etc.)
15/// - SQL keywords as standalone identifiers
16/// - Unicode characters that could cause confusion
17///
18/// # Examples
19///
20/// ```
21/// use mik_sql::is_valid_sql_identifier;
22///
23/// assert!(is_valid_sql_identifier("users"));
24/// assert!(is_valid_sql_identifier("user_id"));
25/// assert!(is_valid_sql_identifier("_private"));
26/// assert!(is_valid_sql_identifier("Table123"));
27///
28/// // Invalid identifiers
29/// assert!(!is_valid_sql_identifier(""));           // empty
30/// assert!(!is_valid_sql_identifier("123abc"));     // starts with digit
31/// assert!(!is_valid_sql_identifier("user-name"));  // contains hyphen
32/// assert!(!is_valid_sql_identifier("user.id"));    // contains dot
33/// assert!(!is_valid_sql_identifier("user; DROP")); // contains special chars
34/// ```
35#[inline]
36#[must_use]
37pub fn is_valid_sql_identifier(s: &str) -> bool {
38    if s.is_empty() || s.len() > MAX_IDENTIFIER_LENGTH {
39        return false;
40    }
41
42    let mut chars = s.chars();
43
44    // First character must be letter or underscore
45    match chars.next() {
46        Some(c) if c.is_ascii_alphabetic() || c == '_' => {},
47        _ => return false,
48    }
49
50    // Rest must be letters, digits, or underscores
51    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
52}
53
54/// Assert that a string is a valid SQL identifier.
55///
56/// # Panics
57///
58/// Panics with a descriptive error if the identifier is invalid.
59/// This is intended for programmer errors (invalid table/column names in code),
60/// not for user input validation.
61///
62/// # Examples
63///
64/// ```
65/// use mik_sql::assert_valid_sql_identifier;
66///
67/// assert_valid_sql_identifier("users", "table");    // OK
68/// assert_valid_sql_identifier("user_id", "column"); // OK
69/// ```
70///
71/// ```should_panic
72/// use mik_sql::assert_valid_sql_identifier;
73///
74/// assert_valid_sql_identifier("user; DROP TABLE", "table"); // Panics!
75/// ```
76#[inline]
77pub fn assert_valid_sql_identifier(s: &str, context: &str) {
78    assert!(
79        is_valid_sql_identifier(s),
80        "Invalid SQL {context} name '{s}': must start with letter/underscore, \
81             contain only ASCII alphanumeric/underscore, and be 1-63 chars"
82    );
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_valid_sql_identifiers() {
91        // Valid identifiers
92        assert!(is_valid_sql_identifier("users"));
93        assert!(is_valid_sql_identifier("user_id"));
94        assert!(is_valid_sql_identifier("_private"));
95        assert!(is_valid_sql_identifier("Table123"));
96        assert!(is_valid_sql_identifier("a"));
97        assert!(is_valid_sql_identifier("_"));
98        assert!(is_valid_sql_identifier("UPPERCASE"));
99        assert!(is_valid_sql_identifier("mixedCase"));
100        assert!(is_valid_sql_identifier("with_123_numbers"));
101    }
102
103    #[test]
104    fn test_invalid_sql_identifiers() {
105        // Empty
106        assert!(!is_valid_sql_identifier(""));
107
108        // Starts with digit
109        assert!(!is_valid_sql_identifier("123abc"));
110        assert!(!is_valid_sql_identifier("1"));
111
112        // Contains special characters
113        assert!(!is_valid_sql_identifier("user-name"));
114        assert!(!is_valid_sql_identifier("user.id"));
115        assert!(!is_valid_sql_identifier("user name"));
116        assert!(!is_valid_sql_identifier("user;drop"));
117        assert!(!is_valid_sql_identifier("table'"));
118        assert!(!is_valid_sql_identifier("table\""));
119        assert!(!is_valid_sql_identifier("table`"));
120        assert!(!is_valid_sql_identifier("table("));
121        assert!(!is_valid_sql_identifier("table)"));
122
123        // SQL injection attempts
124        assert!(!is_valid_sql_identifier("users; DROP TABLE"));
125        assert!(!is_valid_sql_identifier("users--"));
126        assert!(!is_valid_sql_identifier("users/*"));
127    }
128
129    #[test]
130    fn test_sql_identifier_length_limit() {
131        // 63 chars = OK (PostgreSQL limit)
132        let valid_63 = "a".repeat(63);
133        assert!(is_valid_sql_identifier(&valid_63));
134
135        // 64 chars = too long
136        let invalid_64 = "a".repeat(64);
137        assert!(!is_valid_sql_identifier(&invalid_64));
138    }
139
140    #[test]
141    fn test_identifier_injection_attempts() {
142        // SQL injection via identifier names
143        assert!(!is_valid_sql_identifier("users; DROP TABLE x"));
144        assert!(!is_valid_sql_identifier("users--"));
145        assert!(!is_valid_sql_identifier("users/*comment*/"));
146        assert!(!is_valid_sql_identifier("users'"));
147        assert!(!is_valid_sql_identifier("users\""));
148        assert!(!is_valid_sql_identifier("users`"));
149        assert!(!is_valid_sql_identifier("users;"));
150        assert!(!is_valid_sql_identifier("(SELECT 1)"));
151        assert!(!is_valid_sql_identifier("1 OR 1=1"));
152
153        // Unicode injection attempts
154        assert!(!is_valid_sql_identifier("users\u{0000}")); // Null byte
155        assert!(!is_valid_sql_identifier("users\u{200B}")); // Zero-width space
156        assert!(!is_valid_sql_identifier("usërs")); // Non-ASCII letter
157        assert!(!is_valid_sql_identifier("用户")); // Chinese characters
158
159        // Fullwidth characters (potential bypass)
160        assert!(!is_valid_sql_identifier("users")); // Fullwidth letters
161    }
162
163    #[test]
164    #[should_panic(expected = "Invalid SQL table name")]
165    fn test_assert_valid_identifier_panics() {
166        assert_valid_sql_identifier("users; DROP TABLE", "table");
167    }
168}