212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
/**
|
|
* ReDoS probe corpus self-test (redos-probes.mjs).
|
|
*
|
|
* `src/shared/redosProbes.ts` is the corpus a stored rule is executed against
|
|
* before it may run in the terminal. It is data, so the test has to prove the
|
|
* data does its job:
|
|
*
|
|
* - the set is versioned, bounded (every probe ≤ the engine's 4KB line limit,
|
|
* no more than the plan's ~24 entries) and synthetic — printable ASCII with
|
|
* a handful of distinct characters, so nothing here can be recorded
|
|
* terminal output or user data;
|
|
* - all five backtracking families are present with several fillers, because
|
|
* a pattern can be immune to one filler and helpless against the next;
|
|
* - every shipped preset and ordinary user rule stays FAR inside the budget
|
|
* on the whole corpus (a corpus that false-fails a preset would disable a
|
|
* built-in rule for every user);
|
|
* - the shapes measured as catastrophic in the plan — including the four the
|
|
* static heuristic missed — do NOT finish inside the budget. Those run in a
|
|
* child process with a hard timeout: a single `exec` on a hostile pattern
|
|
* blocks for seconds to minutes and cannot be interrupted from inside, so
|
|
* running them in-process would hang the suite instead of failing it.
|
|
*
|
|
* Direct .ts import (no esbuild bundle): redosProbes.ts and settings.ts have no
|
|
* runtime imports, and Node >= 22.18 strips types itself.
|
|
* Run: node tests/redos-probes.mjs (must exit 0; takes a few seconds)
|
|
*/
|
|
import { spawnSync } from 'node:child_process'
|
|
import { PROBE_BUDGET_MS, PROBE_MAX_LEN, PROBE_VERSION, REDOS_PROBES } from '../src/shared/redosProbes.ts'
|
|
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
|
|
|
|
let failed = 0
|
|
const ok = (cond, msg) => {
|
|
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
|
if (!cond) failed += 1
|
|
}
|
|
|
|
const SHAPES = ['repeat', 'failing-terminator', 'alternation', 'overlap-class', 'literal-prefix']
|
|
|
|
// ---- 1. the corpus itself ---------------------------------------------------
|
|
console.log('[corpus]')
|
|
{
|
|
ok(Number.isInteger(PROBE_VERSION) && PROBE_VERSION >= 1, `PROBE_VERSION is a positive integer (${PROBE_VERSION})`)
|
|
ok(PROBE_BUDGET_MS === 500, `the single-rule budget is the planned 500ms (${PROBE_BUDGET_MS})`)
|
|
ok(PROBE_MAX_LEN === 4096, `the input cap is the engine's 4KB line limit (${PROBE_MAX_LEN})`)
|
|
ok(Array.isArray(REDOS_PROBES) && REDOS_PROBES.length > 0, `the corpus is a non-empty array (${REDOS_PROBES.length})`)
|
|
ok(REDOS_PROBES.length <= 24, `the corpus stays within the plan's ~24-probe budget (${REDOS_PROBES.length})`)
|
|
|
|
const overCap = REDOS_PROBES.filter((p) => p.text.length > PROBE_MAX_LEN)
|
|
ok(overCap.length === 0, `no probe exceeds the cap (${overCap.map((p) => p.text.length).join(', ') || 'none'})`)
|
|
const atCap = REDOS_PROBES.filter((p) => p.text.length === PROBE_MAX_LEN).length
|
|
ok(atCap >= 3, `several probes run at the cap, so a hostile shape cannot finish early (${atCap} at ${PROBE_MAX_LEN})`)
|
|
const short = REDOS_PROBES.filter((p) => p.text.length < 1024)
|
|
ok(short.length === 0, `every probe is long enough to expose exponential growth (${short.length} under 1KB)`)
|
|
|
|
// Synthetic, not recorded: printable ASCII plus a tab head, with a single LF
|
|
// allowed only as the FINAL character (the `.`-family probe). No ESC, no CR,
|
|
// no non-ASCII — nothing here could be recorded terminal output.
|
|
const noisy = REDOS_PROBES.filter((p) => !/^[\x20-\x7e\t]*\n?$/.test(p.text))
|
|
ok(noisy.length === 0, `every probe is printable ASCII with at most a trailing LF (${noisy.length} offending)`)
|
|
const wide = REDOS_PROBES.filter((p) => new Set(p.text).size > 8)
|
|
ok(wide.length === 0, `no probe carries a rich character mix (max ${Math.max(...REDOS_PROBES.map((p) => new Set(p.text).size))} distinct)`)
|
|
|
|
const texts = REDOS_PROBES.map((p) => p.text)
|
|
ok(new Set(texts).size === texts.length, 'no probe text is repeated')
|
|
|
|
const present = new Set(REDOS_PROBES.map((p) => p.shape))
|
|
for (const shape of SHAPES) {
|
|
const n = REDOS_PROBES.filter((p) => p.shape === shape).length
|
|
ok(n >= 2, `the ${shape} family is covered by several variants (${n})`)
|
|
}
|
|
ok(present.size === SHAPES.length, `no probe uses an unknown shape (${[...present].join(', ')})`)
|
|
|
|
// The measured `[(](a+)+$` regression only backtracks when the input actually
|
|
// contains the literal `(`: without it the pattern fails at offset 0 and looks
|
|
// perfectly healthy. The corpus has to carry that head.
|
|
const literalHeads = REDOS_PROBES.filter((p) => p.shape === 'literal-prefix' && p.text.startsWith('('))
|
|
ok(literalHeads.length > 0, 'a literal-prefix probe starts with `(`, so `[(](a+)+$` can backtrack')
|
|
}
|
|
|
|
// ---- 2. legitimate rules stay far inside the budget -------------------------
|
|
console.log('[legit rules]')
|
|
|
|
/** Worst single-probe time for one rule over the whole corpus, in ms. Mirrors
|
|
* what the future worker does: fresh lastIndex per probe, and a step past a
|
|
* zero-width match so an `a*`-style rule cannot spin on one index forever. */
|
|
const corpusCost = (pattern, flags = 'g') => {
|
|
const re = new RegExp(pattern, flags)
|
|
let worst = 0
|
|
for (const probe of REDOS_PROBES) {
|
|
re.lastIndex = 0
|
|
const start = performance.now()
|
|
let match
|
|
let steps = 0
|
|
while ((match = re.exec(probe.text)) !== null) {
|
|
if (match[0] === '') re.lastIndex = match.index + 1
|
|
if (++steps > 20000) break
|
|
}
|
|
const ms = performance.now() - start
|
|
if (ms > worst) worst = ms
|
|
}
|
|
return worst
|
|
}
|
|
|
|
{
|
|
const flagOf = (rule) => `g${rule.caseInsensitive ? 'i' : ''}${rule.lineAnchor ? 'm' : ''}`
|
|
const slowPresets = DEFAULT_HIGHLIGHT_RULES.filter((rule) => corpusCost(rule.pattern, flagOf(rule)) >= PROBE_BUDGET_MS)
|
|
ok(
|
|
slowPresets.length === 0,
|
|
`no shipped preset would be quarantined by the corpus (${slowPresets.map((r) => r.id).join(', ') || 'none'})`
|
|
)
|
|
|
|
// A practical margin on top of the hard budget: the presets are plain keyword
|
|
// patterns, so a probe that made one of them take anywhere near the budget
|
|
// would mean the corpus itself is wrong, not the rule.
|
|
const PRACTICAL_MS = 50
|
|
const worst = DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, corpusCost(rule.pattern, flagOf(rule))])
|
|
const over = worst.filter(([, ms]) => ms >= PRACTICAL_MS)
|
|
ok(
|
|
over.length === 0,
|
|
`every preset stays far under the budget (worst: ${Math.max(...worst.map(([, ms]) => ms)).toFixed(2)}ms < ${PRACTICAL_MS}ms)`
|
|
)
|
|
|
|
// Ordinary user rules — the shapes the editor invites — are just as cheap.
|
|
const userPatterns = [
|
|
'\\b(?:ERROR|FAILED)\\b',
|
|
'\\d+%',
|
|
'(?<!not\\s)\\b(?:SUCCESS|PASS|OK)\\b',
|
|
'BUG-(\\d+)',
|
|
'https?://\\S+',
|
|
'\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b',
|
|
'^ERROR$',
|
|
'(?<=^|\\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])',
|
|
'[A-Z][a-z]+\\s+\\d{4}-\\d{2}-\\d{2}'
|
|
]
|
|
const slowUser = userPatterns.filter((p) => corpusCost(p) >= PROBE_BUDGET_MS)
|
|
ok(slowUser.length === 0, `no ordinary user pattern is quarantined (${slowUser.join(' | ') || 'none'})`)
|
|
}
|
|
|
|
// ---- 3. measured catastrophic shapes do not finish in budget ---------------
|
|
console.log('[hostile shapes]')
|
|
|
|
/** Hard timeout for a child that runs one pattern over the corpus. Long enough
|
|
* that node startup plus a full corpus scan fits (see the control below), short
|
|
* enough that several of them do not dominate the suite. */
|
|
const CHILD_TIMEOUT_MS = 800
|
|
const CORPUS_URL = new URL('../src/shared/redosProbes.ts', import.meta.url).href
|
|
|
|
/** Run one pattern over the real corpus in a child process. Returns true when
|
|
* the child finished on its own; false when it had to be killed. */
|
|
const finishes = (pattern) => {
|
|
const code = `
|
|
const { REDOS_PROBES } = await import(${JSON.stringify(CORPUS_URL)})
|
|
const re = new RegExp(${JSON.stringify(pattern)}, 'g')
|
|
for (const probe of REDOS_PROBES) {
|
|
re.lastIndex = 0
|
|
let match
|
|
while ((match = re.exec(probe.text)) !== null) {
|
|
if (match[0] === '') re.lastIndex = match.index + 1
|
|
}
|
|
}
|
|
console.log('finished')
|
|
`
|
|
const child = spawnSync(process.execPath, ['--input-type=module', '-e', code], {
|
|
encoding: 'utf8',
|
|
timeout: CHILD_TIMEOUT_MS
|
|
})
|
|
return { finished: child.status === 0 && (child.stdout ?? '').includes('finished'), killed: child.error?.code === 'ETIMEDOUT' }
|
|
}
|
|
|
|
{
|
|
// Control: a cheap pattern must complete inside the same timeout, otherwise a
|
|
// killed child would prove nothing about the pattern. It also exercises the
|
|
// child's whole path (startup, .ts import, corpus scan).
|
|
const started = performance.now()
|
|
const control = finishes('a')
|
|
const controlMs = performance.now() - started
|
|
ok(
|
|
control.finished,
|
|
`the control child scans the whole corpus (${controlMs.toFixed(0)}ms of ${CHILD_TIMEOUT_MS}ms, killed=${control.killed})`
|
|
)
|
|
ok(
|
|
controlMs < CHILD_TIMEOUT_MS * 0.6,
|
|
`the control leaves headroom, so a killed child means the pattern, not the machine (${controlMs.toFixed(0)}ms)`
|
|
)
|
|
|
|
// `(a+)+$` is the classic. The rest include the plan's static-heuristic misses
|
|
// that DO reproduce here (`[(](a+)+$`, `(?:a|a)+$`, `((a|a))+$`), plus the
|
|
// `(`-star families that need a line-terminator tail (`(.+)+$`, `(.*)*$`) and
|
|
// an inner quantifier that can match empty (`(a*)*b`). All of them compile
|
|
// instantly and then block for seconds on a few dozen characters, so no length
|
|
// or time guard downstream can help.
|
|
//
|
|
// The plan's fourth miss, `(\w|\d)+$`, is NOT asserted: it finishes in well
|
|
// under a millisecond on this V8 (see the note in redosProbes.ts), so the
|
|
// corpus correctly does not quarantine it. The probe set is judged by
|
|
// measurement, not by a table of supposedly-dangerous shapes.
|
|
const dangerous = ['(a+)+$', '[(](a+)+$', '(?:a|a)+$', '((a|a))+$', '([a-z]+)*$', '(a*)*b', '(.+)+$', '(.*)*$']
|
|
for (const pattern of dangerous) {
|
|
const { finished, killed } = finishes(pattern)
|
|
ok(
|
|
!finished && killed,
|
|
`the corpus blows the budget on ${pattern} (finished=${finished} killed=${killed})`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (failed > 0) {
|
|
console.error(`\n[redos-probes] ${failed} check(s) FAILED`)
|
|
process.exit(1)
|
|
}
|
|
console.log('\n[redos-probes] ALL CHECKS PASSED')
|