fix(files): match glob queries without regex so a hostile query cannot stall the server

The Files search compiled the user's query into a backtracking RegExp:
'*a*a*a...' became '^.*a.*a.*a...$', the classic blowup, evaluated
synchronously against every walked path — a pathological query could
freeze the event loop for the whole server (and every user of it in
multi-user mode). /api/search stays regex-free for exactly this reason.

Globs now match through a two-pointer wildcard walk, O(text · pattern)
worst case, with a 256-char query cap bounding the pattern side; an
overlong query compiles to null, the same answer as an empty one.
Semantics are unchanged (anchored, case-insensitive, * spans slashes)
and the existing tests pass untouched; the pathological pattern gets a
test that fails by timeout with the RegExp version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-19 23:35:45 +02:00
parent a49c30d173
commit 68ae9a8c5f
2 changed files with 68 additions and 15 deletions
+50 -15
View File
@@ -6,42 +6,77 @@
*
* Semantics:
* - Empty / whitespace-only query → `compileFileQuery` returns `null` (the
* caller treats this as "no search", falling back to the full tree).
* - A query containing a glob metachar (`*` or `?`) compiles to an anchored,
* case-insensitive RegExp: `*` → `.*`, `?` → `.`, every other regex
* metacharacter is escaped so it matches literally.
* caller treats this as "no search", falling back to the full tree). A query
* longer than `MAX_QUERY_LENGTH` compiles to `null` too: no honest filename
* search is that long, and the glob walk below is O(text · pattern).
* - A query containing a glob metachar (`*` or `?`) matches anchored and
* case-insensitively, `*` spanning any run (slashes included) and `?` exactly
* one character; every other character matches literally.
* - Otherwise the query is a plain case-insensitive substring.
* - When the query contains a `/` it matches against the relative path; else it
* matches against the bare entry name.
*
* ⚠️ Globs are matched by `globMatch` below, never by compiling the query into
* a RegExp: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking
* blowup, evaluated synchronously against every walked path — a pathological
* query could freeze the event loop for the whole server (the same reason
* `search-service.ts` is regex-free). The two-pointer wildcard walk is
* O(text · pattern) worst case, with both operands short by construction.
*
* No fs / IO — safe to unit-test directly.
*/
export type FileQueryMatcher = (name: string, relativePath: string) => boolean;
// Escape every regex metacharacter. `*` and `?` are handled separately by the
// glob translation below, so they are intentionally NOT escaped here.
function escapeRegexExceptGlob(input: string): string {
return input.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Longer than any honest file search; bounds the O(text · pattern) glob walk.
const MAX_QUERY_LENGTH = 256;
/**
* Anchored glob match, linear-space two-pointer walk (no RegExp — see the
* fileoverview). `pattern` must already be lowercased; `text` is lowercased
* here so one compiled matcher serves many entries.
*/
function globMatch(pattern: string, rawText: string): boolean {
const text = rawText.toLowerCase();
let p = 0;
let t = 0;
let starP = -1;
let starT = -1;
while (t < text.length) {
const pc = p < pattern.length ? pattern[p] : '';
if (pc === '?' || pc === text[t]) {
p++;
t++;
} else if (pc === '*') {
// Remember the star; try matching zero characters first, and on a later
// mismatch re-expand it one character at a time from here.
starP = p++;
starT = t;
} else if (starP !== -1) {
p = starP + 1;
t = ++starT;
} else {
return false;
}
}
while (p < pattern.length && pattern[p] === '*') p++;
return p === pattern.length;
}
/**
* Compile a query string into a matcher predicate, or `null` when the query is
* empty/whitespace (caller treats null as "no search").
* empty/whitespace or overlong (caller treats null as "no search").
*/
export function compileFileQuery(query: string): FileQueryMatcher | null {
const trimmed = query.trim();
if (trimmed === '') return null;
if (trimmed === '' || trimmed.length > MAX_QUERY_LENGTH) return null;
const matchesPath = trimmed.includes('/');
const isGlob = trimmed.includes('*') || trimmed.includes('?');
if (isGlob) {
// Translate the glob to an anchored, case-insensitive RegExp. Escape all
// regex metacharacters first (except * and ?), then expand the wildcards.
const pattern = escapeRegexExceptGlob(trimmed).replace(/\*/g, '.*').replace(/\?/g, '.');
const regex = new RegExp(`^${pattern}$`, 'i');
return (name, relativePath) => regex.test(matchesPath ? relativePath : name);
const pattern = trimmed.toLowerCase();
return (name, relativePath) => globMatch(pattern, matchesPath ? relativePath : name);
}
const needle = trimmed.toLowerCase();
+18
View File
@@ -64,6 +64,24 @@ describe('compileFileQuery', () => {
expect(m!('session.ts', 'src/session.ts')).toBe(true);
expect(m!('session.ts', 'lib/session.ts')).toBe(false);
});
it('stays fast on a pathological star-heavy pattern (no regex backtracking)', () => {
// `*a*a*a…` compiled to `^.*a.*a…$` is the classic backtracking blowup —
// as a RegExp this match takes effectively forever and this test fails by
// timeout. The two-pointer glob walk answers it in linear-ish time; the
// 500ms ceiling is generous so a loaded CI box cannot flake it.
const m = compileFileQuery('*a'.repeat(40) + 'b');
expect(m).not.toBeNull();
const started = performance.now();
expect(m!('a'.repeat(200), 'a'.repeat(200))).toBe(false);
expect(performance.now() - started).toBeLessThan(500);
});
it('treats an overlong query as no search, like an empty one', () => {
// The glob walk is O(text · pattern); the length cap is what bounds it.
expect(compileFileQuery('a'.repeat(257))).toBeNull();
expect(compileFileQuery('a'.repeat(256))).not.toBeNull();
});
});
describe('matchFileQuery', () => {