Skip to content

fix(enhancer-search): escape regex metacharacters in search queries - #303

Open
mbret wants to merge 2 commits into
masterfrom
fix/bug-hunt-2026-08-10
Open

fix(enhancer-search): escape regex metacharacters in search queries#303
mbret wants to merge 2 commits into
masterfrom
fix/bug-hunt-2026-08-10

Conversation

@mbret

@mbret mbret commented Aug 10, 2026

Copy link
Copy Markdown
Owner

The bug

searchNodeContainingText interpolated the user's query verbatim into a RegExp:

collectMatchingRanges(node, RegExp(`(${text})`, `gi`), text.length, rangeList)

Three observable failures:

  1. Queries with regex metacharacters silently return zero results for the whole book. search("c++") throws SyntaxError: Invalid regular expression at construction. The throw is swallowed by the catchError in index.ts (and report.error is a no-op outside debug mode), so the user just sees no matches. Any query containing + ? ( ) [ ] * . $ ^ | \ is affected — "what?", "(a)", "1+1"
  2. Metacharacters that survive construction match the wrong content. search("c.t") matches cat and cut; search("the (end)") matches "the end" and therefore never finds the literal text the (end).
  3. Ranges could be anchored at the wrong offsets. The range end used text.length instead of the actual match length, and matching ran against a toLowerCase() copy of the node data — for characters whose lowercase form has a different length (e.g. İ), every subsequent match index shifts, producing ranges over the wrong characters or an IndexSizeError that again drops the whole spine item's results.

The fix

  • Escape the query before building the RegExp — the query is literal text, not a pattern.
  • Match against the node data as-is; the regexp already carries the i flag, so lowercasing was redundant and offset-shifting.
  • Anchor the range end to match[0].length instead of the query length.

No public surface changes. The gitbook/enhancers/search.md page is an empty stub, so no doc update applies.

Verification

Added packages/enhancer-search/src/search.test.ts (jsdom environment, same setup as the cfi package). Before the fix, 4 of the 5 new tests fail exactly along the lines above (SyntaxError for c++, false positives for c.t, no match for the (end), IndexSizeError for the İ document). After the fix all pass.

Gates run locally on the pinned Node 25: biome format ✓, biome lint ✓, build ✓, tsc ✓, unit tests for all 15 packages ✓. (The Playwright e2e suite could not run in this container — no webkit — but this change touches only the search enhancer's text matching, which that suite does not cover.)

Other findings (not addressed in this PR)

Confirmed during this hunt, left for future runs:

  • @prose-reader/cfi generates non-spec text-node steps. generate.ts computes text steps as childNodes index + 1 instead of the spec's 2 × (preceding element siblings) + 1, so a text node directly following an element gets an even step and resolves back to the wrong node (or fails). Executed trace: <p><b>Bold</b>tail</p>epubcfi(/4/2[p1]/2:3) resolves to the <b> element instead of the text node.
  • @prose-reader/cfi serialize() corrupts offset-only range CFIs. parse() clones the whole parent path into start/end for ,:5,:10 forms and serialize() writes the clone back verbatim: serialize(parse("epubcfi(/6/4!/4/2/1,:5,:10)"))epubcfi(/6/4!/4/2/1,/4/2/1:5,/4/2/1:10), which no longer resolves. updateEpubCfiSpineItemref (used by the cbz enhancer) round-trips through this and corrupts every such range.
  • Deleted annotations never leave the page. In SpineItemHighlights.ts the stale-highlight predicate compares itemIndex (always equal, since the incoming stream is pre-filtered by item index in ReaderHighlights.ts), so deleted highlights are never destroyed; the removal line this.highlights = this.highlights.splice(index, 1) would also replace the array with the removed element if it ever ran.
  • EPUB3 nav TOC ignores epub:type="toc". toc/nav.ts takes the first <nav> via descendantWithPath("body.nav.ol"), so a book authored with landmarks or page-list before the toc nav gets the wrong TOC (executed against the repo's xmldoc). Relatedly, an unrecognized nav shape returns [], which the truthy check in resolveArchiveToc.ts accepts, shadowing a valid NCX fallback.
  • Streamer returns HTTP 500 for non-well-formed .xhtml. calibreFixHook parses every .xhtml with XmlDocument before checking it's a calibre cover; a parse throw (e.g. unescaped & in an href, unclosed <img>) propagates and the raw-blob fallback is never reached, so the chapter never renders.
  • sortByTitleComparator returns 1 for equal or prefix strings (cmp("chapter","chapter") === 1), so sorts containing equal/prefix titles produce unstable, wrong orderings (executed). Public export of @prose-reader/shared.

Generated by Claude Code

The user query was interpolated verbatim into a RegExp, so a search for
"c++" threw at construction (silently swallowed into 0 results for the
whole book) and queries like "c.t" matched the wrong content. The range
end was also set from the query length instead of the actual match, and
matching against a lowercased copy of the node data shifted offsets for
characters whose lowercase form has a different length.

Escape the query, match against the node data as-is (the regexp already
carries the i flag) and anchor ranges to the real match boundaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYuB19e7ifbJmDnBoF4Aqz
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
prose-reader-demo Ready Ready Preview Aug 10, 2026 7:37am
prose-reader-front Ready Ready Preview Aug 10, 2026 7:37am

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9414968698

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Match against the node data as-is: lowercasing here would shift match
// indices for characters whose lowercase form has a different length
// (e.g. İ), while the regexp already carries the `i` flag.
const content = (subNode as Text).data

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Unicode case-folded matches

When the document contains characters whose lowercase mapping is not handled equivalently by JavaScript's non-Unicode i flag, matching the original text now drops valid case-insensitive results. For example, a query of "i" previously matched the first character of "İstanbul" because toLowerCase() produces "i̇stanbul", but /i/gi.exec("İstanbul") returns no match; uppercase Deseret letters and the Kelvin sign have similar regressions. Since case-insensitive search remains intended, preserve an index mapping from case-folded text to the original text or use another Unicode-aware matching strategy while calculating ranges against the original node.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in af27a77: the regexp now uses giu, so the i flag applies Unicode simple case folding — this restores the Kelvin-sign and Deseret matches with no index remapping needed (folding is per-code-point, so match.index/match[0].length stay valid against the original text).

İ (U+0130) is deliberately left unmatched by "i": it is only reachable through full case folding (İ, two code points), which regex engines don't apply, and handling it would require building a fold-index map for every text node on this hot path. Note the previous toLowerCase() behavior wasn't a working baseline for these documents either — it matched İ at the cost of shifting every subsequent match in the node by one unit, producing ranges over the wrong characters or an IndexSizeError that dropped the whole spine item's results (covered by the İstanbul regression test).


Generated by Claude Code

Matching the original node data with a plain `i` flag lost the exotic
case-folded matches the old lowercased copy happened to find (Kelvin
sign, Deseret letters). The `u` flag restores them through Unicode
simple case folding without any index remapping. Characters only
reachable through full case folding (İ) stay unmatched on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYuB19e7ifbJmDnBoF4Aqz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants