Round 2 per Sage's notes revs 521 and 523: a repeated Host is 400 before
any check; HEADER with a Host other than NAME is 403 proxy Host refused;
HEAD, x.NAME and --auth-header tests; the README proxy contract lines.
Candidate manifest 17b6d7f6, base 17ea0dbc. 34 of 34 mutants killed;
gate green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
83 lines
6.9 KiB
Python
83 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
# Dewey row 74 mutants: single-site edits in the mutant worktree (never the build tree),
|
|
# each run against the preview and serve tests, then restored from the index. Round 2 runs in
|
|
# mut2 (17ea0dbc plus the round 2 patch) and adds the repeated-Host, proxy-Host, HEAD and HEADER
|
|
# mutants after the round 1 list.
|
|
import os, subprocess, sys
|
|
|
|
R = os.path.expanduser('~/dewey-scratch/r74')
|
|
T = R + '/mut2'
|
|
OUT = R + '/out2'
|
|
SERVE = 'packages/webui/src/serve.mjs'
|
|
CLI = 'packages/webui/src/cli.mjs'
|
|
TESTS = ['node', '--test', 'packages/webui/tests/preview.test.mjs', 'packages/webui/tests/serve.test.mjs']
|
|
|
|
MUTANTS = [
|
|
('partial-allowed', SERVE, "if (given.length !== 3) throw", "if (given.length < 1) throw", TESTS),
|
|
('host-not-lowercased', SERVE, "const host = String(publicHost).toLowerCase(), header", "const host = String(publicHost), header", TESTS),
|
|
('header-not-lowercased', SERVE, "header = String(authHeader).toLowerCase();", "header = String(authHeader);", TESTS),
|
|
('ip-allowed', SERVE, "if (!HOSTNAME.test(host) || isIP(host)) throw", "if (!HOSTNAME.test(host)) throw", TESTS),
|
|
('host-header-allowed', SERVE, "['host', 'origin'].includes(header)", "['origin'].includes(header)", TESTS),
|
|
('user-comma-allowed', SERVE, "const USER = /^[\\x21-\\x2b\\x2d-\\x7e]{1,256}$/;", "const USER = /^[\\x21-\\x7e]{1,256}$/;", TESTS),
|
|
('empty-object-serves', SERVE, "if (preview !== null && !(preview = previewOptions(", "if (preview !== null && false && !(preview = previewOptions(", TESTS),
|
|
('named-case-sensitive', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '') === preview.host", TESTS),
|
|
('named-prefix', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().startsWith(preview.host)", TESTS),
|
|
('named-hostname-only', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().split(':')[0] === preview.host", TESTS),
|
|
('origin-http-allowed', SERVE, "(named ? `https://${preview.host}` : `http://${req.headers.host}`)", "(named ? `http://${preview.host}` : `http://${req.headers.host}`)", TESTS),
|
|
('origin-public-on-loopback', SERVE, "req.headers.origin !== (named ? `https://${preview.host}`", "req.headers.origin !== `https://${preview?.host}` && req.headers.origin !== (named ? `https://${preview.host}`", TESTS),
|
|
('header-check-skipped', SERVE, " if (named) {\n // One value", " if (false) {\n // One value", TESTS),
|
|
('two-values-allowed', SERVE, "values.length !== 1 || values[0] !== preview.user", "!values.includes(preview.user)", TESTS),
|
|
('value-prefix', SERVE, "values[0] !== preview.user", "!values[0].startsWith(preview.user)", TESTS),
|
|
('value-case-insensitive', SERVE, "values[0] !== preview.user", "values[0].toLowerCase() !== preview.user.toLowerCase()", TESTS),
|
|
('header-check-raw-name', SERVE, "const values = req.headersDistinct[preview.header];", "const values = req.headersDistinct[preview.header.toUpperCase()];", TESTS),
|
|
('readonly-named-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (named && req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
|
|
('readonly-post-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (preview !== null && req.method === 'POST') return", TESTS),
|
|
('readonly-always', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
|
|
('cli-preview-dropped', CLI, "bus, reads, preview: options.preview });", "bus, reads });", TESTS),
|
|
('cli-checked-late', CLI, " options.preview = previewOptions(preview);\n", "", TESTS),
|
|
# Round 2.
|
|
('repeated-host-allowed', SERVE, "(req.headersDistinct.host?.length ?? 0) > 1", "(req.headersDistinct.host?.length ?? 0) > 2", TESTS),
|
|
('repeated-host-preview-only', SERVE, "if ((req.headersDistinct.host?.length ?? 0) > 1)", "if (preview !== null && (req.headersDistinct.host?.length ?? 0) > 1)", TESTS),
|
|
('proxy-host-skipped', SERVE, "if (preview !== null && !named && req.headersDistinct[preview.header])", "if (false && preview !== null && !named && req.headersDistinct[preview.header])", TESTS),
|
|
('proxy-host-wrong-user-only', SERVE, "!named && req.headersDistinct[preview.header])", "!named && req.headersDistinct[preview.header]?.some(v => v !== preview.user))", TESTS),
|
|
('proxy-host-nonempty-only', SERVE, "!named && req.headersDistinct[preview.header])", "!named && req.headersDistinct[preview.header]?.[0])", TESTS),
|
|
('proxy-host-get-only', SERVE, "if (preview !== null && !named && req.headersDistinct", "if (preview !== null && !named && req.method === 'GET' && req.headersDistinct", TESTS),
|
|
('proxy-host-loopback-only', SERVE, "if (preview !== null && !named && req.headersDistinct", "if (preview !== null && !named && /^(127\\.|localhost|\\[)/.test(req.headers.host ?? '') && req.headersDistinct", TESTS),
|
|
('proxy-host-raw-name', SERVE, "!named && req.headersDistinct[preview.header])", "!named && req.headersDistinct[preview.header.toUpperCase()])", TESTS),
|
|
('header-check-skips-head', SERVE, " if (named) {\n // One value", " if (named && req.method !== 'HEAD') {\n // One value", TESTS),
|
|
('head-refused', SERVE, "req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only'", "req.method !== 'GET') return json(res, 403, { error: 'read-only'", TESTS),
|
|
('header-leading-dash', SERVE, "const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;", "const HEADER = /^[a-z0-9-]{1,64}$/;", TESTS),
|
|
('header-underscore', SERVE, "const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;", "const HEADER = /^[a-z0-9_][a-z0-9_-]{0,63}$/;", TESTS),
|
|
]
|
|
|
|
def run(cmd):
|
|
try:
|
|
p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, timeout=120, env={**os.environ, 'TMPDIR': R + '/tmp'})
|
|
except subprocess.TimeoutExpired as e:
|
|
return 'timeout', str(e.stdout or '')
|
|
return p.returncode, p.stdout + p.stderr
|
|
|
|
want = sys.argv[1:]
|
|
results = []
|
|
for name, f, old, new, tests in MUTANTS:
|
|
if want and name not in want:
|
|
continue
|
|
path = os.path.join(T, f)
|
|
src = open(path).read()
|
|
if src.count(old) != 1:
|
|
results.append(f'{name} BAD-SITE ({src.count(old)} matches)')
|
|
print(results[-1], flush=True)
|
|
continue
|
|
open(path, 'w').write(src.replace(old, new))
|
|
try:
|
|
rc, out = run(tests)
|
|
finally:
|
|
subprocess.run(['git', 'checkout', '--', f], cwd=T, check=True)
|
|
open(f'{OUT}/mut-{name}.txt', 'w').write(out)
|
|
results.append(f"{name} {'killed (timeout)' if rc == 'timeout' else 'killed' if rc else 'SURVIVED'}")
|
|
print(results[-1], flush=True)
|
|
rc, diff = run(['git', 'diff', '--stat'])
|
|
print('worktree diff after:', diff.strip() or 'clean')
|
|
killed = sum(1 for r in results if ' killed' in r)
|
|
print(f'summary {killed} of {len(results)} killed')
|