Files
OpenTerminal/tests/redos-validator.mjs
T

487 lines
22 KiB
JavaScript

/**
* Rule-validation service self-test (redos-validator.mjs).
*
* `src/main/redosValidator.ts` is the controlling half of the ReDoS gate: it
* owns the probe worker pool and decides what a rule's verdict is. The corpus
* itself is pinned in `tests/redos-probes.mjs`, and the gate the engines apply
* in `tests/rule-validation.mjs` — this file covers the service in between, and
* the properties that make the gate trustworthy rather than decorative:
*
* - a legal rule finishes the corpus and comes back `passed`; the 22 shipped
* presets are never quarantined (a corpus that failed one would disable a
* built-in rule for every user);
* - a rule that blows the wall-clock budget is terminated from the OUTSIDE and
* reported `quarantined` — the worker cannot stop itself, which is the whole
* reason the budget lives here;
* - the main thread keeps ticking while a hostile pattern blocks its worker
* (a timer that fired late would mean the pool had blocked the process);
* - a worker that cannot be created, crashes, or exits yields `pending` and
* never `quarantined` — "we could not measure it" must not read as "we
* measured it as dangerous";
* - the pool stays at its concurrency cap, reuses one run for two rules with
* the same pattern, rebuilds a terminated worker and reclaims idle ones;
* - "no worker was needed yet" is not "the worker is broken": a fresh
* validator reports the feature as available, and only an attempted-and-
* failed creation (or a worker that dies on the job) marks it unavailable;
* - a reset (the test seam / the app's quit path) settles whatever was in
* flight in a worker slot as `pending`, so no caller is left awaiting an
* answer that can never arrive;
* - the background pass over stored rules writes proofs back through the
* settings pipeline, and a proof from another environment is normalized to
* `pending` instead of being trusted.
*
* Build: node tests/build-bundles.cjs
* Run: node tests/redos-validator.mjs (must exit 0; ~2s)
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { PROBE_VERSION, PROBE_BUDGET_MS } from '../src/shared/redosProbes.ts'
import { runtimeKeyOf } from '../src/shared/ruleValidation.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const userData = mkdtempSync(join(tmpdir(), 'ot-redos-validator-'))
process.env.OT_STUB_USERDATA = userData
const validator = require('./.redos-validator.cjs')
const settingsStore = require('./.settings-store.cjs')
const PROBE_WORKER_PATH = join(__dirname, '.redos-probe-worker.cjs')
let failed = 0
let passed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
else passed += 1
}
/** The fingerprint the bundled modules installed (same inputs, same function). */
const RUNTIME = runtimeKeyOf({
app: '0.0.0-stub',
electron: process.versions.electron ?? '',
v8: process.versions.v8 ?? ''
})
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** Poll until `check()` is truthy or the deadline passes. */
const waitFor = async (check, timeoutMs = 4000, stepMs = 20) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (check()) return true
await sleep(stepMs)
}
return check()
}
const request = (pattern, flags = 'g') => ({ key: `${pattern}\u0000${flags}`, pattern, flags })
/** The shipped probe worker, in a real thread. */
const realWorkerFactory = () => {
const worker = new Worker(PROBE_WORKER_PATH)
worker.unref?.()
return worker
}
/**
* A scriptable stand-in for the worker thread: the test decides what happens
* (reply, stay silent, crash, exit) and can count what the pool did to it.
* `postMessage`/`on`/`terminate` are the only surface the pool uses.
*/
function makeStubWorker(options = {}) {
const listeners = { message: [], error: [], exit: [] }
const stub = {
created: true,
posts: [],
terminated: false,
on(event, listener) {
listeners[event]?.push(listener)
},
postMessage(message) {
stub.posts.push(message)
if (options.reply === false) return
const delay = options.delayMs ?? 0
const answer =
options.answer ??
((request) => ({ id: request.id, ok: true, probeVersion: PROBE_VERSION, worstMs: 1, totalMs: 1, probes: 24 }))
setTimeout(() => {
if (stub.terminated) return
for (const listener of listeners.message) listener(answer(message))
}, delay)
},
terminate() {
stub.terminated = true
},
emitError(message = 'stub worker failed') {
for (const listener of listeners.error) listener(new Error(message))
},
emitExit(code = 1) {
for (const listener of listeners.exit) listener(code)
}
}
return stub
}
/** A factory that hands out stubs and keeps them for assertions. */
const stubFactory = (options = {}) => {
const made = []
const factory = () => {
const stub = makeStubWorker(options)
made.push(stub)
return stub
}
factory.made = made
return factory
}
// ---- 1. no worker at all ----------------------------------------------------
console.log('[unavailable]')
{
// The other half of the flag: a validator that simply has not needed a worker
// yet must NOT report the feature as broken. A default install ships no custom
// rules, so the pool is never created — if that read as "unavailable", every
// user would see a red error for a feature that is working perfectly.
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(realWorkerFactory)
const fresh = validator.getRuleValidationState()
ok(fresh.workerAvailable === true, 'a fresh validator with a factory reports the worker as available')
ok(fresh.pending === 0, `and no shipped preset is waiting for a verdict (pending ${fresh.pending})`)
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(null)
const results = await validator.validateRules([request('\\bOK\\b')])
ok(results[0].verdict === 'pending', `no factory → pending, never quarantined (got ${results[0].verdict})`)
ok(validator.getRuleValidationState().workerAvailable === false, 'the state reports the worker as unavailable')
// A factory that throws is the "build artifact missing" path: the pool must
// report it, not crash the settings pipeline.
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(() => {
throw new Error('probe worker chunk not found')
})
const report = await validator.handleRuleValidationRequest({
kind: 'highlight',
items: [{ id: 'a', pattern: '\\bOK\\b' }]
})
ok(report.results[0].state === 'pending', `an unbuildable worker leaves rules pending (got ${report.results[0].state})`)
ok(report.workerAvailable === false, 'the report says the worker is unavailable')
ok(report.probeVersion === PROBE_VERSION, `the report carries the probe version (${report.probeVersion})`)
ok(report.runtime === RUNTIME, `the report carries the runtime fingerprint (${report.runtime})`)
}
// ---- 1b. a reset settles in-flight work -------------------------------------
console.log('[reset with a probe in flight]')
{
validator.resetRuleValidator()
const factory = stubFactory({ reply: false })
validator.setValidatorWorkerFactory(factory)
validator.setValidatorBudgets({ ruleBudgetMs: 30_000 })
const promise = validator.validateRules([request('\\bOK\\b')])
await waitFor(() => factory.made.length > 0 && factory.made[0].posts.length > 0, 500)
// The worker is holding the task; the reset tears the thread down under it.
// The caller's promise must still settle: a dangling await is exactly the
// shape of a hung shutdown path.
validator.resetRuleValidator()
const settled = await Promise.race([promise, sleep(500).then(() => 'timeout')])
ok(settled !== 'timeout', 'a reset with a probe in flight does not hang the caller')
ok(
Array.isArray(settled) && settled[0].verdict === 'pending',
`the in-flight task is settled as pending (got ${JSON.stringify(settled)})`
)
ok(factory.made[0].terminated, 'and its worker was terminated')
ok(validator.getRuleValidationState().workerAvailable === true, 'the reset returns the feature flag to its default (available)')
}
// ---- 2. the real worker: legal rules pass -----------------------------------
console.log('[legit rules through the real worker]')
{
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(realWorkerFactory)
const requests = DEFAULT_HIGHLIGHT_RULES.map((rule) => request(rule.pattern, `g${rule.caseInsensitive ? 'i' : ''}${rule.lineAnchor ? 'm' : ''}`))
const started = Date.now()
const results = await validator.validateRules(requests)
const elapsed = Date.now() - started
const bad = results.filter((result) => result.verdict !== 'passed')
ok(bad.length === 0, `every shipped preset passes the corpus (${bad.map((r) => r.pattern).join(' | ') || 'none'} failed)`)
const worst = Math.max(...results.map((result) => result.ms ?? 0))
ok(worst < PROBE_BUDGET_MS / 2, `the slowest preset stays well inside the budget (${worst.toFixed(2)}ms < ${PROBE_BUDGET_MS / 2}ms)`)
ok(elapsed < 3000, `the whole preset set finishes quickly (${elapsed}ms for ${results.length} rules)`)
const user = await validator.validateRules([request('BUG-(\\d+)'), request('(?<!not\\s)\\b(?:SUCCESS|PASS|OK)\\b'), request('\\b\\d{1,3}(\\.\\d{1,3}){3}\\b')])
ok(user.every((result) => result.verdict === 'passed'), 'ordinary user patterns pass too')
}
// ---- 3. a hostile rule is terminated from the outside ------------------------
console.log('[hard budget]')
{
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(realWorkerFactory)
validator.setValidatorBudgets({ ruleBudgetMs: 150 })
// Main-thread responsiveness while the worker is stuck: a hostile pattern
// blocks its own thread for seconds, and the pool must not block this one.
let ticks = 0
const ticker = setInterval(() => {
ticks += 1
}, 10)
const started = Date.now()
const results = await validator.validateRules([request('(a|a)+$')])
const elapsed = Date.now() - started
clearInterval(ticker)
ok(results[0].verdict === 'quarantined', `a pattern that blows the budget is quarantined (got ${results[0].verdict})`)
ok(elapsed < 1500, `the verdict lands near the budget, not after the pattern finishes (${elapsed}ms)`)
ok(ticks > 0, `the main thread kept ticking while the worker was blocked (${ticks} ticks in ${elapsed}ms)`)
ok(validator.getRuleValidationState().workerAvailable === true, 'a terminated worker does not mark the feature unavailable')
// The terminated worker is gone: the next rule gets a fresh thread.
const after = await validator.validateRules([request('\\bOK\\b')])
ok(after[0].verdict === 'passed', 'the pool rebuilds a terminated worker and keeps serving')
}
// ---- 4. crashes and exits never look like a hostile rule ---------------------
console.log('[worker failures]')
{
validator.resetRuleValidator()
const factory = stubFactory({ reply: false })
validator.setValidatorWorkerFactory(factory)
validator.setValidatorBudgets({ ruleBudgetMs: 5000 })
const promise = validator.validateRules([request('\\bOK\\b')])
// Let the pool dispatch, then kill the initial try and its retry: the task gets
// one retry on a fresh worker and then gives up.
await waitFor(() => factory.made.length > 0, 500)
factory.made[0].emitError('boom')
await waitFor(() => factory.made.length > 1, 500)
factory.made[1].emitError('boom again')
const results = await promise
ok(results[0].verdict === 'pending', `a twice-crashed worker yields pending, not quarantined (got ${results[0].verdict})`)
ok(factory.made.length === 2, `the task was retried exactly once (${factory.made.length} workers created)`)
ok(validator.getRuleValidationState().workerAvailable === false, 'a crashing worker marks the feature unavailable')
// The unavailable flag is not sticky: a reset (which is also the app's quit
// path) restores the default, so a later run does not inherit the verdict.
validator.resetRuleValidator()
ok(validator.getRuleValidationState().workerAvailable === true, 'a reset clears the unavailable flag back to its default')
// Same for an exit while busy (a thread that dies without an error event).
validator.resetRuleValidator()
const exits = stubFactory({ reply: false })
validator.setValidatorWorkerFactory(exits)
const exitPromise = validator.validateRules([request('\\bOK\\b')])
await waitFor(() => exits.made.length > 0, 500)
exits.made[0].emitExit(1)
await waitFor(() => exits.made.length > 1, 500)
exits.made[1].emitExit(1)
const exitResults = await exitPromise
ok(exitResults[0].verdict === 'pending', `an exiting worker yields pending (got ${exitResults[0].verdict})`)
}
// ---- 5. dedupe, concurrency cap, idle reclamation ---------------------------
console.log('[pool behaviour]')
{
validator.resetRuleValidator()
const factory = stubFactory({ delayMs: 60 })
validator.setValidatorWorkerFactory(factory)
validator.setValidatorBudgets({ ruleBudgetMs: 5000, idleMs: 40 })
const [a, b] = await Promise.all([
validator.validateRules([request('\\bOK\\b')]),
validator.validateRules([request('\\bOK\\b')])
])
ok(a[0].verdict === 'passed' && b[0].verdict === 'passed', 'two callers asking for the same rule both get an answer')
ok(factory.made.length === 1, `the second caller shares the in-flight run (${factory.made.length} worker(s) created)`)
ok(factory.made[0].posts.length === 1, `the rule was probed once, not twice (${factory.made[0].posts.length} request(s))`)
// Concurrency: three rules at once may not open more than two threads.
validator.resetRuleValidator()
const capped = stubFactory({ reply: false })
validator.setValidatorWorkerFactory(capped)
validator.setValidatorBudgets({ ruleBudgetMs: 4000, maxWorkers: 2 })
const pending = validator.validateRules([request('A'), request('B'), request('C')])
await waitFor(() => capped.made.length >= 2, 500)
await sleep(60)
ok(capped.made.length === 2, `the pool stops at its concurrency cap (${capped.made.length} workers for 3 rules)`)
ok(validator.validatorConfig().maxWorkers === 2, 'the cap is the configured one')
for (const stub of capped.made) stub.emitError('done')
await pending
// Idle reclamation: a worker that has nothing to do is terminated.
validator.resetRuleValidator()
const idler = stubFactory({ delayMs: 0 })
validator.setValidatorWorkerFactory(idler)
validator.setValidatorBudgets({ idleMs: 40 })
await validator.validateRules([request('\\bOK\\b')])
ok(idler.made.length === 1, 'a worker was created for the run')
const reclaimed = await waitFor(() => idler.made[0].terminated, 1000)
ok(reclaimed, 'the idle worker is terminated instead of sitting on a thread')
ok(validator.validatorConfig().ruleBudgetMs === PROBE_BUDGET_MS, `reset restores the production budget (${validator.validatorConfig().ruleBudgetMs}ms)`)
}
// ---- 6. IPC batch semantics -------------------------------------------------
console.log('[batch]')
{
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(realWorkerFactory)
const builtin = DEFAULT_HIGHLIGHT_RULES[0]
const report = await validator.handleRuleValidationRequest({
kind: 'highlight',
items: [
{ id: 'builtin', pattern: builtin.pattern, caseInsensitive: builtin.caseInsensitive, lineAnchor: builtin.lineAnchor },
{ id: 'custom', pattern: 'BUG-(\\d+)' },
{ id: 'dupe', pattern: 'BUG-(\\d+)' },
{ id: 'empty', pattern: '' },
{ id: 'long', pattern: 'a'.repeat(600) }
]
})
const byId = new Map(report.results.map((result) => [result.id, result]))
ok(byId.get('builtin').state === 'builtin', 'a factory preset is reported as built-in, not probed')
ok(byId.get('custom').state === 'passed', 'a custom pattern comes back passed')
ok(byId.get('dupe').state === 'passed', 'a duplicate pattern in the same batch gets the same verdict')
ok(byId.get('empty').state === 'pending', 'an empty pattern gets no verdict')
ok(byId.get('long').state === 'pending', 'a pattern past the length bound gets no verdict')
ok(report.truncated === false, 'a batch inside the cap is not reported as truncated')
ok(
report.results.map((result) => result.id).join(',') === 'builtin,custom,dupe,empty,long',
`results come back in request order (${report.results.map((result) => result.id).join(',')})`
)
// The order key is the request INDEX, not the id: two items may legitimately
// share an id (the caller echoes back whatever it was handed), and an
// id-keyed map collapses both onto one rank and reorders the report.
const sameIds = await validator.handleRuleValidationRequest({
kind: 'highlight',
items: [
{ id: 'same', pattern: '\\bOK\\b' },
{ id: 'other', pattern: '' },
{ id: 'same', pattern: 'BUG-(\\d+)' }
]
})
ok(
sameIds.results.map((result) => result.id).join(',') === 'same,other,same',
`repeated ids keep their request positions (${sameIds.results.map((result) => result.id).join(',')})`
)
const linkReport = await validator.handleRuleValidationRequest({
kind: 'link',
items: [{ id: 'link', pattern: 'BUG-(\\d+)' }]
})
ok(linkReport.results[0].state === 'passed', 'a link rule is probed with the link flag string')
const hostile = await validator.handleRuleValidationRequest({
kind: 'highlight',
items: [{ id: 'hostile', pattern: '((a|a))+$' }]
})
ok(hostile.results[0].state === 'quarantined', `a hostile draft is quarantined over IPC (got ${hostile.results[0].state})`)
validator.setValidatorBudgets({ ruleBudgetMs: 60 })
const big = await validator.handleRuleValidationRequest({
kind: 'highlight',
items: Array.from({ length: validator.MAX_VALIDATE_ITEMS + 5 }, (_v, index) => ({
id: `#${index}`,
pattern: 'x'
}))
})
ok(big.truncated === true, 'a batch past the cap is reported as truncated')
ok(big.results.length <= validator.MAX_VALIDATE_ITEMS, `the tail is dropped (${big.results.length} results)`)
ok(
validator.MAX_VALIDATE_ITEMS <= 200,
`the batch cap stays small enough to bound memory (${validator.MAX_VALIDATE_ITEMS})`
)
}
// ---- 7. the background pass writes proofs back ------------------------------
console.log('[settings pass]')
{
validator.resetRuleValidator()
validator.setValidatorWorkerFactory(realWorkerFactory)
const rule = {
id: 'custom',
pattern: 'BUG-(\\d+)',
enabled: true,
priority: 5,
color: { fg: '#ff0000' }
}
// Written straight to the file: this is the "rules already on disk" path the
// applier runs at startup.
writeFileSync(
join(userData, 'settings.json'),
JSON.stringify({ ...settingsStore.loadSettings(), highlightRules: [rule] }, null, 2),
'utf8'
)
const stored = settingsStore.loadSettings()
ok(stored.highlightRules[0].validation === undefined, 'a rule without a proof starts unproven')
validator.validateSettingsRules(stored)
const proved = await waitFor(() => {
const reloaded = settingsStore.loadSettings()
return reloaded.highlightRules[0].validation?.status === 'passed'
}, 4000)
const reloaded = settingsStore.loadSettings()
const proof = reloaded.highlightRules[0].validation
ok(proved, `the background pass writes a passed proof back to settings (${JSON.stringify(proof)})`)
ok(proof?.pattern === rule.pattern, 'the proof names the exact pattern it was obtained for')
ok(proof?.flags === 'g', `the proof carries the engine's flag string (${proof?.flags})`)
ok(proof?.probeVersion === PROBE_VERSION, 'the proof carries the current probe version')
ok(proof?.runtime === RUNTIME, `the proof carries the runtime it was measured in (${proof?.runtime})`)
// Idempotence: a second pass over the same rules finds nothing to do, so the
// write-back cannot loop through the settings broadcast.
const before = readFileSync(join(userData, 'settings.json'), 'utf8')
validator.validateSettingsRules(reloaded)
await sleep(200)
ok(readFileSync(join(userData, 'settings.json'), 'utf8') === before, 'a second pass over proved rules writes nothing')
// A proof from another environment is not trusted: the store normalizes it to
// pending, and the pass then re-proves it.
const foreign = {
...reloaded,
highlightRules: [{ ...rule, validation: { ...proof, runtime: 'other-app|other-electron|other-v8' } }]
}
writeFileSync(join(userData, 'settings.json'), JSON.stringify(foreign, null, 2), 'utf8')
const normalized = settingsStore.loadSettings()
ok(
normalized.highlightRules[0].validation?.status === 'pending',
`a proof from another build is normalized to pending (got ${normalized.highlightRules[0].validation?.status})`
)
validator.validateSettingsRules(normalized)
const reproved = await waitFor(() => settingsStore.loadSettings().highlightRules[0].validation?.runtime === RUNTIME, 4000)
ok(reproved, 'the pass re-proves a rule whose verdict no longer applies')
// A hostile stored rule ends up quarantined, and stays that way: the stored
// status settles the question, so a settings save does not re-run a 500ms
// probe over and over.
validator.setValidatorBudgets({ ruleBudgetMs: 150 })
writeFileSync(
join(userData, 'settings.json'),
JSON.stringify({ ...settingsStore.loadSettings(), highlightRules: [{ ...rule, pattern: '(a|a)+$' }] }, null, 2),
'utf8'
)
const hostile = settingsStore.loadSettings()
validator.validateSettingsRules(hostile)
const quarantined = await waitFor(() => settingsStore.loadSettings().highlightRules[0].validation?.status === 'quarantined', 4000)
ok(quarantined, 'a stored hostile rule is quarantined by the background pass')
const state = validator.getRuleValidationState()
ok(state.quarantined >= 1, `the aggregate state counts the quarantined rule (${state.quarantined})`)
validator.validateSettingsRules(settingsStore.loadSettings())
await sleep(200)
ok(
settingsStore.loadSettings().highlightRules[0].validation?.status === 'quarantined',
'a quarantined rule is not re-probed on the next pass'
)
}
validator.shutdownRuleValidator()
rmSync(userData, { recursive: true, force: true })
console.log(`\n[redos-validator] ${passed} passed, ${failed} failed`)
if (failed > 0) {
console.error(`\n[redos-validator] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[redos-validator] ALL CHECKS PASSED')