From e7a31c38661dbbdac827a845bd564e1e853559f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 6 Oct 2026 20:46:31 -0700 Subject: [PATCH] feat(fuzz): add structural reduction Character deletion can leave unrelated language constructs in saved findings. Use DOM subtree and attribute deletion, parsed CSS declarations and a pinned UglifyJS AST fork before character reduction. Keep the original finding predicate and share the 600-comparison limit across structural reduction and separate JSON fields. Refs #1019 --- docs/changelog/1019.feature.rst | 1 + pyproject.toml | 1 + tests/test_fuzz_reduce.py | 136 ++++++++ tests/test_fuzz_reduce_cli.py | 107 +++++++ tools/bench/node/package-lock.json | 15 +- tools/bench/node/package.json | 3 +- tools/fuzz/LICENSE-UGLIFYJS | 29 ++ tools/fuzz/js_reduce.js | 498 +++++++++++++++++++++++++++++ tools/fuzz/js_reduce_runner.js | 22 ++ tools/fuzz/reduce.py | 119 +++++++ tools/fuzz/round_trip_oracles.py | 90 ++++-- 11 files changed, 1001 insertions(+), 20 deletions(-) create mode 100644 docs/changelog/1019.feature.rst create mode 100644 tests/test_fuzz_reduce.py create mode 100644 tests/test_fuzz_reduce_cli.py create mode 100644 tools/fuzz/LICENSE-UGLIFYJS create mode 100644 tools/fuzz/js_reduce.js create mode 100644 tools/fuzz/js_reduce_runner.js create mode 100644 tools/fuzz/reduce.py diff --git a/docs/changelog/1019.feature.rst b/docs/changelog/1019.feature.rst new file mode 100644 index 000000000..0ff06eb13 --- /dev/null +++ b/docs/changelog/1019.feature.rst @@ -0,0 +1 @@ +Reduce saved HTML, CSS and JavaScript findings with language-aware deletion. diff --git a/pyproject.toml b/pyproject.toml index 3bab168f4..1b14dbd6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ test = [ "pytest-mock>=3.16", "pytest-run-parallel>=0.10", # runs each test in many threads to surface free-threaded data races "tenacity>=9.1.4", # the retry policy wrapping every tools/ HTTP fetch (httpfetch.py) + "tinycss2>=1.5.1", # preserve nested CSS contexts during structural reduction "typing-extensions>=4.16", ] type = [ diff --git a/tests/test_fuzz_reduce.py b/tests/test_fuzz_reduce.py new file mode 100644 index 000000000..c5e274529 --- /dev/null +++ b/tests/test_fuzz_reduce.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import io +import json +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import pytest +from fuzz.reduce import minimize + +from turbohtml import parse_fragment + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_reduce_html_removes_subtrees_and_attributes() -> None: + source = '

x

' + + def reproduces(text: str) -> bool: + root = parse_fragment(text) + return any(node.attrs.get("id") == "keep" and node.text == "x" for node in root.iter_elements()) + + assert minimize(source, reproduces, "html") == '

x

' + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param("p{color:red;margin:1px}", "p{color:red;}", id="style-rule"), + pytest.param("@media screen{p{color:red;margin:1px}}", "@media screen{p{color:red;}}", id="media"), + pytest.param("@font-face{color:red;src:url(x)}", "@font-face{color:red;}", id="at-rule-declarations"), + pytest.param("color:red;margin:1px", "color:red;", id="inline"), + pytest.param('p{color:red;content:"a;b"}', "p{color:red;}", id="string-semicolon"), + pytest.param("@import 'x';p{color:red;margin:1px}", '@import "x";p{color:red;}', id="keep-other-rule"), + pytest.param("p{broken;}", "p{broken;}", id="invalid-declaration"), + pytest.param("", "", id="empty"), + ], +) +def test_reduce_css_removes_declarations(source: str, expected: str) -> None: + assert minimize(source, lambda candidate: "color:red" in candidate, "css") == expected + + +@pytest.mark.parametrize("syntax", ["html", "css", "js"]) +def test_reduce_zero_budget_does_not_compare(syntax: Literal["html", "css", "js"]) -> None: + assert minimize("

x

", lambda _text: pytest.fail("zero budget compared"), syntax, budget=0) == "

x

