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/1198.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reject undefined RELAX NG references and ignore foreign annotation subtrees during compilation.
1 change: 1 addition & 0 deletions docs/changelog/1198.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compare inline XSLT and schema outcomes with libxml2 and libxslt, including repaired seed-tree mutations.
76 changes: 69 additions & 7 deletions src/turbohtml/_c/validate/relaxng.h
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ static pattern *rng_build(th_schema *schema, th_node *node) {
node_pat->def_index = (int)index;
return node_pat;
}
PyErr_SetString(PyExc_ValueError, "RELAX NG <ref> has no matching define");
return schema->p_notallowed;
}
return schema->p_notallowed;
Expand Down Expand Up @@ -1105,13 +1106,16 @@ static pattern *rng_child_element(valctx *ctx, pattern *p, th_node *element) {
/* ---- compile & entry ---- */

static int rng_scan(th_schema *schema, th_node *container, int depth);
static int rng_check_unused_refs(th_schema *schema, th_node *container);
static void rng_prune_annotations(th_schema *schema, th_node *container);

static int rng_compile(th_schema *schema) {
qname root_name = schema_direct_qname(schema, schema->root);
if (!u_eq_ascii(root_name.uri, root_name.uri_len, RNG_NS)) {
PyErr_SetString(PyExc_ValueError, "RELAX NG schema root must use the structure namespace");
return 0;
}
rng_prune_annotations(schema, schema->root);
th_tree *tree = schema->tree;
schema->p_empty = pat_new(schema, P_EMPTY);
schema->p_notallowed = pat_new(schema, P_NOTALLOWED);
Expand Down Expand Up @@ -1144,10 +1148,65 @@ static int rng_compile(th_schema *schema) {
if (rng_scan(schema, start, 0) < 0) {
return 0;
}
for (Py_ssize_t index = 0; index < schema->defines.len; index++) {
def_entry *entry = &schema->defines.items[index];
if (entry->cycle_depth != -1) {
continue;
}
if (rng_check_unused_refs(schema, entry->first) < 0) {
return 0;
}
for (def_part *part = entry->extra; part != NULL; part = part->next) {
if (rng_check_unused_refs(schema, part->node) < 0) {
return 0;
}
}
}
schema->start = rng_build_children(schema, start, NULL);
return 1;
}

/* Section 4.1 removes annotation subtrees before pattern and name-class construction. */
static void rng_prune_annotations(th_schema *schema, th_node *container) {
th_node *child = container->first_child;
while (child != NULL) {
th_node *next = child->next_sibling;
if (child->type == TH_NODE_ELEMENT) {
qname name = schema_direct_qname(schema, child);
if (!u_eq_ascii(name.uri, name.uri_len, RNG_NS)) {
th_node_remove(child);
} else {
rng_prune_annotations(schema, child);
}
}
child = next;
}
}

/* Section 4.18 resolves names before 4.19 removes unused definitions. */
static int rng_check_unused_refs(th_schema *schema, th_node *container) {
for (th_node *child = container->first_child; child != NULL; child = child->next_sibling) {
if (child->type != TH_NODE_ELEMENT) {
continue;
}
if (is_schema_el(schema, child, RNG_NS, "ref")) {
const th_node_attr *name = attr_exact(schema->tree, child, "name", 4);
if (name == NULL) {
PyErr_SetString(PyExc_ValueError, "RELAX NG <ref> is missing the required name attribute");
return -1;
}
if (def_find(&schema->defines, name->value, name->value_len) < 0) {
PyErr_SetString(PyExc_ValueError, "RELAX NG <ref> has no matching define");
return -1;
}
}
if (rng_check_unused_refs(schema, child) < 0) {
return -1;
}
}
return 0;
}

static int rng_scan_node(th_schema *schema, th_node *node, int depth);

/* Checks 4.10, 4.19 and 7.4 on reachable patterns only: an unreachable <define> is never built, so it can neither
Expand All @@ -1166,19 +1225,23 @@ static int rng_scan(th_schema *schema, th_node *container, int depth) {

enum { RNG_SCAN_OTHER, RNG_SCAN_REF, RNG_SCAN_INTERLEAVE, RNG_SCAN_ELEMENT };

static int rng_scan_kind(const th_schema *schema, th_node *node);
static int rng_scan_kind(th_node *node);
static int rng_scan_define(th_schema *schema, Py_ssize_t def_index, int depth);

static int rng_scan_node(th_schema *schema, th_node *node, int depth) {
switch (rng_scan_kind(schema, node)) {
switch (rng_scan_kind(node)) {
case RNG_SCAN_REF: {
const th_node_attr *name = attr_exact(schema->tree, node, "name", 4);
if (name == NULL) {
PyErr_SetString(PyExc_ValueError, "RELAX NG <ref> is missing the required name attribute");
return -1;
}
Py_ssize_t index = def_find(&schema->defines, name->value, name->value_len);
return index >= 0 ? rng_scan_define(schema, index, depth) : 0;
if (index < 0) {
PyErr_SetString(PyExc_ValueError, "RELAX NG <ref> has no matching define");
return -1;
}
return rng_scan_define(schema, index, depth);
}
case RNG_SCAN_INTERLEAVE:
return rng_check_interleave_node(schema, node) < 0 ? -1 : rng_scan(schema, node, depth);
Expand All @@ -1189,7 +1252,7 @@ static int rng_scan_node(th_schema *schema, th_node *node, int depth) {
}
}

static int rng_scan_kind(const th_schema *schema, th_node *node) {
static int rng_scan_kind(th_node *node) {
const Py_UCS4 *local, *prefix;
Py_ssize_t local_len = 0, prefix_len = 0;
split_prefix(node->text, node->text_len, &local, &local_len, &prefix, &prefix_len);
Expand All @@ -1201,10 +1264,9 @@ static int rng_scan_kind(const th_schema *schema, th_node *node) {
} else if (u_eq_ascii(local, local_len, "element")) {
kind = RNG_SCAN_ELEMENT;
} else {
return RNG_SCAN_OTHER; /* most nodes are not a restriction keyword: skip the namespace resolution */
return RNG_SCAN_OTHER;
}
qname name = schema_direct_qname(schema, node);
return u_eq_ascii(name.uri, name.uri_len, RNG_NS) ? kind : RNG_SCAN_OTHER;
return kind;
}

