From ae10eb9f19091d2c153d392544366f19eddcbc44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:11:53 +0000 Subject: [PATCH] Treat % and _ as literals in lesson substring search search_lessons wrapped the query in LIKE %q% without escaping metacharacters, so GET /v1/lessons?q= and /lesson search treated 100% as a prefix wildcard and foo_bar as a single-character match. Escape \, %, and _ and pair the pattern with ESCAPE '\'. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 7 +++++++ docs/api/lessons/list.md | 2 +- src/tenant_store.cpp | 30 ++++++++++++++++++++++++------ tests/test_lessons.cpp | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..4a1f0d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Lesson search is a literal substring.** `search_lessons` (`GET /v1/lessons?q=` + and `/lesson search`) wrapped the query in `LIKE %q%` without escaping `%` / + `_`, so those characters acted as SQL wildcards. A search for `100%` matched + `1000`, and `foo_bar` matched `fooXbar`. Patterns now escape LIKE + metacharacters (`ESCAPE '\'`). + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/docs/api/lessons/list.md b/docs/api/lessons/list.md index dec10ea4..de3c7b18 100644 --- a/docs/api/lessons/list.md +++ b/docs/api/lessons/list.md @@ -11,7 +11,7 @@ List the calling tenant's lessons. Newest `last_seen_at` first by default; with | Param | Description | |------------|-------------| | `agent_id` | Filter to one agent's lessons. Omit for the tenant's full set. | -| `q` | Substring match against signature OR lesson_text (case-insensitive). | +| `q` | Literal substring match against signature OR lesson_text (case-insensitive). `%` and `_` are literals, not wildcards. | | `limit` | Cap; max 200 for browse, 50 for search. | ```bash diff --git a/src/tenant_store.cpp b/src/tenant_store.cpp index b48878fe..e60a4297 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -189,6 +189,19 @@ std::string fts5_escape(const std::string& q) { return out; } +// Neutralise SQLite LIKE wildcards so a user query is a true substring. +// Paired with `ESCAPE '\'` at the call site. Backslash itself is escaped +// so a search for `\` does not swallow the next character. +std::string like_literal(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '\\' || c == '%' || c == '_') out.push_back('\\'); + out.push_back(c); + } + return out; +} + std::string bytes_to_hex(const unsigned char* data, size_t len) { std::ostringstream ss; ss << std::hex << std::setfill('0'); @@ -4193,20 +4206,25 @@ TenantStore::search_lessons(int64_t tenant_id, if (limit <= 0 || limit > 50) limit = 20; // Substring match — case-insensitive on lesson_text + signature. At // tens-to-hundreds of rows per agent this is fine; an FTS index - // would be premature. + // would be premature. Escape LIKE metacharacters so `100%` / `foo_bar` + // are literals (`GET /v1/lessons?q=` and `/lesson search`). std::string sql = std::string("SELECT ") + kLessonCols + " FROM lessons WHERE tenant_id = ?"; if (!agent_id.empty()) sql += " AND agent_id = ?"; - sql += " AND (lower(signature) LIKE ? OR lower(lesson_text) LIKE ?)" + sql += " AND (lower(signature) LIKE ? ESCAPE '\\' " + "OR lower(lesson_text) LIKE ? ESCAPE '\\')" " ORDER BY hit_count DESC, last_seen_at DESC LIMIT ?;"; Stmt q(db_, sql.c_str()); int idx = 1; q.bind(idx++, tenant_id); if (!agent_id.empty()) q.bind(idx++, agent_id); - std::string pat = "%"; - for (char c : query) pat.push_back(static_cast(std::tolower( - static_cast(c)))); - pat.push_back('%'); + std::string lowered; + lowered.reserve(query.size()); + for (char c : query) { + lowered.push_back(static_cast(std::tolower( + static_cast(c)))); + } + const std::string pat = "%" + like_literal(lowered) + "%"; q.bind(idx++, pat); q.bind(idx++, pat); q.bind(idx, static_cast(limit)); diff --git a/tests/test_lessons.cpp b/tests/test_lessons.cpp index a15fc976..ce2521c7 100644 --- a/tests/test_lessons.cpp +++ b/tests/test_lessons.cpp @@ -117,6 +117,39 @@ TEST_CASE("substring search hits signature OR lesson_text, case-insensitive") { CHECK(d.empty()); } +TEST_CASE("substring search treats LIKE wildcards as literals") { + TempDb db; TenantStore s; s.open(db.path.string()); + const int64_t tid = make_tenant(s, "acme"); + + s.create_lesson(tid, "x", "use 100% CPU for encode", + "cap ffmpeg threads"); + s.create_lesson(tid, "x", "use 1000 CPU for encode", + "percent sibling without the sign"); + s.create_lesson(tid, "x", "foo_bar retry", + "underscore signature"); + s.create_lesson(tid, "x", "fooXbar retry", + "single-char sibling"); + s.create_lesson(tid, "x", "path\\windows", + "backslash in signature"); + + auto pct = s.search_lessons(tid, "x", "100%", 50); + REQUIRE(pct.size() == 1); + CHECK(pct[0].signature == "use 100% CPU for encode"); + + auto us = s.search_lessons(tid, "x", "foo_bar", 50); + REQUIRE(us.size() == 1); + CHECK(us[0].signature == "foo_bar retry"); + + auto bs = s.search_lessons(tid, "x", "path\\windows", 50); + REQUIRE(bs.size() == 1); + CHECK(bs[0].signature == "path\\windows"); + + // Still a substring, not an anchored match. + auto sub = s.search_lessons(tid, "x", "100% CPU", 50); + REQUIRE(sub.size() == 1); + CHECK(sub[0].signature == "use 100% CPU for encode"); +} + TEST_CASE("hit bump tracks last_seen_at and ranks by hit_count") { TempDb db; TenantStore s; s.open(db.path.string()); const int64_t tid = make_tenant(s, "acme");