fix(memory): sanitize fts5 user queries (#2531)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YOMXXX
2026-05-24 21:43:33 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent e0e9a11172
commit 9cffd3a17f
3 changed files with 78 additions and 14 deletions
+13 -3
View File
@@ -224,6 +224,17 @@ pub fn event_search_fts(
limit: usize,
) -> anyhow::Result<Vec<EventRecord>> {
let conn = conn.lock();
let trimmed = query.trim();
if trimmed.is_empty() {
tracing::debug!("[events] FTS search skipped — empty query");
return Ok(Vec::new());
}
let phrase_query = super::fts5::sanitize_fts_query(trimmed);
if phrase_query.is_empty() {
tracing::debug!("[events] FTS search skipped — sanitised query is empty");
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT el.event_id, el.segment_id, el.session_id, el.namespace,
el.event_type, el.content, el.subject, el.timestamp_ref,
@@ -235,13 +246,12 @@ pub fn event_search_fts(
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![query, namespace, limit as i64], |row| {
.query_map(params![phrase_query, namespace, limit as i64], |row| {
row_to_event(row)
})?
.collect::<Result<Vec<_>, _>>()?;
tracing::debug!(
"[events] FTS search '{}' (ns={}) returned {} results",
query,
"[events] FTS search ns={} returned {} results",
namespace,
rows.len()
);
@@ -211,6 +211,32 @@ fn event_fts_matches_subject_field() {
assert_eq!(by_subject[0].event_id, "evt-subj");
}
#[test]
fn event_fts_sanitises_punctuation_safely() {
let conn = setup_db();
let event = EventRecord {
event_id: "evt-punct".into(),
segment_id: "seg-1".into(),
session_id: "s1".into(),
namespace: "global".into(),
event_type: EventType::Decision,
content: "We decided to use Rust for backend deployment".into(),
subject: Some("backend deployment".into()),
timestamp_ref: None,
confidence: 0.85,
embedding: None,
source_turn_ids: None,
created_at: 1000.0,
};
event_insert(&conn, &event).unwrap();
let results = event_search_fts(&conn, "global", "\"Rust\"(backend)?", 5)
.expect("punctuated user query should not trip FTS5 syntax errors");
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_id, "evt-punct");
}
#[test]
fn event_embeddings_are_scoped_by_model_signature() {
let conn = setup_db();
+39 -11
View File
@@ -154,6 +154,17 @@ pub fn episodic_search(
limit: usize,
) -> anyhow::Result<Vec<EpisodicEntry>> {
let conn = conn.lock();
let trimmed = query.trim();
if trimmed.is_empty() {
tracing::debug!("[fts5] search skipped — empty query");
return Ok(Vec::new());
}
let phrase_query = sanitize_fts_query(trimmed);
if phrase_query.is_empty() {
tracing::debug!("[fts5] search skipped — sanitised query is empty");
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT el.id, el.session_id, el.timestamp, el.role, el.content, el.lesson,
el.tool_calls_json, el.cost_microdollars
@@ -165,7 +176,7 @@ pub fn episodic_search(
)?;
let rows = stmt
.query_map(rusqlite::params![query, limit as i64], |row| {
.query_map(rusqlite::params![phrase_query, limit as i64], |row| {
Ok(EpisodicEntry {
id: row.get(0)?,
session_id: row.get(1)?,
@@ -179,7 +190,7 @@ pub fn episodic_search(
})?
.collect::<Result<Vec<_>, _>>()?;
tracing::debug!("[fts5] search '{}' returned {} results", query, rows.len());
tracing::debug!("[fts5] search returned {} results", rows.len());
Ok(rows)
}
@@ -281,17 +292,19 @@ pub fn episodic_cross_session_search(
Ok(rows)
}
/// Best-effort FTS5 query sanitiser: strip characters that break the
/// MATCH grammar (quotes, parens, asterisks) and wrap each surviving
/// whitespace-delimited token in double quotes so the FTS engine treats
/// it as a literal phrase. Returns an empty string when nothing usable
/// survives — the caller short-circuits to "no hits".
fn sanitize_fts_query(query: &str) -> String {
/// Best-effort FTS5 query sanitiser: split user text on punctuation and
/// symbols that break the MATCH grammar, then quote each surviving token
/// so FTS5 treats it as literal text. Returns an empty string when
/// nothing usable survives — callers short-circuit to "no hits".
pub(super) fn sanitize_fts_query(query: &str) -> String {
let cleaned: String = query
.chars()
.map(|c| match c {
'"' | '(' | ')' | '*' | ':' => ' ',
other => other,
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
' '
}
})
.collect();
let tokens: Vec<String> = cleaned
@@ -539,6 +552,21 @@ mod tests {
assert!(hits[0].content.contains("Postgres"));
}
#[test]
fn episodic_search_sanitises_punctuation_safely() {
let conn = setup_db();
insert_turn(&conn, "session-a", 1000.0, "Postgres deployment notes");
let hits = episodic_search(&conn, "\"Postgres\"(deployment)?", 10)
.expect("punctuated user query should not trip FTS5 syntax errors");
assert!(
!hits.is_empty(),
"punctuated query whose surviving tokens match the indexed row must still surface it"
);
assert!(hits[0].content.contains("Postgres"));
}
#[test]
fn cross_session_search_does_not_panic_on_pure_punctuation() {
let conn = setup_db();