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
5 changes: 5 additions & 0 deletions docs/development/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
114 changes: 76 additions & 38 deletions src/turbohtml/_c/query/xpath/eval.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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, &regex_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, &regex_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);
Expand All @@ -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,
&regex_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,
&regex_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);
Expand All @@ -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, &regex_cache,
&live, before_python, NULL, NULL, 0};
tree, context, -1, 1, 1, feature, vars, namespaces, extension, extension_ctx, 0, &regex_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);
Expand Down
17 changes: 17 additions & 0 deletions src/turbohtml/_c/query/xpath/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions src/turbohtml/_c/query/xslt.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions tests/fuzz_build/test_operation_limit.py
Original file line number Diff line number Diff line change
@@ -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("<r>" + "<a/>" * 100 + "</r>")
_NESTED: Final = "count(//a[count(//a[count(//a) > 0]) > 0])"
_DOUBLING: Final = parse_xml(
'<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
'<xsl:output method="text"/><xsl:param name="depth"/>'
'<xsl:template match="/"><xsl:call-template name="split">'
'<xsl:with-param name="level" select="$depth"/></xsl:call-template></xsl:template>'
'<xsl:template name="split"><xsl:param name="level"/>'
'<xsl:if test="$level &gt; 0">'
'<xsl:call-template name="split"><xsl:with-param name="level" select="$level - 1"/></xsl:call-template>'
'<xsl:call-template name="split"><xsl:with-param name="level" select="$level - 1"/></xsl:call-template>'
'</xsl:if><xsl:if test="$level = 0">x</xsl:if></xsl:template></xsl:stylesheet>'
)


@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(
'<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
f'<xsl:output method="text"/><xsl:template match="/">{"<e>x</e>" * elements}</xsl:template></xsl:stylesheet>'
)


def _value_of_sheet(expression: str) -> Document:
return parse_xml(
'<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"><xsl:output method="text"/>'
f'<xsl:template match="/"><xsl:value-of select="{expression.replace(">", "&gt;")}"/></xsl:template>'
"</xsl:stylesheet>"
)
Loading
Loading