" + + +def test_reduce_budget_stops_predicate_calls() -> None: + compared: list[str] = [] + assert minimize( + "p{color:red;margin:1px;padding:2px}", lambda candidate: bool(compared.append(candidate)), "css", 1 + ) == ("p{color:red;margin:1px;padding:2px}") + assert len(compared) == 1 + + +def test_reduce_keeps_original_finding_class() -> None: + source = "p{color:red;margin:1px}" + + def finding(text: str) -> str | None: + return "original" if "color:red" in text and "margin:1px" in text else "different" + + assert minimize(source, lambda candidate: finding(candidate) == "original", "css") == source + + +def test_reduce_js_protocol_checks_candidates(mocker: MockerFixture) -> None: + mocker.patch("fuzz.reduce.shutil.which", return_value="node") + mocker.patch("fuzz.reduce.Path.is_dir", return_value=True) + replies = io.StringIO( + json.dumps({"kind": "candidate", "text": "keep();"}) + + "\n" + + json.dumps({"kind": "done", "text": "keep();"}) + + "\n" + ) + answers = io.StringIO() + mocker.patch("fuzz.reduce.subprocess.Popen", autospec=True).return_value.__enter__.return_value = mocker.MagicMock( + stdin=answers, stdout=replies + ) + assert minimize("discard();keep();", lambda source: source == "keep();", "js") == "keep();" + assert answers.getvalue() == '{"text": "discard();keep();", "budget": 600}\ntrue\n' + + +def test_reduce_js_reports_premature_exit(mocker: MockerFixture) -> None: + mocker.patch("fuzz.reduce.shutil.which", return_value="node") + mocker.patch("fuzz.reduce.Path.is_dir", return_value=True) + mocker.patch("fuzz.reduce.subprocess.Popen", autospec=True).return_value.__enter__.return_value = mocker.MagicMock( + stdin=io.StringIO(), stdout=io.StringIO(), returncode=3 + ) + with pytest.raises(RuntimeError, match="exited without a result: 3"): + minimize("keep();", lambda _source: True, "js") + + +@pytest.mark.parametrize("missing", [pytest.param("node", id="node"), pytest.param("package", id="package")]) +def test_reduce_js_reports_missing_backend(mocker: MockerFixture, missing: str) -> None: + mocker.patch("fuzz.reduce.shutil.which", return_value=None if missing == "node" else "node") + mocker.patch("fuzz.reduce.Path.is_dir", return_value=False) + with pytest.raises(FileNotFoundError, match="required for JS reduction"): + minimize("keep();", lambda _source: True, "js") + + +@pytest.mark.oracle +@pytest.mark.skipif( + shutil.which("node") is None + or not (Path(__file__).parents[1] / "tools/bench/node/node_modules/uglify-js").is_dir(), + reason="node/npm backend absent", +) +@pytest.mark.parametrize( + "source", + [ + pytest.param("discard();keep();", id="statements"), + pytest.param("function unused(){return 42}keep();", id="function"), + pytest.param("if (noise) {discard()} keep();", id="conditional"), + pytest.param("for(let i=0;i<3;i++)discard(i);keep();", id="loop"), + pytest.param("const unused={name:'text'};keep();", id="object"), + pytest.param("const unused=[1,2,3];keep();", id="array"), + pytest.param("try{discard()}catch(e){discard(e)}finally{discard()}keep();", id="try"), + pytest.param("class Unused{method(){return 4}}keep();", id="class"), + ], +) +def test_reduce_js_uses_pinned_ast_fork(source: str) -> None: + assert minimize(source, lambda candidate: "keep()" in candidate, "js", 100) == "keep();" + + +@pytest.mark.oracle +@pytest.mark.skipif( + shutil.which("node") is None + or not (Path(__file__).parents[1] / "tools/bench/node/node_modules/uglify-js").is_dir(), + reason="node/npm backend absent", +) +def test_reduce_js_keeps_invalid_source() -> None: + assert minimize("function {", lambda _candidate: True, "js") == "function {" + + +def test_reduce_acceptance_consumes_last_budget_call() -> None: + assert minimize("p{color:red;margin:1px}", lambda _candidate: True, "css", 1) == "p{margin:1px;}" diff --git a/tests/test_fuzz_reduce_cli.py b/tests/test_fuzz_reduce_cli.py new file mode 100644 index 000000000..048b1eecc --- /dev/null +++ b/tests/test_fuzz_reduce_cli.py @@ -0,0 +1,107 @@ +"""Reduced finding artifacts retain the original public oracle result.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Literal + +import pytest +from fuzz.round_trip_oracles import ORACLES, Floor, Oracle, main +from tinycss2 import parse_stylesheet, serialize + +from turbohtml import parse_fragment + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +@pytest.mark.parametrize( + ("source", "syntax", "expected"), + [ + pytest.param("

x

", "html", "

x

", id="html-subtree"), + pytest.param("p{color:red;margin:1px;}", "css", "p{margin:1px;}", id="css-declaration"), + ], +) +def test_reduce_cli_saves_structural_finding( + mocker: MockerFixture, tmp_path: Path, source: str, syntax: Literal["html", "css"], expected: str +) -> None: + def check(text: str) -> str | None: + if syntax == "html": + return "retained" if parse_fragment(text).serialize(inner=True) == text and "x" in text else None + return ( + "retained" + if text.startswith("p{") and serialize(parse_stylesheet(text)) == text and "margin:1px;" in text + else None + ) + + mocker.patch.dict( + ORACLES, + { + "probe": Oracle( + check, lambda _rng: source, lambda: [source], lambda: {"control": True}, Floor(1, 1), syntax=syntax + ) + }, + clear=True, + ) + code = main(["--minutes", "0", "--crash-dir", str(tmp_path)]) + artifacts = [path.read_text() for path in tmp_path.glob("crash-*") if path.suffix != ".json"] + assert (code, artifacts) == (1, [expected]) + + +def test_reduce_cli_preserves_json_fields(mocker: MockerFixture, tmp_path: Path) -> None: + source = json.dumps({"html": "

x

", "css": "p{color:red;margin:1px;}"}) + + def check(text: str) -> str | None: + payload = json.loads(text) + html, css = payload["html"], payload["css"] + return ( + "retained" + if ( + parse_fragment(html).serialize(inner=True) == html + and "x" in html + and css.startswith("p{") + and serialize(parse_stylesheet(css)) == css + and "margin:1px;" in css + ) + else None + ) + + mocker.patch.dict( + ORACLES, + { + "probe": Oracle( + check, + lambda _rng: source, + lambda: [source], + lambda: {"control": True}, + Floor(1, 1), + fields=("html", "css"), + syntax="css", + ) + }, + clear=True, + ) + code = main(["--minutes", "0", "--crash-dir", str(tmp_path)]) + artifacts = [json.loads(path.read_text()) for path in tmp_path.glob("crash-*") if path.suffix != ".json"] + assert (code, artifacts) == (1, [{"html": "

x

", "css": "p{margin:1px;}"}]) + + +def test_reduce_cli_caps_comparison_calls(mocker: MockerFixture, tmp_path: Path) -> None: + source = "z" * 2000 + calls = 0 + + def check(text: str) -> str | None: + nonlocal calls + calls += 1 + return "retained" if text == source else None + + mocker.patch.dict( + ORACLES, + {"probe": Oracle(check, lambda _rng: source, lambda: [source], lambda: {"control": True}, Floor(1, 1))}, + clear=True, + ) + code = main(["--minutes", "0", "--crash-dir", str(tmp_path)]) + artifacts = [path.read_text() for path in tmp_path.glob("crash-*") if path.suffix != ".json"] + assert (code, artifacts, calls) == (1, [source], 601) diff --git a/tools/bench/node/package-lock.json b/tools/bench/node/package-lock.json index c6c049fdb..a5e3bda6a 100644 --- a/tools/bench/node/package-lock.json +++ b/tools/bench/node/package-lock.json @@ -14,7 +14,8 @@ "isomorphic-dompurify": "^3.18.0", "jsdom": "^29.0.0", "parse5": "^8.0.1", - "sanitize-html": "^2.17.0" + "sanitize-html": "^2.17.0", + "uglify-js": "3.19.3" } }, "node_modules/@asamuzakjp/css-color": { @@ -754,6 +755,18 @@ "node": ">=20" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici": { "version": "7.30.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", diff --git a/tools/bench/node/package.json b/tools/bench/node/package.json index ec6282575..9b4aec529 100644 --- a/tools/bench/node/package.json +++ b/tools/bench/node/package.json @@ -16,6 +16,7 @@ "isomorphic-dompurify": "^3.18.0", "jsdom": "^29.0.0", "parse5": "^8.0.1", - "sanitize-html": "^2.17.0" + "sanitize-html": "^2.17.0", + "uglify-js": "3.19.3" } } diff --git a/tools/fuzz/LICENSE-UGLIFYJS b/tools/fuzz/LICENSE-UGLIFYJS new file mode 100644 index 000000000..6a5370e86 --- /dev/null +++ b/tools/fuzz/LICENSE-UGLIFYJS @@ -0,0 +1,29 @@ +UglifyJS is released under the BSD license: + +Copyright 2012-2024 (c) Mihai Bazon + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above + copyright notice, this list of conditions and the following + disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER “AS IS” AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/tools/fuzz/js_reduce.js b/tools/fuzz/js_reduce.js new file mode 100644 index 000000000..91a098f77 --- /dev/null +++ b/tools/fuzz/js_reduce.js @@ -0,0 +1,498 @@ +// Fork of UglifyJS 111746bbae5f55c88e3b82b42f14fd0f3129ea53; see LICENSE-UGLIFYJS. +// Use the parent's finding predicate because reduction must not execute JavaScript. +const UGLIFY = require("../bench/node/node_modules/uglify-js"); + +const LIST = UGLIFY.List; + +module.exports = function reduce_test(testcase, reproduces, budget) { + const REPLACEMENTS = [ "1", "0" ]; + const steps = 4; + const step = 1 / steps; + let changed = false; + var transformer = new UGLIFY.TreeTransformer(function(node, descend, in_list) { + if (changed) return; + if (node instanceof UGLIFY.AST_Accessor) return; + if (node instanceof UGLIFY.AST_Directive) return; + if (!in_list && node instanceof UGLIFY.AST_EmptyStatement) return; + if (node instanceof UGLIFY.AST_Label) return; + if (node instanceof UGLIFY.AST_LabelRef) return; + if (node instanceof UGLIFY.AST_Toplevel) return; + var parent = transformer.parent(); + if (node instanceof UGLIFY.AST_SymbolFunarg && parent instanceof UGLIFY.AST_Accessor) return; + if (!in_list && parent.rest !== node && node instanceof UGLIFY.AST_SymbolDeclaration) return; + if (typeof node.start._permute === "undefined") node.start._permute = 0; + if (node.start._permute >= REPLACEMENTS.length) return; + if (parent instanceof UGLIFY.AST_Assign && parent.left === node) return; + if (parent instanceof UGLIFY.AST_DefaultValue && parent.name === node) return; + if (parent instanceof UGLIFY.AST_DestructuredKeyVal && parent.value === node) return; + if (parent instanceof UGLIFY.AST_Unary && parent.expression === node) switch (parent.operator) { + case "++": + case "--": + case "delete": + return; + } + if (parent instanceof UGLIFY.AST_VarDef && parent.name === node) return; + if (parent instanceof UGLIFY.AST_ClassMethod && parent.value === node) return; + if (parent instanceof UGLIFY.AST_ExportDeclaration) return; + if (parent instanceof UGLIFY.AST_ExportDefault) return; + if (parent instanceof UGLIFY.AST_ExportForeign) return; + if (parent instanceof UGLIFY.AST_ExportReferences) return; + if (node instanceof UGLIFY.AST_VarDef && parent.definitions.length == 1 + && transformer.parent(1) instanceof UGLIFY.AST_ExportDeclaration) { + return; + } + if (parent instanceof UGLIFY.AST_For && parent.init === node && node instanceof UGLIFY.AST_Definitions) return node; + if (parent instanceof UGLIFY.AST_ForEnumeration && parent.init === node) return node; + if (node.TYPE == "Call" && node.expression instanceof UGLIFY.AST_Super) return; + if (node instanceof UGLIFY.AST_Super && parent.TYPE == "Call" && parent.expression === node) return node; + if (node instanceof UGLIFY.AST_Array) { + var expr = node.elements[0]; + if (expr && !(expr instanceof UGLIFY.AST_Hole)) { + node.start._permute++; + changed = true; + return expr instanceof UGLIFY.AST_Spread ? expr.expression : expr; + } + } else if (node instanceof UGLIFY.AST_Await) { + node.start._permute++; + changed = true; + return node.expression; + } else if (node instanceof UGLIFY.AST_Binary) { + var permute = ((node.start._permute += step) * steps | 0) % 4; + var expr = [ node.left, node.right ][permute & 1]; + if (expr instanceof UGLIFY.AST_Destructured) expr = expr.transform( + new UGLIFY.TreeTransformer(function(node, descend) { + if (node instanceof UGLIFY.AST_DefaultValue) return new UGLIFY.AST_Assign({ + operator: "=", + left: node.name.transform(this), + right: node.value, + start: {} + }); + if (node instanceof UGLIFY.AST_DestructuredKeyVal) return new UGLIFY.AST_ObjectKeyVal(node); + if (node instanceof UGLIFY.AST_Destructured) { + node = new (node instanceof UGLIFY.AST_DestructuredArray ? UGLIFY.AST_Array : UGLIFY.AST_Object)(node); + descend(node, this); + } + return node; + })); + changed = true; + return permute < 2 ? expr : wrap_with_console_log(expr); + } else if (node instanceof UGLIFY.AST_BlockStatement) { + if (in_list && node.body.filter(function(node) { + return node instanceof UGLIFY.AST_Const || node instanceof UGLIFY.AST_Let; + }).length == 0) { + node.start._permute++; + changed = true; + return LIST.splice(node.body); + } + } else if (node instanceof UGLIFY.AST_Call) { + var expr = [ + !(node.expression instanceof UGLIFY.AST_Super) && node.expression, node.args[0], null + ][((node.start._permute += step) * steps | 0) % 3]; + if (expr) { + changed = true; + return expr instanceof UGLIFY.AST_Spread ? expr.expression : expr; + } + if (node.expression instanceof UGLIFY.AST_Arrow && node.expression.value) { + var seq = node.args.slice(); + seq.push(node.expression.value); + changed = true; + return to_sequence(seq); + } + if (node.expression instanceof UGLIFY.AST_Function) { + var scope = transformer.find_parent(UGLIFY.AST_Scope), seq = []; + node.expression.body.forEach(function(node) { + var expr = node instanceof UGLIFY.AST_Exit ? node.value : node.body; + if (expr instanceof UGLIFY.AST_Node && !UGLIFY.is_statement(expr) && can_hoist(expr, scope)) { + seq.push(expr); + } + }); + changed = true; + return to_sequence(seq); + } + } else if (node instanceof UGLIFY.AST_Catch) { + node.start._permute++; + changed = true; + return null; + } else if (node instanceof UGLIFY.AST_Conditional) { + changed = true; + return [ node.condition, node.consequent, node.alternative ][((node.start._permute += step) * steps | 0) % 3]; + } else if (node instanceof UGLIFY.AST_DefaultValue) { + node.start._permute++; + changed = true; + return node.name; + } else if (node instanceof UGLIFY.AST_Defun) { + switch (((node.start._permute += step) * steps | 0) % 2) { + case 0: + changed = true; + return LIST.skip; + + default: + if (can_hoist(node, transformer.find_parent(UGLIFY.AST_Scope))) { + var body = node.body; + node.body = []; + body.push(node); + changed = true; + return LIST.splice(body); + } + } + } else if (node instanceof UGLIFY.AST_DestructuredArray) { + var expr = node.elements[0]; + if (expr && !(expr instanceof UGLIFY.AST_Hole)) { + node.start._permute++; + changed = true; + return expr; + } + } else if (node instanceof UGLIFY.AST_DestructuredObject) { + var expr = node.properties[0]; + if (expr) { + node.start._permute++; + changed = true; + return expr.value; + } + } else if (node instanceof UGLIFY.AST_DWLoop) { + var expr = [ node.condition, node.body, null ][(node.start._permute * steps | 0) % 3]; + node.start._permute += step; + if (!expr) { + if (node.body[0] instanceof UGLIFY.AST_Break) { + if (node instanceof UGLIFY.AST_Do) { + changed = true; + return LIST.skip; + } + expr = node.condition; + } + } + if (expr && (expr !== node.body || !has_loopcontrol(expr, node, parent))) { + changed = true; + return to_statement(expr); + } + } else if (node instanceof UGLIFY.AST_ExportDeclaration) { + node.start._permute++; + changed = true; + return node.body; + } else if (node instanceof UGLIFY.AST_ExportDefault) { + node.start._permute++; + changed = true; + return to_statement(node.body); + } else if (node instanceof UGLIFY.AST_Finally) { + node.start._permute++; + changed = true; + return null; + } else if (node instanceof UGLIFY.AST_For) { + var expr = [ node.init, node.condition, node.step, node.body ][(node.start._permute * steps | 0) % 4]; + node.start._permute += step; + if (expr && (expr !== node.body || !has_loopcontrol(expr, node, parent))) { + changed = true; + return to_statement_init(expr); + } + } else if (node instanceof UGLIFY.AST_ForEnumeration) { + var expr; + switch ((node.start._permute * steps | 0) % 4) { + case 0: + expr = node.object; + break; + + case 1: + expr = wrap_with_console_log(node.object); + break; + + case 2: + if (has_loopcontrol(node.body, node, parent)) break; + expr = node.body; + break; + + case 3: + if (!(node.init instanceof UGLIFY.AST_Var)) break; + if (node.init.definitions[0].name instanceof UGLIFY.AST_Destructured) break; + expr = node.init; + break; + } + node.start._permute += step; + if (expr) { + changed = true; + return to_statement_init(expr); + } + } else if (node instanceof UGLIFY.AST_If) { + var expr = [ node.condition, node.body, node.alternative, node ][(node.start._permute * steps | 0) % 4]; + node.start._permute += step; + if (expr === node) { + if (node.alternative) { + expr = node.clone(); + expr.alternative = null; + changed = true; + return expr; + } + } else if (expr) { + changed = true; + return to_statement(expr); + } + } else if (node instanceof UGLIFY.AST_LabeledStatement) { + if (node.body instanceof UGLIFY.AST_Statement && !has_loopcontrol(node.body, node.body, node)) { + node.start._permute = REPLACEMENTS.length; + changed = true; + return node.body; + } + } else if (node instanceof UGLIFY.AST_Object) { + var expr = node.properties[0]; + if (expr instanceof UGLIFY.AST_ObjectKeyVal) { + expr = expr.value; + } else if (expr instanceof UGLIFY.AST_Spread) { + expr = expr.expression; + } else if (expr && expr.key instanceof UGLIFY.AST_Node) { + expr = expr.key; + } else { + expr = null; + } + if (expr) { + node.start._permute++; + changed = true; + return expr; + } + } else if (node instanceof UGLIFY.AST_PropAccess) { + var expr = [ + !(node.expression instanceof UGLIFY.AST_Super) && node.expression, + node.property instanceof UGLIFY.AST_Node && !(parent instanceof UGLIFY.AST_Destructured) && node.property + ][node.start._permute++ % 2]; + if (expr) { + changed = true; + return expr; + } + } else if (node instanceof UGLIFY.AST_SimpleStatement) { + if (node.body instanceof UGLIFY.AST_Call && node.body.expression instanceof UGLIFY.AST_Function) { + node.start._permute++; + if (can_hoist(node.body.expression, transformer.find_parent(UGLIFY.AST_Scope))) { + changed = true; + return LIST.splice(node.body.expression.body); + } + } + } else if (node instanceof UGLIFY.AST_Switch) { + var expr = [ + node.expression, node.body[0] && node.body[0].expression, node.body[0] + ][(node.start._permute * steps | 0) % 4]; + node.start._permute += step; + if (expr && (!(expr instanceof UGLIFY.AST_Statement) || !has_loopcontrol(expr, node, parent))) { + changed = true; + return expr instanceof UGLIFY.AST_SwitchBranch ? new UGLIFY.AST_BlockStatement({ + body: expr.body.slice(), + start: {} + }) : to_statement(expr); + } + } else if (node instanceof UGLIFY.AST_Try) { + var body = [ + node.body, node.bcatch && node.bcatch.body, node.bfinally && node.bfinally.body, null + ][(node.start._permute * steps | 0) % 4]; + node.start._permute += step; + if (body) { + changed = true; + return new UGLIFY.AST_BlockStatement({ + body: body, + start: {} + }); + } else { + if (node.body[0] instanceof UGLIFY.AST_Break || node.body[0] instanceof UGLIFY.AST_Return) { + changed = true; + return node.body[0]; + } + } + } else if (node instanceof UGLIFY.AST_Unary) { + node.start._permute++; + changed = true; + return node.expression; + } else if (node instanceof UGLIFY.AST_Var) { + if (node.definitions.length == 1 && node.definitions[0].value) { + node.start._permute++; + changed = true; + return to_statement(node.definitions[0].value); + } + } else if (node instanceof UGLIFY.AST_VarDef) { + if (node.value && !(node.name instanceof UGLIFY.AST_Destructured || parent instanceof UGLIFY.AST_Const)) { + node.start._permute++; + changed = true; + return new UGLIFY.AST_VarDef({ + name: node.name, + start: {} + }); + } + } + if (in_list) { + if (parent instanceof UGLIFY.AST_Switch && parent.expression != node) { + node.start._permute++; + changed = true; + return LIST.skip; + } + if (node instanceof UGLIFY.AST_Statement) { + node.start._permute++; + changed = true; + return LIST.skip; + } + if (!(parent instanceof UGLIFY.AST_Sequence) || parent.expressions.length > 1) { + node.start._permute++; + changed = true; + return LIST.skip; + } + } else if (parent.rest === node) { + node.start._permute++; + changed = true; + return null; + } + var newNode = UGLIFY.is_statement(node) ? new UGLIFY.AST_EmptyStatement({ + start: {} + }) : UGLIFY.parse(REPLACEMENTS[node.start._permute % REPLACEMENTS.length | 0], { + expression: true + }); + newNode.start._permute = ++node.start._permute; + changed = true; + return newNode; + }, function(node, in_list) { + if (node instanceof UGLIFY.AST_Definitions) { + if (node.definitions.length == 0) return in_list ? LIST.skip : new UGLIFY.AST_EmptyStatement({ + start: {} + }); + } else if (node instanceof UGLIFY.AST_ObjectMethod) { + if (!/Function$/.test(node.value.TYPE)) return new UGLIFY.AST_ObjectKeyVal({ + key: node.key, + value: node.value, + start: {} + }); + } else if (node instanceof UGLIFY.AST_Sequence) { + if (node.expressions.length == 1) return node.expressions[0]; + } else if (node instanceof UGLIFY.AST_Try) { + if (!node.bcatch && !node.bfinally) return new UGLIFY.AST_BlockStatement({ + body: node.body, + start: {} + }); + } + }); + let tree; + try { + tree = UGLIFY.parse(testcase, { + module: true + }); + } catch (error) { + if (error.name === "SyntaxError") return testcase; + throw error; + } + for (let pass = 0; pass < 3; pass++) { + tree.walk(new UGLIFY.TreeWalker(function(node) { + node.start = { + ...node.start, + _permute: 0 + }; + })); + let accepted = false; + for (let iteration = 0; iteration < budget; iteration++) { + changed = false; + const candidate = tree.clone(true).transform(transformer); + if (!changed) break; + let code; + try { + code = candidate.print_to_string(); + UGLIFY.parse(code, { + module: true + }); + } catch (error) { + if (error.name === "SyntaxError" || error instanceof TypeError) continue; + throw error; + } + if (code.length < testcase.length && reproduces(code)) { + testcase = code; + tree = candidate; + accepted = true; + } + } + if (!accepted) break; + } + return testcase; +}; + +function has_loopcontrol(body, loop, label) { + var found = false; + var walker = new UGLIFY.TreeWalker(function(node) { + if (found) return true; + if (node instanceof UGLIFY.AST_LoopControl && this.loopcontrol_target(node) === loop) { + return found = true; + } + }); + if (label instanceof UGLIFY.AST_LabeledStatement) walker.push(label); + walker.push(loop); + body.walk(walker); + return found; +} + +function can_hoist(body, scope) { + var found = false; + var walker = new UGLIFY.TreeWalker(function(node) { + if (found) return true; + if (node instanceof UGLIFY.AST_Exit) return found = true; + if (node instanceof UGLIFY.AST_NewTarget) return found = true; + if (node instanceof UGLIFY.AST_Scope) { + if (node === body) return; + if (node instanceof UGLIFY.AST_Arrow || node instanceof UGLIFY.AST_AsyncArrow) + node.argnames.forEach(function(sym) { + sym.walk(walker); + }); + return true; + } + if (node instanceof UGLIFY.AST_Super) return found = true; + if (node instanceof UGLIFY.AST_SymbolDeclaration || node instanceof UGLIFY.AST_SymbolRef) switch (node.name) { + case "await": + if (/^Async/.test(scope.TYPE)) return found = true; + return; + + case "yield": + if (/Generator/.test(scope.TYPE)) return found = true; + return; + } + }); + body.walk(walker); + return !found; +} + +function merge_sequence(array, node) { + if (node instanceof UGLIFY.AST_Sequence) { + array.push.apply(array, node.expressions); + } else { + array.push(node); + } + return array; +} + +function to_sequence(expressions) { + if (expressions.length == 0) return new UGLIFY.AST_Number({ + value: 0, + start: {} + }); + if (expressions.length == 1) return expressions[0]; + return new UGLIFY.AST_Sequence({ + expressions: expressions.reduce(merge_sequence, []), + start: {} + }); +} + +function to_statement(node) { + return UGLIFY.is_statement(node) ? node : new UGLIFY.AST_SimpleStatement({ + body: node, + start: {} + }); +} + +function to_statement_init(node) { + return node instanceof UGLIFY.AST_Const || node instanceof UGLIFY.AST_Let ? new UGLIFY.AST_BlockStatement({ + body: [ node ], + start: {} + }) : to_statement(node); +} + +function wrap_with_console_log(node) { + return new UGLIFY.AST_Call({ + expression: new UGLIFY.AST_Dot({ + expression: new UGLIFY.AST_SymbolRef({ + name: "console", + start: {} + }), + property: "log", + start: {} + }), + args: [ node ], + start: {} + }); +} diff --git a/tools/fuzz/js_reduce_runner.js b/tools/fuzz/js_reduce_runner.js new file mode 100644 index 000000000..4f5ac8257 --- /dev/null +++ b/tools/fuzz/js_reduce_runner.js @@ -0,0 +1,22 @@ +const fs = require("node:fs"); +const reduce = require("./js_reduce"); + +function readLine() { + const bytes = []; + const byte = Buffer.alloc(1); + while (fs.readSync(0, byte, 0, 1, null)) { + if (byte[0] === 10) return Buffer.from(bytes).toString("utf8"); + bytes.push(byte[0]); + } + throw new Error("reducer parent closed its input"); +} + +function send(kind, text) { + process.stdout.write(JSON.stringify({kind, text}) + "\n"); +} + +const {text, budget} = JSON.parse(readLine()); +send("done", reduce(text, candidate => { + send("candidate", candidate); + return JSON.parse(readLine()); +}, budget)); diff --git a/tools/fuzz/reduce.py b/tools/fuzz/reduce.py new file mode 100644 index 000000000..a17df86e7 --- /dev/null +++ b/tools/fuzz/reduce.py @@ -0,0 +1,119 @@ +"""Structural deletion keeps language constructs intact before character reduction.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Final, Literal, TypedDict, cast + +from tinycss2 import parse_blocks_contents, parse_component_value_list, parse_stylesheet, serialize +from tinycss2.ast import AtRule, Declaration, ParseError, QualifiedRule + +from turbohtml import Element, parse, parse_fragment + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from typing import TextIO + + from tinycss2.ast import Node + +_NODE_DIR: Final = Path(__file__).resolve().parents[1] / "bench" / "node" + + +def minimize( + text: str, reproduces: Callable[[str], bool], syntax: Literal["html", "css", "js"], budget: int = 600 +) -> str: + """Accept shorter candidates only when the original finding predicate holds.""" + if budget <= 0: + return text + calls = 0 + + def check(candidate: str) -> bool: + nonlocal calls + if calls >= budget: + return False + calls += 1 + return reproduces(candidate) + + if syntax == "js": + return _js(text, check, budget) + candidates = _html if syntax == "html" else _css + while calls < budget: + for candidate in candidates(text): + if len(candidate) < len(text) and check(candidate): + text = candidate + break + else: + break + return text + + +def _js(text: str, reproduces: Callable[[str], bool], budget: int) -> str: + if (node := shutil.which("node")) is None or not (_NODE_DIR / "node_modules" / "uglify-js").is_dir(): + msg = f"node and npm ci in {_NODE_DIR} are required for JS reduction" + raise FileNotFoundError(msg) + with subprocess.Popen( + [node, str(Path(__file__).with_name("js_reduce_runner.js"))], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + encoding="utf-8", + env={key: value for key, value in os.environ.items() if key not in {"LD_PRELOAD", "DYLD_INSERT_LIBRARIES"}}, + ) as process: + stdin = cast("TextIO", process.stdin) + stdout = cast("TextIO", process.stdout) + stdin.write(json.dumps({"text": text, "budget": budget}) + "\n") + stdin.flush() + while line := stdout.readline(): + reply = cast("_Reply", json.loads(line)) + if reply["kind"] == "done": + return reply["text"] + stdin.write(json.dumps(reproduces(reply["text"])) + "\n") + stdin.flush() + msg = f"JS reducer exited without a result: {process.returncode}" + raise RuntimeError(msg) + + +def _html(text: str) -> Iterator[str]: + for root, inner in ((parse(text), False), (parse_fragment(text), True)): + nodes = list(root.descendants) + for index, node in enumerate(nodes): + replacement = parse_fragment(text) if inner else parse(text) + list(replacement.descendants)[index].extract() + yield replacement.serialize(inner=inner) + if isinstance(node, Element): + for name in node.attrs: + replacement = parse_fragment(text) if inner else parse(text) + target = cast("Element", list(replacement.descendants)[index]) + del target.attrs[name] + yield replacement.serialize(inner=inner) + + +def _css(text: str) -> Iterator[str]: + yield from _css_delete(cast("list[Node]", parse_stylesheet(text))) + yield from _css_delete(cast("list[Node]", parse_blocks_contents(text))) + + +def _css_delete(nodes: list[Node]) -> Iterator[str]: + if any(isinstance(node, ParseError) for node in nodes): + return + for index, node in enumerate(nodes): + if isinstance(node, Declaration): + yield serialize(nodes[:index] + nodes[index + 1 :]) + elif isinstance(node, (AtRule, QualifiedRule)) and node.content is not None: + content = node.content + for candidate in _css_delete(cast("list[Node]", parse_blocks_contents(content))): + node.content = parse_component_value_list(candidate) + yield serialize(nodes) + node.content = content + + +class _Reply(TypedDict): + kind: Literal["candidate", "done"] + text: str + + +__all__ = ["minimize"] diff --git a/tools/fuzz/round_trip_oracles.py b/tools/fuzz/round_trip_oracles.py index 2bf765a91..c351f1b24 100644 --- a/tools/fuzz/round_trip_oracles.py +++ b/tools/fuzz/round_trip_oracles.py @@ -33,7 +33,7 @@ from functools import cache, partial from itertools import pairwise, starmap from pathlib import Path -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Literal from urllib.parse import urlsplit from fuzz.css_custom_oracles import ( @@ -100,6 +100,7 @@ parser_bytes_generate, parser_bytes_seeds, ) +from fuzz.reduce import minimize from fuzz.xml_grammar_oracles import ( UnsupportedXmlLiteralCaseError, xml_literal_check, @@ -277,6 +278,7 @@ class Oracle: controls: Callable[[], dict[str, bool]] floor: Floor fields: tuple[str, ...] = () + syntax: Literal["html", "css", "js"] | None = None def main(argv: Sequence[str] | None = None) -> int: @@ -359,7 +361,7 @@ def case(self, name: str, text: str) -> None: def report(self, rng_seed: int) -> int: for found in self.found.values(): oracle = ORACLES[found.oracle] - text = _minimize(found.text, partial(_reproduces, oracle.check, found.detail), oracle.fields) + text = _minimize(found.text, partial(_reproduces, oracle.check, found.detail), oracle.fields, oracle.syntax) data = text.encode("utf-8", "surrogatepass") digest = hashlib.sha256(data).hexdigest() (self.crash_dir / f"crash-{digest}").write_bytes(data) @@ -391,22 +393,45 @@ def _reproduces(check: Callable[[str], str | None], detail: str, text: str) -> b return False -def _minimize(text: str, reproduces: Callable[[str], bool], fields: Sequence[str]) -> str: +def _minimize( + text: str, + reproduces: Callable[[str], bool], + fields: Sequence[str], + syntax: Literal["html", "css", "js"] | None, +) -> str: + remaining = _MINIMIZE_BUDGET + + def check(candidate: str) -> bool: + nonlocal remaining + remaining -= 1 + return reproduces(candidate) + + def shrink(source: str, predicate: Callable[[str], bool], language: Literal["html", "css", "js"] | None) -> str: + if language is not None: + source = minimize(source, predicate, language, remaining) + return _ddmin(source, predicate, remaining) + if not fields: - return _ddmin(text, reproduces) + return shrink(text, check, syntax) payload = json.loads(text) for name in fields: - payload[name] = _ddmin(payload[name], lambda value, name=name: reproduces(json.dumps({**payload, name: value}))) + payload[name] = shrink( + payload[name], + lambda value, name=name: check(json.dumps({**payload, name: value})), + "html" if name == "html" else syntax, + ) return json.dumps(payload) -def _ddmin(text: str, reproduces: Callable[[str], bool]) -> str: +def _ddmin(text: str, reproduces: Callable[[str], bool], budget: int) -> str: """Shrink ``text`` while ``reproduces`` holds, with Zeller and Hildebrandt's ddmin under a call budget.""" calls = 0 granularity = 2 - while len(text) >= 2 and calls < _MINIMIZE_BUDGET: + while len(text) >= 2 and calls < budget: chunk = max(len(text) // granularity, 1) for start in range(0, len(text), chunk): + if calls == budget: + return text calls += 1 if reproduces(candidate := text[:start] + text[start + chunk :]): text = candidate @@ -2423,34 +2448,63 @@ def _xml_literal(case: str) -> str | None: _generated(_generate_resolution, 500), _resolution_controls, Floor(100, 1), + syntax="html", ), - "html-fixpoint": Oracle(html_check, _generate_html, _seeds_html, _html_controls, Floor(500, 0.85)), + "html-fixpoint": Oracle(html_check, _generate_html, _seeds_html, _html_controls, Floor(500, 0.85), syntax="html"), "html-table-grammar": Oracle( - _html_table_check, html_table_generate, html_table_seeds, html_table_controls, Floor(24, 1) + _html_table_check, + html_table_generate, + html_table_seeds, + html_table_controls, + Floor(24, 1), + syntax="html", ), "html-sibling-grammar": Oracle( - _html_sibling_check, html_sibling_generate, html_sibling_seeds, html_sibling_controls, Floor(100, 1) + _html_sibling_check, + html_sibling_generate, + html_sibling_seeds, + html_sibling_controls, + Floor(100, 1), + syntax="html", ), "html-foreign-grammar": Oracle( - _html_foreign_check, html_foreign_generate, html_foreign_seeds, html_foreign_controls, Floor(36, 1) + _html_foreign_check, + html_foreign_generate, + html_foreign_seeds, + html_foreign_controls, + Floor(36, 1), + syntax="html", ), "html-list-grammar": Oracle( - _html_list_check, html_list_generate, html_list_seeds, html_list_controls, Floor(24, 1) + _html_list_check, + html_list_generate, + html_list_seeds, + html_list_controls, + Floor(24, 1), + syntax="html", ), "xml-fixpoint": Oracle(xml_check, _generate_html, _seeds_html, _xml_controls, Floor(500, 0.95)), - "css-fixpoint": Oracle(_css_fixpoint, _generate_css, _seeds_css, _css_controls, Floor(500, 0.95)), - "js-fixpoint": Oracle(_js_fixpoint, _generate_js, _seeds_js, _js_controls, Floor(300, 0.6)), - "style-fixpoint": Oracle(style_check, _generate_style, _seeds_style, _style_controls, Floor(300, 0.95)), + "css-fixpoint": Oracle(_css_fixpoint, _generate_css, _seeds_css, _css_controls, Floor(500, 0.95), syntax="css"), + "js-fixpoint": Oracle(_js_fixpoint, _generate_js, _seeds_js, _js_controls, Floor(300, 0.6), syntax="js"), + "style-fixpoint": Oracle( + style_check, _generate_style, _seeds_style, _style_controls, Floor(300, 0.95), syntax="css" + ), "markdown-fixpoint": Oracle( - markdown_check, _markdown_document, _generated(_markdown_document, 500), _markdown_controls, Floor(500, 0.95) + markdown_check, + _markdown_document, + _generated(_markdown_document, 500), + _markdown_controls, + Floor(500, 0.95), + syntax="html", ), - "js-names": Oracle(_js_names, _generate_js, _seeds_js, _js_names_controls, Floor(300, 0.6)), + "js-names": Oracle(_js_names, _generate_js, _seeds_js, _js_names_controls, Floor(300, 0.6), syntax="js"), "css-semantics": Oracle( css_semantics_check, _generate_semantic, _generated(_generate_semantic), _css_semantics_controls, Floor(100, 0.95), + syntax="html", ), "xpath-entry": Oracle( xpath_entry_check, @@ -2468,7 +2522,7 @@ def _xml_literal(case: str) -> str | None: Floor(100, 0.85), fields=("html", "css"), ), - "spans": Oracle(span_check, _generate_html, _seeds_html, _span_controls, Floor(500, 0.95)), + "spans": Oracle(span_check, _generate_html, _seeds_html, _span_controls, Floor(500, 0.95), syntax="html"), }