1pub fn sanitize_fts_query(input: &str) -> String {
5 let cleaned: String = input
6 .chars()
7 .map(|c| {
8 if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' || c == '.' {
9 c
10 } else {
11 ' '
12 }
13 })
14 .collect();
15
16 let fts_operators = ["NEAR", "AND", "OR", "NOT"];
17
18 let terms: Vec<String> = cleaned
19 .split_whitespace()
20 .filter(|term| {
21 let upper = term.to_uppercase();
22 !fts_operators.contains(&upper.as_str())
23 })
24 .filter(|term| !term.contains(':'))
25 .map(|term| format!("\"{term}\""))
26 .collect();
27
28 terms.join(" ")
29}
30
31#[cfg(test)]
32mod tests {
33 use super::*;
34
35 #[test]
36 fn sanitizes_normal_query() {
37 assert_eq!(sanitize_fts_query("hello world"), "\"hello\" \"world\"");
38 }
39
40 #[test]
41 fn strips_sql_injection() {
42 let result = sanitize_fts_query("'; DROP TABLE memories; --");
43 assert!(!result.contains(';'));
45 assert!(!result.contains('\''));
46 assert!(result.contains("\"DROP\""));
49 assert!(result.contains("\"TABLE\""));
50 assert!(result.contains("\"memories\""));
51 }
52
53 #[test]
54 fn strips_fts5_operators() {
55 let result = sanitize_fts_query("NEAR(password admin)");
56 assert!(!result.contains("NEAR"));
57 }
58
59 #[test]
60 fn strips_column_filters() {
61 let result = sanitize_fts_query("key:* OR 1=1");
62 assert!(!result.contains("key:"));
63 assert!(!result.contains("OR"));
64 }
65
66 #[test]
67 fn strips_special_chars() {
68 let result = sanitize_fts_query("test * ^ { } ( )");
69 assert_eq!(result, "\"test\"");
70 }
71
72 #[test]
73 fn empty_input_returns_empty() {
74 assert_eq!(sanitize_fts_query(""), "");
75 assert_eq!(sanitize_fts_query(" "), "");
76 }
77
78 #[test]
79 fn all_operators_returns_empty() {
80 assert_eq!(sanitize_fts_query("AND OR NOT"), "");
81 }
82
83 #[test]
84 fn preserves_hyphens_underscores_dots() {
85 let result = sanitize_fts_query("my-key some_thing file.rs");
86 assert_eq!(result, "\"my-key\" \"some_thing\" \"file.rs\"");
87 }
88}