rivven-rdbc 0.0.22

Production-grade relational database connectivity for rivven-connect
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
//! Security utilities for SQL injection prevention in rivven-rdbc.
//!
//! Provides:
//! - Identifier validation for savepoints, table names, schema names
//! - String literal escaping for SQL string contexts
//!
//! These functions are used by all RDBC backends (PostgreSQL, MySQL, SQL Server)
//! and the dialect abstraction layer to prevent SQL injection attacks.

use crate::error::Error;

/// Validate a SQL identifier (savepoint, table, schema names).
///
/// Prevents SQL injection by enforcing strict character rules:
/// - Must not be empty
/// - Maximum 255 characters
/// - Must start with ASCII letter or underscore
/// - May only contain ASCII alphanumeric characters and underscores
///
/// This matches the `IDENTIFIER_REGEX` pattern from `rivven-core::validation`
/// (`^[a-zA-Z_][a-zA-Z0-9_]{0,254}$`) but uses char-iteration instead
/// of regex for zero-dependency, zero-allocation validation on the hot path.
///
/// # Examples
///
/// ```
/// use rivven_rdbc::security::validate_sql_identifier;
///
/// assert!(validate_sql_identifier("users").is_ok());
/// assert!(validate_sql_identifier("my_table_123").is_ok());
/// assert!(validate_sql_identifier("_private").is_ok());
///
/// // Rejects injection attempts
/// assert!(validate_sql_identifier("x; DROP TABLE users--").is_err());
/// assert!(validate_sql_identifier("").is_err());
/// assert!(validate_sql_identifier("123abc").is_err());
/// ```
pub fn validate_sql_identifier(name: &str) -> crate::Result<()> {
    if name.is_empty() {
        return Err(Error::config("SQL identifier cannot be empty"));
    }

    if name.len() > 255 {
        return Err(Error::config(format!(
            "SQL identifier too long: {} chars (max 255)",
            name.len()
        )));
    }

    let mut chars = name.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => {
            return Err(Error::config(format!(
                "Invalid SQL identifier '{}': must start with a letter or underscore",
                name
            )));
        }
    }

    for c in chars {
        if !c.is_ascii_alphanumeric() && c != '_' {
            return Err(Error::config(format!(
                "Invalid SQL identifier '{}': contains invalid character '{}'",
                name, c
            )));
        }
    }

    Ok(())
}

/// Escape a string value for safe interpolation into a SQL string literal context.
///
/// Replaces `'` with `''` (standard SQL escaping for single-quoted string literals).
/// This is used for `information_schema` queries where parameterized queries
/// are not practical (e.g., `table_exists_sql`, `list_columns_sql`) because
/// the SQL must be returned as a pre-built string.
///
/// **Prefer parameterized queries whenever possible.** This function is a fallback
/// for cases where the SQL generation API requires a complete SQL string.
///
/// # Examples
///
/// ```
/// use rivven_rdbc::security::escape_string_literal;
///
/// assert_eq!(escape_string_literal("users"), "users");
/// assert_eq!(escape_string_literal("don't"), "don''t");
/// assert_eq!(escape_string_literal("x'; DROP TABLE users--"), "x''; DROP TABLE users--");
/// ```
pub fn escape_string_literal(value: &str) -> String {
    // Fast path: no escaping needed (common case)
    if !value.contains('\'') {
        return value.to_string();
    }
    value.replace('\'', "''")
}

