Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions memoria/crates/memoria-api/tests/api_db_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
32 changes: 23 additions & 9 deletions memoria/crates/memoria-storage/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Mutex<()>> = OnceLock::new();

#[test]
Expand Down
Loading