feat(highlight): 主进程 ReDoS 校验闸门与 renderer 性能时间线
This commit is contained in:
1 parent
68fa46b2e3
commit
fc0d1afdce
43 files changed
+5711
-179
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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' }]
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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]')
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user