1+ import { RE2JS } from 're2js'
12import {
23 materializeLargeArrayManifest ,
34 readLargeArrayManifestSlice ,
@@ -26,17 +27,21 @@ const DEFAULT_MAX_SLICES_SCANNED = 200
2627/**
2728 * Cumulative time the pattern itself may spend matching, across all spans/fields.
2829 *
29- * Deliberately counts only time inside `test`/`exec`, not the grep's wall clock:
30- * the scan awaits blob-store reads (array slices, large-value refs) between
31- * matches, and charging that I/O to the budget would truncate slow-but-legitimate
32- * greps under load. Matching is the only part that occupies the event loop, so
33- * it is the only part worth bounding.
30+ * Deliberately counts only time spent matching, not the grep's wall clock: the
31+ * scan awaits blob-store reads (array slices, large-value refs) between matches,
32+ * and charging that I/O to the budget would truncate slow-but-legitimate greps
33+ * under load. Matching is the only part that occupies the event loop, so it is
34+ * the only part worth bounding.
35+ *
36+ * RE2JS trades throughput for its linear-time guarantee — roughly 100x slower
37+ * than the built-in engine, ~25ms per megabyte — so on a very large trace this
38+ * budget is what actually caps the scan rather than a formality.
3439 */
3540const DEFAULT_MATCH_TIME_BUDGET_MS = 5_000
3641/**
37- * Total characters a single grep may run the pattern over. A backstop against a
38- * pattern that slips past the backtracking screen being amplified across every
39- * slice — set well above any realistic trace so normal greps never trip it.
42+ * Total characters a single grep may run the pattern over. Bounds the work one
43+ * request can demand across every span and slice; set well above any realistic
44+ * trace so normal greps never trip it.
4045 */
4146const DEFAULT_MAX_SCANNED_CHARS = 64 * 1024 * 1024
4247
@@ -196,10 +201,10 @@ export interface GrepSpansResult {
196201 */
197202 truncated : boolean
198203 /**
199- * Present when the pattern contained regex syntax, which is matched literally.
200- * The tool catalog cannot say so up front — it is generated from a contract in
201- * another repository — so the caller is told here instead of silently reading
202- * zero matches as "not present in the trace".
204+ * Present only when the pattern used syntax RE2 does not implement and was
205+ * therefore matched literally. The tool catalog cannot warn up front — it is
206+ * generated from a contract in another repository — so the caller is told
207+ * here rather than reading zero matches as "not present in the trace".
203208 */
204209 patternNotice ?: string
205210}
@@ -223,67 +228,84 @@ interface GrepState {
223228 maxSlicesScanned : number
224229 maxScannedChars : number
225230 matchTimeBudgetMs : number
226- regex : RegExp
231+ find : FindMatch
227232}
228233
229234function escapeRegExp ( input : string ) : string {
230235 return input . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
231236}
232237
233- /**
234- * Signals that the caller wrote a pattern *intending* regex semantics: escape
235- * classes, a character class, a group, alternation, a leading/trailing anchor,
236- * or a repetition quantifier.
237- *
238- * Deliberately narrower than the set `escapeRegExp` escapes. A bare `.` or `?`
239- * is ordinary text in the things people actually grep for — `example.com`,
240- * `file.pdf`, `v1.2.3`, `block_1.output` — and flagging those would tell the
241- * caller its correct search was misinterpreted, prompting a pointless retry.
242- */
243- const REGEX_INTENT = / \\ [ d w s b D W S B ] | [ [ \] ( ) | * + ] | ^ \^ | \$ $ | \{ \d + , ? \d * \} /
238+ /** Any regex metacharacter — a pattern without one behaves the same either way. */
239+ const REGEX_METACHARACTERS = / [ . * + ? ^ $ { } ( ) | [ \] \\ ] /
240+
241+ /** Index of the first case-insensitive match in `text`, or -1. */
242+ type FindMatch = ( text : string ) => number
243+
244+ /** Escaped-literal search on the built-in engine: linear, and allocation-free. */
245+ function literalFinder ( pattern : string ) : FindMatch {
246+ const regex = new RegExp ( escapeRegExp ( pattern ) , 'i' )
247+ return ( text ) => {
248+ const match = regex . exec ( text )
249+ return match ? match . index : - 1
250+ }
251+ }
244252
245253/**
246- * Compile a caller-supplied grep pattern as a case-insensitive literal .
254+ * Compile a caller-supplied grep pattern into a matcher that cannot backtrack .
247255 *
248- * Caller patterns are deliberately never executed as regexes. Trace text is
249- * attacker-influenced — a workflow can emit arbitrarily long uniform runs into
250- * its own block outputs — and matching runs synchronously on the shared event
251- * loop, so a backtracking pattern stalls every request on the instance.
256+ * Trace text is attacker-influenced — a workflow can emit arbitrarily long
257+ * uniform runs into its own block outputs — and matching runs synchronously on
258+ * the shared event loop, so a backtracking engine lets one request stall every
259+ * other request on the instance. RE2JS is a port of RE2: it matches in time
260+ * linear in the input, so no pattern can blow up regardless of the text.
252261 *
253- * Screening was tried and abandoned: `safe-regex2` (which the guardrails
254- * validator uses) documents itself as having false negatives and passes
255- * `(a|a)*b`; rejecting quantified groups on top of it still passes `a*a*b`,
256- * measured at 213s on JSC and 132s on V8 over a 10k-character run. Each rule
257- * only excludes the shapes someone thought to enumerate. An escaped literal is
258- * linear on every engine, which is the one property worth relying on here.
262+ * Screening the pattern instead was tried and abandoned. `safe-regex2` (used by
263+ * the guardrails validator) documents itself as having false negatives and
264+ * passes `(a|a)*b`; rejecting quantified groups on top of it still passes
265+ * `a*a*b`, measured at 213s on JSC and 132s on V8 over a 10k-character run.
266+ * Every syntactic rule only excludes the shapes someone thought to enumerate,
267+ * which is why the engine changed instead of the filter.
268+ *
269+ * Two escape hatches keep the common path fast and the rare one honest:
270+ * a pattern with no metacharacter takes the built-in engine (identical
271+ * semantics, ~100x quicker), and syntax RE2 does not implement — lookaround,
272+ * backreferences — falls back to a literal with a notice, since RE2JS rejects
273+ * those at compile time rather than matching them.
259274 */
260- function compilePattern ( pattern : string ) : { regex : RegExp ; notice ?: string } {
261- const regex = new RegExp ( escapeRegExp ( pattern ) , 'i' )
262- if ( ! REGEX_INTENT . test ( pattern ) ) return { regex }
263- return {
264- regex,
265- notice :
266- 'Matched as a literal, case-insensitive substring — regex syntax is not interpreted. Search for the literal text you expect to see in the trace.' ,
275+ function compilePattern ( pattern : string ) : { find : FindMatch ; notice ?: string } {
276+ if ( ! REGEX_METACHARACTERS . test ( pattern ) ) return { find : literalFinder ( pattern ) }
277+
278+ try {
279+ const compiled = RE2JS . compile ( pattern , RE2JS . CASE_INSENSITIVE )
280+ return {
281+ find : ( text ) => {
282+ const matcher = compiled . matcher ( text )
283+ return matcher . find ( ) ? matcher . start ( ) : - 1
284+ } ,
285+ }
286+ } catch {
287+ return {
288+ find : literalFinder ( pattern ) ,
289+ notice :
290+ 'Pattern is not valid RE2 syntax (lookahead, lookbehind and backreferences are unsupported), so it was matched as a literal string. Rewrite it without those constructs to search by regex.' ,
291+ }
267292 }
268293}
269294
270295/**
271- * Charge the elapsed matching time to the grep's budget. Every `test`/`exec` on
272- * caller-supplied patterns goes through here.
296+ * Run the pattern over `text`, charging the elapsed matching time to the grep's
297+ * budget. Every match on a caller-supplied pattern goes through here.
273298 */
274- function runTimed < T > ( state : GrepState , match : ( regex : RegExp ) => T ) : T {
299+ function findTimed ( text : string , state : GrepState ) : number {
275300 const started = performance . now ( )
276301 try {
277- state . regex . lastIndex = 0
278- return match ( state . regex )
302+ return state . find ( text )
279303 } finally {
280304 state . matchTimeMs += performance . now ( ) - started
281305 }
282306}
283307
284- function snippetAround ( text : string , state : GrepState ) : string {
285- const m = runTimed ( state , ( regex ) => regex . exec ( text ) )
286- const index = m ? m . index : 0
308+ function snippetAround ( text : string , index : number , state : GrepState ) : string {
287309 const maxChars = state . maxSnippetChars
288310 const half = Math . floor ( maxChars / 2 )
289311 const start = Math . max ( 0 , index - half )
@@ -314,13 +336,14 @@ function recordIfMatch(
314336 return
315337 }
316338 state . scannedChars += text . length
317- if ( ! runTimed ( state , ( regex ) => regex . test ( text ) ) ) return
339+ const index = findTimed ( text , state )
340+ if ( index < 0 ) return
318341 state . matches . push ( {
319342 spanId : span . id ,
320343 blockId : span . blockId ,
321344 name : span . name ,
322345 field,
323- snippet : snippetAround ( text , state ) ,
346+ snippet : snippetAround ( text , index , state ) ,
324347 } )
325348 if ( state . matches . length >= state . maxMatches ) state . truncated = true
326349}
@@ -394,12 +417,11 @@ function safeStringify(value: unknown): string {
394417 * next); single large refs are materialized under a byte cap (falling back to
395418 * the ref preview). Only bounded match snippets are accumulated.
396419 *
397- * `pattern` is matched as a literal substring, never as a regex — see
398- * `compilePattern`. Two budgets bound the scan on top of that: a total
399- * character budget and a cumulative match-time budget. Neither counts the
400- * blob-store I/O this scan awaits, so a slow-but-legitimate grep is not
401- * truncated for being slow. With literal matching both are backstops rather
402- * than load-bearing, and they stay to bound total work per request.
420+ * `pattern` is matched by a non-backtracking engine — see `compilePattern` — so
421+ * no pattern can blow up on any input. Two budgets bound total work on top of
422+ * that: a character budget and a cumulative match-time budget. Neither counts
423+ * the blob-store I/O this scan awaits, so a slow-but-legitimate grep is not
424+ * truncated merely for being slow.
403425 */
404426export async function grepSpans (
405427 spans : TraceSpan [ ] ,
@@ -419,7 +441,7 @@ export async function grepSpans(
419441 maxSlicesScanned : opts ?. maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED ,
420442 maxScannedChars : opts ?. maxScannedChars ?? DEFAULT_MAX_SCANNED_CHARS ,
421443 matchTimeBudgetMs : opts ?. matchTimeBudgetMs ?? DEFAULT_MATCH_TIME_BUDGET_MS ,
422- regex : compiled . regex ,
444+ find : compiled . find ,
423445 }
424446
425447 const walk = async ( list : TraceSpan [ ] ) : Promise < void > => {
0 commit comments