fix: escape LIKE wildcards in user-search ILIKE queries
A `%` or `_` in a search term silently acted as a LIKE wildcard rather than
matching literally (`50%` matched everything, `a_b` matched `axb`). Not
injection — terms are bound — but a search-correctness bug across every
user-facing ILIKE site.
- repo::escape_like: shared pub(crate) escaper (promoted from page_tag), unit-tested.
- Trigram-entangled sites (manga, tag, author) append a separate escaped param
used only by the ILIKE branch; the trigram/similarity branches keep the raw term.
- Pattern-built sites (admin manga list, admin users, analysis coverage + history,
crawler search incl. JSONB payload title) escape inside format!("%{}%", ..) and
pair each ILIKE with ESCAPE '\'.
Tests: escape_like unit tests + integration tests on public manga search, author
autocomplete, and admin user search proving `_` matches literally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,47 @@ fn first_author_id(manga: &Value) -> String {
|
||||
manga["authors"][0]["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn search_treats_like_wildcards_literally(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
// Long author names differing only at one position, short search term — so
|
||||
// the trigram OR (which keeps the raw term by design) stays under threshold
|
||||
// and the ILIKE branch is what decides. Unescaped `%a_b%` matches both the
|
||||
// "axb" and "a_b" names; escaped, only the literal "a_b" name.
|
||||
create_manga(
|
||||
&h.app,
|
||||
&cookie,
|
||||
json!({ "title": "M1", "authors": ["The Quick Brown Fox axb Jumps Over"] }),
|
||||
)
|
||||
.await;
|
||||
create_manga(
|
||||
&h.app,
|
||||
&cookie,
|
||||
json!({ "title": "M2", "authors": ["The Quick Brown Fox a_b Jumps Over"] }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/authors?search=a_b"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let names: Vec<&str> = body
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| a["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["The Quick Brown Fox a_b Jumps Over"],
|
||||
"the `_` in the search term must match literally, not as a wildcard"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_returns_name_and_manga_count(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
Reference in New Issue
Block a user