// Row 148 round 3 (sage-0621): a stack verdict for every regex in markdown-preview.js. // Each regex is taken from the module's own source text (the needle must occur in it), then run on // its worst shapes at 4 MiB, one process per (regex, shape), so one run's heap cannot help another. // A verdict is "returns" or "RangeError" (or "other: " / "timeout"). // Usage: node stack-audit.mjs --module F --out stack-audit.md [--kib 4096] [--timeout 60] // node stack-audit.mjs one --module F (internal) import { readFileSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const args = process.argv.slice(2); const opt = (name, dflt) => { const i = args.indexOf('--' + name); return i < 0 ? dflt : args[i + 1]; }; const MODULE = resolve(opt('module')); const src = readFileSync(MODULE, 'utf8'); const SEP = String.fromCharCode(0x2028); // [literal as written in the module, how it is used, shapes]. A shape is [name, n => string]. const R = (n, c) => c.repeat(n); const REGEXES = [ ['/[!-/:-@[-`{-~]/', 'test', [['letters', n => R(n, 'a')], ['punct', n => R(n, '!')]]], ['/^( {0,3})(`{3,}|~{3,})(.*)$/s', 'test', [['backticks', n => R(n, '`')], ['tildes', n => R(n, '~')], ['backticks + x', n => R(n, '`') + 'x'], ['backticks + separator', n => R(n, '`') + SEP], ['fence + blanks', n => '```' + R(n, ' ')]]], ['/^ {0,3}(#{1,6})(?:[ \\t]+(.*))?$/s', 'test', [['hashes', n => R(n, '#')], ['hash + blanks + x', n => '#' + R(n, ' ') + 'x'], ['hash + blanks', n => '#' + R(n, ' ')], ['hash + blanks + separator', n => '# ' + R(n, ' ') + SEP]]], ['/^ {0,3}(=+|-+)[ \\t]*$/', 'test', [['equals', n => R(n, '=')], ['dashes', n => R(n, '-')], ['equals + blanks + x', n => '=' + R(n, ' ') + 'x'], ['dash + tabs + x', n => '-' + R(n, '\t') + 'x']]], ['/^ {0,3}> ?/', 'test', [['quotes', n => R(n, '>')], ['quote + blanks', n => '>' + R(n, ' ')], ['blanks + quote', n => R(n, ' ') + '>']]], ['/^( {0,3})([-+*]|\\d{1,9}[.)])([ \\t]+|$)(.*)$/s', 'exec', [['dash + blanks', n => '-' + R(n, ' ')], ['dash + blanks + x', n => '-' + R(n, ' ') + 'x'], ['digits + dot', n => R(n, '1') + '.'], ['dash + tabs + separator', n => '-' + R(n, '\t') + SEP], ['dash + blanks + separator', n => '- ' + R(n, ' ') + SEP]]], ['/[\\u0000-\\u0020\\u007f-\\u009f\\\\\\u200b-\\u200f\\u2028-\\u202e\\u2066-\\u2069\\ufeff]/', 'test', [['letters', n => R(n, 'a')], ['letters + separator', n => R(n, 'a') + SEP]]], ['/^[A-Za-z][A-Za-z0-9+.-]*:/', 'test', [['letters', n => R(n, 'a')], ['letters + colon', n => R(n, 'a') + ':']]], ['/^https?:\\/\\/[^/?#]/i', 'test', [['h', n => R(n, 'h')], ['http + letters', n => 'http://' + R(n, 'a')]]], ['/[ \\t\\n]/', 'test', [['letters', n => R(n, 'a')]]], ['/[\\n<]/', 'test', [['letters', n => R(n, 'a')]]], ['/\\s/', 'test', [['letters', n => R(n, 'a')]]], ['/\\\\([!-/:-@[-`{-~])/g', 'replace', [['escaped punct', n => R(n / 2, '\\!')], ['backslashes', n => R(n, '\\')], ['escaped letters', n => R(n / 2, '\\a')]]], ['/[A-Za-z0-9]/', 'test', [['punct', n => R(n, '!')], ['letters', n => R(n, 'a')]]], ['/\\n/g', 'replace', [['newlines', n => R(n, '\n')], ['letters', n => R(n, 'a')]]], ['/^(`+|~+)[ \\t]*$/', 'exec', [['backticks', n => R(n, '`')], ['backticks + blank', n => R(n, '`') + ' '], ['backticks + x', n => R(n, '`') + 'x'], ['backtick + blanks + x', n => '`' + R(n, ' ') + 'x'], ['tildes + x', n => R(n, '~') + 'x']]], ['/\\r\\n?/g', 'replace', [['CR', n => R(n, '\r')], ['CRLF', n => R(n / 2, '\r\n')], ['letters', n => R(n, 'a')]]], ['/^[ \\t]+/', 'replace', [['blanks', n => R(n, ' ')], ['blanks + x', n => R(n, ' ') + 'x'], ['tabs', n => R(n, '\t')], ['letters', n => R(n, 'a')]]], ['/\\t/g', 'replace', [['tabs', n => R(n, '\t')], ['letters', n => R(n, 'a')]]], ]; // The regex round 3 removed, kept here as the control: the audit must report it. const REMOVED = ['/^ {0,3}([-*_])(?:[ \\t]*\\1){2,}[ \\t]*$/', 'test', [['dashes', n => R(n, '-')], ['dashes + blank', n => R(n, '-') + ' '], ['spaced dashes', n => R(n / 2, '- ')], ['spaced stars', n => R(n / 2, '* ')], ['underscores', n => R(n, '_')]]]; if (args[0] === 'one') { const [, ni, si, kib] = args; const entry = ni === 'removed' ? REMOVED : REGEXES[Number(ni)]; const re = new Function('return ' + entry[0])(); const text = entry[2][Number(si)][1](Number(kib) * 1024); const t0 = performance.now(); try { if (entry[1] === 'test') re.test(text); else if (entry[1] === 'exec') re.exec(text); else if (entry[1] === 'replace') text.replace(re, w => w); console.log(JSON.stringify({ verdict: 'returns', ms: +(performance.now() - t0).toFixed(1) })); } catch (e) { console.log(JSON.stringify({ verdict: e instanceof RangeError ? 'RangeError' : 'other: ' + e.message, ms: +(performance.now() - t0).toFixed(1) })); } process.exit(0); } const KIB = Number(opt('kib', '4096')), TIMEOUT = Number(opt('timeout', '60')), OUT = resolve(opt('out', 'stack-audit.md')); const SELF = fileURLToPath(import.meta.url); const lineOf = needle => { const out = []; src.split('\n').forEach((l, i) => { if (l.includes(needle)) out.push(i + 1); }); return out; }; const quantifiedGroup = lit => /\)[*+{]/.test(lit) || /\\[1-9]/.test(lit); const L = ['# Stack verdict for every regex in markdown-preview.js (sage-0621)', '', `Module ${MODULE}. Each regex is taken from the module's source text (it must occur there), then run alone on its worst shapes at ${KIB} KiB (${KIB * 1024} characters), one process per regex and shape, timeout ${TIMEOUT} s. A verdict is "returns" or "RangeError".`, 'Column "quantified group or backreference" is a text test on the literal: a `)` followed by `*`, `+` or `{`, or a `\\1`-style backreference. A `)?` is a group that matches at most once and is not counted.', '', '| line(s) | regex | quantified group or backreference | shape | verdict | ms |', '|---|---|---|---|---|---|']; let bad = 0, total = 0; const run = (ni, si) => { const r = spawnSync(process.execPath, [SELF, 'one', String(ni), String(si), String(KIB), '--module', MODULE], { encoding: 'utf8', timeout: TIMEOUT * 1000, killSignal: 'SIGKILL' }); if (r.error && r.error.code === 'ETIMEDOUT') return { verdict: 'timeout', ms: TIMEOUT * 1000 }; if (r.status !== 0) return { verdict: 'process failed: ' + (r.stderr || '').slice(0, 80), ms: 0 }; return JSON.parse(r.stdout); }; const missing = []; REGEXES.forEach((entry, ni) => { const lines = lineOf(entry[0]); if (!lines.length) missing.push(entry[0]); entry[2].forEach((shape, si) => { const v = run(ni, si); total++; if (v.verdict !== 'returns') bad++; L.push(`| ${lines.join(', ') || 'NOT FOUND'} | \`${entry[0].replace(/\|/g, '\\|')}\` | ${quantifiedGroup(entry[0]) ? 'yes' : 'no'} | ${shape[0]} | ${v.verdict} | ${v.ms} |`); }); }); L.push('', '## Control: the regex round 3 removed (it must not be in the module, and it must fail here)', '', '| line(s) in module | regex | quantified group or backreference | shape | verdict | ms |', '|---|---|---|---|---|---|'); const inModule = lineOf(REMOVED[0]); let controlFails = 0; REMOVED[2].forEach((shape, si) => { const v = run('removed', si); if (v.verdict === 'RangeError') controlFails++; L.push(`| ${inModule.join(', ') || 'none'} | \`${REMOVED[0]}\` | ${quantifiedGroup(REMOVED[0]) ? 'yes' : 'no'} | ${shape[0]} | ${v.verdict} | ${v.ms} |`); }); L.push('', `${total} regex and shape runs on the module's regexes: ${bad} not "returns". Regexes missing from the module's source: ${missing.length}${missing.length ? ' (' + missing.join(', ') + ')' : ''}. Regexes in the module with a quantified group or backreference: ${REGEXES.filter(e => quantifiedGroup(e[0])).length}. The removed regex is ${inModule.length ? 'STILL IN THE MODULE' : 'not in the module'} and fails ${controlFails} of ${REMOVED[2].length} control shapes.`); // The scan reads code only: whole-line comments and a trailing " // comment" are cut first. const code = src.split('\n').filter(l => !l.trim().startsWith('//') && !l.trim().startsWith('*')).map(l => l.replace(/\s\/\/\s.*$/, '')).join('\n'); const literals = [...code.matchAll(/(? m[0]); const known = new Set(REGEXES.map(e => e[0])); const unlisted = literals.filter(l => !known.has(l)); L.push('', `Regex literals found by a scan of the module source that are not in the table above: ${unlisted.length}${unlisted.length ? ' - ' + unlisted.map(x => '`' + x + '`').join(', ') : ''}.`); writeFileSync(OUT, L.join('\n') + '\n'); console.log(`${total} runs, ${bad} not returns, missing ${missing.length}, unlisted ${unlisted.length}, control fails ${controlFails}/${REMOVED[2].length}`); process.exit(bad || missing.length || unlisted.length || inModule.length ? 1 : 0);