From f46c3edfd3f8d56a3f35ffed1960b622533bb76b Mon Sep 17 00:00:00 2001 From: MatrixAdventurer <85048713+MatrixAdventurer@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:49:10 -0700 Subject: [PATCH 1/2] fix(storage): match purge topics literally in LIKE (escape % and _) Signed-off-by: MatrixAdventurer <85048713+MatrixAdventurer@users.noreply.github.com> --- .../crates/memoria-api/tests/api_db_verify.rs | 40 +++++++++++++++++++ memoria/crates/memoria-storage/src/store.rs | 35 +++++++++++----- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/memoria/crates/memoria-api/tests/api_db_verify.rs b/memoria/crates/memoria-api/tests/api_db_verify.rs index 4272aea6..d38c0760 100644 --- a/memoria/crates/memoria-api/tests/api_db_verify.rs +++ b/memoria/crates/memoria-api/tests/api_db_verify.rs @@ -531,6 +531,46 @@ 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); +} + #[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..8977bef3 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -843,15 +843,22 @@ pub fn validate_fulltext_query(query: &str) -> Result<(), MemoriaError> { } /// Sanitize a string for use in a LIKE pattern (escapes `%`). +/// Build the body of a `LIKE ? ESCAPE '!'` pattern that matches `s` as a +/// literal substring: `%`, `_` and the escape character itself are escaped so +/// they cannot act as wildcards (a topic purge of `a_b` must not delete `aXb`). 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') { + match c { + '\'' | '\\' => out.push(' '), + '!' | '%' | '_' => { + out.push('!'); + out.push(c); + } + _ => out.push(c), + } + } + out } fn vec_to_mo(v: &[f32]) -> String { @@ -5812,7 +5819,7 @@ impl SqlMemoryStore { "SELECT memory_id FROM {table} \ WHERE user_id = ? AND is_active = 1 \ AND MATCH(content) AGAINST('{ft_terms}' IN BOOLEAN MODE) \ - AND content LIKE ?" + AND content LIKE ? ESCAPE '!'" ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .bind(user_id) @@ -5828,7 +5835,7 @@ impl SqlMemoryStore { // Fallback: LIKE on user's active memories (idx_user_active narrows scan) let sql = format!( "SELECT memory_id FROM {table} \ - WHERE user_id = ? AND is_active = 1 AND content LIKE ? LIMIT 500" + WHERE user_id = ? AND is_active = 1 AND content LIKE ? ESCAPE '!' LIMIT 500" ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .bind(user_id) @@ -6678,6 +6685,14 @@ mod tests { ConnectionAnomalyKind, OwnedEditLogEntry, PoolHealthLevel, PoolHealthSnapshot, SqlMemoryStore, FULLTEXT_QUERY_MAX_BYTES, }; + + #[test] + fn sanitize_like_pattern_escapes_wildcards_for_literal_topic_match() { + assert_eq!(super::sanitize_like_pattern("user_id"), "user!_id"); + assert_eq!(super::sanitize_like_pattern("50% off!"), "50!% off!!"); + assert_eq!(super::sanitize_like_pattern("it's a\\b\0"), "it s a b"); + assert_eq!(super::sanitize_like_pattern("plain topic"), "plain topic"); + } use memoria_core::{Memory, MemoryType, TrustTier}; use sqlx::mysql::MySqlPoolOptions; use std::io::{self, Write}; From f37746cac21046b92896fd2573a672def738f69e Mon Sep 17 00:00:00 2001 From: MatrixAdventurer <85048713+MatrixAdventurer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:09:21 -0700 Subject: [PATCH 2/2] fix(storage): use portable backslash LIKE escaping for purge topics Address review on #298: - Escape \, % and _ with the default backslash instead of ESCAPE '!', which MatrixOne 4.1.x silently ignores. - Pass ' and \ through verbatim (the pattern is a bind parameter), so ab'cd no longer over-matches "ab cd". - Add LIKE-fallback-only regressions (cfg_key vs cfgXkey, quote and backslash topics), move the unit test after the use block, drop the stale doc line and add the closing println. --- .../crates/memoria-api/tests/api_db_verify.rs | 57 +++++++++++++++++++ memoria/crates/memoria-storage/src/store.rs | 39 +++++++------ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/memoria/crates/memoria-api/tests/api_db_verify.rs b/memoria/crates/memoria-api/tests/api_db_verify.rs index d38c0760..97d3b6a5 100644 --- a/memoria/crates/memoria-api/tests/api_db_verify.rs +++ b/memoria/crates/memoria-api/tests/api_db_verify.rs @@ -569,6 +569,63 @@ async fn test_purge_by_topic_treats_like_wildcards_literally_verify_db() { } 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] diff --git a/memoria/crates/memoria-storage/src/store.rs b/memoria/crates/memoria-storage/src/store.rs index 8977bef3..82666569 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -842,21 +842,20 @@ 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 ? ESCAPE '!'` pattern that matches `s` as a -/// literal substring: `%`, `_` and the escape character itself are escaped so -/// they cannot act as wildcards (a topic purge of `a_b` must not delete `aXb`). +/// 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 { let mut out = String::with_capacity(s.len()); for c in s.chars().filter(|c| *c != '\0') { - match c { - '\'' | '\\' => out.push(' '), - '!' | '%' | '_' => { - out.push('!'); - out.push(c); - } - _ => out.push(c), + if matches!(c, '\\' | '%' | '_') { + out.push('\\'); } + out.push(c); } out } @@ -5819,7 +5818,7 @@ impl SqlMemoryStore { "SELECT memory_id FROM {table} \ WHERE user_id = ? AND is_active = 1 \ AND MATCH(content) AGAINST('{ft_terms}' IN BOOLEAN MODE) \ - AND content LIKE ? ESCAPE '!'" + AND content LIKE ?" ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .bind(user_id) @@ -5835,7 +5834,7 @@ impl SqlMemoryStore { // Fallback: LIKE on user's active memories (idx_user_active narrows scan) let sql = format!( "SELECT memory_id FROM {table} \ - WHERE user_id = ? AND is_active = 1 AND content LIKE ? ESCAPE '!' LIMIT 500" + WHERE user_id = ? AND is_active = 1 AND content LIKE ? LIMIT 500" ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .bind(user_id) @@ -6685,18 +6684,18 @@ mod tests { ConnectionAnomalyKind, OwnedEditLogEntry, PoolHealthLevel, PoolHealthSnapshot, SqlMemoryStore, FULLTEXT_QUERY_MAX_BYTES, }; + use memoria_core::{Memory, MemoryType, TrustTier}; + use sqlx::mysql::MySqlPoolOptions; + 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"), "user!_id"); - assert_eq!(super::sanitize_like_pattern("50% off!"), "50!% off!!"); - assert_eq!(super::sanitize_like_pattern("it's a\\b\0"), "it s a b"); + 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"); } - use memoria_core::{Memory, MemoryType, TrustTier}; - use sqlx::mysql::MySqlPoolOptions; - use std::io::{self, Write}; - use std::sync::{Arc, Mutex, OnceLock}; static LOG_TEST_LOCK: OnceLock> = OnceLock::new();