Coverage for /pythoncovmergedfiles/medio/medio/src/turbohtml/tools/fuzz/reference_targets.py: 78%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Reference-shaped consumers must agree with their compiled public entry points."""
3from __future__ import annotations
5import json
6from pathlib import Path
7from tempfile import TemporaryDirectory
8from typing import TYPE_CHECKING, Final, cast, get_args
10import turbohtml
11from turbohtml import Canonical, HTMLParseError, Markdown, ParseError, PlainText, annotation_surface, annotation_tags
12from turbohtml.__main__ import main
13from turbohtml.conformance import ConformanceMessage, ConformanceReport, Severity, check, check_html
14from turbohtml.convert import (
15 ExpressionError,
16 GenericTranslator,
17 HTMLTranslator,
18 SelectorError,
19 css_specificity,
20 css_to_xpath,
21)
22from turbohtml.cssom import ComputedStyle, RuleList, StyleDeclaration, StyleRule, StyleSheet, computed_style
23from turbohtml.transform import Transform, strparam, transform
24from turbohtml.validate import RelaxNG, SchemaValidationError, ValidationError, ValidationResult, XMLSchema
26from .atheris_invariants import assert_invariant
27from .atheris_registry import Target
28from .round_trip_oracles import OutOfScopeError, selector_entry_check, style_check, xml_check
30if TYPE_CHECKING:
31 from collections.abc import Callable
33__all__ = [
34 "cli_observation",
35 "computed_observation",
36 "conformance_observation",
37 "cssom_observation",
38 "reference_targets",
39 "render_observation",
40 "schema_observation",
41 "selector_observation",
42 "transform_observation",
43]
46_SELECTOR_DOCUMENT: Final = '<!doctype html><div id="a"><p class="x">one</p><p>two</p></div>'
49def reference_targets() -> tuple[Target, ...]:
50 """Keep each grammar and its documented rejection set independent."""
51 return (
52 Target(
53 "html-render",
54 _render,
55 (
56 "turbohtml.Canonical",
57 "turbohtml.Markdown",
58 "turbohtml.PlainText",
59 "turbohtml.annotation_surface",
60 "turbohtml.annotation_tags",
61 "turbohtml.escape",
62 "turbohtml.unescape",
63 "turbohtml.__version__",
64 ),
65 (UnicodeDecodeError,),
66 ),
67 Target(
68 "html-conformance",
69 _conformance,
70 (
71 "turbohtml.conformance.ConformanceMessage",
72 "turbohtml.conformance.ConformanceReport",
73 "turbohtml.conformance.Severity",
74 "turbohtml.conformance.check",
75 "turbohtml.conformance.check_html",
76 "turbohtml.HTMLParseError",
77 "turbohtml.ParseError",
78 ),
79 (UnicodeDecodeError,),
80 ),
81 Target(
82 "css-translate",
83 _selector,
84 (
85 "turbohtml.convert.ExpressionError",
86 "turbohtml.convert.GenericTranslator",
87 "turbohtml.convert.HTMLTranslator",
88 "turbohtml.convert.SelectorError",
89 "turbohtml.convert.SelectorSyntaxError",
90 "turbohtml.convert.css_specificity",
91 "turbohtml.convert.css_to_xpath",
92 "turbohtml.SelectorSyntaxError",
93 ),
94 (UnicodeDecodeError, turbohtml.SelectorSyntaxError, ExpressionError, OutOfScopeError),
95 ),
96 Target(
97 "css-object-model",
98 _cssom,
99 (
100 "turbohtml.cssom.ComputedStyle",
101 "turbohtml.cssom.RuleList",
102 "turbohtml.cssom.StyleDeclaration",
103 "turbohtml.cssom.StyleRule",
104 "turbohtml.cssom.StyleSheet",
105 "turbohtml.cssom.computed_style",
106 ),
107 (UnicodeDecodeError,),
108 ),
109 Target(
110 "xml-schema",
111 _schema,
112 (
113 "turbohtml.parse_xml",
114 "turbohtml.validate.RelaxNG",
115 "turbohtml.validate.SchemaValidationError",
116 "turbohtml.validate.ValidationError",
117 "turbohtml.validate.ValidationResult",
118 "turbohtml.validate.XMLSchema",
119 ),
120 (UnicodeDecodeError, HTMLParseError),
121 ),
122 Target(
123 "xml-transform",
124 _transform,
125 (
126 "turbohtml.transform.Transform",
127 "turbohtml.transform.strparam",
128 "turbohtml.transform.transform",
129 ),
130 (UnicodeDecodeError,),
131 ),
132 Target("html-cli", _cli, ("turbohtml.__main__.main",), (UnicodeDecodeError,)),
133 )
136def _render(data: bytes) -> None:
137 render_observation(data)
140def _conformance(data: bytes) -> None:
141 conformance_observation(data)
144def _selector(data: bytes) -> None:
145 selector_observation(data)
148def _cssom(data: bytes) -> None:
149 cssom_observation(data)
152def _schema(data: bytes) -> None:
153 schema_observation(data)
156def _transform(data: bytes) -> None:
157 transform_observation(data)
160def _cli(data: bytes) -> None:
161 cli_observation(data)
164def render_observation(data: bytes) -> tuple[str, str, str, bytes]:
165 """Escaping text must preserve it through a parsed element."""
166 source: Final = data.decode("utf-8")
167 document: Final = turbohtml.parse(source)
168 _require("Escape round trip differs", condition=turbohtml.unescape(turbohtml.escape(source)) == source)
169 _require("Version result", condition=isinstance(turbohtml.__version__, str) and bool(turbohtml.__version__))
170 plain: Final = document.to_text(PlainText())
171 annotated, spans = document.to_annotated_text({"a": ("link",)}, PlainText())
172 _require("Annotated text differs", condition=annotated == plain)
173 surface: Final = annotation_surface(annotated, spans)
174 _require(
175 "Annotation values", condition=all(isinstance(value, str) for values in surface.values() for value in values)
176 )
177 return (
178 document.to_markdown(Markdown()),
179 plain,
180 annotation_tags(annotated, spans),
181 document.canonicalize(Canonical()),
182 )
185def conformance_observation(data: bytes) -> tuple[bool, tuple[str, ...], tuple[str, int, int] | None]:
186 """Strict parse diagnostics and conformance reports use separate records."""
187 source: Final = data.decode("utf-8")
188 report: Final = check(turbohtml.parse(source))
189 _require("Conformance entry points differ", condition=report == check_html(source))
190 _require("Conformance result type", condition=isinstance(report, ConformanceReport))
191 _require("Conformance verdict differs", condition=bool(report) == (not report.errors))
192 _require(
193 "Severity views differ",
194 condition=sorted(report.errors + report.warnings + report.infos) == sorted(report.messages),
195 )
196 _require(
197 "Conformance record fields",
198 condition=all(
199 isinstance(message, ConformanceMessage) and message.severity in get_args(Severity)
200 for message in report.messages
201 ),
202 )
203 try:
204 turbohtml.parse(source, strict=True)
205 except HTMLParseError as error:
206 _require("Parse error record", condition=isinstance(error.error, ParseError))
207 diagnostic = (error.error.code, error.error.line, error.error.col)
208 else:
209 diagnostic = None
210 return report.valid, tuple(message.code for message in report.messages), diagnostic
213def selector_observation(
214 data: bytes, entries: Callable[[str], str | None] = selector_entry_check
215) -> tuple[str, tuple[tuple[int, int, int], ...]]:
216 """Both translator wrappers must retain selector specificity and node selection."""
217 selector: Final = data.decode("utf-8")
218 assert_invariant(entries, json.dumps({"html": _SELECTOR_DOCUMENT, "css": selector}))
219 try:
220 expression: Final = css_to_xpath(selector)
221 except ExpressionError as error:
222 _require("Expression error ancestry", condition=isinstance(error, SelectorError))
223 raise
224 _require("Generic translator differs", condition=GenericTranslator().css_to_xpath(selector) == expression)
225 _require("HTML translator differs", condition=HTMLTranslator().css_to_xpath(selector) == expression)
226 document: Final = turbohtml.parse(_SELECTOR_DOCUMENT)
227 _require(
228 "Selector translation differs",
229 condition=tuple(node.serialize() for node in document.select(selector))
230 == tuple(node.serialize() for node in cast("list[turbohtml.Node]", document.xpath(expression))),
231 )
232 return expression, tuple(css_specificity(selector))
235def cssom_observation(
236 data: bytes, style: Callable[[str], str | None] = style_check
237) -> tuple[tuple[str, str, bool], ...]:
238 """Preserve declaration records through rule attachment and a ``StyleDeclaration.text`` re-read."""
239 source: Final = data.decode("utf-8")
240 assert_invariant(style, source)
241 declaration: Final = StyleDeclaration.parse(source)
242 observed: Final = tuple((name, declaration[name], declaration.important(name)) for name in declaration)
243 _require("Property order differs", condition=declaration.properties() == tuple(name for name, _, _ in observed))
244 _require("Declaration length differs", condition=len(declaration) == len(observed))
245 _require(
246 "Declaration lookup differs",
247 condition=all(name in declaration and declaration.get(name) == value for name, value, _ in observed),
248 )
249 rebuilt: Final = StyleDeclaration(observed)
250 _require(
251 "Declaration reconstruction differs",
252 condition=tuple((name, rebuilt[name], rebuilt.important(name)) for name in rebuilt) == observed,
253 )
254 rule: Final = StyleRule("p", declaration)
255 rules: Final = RuleList((rule,))
256 _require("Rule wrapper differs", condition=tuple(rules) == (rules[0],) and rules[0].style is declaration)
257 if _rule_close(declaration.text) == " }":
258 sheet: Final = StyleSheet("p { " + declaration.text + " }")
259 if len(sheet.rules) == 1 and sheet.rules[0].selector_text == "p":
260 _require("Sheet declaration differs", condition=sheet.rules[0].style.text == declaration.text)
261 computed_observation(data)
262 return observed
265def _rule_close(text: str) -> str | None:
266 """
267 Return what closes ``p { <text>`` so the rule body holds text as written, or None when no closer can.
269 A rule body reads its declarations nested, so a top-level ``}`` ends the rule where a style attribute keeps it as a
270 value (CSS Syntax 3 §5.5.7). A block, string, comment or escape still open at the end of text runs to the end of
271 input (§5.5.8, §4.3.5, §4.3.2, §4.3.7) and would swallow a written ``}``, so the rule ends at end of input as well.
272 """
273 closers: list[str] = []
274 pos = 0
275 while pos < len(text):
276 char = text[pos]
277 if char in "([{":
278 closers.append(")]}"["([{".index(char)])
279 elif closers and char == closers[-1]:
280 closers.pop()
281 elif not closers and char == "}":
282 return None
283 elif (pos := _opaque_end(text, pos)) < 0:
284 return ""
285 pos += 1
286 return "" if closers else " }"
289def _opaque_end(text: str, pos: int) -> int:
290 """Return the last offset of the escape, comment or string at pos, pos for any other code point, or -1 if open."""
291 if text[pos] == "\\":
292 return pos + 1 if pos + 1 < len(text) else -1
293 if text.startswith("/*", pos):
294 return end + 1 if (end := text.find("*/", pos + 2)) >= 0 else -1
295 if (quote := text[pos]) in "\"'":
296 pos += 1
297 while pos < len(text) and text[pos] not in {quote, "\n"}:
298 pos += 2 if text[pos] == "\\" else 1
299 return pos if pos < len(text) and text[pos] == quote else -1
300 return pos
303def computed_observation(data: bytes) -> str:
304 """Attach the declarations to the element whose cascade consumes them."""
305 declaration: Final = StyleDeclaration.parse(data.decode("utf-8"))
306 document: Final = turbohtml.parse("<style></style><p>x</p>")
307 document.select("style")[0].set_text("p { color: black; " + declaration.text + " }")
308 computed: Final = computed_style(document.select("p")[0])
309 _require("Computed result type", condition=isinstance(computed, ComputedStyle))
310 _require(
311 "Computed lookup differs",
312 condition=all(name in computed and computed.get(name) == computed[name] for name in computed),
313 )
314 _require(
315 "Computed reconstruction differs",
316 condition=ComputedStyle(tuple((name, computed[name]) for name in computed)).properties()
317 == computed.properties(),
318 )
319 _require("Computed color missing", condition=computed.get("color") == computed["color"])
320 return computed["color"]
323def schema_observation(data: bytes, xml: Callable[[str], str | None] = xml_check) -> tuple[bool, bool]:
324 """Compiled schema verdicts and assertion errors must describe the same instance, and XML output must re-read."""
325 source: Final = data.decode("utf-8")
326 assert_invariant(xml, source)
327 document: Final = turbohtml.parse_xml(source)
328 schemas: Final = (
329 XMLSchema(
330 '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">'
331 '<xs:element name="root" type="xs:string"/></xs:schema>'
332 ),
333 RelaxNG('<element xmlns="http://relaxng.org/ns/structure/1.0" name="root"><text/></element>'),
334 )
335 verdicts: list[bool] = []
336 for schema in schemas:
337 result: Final = schema.validate(document)
338 _require("Validation result type", condition=isinstance(result, ValidationResult))
339 _require("Validation verdict differs", condition=bool(result) == schema.is_valid(document))
340 _require(
341 "Validation error records",
342 condition=all(isinstance(error, ValidationError) and bool(error.message) for error in result.errors),
343 )
344 try:
345 schema.assert_valid(document)
346 except SchemaValidationError as error:
347 _require("Validation assertion differs", condition=not result.valid and error.errors == result.errors)
348 else:
349 _require("Validation assertion accepted invalid instance", condition=result.valid)
350 verdicts.append(result.valid)
351 return verdicts[0], verdicts[1]
354def transform_observation(data: bytes) -> str:
355 """Parameters must remain string values even when they contain XPath quotes."""
356 source: Final = data.decode("utf-8")
357 stylesheet: Final = turbohtml.parse_xml(
358 '<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">'
359 '<xsl:output method="text"/><xsl:param name="value"/><xsl:template match="/">'
360 '<xsl:value-of select="$value"/></xsl:template></xsl:stylesheet>'
361 )
362 document: Final = turbohtml.parse_xml("<root/>")
363 quoted: Final = strparam(source)
364 observed: Final = Transform(stylesheet, allow_imports=False)(document, value=quoted)
365 _require("Transform parameter differs", condition=observed == source)
366 _require(
367 "Transform entry points differ",
368 condition=transform(stylesheet, document, allow_imports=False, value=quoted) == observed,
369 )
370 return observed
373def cli_observation(data: bytes) -> str:
374 """File-based CLI output must match the library conversion."""
375 source: Final = data.decode("utf-8")
376 with TemporaryDirectory(prefix="turbohtml-atheris-") as directory:
377 input_file: Final = Path(directory) / "input.html"
378 output_file: Final = Path(directory) / "output.txt"
379 input_file.write_text(source, encoding="utf-8")
380 _require("CLI exit status", condition=main(("to-text", str(input_file), "-o", str(output_file))) == 0)
381 observed: Final = output_file.read_text(encoding="utf-8")
382 _require("CLI result differs", condition=observed == turbohtml.parse(source).to_text())
383 return observed
386def _require(message: str, *, condition: bool) -> None:
387 if not condition:
388 raise AssertionError(message)