diff --git a/osmsg/query.py b/osmsg/query.py index b297b4e..6ee6306 100644 --- a/osmsg/query.py +++ b/osmsg/query.py @@ -48,6 +48,13 @@ def _rows(result) -> list[dict[str, Any]]: return [dict(zip(cols, r, strict=True)) for r in result.fetchall()] +def _like_escape(value: str) -> str: + """Escape LIKE metacharacters so a contributor search matches them literally. Without this `_` + matches any character and `%` any sequence, so searching `a_il` returns `Anil` and `%` returns + everyone. Paired with `ESCAPE '\\'` at the call site.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _prefixes(hashtag: str | list[str]) -> list[tuple[str, str]]: """Normalize one hashtag or many into deduped `(lo, hi)` prefix-range pairs, case-insensitive and order-preserving. Each hashtag matches as a prefix; the scope is the union across them.""" @@ -412,8 +419,8 @@ def leaderboard( rrel, rp = _recent_leaderboard(s, prefixes, start, end) search_pred, search_params = "", [] if q: - search_pred = " WHERE lower(name) LIKE ?" - search_params = [f"%{q.strip().lower()}%"] + search_pred = " WHERE lower(name) LIKE ? ESCAPE '\\'" + search_params = [f"%{_like_escape(q.strip().lower())}%"] con.execute( f""" CREATE OR REPLACE TEMP TABLE _lb_agg AS diff --git a/tests/test_query.py b/tests/test_query.py index dc7a527..7bba2de 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -87,6 +87,14 @@ def test_leaderboard_page_size_and_sort(con, sources): assert found["total"] == 1 and found["items"][0]["name"] == "alice" +def test_leaderboard_search_escapes_like_wildcards(con, sources): + # `_` and `%` are LIKE metacharacters: unescaped, `a_ice` matched `alice` and a bare `%` returned + # every contributor. They must match literally. + assert query.leaderboard(con, "hotosm", sources, q="ali")["total"] == 1 + assert query.leaderboard(con, "hotosm", sources, q="a_ice")["total"] == 0 + assert query.leaderboard(con, "hotosm", sources, q="%")["total"] == 0 + + def test_leaderboard_includes_per_user_tag_stats(con, sources): # The frontend reads per-user `tag_stats` (nested {key: {value: {c, m}}}) to show building/highway # per contributor; regression guard that leaderboard rows carry it across the history+recent seam.