Skip to content

Commit 9d1129c

Browse files
committed
CSS selector fuzz: harden fuzzer follow-ups
1 parent 42c267a commit 9d1129c

11 files changed

Lines changed: 369 additions & 104 deletions

tools/css-selector-fuzz/FINDINGS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ completely clean, and the lexbor differential (third independent oracle) agreed
1717
with the reference matcher on every compared no-quirks case (0 `lexbor-divergence`).
1818
Caveats on the strength of that agreement: roughly half of the `compared`
1919
cases (and ~62% of all match assertions across buckets) are vacuous `[] == []`;
20-
quirks-mode class/ID matching is excluded from the differential (lexbor #368)
21-
and so rests on `ReferenceMatcher` alone. See `README.md` for the full
22-
disclosure.
20+
older lexbor builds with #368 exclude quirks-mode class/ID matching from the
21+
differential, though current harnesses include it when the startup probe reports
22+
reliable class/#id behavior in both no-quirks and quirks mode. See `README.md`
23+
for the full disclosure.
2324

2425
Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '<sel>' [--html '<html>']`.
2526
Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed <seed>`

tools/css-selector-fuzz/NEXT-STEPS.md

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,17 @@
1414
> (`CSS selector:` commits `7419a9fef6` / `0cefeb2fc8` / `16d03e2c5f`), each
1515
> with PHPUnit regression tests. A post-fix 5000-seed run is clean.
1616
>
17-
> **Open follow-up hardening (post-review):** `tests/self-check.php` runs its
18-
> parse-expectation assertions over a fixed seed window (1–400) that, against
19-
> an *unfixed* core, dodges the known core bugs only by seed luck. On this
20-
> branch the bugs are fixed so the collision risk is gone, but the hazard
21-
> returns whenever the tooling runs against a core without the fixes (e.g.
22-
> cherry-picked onto trunk before the fixes land) or when a future unfixed bug
23-
> is found. Decouple self-check from unfixed core bugs — e.g. allowlist known
24-
> signatures in the parse-expectation loop — as a standalone hardening. This is
25-
> worth doing on its own (it makes self-check robust to *any* future generator
26-
> change) and is the prerequisite for randomized class-NUL document injection.
17+
> **Fuzzer-side follow-up hardening implemented (2026-06-12):**
18+
> `tests/self-check.php` now allowlists known core parse-bug signatures in its
19+
> fixed seed-window parse-expectation loop, while unknown mismatches still fail.
20+
> The safe and wild document generators now inject NUL into random class tokens
21+
> and expose the decoded U+FFFD token to class-selector generation, without
22+
> leaking raw class values into the generic attribute-value pool. The lexbor
23+
> differential includes quirks documents whenever the startup probe confirms
24+
> class/#id behavior in both no-quirks and quirks mode (local master-built
25+
> harness `3a2d595fe8c50e5076ac79c02b2ded79a777bb52` passes), and `runner.php`
26+
> reports per-bucket/per-target vacuous and non-vacuous match assertion rates
27+
> under `matchStats`.
2728
>
2829
> **Candidate finding 4 — FIXED:** per CSS Syntax 3 §4.3.8, `\` followed by
2930
> EOF is a valid escape (EOF is not a newline), and §4.3.7 says consuming it
@@ -175,19 +176,15 @@
175176
> was skipped deliberately: it is near-tautological (it could only catch
176177
> a `from_selectors()` bypass, and no public path bypasses it).
177178
>
178-
> **Still open from the original follow-up list:** the tooling items in
179-
> this file's hardening notes (self-check decoupling, class-NUL injection,
180-
> vacuous-assertion rate, quirks-mode single-oracle gap). New small item
181-
> from the 2026-06-11 review: `gen_chaos()`'s whole-codepoint `unicode`
182-
> branch is dead code — it compares the alphabet *string* against the key
183-
> `'unicode'` after the value lookup already happened — so the unicode
184-
> alphabet is byte-sliced by the generic fallback instead. That slicing is
185-
> what makes chaos emit invalid UTF-8 organically (~15% of chaos cases),
186-
> so making the branch live is a behavior decision, not just a cleanup:
187-
> it would remove chaos's organic ill-formed-byte production, leaving the
188-
> deliberate paths (`invalid-utf8` bucket, `mutated` splice) plus
189-
> `mutated`'s residual organic corruption of pool multibyte characters
190-
> (~2% of mutated cases even without the splice).
179+
> **Still open:** `gen_chaos()`'s whole-codepoint `unicode` branch is dead
180+
> code — it compares the alphabet *string* against the key `'unicode'` after
181+
> the value lookup already happened — so the unicode alphabet is byte-sliced
182+
> by the generic fallback instead. That slicing is what makes chaos emit
183+
> invalid UTF-8 organically (~15% of chaos cases), so making the branch live
184+
> is a behavior decision, not just a cleanup: it would remove chaos's organic
185+
> ill-formed-byte production, leaving the deliberate paths (`invalid-utf8`
186+
> bucket, `mutated` splice) plus `mutated`'s residual organic corruption of
187+
> pool multibyte characters (~2% of mutated cases even without the splice).
191188
192189
Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch
193190
`html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`).

tools/css-selector-fuzz/README.md

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ produces the same document, the same selector, and the same verdict.
4545
path-directed generation is that the *combinator/breadcrumb* walker —
4646
the part most likely to harbor a matching bug — is now exercised with
4747
real depth, not that every assertion is non-vacuous.
48+
`runner.php` persists per-bucket/per-target match assertion counts and
49+
vacuous/non-vacuous rates under `matchStats` in `state.json`, so this
50+
distribution is reported on every run instead of relying on stale notes.
4851
- `unsupported` — valid CSS the API intentionally rejects (pseudo-classes
4952
and -elements, `+`/`~`/`||` combinators, namespaces, non-type context
5053
selectors); must not parse.
@@ -88,15 +91,16 @@ produces the same document, the same selector, and the same verdict.
8891
Skipped for ASTs containing invalid UTF-8 (reachable only from
8992
chaos/mutated inputs), which the renderer cannot round-trip.
9093
- lexbor differential (third, independent oracle; requires the harness —
91-
see below): on no-quirks documents whose selector parsed, a canonical
94+
see below): on full-document cases whose selector parsed, a canonical
9295
re-render of the verified AST is matched by liblexbor and compared,
93-
as a multiset of fids, against the reference matcher. Gated on WP and
96+
as a multiset of fids, against the reference matcher. Quirks documents
97+
participate only when the startup probe confirms lexbor's class/#id
98+
folding behavior in both no-quirks and quirks mode. Gated on WP and
9499
lexbor building the same element tree (fid/tag/ancestry), so it tests
95-
the selector layer, not tree construction. Verdicts: `lexbor-divergence`
96-
(lexbor ≠ reference) is a fuzzer-oracle problem; `match-mismatch-html`
97-
with no accompanying divergence means reference == lexbor ≠ WP — a
98-
high-confidence WP finding. (Roughly half of `compared` cases are
99-
themselves non-vacuous; the rest assert `[] == []` on both engines.)
100+
the selector layer, not tree construction. Verdicts:
101+
`lexbor-divergence` (lexbor ≠ reference) is a fuzzer-oracle problem;
102+
`match-mismatch-html` with no accompanying divergence means reference
103+
== lexbor ≠ WP — a high-confidence WP finding.
100104
- Repeating a case yields a byte-identical result digest (determinism).
101105
Note the digest covers the WP-under-test surface (selector, html,
102106
parse-nullness, ASTs, failure invariants) but **not** the lexbor
@@ -112,7 +116,8 @@ and reports per-batch tallies, persisted to `state.json` under `lexbor`:
112116

113117
- `compared` — the differential ran and matched fid-multisets.
114118
- `tree-gated` — WP and lexbor built different trees; differential skipped.
115-
- `skipped-quirks` / `skipped-utf8` — quirks document / non-UTF-8 AST.
119+
- `skipped-quirks` / `skipped-utf8` — quirks document while lexbor class/#id
120+
case behavior is not trusted / non-UTF-8 AST.
116121
- `n/a` — the differential does not apply (unparseable selector, fragment, no
117122
captured tree).
118123
- `unavailable` / `error` — the harness was missing or died. The runner prints
@@ -127,14 +132,9 @@ Known lexbor issues compensated for when present:
127132
case-sensitive). Detected by a startup probe; when present, lexbor is
128133
compared against the reference matcher run with quirks-style class/ID
129134
folding, and quirks-mode documents are excluded from the differential
130-
entirely. **Consequence — a real coverage hole:** quirks-mode class/ID
131-
matching has no independent third oracle. `ReferenceMatcher` is the sole
132-
authority there, and it encodes the same "ASCII-only case fold in quirks"
133-
reading WP does (both fold via ASCII-only lowercasing), so if that reading
134-
is wrong they would be wrong identically and lexbor — the one engine that
135-
could disagree — is excluded. This is inherent to lexbor #368 being open;
136-
it is the weakest-covered behavior in the suite and is called out here
137-
rather than papered over.
135+
entirely. The same startup probe also checks class and `#id` selectors in
136+
quirks mode; only when all four probes pass is quirks-mode class/ID matching
137+
included in the differential.
138138
- lexbor rejects uppercase `I`/`S` attribute-selector modifiers, and its
139139
non-ASCII ident-codepoint table omits U+00B7 and U+00C0–U+00F6 (it
140140
starts at U+00F8), rejecting e.g. `.Über` while accepting `.über`.
@@ -159,13 +159,11 @@ The match oracle's independence differs between class and attribute selectors:
159159
splits on ASCII whitespace and folds NUL → U+FFFD per token;
160160
`ReferenceMatcher::class_matches()` reimplements that independently (and is
161161
pinned against `class_list()` on NUL/FF boundary inputs by `self-check.php`).
162-
The random document generators do **not** emit control bytes inside class
163-
values, so the *randomized* fuzzing never exercises this boundary — it is
164-
covered only by the deterministic self-check cases. Randomized document-side
165-
injection is deliberately deferred: adding it to the hot path perturbs the
166-
deterministic self-check seed space enough to surface the known Bug 3, which
167-
would first require decoupling `self-check.php` from the unfixed core bugs.
168-
A worthwhile, scoped future improvement.
162+
The safe and wild random document generators now inject NUL into class
163+
tokens occasionally and expose the decoded U+FFFD token to class-selector
164+
generation. Raw class attribute values are intentionally kept out of the
165+
generic `attrValues` pool so attribute-selector generation does not inherit
166+
class-list-only decoding semantics.
169167
- **Attribute values are matched through a single shared read.** Both WP's
170168
attribute matcher and `ReferenceMatcher::attr_matches()` read the same
171169
`get_attribute()` output, so a value-decoding bug there would be shared and

