419 lines
19 KiB
JavaScript
419 lines
19 KiB
JavaScript
/**
|
|
* 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')
|