fix(sessions): escape LIKE wildcards in the cwd-prefix clause

_cwd_prefix_clause builds "cwd is this directory or under it" for session
listing, workspace resume and prune/archive. The two LIKE arms bound the
raw prefix, so `_` and `%` acted as wildcards on a value that is a path:

  cwd_prefix="/home/me/my_project"
    main -> ['sibling', 'target']    # /home/me/myXproject/src matched too
    fix  -> ['target']

`_` matches any single character, so a same-length sibling directory with
children falls inside the pattern. prune_sessions() deletes the rows it
matches (and their on-disk transcripts), so an unrelated project's history
goes with it.

Escape the needle and pair both arms with ESCAPE, the convention the rest
of this file already uses; the literal separator backslash in the Windows
pattern is escaped for the same reason. The `=` arm is an exact compare and
keeps the raw prefix, so directory-and-children matching is unchanged.

Follow-up to the *_like filters in #78681, kept separate because this helper
is shared by four call sites beyond prune.
This commit is contained in:
Drexuxux 2026-08-05 00:58:43 +03:00 committed by kshitij
parent 1d2dabce56
commit b37de01926
2 changed files with 39 additions and 1 deletions

View File

@ -189,7 +189,16 @@ def _escape_like(text: str) -> str:
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
# ``_`` and ``%`` are LIKE wildcards but ordinary characters in a path
# (``my_project``), so an unescaped prefix also matches sibling directories.
# Escape the needle and pair it with ESCAPE; the literal separator
# backslash in the Windows pattern needs escaping for the same reason. The
# ``=`` arm is an exact compare and keeps the raw prefix.
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return (
"(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')",
[prefix, f"{esc}/%", f"{esc}\\\\%"],
)
def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:

View File

@ -1121,6 +1121,35 @@ class TestPruneSessionFilters:
assert {r["id"] for r in db.list_prune_candidates(title_like="smoke")} == {"smoke"}
assert {r["id"] for r in db.list_prune_candidates(title_like=r"c:\tmp")} == {"winpath"}
def test_cwd_prefix_underscore_is_literal_not_a_wildcard(self, db):
"""``_`` is a LIKE wildcard but an ordinary character in a path, so an
unescaped prefix also matched a same-length sibling directory and
prune_sessions deletes what it matches."""
self._mk(db, "target", cwd="/home/me/my_project/src")
self._mk(db, "sibling", cwd="/home/me/myXproject/src")
rows = db.list_prune_candidates(cwd_prefix="/home/me/my_project")
assert {r["id"] for r in rows} == {"target"}
pruned = db.prune_sessions(older_than_days=None, cwd_prefix="/home/me/my_project")
assert pruned == 1
assert db.get_session("sibling") is not None
def test_cwd_prefix_percent_does_not_select_everything(self, db):
self._mk(db, "a", cwd="/home/me/one")
self._mk(db, "b", cwd="/home/me/two")
assert db.list_prune_candidates(cwd_prefix="/home/me/%") == []
def test_cwd_prefix_still_matches_the_directory_and_its_children(self, db):
"""Control: the prefix must keep matching itself and anything under it."""
self._mk(db, "root", cwd="/home/me/proj")
self._mk(db, "child", cwd="/home/me/proj/src")
self._mk(db, "outside", cwd="/home/me/other")
rows = db.list_prune_candidates(cwd_prefix="/home/me/proj")
assert {r["id"] for r in rows} == {"root", "child"}
def test_unknown_filter_rejected(self, db):
import pytest as _pytest
with _pytest.raises(TypeError):