/// Validate a SQL type name for safe interpolation into DDL statements.
///
/// Prevents SQL injection via `ColumnMetadata::type_name` by allowing only
/// characters that appear in legitimate SQL type specifications:
/// - ASCII letters, digits, underscores: `VARCHAR`, `INT`, `BIGINT`
/// - Parentheses and commas: `DECIMAL(10,2)`, `ENUM('a','b')`
/// - Spaces: `INT UNSIGNED`, `DOUBLE PRECISION`
/// - Single quotes: `ENUM('x','y')` (MySQL set/enum value lists)
/// - Periods: `NUMERIC(10.2)` (some dialects)
///
/// Rejects semicolons, double-dashes, newlines, backticks, and other
/// metacharacters that could escape the DDL context.
///
/// # Examples
///
/// ```
/// use rivven_rdbc::security::validate_sql_type_name;
///
/// assert!(validate_sql_type_name("INT").is_ok());
/// assert!(validate_sql_type_name("VARCHAR(255)").is_ok());
/// assert!(validate_sql_type_name("DECIMAL(10,2)").is_ok());
/// assert!(validate_sql_type_name("INT UNSIGNED").is_ok());
/// assert!(validate_sql_type_name("ENUM('a','b')").is_ok());
///
/// // Rejects injection attempts
/// assert!(validate_sql_type_name("INT; DROP TABLE users--").is_err());
/// assert!(validate_sql_type_name("").is_err());
/// ```
pub fn validate_sql_type_name(type_name: &str) -> crate::Result<()> {
    if type_name.is_empty() {
        return Err(Error::config("SQL type name cannot be empty"));
    }

    if type_name.len() > 255 {
        return Err(Error::config(format!(
            "SQL type name too long: {} chars (max 255)",
            type_name.len()
        )));
    }

    for c in type_name.chars() {
        if !(c.is_ascii_alphanumeric()
            || c == '_'
            || c == '('
            || c == ')'
            || c == ','
            || c == ' '
            || c == '\''
            || c == '.')
        {
            return Err(Error::config(format!(
                "Invalid SQL type name '{}': contains invalid character '{}'",
                type_name, c
            )));
        }
    }

    Ok(())
}

/// Validate a user-supplied WHERE clause for safe interpolation.
///
/// The clause is injected as a raw SQL fragment (via `Expr::cust()`), so it
/// **cannot** be parameterized. This function applies deny-list checks
/// to reject the most common SQL injection patterns:
///
/// - Semicolons (statement terminators / stacking)
/// - Double-dash `--` line comments
/// - C-style `/* */` block comments
/// - Backslash escapes (MySQL-specific injection vector)
/// - `UNION` keyword (second-order query injection)
/// - Subquery delimiters `SELECT` within parentheses
/// - `EXEC`/`EXECUTE` (stored procedure invocation)
/// - `xp_` prefixed calls (SQL Server extended procedures)
/// - `WAITFOR` / `BENCHMARK` / `SLEEP` (timing side-channels)
/// - `INTO OUTFILE` / `INTO DUMPFILE` (file-system writes)
/// - `LOAD_FILE` (file-system reads)
/// - Null bytes (string truncation)
/// - Newlines (multi-line injection)
///
/// # Security note
///
/// This is a **best-effort** safeguard, **not** a guarantee. A determined
/// attacker can craft payloads that bypass simple deny-list checks. Prefer
/// parameterized queries wherever possible. This clause should only come
/// from **trusted connector configuration**, never from end-user input.
///
/// # Examples
///
/// ```
/// use rivven_rdbc::security::validate_where_clause;
///
/// assert!(validate_where_clause("status = 'active'").is_ok());
/// assert!(validate_where_clause("id > 0 AND deleted = false").is_ok());
///
/// assert!(validate_where_clause("1=1; DROP TABLE users").is_err());
/// assert!(validate_where_clause("1=1 -- bypass").is_err());
/// assert!(validate_where_clause("1=1 /* comment */").is_err());
/// assert!(validate_where_clause("1=1 UNION SELECT * FROM passwords").is_err());
/// ```
pub fn validate_where_clause(clause: &str) -> crate::Result<()> {
    if clause.is_empty() {
        return Err(Error::config("WHERE clause cannot be empty"));
    }

    if clause.len() > 4096 {
        return Err(Error::config(format!(
            "WHERE clause too long: {} chars (max 4096)",
            clause.len()
        )));
    }

    // Null bytes — can truncate strings in C-backed drivers
    if clause.contains('\0') {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited null byte: {}",
            clause
        )));
    }

    // Newlines — multi-line injection
    if clause.contains('\n') || clause.contains('\r') {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited newline: {}",
            clause
        )));
    }

    if clause.contains(';') {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited character ';': {}",
            clause
        )));
    }

    if clause.contains("--") {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited pattern '--': {}",
            clause
        )));
    }

    if clause.contains("/*") || clause.contains("*/") {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited comment syntax: {}",
            clause
        )));
    }

    if clause.contains('\\') {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited backslash escape: {}",
            clause
        )));
    }

    // Case-insensitive keyword checks using word-boundary matching.
    // A "word boundary" means the character before/after the keyword is NOT
    // alphanumeric or underscore, preventing false positives like
    // `executor_status` matching `EXEC` or `SELECTIVITY` matching `SELECT`.
    let upper = clause.to_uppercase();

    // Single-word prohibited keywords (word-boundary matched to prevent false
    // positives like `executor_status` matching EXEC or `selectivity` matching SELECT)
    for keyword in &[
        // Query manipulation
        "UNION",
        "SELECT",
        "INSERT",
        "UPDATE",
        "DELETE",
        // DDL
        "DROP",
        "ALTER",
        "CREATE",
        "TRUNCATE",
        // Stored procedure / dynamic execution
        "EXEC",
        "EXECUTE",
        "DECLARE",
        "CALL",
        // Privilege escalation
        "GRANT",
        "REVOKE",
        // Timing side-channels
        "WAITFOR",
        "BENCHMARK",
        "SLEEP",
        "PG_SLEEP",
        // PostgreSQL file-access functions (exploitable without SELECT)
        "PG_READ_FILE",
        "PG_LS_DIR",
        "PG_READ_BINARY_FILE",
    ] {
        if contains_word(&upper, keyword) {
            return Err(Error::config(format!(
                "WHERE clause contains prohibited keyword '{}': {}",
                keyword, clause
            )));
        }
    }

    // SQL Server extended procedures (prefix match: xp_ followed by word chars)
    if contains_word_prefix(&upper, "XP_") {
        return Err(Error::config(format!(
            "WHERE clause contains prohibited pattern 'xp_': {}",
            clause
        )));
    }

    // Multi-word prohibited patterns (already act as their own boundary)
    for keyword in &["INTO OUTFILE", "INTO DUMPFILE", "LOAD_FILE"] {
        if upper.contains(keyword) {
            return Err(Error::config(format!(
                "WHERE clause contains prohibited keyword '{}': {}",
                keyword, clause
            )));
        }
    }

    // Note: MySQL `#` line comments are NOT blocked because `#` is a valid
    // PostgreSQL bitwise XOR operator and JSON path operator (#>, #>>).
    // The `--` and `/* */` checks cover the common cross-dialect comment vectors.

    Ok(())
}

