diff --git a/docs/development/index.rst b/docs/development/index.rst index 229bc1baf..2294d9162 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -147,6 +147,11 @@ ship this build. The ``preprocess-identity`` environment checks that its branche byte-identical, and the ``scalar`` environment runs the suite with ``-Dforce_scalar=true``, which swaps the SSE2 and NEON scan loops for the portable SWAR ones. +The fuzz environments compile the extension with ``-DTH_OPERATION_LIMIT=100000``. XPath evaluation, XSLT stylesheet +compilation and XSLT application then count their steps and raise ``ValueError`` past the limit, so a known super-linear +input fails fast instead of timing out and hiding the next find. Release builds leave the macro undefined and compile no +counter. ``fuzz-smoke`` runs ``tests/fuzz_build/`` to check that the limit stops those inputs. + The in-process driver runs each input under pymalloc and again under ``PYTHONMALLOC=malloc``, because AddressSanitizer cannot see an over-read that stays inside a pymalloc pool. The deep run splits ``--minutes`` between the two passes. Both environments pin ``PYTHONHASHSEED=0``. ``--rng-seed`` (default ``$FUZZ_RNG_SEED``, else 0) fixes the mutation diff --git a/pyproject.toml b/pyproject.toml index f2cfdf84b..25dcd5b24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -403,6 +403,7 @@ run.omit = [ "tests/conformance/test_cssom_jsdom_conformance.py", # differential oracle: skips when the node/jsdom toolchain is absent "tests/conformance/test_dom_jsdom_differential.py", "tests/conformance/test_xml_conformance.py", + "tests/fuzz_build/*", # needs the operation limit only the fuzz-smoke build compiles in ] run.parallel = true run.plugins = [ diff --git a/src/turbohtml/_c/query/xpath/eval.c b/src/turbohtml/_c/query/xpath/eval.c index bffb17658..e0fb10a51 100644 --- a/src/turbohtml/_c/query/xpath/eval.c +++ b/src/turbohtml/_c/query/xpath/eval.c @@ -1044,23 +1044,28 @@ static int apply_predicates(const xp_program *prog, int32_t pred_head, xp_ctx *c Py_ssize_t size = set->len; Py_ssize_t write_pos = 0; for (Py_ssize_t index = 0; index < set->len; index++) { - xp_ctx pctx = {ctx->tree, - set->items[index].node, - set->items[index].attr, - index + 1, - size, - ctx->feature, - ctx->vars, - ctx->namespaces, - ctx->extension, - ctx->extension_ctx, - ctx->depth, - ctx->regex_cache, - ctx->live, - ctx->before_python, - ctx->name_test, - ctx->name_test_ctx, - ctx->strict_no_ns}; + xp_ctx pctx = { + ctx->tree, + set->items[index].node, + set->items[index].attr, + index + 1, + size, + ctx->feature, + ctx->vars, + ctx->namespaces, + ctx->extension, + ctx->extension_ctx, + ctx->depth, + ctx->regex_cache, + ctx->live, + ctx->before_python, + ctx->name_test, + ctx->name_test_ctx, + ctx->strict_no_ns, +#ifdef TH_OPERATION_LIMIT + ctx->operations, +#endif + }; xp_result value; int rc = eval_expr(prog, expr, &pctx, &value); if (rc < 0) { @@ -1286,6 +1291,11 @@ static int eval_path_step_inner(const xp_program *prog, const xn *step, const st if (stepped < 0) { /* GCOVR_EXCL_BR_LINE: alloc */ return -1; /* GCOVR_EXCL_LINE */ } +#ifdef TH_OPERATION_LIMIT + if (xp_charge(ctx, 1 + (size_t)(following->len - before)) < 0) { + return -3; + } +#endif if (step->first >= 0) { xp_nodeset slice = {following->items + before, following->len - before, 0, following->snapshots}; int rc; @@ -1810,6 +1820,11 @@ int eval_expr(const xp_program *prog, int32_t idx, xp_ctx *ctx, xp_result *out) *ctx->feature = "an expression nested too deeply"; return -3; } +#ifdef TH_OPERATION_LIMIT + if (xp_charge(ctx, 1) < 0) { + return -3; + } +#endif ctx->depth++; int rc; if (ctx->live == NULL) { @@ -1828,8 +1843,16 @@ int xp_eval_at(const xp_program *prog, struct th_tree *tree, struct th_node *con const xp_bindings *vars, const xp_namespaces *namespaces, xp_extension_fn extension, void *extension_ctx, xp_result *out, const char **feature) { xr_cache *regex_cache = NULL; - xp_ctx ctx = {tree, context, -1, pos, size, feature, vars, namespaces, extension, - extension_ctx, 0, ®ex_cache, NULL, NULL, NULL, NULL, 0}; +#ifdef TH_OPERATION_LIMIT + size_t operations = 0; +#endif + xp_ctx ctx = { + tree, context, -1, pos, size, feature, vars, namespaces, extension, + extension_ctx, 0, ®ex_cache, NULL, NULL, NULL, NULL, 0, +#ifdef TH_OPERATION_LIMIT + &operations, +#endif + }; int rc = eval_expr(prog, prog->root, &ctx, out); if (regex_cache != NULL) { xr_cache_free(regex_cache); @@ -1841,23 +1864,31 @@ int xp_eval_pattern_at(const xp_program *prog, struct th_tree *tree, struct th_n void *extension_ctx, xp_name_test_fn name_test, void *name_test_ctx, xp_result *out, const char **feature) { xr_cache *regex_cache = NULL; - xp_ctx ctx = {tree, - context, - -1, - 1, - 1, - feature, - NULL, - NULL, - extension, - extension_ctx, - 0, - ®ex_cache, - NULL, - NULL, - name_test, - name_test_ctx, - name_test == NULL && th_tree_is_xml(tree)}; /* GCOVR_EXCL_BR_LINE: XML only */ +#ifdef TH_OPERATION_LIMIT + size_t operations = 0; +#endif + xp_ctx ctx = { + tree, + context, + -1, + 1, + 1, + feature, + NULL, + NULL, + extension, + extension_ctx, + 0, + ®ex_cache, + NULL, + NULL, + name_test, + name_test_ctx, + name_test == NULL && th_tree_is_xml(tree), /* GCOVR_EXCL_BR_LINE: XML only */ +#ifdef TH_OPERATION_LIMIT + &operations, +#endif + }; int rc = eval_expr(prog, prog->root, &ctx, out); if (regex_cache != NULL) { xr_cache_free(regex_cache); @@ -1876,9 +1907,16 @@ int xp_eval_snapshot(const xp_program *prog, struct th_tree *tree, struct th_nod xp_before_python_fn before_python, xp_result *out, const char **feature) { xr_cache *regex_cache = NULL; xp_live_registry live = {0}; +#ifdef TH_OPERATION_LIMIT + size_t operations = 0; +#endif xp_ctx ctx = { - tree, context, -1, 1, 1, feature, vars, namespaces, extension, extension_ctx, 0, ®ex_cache, - &live, before_python, NULL, NULL, 0}; + tree, context, -1, 1, 1, feature, vars, namespaces, extension, extension_ctx, 0, ®ex_cache, + &live, before_python, NULL, NULL, 0, +#ifdef TH_OPERATION_LIMIT + &operations, +#endif + }; xp_live_frame frame = {.node = context, .vars = vars}; xp_live_enter(&ctx, &frame); int rc = eval_expr(prog, prog->root, &ctx, out); diff --git a/src/turbohtml/_c/query/xpath/internal.h b/src/turbohtml/_c/query/xpath/internal.h index ee175e670..aa38e1791 100644 --- a/src/turbohtml/_c/query/xpath/internal.h +++ b/src/turbohtml/_c/query/xpath/internal.h @@ -189,6 +189,9 @@ typedef struct { xp_name_test_fn name_test; void *name_test_ctx; int strict_no_ns; +#ifdef TH_OPERATION_LIMIT + size_t *operations; /* shared by the predicate contexts of one top-level evaluation */ +#endif } xp_ctx; struct xp_live_frame { @@ -227,6 +230,20 @@ static inline int xp_before_python(xp_ctx *ctx) { return ctx->before_python == NULL ? 0 : ctx->before_python(ctx->extension_ctx, ctx->live->current); } +#ifdef TH_OPERATION_LIMIT +/* Fuzz builds pass -DTH_OPERATION_LIMIT so a super-linear expression stops with a ValueError instead of a timeout that + hides other findings. libxml2 charges each evaluated operation and each node a step yields the same way + (xpath.c xmlXPathCheckOpLimit); release builds compile none of this. */ +static inline int xp_charge(xp_ctx *ctx, size_t count) { + *ctx->operations += count; + if (*ctx->operations <= TH_OPERATION_LIMIT) { + return 0; + } + *ctx->feature = "evaluation exceeded the operation limit"; + return -3; +} +#endif + /* Pre-order successor, shared by the evaluator and id(). ns_push is declared in the public xpath.h because the marshaling boundary also builds node-sets through it. */ struct th_node *document_next(struct th_node *node); diff --git a/src/turbohtml/_c/query/xslt.c b/src/turbohtml/_c/query/xslt.c index d937b3e7b..4deb0b7e8 100644 --- a/src/turbohtml/_c/query/xslt.c +++ b/src/turbohtml/_c/query/xslt.c @@ -758,6 +758,9 @@ typedef struct engine { const char *error; int py_error; int owns_model; +#ifdef TH_OPERATION_LIMIT + size_t operations; /* stylesheet elements compiled, then instructions applied in one run */ +#endif } engine; typedef struct { @@ -929,6 +932,19 @@ static int fail_py(engine *eng) { return -1; } +#ifdef TH_OPERATION_LIMIT +/* Fuzz builds pass -DTH_OPERATION_LIMIT so a super-linear stylesheet stops with a ValueError instead of a timeout that + hides other findings. libxslt charges each parsed instruction (xslt.c xsltParseSequenceConstructor) and each + instantiated one (transform.c xsltApplySequenceConstructor) the same way; release builds compile none of this. */ +static int charge_operation(engine *eng, const char *phase) { + if (++eng->operations <= TH_OPERATION_LIMIT) { + return 0; + } + PyErr_Format(PyExc_ValueError, "xslt: %s exceeded the operation limit of %d", phase, TH_OPERATION_LIMIT); + return fail_py(eng); +} +#endif + /* ---- compile a match pattern to an equivalent absolute expression --------- */ /* Split a pattern on top-level '|' (outside brackets, parentheses and string @@ -4833,6 +4849,11 @@ static int instantiate_body(engine *eng, th_node *body, th_node *out_parent) { if (is_xsl_dynamic(eng, child, "param") || is_xsl_dynamic(eng, child, "sort")) { continue; } +#ifdef TH_OPERATION_LIMIT + if ((rc = charge_operation(eng, "applying the stylesheet")) < 0) { + break; + } +#endif rc = instantiate_one_dynamic(eng, child, out_parent); } } else { @@ -4842,6 +4863,11 @@ static int instantiate_body(engine *eng, th_node *body, th_node *out_parent) { if (is_xsl_fast(eng, child, "param") || is_xsl_fast(eng, child, "sort")) { continue; } +#ifdef TH_OPERATION_LIMIT + if ((rc = charge_operation(eng, "applying the stylesheet")) < 0) { + break; + } +#endif rc = instantiate_one_fast(eng, child, out_parent); } } @@ -5625,6 +5651,9 @@ static int engine_start_run(engine *eng, const engine *model, th_tree *src_tree, eng->ns_counter = 0; eng->gen_counter = 0; eng->depth = 0; +#ifdef TH_OPERATION_LIMIT + eng->operations = 0; +#endif eng->number_count_match = (xslt_number_match){0}; eng->number_from_match = (xslt_number_match){0}; eng->explicit_any = (xslt_number_prefix){0}; @@ -6099,6 +6128,11 @@ static int precompile_stylesheet(engine *eng, th_node *root) { if (node->type != TH_NODE_ELEMENT) { continue; } +#ifdef TH_OPERATION_LIMIT + if (charge_operation(eng, "compiling the stylesheet") < 0) { + return -1; + } +#endif if (node_is_xsl(eng, node)) { if (precompile_instruction(eng, node) < 0) { return -1; diff --git a/tests/fuzz_build/test_operation_limit.py b/tests/fuzz_build/test_operation_limit.py new file mode 100644 index 000000000..caa27b947 --- /dev/null +++ b/tests/fuzz_build/test_operation_limit.py @@ -0,0 +1,84 @@ +"""Fuzz builds stop super-linear XPath and XSLT work at the native operation limit instead of timing out.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +import pytest + +from turbohtml import parse_xml +from turbohtml.transform import Transform + +if TYPE_CHECKING: + from collections.abc import Callable + + from turbohtml import Document + +_SIBLINGS: Final = parse_xml("" + "" * 100 + "") +_NESTED: Final = "count(//a[count(//a[count(//a) > 0]) > 0])" +_DOUBLING: Final = parse_xml( + '' + '' + '' + '' + '' + '' + '' + '' + 'x' +) + + +@pytest.mark.parametrize( + ("run", "expected"), + [ + pytest.param(lambda: str(_SIBLINGS.xpath("count(//a[count(//a) > 0])")), "100.0", id="xpath"), + pytest.param(lambda: Transform(_DOUBLING)(_SIBLINGS, depth="10"), "x" * 1024, id="xslt-apply"), + pytest.param(lambda: Transform(_literal_sheet(100))(_SIBLINGS), "x" * 100, id="xslt-compile"), + ], +) +def test_operation_limit_within_budget(run: Callable[[], str], expected: str) -> None: + assert run() == expected + + +@pytest.mark.parametrize( + ("run", "message"), + [ + pytest.param( + lambda: str(_SIBLINGS.xpath(_NESTED)), r"^xpath: evaluation exceeded the operation limit$", id="xpath" + ), + pytest.param( + lambda: Transform(_DOUBLING)(_SIBLINGS, depth="17"), + r"^xslt: applying the stylesheet exceeded the operation limit of \d+$", + id="xslt-apply", + ), + pytest.param( + lambda: Transform(_value_of_sheet(_NESTED))(_SIBLINGS), + r"^xslt: expression error \(evaluation exceeded the operation limit\)$", + id="xslt-select", + ), + pytest.param( + lambda: Transform(_literal_sheet(200_000))(_SIBLINGS), + r"^xslt: compiling the stylesheet exceeded the operation limit of \d+$", + id="xslt-compile", + ), + ], +) +def test_operation_limit_stops(run: Callable[[], str], message: str) -> None: + with pytest.raises(ValueError, match=message): + run() + + +def _literal_sheet(elements: int) -> Document: + return parse_xml( + '' + f'{"x" * elements}' + ) + + +def _value_of_sheet(expression: str) -> Document: + return parse_xml( + '' + f'", ">")}"/>' + "" + ) diff --git a/tools/fuzz/fuzz.py b/tools/fuzz/fuzz.py index f598724a0..a1a452f4a 100644 --- a/tools/fuzz/fuzz.py +++ b/tools/fuzz/fuzz.py @@ -12,13 +12,14 @@ fault aborts the interpreter with a stack trace. It calls the public API, so it survives the in-flight C refactors. ``smoke`` replays the past finds under ``tests/fuzz_regressions`` and a benign seed corpus once (fast, deterministic, -gates every PR). ``deep`` adds a mutation loop and structural probes for a per-target budget (the scheduled/manual run -that hunts for crashes). ``oracle`` runs the sanitizer wrong-output oracles (``sanitize_oracles.py``) instead, -because a sanitizer bug usually returns unsafe markup without crashing. ``round-trip`` runs the printer, minifier, -entry-point and source-span oracles (``round_trip_oracles.py``) and ``release-diff`` compares HEAD with the latest PyPI -release (``release_diff.py``), for the bugs that return wrong text. A crashing input lands in ``--crash-dir`` as -``crash-``, and the log names it only by hash, length and harness, because CI logs on a public repository are -public. The in-process extension is expected to be pre-built by the tox env; ``--build`` builds it here for a local run. +gates every PR), then runs ``tests/fuzz_build`` against the operation limit the fuzz build compiles in. ``deep`` adds a +mutation loop and structural probes for a per-target budget (the scheduled/manual run that hunts for crashes). +``oracle`` runs the sanitizer wrong-output oracles (``sanitize_oracles.py``) instead, because a sanitizer bug usually +returns unsafe markup without crashing. ``round-trip`` runs the printer, minifier, entry-point and source-span oracles +(``round_trip_oracles.py``) and ``release-diff`` compares HEAD with the latest PyPI release (``release_diff.py``), for +the bugs that return wrong text. A crashing input lands in ``--crash-dir`` as ``crash-``, and the log names it +only by hash, length and harness, because CI logs on a public repository are public. The in-process extension is +expected to be pre-built by the tox env; ``--build`` builds it here for a local run. ``smoke`` and ``deep`` build everything in the fuzz-only mode (meson ``-Dfuzzing=true``) and start with a self-test: each ``_fuzz_crash`` kind must draw its AddressSanitizer report, and an injected allocation failure must raise @@ -101,9 +102,11 @@ def main(argv: Sequence[str] | None = None) -> int: return _run_round_trip(args.mode, args.minutes, args.rng_seed, args.crash_dir, passthrough) if not args.skip_inprocess and (code := _self_test()) != 0: return code - if (code := _run_standalone(args.mode, args.extra_corpus, args.crash_dir)) != 0: + if (code := _run_standalone(args.mode, args.extra_corpus, args.crash_dir)) != 0 or args.skip_inprocess: return code - return 0 if args.skip_inprocess else _run_inprocess(args.mode, args.minutes, args.rng_seed, args.crash_dir) + return _run_inprocess(args.mode, args.minutes, args.rng_seed, args.crash_dir) or ( + _run_operation_limit() if args.mode == "smoke" else 0 + ) def _build_extension(build_dir: Path) -> None: @@ -117,7 +120,7 @@ def _build_extension(build_dir: Path) -> None: "--editable", str(_ROOT), f"--config-settings=build-dir={build_dir}", - "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined", + "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined -DTH_OPERATION_LIMIT=100000", "--config-settings=setup-args=-Dc_link_args=-fsanitize=address,undefined", "--config-settings=setup-args=-Dbuildtype=debugoptimized", "--config-settings=setup-args=-Dfuzzing=true", @@ -380,6 +383,16 @@ def _run_inprocess(mode: str, minutes: float, rng_seed: int, crash_dir: Path) -> return status +def _run_operation_limit() -> int: + # the release build compiles no counters, so these checks run only here, against the fuzz build + return subprocess.run( + [sys.executable, "-m", "pytest", str(_ROOT / "tests" / "fuzz_build"), "--no-cov", "-p", "no:cacheprovider"], + cwd=_ROOT, + env={"PYTHONHASHSEED": "0", **_asan_preload()}, + check=False, + ).returncode + + def _asan_preload() -> dict[str, str]: """Build the env preloading the ASan runtime ahead of the interpreter so the instrumented .so's interceptors arm.""" on_linux = platform.system() == "Linux" diff --git a/tox.toml b/tox.toml index 4b2fd458b..194c08a57 100644 --- a/tox.toml +++ b/tox.toml @@ -86,6 +86,8 @@ commands = [ # env (the "🔬 conformance" CI job). Deselect them here so the normal matrix never runs an oracle suite # without its oracle -- a missing oracle must error there, not skip silently in the matrix. "--ignore={tox_root}{/}tests{/}conformance", + # tests/fuzz_build checks the operation limit only the sanitizer fuzz build compiles in; fuzz-smoke runs it + "--ignore={tox_root}{/}tests{/}fuzz_build", { replace = "posargs", default = [ "tests" ], extend = true }, ], # enforce 100% C LINE coverage everywhere gcov runs, and 100% BRANCH coverage on @@ -281,6 +283,7 @@ commands = [ "10", "--no-cov", "--ignore={tox_root}{/}tests{/}conformance", + "--ignore={tox_root}{/}tests{/}fuzz_build", { replace = "posargs", default = [ "tests" ], extend = true }, ], ] @@ -467,7 +470,7 @@ commands_pre = [ "--editable", "{tox_root}", "--config-settings=build-dir={env_dir}{/}cbuild", - "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined", + "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined -DTH_OPERATION_LIMIT=100000", "--config-settings=setup-args=-Dc_link_args=-fsanitize=address,undefined", "--config-settings=setup-args=-Dbuildtype=debugoptimized", "--config-settings=setup-args=-Dfuzzing=true", @@ -549,7 +552,7 @@ commands_pre = [ "--editable", "{tox_root}", "--config-settings=build-dir={env_dir}{/}cbuild", - "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined", + "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined -DTH_OPERATION_LIMIT=100000", "--config-settings=setup-args=-Dc_link_args=-fsanitize=address,undefined", "--config-settings=setup-args=-Dbuildtype=debugoptimized", ], @@ -617,7 +620,7 @@ commands_pre = [ "--editable", "{tox_root}", "--config-settings=build-dir={env_dir}{/}cbuild", - "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined", + "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined -DTH_OPERATION_LIMIT=100000", "--config-settings=setup-args=-Dc_link_args=-fsanitize=address,undefined", "--config-settings=setup-args=-Dbuildtype=debugoptimized", ], @@ -641,7 +644,8 @@ set_env = { CC = "clang", PYTHONHASHSEED = "0" } commands_pre = [ # build the extension with the sanitizers so the in-process harness faults on a C memory error. b_sanitize trips # meson's linker capability check for an extension module on macOS, so pass the flag through c_args/c_link_args, which - # the driver's ASan-runtime preload then arms; buildtype matches the tsan env's instrumented build. + # the driver's ASan-runtime preload then arms; buildtype matches the tsan env's instrumented build. The operation + # limit is the XPath opLimit libxslt's own fuzzer sets (libxslt tests/fuzz/fuzz.c), applied to each counter. [ "uv", "pip", @@ -652,7 +656,7 @@ commands_pre = [ "--editable", "{tox_root}", "--config-settings=build-dir={env_dir}{/}cbuild", - "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined", + "--config-settings=setup-args=-Dc_args=-fsanitize=address,undefined -DTH_OPERATION_LIMIT=100000", "--config-settings=setup-args=-Dc_link_args=-fsanitize=address,undefined", "--config-settings=setup-args=-Dbuildtype=debugoptimized", "--config-settings=setup-args=-Dfuzzing=true", @@ -737,6 +741,7 @@ commands = [ "pytest", "--no-cov", "--ignore={tox_root}{/}tests{/}conformance", + "--ignore={tox_root}{/}tests{/}fuzz_build", { replace = "posargs", default = [ "tests" ], extend = true }, ], ]