fix(scripts): actually escape LIKE pattern literals + scope count endpoint

Codex review of #36 + #37 surfaced that my prior `escape="\\"` only
declared the escape character — the pattern literals themselves still
contained unescaped `_` and `%`, so `parent_path="foo_bar"` continued
to match `fooXbar/...`, `foo2bar/...`, etc. My earlier ESCAPE-clause
assertions were tautological: they verified the SQL rendered the
ESCAPE keyword without ever checking that the pattern was actually
escaped. The tests passed; the leak persisted.

Fix in three layers:

1. Real escape: `backend/src/backend/scripts.py` gains
   `_escape_like_pattern(value)` that escapes `\` → `\\`, `%` → `\%`,
   `_` → `\_` (in that order — the escape char MUST be escaped first).
   `_build_list_scripts_descendant_prefix` now returns the escaped
   prefix. `list_workspace_directories` and `delete_workspace_directory`
   also escape their server-built prefixes. `count_scripts` escapes
   the user subtree prefix.

2. Same bug elsewhere: `backend/src/backend/resources.py:404` had the
   identical `DataResources.resource_name.like(f"%{keyword}%")`
   pattern; a search for "100%" would match everything. Now escaped
   too.

3. Count endpoint scope: `count_scripts` was workspace-wide and
   skipped the StorageObjects JOIN. Now INNER JOINs StorageObjects
   (drops orphans whose current_object_id is dangling) and filters
   by `workspace/{user_id}/` subtree so the result matches what
   `list_scripts(parent_path="")` would return. Multi-member
   workspaces no longer over-report, and orphan rows no longer
   inflate the count.

Frontend: `DashboardRoute` is not keyed by workspace/user (only
ScriptsPage is), so without a workspace_id dep the previous
workspace's count persisted across navigation. useEffect now depends
on `currentWorkspace?.workspace_id`; `loadScriptCount` clears the
count to null at the start of the fetch so the dashboard doesn't
flash a stale number.

Tests — backend/tests/test_list_scripts_parent_path.py
- Rewritten with three layers of coverage:
  * Pure helper tests for `_escape_like_pattern` (7 cases including
    backslash-escape-first ordering).
  * SQL-contract tests asserting the COMPILED PATTERN contains the
    escaped form (lowercased to neutralise SQLAlchemy keyword casing).
  * BEHAVIORAL tests on SQLite in-memory with the same LIKE
    semantics — proves the fix actually prevents the wildcard leak.
    Includes a negative test (without escape, siblings DO match) so
    the fixture is verified to exercise the bug.

Tests — backend/tests/test_count_scripts.py
- Updated to assert the JOIN + user-scope filter. New test verifies
  two different users in the same workspace get different subtrees.

Verified:
- pytest backend/tests: 65 passed (43 baseline + 12 list_scripts + 4 count + 6 helper/SQLite behavioral)
- pnpm typecheck: clean
- Raw SQL on MySQL (live DB) confirms `LIKE 'workspace/.../foo\_bar/%%' ESCAPE '\\'`.
This commit is contained in:
tao.chen
2026-08-21 11:17:45 +08:00
parent 79650c61ed
commit ffec234e40
6 changed files with 312 additions and 120 deletions
+5 -1
View File
@@ -400,8 +400,12 @@ async def list_resources(
)
statement = statement.where(DataResources.visibility == visibility)
if keyword:
# Escape LIKE metacharacters so a search like "100%" or "my_file"
# doesn't act as a wildcard. The outer "%...%" wildcards stay raw.
from backend.scripts import _escape_like_pattern # local import: avoid cycle
escaped = _escape_like_pattern(keyword.strip())
statement = statement.where(
DataResources.resource_name.like(f"%{keyword.strip()}%")
DataResources.resource_name.like(f"%{escaped}%", escape="\\")
)
rows = (await session.execute(statement)).all()
return {
+37 -5
View File
@@ -115,10 +115,25 @@ def user_relative_path(context: RequestContext, child_path: str = "") -> str:
return f"{base}/{normalized}" if normalized else base
def _escape_like_pattern(value: str) -> str:
"""Escape SQL LIKE metacharacters so user-supplied folder names that
contain ``_`` or ``%`` do not act as wildcards.
Must be paired with ``escape="\\\\"`` on the LIKE clause so MySQL
recognizes the doubled backslash as a single literal backslash escape.
The trailing ``%`` / ``%/%`` SQL wildcards are NOT escaped — they are
added by the caller and are meant to be wildcards.
"""
# Order matters: escape the escape char FIRST, otherwise the next two
# replacements would double-escape our newly inserted backslashes.
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _build_list_scripts_descendant_prefix(
context: RequestContext, parent_path: str
) -> str:
"""Return the materialized-path prefix for direct children of ``parent_path``.
"""Return the escaped materialized-path prefix for direct children of
``parent_path``.
The endpoint appends ``LIKE '<prefix>/%' AND NOT LIKE '<prefix>/%/%'``
against ``storage_objects.relative_path`` so only scripts whose parent
@@ -127,6 +142,10 @@ def _build_list_scripts_descendant_prefix(
Empty ``parent_path`` produces the user-scoped root prefix — i.e. the
endpoint returns root-level scripts only, not the full workspace.
The prefix is run through ``_escape_like_pattern`` so folder names
containing ``_`` / ``%`` do not act as wildcards. The trailing ``/``
is appended AFTER escaping so it remains a literal slash.
"""
normalized_parent = normalize_user_path(parent_path)
scoped_prefix = user_relative_path(context)
@@ -134,7 +153,7 @@ def _build_list_scripts_descendant_prefix(
target_prefix = f"{scoped_prefix}/{normalized_parent}"
else:
target_prefix = scoped_prefix
return f"{target_prefix}/"
return f"{_escape_like_pattern(target_prefix)}/"
def safe_script_name(value: str, script_type: str) -> str:
@@ -769,7 +788,7 @@ async def list_workspace_directories(
scoped_prefix = user_relative_path(context)
parent = normalize_user_path(parent_path)
target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix
descendant_prefix = f"{target_prefix}/"
descendant_prefix = f"{_escape_like_pattern(target_prefix)}/"
rows = (
await session.execute(
@@ -805,7 +824,8 @@ async def list_workspace_directories(
for directory in directories.values():
# directory['path'] is already workspace-relative and includes the parent segment.
child_prefix = f"{scoped_prefix}/{directory['path']}/"
# Escape defensively in case the DB has folder names containing `_` or `%`.
child_prefix = f"{_escape_like_pattern(scoped_prefix)}/{_escape_like_pattern(directory['path'])}/"
has_children = await session.scalar(
select(StorageObjects.storage_object_id).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
@@ -1007,7 +1027,7 @@ async def delete_workspace_directory(
raise HTTPException(status.HTTP_404_NOT_FOUND, "directory not found")
target_ulid = target_dir_row.storage_object_id
child_prefix = f"{target_relative}/"
child_prefix = f"{_escape_like_pattern(target_relative)}/"
descendants = (
(
await session.execute(
@@ -1117,17 +1137,29 @@ async def list_scripts(
# 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用,
# 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明
# ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。
#
# 范围与 list_scripts(parent_path="") 对齐:INNER JOIN 到 StorageObjects 以排除
# 孤儿脚本(其 current_object_id 没有 joinable row),按用户子树
# (workspace/{user_id}/) 过滤。否则:
# - 含孤儿 → 数字虚高
# - workspace 范围 → 多成员工作区里 dashboard 会显示用户看不见的脚本
@router.get("/api/v1/scripts/count")
async def count_scripts(
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
user_subtree_prefix = f"{_escape_like_pattern(user_relative_path(context))}/%"
total = await session.scalar(
select(func.count())
.select_from(Scripts)
.join(
StorageObjects,
StorageObjects.storage_object_id == Scripts.current_object_id,
)
.where(
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
StorageObjects.relative_path.like(user_subtree_prefix, escape="\\"),
)
)
return {