Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog/1019.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reduce saved HTML, CSS and JavaScript findings with language-aware deletion.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
136 changes: 136 additions & 0 deletions tests/test_fuzz_reduce.py
Original file line number Diff line number Diff line change
@@ -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 = '<section><p id="keep" title="noise">x</p><aside>discard</aside></section>'

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") == '<section><p id="keep">x</p></section>'


@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("<p>x</p>", lambda _text: pytest.fail("zero budget compared"), syntax, budget=0) == "<p>x</p>"


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;}"
107 changes: 107 additions & 0 deletions tests/test_fuzz_reduce_cli.py
Original file line number Diff line number Diff line change
@@ -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("<div><aside>noise</aside><p>x</p></div>", "html", "<div><p>x</p></div>", 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": "<div><p>x</p><aside>noise</aside></div>", "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": "<div><p>x</p></div>", "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)
15 changes: 14 additions & 1 deletion tools/bench/node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion tools/bench/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
29 changes: 29 additions & 0 deletions tools/fuzz/LICENSE-UGLIFYJS
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
UglifyJS is released under the BSD license:

Copyright 2012-2024 (c) Mihai Bazon <mihai.bazon@gmail.com>

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.
Loading
Loading