/** * 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')