tools/css-selector-fuzz/lib/DocumentGenerator.php

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ private function random_attrs(): array {
314314
}
315315

316316
$this->pools['attrNames'][] = ascii_strtolower( $name );
317-
if ( is_string( $value ) ) {
317+
if ( is_string( $value ) && 'class' !== $lower ) {
318318
$this->pools['attrValues'][] = $value;
319319
}
320320

@@ -328,9 +328,12 @@ private function random_class_value(): string {
328328
$count = $this->prng->int( 1, 4 );
329329
$classes = array();
330330
for ( $i = 0; $i < $count; $i++ ) {
331-
$class = $this->random_word( true );
332-
$classes[] = $class;
333-
$this->pools['classes'][] = $class;
331+
$class = $this->random_word( true );
332+
$raw_class = $this->maybe_inject_class_nul( $class );
333+
$classes[] = $raw_class;
334+
foreach ( self::class_tokens( $raw_class ) as $token ) {
335+
$this->pools['classes'][] = $token;
336+
}
334337
}
335338

336339
$ws = array( ' ', ' ', ' ', "\t", "\n", "\f", ' ' );
@@ -347,6 +350,23 @@ private function random_class_value(): string {
347350
return $value;
348351
}
349352

353+
private function maybe_inject_class_nul( string $class ): string {
354+
if ( '' === $class || ! $this->prng->chance( 12 ) ) {
355+
return $class;
356+
}
357+
358+
$points = utf8_codepoints( $class );
359+
$at = $this->prng->int( 0, count( $points ) );
360+
$out = '';
361+
foreach ( $points as $i => $point ) {
362+
if ( $i === $at ) {
363+
$out .= "\0";
364+
}
365+
$out .= $point[0];
366+
}
367+
return $at === count( $points ) ? $out . "\0" : $out;
368+
}
369+
350370
private function random_id_value(): string {
351371
$id = $this->random_word( true );
352372
$this->pools['ids'][] = $id;
@@ -581,4 +601,28 @@ public static function get_attribute_value( array $element, string $name ) {
581601
}
582602
return null;
583603
}
604+
605+
/**
606+
* Class tokens as seen by selector matching: ASCII whitespace separates
607+
* tokens, and NUL inside a token is exposed as U+FFFD by class_list().
608+
*
609+
* @return string[]
610+
*/
611+
public static function class_tokens( string $class_value ): array {
612+
$tokens = array();
613+
$length = strlen( $class_value );
614+
$at = 0;
615+
$ws = " \t\r\n\f";
616+
while ( $at < $length ) {
617+
$at += strspn( $class_value, $ws, $at );
618+
if ( $at >= $length ) {
619+
break;
620+
}
621+
622+
$token_length = strcspn( $class_value, $ws, $at );
623+
$tokens[] = str_replace( "\0", "\u{FFFD}", substr( $class_value, $at, $token_length ) );
624+
$at += $token_length;
625+
}
626+
return $tokens;
627+
}
584628
}

tools/css-selector-fuzz/lib/LexborOracle.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
* ASCII case-insensitively even in no-quirks mode ( attribute selectors
2020
* like [id=x] are correctly case-sensitive ). Detected by probe at startup;
2121
* when present, lexbor is compared against the reference matcher run with
22-
* quirks-style class/ID folding.
22+
* quirks-style class/ID folding. Quirks documents are compared only when
23+
* the probe also confirms class and #id selectors fold in quirks mode.
2324
*/
2425
class LexborOracle {
2526

@@ -33,6 +34,8 @@ class LexborOracle {
3334
private static $available = null;
3435
/** @var bool */
3536
private static $issue368 = false;
37+
/** @var bool */
38+
private static $quirks_class_id_reliable = false;
3639

3740
public static function harness_path(): string {
3841
return dirname( __DIR__ ) . '/lexbor/harness';
@@ -49,15 +52,31 @@ public static function available(): bool {
4952
return false;
5053
}
5154

52-
// Probe: sanity plus issue-#368 detection.
55+
// Probe: sanity plus class/#id case-sensitivity behavior.
5356
$sane = self::query( '<!DOCTYPE html><div class="a" data-fid="x"></div>', 'div.a' );
5457
if ( null === $sane || array( 'x' ) !== $sane['matches'] ) {
5558
self::stop();
5659
return false;
5760
}
5861

59-
$folded = self::query( '<!DOCTYPE html><div class="a" data-fid="x"></div>', '.A' );
60-
self::$issue368 = null !== $folded && array( 'x' ) === $folded['matches'];
62+
$no_quirks_class = self::query( '<!DOCTYPE html><div class="a" data-fid="x"></div>', '.A' );
63+
$no_quirks_id = self::query( '<!DOCTYPE html><div id="a" data-fid="x"></div>', '#A' );
64+
$quirks_class = self::query( '<div class="a" data-fid="x"></div>', '.A' );
65+
$quirks_id = self::query( '<div id="a" data-fid="x"></div>', '#A' );
66+
foreach ( array( $no_quirks_class, $no_quirks_id, $quirks_class, $quirks_id ) as $probe ) {
67+
if ( null === $probe || null !== $probe['error'] ) {
68+
self::stop();
69+
return false;
70+
}
71+
}
72+
73+
self::$issue368 = array( 'x' ) === $no_quirks_class['matches']
74+
|| array( 'x' ) === $no_quirks_id['matches'];
75+
self::$quirks_class_id_reliable = ! self::$issue368
76+
&& array() === $no_quirks_class['matches']
77+
&& array() === $no_quirks_id['matches']
78+
&& array( 'x' ) === $quirks_class['matches']
79+
&& array( 'x' ) === $quirks_id['matches'];
6180
self::$available = true;
6281
return true;
6382
}
@@ -67,6 +86,11 @@ public static function has_issue_368(): bool {
6786
return self::$issue368;
6887
}
6988

89+
/** Whether lexbor can be trusted on quirks class/#id case folding. */
90+
public static function quirks_class_id_reliable(): bool {
91+
return self::$quirks_class_id_reliable;
92+
}
93+
7094
/**
7195
* Runs one case through lexbor.
7296
*

tools/css-selector-fuzz/lib/ReferenceMatcher.php

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -223,25 +223,7 @@ private static function class_matches( string $wanted, array $row, bool $quirks
223223
return false;
224224
}
225225

226-
$length = strlen( $class_value );
227-
$at = 0;
228-
while ( $at < $length ) {
229-
$at += strspn( $class_value, self::WHITESPACE, $at );
230-
if ( $at >= $length ) {
231-
break;
232-
}
233-
$word_length = strcspn( $class_value, self::WHITESPACE, $at );
234-
$word = substr( $class_value, $at, $word_length );
235-
$at += $word_length;
236-
237-
/*
238-
* WP_HTML_Tag_Processor::class_list() replaces NUL with U+FFFD in
239-
* each class token before comparison; model that so a class value
240-
* containing a raw NUL matches a `\0`-escaped ( U+FFFD ) selector
241-
* the same way select() does.
242-
*/
243-
$word = str_replace( "\0", "\u{FFFD}", $word );
244-
226+
foreach ( DocumentGenerator::class_tokens( $class_value ) as $word ) {
245227
if (
246228
$quirks
247229
? ascii_strtolower( $word ) === ascii_strtolower( $wanted )

tools/css-selector-fuzz/lib/SelectorGenerator.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ private function path_compound_for( array $element ): array {
957957

958958
$class_value = DocumentGenerator::get_attribute_value( $element, 'class' );
959959
if ( is_string( $class_value ) ) {
960-
foreach ( preg_split( '/[ \t\n\f\r]+/', $class_value, -1, PREG_SPLIT_NO_EMPTY ) as $word ) {
960+
foreach ( DocumentGenerator::class_tokens( $class_value ) as $word ) {
961961
$features[] = array( 'kind' => 'class', 'name' => $word );
962962
}
963963
}
@@ -974,6 +974,9 @@ private function path_compound_for( array $element ): array {
974974
continue;
975975
}
976976
$seen_attrs[ $lower ] = true;
977+
if ( 'class' === $lower && is_string( $attr[1] ) && false !== strpos( $attr[1], "\0" ) ) {
978+
continue;
979+
}
977980
$features[] = $this->path_attr_feature( $lower, $attr[1], 'html' === ( $element['namespace'] ?? 'html' ) );
978981
}
979982

tools/css-selector-fuzz/lib/WildDocumentGenerator.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,11 @@ private function random_attrs(): array {
299299
$words = array();
300300
$n = $this->prng->int( 1, 3 );
301301
for ( $j = 0; $j < $n; $j++ ) {
302-
$word = $this->random_word();
303-
$words[] = $word;
304-
$this->pools['classes'][] = $word;
302+
$word = $this->maybe_inject_class_nul( $this->random_word() );
303+
$words[] = $word;
304+
foreach ( DocumentGenerator::class_tokens( $word ) as $token ) {
305+
$this->pools['classes'][] = $token;
306+
}
305307
}
306308
$value = implode( ' ', $words );
307309
} elseif ( 'id' === $lower ) {
@@ -317,7 +319,7 @@ private function random_attrs(): array {
317319
}
318320

319321
$this->pools['attrNames'][] = $lower;
320-
if ( is_string( $value ) ) {
322+
if ( is_string( $value ) && 'class' !== $lower ) {
321323
$this->pools['attrValues'][] = $value;
322324
}
323325
$attrs[] = array( $name, $value );
@@ -326,6 +328,23 @@ private function random_attrs(): array {
326328
return $attrs;
327329
}
328330

331+
private function maybe_inject_class_nul( string $class ): string {
332+
if ( '' === $class || ! $this->prng->chance( 12 ) ) {
333+
return $class;
334+
}
335+
336+
$points = utf8_codepoints( $class );
337+
$at = $this->prng->int( 0, count( $points ) );
338+
$out = '';
339+
foreach ( $points as $i => $point ) {
340+
if ( $i === $at ) {
341+
$out .= "\0";
342+
}
343+
$out .= $point[0];
344+
}
345+
return $at === count( $points ) ? $out . "\0" : $out;
346+
}
347+
329348
private function render_attrs( array $attrs ): string {
330349
$out = '';
331350
foreach ( $attrs as $attr ) {

0 commit comments

Comments
 (0)