mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
fix(search): local index rejects FTS syntax in live search queries (#983)
* fix(search): reject FTS syntax chars in local index live search queries FTS5 treats `=` and similar characters as query syntax, so tokens like 1=2 produced unrelated prefix hits. Skip FTS for unsafe tokens instead. * docs: CHANGELOG for local index FTS syntax query fix (PR #983) * fix(search): reject syntax junk in server search3 and live search race Share FTS-safe token guard with search3; skip network invoke for ** and similar queries; show empty dropdown without misleading source badge. * fix(search): allow censorship stars in queries, reject wildcard-only tokens Block ** and **** but keep ***Flawless-style title searches working in local FTS and search3.
This commit is contained in:
@@ -659,6 +659,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_equals_query_returns_no_false_positives() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track(
|
||||
"s1",
|
||||
"t1",
|
||||
"Intro",
|
||||
"Smith & Myers",
|
||||
"Volume 1 & 2",
|
||||
"al_vol",
|
||||
"ar1",
|
||||
),
|
||||
track("s1", "t2", "Hello", "Adele", "25", "al_25", "ar2"),
|
||||
track("s1", "t3", "Track", "Y.O.M.C.", "Single", "al_yo", "ar3"),
|
||||
])
|
||||
.unwrap();
|
||||
for q in ["1=2", "1=1", "M=c"] {
|
||||
let resp = run_live_search(&store, "s1", q, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.tracks.is_empty() && resp.albums.is_empty() && resp.artists.is_empty(),
|
||||
"query {q:?} must not fuzzy-match unrelated library rows"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_censorship_stars_in_title_is_searchable() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track(
|
||||
"s1",
|
||||
"t1",
|
||||
"***Flawless",
|
||||
"Beyoncé",
|
||||
"BEYONCÉ",
|
||||
"al1",
|
||||
"ar1",
|
||||
),
|
||||
track("s1", "t2", "Other Song", "Artist", "Album", "al2", "ar2"),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "***Flawless", None, 5, 5, 10).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].title, "***Flawless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_multiword_album_matches_any_token_not_only_first() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -62,6 +62,32 @@ pub fn search_tracks(
|
||||
/// Callers clamp their requested `limit` into `1..=PAGE_LIMIT_MAX`.
|
||||
pub(crate) const PAGE_LIMIT_MAX: u32 = 500;
|
||||
|
||||
/// Characters that break FTS5 quoted tokens — not `*` (censorship stars in titles).
|
||||
const FTS_QUERY_SYNTAX_CHARS: &[char] = &['=', ':', '(', ')', '^', '<', '>', '%', '|', '\\'];
|
||||
|
||||
fn is_wildcard_only_token(token: &str) -> bool {
|
||||
!token.is_empty() && token.chars().all(|c| c == '*')
|
||||
}
|
||||
|
||||
/// True when `token` can be safely wrapped in FTS5 quotes for prefix/phrase match.
|
||||
pub(crate) fn fts_token_is_safe(token: &str) -> bool {
|
||||
let t = token.trim();
|
||||
!t.is_empty()
|
||||
&& !is_wildcard_only_token(t)
|
||||
&& !t.chars().any(|c| FTS_QUERY_SYNTAX_CHARS.contains(&c))
|
||||
&& t.chars().any(|c| c.is_alphanumeric() || c as u32 >= 0x80)
|
||||
}
|
||||
|
||||
/// Whitespace-split tokens when every segment is FTS-safe; otherwise `None`.
|
||||
pub(crate) fn fts_safe_whitespace_tokens(raw: &str) -> Option<Vec<&str>> {
|
||||
let tokens: Vec<&str> = raw.split_whitespace().filter(|t| !t.is_empty()).collect();
|
||||
if tokens.is_empty() || !tokens.iter().all(|t| fts_token_is_safe(t)) {
|
||||
None
|
||||
} else {
|
||||
Some(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/// Local FTS is skipped below this length — single-character queries (e.g. Cyrillic
|
||||
/// «а», Latin «a») match huge fractions of a large library and bm25+LIMIT can
|
||||
/// take tens of seconds (§5.9: no heavy work on every keystroke).
|
||||
@@ -92,13 +118,11 @@ pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option<String> {
|
||||
|
||||
/// Navidrome-style any-word prefix match (`"a"* OR "b"*`).
|
||||
pub(crate) fn fts_prefix_token_or_expr(raw: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = raw
|
||||
.split_whitespace()
|
||||
let tokens: Vec<String> = fts_safe_whitespace_tokens(raw)?
|
||||
.into_iter()
|
||||
.map(|t| format!("\"{}\"*", t.replace('"', "\"\"")))
|
||||
.collect();
|
||||
if tokens.is_empty() {
|
||||
None
|
||||
} else if tokens.len() == 1 {
|
||||
if tokens.len() == 1 {
|
||||
Some(tokens.into_iter().next().unwrap())
|
||||
} else {
|
||||
Some(tokens.join(" OR "))
|
||||
@@ -106,8 +130,8 @@ pub(crate) fn fts_prefix_token_or_expr(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn fts_token_expr_with(raw: &str, prefix: bool) -> Option<String> {
|
||||
let tokens: Vec<String> = raw
|
||||
.split_whitespace()
|
||||
let tokens: Vec<String> = fts_safe_whitespace_tokens(raw)?
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let quoted = format!("\"{}\"", t.replace('"', "\"\""));
|
||||
if prefix {
|
||||
@@ -117,11 +141,7 @@ fn fts_token_expr_with(raw: &str, prefix: bool) -> Option<String> {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if tokens.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tokens.join(" "))
|
||||
}
|
||||
Some(tokens.join(" "))
|
||||
}
|
||||
|
||||
/// Column-scoped prefix match (`artist : "met"*` → Metallica).
|
||||
@@ -470,6 +490,37 @@ mod tests {
|
||||
assert!(fts_query(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_prefix_token_or_expr_rejects_syntax_metachar_tokens() {
|
||||
assert!(fts_prefix_token_or_expr("1=2").is_none());
|
||||
assert!(fts_prefix_token_or_expr("1=1").is_none());
|
||||
assert!(fts_prefix_token_or_expr("M=c").is_none());
|
||||
assert!(fts_prefix_token_or_expr("V()>P").is_none());
|
||||
assert!(fts_prefix_token_or_expr("**").is_none());
|
||||
assert!(fts_prefix_token_or_expr("****").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_prefix_token_or_expr_allows_censorship_stars_in_titles() {
|
||||
assert_eq!(
|
||||
fts_prefix_token_or_expr("***Flawless").as_deref(),
|
||||
Some("\"***Flawless\"*")
|
||||
);
|
||||
assert_eq!(
|
||||
fts_prefix_token_or_expr("B********").as_deref(),
|
||||
Some("\"B********\"*")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_prefix_token_or_expr_still_builds_safe_tokens() {
|
||||
assert_eq!(
|
||||
fts_prefix_token_or_expr("love supreme").as_deref(),
|
||||
Some("\"love\"* OR \"supreme\"*")
|
||||
);
|
||||
assert_eq!(fts_prefix_token_or_expr("25").as_deref(), Some("\"25\"*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliased_track_columns_prefixes_every_column() {
|
||||
let cols = aliased_track_columns("t");
|
||||
|
||||
Reference in New Issue
Block a user