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 },
],
]