/// Check whether `haystack` contains `word` at a word boundary.
///
/// A word boundary is any position where the adjacent character (or
/// start/end of string) is not ASCII alphanumeric or underscore.
///
/// # Safety invariant
/// This operates on bytes, not chars. The `u8 as char` cast is safe because:
/// - All keywords are ASCII, so byte-level comparison is correct (UTF-8
///   guarantees ASCII bytes never appear as continuation bytes).
/// - `is_word_boundary` only checks `is_ascii_alphanumeric()` and `!= '_'`,
///   both of which correctly classify non-ASCII bytes as boundaries.
/// - If `is_word_boundary` is ever extended to check Unicode letter categories
///   (e.g., `char::is_alphabetic()`), this must be rewritten to use char offsets.
#[inline]
fn is_word_boundary(c: Option<char>) -> bool {
    match c {
        None => true,
        Some(ch) => !ch.is_ascii_alphanumeric() && ch != '_',
    }
}

/// Returns `true` if `haystack` contains `word` surrounded by word boundaries.
fn contains_word(haystack: &str, word: &str) -> bool {
    let h = haystack.as_bytes();
    let w = word.as_bytes();
    if w.is_empty() || w.len() > h.len() {
        return false;
    }
    for start in 0..=(h.len() - w.len()) {
        if &h[start..start + w.len()] == w {
            let before = if start == 0 {
                None
            } else {
                Some(h[start - 1] as char)
            };
            let after = if start + w.len() >= h.len() {
                None
            } else {
                Some(h[start + w.len()] as char)
            };
            if is_word_boundary(before) && is_word_boundary(after) {
                return true;
            }
        }
    }
    false
}

