diff --git a/memoria/crates/memoria-api/tests/api_db_verify.rs b/memoria/crates/memoria-api/tests/api_db_verify.rs index 4272aea6..97d3b6a5 100644 --- a/memoria/crates/memoria-api/tests/api_db_verify.rs +++ b/memoria/crates/memoria-api/tests/api_db_verify.rs @@ -531,6 +531,103 @@ async fn test_purge_by_topic_special_chars_verify_db() { println!("✅ purge by topic with special chars: LIKE fallback works"); } +#[tokio::test] +#[serial] +async fn test_purge_by_topic_treats_like_wildcards_literally_verify_db() { + let (base, client, server) = spawn_server().await; + let uid = uid(); + + // `_` and `%` in a topic are literal: purging "cfg_key" must not delete + // "cfgXkey", and "50%" must not delete "500 units". + for content in [ + "rotate cfg_key monthly", + "rotate cfgXkey monthly", + "discount 50% applied", + "discount 500 units applied", + ] { + client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &uid) + .json(&json!({"content": content})) + .send() + .await + .unwrap(); + } + assert_eq!(db_count_active(&server, &uid).await, 4); + + for topic in ["cfg_key", "50%"] { + let r = client + .post(format!("{base}/v1/memories/purge")) + .header("X-User-Id", &uid) + .json(&json!({"topic": topic})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + let body: Value = r.json().await.unwrap(); + assert_eq!(body["purged"], 1, "topic {topic:?} must match literally"); + } + + assert_eq!(db_count_active(&server, &uid).await, 2); + + // Topics that reach only the LIKE fallback (no literal match stored) must + // delete nothing: `_` is not a wildcard, and `'` / `\` are not replaced by + // spaces. + for (content, topic) in [ + ("rotate cfgXkey monthly", "cfg_key"), + ("rotate ab cd daily", "ab'cd"), + ("rotate ab cd daily", "ab\\cd"), + ("rotate abXcd daily", "ab\\_cd"), + ] { + let user = crate::uid(); + client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &user) + .json(&json!({"content": content})) + .send() + .await + .unwrap(); + let r = client + .post(format!("{base}/v1/memories/purge")) + .header("X-User-Id", &user) + .json(&json!({"topic": topic})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + let body: Value = r.json().await.unwrap(); + assert_eq!( + body["purged"], 0, + "topic {topic:?} must not match {content:?}" + ); + assert_eq!(db_count_active(&server, &user).await, 1); + } + + // `'` and `\` themselves match literally. + for (content, topic) in [("note ab'cd here", "ab'cd"), ("path a\\b here", "a\\b")] { + let user = crate::uid(); + client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &user) + .json(&json!({"content": content})) + .send() + .await + .unwrap(); + let r = client + .post(format!("{base}/v1/memories/purge")) + .header("X-User-Id", &user) + .json(&json!({"topic": topic})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + let body: Value = r.json().await.unwrap(); + assert_eq!(body["purged"], 1, "topic {topic:?} must match {content:?}"); + assert_eq!(db_count_active(&server, &user).await, 0); + } + println!("✅ purge by topic treats LIKE wildcards, quotes and backslashes literally"); +} + #[tokio::test] #[serial] async fn test_purge_by_topic_batch_perf_verify_db() { diff --git a/memoria/crates/memoria-storage/src/store.rs b/memoria/crates/memoria-storage/src/store.rs index 147183be..82666569 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -842,16 +842,22 @@ pub fn validate_fulltext_query(query: &str) -> Result<(), MemoriaError> { Ok(()) } -/// Sanitize a string for use in a LIKE pattern (escapes `%`). +/// Build the body of a `LIKE ?` pattern that matches `s` as a literal +/// substring: `%`, `_` and the default escape character `\` are +/// backslash-escaped so they cannot act as wildcards (a topic purge of `a_b` +/// must not delete `aXb`). The default backslash escape is used instead of an +/// `ESCAPE` clause because MatrixOne 4.1.x ignores custom `ESCAPE` characters. +/// The pattern is a bind parameter, so `'` needs no special handling; only NUL +/// is dropped. fn sanitize_like_pattern(s: &str) -> String { - s.chars() - .filter(|c| *c != '\0') - .map(|c| match c { - '\'' | '\\' => ' ', - '%' => ' ', - _ => c, - }) - .collect() + let mut out = String::with_capacity(s.len()); + for c in s.chars().filter(|c| *c != '\0') { + if matches!(c, '\\' | '%' | '_') { + out.push('\\'); + } + out.push(c); + } + out } fn vec_to_mo(v: &[f32]) -> String { @@ -6683,6 +6689,14 @@ mod tests { use std::io::{self, Write}; use std::sync::{Arc, Mutex, OnceLock}; + #[test] + fn sanitize_like_pattern_escapes_wildcards_for_literal_topic_match() { + assert_eq!(super::sanitize_like_pattern("user_id"), r"user\_id"); + assert_eq!(super::sanitize_like_pattern("50% off!"), r"50\% off!"); + assert_eq!(super::sanitize_like_pattern("it's a\\b\0"), r"it's a\\b"); + assert_eq!(super::sanitize_like_pattern("plain topic"), "plain topic"); + } + static LOG_TEST_LOCK: OnceLock> = OnceLock::new(); #[test]