/* RELAX NG 4.19: meeting a define again at the element depth where its expansion began is a ref loop with no
Expand Down
152 changes: 152 additions & 0 deletions tests/test_fuzz_rng_inline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, Final

import pytest

if TYPE_CHECKING:
from types import ModuleType


@pytest.fixture
def engine() -> ModuleType:
return pytest.importorskip("fuzz.rng_inline", exc_type=ImportError)


@pytest.mark.oracle
@pytest.mark.parametrize("seed", range(8))
def test_rng_inline_generated_labels(engine: ModuleType, seed: int) -> None:
case: Final = engine.generate(seed)
assert [(row.engine, row.phase, row.expected, row.actual) for row in engine.compare(case)] == [
("turbohtml", "compilation", case.compiles, case.compiles),
("libxml2", "compilation", case.compiles, case.compiles),
*[
(name, "validation", expected, expected)
for _, expected in case.documents
for name in ("turbohtml", "libxml2")
],
]


@pytest.mark.oracle
@pytest.mark.parametrize("schema", ["<", '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>'])
def test_rng_inline_compile_verdicts(engine: ModuleType, schema: str) -> None:
expected: Final = False
case: Final = engine.Case(0, schema, expected, ())
assert [(row.engine, row.phase, row.expected, row.actual) for row in engine.compare(case)] == [
("turbohtml", "compilation", expected, expected),
("libxml2", "compilation", expected, expected),
]


@pytest.mark.oracle
def test_rng_inline_unavailable_documents_remain_findings(engine: ModuleType) -> None:
case: Final = engine.Case(0, "<", compiles=False, documents=(("<v/>", True),))
assert [(row.engine, row.phase, row.expected, row.actual) for row in engine.compare(case)] == [
("turbohtml", "compilation", False, False),
("libxml2", "compilation", False, False),
("turbohtml", "validation", True, None),
("libxml2", "validation", True, None),
]


@pytest.mark.oracle
def test_rng_inline_negative_control_changes_public_verdict(engine: ModuleType) -> None:
case: Final = engine.generate(0)
assert [
(row.engine, row.phase, row.expected, row.actual) for row in engine.compare(case, negative_control=True)
] == [
("turbohtml", "compilation", True, True),
("libxml2", "compilation", True, True),
("turbohtml", "validation", True, False),
("libxml2", "validation", True, True),
("turbohtml", "validation", True, False),
("libxml2", "validation", True, True),
("turbohtml", "validation", False, True),
("libxml2", "validation", False, False),
]


@pytest.mark.oracle
@pytest.mark.parametrize(
("arguments", "exit_status", "findings"),
[
pytest.param(["--cases", "8"], 0, 0, id="agreement"),
pytest.param(["--cases", "8", "--negative-control"], 1, 18, id="wrong-verdict"),
],
)
def test_rng_inline_cli(
engine: ModuleType,
capsys: pytest.CaptureFixture[str],
arguments: list[str],
exit_status: int,
findings: int,
) -> None:
assert engine.main(arguments) == exit_status
rows: Final = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
assert rows[-1] == {"summary": {"cases": 8, "rows": 52, "findings": findings}}
assert {(row["engine"], row["phase"]) for row in rows[:-1]} == {
("turbohtml", "compilation"),
("libxml2", "compilation"),
("turbohtml", "validation"),
("libxml2", "validation"),
}


@pytest.mark.oracle
@pytest.mark.parametrize("count", ["0", "257", "-1"])
def test_rng_inline_cli_rejects_unbounded_cases(engine: ModuleType, count: str) -> None:
with pytest.raises(SystemExit, match="2"):
engine.main(["--cases", count])


@pytest.mark.oracle
@pytest.mark.parametrize(
("pattern", "documents"),
[
pytest.param("<empty/>", (("<v/>", True), ("<v>wrong</v>", False)), id="group"),
pytest.param(
'<choice><value type="string">ok</value>{annotation}</choice>',
(("<v>ok</v>", True), ("<v/>", False)),
id="choice",
),
pytest.param(
"<interleave><empty/>{annotation}</interleave>",
(("<v/>", True), ("<v>wrong</v>", False)),
id="interleave",
),
pytest.param(
'<attribute name="key">{annotation}</attribute><empty/>',
(('<v key="anything"/>', True), ("<v/>", False)),
id="attribute-default-text",
),
],
)
def test_rng_inline_foreign_annotations_preserve_pattern_verdicts(
engine: ModuleType,
pattern: str,
documents: tuple[tuple[str, bool], ...],
) -> None:
annotation: Final = '<doc:annotation><ref name="missing"/></doc:annotation>'
body: Final = pattern.replace("{annotation}", annotation)
schema: Final = (
'<grammar xmlns="http://relaxng.org/ns/structure/1.0" xmlns:doc="urn:documentation">'
f'<start><element name="v">{body}{annotation}</element></start></grammar>'
)
case: Final = engine.Case(0, schema, compiles=True, documents=documents)
assert [(row.engine, row.phase, row.expected, row.actual) for row in engine.compare(case)] == [
("turbohtml", "compilation", True, True),
("libxml2", "compilation", True, True),
*[(name, "validation", expected, expected) for _, expected in documents for name in ("turbohtml", "libxml2")],
]


@pytest.mark.oracle
def test_rng_inline_foreign_annotation_preserves_explicit_name_class(engine: ModuleType) -> None:
schema: Final = (
'<element xmlns="http://relaxng.org/ns/structure/1.0" xmlns:doc="urn:documentation">'
'<doc:annotation><ref name="missing"/></doc:annotation><name>v</name><empty/></element>'
)
case: Final = engine.Case(0, schema, compiles=True, documents=(("<v/>", True), ("<wrong/>", False)))
assert [row.actual == row.expected for row in engine.compare(case)] == [True] * 6
Loading
Loading