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

171 statements  

1"""Reference-shaped consumers must agree with their compiled public entry points.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7from tempfile import TemporaryDirectory 

8from typing import TYPE_CHECKING, Final, cast, get_args 

9 

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 

25 

26from .atheris_invariants import assert_invariant 

27from .atheris_registry import Target 

28from .round_trip_oracles import OutOfScopeError, selector_entry_check, style_check, xml_check 

29 

30if TYPE_CHECKING: 

31 from collections.abc import Callable 

32 

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] 

44 

45 

46_SELECTOR_DOCUMENT: Final = '<!doctype html><div id="a"><p class="x">one</p><p>two</p></div>' 

47 

48 

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 ) 

134 

135 

136def _render(data: bytes) -> None: 

137 render_observation(data) 

138 

139 

140def _conformance(data: bytes) -> None: 

141 conformance_observation(data) 

142 

143 

144def _selector(data: bytes) -> None: 

145 selector_observation(data) 

146 

147 

148def _cssom(data: bytes) -> None: 

149 cssom_observation(data) 

150 

151 

152def _schema(data: bytes) -> None: 

153 schema_observation(data) 

154 

155 

156def _transform(data: bytes) -> None: 

157 transform_observation(data) 

158 

159 

160def _cli(data: bytes) -> None: 

161 cli_observation(data) 

162 

163 

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 ) 

183 

184 

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 

211 

212 

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)) 

233 

234 

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 

263 

264 

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. 

268 

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 " }" 

287 

288 

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 

301 

302 

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"] 

321 

322 

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] 

352 

353 

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 

371 

372 

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 

384 

385 

386def _require(message: str, *, condition: bool) -> None: 

387 if not condition: 

388 raise AssertionError(message)