diff --git a/app/database/services/info.py b/app/database/services/info.py index 13af76ea0..758338368 100644 --- a/app/database/services/info.py +++ b/app/database/services/info.py @@ -47,7 +47,8 @@ async def list_asset_infos_page( ) if name_contains: - base = base.where(AssetInfo.name.ilike(f"%{name_contains}%")) + escaped, esc = escape_like_prefix(name_contains) + base = base.where(AssetInfo.name.ilike(f"%{escaped}%", escape=esc)) base = apply_tag_filters(base, include_tags, exclude_tags) base = apply_metadata_filter(base, metadata_filter) @@ -73,7 +74,8 @@ async def list_asset_infos_page( .where(visible_owner_clause(owner_id)) ) if name_contains: - count_stmt = count_stmt.where(AssetInfo.name.ilike(f"%{name_contains}%")) + escaped, esc = escape_like_prefix(name_contains) + count_stmt = count_stmt.where(AssetInfo.name.ilike(f"%{escaped}%", escape=esc)) count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags) count_stmt = apply_metadata_filter(count_stmt, metadata_filter) diff --git a/tests-assets/test_list_filter.py b/tests-assets/test_list_filter.py index b0b476af5..835de0367 100644 --- a/tests-assets/test_list_filter.py +++ b/tests-assets/test_list_filter.py @@ -1,4 +1,5 @@ import asyncio +import uuid import aiohttp import pytest @@ -301,3 +302,36 @@ async def test_list_assets_invalid_query_rejected(http: aiohttp.ClientSession, a b2 = await r2.json() assert r2.status == 400 assert b2["error"]["code"] == "INVALID_QUERY" + + +@pytest.mark.asyncio +async def test_list_assets_name_contains_literal_underscore( + http, + api_base, + asset_factory, + make_asset_bytes, +): + """'name_contains' must treat '_' literally, not as a SQL wildcard. + We create: + - foo_bar.safetensors (should match) + - fooxbar.safetensors (must NOT match if '_' is escaped) + - foobar.safetensors (must NOT match) + """ + scope = f"lf-underscore-{uuid.uuid4().hex[:6]}" + tags = ["models", "checkpoints", "unit-tests", scope] + + a = await asset_factory("foo_bar.safetensors", tags, {}, make_asset_bytes("a", 700)) + b = await asset_factory("fooxbar.safetensors", tags, {}, make_asset_bytes("b", 700)) + c = await asset_factory("foobar.safetensors", tags, {}, make_asset_bytes("c", 700)) + + async with http.get( + api_base + "/api/assets", + params={"include_tags": f"unit-tests,{scope}", "name_contains": "foo_bar"}, + ) as r: + body = await r.json() + assert r.status == 200, body + names = [x["name"] for x in body["assets"]] + assert a["name"] in names, f"Expected literal underscore match to include {a['name']}" + assert b["name"] not in names, "Underscore must be escaped — should not match 'fooxbar'" + assert c["name"] not in names, "Underscore must be escaped — should not match 'foobar'" + assert body["total"] == 1