feat(highlight): 主进程 ReDoS 校验闸门与 renderer 性能时间线

This commit is contained in:
Bill committed 2026-10-18 10:09:07 +08:00
1 parent 68fa46b2e3
commit fc0d1afdce
43 files changed
+5711 -179

No files matched your search

+8 -1
View File
@@ -73,7 +73,14 @@ const BUNDLES = [
// Preset-rule assertions (word boundaries, case flag) over the same engine.
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' },
// Diagnostic app log: daily naming, retention sweep, async append queue.
{ entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' }
{ entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' },
// Rule-validation service: worker pool, hard budget, dedupe, write-back.
// `node:worker_threads` is a Node builtin, so the pool itself bundles.
{ entry: 'src/main/redosValidator.ts', out: 'tests/.redos-validator.cjs' },
// The REAL probe worker as a standalone script the test can spawn in a thread
// — the pool's terminate path only means something against the shipped
// implementation, not against a stub.
{ entry: 'src/main/redosProbeWorker.ts', out: 'tests/.redos-probe-worker.cjs' }
]
for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) {
+180 -34
View File
@@ -32,6 +32,8 @@ import {
paletteMatch,
themeColorFor
} from '../src/renderer/src/theme/highlightColors.ts'
import { PROBE_VERSION } from '../src/shared/redosProbes.ts'
import { highlightFlags, setRuleRuntimeKey } from '../src/shared/ruleValidation.ts'
let failed = 0
const ok = (cond, msg) => {
@@ -39,8 +41,35 @@ const ok = (cond, msg) => {
if (!cond) failed++
}
/**
* The environment the proofs in this file belong to. In the app the main
* process installs its own fingerprint and the renderer is handed the same
* string over IPC; here it is a literal, and installing it is what makes the
* hand-written proofs below count for THIS engine.
*/
const RUNTIME = 'test-app|test-electron|test-v8'
setRuleRuntimeKey(RUNTIME)
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
/**
* `compileRules` with a passed proof stamped on every rule the caller wrote by
* hand. The tables in this file are about matching semantics, and an unproven
* rule is inert by design — so each hand-written rule gets the proof its own
* pattern and flags need. The gate itself (pending / quarantined / expired /
* edited pattern, another runtime, and the preset whitelist) is pinned in
* `tests/rule-validation.mjs` and in the `[proof gate]` section at the end.
*/
const proofFor = (rule) => ({
status: 'passed',
pattern: rule.pattern,
flags: highlightFlags(rule.caseInsensitive, rule.lineAnchor),
probeVersion: PROBE_VERSION,
runtime: RUNTIME
})
const proved = (rule) => ({ ...rule, validation: proofFor(rule) })
const compile = (list) => compileRules(list.map(proved))
/** truecolor prefix for a hex colour, as the engine emits it */
const sgr = (hex) => {
const n = parseInt(hex.slice(1), 16)
@@ -254,11 +283,11 @@ console.log('[danger]')
// ---- 8. case-insensitive flag is opt-in per rule ----------------------------
console.log('[case flag]')
{
const plain = compileRules([
const plain = compile([
{ id: 'cs', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(!applyHighlights('error', plain).includes(sgr(BAD_RED)), 'a rule without the flag still respects case')
const folded = compileRules([
const folded = compile([
{ id: 'ci', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED }, caseInsensitive: true }
])
ok(applyHighlights('error', folded).includes(sgr(BAD_RED)), 'the flag makes a rule case-insensitive')
@@ -417,13 +446,13 @@ console.log('[value bands]')
)
// a rule without bands keeps one colour whatever the number
const flat = compileRules([
const flat = compile([
{ id: 'flat', pattern: '\\d+%', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(applyHighlights('77%', flat).includes(sgr(BAD_RED)), 'a rule without bands stays flat')
// with bands: the match's own number decides, and no number falls back
const banded = compileRules([
const banded = compile([
{
id: 'banded',
pattern: '\\bDONE\\b|\\d+%',
@@ -441,7 +470,7 @@ console.log('[value bands]')
ok(applyHighlights('95%', banded).includes(sgr(OK_GREEN)), 'a number above the last min → the last band')
// bands stored out of order still resolve lowest-first
const unsorted = compileRules([
const unsorted = compile([
{
id: 'unsorted',
pattern: '\\d+%',
@@ -595,6 +624,40 @@ console.log('[import / export]')
ok(appended.length === DEFAULT_HIGHLIGHT_RULES.length + 1, 'append keeps the existing rules')
ok(new Set(appended.map((r) => r.id)).size === appended.length, 'append never leaves duplicate ids')
ok(mergeRules([mine], back.rules, 'replace').length === DEFAULT_HIGHLIGHT_RULES.length, 'replace drops the old set')
// The internal validation proof travels in neither direction. An exported file
// is meant to be shared or archived, so it must not carry what reads like a
// trust credential; an imported file is untrusted input, so a proof inside it
// is ignored and the rule starts unproven (the validator re-proves it).
{
const proven = DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule, validation: proofFor(rule) }))
const file = exportHighlightRules(proven)
ok(!file.includes('"validation"'), 'an export carries no validation field')
ok(!file.includes('probeVersion'), 'an export carries no probe version either')
const reloaded = parseHighlightRules(file)
ok(
reloaded.rules.length === proven.length && reloaded.rules.every((r) => r.validation === undefined),
'an exported set comes back whole and unproven'
)
const smuggled = parseHighlightRules(
JSON.stringify({
kind: HIGHLIGHT_FILE_KIND,
version: HIGHLIGHT_FILE_VERSION,
rules: [
{
pattern: '\\bOK\\b',
validation: { status: 'passed', pattern: '\\bOK\\b', flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME }
}
]
})
)
ok(
smuggled.rules.length === 1 && smuggled.rules[0].validation === undefined,
'a validation field inside an imported file is ignored'
)
}
}
// ---- 13. editor preview spans ----------------------------------------------
@@ -999,10 +1062,10 @@ console.log('[long lines]')
console.log('[line anchor]')
{
const TEXT = 'a\nERROR\nERROR b\n'
const plain = compileRules([
const plain = compile([
{ id: 'anch', pattern: '^ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
const perLine = compileRules([
const perLine = compile([
{
id: 'anch',
pattern: '^ERROR$',
@@ -1033,7 +1096,7 @@ console.log('[line anchor]')
)
// Anchoring only moves the boundaries: '$' works the same way.
const tail = compileRules([
const tail = compile([
{ id: 'tail', pattern: 'ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED }, lineAnchor: true }
])
const tailOut = applyHighlights(TEXT, tail)
@@ -1044,7 +1107,7 @@ console.log('[line anchor]')
)
// The two flags compose: 'i' and 'm' both apply when both switches are on.
const both = compileRules([
const both = compile([
{
id: 'both',
pattern: '^error$',
@@ -1067,7 +1130,7 @@ console.log('[match events]')
// Only spans that actually get painted are reported, and each carries the
// rule id plus the raw matched text.
const events = []
const one = compileRules([
const one = compile([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
])
const out = applyHighlights('all OK here', one, undefined, undefined, (id, text) =>
@@ -1087,7 +1150,7 @@ console.log('[match events]')
// report either — otherwise a consumer would alert about colour that is not
// on screen. `done` is the documented case: shellkw beats status words.
const events = []
const overlap = compileRules([
const overlap = compile([
{ id: 'first', pattern: '\\bdone\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } },
{ id: 'second', pattern: '\\b(done|DONE)\\b', enabled: true, priority: 2, color: { fg: BAD_RED } }
])
@@ -1101,7 +1164,7 @@ console.log('[match events]')
// The per-rule budget is the other silent case: a rule that hits MAX_PER_RULE
// (300) times in one run reports exactly that many times, not one more.
let count = 0
const many = compileRules([
const many = compile([
{ id: 'x', pattern: 'ERROR', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
applyHighlights(Array.from({ length: 301 }, () => 'ERROR').join(' '), many, undefined, undefined, () => {
@@ -1122,7 +1185,7 @@ console.log('[match events]')
// only completes once the held carry is flushed still reports exactly once.
const events = []
const stream = new HighlightStream(
compileRules([
compile([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
]),
(id, text) => events.push([id, text])
@@ -1143,7 +1206,7 @@ console.log('[match events]')
// Rules without the flag are irrelevant to the engine: it reports every painted
// span and leaves the notify policy to the consumer.
const events = []
const mixed = compileRules([
const mixed = compile([
{ id: 'silent', pattern: '\\bWARN\\b', enabled: true, priority: 1, color: { fg: WARN_YELLOW } }
])
applyHighlights('WARN', mixed, undefined, undefined, (id) => events.push(id))
@@ -1181,7 +1244,7 @@ console.log('[reset spellings and CSI parameters]')
// canonical ones let the other spellings be APPENDED to the SGR accumulator,
// so it grew a few bytes per line until it blew the cap and every later span
// lost its restore. Go/Java log libraries routinely emit ESC[0;32m.
const warn = compileRules([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }])
const warn = compile([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }])
const tailAfter = (input) => {
const out = applyHighlights(input, warn)
return out.slice(out.indexOf('WARNING') + 'WARNING'.length)
@@ -1211,18 +1274,19 @@ console.log('[reset spellings and CSI parameters]')
console.log('[ReDoS shapes]')
{
// The hot path runs a user pattern on every output line, so exponential
// backtracking must be rejected BEFORE it can run. `(a+)+$` compiles in
// microseconds and then takes ~8s on 30 characters — no length or time guard
// downstream can fix that. These shapes are caught by a string scan at compile
// time, which is why the check has to be exact in both directions.
// The editor's advisory heuristic. It flags the shapes that compile in
// microseconds and then block for seconds (`(a+)+$` takes ~8s on 30
// characters), so the user gets a warning while typing. It is NOT the gate —
// it has measured misses and false positives, so a shape test can decide
// neither what is safe nor what is hostile. What runs is decided by the proof
// (see [proof gate] below).
const dangerous = ['(a+)+$', '(a+)+', '(.*)*', '(.+)+', '([a-z]+)*', '(a*)*b', '(a|a)*', '(a|a)+', '(?:a+)+']
for (const p of dangerous) {
ok(isReDoSProne(p), `rejects exponential shape ${p}`)
ok(isReDoSProne(p), `flags exponential shape ${p}`)
}
// ...and it must not touch legitimate patterns. The most important case is the
// shipped presets: rejecting one of those would silently disable a built-in
// rule for every user. `ipv4` (`(?:\\d{1,3}\\.){3}`) is the tricky one — a
// shipped presets: flagging one of those would nag about a built-in rule for
// every user. `ipv4` (`(?:\\d{1,3}\\.){3}`) is the tricky one — a
// BOUNDED outer quantifier cannot backtrack exponentially.
const safe = [
'\\b(?:ERROR|FAILED)\\b',
@@ -1233,20 +1297,102 @@ console.log('[ReDoS shapes]')
'\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b'
]
for (const p of safe) {
ok(!isReDoSProne(p), `accepts safe pattern ${p}`)
ok(!isReDoSProne(p), `leaves safe pattern ${p} unflagged`)
}
const flaggedPresets = DEFAULT_HIGHLIGHT_RULES.filter((r) => isReDoSProne(r.pattern)).map((r) => r.id)
ok(flaggedPresets.length === 0, `no shipped preset is rejected (flagged: ${flaggedPresets.join(', ') || 'none'})`)
ok(flaggedPresets.length === 0, `no shipped preset is flagged (flagged: ${flaggedPresets.join(', ') || 'none'})`)
}
// The heuristic is ADVISORY: it must not gate the hot path. Rejecting on it
// would silently disable legitimate rules (measured false positives) while
// still admitting hostile ones (measured misses: `[(](a+)+`, `(?:a|a)+`), so
// compileRules keeps both and only the editor surfaces the warning.
const compiled = compileRules([
{ id: 'flagged', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } },
{ id: 'plain', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } }
])
ok(compiled.length === 2, 'compileRules keeps flagged rules (the check is advisory, not a gate)')
console.log('[proof gate]')
{
// What admits a stored pattern is the validation proof: a `passed` record
// matching the rule's own pattern, its compile flags and the current probe
// version (shared/ruleValidation + shared/redosProbes). The full truth table
// lives in tests/rule-validation.mjs; these assertions pin the engine's side,
// which is where a bypass would actually hurt.
const plain = { id: 'plain', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } }
const flagged = { id: 'flagged', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } }
ok(compileRules([flagged, plain]).length === 0, 'unproven custom rules never reach the hot path')
ok(
compile([flagged, plain]).length === 2,
'the heuristic is not the gate: a PROVEN rule compiles even when it is flagged'
)
ok(
compileRules(DEFAULT_HIGHLIGHT_RULES).length === DEFAULT_HIGHLIGHT_RULES.length,
`every shipped preset compiles without a proof (${compileRules(DEFAULT_HIGHLIGHT_RULES).length}/${DEFAULT_HIGHLIGHT_RULES.length})`
)
// Each way a proof can stop matching its rule is inert, and the rule itself is
// kept in settings — a stale or edited rule is skipped, never repaired.
ok(compileRules([{ ...plain, validation: proofFor(plain) }]).length === 1, 'a matching proof admits the rule')
ok(
compileRules([{ ...plain, caseInsensitive: true, validation: proofFor(plain) }]).length === 0,
'a proof under other flags does not admit the rule'
)
ok(
compileRules([{ ...plain, pattern: 'WARNING', validation: proofFor(plain) }]).length === 0,
'a proof for the pre-edit pattern does not admit the edited rule'
)
ok(
compileRules([
{
...plain,
validation: { status: 'passed', pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION - 1, runtime: RUNTIME }
}
]).length === 0,
'a proof from an older probe set does not admit the rule'
)
ok(
compileRules([
{
...plain,
validation: { status: 'passed', pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION, runtime: 'other|other|other' }
}
]).length === 0,
'a proof measured in another runtime does not admit the rule'
)
for (const status of ['pending', 'quarantined']) {
ok(
compileRules([
{ ...plain, validation: { status, pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME } }
]).length === 0,
`a ${status} rule is kept in settings but does not run`
)
}
// The gate only removes rules: priorities still decide which rule claims a
// span first, so a filtered set keeps its order.
{
const provenRule = (id, pattern, priority) => ({
id,
pattern,
priority,
color: { fg: '#ff0000' },
validation: { status: 'passed', pattern, flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME }
})
const out = compileRules([
provenRule('late', 'LATE', 9),
{ id: 'unproven', pattern: 'SKIP', priority: 1, color: { fg: '#ff0000' } },
provenRule('early', 'EARLY', 2)
])
ok(
out.map((r) => r.id).join(',') === 'early,late',
`the gate filters without disturbing priority order (got ${out.map((r) => r.id).join(',')})`
)
}
// The editor preview runs on compiled rules, so it cannot bypass the gate:
// an unproven rule yields the plain sample, a proven one yields a coloured span.
{
const unproven = previewSpans('ERROR', compileRules([plain]))
ok(unproven.length === 1 && unproven[0].fg === undefined, 'the preview cannot bypass the gate')
const provenPreview = previewSpans('ERROR', compile([plain]))
ok(
provenPreview.some((s) => s.text === 'ERROR' && s.fg === '#00ff00'),
'a proven rule previews with its colour'
)
}
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
+51 -3
View File
@@ -105,7 +105,7 @@ const invoke = (channel, frame, ...args) => {
return listener({ senderFrame: frame, sender: { id: 1 } }, ...args)
}
withDevUrl(DEV_URL, () => {
await withDevUrl(DEV_URL, () => {
ok(typeof handlers.get(Ipc.APP_INFO) === 'function', 'APP_INFO reached the registration path')
ok(handlers.get(Ipc.APP_INFO) !== undefined, 'the stub recorded a handler (registrations are not dropped)')
@@ -139,13 +139,25 @@ withDevUrl(DEV_URL, () => {
// A hostile frame gets the same refusal on every other invoke channel, and the
// handler is never reached: CWD_OPEN would have thrown/misbehaved, PTY_CREATE
// would have spawned a shell.
for (const channel of [Ipc.CWD_OPEN, Ipc.PTY_CREATE, Ipc.CONNECTIONS_LIST, Ipc.SETTINGS_GET, Ipc.LOCK_STATE_GET]) {
// would have spawned a shell, RULE_VALIDATE would have started a probe worker.
for (const channel of [
Ipc.CWD_OPEN,
Ipc.PTY_CREATE,
Ipc.CONNECTIONS_LIST,
Ipc.SETTINGS_GET,
Ipc.LOCK_STATE_GET,
Ipc.RULE_VALIDATE,
Ipc.RULE_VALIDATION_STATE_GET
]) {
if (!handlers.has(channel)) continue
const m = refused(channel)
ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`)
}
// The rule-validation batch answers a trusted frame without touching settings:
// drafts are probed, never stored. Its own section below awaits the handler,
// so it cannot live inside this synchronous block (see the note there).
const missingFrame = (() => {
try {
handlers.get(Ipc.APP_INFO)({ sender: { id: 1 } }, )
@@ -217,6 +229,42 @@ withDevUrl(DEV_URL, () => {
ok((m.match(/untrusted frame/g) ?? []).length === 1, 'the refusal message is not nested (exactly one guard layer)')
})
// ---- 4. the rule-validation channel answers a trusted frame -----------------
// Awaiting the handler needs an async scope, and `withDevUrl` restores the env
// var as soon as its callback returns — which for an async callback is the
// moment it hits its first `await`, i.e. before the awaited work resumes. So
// the variable is set by hand around this block instead.
console.log('the rule-validation channel')
{
const previous = process.env.ELECTRON_RENDERER_URL
process.env.ELECTRON_RENDERER_URL = DEV_URL
try {
const report = await invoke(Ipc.RULE_VALIDATE, trustedFrame, {
kind: 'highlight',
items: [{ id: 'draft', pattern: '\\bERROR\\b' }]
})
ok(
report && Array.isArray(report.results) && report.results.length === 1,
'a trusted frame gets a report back from RULE_VALIDATE'
)
// No worker factory is installed in this bundle (the `?nodeWorker` wiring
// lives in index.ts), so every rule stays pending — the fail-closed answer,
// not an error.
ok(report.results[0].state === 'pending', `no worker wired here → pending (got ${report.results[0].state})`)
ok(report.workerAvailable === false, 'the report says the validator is unavailable')
ok(typeof report.runtime === 'string', 'the report names the runtime fingerprint it belongs to')
const state = invoke(Ipc.RULE_VALIDATION_STATE_GET, trustedFrame)
ok(
state && typeof state.pending === 'number' && typeof state.quarantined === 'number',
`the aggregate state answers with counts (${JSON.stringify(state)})`
)
} finally {
if (previous === undefined) delete process.env.ELECTRON_RENDERER_URL
else process.env.ELECTRON_RENDERER_URL = previous
}
}
rmSync(userData, { recursive: true, force: true })
if (failed > 0) {
+32 -5
View File
@@ -22,8 +22,12 @@
* ranges, including a pattern that can match the empty string;
* - `mergeLinkSpans` keeps the user rule and drops the colliding built-in.
*
* Direct .ts import (no esbuild bundle): the module has no imports and no
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
* The rules written here are all legitimate, so `proved()` stamps each one with
* the proof the compile gate now requires (a rule without one is inert by
* design — the gate itself is pinned in tests/rule-validation.mjs, not here).
*
* Direct .ts import (no esbuild bundle): the module has no extension-less
* imports and no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/link-rules.mjs (must exit 0)
*/
import {
@@ -40,6 +44,8 @@ import {
sanitizeLinkRules,
isSafeLinkTemplate
} from '../src/shared/linkRules.ts'
import { PROBE_VERSION } from '../src/shared/redosProbes.ts'
import { LINK_RULE_FLAGS, setRuleRuntimeKey } from '../src/shared/ruleValidation.ts'
let failed = 0
const ok = (cond, msg) => {
@@ -47,6 +53,21 @@ const ok = (cond, msg) => {
if (!cond) failed += 1
}
/**
* The environment the proofs below belong to. The main process installs its own
* fingerprint and the renderer is handed the same string over IPC; here it is a
* literal, and installing it is what makes a proof count for THIS engine.
*/
const RUNTIME = 'test-app|test-electron|test-v8'
setRuleRuntimeKey(RUNTIME)
/** Stamp the proof the gate demands for this exact pattern + flag string. */
const proved = (rule) => ({
...rule,
validation: { status: 'passed', pattern: rule.pattern, flags: LINK_RULE_FLAGS, probeVersion: PROBE_VERSION, runtime: RUNTIME }
})
const compile = (rules) => compileLinkRules(rules.map(proved))
const raw = (over) => ({ id: 'r1', pattern: '\\b(\\d+)\\b', template: 'https://x/$1', ...over })
// ---- sanitize ---------------------------------------------------------------
@@ -205,19 +226,25 @@ console.log('[compileLinkRules]')
{
ok(compileLinkRules(undefined).length === 0, 'an absent rule list compiles to nothing')
ok(compileLinkRules([]).length === 0, 'an empty rule list compiles to nothing')
const compiled = compileLinkRules([
const compiled = compile([
{ id: 'a', pattern: 'x', template: 'https://x/a' },
{ id: 'b', pattern: 'y', template: 'https://x/b', enabled: false },
{ id: 'c', pattern: 'z', template: 'https://x/c', enabled: true }
])
ok(compiled.length === 2, 'disabled rules are not compiled')
ok(compiled[0].regex.flags.includes('g'), 'the compiled regex is global')
// The compile gate is fail-closed: a rule with no proof (or one that does not
// match its pattern) never reaches the terminal. `tests/rule-validation.mjs`
// owns the full truth table; this is the caller-side reminder that `proved()`
// is load-bearing for every table above.
ok(compileLinkRules([{ id: 'a', pattern: 'x', template: 'https://x/a' }]).length === 0, 'an unproven rule is inert')
ok(compiled.every((r) => r.regex.flags === LINK_RULE_FLAGS), `every compiled rule uses '${LINK_RULE_FLAGS}'`)
}
// ---- findRuleLinks ----------------------------------------------------------
console.log('[findRuleLinks]')
const find = (text, rules) =>
findRuleLinks(text, compileLinkRules(rules)).map((s) => [s.start, s.end, s.url])
const find = (text, rules) => findRuleLinks(text, compile(rules)).map((s) => [s.start, s.end, s.url])
const ruleSet = [{ id: 't', pattern: 'BUG-(\\d+)', template: 'https://tracker/bug/$1' }]
+598
View File
@@ -0,0 +1,598 @@
/**
* perf-diagnostics self-test (perf-diagnostics.mjs).
*
* The observation-only first phase of the stall timeline
* (docs/PLAN-2026-10-17-font-highlight.md, 独立方案 B §B2) is observation only, so
* the thing worth pinning is the *decision* logic that turns raw ticks into log
* lines:
*
* - `classifyWatchdogLag`: a late tick is only a "renderer long task" when an
* observed long task actually overlaps the window; otherwise it is
* "scheduler delay/unclassified" with the unexplained remainder (`uncovered`)
* carried explicitly. Below the lag threshold nothing is reported at all.
* - `fitTriggerFor`: the fit label comes from the font group's FIELD VALUES, so
* a group object rebuilt for a non-metric member (cursor blink/style) is not
* mislabelled as a scrollback reflow.
* - `intervalLogDecision`: per-pane fit lines — one per window, the next one
* carrying `suppressed=N`.
* - `perfLogDecision`: the 5/minute global PERF cap, which urgent (severe
* watchdog) lines bypass without resetting the normal budget.
* - the pane registry: per-bind counters, aggregate snapshot, and the rule that
* a dropped (unmounted/rebound) pane id stops receiving notes so an in-flight
* write callback cannot pollute the next bind's numbers.
* - `WriteBatcher.pendingChars`: a JS string length (NOT bytes) that tracks
* the buffer and clears on flush/dispose.
*
* Direct .ts import (no esbuild bundle needed): perfDiagnostics.ts has no
* imports and writeBatcher.ts has none either, and Node >= 22.18 strips types
* itself.
* Run: node tests/perf-diagnostics.mjs (must exit 0)
*/
import {
classifyWatchdogLag,
clearPanes,
currentPerfLogGate,
emitPerfLog,
FIT_LOG_INTERVAL_MS,
fitTriggerFor,
formatFitReport,
formatWatchdogReport,
intervalLogDecision,
LongTaskRing,
MAX_LONG_TASKS,
newIntervalGate,
newPerfLogGate,
noteFit,
notePaneRenderer,
noteWebgl,
noteWriteDone,
noteWriteEnqueue,
noteWriteStart,
overlapLongTasks,
PERF_LOG_MAX_PER_WINDOW,
PERF_LOG_WINDOW_MS,
perfLogDecision,
registerPane,
resetPerfLogSink,
setPerfLogSink,
snapshotPanes,
unregisterPane,
WATCHDOG_LAG_LOG_MS,
sanitizePerfDetail
} from '../src/renderer/src/terminal/perfDiagnostics.ts'
import { WriteBatcher } from '../src/renderer/src/terminal/writeBatcher.ts'
const DELAY = 10
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const eq = (actual, expected, msg) =>
ok(actual === expected, `${msg} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`)
// ---- watchdog classification (table driven) --------------------------------
console.log('watchdog: lag below the threshold is not reported at all')
{
const rows = [
{ lag: 0, minLag: undefined, want: null },
{ lag: 2999, minLag: undefined, want: null },
{ lag: WATCHDOG_LAG_LOG_MS, minLag: undefined, want: null }, // strictly greater
{ lag: 3001, minLag: undefined, want: true },
{ lag: -5000, minLag: undefined, want: null }
]
for (const row of rows) {
const got = classifyWatchdogLag({
lagMs: row.lag,
windowStart: 1000,
windowEnd: 1000 + 2000 + row.lag,
minLagMs: row.minLag
})
eq(got === null, row.want === null, `lag ${row.lag}ms is ${row.want === null ? 'silent' : 'reported'}`)
}
}
console.log('watchdog: classification follows long-task overlap, not the lag alone')
{
// window [1000, 9000]; lag 6000
const rows = [
{
name: 'no long tasks -> scheduler delay',
tasks: [],
kind: 'scheduler-delay',
count: 0,
overlap: 0,
uncovered: 6000
},
{
name: 'one long task fully inside -> renderer long task',
tasks: [{ startTime: 3000, duration: 4000 }],
kind: 'renderer-long-task',
count: 1,
overlap: 4000,
uncovered: 2000
},
{
name: 'long task straddling the start is clamped',
tasks: [{ startTime: 0, duration: 2500 }],
kind: 'renderer-long-task',
count: 1,
overlap: 1500,
uncovered: 4500
},
{
name: 'task ending exactly at the window start does not overlap',
tasks: [{ startTime: 0, duration: 1000 }],
kind: 'scheduler-delay',
count: 0,
overlap: 0,
uncovered: 6000
},
{
name: 'task starting exactly at the window end does not overlap',
tasks: [{ startTime: 9000, duration: 500 }],
kind: 'scheduler-delay',
count: 0,
overlap: 0,
uncovered: 6000
},
{
name: 'two adjacent tasks sum their overlap',
tasks: [
{ startTime: 2000, duration: 2000 },
{ startTime: 4000, duration: 3000 }
],
kind: 'renderer-long-task',
count: 2,
overlap: 5000,
uncovered: 1000
},
{
name: 'a long task outside the window is ignored',
tasks: [{ startTime: 20000, duration: 9000 }],
kind: 'scheduler-delay',
count: 0,
overlap: 0,
uncovered: 6000
}
]
for (const row of rows) {
const got = classifyWatchdogLag({
lagMs: 6000,
windowStart: 1000,
windowEnd: 9000,
longTasks: row.tasks
})
ok(got !== null, `${row.name}: reported`)
if (got === null) continue
eq(got.kind, row.kind, `${row.name}: kind`)
eq(got.taskCount, row.count, `${row.name}: taskCount`)
eq(got.overlapMs, row.overlap, `${row.name}: overlapMs`)
eq(got.uncoveredMs, row.uncovered, `${row.name}: uncoveredMs`)
}
}
console.log('watchdog: maxTaskMs reports the real long-task size, not the clamp')
{
const got = classifyWatchdogLag({
lagMs: 5000,
windowStart: 1000,
windowEnd: 7000,
longTasks: [{ startTime: 900, duration: 3000 }]
})
ok(got !== null, 'reported')
eq(got.overlapMs, 2900, 'overlap is clamped to the 1000..3900 intersection')
eq(got.maxTaskMs, 3000, 'maxTaskMs keeps the full task duration')
}
console.log('overlapLongTasks: degenerate windows report nothing')
{
for (const [start, end] of [[1000, 1000], [2000, 1000]]) {
const got = overlapLongTasks([{ startTime: 500, duration: 5000 }], start, end)
eq(got.count, 0, `window [${start}, ${end}] is empty`)
}
}
console.log('LongTaskRing: bounded FIFO, newest last')
{
const ring = new LongTaskRing(3)
for (let i = 0; i < 5; i += 1) ring.push({ startTime: i * 10, duration: 50 })
eq(ring.size, 3, 'capacity is enforced')
const all = ring.all()
eq(all[0].startTime, 20, 'oldest surviving entry is the 3rd push')
eq(all[2].startTime, 40, 'newest entry is last')
eq(ring.latest()?.startTime, 40, 'latest() returns the newest')
const copy = ring.all()
copy.push({ startTime: 999, duration: 1 })
eq(ring.size, 3, 'all() returns a copy, not the live array')
ring.clear()
eq(ring.size, 0, 'clear empties the ring')
eq(ring.latest(), null, 'latest() is null when empty')
const big = new LongTaskRing()
for (let i = 0; i < MAX_LONG_TASKS + 20; i += 1) big.push({ startTime: i, duration: 60 })
eq(big.size, MAX_LONG_TASKS, 'default capacity is MAX_LONG_TASKS')
}
// ---- fit trigger labelling -------------------------------------------------
console.log('fit trigger: labelled from field values, not from object identity')
{
const base = {
fontSize: 14,
fontFamily: 'Consolas',
cjkFontFamily: '',
fontWeight: 400,
boldFontWeight: 700,
letterSpacing: 0,
lineHeight: 1,
scrollback: 5000
}
const rows = [
{ name: 'the first fit after construction', next: base, want: 'mount' },
{ name: 'a group object rebuilt with identical values', next: { ...base }, want: 'unknown' },
{ name: 'font size', next: { ...base, fontSize: 15 }, want: 'font' },
{ name: 'font family', next: { ...base, fontFamily: 'Fira Code' }, want: 'font' },
{ name: 'the CJK fallback slot', next: { ...base, cjkFontFamily: 'Sarasa Mono SC' }, want: 'font' },
{ name: 'font weight', next: { ...base, fontWeight: 500 }, want: 'font' },
{ name: 'bold font weight', next: { ...base, boldFontWeight: 800 }, want: 'font' },
{ name: 'letter spacing', next: { ...base, letterSpacing: 1 }, want: 'font' },
{ name: 'line height', next: { ...base, lineHeight: 1.2 }, want: 'font' },
{ name: 'only the scrollback cap', next: { ...base, scrollback: 10000 }, want: 'scrollback' },
{
name: 'scrollback and a font metric together',
next: { ...base, scrollback: 10000, fontSize: 16 },
want: 'font'
}
]
for (const row of rows) {
const got = fitTriggerFor(row.name.startsWith('the first') ? null : base, row.next)
eq(got, row.want, row.name)
}
// The point of the value comparison: a member of the font group that changes
// neither a metric nor the scrollback cap must NOT be reported as a scrollback
// reflow. The caller's snapshot simply has no cursor field, so this is the same
// call it would make.
eq(fitTriggerFor(base, { ...base }), 'unknown', 'a non-metric group change is not called a scrollback reflow')
}
// ---- log rate limiters -----------------------------------------------------
console.log('fit log gate: one line per window, next line carries suppressed=N')
{
const rows = [
{ now: 1000, emit: true, suppressed: 0, note: 'first line always passes' },
{ now: 1000 + 1, emit: false, suppressed: 0, note: 'just after: swallowed' },
{ now: 1000 + 5000, emit: false, suppressed: 0, note: 'mid-window: swallowed' },
{ now: 1000 + FIT_LOG_INTERVAL_MS, emit: true, suppressed: 2, note: 'window elapsed: emits with the 2 swallowed' },
{ now: 1000 + FIT_LOG_INTERVAL_MS + 1, emit: false, suppressed: 0, note: 'new window: counter reset' }
]
let gate = newIntervalGate()
for (const row of rows) {
const decision = intervalLogDecision(gate, row.now, FIT_LOG_INTERVAL_MS)
eq(decision.emit, row.emit, row.note)
eq(decision.suppressed, row.suppressed, `${row.note}: suppressed`)
gate = decision.next
}
}
console.log('fit log gate: the decision is pure (input gate is not mutated)')
{
const gate = newIntervalGate()
const before = { ...gate }
const first = intervalLogDecision(gate, 5000, FIT_LOG_INTERVAL_MS)
eq(gate.lastEmitAt, before.lastEmitAt, 'input gate untouched')
eq(gate.suppressed, before.suppressed, 'input suppressed untouched')
eq(first.next.lastEmitAt, 5000, 'returned gate carries the new timestamp')
}
console.log('PERF budget: max 5 lines per minute, urgent lines bypass it')
{
let gate = newPerfLogGate()
const decisions = []
for (let i = 0; i < PERF_LOG_MAX_PER_WINDOW + 2; i += 1) {
const d = perfLogDecision(gate, 10_000, {})
decisions.push(d.emit)
gate = d.next
}
eq(decisions.filter(Boolean).length, PERF_LOG_MAX_PER_WINDOW, 'exactly 5 normal lines pass')
eq(decisions.at(-1), false, 'the 6th and 7th are dropped')
eq(gate.suppressed, 2, 'the dropped count is carried')
const urgent = perfLogDecision(gate, 10_000, { urgent: true })
eq(urgent.emit, true, 'an urgent line passes a spent budget')
eq(urgent.suppressed, 2, 'and still reports what was dropped before it')
const afterWindow = perfLogDecision(urgent.next, 10_000 + PERF_LOG_WINDOW_MS, {})
eq(afterWindow.emit, true, 'a new window restores the budget')
eq(afterWindow.suppressed, 0, 'and clears the dropped count')
}
console.log('PERF budget: emitPerfLog drops silently once the cap is spent')
{
const lines = []
resetPerfLogSink()
setPerfLogSink((line) => lines.push(line))
for (let i = 0; i < PERF_LOG_MAX_PER_WINDOW + 3; i += 1) emitPerfLog(`line-${i}`, { now: 5000 })
eq(lines.length, PERF_LOG_MAX_PER_WINDOW, 'only the budgeted lines reach the sink')
ok(lines.every((l) => !l.includes('suppressed=')), 'no suppressed trailer while nothing was dropped')
// The dropped count rides on the next accepted line, once the window rolls.
const emitted = emitPerfLog('after-window', { now: 5000 + PERF_LOG_WINDOW_MS })
eq(emitted, true, 'the first line of the next window is accepted')
eq(lines.length, PERF_LOG_MAX_PER_WINDOW + 1, 'and it reached the sink')
const urgent = emitPerfLog('urgent-line', { urgent: true, now: 5000 + PERF_LOG_WINDOW_MS })
eq(urgent, true, 'urgent lines bypass the budget through emitPerfLog')
resetPerfLogSink()
}
console.log('PERF budget: a thrown sink never propagates')
{
resetPerfLogSink()
setPerfLogSink(() => {
throw new Error('sink exploded')
})
let threw = false
try {
emitPerfLog('boom', { now: 1 })
} catch {
threw = true
}
ok(!threw, 'emitPerfLog swallows a failing sink')
resetPerfLogSink()
}
// ---- pane registry ---------------------------------------------------------
console.log('pane registry: counters aggregate across panes')
{
clearPanes()
const a = registerPane('pane-a', { rendererMode: 'webgl', registeredAt: 0 })
const b = registerPane('pane-b', { rendererMode: 'dom', registeredAt: 0 })
registerPane('pane-c', { registeredAt: 0 }) // unknown mode on purpose
noteWriteStart('pane-a', 120)
noteWriteEnqueue('pane-a', 4)
noteWriteDone('pane-a', 120, 45)
noteWriteStart('pane-b', 30)
noteWriteEnqueue('pane-b', 9)
// pane-b's write is deliberately left in flight
noteFit('pane-a', {
trigger: 'resize',
startedAt: 1000,
durationMs: 240,
cols: 120,
rows: 40,
bufferLines: 5120,
scrollback: 5000
})
noteFit('pane-b', {
trigger: 'mount',
startedAt: 500,
durationMs: 12,
cols: 80,
rows: 24,
bufferLines: 24,
scrollback: 5000
})
noteWebgl('pane-a', 'init')
noteWebgl('pane-a', 'context-loss')
noteWebgl('pane-b', 'init-failed')
a.pendingCharsProbe = () => 77
const snap = snapshotPanes(3000)
eq(snap.paneCount, 3, 'all three panes are counted')
eq(snap.webglPanes, 1, 'one webgl pane')
eq(snap.domPanes, 1, 'one dom pane')
eq(snap.unknownModePanes, 1, 'one pane with no renderer recorded yet')
eq(snap.writeCount, 2, 'write count sums across panes')
eq(snap.writeInflight, 1, 'the unfinished write is still counted')
eq(snap.queuedChars, 30, 'queued characters are what is left unconsumed')
eq(snap.batcherPendingChars, 77, 'the batcher probe contributes its pendingChars')
eq(snap.ackMsLast, 45, 'ack latency aggregates')
eq(snap.ackMsMax, 45, 'ack max aggregates')
eq(snap.enqueueMsMax, 9, 'enqueue max aggregates')
eq(snap.fitCount, 2, 'fit count sums')
eq(snap.lastFitPaneId, 'pane-a', 'the newest fit wins')
eq(snap.lastFit?.trigger, 'resize', 'and carries its trigger')
eq(snap.lastFitAgeMs, 2000, 'fit age is measured from the sample start')
eq(snap.webglContextLosses, 1, 'context losses are counted')
eq(b.writes.inflight, 1, 'per-pane counters stay intact')
// A probe that throws must not take the snapshot down.
b.pendingCharsProbe = () => {
throw new Error('probe exploded')
}
const safe = snapshotPanes(3000)
eq(safe.batcherPendingChars, 77, 'a throwing probe contributes 0')
clearPanes()
}
console.log('pane registry: counters are per BIND — a dropped id stops receiving notes')
{
clearPanes()
registerPane('bind-1', { rendererMode: 'webgl', registeredAt: 0 })
noteWriteStart('bind-1', 500)
noteWriteDone('bind-1', 500, 20)
eq(snapshotPanes(100).writeCount, 1, 'the first bind counted its write')
// Rebind: the old terminal is disposed, its in-flight callback still fires.
unregisterPane('bind-1')
registerPane('bind-2', { rendererMode: 'webgl', registeredAt: 100 })
noteWriteDone('bind-1', 500, 9999)
noteFit('bind-1', {
trigger: 'resize',
startedAt: 50,
durationMs: 5000,
cols: 1,
rows: 1,
bufferLines: 1,
scrollback: 1
})
noteWebgl('bind-1', 'context-loss')
const snap = snapshotPanes(200)
eq(snap.writeCount, 0, "the new bind did not inherit the old bind's writes")
eq(snap.fitCount, 0, 'nor its fits')
eq(snap.webglContextLosses, 0, 'nor its webgl events')
eq(snap.ackMsMax, 0, 'nor its ack latency')
eq(snap.paneCount, 1, 'only the live bind is registered')
clearPanes()
}
console.log('pane registry: write completion cannot drive the counters negative')
{
clearPanes()
registerPane('p', { registeredAt: 0 })
noteWriteDone('p', 100, 5) // completion with no matching start
noteWriteDone('p', 0, -100) // a negative/zero measurement
const stats = snapshotPanes(1)
eq(stats.writeInflight, 0, 'inflight floors at 0')
eq(stats.queuedChars, 0, 'queued chars floor at 0')
eq(stats.ackMsMax, 5, 'the max ack is unaffected by a clamp')
clearPanes()
}
console.log('pane registry: renderer mode re-records in place')
{
clearPanes()
registerPane('p', { rendererMode: 'webgl', registeredAt: 0 })
notePaneRenderer('p', 'dom')
eq(snapshotPanes(1).domPanes, 1, 'the pane moved to the dom bucket')
eq(snapshotPanes(1).webglPanes, 0, 'and left the webgl bucket')
clearPanes()
}
// ---- log line formatting ---------------------------------------------------
console.log('fit report: metadata only, with a suppressed trailer when needed')
{
const sample = {
trigger: 'resize',
startedAt: 12345.6,
durationMs: 240.4,
cols: 120,
rows: 40,
bufferLines: 5120,
scrollback: 5000
}
const plain = formatFitReport(sample, 0, 'abcdef123456')
ok(plain.startsWith('PERF fit pane abcdef '), 'pane id is shortened to 6 chars')
ok(plain.includes('trigger=resize'), 'trigger is present')
ok(plain.includes('240ms'), 'duration is rounded')
ok(plain.includes('120x40'), 'grid is present')
ok(plain.includes('bufferLines=5120'), 'real buffer length is present')
ok(plain.includes('scrollback=5000'), 'scrollback cap is present')
ok(!plain.includes('suppressed='), 'no trailer when nothing was swallowed')
const gated = formatFitReport(sample, 7, 'abcdef123456')
ok(gated.endsWith('suppressed=7'), 'the swallowed count is the last field')
const noPane = formatFitReport(sample, 0, '')
ok(!noPane.includes(' pane '), 'an empty pane id is omitted')
}
console.log('watchdog report: names the kind, the window and the estimate')
{
const snap = snapshotPanes(0)
const verdict = classifyWatchdogLag({
lagMs: 5000,
windowStart: 1000,
windowEnd: 8000,
longTasks: [{ startTime: 2000, duration: 3000 }]
})
ok(verdict !== null, 'verdict built')
const line = formatWatchdogReport(verdict, snap, {
now: 8000,
wallNow: 1_700_000_000_000,
recentLongTasks: [{ startTime: 2000, duration: 3000 }]
})
ok(line.includes('renderer long task'), 'the kind is spelled out')
ok(line.includes('panes 0'), 'pane count is present')
ok(line.includes('no fit recorded'), 'a missing fit says so instead of printing zeros')
ok(line.includes('after recovery'), 'the log-time caveat is present')
ok(!/[\r\n]/.test(line), 'and the whole report is one line')
const noTask = classifyWatchdogLag({ lagMs: 5000, windowStart: 1000, windowEnd: 8000, longTasks: [] })
ok(noTask !== null, 'verdict built without long tasks')
const schedulerLine = formatWatchdogReport(noTask, snap, {
now: 8000,
wallNow: 1_700_000_000_000,
recentLongTasks: []
})
ok(schedulerLine.includes('scheduler delay/unclassified'), 'unexplained lag is not called a stall')
ok(schedulerLine.includes('none observed'), 'and says no long task was seen')
}
console.log('sanitizePerfDetail: one line, capped')
{
eq(sanitizePerfDetail('a\nb\tc'), 'a b c', 'newlines and tabs are flattened')
eq(sanitizePerfDetail(' '), '', 'whitespace-only detail becomes empty')
const long = sanitizePerfDetail('x'.repeat(200), 10)
eq(long.length, 13, 'long text is truncated with an ellipsis')
}
// ---- WriteBatcher.pendingChars --------------------------------------------
console.log('WriteBatcher: pendingChars is a string length, not bytes')
{
const calls = []
const b = new WriteBatcher((text) => calls.push(text), DELAY)
eq(b.pendingChars, 0, 'an empty batcher reports 0')
b.push('abc')
eq(b.pendingChars, 3, 'three ASCII chars = 3')
b.push('中文')
eq(b.pendingChars, 5, 'two CJK chars add 2 code units (NOT 6 bytes)')
ok(b.hasPending(), 'hasPending agrees with the count')
b.push('')
eq(b.pendingChars, 5, 'an empty push changes nothing')
await sleep(DELAY * 4)
eq(calls.length, 1, 'one flush')
eq(b.pendingChars, 0, 'pendingChars clears after the flush')
b.dispose()
}
console.log('WriteBatcher: dispose drops pendingChars')
{
const calls = []
const b = new WriteBatcher((text) => calls.push(text), DELAY)
b.push('dropped')
eq(b.pendingChars, 7, 'the buffer is counted before dispose')
b.dispose()
eq(b.pendingChars, 0, 'dispose clears the count')
await sleep(DELAY * 4)
eq(calls.length, 0, 'and nothing is written')
}
console.log('WriteBatcher: flushNow clears pendingChars synchronously')
{
const calls = []
const b = new WriteBatcher((text) => calls.push(text), DELAY)
b.push('tail')
b.flushNow()
eq(b.pendingChars, 0, 'flushNow empties the count before returning')
eq(calls[0], 'tail', 'and handed the text over')
await sleep(DELAY * 4)
eq(calls.length, 1, 'the cancelled timer never fires again')
b.dispose()
}
// ---- global budget sanity --------------------------------------------------
console.log('the shared gate starts fresh')
{
resetPerfLogSink()
const gate = currentPerfLogGate()
eq(gate.windowStart, 0, 'no window is open yet')
eq(gate.count, 0, 'and no lines have been counted')
}
if (failed > 0) {
console.error(`\n[perf-diagnostics] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[perf-diagnostics] ALL CHECKS PASSED')
+211
View File
@@ -0,0 +1,211 @@
/**
* 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')
+486
View File
@@ -0,0 +1,486 @@
/**
* 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')
+418
View File
@@ -0,0 +1,418 @@
/**
* Rule-validation gate self-test (rule-validation.mjs).
*
* `src/shared/ruleValidation.ts` is the fail-closed front door: a stored pattern
* runs only with a proof that matches it exactly, or as a factory built-in on
* the whitelist. It is shared by the highlight engine and the link-rule engine,
* so its decisions are pinned here rather than through either caller:
*
* - the shape cleaner keeps exactly the four known fields and rejects
* everything else (a hand-edited settings.json can put anything under
* `validation`), while a stale-but-intact record survives it — staleness is
* the gate's business, not the cleaner's;
* - the flag spelling is the one both engines compile with, so a proof can
* never be compared against flags the rule does not run under;
* - the truth table is fail-closed in every direction: pending, quarantined,
* missing, expired probe version, edited pattern, edited flags;
* - the built-in whitelist is DERIVED from the shipped presets (no hand-copied
* pattern list) and exempts them without a proof, while a preset whose
* pattern or flags were edited loses the exemption;
* - link rules get no whitelist at all, and their gate runs inside
* `compileLinkRules` — the function the terminal's link provider calls.
*
* Direct .ts import (no esbuild bundle): ruleValidation.ts is zero-import and
* linkRules.ts reaches for it through an explicit `.ts` specifier (Node's ESM
* resolver does not try extensions). settings.ts has no runtime imports.
* Run: node tests/rule-validation.mjs (must exit 0)
*/
import {
LINK_RULE_FLAGS,
MAX_PATTERN_LEN,
highlightFlags,
makeBuiltinWhitelist,
needsRuleValidation,
normalizeRuleValidation,
ruleKeyOf,
ruleRunState,
sanitizeRuleValidation,
setRuleRuntimeKey,
validationAllows
} from '../src/shared/ruleValidation.ts'
import { PROBE_VERSION } from '../src/shared/redosProbes.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { compileLinkRules, sanitizeLinkRules } from '../src/shared/linkRules.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/**
* The environment the proofs in this file belong to. The main process installs
* its own fingerprint at startup and the renderer is handed the same string
* over IPC; here it is a literal, which also makes the runtime dimension
* testable — a proof is only evidence for the environment it was measured in.
*/
const RUNTIME = 'test-app|test-electron|test-v8'
setRuleRuntimeKey(RUNTIME)
/** A well-formed proof for one pattern + flags. */
const proof = (pattern, flags = 'g', over = {}) => ({
status: 'passed',
pattern,
flags,
probeVersion: PROBE_VERSION,
runtime: RUNTIME,
...over
})
// ---- 1. shape cleaner -------------------------------------------------------
console.log('[shape cleaner]')
{
for (const status of ['passed', 'pending', 'quarantined']) {
const raw = proof('a', 'g', { status })
const out = sanitizeRuleValidation(raw)
ok(out?.status === status, `a well-formed '${status}' record survives`)
}
{
const raw = proof('a', 'g', { junk: 'x', nested: { deep: true } })
const out = sanitizeRuleValidation(raw)
ok(out !== undefined, 'a well-formed record with extra fields is kept')
ok(
JSON.stringify(Object.keys(out).sort()) === JSON.stringify(['flags', 'pattern', 'probeVersion', 'runtime', 'status']),
`only the five known fields survive (${Object.keys(out).join(', ')})`
)
ok(out !== raw, 'the cleaner returns a fresh object rather than the input')
ok(raw.junk === 'x', 'the cleaner does not mutate the input')
}
{
// Staleness is not a shape problem: the record is kept and the GATE refuses
// it. Erasing it here would lose the evidence a settings page wants to show.
const stale = sanitizeRuleValidation(proof('a', 'g', { probeVersion: PROBE_VERSION - 1 }))
ok(stale?.probeVersion === PROBE_VERSION - 1, 'a record from an older probe set is kept as-is')
}
const rejected = [
['undefined', undefined],
['null', null],
['a string', 'passed'],
['a number', 1],
['an array', []],
['an empty object', {}],
['a missing status', { pattern: 'a', flags: 'g', probeVersion: 1 }],
['an unknown status', proof('a', 'g', { status: 'trusted' })],
['a non-string pattern', proof(7, 'g')],
['a missing pattern', proof('a', 'g', { pattern: undefined })],
['a non-string flags', proof('a', 7)],
['a missing probeVersion', proof('a', 'g', { probeVersion: undefined })],
['a string probeVersion', proof('a', 'g', { probeVersion: '1' })],
['a NaN probeVersion', proof('a', 'g', { probeVersion: Number.NaN })],
['an infinite probeVersion', proof('a', 'g', { probeVersion: Number.POSITIVE_INFINITY })],
// A record written before proofs carried a runtime cannot say which engine
// measured it, so it is not evidence for any environment — the rule lands as
// `pending` and is re-proved, which is the point of the field.
['a missing runtime', proof('a', 'g', { runtime: undefined })],
['an empty runtime', proof('a', 'g', { runtime: '' })],
['a non-string runtime', proof('a', 'g', { runtime: 7 })]
]
for (const [label, value] of rejected) {
ok(sanitizeRuleValidation(value) === undefined, `${label} is rejected`)
}
ok(MAX_PATTERN_LEN === 500, `the shared pattern bound is 500 (${MAX_PATTERN_LEN})`)
}
// ---- 2. flag spelling -------------------------------------------------------
console.log('[flags]')
{
const cases = [
['no switches', undefined, undefined, 'g'],
['case-insensitive', true, undefined, 'gi'],
['line-anchored', undefined, true, 'gm'],
['both', true, true, 'gim'],
['a truthy non-boolean stays off', 1, 'yes', 'g']
]
for (const [label, ci, la, want] of cases) {
const got = highlightFlags(ci, la)
ok(got === want, `${label} compiles as '${want}' (got '${got}')`)
}
ok(LINK_RULE_FLAGS === 'g', `link rules compile as plain 'g' (${LINK_RULE_FLAGS})`)
{
// The gate compares against this constant and `compileLinkRules` compiles
// with it, so the two cannot drift apart into "proved under X, runs under Y".
const [compiled] = compileLinkRules([
{ id: 'r', pattern: 'x', template: 'https://x', validation: proof('x', LINK_RULE_FLAGS) }
])
ok(compiled?.regex.flags === LINK_RULE_FLAGS, `a compiled link regex uses exactly '${LINK_RULE_FLAGS}'`)
}
}
// ---- 3. the gate ------------------------------------------------------------
console.log('[gate]')
{
const cases = [
['no proof at all', 'ERROR', 'g', undefined, false],
['a matching passed proof', 'ERROR', 'g', proof('ERROR'), true],
['a pending proof', 'ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), false],
['a quarantined proof', 'ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), false],
['a proof for another pattern', 'ERROR', 'g', proof('WARNING'), false],
['a proof for another flag string', 'ERROR', 'gi', proof('ERROR', 'g'), false],
['a proof from an older probe set', 'ERROR', 'g', proof('ERROR', 'g', { probeVersion: PROBE_VERSION - 1 }), false],
['a proof from a newer probe set', 'ERROR', 'g', proof('ERROR', 'g', { probeVersion: PROBE_VERSION + 1 }), false],
// The runtime is part of the proof: the same pattern measured on another V8
// (another app build) says nothing about this one.
['a proof from another runtime', 'ERROR', 'g', proof('ERROR', 'g', { runtime: 'other|other|other' }), false],
['a proof for a prefixed pattern', 'ERROR', 'g', proof('^ERROR'), false]
]
for (const [label, pattern, flags, validation, want] of cases) {
const got = validationAllows(pattern, flags, validation, PROBE_VERSION)
ok(got === want, `${label} → ${want} (got ${got})`)
}
// The engine passes a whitelist; link rules pass none. Everything above was
// "no whitelist", so the empty-set case must behave identically.
ok(
!validationAllows('ERROR', 'g', undefined, PROBE_VERSION, new Set()),
'an empty whitelist changes nothing'
)
// With no fingerprint installed there is no environment a proof could belong
// to, so the gate refuses every one of them rather than accepting a verdict
// from somewhere unknown.
setRuleRuntimeKey('')
ok(
!validationAllows('ERROR', 'g', proof('ERROR'), PROBE_VERSION),
'an uninstalled runtime refuses even a matching proof (fail-closed)'
)
setRuleRuntimeKey(RUNTIME)
}
// ---- 3b. run state, normalization and the work predicate --------------------
console.log('[run state]')
{
const whitelist = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES)
const sample = DEFAULT_HIGHLIGHT_RULES[0]
const sampleFlags = highlightFlags(sample.caseInsensitive, sample.lineAnchor)
ok(
ruleRunState(sample.pattern, sampleFlags, undefined, PROBE_VERSION, whitelist) === 'builtin',
'a factory preset reads as built-in'
)
ok(ruleRunState('ERROR', 'g', proof('ERROR'), PROBE_VERSION) === 'passed', 'a matching passed proof reads as passed')
ok(ruleRunState('ERROR', 'g', undefined, PROBE_VERSION) === 'pending', 'no proof reads as pending')
ok(
ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), PROBE_VERSION) === 'pending',
'a pending record reads as pending'
)
ok(
ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION) === 'quarantined',
'a quarantined record reads as quarantined'
)
ok(
ruleRunState('WARNING', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION) === 'pending',
'a quarantined record for another pattern does not label this one'
)
ok(
ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined', runtime: 'other' }), PROBE_VERSION) === 'pending',
'a quarantine from another runtime reads as pending'
)
// Normalization: what the settings store runs on every load and save.
ok(
normalizeRuleValidation(proof('ERROR'), 'ERROR', 'g', PROBE_VERSION)?.status === 'passed',
'a proof that still applies is kept untouched'
)
{
const stale = normalizeRuleValidation(proof('ERROR'), 'ERROR', 'gi', PROBE_VERSION)
ok(stale?.status === 'pending', 'a proof for other flags is rewritten to pending')
ok(stale?.flags === 'gi' && stale?.pattern === 'ERROR', 'the rewritten record describes the rule as it stands')
ok(stale?.runtime === RUNTIME && stale?.probeVersion === PROBE_VERSION, 'the rewritten record names the current environment')
ok(
sanitizeRuleValidation(stale) !== undefined,
'the rewritten record is itself a well-formed record (not "corrupt data" on the next load)'
)
}
ok(
normalizeRuleValidation(proof('ERROR'), 'ERROR', 'g', PROBE_VERSION + 1)?.status === 'pending',
'a proof from another probe set is rewritten to pending'
)
ok(
normalizeRuleValidation(proof('ERROR', 'g', { runtime: 'other' }), 'ERROR', 'g', PROBE_VERSION)?.status === 'pending',
'a proof from another runtime is rewritten to pending'
)
ok(normalizeRuleValidation(undefined, 'ERROR', 'g', PROBE_VERSION) === undefined, 'no proof stays no proof')
{
setRuleRuntimeKey('')
const untouched = normalizeRuleValidation(proof('ERROR', 'g', { runtime: 'other' }), 'ERROR', 'g', PROBE_VERSION)
ok(untouched?.runtime === 'other', 'without a fingerprint the record is left alone instead of being stamped with nothing')
setRuleRuntimeKey(RUNTIME)
}
// The validator's work predicate: probe what has no verdict, never re-probe a
// verdict that still applies (a write-back would otherwise loop through the
// settings broadcast).
ok(needsRuleValidation('ERROR', 'g', undefined, PROBE_VERSION), 'a rule with no proof needs validation')
ok(needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), PROBE_VERSION), 'a pending rule needs validation')
ok(
needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { runtime: 'other' }), PROBE_VERSION),
'a rule whose verdict belongs to another runtime needs validation'
)
ok(!needsRuleValidation('ERROR', 'g', proof('ERROR'), PROBE_VERSION), 'a proved rule does not need validation')
ok(
!needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION),
'a quarantined rule is not probed again on every settings change'
)
ok(ruleKeyOf('a', 'g') !== ruleKeyOf('a', 'gi'), 'the rule key separates flag strings')
ok(ruleKeyOf('a', 'g') === ruleKeyOf('a', 'g'), 'the rule key is stable for the same rule')
}
// ---- 4. built-in whitelist --------------------------------------------------
console.log('[builtin whitelist]')
{
const whitelist = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES)
ok(DEFAULT_HIGHLIGHT_RULES.length > 20, `the presets are the whitelist source (${DEFAULT_HIGHLIGHT_RULES.length} rules)`)
ok(
whitelist.size === DEFAULT_HIGHLIGHT_RULES.length,
`every preset contributes its own key, none collide (${whitelist.size} keys)`
)
// Derived, not hand-copied: no preset may be missing, under its OWN flags.
const missing = DEFAULT_HIGHLIGHT_RULES.filter(
(rule) => !validationAllows(rule.pattern, highlightFlags(rule.caseInsensitive, rule.lineAnchor), undefined, PROBE_VERSION, whitelist)
)
ok(missing.length === 0, `every preset runs without a proof (missing: ${missing.map((r) => r.id).join(', ') || 'none'})`)
// A preset is exempt because of the EXACT pattern + flags, so an edited copy
// loses the exemption and has to earn a proof like any other rule.
const sample = DEFAULT_HIGHLIGHT_RULES[0]
ok(sample !== undefined, 'there is a preset to edit')
ok(
!validationAllows(sample.pattern, 'gi', undefined, PROBE_VERSION, whitelist) ||
highlightFlags(sample.caseInsensitive, sample.lineAnchor) === 'gi',
'a preset pattern under a DIFFERENT flag string is not exempt'
)
ok(
!validationAllows(`${sample.pattern}x`, highlightFlags(sample.caseInsensitive, sample.lineAnchor), undefined, PROBE_VERSION, whitelist),
'a preset pattern with an edited pattern is not exempt'
)
ok(
!validationAllows('(a+)+$', 'g', undefined, PROBE_VERSION, whitelist),
'a user pattern is not exempt just for looking like something'
)
ok(
!validationAllows('', 'g', undefined, PROBE_VERSION, whitelist),
'the empty pattern is never exempt'
)
// The exemption is about the pattern, not about the proof: a preset stays
// runnable whatever state a stale record happens to be in.
ok(
validationAllows(sample.pattern, highlightFlags(sample.caseInsensitive, sample.lineAnchor), undefined, PROBE_VERSION + 5, whitelist),
'a preset is exempt regardless of the probe version'
)
// ...and a user rule still needs its proof even when a whitelist is supplied.
ok(
validationAllows('BUG-\\d+', 'g', proof('BUG-\\d+'), PROBE_VERSION, whitelist),
'a proven user rule runs alongside the whitelist'
)
ok(
!validationAllows('BUG-\\d+', 'g', undefined, PROBE_VERSION, whitelist),
'an unproven user rule does not run just because a whitelist exists'
)
}
// ---- 5. link rules: gate, no whitelist --------------------------------------
console.log('[link gate]')
{
const base = { id: 'r', pattern: 'BUG-(\\d+)', template: 'https://tracker/$1' }
const compiled = (over) => compileLinkRules([{ ...base, ...over }])
ok(compiled({}).length === 0, 'an unproven link rule is not compiled')
ok(compiled({ validation: proof(base.pattern) }).length === 1, 'a proven link rule is compiled')
ok(compiled({ validation: proof(base.pattern, 'g', { status: 'pending' }) }).length === 0, 'a pending link rule is not compiled')
ok(compiled({ validation: proof(base.pattern, 'g', { status: 'quarantined' }) }).length === 0, 'a quarantined link rule is not compiled')
ok(
compiled({ validation: proof(base.pattern, 'g', { probeVersion: PROBE_VERSION - 1 }) }).length === 0,
'a link proof from an older probe set does not compile'
)
ok(compiled({ validation: proof('BUG-\\d+') }).length === 0, 'a proof for an edited pattern does not compile')
ok(compiled({ validation: proof(base.pattern, 'gi') }).length === 0, 'a proof under other flags does not compile')
ok(compiled({ enabled: false, validation: proof(base.pattern) }).length === 0, 'a disabled rule stays off even with a proof')
{
// No built-in exemption on this side: nothing in a link rule ships with the
// app, so a preset pattern pasted into a link rule is just another user
// pattern and needs its own proof.
const preset = DEFAULT_HIGHLIGHT_RULES.find((rule) => rule.id === 'ipv4')
ok(preset !== undefined, 'the ipv4 preset is available for the paste test')
const pasted = compileLinkRules([{ id: 'p', pattern: preset.pattern, template: 'https://x/$0' }])
ok(pasted.length === 0, 'a link rule reusing a preset pattern is not exempt')
const proved = compileLinkRules([{ id: 'p', pattern: preset.pattern, template: 'https://x/$0', validation: proof(preset.pattern) }])
ok(proved.length === 1, 'the same link rule compiles once it holds its own proof')
}
{
// Rule order is what decides which rule claims a span first, so the gate may
// only remove rules, never reorder the survivors.
const rules = [
{ id: 'a', pattern: 'A', template: 'https://a', validation: proof('A') },
{ id: 'b', pattern: 'B', template: 'https://b' },
{ id: 'c', pattern: 'C', template: 'https://c', validation: proof('C') }
]
const out = compileLinkRules(rules)
ok(
out.map((r) => r.id).join(',') === 'a,c',
`the gate filters without reordering (got ${out.map((r) => r.id).join(',')})`
)
}
ok(compileLinkRules(undefined).length === 0, 'an absent list still compiles to nothing')
}
// ---- 6. the link load path keeps the proof's shape --------------------------
console.log('[link sanitize]')
{
const raw = (over) => ({ id: 'r1', pattern: 'BUG-(\\d+)', template: 'https://x/$1', ...over })
{
const out = sanitizeLinkRules([raw({ validation: proof('BUG-(\\d+)', 'g', { junk: 1 }) })])
ok(out.length === 1, 'a rule with a proof survives the sanitizer')
ok(out[0].validation !== undefined, 'a well-formed proof is kept')
ok(out[0].validation.junk === undefined, 'extra fields are dropped from the proof')
}
{
const warnings = []
const out = sanitizeLinkRules([raw({ validation: { status: 'nonsense' } })], (m) => warnings.push(m))
ok(out.length === 1, 'a malformed proof does not cost the user the rule')
ok(out[0].validation === undefined, 'a malformed proof is dropped')
ok(
warnings.some((m) => m.includes('validation')),
`the drop is reported to the caller (${warnings.join(' | ') || 'no warning'})`
)
}
{
const out = sanitizeLinkRules([raw()])
ok(out[0].validation === undefined, 'a rule without a proof stays without one (not defaulted to passed)')
}
{
// End to end through the load path: sanitize keeps the proof, the gate then
// accepts it — the two must not disagree about the flag string.
const loaded = sanitizeLinkRules([raw({ validation: proof('BUG-(\\d+)', LINK_RULE_FLAGS) })])
ok(compileLinkRules(loaded).length === 1, 'a sanitized proven rule still compiles')
}
}
if (failed > 0) {
console.error(`\n[rule-validation] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[rule-validation] ALL CHECKS PASSED')
+127
View File
@@ -21,6 +21,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { MAX_PATTERN_LEN, runtimeKeyOf } from '../src/shared/ruleValidation.ts'
import { PROBE_VERSION } from '../src/shared/redosProbes.ts'
const require = createRequire(import.meta.url)
const userData = mkdtempSync(join(tmpdir(), 'ot-settings-'))
@@ -144,6 +146,131 @@ console.log('[highlight rules]')
ok(log.includes('highlightRules: not an array'), 'the fallback is recorded in settings-warnings.log')
}
// ---- 2b. the internal validation proof --------------------------------------
// `validation` is internal metadata written by the rule validator, not user
// input. The store keeps a well-formed record — the compile gate re-checks it
// against the rule's pattern, its flags, the current probe version and the
// running build — and drops a malformed one with a trace, without costing the
// user the rule itself. A proof that is well-formed but no longer applies (an
// older corpus, another environment, an edited pattern) is rewritten to
// `pending` rather than carried forward claiming to be a verdict.
console.log('[rule validation]')
{
/** The fingerprint the bundled store installed (same inputs, same function). */
const runtime = runtimeKeyOf({
app: '0.0.0-stub',
electron: process.versions.electron ?? '',
v8: process.versions.v8 ?? ''
})
const proof = (over = {}) => ({
status: 'passed',
pattern: '\\bERROR\\b',
flags: 'g',
probeVersion: PROBE_VERSION,
runtime,
...over
})
const kept = (await roundTrip({ highlightRules: [rule({ validation: proof() })] })).highlightRules
ok(kept.length === 1 && kept[0].validation?.status === 'passed', 'a well-formed proof survives the round trip')
ok(
JSON.stringify(Object.keys(kept[0].validation).sort()) ===
JSON.stringify(['flags', 'pattern', 'probeVersion', 'runtime', 'status']),
`only the five known fields survive (${Object.keys(kept[0].validation).join(', ')})`
)
const shaped = (await roundTrip({ highlightRules: [rule({ validation: proof({ junk: 1 }) })] })).highlightRules
ok(shaped[0].validation?.junk === undefined, 'extra fields are dropped from the proof')
for (const [label, bad] of [
['a string', 'passed'],
['an unknown status', proof({ status: 'trusted' })],
['a non-string pattern', proof({ pattern: 7 })],
['a non-numeric probe version', proof({ probeVersion: '1' })],
['a missing runtime', proof({ runtime: undefined })],
['an empty runtime', proof({ runtime: '' })],
['a missing status', { pattern: '\\bERROR\\b', flags: 'g', probeVersion: PROBE_VERSION, runtime }]
]) {
const out = (await roundTrip({ highlightRules: [rule({ validation: bad })] })).highlightRules
ok(out.length === 1, `a ${label} proof does not cost the user the rule`)
ok(out[0].validation === undefined, `a ${label} proof is dropped`)
}
// Stale verdicts: an upgrade (new corpus or new build) must not leave a rule
// claiming to have passed something it never ran against.
const olderCorpus = (await roundTrip({
highlightRules: [rule({ validation: proof({ probeVersion: PROBE_VERSION - 1 }) })]
})).highlightRules
ok(olderCorpus[0].validation?.status === 'pending', 'a proof from an older probe set becomes pending')
ok(olderCorpus[0].validation?.probeVersion === PROBE_VERSION, 'the pending record names the current probe set')
const otherBuild = (await roundTrip({
highlightRules: [rule({ validation: proof({ runtime: 'other|other|other' }) })]
})).highlightRules
ok(otherBuild[0].validation?.status === 'pending', 'a proof from another build becomes pending')
ok(otherBuild[0].validation?.runtime === runtime, 'the pending record names the running build')
const otherFlags = (await roundTrip({
highlightRules: [rule({ caseInsensitive: true, validation: proof() })]
})).highlightRules
ok(otherFlags[0].validation?.status === 'pending', 'a proof for other flags becomes pending')
ok(otherFlags[0].validation?.flags === 'gi', 'the pending record carries the flags the rule now compiles with')
// A quarantined verdict that still applies is evidence, not a gap: it stays.
const quarantined = (await roundTrip({
highlightRules: [rule({ validation: proof({ status: 'quarantined' }) })]
})).highlightRules
ok(quarantined[0].validation?.status === 'quarantined', 'a current quarantine is kept as it stands')
// Same contract for link rules, which go through the sanitizer instead.
const linkKept = (await roundTrip({
linkRules: [{ id: 'l1', pattern: 'BUG-(\\d+)', template: 'https://x/$1', validation: proof({ pattern: 'BUG-(\\d+)' }) }]
})).linkRules
ok(linkKept[0].validation?.status === 'passed', 'a well-formed link-rule proof survives the round trip')
const linkStale = (await roundTrip({
linkRules: [
{
id: 'l1',
pattern: 'BUG-(\\d+)',
template: 'https://x/$1',
validation: proof({ pattern: 'BUG-(\\d+)', runtime: 'other|other|other' })
}
]
})).linkRules
ok(linkStale[0].validation?.status === 'pending', 'a link-rule proof from another build becomes pending')
{
// The hand-edited case is the one that needs a trace: the record has to be
// ON DISK when the store reads it (a save sanitizes on the way out and would
// leave nothing to warn about), so write the file directly.
writeFileSync(
join(userData, 'settings.json'),
JSON.stringify({ highlightRules: [rule({ validation: { status: 'trusted' } })] }),
'utf8'
)
const loaded = store.loadSettings().highlightRules
ok(
loaded.length === 1 && loaded[0].validation === undefined,
'a malformed proof in a hand-edited file is dropped on load'
)
const proofLog = existsSync(join(userData, 'settings-warnings.log'))
? readFileSync(join(userData, 'settings-warnings.log'), 'utf8')
: ''
ok(proofLog.includes('.validation'), 'the drop is recorded in settings-warnings.log')
}
}
// ---- 2c. the pattern length bound -------------------------------------------
// The engine refuses a pattern over MAX_PATTERN_LEN anyway; refusing it at load
// keeps the paste out of the settings file and leaves a trace.
console.log('[pattern length]')
{
const over = (await roundTrip({ highlightRules: [rule({ pattern: 'a'.repeat(MAX_PATTERN_LEN + 1) })] })).highlightRules
ok(over.length === 0, `a pattern over ${MAX_PATTERN_LEN} characters is refused at load`)
const atLimit = (await roundTrip({ highlightRules: [rule({ pattern: 'a'.repeat(MAX_PATTERN_LEN) })] })).highlightRules
ok(atLimit.length === 1, `a pattern of exactly ${MAX_PATTERN_LEN} characters is kept`)
}
// ---- 3. value bands ---------------------------------------------------------
console.log('[value bands]')
{
+172
View File
@@ -0,0 +1,172 @@
/**
* Probe-worker build artifact smoke test (worker-artifact.mjs).
*
* The ReDoS gate is only real if the SHIPPED worker chunk is real. The pool's
* factory is the `?nodeWorker` import in `main/index.ts`, which electron-vite
* emits into `out/main` — and nothing else in the suite ever loads that file:
* `tests/redos-validator.mjs` spawns its own esbuild bundle of the same source,
* so a build that stopped emitting the worker (a renamed entry, a plugin that
* no longer fires, a broken chunk) would leave every unit test green while the
* packaged app reported every custom rule as "pending" forever.
*
* So this is a BUILD test, not a unit test: it runs against `out/main` and is
* wired into `npm run build` / `npm run dist` / `npm run dist:dir` (after the
* build, before electron-builder). It is deliberately NOT in `npm test`: CI runs
* `npm test` before `npm run build`, so a test that requires build output would
* fail a clean checkout.
*
* What it proves, in a real thread: the artifact loads, answers a benign pattern
* within budget with the corpus size and probe version the source declares, and
* reports a pattern that does not compile as `invalid-regex` instead of crashing.
*
* Run: node tests/worker-artifact.mjs (must exit 0; needs a completed build)
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import { PROBE_BUDGET_MS, PROBE_VERSION, REDOS_PROBES } from '../src/shared/redosProbes.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT_MAIN = join(__dirname, '..', 'out', 'main')
/** How long the artifact gets to answer one request before it counts as broken. */
const REPLY_TIMEOUT_MS = 10_000
/**
* Content fallback for the case where the emitted chunk no longer looks like the
* worker by name (a build-tool rename must not silently turn this test into a
* no-op). `parentPort` is the worker ENTRY marker — the corpus literal is not
* usable here, because electron-vite emits it as its own shared data chunk that
* is `require`d by both the worker and the main bundle.
*/
const WORKER_MARKERS = ['parentPort', 'worker_threads']
let failed = 0
let passed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
else passed += 1
}
/** Every `.js`/`.cjs`/`.mjs` file under a directory, recursively. */
function listScripts(dir) {
const out = []
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
const full = join(dir, entry.name)
if (entry.isDirectory()) out.push(...listScripts(full))
else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(full)
}
return out
}
/**
* The emitted worker chunk: by name when the build still names it after the
* source file, otherwise by the content markers a worker entry point must carry.
*/
function findWorkerArtifact(scripts) {
const byName = scripts.filter((file) => /redosProbeWorker/i.test(relative(OUT_MAIN, file)))
if (byName.length > 0) return { files: byName, how: 'file name' }
const byContent = scripts.filter((file) => {
try {
if (statSync(file).size >= 5_000_000) return false
const text = readFileSync(file, 'utf8')
return WORKER_MARKERS.every((marker) => text.includes(marker))
} catch {
return false
}
})
return { files: byContent, how: 'worker markers' }
}
/** One request/answer round trip against the artifact, in a real thread. */
function ask(artifact, request, timeoutMs = REPLY_TIMEOUT_MS) {
const worker = new Worker(artifact)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
void worker.terminate()
reject(new Error(`no reply within ${timeoutMs}ms`))
}, timeoutMs)
const done = (fn, value) => {
clearTimeout(timer)
fn(value)
}
worker.on('message', (message) => done(resolve, message))
worker.on('error', (err) => done(reject, err))
worker.on('exit', (code) => done(reject, new Error(`worker exited (code ${code}) before answering`)))
worker.postMessage(request)
}).finally(() => {
void worker.terminate()
})
}
console.log('[worker artifact]')
{
const scripts = listScripts(OUT_MAIN)
if (scripts.length === 0) {
console.error(`\n[worker-artifact] FAILED: no build output under ${OUT_MAIN} — run "npm run build" first`)
process.exit(1)
}
const { files, how } = findWorkerArtifact(scripts)
ok(files.length > 0, `the probe worker chunk is present in out/main (found by ${how})`)
if (files.length === 0) {
console.error(`\n[worker-artifact] FAILED: ${scripts.length} build script(s) scanned, none is the probe worker`)
process.exit(1)
}
console.log(` info: ${files.map((file) => relative(OUT_MAIN, file)).join(', ')}`)
const artifact = files[0]
// 1. a benign rule: the answer carries the corpus it actually ran.
let reply
try {
reply = await ask(artifact, { id: 7, pattern: '\\bOK\\b', flags: 'g' })
} catch (err) {
ok(false, `the artifact answers a benign probe (${err instanceof Error ? err.message : String(err)})`)
reply = null
}
if (reply !== null) {
ok(reply.id === 7, `the answer echoes the request id (${reply.id})`)
ok(reply.ok === true, `a benign pattern is reported as ok (${JSON.stringify(reply)})`)
ok(reply.probeVersion === PROBE_VERSION, `the answer carries the source probe version (${reply.probeVersion})`)
ok(
reply.probes === REDOS_PROBES.length,
`the shipped corpus is what ran (${reply.probes} of ${REDOS_PROBES.length} probes)`
)
ok(
typeof reply.worstMs === 'number' && Number.isFinite(reply.worstMs) && reply.worstMs >= 0,
`the slowest probe is a real measurement (${reply.worstMs}ms)`
)
ok(
typeof reply.worstMs === 'number' && reply.worstMs < PROBE_BUDGET_MS,
`a benign rule stays inside the budget (${reply.worstMs}ms < ${PROBE_BUDGET_MS}ms)`
)
}
// 2. a pattern that does not compile: a verdict, not a crash.
let invalid
try {
invalid = await ask(artifact, { id: 8, pattern: '(', flags: 'g' })
} catch (err) {
ok(false, `the artifact survives an uncompilable pattern (${err instanceof Error ? err.message : String(err)})`)
invalid = null
}
if (invalid !== null) {
ok(invalid.ok === false && invalid.reason === 'invalid-regex', `an uncompilable pattern is reported as such (${JSON.stringify(invalid)})`)
ok(invalid.id === 8, 'the failure answer echoes the request id too')
}
}
console.log(`\n[worker-artifact] ${passed} passed, ${failed} failed`)
if (failed > 0) {
console.error(`\n[worker-artifact] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[worker-artifact] ALL CHECKS PASSED')
+39
View File
@@ -98,6 +98,45 @@ console.log('an empty flush never reaches the sink')
b.dispose()
}
// `pendingChars` is the diagnostic view of the backlog the batcher is holding —
// the one number a stall report needs to tell "output is stuck here" apart from
// "xterm is behind". It is a JS string length (UTF-16 code units), deliberately
// NOT a byte count: the highlight path hands over decoded text, and one CJK
// ideograph is 1 here but 3 bytes on the wire.
console.log('pendingChars tracks the buffer as a string length')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
ok(b.pendingChars === 0, 'an empty batcher reports 0')
b.push('abc')
ok(b.pendingChars === 3, 'three ASCII chars count 3')
b.push('中文')
ok(b.pendingChars === 5, 'two CJK chars add 2 code units, not 6 bytes')
ok(b.hasPending() === (b.pendingChars > 0), 'hasPending agrees with pendingChars')
b.push('')
ok(b.pendingChars === 5, 'an empty push changes nothing')
await sleep(DELAY * 4)
ok(b.pendingChars === 0, 'pendingChars clears on flush')
ok(calls.length === 1, 'and the flush still happened once')
b.dispose()
}
console.log('pendingChars clears on flushNow and dispose')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('tail')
b.flushNow()
ok(b.pendingChars === 0, 'flushNow empties the count synchronously')
await sleep(DELAY * 4)
b.push('dropped')
ok(b.pendingChars === 7, 'the rebuffered count is visible')
b.dispose()
ok(b.pendingChars === 0, 'dispose clears the count')
await sleep(DELAY * 4)
ok(calls.length === 1, 'the disposed batcher never wrote the dropped text')
}
if (failed > 0) {
console.error(`\n[write-batcher] ${failed} check(s) FAILED`)
process.exit(1)