Skip to main content

memory_core/
search.rs

1/// Sanitize user input for FTS5 queries.
2/// Whitelist approach: only alphanumeric, spaces, hyphens, underscores, dots.
3/// All FTS5 operators (NEAR, AND, OR, NOT, *, ^, column:) are stripped.
4pub 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/// Build an OR fallback query from an already-sanitized AND query.
32/// Returns `None` for single-term queries (OR fallback has no effect).
33///
34/// Input:  `"authentication" "auth" "security"`
35/// Output: `Some("\"authentication\" OR \"auth\" OR \"security\"")`
36pub fn make_or_fallback(and_query: &str) -> Option<String> {
37    let terms: Vec<&str> = and_query.split_whitespace().collect();
38    if terms.len() <= 1 {
39        return None;
40    }
41    Some(terms.join(" OR "))
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn sanitizes_normal_query() {
50        assert_eq!(sanitize_fts_query("hello world"), "\"hello\" \"world\"");
51    }
52
53    #[test]
54    fn strips_sql_injection() {
55        let result = sanitize_fts_query("'; DROP TABLE memories; --");
56        // Special chars like ' and ; are stripped
57        assert!(!result.contains(';'));
58        assert!(!result.contains('\''));
59        // Remaining words are safely quoted as FTS5 search terms
60        // "--" becomes a quoted term (harmless in FTS5 MATCH)
61        assert!(result.contains("\"DROP\""));
62        assert!(result.contains("\"TABLE\""));
63        assert!(result.contains("\"memories\""));
64    }
65
66    #[test]
67    fn strips_fts5_operators() {
68        let result = sanitize_fts_query("NEAR(password admin)");
69        assert!(!result.contains("NEAR"));
70    }
71
72    #[test]
73    fn strips_column_filters() {
74        let result = sanitize_fts_query("key:* OR 1=1");
75        assert!(!result.contains("key:"));
76        assert!(!result.contains("OR"));
77    }
78
79    #[test]
80    fn strips_special_chars() {
81        let result = sanitize_fts_query("test * ^ { } ( )");
82        assert_eq!(result, "\"test\"");
83    }
84
85    #[test]
86    fn empty_input_returns_empty() {
87        assert_eq!(sanitize_fts_query(""), "");
88        assert_eq!(sanitize_fts_query("   "), "");
89    }
90
91    #[test]
92    fn all_operators_returns_empty() {
93        assert_eq!(sanitize_fts_query("AND OR NOT"), "");
94    }
95
96    #[test]
97    fn preserves_hyphens_underscores_dots() {
98        let result = sanitize_fts_query("my-key some_thing file.rs");
99        assert_eq!(result, "\"my-key\" \"some_thing\" \"file.rs\"");
100    }
101
102    #[test]
103    fn make_or_fallback_joins_with_or() {
104        let and_q = sanitize_fts_query("authentication auth security");
105        assert_eq!(and_q, "\"authentication\" \"auth\" \"security\"");
106        let or_q = make_or_fallback(&and_q).unwrap();
107        assert_eq!(or_q, "\"authentication\" OR \"auth\" OR \"security\"");
108    }
109
110    #[test]
111    fn make_or_fallback_single_term_returns_none() {
112        let and_q = sanitize_fts_query("authentication");
113        assert!(make_or_fallback(&and_q).is_none());
114    }
115
116    #[test]
117    fn make_or_fallback_empty_returns_none() {
118        assert!(make_or_fallback("").is_none());
119    }
120}