/// Returns `true` if `haystack` contains `prefix` at a word boundary on the left.
/// Used for patterns like `XP_` where only the start boundary matters.
fn contains_word_prefix(haystack: &str, prefix: &str) -> bool {
    let h = haystack.as_bytes();
    let p = prefix.as_bytes();
    if p.len() > h.len() {
        return false;
    }
    for start in 0..=(h.len() - p.len()) {
        if &h[start..start + p.len()] == p {
            let before = if start == 0 {
                None
            } else {
                Some(h[start - 1] as char)
            };
            if is_word_boundary(before) {
                return true;
            }
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    // -----------------------------------------------------------------------
    // validate_sql_identifier
    // -----------------------------------------------------------------------

    #[test]
    fn test_valid_identifiers() {
        assert!(validate_sql_identifier("users").is_ok());
        assert!(validate_sql_identifier("my_table").is_ok());
        assert!(validate_sql_identifier("_private").is_ok());
        assert!(validate_sql_identifier("a").is_ok());
        assert!(validate_sql_identifier("TABLE_123").is_ok());
        assert!(validate_sql_identifier("sp1").is_ok());
    }

    #[test]
    fn test_empty_identifier() {
        assert!(validate_sql_identifier("").is_err());
    }

    #[test]
    fn test_too_long_identifier() {
        let long = "a".repeat(256);
        assert!(validate_sql_identifier(&long).is_err());

        let max = "a".repeat(255);
        assert!(validate_sql_identifier(&max).is_ok());
    }

    #[test]
    fn test_starts_with_digit() {
        assert!(validate_sql_identifier("123abc").is_err());
        assert!(validate_sql_identifier("0").is_err());
    }

    #[test]
    fn test_injection_attempts() {
        // SQL injection via semicolon
        assert!(validate_sql_identifier("x; DROP TABLE users--").is_err());
        // SQL injection via quote
        assert!(validate_sql_identifier("x' OR '1'='1").is_err());
        // SQL injection via comment
        assert!(validate_sql_identifier("x--").is_err());
        // SQL injection via parentheses
        assert!(validate_sql_identifier("x()").is_err());
        // Unicode smuggling
        assert!(validate_sql_identifier("tabl\u{0435}").is_err()); // Cyrillic е
                                                                   // Whitespace
        assert!(validate_sql_identifier("user name").is_err());
        // Newlines
        assert!(validate_sql_identifier("x\nDROP TABLE").is_err());
        // Null bytes
        assert!(validate_sql_identifier("x\0").is_err());
        // Dots (schema.table injection)
        assert!(validate_sql_identifier("schema.table").is_err());
    }

    #[test]
    fn test_special_chars_rejected() {
        for ch in &[
            '.', '-', '@', '#', '$', '!', '%', '&', '*', '[', ']', '"', '`',
        ] {
            let name = format!("a{}", ch);
            assert!(
                validate_sql_identifier(&name).is_err(),
                "Should reject '{}'",
                name
            );
        }
    }

    // -----------------------------------------------------------------------
    // escape_string_literal
    // -----------------------------------------------------------------------

    #[test]
    fn test_escape_no_quotes() {
        assert_eq!(escape_string_literal("users"), "users");
        assert_eq!(escape_string_literal("my_table"), "my_table");
    }

    #[test]
    fn test_escape_single_quotes() {
        assert_eq!(escape_string_literal("don't"), "don''t");
        assert_eq!(escape_string_literal("'hello'"), "''hello''");
    }

    #[test]
    fn test_escape_injection_attempt() {
        assert_eq!(
            escape_string_literal("x'; DROP TABLE users--"),
            "x''; DROP TABLE users--"
        );
        assert_eq!(escape_string_literal("' OR '1'='1"), "'' OR ''1''=''1");
    }

    #[test]
    fn test_escape_empty_string() {
        assert_eq!(escape_string_literal(""), "");
    }

    // -----------------------------------------------------------------------
    // validate_sql_type_name
    // -----------------------------------------------------------------------

    #[test]
    fn test_valid_type_names() {
        assert!(validate_sql_type_name("INT").is_ok());
        assert!(validate_sql_type_name("BIGINT").is_ok());
        assert!(validate_sql_type_name("VARCHAR(255)").is_ok());
        assert!(validate_sql_type_name("DECIMAL(10,2)").is_ok());
        assert!(validate_sql_type_name("INT UNSIGNED").is_ok());
        assert!(validate_sql_type_name("DOUBLE PRECISION").is_ok());
        assert!(validate_sql_type_name("ENUM('a','b','c')").is_ok());
        assert!(validate_sql_type_name("SET('x','y')").is_ok());
        assert!(validate_sql_type_name("NUMERIC(10.2)").is_ok());
        assert!(validate_sql_type_name("timestamp").is_ok());
        assert!(validate_sql_type_name("TINYINT(1)").is_ok());
    }

    #[test]
    fn test_empty_type_name() {
        assert!(validate_sql_type_name("").is_err());
    }

    #[test]
    fn test_type_name_injection_attempts() {
        assert!(validate_sql_type_name("INT; DROP TABLE users--").is_err());
        assert!(validate_sql_type_name("INT`; DROP TABLE").is_err());
        assert!(validate_sql_type_name("INT\nDROP TABLE").is_err());
        assert!(validate_sql_type_name("INT\0").is_err());
        assert!(validate_sql_type_name("INT--comment").is_err());
    }

    #[test]
    fn test_type_name_too_long() {
        let long = "A".repeat(256);
        assert!(validate_sql_type_name(&long).is_err());
    }

    // -----------------------------------------------------------------------
    // validate_where_clause
    // -----------------------------------------------------------------------

    #[test]
    fn test_valid_where_clauses() {
        assert!(validate_where_clause("status = 'active'").is_ok());
        assert!(validate_where_clause("id > 0 AND deleted = false").is_ok());
        assert!(validate_where_clause("age BETWEEN 18 AND 65").is_ok());
        assert!(validate_where_clause("name LIKE '%test%'").is_ok());
        assert!(validate_where_clause("id IN (1, 2, 3)").is_ok());
    }

    #[test]
    fn test_where_clause_injection_attacks() {
        // Statement stacking
        assert!(validate_where_clause("1=1; DROP TABLE users").is_err());
        // Line comments
        assert!(validate_where_clause("1=1 -- bypass").is_err());
        // Block comments
        assert!(validate_where_clause("1=1 /* comment */").is_err());
        // Backslash escape
        assert!(validate_where_clause("name = '\\' OR 1=1").is_err());
        // UNION injection
        assert!(validate_where_clause("1=1 UNION SELECT * FROM passwords").is_err());
        assert!(validate_where_clause("1=1 union select * from passwords").is_err());
        // Subquery
        assert!(validate_where_clause("id = (SELECT MAX(id) FROM users)").is_err());
        // EXEC
        assert!(validate_where_clause("1=1; EXEC sp_help").is_err());
        assert!(validate_where_clause("EXECUTE xp_cmdshell 'dir'").is_err());
        // xp_ extended procedures
        assert!(validate_where_clause("xp_cmdshell('dir')").is_err());
        // Timing attacks
        assert!(validate_where_clause("1=1 AND SLEEP(5)").is_err());
        assert!(validate_where_clause("1=1 AND BENCHMARK(1000000, SHA1('test'))").is_err());
        assert!(validate_where_clause("1=1; WAITFOR DELAY '0:0:5'").is_err());
        assert!(validate_where_clause("1=1 AND PG_SLEEP(5)").is_err());
        // File access
        assert!(validate_where_clause("1=1 INTO OUTFILE '/tmp/data'").is_err());
        assert!(validate_where_clause("1=1 INTO DUMPFILE '/tmp/data'").is_err());
        assert!(validate_where_clause("LOAD_FILE('/etc/passwd')").is_err());
        // Null bytes
        assert!(validate_where_clause("name = 'test\0").is_err());
        // Newlines
        assert!(validate_where_clause("1=1\nDROP TABLE users").is_err());
    }

    #[test]
    fn test_where_clause_empty() {
        assert!(validate_where_clause("").is_err());
    }

    #[test]
    fn test_where_clause_too_long() {
        let long = "a".repeat(4097);
        assert!(validate_where_clause(&long).is_err());
    }

    #[test]
    fn test_where_clause_word_boundary_no_false_positives() {
        // Should NOT reject: keyword is part of a larger word
        assert!(validate_where_clause("executor_status = 'running'").is_ok());
        assert!(validate_where_clause("execution_count > 0").is_ok());
        assert!(validate_where_clause("selectivity > 0.5").is_ok());
        assert!(validate_where_clause("selected = true").is_ok());
        assert!(validate_where_clause("preselected = true").is_ok());
        assert!(validate_where_clause("reunionist = 'alice'").is_ok());
        assert!(validate_where_clause("asleep = false").is_ok());
    }

    #[test]
    fn test_where_clause_word_boundary_still_catches_keywords() {
        // Should STILL reject: keyword at word boundary
        assert!(validate_where_clause("1=1 UNION ALL").is_err());
        assert!(validate_where_clause("(SELECT 1)").is_err());
        assert!(validate_where_clause("EXEC sp_help").is_err());
        assert!(validate_where_clause("id=1 AND SLEEP(5)").is_err());
        assert!(validate_where_clause("EXECUTE('cmd')").is_err());
        // DDL keywords
        assert!(validate_where_clause("1=1; DROP TABLE users").is_err()); // also blocked by ;
        assert!(validate_where_clause("ALTER TABLE users").is_err());
        assert!(validate_where_clause("CREATE TABLE evil").is_err());
        assert!(validate_where_clause("TRUNCATE TABLE users").is_err());
        // DML keywords
        assert!(validate_where_clause("INSERT INTO users").is_err());
        assert!(validate_where_clause("UPDATE users SET x=1").is_err());
        assert!(validate_where_clause("DELETE FROM users").is_err());
        // Privilege escalation
        assert!(validate_where_clause("GRANT ALL ON users").is_err());
        assert!(validate_where_clause("REVOKE ALL ON users").is_err());
    }

    #[test]
    fn test_where_clause_hash_comment_allowed() {
        // `#` is NOT blocked because it is a valid PostgreSQL operator
        // (bitwise XOR, JSON path operators #>, #>>)
        assert!(validate_where_clause("flags # 4 > 0").is_ok());
        assert!(validate_where_clause("data #>> '{key}'").is_ok());
    }

    #[test]
    fn test_where_clause_ddl_word_boundary_no_false_positives() {
        // DDL keywords as substrings should NOT be rejected
        assert!(validate_where_clause("droplet_count > 0").is_ok());
        assert!(validate_where_clause("created_at > '2024-01-01'").is_ok());
        assert!(validate_where_clause("alteration = 'none'").is_ok());
        assert!(validate_where_clause("undeleted = true").is_ok());
        assert!(validate_where_clause("inserted = false").is_ok());
        assert!(validate_where_clause("updated_at IS NOT NULL").is_ok());
        assert!(validate_where_clause("revoked = false").is_ok());
        assert!(validate_where_clause("granted = true").is_ok());
    }

    // -----------------------------------------------------------------------
    // contains_word / contains_word_prefix
    // -----------------------------------------------------------------------

    #[test]
    fn test_contains_word_boundaries() {
        assert!(contains_word("HELLO WORLD", "HELLO"));
        assert!(contains_word("HELLO WORLD", "WORLD"));
        assert!(contains_word("(EXEC)", "EXEC"));
        assert!(!contains_word("EXECUTOR", "EXEC"));
        assert!(!contains_word("PRESELECT", "SELECT"));
        assert!(!contains_word("SELECTIVITY", "SELECT"));
        assert!(contains_word("SELECT", "SELECT"));
        assert!(contains_word(" SELECT ", "SELECT"));
        // Underscore is NOT a word boundary
        assert!(!contains_word("A_EXEC_B", "EXEC"));
        assert!(!contains_word("DROP_TABLE", "DROP"));
        // Empty inputs
        assert!(!contains_word("", "EXEC"));
        assert!(!contains_word("EX", "EXEC")); // keyword longer than haystack
                                               // Tab/special chars ARE word boundaries
        assert!(contains_word("EXEC\tSOMETHING", "EXEC"));
        assert!(contains_word("X=EXEC", "EXEC")); // = is boundary
    }
}