', 12 );
+$lexbor_deep = $oracle->render( $deep_html, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $deep_limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( 'depth-limit-exceeded' === ( $lexbor_deep['failureClass'] ?? null ), 'Expected Lexbor depth limiting.' );
+$wordpress_deep = \HtmlApiFuzz\TreeRenderer::render_wordpress( $deep_html, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $deep_limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( 'depth-limit-exceeded' === ( $wordpress_deep['failureClass'] ?? null ), 'Expected WordPress tree depth limiting.' );
+
+$byte_limits = $limits;
+$byte_limits['maxTreeBytes'] = 20;
+$lexbor_bytes = $oracle->render( '
tree output must exceed twenty bytes
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $byte_limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( 'tree-byte-limit-exceeded' === ( $lexbor_bytes['failureClass'] ?? null ), 'Expected Lexbor tree byte limiting.' );
+$wordpress_bytes = \HtmlApiFuzz\TreeRenderer::render_wordpress( '
tree output must exceed twenty bytes
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $byte_limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( 'tree-byte-limit-exceeded' === ( $wordpress_bytes['failureClass'] ?? null ), 'Expected WordPress tree byte limiting.' );
+
+$empty_worker_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( '' ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '100',
+ 'output-dir' => $work_dir . '/empty-fragment',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $empty_worker_result['ok'] ?? null ), 'Expected empty fragment Worker run to pass against the Lexbor source oracle.' );
+
+$processing_instruction = '
x';
+$rendered_processing_instruction = $oracle->render( $processing_instruction, \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_processing_instruction['status'] ?? null ), 'Expected Lexbor to parse a processing instruction.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_processing_instruction['tree'] ?? '', "\n" ), 'Expected Lexbor to render the processing-instruction target and data.' );
+$processing_instruction_worker = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $processing_instruction ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'profile' => 'replay',
+ 'seed' => '101',
+ 'output-dir' => $work_dir . '/processing-instruction',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $processing_instruction_worker['ok'] ?? null ), 'Expected a processing instruction to pass the WordPress/Lexbor differential.' );
+
+$escaped_tag_name = $oracle->render( ' ', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $escaped_tag_name['status'] ?? null ), 'Expected Lexbor to parse a quoted tag-name fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $escaped_tag_name['tree'] ?? '', "\n" ), 'Expected Lexbor to escape odd tag-name bytes in tree output.' );
+$dom_escaped_tag_name = \HtmlApiFuzz\TreeRenderer::render_dom( ' ', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_escaped_tag_name['status'] ?? null ), 'Expected PHP DOM oracle to parse the quoted tag-name fixture.' );
+html_api_fuzz_lexbor_smoke_assert( $dom_escaped_tag_name['tree'] === $escaped_tag_name['tree'], 'Expected Lexbor quoted tag-name tree to match PHP DOM escaping.' );
+
+$adjusted_svg_names = '
';
+$rendered_adjusted_svg_names = $oracle->render( $adjusted_svg_names, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_adjusted_svg_names['status'] ?? null ), 'Expected Lexbor to parse adjusted SVG names fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG foreignObject casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG altGlyph casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG linearGradient casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "attributeName=\"x\"" ), 'Expected Lexbor to render adjusted SVG attributeName casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "attributeType=\"XML\"" ), 'Expected Lexbor to render adjusted SVG attributeType casing.' );
+$dom_adjusted_svg_names = \HtmlApiFuzz\TreeRenderer::render_dom( $adjusted_svg_names, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_adjusted_svg_names['status'] ?? null ), 'Expected PHP DOM oracle to parse adjusted SVG names fixture.' );
+html_api_fuzz_lexbor_smoke_assert( $dom_adjusted_svg_names['tree'] === $rendered_adjusted_svg_names['tree'], 'Expected Lexbor adjusted SVG names tree to match PHP DOM.' );
+
+$issue_372 = ' ';
+$rendered_372 = $oracle->render( $issue_372, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_372['status'] ?? null ), 'Expected Lexbor to parse issue 372 fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_372['tree'] ?? '', "href=\"plain\"" ), 'Expected Lexbor to keep the bare href attribute from issue 372.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_372['tree'] ?? '', "xlink href=\"qual\"" ), 'Expected Lexbor to keep the namespaced xlink:href attribute from issue 372.' );
+
+$issue_373 = 'x k';
+$rendered_373 = $oracle->render( $issue_373, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_373['status'] ?? null ), 'Expected Lexbor to parse issue 373 fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_373['tree'] ?? '', "\n \"xk\"" ), 'Expected Lexbor to keep post-heading text in the MathML mi element for issue 373.' );
+
+$worker_result_372 = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $issue_372 ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '372',
+ 'output-dir' => $work_dir . '/issue-372',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $worker_result_372['ok'] ?? null ), 'Expected issue 372 to pass Worker against the Lexbor source oracle.' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $worker_result_372['oracle']['kind'] ?? null ), 'Expected Worker result to record Lexbor source oracle kind.' );
+
+$worker_replay_372 = \HtmlApiFuzz\read_json_file( $work_dir . '/issue-372/replay.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $worker_replay_372['options']['domOracle'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle kind.' );
+html_api_fuzz_lexbor_smoke_assert( $binary === ( $worker_replay_372['options']['lexborOracleBin'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle binary.' );
+html_api_fuzz_lexbor_smoke_assert( ( $metadata['lexborCommit'] ?? null ) === ( $worker_replay_372['oracle']['lexborCommit'] ?? null ), 'Expected replay metadata to preserve the Lexbor source commit.' );
+
+$replay_dir = $work_dir . '/issue-372-replay';
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $work_dir . '/issue-372/replay.json',
+ '--output-dir',
+ $replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $work_dir . '/replay.log'
+);
+html_api_fuzz_lexbor_smoke_assert( 0 === $proc['code'], 'Expected replay to pass while preserving the Lexbor source oracle.' );
+$replayed = \HtmlApiFuzz\read_json_file( $replay_dir . '/result.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $replayed['oracle']['kind'] ?? null ), 'Expected replayed result to use the Lexbor source oracle.' );
+
+$worker_result_373 = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $issue_373 ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '373',
+ 'output-dir' => $work_dir . '/issue-373',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $worker_result_373['ok'] ?? null ), 'Expected issue 373 to pass Worker against the Lexbor source oracle.' );
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+html_api_fuzz_lexbor_smoke_assert( ! is_dir( $work_dir ), 'Expected smoke work directory cleanup.' );
+
+echo "OK lexbor-oracle-smoke\n";
diff --git a/tools/html-api-fuzz/tests/result-store-smoke.php b/tools/html-api-fuzz/tests/result-store-smoke.php
new file mode 100644
index 0000000000000..6b9f5aa9b52c7
--- /dev/null
+++ b/tools/html-api-fuzz/tests/result-store-smoke.php
@@ -0,0 +1,234 @@
+#!/usr/bin/env php
+ 'php-dom',
+ 'phpVersion' => PHP_VERSION,
+);
+$lexbor_oracle = array(
+ 'kind' => 'lexbor-source',
+ 'lexborVersion' => '2.10.0',
+ 'lexborCommit' => '481c444261a132190a3fb746d6d2f60824af3717',
+ 'binary' => '/tmp/lexbor-tree-oracle',
+);
+
+$pass_summary = array(
+ 'kind' => 'attempt',
+ 'ok' => true,
+ 'status' => 'passed',
+ 'failureClass' => null,
+ 'seed' => 11,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'pass' ),
+ 'inputLength' => 4,
+ 'signature' => null,
+ 'oracle' => $php_oracle,
+ 'artifactsRetained' => false,
+ 'resultPath' => null,
+ 'replayPath' => null,
+ 'logPath' => null,
+ 'durationMs' => 12,
+ 'workerCode' => 0,
+ 'workerTimedOut' => false,
+);
+$pass_id = $store->record_attempt( $pass_summary );
+
+$failure_summary = array(
+ 'kind' => 'failure',
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'seed' => 12,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'fail' ),
+ 'inputLength' => 8,
+ 'signature' => array(
+ 'hash' => 'abc123def456',
+ 'familyKey' => 'fam456789abc',
+ ),
+ 'oracle' => $lexbor_oracle,
+ 'artifactsRetained' => true,
+ 'resultPath' => $work_dir . '/seed-12/primary/result.json',
+ 'replayPath' => $work_dir . '/seed-12/primary/replay.json',
+ 'logPath' => null,
+ 'durationMs' => 30,
+ 'workerCode' => 2,
+ 'workerTimedOut' => false,
+);
+$failure_result = array(
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'signature' => array( 'hash' => 'abc123def456' ),
+);
+$failure_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 12,
+ 'inputBase64' => base64_encode( 'fail ' ),
+);
+$failure_summary['failureArtifactsRetained'] = true;
+$failure_summary['oracleArtifactsRetained'] = false;
+$failure_id = $store->record_attempt( $failure_summary, $failure_result, $failure_replay );
+
+$pruned_summary = $failure_summary;
+$pruned_summary['seed'] = 13;
+$pruned_summary['artifactsRetained'] = false;
+$pruned_summary['failureArtifactsRetained'] = false;
+$pruned_summary['oracleArtifactsRetained'] = false;
+$pruned_summary['resultPath'] = null;
+$pruned_summary['replayPath'] = null;
+$pruned_id = $store->record_attempt( $pruned_summary, $failure_result, $failure_replay );
+
+$same_seed_summary = $pruned_summary;
+$same_seed_summary['signature'] = array(
+ 'hash' => 'newseedabc123',
+ 'familyKey' => 'newseedfam456',
+);
+$same_seed_result = array(
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'signature' => array( 'hash' => 'newseedabc123' ),
+);
+$same_seed_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 13,
+ 'inputBase64' => base64_encode( 'new replay ' ),
+);
+$same_seed_id = $store->record_attempt( $same_seed_summary, $same_seed_result, $same_seed_replay );
+
+$oracle_summary = array(
+ 'kind' => 'oracle-finding',
+ 'ok' => true,
+ 'status' => 'oracle-tolerated',
+ 'failureClass' => 'oracle-tolerated',
+ 'seed' => 14,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'oracle' ),
+ 'inputLength' => 12,
+ 'signature' => null,
+ 'oracle' => $php_oracle,
+ 'oracleFinding' => array(
+ 'classification' => 'oracle-bug',
+ 'type' => 'dom-xlink-dropped-local-name-after-xlink',
+ 'suspectedOwner' => 'Lexbor/PHP DOM',
+ 'signature' => array(
+ 'hash' => 'oracle-abc123',
+ 'familyKey' => 'oracle-fam123',
+ ),
+ ),
+ 'artifactsRetained' => true,
+ 'failureArtifactsRetained' => false,
+ 'oracleArtifactsRetained' => true,
+ 'resultPath' => $work_dir . '/seed-14/primary/result.json',
+ 'replayPath' => $work_dir . '/seed-14/primary/replay.json',
+ 'logPath' => null,
+ 'durationMs' => 18,
+ 'workerCode' => 0,
+ 'workerTimedOut' => false,
+);
+$oracle_result = array(
+ 'ok' => true,
+ 'status' => 'oracle-tolerated',
+ 'oracleFinding' => $oracle_summary['oracleFinding'],
+);
+$oracle_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 14,
+ 'inputBase64' => base64_encode( ' ' ),
+ 'oracleFinding' => $oracle_summary['oracleFinding'],
+);
+$oracle_id = $store->record_attempt( $oracle_summary, $oracle_result, $oracle_replay );
+
+html_api_fuzz_smoke_assert( 5 === $store->count_attempts(), 'Expected five recorded attempts.' );
+html_api_fuzz_smoke_assert( array( 12 ) === $store->retained_seeds( 'abc123def456' ), 'Expected seed 12 as the retained exemplar for the signature.' );
+html_api_fuzz_smoke_assert( array() === $store->retained_seeds( 'unseen' ), 'Expected no retained exemplars for an unseen signature.' );
+html_api_fuzz_smoke_assert( array( 14 ) === $store->oracle_retained_seeds( 'oracle-abc123' ), 'Expected seed 14 as the retained exemplar for the oracle signature.' );
+html_api_fuzz_smoke_assert( $store->seed_artifacts_retained( 12 ), 'Expected seed 12 to be marked as retained.' );
+html_api_fuzz_smoke_assert( ! $store->seed_artifacts_retained( 13 ), 'Expected seed 13 not to be marked as retained.' );
+html_api_fuzz_smoke_assert( 5 === $store->max_id(), 'Expected max id of five.' );
+
+$stored_replay = $store->replay_for_seed( 13 );
+html_api_fuzz_smoke_assert( is_array( $stored_replay ) && base64_encode( 'new replay ' ) === ( $stored_replay['inputBase64'] ?? null ), 'Expected seed replay lookup to return the most recent replay for compatibility.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $pruned_id ) ) && base64_encode( 'fail ' ) === ( $store->replay_for_attempt_id( $pruned_id )['inputBase64'] ?? null ), 'Expected exact attempt replay lookup to survive same-seed reruns.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $same_seed_id ) ) && base64_encode( 'new replay ' ) === ( $store->replay_for_attempt_id( $same_seed_id )['inputBase64'] ?? null ), 'Expected exact attempt replay lookup to retrieve the newer same-seed replay.' );
+html_api_fuzz_smoke_assert( null === $store->replay_for_seed( 11 ), 'Expected no stored replay for a passing seed.' );
+$stored_oracle_replay = $store->replay_for_seed( 14 );
+html_api_fuzz_smoke_assert( is_array( $stored_oracle_replay ) && base64_encode( ' ' ) === ( $stored_oracle_replay['inputBase64'] ?? null ), 'Expected the oracle finding replay to be retrievable from the store.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $oracle_id ) ) && base64_encode( ' ' ) === ( $store->replay_for_attempt_id( $oracle_id )['inputBase64'] ?? null ), 'Expected the oracle finding replay to be retrievable by attempt id.' );
+
+$failures = $store->failures_after( 0, $store->max_id() );
+html_api_fuzz_smoke_assert( 3 === count( $failures ), 'Expected three failure rows.' );
+html_api_fuzz_smoke_assert( 12 === ( $failures[0]['record']['seed'] ?? null ), 'Expected the first failure record to be seed 12.' );
+html_api_fuzz_smoke_assert( 'abc123def456' === ( $failures[0]['record']['signature']['hash'] ?? null ), 'Expected the failure record to carry its signature.' );
+
+$tail = $store->failures_after( $failures[0]['id'], $store->max_id() );
+html_api_fuzz_smoke_assert( 2 === count( $tail ) && 13 === ( $tail[0]['record']['seed'] ?? null ) && 13 === ( $tail[1]['record']['seed'] ?? null ), 'Expected incremental reads to resume after an offset.' );
+
+$oracle_findings = $store->oracle_findings_after( 0, $store->max_id() );
+html_api_fuzz_smoke_assert( 1 === count( $oracle_findings ), 'Expected one oracle finding row.' );
+html_api_fuzz_smoke_assert( 14 === ( $oracle_findings[0]['record']['seed'] ?? null ), 'Expected the oracle finding record to be seed 14.' );
+html_api_fuzz_smoke_assert( 'oracle-abc123' === ( $oracle_findings[0]['record']['oracleFinding']['signature']['hash'] ?? null ), 'Expected the oracle finding record to carry its oracle signature.' );
+
+$store->close();
+
+// Reopen read-only as the watcher does and confirm persistence.
+$reader = new \HtmlApiFuzz\ResultStore( $db_path, true );
+html_api_fuzz_smoke_assert( 5 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
+html_api_fuzz_smoke_assert( 3 === count( $reader->failures_after( 0, $reader->max_id() ) ), 'Expected failures to persist across reopen.' );
+html_api_fuzz_smoke_assert( 1 === count( $reader->oracle_findings_after( 0, $reader->max_id() ) ), 'Expected oracle findings to persist across reopen.' );
+$reader->close();
+
+// The grouping columns must be queryable without json_extract.
+$raw = new SQLite3( $db_path, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 2 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE family_key = 'fam456789abc'" ), 'Expected family_key to be stored per failure row.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_family_key = 'oracle-fam123'" ), 'Expected oracle_family_key to be stored per oracle finding row.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_signature_hash = 'oracle-abc123'" ), 'Expected oracle_signature_hash to be queryable.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE signature_hash = 'abc123def456' AND failure_artifacts_retained = 1" ), 'Expected failure retention to use its own budget flag.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_signature_hash = 'oracle-abc123' AND oracle_artifacts_retained = 1" ), 'Expected oracle retention to use its own budget flag.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 11 AND oracle_kind = 'php-dom' AND oracle_version = '" . SQLite3::escapeString( PHP_VERSION ) . "'" ), 'Expected passing rows to keep PHP DOM oracle metadata in scalar columns.' );
+html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_kind = 'lexbor-source' AND oracle_version = '2.10.0' AND oracle_commit = '481c444261a132190a3fb746d6d2f60824af3717'" ), 'Expected Lexbor oracle metadata to be queryable for failure rows.' );
+html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_binary = '/tmp/lexbor-tree-oracle'" ), 'Expected Lexbor oracle binary to be stored in a scalar column.' );
+$raw->close();
+
+$future_db_path = $work_dir . '/future.sqlite';
+$future = new SQLite3( $future_db_path, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE );
+$future->exec( 'PRAGMA user_version = 99' );
+$future->close();
+$future_store = new \HtmlApiFuzz\ResultStore( $future_db_path );
+$future_store->close();
+$future = new SQLite3( $future_db_path, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 99 === (int) $future->querySingle( 'PRAGMA user_version' ), 'Opening a future schema should not downgrade user_version.' );
+$future->close();
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+html_api_fuzz_smoke_assert( ! is_dir( $work_dir ), 'Expected remove_dir_recursive to delete the work directory.' );
+
+echo "OK result-store-smoke\n";
diff --git a/tools/html-api-fuzz/tests/runner-retention-smoke.php b/tools/html-api-fuzz/tests/runner-retention-smoke.php
new file mode 100644
index 0000000000000..2189cf84b4c6f
--- /dev/null
+++ b/tools/html-api-fuzz/tests/runner-retention-smoke.php
@@ -0,0 +1,232 @@
+#!/usr/bin/env php
+querySingle( 'SELECT COUNT(*) FROM attempts' ), 'Expected six recorded attempts.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 1 AND artifacts_retained = 1' ), 'Expected no retained artifacts for passing attempts.' );
+$rows = $db->query( 'SELECT seed, artifacts_retained FROM attempts' );
+while ( false !== ( $row = $rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert(
+ is_dir( $run_dir . '/seed-' . $row['seed'] ) === (bool) $row['artifacts_retained'],
+ "Expected seed {$row['seed']} directory presence to match artifacts_retained={$row['artifacts_retained']}."
+ );
+}
+$db->close();
+
+/*
+ * 2. The failure path, deterministically: --fail-unsupported turns the many
+ * unsupported fragment contexts into failures with repeating signatures, so
+ * a cap of 1 must prune repeats while archiving their replay documents.
+ */
+$cap = 1;
+$fail_run_dir = $work_dir . '/fail-run';
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $runner,
+ '--output-dir',
+ $fail_run_dir,
+ '--max-seeds',
+ '40',
+ '--duration-seconds',
+ '0',
+ '--batch-size',
+ '10',
+ '--max-input-bytes',
+ '512',
+ '--fail-unsupported',
+ '--max-keep-per-signature',
+ (string) $cap,
+ ),
+ $repo_root,
+ 300000
+);
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected failure-path runner to exit cleanly: ' . substr( $proc['output'], -1000 ) );
+
+$db = new SQLite3( $fail_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME, SQLITE3_OPEN_READONLY );
+// Precondition guard: this run must actually exercise pruning. If generator
+// or signature changes stop producing repeated signatures here, fail loudly
+// so the test can be re-tuned instead of silently going vacuous.
+$pruned_failures = (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND artifacts_retained = 0' );
+html_api_fuzz_smoke_assert( $pruned_failures > 0, 'Expected the failure-path run to prune at least one over-cap failure; re-tune the seed range.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND summary_json IS NULL' ), 'Expected failures to store their summary JSON.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND artifacts_retained = 0 AND replay_json IS NULL' ), 'Expected pruned failures to archive their replay JSON.' );
+
+$max_retained_per_signature = (int) $db->querySingle(
+ 'SELECT COALESCE( MAX( n ), 0 ) FROM ( SELECT COUNT(*) AS n FROM attempts WHERE artifacts_retained = 1 AND signature_hash IS NOT NULL GROUP BY signature_hash )'
+);
+html_api_fuzz_smoke_assert( $max_retained_per_signature <= $cap, 'Expected retained exemplars per signature to respect the cap.' );
+
+// Every signature with failures keeps its first exemplar on disk.
+$sig_rows = $db->query( 'SELECT signature_hash, MAX(artifacts_retained) AS retained FROM attempts WHERE ok = 0 AND signature_hash IS NOT NULL GROUP BY signature_hash' );
+while ( false !== ( $row = $sig_rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert( 1 === (int) $row['retained'], "Expected signature {$row['signature_hash']} to retain its first exemplar." );
+}
+
+$rows = $db->query( 'SELECT seed, artifacts_retained FROM attempts' );
+while ( false !== ( $row = $rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert(
+ is_dir( $fail_run_dir . '/seed-' . $row['seed'] ) === (bool) $row['artifacts_retained'],
+ "Expected seed {$row['seed']} directory presence to match artifacts_retained={$row['artifacts_retained']}."
+ );
+}
+
+$pruned = $db->querySingle( 'SELECT seed, signature_hash FROM attempts WHERE ok = 0 AND artifacts_retained = 0 LIMIT 1', true );
+$db->close();
+
+// A pruned failure must be reproducible from the store alone.
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $replay,
+ '--store',
+ $fail_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME,
+ '--seed',
+ (string) $pruned['seed'],
+ '--output-dir',
+ $work_dir . '/store-replay',
+ ),
+ $repo_root,
+ 60000
+);
+$replay_report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( is_array( $replay_report ), 'Expected replay --store to produce a JSON report: ' . substr( $proc['output'], -1000 ) );
+html_api_fuzz_smoke_assert( false === ( $replay_report['ok'] ?? true ), 'Expected the store replay to reproduce a failure.' );
+html_api_fuzz_smoke_assert(
+ ( $replay_report['signature']['hash'] ?? null ) === $pruned['signature_hash'],
+ 'Expected the store replay to reproduce the original signature.'
+);
+
+/*
+ * 3. A pre-existing stop file must refuse to start rather than silently
+ * succeed with zero seeds.
+ */
+$stop_run_dir = $work_dir . '/stop-run';
+\HtmlApiFuzz\ensure_dir( $stop_run_dir );
+file_put_contents( $stop_run_dir . '/STOP', "{}\n" );
+$proc = \HtmlApiFuzz\run_php_process(
+ array( $runner, '--output-dir', $stop_run_dir, '--max-seeds', '0', '--duration-seconds', '0' ),
+ $repo_root,
+ 60000
+);
+html_api_fuzz_smoke_assert( 0 !== $proc['code'], 'Expected runner to refuse to start over a pre-existing stop file.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['output'], 'Stop file already exists' ), 'Expected a clear stale stop file message.' );
+html_api_fuzz_smoke_assert( ! is_file( $stop_run_dir . '/state.json' ), 'Expected no state to be written when refusing to start.' );
+
+/*
+ * 4. Mid-run graceful stop: an indefinite runner must finish its in-flight
+ * batch, record it, and exit with stopReason stop-requested.
+ */
+$mid_run_dir = $work_dir . '/mid-run';
+$spec = array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) );
+$process = proc_open(
+ array( PHP_BINARY, $runner, '--output-dir', $mid_run_dir, '--max-seeds', '0', '--duration-seconds', '0', '--batch-size', '5', '--max-input-bytes', '512' ),
+ $spec,
+ $pipes,
+ $repo_root
+);
+html_api_fuzz_smoke_assert( is_resource( $process ), 'Expected the indefinite runner to start.' );
+fclose( $pipes[0] );
+stream_set_blocking( $pipes[1], false );
+stream_set_blocking( $pipes[2], false );
+
+$deadline = microtime( true ) + 120.0;
+$progress = false;
+while ( microtime( true ) < $deadline ) {
+ $mid_state = is_file( $mid_run_dir . '/state.json' ) ? @json_decode( (string) @file_get_contents( $mid_run_dir . '/state.json' ), true ) : null;
+ $attempted = is_array( $mid_state )
+ ? (int) ( $mid_state['successes'] ?? 0 ) + (int) ( $mid_state['failures'] ?? 0 ) + (int) ( $mid_state['unsupported'] ?? 0 )
+ + (int) ( $mid_state['oracleParseErrors'] ?? 0 ) + (int) ( $mid_state['oracleUnsupported'] ?? 0 ) + (int) ( $mid_state['oracleTolerated'] ?? 0 )
+ : 0;
+ if ( $attempted > 0 ) {
+ $progress = true;
+ break;
+ }
+ usleep( 100000 );
+}
+html_api_fuzz_smoke_assert( $progress, 'Expected the indefinite runner to record progress before the stop request.' );
+
+// Request the stop through the stop tool to cover its run-dir path.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $mid_run_dir ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected stop.php to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( is_file( $mid_run_dir . '/STOP' ), 'Expected stop.php to create the stop file.' );
+
+$deadline = microtime( true ) + 120.0;
+$exited = false;
+$code = null;
+while ( microtime( true ) < $deadline ) {
+ stream_get_contents( $pipes[1] );
+ stream_get_contents( $pipes[2] );
+ $status = proc_get_status( $process );
+ if ( ! $status['running'] ) {
+ $exited = true;
+ $code = $status['exitcode'];
+ break;
+ }
+ usleep( 100000 );
+}
+if ( ! $exited ) {
+ proc_terminate( $process, 9 );
+}
+fclose( $pipes[1] );
+fclose( $pipes[2] );
+proc_close( $process );
+html_api_fuzz_smoke_assert( $exited, 'Expected the runner to exit after the stop request.' );
+html_api_fuzz_smoke_assert( 0 === $code, 'Expected the stopped runner to exit cleanly.' );
+
+$mid_state = \HtmlApiFuzz\read_json_file( $mid_run_dir . '/state.json' );
+html_api_fuzz_smoke_assert( 'stop-requested' === ( $mid_state['stopReason'] ?? null ), 'Expected stopReason stop-requested after a mid-run stop.' );
+
+$db = new SQLite3( $mid_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 0 < (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts' ), 'Expected the in-flight batch to be recorded before stopping.' );
+$db->close();
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+
+echo "OK runner-retention-smoke\n";
diff --git a/tools/html-api-fuzz/tests/stop-smoke.php b/tools/html-api-fuzz/tests/stop-smoke.php
new file mode 100644
index 0000000000000..54d904381edba
--- /dev/null
+++ b/tools/html-api-fuzz/tests/stop-smoke.php
@@ -0,0 +1,531 @@
+#!/usr/bin/env php
+ 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => dirname( $path ) . '/STOP',
+ 'stopReason' => null,
+ ),
+ $overrides
+ )
+ );
+}
+
+function html_api_fuzz_smoke_touch( string $path, int $mtime ): void {
+ html_api_fuzz_smoke_assert( touch( $path, $mtime ), "Expected touch to succeed for {$path}." );
+ clearstatcache( true, $path );
+ html_api_fuzz_smoke_assert( $mtime === (int) filemtime( $path ), "Expected mtime {$mtime} for {$path}." );
+}
+
+$stop_tool = dirname( __DIR__ ) . '/stop.php';
+$runner_tool = dirname( __DIR__ ) . '/runner.php';
+$repo_root = \HtmlApiFuzz\repo_root();
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-stop-' . \HtmlApiFuzz\timestamp();
+$repo_artifacts_dir = $repo_root . '/artifacts';
+$repo_fuzz_artifacts_dir = $repo_artifacts_dir . '/html-api-fuzz';
+$had_repo_artifacts_dir = is_dir( $repo_artifacts_dir );
+$had_repo_fuzz_artifacts_dir = is_dir( $repo_fuzz_artifacts_dir );
+$repo_relative_run_dir = $repo_fuzz_artifacts_dir . '/run-stop-smoke-' . basename( $work_dir );
+
+register_shutdown_function(
+ static function () use ( $work_dir, $repo_relative_run_dir, $repo_fuzz_artifacts_dir, $repo_artifacts_dir, $had_repo_fuzz_artifacts_dir, $had_repo_artifacts_dir ): void {
+ \HtmlApiFuzz\remove_dir_recursive( $work_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $repo_relative_run_dir );
+ if ( ! $had_repo_fuzz_artifacts_dir ) {
+ @rmdir( $repo_fuzz_artifacts_dir );
+ }
+ if ( ! $had_repo_artifacts_dir ) {
+ @rmdir( $repo_artifacts_dir );
+ }
+ }
+);
+
+/*
+ * Discovery must prefer an unfinished launcher run over a more recently
+ * touched but already finished one.
+ */
+$launcher_artifacts = $work_dir . '/launcher-discovery';
+$finished_run = $launcher_artifacts . '/run-finished';
+\HtmlApiFuzz\ensure_dir( $finished_run );
+html_api_fuzz_smoke_write_runner_state(
+ $finished_run . '/state.json',
+ array(
+ 'stopReason' => 'max-seeds',
+ )
+);
+
+$active_run = $launcher_artifacts . '/run-active';
+\HtmlApiFuzz\ensure_dir( $active_run );
+\HtmlApiFuzz\write_json_file(
+ $active_run . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+// Make the finished run the more recently touched one.
+html_api_fuzz_smoke_touch( $finished_run . '/state.json', time() + 5 );
+
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $launcher_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected stop.php launcher discovery to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $active_run === ( $report['runDir'] ?? null ), 'Expected discovery to prefer the unfinished launcher run.' );
+html_api_fuzz_smoke_assert( is_file( $active_run . '/STOP' ), 'Expected the stop file in the active launcher run.' );
+html_api_fuzz_smoke_assert( ! is_file( $finished_run . '/STOP' ), 'Expected no stop file in the finished run.' );
+html_api_fuzz_smoke_assert( false === ( $report['looksFinished'] ?? null ), 'Expected the chosen launcher run not to look finished.' );
+
+// Lane runner state alone is also enough to mark a launch run unfinished.
+$lane_artifacts = $work_dir . '/lane-discovery';
+$lane_run = $lane_artifacts . '/run-lane-active';
+\HtmlApiFuzz\ensure_dir( $lane_run . '/lane-00' );
+html_api_fuzz_smoke_write_runner_state( $lane_run . '/lane-00/state.json' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $lane_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $lane_run === ( $report['runDir'] ?? null ), 'Expected lane runner state to mark a launch run unfinished.' );
+html_api_fuzz_smoke_assert( is_file( $lane_run . '/STOP' ), 'Expected the stop file in the lane-active run.' );
+
+/*
+ * A standalone runner writes root state.json and may advertise a custom
+ * stopFile. A newer malformed runner state without stopReason must not be
+ * treated as unfinished.
+ */
+$standalone_artifacts = $work_dir . '/standalone-discovery';
+$missing_run = $standalone_artifacts . '/run-missing-stop-reason';
+\HtmlApiFuzz\ensure_dir( $missing_run );
+\HtmlApiFuzz\write_json_file(
+ $missing_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+html_api_fuzz_smoke_touch( $missing_run . '/state.json', time() + 10 );
+
+$standalone_run = $standalone_artifacts . '/run-standalone-active';
+$custom_stop = $standalone_artifacts . '/custom-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $standalone_run );
+html_api_fuzz_smoke_write_runner_state(
+ $standalone_run . '/state.json',
+ array(
+ 'stopFile' => $custom_stop,
+ )
+);
+
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $standalone_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected stop.php standalone discovery to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $standalone_run === ( $report['runDir'] ?? null ), 'Expected discovery to prefer the unfinished standalone run.' );
+html_api_fuzz_smoke_assert( $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected discovery to report the run-dir stop file as the primary stop file.' );
+html_api_fuzz_smoke_assert( in_array( $custom_stop, $report['stopFiles'] ?? array(), true ), 'Expected discovery to include the standalone runner custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected the custom stop file to be created.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected the run-dir stop file to be created for watcher and orchestrator paths.' );
+html_api_fuzz_smoke_assert( ! is_file( $missing_run . '/STOP' ), 'Expected no stop file in the malformed runner-state run.' );
+html_api_fuzz_smoke_assert( false === ( $report['looksFinished'] ?? null ), 'Expected the standalone run not to look finished.' );
+
+// A second invocation reports the existing request instead of failing.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $standalone_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['alreadyRequested'] ?? null ), 'Expected a repeat stop request to be reported as already requested.' );
+
+// Explicit --run-dir inspection also honors the custom stop file.
+unlink( $custom_stop );
+unlink( $standalone_run . '/STOP' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $standalone_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected --run-dir to report the run-dir stop file as primary.' );
+html_api_fuzz_smoke_assert( in_array( $custom_stop, $report['stopFiles'] ?? array(), true ), 'Expected --run-dir to include the standalone runner custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected --run-dir to create the custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected --run-dir to create the run-dir stop file too.' );
+
+// The README-documented relative command works from the repo root.
+$repo_relative_run_arg = 'artifacts/html-api-fuzz/' . basename( $repo_relative_run_dir );
+\HtmlApiFuzz\ensure_dir( $repo_relative_run_dir );
+\HtmlApiFuzz\write_json_file(
+ $repo_relative_run_dir . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( 'tools/html-api-fuzz/stop.php', '--run-dir', $repo_relative_run_arg ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $repo_relative_run_arg === ( $report['runDir'] ?? null ), 'Expected README-style relative --run-dir to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $repo_relative_run_arg . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected README-style relative --run-dir to report a relative run-dir stop file.' );
+html_api_fuzz_smoke_assert( is_file( $repo_relative_run_dir . '/STOP' ), 'Expected README-style relative --run-dir to create RUN_DIR/STOP.' );
+
+// Explicit --stop-file is added to the discovered stop files.
+unlink( $custom_stop );
+unlink( $standalone_run . '/STOP' );
+$override_stop = $standalone_artifacts . '/override-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $standalone_run, '--stop-file', $override_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected --run-dir --stop-file to report the run-dir stop file as primary.' );
+html_api_fuzz_smoke_assert( in_array( $override_stop, $report['stopFiles'] ?? array(), true ), 'Expected --stop-file to be included in stopFiles.' );
+html_api_fuzz_smoke_assert( is_file( $override_stop ), 'Expected --stop-file to create the override stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected --run-dir --stop-file to create the advertised custom stop file too.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected --run-dir --stop-file to create the run-dir stop file too.' );
+
+// Explicit --stop-file also works as a direct write without run discovery.
+$direct_stop = $work_dir . '/direct-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-file', $direct_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected direct --stop-file to succeed without run discovery: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( null === ( $report['runDir'] ?? null ), 'Expected direct --stop-file to report no run directory.' );
+html_api_fuzz_smoke_assert( is_file( $direct_stop ), 'Expected direct --stop-file to create the requested file.' );
+
+// A run directory without state can only be stopped unambiguously with --stop-file.
+$no_state_run = $work_dir . '/no-state-run';
+\HtmlApiFuzz\ensure_dir( $no_state_run );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $no_state_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected --run-dir without state to report warning status.' );
+$no_state_custom_stop = $work_dir . '/no-state-custom-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $no_state_run, '--stop-file', $no_state_custom_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_file( $no_state_custom_stop ) && is_file( $no_state_run . '/STOP' ), 'Expected --run-dir --stop-file without state to write both stop files.' );
+
+// Bare and ambiguous CLI invocations fail.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-file' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --stop-file to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', '--stop-file', $work_dir . '/bare-run-dir-stop/STOP' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --run-dir to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-stale-seconds' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'numeric' ), 'Expected bare --stop-stale-seconds to fail with a numeric error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-stale-seconds', 'nope' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'numeric' ), 'Expected non-numeric --stop-stale-seconds to fail with a numeric error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --artifacts-dir to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $launcher_artifacts, '--stop-file', $work_dir . '/ambiguous-stop/STOP' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'Pass --run-dir' ), 'Expected --artifacts-dir --stop-file without --run-dir to fail as ambiguous.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $runner_tool, '--max-seeds', '1', '--stop-file=' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected runner --stop-file= to fail with a path error.' );
+
+// Relative advertised stop files are resolved from real runner cwd, not stop.php's cwd.
+$relative_artifacts = $work_dir . '/relative-discovery';
+$relative_run = $relative_artifacts . '/run-relative-stop';
+$relative_cwd = $work_dir . '/relative-cwd';
+$relative_invoke_cwd = $work_dir . '/relative-invoke-cwd';
+$relative_stop = 'custom-relative-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $relative_cwd );
+\HtmlApiFuzz\ensure_dir( $relative_invoke_cwd );
+$relative_cwd_real = realpath( $relative_cwd );
+html_api_fuzz_smoke_assert( is_string( $relative_cwd_real ), 'Expected relative runner cwd realpath.' );
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $runner_tool,
+ '--output-dir',
+ $relative_run,
+ '--max-seeds',
+ '1',
+ '--stop-file',
+ $relative_stop,
+ ),
+ $relative_cwd,
+ 30000
+);
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected real runner with relative stop file to finish: ' . substr( $proc['output'], -500 ) );
+$runner_state = \HtmlApiFuzz\read_json_file( $relative_run . '/state.json' );
+html_api_fuzz_smoke_assert( is_array( $runner_state ) && $relative_cwd_real === ( $runner_state['cwd'] ?? null ), 'Expected runner state to record its cwd.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $relative_run ), $relative_invoke_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+$expected_relative_stop = rtrim( $relative_cwd_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $relative_stop;
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && in_array( $expected_relative_stop, $report['stopFiles'] ?? array(), true ), 'Expected relative advertised stopFile to resolve from runner cwd.' );
+html_api_fuzz_smoke_assert( is_file( $expected_relative_stop ), 'Expected the runner-cwd-relative stop file to be created.' );
+html_api_fuzz_smoke_assert( ! is_file( $relative_invoke_cwd . '/' . $relative_stop ), 'Expected no stop file relative to stop.php invocation cwd.' );
+
+// On POSIX, a leading backslash is still relative to runner cwd.
+if ( '\\' !== DIRECTORY_SEPARATOR ) {
+ $backslash_run = $work_dir . '/backslash-relative-stop';
+ $backslash_cwd = $work_dir . '/backslash-cwd';
+ $backslash_stop = '\\custom-backslash-stop/STOP';
+ \HtmlApiFuzz\ensure_dir( $backslash_run );
+ \HtmlApiFuzz\ensure_dir( $backslash_cwd );
+ html_api_fuzz_smoke_write_runner_state(
+ $backslash_run . '/state.json',
+ array(
+ 'stopFile' => $backslash_stop,
+ 'cwd' => $backslash_cwd,
+ )
+ );
+ $proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $backslash_run ), $relative_invoke_cwd, 30000 );
+ $report = json_decode( trim( $proc['stdout'] ), true );
+ $expected_backslash_stop = rtrim( $backslash_cwd, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $backslash_stop;
+ html_api_fuzz_smoke_assert( 0 === $proc['code'] && in_array( $expected_backslash_stop, $report['stopFiles'] ?? array(), true ), 'Expected POSIX leading-backslash stopFile to resolve from runner cwd.' );
+ html_api_fuzz_smoke_assert( is_file( $expected_backslash_stop ), 'Expected POSIX leading-backslash stop file to be created under runner cwd.' );
+}
+
+// Legacy active relative stopFile state without cwd warns because the watched file is ambiguous.
+$legacy_relative_run = $work_dir . '/legacy-relative-stop';
+$legacy_relative_cwd = $work_dir . '/legacy-relative-cwd';
+$legacy_relative_stop = 'legacy-relative-caller-cwd/STOP';
+\HtmlApiFuzz\ensure_dir( $legacy_relative_run );
+\HtmlApiFuzz\ensure_dir( $legacy_relative_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $legacy_relative_run . '/state.json',
+ array(
+ 'stopFile' => $legacy_relative_stop,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $legacy_relative_run ), $legacy_relative_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected legacy relative stopFile without cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected legacy relative stopFile without absolute cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $legacy_relative_run . '/STOP' ), 'Expected legacy relative stopFile warning path to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( is_file( $legacy_relative_cwd . '/' . $legacy_relative_stop ), 'Expected legacy relative stopFile warning path to write a caller-cwd candidate.' );
+
+$finished_legacy_relative_run = $work_dir . '/finished-legacy-relative-stop';
+$finished_legacy_relative_cwd = $work_dir . '/finished-legacy-relative-cwd';
+$finished_legacy_relative_stop = 'finished-legacy-relative-caller-cwd/STOP';
+\HtmlApiFuzz\ensure_dir( $finished_legacy_relative_run );
+\HtmlApiFuzz\ensure_dir( $finished_legacy_relative_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $finished_legacy_relative_run . '/state.json',
+ array(
+ 'stopFile' => $finished_legacy_relative_stop,
+ 'stopReason' => 'max-seeds',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $finished_legacy_relative_run ), $finished_legacy_relative_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected finished legacy relative stopFile without cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected finished legacy relative stopFile without cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $finished_legacy_relative_run . '/STOP' ), 'Expected finished legacy relative stopFile warning path to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( is_file( $finished_legacy_relative_cwd . '/' . $finished_legacy_relative_stop ), 'Expected finished legacy relative stopFile warning path to write a caller-cwd candidate.' );
+
+// Active malformed advertised stop files must not report unqualified success.
+$bad_advertised_run = $work_dir . '/bad-advertised-stop';
+\HtmlApiFuzz\ensure_dir( $bad_advertised_run );
+html_api_fuzz_smoke_write_runner_state(
+ $bad_advertised_run . '/state.json',
+ array(
+ 'stopFile' => '',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $bad_advertised_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && is_file( $bad_advertised_run . '/STOP' ), 'Expected RUN_DIR/STOP to be written with warning status when advertised stopFile is empty.' );
+html_api_fuzz_smoke_assert( false === ( $report['ok'] ?? null ) && false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected empty advertised stopFile to warn.' );
+
+$unknown_kind_run = $work_dir . '/unknown-kind-runner-state';
+$unknown_kind_stop = $work_dir . '/unknown-kind-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $unknown_kind_run );
+\HtmlApiFuzz\write_json_file(
+ $unknown_kind_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => $unknown_kind_stop,
+ 'stopReason' => null,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unknown_kind_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected runner-like state with missing kind to report warning status.' );
+html_api_fuzz_smoke_assert( is_file( $unknown_kind_stop ), 'Expected runner-like state with missing kind to write the advertised custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $unknown_kind_run . '/STOP' ), 'Expected runner-like state with missing kind to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'missing or unknown kind' ), 'Expected runner-like state with missing kind to warn.' );
+
+$missing_stop_file_run = $work_dir . '/missing-stop-file';
+\HtmlApiFuzz\ensure_dir( $missing_stop_file_run );
+\HtmlApiFuzz\write_json_file(
+ $missing_stop_file_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopReason' => null,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $missing_stop_file_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected active runner state missing stopFile to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'missing or malformed' ), 'Expected active runner state missing stopFile to warn.' );
+
+$relative_cwd_run = $work_dir . '/relative-cwd-stop';
+$relative_bad_cwd = $work_dir . '/relative-bad-cwd';
+\HtmlApiFuzz\ensure_dir( $relative_cwd_run );
+\HtmlApiFuzz\ensure_dir( $relative_bad_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $relative_cwd_run . '/state.json',
+ array(
+ 'stopFile' => 'relative-cwd-caller-cwd/STOP',
+ 'cwd' => 'not-absolute',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $relative_cwd_run ), $relative_bad_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected relative recorded cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'no recorded absolute cwd' ), 'Expected relative recorded cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $relative_bad_cwd . '/relative-cwd-caller-cwd/STOP' ), 'Expected relative recorded cwd warning path to write a caller-cwd candidate.' );
+
+$unknown_runner_run = $work_dir . '/unknown-runner-stop-file';
+$unknown_runner_cwd = $work_dir . '/unknown-runner-cwd';
+\HtmlApiFuzz\ensure_dir( $unknown_runner_run );
+\HtmlApiFuzz\ensure_dir( $unknown_runner_cwd );
+\HtmlApiFuzz\write_json_file(
+ $unknown_runner_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => 'unknown-runner-caller-cwd/STOP',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unknown_runner_run ), $unknown_runner_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected runner state without stopReason and relative stopFile to warn.' );
+
+// Unreadable state warns and still writes RUN_DIR/STOP.
+if ( '\\' !== DIRECTORY_SEPARATOR ) {
+ $unreadable_run = $work_dir . '/unreadable-state';
+ \HtmlApiFuzz\ensure_dir( $unreadable_run );
+ $unreadable_state = $unreadable_run . '/state.json';
+ html_api_fuzz_smoke_write_runner_state( $unreadable_state );
+ html_api_fuzz_smoke_assert( chmod( $unreadable_state, 0000 ), 'Expected chmod to make state unreadable.' );
+ if ( ! is_readable( $unreadable_state ) ) {
+ $proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unreadable_run ), $repo_root, 30000 );
+ chmod( $unreadable_state, 0600 );
+ $report = json_decode( trim( $proc['stdout'] ), true );
+ html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected unreadable state to report warning status.' );
+ html_api_fuzz_smoke_assert( is_file( $unreadable_run . '/STOP' ), 'Expected unreadable state fallback to write RUN_DIR/STOP.' );
+ } else {
+ chmod( $unreadable_state, 0600 );
+ }
+}
+
+// Unreadable in-progress state warns and still writes RUN_DIR/STOP.
+$corrupt_run = $work_dir . '/corrupt-state';
+\HtmlApiFuzz\ensure_dir( $corrupt_run );
+file_put_contents( $corrupt_run . '/state.json', "{not-json\n" );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $corrupt_run ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && is_file( $corrupt_run . '/STOP' ), 'Expected corrupt state fallback to write RUN_DIR/STOP with warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'could not read' ), 'Expected corrupt state fallback to warn.' );
+
+// Stale corrupt state is not preferred during discovery.
+$stale_corrupt_artifacts = $work_dir . '/stale-corrupt-discovery';
+$stale_corrupt_run = $stale_corrupt_artifacts . '/run-stale-corrupt';
+$recent_finished_run = $stale_corrupt_artifacts . '/run-recent-finished';
+\HtmlApiFuzz\ensure_dir( $stale_corrupt_run );
+file_put_contents( $stale_corrupt_run . '/state.json', "{not-json\n" );
+html_api_fuzz_smoke_touch( $stale_corrupt_run . '/state.json', time() - 3600 );
+\HtmlApiFuzz\ensure_dir( $recent_finished_run );
+html_api_fuzz_smoke_write_runner_state(
+ $recent_finished_run . '/state.json',
+ array(
+ 'stopReason' => 'max-seeds',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_corrupt_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $recent_finished_run === ( $report['runDir'] ?? null ), 'Expected stale corrupt state not to be preferred during discovery.' );
+html_api_fuzz_smoke_assert( true === ( $report['looksFinished'] ?? null ), 'Expected recent finished fallback to report looksFinished.' );
+html_api_fuzz_smoke_assert( is_file( $recent_finished_run . '/STOP' ), 'Expected recent finished fallback to create STOP.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected recent finished fallback to warn.' );
+
+// A stale runner state does not count as unfinished and therefore warns.
+$stale_artifacts = $work_dir . '/stale-discovery';
+$stale_run = $stale_artifacts . '/run-stale';
+$stale_custom = $stale_artifacts . '/custom-stale-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $stale_run );
+html_api_fuzz_smoke_write_runner_state(
+ $stale_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c', time() - 3600 ),
+ 'batchBudgetMs' => 0,
+ 'stopFile' => $stale_custom,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected a stale-only artifacts dir to be reported as looksFinished.' );
+html_api_fuzz_smoke_assert( is_file( $stale_custom ), 'Expected stale custom stop file to be created for the targeted run.' );
+html_api_fuzz_smoke_assert( is_file( $stale_run . '/STOP' ), 'Expected stale run-dir stop file to be created for watcher and orchestrator paths.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected a warning when only stale runs exist.' );
+
+// Missing updatedAt falls back to state file mtime for stale detection.
+$mtime_stale_artifacts = $work_dir . '/mtime-stale-discovery';
+$mtime_stale_run = $mtime_stale_artifacts . '/run-mtime-stale';
+\HtmlApiFuzz\ensure_dir( $mtime_stale_run );
+\HtmlApiFuzz\write_json_file(
+ $mtime_stale_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'batchBudgetMs' => 0,
+ 'stopReason' => null,
+ )
+);
+html_api_fuzz_smoke_touch( $mtime_stale_run . '/state.json', time() - 3600 );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $mtime_stale_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected missing updatedAt to use stale file mtime.' );
+
+// A large batch budget floors the stale threshold so long-running batches still look active.
+$budget_artifacts = $work_dir . '/batch-budget-discovery';
+$budget_run = $budget_artifacts . '/run-budget-active';
+\HtmlApiFuzz\ensure_dir( $budget_run );
+html_api_fuzz_smoke_write_runner_state(
+ $budget_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c', time() - 30 ),
+ 'batchBudgetMs' => 600000,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $budget_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && false === ( $report['looksFinished'] ?? null ), 'Expected batch budget floor to keep the old runner state active.' );
+
+// A stale launcher state does not count as unfinished and therefore warns.
+$stale_launcher_artifacts = $work_dir . '/stale-launcher-discovery';
+$stale_launcher_run = $stale_launcher_artifacts . '/run-stale-launcher';
+\HtmlApiFuzz\ensure_dir( $stale_launcher_run );
+\HtmlApiFuzz\write_json_file(
+ $stale_launcher_run . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c', time() - 3600 ),
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_launcher_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected a stale launcher-only artifacts dir to be reported as looksFinished.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected a warning when only stale launcher state exists.' );
+
+// Same-second active runs are ordered deterministically by the run path.
+$tie_artifacts = $work_dir . '/tie-discovery';
+$first_tie_run = $tie_artifacts . '/run-20260101T000000000001Z';
+$next_tie_run = $tie_artifacts . '/run-20260101T000000000002Z';
+\HtmlApiFuzz\ensure_dir( $first_tie_run );
+\HtmlApiFuzz\ensure_dir( $next_tie_run );
+html_api_fuzz_smoke_write_runner_state( $first_tie_run . '/state.json' );
+html_api_fuzz_smoke_write_runner_state( $next_tie_run . '/state.json' );
+$same_mtime = time() + 20;
+html_api_fuzz_smoke_touch( $first_tie_run . '/state.json', $same_mtime );
+html_api_fuzz_smoke_touch( $next_tie_run . '/state.json', $same_mtime );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $tie_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $next_tie_run === ( $report['runDir'] ?? null ), 'Expected same-second active runs to prefer the later path.' );
+
+// An empty artifacts dir is an error, not a silent success.
+$empty_dir = $work_dir . '/empty';
+\HtmlApiFuzz\ensure_dir( $empty_dir );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $empty_dir ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'], 'Expected stop.php to fail when no run directory exists.' );
+
+echo "OK stop-smoke\n";
diff --git a/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php b/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php
new file mode 100644
index 0000000000000..01aa368b1d9c6
--- /dev/null
+++ b/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php
@@ -0,0 +1,421 @@
+#!/usr/bin/env php
+ base64_encode( $input ),
+ 'profile' => 'replay',
+ 'mode' => $mode,
+ 'output-dir' => $tmp . '/' . $name,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+ );
+}
+
+/*
+ * Synthetic compare_trees() cases exercise the comparison logic directly and
+ * need no DOM oracle, so they run before the Dom\HTMLDocument guard below.
+ *
+ * The comparison must keep failing on structural differences: scalar
+ * tolerance only applies when the spec substitution explains the entire
+ * differing line.
+ */
+$synthetic_mismatch = \HtmlApiFuzz\TreeRenderer::compare_trees( "\n \"a\"\n\n", "
\n \"b\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_mismatch['ok'] ?? null ), 'Structural tree mismatches should still fail.' );
+html_api_fuzz_tree_normalization_assert( is_array( $synthetic_mismatch['firstDifference'] ?? null ) && 2 === ( $synthetic_mismatch['firstDifference']['line'] ?? null ), 'Structural mismatch should report the first differing line.' );
+
+$synthetic_structure_with_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0\"\n \"a\"\n\n", "
\n x=\"\xEF\xBF\xBD\"\n \"b\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_structure_with_nul['ok'] ?? null ), 'Scalar tolerance must not mask structural differences on other lines.' );
+
+$synthetic_tolerated = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0\"\n\n", "
\n x=\"\xEF\xBF\xBD\"\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_tolerated['ok'] ?? null ), 'Scalar-only differences should be tolerated.' );
+html_api_fuzz_tree_normalization_assert( array( 1 ) === ( $synthetic_tolerated['scalarToleratedLines'] ?? null ), 'Scalar tolerance should report the tolerated line number.' );
+
+$synthetic_nul_with_agreed_cr = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0)\\r\"\n\n", "
\n x=\"\xEF\xBF\xBD)\\r\"\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_nul_with_agreed_cr['ok'] ?? null ), 'NUL tolerance should not rewrite an agreed escaped CR on the same line.' );
+
+$synthetic_cr_only_wordpress = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"a\\nb\"\n\n", "
\n x=\"a\\rb\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_cr_only_wordpress['ok'] ?? null ), 'A DOM-side CR where WordPress holds LF is not the spec substitution and must fail.' );
+
+$synthetic_cr_before_decoded_lf = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\r\\n\"\n\n", "
\n x=\"\\n\\n\"\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_cr_before_decoded_lf['ok'] ?? null ), 'WordPress CR+LF opposite DOM LF+LF should be tolerated as CR-to-LF plus an agreed LF.' );
+
+$synthetic_raw_crlf = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\r\\nX\"\n\n", "
\n x=\"\\nX\"\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_raw_crlf['ok'] ?? null ), 'Raw CRLF collapsed to a single DOM LF should remain tolerated.' );
+
+$synthetic_backslash_collision = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\\\r\"\n\n", "
\n x=\"\\\\n\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_backslash_collision['ok'] ?? null ), 'A literal backslash followed by r must not be rewritten as a CR escape.' );
+
+$synthetic_repeated_cr_lf = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ "
\n x=\"" . str_repeat( '\\r\\n', 500 ) . "\"\n\n",
+ "
\n x=\"" . str_repeat( '\\n\\n', 500 ) . "\"\n\n"
+);
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_repeated_cr_lf['ok'] ?? null ), 'A long run of raw CR plus decoded LF pairs should be tolerated without exhausting the matcher.' );
+
+/*
+ * WordPress preserves raw NUL/CR only in attribute values and tag/attribute
+ * names. In text, RCDATA, rawtext, and comments it applies the spec
+ * substitutions itself, so a scalar difference on those lines is a real
+ * divergence and the tolerance must not mask it.
+ */
+$synthetic_text_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \"a\\0b\"\n\n", "
\n \"a\xEF\xBF\xBDb\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_text_nul['ok'] ?? null ), 'Scalar tolerance must not apply to NUL differences on text lines.' );
+
+$synthetic_text_cr = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \"x\\ry\"\n\n", "
\n \"x\\ny\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_text_cr['ok'] ?? null ), 'Scalar tolerance must not apply to CR differences on text lines.' );
+
+$synthetic_comment_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \n\n", "
\n \n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_comment_nul['ok'] ?? null ), 'Scalar tolerance must not apply to NUL differences on comment lines.' );
+
+$synthetic_tag_name_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n
\n\n", "\n
\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_tag_name_nul['ok'] ?? null ), 'Scalar tolerance should still apply to NUL differences on tag-name lines.' );
+
+$synthetic_quoted_attribute_name_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "\n \"a\\0\"=\"\"\n\n", "
\n \"a\xEF\xBF\xBD\"=\"\"\n\n" );
+html_api_fuzz_tree_normalization_assert( true === ( $synthetic_quoted_attribute_name_nul['ok'] ?? null ), 'An attribute name that begins with a quote is still an attribute line, not a text line.' );
+
+/*
+ * The tokenizer permits `<` and `!` in attribute names, so `
`
+ * carries an attribute named ``, not a quoted value.
+ */
+$synthetic_comment_prefixed_attribute = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \n")?;
+ Ok(())
+ }
+ NodeData::ProcessingInstruction { target, contents } => {
+ push_indent(indent, state)?;
+ append_output(state, "")?;
+ append_escaped_scalar(state, target.as_ref())?;
+ append_output(state, " ")?;
+ append_escaped_scalar(state, contents.as_ref())?;
+ append_output(state, "?>\n")?;
+ Ok(())
+ }
+ NodeData::Element {
+ name,
+ attrs,
+ template_contents,
+ ..
+ } => {
+ push_indent(indent, state)?;
+ append_output(state, "<")?;
+ append_escaped_scalar(state, &element_display_name(name))?;
+ append_output(state, ">\n")?;
+
+ let attributes = attrs.borrow();
+ let mut attribute_order: Vec<(String, usize)> = attributes
+ .iter()
+ .enumerate()
+ .map(|(index, attribute)| {
+ (
+ escaped_scalar(&attribute_display_name(&attribute.name)),
+ index,
+ )
+ })
+ .collect();
+ attribute_order.sort_by(|left, right| compare_display_names(&left.0, &right.0));
+ for (render_name, index) in attribute_order {
+ let attribute = &attributes[index];
+ push_indent(indent + 1, state)?;
+ append_output(state, &render_name)?;
+ append_output(state, "=\"")?;
+ append_escaped_scalar(state, attribute.value.as_ref())?;
+ append_output(state, "\"\n")?;
+ }
+
+ if name.ns.as_ref() == HTML_NS && name.local.as_ref() == "template" {
+ push_indent(indent + 1, state)?;
+ append_output(state, "content\n")?;
+ if let Some(contents) = template_contents.borrow().as_ref() {
+ render_children(contents, indent + 2, state)?;
+ }
+ Ok(())
+ } else {
+ render_children(node, indent + 1, state)
+ }
+ }
+ }
+}
+
+fn append_output(state: &mut RenderState, value: &str) -> Result<(), OracleError> {
+ let next_length = state.tree.len().checked_add(value.len());
+ if next_length.is_none() || next_length.unwrap() > state.max_tree_bytes {
+ return Err(OracleError {
+ failure_class: "tree-byte-limit-exceeded",
+ message: "Rendered tree byte limit exceeded.".to_string(),
+ node_count: state.node_count,
+ });
+ }
+ state.tree.push_str(value);
+ Ok(())
+}
+
+fn append_escaped_scalar(state: &mut RenderState, value: &str) -> Result<(), OracleError> {
+ for character in value.chars() {
+ match character {
+ '\n' => append_output(state, "\\n")?,
+ '\r' => append_output(state, "\\r")?,
+ '\t' => append_output(state, "\\t")?,
+ '\0' => append_output(state, "\\0")?,
+ '\\' => append_output(state, "\\\\")?,
+ '"' => append_output(state, "\\\"")?,
+ character if (character as u32) < 0x20 || character == '\u{7f}' => {
+ append_output(state, &format!("\\x{:02X}", character as u32))?;
+ }
+ character => {
+ let mut encoded = [0; 4];
+ append_output(state, character.encode_utf8(&mut encoded))?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn element_display_name(name: &QualName) -> String {
+ match name.ns.as_ref() {
+ HTML_NS => name.local.as_ref().to_ascii_lowercase(),
+ SVG_NS => format!("svg {}", name.local),
+ MATH_NS => format!("math {}", name.local),
+ _ => qualified_name(name),
+ }
+}
+
+fn attribute_display_name(name: &QualName) -> String {
+ match name.ns.as_ref() {
+ XLINK_NS => format!("xlink {}", name.local),
+ XML_NS => format!("xml {}", name.local),
+ XMLNS_NS => format!("xmlns {}", name.local),
+ _ => qualified_name(name),
+ }
+}
+
+fn qualified_name(name: &QualName) -> String {
+ match name.prefix.as_ref() {
+ Some(prefix) => format!("{prefix}:{}", name.local),
+ None => name.local.to_string(),
+ }
+}
+
+fn compare_display_names(left: &str, right: &str) -> std::cmp::Ordering {
+ left.contains(':')
+ .cmp(&right.contains(':'))
+ .then_with(|| left.contains(' ').cmp(&right.contains(' ')))
+ .then_with(|| left.as_bytes().cmp(right.as_bytes()))
+}
+
+fn push_indent(indent: usize, state: &mut RenderState) -> Result<(), OracleError> {
+ for _ in 0..indent {
+ append_output(state, " ")?;
+ }
+ Ok(())
+}
+
+fn escape_scalar_into(value: &str, output: &mut String) {
+ for character in value.chars() {
+ match character {
+ '\n' => output.push_str("\\n"),
+ '\r' => output.push_str("\\r"),
+ '\t' => output.push_str("\\t"),
+ '\0' => output.push_str("\\0"),
+ '\\' => output.push_str("\\\\"),
+ '"' => output.push_str("\\\""),
+ character if (character as u32) < 0x20 || character == '\u{7f}' => {
+ output.push_str(&format!("\\x{:02X}", character as u32));
+ }
+ character => output.push(character),
+ }
+ }
+}
+
+fn escaped_scalar(value: &str) -> String {
+ let mut output = String::with_capacity(value.len());
+ escape_scalar_into(value, &mut output);
+ output
+}
+
+fn is_lowercase_sha256(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn identity_is_valid(build_identity: &str, cargo_lock_sha256: &str) -> bool {
+ is_lowercase_sha256(build_identity) && is_lowercase_sha256(cargo_lock_sha256)
+}
+
+fn oracle_json() -> String {
+ oracle_json_with_identity(BUILD_IDENTITY, CARGO_LOCK_SHA256)
+}
+
+fn oracle_json_with_identity(build_identity: &str, cargo_lock_sha256: &str) -> String {
+ let available = identity_is_valid(build_identity, cargo_lock_sha256);
+ let build_identity_json = json_string(build_identity);
+ let cargo_lock_sha256_json = json_string(cargo_lock_sha256);
+ format!(
+ "{{\"kind\":\"html5ever-source\",\"available\":{available},\"html5everVersion\":\"{HTML5EVER_VERSION}\",\"html5everChecksum\":\"{HTML5EVER_CHECKSUM}\",\"markup5everRcdomVersion\":\"{RCDOM_VERSION}\",\"markup5everRcdomChecksum\":\"{RCDOM_CHECKSUM}\",\"rustToolchain\":\"{RUST_TOOLCHAIN}\",\"cargoLockSha256\":{cargo_lock_sha256_json},\"buildIdentity\":{build_identity_json}}}"
+ )
+}
+
+fn print_ok(tree: &str, node_count: usize) {
+ println!(
+ "{{\"status\":\"ok\",\"oracle\":{},\"tree\":{},\"treeBase64\":\"{}\",\"nodeCount\":{}}}",
+ oracle_json(),
+ json_string(tree),
+ base64(tree.as_bytes()),
+ node_count
+ );
+}
+
+fn print_error(failure_class: &str, message: &str, node_count: usize) {
+ println!("{}", error_json(failure_class, message, node_count));
+}
+
+fn error_json(failure_class: &str, message: &str, node_count: usize) -> String {
+ error_json_with_identity(
+ failure_class,
+ message,
+ node_count,
+ BUILD_IDENTITY,
+ CARGO_LOCK_SHA256,
+ )
+}
+
+fn error_json_with_identity(
+ failure_class: &str,
+ message: &str,
+ node_count: usize,
+ build_identity: &str,
+ cargo_lock_sha256: &str,
+) -> String {
+ let status = if failure_class == "oracle-unsupported" {
+ "unsupported"
+ } else {
+ "error"
+ };
+ let oracle = oracle_json_with_identity(build_identity, cargo_lock_sha256);
+ if status == "unsupported" {
+ format!(
+ "{{\"status\":\"unsupported\",\"oracle\":{},\"nodeCount\":{},\"failureClass\":\"oracle-unsupported\",\"unsupported\":{{\"message\":{}}}}}",
+ oracle,
+ node_count,
+ json_string(message)
+ )
+ } else {
+ format!(
+ "{{\"status\":\"error\",\"oracle\":{},\"nodeCount\":{},\"failureClass\":{},\"error\":{}}}",
+ oracle,
+ node_count,
+ json_string(failure_class),
+ json_string(message)
+ )
+ }
+}
+
+fn json_string(value: &str) -> String {
+ let mut output = String::with_capacity(value.len() + 2);
+ output.push('"');
+ for character in value.chars() {
+ match character {
+ '"' => output.push_str("\\\""),
+ '\\' => output.push_str("\\\\"),
+ '\u{08}' => output.push_str("\\b"),
+ '\u{0c}' => output.push_str("\\f"),
+ '\n' => output.push_str("\\n"),
+ '\r' => output.push_str("\\r"),
+ '\t' => output.push_str("\\t"),
+ character if (character as u32) < 0x20 => {
+ output.push_str(&format!("\\u{:04X}", character as u32));
+ }
+ character => output.push(character),
+ }
+ }
+ output.push('"');
+ output
+}
+
+fn base64(input: &[u8]) -> String {
+ const ALPHABET: &[u8; 64] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
+ for chunk in input.chunks(3) {
+ let a = chunk[0];
+ let b = *chunk.get(1).unwrap_or(&0);
+ let c = *chunk.get(2).unwrap_or(&0);
+ output.push(ALPHABET[(a >> 2) as usize] as char);
+ output.push(ALPHABET[(((a & 0x03) << 4) | (b >> 4)) as usize] as char);
+ output.push(if chunk.len() > 1 {
+ ALPHABET[(((b & 0x0f) << 2) | (c >> 6)) as usize] as char
+ } else {
+ '='
+ });
+ output.push(if chunk.len() > 2 {
+ ALPHABET[(c & 0x3f) as usize] as char
+ } else {
+ '='
+ });
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use markup5ever_rcdom::Node;
+ use std::process::Command;
+
+ fn assert_identity_error_json(build_identity: &str, cargo_lock_sha256: &str) {
+ let response = error_json_with_identity(
+ "oracle-identity-error",
+ "Embedded html5ever build identity is missing or malformed.",
+ 0,
+ build_identity,
+ cargo_lock_sha256,
+ );
+ let parsed = Command::new("php")
+ .args([
+ "-r",
+ r#"
+$result = json_decode( $argv[1], true, 512, JSON_THROW_ON_ERROR );
+if (
+ "error" !== ( $result["status"] ?? null ) ||
+ "oracle-identity-error" !== ( $result["failureClass"] ?? null ) ||
+ false !== ( $result["oracle"]["available"] ?? null ) ||
+ $argv[2] !== ( $result["oracle"]["buildIdentity"] ?? null ) ||
+ $argv[3] !== ( $result["oracle"]["cargoLockSha256"] ?? null )
+) {
+ exit( 2 );
+}
+"#,
+ &response,
+ build_identity,
+ cargo_lock_sha256,
+ ])
+ .output()
+ .expect("PHP must be available to validate the oracle JSON protocol");
+ assert!(
+ parsed.status.success(),
+ "malformed identity response was not valid fail-closed JSON: {}",
+ String::from_utf8_lossy(&parsed.stderr)
+ );
+ }
+
+ #[test]
+ fn malformed_build_identity_is_never_available() {
+ let valid = "a".repeat(64);
+ for malformed in [
+ "",
+ "unconfigured",
+ "ABCDEF",
+ "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
+ "bad\"quote",
+ "bad\\backslash",
+ "bad\nnewline",
+ "bad\u{001f}control",
+ ] {
+ assert!(!identity_is_valid(malformed, &valid));
+ assert_identity_error_json(malformed, &valid);
+ assert_identity_error_json(&valid, malformed);
+ }
+ let uppercase = "A".repeat(64);
+ assert!(!identity_is_valid(&uppercase, &valid));
+ assert_identity_error_json(&uppercase, &valid);
+ }
+
+ #[test]
+ fn processing_instruction_uses_canonical_tree_format() {
+ let node = Node::new(NodeData::ProcessingInstruction {
+ target: "wp".into(),
+ contents: "data".into(),
+ });
+ let mut state = RenderState {
+ tree: String::new(),
+ node_count: 0,
+ max_nodes: 10,
+ max_depth: 10,
+ max_tree_bytes: 100,
+ };
+ render_node(&node, 1, &mut state).expect("processing instruction should render");
+ assert_eq!(" \n", state.tree);
+ assert_eq!(1, state.node_count);
+ }
+}
diff --git a/tools/html-api-fuzz/tests/html5ever-install-integrity-smoke.sh b/tools/html-api-fuzz/tests/html5ever-install-integrity-smoke.sh
new file mode 100755
index 0000000000000..3427b5b43c36d
--- /dev/null
+++ b/tools/html-api-fuzz/tests/html5ever-install-integrity-smoke.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+FUZZ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
+HTML5EVER_DIR="$FUZZ_ROOT/oracles/html5ever"
+INSTALLER="$HTML5EVER_DIR/install-rust.sh"
+CHECKSUMS="$HTML5EVER_DIR/RUSTUP_SHA256SUMS"
+RUSTUP_VERSION='1.28.2'
+
+fail() {
+ printf 'FAIL: %s\n' "$*" >&2
+ exit 1
+}
+
+file_mode() {
+ case "$(uname -s)" in
+ Darwin) /usr/bin/stat -f '%Lp' "$1" ;;
+ Linux) stat -c '%a' "$1" ;;
+ *) fail 'file-mode check requires macOS or Linux' ;;
+ esac
+}
+
+case "$(uname -s)-$(uname -m)" in
+ Darwin-arm64) target='aarch64-apple-darwin' ;;
+ Darwin-x86_64) target='x86_64-apple-darwin' ;;
+ Linux-aarch64) target='aarch64-unknown-linux-gnu' ;;
+ Linux-x86_64|Linux-amd64) target='x86_64-unknown-linux-gnu' ;;
+ *) fail "unsupported smoke-test host: $(uname -s)-$(uname -m)" ;;
+esac
+
+expected_names=(
+ "rustup-init-$RUSTUP_VERSION-aarch64-apple-darwin"
+ "rustup-init-$RUSTUP_VERSION-x86_64-apple-darwin"
+ "rustup-init-$RUSTUP_VERSION-aarch64-unknown-linux-gnu"
+ "rustup-init-$RUSTUP_VERSION-x86_64-unknown-linux-gnu"
+)
+entry_count="$(awk 'NF && $1 !~ /^#/ { count++ } END { print count + 0 }' "$CHECKSUMS")"
+[[ "$entry_count" -eq "${#expected_names[@]}" ]] || fail "checksum manifest has $entry_count entries"
+for name in "${expected_names[@]}"; do
+ matches="$(awk -v name="$name" '$2 == name { count++ } END { print count + 0 }' "$CHECKSUMS")"
+ fields="$(awk -v name="$name" '$2 == name { print NF }' "$CHECKSUMS")"
+ digest="$(awk -v name="$name" '$2 == name { print $1 }' "$CHECKSUMS")"
+ [[ "$matches" -eq 1 ]] || fail "$name must have exactly one checksum"
+ [[ "$fields" -eq 2 ]] || fail "$name checksum must have exactly two fields"
+ [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || fail "$name checksum is not lowercase SHA-256"
+done
+
+tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/html5ever-install-integrity.XXXXXX")"
+trap 'rm -rf "$tmp_root"' EXIT HUP INT TERM
+fake_bin="$tmp_root/fake-bin"
+external_log="$tmp_root/external.log"
+mkdir -p "$fake_bin"
+for command in curl; do
+ {
+ printf '%s\n' '#!/bin/sh'
+ printf '%s\n' 'printf "%s\n" "$0" >> "$HTML_API_FUZZ_EXTERNAL_LOG"'
+ printf '%s\n' 'exit 97'
+ } >"$fake_bin/$command"
+ chmod 0755 "$fake_bin/$command"
+done
+
+run_installer() {
+ local fixture_dir="$1"
+ local rust_root="$2"
+ PATH="$fake_bin:$PATH" \
+ HTML_API_FUZZ_EXTERNAL_LOG="$external_log" \
+ HTML5EVER_RUST_ROOT="$rust_root" \
+ CARGO_HOME="$rust_root/cargo" \
+ RUSTUP_HOME="$rust_root/rustup" \
+ HTML_API_FUZZ_INVOCATION_LOG="$tmp_root/invocations.log" \
+ "$fixture_dir/install-rust.sh"
+}
+
+# Manifest validation must fail before a network tool or cached bootstrap can run.
+for mutation in missing duplicate malformed; do
+ fixture="$tmp_root/manifest-$mutation"
+ rust_root="$tmp_root/rust-$mutation"
+ mkdir -p "$fixture"
+ cp "$INSTALLER" "$fixture/install-rust.sh"
+ case "$mutation" in
+ missing)
+ awk -v target="rustup-init-$RUSTUP_VERSION-$target" '$2 != target' "$CHECKSUMS" >"$fixture/RUSTUP_SHA256SUMS"
+ ;;
+ duplicate)
+ cp "$CHECKSUMS" "$fixture/RUSTUP_SHA256SUMS"
+ awk -v target="rustup-init-$RUSTUP_VERSION-$target" '$2 == target { print }' "$CHECKSUMS" >>"$fixture/RUSTUP_SHA256SUMS"
+ ;;
+ malformed)
+ awk -v target="rustup-init-$RUSTUP_VERSION-$target" '$2 == target { print "not-a-digest " $2; next } { print }' "$CHECKSUMS" >"$fixture/RUSTUP_SHA256SUMS"
+ ;;
+ esac
+ if run_installer "$fixture" "$rust_root" >"$tmp_root/$mutation.out" 2>"$tmp_root/$mutation.err"; then
+ fail "$mutation checksum manifest was accepted"
+ fi
+ [[ ! -e "$external_log" ]] || fail "$mutation manifest reached an external command"
+ [[ ! -e "$tmp_root/invocations.log" ]] || fail "$mutation manifest executed a bootstrap"
+done
+
+# A corrupt cached bootstrap must be hashed before chmod or execution.
+rust_root="$tmp_root/rust-corrupt"
+rustup_init="$rust_root/downloads/rustup-init-$RUSTUP_VERSION-$target"
+mkdir -p "$(dirname "$rustup_init")"
+{
+ printf '%s\n' '#!/bin/sh'
+ printf '%s\n' 'printf "%s\n" invoked >> "$HTML_API_FUZZ_INVOCATION_LOG"'
+} >"$rustup_init"
+chmod 0644 "$rustup_init"
+mode_before="$(file_mode "$rustup_init")"
+if run_installer "$HTML5EVER_DIR" "$rust_root" >"$tmp_root/corrupt.out" 2>"$tmp_root/corrupt.err"; then
+ fail 'corrupt cached rustup-init was accepted'
+fi
+[[ "$(file_mode "$rustup_init")" = "$mode_before" ]] || fail 'corrupt bootstrap mode changed before verification'
+[[ ! -x "$rustup_init" ]] || fail 'corrupt bootstrap was made executable'
+[[ ! -e "$tmp_root/invocations.log" ]] || fail 'corrupt bootstrap was executed'
+[[ ! -e "$external_log" ]] || fail 'corrupt cache unexpectedly reached the network'
+grep -q 'SHA-256 mismatch' "$tmp_root/corrupt.err" || fail 'corrupt bootstrap rejection lacked a checksum diagnostic'
+
+# Also prove an already-executable corrupt cache is not trusted.
+rust_root="$tmp_root/rust-corrupt-executable"
+rustup_init="$rust_root/downloads/rustup-init-$RUSTUP_VERSION-$target"
+mkdir -p "$(dirname "$rustup_init")"
+{
+ printf '%s\n' '#!/bin/sh'
+ printf '%s\n' 'printf "%s\n" invoked >> "$HTML_API_FUZZ_INVOCATION_LOG"'
+} >"$rustup_init"
+chmod 0755 "$rustup_init"
+if run_installer "$HTML5EVER_DIR" "$rust_root" >"$tmp_root/executable.out" 2>"$tmp_root/executable.err"; then
+ fail 'executable corrupt cached rustup-init was accepted'
+fi
+[[ ! -e "$tmp_root/invocations.log" ]] || fail 'executable corrupt bootstrap was executed'
+[[ ! -e "$external_log" ]] || fail 'executable corrupt cache unexpectedly reached the network'
+grep -q 'SHA-256 mismatch' "$tmp_root/executable.err" || fail 'executable corrupt rejection lacked a checksum diagnostic'
+
+printf '%s\n' 'OK html5ever-install-integrity-smoke'
From 2024490aa636a88a2c6b0f9226c9500344d39a13 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Thu, 16 Jul 2026 02:36:58 +0200
Subject: [PATCH 009/149] oracles: add pinned Chrome CDP runner
---
.gitignore | 3 +
.../oracles/chrome/EXECUTABLE_SHA256SUMS | 3 +
tools/html-api-fuzz/oracles/chrome/README.md | 146 +
tools/html-api-fuzz/oracles/chrome/SHA256SUMS | 4 +
tools/html-api-fuzz/oracles/chrome/VERSION | 1 +
.../oracles/chrome/chrome-tree-oracle.js | 3516 +++++++++++++++++
tools/html-api-fuzz/oracles/chrome/install.sh | 775 ++++
.../oracles/chrome/smoke-test.js | 2091 ++++++++++
.../oracles/fragment-contexts.json | 19 +
.../tests/chrome-install-integrity-smoke.sh | 664 ++++
10 files changed, 7222 insertions(+)
create mode 100644 tools/html-api-fuzz/oracles/chrome/EXECUTABLE_SHA256SUMS
create mode 100644 tools/html-api-fuzz/oracles/chrome/README.md
create mode 100644 tools/html-api-fuzz/oracles/chrome/SHA256SUMS
create mode 100644 tools/html-api-fuzz/oracles/chrome/VERSION
create mode 100755 tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js
create mode 100755 tools/html-api-fuzz/oracles/chrome/install.sh
create mode 100755 tools/html-api-fuzz/oracles/chrome/smoke-test.js
create mode 100644 tools/html-api-fuzz/oracles/fragment-contexts.json
create mode 100755 tools/html-api-fuzz/tests/chrome-install-integrity-smoke.sh
diff --git a/.gitignore b/.gitignore
index dd109c0a03fb8..a0a821d4c15f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,3 +120,6 @@ wp-tests-config.php
# Visual regression test diffs
tests/visual-regression/specs/__snapshots__
+
+# Local Chrome for Testing cache used by the HTML API fuzzer.
+/tools/html-api-fuzz/oracles/chrome/.chrome-for-testing
diff --git a/tools/html-api-fuzz/oracles/chrome/EXECUTABLE_SHA256SUMS b/tools/html-api-fuzz/oracles/chrome/EXECUTABLE_SHA256SUMS
new file mode 100644
index 0000000000000..60b4b9ff54879
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/EXECUTABLE_SHA256SUMS
@@ -0,0 +1,3 @@
+f00d063ede900e7ef859fdc6fdea635aa724b0117162db973a0b23f256496129 mac-arm64.executable
+a39e171f8b63986ce5ee94012506337fc97a6be3045a22b4161b2379f743c4de mac-x64.executable
+e3898eb8da0a85d653bda3280f4bf5cb7fcac26b229c7fdc2c515ccbdef197ce linux64.executable
diff --git a/tools/html-api-fuzz/oracles/chrome/README.md b/tools/html-api-fuzz/oracles/chrome/README.md
new file mode 100644
index 0000000000000..44565c5ad0747
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/README.md
@@ -0,0 +1,146 @@
+# Pinned Chrome CDP tree oracle
+
+This oracle parses HTML with Chrome for Testing and serializes the resulting
+DOM in the fuzzer's canonical tree format. It uses Node's built-in modules and
+the Chrome DevTools Protocol directly; it has no npm, Playwright, or Puppeteer
+dependency.
+
+## Install and verify
+
+`VERSION` pins Chrome for Testing 150.0.7871.114. `SHA256SUMS` independently
+anchors each official archive and `EXECUTABLE_SHA256SUMS` anchors the Chrome
+executable extracted from each archive for macOS arm64/x64 and Linux x64.
+Foreign-platform executable hashes are derived without executing those
+binaries.
+
+```sh
+tools/html-api-fuzz/oracles/chrome/install.sh
+tools/html-api-fuzz/oracles/chrome/install.sh --print-path
+```
+
+The installer requires HTTPS, authenticates the archive before extraction,
+and authenticates the executable before its version probe. Publication uses a
+serialized lock, unique staging, rollback backup, and marker-last commit. The
+marker records the authenticated snapshot for diagnostics but never acts as
+its own trust anchor: cache hits always re-read the checked-in hashes and
+rehash around the version probe. Downloads have a 15-second connect timeout
+and a 300-second overall timeout. A dependency-free Node watchdog caps the
+version probe at 10 seconds and 16 KiB of combined output, terminates its whole
+process group with bounded TERM/KILL escalation, and removes the exact staging
+directory and authenticated lock if the installer owner dies. An elected
+in-lock reaper may also recover a lock whose exact published owner PID is dead.
+Missing or malformed owner metadata fails closed and requires manual removal
+because it cannot be reaped without a race against an owner that has not
+finished publishing its identity.
+
+Set `HTML_API_FUZZ_CHROME_INSTALL_ROOT` to relocate the cache. An alternate
+`--chrome-executable` path is accepted only when its bytes match the current
+platform's checked-in executable hash and its live CDP version matches
+`VERSION`. Oracle startup does not take the installer's publication lock: it
+compares read-only executable and marker identity snapshots around supervised
+startup, so concurrent replacement fails closed without leaving an
+owner-created lock. `--print-path` computes a path only; it downloads and
+executes nothing.
+
+## Run
+
+One-shot mode accepts an exact input file:
+
+```sh
+node tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js \
+ --mode full-document --input /path/to/input.html
+```
+
+Persistent stdio uses one newline-delimited JSON request at a time:
+
+```sh
+node tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js --serve
+```
+
+Socket mode keeps the same Chrome process warm for multiple clients. The
+socket parent must already be owned by the current uid with mode exactly 0700;
+the socket is created with mode 0600 only after Chrome has been authenticated
+and CDP is ready. Each socket connection carries exactly one request and
+response.
+
+```sh
+runtime_dir="$(mktemp -d)"
+chmod 0700 "$runtime_dir"
+node tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js \
+ --serve --socket "$runtime_dir/oracle.sock"
+```
+
+Commands are `render`, `version`, and `shutdown`. Render payloads contain
+canonical `htmlBase64`, `mode`, and positive `maxNodes`, `maxDepth`, and
+`maxTreeBytes` limits. Fragment mode accepts only the 17 contexts in
+`../fragment-contexts.json`, which the smoke test checks against
+`Generator::fragment_contexts()`. Request frames must be valid UTF-8 JSON.
+Optional request IDs are strings or JSON numbers that decode to safe integers;
+string IDs are capped at 4,096 UTF-8 bytes, and invalid or rounded numeric IDs
+are rejected and never echoed. Socket admission is capped at eight peers. An
+incomplete request and a blocked response each have a 10-second deadline, and
+disconnected requests are cancelled before dispatch when possible.
+
+Successful renders return only `treeBase64` for tree bytes—never a duplicate
+JSON string—plus `treeBytes`, `treeSha256`, and `nodeCount`. Raw input is capped
+at 2 MiB, nodes at 100,000, rendered indentation depth at 1,024, canonical tree
+bytes at 16 MiB, request frames at 4 MiB, response frames at 24 MiB, and
+internal CDP messages at 32 MiB. Invalid UTF-8 is a structured unsupported
+result; malformed or noncanonical base64 is a protocol error. Renderer
+envelopes use exact schemas and fixed failure-class allowlists, and successful
+tree bytes must be canonical UTF-8 text.
+
+Documents are parsed with a detached `DOMParser` document. Fragments use a
+detached inert contextual document. Author nodes are never inserted into the
+active trusted renderer page, and outbound protocols are blocked. Chrome,
+Node, script, and fragment-context hashes/versions, the ordered context list,
+and the live CDP protocol form durable `oracle.identity`. Paths, endpoints,
+PIDs, profiles, sockets, and instance counters appear only in
+`oracle.transport`, marked `replayExcluded`.
+
+The owner creates no runtime resources and executes no Chrome binary before
+starting an internal direct-parent supervisor. That supervisor creates the
+runtime/profile and starts one token/profile-bearing Chrome tree as its
+non-detached child; the live `Browser.getVersion` CDP response proves the
+runtime version before the oracle becomes healthy, and a second live identity
+round trip gates publication after setup. The entire startup has one
+30-second absolute deadline. Supervisor events are fatal-UTF-8, capped at 1
+MiB, and accepted only through exact per-event schemas; a protocol failure is
+fatal both before and after readiness. The separate bounded
+`--version` probe belongs to installation, where installer signal/rollback
+handling owns it. The owner and supervisor monitor each other: owner death
+closes an unshared pipe and triggers supervisor cleanup; supervisor death
+triggers authenticated owner-side process-tree cleanup and a fatal
+infrastructure result. Process birth identity and the unique profile/token
+prevent signaling PID-reuse victims. The entire tree continues to inherit the
+outer worker process group. Supervisor
+process-snapshot heartbeats are dropped while its owner pipe is backpressured.
+Cleanup uses one process-table snapshot per signaling pass, gives each
+synchronous inspection only the remaining absolute cleanup budget, and
+preserves the runtime/profile whenever the authenticated tree is not proven
+gone. A cleanup failure is an oracle-infrastructure failure.
+A render is attempted again at most once, and only after an authenticated live
+CDP session is observed closed. Each attempt uses one exact captured session;
+timeouts, malformed trusted output, and a failed final retry quarantine that
+session without replaying author input again.
+
+## Verify
+
+```sh
+tools/html-api-fuzz/tests/chrome-install-integrity-smoke.sh
+node tools/html-api-fuzz/oracles/chrome/smoke-test.js
+```
+
+The first suite is network-free fault injection for manifests, archive and
+executable authentication, cache races, concurrent locks, transactional
+rollback, signals, and stale-lock election. The second runs the real pinned
+browser through canonical/security/limit/framing tests, exact 16 MiB output,
+exact 2 MiB raw-input acceptance and overflow rejection, warm reuse and
+restart, signals, bounded partial clients and writes, and separate
+owner/supervisor hard-kill tests during startup both before any executable
+child/socket exists and after the authenticated browser tree has spawned.
+Additional supervisor/browser hard kills after the CDP handshake prove that
+death remains latched until healthy publication; steady-state owner/supervisor
+hard-kill cleanup is covered separately. A real paused Unix-socket client
+forces a 20 MiB response into backpressure, disconnects, and proves bounded
+slot release and dispatcher recovery.
diff --git a/tools/html-api-fuzz/oracles/chrome/SHA256SUMS b/tools/html-api-fuzz/oracles/chrome/SHA256SUMS
new file mode 100644
index 0000000000000..59e4d04b2e8a0
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/SHA256SUMS
@@ -0,0 +1,4 @@
+# Chrome for Testing 150.0.7871.114 official platform archives.
+c3315c85a884b20e7db0f15b946cb28132bd48bbd8638cdf60895187693ececa chrome-150.0.7871.114-mac-arm64.zip
+bebf255c76858ee907a55fffcd0907185045ecb5f486b5c604feb73c9de71332 chrome-150.0.7871.114-mac-x64.zip
+03963c0dd9bf91e9b0e760cff37680f9b92ff42758182286382787622323cf9d chrome-150.0.7871.114-linux64.zip
diff --git a/tools/html-api-fuzz/oracles/chrome/VERSION b/tools/html-api-fuzz/oracles/chrome/VERSION
new file mode 100644
index 0000000000000..5383551615946
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/VERSION
@@ -0,0 +1 @@
+150.0.7871.114
diff --git a/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js b/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js
new file mode 100755
index 0000000000000..98e73b3e3921b
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js
@@ -0,0 +1,3516 @@
+#!/usr/bin/env node
+'use strict';
+
+const crypto = require( 'node:crypto' );
+const fs = require( 'node:fs' );
+const net = require( 'node:net' );
+const os = require( 'node:os' );
+const path = require( 'node:path' );
+const { once } = require( 'node:events' );
+const { spawn, spawnSync } = require( 'node:child_process' );
+
+const SCRIPT_DIR = __dirname;
+const VERSION_FILE = path.join( SCRIPT_DIR, 'VERSION' );
+const VERSION_FILE_CONTENTS = fs.readFileSync( VERSION_FILE, 'utf8' );
+if ( ! /^[0-9]+(?:\.[0-9]+){3}\n$/.test( VERSION_FILE_CONTENTS ) ) {
+ throw new Error( 'VERSION must contain exactly one canonical four-component version and a final newline.' );
+}
+const PINNED_CHROME_VERSION = VERSION_FILE_CONTENTS.slice( 0, -1 );
+const CONTEXT_FILE = path.join( SCRIPT_DIR, '..', 'fragment-contexts.json' );
+const CONTEXT_FILE_CONTENTS = fs.readFileSync( CONTEXT_FILE );
+const CONTEXT_FILE_SHA256 = crypto.createHash( 'sha256' ).update( CONTEXT_FILE_CONTENTS ).digest( 'hex' );
+const CONTEXTS = Object.freeze( JSON.parse( CONTEXT_FILE_CONTENTS.toString( 'utf8' ) ) );
+const CONTEXT_SET = new Set( CONTEXTS );
+const HTML_NS = 'http://www.w3.org/1999/xhtml';
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const MATH_NS = 'http://www.w3.org/1998/Math/MathML';
+const MAX_INPUT_BYTES = 2 * 1024 * 1024;
+const MAX_NODES = 100000;
+const MAX_DEPTH = 1024;
+const MAX_TREE_BYTES = 16 * 1024 * 1024;
+const MAX_REQUEST_FRAME_BYTES = 4 * 1024 * 1024;
+const MAX_RESPONSE_FRAME_BYTES = 24 * 1024 * 1024;
+const MAX_CDP_FRAME_BYTES = 32 * 1024 * 1024;
+const MAX_SOCKET_CLIENTS = 8;
+const MAX_REQUEST_ID_BYTES = 4096;
+const SOCKET_FRAME_TIMEOUT_MS = 10000;
+const SOCKET_WRITE_TIMEOUT_MS = 10000;
+const CHROME_STDERR_BYTES = 16384;
+const INTERNAL_SUPERVISOR_ENV = 'HTML_API_FUZZ_CHROME_INTERNAL_SUPERVISOR';
+const TEST_ALLOW_INTERNAL_COMMANDS_ENV = 'HTML_API_FUZZ_CHROME_TEST_ALLOW_INTERNAL_COMMANDS';
+const TERMINAL_OUTPUT_STREAMS = new WeakSet();
+let forceProcessExitAfterCleanup = false;
+const JSON_FRAME_DECODER = new TextDecoder( 'utf-8', { fatal: true } );
+
+if (
+ ! Array.isArray( CONTEXTS ) ||
+ 17 !== CONTEXTS.length ||
+ 17 !== CONTEXT_SET.size ||
+ CONTEXTS.some( ( value ) => 'string' !== typeof value || ! /^[a-z]+$/.test( value ) )
+) {
+ throw new Error( 'fragment-contexts.json must contain 17 unique lowercase context names.' );
+}
+
+function delay( milliseconds ) {
+ return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) );
+}
+
+function appendByteTail( current, chunk, maximumBytes ) {
+ const combined = Buffer.concat( [ current, chunk ] );
+ return combined.length <= maximumBytes ? combined : combined.subarray( combined.length - maximumBytes );
+}
+
+function decodeBoundedUtf8Tail( buffer, maximumBytes ) {
+ let decoded = buffer.toString( 'utf8' );
+ while ( Buffer.byteLength( decoded, 'utf8' ) > maximumBytes ) {
+ decoded = decoded.slice( 1 );
+ }
+ return decoded;
+}
+
+function transportError( message, cause ) {
+ const error = new Error( message, cause ? { cause } : undefined );
+ error.transportFailure = true;
+ return error;
+}
+
+function sessionDeathError( message, cause ) {
+ const error = transportError( message, cause );
+ error.recoverableSessionDeath = true;
+ return error;
+}
+
+function cdpTimeoutError( method, kind = 'command' ) {
+ const error = new Error( `CDP ${ kind } ${ method } timed out.` );
+ error.failureClass = 'Runtime.evaluate' === method
+ ? 'oracle-evaluation-timeout'
+ : 'oracle-infrastructure-timeout';
+ error.invalidateSession = true;
+ return error;
+}
+
+function cdpProtocolError( message, cause ) {
+ const error = transportError( message, cause );
+ error.failureClass = 'oracle-infrastructure-failure';
+ error.invalidateSession = true;
+ return error;
+}
+
+function markTransportError( error ) {
+ if ( error && 'object' === typeof error ) {
+ error.transportFailure = true;
+ return error;
+ }
+ return transportError( String( error ) );
+}
+
+function isRecoverableSessionDeath( error ) {
+ return true === error?.recoverableSessionDeath;
+}
+
+function isValidRequestId( id ) {
+ return ( 'string' === typeof id && Buffer.byteLength( id, 'utf8' ) <= MAX_REQUEST_ID_BYTES ) ||
+ ( 'number' === typeof id && Number.isSafeInteger( id ) );
+}
+
+function hasExactOwnKeys( value, expected ) {
+ if ( null === value || 'object' !== typeof value || Array.isArray( value ) ) {
+ return false;
+ }
+ const actual = Object.keys( value ).sort();
+ const wanted = [ ...expected ].sort();
+ return actual.length === wanted.length && actual.every( ( key, index ) => key === wanted[ index ] );
+}
+
+function platformConfiguration() {
+ if ( 'darwin' === process.platform && 'arm64' === process.arch ) {
+ return {
+ platform: 'mac-arm64',
+ archiveDirectory: 'chrome-mac-arm64',
+ executableRelative: 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
+ };
+ }
+ if ( 'darwin' === process.platform && 'x64' === process.arch ) {
+ return {
+ platform: 'mac-x64',
+ archiveDirectory: 'chrome-mac-x64',
+ executableRelative: 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
+ };
+ }
+ if ( 'linux' === process.platform && 'x64' === process.arch ) {
+ return {
+ platform: 'linux64',
+ archiveDirectory: 'chrome-linux64',
+ executableRelative: 'chrome',
+ };
+ }
+ throw new Error( 'Chrome for Testing is unsupported on ' + process.platform + '/' + process.arch + '.' );
+}
+
+function manifestDigest( filename, key ) {
+ const lines = fs.readFileSync( filename, 'utf8' ).split( /\r?\n/ );
+ const matches = [];
+ for ( const line of lines ) {
+ const trimmed = line.trim();
+ if ( '' === trimmed || trimmed.startsWith( '#' ) ) {
+ continue;
+ }
+ const fields = trimmed.split( /\s+/ );
+ if ( key === fields[ 1 ] ) {
+ matches.push( fields );
+ }
+ }
+ if (
+ 1 !== matches.length ||
+ 2 !== matches[ 0 ].length ||
+ ! /^[0-9a-f]{64}$/.test( matches[ 0 ][ 0 ] )
+ ) {
+ throw new Error( 'Expected one valid checksum for ' + key + ' in ' + filename + '.' );
+ }
+ return matches[ 0 ][ 0 ];
+}
+
+async function hashFile( filename ) {
+ const hash = crypto.createHash( 'sha256' );
+ const stream = fs.createReadStream( filename );
+ stream.on( 'data', ( chunk ) => hash.update( chunk ) );
+ await once( stream, 'close' );
+ return hash.digest( 'hex' );
+}
+
+function hashFileSync( filename ) {
+ const hash = crypto.createHash( 'sha256' );
+ const descriptor = fs.openSync( filename, 'r' );
+ const buffer = Buffer.allocUnsafe( 1024 * 1024 );
+ try {
+ for ( ;; ) {
+ const bytes = fs.readSync( descriptor, buffer, 0, buffer.length, null );
+ if ( 0 === bytes ) {
+ break;
+ }
+ hash.update( buffer.subarray( 0, bytes ) );
+ }
+ } finally {
+ fs.closeSync( descriptor );
+ }
+ return hash.digest( 'hex' );
+}
+
+function fileIdentitySnapshot( filename ) {
+ const realpath = fs.realpathSync( filename );
+ const stat = fs.statSync( realpath, { bigint: true } );
+ if ( ! stat.isFile() ) {
+ throw new Error( 'Authenticated path is not a regular file: ' + filename + '.' );
+ }
+ return {
+ realpath,
+ device: stat.dev.toString(),
+ inode: stat.ino.toString(),
+ size: stat.size.toString(),
+ mode: stat.mode.toString(),
+ links: stat.nlink.toString(),
+ modifiedNanoseconds: stat.mtimeNs.toString(),
+ changedNanoseconds: stat.ctimeNs.toString(),
+ };
+}
+
+function inputFileChangedError( message ) {
+ const error = new Error( message );
+ error.failureClass = 'input-file-changed';
+ return error;
+}
+
+function inputByteLimitError() {
+ const error = new Error( 'Input file must be regular and no larger than 2 MiB.' );
+ error.failureClass = 'input-byte-limit-exceeded';
+ return error;
+}
+
+function sameInputFileStat( left, right ) {
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
+}
+
+async function readBoundedRegularFile( filename, maximumBytes ) {
+ const descriptor = fs.openSync(
+ filename,
+ fs.constants.O_RDONLY | fs.constants.O_NONBLOCK
+ );
+ try {
+ const before = fs.fstatSync( descriptor, { bigint: true } );
+ if ( ! before.isFile() || before.size > BigInt( maximumBytes ) ) {
+ throw inputByteLimitError();
+ }
+ const pauseFile = process.env.HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_INPUT_OPEN;
+ if ( pauseFile ) {
+ fs.writeFileSync(
+ pauseFile,
+ JSON.stringify( { pid: process.pid, input: filename } ) + '\n',
+ { flag: 'wx', mode: 0o600 }
+ );
+ while ( fs.existsSync( pauseFile ) ) {
+ await delay( 10 );
+ }
+ }
+ const chunks = [];
+ let bytes = 0;
+ while ( bytes <= maximumBytes ) {
+ const buffer = Buffer.allocUnsafe( Math.min( 65536, maximumBytes + 1 - bytes ) );
+ const count = fs.readSync( descriptor, buffer, 0, buffer.length, null );
+ if ( 0 === count ) {
+ break;
+ }
+ chunks.push( buffer.subarray( 0, count ) );
+ bytes += count;
+ }
+ if ( bytes > maximumBytes ) {
+ throw inputByteLimitError();
+ }
+ const after = fs.fstatSync( descriptor, { bigint: true } );
+ let current;
+ try {
+ current = fs.statSync( filename, { bigint: true } );
+ } catch ( error ) {
+ throw inputFileChangedError( 'Input path disappeared during its bounded read.' );
+ }
+ if ( ! sameInputFileStat( before, after ) || ! sameInputFileStat( after, current ) ) {
+ throw inputFileChangedError( 'Input file changed during its bounded read.' );
+ }
+ return Buffer.concat( chunks, bytes );
+ } finally {
+ fs.closeSync( descriptor );
+ }
+}
+
+function sameFileIdentitySnapshot( left, right ) {
+ return JSON.stringify( left ) === JSON.stringify( right );
+}
+
+function sameMarkerSnapshot( left, right ) {
+ return ( null === left || null === right )
+ ? left === right
+ : left.contents === right.contents && sameFileIdentitySnapshot( left.identity, right.identity );
+}
+
+function readProcessTable( timeoutMilliseconds = 1000 ) {
+ const timeout = Math.max( 1, Math.min( 1000, Math.floor( timeoutMilliseconds ) ) );
+ const checked = spawnSync(
+ 'ps',
+ [ '-axww', '-o', 'pid=,ppid=,lstart=,command=' ],
+ {
+ encoding: 'utf8',
+ timeout,
+ maxBuffer: 4 * 1024 * 1024,
+ env: { ...process.env, LC_ALL: 'C' },
+ }
+ );
+ if ( checked.error || 0 !== checked.status ) {
+ throw new Error( 'Could not inspect the process table.' );
+ }
+ const records = [];
+ for ( const line of checked.stdout.split( '\n' ) ) {
+ const match = line.match( /^\s*(\d+)\s+(\d+)\s+(.{24})\s+(.*)$/ );
+ if ( ! match ) {
+ continue;
+ }
+ records.push( {
+ pid: Number.parseInt( match[ 1 ], 10 ),
+ ppid: Number.parseInt( match[ 2 ], 10 ),
+ start: match[ 3 ],
+ command: match[ 4 ],
+ } );
+ }
+ return records;
+}
+
+function linuxStartTicks( pid ) {
+ if ( 'linux' !== process.platform ) {
+ return null;
+ }
+ try {
+ const stat = fs.readFileSync( '/proc/' + pid + '/stat', 'utf8' );
+ const close = stat.lastIndexOf( ')' );
+ if ( close < 0 ) {
+ return null;
+ }
+ const fields = stat.slice( close + 2 ).trim().split( /\s+/ );
+ return fields[ 19 ] || null;
+ } catch ( _error ) {
+ return null;
+ }
+}
+
+function processIdentity( record ) {
+ const ticks = linuxStartTicks( record.pid );
+ return {
+ pid: record.pid,
+ ppid: record.ppid,
+ birth: null === ticks ? record.start : 'linux:' + ticks,
+ command: record.command,
+ };
+}
+
+function captureProcessTree( rootPid, profilePath, ownershipToken, table = readProcessTable() ) {
+ const byPid = new Map( table.map( ( record ) => [ record.pid, record ] ) );
+ const root = byPid.get( rootPid );
+ if ( ! root ) {
+ return [];
+ }
+ if ( ! root.command.includes( '--user-data-dir=' + profilePath ) || ! root.command.includes( ownershipToken ) ) {
+ throw new Error( 'Chrome process command does not carry its unique ownership identity.' );
+ }
+ const children = new Map();
+ for ( const record of table ) {
+ const list = children.get( record.ppid ) || [];
+ list.push( record.pid );
+ children.set( record.ppid, list );
+ }
+ const ordered = [];
+ const queue = [ rootPid ];
+ while ( queue.length ) {
+ const pid = queue.shift();
+ const record = byPid.get( pid );
+ if ( record ) {
+ ordered.push( processIdentity( record ) );
+ queue.push( ...( children.get( pid ) || [] ) );
+ }
+ }
+ return ordered;
+}
+
+function scanOwnedChromeTree( profilePath, ownershipToken, table = readProcessTable() ) {
+ const root = table.find(
+ ( record ) =>
+ record.command.includes( '--user-data-dir=' + profilePath ) &&
+ record.command.includes( ownershipToken ) &&
+ ! record.command.includes( '--internal-supervisor' )
+ );
+ return root ? captureProcessTree( root.pid, profilePath, ownershipToken, table ) : [];
+}
+
+function currentIdentityForPid( pid ) {
+ const record = readProcessTable().find( ( item ) => item.pid === pid );
+ return record ? processIdentity( record ) : null;
+}
+
+function sameProcessIdentity( expected, actual ) {
+ return Boolean(
+ actual &&
+ expected.pid === actual.pid &&
+ expected.birth === actual.birth &&
+ expected.command === actual.command
+ );
+}
+
+function mergeProcessSnapshots( snapshots ) {
+ const merged = new Map();
+ for ( const snapshot of snapshots ) {
+ for ( const record of snapshot || [] ) {
+ merged.set( record.pid + ':' + record.birth, record );
+ }
+ }
+ return [ ...merged.values() ];
+}
+
+function retainProcessSnapshot( state, snapshot ) {
+ state.processSnapshots.push( snapshot );
+ if ( state.processSnapshots.length > 8 ) {
+ state.processSnapshots.splice( 0, state.processSnapshots.length - 8 );
+ }
+ return state.processSnapshots.slice();
+}
+
+function isValidProcessSnapshot( snapshot ) {
+ return Array.isArray( snapshot ) && snapshot.length > 0 && snapshot.length <= 4096 && snapshot.every( ( record ) => (
+ hasExactOwnKeys( record, [ 'pid', 'ppid', 'birth', 'command' ] ) &&
+ Number.isSafeInteger( record.pid ) && record.pid >= 2 &&
+ Number.isSafeInteger( record.ppid ) && record.ppid >= 0 &&
+ 'string' === typeof record.birth && record.birth.length > 0 && record.birth.length <= 256 &&
+ 'string' === typeof record.command && Buffer.byteLength( record.command, 'utf8' ) <= 65536
+ ) );
+}
+
+async function reapAuthenticatedTree( snapshots, profilePath, ownershipToken, options = {} ) {
+ const tableReader = options.tableReader || readProcessTable;
+ const signalProcess = options.signalProcess || ( ( pid, signal ) => process.kill( pid, signal ) );
+ const totalDeadline = Date.now() + ( options.totalTimeoutMs || 6000 );
+ const termDeadline = Math.min( totalDeadline, Date.now() + ( options.termTimeoutMs || 2000 ) );
+ const pollMilliseconds = options.pollMilliseconds || 50;
+ let records = mergeProcessSnapshots( snapshots );
+ const inspect = async () => {
+ const remaining = totalDeadline - Date.now();
+ if ( remaining <= 0 ) {
+ throw new Error( 'Authenticated Chrome cleanup exceeded its absolute deadline.' );
+ }
+ const table = await new Promise( ( resolve, reject ) => {
+ const timer = setTimeout(
+ () => reject( new Error( 'Process-table inspection exceeded the Chrome cleanup deadline.' ) ),
+ remaining
+ );
+ timer.unref();
+ Promise.resolve().then( () => tableReader( remaining ) ).then(
+ ( value ) => {
+ clearTimeout( timer );
+ resolve( value );
+ },
+ ( error ) => {
+ clearTimeout( timer );
+ reject( error );
+ }
+ );
+ } );
+ if ( Date.now() >= totalDeadline ) {
+ throw new Error( 'Process-table inspection exceeded the Chrome cleanup deadline.' );
+ }
+ const owned = scanOwnedChromeTree( profilePath, ownershipToken, table );
+ records = mergeProcessSnapshots( [ records, owned ] );
+ const rawByPid = new Map( table.map( ( record ) => [ record.pid, record ] ) );
+ const surviving = [];
+ for ( const expected of records ) {
+ if ( Date.now() >= totalDeadline ) {
+ throw new Error( 'Process identity inspection exceeded the Chrome cleanup deadline.' );
+ }
+ const raw = rawByPid.get( expected.pid );
+ if ( raw && sameProcessIdentity( expected, processIdentity( raw ) ) ) {
+ surviving.push( expected );
+ }
+ }
+ if ( Date.now() >= totalDeadline ) {
+ throw new Error( 'Process identity inspection exceeded the Chrome cleanup deadline.' );
+ }
+ return { owned, surviving };
+ };
+ const signalMatching = ( inspection, signal ) => {
+ for ( const record of [ ...inspection.surviving ].reverse() ) {
+ try {
+ signalProcess( record.pid, signal );
+ } catch ( error ) {
+ if ( 'ESRCH' !== error.code ) {
+ throw error;
+ }
+ }
+ }
+ };
+ let inspection = await inspect();
+ if ( 0 === inspection.surviving.length && 0 === inspection.owned.length ) {
+ return;
+ }
+ signalMatching( inspection, 'SIGTERM' );
+ while ( Date.now() < termDeadline ) {
+ await delay( Math.min( pollMilliseconds, Math.max( 1, termDeadline - Date.now() ) ) );
+ inspection = await inspect();
+ if ( 0 === inspection.surviving.length && 0 === inspection.owned.length ) {
+ return;
+ }
+ }
+ inspection = await inspect();
+ signalMatching( inspection, 'SIGKILL' );
+ while ( Date.now() < totalDeadline ) {
+ await delay( Math.min( pollMilliseconds, Math.max( 1, totalDeadline - Date.now() ) ) );
+ inspection = await inspect();
+ if ( 0 === inspection.surviving.length && 0 === inspection.owned.length ) {
+ return;
+ }
+ }
+ throw new Error( 'Authenticated Chrome process tree survived the absolute SIGKILL deadline.' );
+}
+
+function removeRuntimeResources( runtimeRoot, socketPath ) {
+ if ( socketPath ) {
+ try {
+ fs.unlinkSync( socketPath );
+ } catch ( error ) {
+ if ( 'ENOENT' !== error.code ) {
+ throw error;
+ }
+ }
+ }
+ if ( runtimeRoot ) {
+ fs.rmSync( runtimeRoot, { recursive: true, force: true } );
+ }
+}
+
+async function reapAndRemoveRuntimeResources(
+ snapshots,
+ profilePath,
+ ownershipToken,
+ runtimeRoot,
+ socketPath,
+ reapOptions = {}
+) {
+ if ( profilePath && ownershipToken ) {
+ await reapAuthenticatedTree( snapshots, profilePath, ownershipToken, reapOptions );
+ }
+ removeRuntimeResources( runtimeRoot, socketPath );
+}
+
+function parseInternalSupervisorArgs( argv ) {
+ if ( '--internal-supervisor' !== argv[ 2 ] ) {
+ return null;
+ }
+ const separator = argv.indexOf( '--' );
+ const expectedNames = [
+ '--token',
+ '--chrome-executable',
+ '--expected-sha256',
+ '--profile',
+ '--runtime-root',
+ '--socket',
+ ];
+ if ( 3 + expectedNames.length * 2 !== separator || separator >= argv.length - 1 ) {
+ throw new Error( 'Malformed internal supervisor arguments.' );
+ }
+ const options = {};
+ for ( let index = 0; index < expectedNames.length; index++ ) {
+ const nameIndex = 3 + index * 2;
+ if ( expectedNames[ index ] !== argv[ nameIndex ] || nameIndex + 1 >= separator ) {
+ throw new Error( 'Malformed internal supervisor arguments.' );
+ }
+ options[ expectedNames[ index ].slice( 2 ) ] = argv[ nameIndex + 1 ];
+ }
+ if (
+ ! /^[0-9a-f]{32}$/.test( options.token ) ||
+ process.env[ INTERNAL_SUPERVISOR_ENV ] !== options.token ||
+ ! /^[0-9a-f]{64}$/.test( options[ 'expected-sha256' ] ) ||
+ 0 === argv.slice( separator + 1 ).length
+ ) {
+ throw new Error( 'Unauthenticated internal supervisor invocation.' );
+ }
+ options.chromeArguments = argv.slice( separator + 1 );
+ return options;
+}
+
+function createSupervisorEmitter( output ) {
+ let backpressured = false;
+ output.on( 'drain', () => {
+ backpressured = false;
+ } );
+ return {
+ emit( event, lossy = false ) {
+ if ( output.destroyed || ( lossy && backpressured ) ) {
+ return false;
+ }
+ const frame = Buffer.from( JSON.stringify( event ) + '\n', 'utf8' );
+ if ( frame.length > 1024 * 1024 ) {
+ if ( lossy ) {
+ return false;
+ }
+ throw new Error( 'Critical Chrome supervisor event exceeded 1 MiB.' );
+ }
+ if ( ! output.write( frame ) ) {
+ backpressured = true;
+ }
+ return true;
+ },
+ isBackpressured() {
+ return backpressured;
+ },
+ };
+}
+
+async function runInternalSupervisor( options ) {
+ const executable = path.resolve( options[ 'chrome-executable' ] );
+ const profilePath = path.resolve( options.profile );
+ const runtimeRoot = path.resolve( options[ 'runtime-root' ] );
+ const socketPath = options.socket ? path.resolve( options.socket ) : '';
+ const snapshots = [];
+ let chrome = null;
+ let chromeCarriesOwnershipToken = false;
+ let cleaning = false;
+ let heartbeat = null;
+ let stderrTailBuffer = Buffer.alloc( 0 );
+ let controlledShutdown = false;
+ let resolveDone;
+ const done = new Promise( ( resolve ) => {
+ resolveDone = resolve;
+ } );
+ process.stdout.on( 'error', () => {} );
+ process.stderr.on( 'error', () => {} );
+ const emitter = createSupervisorEmitter( process.stdout );
+ const emit = ( event ) => emitter.emit( event, false );
+ const cleanup = async ( reason ) => {
+ if ( cleaning ) {
+ return;
+ }
+ cleaning = true;
+ clearInterval( heartbeat );
+ const errors = [];
+ if ( chrome?.pid ) {
+ try {
+ if ( chromeCarriesOwnershipToken ) {
+ snapshots.push( captureProcessTree( chrome.pid, profilePath, options.token ) );
+ }
+ } catch ( _error ) {}
+ }
+ try {
+ await reapAndRemoveRuntimeResources(
+ snapshots,
+ profilePath,
+ options.token,
+ runtimeRoot,
+ controlledShutdown ? '' : socketPath
+ );
+ } catch ( error ) {
+ errors.push( error );
+ }
+ try {
+ emit( { event: 'cleaned', reason } );
+ } catch ( error ) {
+ errors.push( error );
+ }
+ if ( 0 === errors.length ) {
+ process.exitCode = 0;
+ } else {
+ process.stderr.write(
+ 'Chrome supervisor cleanup failed: ' +
+ errors.map( ( error ) => error.stack || error.message || String( error ) ).join( '\n' ) +
+ '\n'
+ );
+ process.exitCode = 1;
+ }
+ resolveDone();
+ };
+
+ process.once( 'SIGINT', () => cleanup( 'signal' ) );
+ process.once( 'SIGTERM', () => cleanup( 'signal' ) );
+ let controlBuffer = '';
+ process.stdin.setEncoding( 'utf8' );
+ process.stdin.on( 'data', ( chunk ) => {
+ controlBuffer = ( controlBuffer + chunk ).slice( -64 );
+ if ( controlBuffer.includes( 'controlled-shutdown\n' ) ) {
+ controlledShutdown = true;
+ }
+ } );
+ process.stdin.resume();
+ process.stdin.once( 'end', () => cleanup( 'owner-eof' ) );
+ process.stdin.once( 'close', () => cleanup( 'owner-close' ) );
+ process.stdin.once( 'error', () => cleanup( 'owner-stdin-error' ) );
+
+ let supervisorHash;
+ try {
+ fs.mkdirSync( runtimeRoot, { mode: 0o700 } );
+ fs.mkdirSync( profilePath, { mode: 0o700 } );
+ const pauseFile = process.env.HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_SUPERVISOR_SPAWN;
+ if ( pauseFile ) {
+ fs.writeFileSync(
+ pauseFile,
+ JSON.stringify( {
+ ownerPid: process.ppid,
+ supervisorPid: process.pid,
+ runtimeRoot,
+ profilePath,
+ socketPath,
+ } ) + '\n',
+ { mode: 0o600 }
+ );
+ while ( fs.existsSync( pauseFile ) && ! cleaning ) {
+ await delay( 10 );
+ }
+ if ( cleaning ) {
+ await done;
+ return;
+ }
+ }
+ supervisorHash = await hashFile( executable );
+ if ( cleaning ) {
+ await done;
+ return;
+ }
+ if ( supervisorHash !== options[ 'expected-sha256' ] ) {
+ throw new Error( 'Chrome executable changed before supervisor launch.' );
+ }
+ chromeCarriesOwnershipToken = true;
+ chrome = spawn( executable, options.chromeArguments, {
+ detached: false,
+ stdio: [ 'ignore', 'ignore', 'pipe' ],
+ } );
+ if ( ! Number.isSafeInteger( chrome.pid ) || chrome.pid < 2 ) {
+ throw new Error( 'Chrome supervisor did not receive a browser PID.' );
+ }
+ let readySent = false;
+ let readyAllowed = false;
+ let readyEndpoint = null;
+ const sendReady = () => {
+ if ( cleaning || readySent || ! readyAllowed || ! readyEndpoint ) {
+ return;
+ }
+ readySent = true;
+ try {
+ const tree = captureProcessTree( chrome.pid, profilePath, options.token );
+ snapshots.push( tree );
+ emit( {
+ event: 'ready',
+ browserPid: chrome.pid,
+ executableSha256: supervisorHash,
+ endpoint: readyEndpoint,
+ processes: tree,
+ } );
+ } catch ( error ) {
+ emit( { event: 'startup-error', error: error.message } );
+ cleanup( 'identity-failure' );
+ }
+ };
+ chrome.stderr.on( 'data', ( chunk ) => {
+ stderrTailBuffer = appendByteTail( stderrTailBuffer, chunk, CHROME_STDERR_BYTES );
+ const stderrTail = decodeBoundedUtf8Tail( stderrTailBuffer, CHROME_STDERR_BYTES );
+ const match = stderrTail.match( /DevTools listening on (ws:\/\/[^\s]+)/ );
+ if ( match ) {
+ readyEndpoint = match[ 1 ];
+ sendReady();
+ }
+ } );
+ chrome.once( 'error', ( error ) => {
+ emit( { event: 'startup-error', error: error.message } );
+ cleanup( 'spawn-error' );
+ } );
+ chrome.once( 'exit', ( code, signal ) => {
+ clearInterval( heartbeat );
+ heartbeat = null;
+ emit( {
+ event: 'browser-exit',
+ code,
+ signal,
+ stderrTail: decodeBoundedUtf8Tail( stderrTailBuffer, CHROME_STDERR_BYTES ),
+ } );
+ } );
+ heartbeat = setInterval( () => {
+ if ( cleaning || ! chrome?.pid || emitter.isBackpressured() ) {
+ return;
+ }
+ try {
+ const tree = captureProcessTree( chrome.pid, profilePath, options.token );
+ if ( 0 === tree.length ) {
+ return;
+ }
+ snapshots.push( tree );
+ while ( snapshots.length > 8 ) {
+ snapshots.shift();
+ }
+ emitter.emit( { event: 'processes', processes: tree }, true );
+ } catch ( _error ) {
+ // Browser exit is reported by the child event. A transient ps race
+ // must not terminate a healthy owner.
+ }
+ }, 250 );
+ heartbeat.unref();
+ const browserPauseFile = process.env.HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_BROWSER_SPAWN;
+ if ( browserPauseFile ) {
+ fs.writeFileSync(
+ browserPauseFile,
+ JSON.stringify( {
+ ownerPid: process.ppid,
+ supervisorPid: process.pid,
+ browserPid: chrome.pid,
+ runtimeRoot,
+ profilePath,
+ socketPath,
+ } ) + '\n',
+ { mode: 0o600 }
+ );
+ while ( fs.existsSync( browserPauseFile ) && ! cleaning ) {
+ await delay( 10 );
+ }
+ if ( cleaning ) {
+ await done;
+ return;
+ }
+ }
+ readyAllowed = true;
+ sendReady();
+
+ await done;
+ } catch ( error ) {
+ if ( cleaning ) {
+ await done;
+ return;
+ }
+ await cleanup( 'supervisor-error' );
+ throw error;
+ }
+}
+
+function parseArgs( argv ) {
+ const booleanOptions = new Set( [ 'help', 'serve', 'version' ] );
+ const valueOptions = new Set( [
+ 'engine',
+ 'socket',
+ 'chrome-executable',
+ 'mode',
+ 'input',
+ 'context',
+ 'max-nodes',
+ 'max-depth',
+ 'max-tree-bytes',
+ ] );
+ const options = { engine: 'chrome' };
+ const seen = new Set();
+ for ( let index = 2; index < argv.length; index++ ) {
+ const argument = argv[ index ];
+ if ( ! argument.startsWith( '--' ) || 2 === argument.length || argument.includes( '=' ) ) {
+ throw new Error( 'Unexpected argument: ' + argument );
+ }
+ const name = argument.slice( 2 );
+ if ( seen.has( name ) ) {
+ throw new Error( 'Duplicate option --' + name + '.' );
+ }
+ seen.add( name );
+ if ( booleanOptions.has( name ) ) {
+ options[ name ] = true;
+ continue;
+ }
+ if ( ! valueOptions.has( name ) ) {
+ throw new Error( 'Unknown option --' + name + '.' );
+ }
+ if ( index + 1 >= argv.length || argv[ index + 1 ].startsWith( '--' ) ) {
+ throw new Error( 'Missing value for --' + name + '.' );
+ }
+ const value = argv[ ++index ];
+ if ( '' === value ) {
+ throw new Error( 'Empty value for --' + name + '.' );
+ }
+ options[ name ] = value;
+ }
+ if ( 'chrome' !== options.engine ) {
+ throw new Error( '--engine must be chrome.' );
+ }
+ if ( options.help && ( options.serve || options.version || options.socket || options.input || options.mode || options.context || options[ 'max-nodes' ] || options[ 'max-depth' ] || options[ 'max-tree-bytes' ] || options[ 'chrome-executable' ] ) ) {
+ throw new Error( '--help cannot be combined with an operation.' );
+ }
+ if ( options.version && ( options.serve || options.socket || options.input || options.mode || options.context || options[ 'max-nodes' ] || options[ 'max-depth' ] || options[ 'max-tree-bytes' ] ) ) {
+ throw new Error( '--version cannot be combined with another operation.' );
+ }
+ if ( options.socket && ! options.serve ) {
+ throw new Error( '--socket requires --serve.' );
+ }
+ if ( options.socket && ! path.isAbsolute( options.socket ) ) {
+ throw new Error( '--socket must be an absolute path.' );
+ }
+ if ( options.socket ) {
+ assertSecureSocketPath( options.socket );
+ }
+ if ( options.serve && ( options.input || options.mode || options.context || options[ 'max-nodes' ] || options[ 'max-depth' ] || options[ 'max-tree-bytes' ] ) ) {
+ throw new Error( 'Per-render options are not valid with --serve.' );
+ }
+ if ( options.mode && ! [ 'full-document', 'fragment-body' ].includes( options.mode ) ) {
+ throw new Error( '--mode must be full-document or fragment-body.' );
+ }
+ if ( options.context && ! CONTEXT_SET.has( options.context ) ) {
+ throw new Error( 'Unsupported fragment context: ' + options.context + '.' );
+ }
+ for ( const [ name, maximum ] of [
+ [ 'max-nodes', MAX_NODES ],
+ [ 'max-depth', MAX_DEPTH ],
+ [ 'max-tree-bytes', MAX_TREE_BYTES ],
+ ] ) {
+ if ( undefined !== options[ name ] ) {
+ const value = Number( options[ name ] );
+ if ( ! Number.isSafeInteger( value ) || value < 1 || value > maximum || String( value ) !== options[ name ] ) {
+ throw new Error( '--' + name + ' must be a canonical positive integer no greater than ' + maximum + '.' );
+ }
+ }
+ }
+ if ( ! options.help && ! options.version && ! options.serve ) {
+ if ( ! options.input || ! options.mode ) {
+ throw new Error( 'One-shot mode requires --input and --mode.' );
+ }
+ if ( 'full-document' === options.mode && options.context ) {
+ throw new Error( '--context is only valid for fragment-body mode.' );
+ }
+ }
+ return options;
+}
+
+class CdpWebSocket {
+ constructor( socket, initialData = Buffer.alloc( 0 ), maxFrameBytes = MAX_CDP_FRAME_BYTES ) {
+ this.socket = socket;
+ this.maxFrameBytes = maxFrameBytes;
+ this.buffer = Buffer.alloc( 0 );
+ this.fragmentOpcode = null;
+ this.fragments = [];
+ this.fragmentBytes = 0;
+ this.messageHandler = () => {};
+ this.closeHandlers = [];
+ this.closed = false;
+ this.closeError = null;
+ socket.on( 'data', ( data ) => this.consume( data ) );
+ socket.on( 'close', () => this.handleClose( sessionDeathError( 'CDP WebSocket closed.' ) ) );
+ socket.on( 'error', ( error ) => this.handleClose( sessionDeathError( 'CDP WebSocket I/O failed.', error ) ) );
+ if ( initialData.length ) {
+ this.consume( initialData );
+ }
+ }
+
+ onMessage( handler ) {
+ this.messageHandler = handler;
+ }
+
+ onClose( handler ) {
+ this.closeHandlers.push( handler );
+ }
+
+ handleClose( error ) {
+ if ( this.closed ) {
+ return;
+ }
+ this.closed = true;
+ const closed = markTransportError( error );
+ this.closeError = closed;
+ for ( const handler of this.closeHandlers ) {
+ handler( closed );
+ }
+ }
+
+ failProtocol( message, cause ) {
+ this.terminate( cdpProtocolError( 'Invalid CDP WebSocket protocol: ' + message, cause ) );
+ }
+
+ consume( data ) {
+ if ( this.closed ) {
+ return;
+ }
+ if ( this.buffer.length + data.length > this.maxFrameBytes + 14 ) {
+ this.failProtocol( 'input exceeded its byte limit.' );
+ return;
+ }
+ this.buffer = Buffer.concat( [ this.buffer, data ] );
+ while ( this.buffer.length >= 2 ) {
+ const first = this.buffer[ 0 ];
+ const second = this.buffer[ 1 ];
+ const final = Boolean( first & 0x80 );
+ const opcode = first & 0x0f;
+ const masked = Boolean( second & 0x80 );
+ if ( 0 !== ( first & 0x70 ) ) {
+ this.failProtocol( 'RSV bits were set.' );
+ return;
+ }
+ if ( ! [ 0x0, 0x1, 0x2, 0x8, 0x9, 0xA ].includes( opcode ) ) {
+ this.failProtocol( 'an unknown opcode was received.' );
+ return;
+ }
+ if ( masked ) {
+ this.failProtocol( 'a server frame was masked.' );
+ return;
+ }
+ let length = second & 0x7f;
+ let offset = 2;
+ if ( 126 === length ) {
+ if ( opcode >= 0x8 ) {
+ this.failProtocol( 'a control frame used an extended length.' );
+ return;
+ }
+ if ( this.buffer.length < 4 ) {
+ return;
+ }
+ length = this.buffer.readUInt16BE( 2 );
+ if ( length < 126 ) {
+ this.failProtocol( 'a frame used a nonminimal 16-bit length.' );
+ return;
+ }
+ offset = 4;
+ } else if ( 127 === length ) {
+ if ( opcode >= 0x8 ) {
+ this.failProtocol( 'a control frame used an extended length.' );
+ return;
+ }
+ if ( this.buffer.length < 10 ) {
+ return;
+ }
+ const longLength = this.buffer.readBigUInt64BE( 2 );
+ if ( longLength < 65536n || 0n !== ( longLength & ( 1n << 63n ) ) ) {
+ this.failProtocol( 'a frame used an invalid 64-bit length.' );
+ return;
+ }
+ if ( longLength > BigInt( Number.MAX_SAFE_INTEGER ) ) {
+ this.failProtocol( 'a frame length exceeded the safe integer range.' );
+ return;
+ }
+ length = Number( longLength );
+ offset = 10;
+ }
+ if ( length > this.maxFrameBytes ) {
+ this.failProtocol( 'a frame exceeded its byte limit.' );
+ return;
+ }
+ if ( opcode >= 0x8 && ( ! final || length > 125 ) ) {
+ this.failProtocol( 'a control frame was fragmented or oversized.' );
+ return;
+ }
+ if ( this.buffer.length < offset + length ) {
+ return;
+ }
+ const payload = Buffer.from( this.buffer.subarray( offset, offset + length ) );
+ this.buffer = this.buffer.subarray( offset + length );
+ if ( 0x8 === opcode ) {
+ if ( 1 === payload.length ) {
+ this.failProtocol( 'a close frame carried a one-byte status.' );
+ return;
+ }
+ if ( payload.length >= 2 ) {
+ const status = payload.readUInt16BE( 0 );
+ if (
+ status < 1000 ||
+ status > 4999 ||
+ [ 1004, 1005, 1006, 1015 ].includes( status )
+ ) {
+ this.failProtocol( 'a close frame carried a forbidden status code.' );
+ return;
+ }
+ }
+ if ( payload.length > 2 ) {
+ try {
+ new TextDecoder( 'utf-8', { fatal: true } ).decode( payload.subarray( 2 ) );
+ } catch ( error ) {
+ this.failProtocol( 'a close reason was not valid UTF-8.', error );
+ return;
+ }
+ }
+ this.sendFrame( 0x8, payload );
+ this.socket.end();
+ this.buffer = Buffer.alloc( 0 );
+ this.fragments = [];
+ this.fragmentBytes = 0;
+ this.fragmentOpcode = null;
+ this.handleClose( sessionDeathError( 'CDP WebSocket closed by the server.' ) );
+ return;
+ }
+ if ( 0x9 === opcode ) {
+ this.sendFrame( 0xA, payload );
+ continue;
+ }
+ if ( 0xA === opcode ) {
+ continue;
+ }
+ if ( 0x2 === opcode ) {
+ this.failProtocol( 'a binary CDP message was received.' );
+ return;
+ }
+ if ( 0x1 === opcode ) {
+ if ( null !== this.fragmentOpcode ) {
+ this.failProtocol( 'a new data frame interrupted a fragmented message.' );
+ return;
+ }
+ this.fragmentOpcode = opcode;
+ this.fragments = [ payload ];
+ this.fragmentBytes = payload.length;
+ } else if ( 0x0 === opcode ) {
+ if ( null === this.fragmentOpcode ) {
+ this.failProtocol( 'an orphan continuation frame was received.' );
+ return;
+ }
+ if ( this.fragmentBytes + payload.length > this.maxFrameBytes ) {
+ this.failProtocol( 'a fragmented message exceeded its byte limit.' );
+ return;
+ }
+ this.fragments.push( payload );
+ this.fragmentBytes += payload.length;
+ }
+ if ( final ) {
+ if ( this.fragmentBytes > this.maxFrameBytes ) {
+ this.failProtocol( 'a fragmented message exceeded its byte limit.' );
+ return;
+ }
+ const message = Buffer.concat( this.fragments );
+ const messageOpcode = this.fragmentOpcode;
+ this.fragmentOpcode = null;
+ this.fragments = [];
+ this.fragmentBytes = 0;
+ if ( 0x1 === messageOpcode ) {
+ let text;
+ try {
+ text = new TextDecoder( 'utf-8', { fatal: true } ).decode( message );
+ } catch ( error ) {
+ this.failProtocol( 'a text message was not valid UTF-8.', error );
+ return;
+ }
+ this.messageHandler( text );
+ }
+ }
+ }
+ }
+
+ sendFrame( opcode, payload ) {
+ if ( this.closed ) {
+ throw this.closeError || sessionDeathError( 'Cannot write to a closed CDP WebSocket.' );
+ }
+ payload = Buffer.isBuffer( payload ) ? payload : Buffer.from( payload );
+ let header;
+ if ( payload.length < 126 ) {
+ header = Buffer.alloc( 2 );
+ header[ 1 ] = 0x80 | payload.length;
+ } else if ( payload.length <= 0xffff ) {
+ header = Buffer.alloc( 4 );
+ header[ 1 ] = 0x80 | 126;
+ header.writeUInt16BE( payload.length, 2 );
+ } else {
+ header = Buffer.alloc( 10 );
+ header[ 1 ] = 0x80 | 127;
+ header.writeBigUInt64BE( BigInt( payload.length ), 2 );
+ }
+ header[ 0 ] = 0x80 | opcode;
+ const mask = crypto.randomBytes( 4 );
+ const masked = Buffer.allocUnsafe( payload.length );
+ for ( let i = 0; i < payload.length; i++ ) {
+ masked[ i ] = payload[ i ] ^ mask[ i % 4 ];
+ }
+ this.socket.write( Buffer.concat( [ header, mask, masked ] ) );
+ }
+
+ sendJson( value ) {
+ this.sendFrame( 0x1, Buffer.from( JSON.stringify( value ), 'utf8' ) );
+ }
+
+ close() {
+ if ( ! this.closed ) {
+ this.sendFrame( 0x8, Buffer.alloc( 0 ) );
+ this.socket.end();
+ }
+ }
+
+ terminate( error ) {
+ this.handleClose( error );
+ this.socket.destroy();
+ }
+}
+
+function connectWebSocket( endpoint ) {
+ return new Promise( ( resolve, reject ) => {
+ const url = new URL( endpoint );
+ if ( 'ws:' !== url.protocol ) {
+ reject( new Error( `Unsupported CDP WebSocket URL: ${ endpoint }` ) );
+ return;
+ }
+ const key = crypto.randomBytes( 16 ).toString( 'base64' );
+ const expectedAccept = crypto.createHash( 'sha1' )
+ .update( `${ key }258EAFA5-E914-47DA-95CA-C5AB0DC85B11` )
+ .digest( 'base64' );
+ const socket = net.createConnection( { host: url.hostname, port: Number( url.port ) } );
+ let headers = Buffer.alloc( 0 );
+ let settled = false;
+ let timer = null;
+ const fail = ( error ) => {
+ if ( ! settled ) {
+ settled = true;
+ clearTimeout( timer );
+ socket.destroy();
+ reject( error );
+ }
+ };
+ timer = setTimeout( () => fail( transportError( 'CDP WebSocket handshake timed out.' ) ), 5000 );
+ socket.once( 'error', fail );
+ socket.once( 'connect', () => {
+ const target = `${ url.pathname }${ url.search }`;
+ socket.write(
+ `GET ${ target } HTTP/1.1\r\n` +
+ `Host: ${ url.host }\r\n` +
+ 'Upgrade: websocket\r\n' +
+ 'Connection: Upgrade\r\n' +
+ `Sec-WebSocket-Key: ${ key }\r\n` +
+ 'Sec-WebSocket-Version: 13\r\n\r\n'
+ );
+ } );
+ const onData = ( data ) => {
+ if ( headers.length + data.length > 65536 ) {
+ fail( new Error( 'CDP WebSocket handshake headers exceeded 64 KiB.' ) );
+ return;
+ }
+ headers = Buffer.concat( [ headers, data ] );
+ const boundary = headers.indexOf( '\r\n\r\n' );
+ if ( -1 === boundary ) {
+ return;
+ }
+ const headerText = headers.subarray( 0, boundary ).toString( 'latin1' );
+ const remainder = headers.subarray( boundary + 4 );
+ if ( ! /^HTTP\/1\.1 101\b/.test( headerText ) ) {
+ fail( new Error( `CDP WebSocket handshake failed: ${ headerText.split( '\r\n' )[ 0 ] }` ) );
+ return;
+ }
+ const acceptMatch = headerText.match( /^Sec-WebSocket-Accept:\s*(.+)$/im );
+ if ( ! acceptMatch || acceptMatch[ 1 ].trim() !== expectedAccept ) {
+ fail( new Error( 'CDP WebSocket handshake returned an invalid accept key.' ) );
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ socket.removeListener( 'data', onData );
+ socket.removeListener( 'error', fail );
+ resolve( new CdpWebSocket( socket, remainder ) );
+ };
+ socket.on( 'data', onData );
+ } );
+}
+
+class CdpClient {
+ constructor( websocket ) {
+ this.websocket = websocket;
+ this.nextId = 0;
+ this.pending = new Map();
+ this.waiters = [];
+ websocket.onMessage( ( message ) => this.receive( message ) );
+ websocket.onClose( ( error ) => this.failAll( error ) );
+ }
+
+ receive( message ) {
+ let decoded;
+ try {
+ decoded = JSON.parse( message );
+ } catch ( error ) {
+ this.failProtocol( cdpProtocolError( `Chrome returned invalid CDP JSON: ${ error.message }`, error ) );
+ return;
+ }
+ try {
+ this.validateEnvelope( decoded );
+ } catch ( error ) {
+ this.failProtocol( error );
+ return;
+ }
+ if ( Object.hasOwn( decoded, 'id' ) ) {
+ if ( ! this.pending.has( decoded.id ) ) {
+ this.failProtocol( cdpProtocolError( 'Chrome returned an unknown or duplicate CDP response id.' ) );
+ return;
+ }
+ const pending = this.pending.get( decoded.id );
+ const responseHasSession = Object.hasOwn( decoded, 'sessionId' );
+ if (
+ ( undefined === pending.sessionId && responseHasSession ) ||
+ ( undefined !== pending.sessionId && decoded.sessionId !== pending.sessionId )
+ ) {
+ this.failProtocol( cdpProtocolError( 'Chrome returned a CDP response on the wrong session route.' ) );
+ return;
+ }
+ this.pending.delete( decoded.id );
+ clearTimeout( pending.timer );
+ if ( decoded.error ) {
+ const error = new Error( `CDP ${ pending.method } failed: ${ decoded.error.message }` );
+ error.code = decoded.error.code;
+ if ( /(?:Target closed|Session with given id not found|No target with given id)/i.test( decoded.error.message || '' ) ) {
+ error.transportFailure = true;
+ error.recoverableSessionDeath = true;
+ }
+ pending.reject( error );
+ } else {
+ pending.resolve( decoded.result );
+ }
+ return;
+ }
+ if ( Object.hasOwn( decoded, 'method' ) ) {
+ for ( const waiter of [ ...this.waiters ] ) {
+ if ( waiter.method === decoded.method && ( ! waiter.sessionId || waiter.sessionId === decoded.sessionId ) ) {
+ this.waiters.splice( this.waiters.indexOf( waiter ), 1 );
+ clearTimeout( waiter.timer );
+ waiter.resolve( decoded.params || {} );
+ }
+ }
+ }
+ }
+
+ validateEnvelope( decoded ) {
+ if ( null === decoded || 'object' !== typeof decoded || Array.isArray( decoded ) ) {
+ throw cdpProtocolError( 'Chrome returned a non-object CDP envelope.' );
+ }
+ const allowed = new Set( [ 'id', 'method', 'params', 'result', 'error', 'sessionId' ] );
+ for ( const key of Object.keys( decoded ) ) {
+ if ( ! allowed.has( key ) ) {
+ throw cdpProtocolError( 'Chrome returned an unknown CDP envelope field: ' + key + '.' );
+ }
+ }
+ if ( undefined !== decoded.sessionId && 'string' !== typeof decoded.sessionId ) {
+ throw cdpProtocolError( 'Chrome returned a non-string CDP sessionId.' );
+ }
+ const hasId = Object.hasOwn( decoded, 'id' );
+ const hasMethod = Object.hasOwn( decoded, 'method' );
+ if ( hasId === hasMethod ) {
+ throw cdpProtocolError( 'Chrome returned an ambiguous CDP envelope.' );
+ }
+ if ( hasId ) {
+ if ( ! Number.isSafeInteger( decoded.id ) || decoded.id < 1 ) {
+ throw cdpProtocolError( 'Chrome returned an invalid CDP response id.' );
+ }
+ const hasResult = Object.hasOwn( decoded, 'result' );
+ const hasError = Object.hasOwn( decoded, 'error' );
+ if ( hasResult === hasError || Object.hasOwn( decoded, 'params' ) ) {
+ throw cdpProtocolError( 'Chrome returned an invalid CDP response envelope.' );
+ }
+ if ( hasResult && ( null === decoded.result || 'object' !== typeof decoded.result || Array.isArray( decoded.result ) ) ) {
+ throw cdpProtocolError( 'Chrome returned a non-object CDP result.' );
+ }
+ if (
+ hasError &&
+ (
+ null === decoded.error ||
+ 'object' !== typeof decoded.error ||
+ Array.isArray( decoded.error ) ||
+ ! Number.isSafeInteger( decoded.error.code ) ||
+ 'string' !== typeof decoded.error.message
+ )
+ ) {
+ throw cdpProtocolError( 'Chrome returned a malformed CDP error.' );
+ }
+ return;
+ }
+ if (
+ 'string' !== typeof decoded.method ||
+ '' === decoded.method ||
+ Object.hasOwn( decoded, 'result' ) ||
+ Object.hasOwn( decoded, 'error' ) ||
+ (
+ Object.hasOwn( decoded, 'params' ) &&
+ ( null === decoded.params || 'object' !== typeof decoded.params || Array.isArray( decoded.params ) )
+ )
+ ) {
+ throw cdpProtocolError( 'Chrome returned an invalid CDP event envelope.' );
+ }
+ }
+
+ failProtocol( error ) {
+ this.failAll( error );
+ if ( 'function' === typeof this.websocket.terminate ) {
+ this.websocket.terminate( error );
+ } else {
+ this.websocket.close?.();
+ }
+ }
+
+ send( method, params = {}, sessionId = undefined, timeoutMs = 15000 ) {
+ const id = ++this.nextId;
+ return new Promise( ( resolve, reject ) => {
+ const timer = setTimeout( () => {
+ this.pending.delete( id );
+ reject( cdpTimeoutError( method ) );
+ }, timeoutMs );
+ this.pending.set( id, { method, sessionId, resolve, reject, timer } );
+ const message = { id, method, params };
+ if ( sessionId ) {
+ message.sessionId = sessionId;
+ }
+ try {
+ this.websocket.sendJson( message );
+ } catch ( error ) {
+ clearTimeout( timer );
+ this.pending.delete( id );
+ reject( error );
+ }
+ } );
+ }
+
+ waitFor( method, sessionId, timeoutMs = 15000 ) {
+ return new Promise( ( resolve, reject ) => {
+ const waiter = { method, sessionId, resolve, reject, timer: null };
+ waiter.timer = setTimeout( () => {
+ this.waiters.splice( this.waiters.indexOf( waiter ), 1 );
+ reject( cdpTimeoutError( method, 'event' ) );
+ }, timeoutMs );
+ this.waiters.push( waiter );
+ } );
+ }
+
+ failAll( error ) {
+ for ( const pending of this.pending.values() ) {
+ clearTimeout( pending.timer );
+ pending.reject( error );
+ }
+ this.pending.clear();
+ for ( const waiter of this.waiters ) {
+ clearTimeout( waiter.timer );
+ waiter.reject( error );
+ }
+ this.waiters = [];
+ }
+
+ close() {
+ this.websocket.close();
+ }
+}
+
+function browserRender( args ) {
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+ const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+ const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
+ const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace';
+ const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/';
+ const encoder = new TextEncoder();
+ const chunks = [];
+ let pending = '';
+ let treeBytes = 0;
+ let nodeCount = 0;
+
+ function limit( failureClass, message ) {
+ const error = new Error( message );
+ error.failureClass = failureClass;
+ throw error;
+ }
+
+ function utf8Length( value ) {
+ let bytes = 0;
+ for ( const character of value ) {
+ const code = character.codePointAt( 0 );
+ bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
+ }
+ return bytes;
+ }
+
+ function flush() {
+ if ( '' !== pending ) {
+ chunks.push( encoder.encode( pending ) );
+ pending = '';
+ }
+ }
+
+ function append( value ) {
+ const encodedLength = utf8Length( value );
+ if ( treeBytes + encodedLength > args.maxTreeBytes ) {
+ limit( 'tree-byte-limit-exceeded', 'Canonical tree byte limit exceeded.' );
+ }
+ treeBytes += encodedLength;
+ pending += value;
+ if ( pending.length >= 65536 ) {
+ flush();
+ }
+ }
+
+ function appendEscaped( value ) {
+ for ( const character of String( value ) ) {
+ switch ( character ) {
+ case '\n': append( '\\n' ); break;
+ case '\r': append( '\\r' ); break;
+ case '\t': append( '\\t' ); break;
+ case '\0': append( '\\0' ); break;
+ case '\\': append( '\\\\' ); break;
+ case '"': append( '\\"' ); break;
+ default: {
+ const code = character.codePointAt( 0 );
+ if ( code < 0x20 || 0x7f === code ) {
+ append( '\\x' + code.toString( 16 ).toUpperCase().padStart( 2, '0' ) );
+ } else {
+ append( character );
+ }
+ }
+ }
+ }
+ }
+
+ function escapedForSort( value ) {
+ let output = '';
+ for ( const character of String( value ) ) {
+ switch ( character ) {
+ case '\n': output += '\\n'; break;
+ case '\r': output += '\\r'; break;
+ case '\t': output += '\\t'; break;
+ case '\0': output += '\\0'; break;
+ case '\\': output += '\\\\'; break;
+ case '"': output += '\\"'; break;
+ default: {
+ const code = character.codePointAt( 0 );
+ output += code < 0x20 || 0x7f === code
+ ? '\\x' + code.toString( 16 ).toUpperCase().padStart( 2, '0' )
+ : character;
+ }
+ }
+ }
+ return output;
+ }
+
+ function compareUtf8( left, right ) {
+ const a = encoder.encode( left );
+ const b = encoder.encode( right );
+ const length = Math.min( a.length, b.length );
+ for ( let index = 0; index < length; index++ ) {
+ if ( a[ index ] !== b[ index ] ) {
+ return a[ index ] - b[ index ];
+ }
+ }
+ return a.length - b.length;
+ }
+
+ function compareDisplayNames( left, right ) {
+ const leftHasColon = left.includes( ':' );
+ const rightHasColon = right.includes( ':' );
+ if ( leftHasColon !== rightHasColon ) {
+ return leftHasColon ? 1 : -1;
+ }
+ const leftHasSpace = left.includes( ' ' );
+ const rightHasSpace = right.includes( ' ' );
+ if ( leftHasSpace !== rightHasSpace ) {
+ return leftHasSpace ? 1 : -1;
+ }
+ return compareUtf8( left, right );
+ }
+
+ function elementName( element ) {
+ if ( HTML_NAMESPACE === element.namespaceURI ) {
+ return element.localName;
+ }
+ if ( SVG_NAMESPACE === element.namespaceURI ) {
+ return 'svg ' + element.localName;
+ }
+ if ( MATH_NAMESPACE === element.namespaceURI ) {
+ return 'math ' + element.localName;
+ }
+ return element.nodeName;
+ }
+
+ function attributeName( attribute ) {
+ if ( XLINK_NAMESPACE === attribute.namespaceURI ) {
+ return 'xlink ' + attribute.localName;
+ }
+ if ( XML_NAMESPACE === attribute.namespaceURI ) {
+ return 'xml ' + attribute.localName;
+ }
+ if ( XMLNS_NAMESPACE === attribute.namespaceURI ) {
+ return 'xmlns ' + attribute.localName;
+ }
+ return attribute.name;
+ }
+
+ function appendIndent( indent ) {
+ append( ' '.repeat( indent ) );
+ }
+
+ function renderAttributes( element, indent ) {
+ const records = Array.from( element.attributes, ( attribute ) => {
+ const displayName = attributeName( attribute );
+ return {
+ attribute,
+ sortName: escapedForSort( displayName ),
+ renderName: displayName,
+ };
+ } );
+ records.sort( ( left, right ) => {
+ const sorted = compareDisplayNames( left.sortName, right.sortName );
+ return 0 !== sorted ? sorted : compareDisplayNames( left.renderName, right.renderName );
+ } );
+ for ( const record of records ) {
+ appendIndent( indent );
+ appendEscaped( record.renderName );
+ append( '="' );
+ appendEscaped( record.attribute.value );
+ append( '"\n' );
+ }
+ }
+
+ function renderNode( node, indent ) {
+ nodeCount++;
+ if ( nodeCount > args.maxNodes ) {
+ limit( 'node-limit-exceeded', 'DOM node limit exceeded.' );
+ }
+ if ( indent > args.maxDepth ) {
+ limit( 'depth-limit-exceeded', 'DOM depth limit exceeded.' );
+ }
+ switch ( node.nodeType ) {
+ case Node.DOCUMENT_TYPE_NODE:
+ append( '\n' );
+ return;
+ case Node.ELEMENT_NODE:
+ appendIndent( indent );
+ append( '<' );
+ appendEscaped( elementName( node ) );
+ append( '>\n' );
+ renderAttributes( node, indent + 1 );
+ if ( HTML_NAMESPACE === node.namespaceURI && 'template' === node.localName ) {
+ appendIndent( indent + 1 );
+ append( 'content\n' );
+ for ( const child of node.content.childNodes ) {
+ renderNode( child, indent + 2 );
+ }
+ return;
+ }
+ for ( const child of node.childNodes ) {
+ renderNode( child, indent + 1 );
+ }
+ return;
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ if ( '' !== node.nodeValue ) {
+ appendIndent( indent );
+ append( '"' );
+ appendEscaped( node.nodeValue );
+ append( '"\n' );
+ }
+ return;
+ case Node.COMMENT_NODE:
+ appendIndent( indent );
+ append( '\n' );
+ return;
+ case Node.PROCESSING_INSTRUCTION_NODE:
+ appendIndent( indent );
+ append( '' );
+ appendEscaped( node.target );
+ append( ' ' );
+ appendEscaped( node.data );
+ append( '?>\n' );
+ return;
+ default: {
+ const error = new Error( 'Unexpected DOM node type ' + node.nodeType + '.' );
+ error.failureClass = 'oracle-renderer-error';
+ throw error;
+ }
+ }
+ }
+
+ function encodeBase64() {
+ flush();
+ const bytes = new Uint8Array( treeBytes );
+ let offset = 0;
+ for ( const chunk of chunks ) {
+ bytes.set( chunk, offset );
+ offset += chunk.length;
+ }
+ let binary = '';
+ for ( let index = 0; index < bytes.length; index += 32768 ) {
+ binary += String.fromCharCode( ...bytes.subarray( index, Math.min( index + 32768, bytes.length ) ) );
+ }
+ return btoa( binary );
+ }
+
+ try {
+ let roots;
+ if ( args.testProcessingInstruction ) {
+ const xml = new DOMParser().parseFromString( ' ', 'application/xml' );
+ roots = [ xml.createProcessingInstruction(
+ args.testProcessingInstruction.target,
+ args.testProcessingInstruction.data
+ ) ];
+ } else if ( 'full-document' === args.mode ) {
+ const parsed = new DOMParser().parseFromString( args.html, 'text/html' );
+ roots = parsed.childNodes;
+ } else {
+ const owner = new DOMParser().parseFromString(
+ '',
+ 'text/html'
+ );
+ const lower = args.context.toLowerCase();
+ let contextElement;
+ if ( 'svg' === lower ) {
+ contextElement = owner.createElementNS( SVG_NAMESPACE, 'svg' );
+ } else if ( 'math' === lower ) {
+ contextElement = owner.createElementNS( MATH_NAMESPACE, 'math' );
+ } else {
+ contextElement = owner.createElementNS( HTML_NAMESPACE, lower );
+ }
+ const range = owner.createRange();
+ range.selectNodeContents( contextElement );
+ roots = range.createContextualFragment( args.html ).childNodes;
+ }
+ for ( const child of roots ) {
+ renderNode( child, 0 );
+ }
+ append( '\n' );
+ return {
+ status: 'ok',
+ treeBase64: encodeBase64(),
+ treeBytes,
+ nodeCount,
+ };
+ } catch ( error ) {
+ return {
+ status: error.failureClass?.endsWith( '-limit-exceeded' ) ? 'limit' : 'error',
+ failureClass: error.failureClass || 'oracle-renderer-error',
+ error: error.message || String( error ),
+ nodeCount,
+ treeBytes,
+ };
+ }
+}
+
+class ChromeOracle {
+ constructor( options, socketPath = '' ) {
+ this.options = options;
+ this.socketPath = socketPath;
+ this.platform = platformConfiguration();
+ this.installRoot = path.resolve(
+ process.env.HTML_API_FUZZ_CHROME_INSTALL_ROOT ||
+ path.join( SCRIPT_DIR, '.chrome-for-testing' )
+ );
+ this.defaultExecutable = path.join(
+ this.installRoot,
+ PINNED_CHROME_VERSION,
+ this.platform.platform,
+ this.platform.archiveDirectory,
+ this.platform.executableRelative
+ );
+ const optionExecutable = options[ 'chrome-executable' ];
+ const environmentExecutable = process.env.HTML_API_FUZZ_CHROME_EXECUTABLE;
+ if ( optionExecutable && environmentExecutable && path.resolve( optionExecutable ) !== path.resolve( environmentExecutable ) ) {
+ throw new Error( '--chrome-executable conflicts with HTML_API_FUZZ_CHROME_EXECUTABLE.' );
+ }
+ this.executable = path.resolve( optionExecutable || environmentExecutable || this.defaultExecutable );
+ this.usesDefaultInstallation = this.executable === path.resolve( this.defaultExecutable );
+ this.expectedArchiveSha256 = manifestDigest(
+ path.join( SCRIPT_DIR, 'SHA256SUMS' ),
+ 'chrome-' + PINNED_CHROME_VERSION + '-' + this.platform.platform + '.zip'
+ );
+ this.expectedExecutableSha256 = manifestDigest(
+ path.join( SCRIPT_DIR, 'EXECUTABLE_SHA256SUMS' ),
+ this.platform.platform + '.executable'
+ );
+ this.scriptPath = fs.realpathSync( __filename );
+ this.nodeExecutablePath = fs.realpathSync( process.execPath );
+ this.durableIdentity = {
+ schemaVersion: 1,
+ kind: 'chrome-cdp',
+ platform: this.platform.platform,
+ pinnedChromeVersion: PINNED_CHROME_VERSION,
+ chromeArchiveSha256: this.expectedArchiveSha256,
+ expectedChromeExecutableSha256: this.expectedExecutableSha256,
+ chromeExecutableSha256: this.expectedExecutableSha256,
+ oracleScriptSha256: hashFileSync( this.scriptPath ),
+ fragmentContextsSha256: CONTEXT_FILE_SHA256,
+ fragmentContexts: Object.freeze( [ ...CONTEXTS ] ),
+ nodeExecutableSha256: hashFileSync( this.nodeExecutablePath ),
+ nodeVersion: process.version,
+ };
+ this.chromeVersion = null;
+ this.runtimeRoot = null;
+ this.profilePath = null;
+ this.ownershipToken = null;
+ this.supervisor = null;
+ this.supervisorStderr = '';
+ this.supervisorEventsBuffer = Buffer.alloc( 0 );
+ this.processSnapshots = [];
+ this.browserPid = null;
+ this.endpoint = null;
+ this.websocket = null;
+ this.cdp = null;
+ this.targetId = null;
+ this.sessionId = null;
+ this.browserInstance = 0;
+ this.startPromise = null;
+ this.closePromise = null;
+ this.renderQueue = Promise.resolve();
+ this.closing = false;
+ this.healthy = false;
+ this.lastTransportError = null;
+ this.intentionalSupervisorExit = false;
+ this.fatalInfrastructure = false;
+ this.fatalHandler = null;
+ }
+
+ setFatalHandler( handler ) {
+ this.fatalHandler = handler;
+ }
+
+ metadata() {
+ const identity = { ...this.durableIdentity };
+ if ( this.chromeVersion ) {
+ identity.chromeVersion = this.chromeVersion;
+ }
+ const transport = {
+ replayExcluded: true,
+ ownerPid: process.pid,
+ chromeExecutablePath: this.executable,
+ oracleScriptPath: this.scriptPath,
+ nodeExecutablePath: this.nodeExecutablePath,
+ };
+ if ( this.runtimeRoot ) {
+ transport.runtimeRoot = this.runtimeRoot;
+ }
+ if ( this.profilePath ) {
+ transport.profilePath = this.profilePath;
+ }
+ if ( this.socketPath ) {
+ transport.socketPath = this.socketPath;
+ }
+ if ( this.endpoint ) {
+ transport.debugEndpoint = this.endpoint;
+ }
+ if ( this.supervisor?.pid ) {
+ transport.supervisorPid = this.supervisor.pid;
+ }
+ if ( this.browserPid ) {
+ transport.browserPid = this.browserPid;
+ }
+ if ( this.browserInstance ) {
+ transport.browserInstance = this.browserInstance;
+ }
+ return {
+ kind: 'chrome-cdp',
+ engine: 'chrome',
+ available: this.healthy,
+ identity,
+ transport,
+ };
+ }
+
+ expectedMarker() {
+ return [
+ 'schema=1',
+ 'version=' + PINNED_CHROME_VERSION,
+ 'platform=' + this.platform.platform,
+ 'archive_sha256=' + this.expectedArchiveSha256,
+ 'executable_sha256=' + this.expectedExecutableSha256,
+ '',
+ ].join( '\n' );
+ }
+
+ markerPath() {
+ return path.join(
+ this.installRoot,
+ PINNED_CHROME_VERSION,
+ this.platform.platform,
+ '.html-api-fuzz-verified'
+ );
+ }
+
+ readMarkerSnapshot() {
+ if ( ! this.usesDefaultInstallation ) {
+ return null;
+ }
+ const markerPath = this.markerPath();
+ const identity = fileIdentitySnapshot( markerPath );
+ const marker = fs.readFileSync( markerPath, 'utf8' );
+ if ( marker !== this.expectedMarker() ) {
+ throw new Error( 'Default Chrome installation marker does not match checked-in trust anchors.' );
+ }
+ return { contents: marker, identity };
+ }
+
+ async authenticateExecutable() {
+ if ( ! fs.existsSync( this.executable ) ) {
+ throw new Error(
+ 'Pinned Chrome for Testing is not installed at ' + this.executable +
+ '. Run ' + path.join( SCRIPT_DIR, 'install.sh' ) + '.'
+ );
+ }
+ const executableIdentity = fileIdentitySnapshot( this.executable );
+ this.executable = executableIdentity.realpath;
+ const markerBefore = this.readMarkerSnapshot();
+ const hashBefore = await hashFile( this.executable );
+ if ( hashBefore !== this.expectedExecutableSha256 ) {
+ throw new Error( 'Chrome executable does not match the checked-in SHA-256 trust anchor.' );
+ }
+ return { hash: hashBefore, executableIdentity, marker: markerBefore };
+ }
+
+ planRuntime() {
+ const token = crypto.randomBytes( 16 ).toString( 'hex' );
+ const root = path.join( os.tmpdir(), 'html-api-fuzz-chrome-' + process.pid + '-' + token );
+ if ( fs.existsSync( root ) ) {
+ throw new Error( 'Refusing pre-existing Chrome runtime path.' );
+ }
+ this.runtimeRoot = root;
+ this.profilePath = path.join( root, 'profile' );
+ this.ownershipToken = token;
+ }
+
+ chromeArguments() {
+ const args = [
+ '--headless=new',
+ '--remote-debugging-port=0',
+ '--remote-debugging-address=127.0.0.1',
+ '--remote-allow-origins=*',
+ '--user-data-dir=' + this.profilePath,
+ '--html-api-fuzz-owner-token=' + this.ownershipToken,
+ '--no-first-run',
+ '--no-default-browser-check',
+ '--disable-background-networking',
+ '--disable-component-update',
+ '--disable-domain-reliability',
+ '--disable-features=OptimizationHints,MediaRouter,Translate',
+ '--disable-sync',
+ '--metrics-recording-only',
+ '--mute-audio',
+ '--no-pings',
+ '--password-store=basic',
+ '--use-mock-keychain',
+ 'about:blank',
+ ];
+ if ( 'linux' === process.platform && 0 === process.getuid?.() ) {
+ args.unshift( '--no-sandbox' );
+ }
+ return args;
+ }
+
+ consumeSupervisorOutput( chunk, state, ready ) {
+ if ( state.supervisorProtocolFailure ) {
+ return;
+ }
+ this.supervisorEventsBuffer = Buffer.concat( [ this.supervisorEventsBuffer, chunk ] );
+ if ( this.supervisorEventsBuffer.length > 1024 * 1024 ) {
+ this.failSupervisorProtocol(
+ transportError( 'Chrome supervisor event frame exceeded 1 MiB.' ),
+ state,
+ ready
+ );
+ return;
+ }
+ for ( ;; ) {
+ const newline = this.supervisorEventsBuffer.indexOf( 0x0a );
+ if ( newline < 0 ) {
+ break;
+ }
+ const line = this.supervisorEventsBuffer.subarray( 0, newline );
+ this.supervisorEventsBuffer = this.supervisorEventsBuffer.subarray( newline + 1 );
+ let event;
+ try {
+ event = JSON.parse( JSON_FRAME_DECODER.decode( line ) );
+ } catch ( error ) {
+ this.failSupervisorProtocol(
+ transportError( 'Chrome supervisor emitted invalid UTF-8 JSON.', error ),
+ state,
+ ready
+ );
+ return;
+ }
+ try {
+ this.validateSupervisorEvent( event, state );
+ } catch ( error ) {
+ this.failSupervisorProtocol( transportError( error.message, error ), state, ready );
+ return;
+ }
+ if ( 'ready' === event.event || 'processes' === event.event ) {
+ this.processSnapshots = retainProcessSnapshot( state, event.processes );
+ }
+ if ( 'ready' === event.event ) {
+ state.readyEventSeen = true;
+ ready.resolve( event );
+ } else if ( 'startup-error' === event.event ) {
+ const failure = transportError( event.error || 'Chrome supervisor startup failed.' );
+ state.supervisorExit = failure;
+ state.rejectStartup?.( failure );
+ ready.reject( failure );
+ } else if ( 'browser-exit' === event.event && this.supervisor === state.supervisor ) {
+ const failure = sessionDeathError(
+ 'Chrome exited during oracle operation (code ' + event.code + ', signal ' + event.signal + ').'
+ );
+ state.browserExit = failure;
+ state.rejectStartup?.( failure );
+ this.healthy = false;
+ this.lastTransportError = failure;
+ }
+ }
+ }
+
+ validateSupervisorEvent( event, state ) {
+ if ( ! event || 'object' !== typeof event || Array.isArray( event ) || 'string' !== typeof event.event ) {
+ throw new Error( 'Chrome supervisor event must be an object with a string event name.' );
+ }
+ switch ( event.event ) {
+ case 'ready':
+ if (
+ state.readyEventSeen || state.active ||
+ ! hasExactOwnKeys( event, [ 'event', 'browserPid', 'executableSha256', 'endpoint', 'processes' ] ) ||
+ ! Number.isSafeInteger( event.browserPid ) || event.browserPid < 2 ||
+ ! /^[0-9a-f]{64}$/.test( event.executableSha256 ) ||
+ 'string' !== typeof event.endpoint || Buffer.byteLength( event.endpoint, 'utf8' ) > 4096 ||
+ ! isValidProcessSnapshot( event.processes ) ||
+ ! event.processes.some( ( record ) => record.pid === event.browserPid )
+ ) {
+ throw new Error( 'Chrome supervisor emitted an invalid or duplicate ready event.' );
+ }
+ break;
+ case 'processes':
+ if ( ! hasExactOwnKeys( event, [ 'event', 'processes' ] ) || ! isValidProcessSnapshot( event.processes ) ) {
+ throw new Error( 'Chrome supervisor emitted an invalid process snapshot event.' );
+ }
+ break;
+ case 'startup-error':
+ if (
+ state.active || ! hasExactOwnKeys( event, [ 'event', 'error' ] ) ||
+ 'string' !== typeof event.error || 0 === event.error.length ||
+ Buffer.byteLength( event.error, 'utf8' ) > 65536
+ ) {
+ throw new Error( 'Chrome supervisor emitted an invalid startup-error event.' );
+ }
+ break;
+ case 'browser-exit':
+ if (
+ ! hasExactOwnKeys( event, [ 'event', 'code', 'signal', 'stderrTail' ] ) ||
+ ! ( null === event.code || Number.isSafeInteger( event.code ) ) ||
+ ! ( null === event.signal || ( 'string' === typeof event.signal && event.signal.length <= 64 ) ) ||
+ 'string' !== typeof event.stderrTail || Buffer.byteLength( event.stderrTail, 'utf8' ) > CHROME_STDERR_BYTES
+ ) {
+ throw new Error( 'Chrome supervisor emitted an invalid browser-exit event.' );
+ }
+ break;
+ case 'cleaned':
+ if (
+ ( ! state.intentionalExit && ! state.supervisor?.__htmlApiFuzzIntentional ) ||
+ ! hasExactOwnKeys( event, [ 'event', 'reason' ] ) ||
+ 'string' !== typeof event.reason || 0 === event.reason.length || event.reason.length > 256
+ ) {
+ throw new Error( 'Chrome supervisor emitted an invalid cleaned event.' );
+ }
+ break;
+ default:
+ throw new Error( 'Chrome supervisor emitted an unknown event: ' + event.event + '.' );
+ }
+ }
+
+ failSupervisorProtocol( failure, state, ready ) {
+ if ( state.supervisorProtocolFailure ) {
+ return;
+ }
+ failure.failureClass = 'oracle-infrastructure-failure';
+ state.supervisorProtocolFailure = failure;
+ this.supervisorEventsBuffer = Buffer.alloc( 0 );
+ ready.reject( failure );
+ state.rejectStartup?.( failure );
+ if ( state.active && this.supervisor === state.supervisor && ! this.closing ) {
+ void this.handleSupervisorInfrastructureFailure( failure );
+ }
+ }
+
+ async start() {
+ if ( this.fatalInfrastructure ) {
+ throw transportError( 'Chrome oracle ownership infrastructure failed.' );
+ }
+ if ( this.closing ) {
+ throw new Error( 'Chrome oracle is shutting down.' );
+ }
+ if ( this.healthy && this.cdp && this.sessionId && this.supervisor ) {
+ return;
+ }
+ if ( this.startPromise ) {
+ return this.startPromise;
+ }
+ this.startPromise = ( async () => {
+ await this.resetState( false );
+ await this.startChrome();
+ } )();
+ try {
+ await this.startPromise;
+ } finally {
+ this.startPromise = null;
+ }
+ }
+
+ async startChrome() {
+ const startupDeadline = Date.now() + 30000;
+ const state = {
+ supervisor: null,
+ runtimeRoot: null,
+ profilePath: null,
+ ownershipToken: null,
+ processSnapshots: [],
+ browserPid: null,
+ endpoint: null,
+ websocket: null,
+ cdp: null,
+ targetId: null,
+ sessionId: null,
+ intentionalExit: false,
+ active: false,
+ supervisorExit: null,
+ browserExit: null,
+ readyEventSeen: false,
+ supervisorProtocolFailure: null,
+ rejectStartup: null,
+ };
+ try {
+ const ownerSnapshot = await this.authenticateExecutable();
+ this.planRuntime();
+ state.runtimeRoot = this.runtimeRoot;
+ state.profilePath = this.profilePath;
+ state.ownershipToken = this.ownershipToken;
+ const environment = { ...process.env, [ INTERNAL_SUPERVISOR_ENV ]: this.ownershipToken };
+ const supervisorArguments = [
+ __filename,
+ '--internal-supervisor',
+ '--token', this.ownershipToken,
+ '--chrome-executable', this.executable,
+ '--expected-sha256', this.expectedExecutableSha256,
+ '--profile', this.profilePath,
+ '--runtime-root', this.runtimeRoot,
+ '--socket', this.socketPath,
+ '--',
+ ...this.chromeArguments(),
+ ];
+ state.supervisor = spawn(
+ process.execPath,
+ supervisorArguments,
+ { detached: false, stdio: [ 'pipe', 'pipe', 'pipe' ], env: environment }
+ );
+ if ( ! Number.isSafeInteger( state.supervisor.pid ) || state.supervisor.pid < 2 ) {
+ throw new Error( 'Chrome owner did not receive a supervisor PID.' );
+ }
+ this.supervisor = state.supervisor;
+ this.supervisorStderr = '';
+ this.supervisorEventsBuffer = Buffer.alloc( 0 );
+ state.supervisor.stdin.on( 'error', ( error ) => {
+ if ( ! [ 'EPIPE', 'ERR_STREAM_DESTROYED' ].includes( error.code ) ) {
+ this.lastTransportError = transportError( 'Chrome supervisor control pipe failed.', error );
+ }
+ } );
+ const ready = {};
+ let startupSettled = false;
+ const startupAbortPromise = new Promise( ( _resolve, reject ) => {
+ state.rejectStartup = ( error ) => {
+ if ( ! startupSettled ) {
+ startupSettled = true;
+ reject( error );
+ }
+ };
+ } );
+ const raceStartup = ( promise ) => {
+ const remaining = startupDeadline - Date.now();
+ if ( remaining <= 0 ) {
+ return Promise.reject( transportError( 'Chrome startup exceeded its absolute deadline.' ) );
+ }
+ let deadlineTimer;
+ const deadline = new Promise( ( _resolve, reject ) => {
+ deadlineTimer = setTimeout(
+ () => reject( transportError( 'Chrome startup exceeded its absolute deadline.' ) ),
+ remaining
+ );
+ } );
+ return Promise.race( [ promise, startupAbortPromise, deadline ] ).finally(
+ () => clearTimeout( deadlineTimer )
+ );
+ };
+ const readyPromise = new Promise( ( resolve, reject ) => {
+ let settled = false;
+ ready.resolve = ( value ) => {
+ if ( ! settled ) {
+ settled = true;
+ resolve( value );
+ }
+ };
+ ready.reject = ( error ) => {
+ if ( ! settled ) {
+ settled = true;
+ reject( error );
+ }
+ };
+ } );
+ state.supervisor.stdout.on( 'data', ( chunk ) => this.consumeSupervisorOutput( chunk, state, ready ) );
+ state.supervisor.stderr.on( 'data', ( chunk ) => {
+ this.supervisorStderr = ( this.supervisorStderr + chunk.toString( 'utf8' ) ).slice( -CHROME_STDERR_BYTES );
+ } );
+ state.supervisor.once( 'error', ( error ) => {
+ const failure = transportError( 'Chrome supervisor process failed.', error );
+ state.supervisorExit = failure;
+ state.rejectStartup( failure );
+ ready.reject( failure );
+ if ( state.active && this.supervisor === state.supervisor && ! this.closing ) {
+ void this.handleSupervisorInfrastructureFailure( failure );
+ }
+ } );
+ state.supervisor.once( 'exit', ( code, signal ) => {
+ const failure = transportError(
+ 'Chrome supervisor exited before startup (code ' + code + ', signal ' + signal + '). ' +
+ this.supervisorStderr
+ );
+ state.supervisorExit = failure;
+ state.rejectStartup( failure );
+ ready.reject( failure );
+ if (
+ state.active &&
+ this.supervisor === state.supervisor &&
+ ! state.supervisor.__htmlApiFuzzIntentional &&
+ ! this.closing
+ ) {
+ void this.handleUnexpectedSupervisorExit( code, signal );
+ }
+ } );
+
+ const timeout = new Promise( ( _resolve, reject ) => {
+ setTimeout( () => reject( transportError( 'Chrome supervisor startup timed out.' ) ), 15000 ).unref();
+ } );
+ const event = await raceStartup( Promise.race( [ readyPromise, timeout ] ) );
+ if (
+ event.executableSha256 !== ownerSnapshot.hash ||
+ event.executableSha256 !== this.expectedExecutableSha256 ||
+ ! Number.isSafeInteger( event.browserPid ) ||
+ ! Array.isArray( event.processes )
+ ) {
+ throw new Error( 'Chrome supervisor handshake did not match authenticated launch intent.' );
+ }
+ const executableIdentityAfter = fileIdentitySnapshot( this.executable );
+ const ownerHashAfter = await raceStartup( hashFile( this.executable ) );
+ const markerAfter = this.readMarkerSnapshot();
+ if (
+ ownerHashAfter !== this.expectedExecutableSha256 ||
+ ! sameFileIdentitySnapshot( ownerSnapshot.executableIdentity, executableIdentityAfter ) ||
+ ! sameMarkerSnapshot( ownerSnapshot.marker, markerAfter )
+ ) {
+ throw new Error( 'Chrome installation changed during supervised startup.' );
+ }
+ const endpoint = new URL( event.endpoint );
+ if ( 'ws:' !== endpoint.protocol || ! [ '127.0.0.1', 'localhost', '::1' ].includes( endpoint.hostname ) ) {
+ throw new Error( 'Chrome supervisor exposed a non-loopback CDP endpoint.' );
+ }
+ state.browserPid = event.browserPid;
+ state.endpoint = event.endpoint;
+ this.processSnapshots = state.processSnapshots.slice();
+ this.browserPid = event.browserPid;
+ this.endpoint = event.endpoint;
+ state.websocket = await raceStartup( connectWebSocket( event.endpoint ) );
+ state.cdp = new CdpClient( state.websocket );
+ state.websocket.onClose( ( error ) => {
+ if ( ! state.active ) {
+ state.rejectStartup( markTransportError( error ) );
+ } else if ( this.websocket === state.websocket ) {
+ this.healthy = false;
+ this.lastTransportError = markTransportError( error );
+ }
+ } );
+ const browserVersion = await raceStartup( state.cdp.send( 'Browser.getVersion' ) );
+ if (
+ 'string' !== typeof browserVersion.product ||
+ '' === browserVersion.product ||
+ 'string' !== typeof browserVersion.protocolVersion ||
+ '' === browserVersion.protocolVersion
+ ) {
+ throw new Error( 'Live Chrome returned incomplete browser identity.' );
+ }
+ const liveVersion = browserVersion.product.replace( /^[^/]+\//, '' );
+ if ( liveVersion !== PINNED_CHROME_VERSION ) {
+ throw new Error( 'Live Chrome version does not match pin ' + PINNED_CHROME_VERSION + '.' );
+ }
+ const target = await raceStartup( state.cdp.send( 'Target.createTarget', { url: 'about:blank', background: true } ) );
+ state.targetId = target.targetId;
+ const attached = await raceStartup( state.cdp.send( 'Target.attachToTarget', { targetId: state.targetId, flatten: true } ) );
+ state.sessionId = attached.sessionId;
+ await raceStartup( state.cdp.send( 'Page.enable', {}, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Runtime.enable', {}, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Network.enable', {}, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Network.setCacheDisabled', { cacheDisabled: true }, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Network.setBypassServiceWorker', { bypass: true }, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Network.setBlockedURLs', {
+ urls: [ 'http://*', 'https://*', 'ftp://*', 'file://*', 'ws://*', 'wss://*', '*://*' ],
+ }, state.sessionId ) );
+ await raceStartup( state.cdp.send( 'Browser.setDownloadBehavior', { behavior: 'deny' } ) );
+
+ const handshakePauseFile = process.env.HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_CDP_HANDSHAKE;
+ if ( handshakePauseFile ) {
+ fs.writeFileSync(
+ handshakePauseFile,
+ JSON.stringify( {
+ ownerPid: process.pid,
+ supervisorPid: state.supervisor.pid,
+ browserPid: state.browserPid,
+ runtimeRoot: state.runtimeRoot,
+ profilePath: state.profilePath,
+ socketPath: this.socketPath,
+ } ) + '\n',
+ { mode: 0o600 }
+ );
+ while ( fs.existsSync( handshakePauseFile ) ) {
+ await raceStartup( delay( 10 ) );
+ }
+ }
+ const publicationVersion = await raceStartup( state.cdp.send( 'Browser.getVersion' ) );
+ if (
+ publicationVersion.product !== browserVersion.product ||
+ publicationVersion.protocolVersion !== browserVersion.protocolVersion
+ ) {
+ throw new Error( 'Live Chrome identity changed before healthy publication.' );
+ }
+ await raceStartup( delay( 0 ) );
+ if (
+ state.supervisorExit ||
+ state.browserExit ||
+ state.supervisorProtocolFailure ||
+ null !== state.supervisor.exitCode ||
+ null !== state.supervisor.signalCode ||
+ state.websocket.closed
+ ) {
+ throw state.supervisorExit || state.browserExit || state.supervisorProtocolFailure || state.websocket.closeError ||
+ transportError( 'Chrome startup ownership ended before healthy publication.' );
+ }
+
+ this.websocket = state.websocket;
+ this.cdp = state.cdp;
+ this.targetId = state.targetId;
+ this.sessionId = state.sessionId;
+ this.chromeVersion = liveVersion;
+ this.durableIdentity.cdpProtocolVersion = browserVersion.protocolVersion;
+ this.browserInstance++;
+ state.active = true;
+ this.lastTransportError = null;
+ this.healthy = true;
+ startupSettled = true;
+ } catch ( error ) {
+ state.intentionalExit = true;
+ await this.disposeState( state, false ).catch( ( cleanupError ) => {
+ error = new AggregateError( [ error, cleanupError ], 'Chrome startup and cleanup both failed.' );
+ } );
+ throw error.failureClass ? error : markTransportError( error );
+ }
+ }
+
+ async handleUnexpectedSupervisorExit( code, signal ) {
+ return this.handleSupervisorInfrastructureFailure( transportError(
+ 'Chrome supervisor ownership failed (code ' + code + ', signal ' + signal + ').'
+ ) );
+ }
+
+ async handleSupervisorInfrastructureFailure( failure ) {
+ if ( this.fatalInfrastructure || this.closing ) {
+ return;
+ }
+ this.fatalInfrastructure = true;
+ this.healthy = false;
+ failure.failureClass = 'oracle-infrastructure-failure';
+ this.lastTransportError = markTransportError( failure );
+ try {
+ await this.resetState( false );
+ } catch ( error ) {
+ const combined = new AggregateError(
+ [ this.lastTransportError, error ],
+ 'Chrome supervisor failure cleanup failed.'
+ );
+ combined.failureClass = 'oracle-infrastructure-failure';
+ this.lastTransportError = markTransportError( combined );
+ }
+ if ( this.fatalHandler ) {
+ try {
+ await this.fatalHandler( this.lastTransportError );
+ } catch ( error ) {
+ const combined = new AggregateError(
+ [ this.lastTransportError, error ],
+ 'Chrome fatal infrastructure shutdown failed.'
+ );
+ combined.failureClass = 'oracle-infrastructure-failure';
+ this.lastTransportError = markTransportError( combined );
+ }
+ }
+ }
+
+ async waitForSupervisorExit( supervisor, timeoutMs ) {
+ if ( ! supervisor || null !== supervisor.exitCode || null !== supervisor.signalCode ) {
+ return true;
+ }
+ const outcome = await Promise.race( [
+ once( supervisor, 'exit' ).then( () => true ),
+ delay( timeoutMs ).then( () => false ),
+ ] );
+ return outcome;
+ }
+
+ async disposeState( state, graceful ) {
+ const errors = [];
+ state.intentionalExit = true;
+ if ( state.supervisor ) {
+ state.supervisor.__htmlApiFuzzIntentional = true;
+ }
+ if ( graceful && state.cdp ) {
+ if ( state.targetId ) {
+ await state.cdp.send( 'Target.closeTarget', { targetId: state.targetId }, undefined, 500 ).catch( () => {} );
+ }
+ await state.cdp.send( 'Browser.close', {}, undefined, 1000 ).catch( () => {} );
+ }
+ try {
+ state.websocket?.close();
+ } catch ( _error ) {}
+ if ( state.supervisor?.stdin && ! state.supervisor.stdin.destroyed ) {
+ if ( null !== state.supervisor.exitCode || null !== state.supervisor.signalCode ) {
+ state.supervisor.stdin.destroy();
+ } else {
+ state.supervisor.stdin.end( 'controlled-shutdown\n' );
+ }
+ }
+ let supervisorGone = ! state.supervisor || await this.waitForSupervisorExit( state.supervisor, 5000 );
+ if ( state.supervisor && ! supervisorGone ) {
+ try {
+ state.supervisor.kill( 'SIGTERM' );
+ } catch ( _error ) {}
+ supervisorGone = await this.waitForSupervisorExit( state.supervisor, 1000 );
+ if ( ! supervisorGone ) {
+ try {
+ state.supervisor.kill( 'SIGKILL' );
+ } catch ( _error ) {}
+ supervisorGone = await this.waitForSupervisorExit( state.supervisor, 1000 );
+ }
+ }
+ if ( ! supervisorGone ) {
+ errors.push( new Error( 'Chrome supervisor survived its final SIGKILL deadline.' ) );
+ } else {
+ try {
+ await reapAndRemoveRuntimeResources(
+ state.processSnapshots,
+ state.profilePath,
+ state.ownershipToken,
+ state.runtimeRoot,
+ ''
+ );
+ } catch ( error ) {
+ errors.push( error );
+ }
+ }
+ if ( errors.length ) {
+ const failure = 1 === errors.length ? errors[ 0 ] : new AggregateError( errors, 'Chrome state cleanup failed.' );
+ failure.failureClass = 'oracle-infrastructure-failure';
+ throw markTransportError( failure );
+ }
+ }
+
+ async resetState( graceful ) {
+ const state = {
+ supervisor: this.supervisor,
+ runtimeRoot: this.runtimeRoot,
+ profilePath: this.profilePath,
+ ownershipToken: this.ownershipToken,
+ processSnapshots: this.processSnapshots,
+ browserPid: this.browserPid,
+ endpoint: this.endpoint,
+ websocket: this.websocket,
+ cdp: this.cdp,
+ targetId: this.targetId,
+ sessionId: this.sessionId,
+ intentionalExit: true,
+ };
+ this.supervisor = null;
+ this.runtimeRoot = null;
+ this.profilePath = null;
+ this.ownershipToken = null;
+ this.processSnapshots = [];
+ this.browserPid = null;
+ this.endpoint = null;
+ this.websocket = null;
+ this.cdp = null;
+ this.targetId = null;
+ this.sessionId = null;
+ this.healthy = false;
+ await this.disposeState( state, graceful );
+ }
+
+ validateRequest( request ) {
+ if ( ! request || 'object' !== typeof request || Array.isArray( request ) ) {
+ throw new Error( 'Request must be a JSON object.' );
+ }
+ const allowed = new Set( [
+ 'id', 'command', 'htmlBase64', 'mode', 'context',
+ 'maxNodes', 'maxDepth', 'maxTreeBytes', 'securityAudit',
+ ] );
+ if ( 'render' !== request.command ) {
+ throw new Error( 'Render request command must be render.' );
+ }
+ if ( undefined !== request.id && ! isValidRequestId( request.id ) ) {
+ throw new Error( 'Request id must be a string or safe integer.' );
+ }
+ for ( const key of Object.keys( request ) ) {
+ if ( ! allowed.has( key ) ) {
+ throw new Error( 'Unknown request field: ' + key + '.' );
+ }
+ }
+ const mode = undefined === request.mode ? 'fragment-body' : request.mode;
+ if ( ! [ 'full-document', 'fragment-body' ].includes( mode ) ) {
+ throw new Error( 'Unsupported parse mode: ' + mode + '.' );
+ }
+ const context = undefined === request.context ? 'body' : request.context;
+ if ( 'fragment-body' === mode && ! CONTEXT_SET.has( context ) ) {
+ throw new Error( 'Unsupported fragment context: ' + context + '.' );
+ }
+ if ( 'full-document' === mode && undefined !== request.context ) {
+ throw new Error( 'context is only valid for fragment-body mode.' );
+ }
+ const limits = {};
+ for ( const [ key, defaultValue, maximum ] of [
+ [ 'maxNodes', 3000, MAX_NODES ],
+ [ 'maxDepth', 512, MAX_DEPTH ],
+ [ 'maxTreeBytes', MAX_TREE_BYTES, MAX_TREE_BYTES ],
+ ] ) {
+ const value = undefined === request[ key ] ? defaultValue : request[ key ];
+ if ( ! Number.isSafeInteger( value ) || value < 1 || value > maximum ) {
+ throw new Error( key + ' must be a positive integer no greater than ' + maximum + '.' );
+ }
+ limits[ key ] = value;
+ }
+ if ( undefined !== request.securityAudit && 'boolean' !== typeof request.securityAudit ) {
+ throw new Error( 'securityAudit must be boolean.' );
+ }
+ if ( 'string' !== typeof request.htmlBase64 ) {
+ throw new Error( 'htmlBase64 must be a string.' );
+ }
+ const encoded = request.htmlBase64;
+ if (
+ 0 !== encoded.length % 4 ||
+ ! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( encoded )
+ ) {
+ throw new Error( 'htmlBase64 must be canonical base64.' );
+ }
+ const bytes = Buffer.from( encoded, 'base64' );
+ if ( bytes.toString( 'base64' ) !== encoded ) {
+ throw new Error( 'htmlBase64 must be canonical base64.' );
+ }
+ if ( bytes.length > MAX_INPUT_BYTES ) {
+ const error = new Error( 'HTML input exceeds 2 MiB.' );
+ error.failureClass = 'input-byte-limit-exceeded';
+ throw error;
+ }
+ let html = null;
+ let invalidUtf8 = false;
+ try {
+ html = new TextDecoder( 'utf-8', { fatal: true } ).decode( bytes );
+ } catch ( _error ) {
+ invalidUtf8 = true;
+ }
+ return { mode, context, limits, html, invalidUtf8, securityAudit: true === request.securityAudit };
+ }
+
+ render( request ) {
+ const work = this.renderQueue.then( () => this.renderWithRecovery( request ) );
+ this.renderQueue = work.catch( () => {} );
+ return work;
+ }
+
+ async observeSessionDeath( error, supervisor ) {
+ let observed = isRecoverableSessionDeath( error );
+ if (
+ ! observed &&
+ supervisor &&
+ true === error?.transportFailure &&
+ ! error.invalidateSession
+ ) {
+ const deadline = Date.now() + 500;
+ while (
+ Date.now() < deadline &&
+ this.supervisor === supervisor &&
+ ! this.fatalInfrastructure
+ ) {
+ if ( isRecoverableSessionDeath( this.lastTransportError ) ) {
+ observed = true;
+ break;
+ }
+ await delay( 10 );
+ }
+ }
+ return observed;
+ }
+
+ async resetFailedRenderSession( error, message ) {
+ try {
+ await this.resetState( false );
+ } catch ( cleanupError ) {
+ const combined = new AggregateError( [ error, cleanupError ], message );
+ combined.failureClass = 'oracle-infrastructure-failure';
+ throw markTransportError( combined );
+ }
+ }
+
+ async renderWithRecovery( request ) {
+ const validated = this.validateRequest( request );
+ let liveSupervisor = null;
+ try {
+ await this.start();
+ liveSupervisor = this.supervisor;
+ const session = {
+ supervisor: liveSupervisor,
+ cdp: this.cdp,
+ sessionId: this.sessionId,
+ oracle: this.metadata(),
+ };
+ return await this.renderNow( validated, session );
+ } catch ( error ) {
+ const observedSessionDeath = await this.observeSessionDeath( error, liveSupervisor );
+ if ( ! observedSessionDeath ) {
+ if (
+ liveSupervisor &&
+ this.supervisor === liveSupervisor &&
+ ! this.closing &&
+ ! this.fatalInfrastructure &&
+ true === error?.invalidateSession
+ ) {
+ await this.resetFailedRenderSession(
+ error,
+ 'Chrome request failure and session teardown both failed.'
+ );
+ }
+ throw error;
+ }
+ if ( ! liveSupervisor || this.closing || this.fatalInfrastructure ) {
+ throw error;
+ }
+ await this.resetFailedRenderSession(
+ error,
+ 'Chrome session death and cleanup both failed.'
+ );
+ let retrySupervisor = null;
+ try {
+ await this.start();
+ retrySupervisor = this.supervisor;
+ const retrySession = {
+ supervisor: retrySupervisor,
+ cdp: this.cdp,
+ sessionId: this.sessionId,
+ oracle: this.metadata(),
+ };
+ return await this.renderNow( validated, retrySession );
+ } catch ( retryError ) {
+ const retrySessionDeath = await this.observeSessionDeath( retryError, retrySupervisor );
+ if (
+ retrySupervisor &&
+ this.supervisor === retrySupervisor &&
+ ! this.closing &&
+ ! this.fatalInfrastructure &&
+ ( true === retryError?.invalidateSession || retrySessionDeath )
+ ) {
+ await this.resetFailedRenderSession(
+ retryError,
+ 'Final Chrome retry failure and session teardown both failed.'
+ );
+ }
+ throw retryError;
+ }
+ }
+ }
+
+ async renderNow( request, session ) {
+ if ( ! session?.supervisor || ! session.cdp || ! session.sessionId ) {
+ throw this.lastTransportError || transportError( 'Chrome render session was unavailable after startup.' );
+ }
+ if ( request.invalidUtf8 ) {
+ return {
+ status: 'unsupported',
+ failureClass: 'invalid-utf8',
+ unsupported: { reason: 'invalid-utf8' },
+ oracle: session.oracle,
+ };
+ }
+ const expression = '(' + browserRender.toString() + ')(' + JSON.stringify( {
+ html: request.html,
+ mode: request.mode,
+ context: request.context,
+ maxNodes: request.limits.maxNodes,
+ maxDepth: request.limits.maxDepth,
+ maxTreeBytes: request.limits.maxTreeBytes,
+ ...( request.testProcessingInstruction
+ ? { testProcessingInstruction: request.testProcessingInstruction }
+ : {} ),
+ } ) + ')';
+ const evaluated = await session.cdp.send( 'Runtime.evaluate', {
+ expression,
+ returnByValue: true,
+ awaitPromise: false,
+ userGesture: false,
+ }, session.sessionId, 10000 );
+ if ( Object.hasOwn( evaluated, 'exceptionDetails' ) ) {
+ throw cdpProtocolError(
+ evaluated.exceptionDetails?.exception?.description ||
+ evaluated.exceptionDetails?.text ||
+ 'Chrome renderer evaluation escaped its structured result.'
+ );
+ }
+ const rendered = evaluated.result?.value;
+ const renderedKeys = 'ok' === rendered?.status
+ ? [ 'status', 'treeBase64', 'treeBytes', 'nodeCount' ]
+ : [ 'status', 'failureClass', 'error', 'nodeCount', 'treeBytes' ];
+ if (
+ ! hasExactOwnKeys( rendered, renderedKeys ) ||
+ ! [ 'ok', 'limit', 'error' ].includes( rendered.status ) ||
+ ! Number.isSafeInteger( rendered.nodeCount ) ||
+ rendered.nodeCount < 0 ||
+ rendered.nodeCount > request.limits.maxNodes + 1 ||
+ ! Number.isSafeInteger( rendered.treeBytes ) ||
+ rendered.treeBytes < 0 ||
+ rendered.treeBytes > request.limits.maxTreeBytes
+ ) {
+ throw cdpProtocolError( 'Chrome returned an invalid renderer result envelope.' );
+ }
+ if ( 'ok' !== rendered.status ) {
+ const isNodeLimit = 'limit' === rendered.status && 'node-limit-exceeded' === rendered.failureClass;
+ const validFailureClass = 'limit' === rendered.status
+ ? [ 'node-limit-exceeded', 'depth-limit-exceeded', 'tree-byte-limit-exceeded' ].includes( rendered.failureClass )
+ : 'oracle-renderer-error' === rendered.failureClass;
+ if (
+ ! validFailureClass ||
+ 'string' !== typeof rendered.error ||
+ '' === rendered.error ||
+ Buffer.byteLength( rendered.error, 'utf8' ) > 65536 ||
+ ( isNodeLimit
+ ? rendered.nodeCount !== request.limits.maxNodes + 1
+ : rendered.nodeCount > request.limits.maxNodes )
+ ) {
+ throw cdpProtocolError( 'Chrome returned an invalid non-success renderer result.' );
+ }
+ return {
+ status: rendered.status,
+ failureClass: rendered.failureClass,
+ error: rendered.error,
+ nodeCount: rendered.nodeCount,
+ treeBytes: rendered.treeBytes,
+ oracle: session.oracle,
+ };
+ }
+ if (
+ rendered.nodeCount > request.limits.maxNodes ||
+ 'string' !== typeof rendered.treeBase64
+ ) {
+ throw cdpProtocolError( 'Chrome did not return treeBase64.' );
+ }
+ const tree = Buffer.from( rendered.treeBase64, 'base64' );
+ if (
+ tree.toString( 'base64' ) !== rendered.treeBase64 ||
+ tree.length !== rendered.treeBytes ||
+ tree.length > request.limits.maxTreeBytes
+ ) {
+ throw cdpProtocolError( 'Chrome returned inconsistent canonical tree bytes.' );
+ }
+ let canonicalTree;
+ try {
+ canonicalTree = JSON_FRAME_DECODER.decode( tree );
+ } catch ( error ) {
+ throw cdpProtocolError( 'Chrome returned non-UTF-8 canonical tree bytes.', error );
+ }
+ if ( ! Buffer.from( canonicalTree, 'utf8' ).equals( tree ) ) {
+ throw cdpProtocolError( 'Chrome returned noncanonical UTF-8 tree text.' );
+ }
+ const result = {
+ status: 'ok',
+ oracle: session.oracle,
+ treeBase64: rendered.treeBase64,
+ treeBytes: tree.length,
+ treeSha256: crypto.createHash( 'sha256' ).update( tree ).digest( 'hex' ),
+ nodeCount: rendered.nodeCount,
+ };
+ if ( request.securityAudit ) {
+ const audited = await session.cdp.send( 'Runtime.evaluate', {
+ expression: '({authorRan:Boolean(globalThis.__htmlApiFuzzAuthorRan),' +
+ 'activeMarkup:document.documentElement.outerHTML,' +
+ 'resources:performance.getEntriesByType("resource").map((entry)=>entry.name)})',
+ returnByValue: true,
+ awaitPromise: false,
+ userGesture: false,
+ }, session.sessionId, 2000 );
+ const audit = audited.result?.value;
+ if (
+ audited.exceptionDetails ||
+ ! hasExactOwnKeys( audit, [ 'authorRan', 'activeMarkup', 'resources' ] ) ||
+ 'boolean' !== typeof audit.authorRan ||
+ 'string' !== typeof audit.activeMarkup ||
+ ! Array.isArray( audit.resources ) ||
+ audit.resources.some( ( resource ) => 'string' !== typeof resource )
+ ) {
+ throw cdpProtocolError( 'Chrome returned an invalid security-audit result.' );
+ }
+ result.securityAudit = audit;
+ }
+ return result;
+ }
+
+ async killBrowserForTest() {
+ await this.start();
+ const records = this.processSnapshots.at( -1 ) || [];
+ const root = records.find( ( record ) => record.pid === this.browserPid );
+ if ( ! root || ! sameProcessIdentity( root, currentIdentityForPid( root.pid ) ) ) {
+ throw new Error( 'Could not authenticate browser process for test termination.' );
+ }
+ process.kill( root.pid, 'SIGKILL' );
+ return { status: 'ok', killedBrowserPid: root.pid, oracle: this.metadata() };
+ }
+
+ async renderProcessingInstructionForTest() {
+ await this.start();
+ const session = {
+ supervisor: this.supervisor,
+ cdp: this.cdp,
+ sessionId: this.sessionId,
+ oracle: this.metadata(),
+ };
+ return this.renderNow( {
+ html: '',
+ mode: 'full-document',
+ context: 'body',
+ limits: { maxNodes: 10, maxDepth: 10, maxTreeBytes: 1024 },
+ invalidUtf8: false,
+ securityAudit: false,
+ testProcessingInstruction: { target: 'pi', data: '' },
+ }, session );
+ }
+
+ close() {
+ if ( this.closePromise ) {
+ return this.closePromise;
+ }
+ this.closing = true;
+ this.closePromise = ( async () => {
+ await this.renderQueue.catch( () => {} );
+ await this.startPromise?.catch( () => {} );
+ await this.resetState( true );
+ } )();
+ return this.closePromise;
+ }
+}
+
+function protocolError( message ) {
+ const error = new Error( message );
+ error.failureClass = 'protocol-error';
+ return error;
+}
+
+function errorResult( error, oracle, id ) {
+ const message = error?.message || String( error );
+ let status = 'error';
+ let failureClass = error?.failureClass;
+ if ( failureClass?.endsWith( '-limit-exceeded' ) ) {
+ status = 'limit';
+ }
+ if ( ! failureClass ) {
+ failureClass = error?.transportFailure ? 'oracle-infrastructure-failure' : 'oracle-renderer-error';
+ }
+ const result = {
+ status,
+ failureClass,
+ error: message,
+ oracle,
+ };
+ if ( undefined !== id ) {
+ result.id = id;
+ }
+ return result;
+}
+
+async function writeJsonFrame( stream, result, drainTimeoutMs = SOCKET_WRITE_TIMEOUT_MS ) {
+ if ( TERMINAL_OUTPUT_STREAMS.has( stream ) ) {
+ const error = new Error( 'Oracle response stream is terminally unavailable.' );
+ error.code = 'HTML_API_FUZZ_OUTPUT_TIMEOUT';
+ throw error;
+ }
+ if ( false === stream.writable || stream.destroyed ) {
+ return;
+ }
+ const frame = Buffer.from( JSON.stringify( result ) + '\n', 'utf8' );
+ if ( frame.length > MAX_RESPONSE_FRAME_BYTES ) {
+ throw new Error( 'Oracle response frame exceeded 24 MiB.' );
+ }
+ if ( ! stream.write( frame ) ) {
+ if ( drainTimeoutMs <= 0 ) {
+ await once( stream, 'drain' );
+ return;
+ }
+ await new Promise( ( resolve, reject ) => {
+ let settled = false;
+ const finish = ( error = null ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ stream.removeListener( 'drain', onDrain );
+ stream.removeListener( 'close', onClose );
+ stream.removeListener( 'error', onError );
+ error ? reject( error ) : resolve();
+ };
+ const onDrain = () => finish();
+ const onClose = () => {
+ const error = new Error( 'Oracle response stream closed before draining.' );
+ error.code = 'HTML_API_FUZZ_OUTPUT_CLOSED';
+ finish( error );
+ };
+ const onError = ( error ) => finish( error );
+ const timer = setTimeout( () => {
+ const error = new Error( 'Oracle response drain timed out.' );
+ error.code = 'HTML_API_FUZZ_OUTPUT_TIMEOUT';
+ TERMINAL_OUTPUT_STREAMS.add( stream );
+ if ( stream === process.stdout ) {
+ forceProcessExitAfterCleanup = true;
+ process.exitCode ||= 1;
+ }
+ finish( error );
+ if (
+ stream !== process.stdout &&
+ ! stream.destroyed &&
+ 'function' === typeof stream.destroy
+ ) {
+ stream.destroy();
+ }
+ }, drainTimeoutMs );
+ timer.unref();
+ stream.once( 'drain', onDrain );
+ stream.once( 'close', onClose );
+ stream.once( 'error', onError );
+ } );
+ }
+}
+
+function assertCommandFields( request, allowed ) {
+ if ( ! request || 'object' !== typeof request || Array.isArray( request ) ) {
+ throw protocolError( 'Request must be a JSON object.' );
+ }
+ if ( undefined !== request.id && ! isValidRequestId( request.id ) ) {
+ throw protocolError( 'Request id must be a string or safe integer.' );
+ }
+ for ( const key of Object.keys( request ) ) {
+ if ( ! allowed.has( key ) ) {
+ throw protocolError( 'Unknown request field: ' + key + '.' );
+ }
+ }
+}
+
+async function dispatchRequest( oracle, request ) {
+ if ( ! request || 'object' !== typeof request || Array.isArray( request ) ) {
+ throw protocolError( 'Request must be a JSON object.' );
+ }
+ if ( 'string' !== typeof request.command ) {
+ throw protocolError( 'command must be a string.' );
+ }
+ if ( 'version' === request.command ) {
+ assertCommandFields( request, new Set( [ 'id', 'command' ] ) );
+ await oracle.start();
+ return { status: 'ok', oracle: oracle.metadata() };
+ }
+ if ( 'shutdown' === request.command ) {
+ assertCommandFields( request, new Set( [ 'id', 'command' ] ) );
+ return { status: 'ok', oracle: oracle.metadata(), shutdown: true };
+ }
+ if (
+ 'test-kill-browser' === request.command &&
+ '1' === process.env[ TEST_ALLOW_INTERNAL_COMMANDS_ENV ]
+ ) {
+ assertCommandFields( request, new Set( [ 'id', 'command' ] ) );
+ return oracle.killBrowserForTest();
+ }
+ if (
+ 'test-render-processing-instruction' === request.command &&
+ '1' === process.env[ TEST_ALLOW_INTERNAL_COMMANDS_ENV ]
+ ) {
+ assertCommandFields( request, new Set( [ 'id', 'command' ] ) );
+ return oracle.renderProcessingInstructionForTest();
+ }
+ if (
+ 'test-large-response' === request.command &&
+ '1' === process.env[ TEST_ALLOW_INTERNAL_COMMANDS_ENV ]
+ ) {
+ assertCommandFields( request, new Set( [ 'id', 'command' ] ) );
+ return { status: 'ok', testPayload: 'x'.repeat( 20 * 1024 * 1024 ), oracle: oracle.metadata() };
+ }
+ if ( 'render' !== request.command ) {
+ throw protocolError( 'Unknown command: ' + request.command + '.' );
+ }
+ try {
+ return await oracle.render( request );
+ } catch ( error ) {
+ if (
+ ! error.failureClass &&
+ /^(?:Request|Render request|Unknown request field|Unsupported parse mode|Unsupported fragment context|context |maxNodes |maxDepth |maxTreeBytes |securityAudit |htmlBase64 )/.test( error.message )
+ ) {
+ error.failureClass = 'protocol-error';
+ }
+ throw error;
+ }
+}
+
+class FramePeer {
+ constructor( input, output, oracle, globalDispatch, options = {} ) {
+ this.input = input;
+ this.output = output;
+ this.oracle = oracle;
+ this.globalDispatch = globalDispatch;
+ this.closeOnOversize = true === options.closeOnOversize;
+ this.singleFrame = true === options.singleFrame;
+ this.cancelOnDisconnect = true === options.cancelOnDisconnect;
+ this.writeTimeoutMs = options.writeTimeoutMs ?? SOCKET_WRITE_TIMEOUT_MS;
+ this.onShutdown = options.onShutdown || ( async () => {} );
+ this.buffer = Buffer.alloc( 0 );
+ this.discarding = false;
+ this.oversizeReported = false;
+ this.closed = false;
+ this.processing = Promise.resolve();
+ this.resolveClosed = null;
+ this.closedPromise = new Promise( ( resolve ) => {
+ this.resolveClosed = resolve;
+ } );
+ this.readTimer = options.readTimeoutMs > 0
+ ? setTimeout( () => this.finish(), options.readTimeoutMs )
+ : null;
+ this.readTimer?.unref();
+ this.input.on( 'data', ( chunk ) => this.receive( chunk ) );
+ this.input.once( 'end', () => this.finish() );
+ this.input.once( 'close', () => this.finish() );
+ this.input.once( 'error', () => this.finish() );
+ }
+
+ receive( chunk ) {
+ if ( this.closed ) {
+ return;
+ }
+ this.input.pause();
+ this.processing = this.processing
+ .then( () => this.consume( Buffer.from( chunk ) ) )
+ .catch( async ( error ) => {
+ await this.safeWrite( errorResult( error, this.oracle.metadata() ) );
+ this.finish();
+ } )
+ .finally( () => {
+ if ( ! this.closed && ! this.input.destroyed ) {
+ this.input.resume();
+ }
+ } );
+ }
+
+ async reportOversize() {
+ if ( this.oversizeReported ) {
+ return;
+ }
+ this.oversizeReported = true;
+ await this.safeWrite(
+ errorResult(
+ protocolError( 'Request frame exceeded 4 MiB.' ),
+ this.oracle.metadata()
+ )
+ );
+ }
+
+ async consume( chunk ) {
+ let offset = 0;
+ while ( offset < chunk.length ) {
+ if ( this.discarding ) {
+ const newline = chunk.indexOf( 0x0a, offset );
+ if ( newline < 0 ) {
+ return;
+ }
+ offset = newline + 1;
+ this.discarding = false;
+ this.oversizeReported = false;
+ this.buffer = Buffer.alloc( 0 );
+ continue;
+ }
+ const newline = chunk.indexOf( 0x0a, offset );
+ const end = newline < 0 ? chunk.length : newline;
+ const slice = chunk.subarray( offset, end );
+ if ( this.buffer.length + slice.length > MAX_REQUEST_FRAME_BYTES ) {
+ this.buffer = Buffer.alloc( 0 );
+ await this.reportOversize();
+ if ( this.closeOnOversize ) {
+ this.finish();
+ return;
+ }
+ if ( newline < 0 ) {
+ this.discarding = true;
+ return;
+ }
+ offset = newline + 1;
+ this.oversizeReported = false;
+ continue;
+ }
+ this.buffer = Buffer.concat( [ this.buffer, slice ] );
+ if ( newline < 0 ) {
+ return;
+ }
+ const frame = this.buffer;
+ this.buffer = Buffer.alloc( 0 );
+ offset = newline + 1;
+ await this.handleFrame( frame );
+ if ( this.closed ) {
+ return;
+ }
+ }
+ }
+
+ async handleFrame( frame ) {
+ clearTimeout( this.readTimer );
+ this.readTimer = null;
+ let request;
+ try {
+ request = JSON.parse( JSON_FRAME_DECODER.decode( frame ) );
+ } catch ( error ) {
+ error.failureClass = 'protocol-error';
+ await this.safeWrite( errorResult( error, this.oracle.metadata() ) );
+ if ( this.singleFrame ) {
+ this.finishAfterResponse();
+ }
+ return;
+ }
+ const id = request && 'object' === typeof request && isValidRequestId( request.id )
+ ? request.id
+ : undefined;
+ try {
+ const result = await this.globalDispatch( () => {
+ if ( this.cancelOnDisconnect && this.closed ) {
+ const error = new Error( 'Socket client disconnected before dispatch.' );
+ error.clientDisconnected = true;
+ throw error;
+ }
+ return dispatchRequest( this.oracle, request );
+ } );
+ if ( undefined !== id ) {
+ result.id = id;
+ }
+ await this.safeWrite( result );
+ if ( result.shutdown ) {
+ await this.onShutdown();
+ } else if ( this.singleFrame ) {
+ this.finishAfterResponse();
+ }
+ } catch ( error ) {
+ if ( error.clientDisconnected ) {
+ return;
+ }
+ await this.safeWrite( errorResult( error, this.oracle.metadata(), id ) );
+ if ( this.singleFrame ) {
+ this.finishAfterResponse();
+ }
+ }
+ }
+
+ async safeWrite( result ) {
+ try {
+ await writeJsonFrame( this.output, result, this.writeTimeoutMs );
+ } catch ( error ) {
+ if ( [
+ 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END', 'EPIPE',
+ 'HTML_API_FUZZ_OUTPUT_CLOSED', 'HTML_API_FUZZ_OUTPUT_TIMEOUT',
+ ].includes( error.code ) ) {
+ this.finish();
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ finish() {
+ if ( this.closed ) {
+ return;
+ }
+ this.closed = true;
+ clearTimeout( this.readTimer );
+ this.readTimer = null;
+ this.input.pause();
+ if ( this.closeOnOversize && ! this.input.destroyed ) {
+ this.input.destroy();
+ }
+ this.resolveClosed();
+ }
+
+ finishAfterResponse() {
+ if ( this.closed ) {
+ return;
+ }
+ this.closed = true;
+ clearTimeout( this.readTimer );
+ this.readTimer = null;
+ this.input.pause();
+ if ( ! this.output.destroyed ) {
+ this.output.end();
+ }
+ this.resolveClosed();
+ }
+}
+
+function serializedDispatcher() {
+ let queue = Promise.resolve();
+ return ( callback ) => {
+ const result = queue.then( callback );
+ queue = result.catch( () => {} );
+ return result;
+ };
+}
+
+async function runStdioServer( oracle, registerShutdown ) {
+ let shuttingDown = false;
+ let fatalError = null;
+ let resolveStop;
+ const stopped = new Promise( ( resolve ) => {
+ resolveStop = resolve;
+ } );
+ const shutdown = async ( error = null ) => {
+ if ( shuttingDown ) {
+ return;
+ }
+ shuttingDown = true;
+ fatalError = error;
+ peer.finish();
+ process.stdin.destroy();
+ resolveStop();
+ };
+ const peer = new FramePeer(
+ process.stdin,
+ process.stdout,
+ oracle,
+ serializedDispatcher(),
+ { closeOnOversize: false, onShutdown: shutdown }
+ );
+ registerShutdown( shutdown );
+ oracle.setFatalHandler( shutdown );
+ peer.closedPromise.then( () => shutdown() );
+ process.stdin.resume();
+ await stopped;
+ await peer.processing.catch( () => {} );
+ await oracle.close().catch( ( error ) => {
+ fatalError ||= error;
+ } );
+ if ( fatalError ) {
+ throw fatalError;
+ }
+}
+
+function assertSecureSocketPath( socketPath ) {
+ if ( ! path.isAbsolute( socketPath ) ) {
+ throw new Error( '--socket must be an absolute path.' );
+ }
+ const parent = path.dirname( socketPath );
+ const parentStat = fs.lstatSync( parent );
+ if (
+ ! parentStat.isDirectory() ||
+ parentStat.isSymbolicLink() ||
+ ( 'function' === typeof process.getuid && parentStat.uid !== process.getuid() ) ||
+ 0o700 !== ( parentStat.mode & 0o777 )
+ ) {
+ throw new Error( 'Socket parent must be a nonsymlink directory owned by the current uid with mode 0700.' );
+ }
+ if ( fs.existsSync( socketPath ) ) {
+ throw new Error( 'Refusing pre-existing socket path: ' + socketPath );
+ }
+}
+
+async function runSocketServer( oracle, socketPath, registerShutdown ) {
+ assertSecureSocketPath( socketPath );
+ let shuttingDown = false;
+ let fatalError = null;
+ let resolveStopped;
+ const stopped = new Promise( ( resolve ) => {
+ resolveStopped = resolve;
+ } );
+ const peers = new Set();
+ const dispatch = serializedDispatcher();
+ const server = net.createServer( ( socket ) => {
+ if ( peers.size >= MAX_SOCKET_CLIENTS ) {
+ writeJsonFrame(
+ socket,
+ errorResult( protocolError( 'Socket client limit exceeded.' ), oracle.metadata() ),
+ SOCKET_WRITE_TIMEOUT_MS
+ ).catch( () => {} ).finally( () => socket.destroy() );
+ return;
+ }
+ const peer = new FramePeer(
+ socket,
+ socket,
+ oracle,
+ dispatch,
+ {
+ closeOnOversize: true,
+ singleFrame: true,
+ cancelOnDisconnect: true,
+ readTimeoutMs: SOCKET_FRAME_TIMEOUT_MS,
+ writeTimeoutMs: SOCKET_WRITE_TIMEOUT_MS,
+ onShutdown: shutdown,
+ }
+ );
+ peers.add( peer );
+ peer.closedPromise.then( () => peer.processing.catch( () => {} ) ).then( () => peers.delete( peer ) );
+ } );
+ const shutdown = async ( error = null ) => {
+ if ( shuttingDown ) {
+ return;
+ }
+ shuttingDown = true;
+ fatalError = error;
+ await new Promise( ( resolve ) => {
+ try {
+ server.close( () => resolve() );
+ } catch ( _error ) {
+ resolve();
+ }
+ setTimeout( resolve, 1000 ).unref();
+ } );
+ for ( const peer of peers ) {
+ peer.finish();
+ if ( ! peer.output.destroyed ) {
+ peer.output.end();
+ }
+ }
+ await oracle.close().catch( ( closeError ) => {
+ fatalError ||= closeError;
+ } );
+ try {
+ fs.unlinkSync( socketPath );
+ } catch ( unlinkError ) {
+ if ( 'ENOENT' !== unlinkError.code ) {
+ fatalError ||= unlinkError;
+ }
+ }
+ resolveStopped();
+ };
+ registerShutdown( shutdown );
+ oracle.setFatalHandler( shutdown );
+ server.on( 'error', ( error ) => shutdown( error ) );
+ try {
+ await oracle.start();
+ } catch ( error ) {
+ await shutdown( error );
+ throw error;
+ }
+ if ( shuttingDown ) {
+ await stopped;
+ if ( fatalError ) {
+ throw fatalError;
+ }
+ return;
+ }
+ assertSecureSocketPath( socketPath );
+ const previousUmask = process.umask( 0o177 );
+ try {
+ try {
+ await new Promise( ( resolve, reject ) => {
+ server.once( 'error', reject );
+ server.listen( socketPath, resolve );
+ } );
+ } finally {
+ process.umask( previousUmask );
+ }
+ fs.chmodSync( socketPath, 0o600 );
+ } catch ( error ) {
+ await shutdown( error );
+ throw error;
+ }
+ try {
+ await writeJsonFrame( process.stdout, {
+ status: 'ready',
+ oracle: oracle.metadata(),
+ } );
+ } catch ( error ) {
+ await shutdown( error );
+ throw error;
+ }
+ await stopped;
+ if ( fatalError ) {
+ throw fatalError;
+ }
+}
+
+function printUsage() {
+ process.stdout.write(
+ 'Usage: chrome-tree-oracle.js --engine chrome --mode full-document|fragment-body --input PATH ' +
+ '[--context TAG] [--max-nodes N] [--max-depth N] [--max-tree-bytes N] [--chrome-executable PATH]\n' +
+ ' chrome-tree-oracle.js --serve [--socket ABSOLUTE_PATH] [--engine chrome] [--chrome-executable PATH]\n' +
+ ' chrome-tree-oracle.js --version [--engine chrome] [--chrome-executable PATH]\n'
+ );
+}
+
+async function main() {
+ if ( '--internal-supervisor' === process.argv[ 2 ] ) {
+ try {
+ const internal = parseInternalSupervisorArgs( process.argv );
+ await runInternalSupervisor( internal );
+ } catch ( error ) {
+ process.stderr.write( 'Chrome internal supervisor failed: ' + ( error.stack || error.message || error ) + '\n' );
+ process.exitCode = 1;
+ }
+ return;
+ }
+
+ let options;
+ try {
+ options = parseArgs( process.argv );
+ } catch ( error ) {
+ await writeJsonFrame(
+ process.stdout,
+ errorResult( protocolError( error.message ), { kind: 'chrome-cdp', available: false } )
+ );
+ process.exitCode = 1;
+ return;
+ }
+ if ( options.help ) {
+ printUsage();
+ return;
+ }
+
+ const socketPath = options.socket ? path.resolve( options.socket ) : '';
+ let oracle;
+ try {
+ oracle = new ChromeOracle( options, socketPath );
+ } catch ( error ) {
+ await writeJsonFrame(
+ process.stdout,
+ errorResult( error, { kind: 'chrome-cdp', available: false } )
+ );
+ process.exitCode = 1;
+ return;
+ }
+
+ let signalShutdown = null;
+ let stopping = false;
+ const stopForSignal = async ( signal ) => {
+ if ( stopping ) {
+ return;
+ }
+ stopping = true;
+ try {
+ if ( signalShutdown ) {
+ await signalShutdown();
+ } else {
+ await oracle.close();
+ }
+ process.exitCode = 0;
+ } catch ( error ) {
+ process.stderr.write( 'Chrome cleanup failed after ' + signal + ': ' + ( error.stack || error.message || error ) + '\n' );
+ process.exitCode = 1;
+ }
+ };
+ process.once( 'SIGINT', () => stopForSignal( 'SIGINT' ) );
+ process.once( 'SIGTERM', () => stopForSignal( 'SIGTERM' ) );
+
+ try {
+ if ( options.serve ) {
+ if ( socketPath ) {
+ await runSocketServer( oracle, socketPath, ( shutdown ) => {
+ signalShutdown = shutdown;
+ } );
+ } else {
+ await runStdioServer( oracle, ( shutdown ) => {
+ signalShutdown = shutdown;
+ } );
+ }
+ return;
+ }
+ if ( options.version ) {
+ await oracle.start();
+ await writeJsonFrame( process.stdout, { status: 'ok', oracle: oracle.metadata() } );
+ return;
+ }
+ const html = await readBoundedRegularFile( options.input, MAX_INPUT_BYTES );
+ const result = await oracle.render( {
+ command: 'render',
+ htmlBase64: html.toString( 'base64' ),
+ mode: options.mode,
+ ...( options.context ? { context: options.context } : {} ),
+ maxNodes: Number( options[ 'max-nodes' ] || 3000 ),
+ maxDepth: Number( options[ 'max-depth' ] || 512 ),
+ maxTreeBytes: Number( options[ 'max-tree-bytes' ] || MAX_TREE_BYTES ),
+ } );
+ await writeJsonFrame( process.stdout, result );
+ } catch ( error ) {
+ await writeJsonFrame( process.stdout, errorResult( error, oracle.metadata() ) );
+ process.exitCode = 2;
+ } finally {
+ await oracle.close().catch( ( error ) => {
+ process.stderr.write( 'Chrome final cleanup failed: ' + ( error.stack || error.message || error ) + '\n' );
+ process.exitCode = 1;
+ } );
+ }
+}
+
+if ( require.main === module ) {
+ main().catch( async ( error ) => {
+ try {
+ await writeJsonFrame(
+ process.stdout,
+ errorResult( error, { kind: 'chrome-cdp', available: false } )
+ );
+ } catch ( _writeError ) {}
+ process.exitCode = 1;
+ } ).finally( () => {
+ if ( forceProcessExitAfterCleanup ) {
+ process.exit( process.exitCode || 1 );
+ }
+ } );
+} else {
+ module.exports = {
+ CdpClient,
+ CdpWebSocket,
+ ChromeOracle,
+ FramePeer,
+ createSupervisorEmitter,
+ manifestDigest,
+ processIdentity,
+ readProcessTable,
+ reapAndRemoveRuntimeResources,
+ reapAuthenticatedTree,
+ retainProcessSnapshot,
+ sameProcessIdentity,
+ serializedDispatcher,
+ writeJsonFrame,
+ };
+}
diff --git a/tools/html-api-fuzz/oracles/chrome/install.sh b/tools/html-api-fuzz/oracles/chrome/install.sh
new file mode 100755
index 0000000000000..0f45a426badb8
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/install.sh
@@ -0,0 +1,775 @@
+#!/bin/sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+VERSION_FILE="$SCRIPT_DIR/VERSION"
+if [ ! -f "$VERSION_FILE" ]; then
+ echo "Missing Chrome VERSION file: $VERSION_FILE" >&2
+ exit 1
+fi
+VERSION=$(sed -n '1p' "$VERSION_FILE")
+is_canonical_version() {
+ case "$1" in
+ ''|*[!0-9.]*) return 1 ;;
+ esac
+ old_ifs=$IFS
+ IFS=.
+ set -- $1
+ IFS=$old_ifs
+ [ "$#" -eq 4 ] || return 1
+ for component do
+ case "$component" in
+ ''|*[!0-9]*) return 1 ;;
+ esac
+ done
+}
+version_snapshot=$(cat "$VERSION_FILE"; printf '%s' '__HTML_API_FUZZ_VERSION_END__')
+expected_version_snapshot=$(printf '%s\n%s' "$VERSION" '__HTML_API_FUZZ_VERSION_END__')
+if ! is_canonical_version "$VERSION" || [ "$version_snapshot" != "$expected_version_snapshot" ]; then
+ echo "Chrome VERSION must contain exactly one canonical four-component version and a final newline." >&2
+ exit 1
+fi
+INSTALL_ROOT=${HTML_API_FUZZ_CHROME_INSTALL_ROOT:-"$SCRIPT_DIR/.chrome-for-testing"}
+ARCHIVE_MANIFEST="$SCRIPT_DIR/SHA256SUMS"
+EXECUTABLE_MANIFEST="$SCRIPT_DIR/EXECUTABLE_SHA256SUMS"
+LOCK_DIRECTORY="$INSTALL_ROOT/.install.lock"
+LOCK_OWNER="$LOCK_DIRECTORY/owner"
+LOCK_TOKEN="installer-$$-$(date +%s)"
+LOCK_HELD=0
+PARTIAL=
+STAGING=
+BACKUP=
+PUBLISHING=0
+COMMITTED=0
+WATCHDOG_PID=
+PROBE_RESULT=
+PROBE_STATUS=
+
+case "$(uname -s):$(uname -m)" in
+ Darwin:arm64)
+ PLATFORM=mac-arm64
+ ARCHIVE_DIR=chrome-mac-arm64
+ EXECUTABLE_RELATIVE='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ Darwin:x86_64)
+ PLATFORM=mac-x64
+ ARCHIVE_DIR=chrome-mac-x64
+ EXECUTABLE_RELATIVE='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ Linux:x86_64|Linux:amd64)
+ PLATFORM=linux64
+ ARCHIVE_DIR=chrome-linux64
+ EXECUTABLE_RELATIVE=chrome
+ ;;
+ *)
+ echo "Unsupported Chrome for Testing platform: $(uname -s) $(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+DESTINATION="$INSTALL_ROOT/$VERSION/$PLATFORM"
+EXECUTABLE="$DESTINATION/$ARCHIVE_DIR/$EXECUTABLE_RELATIVE"
+ARCHIVE_NAME="chrome-$VERSION-$PLATFORM.zip"
+ARCHIVE="$INSTALL_ROOT/.downloads/$ARCHIVE_NAME"
+MARKER="$DESTINATION/.html-api-fuzz-verified"
+
+if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != '--print-path' ]; }; then
+ echo 'Usage: install.sh [--print-path]' >&2
+ exit 2
+fi
+if [ "${1:-}" = '--print-path' ]; then
+ printf '%s\n' "$EXECUTABLE"
+ exit 0
+fi
+
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{ print $1 }'
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$1" | awk '{ print $1 }'
+ else
+ echo 'sha256sum or shasum is required.' >&2
+ return 1
+ fi
+}
+
+manifest_digest() {
+ manifest=$1
+ name=$2
+ label=$3
+ if [ ! -f "$manifest" ]; then
+ echo "Missing Chrome $label checksum manifest: $manifest" >&2
+ return 1
+ fi
+ count=$(awk -v name="$name" '$2 == name { count++ } END { print count + 0 }' "$manifest")
+ if [ "$count" -ne 1 ]; then
+ echo "Expected exactly one $label checksum for $name; found $count." >&2
+ return 1
+ fi
+ fields=$(awk -v name="$name" '$2 == name { print NF }' "$manifest")
+ digest=$(awk -v name="$name" '$2 == name { print $1 }' "$manifest")
+ if [ "$fields" -ne 2 ] || [ "${#digest}" -ne 64 ]; then
+ echo "Malformed $label checksum entry for $name." >&2
+ return 1
+ fi
+ case "$digest" in
+ *[!0-9a-f]*)
+ echo "Malformed $label checksum entry for $name." >&2
+ return 1
+ ;;
+ esac
+ printf '%s\n' "$digest"
+}
+
+EXPECTED_ARCHIVE_SHA256=$(manifest_digest "$ARCHIVE_MANIFEST" "$ARCHIVE_NAME" archive)
+EXPECTED_EXECUTABLE_SHA256=$(manifest_digest "$EXECUTABLE_MANIFEST" "$PLATFORM.executable" executable)
+EXPECTED_MARKER=$(printf 'schema=1\nversion=%s\nplatform=%s\narchive_sha256=%s\nexecutable_sha256=%s' \
+ "$VERSION" "$PLATFORM" "$EXPECTED_ARCHIVE_SHA256" "$EXPECTED_EXECUTABLE_SHA256")
+
+release_lock() {
+ if [ "$LOCK_HELD" -ne 1 ]; then
+ return
+ fi
+ release_attempt=0
+ while [ -d "$LOCK_DIRECTORY" ] && ! mkdir "$LOCK_DIRECTORY/.reaper" 2>/dev/null; do
+ release_attempt=$((release_attempt + 1))
+ if [ "$release_attempt" -ge 100 ]; then
+ echo 'Timed out serializing Chrome install lock release.' >&2
+ return 1
+ fi
+ sleep 0.01
+ done
+ if [ -f "$LOCK_OWNER" ]; then
+ owner_snapshot=$(cat "$LOCK_OWNER"; printf '%s' '__HTML_API_FUZZ_LOCK_END__')
+ expected_owner=$(printf 'schema=1\npid=%s\ntoken=%s\n%s' "$$" "$LOCK_TOKEN" '__HTML_API_FUZZ_LOCK_END__')
+ if [ "$owner_snapshot" = "$expected_owner" ]; then
+ released="$INSTALL_ROOT/.install.lock.released.$LOCK_TOKEN"
+ if [ "${HTML_API_FUZZ_CHROME_TEST_FAIL_RELEASE_MV:-0}" = 1 ] || ! mv "$LOCK_DIRECTORY" "$released" 2>/dev/null; then
+ rmdir "$LOCK_DIRECTORY/.reaper" 2>/dev/null || true
+ echo 'Could not atomically release the authenticated Chrome install lock.' >&2
+ return 1
+ fi
+ if ! rm -rf "$released"; then
+ echo 'Could not remove the released Chrome install lock tombstone.' >&2
+ return 1
+ fi
+ else
+ rmdir "$LOCK_DIRECTORY/.reaper" 2>/dev/null || true
+ echo 'Chrome install lock owner identity changed before release.' >&2
+ return 1
+ fi
+ else
+ rmdir "$LOCK_DIRECTORY/.reaper" 2>/dev/null || true
+ echo 'Chrome install lock owner metadata disappeared before release.' >&2
+ return 1
+ fi
+ LOCK_HELD=0
+}
+
+cleanup() {
+ status=$?
+ if [ -n "$WATCHDOG_PID" ]; then
+ kill -TERM "$WATCHDOG_PID" 2>/dev/null || true
+ cleanup_wait=0
+ while kill -0 "$WATCHDOG_PID" 2>/dev/null && [ "$cleanup_wait" -lt 500 ]; do
+ cleanup_wait=$((cleanup_wait + 1))
+ sleep 0.01
+ done
+ if kill -0 "$WATCHDOG_PID" 2>/dev/null; then
+ kill -KILL "$WATCHDOG_PID" 2>/dev/null || true
+ fi
+ wait "$WATCHDOG_PID" 2>/dev/null || true
+ WATCHDOG_PID=
+ fi
+ if [ -n "$PROBE_RESULT" ]; then
+ rm -f "$PROBE_RESULT"
+ PROBE_RESULT=
+ fi
+ if [ -n "$PROBE_STATUS" ]; then
+ rm -f "$PROBE_STATUS"
+ PROBE_STATUS=
+ fi
+ if [ -n "$PARTIAL" ] && [ -e "$PARTIAL" ]; then
+ rm -f "$PARTIAL"
+ fi
+ if [ -n "$STAGING" ] && [ -d "$STAGING" ]; then
+ rm -rf "$STAGING"
+ fi
+ if [ "$COMMITTED" -ne 1 ]; then
+ if [ "$PUBLISHING" -eq 1 ]; then
+ rm -rf "$DESTINATION"
+ fi
+ if [ -n "$BACKUP" ] && [ -d "$BACKUP" ]; then
+ rm -rf "$DESTINATION"
+ mv "$BACKUP" "$DESTINATION"
+ fi
+ elif [ -n "$BACKUP" ] && [ -d "$BACKUP" ]; then
+ rm -rf "$BACKUP"
+ fi
+ release_failed=0
+ if ! release_lock; then
+ release_failed=1
+ fi
+ trap - EXIT HUP INT TERM
+ if [ "$release_failed" -eq 1 ] && [ "$status" -eq 0 ]; then
+ status=1
+ fi
+ exit "$status"
+}
+trap cleanup EXIT
+trap 'exit 129' HUP
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+mkdir -p "$INSTALL_ROOT"
+attempt=0
+max_attempts=${HTML_API_FUZZ_CHROME_LOCK_ATTEMPTS:-100}
+case "$max_attempts" in
+ ''|*[!0-9]*) echo 'HTML_API_FUZZ_CHROME_LOCK_ATTEMPTS must be a positive integer.' >&2; exit 2 ;;
+esac
+if [ "$max_attempts" -lt 1 ]; then
+ echo 'HTML_API_FUZZ_CHROME_LOCK_ATTEMPTS must be a positive integer.' >&2
+ exit 2
+fi
+
+try_reap_stale_lock() {
+ reaper="$LOCK_DIRECTORY/.reaper"
+ if ! mkdir "$reaper" 2>/dev/null; then
+ return 1
+ fi
+ if [ ! -f "$LOCK_OWNER" ]; then
+ rmdir "$reaper" 2>/dev/null || true
+ return 1
+ fi
+ first=$(cat "$LOCK_OWNER"; printf '%s' '__HTML_API_FUZZ_LOCK_END__')
+ first_body=${first%__HTML_API_FUZZ_LOCK_END__}
+ stale_pid=$(printf '%s' "$first_body" | sed -n 's/^pid=//p')
+ stale_token=$(printf '%s' "$first_body" | sed -n 's/^token=//p')
+ expected=$(printf 'schema=1\npid=%s\ntoken=%s\n%s' "$stale_pid" "$stale_token" '__HTML_API_FUZZ_LOCK_END__')
+ case "$stale_pid" in
+ ''|*[!0-9]*) stale_pid= ;;
+ esac
+ if [ -z "$stale_pid" ] || [ -z "$stale_token" ] || [ "$first" != "$expected" ]; then
+ rmdir "$reaper" 2>/dev/null || true
+ return 1
+ fi
+ if kill -0 "$stale_pid" 2>/dev/null; then
+ rmdir "$reaper" 2>/dev/null || true
+ return 1
+ fi
+ if [ -n "${HTML_API_FUZZ_CHROME_TEST_REAPER_PAUSE_FILE:-}" ]; then
+ printf '%s\n' "$stale_token" > "$HTML_API_FUZZ_CHROME_TEST_REAPER_PAUSE_FILE"
+ while [ -e "$HTML_API_FUZZ_CHROME_TEST_REAPER_PAUSE_FILE" ]; do
+ sleep 0.01
+ done
+ fi
+ unchanged=0
+ if [ ! -f "$LOCK_OWNER" ]; then
+ # The exact dead owner may have been released by its surviving cleanup
+ # supervisor after this reaper was elected.
+ unchanged=1
+ else
+ second=$(cat "$LOCK_OWNER"; printf '%s' '__HTML_API_FUZZ_LOCK_END__')
+ if [ "$first" = "$second" ]; then
+ unchanged=1
+ fi
+ fi
+ if [ "$unchanged" -ne 1 ]; then
+ rmdir "$reaper" 2>/dev/null || true
+ return 1
+ fi
+ tombstone="$INSTALL_ROOT/.install.lock.stale.$LOCK_TOKEN"
+ if ! mv "$LOCK_DIRECTORY" "$tombstone" 2>/dev/null; then
+ rmdir "$reaper" 2>/dev/null || true
+ return 1
+ fi
+ rm -rf "$tombstone"
+ return 0
+}
+
+while ! mkdir "$LOCK_DIRECTORY" 2>/dev/null; do
+ if try_reap_stale_lock; then
+ continue
+ fi
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge "$max_attempts" ]; then
+ echo "Timed out acquiring Chrome install lock: $LOCK_DIRECTORY" >&2
+ exit 1
+ fi
+ sleep 0.1
+done
+LOCK_HELD=1
+owner_partial="$LOCK_DIRECTORY/owner.$$"
+printf 'schema=1\npid=%s\ntoken=%s\n' "$$" "$LOCK_TOKEN" > "$owner_partial"
+mv "$owner_partial" "$LOCK_OWNER"
+if [ -n "${HTML_API_FUZZ_CHROME_TEST_OWNER_PID_FILE:-}" ]; then
+ printf '%s\n' "$$" > "$HTML_API_FUZZ_CHROME_TEST_OWNER_PID_FILE"
+fi
+
+marker_snapshot() {
+ cat "$MARKER"
+ printf '%s' '__HTML_API_FUZZ_MARKER_END__'
+}
+
+marker_matches_expected() {
+ [ -f "$MARKER" ] || return 1
+ actual=$(marker_snapshot)
+ expected=$(printf '%s\n%s' "$EXPECTED_MARKER" '__HTML_API_FUZZ_MARKER_END__')
+ [ "$actual" = "$expected" ]
+}
+
+run_chrome_version_probe() {
+ probe_executable=$1
+ probe_staging=${2:-}
+ if ! command -v node >/dev/null 2>&1; then
+ echo 'Node.js is required for the bounded Chrome version probe.' >&2
+ return 1
+ fi
+ probe_node=$(command -v node)
+ probe_node_root=${probe_node%/bin/node}
+ for direct_node in "$probe_node_root"/tools/image/node/*/bin/node; do
+ if [ -x "$direct_node" ]; then
+ probe_node=$direct_node
+ fi
+ done
+ if [ ! -x "$probe_node" ]; then
+ echo 'Could not resolve the Node.js executable for the Chrome version probe.' >&2
+ return 1
+ fi
+ PROBE_RESULT="$INSTALL_ROOT/.version-probe.$$.$(date +%s)"
+ PROBE_STATUS="$PROBE_RESULT.status"
+ probe_timeout_ms=${HTML_API_FUZZ_CHROME_TEST_PROBE_TIMEOUT_MS:-10000}
+ case "$probe_timeout_ms" in
+ ''|*[!0-9]*) echo 'Invalid Chrome version-probe timeout.' >&2; return 1 ;;
+ esac
+ if [ "$probe_timeout_ms" -lt 50 ] || [ "$probe_timeout_ms" -gt 10000 ]; then
+ echo 'Invalid Chrome version-probe timeout.' >&2
+ return 1
+ fi
+ rm -f "$PROBE_RESULT"
+ (
+ exec "$probe_node" - "$probe_executable" "$VERSION" "$PROBE_RESULT" "$PROBE_STATUS" "$$" "$LOCK_DIRECTORY" "$LOCK_TOKEN" "$probe_staging" "$INSTALL_ROOT" "$probe_timeout_ms"
+ ) <<'NODE' &
+'use strict';
+const fs = require( 'node:fs' );
+const path = require( 'node:path' );
+const { spawn } = require( 'node:child_process' );
+
+const [ executable, expectedVersion, resultPath, statusPath, ownerText, lockDirectory, lockToken, stagingPath, installRoot, timeoutText ] = process.argv.slice( 2 );
+const ownerPid = Number.parseInt( ownerText, 10 );
+const initialParentPid = process.ppid;
+const maximumOutputBytes = 16 * 1024;
+const probeTimeoutMs = Number.parseInt( timeoutText, 10 );
+let child = null;
+let output = Buffer.alloc( 0 );
+let settling = false;
+let ownerLost = false;
+let ownerResourcesReleased = false;
+let wallTimer = null;
+let ownerTimer = null;
+
+const startupPausePath = process.env.HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PRE_CHILD_PAUSE_FILE;
+if ( startupPausePath ) {
+ fs.writeFileSync( startupPausePath, String( process.pid ) + '\n', { flag: 'wx', mode: 0o600 } );
+ const startupGate = new Int32Array( new SharedArrayBuffer( 4 ) );
+ while ( fs.existsSync( startupPausePath ) ) {
+ Atomics.wait( startupGate, 0, 0, 10 );
+ }
+}
+
+function delay( milliseconds ) {
+ return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) );
+}
+
+function groupAlive() {
+ if ( ! child?.pid ) {
+ return false;
+ }
+ try {
+ process.kill( -child.pid, 0 );
+ return true;
+ } catch ( error ) {
+ return 'EPERM' === error.code;
+ }
+}
+
+function ownerPresent() {
+ if ( process.ppid !== initialParentPid ) {
+ return false;
+ }
+ try {
+ process.kill( ownerPid, 0 );
+ return true;
+ } catch ( error ) {
+ return 'EPERM' === error.code;
+ }
+}
+
+async function terminateGroup() {
+ if ( ! child?.pid ) {
+ return;
+ }
+ try {
+ process.kill( -child.pid, 'SIGTERM' );
+ } catch ( error ) {
+ if ( 'ESRCH' !== error.code ) {
+ throw error;
+ }
+ }
+ let deadline = Date.now() + 250;
+ while ( groupAlive() && Date.now() < deadline ) {
+ await delay( 10 );
+ }
+ if ( groupAlive() ) {
+ try {
+ process.kill( -child.pid, 'SIGKILL' );
+ } catch ( error ) {
+ if ( 'ESRCH' !== error.code ) {
+ throw error;
+ }
+ }
+ }
+ deadline = Date.now() + 3000;
+ while ( groupAlive() && Date.now() < deadline ) {
+ await delay( 10 );
+ }
+ if ( groupAlive() ) {
+ throw new Error( 'Chrome version-probe process group survived SIGKILL.' );
+ }
+}
+
+function exactOwnerRecord() {
+ return 'schema=1\npid=' + ownerPid + '\ntoken=' + lockToken + '\n';
+}
+
+async function releaseOwnerResources() {
+ try {
+ fs.rmSync( resultPath, { force: true } );
+ fs.rmSync( statusPath, { force: true } );
+ } catch ( _error ) {}
+ if ( stagingPath ) {
+ const resolvedRoot = path.resolve( installRoot ) + path.sep;
+ const resolvedStaging = path.resolve( stagingPath );
+ if ( resolvedStaging.startsWith( resolvedRoot ) && path.basename( resolvedStaging ).startsWith( '.installing.' ) ) {
+ fs.rmSync( resolvedStaging, { recursive: true, force: true } );
+ }
+ }
+ const expectedLock = path.join( path.resolve( installRoot ), '.install.lock' );
+ if ( path.resolve( lockDirectory ) !== expectedLock ) {
+ return;
+ }
+ const ownerPath = path.join( expectedLock, 'owner' );
+ const reaperPath = path.join( expectedLock, '.reaper' );
+ const deadline = Date.now() + 3000;
+ while ( Date.now() < deadline ) {
+ if ( ! fs.existsSync( expectedLock ) ) {
+ return;
+ }
+ let owner;
+ try {
+ owner = fs.readFileSync( ownerPath, 'utf8' );
+ } catch ( error ) {
+ if ( 'ENOENT' === error.code ) {
+ await delay( 10 );
+ continue;
+ }
+ throw error;
+ }
+ if ( owner !== exactOwnerRecord() ) {
+ return;
+ }
+ try {
+ fs.mkdirSync( reaperPath, { mode: 0o700 } );
+ } catch ( error ) {
+ if ( 'EEXIST' === error.code || 'ENOENT' === error.code ) {
+ await delay( 10 );
+ continue;
+ }
+ throw error;
+ }
+ try {
+ if ( fs.readFileSync( ownerPath, 'utf8' ) !== exactOwnerRecord() ) {
+ return;
+ }
+ const tombstone = path.join( path.resolve( installRoot ), '.install.lock.abandoned.' + lockToken );
+ fs.renameSync( expectedLock, tombstone );
+ fs.rmSync( tombstone, { recursive: true, force: true } );
+ return;
+ } finally {
+ if ( fs.existsSync( reaperPath ) ) {
+ try {
+ fs.rmdirSync( reaperPath );
+ } catch ( _error ) {}
+ }
+ }
+ }
+ throw new Error( 'Owner-death watchdog could not release the authenticated installer lock.' );
+}
+
+async function finish( error = null ) {
+ if ( settling ) {
+ return;
+ }
+ settling = true;
+ clearTimeout( wallTimer );
+ try {
+ await terminateGroup();
+ ownerLost ||= ! ownerPresent();
+ if ( ownerLost ) {
+ await releaseOwnerResources();
+ ownerResourcesReleased = true;
+ }
+ if ( error ) {
+ throw error;
+ }
+ const text = output.toString( 'utf8' );
+ const matches = [ ...text.matchAll( /(^|[^0-9])([0-9]+(?:\.[0-9]+){3})(?=[^0-9]|$)/g ) ];
+ const version = matches.at( -1 )?.[ 2 ];
+ if ( version !== expectedVersion ) {
+ throw new Error( 'Authenticated Chrome version does not match its pin.' );
+ }
+ if ( ! ownerPresent() ) {
+ ownerLost = true;
+ if ( ! ownerResourcesReleased ) {
+ await releaseOwnerResources();
+ ownerResourcesReleased = true;
+ }
+ throw new Error( 'Chrome version probe installer owner disappeared.' );
+ }
+ fs.writeFileSync( resultPath, version + '\n', { flag: 'wx', mode: 0o600 } );
+ if ( ! ownerPresent() ) {
+ ownerLost = true;
+ if ( ! ownerResourcesReleased ) {
+ await releaseOwnerResources();
+ ownerResourcesReleased = true;
+ }
+ throw new Error( 'Chrome version probe installer owner disappeared.' );
+ }
+ clearInterval( ownerTimer );
+ process.exitCode = 0;
+ } catch ( failure ) {
+ if ( ! ownerPresent() && ! ownerResourcesReleased ) {
+ ownerLost = true;
+ try {
+ await releaseOwnerResources();
+ ownerResourcesReleased = true;
+ } catch ( cleanupError ) {
+ failure = new AggregateError( [ failure, cleanupError ], 'Chrome version probe and owner-death cleanup failed.' );
+ }
+ }
+ clearInterval( ownerTimer );
+ process.stderr.write( 'Chrome version probe failed: ' + ( failure.message || String( failure ) ) + '\n' );
+ process.exitCode = 1;
+ }
+ if ( ! ownerLost ) {
+ try {
+ fs.writeFileSync( statusPath, String( process.exitCode || 0 ) + '\n', { flag: 'wx', mode: 0o600 } );
+ } catch ( error ) {
+ process.stderr.write( 'Chrome version watchdog could not publish completion: ' + error.message + '\n' );
+ process.exitCode = 1;
+ }
+ }
+}
+
+let ownerAlive = true;
+try {
+ process.kill( ownerPid, 0 );
+} catch ( error ) {
+ ownerAlive = 'EPERM' === error.code;
+}
+if ( ! Number.isSafeInteger( ownerPid ) || ownerPid < 2 ) {
+ process.stderr.write( 'Chrome version probe did not start under its authenticated installer owner.\n' );
+ process.exit( 1 );
+} else if ( initialParentPid !== ownerPid || ! ownerAlive ) {
+ ownerLost = true;
+ finish( new Error( 'Chrome version probe installer owner disappeared before probe spawn.' ) );
+} else {
+ process.once( 'SIGINT', () => finish( new Error( 'Chrome version watchdog interrupted.' ) ) );
+ process.once( 'SIGTERM', () => finish( new Error( 'Chrome version watchdog terminated.' ) ) );
+ child = spawn( executable, [ '--version' ], {
+ detached: true,
+ stdio: [ 'ignore', 'pipe', 'pipe' ],
+ } );
+ const collect = ( chunk ) => {
+ if ( settling ) {
+ return;
+ }
+ if ( output.length + chunk.length > maximumOutputBytes ) {
+ finish( new Error( 'Chrome version probe exceeded 16 KiB of combined output.' ) );
+ return;
+ }
+ output = Buffer.concat( [ output, chunk ] );
+ };
+ child.stdout.on( 'data', collect );
+ child.stderr.on( 'data', collect );
+ child.once( 'error', ( error ) => finish( error ) );
+ child.once( 'close', ( code, signal ) => {
+ if ( 0 === code ) {
+ finish();
+ } else {
+ finish( new Error( 'Chrome version probe exited with code ' + code + ' and signal ' + signal + '.' ) );
+ }
+ } );
+ wallTimer = setTimeout( () => finish( new Error( 'Chrome version probe exceeded its 10 second wall timeout.' ) ), probeTimeoutMs );
+ ownerTimer = setInterval( () => {
+ if ( ! ownerPresent() ) {
+ ownerLost = true;
+ finish( new Error( 'Chrome version probe installer owner disappeared.' ) );
+ }
+ }, 25 );
+}
+NODE
+ WATCHDOG_PID=$!
+ if [ -n "${HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PID_FILE:-}" ]; then
+ printf '%s\n' "$WATCHDOG_PID" > "$HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PID_FILE"
+ fi
+ probe_wait=0
+ while kill -0 "$WATCHDOG_PID" 2>/dev/null && [ "$probe_wait" -lt 1400 ]; do
+ probe_wait=$((probe_wait + 1))
+ sleep 0.01
+ done
+ if kill -0 "$WATCHDOG_PID" 2>/dev/null; then
+ kill -TERM "$WATCHDOG_PID" 2>/dev/null || true
+ probe_term_wait=0
+ while kill -0 "$WATCHDOG_PID" 2>/dev/null && [ "$probe_term_wait" -lt 25 ]; do
+ probe_term_wait=$((probe_term_wait + 1))
+ sleep 0.01
+ done
+ if kill -0 "$WATCHDOG_PID" 2>/dev/null; then
+ kill -KILL "$WATCHDOG_PID" 2>/dev/null || true
+ fi
+ fi
+ wait "$WATCHDOG_PID" 2>/dev/null || true
+ probe_status=1
+ if [ -f "$PROBE_STATUS" ]; then
+ probe_status=$(sed -n '1p' "$PROBE_STATUS")
+ fi
+ WATCHDOG_PID=
+ rm -f "$PROBE_STATUS"
+ PROBE_STATUS=
+ if [ "$probe_status" -ne 0 ] || [ ! -f "$PROBE_RESULT" ]; then
+ rm -f "$PROBE_RESULT"
+ PROBE_RESULT=
+ return 1
+ fi
+ PROBED_VERSION=$(sed -n '1p' "$PROBE_RESULT")
+ result_snapshot=$(cat "$PROBE_RESULT"; printf '%s' '__HTML_API_FUZZ_PROBE_END__')
+ expected_result=$(printf '%s\n%s' "$VERSION" '__HTML_API_FUZZ_PROBE_END__')
+ rm -f "$PROBE_RESULT"
+ PROBE_RESULT=
+ [ "$result_snapshot" = "$expected_result" ] || return 1
+}
+
+validate_installed_snapshot() {
+ marker_matches_expected || return 1
+ [ -x "$EXECUTABLE" ] || return 1
+ before_marker=$(marker_snapshot)
+ before_hash=$(sha256_file "$EXECUTABLE")
+ [ "$before_hash" = "$EXPECTED_EXECUTABLE_SHA256" ] || return 1
+ run_chrome_version_probe "$EXECUTABLE" '' || return 2
+ installed_version=$PROBED_VERSION
+ [ "$installed_version" = "$VERSION" ] || return 2
+ after_hash=$(sha256_file "$EXECUTABLE")
+ after_marker=$(marker_snapshot)
+ [ "$after_hash" = "$EXPECTED_EXECUTABLE_SHA256" ] || return 2
+ [ "$before_marker" = "$after_marker" ] || return 2
+}
+
+if validate_installed_snapshot; then
+ printf '%s\n' "$EXECUTABLE"
+ exit 0
+else
+ cache_status=$?
+ if [ "$cache_status" -eq 2 ]; then
+ echo 'Authenticated Chrome installation changed or failed during its cache probe.' >&2
+ exit 1
+ fi
+fi
+
+interrupt_for_test() {
+ if [ "${HTML_API_FUZZ_CHROME_TEST_INTERRUPT_PHASE:-}" = "$1" ]; then
+ kill -TERM "$$"
+ exit 143
+ fi
+}
+
+mkdir -p "$INSTALL_ROOT/.downloads"
+URL="https://storage.googleapis.com/chrome-for-testing-public/$VERSION/$PLATFORM/chrome-$PLATFORM.zip"
+if [ ! -f "$ARCHIVE" ]; then
+ PARTIAL="$ARCHIVE.partial.$$"
+ rm -f "$PARTIAL"
+ curl --fail --location --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 --output "$PARTIAL" "$URL"
+ downloaded_hash=$(sha256_file "$PARTIAL")
+ if [ "$downloaded_hash" != "$EXPECTED_ARCHIVE_SHA256" ]; then
+ echo "Chrome archive SHA-256 mismatch: expected $EXPECTED_ARCHIVE_SHA256, got $downloaded_hash" >&2
+ rm -f "$PARTIAL"
+ exit 1
+ fi
+ mv "$PARTIAL" "$ARCHIVE"
+ PARTIAL=
+fi
+
+ACTUAL_ARCHIVE_SHA256=$(sha256_file "$ARCHIVE")
+if [ "$ACTUAL_ARCHIVE_SHA256" != "$EXPECTED_ARCHIVE_SHA256" ]; then
+ echo "Chrome archive SHA-256 mismatch: expected $EXPECTED_ARCHIVE_SHA256, got $ACTUAL_ARCHIVE_SHA256" >&2
+ exit 1
+fi
+
+STAGING=$(mktemp -d "$INSTALL_ROOT/.installing.$PLATFORM.XXXXXX")
+unzip -q "$ARCHIVE" -d "$STAGING"
+STAGED_EXECUTABLE="$STAGING/$ARCHIVE_DIR/$EXECUTABLE_RELATIVE"
+if [ ! -x "$STAGED_EXECUTABLE" ]; then
+ echo "Chrome archive did not contain the expected executable: $STAGED_EXECUTABLE" >&2
+ exit 1
+fi
+staged_hash=$(sha256_file "$STAGED_EXECUTABLE")
+if [ "$staged_hash" != "$EXPECTED_EXECUTABLE_SHA256" ]; then
+ echo "Chrome executable SHA-256 mismatch: expected $EXPECTED_EXECUTABLE_SHA256, got $staged_hash" >&2
+ exit 1
+fi
+run_chrome_version_probe "$STAGED_EXECUTABLE" "$STAGING" || {
+ echo "Extracted Chrome failed its version check: $STAGED_EXECUTABLE" >&2
+ exit 1
+}
+staged_version=$PROBED_VERSION
+if [ "$staged_version" != "$VERSION" ]; then
+ echo "Extracted Chrome version $staged_version does not match pin $VERSION." >&2
+ exit 1
+fi
+staged_hash_after=$(sha256_file "$STAGED_EXECUTABLE")
+if [ "$staged_hash_after" != "$EXPECTED_EXECUTABLE_SHA256" ]; then
+ echo 'Extracted Chrome changed during its version probe.' >&2
+ exit 1
+fi
+
+mkdir -p "$(dirname "$DESTINATION")"
+BACKUP="$DESTINATION.previous.$$"
+rm -rf "$BACKUP"
+if [ -e "$DESTINATION" ]; then
+ mv "$DESTINATION" "$BACKUP"
+fi
+interrupt_for_test after-backup
+PUBLISHING=1
+if ! mv "$STAGING" "$DESTINATION"; then
+ exit 1
+fi
+STAGING=
+interrupt_for_test after-publish
+marker_partial="$DESTINATION/.html-api-fuzz-verified.partial.$$"
+if ! printf '%s\n' "$EXPECTED_MARKER" > "$marker_partial" || ! mv "$marker_partial" "$MARKER"; then
+ exit 1
+fi
+interrupt_for_test after-marker
+
+if ! marker_matches_expected || [ ! -x "$EXECUTABLE" ] || [ "$(sha256_file "$EXECUTABLE")" != "$EXPECTED_EXECUTABLE_SHA256" ]; then
+ echo 'Published Chrome installation failed its marker/hash snapshot check.' >&2
+ exit 1
+fi
+COMMITTED=1
+PUBLISHING=0
+rm -rf "$BACKUP"
+BACKUP=
+if [ "${HTML_API_FUZZ_CHROME_TEST_STICK_REAPER_ON_RELEASE:-0}" = 1 ]; then
+ mkdir "$LOCK_DIRECTORY/.reaper"
+fi
+printf '%s\n' "$EXECUTABLE"
diff --git a/tools/html-api-fuzz/oracles/chrome/smoke-test.js b/tools/html-api-fuzz/oracles/chrome/smoke-test.js
new file mode 100755
index 0000000000000..bd91ad3a796d4
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/smoke-test.js
@@ -0,0 +1,2091 @@
+#!/usr/bin/env node
+'use strict';
+
+const crypto = require( 'node:crypto' );
+const fs = require( 'node:fs' );
+const http = require( 'node:http' );
+const net = require( 'node:net' );
+const os = require( 'node:os' );
+const path = require( 'node:path' );
+const { once } = require( 'node:events' );
+const { EventEmitter } = require( 'node:events' );
+const { spawn, spawnSync } = require( 'node:child_process' );
+
+const SCRIPT = path.join( __dirname, 'chrome-tree-oracle.js' );
+const INSTALLER = path.join( __dirname, 'install.sh' );
+const CONTEXT_FILE = path.join( __dirname, '..', 'fragment-contexts.json' );
+const MAX_REQUEST_FRAME_BYTES = 4 * 1024 * 1024;
+const MAX_RESPONSE_FRAME_BYTES = 24 * 1024 * 1024;
+const MAX_TREE_BYTES = 16 * 1024 * 1024;
+const MAX_INPUT_BYTES = 2 * 1024 * 1024;
+
+function assert( condition, message ) {
+ if ( ! condition ) {
+ throw new Error( message );
+ }
+}
+
+function delay( milliseconds ) {
+ return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) );
+}
+
+function hashFile( filename ) {
+ const hash = crypto.createHash( 'sha256' );
+ const descriptor = fs.openSync( filename, 'r' );
+ const buffer = Buffer.allocUnsafe( 1024 * 1024 );
+ try {
+ for ( ;; ) {
+ const bytes = fs.readSync( descriptor, buffer, 0, buffer.length, null );
+ if ( 0 === bytes ) {
+ break;
+ }
+ hash.update( buffer.subarray( 0, bytes ) );
+ }
+ } finally {
+ fs.closeSync( descriptor );
+ }
+ return hash.digest( 'hex' );
+}
+
+function executablePath() {
+ const checked = spawnSync( INSTALLER, [ '--print-path' ], { encoding: 'utf8' } );
+ assert( 0 === checked.status, 'Could not resolve installed Chrome path.' );
+ return checked.stdout.trim();
+}
+
+function decodeTree( result ) {
+ assert( 'ok' === result.status, 'Expected successful render; got ' + JSON.stringify( result ).slice( 0, 1000 ) );
+ assert( 'string' === typeof result.treeBase64, 'Successful render omitted treeBase64.' );
+ assert( ! Object.hasOwn( result, 'tree' ), 'Successful render duplicated the canonical tree as text.' );
+ const tree = Buffer.from( result.treeBase64, 'base64' );
+ assert( tree.toString( 'base64' ) === result.treeBase64, 'treeBase64 was not canonical.' );
+ assert( tree.length === result.treeBytes, 'treeBytes did not match decoded bytes.' );
+ assert(
+ hashFileBuffer( tree ) === result.treeSha256,
+ 'treeSha256 did not match decoded bytes.'
+ );
+ return tree.toString( 'utf8' );
+}
+
+function hashFileBuffer( buffer ) {
+ return crypto.createHash( 'sha256' ).update( buffer ).digest( 'hex' );
+}
+
+function waitForReady( child, timeoutMs = 30000 ) {
+ return new Promise( ( resolve, reject ) => {
+ let stdout = Buffer.alloc( 0 );
+ let stderr = '';
+ let settled = false;
+ const finish = ( error, value ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ error ? reject( error ) : resolve( value );
+ };
+ const timer = setTimeout(
+ () => finish( new Error( 'Oracle did not become ready. ' + stderr ) ),
+ timeoutMs
+ );
+ child.stdout.on( 'data', ( chunk ) => {
+ if ( stdout.length + chunk.length > 1024 * 1024 ) {
+ finish( new Error( 'Oracle ready frame exceeded 1 MiB.' ) );
+ return;
+ }
+ stdout = Buffer.concat( [ stdout, chunk ] );
+ const newline = stdout.indexOf( 0x0a );
+ if ( newline >= 0 ) {
+ try {
+ finish( null, JSON.parse( stdout.subarray( 0, newline ).toString( 'utf8' ) ) );
+ } catch ( error ) {
+ finish( error );
+ }
+ }
+ } );
+ child.stderr.on( 'data', ( chunk ) => {
+ stderr = ( stderr + chunk.toString( 'utf8' ) ).slice( -65536 );
+ } );
+ child.once( 'exit', ( code, signal ) => {
+ finish( new Error( 'Oracle exited before ready: code=' + code + ' signal=' + signal + '. ' + stderr ) );
+ } );
+ if ( null !== child.exitCode || null !== child.signalCode ) {
+ finish( new Error( 'Oracle exited before ready: code=' + child.exitCode + ' signal=' + child.signalCode + '. ' + stderr ) );
+ }
+ } );
+}
+
+function requestRaw( socketPath, frame, timeoutMs = 30000 ) {
+ return new Promise( ( resolve, reject ) => {
+ const socket = net.createConnection( socketPath );
+ const chunks = [];
+ let bytes = 0;
+ let settled = false;
+ const finish = ( error, value ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ socket.destroy();
+ error ? reject( error ) : resolve( value );
+ };
+ const timer = setTimeout( () => finish( new Error( 'Socket request timed out.' ) ), timeoutMs );
+ socket.once( 'error', ( error ) => finish( error ) );
+ socket.on( 'data', ( chunk ) => {
+ bytes += chunk.length;
+ if ( bytes > MAX_RESPONSE_FRAME_BYTES ) {
+ finish( new Error( 'Response exceeded 24 MiB.' ) );
+ return;
+ }
+ chunks.push( chunk );
+ const combined = Buffer.concat( chunks );
+ const newline = combined.indexOf( 0x0a );
+ if ( newline >= 0 ) {
+ try {
+ finish( null, JSON.parse( combined.subarray( 0, newline ).toString( 'utf8' ) ) );
+ } catch ( error ) {
+ finish( error );
+ }
+ }
+ } );
+ socket.once( 'connect', () => socket.write( frame ) );
+ } );
+}
+
+function request( socketPath, payload, timeoutMs ) {
+ return requestRaw( socketPath, Buffer.from( JSON.stringify( payload ) + '\n' ), timeoutMs );
+}
+
+async function waitForExit( child, timeoutMs = 15000 ) {
+ if ( null !== child.exitCode || null !== child.signalCode ) {
+ return { code: child.exitCode, signal: child.signalCode };
+ }
+ return new Promise( ( resolve, reject ) => {
+ let settled = false;
+ const finish = ( error, value ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ child.removeListener( 'exit', onExit );
+ error ? reject( error ) : resolve( value );
+ };
+ const onExit = ( code, signal ) => finish( null, { code, signal } );
+ const timer = setTimeout(
+ () => finish( new Error( 'Process ' + child.pid + ' did not exit.' ) ),
+ timeoutMs
+ );
+ child.once( 'exit', onExit );
+ if ( null !== child.exitCode || null !== child.signalCode ) {
+ finish( null, { code: child.exitCode, signal: child.signalCode } );
+ }
+ } );
+}
+
+async function waitForPidGone( pid, timeoutMs = 10000 ) {
+ if ( ! Number.isSafeInteger( pid ) || pid < 2 ) {
+ return;
+ }
+ const deadline = Date.now() + timeoutMs;
+ while ( Date.now() < deadline ) {
+ try {
+ process.kill( pid, 0 );
+ } catch ( error ) {
+ if ( 'ESRCH' === error.code ) {
+ return;
+ }
+ if ( 'EPERM' !== error.code ) {
+ throw error;
+ }
+ }
+ await delay( 25 );
+ }
+ throw new Error( 'PID ' + pid + ' survived cleanup.' );
+}
+
+async function waitForPathGone( filename, timeoutMs = 10000 ) {
+ const deadline = Date.now() + timeoutMs;
+ while ( Date.now() < deadline ) {
+ if ( ! fs.existsSync( filename ) ) {
+ return;
+ }
+ await delay( 25 );
+ }
+ throw new Error( filename + ' survived cleanup.' );
+}
+
+function processCommandsContaining( needle ) {
+ const checked = spawnSync(
+ 'ps',
+ [ '-axww', '-o', 'pid=,command=' ],
+ { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 }
+ );
+ if ( checked.error || 0 !== checked.status ) {
+ throw new Error( 'Could not inspect Chrome processes during smoke test.' );
+ }
+ return checked.stdout.split( '\n' ).filter( ( line ) => line.includes( needle ) );
+}
+
+function captureDescendantIdentities( rootPid ) {
+ const { processIdentity, readProcessTable } = require( SCRIPT );
+ const table = readProcessTable();
+ const byPid = new Map( table.map( ( record ) => [ record.pid, record ] ) );
+ assert( byPid.has( rootPid ), 'Could not capture process-tree root ' + rootPid + '.' );
+ const children = new Map();
+ for ( const record of table ) {
+ const siblings = children.get( record.ppid ) || [];
+ siblings.push( record.pid );
+ children.set( record.ppid, siblings );
+ }
+ const captured = [];
+ const queue = [ rootPid ];
+ while ( queue.length ) {
+ const pid = queue.shift();
+ const record = byPid.get( pid );
+ if ( record ) {
+ captured.push( processIdentity( record ) );
+ queue.push( ...( children.get( pid ) || [] ) );
+ }
+ }
+ return captured;
+}
+
+async function waitForIdentitiesGone( identities, timeoutMs = 10000 ) {
+ const { processIdentity, readProcessTable, sameProcessIdentity } = require( SCRIPT );
+ const deadline = Date.now() + timeoutMs;
+ while ( Date.now() < deadline ) {
+ const current = new Map( readProcessTable().map( ( record ) => [ record.pid, processIdentity( record ) ] ) );
+ if ( identities.every( ( identity ) => ! sameProcessIdentity( identity, current.get( identity.pid ) ) ) ) {
+ return;
+ }
+ await delay( 25 );
+ }
+ const current = new Map( readProcessTable().map( ( record ) => [ record.pid, processIdentity( record ) ] ) );
+ const survivors = identities.filter( ( identity ) => sameProcessIdentity( identity, current.get( identity.pid ) ) );
+ throw new Error( 'Captured process identities survived cleanup: ' + survivors.map( ( identity ) => identity.pid ).join( ', ' ) + '.' );
+}
+
+async function waitForNoOwnedProcesses( profilePath, timeoutMs = 10000 ) {
+ const deadline = Date.now() + timeoutMs;
+ while ( Date.now() < deadline ) {
+ if ( 0 === processCommandsContaining( '--user-data-dir=' + profilePath ).length ) {
+ return;
+ }
+ await delay( 50 );
+ }
+ throw new Error( 'Owned Chrome processes survived for profile ' + profilePath + '.' );
+}
+
+function spawnService( temporaryDirectory, environment = {} ) {
+ const socketPath = path.join( temporaryDirectory, 'oracle-' + crypto.randomBytes( 4 ).toString( 'hex' ) + '.sock' );
+ const child = spawn(
+ process.execPath,
+ [ SCRIPT, '--serve', '--socket', socketPath ],
+ {
+ stdio: [ 'ignore', 'pipe', 'pipe' ],
+ env: { ...process.env, HTML_API_FUZZ_CHROME_TEST_ALLOW_INTERNAL_COMMANDS: '1', ...environment },
+ }
+ );
+ const service = { child, socketPath, stdout: '', stderr: '' };
+ child.stdout.on( 'data', ( chunk ) => {
+ service.stdout = ( service.stdout + chunk.toString( 'utf8' ) ).slice( -1024 * 1024 );
+ } );
+ child.stderr.on( 'data', ( chunk ) => {
+ service.stderr = ( service.stderr + chunk.toString( 'utf8' ) ).slice( -1024 * 1024 );
+ } );
+ return service;
+}
+
+async function shutdownService( service ) {
+ if ( null !== service.child.exitCode || null !== service.child.signalCode ) {
+ return;
+ }
+ const result = await request( service.socketPath, { id: 'shutdown', command: 'shutdown' } );
+ assert( 'ok' === result.status && true === result.shutdown, 'Shutdown request failed.' );
+ const outcome = await waitForExit( service.child );
+ assert( 0 === outcome.code, 'Graceful service shutdown was not clean.' );
+ assert( ! fs.existsSync( service.socketPath ), 'Graceful shutdown left the socket.' );
+}
+
+function assertIdentity( ready, chromeExecutable ) {
+ assert( 'ready' === ready.status, 'Socket server did not report ready.' );
+ assert( ! Object.hasOwn( ready, 'socket' ), 'Ready frame leaked a top-level socket path.' );
+ const oracle = ready.oracle;
+ const identity = oracle.identity;
+ const transport = oracle.transport;
+ assert( true === oracle.available, 'Ready oracle was unavailable.' );
+ assert( 'chrome-cdp' === identity.kind && 1 === identity.schemaVersion, 'Identity schema mismatch.' );
+ assert( '150.0.7871.114' === identity.pinnedChromeVersion, 'Pinned version mismatch.' );
+ assert( identity.pinnedChromeVersion === identity.chromeVersion, 'Live Chrome version mismatch.' );
+ assert( /^[0-9a-f]{64}$/.test( identity.chromeArchiveSha256 ), 'Archive identity hash missing.' );
+ assert( hashFile( chromeExecutable ) === identity.chromeExecutableSha256, 'Chrome executable identity hash mismatch.' );
+ assert( hashFile( SCRIPT ) === identity.oracleScriptSha256, 'Oracle script identity hash mismatch.' );
+ assert( hashFile( CONTEXT_FILE ) === identity.fragmentContextsSha256, 'Fragment context identity hash mismatch.' );
+ assert( JSON.stringify( JSON.parse( fs.readFileSync( CONTEXT_FILE, 'utf8' ) ) ) === JSON.stringify( identity.fragmentContexts ), 'Fragment context identity list mismatch.' );
+ assert( hashFile( process.execPath ) === identity.nodeExecutableSha256, 'Node executable identity hash mismatch.' );
+ assert( process.version === identity.nodeVersion, 'Node version identity mismatch.' );
+ assert( 'string' === typeof identity.cdpProtocolVersion, 'CDP protocol identity missing.' );
+ const forbidden = [ 'path', 'pid', 'socket', 'profile', 'endpoint', 'port', 'session', 'target', 'instance' ];
+ for ( const key of Object.keys( identity ) ) {
+ assert( ! forbidden.some( ( word ) => key.toLowerCase().includes( word ) ), 'Transient key leaked into identity: ' + key );
+ }
+ assert( true === transport.replayExcluded, 'Transport metadata was not marked replay-excluded.' );
+ for ( const key of [ 'ownerPid', 'supervisorPid', 'browserPid', 'runtimeRoot', 'profilePath', 'socketPath', 'debugEndpoint' ] ) {
+ assert( undefined !== transport[ key ], 'Transport metadata omitted ' + key + '.' );
+ }
+}
+
+function assertContextDrift() {
+ const contexts = JSON.parse( fs.readFileSync( CONTEXT_FILE, 'utf8' ) );
+ const generator = path.join( __dirname, '..', '..', 'lib', 'Generator.php' );
+ const code = 'require ' + JSON.stringify( generator ) + '; echo json_encode(HtmlApiFuzz\\Generator::fragment_contexts());';
+ const checked = spawnSync( 'php', [ '-r', code ], { encoding: 'utf8', timeout: 5000 } );
+ assert( 0 === checked.status, 'Could not read Generator::fragment_contexts().' );
+ assert( JSON.stringify( contexts ) === checked.stdout, 'Chrome contexts drifted from Generator::fragment_contexts().' );
+ return contexts;
+}
+
+function assertStrictCli( temporaryDirectory ) {
+ const invalidArgv = [
+ [ '--bogus' ],
+ [ '--help', '--bogus' ],
+ [ '--help', '--chrome-executable', '/tmp/nope' ],
+ [ '--version', '--context', 'body' ],
+ [ '--version', '--version' ],
+ [ '--serve', '--socket', 'relative.sock' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--context', 'nope' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--max-depth', '01' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--max-tree-bytes', String( MAX_TREE_BYTES + 1 ) ],
+ [ '--engine', '' ],
+ [ '--serve', '--socket', '' ],
+ [ '--version', '--chrome-executable', '' ],
+ [ '--mode', '', '--input', '/tmp/nope' ],
+ [ '--mode', 'fragment-body', '--input', '' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--context', '' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--max-nodes', '' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--max-depth', '' ],
+ [ '--mode', 'fragment-body', '--input', '/tmp/nope', '--max-tree-bytes', '' ],
+ ];
+ for ( const argv of invalidArgv ) {
+ const checked = spawnSync( process.execPath, [ SCRIPT, ...argv ], { encoding: 'utf8', timeout: 5000 } );
+ assert( 0 !== checked.status, 'Invalid CLI was accepted: ' + argv.join( ' ' ) );
+ if ( checked.stdout.trim() ) {
+ const parsed = JSON.parse( checked.stdout.trim() );
+ assert( 'protocol-error' === parsed.failureClass, 'Invalid CLI did not report protocol-error.' );
+ }
+ }
+ const executionLog = path.join( temporaryDirectory, 'untrusted-executed' );
+ const fake = path.join( temporaryDirectory, 'fake-chrome' );
+ fs.writeFileSync( fake, '#!/bin/sh\nprintf executed > ' + JSON.stringify( executionLog ) + '\nexit 0\n', { mode: 0o700 } );
+ const rejected = spawnSync(
+ process.execPath,
+ [ SCRIPT, '--version', '--chrome-executable', fake ],
+ { encoding: 'utf8', timeout: 10000 }
+ );
+ assert( 0 !== rejected.status, 'Untrusted custom executable was accepted.' );
+ assert( ! fs.existsSync( executionLog ), 'Untrusted custom executable ran before hash rejection.' );
+ const insecureParent = path.join( temporaryDirectory, 'insecure-socket-parent' );
+ fs.mkdirSync( insecureParent, { mode: 0o500 } );
+ const insecure = spawnSync(
+ process.execPath,
+ [ SCRIPT, '--serve', '--socket', path.join( insecureParent, 'oracle.sock' ) ],
+ { encoding: 'utf8', timeout: 5000 }
+ );
+ assert( 0 !== insecure.status, 'Non-0700 socket parent was accepted.' );
+ assert( 'protocol-error' === JSON.parse( insecure.stdout.trim() ).failureClass, 'Insecure socket parent was not rejected during CLI validation.' );
+ fs.chmodSync( insecureParent, 0o700 );
+}
+
+async function assertOneShotInputSnapshot( temporaryDirectory ) {
+ const input = path.join( temporaryDirectory, 'one-shot-input.html' );
+ const replacement = path.join( temporaryDirectory, 'one-shot-input.replacement' );
+ const pauseFile = path.join( temporaryDirectory, 'one-shot-input.pause' );
+ fs.writeFileSync( input, 'original
' );
+ let stdout = '';
+ let stderr = '';
+ const child = spawn(
+ process.execPath,
+ [ SCRIPT, '--mode', 'full-document', '--input', input ],
+ {
+ stdio: [ 'ignore', 'pipe', 'pipe' ],
+ env: { ...process.env, HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_INPUT_OPEN: pauseFile },
+ }
+ );
+ child.stdout.on( 'data', ( chunk ) => { stdout += chunk.toString( 'utf8' ); } );
+ child.stderr.on( 'data', ( chunk ) => { stderr += chunk.toString( 'utf8' ); } );
+ const deadline = Date.now() + 5000;
+ while ( ! fs.existsSync( pauseFile ) && Date.now() < deadline ) {
+ if ( null !== child.exitCode ) {
+ throw new Error( 'One-shot input fixture exited before opening its descriptor. ' + stderr );
+ }
+ await delay( 10 );
+ }
+ assert( fs.existsSync( pauseFile ), 'One-shot input fixture did not reach its descriptor pause.' );
+ fs.writeFileSync( replacement, Buffer.alloc( MAX_INPUT_BYTES + 1, 0x61 ) );
+ fs.renameSync( replacement, input );
+ fs.unlinkSync( pauseFile );
+ const outcome = await waitForExit( child, 10000 );
+ assert( 0 !== outcome.code, 'Replaced one-shot input was accepted.' );
+ const result = JSON.parse( stdout.trim() );
+ assert( 'input-file-changed' === result.failureClass, 'One-shot input replacement was not rejected as a changed snapshot.' );
+ assert( false === result.oracle.available, 'One-shot input replacement launched Chrome before rejection.' );
+ assert(
+ ! Object.hasOwn( result.oracle.transport, 'supervisorPid' ) &&
+ ! Object.hasOwn( result.oracle.transport, 'runtimeRoot' ),
+ 'One-shot input replacement created runtime ownership resources.'
+ );
+
+ if ( 'win32' !== process.platform ) {
+ const fifo = path.join( temporaryDirectory, 'one-shot-input.fifo' );
+ const created = spawnSync( 'mkfifo', [ fifo ], { encoding: 'utf8', timeout: 2000 } );
+ assert( ! created.error && 0 === created.status, 'Could not create the FIFO input fixture.' );
+ const rejected = spawnSync(
+ process.execPath,
+ [ SCRIPT, '--mode', 'full-document', '--input', fifo ],
+ { encoding: 'utf8', timeout: 2000 }
+ );
+ assert( ! rejected.error, 'FIFO input without a writer was not rejected within two seconds.' );
+ assert( 0 !== rejected.status, 'FIFO input was accepted.' );
+ const fifoResult = JSON.parse( rejected.stdout.trim() );
+ assert( 'input-byte-limit-exceeded' === fifoResult.failureClass, 'FIFO input had the wrong failure class.' );
+ assert( false === fifoResult.oracle.available, 'FIFO input launched Chrome before rejection.' );
+ }
+}
+
+async function assertRealStdoutWriteDeadline() {
+ const child = spawn(
+ process.execPath,
+ [ SCRIPT, '--serve' ],
+ {
+ stdio: [ 'pipe', 'pipe', 'pipe' ],
+ env: { ...process.env, HTML_API_FUZZ_CHROME_TEST_ALLOW_INTERNAL_COMMANDS: '1' },
+ }
+ );
+ let stderr = '';
+ child.stderr.on( 'data', ( chunk ) => { stderr += chunk.toString( 'utf8' ); } );
+ let ready = null;
+ try {
+ const version = waitForReady( child );
+ child.stdin.write( JSON.stringify( { command: 'version' } ) + '\n' );
+ ready = await version;
+ assert( 'ok' === ready.status, 'Persistent stdio fixture did not start Chrome.' );
+ child.stdout.removeAllListeners( 'data' );
+ child.stdout.pause();
+ child.stdin.write( JSON.stringify( { command: 'test-large-response' } ) + '\n' );
+ const outcome = await waitForExit( child, 20000 );
+ assert( 0 !== outcome.code, 'Blocked real stdout did not fail the service after its write deadline.' );
+ await waitForPidGone( ready.oracle.transport.supervisorPid );
+ await waitForPidGone( ready.oracle.transport.browserPid );
+ await waitForPathGone( ready.oracle.transport.runtimeRoot );
+ } catch ( error ) {
+ error.message += '\nstderr: ' + stderr;
+ throw error;
+ } finally {
+ if ( null === child.exitCode && null === child.signalCode ) {
+ child.kill( 'SIGTERM' );
+ await waitForExit( child, 20000 ).catch( () => child.kill( 'SIGKILL' ) );
+ }
+ child.stdout.destroy();
+ }
+}
+
+function assertRuntimeManifestStrictness( temporaryDirectory ) {
+ const { manifestDigest } = require( SCRIPT );
+ const manifest = path.join( temporaryDirectory, 'runtime-manifest' );
+ const key = 'platform.executable';
+ const digest = 'a'.repeat( 64 );
+ fs.writeFileSync( manifest, digest + ' ' + key + '\n' );
+ assert( digest === manifestDigest( manifest, key ), 'Valid runtime manifest entry was rejected.' );
+ for ( const contents of [
+ digest + ' ' + key + '\n' + digest + ' ' + key + ' extra\n',
+ digest + ' ' + key + ' extra\n',
+ 'A'.repeat( 64 ) + ' ' + key + '\n',
+ ] ) {
+ fs.writeFileSync( manifest, contents );
+ let rejected = false;
+ try {
+ manifestDigest( manifest, key );
+ } catch ( _error ) {
+ rejected = true;
+ }
+ assert( rejected, 'Malformed or duplicate runtime manifest record was accepted.' );
+ }
+}
+
+function assertCdpWebSocketProtocol() {
+ const { CdpWebSocket } = require( SCRIPT );
+ class FakeSocket extends EventEmitter {
+ constructor() {
+ super();
+ this.destroyed = false;
+ this.ended = false;
+ }
+ write() {}
+ end() {
+ this.ended = true;
+ }
+ destroy() {
+ this.destroyed = true;
+ }
+ }
+ const frame = ( final, opcode, payload, options = {} ) => {
+ payload = Buffer.from( payload );
+ const first = ( final ? 0x80 : 0 ) | ( options.rsv || 0 ) | opcode;
+ let header;
+ if ( payload.length < 126 ) {
+ header = Buffer.from( [ first, ( options.masked ? 0x80 : 0 ) | payload.length ] );
+ } else {
+ header = Buffer.alloc( 4 );
+ header[ 0 ] = first;
+ header[ 1 ] = ( options.masked ? 0x80 : 0 ) | 126;
+ header.writeUInt16BE( payload.length, 2 );
+ }
+ if ( ! options.masked ) {
+ return Buffer.concat( [ header, payload ] );
+ }
+ const mask = Buffer.from( [ 1, 2, 3, 4 ] );
+ const masked = Buffer.from( payload );
+ for ( let index = 0; index < masked.length; index++ ) {
+ masked[ index ] ^= mask[ index % 4 ];
+ }
+ return Buffer.concat( [ header, mask, masked ] );
+ };
+ const assertInvalid = ( frames, label, maxBytes = 256 ) => {
+ const socket = new FakeSocket();
+ const websocket = new CdpWebSocket( socket, Buffer.alloc( 0 ), maxBytes );
+ let failure = null;
+ websocket.onClose( ( error ) => {
+ failure = error;
+ } );
+ for ( const encoded of frames ) {
+ if ( ! socket.destroyed ) {
+ websocket.consume( encoded );
+ }
+ }
+ assert( socket.destroyed && failure, label + ' did not terminate the CDP channel.' );
+ assert( 'oracle-infrastructure-failure' === failure.failureClass, label + ' was not infrastructure failure.' );
+ assert( failure.invalidateSession && ! failure.recoverableSessionDeath, label + ' was incorrectly recoverable.' );
+ return websocket;
+ };
+ const socket = new FakeSocket();
+ const websocket = new CdpWebSocket( socket, Buffer.alloc( 0 ), 16 );
+ websocket.consume( frame( false, 0x1, Buffer.alloc( 10, 0x61 ) ) );
+ assert( ! socket.destroyed, 'First bounded CDP fragment was rejected.' );
+ websocket.consume( frame( false, 0x0, Buffer.alloc( 7, 0x62 ) ) );
+ assert( socket.destroyed, 'Fragmented CDP input crossed its cumulative cap without immediate rejection.' );
+ assert( websocket.fragments.length <= 1, 'Crossing CDP fragment was retained before rejection.' );
+
+ for ( const [ frames, label ] of [
+ [ [ frame( true, 0x1, '{}', { masked: true } ) ], 'masked server frame' ],
+ [ [ frame( true, 0x1, '{}', { rsv: 0x40 } ) ], 'RSV server frame' ],
+ [ [ frame( true, 0x3, '{}' ) ], 'unknown opcode' ],
+ [ [ frame( true, 0x0, '{}' ) ], 'orphan continuation' ],
+ [ [ frame( false, 0x1, '{' ), frame( true, 0x1, '}' ) ], 'interrupted fragmented message' ],
+ [ [ frame( false, 0x9, 'x' ) ], 'fragmented control frame' ],
+ [ [ frame( true, 0x9, Buffer.alloc( 126 ) ) ], 'oversized control frame' ],
+ [ [ frame( true, 0x1, Buffer.from( [ 0xc3, 0x28 ] ) ) ], 'invalid text UTF-8' ],
+ [ [ frame( true, 0x2, '{}' ) ], 'binary CDP message' ],
+ [ [ frame( true, 0x8, Buffer.from( [ 0x03, 0xe7 ] ) ) ], 'out-of-range close status' ],
+ [ [ frame( true, 0x8, Buffer.from( [ 0x03, 0xed ] ) ) ], 'reserved close status' ],
+ ] ) {
+ assertInvalid( frames, label );
+ }
+
+ const validSocket = new FakeSocket();
+ const valid = new CdpWebSocket( validSocket, Buffer.alloc( 0 ), 256 );
+ let message = null;
+ valid.onMessage( ( value ) => {
+ message = value;
+ } );
+ valid.consume( Buffer.concat( [
+ frame( false, 0x1, 'ab' ),
+ frame( true, 0x9, 'ping' ),
+ frame( true, 0x0, 'cd' ),
+ ] ) );
+ assert( ! validSocket.destroyed && 'abcd' === message, 'Valid fragmented text with interleaved ping was rejected.' );
+ const closePayload = Buffer.alloc( 2 );
+ closePayload.writeUInt16BE( 1000 );
+ valid.consume( frame( true, 0x8, closePayload ) );
+ assert( validSocket.ended && valid.closed && ! validSocket.destroyed, 'Valid WebSocket close status was rejected.' );
+
+ const closingSocket = new FakeSocket();
+ const closing = new CdpWebSocket( closingSocket, Buffer.alloc( 0 ), 256 );
+ let postCloseMessages = 0;
+ closing.onMessage( () => {
+ postCloseMessages++;
+ } );
+ closing.consume( Buffer.concat( [ frame( true, 0x8, closePayload ), frame( true, 0x1, '{}' ) ] ) );
+ closing.consume( frame( true, 0x1, '{}' ) );
+ assert( closing.closed && closingSocket.ended, 'Server close did not immediately terminate the CDP channel.' );
+ assert( 0 === postCloseMessages, 'CDP text was delivered after a server close frame.' );
+ let closedSendError = null;
+ try {
+ closing.sendJson( { id: 1 } );
+ } catch ( error ) {
+ closedSendError = error;
+ }
+ assert(
+ closedSendError === closing.closeError && closedSendError.transportFailure && closedSendError.recoverableSessionDeath,
+ 'Send-on-closed CDP channel lost its authenticated session-death classification.'
+ );
+ const errorSocket = new FakeSocket();
+ const errored = new CdpWebSocket( errorSocket, Buffer.alloc( 0 ), 256 );
+ let socketFailure = null;
+ errored.onClose( ( error ) => {
+ socketFailure = error;
+ } );
+ errorSocket.emit( 'error', new Error( 'synthetic I/O death' ) );
+ errorSocket.emit( 'close' );
+ let erroredSend = null;
+ try {
+ errored.sendJson( { id: 2 } );
+ } catch ( error ) {
+ erroredSend = error;
+ }
+ assert(
+ socketFailure === errored.closeError &&
+ erroredSend === socketFailure &&
+ socketFailure.transportFailure &&
+ socketFailure.recoverableSessionDeath &&
+ ! socketFailure.invalidateSession,
+ 'CDP socket I/O death was not retained as recoverable closed-session evidence.'
+ );
+}
+
+function assertSupervisorBackpressure() {
+ const { createSupervisorEmitter } = require( SCRIPT );
+ const output = new EventEmitter();
+ output.destroyed = false;
+ output.frames = [];
+ output.write = ( frame ) => {
+ output.frames.push( frame );
+ return 1 !== output.frames.length;
+ };
+ const emitter = createSupervisorEmitter( output );
+ assert( emitter.emit( { event: 'ready' } ), 'Initial critical supervisor event was not emitted.' );
+ assert( emitter.isBackpressured(), 'Supervisor emitter did not record stdout backpressure.' );
+ assert( ! emitter.emit( { event: 'processes' }, true ), 'Lossy heartbeat was queued during backpressure.' );
+ assert( 1 === output.frames.length, 'Backpressured heartbeat grew the stdout queue.' );
+ assert( emitter.emit( { event: 'browser-exit' } ), 'Bounded critical event was dropped during backpressure.' );
+ assert( 2 === output.frames.length, 'Critical event count mismatch during backpressure.' );
+ output.emit( 'drain' );
+ assert( emitter.emit( { event: 'processes' }, true ), 'Heartbeat did not resume after stdout drain.' );
+ assert( 3 === output.frames.length, 'Post-drain heartbeat was not emitted.' );
+ const oversized = { event: 'processes', payload: 'x'.repeat( 1024 * 1024 ) };
+ assert( ! emitter.emit( oversized, true ), 'Oversized lossy supervisor event was accepted.' );
+ let criticalRejected = false;
+ try {
+ emitter.emit( oversized );
+ } catch ( error ) {
+ criticalRejected = error.message.includes( 'exceeded 1 MiB' );
+ }
+ assert( criticalRejected, 'Oversized critical supervisor event was not rejected.' );
+}
+
+async function assertSupervisorProtocolValidation() {
+ const { ChromeOracle } = require( SCRIPT );
+ const startupReject = ( frame, label ) => {
+ const oracle = new ChromeOracle( {} );
+ const supervisor = {};
+ oracle.supervisor = supervisor;
+ let readyFailure = null;
+ let startupFailure = null;
+ const state = {
+ supervisor,
+ processSnapshots: [],
+ active: false,
+ intentionalExit: false,
+ readyEventSeen: false,
+ supervisorProtocolFailure: null,
+ rejectStartup: ( error ) => { startupFailure = error; },
+ };
+ oracle.consumeSupervisorOutput( frame, state, { resolve() {}, reject: ( error ) => { readyFailure = error; } } );
+ assert(
+ readyFailure && readyFailure === startupFailure && readyFailure === state.supervisorProtocolFailure,
+ label + ' did not latch one startup infrastructure failure.'
+ );
+ assert( 'oracle-infrastructure-failure' === readyFailure.failureClass, label + ' was not infrastructure failure.' );
+ assert( 0 === oracle.supervisorEventsBuffer.length, label + ' retained supervisor bytes after rejection.' );
+ };
+ startupReject( Buffer.from( '{"event":"unknown"}\n' ), 'Unknown supervisor event' );
+ startupReject( Buffer.from( '{"event":"cleaned","reason":"unexpected"}\n' ), 'Pre-healthy supervisor cleaned event' );
+ startupReject(
+ Buffer.from( JSON.stringify( {
+ event: 'ready', browserPid: 42, executableSha256: 'a'.repeat( 64 ),
+ endpoint: 'ws://127.0.0.1/x',
+ processes: [ { pid: 42, ppid: 1, birth: 'synthetic', command: 'chrome' } ],
+ surplus: true,
+ } ) + '\n' ),
+ 'Surplus supervisor ready field'
+ );
+ {
+ const oracle = new ChromeOracle( {} );
+ const supervisor = {};
+ oracle.supervisor = supervisor;
+ let startupFailure = null;
+ const state = {
+ supervisor,
+ processSnapshots: [],
+ active: false,
+ intentionalExit: false,
+ readyEventSeen: true,
+ supervisorProtocolFailure: null,
+ rejectStartup: ( error ) => { startupFailure = error; },
+ };
+ oracle.consumeSupervisorOutput(
+ Buffer.from( '{"event":"startup-error","error":"synthetic late failure"}\n' ),
+ state,
+ { resolve() {}, reject() {} }
+ );
+ assert(
+ startupFailure && startupFailure === state.supervisorExit,
+ 'Startup-error after ready did not reject the still-inactive startup.'
+ );
+ }
+
+ const activeReject = async ( frame, label ) => {
+ const oracle = new ChromeOracle( {} );
+ const supervisor = {};
+ oracle.supervisor = supervisor;
+ let fatalFailure = null;
+ oracle.handleSupervisorInfrastructureFailure = async ( error ) => { fatalFailure = error; };
+ const state = {
+ supervisor,
+ processSnapshots: [],
+ active: true,
+ intentionalExit: false,
+ readyEventSeen: true,
+ supervisorProtocolFailure: null,
+ rejectStartup() {},
+ };
+ oracle.consumeSupervisorOutput( frame, state, { resolve() {}, reject() {} } );
+ await delay( 0 );
+ assert(
+ fatalFailure && fatalFailure === state.supervisorProtocolFailure,
+ label + ' did not trigger fatal active-service teardown.'
+ );
+ assert( 0 === oracle.supervisorEventsBuffer.length, label + ' retained an unbounded supervisor frame.' );
+ };
+ await activeReject( Buffer.from( '{bad json}\n' ), 'Malformed active supervisor JSON' );
+ await activeReject( Buffer.alloc( 1024 * 1024 + 1, 0x78 ), 'Oversized active supervisor frame' );
+}
+
+function assertSnapshotRetentionBound() {
+ const { retainProcessSnapshot } = require( SCRIPT );
+ const launchState = { processSnapshots: [] };
+ let activeSnapshots = [];
+ for ( let index = 0; index < 20; index++ ) {
+ activeSnapshots = retainProcessSnapshot( launchState, [ { pid: index } ] );
+ }
+ assert( 8 === launchState.processSnapshots.length, 'Launch-state supervisor snapshots grew beyond eight.' );
+ assert( 8 === activeSnapshots.length, 'Active supervisor snapshots grew beyond eight.' );
+ assert( 12 === launchState.processSnapshots[ 0 ][ 0 ].pid, 'Supervisor snapshot retention did not keep the newest eight.' );
+}
+
+async function assertBoundedPeerAndCleanupInfrastructure( temporaryDirectory ) {
+ const {
+ ChromeOracle,
+ FramePeer,
+ processIdentity,
+ readProcessTable,
+ reapAndRemoveRuntimeResources,
+ reapAuthenticatedTree,
+ serializedDispatcher,
+ writeJsonFrame,
+ } = require( SCRIPT );
+ class FakeStream extends EventEmitter {
+ constructor( writable = true ) {
+ super();
+ this.destroyed = false;
+ this.writable = writable;
+ this.frames = [];
+ }
+ pause() {}
+ resume() {}
+ write( frame ) {
+ this.frames.push( frame );
+ return true;
+ }
+ end() {
+ this.destroyed = true;
+ this.emit( 'close' );
+ }
+ destroy() {
+ this.destroyed = true;
+ this.emit( 'close' );
+ }
+ }
+
+ let releaseDispatch;
+ const dispatchGate = new Promise( ( resolve ) => {
+ releaseDispatch = resolve;
+ } );
+ const dispatcher = serializedDispatcher();
+ const blocker = dispatcher( () => dispatchGate );
+ let renders = 0;
+ const input = new FakeStream();
+ const output = new FakeStream();
+ const peer = new FramePeer(
+ input,
+ output,
+ { metadata: () => ( {} ), render: async () => { renders++; return { status: 'ok' }; } },
+ dispatcher,
+ { singleFrame: true, cancelOnDisconnect: true, readTimeoutMs: 1000, writeTimeoutMs: 1000 }
+ );
+ input.emit( 'data', Buffer.from( '{"command":"render","htmlBase64":"","mode":"fragment-body","context":"body"}\n' ) );
+ input.emit( 'close' );
+ releaseDispatch();
+ await blocker;
+ await peer.processing;
+ assert( 0 === renders, 'Disconnected queued socket request reached the oracle dispatcher.' );
+
+ const idleInput = new FakeStream();
+ const idlePeer = new FramePeer(
+ idleInput,
+ new FakeStream(),
+ { metadata: () => ( {} ) },
+ serializedDispatcher(),
+ { closeOnOversize: true, singleFrame: true, readTimeoutMs: 20, writeTimeoutMs: 20 }
+ );
+ await Promise.race( [
+ idlePeer.closedPromise,
+ delay( 200 ).then( () => { throw new Error( 'Idle socket frame deadline did not close its peer.' ); } ),
+ ] );
+ const defaultDeadlinePeer = new FramePeer(
+ new FakeStream(),
+ new FakeStream(),
+ { metadata: () => ( {} ) },
+ serializedDispatcher()
+ );
+ assert( 10000 === defaultDeadlinePeer.writeTimeoutMs, 'Persistent stdio peer did not inherit a finite write deadline.' );
+ defaultDeadlinePeer.finish();
+
+ const closedOutput = new FakeStream();
+ closedOutput.write = () => false;
+ const closedWrite = writeJsonFrame( closedOutput, { status: 'ok' } );
+ setTimeout( () => closedOutput.emit( 'close' ), 10 );
+ let closedWriteError = null;
+ try {
+ await closedWrite;
+ } catch ( error ) {
+ closedWriteError = error;
+ }
+ assert( 'HTML_API_FUZZ_OUTPUT_CLOSED' === closedWriteError?.code, 'Backpressured close did not settle the response writer.' );
+ const timedOutput = new FakeStream();
+ timedOutput.write = () => false;
+ let timedWriteError = null;
+ try {
+ await writeJsonFrame( timedOutput, { status: 'ok' }, 20 );
+ } catch ( error ) {
+ timedWriteError = error;
+ }
+ assert( 'HTML_API_FUZZ_OUTPUT_TIMEOUT' === timedWriteError?.code, 'Backpressured response did not honor its drain deadline.' );
+ assert( timedOutput.destroyed, 'Timed-out response stream retained its queued write resource.' );
+
+ const profilePath = '/tmp/html-api-fuzz-synthetic-profile';
+ const token = 'synthetic-owner-token';
+ const table = [];
+ for ( let index = 0; index < 20; index++ ) {
+ table.push( {
+ pid: 900000 + index,
+ ppid: 0 === index ? 1 : 900000,
+ start: 'Mon Jan 1 00:00:00 2024',
+ command: 0 === index
+ ? 'chrome --user-data-dir=' + profilePath + ' --html-api-fuzz-owner-token=' + token
+ : 'chrome helper ' + index,
+ } );
+ }
+ const snapshots = [ table.map( processIdentity ) ];
+ let tableLive = true;
+ let tableCalls = 0;
+ let termSignals = 0;
+ await reapAuthenticatedTree( snapshots, profilePath, token, {
+ tableReader: () => {
+ tableCalls++;
+ return tableLive ? table : [];
+ },
+ signalProcess: ( _pid, signal ) => {
+ if ( 'SIGTERM' === signal && ++termSignals === table.length ) {
+ tableLive = false;
+ }
+ },
+ totalTimeoutMs: 200,
+ termTimeoutMs: 100,
+ pollMilliseconds: 5,
+ } );
+ assert( table.length === termSignals, 'Synthetic multi-descendant cleanup missed a process.' );
+ assert( tableCalls <= 3, 'Cleanup inspected the process table per PID instead of per pass.' );
+ let inspectionFailure = null;
+ try {
+ await reapAuthenticatedTree( [], profilePath, token, {
+ tableReader: () => { throw new Error( 'synthetic ps failure' ); },
+ totalTimeoutMs: 50,
+ } );
+ } catch ( error ) {
+ inspectionFailure = error;
+ }
+ assert( inspectionFailure?.message.includes( 'synthetic ps failure' ), 'Process-inspection failure did not fail cleanup closed.' );
+ const psStart = Date.now();
+ try {
+ readProcessTable( 1 );
+ } catch ( _error ) {}
+ assert( Date.now() - psStart < 500, 'Synchronous ps inspection ignored its supplied subprocess timeout.' );
+ const delayedStart = Date.now();
+ let deadlineFailure = null;
+ try {
+ await reapAuthenticatedTree( [], profilePath, token, {
+ tableReader: ( remaining ) => {
+ const waitUntil = Date.now() + remaining + 10;
+ while ( Date.now() < waitUntil ) {}
+ return [];
+ },
+ totalTimeoutMs: 25,
+ } );
+ } catch ( error ) {
+ deadlineFailure = error;
+ }
+ assert( deadlineFailure?.message.includes( 'deadline' ), 'Delayed process inspection escaped the absolute cleanup deadline.' );
+ assert( Date.now() - delayedStart < 80, 'Synchronous process inspection exceeded the synthetic wall bound.' );
+
+ const failedReapRuntime = path.join( temporaryDirectory, 'retained-failed-reap-runtime' );
+ fs.mkdirSync( failedReapRuntime );
+ let failedReap = null;
+ try {
+ await reapAndRemoveRuntimeResources( [], profilePath, token, failedReapRuntime, '', {
+ tableReader: () => { throw new Error( 'synthetic retained tree' ); },
+ } );
+ } catch ( error ) {
+ failedReap = error;
+ }
+ assert( failedReap?.message.includes( 'synthetic retained tree' ), 'Failed authenticated reap was not reported.' );
+ assert( fs.existsSync( failedReapRuntime ), 'Runtime was removed after authenticated tree reaping failed.' );
+ fs.rmSync( failedReapRuntime, { recursive: true, force: true } );
+
+ const retainedRuntime = path.join( temporaryDirectory, 'retained-live-supervisor-runtime' );
+ fs.mkdirSync( retainedRuntime );
+ const oracle = new ChromeOracle( {} );
+ const signals = [];
+ oracle.waitForSupervisorExit = async () => false;
+ let disposeFailure = null;
+ try {
+ await oracle.disposeState( {
+ supervisor: {
+ exitCode: null,
+ signalCode: null,
+ stdin: { destroyed: true },
+ kill: ( signal ) => signals.push( signal ),
+ },
+ runtimeRoot: retainedRuntime,
+ profilePath: null,
+ ownershipToken: null,
+ processSnapshots: [],
+ websocket: null,
+ cdp: null,
+ targetId: null,
+ }, false );
+ } catch ( error ) {
+ disposeFailure = error;
+ }
+ assert( 'oracle-infrastructure-failure' === disposeFailure?.failureClass, 'Surviving supervisor cleanup was not infrastructure failure.' );
+ assert( 'SIGTERM,SIGKILL' === signals.join( ',' ), 'Surviving supervisor did not receive bounded TERM/KILL escalation.' );
+ assert( fs.existsSync( retainedRuntime ), 'Runtime was removed underneath a supervisor reported alive.' );
+ fs.rmSync( retainedRuntime, { recursive: true, force: true } );
+}
+
+async function assertCdpEnvelopeValidation() {
+ const { CdpClient } = require( SCRIPT );
+ const assertInvalid = async ( payload, label, sessionId = undefined ) => {
+ let terminated = null;
+ const websocket = {
+ onMessage() {},
+ onClose() {},
+ sendJson() {},
+ terminate( error ) {
+ terminated = error;
+ },
+ };
+ const cdp = new CdpClient( websocket );
+ const pending = cdp.send( 'Browser.getVersion', {}, sessionId, 1000 );
+ cdp.receive( payload );
+ let rejected = null;
+ try {
+ await pending;
+ } catch ( error ) {
+ rejected = error;
+ }
+ assert( rejected && rejected === terminated, label + ' did not terminate and reject the CDP channel consistently.' );
+ assert( 'oracle-infrastructure-failure' === rejected.failureClass, label + ' was not infrastructure failure.' );
+ assert( rejected.transportFailure && rejected.invalidateSession, label + ' did not invalidate the CDP session.' );
+ assert( ! rejected.recoverableSessionDeath, label + ' was incorrectly marked recoverable.' );
+ };
+ for ( const [ payload, label ] of [
+ [ '{', 'invalid JSON' ],
+ [ 'null', 'null envelope' ],
+ [ '1', 'primitive envelope' ],
+ [ '[]', 'array envelope' ],
+ [ '{"id":0,"result":{}}', 'invalid response id' ],
+ [ '{"id":1,"result":{},"error":{"code":-1,"message":"x"}}', 'ambiguous response' ],
+ [ '{"id":1,"result":false}', 'scalar result' ],
+ [ '{"id":1,"error":{"code":"-1","message":"x"}}', 'malformed error' ],
+ [ '{"method":"Page.event","params":false}', 'scalar event params' ],
+ [ '{"id":1,"result":{},"extra":true}', 'unknown envelope field' ],
+ [ '{"id":2,"result":{}}', 'unknown response id' ],
+ [ '{"id":1,"result":{},"sessionId":"unexpected"}', 'unexpected browser response session' ],
+ ] ) {
+ await assertInvalid( payload, label );
+ }
+ await assertInvalid( '{"id":1,"result":{}}', 'missing session response route', 'expected-session' );
+ await assertInvalid( '{"id":1,"result":{},"sessionId":"wrong-session"}', 'wrong session response route', 'expected-session' );
+
+ let terminated = null;
+ const websocket = {
+ onMessage() {},
+ onClose() {},
+ sendJson() {},
+ terminate( error ) {
+ terminated = error;
+ },
+ };
+ const cdp = new CdpClient( websocket );
+ const valid = cdp.send( 'Browser.getVersion', {}, undefined, 1000 );
+ cdp.receive( '{"id":1,"result":{"product":"test"}}' );
+ assert( 'test' === ( await valid ).product, 'Valid CDP response did not resolve exactly.' );
+ cdp.receive( '{"id":1,"result":{"product":"duplicate"}}' );
+ assert( terminated && 'oracle-infrastructure-failure' === terminated.failureClass, 'Duplicate CDP response id did not terminate the channel.' );
+
+ const sessionWebsocket = {
+ onMessage() {},
+ onClose() {},
+ sendJson() {},
+ };
+ const sessionCdp = new CdpClient( sessionWebsocket );
+ const validSession = sessionCdp.send( 'Runtime.evaluate', {}, 'expected-session', 1000 );
+ sessionCdp.receive( '{"id":1,"result":{"value":1},"sessionId":"expected-session"}' );
+ assert( 1 === ( await validSession ).value, 'Valid session-routed CDP response did not resolve exactly.' );
+}
+
+async function assertTimeoutDoesNotReplay() {
+ const { CdpClient, ChromeOracle } = require( SCRIPT );
+ let sends = 0;
+ const websocket = {
+ onMessage() {},
+ onClose() {},
+ sendJson() {
+ sends++;
+ },
+ };
+ const cdp = new CdpClient( websocket );
+ let timeout;
+ try {
+ await cdp.send( 'Runtime.evaluate', {}, 'session', 5 );
+ } catch ( error ) {
+ timeout = error;
+ }
+ assert( timeout, 'Synthetic Runtime.evaluate did not time out.' );
+ assert( 'oracle-evaluation-timeout' === timeout.failureClass, 'Evaluation timeout classification mismatch.' );
+ assert( ! timeout.transportFailure && ! timeout.recoverableSessionDeath, 'Live evaluation timeout was marked recoverable.' );
+ assert( 1 === sends, 'CDP timeout sent more than one command.' );
+
+ const oracle = new ChromeOracle( {} );
+ oracle.validateRequest = () => ( {} );
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ let startCount = 0;
+ let startCalls = 0;
+ oracle.start = async () => {
+ startCalls++;
+ if ( ! oracle.healthy ) {
+ startCount++;
+ oracle.healthy = true;
+ oracle.supervisor = {};
+ }
+ };
+ let resetCount = 0;
+ oracle.resetState = async () => {
+ resetCount++;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ };
+ let renderCount = 0;
+ oracle.renderNow = async () => {
+ renderCount++;
+ throw timeout;
+ };
+ let rejected = false;
+ try {
+ await oracle.renderWithRecovery( {} );
+ } catch ( error ) {
+ rejected = error === timeout;
+ }
+ assert( rejected, 'Evaluation timeout was not returned unchanged.' );
+ assert( 1 === renderCount, 'Evaluation timeout replayed the render.' );
+ assert( 1 === resetCount, 'Evaluation timeout did not tear down its session exactly once.' );
+ assert( 1 === startCount, 'Cold evaluation timeout started more than one session.' );
+ assert( 1 === startCalls, 'Evaluation timeout crossed a redundant start boundary.' );
+
+ const infrastructure = new Error( 'synthetic live infrastructure failure' );
+ infrastructure.transportFailure = true;
+ renderCount = 0;
+ resetCount = 0;
+ startCount = 0;
+ startCalls = 0;
+ oracle.healthy = true;
+ oracle.supervisor = {};
+ oracle.lastTransportError = null;
+ oracle.renderNow = async () => {
+ renderCount++;
+ throw infrastructure;
+ };
+ rejected = false;
+ try {
+ await oracle.renderWithRecovery( {} );
+ } catch ( error ) {
+ rejected = error === infrastructure;
+ }
+ assert( rejected, 'Unconfirmed infrastructure failure was not returned unchanged.' );
+ assert( 1 === renderCount, 'Unconfirmed infrastructure failure replayed the render.' );
+ assert( 0 === resetCount, 'Unconfirmed infrastructure failure was treated as an authenticated dead session.' );
+ assert( 1 === startCalls, 'Live infrastructure failure crossed a redundant start boundary.' );
+
+ const sessionDeath = new Error( 'synthetic authenticated CDP session death' );
+ sessionDeath.transportFailure = true;
+ sessionDeath.recoverableSessionDeath = true;
+ renderCount = 0;
+ resetCount = 0;
+ startCount = 0;
+ startCalls = 0;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ oracle.resetState = async () => {
+ resetCount++;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ };
+ oracle.renderNow = async () => {
+ renderCount++;
+ if ( 1 === renderCount ) {
+ throw sessionDeath;
+ }
+ return { status: 'ok' };
+ };
+ const recovered = await oracle.renderWithRecovery( {} );
+ assert( 'ok' === recovered.status, 'Observed session death did not recover.' );
+ assert( 2 === renderCount && 1 === resetCount, 'Observed session death did not retry exactly once.' );
+ assert( 2 === startCount, 'Cold observed session death did not create exactly one replacement session.' );
+ assert( 2 === startCalls, 'Observed session death did not use one start boundary per attempt.' );
+
+ const protocolFailure = new Error( 'synthetic trusted renderer protocol failure' );
+ protocolFailure.transportFailure = true;
+ protocolFailure.invalidateSession = true;
+ protocolFailure.failureClass = 'oracle-infrastructure-failure';
+ const secondSessionDeath = new Error( 'synthetic second authenticated session death' );
+ secondSessionDeath.transportFailure = true;
+ secondSessionDeath.recoverableSessionDeath = true;
+ const correlatedTransportFailure = new Error( 'synthetic retry WebSocket reset' );
+ correlatedTransportFailure.transportFailure = true;
+ for ( const [ finalFailure, label, correlateDeath ] of [
+ [ timeout, 'timeout', false ],
+ [ protocolFailure, 'protocol failure', false ],
+ [ secondSessionDeath, 'session death', false ],
+ [ correlatedTransportFailure, 'correlated transport death', true ],
+ ] ) {
+ renderCount = 0;
+ resetCount = 0;
+ startCount = 0;
+ startCalls = 0;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ oracle.lastTransportError = null;
+ oracle.resetState = async () => {
+ resetCount++;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ oracle.lastTransportError = null;
+ };
+ oracle.renderNow = async () => {
+ renderCount++;
+ if ( 1 === renderCount ) {
+ throw sessionDeath;
+ }
+ if ( correlateDeath ) {
+ setTimeout( () => {
+ oracle.lastTransportError = secondSessionDeath;
+ }, 10 );
+ }
+ throw finalFailure;
+ };
+ let finalRejected = null;
+ try {
+ await oracle.renderWithRecovery( {} );
+ } catch ( error ) {
+ finalRejected = error;
+ }
+ assert( finalRejected === finalFailure, 'Second-attempt ' + label + ' was not propagated unchanged.' );
+ assert( 2 === renderCount, 'Second-attempt ' + label + ' caused a third render.' );
+ assert( 2 === startCount && 2 === startCalls, 'Second-attempt ' + label + ' crossed the wrong start boundaries.' );
+ assert( 2 === resetCount, 'Second-attempt ' + label + ' did not quarantine both failed sessions.' );
+ assert( ! oracle.healthy && null === oracle.supervisor, 'Second-attempt ' + label + ' retained a failed session.' );
+ }
+
+ const cleanupFailure = new Error( 'synthetic authenticated cleanup failure' );
+ renderCount = 0;
+ startCount = 0;
+ startCalls = 0;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ oracle.lastTransportError = null;
+ oracle.resetState = async () => {
+ throw cleanupFailure;
+ };
+ oracle.renderNow = async () => {
+ renderCount++;
+ throw sessionDeath;
+ };
+ let cleanupRejected = null;
+ try {
+ await oracle.renderWithRecovery( {} );
+ } catch ( error ) {
+ cleanupRejected = error;
+ }
+ assert( cleanupRejected instanceof AggregateError, 'Session-death cleanup failure did not preserve both errors.' );
+ assert( 'oracle-infrastructure-failure' === cleanupRejected.failureClass, 'Cleanup failure was not infrastructure-classified.' );
+ assert( cleanupRejected.errors.includes( sessionDeath ) && cleanupRejected.errors.includes( cleanupFailure ), 'Cleanup aggregate lost its causes.' );
+ assert( 1 === renderCount && 1 === startCalls, 'Cleanup failure started a retry after incomplete teardown.' );
+
+ const supervisorFailureOracle = new ChromeOracle( {} );
+ const supervisorCleanupFailure = new Error( 'synthetic supervisor-exit cleanup failure' );
+ let fatalError = null;
+ supervisorFailureOracle.resetState = async () => {
+ throw supervisorCleanupFailure;
+ };
+ supervisorFailureOracle.setFatalHandler( async ( error ) => {
+ fatalError = error;
+ } );
+ await supervisorFailureOracle.handleUnexpectedSupervisorExit( 9, null );
+ assert( fatalError instanceof AggregateError, 'Supervisor cleanup failure did not preserve both errors.' );
+ assert( fatalError.transportFailure && 'oracle-infrastructure-failure' === fatalError.failureClass, 'Supervisor cleanup failure was not infrastructure-classified.' );
+ assert( fatalError.errors.includes( supervisorCleanupFailure ), 'Supervisor cleanup aggregate lost its cleanup cause.' );
+}
+
+async function assertRendererOutputValidation() {
+ const { ChromeOracle } = require( SCRIPT );
+ const validRendered = {
+ status: 'ok',
+ treeBase64: 'Cg==',
+ treeBytes: 1,
+ nodeCount: 0,
+ };
+ const assertInvalid = async ( responses, securityAudit, label ) => {
+ const oracle = new ChromeOracle( {} );
+ const validated = {
+ html: '',
+ mode: 'fragment-body',
+ context: 'body',
+ limits: { maxNodes: 10, maxDepth: 10, maxTreeBytes: 1024 },
+ invalidUtf8: false,
+ securityAudit,
+ };
+ let sendCount = 0;
+ let resetCount = 0;
+ let startCount = 0;
+ oracle.validateRequest = () => validated;
+ oracle.start = async () => {
+ if ( ! oracle.healthy ) {
+ startCount++;
+ oracle.healthy = true;
+ oracle.supervisor = {};
+ }
+ };
+ oracle.cdp = {
+ send: async () => responses[ sendCount++ ],
+ };
+ oracle.sessionId = 'synthetic-session';
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ oracle.resetState = async () => {
+ resetCount++;
+ oracle.healthy = false;
+ oracle.supervisor = null;
+ };
+ let rejected = null;
+ try {
+ await oracle.renderWithRecovery( {} );
+ } catch ( error ) {
+ rejected = error;
+ }
+ assert( rejected && 'oracle-infrastructure-failure' === rejected.failureClass, label + ' was not infrastructure failure.' );
+ assert( rejected.invalidateSession && ! rejected.recoverableSessionDeath, label + ' was incorrectly recoverable.' );
+ assert( 1 === resetCount, label + ' did not tear down its session exactly once.' );
+ assert( 1 === startCount, label + ' did not quarantine a single cold-start session.' );
+ assert( responses.length === sendCount, label + ' replayed input or issued an unexpected CDP command.' );
+ };
+ await assertInvalid( [ { result: { value: null } } ], false, 'Null renderer value' );
+ await assertInvalid( [ { result: { value: { ...validRendered, nodeCount: '0' } } } ], false, 'Malformed renderer counter' );
+ await assertInvalid( [ { result: { value: { ...validRendered, surplus: true } } } ], false, 'Surplus renderer field' );
+ const missingRendererField = { ...validRendered };
+ delete missingRendererField.treeBytes;
+ await assertInvalid( [ { result: { value: missingRendererField } } ], false, 'Missing renderer field' );
+ await assertInvalid( [ { result: { value: {
+ status: 'limit',
+ failureClass: 'node-limit-exceeded',
+ error: 'bounded',
+ nodeCount: 11,
+ treeBytes: 0,
+ surplus: true,
+ } } } ], false, 'Surplus non-success renderer field' );
+ await assertInvalid( [ { result: { value: { ...validRendered, treeBase64: 'Cg==', treeBytes: 2 } } } ], false, 'Inconsistent renderer tree' );
+ await assertInvalid( [ { result: { value: {
+ status: 'error', failureClass: 'invented-renderer-class', error: 'synthetic', nodeCount: 0, treeBytes: 0,
+ } } } ], false, 'Unknown renderer failure class' );
+ await assertInvalid( [ { result: { value: {
+ status: 'limit', failureClass: 'invented-limit-exceeded', error: 'synthetic', nodeCount: 0, treeBytes: 0,
+ } } } ], false, 'Unknown renderer limit class' );
+ await assertInvalid( [ { result: { value: {
+ ...validRendered, treeBase64: '/w==', treeBytes: 1,
+ } } } ], false, 'Invalid UTF-8 renderer tree' );
+ await assertInvalid( [ { exceptionDetails: { text: 'escaped' }, result: {} } ], false, 'Escaped renderer exception' );
+ await assertInvalid( [
+ { result: { value: validRendered } },
+ { result: { value: { authorRan: 'false', activeMarkup: '', resources: [] } } },
+ ], true, 'Malformed security audit' );
+ await assertInvalid( [
+ { result: { value: validRendered } },
+ { result: { value: { authorRan: false, activeMarkup: '', resources: [], surplus: true } } },
+ ], true, 'Surplus security-audit field' );
+ await assertInvalid( [
+ { result: { value: validRendered } },
+ { result: { value: { authorRan: false, activeMarkup: '' } } },
+ ], true, 'Missing security-audit field' );
+}
+
+async function assertCanonicalAndSecurity( service, contexts ) {
+ const fullHtml = 'x' +
+ 't
';
+ const documentResult = await request( service.socketPath, {
+ id: 1,
+ command: 'render',
+ mode: 'full-document',
+ htmlBase64: Buffer.from( fullHtml ).toString( 'base64' ),
+ maxNodes: 1000,
+ maxDepth: 64,
+ maxTreeBytes: 1024 * 1024,
+ } );
+ const expected = '\n\n \n \n \n' +
+ ' a=\"1\"\n b=\"2\"\n \"x\"\n \n
\n' +
+ ' content\n \n \"t\"\n\n';
+ assert( expected === decodeTree( documentResult ), 'Canonical full-document tree mismatch.' );
+ const nonAsciiName = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( ' ' ).toString( 'base64' ),
+ maxNodes: 10,
+ maxDepth: 10,
+ maxTreeBytes: 1024,
+ } );
+ const nonAsciiTree = decodeTree( nonAsciiName );
+ assert(
+ '\n\n' === nonAsciiTree,
+ 'Non-ASCII HTML tag name was Unicode-lowercased: ' + JSON.stringify( nonAsciiTree )
+ );
+ const processingInstruction = await request( service.socketPath, {
+ command: 'test-render-processing-instruction',
+ } );
+ assert(
+ '\n\n' === decodeTree( processingInstruction ),
+ 'Empty processing-instruction data omitted its canonical separating space.'
+ );
+
+ const contextFixtures = [
+ [ 'body', 'x', '
\n "x"\n\n' ],
+ [ 'div', '
x', '
\n "x"\n\n' ],
+ [ 'p', 'x', '\n "x"\n\n' ],
+ [ 'td', 'x', '\n "x"\n\n' ],
+ [ 'tr', '
x', ' \n "x"\n\n' ],
+ [ 'table', ' x', ' \n \n \n "x"\n\n' ],
+ [ 'caption', 'x', '\n "x"\n\n' ],
+ [ 'colgroup', ' ', ' \n\n' ],
+ [ 'select', 'x y', ' \n "x"\n \n "y"\n\n' ],
+ [ 'option', 'x', '"x"\n\n' ],
+ [ 'template', 'x', '\n "x"\n\n' ],
+ [ 'title', '&', '"&"\n\n' ],
+ [ 'textarea', '&', '"&"\n\n' ],
+ [ 'script', '&', '"&"\n\n' ],
+ [ 'style', '&', '"&"\n\n' ],
+ [ 'svg', '', '\n\n' ],
+ [ 'math', 'x', '\n "x"\n\n' ],
+ ];
+ assert(
+ JSON.stringify( contexts ) === JSON.stringify( contextFixtures.map( ( fixture ) => fixture[ 0 ] ) ),
+ 'Exact context fixtures do not cover the ordered context registry.'
+ );
+ for ( const [ context, html, expectedTree ] of contextFixtures ) {
+ const result = await request( service.socketPath, {
+ id: 'context-' + context,
+ command: 'render',
+ mode: 'fragment-body',
+ context,
+ htmlBase64: Buffer.from( html ).toString( 'base64' ),
+ maxNodes: 100,
+ maxDepth: 32,
+ maxTreeBytes: 65536,
+ } );
+ const actualTree = decodeTree( result );
+ assert( expectedTree === actualTree, context + ' contextual fragment tree mismatch: ' + JSON.stringify( actualTree ) );
+ }
+
+ for ( const probe of [
+ { context: 'table', html: 'x y' },
+ { context: 'svg', html: 's ' },
+ { context: 'math', html: 'x y ' },
+ { context: 'body', html: 'x ', noscript: true },
+ ] ) {
+ const result = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: probe.context,
+ htmlBase64: Buffer.from( probe.html ).toString( 'base64' ),
+ maxNodes: 100,
+ maxDepth: 32,
+ maxTreeBytes: 65536,
+ } );
+ const tree = decodeTree( result );
+ if ( probe.noscript ) {
+ assert(
+ '\n \n "x"\n\n' === tree,
+ 'Body-fragment noscript content was not parsed with scripting disabled.'
+ );
+ }
+ }
+ const adjustedSvg = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'svg',
+ htmlBase64: Buffer.from(
+ ' '
+ ).toString( 'base64' ),
+ maxNodes: 100,
+ maxDepth: 32,
+ maxTreeBytes: 65536,
+ } );
+ assert(
+ '\n viewBox="0 0 1 1"\n xlink href="#x"\n xml lang="en"\n xmlns xlink="urn:x"\n\n' === decodeTree( adjustedSvg ),
+ 'SVG context did not preserve namespace and attribute adjustment semantics.'
+ );
+ const headNoscript = await request( service.socketPath, {
+ command: 'render',
+ mode: 'full-document',
+ htmlBase64: Buffer.from(
+ ' '
+ ).toString( 'base64' ),
+ maxNodes: 100,
+ maxDepth: 32,
+ maxTreeBytes: 65536,
+ } );
+ assert(
+ decodeTree( headNoscript ).includes(
+ '\n \n \n content="y"\n name="x"\n'
+ ),
+ 'Head noscript content was not parsed with scripting disabled.'
+ );
+
+ let networkRequests = 0;
+ const network = http.createServer( ( _request, response ) => {
+ networkRequests++;
+ response.end( 'unexpected' );
+ } );
+ network.listen( 0, '127.0.0.1' );
+ await once( network, 'listening' );
+ const port = network.address().port;
+ const hostile = '' +
+ '' +
+ ' ';
+ const security = await request( service.socketPath, {
+ command: 'render',
+ mode: 'full-document',
+ htmlBase64: Buffer.from( hostile ).toString( 'base64' ),
+ maxNodes: 100,
+ maxDepth: 32,
+ maxTreeBytes: 65536,
+ securityAudit: true,
+ } );
+ decodeTree( security );
+ await delay( 200 );
+ assert( false === security.securityAudit.authorRan, 'Author script or event handler executed.' );
+ assert( ! security.securityAudit.activeMarkup.includes( '__htmlApiFuzzAuthorRan' ), 'Author markup entered the active renderer document.' );
+ assert( 0 === security.securityAudit.resources.length, 'Active renderer document loaded a resource.' );
+ assert( 0 === networkRequests, 'Hostile markup reached the network.' );
+ await new Promise( ( resolve ) => network.close( resolve ) );
+}
+
+async function assertProtocolAndLimits( service ) {
+ const invalidUtf8 = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( [ 0xc3, 0x28 ] ).toString( 'base64' ),
+ maxNodes: 10,
+ maxDepth: 10,
+ maxTreeBytes: 1024,
+ } );
+ assert( 'unsupported' === invalidUtf8.status && 'invalid-utf8' === invalidUtf8.failureClass, 'Invalid UTF-8 was not structured unsupported.' );
+ const maximumRawInput = {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.alloc( 2 * 1024 * 1024, 0xff ).toString( 'base64' ),
+ maxNodes: 10,
+ maxDepth: 10,
+ maxTreeBytes: 1024,
+ };
+ assert(
+ Buffer.byteLength( JSON.stringify( maximumRawInput ) + '\n' ) <= MAX_REQUEST_FRAME_BYTES,
+ 'Maximum raw input did not fit the documented request-frame limit.'
+ );
+ const rawBoundary = await request( service.socketPath, maximumRawInput );
+ assert(
+ 'unsupported' === rawBoundary.status && 'invalid-utf8' === rawBoundary.failureClass,
+ 'Exactly 2 MiB of raw input did not reach UTF-8 validation.'
+ );
+ const rawOverflow = await request( service.socketPath, {
+ ...maximumRawInput,
+ htmlBase64: Buffer.alloc( 2 * 1024 * 1024 + 1, 0xff ).toString( 'base64' ),
+ } );
+ assert(
+ 'limit' === rawOverflow.status && 'input-byte-limit-exceeded' === rawOverflow.failureClass,
+ 'Raw input one byte above 2 MiB was not rejected before decoding.'
+ );
+ for ( const encoded of [ 'A', '====', 'YWJj=', 'YW Jj', 'YWJj\\n' ] ) {
+ const invalid = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: encoded,
+ } );
+ assert( 'protocol-error' === invalid.failureClass, 'Invalid base64 was accepted: ' + JSON.stringify( encoded ) );
+ }
+ const unknown = await request( service.socketPath, { command: 'wat' } );
+ assert( 'protocol-error' === unknown.failureClass, 'Unknown command was not a protocol error.' );
+ const extra = await request( service.socketPath, { command: 'version', extra: true } );
+ assert( 'protocol-error' === extra.failureClass, 'Unknown version field was accepted.' );
+ for ( const badRequest of [
+ { command: 'render', id: {}, htmlBase64: '', mode: 'fragment-body', context: 'body' },
+ { command: 'render', id: [], htmlBase64: '', mode: 'fragment-body', context: 'body' },
+ { command: 'render', htmlBase64: '', mode: '', context: 'body' },
+ { command: 'render', htmlBase64: '', mode: 'fragment-body', context: '' },
+ ] ) {
+ const rejected = await request( service.socketPath, badRequest );
+ assert( 'protocol-error' === rejected.failureClass, 'Malformed render request was defaulted or accepted.' );
+ }
+ const recovered = await request( service.socketPath, { command: 'version' } );
+ assert( 'ok' === recovered.status, 'Server did not recover after bad requests.' );
+ const maximumId = 'i'.repeat( 4096 );
+ const maximumIdResult = await request( service.socketPath, { id: maximumId, command: 'version' } );
+ assert( maximumId === maximumIdResult.id, 'Exact 4096-byte request id was not echoed.' );
+ const oversizedId = await request( service.socketPath, { id: maximumId + 'i', command: 'version' } );
+ assert(
+ 'protocol-error' === oversizedId.failureClass && ! Object.hasOwn( oversizedId, 'id' ),
+ 'Oversized string request id was accepted or echoed.'
+ );
+
+ const nodeOkay = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( ' ' ).toString( 'base64' ),
+ maxNodes: 1, maxDepth: 1, maxTreeBytes: 1024,
+ } );
+ decodeTree( nodeOkay );
+ const nodeLimited = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( 'x ' ).toString( 'base64' ),
+ maxNodes: 1, maxDepth: 2, maxTreeBytes: 1024,
+ } );
+ assert(
+ 'limit' === nodeLimited.status &&
+ 'node-limit-exceeded' === nodeLimited.failureClass &&
+ 2 === nodeLimited.nodeCount,
+ 'Node limit failed or did not report maxNodes + 1.'
+ );
+
+ const depthOkay = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( ' ' ).toString( 'base64' ),
+ maxNodes: 3, maxDepth: 2, maxTreeBytes: 1024,
+ } );
+ decodeTree( depthOkay );
+ const depthLimited = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( ' ' ).toString( 'base64' ),
+ maxNodes: 3, maxDepth: 1, maxTreeBytes: 1024,
+ } );
+ assert( 'limit' === depthLimited.status && 'depth-limit-exceeded' === depthLimited.failureClass, 'Depth limit failed.' );
+
+ const byteProbe = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( 'xy ' ).toString( 'base64' ),
+ maxNodes: 2, maxDepth: 2, maxTreeBytes: 1024,
+ } );
+ decodeTree( byteProbe );
+ const byteExact = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( 'xy ' ).toString( 'base64' ),
+ maxNodes: 2, maxDepth: 2, maxTreeBytes: byteProbe.treeBytes,
+ } );
+ decodeTree( byteExact );
+ const byteLimited = await request( service.socketPath, {
+ command: 'render', mode: 'fragment-body', context: 'body',
+ htmlBase64: Buffer.from( 'xy ' ).toString( 'base64' ),
+ maxNodes: 2, maxDepth: 2, maxTreeBytes: byteProbe.treeBytes - 1,
+ } );
+ assert( 'limit' === byteLimited.status && 'tree-byte-limit-exceeded' === byteLimited.failureClass, 'Tree byte limit failed.' );
+
+ const depth = 1000;
+ const siblings = 7869;
+ const filler = 736;
+ const exactHtml = ''.repeat( depth ) + ' '.repeat( siblings ) + 'x'.repeat( filler ) + '
'.repeat( depth );
+ assert( Buffer.byteLength( exactHtml ) < 2 * 1024 * 1024, 'Exact-boundary fixture exceeded input limit.' );
+ const boundaryProbe = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( exactHtml ).toString( 'base64' ),
+ maxNodes: 20000,
+ maxDepth: 1024,
+ maxTreeBytes: MAX_TREE_BYTES,
+ }, 60000 );
+ decodeTree( boundaryProbe );
+ assert( boundaryProbe.treeBytes < MAX_TREE_BYTES, 'Hard-boundary probe unexpectedly reached its limit.' );
+ const probeTree = Buffer.from( boundaryProbe.treeBase64, 'base64' );
+ const marker = Buffer.from( '\n' );
+ const markerIndex = probeTree.indexOf( marker );
+ const lineStart = probeTree.lastIndexOf( 0x0a, markerIndex - 1 ) + 1;
+ const siblingBytes = markerIndex + marker.length - lineStart;
+ assert( siblingBytes > 4, 'Could not measure deepest sibling indentation.' );
+ const needed = MAX_TREE_BYTES - boundaryProbe.treeBytes;
+ const extraSiblings = Math.floor( needed / siblingBytes );
+ const extraFiller = needed - extraSiblings * siblingBytes;
+ const exactAdjustedHtml = ' '.repeat( depth ) + ' '.repeat( siblings + extraSiblings ) +
+ 'x'.repeat( filler + extraFiller ) + '
'.repeat( depth );
+ assert( Buffer.byteLength( exactAdjustedHtml ) < 2 * 1024 * 1024, 'Adjusted hard-boundary fixture exceeded input limit.' );
+ const exactMaximum = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( exactAdjustedHtml ).toString( 'base64' ),
+ maxNodes: 20000,
+ maxDepth: 1024,
+ maxTreeBytes: MAX_TREE_BYTES,
+ }, 60000 );
+ decodeTree( exactMaximum );
+ assert( MAX_TREE_BYTES === exactMaximum.treeBytes, 'Adjusted fixture did not produce exactly 16 MiB.' );
+ const responseBytes = Buffer.byteLength( JSON.stringify( exactMaximum ) + '\n' );
+ assert( responseBytes <= MAX_RESPONSE_FRAME_BYTES, 'Exact 16 MiB success did not fit its 24 MiB frame.' );
+ assert(
+ Buffer.byteLength( JSON.stringify( { ...exactMaximum, id: 'i'.repeat( 4096 ) } ) + '\n' ) <= MAX_RESPONSE_FRAME_BYTES,
+ 'Exact 16 MiB success plus maximum request id did not fit its 24 MiB frame.'
+ );
+ const belowMaximum = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( exactAdjustedHtml ).toString( 'base64' ),
+ maxNodes: 20000,
+ maxDepth: 1024,
+ maxTreeBytes: MAX_TREE_BYTES - 1,
+ }, 60000 );
+ assert( 'limit' === belowMaximum.status, 'One-byte-below hard boundary did not limit.' );
+}
+
+async function assertFramingAndClients( service, temporaryDirectory ) {
+ const twoFrames = await new Promise( ( resolve, reject ) => {
+ const socket = net.createConnection( service.socketPath );
+ let received = '';
+ const timer = setTimeout( () => {
+ socket.destroy();
+ reject( new Error( 'Two-frame socket did not close after one response.' ) );
+ }, 10000 );
+ socket.once( 'error', reject );
+ socket.on( 'data', ( chunk ) => {
+ received += chunk.toString( 'utf8' );
+ } );
+ socket.once( 'end', () => {
+ clearTimeout( timer );
+ resolve( received.split( '\n' ).filter( Boolean ).map( JSON.parse ) );
+ } );
+ socket.once( 'connect', () => {
+ socket.write(
+ JSON.stringify( { id: 'first', command: 'version' } ) + '\n' +
+ JSON.stringify( { id: 'second', command: 'version' } ) + '\n'
+ );
+ } );
+ } );
+ assert( 1 === twoFrames.length && 'first' === twoFrames[ 0 ].id, 'Socket connection processed more than its first frame.' );
+ const malformedThenValid = await new Promise( ( resolve, reject ) => {
+ const socket = net.createConnection( service.socketPath );
+ let received = '';
+ const timer = setTimeout( () => {
+ socket.destroy();
+ reject( new Error( 'Malformed-first socket did not close after one response.' ) );
+ }, 10000 );
+ socket.once( 'error', reject );
+ socket.on( 'data', ( chunk ) => {
+ received += chunk.toString( 'utf8' );
+ } );
+ socket.once( 'end', () => {
+ clearTimeout( timer );
+ resolve( received.split( '\n' ).filter( Boolean ).map( JSON.parse ) );
+ } );
+ socket.once( 'connect', () => {
+ socket.write( '{bad json}\n' + JSON.stringify( { command: 'version' } ) + '\n' );
+ } );
+ } );
+ assert(
+ 1 === malformedThenValid.length && 'protocol-error' === malformedThenValid[ 0 ].failureClass,
+ 'Malformed first frame did not consume and close its socket exactly once.'
+ );
+ const malformedUtf8 = await requestRaw(
+ service.socketPath,
+ Buffer.concat( [
+ Buffer.from( '{"id":"', 'utf8' ),
+ Buffer.from( [ 0xff ] ),
+ Buffer.from( '","command":"version"}\n', 'utf8' ),
+ ] )
+ );
+ assert(
+ 'protocol-error' === malformedUtf8.failureClass && ! Object.hasOwn( malformedUtf8, 'id' ),
+ 'Malformed UTF-8 frame was accepted or echoed a replacement request id.'
+ );
+ for ( const frame of [
+ '{"id":1e309,"command":"version"}\n',
+ '{"id":9007199254740993,"command":"version"}\n',
+ ] ) {
+ const invalidId = await requestRaw( service.socketPath, Buffer.from( frame, 'utf8' ) );
+ assert(
+ 'protocol-error' === invalidId.failureClass && ! Object.hasOwn( invalidId, 'id' ),
+ 'Unsafe numeric request id was accepted or echoed after mutation.'
+ );
+ }
+
+ const oversized = await requestRaw(
+ service.socketPath,
+ Buffer.concat( [ Buffer.alloc( MAX_REQUEST_FRAME_BYTES + 1, 0x78 ), Buffer.from( '\n' ) ] )
+ );
+ assert( 'protocol-error' === oversized.failureClass, 'Oversized socket frame was not rejected.' );
+ const recovered = await request( service.socketPath, { command: 'version' } );
+ assert( 'ok' === recovered.status, 'Service did not recover after oversized socket client.' );
+
+ const pressureHeld = [];
+ const pressureHeldClosed = [];
+ for ( let index = 0; index < 7; index++ ) {
+ const socket = net.createConnection( service.socketPath );
+ await once( socket, 'connect' );
+ pressureHeld.push( socket );
+ pressureHeldClosed.push( once( socket, 'close' ) );
+ socket.write( '{"command"' );
+ }
+ const pressured = net.createConnection( service.socketPath );
+ pressured.pause();
+ await once( pressured, 'connect' );
+ const pressuredClosed = once( pressured, 'close' );
+ pressured.write( JSON.stringify( { command: 'test-large-response' } ) + '\n' );
+ await delay( 1000 );
+ const pressureNinth = await request( service.socketPath, { command: 'version' } );
+ assert(
+ 'protocol-error' === pressureNinth.failureClass && pressureNinth.error.includes( 'client limit' ),
+ 'Backpressured eighth client did not continue to occupy its admission slot.'
+ );
+ const pressureReleaseStarted = Date.now();
+ pressured.destroy();
+ await pressuredClosed;
+ await delay( 100 );
+ const pressureProgress = await request( service.socketPath, { command: 'version' }, 5000 );
+ assert( 'ok' === pressureProgress.status, 'Dispatcher did not progress after a backpressured client disconnected.' );
+ assert( Date.now() - pressureReleaseStarted < 5000, 'Backpressured client did not settle within its close bound.' );
+ for ( const socket of pressureHeld ) {
+ socket.destroy();
+ }
+ await Promise.all( pressureHeldClosed );
+ assert( null === service.child.exitCode, 'Backpressured client disconnect terminated the service.' );
+
+ const heldClosed = [];
+ for ( let index = 0; index < 8; index++ ) {
+ const socket = net.createConnection( service.socketPath );
+ await once( socket, 'connect' );
+ heldClosed.push( once( socket, 'close' ) );
+ socket.write( '{"command"' );
+ }
+ await delay( 100 );
+ const droppedNinth = net.createConnection( service.socketPath );
+ await once( droppedNinth, 'connect' );
+ const droppedNinthClosed = once( droppedNinth, 'close' );
+ droppedNinth.destroy();
+ await droppedNinthClosed;
+ await delay( 50 );
+ assert( null === service.child.exitCode, 'Disconnected rejected client crashed the socket service.' );
+ const ninth = await request( service.socketPath, { command: 'version' } );
+ assert( 'protocol-error' === ninth.failureClass && ninth.error.includes( 'client limit' ), 'Ninth socket client was accepted.' );
+ await Promise.race( [
+ Promise.all( heldClosed ),
+ delay( 15000 ).then( () => { throw new Error( 'Partial-frame socket clients outlived their read deadline.' ); } ),
+ ] );
+ const afterIdleClients = await request( service.socketPath, { command: 'version' } );
+ assert( 'ok' === afterIdleClients.status, 'Idle socket clients permanently exhausted admission slots.' );
+
+ const stdio = spawn( process.execPath, [ SCRIPT, '--serve' ], {
+ stdio: [ 'pipe', 'pipe', 'pipe' ],
+ env: { ...process.env, HTML_API_FUZZ_CHROME_TEST_ALLOW_INTERNAL_COMMANDS: '1' },
+ } );
+ let stdout = '';
+ let stderr = '';
+ const responses = [];
+ stdio.stdout.on( 'data', ( chunk ) => {
+ stdout += chunk.toString( 'utf8' );
+ for ( ;; ) {
+ const newline = stdout.indexOf( '\n' );
+ if ( newline < 0 ) {
+ break;
+ }
+ responses.push( JSON.parse( stdout.slice( 0, newline ) ) );
+ stdout = stdout.slice( newline + 1 );
+ }
+ } );
+ stdio.stderr.on( 'data', ( chunk ) => {
+ stderr = ( stderr + chunk.toString( 'utf8' ) ).slice( -65536 );
+ } );
+ stdio.stdin.write( Buffer.alloc( MAX_REQUEST_FRAME_BYTES + 1, 0x78 ) );
+ stdio.stdin.write( '\n' + JSON.stringify( { id: 2, command: 'version' } ) + '\n' );
+ const deadline = Date.now() + 30000;
+ while ( responses.length < 2 && Date.now() < deadline ) {
+ await delay( 25 );
+ }
+ assert( responses.length >= 2, 'Stdio framing recovery timed out. ' + stderr );
+ assert( 'protocol-error' === responses[ 0 ].failureClass, 'Oversized stdio frame was not rejected.' );
+ assert( 'ok' === responses[ 1 ].status && 2 === responses[ 1 ].id, 'Stdio did not recover through newline.' );
+ stdio.stdin.write( JSON.stringify( { command: 'shutdown' } ) + '\n' );
+ const outcome = await waitForExit( stdio, 20000 );
+ assert( 0 === outcome.code, 'Stdio service did not shut down after framing test. ' + stderr );
+}
+
+async function assertWarmReuseAndRestart( service ) {
+ const first = await request( service.socketPath, { command: 'version' } );
+ const firstTransport = first.oracle.transport;
+ const pid = firstTransport.browserPid;
+ const concurrent = await Promise.all(
+ [ 1, 2, 3, 4 ].map( ( id ) => request( service.socketPath, {
+ id,
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( '' + id ).toString( 'base64' ),
+ maxNodes: 10,
+ maxDepth: 10,
+ maxTreeBytes: 4096,
+ } ) )
+ );
+ for ( const result of concurrent ) {
+ decodeTree( result );
+ assert( pid === result.oracle.transport.browserPid, 'Concurrent request did not reuse warm Chrome.' );
+ }
+ const oldProcesses = captureDescendantIdentities( firstTransport.supervisorPid );
+ const killed = await request( service.socketPath, { command: 'test-kill-browser' } );
+ assert( 'ok' === killed.status, 'Could not terminate browser for restart test.' );
+ await delay( 500 );
+ const restarted = await request( service.socketPath, {
+ command: 'render',
+ mode: 'fragment-body',
+ context: 'body',
+ htmlBase64: Buffer.from( '
restart' ).toString( 'base64' ),
+ maxNodes: 10,
+ maxDepth: 10,
+ maxTreeBytes: 4096,
+ } );
+ decodeTree( restarted );
+ assert( pid !== restarted.oracle.transport.browserPid, 'Ordinary browser death did not produce one clean restart.' );
+ assert( firstTransport.supervisorPid !== restarted.oracle.transport.supervisorPid, 'Ordinary browser death reused its old supervisor.' );
+ assert( firstTransport.runtimeRoot !== restarted.oracle.transport.runtimeRoot, 'Ordinary browser death reused its old runtime.' );
+ await waitForIdentitiesGone( oldProcesses );
+ await waitForPathGone( firstTransport.profilePath );
+ await waitForPathGone( firstTransport.runtimeRoot );
+}
+
+async function assertLifecycleCleanup( temporaryDirectory, target, phase ) {
+ const pauseFile = path.join( temporaryDirectory, 'pause-' + target + '-' + phase + '-' + crypto.randomBytes( 3 ).toString( 'hex' ) );
+ const environment = 'pre-browser' === phase
+ ? { HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_SUPERVISOR_SPAWN: pauseFile }
+ : 'browser-startup' === phase
+ ? { HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_BROWSER_SPAWN: pauseFile }
+ : 'cdp-handshake' === phase
+ ? { HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_CDP_HANDSHAKE: pauseFile }
+ : {};
+ const service = spawnService( temporaryDirectory, environment );
+ let metadata;
+ if ( 'steady' !== phase ) {
+ const deadline = Date.now() + 35000;
+ while ( ! fs.existsSync( pauseFile ) && Date.now() < deadline ) {
+ if ( null !== service.child.exitCode ) {
+ throw new Error(
+ 'Lifecycle startup service exited before pause.\nstdout: ' + service.stdout +
+ '\nstderr: ' + service.stderr
+ );
+ }
+ await delay( 20 );
+ }
+ if ( ! fs.existsSync( pauseFile ) ) {
+ service.child.kill( 'SIGKILL' );
+ await waitForExit( service.child, 5000 ).catch( () => {} );
+ throw new Error(
+ 'Lifecycle startup pause was not reached.\nstdout: ' + service.stdout +
+ '\nstderr: ' + service.stderr
+ );
+ }
+ metadata = JSON.parse( fs.readFileSync( pauseFile, 'utf8' ) );
+ assert( ! fs.existsSync( metadata.socketPath ), 'Startup exposed its socket before browser authentication.' );
+ assert(
+ ! fs.existsSync( path.join( __dirname, '.chrome-for-testing', '.install.lock' ) ),
+ 'Startup authentication left an installer lock.'
+ );
+ } else {
+ const ready = await waitForReady( service.child );
+ metadata = {
+ ownerPid: ready.oracle.transport.ownerPid,
+ supervisorPid: ready.oracle.transport.supervisorPid,
+ browserPid: ready.oracle.transport.browserPid,
+ runtimeRoot: ready.oracle.transport.runtimeRoot,
+ profilePath: ready.oracle.transport.profilePath,
+ socketPath: ready.oracle.transport.socketPath,
+ };
+ }
+ const capturedProcesses = captureDescendantIdentities( metadata.supervisorPid );
+ if ( 'pre-browser' === phase ) {
+ assert(
+ 1 === capturedProcesses.length && capturedProcesses[ 0 ].pid === metadata.supervisorPid,
+ 'Pre-browser pause occurred after the supervisor launched an executable child.'
+ );
+ } else if ( 'browser-startup' === phase || 'cdp-handshake' === phase ) {
+ assert(
+ capturedProcesses.some( ( processRecord ) => processRecord.pid === metadata.browserPid ) &&
+ capturedProcesses.length > 1,
+ 'Browser startup pause did not capture the token-bearing Chrome tree.'
+ );
+ }
+
+ if ( 'owner' === target ) {
+ process.kill( metadata.ownerPid, 'SIGKILL' );
+ } else if ( 'browser' === target ) {
+ process.kill( metadata.browserPid, 'SIGKILL' );
+ } else {
+ process.kill( metadata.supervisorPid, 'SIGKILL' );
+ }
+ if ( fs.existsSync( pauseFile ) ) {
+ fs.unlinkSync( pauseFile );
+ }
+ let outcome;
+ try {
+ outcome = await waitForExit( service.child, 20000 );
+ } catch ( error ) {
+ service.child.kill( 'SIGKILL' );
+ await waitForExit( service.child, 5000 ).catch( () => {} );
+ error.message += '\nstdout: ' + service.stdout + '\nstderr: ' + service.stderr;
+ throw error;
+ }
+ if ( 'owner' === target ) {
+ assert( 'SIGKILL' === outcome.signal, 'Owner hard-kill did not terminate by SIGKILL.' );
+ } else {
+ assert( 0 !== outcome.code, target + ' hard-kill did not fail the owner service.' );
+ }
+ await waitForPidGone( metadata.supervisorPid );
+ if ( metadata.browserPid ) {
+ await waitForPidGone( metadata.browserPid );
+ }
+ await waitForIdentitiesGone( capturedProcesses );
+ await waitForNoOwnedProcesses( metadata.profilePath );
+ await waitForPathGone( metadata.runtimeRoot );
+ await waitForPathGone( metadata.socketPath );
+ const lockDirectory = path.join( __dirname, '.chrome-for-testing', '.install.lock' );
+ await waitForPathGone( lockDirectory );
+}
+
+async function assertSignalCleanup( temporaryDirectory ) {
+ const service = spawnService( temporaryDirectory );
+ const ready = await waitForReady( service.child );
+ const transport = ready.oracle.transport;
+ const capturedProcesses = captureDescendantIdentities( transport.supervisorPid );
+ service.child.kill( 'SIGTERM' );
+ const outcome = await waitForExit( service.child, 20000 );
+ assert( 0 === outcome.code, 'SIGTERM cleanup did not exit cleanly.' );
+ await waitForPidGone( transport.supervisorPid );
+ await waitForPidGone( transport.browserPid );
+ await waitForIdentitiesGone( capturedProcesses );
+ await waitForNoOwnedProcesses( transport.profilePath );
+ await waitForPathGone( transport.runtimeRoot );
+ await waitForPathGone( transport.socketPath );
+}
+
+async function main() {
+ const temporaryDirectory = fs.mkdtempSync( path.join( os.tmpdir(), 'html-api-fuzz-chrome-smoke-' ) );
+ fs.chmodSync( temporaryDirectory, 0o700 );
+ const chromeExecutable = executablePath();
+ assert( fs.existsSync( chromeExecutable ), 'Pinned Chrome is not installed.' );
+ const contexts = assertContextDrift();
+ assertStrictCli( temporaryDirectory );
+ await assertOneShotInputSnapshot( temporaryDirectory );
+ await assertRealStdoutWriteDeadline();
+ assertRuntimeManifestStrictness( temporaryDirectory );
+ assertCdpWebSocketProtocol();
+ assertSupervisorBackpressure();
+ await assertSupervisorProtocolValidation();
+ assertSnapshotRetentionBound();
+ await assertBoundedPeerAndCleanupInfrastructure( temporaryDirectory );
+ await assertCdpEnvelopeValidation();
+ await assertTimeoutDoesNotReplay();
+ await assertRendererOutputValidation();
+
+ const service = spawnService( temporaryDirectory );
+ try {
+ const ready = await waitForReady( service.child );
+ assertIdentity( ready, chromeExecutable );
+ const socketMode = fs.statSync( service.socketPath ).mode & 0o777;
+ assert( 0o600 === socketMode, 'Oracle socket mode was not 0600.' );
+ await assertCanonicalAndSecurity( service, contexts );
+ await assertProtocolAndLimits( service );
+ await assertFramingAndClients( service, temporaryDirectory );
+ await assertWarmReuseAndRestart( service );
+ await shutdownService( service );
+ await waitForPathGone( ready.oracle.transport.runtimeRoot );
+ await waitForPidGone( ready.oracle.transport.supervisorPid );
+ } finally {
+ if ( null === service.child.exitCode && null === service.child.signalCode ) {
+ service.child.kill( 'SIGTERM' );
+ await waitForExit( service.child ).catch( () => {} );
+ }
+ }
+
+ await assertSignalCleanup( temporaryDirectory );
+ for ( const target of [ 'owner', 'supervisor' ] ) {
+ for ( const phase of [ 'pre-browser', 'browser-startup', 'steady' ] ) {
+ try {
+ await assertLifecycleCleanup( temporaryDirectory, target, phase );
+ } catch ( error ) {
+ error.message = target + '/' + phase + ': ' + error.message;
+ throw error;
+ }
+ }
+ }
+ for ( const target of [ 'supervisor', 'browser' ] ) {
+ try {
+ await assertLifecycleCleanup( temporaryDirectory, target, 'cdp-handshake' );
+ } catch ( error ) {
+ error.message = target + '/cdp-handshake: ' + error.message;
+ throw error;
+ }
+ }
+
+ fs.rmSync( temporaryDirectory, { recursive: true, force: true } );
+ process.stdout.write( 'chrome direct-CDP smoke: ok\n' );
+}
+
+main().catch( ( error ) => {
+ process.stderr.write( ( error.stack || error.message || String( error ) ) + '\n' );
+ process.exitCode = 1;
+} );
diff --git a/tools/html-api-fuzz/oracles/fragment-contexts.json b/tools/html-api-fuzz/oracles/fragment-contexts.json
new file mode 100644
index 0000000000000..5942672816566
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/fragment-contexts.json
@@ -0,0 +1,19 @@
+[
+ "body",
+ "div",
+ "p",
+ "td",
+ "tr",
+ "table",
+ "caption",
+ "colgroup",
+ "select",
+ "option",
+ "template",
+ "title",
+ "textarea",
+ "script",
+ "style",
+ "svg",
+ "math"
+]
diff --git a/tools/html-api-fuzz/tests/chrome-install-integrity-smoke.sh b/tools/html-api-fuzz/tests/chrome-install-integrity-smoke.sh
new file mode 100755
index 0000000000000..45ee9ada10d21
--- /dev/null
+++ b/tools/html-api-fuzz/tests/chrome-install-integrity-smoke.sh
@@ -0,0 +1,664 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+FUZZ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
+CHROME_DIR="$FUZZ_ROOT/oracles/chrome"
+INSTALLER="$CHROME_DIR/install.sh"
+VERSION='150.0.7871.114'
+NODE_BIN="$(dirname "$(command -v node)")"
+
+fail() {
+ printf 'FAIL: %s\n' "$*" >&2
+ exit 1
+}
+
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{ print $1 }'
+ else
+ shasum -a 256 "$1" | awk '{ print $1 }'
+ fi
+}
+
+tree_digest() {
+ tar -cf - -C "$(dirname "$1")" "$(basename "$1")" | shasum -a 256 | awk '{ print $1 }'
+}
+
+tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/chrome-install-integrity.XXXXXX")"
+background_pids=()
+cleanup() {
+ for pid in "${background_pids[@]}"; do
+ kill "$pid" 2>/dev/null || true
+ done
+ for pid in "${background_pids[@]}"; do
+ wait "$pid" 2>/dev/null || true
+ done
+ rm -rf "$tmp_root"
+}
+trap cleanup EXIT HUP INT TERM
+
+for manifest in SHA256SUMS EXECUTABLE_SHA256SUMS; do
+ entries="$(awk 'NF && $1 !~ /^#/ { count++ } END { print count + 0 }' "$CHROME_DIR/$manifest")"
+ [[ "$entries" -eq 3 ]] || fail "$manifest must have exactly three entries"
+ for checked_platform in mac-arm64 mac-x64 linux64; do
+ if [[ "$manifest" = SHA256SUMS ]]; then
+ suffix="chrome-$VERSION-$checked_platform.zip"
+ else
+ suffix="$checked_platform.executable"
+ fi
+ count="$(awk -v name="$suffix" '$2 == name { count++ } END { print count + 0 }' "$CHROME_DIR/$manifest")"
+ fields="$(awk -v name="$suffix" '$2 == name { print NF }' "$CHROME_DIR/$manifest")"
+ digest="$(awk -v name="$suffix" '$2 == name { print $1 }' "$CHROME_DIR/$manifest")"
+ [[ "$count" -eq 1 && "$fields" -eq 2 && "$digest" =~ ^[0-9a-f]{64}$ ]] || fail "invalid $manifest entry for $checked_platform"
+ done
+done
+
+setup_fixture() {
+ local name="$1"
+ local selected_platform="${2:-mac-arm64}"
+ fixture="$tmp_root/$name"
+ oracle_dir="$fixture/oracle"
+ install_root="$fixture/install"
+ fake_bin="$fixture/bin"
+ log_dir="$fixture/log"
+ payload_root="$fixture/payload"
+ mkdir -p "$oracle_dir" "$fake_bin" "$log_dir" "$payload_root"
+ cp "$INSTALLER" "$oracle_dir/install.sh"
+ chmod 0755 "$oracle_dir/install.sh"
+ printf '%s\n' "$VERSION" >"$oracle_dir/VERSION"
+
+ case "$selected_platform" in
+ mac-arm64)
+ fake_os=Darwin
+ fake_arch=arm64
+ platform=mac-arm64
+ archive_dir=chrome-mac-arm64
+ executable_relative='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ mac-x64)
+ fake_os=Darwin
+ fake_arch=x86_64
+ platform=mac-x64
+ archive_dir=chrome-mac-x64
+ executable_relative='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ linux64)
+ fake_os=Linux
+ fake_arch=x86_64
+ platform=linux64
+ archive_dir=chrome-linux64
+ executable_relative=chrome
+ ;;
+ *) fail "unknown fixture platform $selected_platform" ;;
+ esac
+ archive_name="chrome-$VERSION-$platform.zip"
+ archive_source="$fixture/download-source"
+ payload_executable="$payload_root/$archive_dir/$executable_relative"
+ destination="$install_root/$VERSION/$platform"
+ installed_executable="$destination/$archive_dir/$executable_relative"
+ marker="$destination/.html-api-fuzz-verified"
+ mkdir -p "$(dirname "$payload_executable")"
+ printf '%s\n' 'authenticated archive fixture' >"$archive_source"
+
+ cat >"$payload_executable" <<'SH'
+#!/bin/sh
+printf '%s\n' invoked >> "$FAKE_EXECUTION_LOG"
+call_count=$(wc -l < "$FAKE_EXECUTION_LOG" | tr -d '[:space:]')
+if [ -n "${FAKE_VERSION_SLEEP:-}" ]; then
+ sleep "$FAKE_VERSION_SLEEP"
+fi
+if [ -n "${FAKE_VERSION_FORK_PID_FILE:-}" ]; then
+ (
+ trap '' TERM
+ while :; do sleep 1; done
+ ) &
+ printf '%s\n' "$!" > "$FAKE_VERSION_FORK_PID_FILE"
+fi
+if [ -n "${FAKE_VERSION_OUTPUT_BYTES:-}" ]; then
+ dd if=/dev/zero bs="$FAKE_VERSION_OUTPUT_BYTES" count=1 2>/dev/null | tr '\000' x
+fi
+if [ "${FAKE_VERSION_HANG:-0}" = 1 ]; then
+ if [ -n "${FAKE_VERSION_PAUSE_FILE:-}" ]; then
+ printf '%s\n' paused > "$FAKE_VERSION_PAUSE_FILE"
+ fi
+ trap '' TERM
+ while :; do sleep 1; done
+fi
+if [ -n "${FAKE_VERSION_PAUSE_FILE:-}" ] &&
+ { [ -z "${FAKE_VERSION_PAUSE_ON_CALL:-}" ] || [ "$FAKE_VERSION_PAUSE_ON_CALL" = "$call_count" ]; }; then
+ printf '%s\n' paused > "$FAKE_VERSION_PAUSE_FILE"
+ while [ -e "$FAKE_VERSION_PAUSE_FILE" ]; do
+ sleep 0.01
+ done
+fi
+if [ "${FAKE_MUTATE_SELF:-0}" = 1 ] || [ "${FAKE_MUTATE_SELF_ON_CALL:-0}" = "$call_count" ]; then
+ printf '%s\n' '# mutation' >> "$0"
+fi
+if [ "${FAKE_MUTATE_MARKER:-0}" = 1 ]; then
+ printf '%s\n' changed > "$FAKE_MARKER_PATH"
+fi
+printf 'Google Chrome for Testing %s\n' "${FAKE_REPORTED_VERSION:-150.0.7871.114}"
+SH
+ chmod 0755 "$payload_executable"
+
+ cat >"$fake_bin/uname" <<'SH'
+#!/bin/sh
+case "${1:-}" in
+ -s) printf '%s\n' "$FAKE_UNAME_S" ;;
+ -m) printf '%s\n' "$FAKE_UNAME_M" ;;
+ *) exit 2 ;;
+esac
+SH
+cat >"$fake_bin/curl" <<'SH'
+#!/bin/sh
+printf '%s\n' curl >> "$FAKE_CURL_LOG"
+printf '%s\n' "$*" >> "$FAKE_CURL_ARGS_LOG"
+output=
+while [ "$#" -gt 0 ]; do
+ if [ "$1" = --output ]; then
+ output=$2
+ shift 2
+ else
+ shift
+ fi
+done
+[ -n "$output" ]
+if [ "${FAKE_CURL_FAIL:-0}" = 1 ]; then
+ printf '%s\n' partial >"$output"
+ exit 22
+fi
+cp "$FAKE_ARCHIVE_SOURCE" "$output"
+SH
+ cat >"$fake_bin/unzip" <<'SH'
+#!/bin/sh
+printf '%s\n' unzip >> "$FAKE_UNZIP_LOG"
+destination=
+while [ "$#" -gt 0 ]; do
+ if [ "$1" = -d ]; then
+ destination=$2
+ shift 2
+ else
+ shift
+ fi
+done
+[ -n "$destination" ]
+if [ "${FAKE_WRONG_LAYOUT:-0}" = 1 ]; then
+ exit 0
+fi
+target="$destination/$FAKE_ARCHIVE_DIR/$FAKE_EXECUTABLE_RELATIVE"
+mkdir -p "$(dirname "$target")"
+cp "$FAKE_PAYLOAD_EXECUTABLE" "$target"
+chmod 0755 "$target"
+SH
+ chmod 0755 "$fake_bin/uname" "$fake_bin/curl" "$fake_bin/unzip"
+ refresh_manifests
+}
+
+refresh_manifests() {
+ archive_sha="$(sha256_file "$archive_source")"
+ executable_sha="$(sha256_file "$payload_executable")"
+ printf '%s %s\n' "$archive_sha" "$archive_name" >"$oracle_dir/SHA256SUMS"
+ printf '%s %s.executable\n' "$executable_sha" "$platform" >"$oracle_dir/EXECUTABLE_SHA256SUMS"
+}
+
+run_installer() {
+ env \
+ PATH="$fake_bin:$NODE_BIN:/usr/bin:/bin" \
+ FAKE_UNAME_S="$fake_os" \
+ FAKE_UNAME_M="$fake_arch" \
+ FAKE_ARCHIVE_SOURCE="$archive_source" \
+ FAKE_ARCHIVE_DIR="$archive_dir" \
+ FAKE_EXECUTABLE_RELATIVE="$executable_relative" \
+ FAKE_PAYLOAD_EXECUTABLE="$payload_executable" \
+ FAKE_CURL_LOG="$log_dir/curl.log" \
+ FAKE_CURL_ARGS_LOG="$log_dir/curl-args.log" \
+ FAKE_UNZIP_LOG="$log_dir/unzip.log" \
+ FAKE_EXECUTION_LOG="$log_dir/execution.log" \
+ FAKE_MARKER_PATH="$marker" \
+ HTML_API_FUZZ_CHROME_INSTALL_ROOT="$install_root" \
+ HTML_API_FUZZ_CHROME_LOCK_ATTEMPTS=100 \
+ "$@" \
+ "$oracle_dir/install.sh"
+}
+
+run_print_path() {
+ env \
+ PATH="$fake_bin:$NODE_BIN:/usr/bin:/bin" \
+ FAKE_UNAME_S="$fake_os" \
+ FAKE_UNAME_M="$fake_arch" \
+ HTML_API_FUZZ_CHROME_INSTALL_ROOT="$install_root" \
+ "$oracle_dir/install.sh" --print-path
+}
+
+clear_logs() {
+ rm -f "$log_dir"/*.log
+}
+
+wait_for_path_gone() {
+ local target="$1"
+ for _ in {1..500}; do
+ [[ ! -e "$target" ]] && return 0
+ sleep 0.01
+ done
+ fail "path survived bounded cleanup: $target"
+}
+
+wait_for_pid_gone() {
+ local pid="$1"
+ for _ in {1..500}; do
+ if ! kill -0 "$pid" 2>/dev/null; then
+ return 0
+ fi
+ sleep 0.01
+ done
+ fail "process survived bounded cleanup: $pid"
+}
+
+assert_no_execution() {
+ [[ ! -e "$log_dir/execution.log" ]] || fail "$1 executed Chrome unexpectedly"
+}
+
+for selected in mac-arm64 mac-x64 linux64; do
+ setup_fixture "print-$selected" "$selected"
+ printed="$(run_print_path)"
+ [[ "$printed" = "$installed_executable" ]] || fail "--print-path mismatch for $selected"
+ [[ ! -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" && ! -e "$log_dir/execution.log" ]] || fail "--print-path executed a tool"
+done
+
+for mutation in missing embedded-space extra-line missing-newline; do
+ setup_fixture "version-$mutation"
+ case "$mutation" in
+ missing) rm "$oracle_dir/VERSION" ;;
+ embedded-space) printf '%s\n' '150.0.7871. 114' >"$oracle_dir/VERSION" ;;
+ extra-line) printf '%s\n%s\n' "$VERSION" extra >"$oracle_dir/VERSION" ;;
+ missing-newline) printf '%s' "$VERSION" >"$oracle_dir/VERSION" ;;
+ esac
+ if run_installer >"$fixture/out" 2>"$fixture/err"; then
+ fail "VERSION $mutation mutation was accepted"
+ fi
+ [[ ! -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" ]] || fail "VERSION $mutation reached external tools"
+ assert_no_execution "VERSION $mutation"
+done
+
+for manifest in SHA256SUMS EXECUTABLE_SHA256SUMS; do
+ for mutation in missing duplicate malformed; do
+ setup_fixture "manifest-$manifest-$mutation"
+ case "$mutation" in
+ missing) : >"$oracle_dir/$manifest" ;;
+ duplicate) cp "$oracle_dir/$manifest" "$oracle_dir/$manifest.copy"; cat "$oracle_dir/$manifest.copy" >>"$oracle_dir/$manifest" ;;
+ malformed) sed 's/^[0-9a-f][0-9a-f]*/not-a-digest/' "$oracle_dir/$manifest" >"$oracle_dir/$manifest.tmp"; mv "$oracle_dir/$manifest.tmp" "$oracle_dir/$manifest" ;;
+ esac
+ if run_installer >"$fixture/out" 2>"$fixture/err"; then
+ fail "$manifest $mutation mutation was accepted"
+ fi
+ [[ ! -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" ]] || fail "$manifest $mutation reached external tools"
+ assert_no_execution "$manifest $mutation"
+ done
+done
+
+setup_fixture corrupt-archive
+good_sha="$(printf '%s\n' good | shasum -a 256 | awk '{ print $1 }')"
+printf '%s %s\n' "$good_sha" "$archive_name" >"$oracle_dir/SHA256SUMS"
+if run_installer >"$fixture/out" 2>"$fixture/err"; then
+ fail 'corrupt archive was accepted'
+fi
+[[ -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" ]] || fail 'corrupt archive crossed authentication boundary'
+assert_no_execution 'corrupt archive'
+
+setup_fixture curl-failure
+if run_installer FAKE_CURL_FAIL=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'failing curl unexpectedly succeeded'
+fi
+[[ -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" ]] || fail 'failing curl crossed the download boundary'
+assert_no_execution 'failing curl'
+[[ ! -d "$install_root/.install.lock" ]] || fail 'failing curl left its install lock'
+if find "$install_root" -name '*.partial.*' -o -name '.installing.*' -o -name '*.previous.*' | grep -q .; then
+ fail 'failing curl left transaction residue'
+fi
+
+setup_fixture wrong-layout
+if run_installer FAKE_WRONG_LAYOUT=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'wrong archive layout was accepted'
+fi
+[[ -e "$log_dir/unzip.log" ]] || fail 'wrong-layout test did not reach extraction'
+assert_no_execution 'wrong layout'
+
+setup_fixture executable-mismatch
+printf '%064d %s.executable\n' 0 "$platform" >"$oracle_dir/EXECUTABLE_SHA256SUMS"
+if run_installer >"$fixture/out" 2>"$fixture/err"; then
+ fail 'wrong executable hash was accepted'
+fi
+assert_no_execution 'executable mismatch'
+
+setup_fixture wrong-version
+if run_installer FAKE_REPORTED_VERSION=149.0.0.0 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'wrong authenticated version was accepted'
+fi
+if [[ ! -e "$log_dir/execution.log" ]]; then
+ cat "$fixture/err" >&2
+ fail 'wrong-version test did not reach authenticated probe'
+fi
+
+setup_fixture probe-output-limit
+started_at=$SECONDS
+if run_installer FAKE_VERSION_OUTPUT_BYTES=16385 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'oversized Chrome version-probe output was accepted'
+fi
+elapsed=$((SECONDS - started_at))
+[[ "$elapsed" -lt 5 ]] || fail 'oversized version probe did not fail within its bound'
+[[ -e "$log_dir/execution.log" ]] || fail 'oversized-output test did not execute authenticated probe'
+[[ ! -e "$installed_executable" && ! -d "$install_root/.install.lock" ]] || fail 'oversized probe published or left its lock'
+if find "$install_root" \( -name '.installing.*' -o -name '.version-probe.*' \) | grep -q .; then
+ fail 'oversized probe left staging or result residue'
+fi
+
+setup_fixture probe-timeout-fork
+pause_file="$fixture/probe.pause"
+fork_pid_file="$fixture/fork.pid"
+started_at=$SECONDS
+if run_installer \
+ HTML_API_FUZZ_CHROME_TEST_PROBE_TIMEOUT_MS=200 \
+ FAKE_VERSION_HANG=1 \
+ FAKE_VERSION_PAUSE_FILE="$pause_file" \
+ FAKE_VERSION_FORK_PID_FILE="$fork_pid_file" \
+ >"$fixture/out" 2>"$fixture/err"; then
+ fail 'hanging/forking Chrome version probe was accepted'
+fi
+elapsed=$((SECONDS - started_at))
+[[ "$elapsed" -lt 5 ]] || fail 'hanging/forking version probe exceeded its cleanup bound'
+[[ -f "$fork_pid_file" ]] || fail 'forking version probe did not publish its descendant PID'
+wait_for_pid_gone "$(cat "$fork_pid_file")"
+[[ ! -e "$installed_executable" && ! -d "$install_root/.install.lock" ]] || fail 'timed-out probe published or left its lock'
+if find "$install_root" \( -name '.installing.*' -o -name '.version-probe.*' \) | grep -q .; then
+ fail 'timed-out probe left staging or result residue'
+fi
+setup_fixture valid-cache
+run_installer >"$fixture/first.out"
+grep -q -- '--connect-timeout 15' "$log_dir/curl-args.log" || fail 'curl connect timeout was omitted'
+grep -q -- '--max-time 300' "$log_dir/curl-args.log" || fail 'curl overall timeout was omitted'
+if grep -q -- '--retry' "$log_dir/curl-args.log"; then
+ fail 'curl retries made the 300-second transfer bound per-attempt instead of overall'
+fi
+[[ -x "$installed_executable" && -f "$marker" ]] || fail 'valid install was not published'
+grep -qx 'schema=1' "$marker" || fail 'marker schema missing'
+grep -qx "executable_sha256=$executable_sha" "$marker" || fail 'marker executable hash missing'
+clear_logs
+run_installer >"$fixture/cache.out"
+[[ ! -e "$log_dir/curl.log" && ! -e "$log_dir/unzip.log" ]] || fail 'valid cache reached download/extraction'
+[[ "$(wc -l < "$log_dir/execution.log" | tr -d '[:space:]')" -eq 1 ]] || fail 'valid cache probe count mismatch'
+
+setup_fixture coordinated-tamper
+run_installer >/dev/null
+clear_logs
+cat >"$installed_executable" <<'SH'
+#!/bin/sh
+printf evil >> "$FAKE_EVIL_LOG"
+printf 'Google Chrome for Testing 150.0.7871.114\n'
+SH
+chmod 0755 "$installed_executable"
+evil_sha="$(sha256_file "$installed_executable")"
+printf 'schema=1\nversion=%s\nplatform=%s\narchive_sha256=%s\nexecutable_sha256=%s\n' "$VERSION" "$platform" "$archive_sha" "$evil_sha" >"$marker"
+run_installer FAKE_EVIL_LOG="$log_dir/evil.log" >/dev/null
+[[ ! -e "$log_dir/evil.log" ]] || fail 'coordinated marker+executable tamper was executed'
+[[ "$(sha256_file "$installed_executable")" = "$executable_sha" ]] || fail 'coordinated tamper was not replaced'
+
+setup_fixture unmarked-corrupt-archive
+run_installer >/dev/null
+printf '%s\n' malformed >"$marker"
+printf '%s\n' corrupt >"$install_root/.downloads/$archive_name"
+clear_logs
+if run_installer >"$fixture/out" 2>"$fixture/err"; then
+ fail 'unmarked install with corrupt archive was accepted'
+fi
+assert_no_execution 'unmarked existing install'
+
+setup_fixture cache-self-race
+run_installer >/dev/null
+clear_logs
+if run_installer FAKE_MUTATE_SELF=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'self-replacing cache probe was accepted'
+fi
+grep -q 'changed or failed' "$fixture/err" || fail 'self-replacement lacked fail-closed diagnostic'
+[[ ! -e "$log_dir/unzip.log" ]] || fail 'self-replacing cache probe fell through to reinstall'
+
+setup_fixture cache-marker-race
+run_installer >/dev/null
+clear_logs
+if run_installer FAKE_MUTATE_MARKER=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'marker-replacing cache probe was accepted'
+fi
+[[ ! -e "$log_dir/unzip.log" ]] || fail 'marker-replacing cache probe fell through to reinstall'
+
+for phase in after-backup after-publish after-marker; do
+ setup_fixture "interrupt-$phase"
+ run_installer >/dev/null
+ printf '%s\n' stale >"$marker"
+ before="$(tree_digest "$destination")"
+ if run_installer HTML_API_FUZZ_CHROME_TEST_INTERRUPT_PHASE="$phase" >"$fixture/out" 2>"$fixture/err"; then
+ fail "$phase interruption reported success"
+ fi
+ after="$(tree_digest "$destination")"
+ [[ "$before" = "$after" ]] || fail "$phase interruption did not restore prior destination"
+done
+
+setup_fixture staged-auth-preservation
+run_installer >/dev/null
+printf '%s\n' stale >"$marker"
+before="$(tree_digest "$destination")"
+clear_logs
+if run_installer FAKE_MUTATE_SELF_ON_CALL=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'staged authenticated snapshot mutation was accepted'
+fi
+after="$(tree_digest "$destination")"
+[[ "$before" = "$after" ]] || fail 'staged-auth failure changed prior destination'
+
+setup_fixture hard-owner-death-before-probe-child
+owner_pid_file="$fixture/owner.pid"
+watchdog_pid_file="$fixture/watchdog.pid"
+startup_pause_file="$fixture/watchdog-startup.pause"
+run_installer \
+ HTML_API_FUZZ_CHROME_TEST_OWNER_PID_FILE="$owner_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PID_FILE="$watchdog_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PRE_CHILD_PAUSE_FILE="$startup_pause_file" \
+ >"$fixture/out" 2>"$fixture/err" &
+installer_job=$!
+background_pids+=( "$installer_job" )
+for _ in {1..500}; do
+ [[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$startup_pause_file" ]] && break
+ sleep 0.01
+done
+[[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$startup_pause_file" ]] || fail 'pre-child hard-death fixture did not reach watchdog startup pause'
+owner_pid="$(cat "$owner_pid_file")"
+watchdog_pid="$(cat "$watchdog_pid_file")"
+kill -KILL "$owner_pid"
+rm -f "$startup_pause_file"
+wait "$installer_job" 2>/dev/null || true
+background_pids=()
+wait_for_pid_gone "$watchdog_pid"
+wait_for_path_gone "$install_root/.install.lock"
+[[ ! -e "$installed_executable" ]] || fail 'pre-child hard owner death published Chrome'
+[[ ! -e "$log_dir/execution.log" ]] || fail 'pre-child hard owner death executed the probe binary'
+if find "$install_root" \( -name '.installing.*' -o -name '.version-probe.*' \) | grep -q .; then
+ fail 'pre-child hard owner death left staging or result residue'
+fi
+
+setup_fixture hard-owner-death
+run_installer >/dev/null
+printf '%s\n' stale >"$marker"
+before="$(tree_digest "$destination")"
+clear_logs
+owner_pid_file="$fixture/owner.pid"
+watchdog_pid_file="$fixture/watchdog.pid"
+fork_pid_file="$fixture/fork.pid"
+pause_file="$fixture/probe.pause"
+run_installer \
+ HTML_API_FUZZ_CHROME_TEST_OWNER_PID_FILE="$owner_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PID_FILE="$watchdog_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_PROBE_TIMEOUT_MS=10000 \
+ FAKE_VERSION_HANG=1 \
+ FAKE_VERSION_PAUSE_FILE="$pause_file" \
+ FAKE_VERSION_FORK_PID_FILE="$fork_pid_file" \
+ >"$fixture/out" 2>"$fixture/err" &
+installer_job=$!
+background_pids+=( "$installer_job" )
+for _ in {1..500}; do
+ [[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$fork_pid_file" && -f "$pause_file" ]] && break
+ sleep 0.01
+done
+[[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$fork_pid_file" && -f "$pause_file" ]] || fail 'hard-death fixture did not reach its forking probe'
+owner_pid="$(cat "$owner_pid_file")"
+watchdog_pid="$(cat "$watchdog_pid_file")"
+fork_pid="$(cat "$fork_pid_file")"
+kill -KILL "$owner_pid"
+wait "$installer_job" 2>/dev/null || true
+background_pids=()
+wait_for_pid_gone "$watchdog_pid"
+wait_for_pid_gone "$fork_pid"
+wait_for_path_gone "$install_root/.install.lock"
+if find "$install_root" \( -name '.installing.*' -o -name '.version-probe.*' \) | grep -q .; then
+ fail 'hard owner death left staging or result residue'
+fi
+after="$(tree_digest "$destination")"
+[[ "$before" = "$after" ]] || fail 'hard owner death changed the prior installation'
+
+setup_fixture signal-during-probe
+run_installer >/dev/null
+printf '%s\n' stale >"$marker"
+before="$(tree_digest "$destination")"
+clear_logs
+owner_pid_file="$fixture/owner.pid"
+watchdog_pid_file="$fixture/watchdog.pid"
+fork_pid_file="$fixture/fork.pid"
+pause_file="$fixture/probe.pause"
+run_installer \
+ HTML_API_FUZZ_CHROME_TEST_OWNER_PID_FILE="$owner_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_WATCHDOG_PID_FILE="$watchdog_pid_file" \
+ HTML_API_FUZZ_CHROME_TEST_PROBE_TIMEOUT_MS=10000 \
+ FAKE_VERSION_HANG=1 \
+ FAKE_VERSION_PAUSE_FILE="$pause_file" \
+ FAKE_VERSION_FORK_PID_FILE="$fork_pid_file" \
+ >"$fixture/out" 2>"$fixture/err" &
+installer_job=$!
+background_pids+=( "$installer_job" )
+for _ in {1..500}; do
+ [[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$fork_pid_file" && -f "$pause_file" ]] && break
+ sleep 0.01
+done
+[[ -f "$owner_pid_file" && -f "$watchdog_pid_file" && -f "$fork_pid_file" && -f "$pause_file" ]] || fail 'signal fixture did not reach its forking probe'
+owner_pid="$(cat "$owner_pid_file")"
+watchdog_pid="$(cat "$watchdog_pid_file")"
+fork_pid="$(cat "$fork_pid_file")"
+kill -TERM "$owner_pid"
+wait "$installer_job" 2>/dev/null || true
+background_pids=()
+wait_for_pid_gone "$watchdog_pid"
+wait_for_pid_gone "$fork_pid"
+wait_for_path_gone "$install_root/.install.lock"
+if find "$install_root" \( -name '.installing.*' -o -name '.version-probe.*' \) | grep -q .; then
+ fail 'signal during probe left staging or result residue'
+fi
+after="$(tree_digest "$destination")"
+[[ "$before" = "$after" ]] || fail 'signal during probe changed the prior installation'
+
+for failure in layout version; do
+ setup_fixture "preserve-$failure"
+ run_installer >/dev/null
+ printf '%s\n' stale >"$marker"
+ before="$(tree_digest "$destination")"
+ if [[ "$failure" = layout ]]; then
+ run_installer FAKE_WRONG_LAYOUT=1 >"$fixture/out" 2>"$fixture/err" && fail 'wrong layout unexpectedly succeeded over old install'
+ else
+ run_installer FAKE_REPORTED_VERSION=149.0.0.0 >"$fixture/out" 2>"$fixture/err" && fail 'wrong version unexpectedly succeeded over old install'
+ fi
+ after="$(tree_digest "$destination")"
+ [[ "$before" = "$after" ]] || fail "$failure failure changed prior destination"
+done
+
+setup_fixture stale-reaper
+mkdir -p "$install_root/.install.lock"
+printf 'schema=1\npid=999999\ntoken=dead-owner\n' >"$install_root/.install.lock/owner"
+pause_file="$fixture/reaper.pause"
+run_installer HTML_API_FUZZ_CHROME_TEST_REAPER_PAUSE_FILE="$pause_file" >"$fixture/one.out" 2>"$fixture/one.err" &
+first_pid=$!
+background_pids+=( "$first_pid" )
+for _ in {1..500}; do
+ [[ -e "$pause_file" ]] && break
+ sleep 0.01
+done
+[[ -e "$pause_file" && -d "$install_root/.install.lock/.reaper" ]] || fail 'stale reaper did not acquire election'
+if mkdir "$install_root/.install.lock" 2>/dev/null; then
+ fail 'contender replaced lock while stale reaper held election'
+fi
+run_installer >"$fixture/two.out" 2>"$fixture/two.err" &
+second_pid=$!
+background_pids+=( "$second_pid" )
+sleep 0.1
+[[ -d "$install_root/.install.lock/.reaper" ]] || fail 'second contender disturbed elected reaper'
+rm -f "$pause_file"
+wait "$first_pid"
+wait "$second_pid"
+background_pids=()
+[[ -x "$installed_executable" && ! -d "$install_root/.install.lock" ]] || fail 'stale reaper/concurrent installer did not converge'
+
+setup_fixture empty-lock
+mkdir -p "$install_root/.install.lock"
+if run_installer HTML_API_FUZZ_CHROME_LOCK_ATTEMPTS=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'ownerless acquisition-gap lock was unsafely reaped'
+fi
+[[ -d "$install_root/.install.lock" && ! -e "$installed_executable" ]] || fail 'ownerless lock did not fail closed for manual recovery'
+
+# Exercise a live-owner contender through publication and atomic lock release.
+setup_fixture release-vs-reaper
+version_pause="$fixture/version.pause"
+run_installer FAKE_VERSION_PAUSE_FILE="$version_pause" FAKE_VERSION_PAUSE_ON_CALL=1 >"$fixture/one.out" 2>"$fixture/one.err" &
+first_pid=$!
+background_pids+=( "$first_pid" )
+for _ in {1..500}; do
+ [[ -e "$version_pause" ]] && break
+ sleep 0.01
+done
+[[ -e "$version_pause" ]] || fail 'live owner did not reach version pause'
+run_installer >"$fixture/two.out" 2>"$fixture/two.err" &
+second_pid=$!
+background_pids+=( "$second_pid" )
+sleep 0.2
+[[ -d "$install_root/.install.lock" ]] || fail 'live-owner contender disturbed the held lock'
+rm -f "$version_pause"
+wait "$first_pid"
+wait "$second_pid"
+background_pids=()
+[[ -x "$installed_executable" && ! -d "$install_root/.install.lock" ]] || fail 'release-vs-reaper interleaving left a lock or incomplete install'
+
+setup_fixture stuck-reaper
+started_at=$SECONDS
+if run_installer HTML_API_FUZZ_CHROME_TEST_STICK_REAPER_ON_RELEASE=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'stuck release reaper reported success'
+fi
+elapsed=$((SECONDS - started_at))
+[[ "$elapsed" -lt 5 ]] || fail 'stuck release reaper did not fail within its bound'
+grep -q 'Timed out serializing' "$fixture/err" || fail 'stuck release reaper lacked bounded-failure diagnostic'
+
+setup_fixture failed-release-move
+if run_installer HTML_API_FUZZ_CHROME_TEST_FAIL_RELEASE_MV=1 >"$fixture/out" 2>"$fixture/err"; then
+ fail 'failed authenticated lock move reported success'
+fi
+grep -q 'Could not atomically release' "$fixture/err" || fail 'failed lock move lacked fail-closed diagnostic'
+[[ -d "$install_root/.install.lock" ]] || fail 'failed lock move discarded the authenticated lock'
+run_installer >"$fixture/recovered.out"
+[[ ! -d "$install_root/.install.lock" ]] || fail 'stale failed-release lock was not recoverable'
+
+setup_fixture concurrent
+run_installer FAKE_VERSION_SLEEP=0.2 >"$fixture/one.out" 2>"$fixture/one.err" &
+first_pid=$!
+background_pids+=( "$first_pid" )
+run_installer FAKE_VERSION_SLEEP=0.2 >"$fixture/two.out" 2>"$fixture/two.err" &
+second_pid=$!
+background_pids+=( "$second_pid" )
+wait "$first_pid"
+wait "$second_pid"
+background_pids=()
+[[ -x "$installed_executable" && -f "$marker" && ! -d "$install_root/.install.lock" ]] || fail 'concurrent installers left incomplete state'
+[[ "$(cat "$fixture/one.out")" = "$installed_executable" && "$(cat "$fixture/two.out")" = "$installed_executable" ]] || fail 'concurrent installers disagreed'
+
+printf '%s\n' 'OK chrome-install-integrity-smoke'
From f0e74256d0a9fda917287b9e3ee3723ce68f40c4 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Thu, 16 Jul 2026 04:27:44 +0200
Subject: [PATCH 010/149] oracles: supervise source adapters
---
tools/html-api-fuzz/README.md | 82 +-
tools/html-api-fuzz/launcher.php | 2 +-
tools/html-api-fuzz/lib/CommonCrawlRunner.php | 76 +-
tools/html-api-fuzz/lib/OracleRenderer.php | 1657 ++++++++++++++---
tools/html-api-fuzz/lib/ResultStore.php | 7 +-
tools/html-api-fuzz/lib/Worker.php | 24 +-
tools/html-api-fuzz/minimize.php | 91 +-
.../oracle-process-supervisor.php | 1307 +++++++++++++
tools/html-api-fuzz/replay.php | 50 +-
tools/html-api-fuzz/runner.php | 2 +-
.../tests/commoncrawl-analysis-smoke.php | 23 +-
.../commoncrawl-source-oracles-smoke.php | 247 +++
.../tests/generator-policy-smoke.php | 74 +-
.../tests/lexbor-oracle-smoke.php | 23 +-
.../tests/oracle-renderer-protocol-smoke.php | 383 ++++
.../tests/result-store-smoke.php | 82 +-
tools/html-api-fuzz/worker.php | 11 +-
17 files changed, 3805 insertions(+), 336 deletions(-)
create mode 100644 tools/html-api-fuzz/oracle-process-supervisor.php
create mode 100644 tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
create mode 100644 tools/html-api-fuzz/tests/oracle-renderer-protocol-smoke.php
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
index 26c0ef81e0e4a..0005a12ee543f 100644
--- a/tools/html-api-fuzz/README.md
+++ b/tools/html-api-fuzz/README.md
@@ -15,14 +15,18 @@ No browser, Playwright, Node, or `wp-env` is involved.
isolation.
- Run from the repository root.
- Optional source-built Lexbor oracle: `git`, `cmake`, and a C compiler.
+- Optional source-built html5ever oracle: the pinned Rust toolchain installed by
+ `tools/html-api-fuzz/oracles/html5ever/install-rust.sh`, or matching Rust and
+ Cargo 1.88.0 executables.
## Common Crawl with cc-analyzer
`commoncrawl-analysis.php` is an analysis callback for `cc-analyzer.phar`. It
accepts the analyzer's `CcAnalyzer\Analysis\HtmlAnalysisInput` value object,
runs the raw response body through `WP_HTML_Processor` in full-document mode,
-compares the resulting tree with Lexbor, and records the Common Crawl
-provenance needed to locate or replay the document.
+compares the resulting tree with the selected source oracle, and records the
+Common Crawl provenance needed to locate or replay the document. Lexbor is the
+default; `html5ever-source` is an equally supported independent adapter.
Build the oracle from the current upstream `master` first:
@@ -30,6 +34,13 @@ Build the oracle from the current upstream `master` first:
tools/html-api-fuzz/oracles/lexbor/build.sh
```
+Or build the fully pinned html5ever oracle:
+
+```sh
+tools/html-api-fuzz/oracles/html5ever/install-rust.sh
+tools/html-api-fuzz/oracles/html5ever/build.sh
+```
+
The build resolves the moving ref, uses commit-keyed build/install directories,
refuses a dirty Lexbor checkout, and writes `build/build-manifest.json` with the
requested ref, resolved commit, upstream URL, build time, compiler, CMake
@@ -59,9 +70,20 @@ Each accepted document runs in a separate PHP child with its own memory and
wall-clock limit. The callback writes `input.bin` and an initial replay before
starting that child. A timeout, memory exhaustion, crash, or signal is retained
as a finding without terminating cc-analyzer or losing the triggering bytes.
-The child is a new process-group leader, so its Lexbor descendant is terminated
-with it. Stdout/stderr are continuously drained to `worker.log`; only bounded
-tails remain in the long-lived analyzer process.
+The child is a new process-group leader, so its source-oracle descendants are
+terminated with it. Stdout/stderr are continuously drained to `worker.log`;
+only bounded tails remain in the long-lived analyzer process.
+
+Each source-oracle identity probe and render also runs through a dedicated
+POSIX/PCNTL supervisor. It creates a private `0700` ownership root, copies the
+descriptor-stable executable and supervisor to `0500` snapshots, starts a new
+session with a persistent process-group anchor, and does not release the target
+until authenticated state has been durably published. Cleanup reauthenticates
+the exact process identities, sends group `TERM`, waits 250 ms, then sends one
+`KILL` and verifies group/root absence. Owner EOF uses the same path. Uncertain
+identity or cleanup state is an infrastructure failure and retains evidence
+instead of signaling an unverified PID. Source stdout is capped at exactly
+64 MiB and stderr at 1 MiB; either overflow is an infrastructure failure.
The Common Crawl adapter uses Lexbor by default. Its environment is:
@@ -69,10 +91,18 @@ The Common Crawl adapter uses Lexbor by default. Its environment is:
absent, a unique directory is created under `artifacts/html-api-commoncrawl`.
- `HTML_API_CC_RUN_ID`: explicit immutable run identifier. When the analyzer
provides an output directory, the default is a stable hash of that path.
+- `HTML_API_CC_ORACLE`: `lexbor-source` (default), `html5ever-source`, or the
+ `php-dom` testing/debug escape hatch.
- `HTML_API_FUZZ_LEXBOR_ORACLE`: non-default Lexbor oracle binary path.
+- `HTML_API_FUZZ_HTML5EVER_ORACLE`: non-default html5ever oracle binary path.
- `HTML_API_CC_EXPECT_LEXBOR_COMMIT`: optional exact commit assertion; startup
- fails if the executable reports another commit.
-- `HTML_API_CC_ORACLE_TIMEOUT_MS`: Lexbor subprocess timeout; default `10000`.
+ fails if the selected Lexbor executable reports another commit. Setting this
+ while another oracle is selected also fails.
+- `HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256`: optional SHA-256 of the complete
+ normalized selected-oracle metadata envelope. This generic pin works for
+ PHP DOM, Lexbor, and html5ever.
+- `HTML_API_CC_ORACLE_TIMEOUT_MS`: source-oracle subprocess timeout; default
+ `10000`.
- `HTML_API_CC_PROCESS_TIMEOUT_MS`: whole-document worker timeout; default
`30000`.
- `HTML_API_CC_MEMORY_LIMIT`: PHP memory limit for each worker; default `256M`.
@@ -93,17 +123,14 @@ The Common Crawl adapter uses Lexbor by default. Its environment is:
- `HTML_API_CC_MAX_KEEP_PER_SIGNATURE`: retained examples per distinct
signature; default `3`.
- `HTML_API_CC_RETAIN_ALL`: retain passing inputs too; default `0`.
-- `HTML_API_CC_ORACLE=php-dom`: testing/debug escape hatch. Normal Common
- Crawl runs should leave this unset so the independent source-built Lexbor
- oracle is used.
- `CC_ANALYZER_VERSION`, `CC_ANALYZER_CRAWL`, and `CC_ANALYZER_INVOCATION`:
optional provenance strings recorded verbatim in the immutable run config.
The output root contains:
- `configuration.json`: run ID and exact configuration fingerprint plus Git,
- PHP, Lexbor build manifest/hash, limits, and analyzer provenance. Reusing the
- directory with different settings fails at startup.
+ PHP, normalized selected-oracle identity/hash, limits, and analyzer
+ provenance. Reusing the directory with different settings fails at startup.
- `commoncrawl-summary.ndjson`: one locked, append-only record per document;
safe for concurrent analyzer workers.
- `coverage.json`: aggregate total, covered, failed, and per-status counts.
@@ -131,11 +158,14 @@ process-group isolation and synthesizes the same timeout/OOM/crash result when
the Worker produces none. `--worker-script`, `--memory-limit`, and
`--timeout-ms` are explicit diagnostic overrides.
-Replay also verifies the recorded oracle identity before starting the Worker.
-For a source-built Lexbor oracle, both the resolved Lexbor commit and executable
-SHA-256 must match. Use `--allow-oracle-mismatch` only for a deliberate
-diagnostic comparison; the resulting replay records the oracle actually used
-and retains the source identity in its provenance.
+Replay and minimization verify the complete recorded oracle identity before
+creating or claiming an output directory. Lexbor pins its resolved commit,
+manifest fields, self-report, and executable SHA-256. html5ever additionally
+pins the checked source/lock/toolchain hashes, direct crate versions/checksums,
+build identity, and executable SHA-256. Use `--allow-oracle-mismatch` only for a
+deliberate diagnostic comparison; outputs retain `sourceOracle`,
+`actualOracle`, and the exact mismatch reasons. A second identity change while
+a Worker is running is recorded separately and invalidates any stale signature.
Transport charset, content type, target URI, WARC record ID, analyzer state
key, and source range are metadata only. The comparison intentionally feeds
@@ -180,6 +210,20 @@ Use `--lexbor-oracle-bin PATH` or `HTML_API_FUZZ_LEXBOR_ORACLE` when the
oracle binary is not at
`tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle`.
+Build and run against the pinned html5ever oracle:
+
+```sh
+tools/html-api-fuzz/oracles/html5ever/install-rust.sh
+tools/html-api-fuzz/oracles/html5ever/build.sh
+php tools/html-api-fuzz/worker.php --seed 1 --dom-oracle html5ever-source --output-dir artifacts/html-api-fuzz/seed-1-html5ever
+php tools/html-api-fuzz/runner.php --max-seeds 100 --dom-oracle html5ever-source --timeout-ms 10000
+```
+
+Use `--html5ever-oracle-bin PATH` or
+`HTML_API_FUZZ_HTML5EVER_ORACLE` for a non-default build location. Source
+adapters add process-supervision overhead, so give `runner.php` a whole-worker
+`--timeout-ms` budget large enough to cover the configured oracle deadline.
+
Run indefinitely:
```sh
@@ -307,7 +351,9 @@ The runner writes:
`signature_hash` and `family_key` are indexed columns for grouping
failures without `json_extract`. `oracle_kind`, `oracle_version`,
`oracle_commit`, and `oracle_binary` record which oracle generated the
- summary, including for passing rows whose JSON payloads are pruned. The
+ summary, including for passing rows whose JSON payloads are pruned.
+ `oracle_commit` is the Lexbor commit or html5ever build identity, while
+ `oracle_binary` is the executable SHA-256 rather than a host path. The
watcher tails these stores
incrementally by row id. (`summary.ndjson` files from older runs are still
scanned.) Durability is `synchronous=NORMAL`: an OS crash (not a process
diff --git a/tools/html-api-fuzz/launcher.php b/tools/html-api-fuzz/launcher.php
index 6eadad14e6a53..133e794367c15 100755
--- a/tools/html-api-fuzz/launcher.php
+++ b/tools/html-api-fuzz/launcher.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/lib/autoload.php';
function html_api_fuzz_launcher_usage(): void {
- echo "Usage: php tools/html-api-fuzz/launcher.php [--lanes N] [--output-dir DIR] [--duration-seconds N] [--max-seeds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source] [--lexbor-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--watcher] [--triage-oracle-findings]\n";
+ echo "Usage: php tools/html-api-fuzz/launcher.php [--lanes N] [--output-dir DIR] [--duration-seconds N] [--max-seeds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--watcher] [--triage-oracle-findings]\n";
echo "Create OUTPUT_DIR/STOP (see stop.php) to stop all lanes gracefully: each finishes its current batch and exits.\n";
echo "--max-keep-per-signature is applied per lane; a signature seen in every lane keeps up to N x lanes exemplar directories.\n";
echo "--triage-oracle-findings passes oracle findings to the watcher/minimizer when --watcher is used.\n";
diff --git a/tools/html-api-fuzz/lib/CommonCrawlRunner.php b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
index 7e10461d6195c..91931e72850f7 100644
--- a/tools/html-api-fuzz/lib/CommonCrawlRunner.php
+++ b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
@@ -90,25 +90,38 @@ public static function from_environment(): self {
if ( is_string( $oracle_bin ) && '' !== $oracle_bin ) {
$oracle_options['lexbor-oracle-bin'] = $oracle_bin;
}
+ $html5ever_oracle_bin = getenv( 'HTML_API_FUZZ_HTML5EVER_ORACLE' );
+ if ( is_string( $html5ever_oracle_bin ) && '' !== $html5ever_oracle_bin ) {
+ $oracle_options['html5ever-oracle-bin'] = $html5ever_oracle_bin;
+ }
$oracle = OracleRenderer::from_options( $oracle_options );
$metadata = $oracle->metadata();
- if (
- OracleRenderer::KIND_LEXBOR_SOURCE === $oracle_kind &&
- ( false === ( $metadata['available'] ?? true ) || isset( $metadata['versionError'] ) )
- ) {
+ if ( true !== ( $metadata['available'] ?? false ) ) {
throw new \RuntimeException(
- 'Lexbor oracle is unavailable. Run tools/html-api-fuzz/oracles/lexbor/build.sh '
- . 'or set HTML_API_FUZZ_LEXBOR_ORACLE to an executable oracle binary.'
+ "Selected {$oracle_kind} oracle is unavailable: " . (string) ( $metadata['error'] ?? 'unknown identity error' )
);
}
$expected_commit = getenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT' );
if (
is_string( $expected_commit ) && '' !== $expected_commit &&
- $expected_commit !== ( $metadata['lexborCommit'] ?? null )
+ (
+ OracleRenderer::KIND_LEXBOR_SOURCE !== $oracle_kind ||
+ $expected_commit !== ( $metadata['identity']['lexborCommit'] ?? null )
+ )
) {
throw new \RuntimeException( 'Lexbor oracle commit does not match HTML_API_CC_EXPECT_LEXBOR_COMMIT.' );
}
+ $expected_identity_sha256 = getenv( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256' );
+ if ( is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 && 1 !== preg_match( '/^[0-9a-fA-F]{64}$/', $expected_identity_sha256 ) ) {
+ throw new \InvalidArgumentException( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256 must be a SHA-256 hex digest.' );
+ }
+ if (
+ is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 &&
+ ! hash_equals( strtolower( $expected_identity_sha256 ), OracleRenderer::identity_sha256( $metadata ) )
+ ) {
+ throw new \RuntimeException( 'Oracle identity does not match HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256.' );
+ }
$checks = self::environment_string( 'HTML_API_CC_CHECKS', 'sampled' );
if ( ! in_array( $checks, array( 'baseline', 'full', 'sampled' ), true ) ) {
@@ -179,6 +192,7 @@ public function analyze_document( object $document ): array {
$this->persist_initial_input( $staging_dir, $body, $metadata, $seed, $checks );
$process = run_php_process( $this->worker_args( $staging_dir, $seed, $checks ), repo_root(), $this->process_timeout_ms, $staging_dir . '/worker.log', 1048576, true );
$result = $this->load_or_synthesize_result( $staging_dir, $process, $body, $seed, $checks );
+ $result = $this->enforce_worker_oracle_identity( $result );
$result['profile'] = 'commoncrawl';
$result['inputSource'] = 'commoncrawl';
$result['commonCrawl'] = $metadata;
@@ -210,6 +224,24 @@ public function analyze_document( object $document ): array {
}
}
+ private function enforce_worker_oracle_identity( array $result ): array {
+ $expected = $this->oracle->metadata();
+ $actual = $result['oracle'] ?? null;
+ $mismatches = OracleRenderer::identity_mismatches( $expected, is_array( $actual ) ? $actual : array() );
+ if ( empty( $mismatches ) ) {
+ return $result;
+ }
+ $result['ok'] = false;
+ $result['status'] = 'oracle-identity-drift';
+ $result['failureClass'] = 'oracle-identity-drift';
+ $result['failureSnippet'] = implode( '; ', $mismatches );
+ $result['sourceOracle'] = $expected;
+ $result['actualOracle'] = $actual;
+ $result['oracleIdentityMismatches'] = $mismatches;
+ unset( $result['signature'], $result['oracleFinding'], $result['comparison'] );
+ return $result;
+ }
+
private function worker_args( string $staging_dir, int $seed, string $checks ): array {
$args = array(
'-d', 'memory_limit=' . $this->memory_limit,
@@ -241,6 +273,15 @@ private function persist_initial_input( string $staging_dir, string $body, array
$input_path = $staging_dir . '/input.bin';
write_file_atomic( $input_path, $body );
$oracle_options = $this->oracle->replay_options();
+ $replay_options = array_merge(
+ array( 'failUnsupported' => false, 'checks' => $checks ),
+ $oracle_options,
+ array(
+ 'memoryLimit' => $this->memory_limit,
+ 'processTimeoutMs' => $this->process_timeout_ms,
+ 'workerScript' => realpath( $this->worker_script ) ?: $this->worker_script,
+ )
+ );
write_json_file_atomic(
$staging_dir . '/replay.json',
array(
@@ -265,16 +306,7 @@ private function persist_initial_input( string $staging_dir, string $body, array
'inputPreview' => preview_bytes( $body ),
'limits' => $this->limits,
'oracle' => $this->oracle->metadata(),
- 'options' => array(
- 'failUnsupported' => false,
- 'checks' => $checks,
- 'domOracle' => $oracle_options['domOracle'] ?? OracleRenderer::KIND_LEXBOR_SOURCE,
- 'lexborOracleBin' => $oracle_options['lexborOracleBin'] ?? null,
- 'oracleTimeoutMs' => $oracle_options['oracleTimeoutMs'] ?? null,
- 'memoryLimit' => $this->memory_limit,
- 'processTimeoutMs' => $this->process_timeout_ms,
- 'workerScript' => realpath( $this->worker_script ) ?: $this->worker_script,
- ),
+ 'options' => $replay_options,
'commonCrawl' => $metadata,
'status' => 'pending-worker',
)
@@ -354,6 +386,12 @@ private function publish_staging( string $staging_dir, array &$result, array $me
$replay['repoCommit'] = $this->git_metadata['commit'] ?? null;
$replay['repoDirty'] = $this->git_metadata['dirty'] ?? null;
$replay['commonCrawl'] = $metadata;
+ $replay['oracle'] = $this->oracle->metadata();
+ if ( 'oracle-identity-drift' === ( $result['failureClass'] ?? null ) ) {
+ $replay['sourceOracle'] = $result['sourceOracle'] ?? $this->oracle->metadata();
+ $replay['actualOracle'] = $result['actualOracle'] ?? null;
+ $replay['oracleIdentityMismatches'] = $result['oracleIdentityMismatches'] ?? array();
+ }
$replay['options']['checks'] = $result['checks'] ?? $this->checks_for_seed( (int) ( $result['seed'] ?? 1 ) );
$replay['options']['memoryLimit'] = $this->memory_limit;
$replay['options']['processTimeoutMs'] = $this->process_timeout_ms;
@@ -473,6 +511,9 @@ private function summary_from_result( array $result, array $metadata, ?string $a
'signature' => $result['signature'] ?? null,
'oracleFinding' => $result['oracleFinding'] ?? null,
'oracle' => $result['oracle'] ?? $this->oracle->metadata(),
+ 'sourceOracle' => $result['sourceOracle'] ?? null,
+ 'actualOracle' => $result['actualOracle'] ?? null,
+ 'oracleIdentityMismatches' => $result['oracleIdentityMismatches'] ?? array(),
'artifactsRetained' => null !== $artifact_dir,
'artifactDir' => $artifact_dir,
'durationMs' => $result['durationMs'] ?? null,
@@ -599,6 +640,7 @@ private function initialize_configuration(): void {
'repo' => $this->git_metadata,
'phpVersion' => PHP_VERSION,
'oracle' => $this->oracle->metadata(),
+ 'oracleIdentitySha256' => OracleRenderer::identity_sha256( $this->oracle->metadata() ),
'limits' => $this->limits,
'maxInputBytes' => $this->max_input_bytes,
'maxKeepPerSignature' => $this->max_keep_per_signature,
diff --git a/tools/html-api-fuzz/lib/OracleRenderer.php b/tools/html-api-fuzz/lib/OracleRenderer.php
index a22aa3a7d2db4..9bdf39ab46d71 100644
--- a/tools/html-api-fuzz/lib/OracleRenderer.php
+++ b/tools/html-api-fuzz/lib/OracleRenderer.php
@@ -1,94 +1,242 @@
kind = $kind;
- $this->lexbor_oracle_bin = $lexbor_oracle_bin;
- $this->timeout_ms = $timeout_ms;
+ private function __construct( string $input, int $maximum_depth ) {
+ $this->input = $input;
+ $this->length = strlen( $input );
+ $this->maximum_depth = $maximum_depth;
}
- public static function from_options( array $options ): self {
- $kind = option_string( $options, 'dom-oracle', self::KIND_PHP_DOM );
- if ( ! in_array( $kind, self::kinds(), true ) ) {
- throw new \InvalidArgumentException( 'Expected --dom-oracle to be php-dom or lexbor-source.' );
+ public static function decode( string $input, int $maximum_depth = 64 ) {
+ if ( 1 !== preg_match( '//u', $input ) ) {
+ throw new \RuntimeException( 'JSON is not valid UTF-8.' );
+ }
+ $parser = new self( $input, $maximum_depth );
+ $value = $parser->parse_value( 0 );
+ $parser->skip_whitespace();
+ if ( $parser->offset !== $parser->length ) {
+ throw new \RuntimeException( 'JSON contains trailing values or bytes.' );
}
+ return $value;
+ }
- $lexbor_oracle_bin = option_string( $options, 'lexbor-oracle-bin', getenv( 'HTML_API_FUZZ_LEXBOR_ORACLE' ) ?: null );
- if ( self::KIND_LEXBOR_SOURCE === $kind && ( null === $lexbor_oracle_bin || '' === $lexbor_oracle_bin ) ) {
- $lexbor_oracle_bin = repo_root() . '/tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle';
+ private function parse_value( int $depth ) {
+ if ( $depth > $this->maximum_depth ) {
+ throw new \RuntimeException( 'JSON exceeds its nesting-depth limit.' );
+ }
+ $this->skip_whitespace();
+ if ( $this->offset >= $this->length ) {
+ throw new \RuntimeException( 'JSON ended before a value.' );
+ }
+ $byte = $this->input[ $this->offset ];
+ if ( '{' === $byte ) {
+ return $this->parse_object( $depth + 1 );
}
+ if ( '[' === $byte ) {
+ return $this->parse_array( $depth + 1 );
+ }
+ if ( '"' === $byte ) {
+ return $this->parse_string();
+ }
+ foreach ( array( 'true' => true, 'false' => false, 'null' => null ) as $literal => $value ) {
+ if ( substr_compare( $this->input, $literal, $this->offset, strlen( $literal ) ) === 0 ) {
+ $this->offset += strlen( $literal );
+ return $value;
+ }
+ }
+ if ( '-' === $byte || ( $byte >= '0' && $byte <= '9' ) ) {
+ return $this->parse_number();
+ }
+ throw new \RuntimeException( 'JSON contains an invalid value.' );
+ }
- return new self(
- $kind,
- $lexbor_oracle_bin,
- option_int( $options, 'oracle-timeout-ms', 2500 )
- );
+ private function parse_object( int $depth ): array {
+ ++$this->offset;
+ $this->skip_whitespace();
+ $result = array();
+ $seen = array();
+ if ( $this->consume( '}' ) ) {
+ return $result;
+ }
+ while ( true ) {
+ $this->skip_whitespace();
+ if ( $this->offset >= $this->length || '"' !== $this->input[ $this->offset ] ) {
+ throw new \RuntimeException( 'JSON object key is not a string.' );
+ }
+ $key = $this->parse_string();
+ $seen_key = "key\0" . $key;
+ if ( isset( $seen[ $seen_key ] ) ) {
+ throw new \RuntimeException( 'JSON contains a duplicate object key.' );
+ }
+ $seen[ $seen_key ] = true;
+ $this->skip_whitespace();
+ if ( ! $this->consume( ':' ) ) {
+ throw new \RuntimeException( 'JSON object key is missing its colon.' );
+ }
+ $result[ $key ] = $this->parse_value( $depth );
+ $this->skip_whitespace();
+ if ( $this->consume( '}' ) ) {
+ return $result;
+ }
+ if ( ! $this->consume( ',' ) ) {
+ throw new \RuntimeException( 'JSON object is missing a comma.' );
+ }
+ }
}
- public static function kinds(): array {
- return array( self::KIND_PHP_DOM, self::KIND_LEXBOR_SOURCE );
+ private function parse_array( int $depth ): array {
+ ++$this->offset;
+ $this->skip_whitespace();
+ $result = array();
+ if ( $this->consume( ']' ) ) {
+ return $result;
+ }
+ while ( true ) {
+ $result[] = $this->parse_value( $depth );
+ $this->skip_whitespace();
+ if ( $this->consume( ']' ) ) {
+ return $result;
+ }
+ if ( ! $this->consume( ',' ) ) {
+ throw new \RuntimeException( 'JSON array is missing a comma.' );
+ }
+ }
}
- /** Return reasons the current oracle cannot faithfully replay recorded output. */
- public static function identity_mismatches( $recorded, array $current ): array {
- if ( ! is_array( $recorded ) ) {
- return array( 'recorded oracle metadata is missing' );
+ private function parse_string(): string {
+ $start = $this->offset++;
+ while ( $this->offset < $this->length ) {
+ $byte = ord( $this->input[ $this->offset ] );
+ if ( 0x22 === $byte ) {
+ ++$this->offset;
+ $raw = substr( $this->input, $start, $this->offset - $start );
+ try {
+ $value = json_decode( $raw, true, 2, JSON_THROW_ON_ERROR );
+ } catch ( \JsonException $error ) {
+ throw new \RuntimeException( 'JSON contains a malformed string escape.', 0, $error );
+ }
+ if ( ! is_string( $value ) ) {
+ throw new \RuntimeException( 'JSON string could not be decoded.' );
+ }
+ return $value;
+ }
+ if ( $byte < 0x20 ) {
+ throw new \RuntimeException( 'JSON string contains an unescaped control byte.' );
+ }
+ if ( 0x5c === $byte ) {
+ ++$this->offset;
+ if ( $this->offset >= $this->length ) {
+ break;
+ }
+ $escape = $this->input[ $this->offset ];
+ if ( 'u' === $escape ) {
+ if ( $this->offset + 4 >= $this->length || 1 !== preg_match( '/^[0-9a-fA-F]{4}$/D', substr( $this->input, $this->offset + 1, 4 ) ) ) {
+ throw new \RuntimeException( 'JSON contains a malformed Unicode escape.' );
+ }
+ $this->offset += 4;
+ } elseif ( false === strpos( '"\\/bfnrt', $escape ) ) {
+ throw new \RuntimeException( 'JSON contains an unknown string escape.' );
+ }
+ }
+ ++$this->offset;
}
+ throw new \RuntimeException( 'JSON string is unterminated.' );
+ }
- $recorded_kind = $recorded['kind'] ?? null;
- $current_kind = $current['kind'] ?? null;
- if ( ! is_string( $recorded_kind ) || ! in_array( $recorded_kind, self::kinds(), true ) ) {
- return array( 'recorded oracle kind is invalid' );
+ private function parse_number() {
+ $remaining = substr( $this->input, $this->offset );
+ if ( 1 !== preg_match( '/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/', $remaining, $matches ) ) {
+ throw new \RuntimeException( 'JSON contains a malformed number.' );
}
- if ( ! is_string( $current_kind ) || ! in_array( $current_kind, self::kinds(), true ) ) {
- return array( 'current oracle kind is invalid' );
+ $token = $matches[0];
+ $this->offset += strlen( $token );
+ try {
+ $value = json_decode( $token, true, 2, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING );
+ } catch ( \JsonException $error ) {
+ throw new \RuntimeException( 'JSON number could not be decoded.', 0, $error );
}
- if ( $recorded_kind !== $current_kind ) {
- return array( "oracle kind differs (recorded {$recorded_kind}, current {$current_kind})" );
+ if ( is_float( $value ) && ! is_finite( $value ) ) {
+ throw new \RuntimeException( 'JSON number is not finite.' );
}
- if ( self::KIND_LEXBOR_SOURCE !== $current_kind ) {
- return array();
+ return $value;
+ }
+
+ private function skip_whitespace(): void {
+ while ( $this->offset < $this->length && false !== strpos( " \t\r\n", $this->input[ $this->offset ] ) ) {
+ ++$this->offset;
}
+ }
- $mismatches = array();
- if ( false === ( $current['available'] ?? true ) ) {
- $mismatches[] = 'current Lexbor oracle is unavailable';
+ private function consume( string $byte ): bool {
+ if ( $this->offset < $this->length && $byte === $this->input[ $this->offset ] ) {
+ ++$this->offset;
+ return true;
}
- if ( array_key_exists( 'versionError', $current ) ) {
- $mismatches[] = 'current Lexbor oracle failed self-verification';
+ return false;
+ }
+}
+
+class OracleRenderer {
+ public const KIND_PHP_DOM = 'php-dom';
+ public const KIND_LEXBOR_SOURCE = 'lexbor-source';
+ public const KIND_HTML5EVER_SOURCE = 'html5ever-source';
+
+ private const METADATA_SCHEMA_VERSION = 1;
+ private const DEFAULT_TIMEOUT_MS = 2500;
+ private const SOURCE_STDOUT_MAX_BYTES = 67108864;
+ private const SOURCE_STDERR_MAX_BYTES = 1048576;
+ private const CONTROL_MAX_BYTES = 65536;
+ private const MANIFEST_MAX_BYTES = 1048576;
+ private const CLEANUP_GRACE_MS = 5000;
+
+ private string $kind;
+ private ?string $source_binary;
+ private int $timeout_ms;
+ private ?array $metadata = null;
+ private ?string $source_manifest_sha256 = null;
+
+ private function __construct( string $kind, ?string $source_binary = null, int $timeout_ms = self::DEFAULT_TIMEOUT_MS ) {
+ $this->kind = $kind;
+ $this->source_binary = $source_binary;
+ $this->timeout_ms = $timeout_ms;
+ }
+
+ public static function from_options( array $options ): self {
+ $kind = option_string( $options, 'dom-oracle', self::KIND_PHP_DOM );
+ if ( ! in_array( $kind, self::kinds(), true ) ) {
+ throw new \InvalidArgumentException( 'Expected --dom-oracle to be php-dom, lexbor-source, or html5ever-source.' );
}
- foreach (
- array(
- 'lexborCommit' => array( '/^[0-9a-f]{40}$/', 'Lexbor commit' ),
- 'binarySha256' => array( '/^[0-9a-f]{64}$/', 'Lexbor binary SHA-256' ),
- ) as $field => $validation
- ) {
- list( $pattern, $label ) = $validation;
- $recorded_value = $recorded[ $field ] ?? null;
- $current_value = $current[ $field ] ?? null;
- if ( ! is_string( $recorded_value ) || ! preg_match( $pattern, $recorded_value ) ) {
- $mismatches[] = "recorded {$label} is missing or invalid";
- continue;
- }
- if ( ! is_string( $current_value ) || ! preg_match( $pattern, $current_value ) ) {
- $mismatches[] = "current {$label} is missing or invalid";
- continue;
+
+ $source_binary = null;
+ if ( self::KIND_LEXBOR_SOURCE === $kind ) {
+ $source_binary = option_string( $options, 'lexbor-oracle-bin', getenv( 'HTML_API_FUZZ_LEXBOR_ORACLE' ) ?: null );
+ if ( null === $source_binary || '' === $source_binary ) {
+ $source_binary = repo_root() . '/tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle';
}
- if ( $recorded_value !== $current_value ) {
- $mismatches[] = "{$label} differs";
+ } elseif ( self::KIND_HTML5EVER_SOURCE === $kind ) {
+ $source_binary = option_string( $options, 'html5ever-oracle-bin', getenv( 'HTML_API_FUZZ_HTML5EVER_ORACLE' ) ?: null );
+ if ( null === $source_binary || '' === $source_binary ) {
+ $source_binary = repo_root() . '/tools/html-api-fuzz/oracles/html5ever/build/html5ever-tree-oracle';
}
}
- return $mismatches;
+ $timeout_ms = option_int( $options, 'oracle-timeout-ms', self::DEFAULT_TIMEOUT_MS );
+ if ( $timeout_ms < 1 ) {
+ throw new \InvalidArgumentException( 'Expected --oracle-timeout-ms to be positive.' );
+ }
+
+ return new self( $kind, $source_binary, $timeout_ms );
+ }
+
+ public static function kinds(): array {
+ return array( self::KIND_PHP_DOM, self::KIND_LEXBOR_SOURCE, self::KIND_HTML5EVER_SOURCE );
}
public function kind(): string {
@@ -105,74 +253,99 @@ public function metadata(): array {
}
if ( self::KIND_PHP_DOM === $this->kind ) {
+ $available = class_exists( 'Dom\\HTMLDocument' );
$this->metadata = array(
- 'kind' => self::KIND_PHP_DOM,
- 'phpVersion' => PHP_VERSION,
- 'domHTMLDocument' => class_exists( 'Dom\\HTMLDocument' ),
+ 'schemaVersion' => self::METADATA_SCHEMA_VERSION,
+ 'kind' => self::KIND_PHP_DOM,
+ 'available' => $available,
+ 'identity' => $available ? array(
+ 'schemaVersion' => 1,
+ 'kind' => self::KIND_PHP_DOM,
+ 'phpVersion' => PHP_VERSION,
+ 'phpVersionId' => PHP_VERSION_ID,
+ 'phpSapi' => PHP_SAPI,
+ 'zendVersion' => zend_version(),
+ 'libxmlVersion' => defined( 'LIBXML_DOTTED_VERSION' ) ? LIBXML_DOTTED_VERSION : null,
+ 'domHtmlDocument' => true,
+ ) : null,
+ 'error' => $available ? null : 'Dom\\HTMLDocument is not available.',
);
return $this->metadata;
}
- $metadata = array(
- 'kind' => self::KIND_LEXBOR_SOURCE,
- 'binary' => $this->lexbor_oracle_bin,
- );
+ try {
+ $this->metadata = $this->source_metadata();
+ } catch ( \Throwable $error ) {
+ $this->metadata = array(
+ 'schemaVersion' => self::METADATA_SCHEMA_VERSION,
+ 'kind' => $this->kind,
+ 'available' => false,
+ 'identity' => null,
+ 'error' => $error->getMessage(),
+ );
+ }
+ return $this->metadata;
+ }
- if ( is_string( $this->lexbor_oracle_bin ) && is_file( $this->lexbor_oracle_bin ) && is_executable( $this->lexbor_oracle_bin ) ) {
- $metadata['binarySha256'] = hash_file( 'sha256', $this->lexbor_oracle_bin );
- $manifest_path = dirname( $this->lexbor_oracle_bin ) . '/build-manifest.json';
- $manifest = read_json_file( $manifest_path );
- if ( is_array( $manifest ) ) {
- $metadata['buildManifest'] = $manifest;
- }
- $version = $this->run_process( array( $this->lexbor_oracle_bin, '--version' ) );
- $decoded = json_decode( trim( $version['stdout'] ), true );
- if ( is_array( $decoded['oracle'] ?? null ) ) {
- $metadata = array_merge( $metadata, $decoded['oracle'] );
- $metadata['binary'] = $this->lexbor_oracle_bin;
- if (
- is_array( $manifest ) &&
- ( ( $manifest['binarySha256'] ?? null ) !== $metadata['binarySha256'] ||
- ( $manifest['resolvedCommit'] ?? null ) !== ( $metadata['lexborCommit'] ?? null ) )
- ) {
- $metadata['versionError'] = 'Lexbor build manifest does not match the executable hash and embedded commit.';
- }
- } else {
- $metadata['versionError'] = trim( $version['output'] );
- }
- } else {
- $metadata['available'] = false;
+ /** Return reasons the current oracle cannot faithfully replay recorded output. */
+ public static function identity_mismatches( $recorded, array $current ): array {
+ $recorded_error = self::metadata_validation_error( $recorded );
+ $current_error = self::metadata_validation_error( $current );
+ if ( null !== $recorded_error ) {
+ return array( 'recorded oracle metadata is invalid: ' . $recorded_error );
+ }
+ if ( null !== $current_error ) {
+ return array( 'current oracle metadata is invalid: ' . $current_error );
+ }
+ if ( $recorded['kind'] !== $current['kind'] ) {
+ return array( "oracle kind differs (recorded {$recorded['kind']}, current {$current['kind']})" );
+ }
+ if ( true !== $recorded['available'] ) {
+ return array( 'recorded oracle is unavailable' );
}
+ if ( true !== $current['available'] ) {
+ return array( 'current oracle is unavailable' );
+ }
+ if ( ! hash_equals( self::canonical_json( $recorded['identity'] ), self::canonical_json( $current['identity'] ) ) ) {
+ return array( 'oracle identity differs' );
+ }
+ return array();
+ }
- $this->metadata = $metadata;
- return $this->metadata;
+ public static function identity_sha256( array $metadata ): string {
+ $error = self::metadata_validation_error( $metadata );
+ if ( null !== $error || true !== $metadata['available'] ) {
+ throw new \InvalidArgumentException( 'Cannot hash invalid or unavailable oracle metadata' . ( null === $error ? '.' : ': ' . $error ) );
+ }
+ return hash( 'sha256', self::canonical_json( $metadata ) );
}
public function replay_options(): array {
- $options = array(
- 'domOracle' => $this->kind,
- );
- if ( self::KIND_LEXBOR_SOURCE === $this->kind && null !== $this->lexbor_oracle_bin ) {
- $options['lexborOracleBin'] = $this->lexbor_oracle_bin;
+ $options = array( 'domOracle' => $this->kind );
+ if ( self::KIND_LEXBOR_SOURCE === $this->kind && null !== $this->source_binary ) {
+ $options['lexborOracleBin'] = $this->source_binary;
+ } elseif ( self::KIND_HTML5EVER_SOURCE === $this->kind && null !== $this->source_binary ) {
+ $options['html5everOracleBin'] = $this->source_binary;
}
- if ( 2500 !== $this->timeout_ms ) {
+ if ( self::DEFAULT_TIMEOUT_MS !== $this->timeout_ms ) {
$options['oracleTimeoutMs'] = $this->timeout_ms;
}
-
return $options;
}
public function worker_args(): array {
$args = array( '--dom-oracle', $this->kind );
- if ( self::KIND_LEXBOR_SOURCE === $this->kind && null !== $this->lexbor_oracle_bin ) {
+ if ( self::KIND_LEXBOR_SOURCE === $this->kind && null !== $this->source_binary ) {
$args[] = '--lexbor-oracle-bin';
- $args[] = $this->lexbor_oracle_bin;
+ $args[] = $this->source_binary;
+ } elseif ( self::KIND_HTML5EVER_SOURCE === $this->kind && null !== $this->source_binary ) {
+ $args[] = '--html5ever-oracle-bin';
+ $args[] = $this->source_binary;
}
- if ( 2500 !== $this->timeout_ms ) {
+ if ( self::DEFAULT_TIMEOUT_MS !== $this->timeout_ms ) {
$args[] = '--oracle-timeout-ms';
$args[] = (string) $this->timeout_ms;
}
-
return $args;
}
@@ -183,195 +356,1191 @@ public function render( string $html, string $mode, array $limits = array(), str
return $result;
}
- return $this->render_lexbor_source( $html, $mode, $limits, $fragment_context );
- }
-
- private function render_lexbor_source( string $html, string $mode, array $limits, string $fragment_context ): array {
- if ( null === $this->lexbor_oracle_bin || ! is_file( $this->lexbor_oracle_bin ) || ! is_executable( $this->lexbor_oracle_bin ) ) {
- return array(
- 'status' => TreeRenderer::STATUS_ERROR,
- 'error' => 'Lexbor source oracle binary is not available. Build it or pass --lexbor-oracle-bin.',
- 'failureClass' => 'oracle-unavailable',
- 'oracle' => $this->metadata(),
- );
- }
-
- $tmp = tempnam( sys_get_temp_dir(), 'html-api-fuzz-lexbor-input-' );
- if ( false === $tmp ) {
- return array(
- 'status' => TreeRenderer::STATUS_ERROR,
- 'error' => 'Could not create a temporary input file for the Lexbor source oracle.',
- 'failureClass' => 'oracle-renderer-error',
- 'oracle' => $this->metadata(),
- );
+ $metadata = $this->metadata();
+ if ( true !== $metadata['available'] ) {
+ return $this->infrastructure_result( 'Source oracle is unavailable: ' . (string) $metadata['error'], null );
}
+ $max_nodes = self::positive_limit( $limits, 'maxNodes', 3000 );
+ $max_depth = self::positive_limit( $limits, 'maxDepth', 512 );
+ $max_tree_bytes = self::positive_limit( $limits, 'maxTreeBytes', 16777216 );
try {
- if ( false === file_put_contents( $tmp, $html ) ) {
- return array(
- 'status' => TreeRenderer::STATUS_ERROR,
- 'error' => 'Could not write the temporary input file for the Lexbor source oracle.',
- 'failureClass' => 'oracle-renderer-error',
- 'oracle' => $this->metadata(),
- );
- }
- $proc = $this->run_process(
+ $this->assert_source_identity_current( $metadata['identity'] );
+ $process = $this->run_private_process(
array(
- $this->lexbor_oracle_bin,
- '--mode',
- $mode,
- '--context',
- $fragment_context,
- '--max-nodes',
- (string) ( $limits['maxNodes'] ?? 3000 ),
- '--max-depth',
- (string) ( $limits['maxDepth'] ?? 512 ),
- '--max-tree-bytes',
- (string) ( $limits['maxTreeBytes'] ?? 16777216 ),
- '--input',
- $tmp,
- )
+ '--mode', $mode,
+ '--context', $fragment_context,
+ '--max-nodes', (string) $max_nodes,
+ '--max-depth', (string) $max_depth,
+ '--max-tree-bytes', (string) $max_tree_bytes,
+ '--input', '@INPUT@',
+ ),
+ $html,
+ $metadata['identity']['binarySha256']
);
- } finally {
- @unlink( $tmp );
+ $this->assert_source_identity_current( $metadata['identity'] );
+ if (
+ $process['timedOut'] ||
+ $process['stdoutOverflow'] ||
+ $process['stderrOverflow'] ||
+ $process['controlOverflow'] ||
+ null === $process['code'] ||
+ $process['supervisorKilled'] ||
+ $process['supervisorUnexpectedExit']
+ ) {
+ throw new \RuntimeException( 'Source oracle process did not complete within its authenticated transport limits.' );
+ }
+ $decoded = StrictJsonParser::decode( $process['stdout'] );
+ $result = $this->validate_render_response( $decoded, $metadata['identity'], $max_nodes, $max_tree_bytes );
+ $status = $decoded['status'];
+ $expected_code = self::KIND_LEXBOR_SOURCE === $this->kind && 'error' === $status ? 1 : 0;
+ if ( $expected_code !== $process['code'] ) {
+ throw new \RuntimeException( 'Source oracle exit code does not match its validated outcome.' );
+ }
+ $result['oracle'] = $metadata;
+ $result['process'] = self::compact_process( $process );
+ return $result;
+ } catch ( \Throwable $error ) {
+ return $this->infrastructure_result( $error->getMessage(), $process ?? null );
}
+ }
- if ( $proc['timedOut'] ) {
- return array(
- 'status' => TreeRenderer::STATUS_ERROR,
- 'error' => 'Lexbor source oracle timed out.',
- 'failureClass' => 'oracle-renderer-error',
- 'oracle' => $this->metadata(),
- 'process' => self::compact_process( $proc ),
- );
+ private function source_metadata(): array {
+ if ( ! is_string( $this->source_binary ) || '' === $this->source_binary ) {
+ throw new \RuntimeException( 'Source oracle binary path is missing.' );
}
+ clearstatcache( true, $this->source_binary );
+ $binary = realpath( $this->source_binary );
+ if ( false === $binary || ! is_file( $binary ) || ! is_executable( $binary ) ) {
+ throw new \RuntimeException( 'Source oracle binary is not an executable regular file.' );
+ }
+ $manifest_path = dirname( $binary ) . '/build-manifest.json';
+ $manifest_before = self::read_stable_file( $manifest_path, self::MANIFEST_MAX_BYTES );
+ $manifest = StrictJsonParser::decode( $manifest_before );
+ if ( ! is_array( $manifest ) ) {
+ throw new \RuntimeException( 'Source oracle build manifest is not an object.' );
+ }
+ $process = $this->run_private_process( array( '--version' ) );
+ if (
+ 0 !== $process['code'] ||
+ $process['timedOut'] ||
+ $process['stdoutOverflow'] ||
+ $process['stderrOverflow'] ||
+ $process['controlOverflow'] ||
+ $process['supervisorKilled'] ||
+ $process['supervisorUnexpectedExit']
+ ) {
+ throw new \RuntimeException( 'Source oracle identity probe failed.' );
+ }
+ $manifest_after = self::read_stable_file( $manifest_path, self::MANIFEST_MAX_BYTES );
+ if ( ! hash_equals( hash( 'sha256', $manifest_before ), hash( 'sha256', $manifest_after ) ) ) {
+ throw new \RuntimeException( 'Source oracle build manifest changed during identity probing.' );
+ }
+ if ( ! hash_equals( $process['sourceSha256'], self::hash_stable_file( $binary, true ) ) ) {
+ throw new \RuntimeException( 'Source oracle executable changed during identity probing.' );
+ }
+ $version = StrictJsonParser::decode( $process['stdout'] );
+ $oracle = $this->validate_version_response( $version );
+ $identity = self::KIND_LEXBOR_SOURCE === $this->kind
+ ? $this->lexbor_identity( $manifest, $oracle, $process['sourceSha256'] )
+ : $this->html5ever_identity( $manifest, $oracle, $process['sourceSha256'], dirname( dirname( $binary ) ) );
+ $metadata = array(
+ 'schemaVersion' => self::METADATA_SCHEMA_VERSION,
+ 'kind' => $this->kind,
+ 'available' => true,
+ 'identity' => $identity,
+ 'error' => null,
+ );
+ $error = self::metadata_validation_error( $metadata );
+ if ( null !== $error ) {
+ throw new \RuntimeException( 'Constructed source oracle identity is invalid: ' . $error );
+ }
+ $this->source_manifest_sha256 = hash( 'sha256', $manifest_after );
+ return $metadata;
+ }
- $decoded = json_decode( $proc['stdout'], true );
- if ( ! is_array( $decoded ) || ! is_string( $decoded['status'] ?? null ) ) {
- return array(
- 'status' => TreeRenderer::STATUS_ERROR,
- 'error' => 'Lexbor source oracle did not return a valid JSON result.',
- 'failureClass' => 'oracle-renderer-error',
- 'oracle' => $this->metadata(),
- 'process' => self::compact_process( $proc ),
+ private function assert_source_identity_current( array $expected_identity ): void {
+ if ( null === $this->source_manifest_sha256 || ! is_string( $this->source_binary ) ) {
+ throw new \RuntimeException( 'Source oracle identity has not been pinned.' );
+ }
+ clearstatcache( true, $this->source_binary );
+ $binary = realpath( $this->source_binary );
+ if ( false === $binary || ! is_file( $binary ) || ! is_executable( $binary ) ) {
+ throw new \RuntimeException( 'Source oracle binary is no longer an executable regular file.' );
+ }
+ $manifest_contents = self::read_stable_file( dirname( $binary ) . '/build-manifest.json', self::MANIFEST_MAX_BYTES );
+ if ( ! hash_equals( $this->source_manifest_sha256, hash( 'sha256', $manifest_contents ) ) ) {
+ throw new \RuntimeException( 'Source oracle build manifest no longer matches its verified identity.' );
+ }
+ $manifest = StrictJsonParser::decode( $manifest_contents );
+ if ( ! is_array( $manifest ) ) {
+ throw new \RuntimeException( 'Source oracle build manifest is not an object.' );
+ }
+ $binary_sha256 = self::hash_stable_file( $binary, true );
+ if ( self::KIND_LEXBOR_SOURCE === $this->kind ) {
+ $oracle = array(
+ 'kind' => self::KIND_LEXBOR_SOURCE,
+ 'lexborCommit' => $expected_identity['lexborCommit'] ?? null,
+ 'lexborVersion' => $expected_identity['lexborVersion'] ?? null,
);
+ $current_identity = $this->lexbor_identity( $manifest, $oracle, $binary_sha256 );
+ } else {
+ $oracle = array(
+ 'kind' => self::KIND_HTML5EVER_SOURCE,
+ 'available' => true,
+ 'html5everVersion' => $expected_identity['html5everVersion'] ?? null,
+ 'html5everChecksum' => $expected_identity['html5everChecksum'] ?? null,
+ 'markup5everRcdomVersion' => $expected_identity['markup5everRcdomVersion'] ?? null,
+ 'markup5everRcdomChecksum' => $expected_identity['markup5everRcdomChecksum'] ?? null,
+ 'rustToolchain' => $expected_identity['rustToolchain'] ?? null,
+ 'cargoLockSha256' => $expected_identity['cargoLockSha256'] ?? null,
+ 'buildIdentity' => $expected_identity['buildIdentity'] ?? null,
+ );
+ $current_identity = $this->html5ever_identity( $manifest, $oracle, $binary_sha256, dirname( dirname( $binary ) ) );
+ }
+ if ( ! hash_equals( self::canonical_json( $expected_identity ), self::canonical_json( $current_identity ) ) ) {
+ throw new \RuntimeException( 'Source oracle identity changed after it was verified.' );
+ }
+ }
+
+ private function validate_version_response( $version ): array {
+ if ( ! is_array( $version ) || ! self::exact_keys( $version, array( 'status', 'oracle' ) ) || 'ok' !== $version['status'] || ! is_array( $version['oracle'] ) ) {
+ throw new \RuntimeException( 'Source oracle returned an invalid version response schema.' );
+ }
+ $oracle = $version['oracle'];
+ if ( self::KIND_LEXBOR_SOURCE === $this->kind ) {
+ if (
+ ! self::exact_keys( $oracle, array( 'kind', 'lexborCommit', 'lexborVersion' ) ) ||
+ self::KIND_LEXBOR_SOURCE !== $oracle['kind'] ||
+ ! self::matches( $oracle['lexborCommit'], '/^[0-9a-f]{40}$/' ) ||
+ ! self::nonempty_string( $oracle['lexborVersion'] )
+ ) {
+ throw new \RuntimeException( 'Lexbor oracle returned invalid version identity.' );
+ }
+ } elseif (
+ ! self::exact_keys(
+ $oracle,
+ array( 'kind', 'available', 'html5everVersion', 'html5everChecksum', 'markup5everRcdomVersion', 'markup5everRcdomChecksum', 'rustToolchain', 'cargoLockSha256', 'buildIdentity' )
+ ) ||
+ self::KIND_HTML5EVER_SOURCE !== $oracle['kind'] ||
+ true !== $oracle['available'] ||
+ ! self::nonempty_string( $oracle['html5everVersion'] ) ||
+ ! self::matches( $oracle['html5everChecksum'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::nonempty_string( $oracle['markup5everRcdomVersion'] ) ||
+ ! self::matches( $oracle['markup5everRcdomChecksum'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::nonempty_string( $oracle['rustToolchain'] ) ||
+ ! self::matches( $oracle['cargoLockSha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::matches( $oracle['buildIdentity'], '/^[0-9a-f]{64}$/' )
+ ) {
+ throw new \RuntimeException( 'html5ever oracle returned invalid version identity.' );
}
+ return $oracle;
+ }
- $status = $decoded['status'];
- if ( ! in_array( $status, array( TreeRenderer::STATUS_OK, TreeRenderer::STATUS_UNSUPPORTED, TreeRenderer::STATUS_ERROR ), true ) ) {
- $status = TreeRenderer::STATUS_ERROR;
+ private function lexbor_identity( array $manifest, array $oracle, string $binary_sha256 ): array {
+ if (
+ ! self::exact_keys( $manifest, array( 'kind', 'requestedRef', 'resolvedCommit', 'upstream', 'builtAt', 'binarySha256', 'compiler', 'cmake' ) ) ||
+ 'html-api-fuzz-lexbor-build' !== $manifest['kind'] ||
+ ! self::nonempty_string( $manifest['requestedRef'] ) ||
+ ! self::matches( $manifest['resolvedCommit'], '/^[0-9a-f]{40}$/' ) ||
+ 'https://github.com/lexbor/lexbor.git' !== $manifest['upstream'] ||
+ ! self::nonempty_string( $manifest['builtAt'] ) ||
+ ! self::matches( $manifest['binarySha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::nonempty_string( $manifest['compiler'] ) ||
+ ! self::nonempty_string( $manifest['cmake'] ) ||
+ ! hash_equals( $binary_sha256, $manifest['binarySha256'] ) ||
+ ! hash_equals( $oracle['lexborCommit'], $manifest['resolvedCommit'] )
+ ) {
+ throw new \RuntimeException( 'Lexbor build manifest does not agree with its executable and self-report.' );
}
+ return array(
+ 'schemaVersion' => 1,
+ 'kind' => self::KIND_LEXBOR_SOURCE,
+ 'binarySha256' => $binary_sha256,
+ 'lexborCommit' => $oracle['lexborCommit'],
+ 'lexborVersion' => $oracle['lexborVersion'],
+ 'build' => array(
+ 'kind' => $manifest['kind'],
+ 'requestedRef' => $manifest['requestedRef'],
+ 'resolvedCommit' => $manifest['resolvedCommit'],
+ 'upstream' => $manifest['upstream'],
+ 'compiler' => $manifest['compiler'],
+ 'cmake' => $manifest['cmake'],
+ ),
+ );
+ }
- $result = array(
- 'status' => $status,
- 'oracle' => is_array( $decoded['oracle'] ?? null ) ? array_merge( $this->metadata(), $decoded['oracle'] ) : $this->metadata(),
- 'nodeCount' => $decoded['nodeCount'] ?? null,
- 'process' => self::compact_process( $proc ),
+ private function html5ever_identity( array $manifest, array $oracle, string $binary_sha256, string $project_root ): array {
+ $manifest_keys = array(
+ 'schemaVersion', 'kind', 'publicationProtocol', 'builtAt', 'buildIdentity',
+ 'cargoTomlSha256', 'cargoLockSha256', 'rustToolchainSha256', 'sourceSha256',
+ 'rustc', 'cargo', 'html5ever', 'markup5everRcdom', 'binarySha256',
+ );
+ if (
+ ! self::exact_keys( $manifest, $manifest_keys ) ||
+ 1 !== $manifest['schemaVersion'] ||
+ 'html-api-fuzz-html5ever-build' !== $manifest['kind'] ||
+ 'manifest-last-v1' !== $manifest['publicationProtocol'] ||
+ ! self::nonempty_string( $manifest['builtAt'] ) ||
+ ! self::matches( $manifest['buildIdentity'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::matches( $manifest['cargoTomlSha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::matches( $manifest['cargoLockSha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::matches( $manifest['rustToolchainSha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::matches( $manifest['sourceSha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::nonempty_string( $manifest['rustc'] ) ||
+ ! self::nonempty_string( $manifest['cargo'] ) ||
+ ! self::matches( $manifest['binarySha256'], '/^[0-9a-f]{64}$/' ) ||
+ ! self::package_identity_valid( $manifest['html5ever'] ) ||
+ ! self::package_identity_valid( $manifest['markup5everRcdom'] )
+ ) {
+ throw new \RuntimeException( 'html5ever build manifest schema is invalid.' );
+ }
+ $paths = array(
+ 'cargoTomlSha256' => $project_root . '/Cargo.toml',
+ 'cargoLockSha256' => $project_root . '/Cargo.lock',
+ 'rustToolchainSha256' => $project_root . '/rust-toolchain.toml',
+ 'sourceSha256' => $project_root . '/src/main.rs',
);
+ $contents = array();
+ foreach ( $paths as $field => $path ) {
+ $contents[ $field ] = self::read_stable_file( $path, 16777216 );
+ if ( ! hash_equals( $manifest[ $field ], hash( 'sha256', $contents[ $field ] ) ) ) {
+ throw new \RuntimeException( 'html5ever checked-in build input does not match its manifest.' );
+ }
+ }
+ $build_identity = hash(
+ 'sha256',
+ "Cargo.toml {$manifest['cargoTomlSha256']}\n" .
+ "Cargo.lock {$manifest['cargoLockSha256']}\n" .
+ "rust-toolchain.toml {$manifest['rustToolchainSha256']}\n" .
+ "src/main.rs {$manifest['sourceSha256']}\n"
+ );
+ $locked_html5ever = self::cargo_lock_package( $contents['cargoLockSha256'], 'html5ever' );
+ $locked_rcdom = self::cargo_lock_package( $contents['cargoLockSha256'], 'markup5ever_rcdom' );
+ if (
+ ! hash_equals( $binary_sha256, $manifest['binarySha256'] ) ||
+ ! hash_equals( $build_identity, $manifest['buildIdentity'] ) ||
+ $manifest['html5ever'] !== $locked_html5ever ||
+ $manifest['markup5everRcdom'] !== $locked_rcdom ||
+ $oracle['html5everVersion'] !== $locked_html5ever['version'] ||
+ $oracle['html5everChecksum'] !== $locked_html5ever['checksum'] ||
+ $oracle['markup5everRcdomVersion'] !== $locked_rcdom['version'] ||
+ $oracle['markup5everRcdomChecksum'] !== $locked_rcdom['checksum'] ||
+ $oracle['rustToolchain'] !== self::rust_toolchain_channel( $contents['rustToolchainSha256'] ) ||
+ ! hash_equals( $oracle['cargoLockSha256'], $manifest['cargoLockSha256'] ) ||
+ ! hash_equals( $oracle['buildIdentity'], $manifest['buildIdentity'] )
+ ) {
+ throw new \RuntimeException( 'html5ever build manifest, lockfile, executable, and self-report do not agree.' );
+ }
+ return array(
+ 'schemaVersion' => 1,
+ 'kind' => self::KIND_HTML5EVER_SOURCE,
+ 'binarySha256' => $binary_sha256,
+ 'html5everVersion' => $oracle['html5everVersion'],
+ 'html5everChecksum' => $oracle['html5everChecksum'],
+ 'markup5everRcdomVersion' => $oracle['markup5everRcdomVersion'],
+ 'markup5everRcdomChecksum' => $oracle['markup5everRcdomChecksum'],
+ 'rustToolchain' => $oracle['rustToolchain'],
+ 'cargoLockSha256' => $oracle['cargoLockSha256'],
+ 'buildIdentity' => $oracle['buildIdentity'],
+ 'build' => array(
+ 'schemaVersion' => $manifest['schemaVersion'],
+ 'kind' => $manifest['kind'],
+ 'publicationProtocol' => $manifest['publicationProtocol'],
+ 'cargoTomlSha256' => $manifest['cargoTomlSha256'],
+ 'cargoLockSha256' => $manifest['cargoLockSha256'],
+ 'rustToolchainSha256' => $manifest['rustToolchainSha256'],
+ 'sourceSha256' => $manifest['sourceSha256'],
+ 'rustc' => $manifest['rustc'],
+ 'cargo' => $manifest['cargo'],
+ 'html5ever' => $manifest['html5ever'],
+ 'markup5everRcdom' => $manifest['markup5everRcdom'],
+ ),
+ );
+ }
- if ( TreeRenderer::STATUS_OK === $status && is_string( $decoded['treeBase64'] ?? null ) ) {
- $tree = base64_decode( $decoded['treeBase64'], true );
- if ( false === $tree ) {
- $result['status'] = TreeRenderer::STATUS_ERROR;
- $result['error'] = 'Lexbor source oracle returned invalid treeBase64.';
- $result['failureClass'] = 'oracle-renderer-error';
- return $result;
+ private function validate_render_response( $response, array $identity, int $max_nodes, int $max_tree_bytes ): array {
+ if ( ! is_array( $response ) || ! is_string( $response['status'] ?? null ) ) {
+ throw new \RuntimeException( 'Source oracle did not return a result object.' );
+ }
+ $status = $response['status'];
+ $keys = array(
+ 'ok' => array( 'status', 'oracle', 'tree', 'treeBase64', 'nodeCount' ),
+ 'unsupported' => array( 'status', 'oracle', 'nodeCount', 'failureClass', 'unsupported' ),
+ 'error' => array( 'status', 'oracle', 'nodeCount', 'failureClass', 'error' ),
+ );
+ if ( ! isset( $keys[ $status ] ) || ! self::exact_keys( $response, $keys[ $status ] ) ) {
+ throw new \RuntimeException( 'Source oracle result has an invalid exact schema.' );
+ }
+ if ( ! is_int( $response['nodeCount'] ) || $response['nodeCount'] < 0 || $response['nodeCount'] > $max_nodes + 1 ) {
+ throw new \RuntimeException( 'Source oracle returned an invalid node count.' );
+ }
+ $this->validate_render_oracle_identity( $response['oracle'], $identity );
+ if ( 'ok' === $status ) {
+ if ( ! is_string( $response['tree'] ) || ! is_string( $response['treeBase64'] ) ) {
+ throw new \RuntimeException( 'Source oracle returned a non-string tree.' );
}
- $result['tree'] = $tree;
- } elseif ( TreeRenderer::STATUS_OK === $status && is_string( $decoded['tree'] ?? null ) ) {
- $result['tree'] = $decoded['tree'];
+ $decoded_tree = base64_decode( $response['treeBase64'], true );
+ if (
+ false === $decoded_tree ||
+ ! hash_equals( base64_encode( $decoded_tree ), $response['treeBase64'] ) ||
+ ! hash_equals( $decoded_tree, $response['tree'] ) ||
+ strlen( $decoded_tree ) > $max_tree_bytes
+ ) {
+ throw new \RuntimeException( 'Source oracle tree and canonical treeBase64 do not agree with the byte limit.' );
+ }
+ return array( 'status' => TreeRenderer::STATUS_OK, 'tree' => $decoded_tree, 'nodeCount' => $response['nodeCount'] );
+ }
+ if ( ! is_string( $response['failureClass'] ) ) {
+ throw new \RuntimeException( 'Source oracle failure class is invalid.' );
}
- if ( is_string( $decoded['failureClass'] ?? null ) ) {
- $result['failureClass'] = $decoded['failureClass'];
+ if ( 'unsupported' === $status ) {
+ if (
+ 'oracle-unsupported' !== $response['failureClass'] ||
+ ! is_array( $response['unsupported'] ) ||
+ ! self::exact_keys( $response['unsupported'], array( 'message' ) ) ||
+ ! is_string( $response['unsupported']['message'] )
+ ) {
+ throw new \RuntimeException( 'Source oracle returned an invalid unsupported outcome.' );
+ }
+ return array(
+ 'status' => TreeRenderer::STATUS_UNSUPPORTED,
+ 'failureClass' => 'oracle-unsupported',
+ 'unsupported' => $response['unsupported'],
+ 'nodeCount' => $response['nodeCount'],
+ );
}
- if ( is_string( $decoded['error'] ?? null ) ) {
- $result['error'] = $decoded['error'];
+ $allowed = array( 'oracle-parse-error', 'node-limit-exceeded', 'depth-limit-exceeded', 'tree-byte-limit-exceeded', 'oracle-renderer-error' );
+ if ( ! in_array( $response['failureClass'], $allowed, true ) || ! is_string( $response['error'] ) ) {
+ throw new \RuntimeException( 'Source oracle returned an untrusted error outcome.' );
}
- if ( is_array( $decoded['unsupported'] ?? null ) ) {
- $result['unsupported'] = $decoded['unsupported'];
+ $result = array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'failureClass' => $response['failureClass'],
+ 'error' => $response['error'],
+ 'nodeCount' => $response['nodeCount'],
+ );
+ if ( 'oracle-renderer-error' === $response['failureClass'] ) {
+ $result['infrastructure'] = true;
}
+ return $result;
+ }
- if ( TreeRenderer::STATUS_OK === $status && ! is_string( $result['tree'] ?? null ) ) {
- $result['status'] = TreeRenderer::STATUS_ERROR;
- $result['error'] = 'Lexbor source oracle returned ok without a tree.';
- $result['failureClass'] = 'oracle-renderer-error';
+ private function validate_render_oracle_identity( $oracle, array $identity ): void {
+ if ( ! is_array( $oracle ) ) {
+ throw new \RuntimeException( 'Source oracle result identity is missing.' );
}
+ if ( self::KIND_LEXBOR_SOURCE === $this->kind ) {
+ if (
+ ! self::exact_keys( $oracle, array( 'kind', 'lexborCommit', 'lexborVersion' ) ) ||
+ self::KIND_LEXBOR_SOURCE !== $oracle['kind'] ||
+ $identity['lexborCommit'] !== $oracle['lexborCommit'] ||
+ $identity['lexborVersion'] !== $oracle['lexborVersion']
+ ) {
+ throw new \RuntimeException( 'Lexbor result identity differs from its verified identity.' );
+ }
+ return;
+ }
+ $expected = array(
+ 'kind' => self::KIND_HTML5EVER_SOURCE,
+ 'available' => true,
+ 'html5everVersion' => $identity['html5everVersion'],
+ 'html5everChecksum' => $identity['html5everChecksum'],
+ 'markup5everRcdomVersion' => $identity['markup5everRcdomVersion'],
+ 'markup5everRcdomChecksum' => $identity['markup5everRcdomChecksum'],
+ 'rustToolchain' => $identity['rustToolchain'],
+ 'cargoLockSha256' => $identity['cargoLockSha256'],
+ 'buildIdentity' => $identity['buildIdentity'],
+ );
+ if ( ! self::exact_keys( $oracle, array_keys( $expected ) ) || $oracle !== $expected ) {
+ throw new \RuntimeException( 'html5ever result identity differs from its verified identity.' );
+ }
+ }
- return $result;
+ private function run_private_process( array $target_arguments, ?string $input = null, ?string $expected_sha256 = null ): array {
+ if ( ! is_string( $this->source_binary ) ) {
+ throw new \RuntimeException( 'Source oracle path is unavailable.' );
+ }
+ $supervisor_source = dirname( __DIR__ ) . '/oracle-process-supervisor.php';
+ require_once $supervisor_source;
+ $root = self::create_ownership_root();
+ $token = bin2hex( random_bytes( 16 ) );
+ $ownership = null;
+ try {
+ self::write_private_file( $root . '/owner-token', $token . "\n", 0600 );
+ $ownership = \HtmlApiFuzz\OracleProcessSupervisor\ownership_identity( $root );
+ if ( ! is_array( $ownership ) ) {
+ throw new \RuntimeException( 'Could not pin private oracle ownership identity.' );
+ }
+ \HtmlApiFuzz\OracleProcessSupervisor\register_ownership_identity( $root, $ownership['root'], $ownership['owner'] );
+ $target_copy = self::copy_stable_private( $this->source_binary, $root . '/oracle', true );
+ $supervisor_copy = self::copy_stable_private( $supervisor_source, $root . '/supervisor.php', false );
+ if ( null !== $expected_sha256 && ! hash_equals( $expected_sha256, $target_copy['sha256'] ) ) {
+ throw new \RuntimeException( 'Source oracle executable no longer matches its verified identity.' );
+ }
+ if ( null !== $input ) {
+ self::write_private_file( $root . '/input.bin', $input, 0600 );
+ }
+ $private_arguments = array_map(
+ static fn ( $argument ) => '@INPUT@' === $argument ? $root . '/input.bin' : (string) $argument,
+ $target_arguments
+ );
+ $command = array(
+ PHP_BINARY,
+ $supervisor_copy['path'],
+ '--root', $root,
+ '--token', $token,
+ '--root-dev', (string) $ownership['root']['dev'],
+ '--root-ino', (string) $ownership['root']['ino'],
+ '--owner-dev', (string) $ownership['owner']['dev'],
+ '--owner-ino', (string) $ownership['owner']['ino'],
+ '--target', $target_copy['path'],
+ '--target-sha256', $target_copy['sha256'],
+ '--supervisor-sha256', $supervisor_copy['sha256'],
+ '--',
+ ...$private_arguments,
+ );
+ $process = $this->monitor_supervisor( $command, $root, $token, $target_copy, $supervisor_copy );
+ $process['sourceSha256'] = $target_copy['sha256'];
+ return $process;
+ } catch ( \Throwable $error ) {
+ if ( is_dir( $root ) ) {
+ $state = \HtmlApiFuzz\OracleProcessSupervisor\read_state( $root, $token );
+ if ( null === $state && ! self::remove_unstarted_root( $root, $token ) ) {
+ throw new \RuntimeException( $error->getMessage() . '; private ownership root was retained at ' . $root, 0, $error );
+ }
+ }
+ throw $error;
+ }
}
- private function run_process( array $command ): array {
+ private function monitor_supervisor( array $command, string $root, string $token, array $target_copy, array $supervisor_copy ): array {
$spec = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
2 => array( 'pipe', 'w' ),
+ 3 => array( 'pipe', 'w' ),
);
-
- $process = proc_open( $command, $spec, $pipes, repo_root() );
+ $process = @proc_open( $command, $spec, $pipes, $root, null, array( 'bypass_shell' => true ) );
if ( ! is_resource( $process ) ) {
- throw new \RuntimeException( 'Could not start oracle subprocess.' );
+ throw new \RuntimeException( 'Could not start private oracle supervisor.' );
}
+ foreach ( array( 1, 2, 3 ) as $descriptor ) {
+ stream_set_blocking( $pipes[ $descriptor ], false );
+ }
+ stream_set_blocking( $pipes[0], false );
+ stream_set_write_buffer( $pipes[0], 0 );
- fclose( $pipes[0] );
- stream_set_blocking( $pipes[1], false );
- stream_set_blocking( $pipes[2], false );
-
- $stdout = '';
- $stderr = '';
- $start = microtime( true );
+ $stdout = '';
+ $stderr = '';
+ $control = '';
+ $stdout_overflow = false;
+ $stderr_overflow = false;
+ $control_overflow = false;
$timed_out = false;
+ $shutdown_sent = false;
+ $forced_supervisor_kill = false;
+ $monitor_error = null;
+ $owner_pipe_closed = false;
+ $start = microtime( true );
+ $shutdown_at = null;
+ $last_status = proc_get_status( $process );
+ $supervisor_pid = (int) ( $last_status['pid'] ?? 0 );
+ $observed_exit_code = null;
while ( true ) {
- $stdout .= stream_get_contents( $pipes[1] );
- $stderr .= stream_get_contents( $pipes[2] );
-
- $status = proc_get_status( $process );
- if ( ! $status['running'] ) {
+ self::drain_bounded( $pipes[1], $stdout, self::SOURCE_STDOUT_MAX_BYTES, $stdout_overflow );
+ self::drain_bounded( $pipes[2], $stderr, self::SOURCE_STDERR_MAX_BYTES, $stderr_overflow );
+ self::drain_bounded( $pipes[3], $control, self::CONTROL_MAX_BYTES, $control_overflow );
+ $last_status = proc_get_status( $process );
+ if ( ! $last_status['running'] ) {
+ $observed_exit_code = isset( $last_status['exitcode'] ) && $last_status['exitcode'] >= 0 ? (int) $last_status['exitcode'] : null;
break;
}
+ $elapsed_ms = ( microtime( true ) - $start ) * 1000;
+ if ( ! $shutdown_sent && ( $elapsed_ms > $this->timeout_ms || $stdout_overflow || $stderr_overflow || $control_overflow ) ) {
+ $timed_out = $elapsed_ms > $this->timeout_ms;
+ $frame = json_encode( array( 'schemaVersion' => 1, 'command' => 'shutdown', 'token' => $token ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ if ( strlen( $frame ) !== @fwrite( $pipes[0], $frame ) || ! @fflush( $pipes[0] ) ) {
+ // Keeping the owner descriptor open still allows authenticated fallback.
+ }
+ $shutdown_sent = true;
+ $shutdown_at = microtime( true );
+ }
+ if ( $shutdown_sent && null !== $shutdown_at && ( microtime( true ) - $shutdown_at ) * 1000 > self::CLEANUP_GRACE_MS ) {
+ $state = \HtmlApiFuzz\OracleProcessSupervisor\read_state( $root, $token );
+ $authenticated = is_array( $state ) ? \HtmlApiFuzz\OracleProcessSupervisor\authenticate_supervisor( $state ) : null;
+ if ( null !== $authenticated && $supervisor_pid === $authenticated['pid'] && @posix_kill( $supervisor_pid, SIGKILL ) ) {
+ $forced_supervisor_kill = true;
+ $shutdown_at = null;
+ } else {
+ $race_status = proc_get_status( $process );
+ if ( ! $race_status['running'] ) {
+ $observed_exit_code = isset( $race_status['exitcode'] ) && $race_status['exitcode'] >= 0 ? (int) $race_status['exitcode'] : null;
+ break;
+ }
+ $monitor_error = 'Timed-out oracle supervisor could not yet be authenticated for termination.';
+ if ( ! $owner_pipe_closed ) {
+ fclose( $pipes[0] );
+ $owner_pipe_closed = true;
+ }
+ $shutdown_at = microtime( true );
+ }
+ }
+ $read = array( $pipes[1], $pipes[2], $pipes[3] );
+ $write = null;
+ $except = null;
+ @stream_select( $read, $write, $except, 0, 10000 );
+ }
+
+ self::drain_bounded( $pipes[1], $stdout, self::SOURCE_STDOUT_MAX_BYTES, $stdout_overflow );
+ self::drain_bounded( $pipes[2], $stderr, self::SOURCE_STDERR_MAX_BYTES, $stderr_overflow );
+ self::drain_bounded( $pipes[3], $control, self::CONTROL_MAX_BYTES, $control_overflow );
+ foreach ( $pipes as $pipe ) {
+ if ( is_resource( $pipe ) ) {
+ fclose( $pipe );
+ }
+ }
+ $closed_code = proc_close( $process );
+ $exit_code = null !== $observed_exit_code ? $observed_exit_code : ( $closed_code >= 0 ? $closed_code : null );
+ $events = array();
+ $control_error = null;
+ if ( $control_overflow ) {
+ $control_error = 'Oracle supervisor control stream exceeded its byte limit.';
+ } else {
+ try {
+ $events = self::parse_control_events( $control, $token );
+ } catch ( \Throwable $error ) {
+ $control_error = $error->getMessage();
+ }
+ }
+ $cleanup_error = $this->finish_private_cleanup(
+ $root,
+ $token,
+ $supervisor_pid,
+ $target_copy,
+ $supervisor_copy,
+ $events
+ );
+ if ( null !== $cleanup_error ) {
+ throw new \RuntimeException( $cleanup_error . '; evidence retained at ' . $root );
+ }
+ if ( null !== $control_error ) {
+ throw new \RuntimeException( 'Oracle supervisor control protocol is invalid: ' . $control_error );
+ }
+ if ( null !== $monitor_error && ! $forced_supervisor_kill ) {
+ throw new \RuntimeException( $monitor_error );
+ }
+ if ( empty( $events ) ) {
+ throw new \RuntimeException( 'Oracle supervisor control protocol was missing.' );
+ }
+ $last_event = $events[ count( $events ) - 1 ];
+ $unexpected_supervisor_exit = ! $forced_supervisor_kill && 'cleaned' !== $last_event['event'];
+
+ return array(
+ 'code' => $exit_code,
+ 'timedOut' => $timed_out,
+ 'durationMs' => (int) round( ( microtime( true ) - $start ) * 1000 ),
+ 'stdout' => $stdout,
+ 'stderr' => $stderr,
+ 'stdoutOverflow' => $stdout_overflow,
+ 'stderrOverflow' => $stderr_overflow,
+ 'controlOverflow' => $control_overflow,
+ 'cleanupVerified' => true,
+ 'supervisorKilled' => $forced_supervisor_kill,
+ 'supervisorUnexpectedExit' => $unexpected_supervisor_exit,
+ );
+ }
- if ( ( microtime( true ) - $start ) * 1000 > $this->timeout_ms ) {
- $timed_out = true;
- proc_terminate( $process );
- usleep( 200000 );
- $status = proc_get_status( $process );
- if ( $status['running'] ) {
- proc_terminate( $process, 9 );
+ private function finish_private_cleanup( string $root, string $token, int $supervisor_pid, array $target_copy, array $supervisor_copy, array $events ): ?string {
+ $ready = null;
+ $anchor = null;
+ foreach ( $events as $event ) {
+ if ( 'supervisor-ready' === $event['event'] ) {
+ $ready = $event['supervisor'];
+ } elseif ( 'anchor-ready' === $event['event'] ) {
+ $anchor = $event['anchor'];
+ }
+ }
+ if ( is_array( $ready ) ) {
+ $current = \HtmlApiFuzz\OracleProcessSupervisor\process_identity( $ready['pid'] );
+ if ( null !== $current && \HtmlApiFuzz\OracleProcessSupervisor\same_identity( $ready, $current ) ) {
+ return 'Authenticated oracle supervisor remained after process completion';
+ }
+ }
+ if ( ! is_dir( $root ) ) {
+ if ( is_array( $anchor ) ) {
+ $members = \HtmlApiFuzz\OracleProcessSupervisor\session_group_members( $anchor['sid'], $anchor['pgid'] );
+ if ( null === $members || array() !== $members ) {
+ return 'Oracle target group absence could not be verified after root removal';
}
- break;
}
+ return null;
+ }
+ $state = \HtmlApiFuzz\OracleProcessSupervisor\read_state( $root, $token );
+ $state_error = self::state_validation_error( $state, $root, $token, $target_copy, $supervisor_copy, $supervisor_pid );
+ if ( null !== $state_error ) {
+ return 'Oracle fallback state is invalid: ' . $state_error;
+ }
+ $phase = $state['phase'];
+ if ( 'supervisor-ready' === $phase ) {
+ $deadline = hrtime( true ) + 2000000000;
+ do {
+ $members = \HtmlApiFuzz\OracleProcessSupervisor\session_members( $state['supervisor']['sid'] );
+ if ( null === $members ) {
+ return 'Oracle supervisor session absence could not be inspected';
+ }
+ if ( array() === $members ) {
+ break;
+ }
+ usleep( 10000 );
+ } while ( hrtime( true ) < $deadline );
+ if ( array() !== $members ) {
+ return 'Unpublished oracle session members survived supervisor exit';
+ }
+ } elseif ( in_array( $phase, array( 'anchor-ready', 'gated', 'running', 'target-exit', 'cleaning' ), true ) ) {
+ $error = \HtmlApiFuzz\OracleProcessSupervisor\cleanup_anchored_group( $root, $token );
+ if ( null !== $error ) {
+ return $error;
+ }
+ } elseif ( 'cleaned' === $phase ) {
+ if ( is_array( $state['anchor'] ) ) {
+ $members = \HtmlApiFuzz\OracleProcessSupervisor\session_group_members( $state['anchor']['sid'], $state['anchor']['pgid'] );
+ if ( null === $members || array() !== $members ) {
+ return 'Cleaned oracle state did not prove group absence';
+ }
+ }
+ } else {
+ return 'Oracle supervisor retained an unsafe cleanup phase';
+ }
+ if ( ! \HtmlApiFuzz\OracleProcessSupervisor\remove_owned_root( $root, $token ) ) {
+ return 'Authenticated oracle ownership root could not be removed';
+ }
+ return is_dir( $root ) ? 'Authenticated oracle ownership root survived removal' : null;
+ }
+
+ private static function parse_control_events( string $control, string $token ): array {
+ if ( '' === $control || ! str_ends_with( $control, "\n" ) ) {
+ throw new \RuntimeException( 'Oracle supervisor control stream is incomplete.' );
+ }
+ $events = array();
+ $seen = array();
+ foreach ( explode( "\n", substr( $control, 0, -1 ) ) as $line ) {
+ $event = StrictJsonParser::decode( $line );
+ if ( ! is_array( $event ) || 1 !== ( $event['schemaVersion'] ?? null ) || $token !== ( $event['token'] ?? null ) || ! is_string( $event['event'] ?? null ) ) {
+ throw new \RuntimeException( 'Oracle supervisor emitted an unauthenticated control event.' );
+ }
+ $name = $event['event'];
+ if ( isset( $seen[ $name ] ) ) {
+ throw new \RuntimeException( 'Oracle supervisor repeated a control phase.' );
+ }
+ $seen[ $name ] = true;
+ if ( 'supervisor-ready' === $name ) {
+ if ( ! self::exact_keys( $event, array( 'schemaVersion', 'event', 'token', 'supervisor' ) ) || null !== self::process_document_error( $event['supervisor'] ) ) {
+ throw new \RuntimeException( 'Invalid supervisor-ready control event.' );
+ }
+ } elseif ( 'anchor-ready' === $name ) {
+ if ( ! self::exact_keys( $event, array( 'schemaVersion', 'event', 'token', 'anchor' ) ) || null !== self::process_document_error( $event['anchor'] ) ) {
+ throw new \RuntimeException( 'Invalid anchor-ready control event.' );
+ }
+ } elseif ( 'running' === $name ) {
+ if ( ! self::exact_keys( $event, array( 'schemaVersion', 'event', 'token', 'target' ) ) || null !== self::process_document_error( $event['target'] ) ) {
+ throw new \RuntimeException( 'Invalid running control event.' );
+ }
+ } elseif ( 'cleaned' === $name ) {
+ if ( ! self::exact_keys( $event, array( 'schemaVersion', 'event', 'token', 'ownerDead' ) ) || ! is_bool( $event['ownerDead'] ) ) {
+ throw new \RuntimeException( 'Invalid cleaned control event.' );
+ }
+ } elseif ( 'cleanup-failed' === $name ) {
+ if ( ! self::exact_keys( $event, array( 'schemaVersion', 'event', 'token', 'error' ) ) || ! is_string( $event['error'] ) ) {
+ throw new \RuntimeException( 'Invalid cleanup-failed control event.' );
+ }
+ } else {
+ throw new \RuntimeException( 'Oracle supervisor emitted an unknown control event.' );
+ }
+ $events[] = $event;
+ }
+ $names = array_column( $events, 'event' );
+ $valid_sequences = array(
+ array( 'supervisor-ready' ),
+ array( 'supervisor-ready', 'anchor-ready' ),
+ array( 'supervisor-ready', 'anchor-ready', 'running' ),
+ array( 'supervisor-ready', 'anchor-ready', 'running', 'cleaned' ),
+ array( 'supervisor-ready', 'anchor-ready', 'running', 'cleanup-failed' ),
+ );
+ if ( ! in_array( $names, $valid_sequences, true ) ) {
+ throw new \RuntimeException( 'Oracle supervisor control phases are out of order or incomplete in an invalid position.' );
+ }
+ $supervisor = $events[0]['supervisor'];
+ if ( $supervisor['pid'] !== $supervisor['sid'] ) {
+ throw new \RuntimeException( 'Oracle supervisor control identity is not its session leader.' );
+ }
+ if ( isset( $events[1] ) ) {
+ $anchor = $events[1]['anchor'];
+ if ( $anchor['pid'] !== $anchor['pgid'] || $anchor['sid'] !== $supervisor['sid'] || $anchor['pid'] === $supervisor['pid'] ) {
+ throw new \RuntimeException( 'Oracle anchor control identity is inconsistent with its supervisor.' );
+ }
+ }
+ if ( isset( $events[2] ) ) {
+ $target = $events[2]['target'];
+ if ( $target['sid'] !== $anchor['sid'] || $target['pgid'] !== $anchor['pgid'] || in_array( $target['pid'], array( $supervisor['pid'], $anchor['pid'] ), true ) ) {
+ throw new \RuntimeException( 'Oracle target control identity is inconsistent with its anchor.' );
+ }
+ }
+ return $events;
+ }
+
+ private static function state_validation_error( $state, string $root, string $token, array $target_copy, array $supervisor_copy, int $supervisor_pid ): ?string {
+ if ( ! is_array( $state ) || ! self::exact_keys( $state, array( 'schemaVersion', 'token', 'root', 'rootIdentity', 'ownerIdentity', 'phase', 'supervisorPath', 'supervisorSha256', 'targetPath', 'targetSha256', 'supervisor', 'anchor', 'target', 'cleanupError' ) ) || ! \HtmlApiFuzz\OracleProcessSupervisor\valid_state_schema( $state ) ) {
+ return 'state schema differs';
+ }
+ $actual_ownership = \HtmlApiFuzz\OracleProcessSupervisor\ownership_identity( $root );
+ if (
+ 1 !== $state['schemaVersion'] ||
+ $token !== $state['token'] ||
+ $root !== $state['root'] ||
+ null !== \HtmlApiFuzz\OracleProcessSupervisor\owner_error( $root, $token ) ||
+ ! \HtmlApiFuzz\OracleProcessSupervisor\valid_inode_identity( $state['rootIdentity'] ) ||
+ ! \HtmlApiFuzz\OracleProcessSupervisor\valid_inode_identity( $state['ownerIdentity'] ) ||
+ $state['rootIdentity'] !== ( $actual_ownership['root'] ?? null ) ||
+ $state['ownerIdentity'] !== ( $actual_ownership['owner'] ?? null ) ||
+ $supervisor_copy['path'] !== $state['supervisorPath'] ||
+ $supervisor_copy['sha256'] !== $state['supervisorSha256'] ||
+ $target_copy['path'] !== $state['targetPath'] ||
+ $target_copy['sha256'] !== $state['targetSha256'] ||
+ $supervisor_pid !== ( $state['supervisor']['pid'] ?? null ) ||
+ null !== self::process_document_error( $state['supervisor'] )
+ ) {
+ return 'state ownership or executable identity differs';
+ }
+ foreach ( array( 'anchor', 'target' ) as $field ) {
+ if ( null !== $state[ $field ] && null !== self::process_document_error( $state[ $field ] ) ) {
+ return "state {$field} identity is invalid";
+ }
+ }
+ if ( null !== $state['cleanupError'] && ! is_string( $state['cleanupError'] ) ) {
+ return 'state cleanup error is invalid';
+ }
+ return null;
+ }
- usleep( 10000 );
+ private static function process_document_error( $document ): ?string {
+ if ( ! is_array( $document ) || ! self::exact_keys( $document, array( 'pid', 'pgid', 'sid', 'birth' ) ) ) {
+ return 'process document schema differs';
}
+ return is_int( $document['pid'] ) && $document['pid'] > 1 &&
+ is_int( $document['pgid'] ) && $document['pgid'] > 1 &&
+ is_int( $document['sid'] ) && $document['sid'] > 1 &&
+ self::nonempty_string( $document['birth'] ) ? null : 'process document values are invalid';
+ }
- $stdout .= stream_get_contents( $pipes[1] );
- $stderr .= stream_get_contents( $pipes[2] );
- fclose( $pipes[1] );
- fclose( $pipes[2] );
+ private static function drain_bounded( $stream, string &$buffer, int $maximum, bool &$overflow ): void {
+ while ( true ) {
+ $chunk = stream_get_contents( $stream, 65536 );
+ if ( false === $chunk || '' === $chunk ) {
+ return;
+ }
+ if ( strlen( $buffer ) + strlen( $chunk ) > $maximum ) {
+ $overflow = true;
+ continue;
+ }
+ $buffer .= $chunk;
+ }
+ }
- $exit_code = proc_close( $process );
- if ( $timed_out ) {
- $exit_code = null;
+ private static function create_ownership_root(): string {
+ $base = realpath( sys_get_temp_dir() );
+ if ( false === $base ) {
+ throw new \RuntimeException( 'Could not resolve the system temporary directory.' );
}
+ for ( $attempt = 0; $attempt < 20; ++$attempt ) {
+ $root = $base . '/html-api-fuzz-oracle-' . bin2hex( random_bytes( 16 ) );
+ if ( @mkdir( $root, 0700 ) ) {
+ chmod( $root, 0700 );
+ return $root;
+ }
+ }
+ throw new \RuntimeException( 'Could not create a private oracle ownership root.' );
+ }
- return array(
- 'command' => command_string( $command ),
- 'code' => $exit_code,
- 'timedOut' => $timed_out,
- 'durationMs' => (int) round( ( microtime( true ) - $start ) * 1000 ),
- 'stdout' => $stdout,
- 'stderr' => $stderr,
- 'output' => $stdout . $stderr,
+ private static function write_private_file( string $path, string $contents, int $mode ): void {
+ $handle = @fopen( $path, 'xb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( 'Could not create a private oracle file.' );
+ }
+ try {
+ $written = 0;
+ while ( $written < strlen( $contents ) ) {
+ $count = fwrite( $handle, substr( $contents, $written, 65536 ) );
+ if ( false === $count || 0 === $count ) {
+ throw new \RuntimeException( 'Could not write a complete private oracle file.' );
+ }
+ $written += $count;
+ }
+ if ( ! fflush( $handle ) || ( function_exists( 'fsync' ) && ! fsync( $handle ) ) ) {
+ throw new \RuntimeException( 'Could not sync a private oracle file.' );
+ }
+ } finally {
+ fclose( $handle );
+ }
+ chmod( $path, $mode );
+ }
+
+ private static function copy_stable_private( string $source, string $destination, bool $require_executable ): array {
+ clearstatcache( true, $source );
+ $resolved = realpath( $source );
+ if ( false !== $resolved ) {
+ clearstatcache( true, $resolved );
+ }
+ $lstat = false === $resolved ? false : @lstat( $resolved );
+ if (
+ false === $resolved ||
+ false === $lstat ||
+ ( $lstat['mode'] & 0170000 ) !== 0100000 ||
+ ( $require_executable && ! is_executable( $resolved ) )
+ ) {
+ throw new \RuntimeException( 'Oracle execution source is not a trusted regular file.' );
+ }
+ $input = @fopen( $resolved, 'rb' );
+ $output = @fopen( $destination, 'xb' );
+ if ( false === $input || false === $output ) {
+ is_resource( $input ) && fclose( $input );
+ is_resource( $output ) && fclose( $output );
+ throw new \RuntimeException( 'Could not open a private oracle execution snapshot.' );
+ }
+ $before = fstat( $input );
+ if ( ! is_array( $before ) || (int) $lstat['dev'] !== (int) $before['dev'] || (int) $lstat['ino'] !== (int) $before['ino'] || ( $before['mode'] & 0170000 ) !== 0100000 ) {
+ fclose( $input );
+ fclose( $output );
+ throw new \RuntimeException( 'Oracle execution source changed before it was opened.' );
+ }
+ $hash = hash_init( 'sha256' );
+ try {
+ while ( ! feof( $input ) ) {
+ $chunk = fread( $input, 65536 );
+ if ( false === $chunk ) {
+ throw new \RuntimeException( 'Could not read oracle execution source.' );
+ }
+ if ( '' !== $chunk ) {
+ hash_update( $hash, $chunk );
+ $offset = 0;
+ while ( $offset < strlen( $chunk ) ) {
+ $written = fwrite( $output, substr( $chunk, $offset ) );
+ if ( false === $written || 0 === $written ) {
+ throw new \RuntimeException( 'Could not write complete oracle execution snapshot.' );
+ }
+ $offset += $written;
+ }
+ }
+ }
+ $after = fstat( $input );
+ if ( ! fflush( $output ) || ( function_exists( 'fsync' ) && ! fsync( $output ) ) ) {
+ throw new \RuntimeException( 'Could not sync oracle execution snapshot.' );
+ }
+ } finally {
+ fclose( $input );
+ fclose( $output );
+ }
+ foreach ( array( 'dev', 'ino', 'mode', 'uid', 'size', 'mtime', 'ctime' ) as $field ) {
+ if ( ! is_array( $before ) || ! is_array( $after ) || ( $before[ $field ] ?? null ) !== ( $after[ $field ] ?? null ) ) {
+ throw new \RuntimeException( 'Oracle execution source changed while it was copied.' );
+ }
+ }
+ chmod( $destination, 0500 );
+ $sha256 = hash_final( $hash );
+ $private_stat = @lstat( $destination );
+ if (
+ false === $private_stat ||
+ ( $private_stat['mode'] & 0170000 ) !== 0100000 ||
+ ( $private_stat['mode'] & 0777 ) !== 0500 ||
+ ! hash_equals( $sha256, hash_file( 'sha256', $destination ) ?: '' )
+ ) {
+ throw new \RuntimeException( 'Private oracle execution snapshot identity is invalid.' );
+ }
+ return array( 'path' => $destination, 'sha256' => $sha256, 'source' => $resolved );
+ }
+
+ private static function read_stable_file( string $path, int $maximum ): string {
+ clearstatcache( true, $path );
+ $resolved = realpath( $path );
+ if ( false !== $resolved ) {
+ clearstatcache( true, $resolved );
+ }
+ $stat = false === $resolved ? false : @lstat( $resolved );
+ if ( false === $resolved || false === $stat || ( $stat['mode'] & 0170000 ) !== 0100000 ) {
+ throw new \RuntimeException( 'Required oracle identity file is not a regular file.' );
+ }
+ $handle = @fopen( $resolved, 'rb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( 'Could not open required oracle identity file.' );
+ }
+ $before = fstat( $handle );
+ if ( ! is_array( $before ) || (int) $stat['dev'] !== (int) $before['dev'] || (int) $stat['ino'] !== (int) $before['ino'] || ( $before['mode'] & 0170000 ) !== 0100000 ) {
+ fclose( $handle );
+ throw new \RuntimeException( 'Required oracle identity file changed before it was opened.' );
+ }
+ $contents = '';
+ try {
+ while ( ! feof( $handle ) ) {
+ $chunk = fread( $handle, min( 65536, $maximum + 1 - strlen( $contents ) ) );
+ if ( false === $chunk ) {
+ throw new \RuntimeException( 'Could not read required oracle identity file.' );
+ }
+ $contents .= $chunk;
+ if ( strlen( $contents ) > $maximum ) {
+ throw new \RuntimeException( 'Required oracle identity file exceeded its byte limit.' );
+ }
+ }
+ $after = fstat( $handle );
+ } finally {
+ fclose( $handle );
+ }
+ clearstatcache( true, $resolved );
+ $path_after = @lstat( $resolved );
+ foreach ( array( 'dev', 'ino', 'mode', 'uid', 'size', 'mtime', 'ctime' ) as $field ) {
+ if (
+ ! is_array( $before ) ||
+ ! is_array( $after ) ||
+ ! is_array( $path_after ) ||
+ ( $before[ $field ] ?? null ) !== ( $after[ $field ] ?? null ) ||
+ ( $after[ $field ] ?? null ) !== ( $path_after[ $field ] ?? null )
+ ) {
+ throw new \RuntimeException( 'Required oracle identity file changed while it was read.' );
+ }
+ }
+ return $contents;
+ }
+
+ private static function hash_stable_file( string $path, bool $require_executable = false ): string {
+ clearstatcache( true, $path );
+ $resolved = realpath( $path );
+ if ( false !== $resolved ) {
+ clearstatcache( true, $resolved );
+ }
+ $stat = false === $resolved ? false : @lstat( $resolved );
+ if (
+ false === $resolved ||
+ false === $stat ||
+ ( $stat['mode'] & 0170000 ) !== 0100000 ||
+ ( $require_executable && ! is_executable( $resolved ) )
+ ) {
+ throw new \RuntimeException( 'Required oracle identity file is not a trusted regular file.' );
+ }
+ $handle = @fopen( $resolved, 'rb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( 'Could not open required oracle identity file.' );
+ }
+ $before = fstat( $handle );
+ if ( ! is_array( $before ) || (int) $stat['dev'] !== (int) $before['dev'] || (int) $stat['ino'] !== (int) $before['ino'] || ( $before['mode'] & 0170000 ) !== 0100000 ) {
+ fclose( $handle );
+ throw new \RuntimeException( 'Required oracle identity file changed before it was opened.' );
+ }
+ $hash = hash_init( 'sha256' );
+ try {
+ while ( ! feof( $handle ) ) {
+ $chunk = fread( $handle, 65536 );
+ if ( false === $chunk ) {
+ throw new \RuntimeException( 'Could not read required oracle identity file.' );
+ }
+ if ( '' !== $chunk ) {
+ hash_update( $hash, $chunk );
+ }
+ }
+ $after = fstat( $handle );
+ } finally {
+ fclose( $handle );
+ }
+ clearstatcache( true, $resolved );
+ $path_after = @lstat( $resolved );
+ foreach ( array( 'dev', 'ino', 'mode', 'uid', 'size', 'mtime', 'ctime' ) as $field ) {
+ if (
+ ! is_array( $before ) ||
+ ! is_array( $after ) ||
+ ! is_array( $path_after ) ||
+ ( $before[ $field ] ?? null ) !== ( $after[ $field ] ?? null ) ||
+ ( $after[ $field ] ?? null ) !== ( $path_after[ $field ] ?? null )
+ ) {
+ throw new \RuntimeException( 'Required oracle identity file changed while it was hashed.' );
+ }
+ }
+ return hash_final( $hash );
+ }
+
+ private static function remove_unstarted_root( string $root, string $token ): bool {
+ require_once dirname( __DIR__ ) . '/oracle-process-supervisor.php';
+ $ownership = \HtmlApiFuzz\OracleProcessSupervisor\ownership_identity( $root );
+ if ( is_array( $ownership ) ) {
+ try {
+ \HtmlApiFuzz\OracleProcessSupervisor\register_ownership_identity( $root, $ownership['root'], $ownership['owner'] );
+ return \HtmlApiFuzz\OracleProcessSupervisor\remove_owned_root( $root, $token );
+ } catch ( \Throwable $ignored ) {
+ return false;
+ }
+ }
+ $stat = @lstat( $root );
+ $entries = @scandir( $root );
+ if (
+ false === $stat ||
+ ( $stat['mode'] & 0170000 ) !== 0040000 ||
+ ( $stat['mode'] & 0777 ) !== 0700 ||
+ ( function_exists( 'posix_geteuid' ) && $stat['uid'] !== posix_geteuid() ) ||
+ array( '.', '..' ) !== $entries
+ ) {
+ return false;
+ }
+ return @rmdir( $root );
+ }
+
+ private function infrastructure_result( string $message, ?array $process ): array {
+ $result = array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'error' => $message,
+ 'failureClass' => 'oracle-renderer-error',
+ 'infrastructure' => true,
+ 'oracle' => $this->metadata(),
);
+ if ( is_array( $process ) ) {
+ $result['process'] = self::compact_process( $process );
+ }
+ return $result;
}
private static function compact_process( array $process ): array {
return array(
- 'command' => $process['command'] ?? null,
- 'code' => $process['code'] ?? null,
- 'timedOut' => $process['timedOut'] ?? null,
- 'durationMs' => $process['durationMs'] ?? null,
- 'stderrTail' => substr( (string) ( $process['stderr'] ?? '' ), -1000 ),
+ 'code' => $process['code'] ?? null,
+ 'timedOut' => $process['timedOut'] ?? false,
+ 'durationMs' => $process['durationMs'] ?? null,
+ 'stdoutBytes' => strlen( (string) ( $process['stdout'] ?? '' ) ),
+ 'stderrTail' => substr( (string) ( $process['stderr'] ?? '' ), -1000 ),
+ 'stdoutOverflow' => $process['stdoutOverflow'] ?? false,
+ 'stderrOverflow' => $process['stderrOverflow'] ?? false,
+ 'cleanupVerified' => $process['cleanupVerified'] ?? false,
+ 'supervisorKilled' => $process['supervisorKilled'] ?? false,
+ 'supervisorUnexpectedExit' => $process['supervisorUnexpectedExit'] ?? false,
);
}
+
+ private static function metadata_validation_error( $metadata ): ?string {
+ if ( ! is_array( $metadata ) || ! self::exact_keys( $metadata, array( 'schemaVersion', 'kind', 'available', 'identity', 'error' ) ) ) {
+ return 'metadata schema differs';
+ }
+ if ( 1 !== $metadata['schemaVersion'] || ! in_array( $metadata['kind'], self::kinds(), true ) || ! is_bool( $metadata['available'] ) ) {
+ return 'metadata header is invalid';
+ }
+ if ( ! $metadata['available'] ) {
+ return null === $metadata['identity'] && self::nonempty_string( $metadata['error'] ) ? null : 'unavailable metadata values are invalid';
+ }
+ if ( null !== $metadata['error'] || ! is_array( $metadata['identity'] ) ) {
+ return 'available metadata values are invalid';
+ }
+ $identity = $metadata['identity'];
+ if ( ( $identity['kind'] ?? null ) !== $metadata['kind'] || 1 !== ( $identity['schemaVersion'] ?? null ) ) {
+ return 'identity header differs';
+ }
+ if ( self::KIND_PHP_DOM === $metadata['kind'] ) {
+ return self::exact_keys( $identity, array( 'schemaVersion', 'kind', 'phpVersion', 'phpVersionId', 'phpSapi', 'zendVersion', 'libxmlVersion', 'domHtmlDocument' ) ) &&
+ self::nonempty_string( $identity['phpVersion'] ) && is_int( $identity['phpVersionId'] ) && $identity['phpVersionId'] > 0 &&
+ self::nonempty_string( $identity['phpSapi'] ) && self::nonempty_string( $identity['zendVersion'] ) &&
+ ( null === $identity['libxmlVersion'] || self::nonempty_string( $identity['libxmlVersion'] ) ) && true === $identity['domHtmlDocument']
+ ? null : 'PHP DOM identity is invalid';
+ }
+ if ( self::KIND_LEXBOR_SOURCE === $metadata['kind'] ) {
+ $build = $identity['build'] ?? null;
+ return self::exact_keys( $identity, array( 'schemaVersion', 'kind', 'binarySha256', 'lexborCommit', 'lexborVersion', 'build' ) ) &&
+ self::matches( $identity['binarySha256'], '/^[0-9a-f]{64}$/' ) && self::matches( $identity['lexborCommit'], '/^[0-9a-f]{40}$/' ) &&
+ self::nonempty_string( $identity['lexborVersion'] ) && is_array( $build ) &&
+ self::exact_keys( $build, array( 'kind', 'requestedRef', 'resolvedCommit', 'upstream', 'compiler', 'cmake' ) ) &&
+ 'html-api-fuzz-lexbor-build' === $build['kind'] && self::nonempty_string( $build['requestedRef'] ) &&
+ $identity['lexborCommit'] === $build['resolvedCommit'] && 'https://github.com/lexbor/lexbor.git' === $build['upstream'] &&
+ self::nonempty_string( $build['compiler'] ) && self::nonempty_string( $build['cmake'] )
+ ? null : 'Lexbor identity is invalid';
+ }
+ $build = $identity['build'] ?? null;
+ $identity_keys = array( 'schemaVersion', 'kind', 'binarySha256', 'html5everVersion', 'html5everChecksum', 'markup5everRcdomVersion', 'markup5everRcdomChecksum', 'rustToolchain', 'cargoLockSha256', 'buildIdentity', 'build' );
+ $build_keys = array( 'schemaVersion', 'kind', 'publicationProtocol', 'cargoTomlSha256', 'cargoLockSha256', 'rustToolchainSha256', 'sourceSha256', 'rustc', 'cargo', 'html5ever', 'markup5everRcdom' );
+ return self::exact_keys( $identity, $identity_keys ) && self::matches( $identity['binarySha256'], '/^[0-9a-f]{64}$/' ) &&
+ self::nonempty_string( $identity['html5everVersion'] ) && self::matches( $identity['html5everChecksum'], '/^[0-9a-f]{64}$/' ) &&
+ self::nonempty_string( $identity['markup5everRcdomVersion'] ) && self::matches( $identity['markup5everRcdomChecksum'], '/^[0-9a-f]{64}$/' ) &&
+ self::nonempty_string( $identity['rustToolchain'] ) && self::matches( $identity['cargoLockSha256'], '/^[0-9a-f]{64}$/' ) &&
+ self::matches( $identity['buildIdentity'], '/^[0-9a-f]{64}$/' ) && is_array( $build ) && self::exact_keys( $build, $build_keys ) &&
+ 1 === $build['schemaVersion'] && 'html-api-fuzz-html5ever-build' === $build['kind'] && 'manifest-last-v1' === $build['publicationProtocol'] &&
+ self::matches( $build['cargoTomlSha256'], '/^[0-9a-f]{64}$/' ) && $identity['cargoLockSha256'] === $build['cargoLockSha256'] &&
+ self::matches( $build['rustToolchainSha256'], '/^[0-9a-f]{64}$/' ) && self::matches( $build['sourceSha256'], '/^[0-9a-f]{64}$/' ) &&
+ self::nonempty_string( $build['rustc'] ) && self::nonempty_string( $build['cargo'] ) && self::package_identity_valid( $build['html5ever'] ) &&
+ self::package_identity_valid( $build['markup5everRcdom'] ) && $identity['html5everVersion'] === $build['html5ever']['version'] &&
+ $identity['html5everChecksum'] === $build['html5ever']['checksum'] && $identity['markup5everRcdomVersion'] === $build['markup5everRcdom']['version'] &&
+ $identity['markup5everRcdomChecksum'] === $build['markup5everRcdom']['checksum']
+ ? null : 'html5ever identity is invalid';
+ }
+
+ private static function cargo_lock_package( string $lock, string $name ): array {
+ $count = preg_match_all( '/\[\[package\]\]\s*(.*?)(?=\n\[\[package\]\]|\z)/s', $lock, $packages );
+ if ( false === $count || 0 === $count ) {
+ throw new \RuntimeException( 'Cargo.lock package records are malformed.' );
+ }
+ foreach ( $packages[1] as $package ) {
+ if ( 1 !== preg_match( '/^name\s*=\s*"([^"]+)"/m', $package, $package_name ) || $name !== $package_name[1] ) {
+ continue;
+ }
+ preg_match( '/^version\s*=\s*"([^"]+)"/m', $package, $version );
+ preg_match( '/^checksum\s*=\s*"([^"]+)"/m', $package, $checksum );
+ if ( ! isset( $version[1], $checksum[1] ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', $checksum[1] ) ) {
+ throw new \RuntimeException( "Cargo.lock identity for {$name} is incomplete." );
+ }
+ return array( 'version' => $version[1], 'checksum' => $checksum[1] );
+ }
+ throw new \RuntimeException( "Cargo.lock does not contain {$name}." );
+ }
+
+ private static function rust_toolchain_channel( string $toolchain ): string {
+ if ( 1 !== preg_match( '/^channel\s*=\s*"([^"]+)"/m', $toolchain, $matches ) ) {
+ throw new \RuntimeException( 'rust-toolchain.toml channel is missing.' );
+ }
+ return $matches[1];
+ }
+
+ private static function package_identity_valid( $package ): bool {
+ return is_array( $package ) && self::exact_keys( $package, array( 'version', 'checksum' ) ) &&
+ self::nonempty_string( $package['version'] ) && self::matches( $package['checksum'], '/^[0-9a-f]{64}$/' );
+ }
+
+ private static function positive_limit( array $limits, string $key, int $default ): int {
+ $value = $limits[ $key ] ?? $default;
+ if ( ! is_int( $value ) || $value < 1 ) {
+ throw new \InvalidArgumentException( "{$key} must be a positive integer." );
+ }
+ return $value;
+ }
+
+ private static function canonical_json( $value ): string {
+ return json_encode( self::canonicalize( $value ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR );
+ }
+
+ private static function canonicalize( $value ) {
+ if ( ! is_array( $value ) ) {
+ return $value;
+ }
+ if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
+ ksort( $value, SORT_STRING );
+ }
+ foreach ( $value as $key => $item ) {
+ $value[ $key ] = self::canonicalize( $item );
+ }
+ return $value;
+ }
+
+ private static function exact_keys( array $value, array $keys ): bool {
+ $actual = array_keys( $value );
+ sort( $actual, SORT_STRING );
+ sort( $keys, SORT_STRING );
+ return $actual === $keys;
+ }
+
+ private static function nonempty_string( $value ): bool {
+ return is_string( $value ) && '' !== $value;
+ }
+
+ private static function matches( $value, string $pattern ): bool {
+ return is_string( $value ) && 1 === preg_match( $pattern, $value );
+ }
}
diff --git a/tools/html-api-fuzz/lib/ResultStore.php b/tools/html-api-fuzz/lib/ResultStore.php
index e5360eff60b10..4a2b52c46de56 100644
--- a/tools/html-api-fuzz/lib/ResultStore.php
+++ b/tools/html-api-fuzz/lib/ResultStore.php
@@ -121,6 +121,7 @@ public function record_attempt( array $summary, ?array $result = null, ?array $r
$ok = (bool) ( $summary['ok'] ?? false );
$oracle_finding = is_array( $summary['oracleFinding'] ?? null ) ? $summary['oracleFinding'] : null;
$oracle = is_array( $summary['oracle'] ?? null ) ? $summary['oracle'] : null;
+ $oracle_identity = is_array( $oracle['identity'] ?? null ) ? $oracle['identity'] : array();
$store_json = ! $ok || null !== $oracle_finding;
$artifacts_retained = (bool) ( $summary['artifactsRetained'] ?? false );
$failure_artifacts_retained = array_key_exists( 'failureArtifactsRetained', $summary )
@@ -202,11 +203,11 @@ public function record_attempt( array $summary, ?array $result = null, ?array $r
$statement->bindValue( ':oracle_family_key', $oracle_family_key, null === $oracle_family_key ? SQLITE3_NULL : SQLITE3_TEXT );
$oracle_kind = $oracle['kind'] ?? null;
$statement->bindValue( ':oracle_kind', $oracle_kind, null === $oracle_kind ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_version = $oracle['lexborVersion'] ?? $oracle['phpVersion'] ?? null;
+ $oracle_version = $oracle_identity['lexborVersion'] ?? $oracle_identity['html5everVersion'] ?? $oracle_identity['phpVersion'] ?? null;
$statement->bindValue( ':oracle_version', $oracle_version, null === $oracle_version ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_commit = $oracle['lexborCommit'] ?? null;
+ $oracle_commit = $oracle_identity['lexborCommit'] ?? $oracle_identity['buildIdentity'] ?? null;
$statement->bindValue( ':oracle_commit', $oracle_commit, null === $oracle_commit ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_binary = $oracle['binary'] ?? null;
+ $oracle_binary = $oracle_identity['binarySha256'] ?? null;
$statement->bindValue( ':oracle_binary', $oracle_binary, null === $oracle_binary ? SQLITE3_NULL : SQLITE3_TEXT );
$statement->bindValue( ':profile', $summary['profile'] ?? null, null === ( $summary['profile'] ?? null ) ? SQLITE3_NULL : SQLITE3_TEXT );
$statement->bindValue( ':mode', $summary['mode'] ?? null, null === ( $summary['mode'] ?? null ) ? SQLITE3_NULL : SQLITE3_TEXT );
diff --git a/tools/html-api-fuzz/lib/Worker.php b/tools/html-api-fuzz/lib/Worker.php
index dab11a142801d..42cd7d7f843ad 100644
--- a/tools/html-api-fuzz/lib/Worker.php
+++ b/tools/html-api-fuzz/lib/Worker.php
@@ -217,6 +217,9 @@ public static function evaluate_input( string $input, int $seed, string $profile
$result['dom'] = $dom_result;
if ( TreeRenderer::STATUS_ERROR === $dom_result['status'] ) {
+ if ( true === ( $dom_result['infrastructure'] ?? false ) ) {
+ $result['oracleInfrastructure'] = true;
+ }
$dom_failure_class = $dom_result['failureClass'] ?? 'oracle-renderer-error';
$result['failureClass'] = self::is_resource_limit_failure( $dom_failure_class ) ? 'resource-limit' : $dom_failure_class;
$result['status'] = self::is_resource_limit_failure( $dom_failure_class )
@@ -277,6 +280,9 @@ public static function evaluate_input( string $input, int $seed, string $profile
$result['timingsMs']['mutation'] = self::stage_elapsed_ms( $stage_started );
$result['mutation'] = $mutation;
if ( false === $mutation['ok'] ) {
+ if ( true === ( $mutation['oracleInfrastructure'] ?? false ) ) {
+ $result['oracleInfrastructure'] = true;
+ }
$result['ok'] = false;
$result['status'] = 'failed';
$result['failureClass'] = $mutation['failureClass'];
@@ -399,6 +405,10 @@ private static function validate_mode_metadata( string $mode ): void {
}
private static function base_replay( int $seed, string $profile, string $mode, ?string $payload_policy, string $fragment_context, ?array $generator_parameters, string $input_source, string $input, string $output_dir, array $limits, bool $fail_unsupported, array $git_metadata, array $oracle_metadata, array $oracle_options, string $checks ): array {
+ $replay_options = array_merge(
+ array( 'failUnsupported' => $fail_unsupported, 'checks' => $checks ),
+ $oracle_options
+ );
return array(
'schemaVersion' => 1,
'kind' => 'html-api-fuzz-replay',
@@ -420,13 +430,7 @@ private static function base_replay( int $seed, string $profile, string $mode, ?
'inputPreview' => preview_bytes( $input ),
'limits' => $limits,
'oracle' => $oracle_metadata,
- 'options' => array(
- 'failUnsupported' => $fail_unsupported,
- 'checks' => $checks,
- 'domOracle' => $oracle_options['domOracle'] ?? OracleRenderer::KIND_PHP_DOM,
- 'lexborOracleBin' => $oracle_options['lexborOracleBin'] ?? null,
- 'oracleTimeoutMs' => $oracle_options['oracleTimeoutMs'] ?? null,
- ),
+ 'options' => $replay_options,
'command' => array(
'program' => PHP_BINARY,
'args' => array(
@@ -491,12 +495,16 @@ private static function check_mutation_differential( string $input, string $mode
if ( TreeRenderer::STATUS_OK !== $dom_updated['status'] ) {
if ( TreeRenderer::STATUS_UNSUPPORTED !== $dom_updated['status'] ) {
$failure_class = $dom_updated['failureClass'] ?? 'mutation-oracle-render-error';
- return array(
+ $result = array(
'ok' => false,
'status' => self::is_resource_limit_failure( $failure_class ) ? 'resource-limit' : 'failed',
'failureClass' => self::is_resource_limit_failure( $failure_class ) ? 'resource-limit' : 'mutation-oracle-render-error',
'renderResult' => $dom_updated,
);
+ if ( true === ( $dom_updated['infrastructure'] ?? false ) ) {
+ $result['oracleInfrastructure'] = true;
+ }
+ return $result;
}
return array(
'ok' => true,
diff --git a/tools/html-api-fuzz/minimize.php b/tools/html-api-fuzz/minimize.php
index 265525b804915..72ee7cc9b76b8 100755
--- a/tools/html-api-fuzz/minimize.php
+++ b/tools/html-api-fuzz/minimize.php
@@ -12,6 +12,31 @@ function html_api_fuzz_min_accepts_result( ?array $result, array $base, bool $an
: ( $any_failure ? ! ( $result['ok'] ?? true ) : ( ( $result['signature']['hash'] ?? null ) === $base['targetHash'] ) );
}
+function html_api_fuzz_min_probe_terminal_error( ?array $result, array $base ): ?string {
+ if ( null === $result ) {
+ return null;
+ }
+ $mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $base['oracle'], is_array( $result['oracle'] ?? null ) ? $result['oracle'] : array() );
+ if ( ! empty( $mismatches ) ) {
+ return 'Oracle identity drift during minimization: ' . implode( '; ', $mismatches );
+ }
+ if (
+ 'oracle-renderer-error' === ( $result['failureClass'] ?? null ) ||
+ 'oracle-renderer-error' === ( $result['dom']['failureClass'] ?? null ) ||
+ 'oracle-renderer-error' === ( $result['mutation']['renderResult']['failureClass'] ?? null ) ||
+ true === ( $result['oracleInfrastructure'] ?? false ) ||
+ true === ( $result['dom']['infrastructure'] ?? false ) ||
+ true === ( $result['mutation']['oracleInfrastructure'] ?? false ) ||
+ true === ( $result['mutation']['renderResult']['infrastructure'] ?? false )
+ ) {
+ return 'Oracle infrastructure failure during minimization: ' . (string) ( $result['dom']['error'] ?? $result['mutation']['renderResult']['error'] ?? $result['failureSnippet'] ?? 'unknown failure' );
+ }
+ if ( 'worker-fatal' === ( $result['status'] ?? null ) ) {
+ return 'Worker infrastructure failure during minimization: ' . (string) ( $result['failureSnippet'] ?? 'unknown failure' );
+ }
+ return null;
+}
+
function html_api_fuzz_min_worker_options( string $candidate, array $base, string $output_dir ): array {
$options = array(
'input-base64' => base64_encode( $candidate ),
@@ -120,8 +145,9 @@ function html_api_fuzz_min_process_test( string $candidate, array $base, string
return array( 'accepted' => false, 'result' => null, 'process' => $proc );
}
- $accepted = html_api_fuzz_min_accepts_result( $result, $base, $any_failure );
- return array( 'accepted' => $accepted, 'result' => $result, 'process' => $proc );
+ $terminal_error = html_api_fuzz_min_probe_terminal_error( $result, $base );
+ $accepted = null === $terminal_error && html_api_fuzz_min_accepts_result( $result, $base, $any_failure );
+ return array( 'accepted' => $accepted, 'result' => $result, 'process' => $proc, 'terminalError' => $terminal_error );
}
function html_api_fuzz_min_in_process_test( string $candidate, array $base, string $work_dir, int $attempt, bool $any_failure ): array {
@@ -155,9 +181,11 @@ function html_api_fuzz_min_in_process_test( string $candidate, array $base, stri
$result = html_api_fuzz_min_fatal_result( $base, $e, $duration_ms );
}
+ $terminal_error = html_api_fuzz_min_probe_terminal_error( $result, $base );
return array(
- 'accepted' => html_api_fuzz_min_accepts_result( $result, $base, $any_failure ),
+ 'accepted' => null === $terminal_error && html_api_fuzz_min_accepts_result( $result, $base, $any_failure ),
'result' => $result,
+ 'terminalError' => $terminal_error,
'process' => array(
'code' => null,
'timedOut' => false,
@@ -176,6 +204,9 @@ function html_api_fuzz_min_test( string $candidate, array $base, string $work_di
}
function html_api_fuzz_min_record_probe( array &$stats, array $test ): void {
+ if ( is_string( $test['terminalError'] ?? null ) ) {
+ throw new \RuntimeException( $test['terminalError'] );
+ }
$duration_ms = $test['process']['durationMs'] ?? null;
if ( ! is_numeric( $duration_ms ) ) {
return;
@@ -240,7 +271,7 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
$options = \HtmlApiFuzz\parse_cli_options( $argv );
$replay_path = \HtmlApiFuzz\option_string( $options, 'replay', $options['_'][0] ?? null );
if ( null === $replay_path || \HtmlApiFuzz\option_bool( $options, 'help', false ) ) {
- echo "Usage: php tools/html-api-fuzz/minimize.php --replay path/to/replay.json [--output-dir DIR] [--target-kind failure|oracle-finding --target-hash HASH] [--dom-oracle php-dom|lexbor-source] [--lexbor-oracle-bin PATH] [--probe-mode auto|in-process|process] [--keep-candidate-artifacts]\n";
+ echo "Usage: php tools/html-api-fuzz/minimize.php --replay path/to/replay.json [--output-dir DIR] [--target-kind failure|oracle-finding --target-hash HASH] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch] [--probe-mode auto|in-process|process] [--keep-candidate-artifacts]\n";
exit( null === $replay_path ? 1 : 0 );
}
@@ -259,7 +290,6 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
}
$output_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', dirname( $replay_path ) . '/minimized-' . \HtmlApiFuzz\timestamp() );
-\HtmlApiFuzz\ensure_dir( $output_dir );
$input = base64_decode( $replay['inputBase64'], true );
if ( false === $input ) {
@@ -275,11 +305,22 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'lexbor-oracle-bin', null ) && is_string( $replay['options']['lexborOracleBin'] ?? null ) ) {
$oracle_options['lexbor-oracle-bin'] = $replay['options']['lexborOracleBin'];
}
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ) && is_string( $replay['options']['html5everOracleBin'] ?? null ) ) {
+ $oracle_options['html5ever-oracle-bin'] = $replay['options']['html5everOracleBin'];
+}
$stored_oracle_timeout_ms = $replay['options']['oracleTimeoutMs'] ?? null;
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && is_numeric( $stored_oracle_timeout_ms ) ) {
$oracle_options['oracle-timeout-ms'] = (string) (int) $stored_oracle_timeout_ms;
}
$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
+$current_oracle = $oracle_renderer->metadata();
+$oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $replay['oracle'] ?? null, $current_oracle );
+if ( ! empty( $oracle_mismatches ) && ! \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false ) ) {
+ fwrite( STDERR, 'Oracle identity mismatch: ' . implode( '; ', $oracle_mismatches ) . ".\n" );
+ fwrite( STDERR, "Pass --allow-oracle-mismatch only for a deliberate diagnostic minimization.\n" );
+ exit( 1 );
+}
+\HtmlApiFuzz\ensure_dir( $output_dir );
$probe_mode = html_api_fuzz_min_probe_mode( $options );
$base = array(
'mode' => $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
@@ -292,11 +333,14 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
'targetHash' => $target_hash,
'targetKind' => $target['kind'] ?? 'failure',
'sourceReplay' => $source_replay,
- 'oracle' => $oracle_renderer->metadata(),
+ 'oracle' => $current_oracle,
+ 'sourceOracle' => $replay['oracle'] ?? null,
+ 'oracleIdentityMismatches' => $oracle_mismatches,
'oracleRenderer' => $oracle_renderer,
'oracleOptions' => array(
'dom-oracle' => \HtmlApiFuzz\option_string( $oracle_options, 'dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM ),
'lexbor-oracle-bin' => \HtmlApiFuzz\option_string( $oracle_options, 'lexbor-oracle-bin', null ),
+ 'html5ever-oracle-bin' => \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ),
'oracle-timeout-ms' => \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ),
),
'oracleWorkerArgs' => $oracle_renderer->worker_args(),
@@ -448,19 +492,46 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
\HtmlApiFuzz\run_php_process( $args, \HtmlApiFuzz\repo_root(), $timeout_ms, $final_dir . '/worker.log' );
$final_result = \HtmlApiFuzz\read_json_file( $final_dir . '/result.json' );
$final_replay = \HtmlApiFuzz\read_json_file( $final_dir . '/replay.json' );
+$final_identity_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $base['oracle'], is_array( $final_result['oracle'] ?? null ) ? $final_result['oracle'] : array() );
+if ( ! empty( $final_identity_mismatches ) ) {
+ $final_result['ok'] = false;
+ $final_result['status'] = 'oracle-identity-drift';
+ $final_result['failureClass'] = 'oracle-identity-drift';
+ $final_result['failureSnippet'] = implode( '; ', $final_identity_mismatches );
+ $final_result['sourceOracle'] = $base['oracle'];
+ $final_result['actualOracle'] = $final_result['oracle'] ?? null;
+ $final_result['oracleIdentityMismatches'] = $final_identity_mismatches;
+ unset( $final_result['signature'], $final_result['oracleFinding'], $final_result['comparison'] );
+ $drift_signature = \HtmlApiFuzz\Signature::from_result( $final_result );
+ if ( null !== $drift_signature ) {
+ $final_result['signature'] = $drift_signature;
+ }
+ \HtmlApiFuzz\write_json_file_atomic( $final_dir . '/result.json', $final_result );
+}
+$final_terminal_error = html_api_fuzz_min_probe_terminal_error( $final_result, $base );
if ( is_array( $final_replay ) && is_array( $base['originalGenerator'] ) ) {
$final_replay['originalGenerator'] = $base['originalGenerator'];
}
if ( is_array( $final_replay ) ) {
$final_replay['sourceReplay'] = $base['sourceReplay'];
- \HtmlApiFuzz\write_json_file( $final_dir . '/replay.json', $final_replay );
+ if ( ! empty( $final_identity_mismatches ) ) {
+ $final_replay['oracle'] = $base['oracle'];
+ $final_replay['finalActualOracle'] = $final_result['oracle'] ?? null;
+ $final_replay['finalOracleIdentityMismatches'] = $final_identity_mismatches;
+ }
+ if ( ! empty( $base['oracleIdentityMismatches'] ) ) {
+ $final_replay['sourceOracle'] = $base['sourceOracle'];
+ $final_replay['actualOracle'] = $base['oracle'];
+ $final_replay['oracleIdentityMismatches'] = $base['oracleIdentityMismatches'];
+ }
+ \HtmlApiFuzz\write_json_file_atomic( $final_dir . '/replay.json', $final_replay );
}
$summary = array(
'schemaVersion' => 1,
'kind' => 'html-api-fuzz-minimize-result',
'createdAt' => gmdate( 'c' ),
- 'ok' => null !== $final_result && ( 'oracle-finding' === $base['targetKind'] ? ( ( $final_result['oracleFinding']['signature']['hash'] ?? null ) === $target_hash ) : ( $any_failure ? ! ( $final_result['ok'] ?? true ) : ( ( $final_result['signature']['hash'] ?? null ) === $target_hash ) ) ),
+ 'ok' => null === $final_terminal_error && ( 'oracle-finding' === $base['targetKind'] ? ( ( $final_result['oracleFinding']['signature']['hash'] ?? null ) === $target_hash ) : ( $any_failure ? ! ( $final_result['ok'] ?? true ) : ( ( $final_result['signature']['hash'] ?? null ) === $target_hash ) ) ),
'targetHash' => $target_hash,
'targetKind' => $base['targetKind'],
'finalHash' => $final_result['signature']['hash'] ?? null,
@@ -471,8 +542,12 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
'originalGenerator' => $base['originalGenerator'],
'sourceReplay' => $base['sourceReplay'],
'oracle' => $final_result['oracle'] ?? $base['oracle'],
+ 'sourceOracle' => empty( $base['oracleIdentityMismatches'] ) ? null : $base['sourceOracle'],
+ 'actualOracle' => empty( $base['oracleIdentityMismatches'] ) ? null : $base['oracle'],
+ 'oracleIdentityMismatches' => $base['oracleIdentityMismatches'],
'finalFailureClass' => $final_result['failureClass'] ?? null,
'finalStatus' => $final_result['status'] ?? null,
+ 'terminalError' => $final_terminal_error,
'originalLength' => strlen( $input ),
'minimizedLength' => strlen( $current ),
'attempts' => $attempt_count,
diff --git a/tools/html-api-fuzz/oracle-process-supervisor.php b/tools/html-api-fuzz/oracle-process-supervisor.php
new file mode 100644
index 0000000000000..fbc8d31c992ad
--- /dev/null
+++ b/tools/html-api-fuzz/oracle-process-supervisor.php
@@ -0,0 +1,1307 @@
+#!/usr/bin/env php
+ $maximum ) {
+ fail( 'Required ownership file exceeded its byte limit.' );
+ }
+ }
+ return $contents;
+ } finally {
+ fclose( $handle );
+ }
+}
+
+function identity_from_stat( array $stat ): array {
+ return array( 'dev' => (int) $stat['dev'], 'ino' => (int) $stat['ino'] );
+}
+
+function ownership_identity( string $root ): ?array {
+ $root_stat = @lstat( $root );
+ $owner_stat = @lstat( $root . DIRECTORY_SEPARATOR . OWNER_FILE );
+ if ( false === $root_stat || false === $owner_stat ) {
+ return null;
+ }
+ return array( 'root' => identity_from_stat( $root_stat ), 'owner' => identity_from_stat( $owner_stat ) );
+}
+
+function valid_inode_identity( $identity ): bool {
+ return is_array( $identity ) && exact_keys( $identity, array( 'dev', 'ino' ) ) &&
+ is_int( $identity['dev'] ) && $identity['dev'] > 0 && is_int( $identity['ino'] ) && $identity['ino'] > 0;
+}
+
+function register_ownership_identity( string $root, array $root_identity, array $owner_identity ): void {
+ global $ownership_identities;
+ if ( ! valid_inode_identity( $root_identity ) || ! valid_inode_identity( $owner_identity ) ) {
+ fail( 'Oracle ownership inode identity is invalid.' );
+ }
+ $ownership_identities[ $root ] = array( 'root' => $root_identity, 'owner' => $owner_identity );
+}
+
+function stat_matches_identity( $stat, array $identity ): bool {
+ return is_array( $stat ) && (int) $stat['dev'] === $identity['dev'] && (int) $stat['ino'] === $identity['ino'];
+}
+
+function owner_error( string $root, string $token ): ?string {
+ global $ownership_identities;
+ $expected = $ownership_identities[ $root ] ?? null;
+ if ( ! is_array( $expected ) ) {
+ return 'Oracle ownership inode identity was not registered.';
+ }
+ $stat = @lstat( $root );
+ if (
+ false === $stat ||
+ ! stat_matches_identity( $stat, $expected['root'] ) ||
+ ( $stat['mode'] & 0170000 ) !== 0040000 ||
+ ( $stat['mode'] & 0777 ) !== 0700 ||
+ ( function_exists( 'posix_geteuid' ) && $stat['uid'] !== posix_geteuid() )
+ ) {
+ return 'Oracle ownership root identity is invalid.';
+ }
+ $marker_path = $root . DIRECTORY_SEPARATOR . OWNER_FILE;
+ $marker_stat = @lstat( $marker_path );
+ if (
+ false === $marker_stat ||
+ ! stat_matches_identity( $marker_stat, $expected['owner'] ) ||
+ ( $marker_stat['mode'] & 0170000 ) !== 0100000 ||
+ ( $marker_stat['mode'] & 0777 ) !== 0600 ||
+ ( function_exists( 'posix_geteuid' ) && $marker_stat['uid'] !== posix_geteuid() )
+ ) {
+ return 'Oracle ownership marker identity is invalid.';
+ }
+ try {
+ $recorded = read_exact_file( $marker_path, 256 );
+ } catch ( \Throwable $error ) {
+ return $error->getMessage();
+ }
+ $root_after = @lstat( $root );
+ $marker_after = @lstat( $marker_path );
+ if ( ! stat_matches_identity( $root_after, $expected['root'] ) || ! stat_matches_identity( $marker_after, $expected['owner'] ) ) {
+ return 'Oracle ownership inode identity changed during authentication.';
+ }
+ return hash_equals( $token . "\n", $recorded ) ? null : 'Oracle ownership token does not match.';
+}
+
+function run_ps( array $arguments, int $timeout_ms = 1000 ): array {
+ $command = array_merge( array( '/bin/ps' ), $arguments );
+ $spec = array(
+ 0 => array( 'pipe', 'r' ),
+ 1 => array( 'pipe', 'w' ),
+ 2 => array( 'pipe', 'w' ),
+ );
+ $environment = array_merge( $_ENV, array( 'LC_ALL' => 'C', 'LANG' => 'C' ) );
+ $process = @proc_open( $command, $spec, $pipes, null, $environment );
+ if ( ! is_resource( $process ) ) {
+ return array( 'code' => null, 'stdout' => '', 'stderr' => 'Could not start ps.' );
+ }
+ fclose( $pipes[0] );
+ stream_set_blocking( $pipes[1], false );
+ stream_set_blocking( $pipes[2], false );
+ $stdout = '';
+ $stderr = '';
+ $overflow = false;
+ $deadline = microtime( true ) + ( $timeout_ms / 1000 );
+ $status = proc_get_status( $process );
+ $observer_pid = (int) ( $status['pid'] ?? 0 );
+ while ( $status['running'] && microtime( true ) < $deadline ) {
+ $stdout_chunk = (string) stream_get_contents( $pipes[1] );
+ $stderr_chunk = (string) stream_get_contents( $pipes[2] );
+ if ( strlen( $stdout ) + strlen( $stdout_chunk ) > 1048576 || strlen( $stderr ) + strlen( $stderr_chunk ) > 65536 ) {
+ $overflow = true;
+ } else {
+ $stdout .= $stdout_chunk;
+ $stderr .= $stderr_chunk;
+ }
+ usleep( 10000 );
+ $status = proc_get_status( $process );
+ }
+ $timed_out = $status['running'];
+ if ( $timed_out ) {
+ proc_terminate( $process, 9 );
+ $kill_deadline = microtime( true ) + 1;
+ do {
+ usleep( 10000 );
+ $status = proc_get_status( $process );
+ } while ( $status['running'] && microtime( true ) < $kill_deadline );
+ }
+ $stdout_chunk = (string) stream_get_contents( $pipes[1] );
+ $stderr_chunk = (string) stream_get_contents( $pipes[2] );
+ if ( strlen( $stdout ) + strlen( $stdout_chunk ) > 1048576 || strlen( $stderr ) + strlen( $stderr_chunk ) > 65536 ) {
+ $overflow = true;
+ } else {
+ $stdout .= $stdout_chunk;
+ $stderr .= $stderr_chunk;
+ }
+ fclose( $pipes[1] );
+ fclose( $pipes[2] );
+ $observed_code = ! $status['running'] && isset( $status['exitcode'] ) && $status['exitcode'] >= 0 ? (int) $status['exitcode'] : null;
+ $closed_code = proc_close( $process );
+ $code = null !== $observed_code ? $observed_code : ( $closed_code >= 0 ? $closed_code : null );
+ return array( 'code' => $timed_out || $overflow ? null : $code, 'stdout' => $stdout, 'stderr' => $stderr, 'overflow' => $overflow, 'observerPid' => $observer_pid );
+}
+
+function parse_linux_stat_identity( string $stat_text, int $pid ): ?array {
+ $close = strrpos( $stat_text, ')' );
+ if ( false === $close ) {
+ return null;
+ }
+ $fields = preg_split( '/\s+/', trim( substr( $stat_text, $close + 1 ) ) );
+ // Fields after comm begin at kernel stat field 3. pgrp=5, session=6,
+ // starttime=22, so their zero-based offsets here are 2, 3, and 19.
+ if ( ! is_array( $fields ) || count( $fields ) <= 19 ) {
+ return null;
+ }
+ return array(
+ 'pid' => $pid,
+ 'pgid' => (int) $fields[2],
+ 'sid' => (int) $fields[3],
+ 'birth' => (string) $fields[19],
+ );
+}
+
+function linux_process_identity( int $pid ): ?array {
+ $stat_before = @file_get_contents( '/proc/' . $pid . '/stat' );
+ $cmdline = @file_get_contents( '/proc/' . $pid . '/cmdline' );
+ $stat_after = @file_get_contents( '/proc/' . $pid . '/stat' );
+ if ( ! is_string( $stat_before ) || ! is_string( $cmdline ) || ! is_string( $stat_after ) ) {
+ return null;
+ }
+ $before = parse_linux_stat_identity( $stat_before, $pid );
+ $after = parse_linux_stat_identity( $stat_after, $pid );
+ if ( null === $before || $before !== $after ) {
+ return null;
+ }
+ $before['arguments'] = array_values( array_filter( explode( "\0", $cmdline ), 'strlen' ) );
+ return $before;
+}
+
+function darwin_process_identity( int $pid ): ?array {
+ $pgid_before = @posix_getpgid( $pid );
+ $sid_before = @posix_getsid( $pid );
+ if ( false === $pgid_before || false === $sid_before ) {
+ return null;
+ }
+ $checked_before = run_ps( array( '-ww', '-p', (string) $pid, '-o', 'lstart=', '-o', 'command=' ) );
+ $checked_after = run_ps( array( '-ww', '-p', (string) $pid, '-o', 'lstart=', '-o', 'command=' ) );
+ $pgid_after = @posix_getpgid( $pid );
+ $sid_after = @posix_getsid( $pid );
+ if (
+ 0 !== $checked_before['code'] ||
+ 0 !== $checked_after['code'] ||
+ ! hash_equals( $checked_before['stdout'], $checked_after['stdout'] ) ||
+ $pgid_before !== $pgid_after ||
+ $sid_before !== $sid_after
+ ) {
+ return null;
+ }
+ $line = rtrim( $checked_before['stdout'], "\r\n" );
+ if ( strlen( $line ) < 25 ) {
+ return null;
+ }
+ $birth = trim( substr( $line, 0, 24 ) );
+ $command = trim( substr( $line, 24 ) );
+ if ( '' === $birth || '' === $command ) {
+ return null;
+ }
+ return array(
+ 'pid' => $pid,
+ 'pgid' => (int) $pgid_before,
+ 'sid' => (int) $sid_before,
+ 'birth' => $birth,
+ 'arguments' => array( $command ),
+ );
+}
+
+function process_identity( int $pid ): ?array {
+ if ( $pid < 2 ) {
+ return null;
+ }
+ if ( 'Linux' === PHP_OS_FAMILY ) {
+ return linux_process_identity( $pid );
+ }
+ if ( 'Darwin' === PHP_OS_FAMILY ) {
+ return darwin_process_identity( $pid );
+ }
+ return null;
+}
+
+function identity_command_contains( array $identity, array $needles ): bool {
+ $haystack = implode( "\0", array_map( 'strval', $identity['arguments'] ?? array() ) );
+ foreach ( $needles as $needle ) {
+ if ( '' === $needle || false === strpos( $haystack, $needle ) ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function identity_document( array $identity ): array {
+ return array(
+ 'pid' => (int) $identity['pid'],
+ 'pgid' => (int) $identity['pgid'],
+ 'sid' => (int) $identity['sid'],
+ 'birth' => (string) $identity['birth'],
+ );
+}
+
+function validate_child_identity_line( $line, int $pid, int $sid, int $pgid, string $supervisor_path, string $root, string $token ): ?array {
+ if ( ! is_string( $line ) || ! str_ends_with( $line, "\n" ) ) {
+ return null;
+ }
+ $document = json_decode( substr( $line, 0, -1 ), true );
+ if (
+ ! is_array( $document ) ||
+ ! exact_keys( $document, array( 'pid', 'pgid', 'sid', 'birth' ) ) ||
+ ! is_int( $document['pid'] ) ||
+ ! is_int( $document['pgid'] ) ||
+ ! is_int( $document['sid'] ) ||
+ ! is_string( $document['birth'] ) ||
+ '' === $document['birth'] ||
+ $pid !== $document['pid'] ||
+ $pgid !== $document['pgid'] ||
+ $sid !== $document['sid'] ||
+ ! hash_equals( json_encode( $document, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n", $line )
+ ) {
+ return null;
+ }
+ $current = process_identity( $pid );
+ return same_identity( $document, $current ) && identity_command_contains( $current, array( $supervisor_path, $root, $token ) ) ? $document : null;
+}
+
+function same_identity( array $recorded, ?array $current ): bool {
+ return null !== $current &&
+ (int) ( $recorded['pid'] ?? 0 ) === $current['pid'] &&
+ (int) ( $recorded['pgid'] ?? 0 ) === $current['pgid'] &&
+ (int) ( $recorded['sid'] ?? 0 ) === $current['sid'] &&
+ (string) ( $recorded['birth'] ?? '' ) === $current['birth'];
+}
+
+/**
+ * Take one complete, bounded process-table snapshot.
+ *
+ * A null result is inspection uncertainty, never an empty process table.
+ */
+function process_table_snapshot(): ?array {
+ $checked = run_ps( array( '-A', '-o', 'pid=', '-o', 'pgid=' ) );
+ if ( 0 !== $checked['code'] || '' !== trim( $checked['stderr'] ) ) {
+ return null;
+ }
+ $rows = array();
+ $observer_seen = false;
+ foreach ( preg_split( '/\r?\n/', trim( $checked['stdout'] ) ) ?: array() as $line ) {
+ if ( '' === trim( $line ) ) {
+ continue;
+ }
+ if ( 1 !== preg_match( '/^\s*([0-9]+)\s+([0-9]+)\s*$/D', $line, $matches ) ) {
+ return null;
+ }
+ $pid = (int) $matches[1];
+ $pgid = (int) $matches[2];
+ if ( $pid < 1 || isset( $rows[ $pid ] ) ) {
+ return null;
+ }
+ if ( 0 === $pgid ) {
+ continue;
+ }
+ if ( $pid === $checked['observerPid'] ) {
+ $observer_seen = true;
+ }
+ $rows[ $pid ] = array( 'pid' => $pid, 'pgid' => $pgid );
+ }
+ return $observer_seen ? $rows : null;
+}
+
+function process_is_proven_absent( int $pid ): bool {
+ if ( @posix_kill( $pid, 0 ) ) {
+ return false;
+ }
+ return 3 === posix_get_last_error();
+}
+
+function process_inspection_failure( string $message ): ?array {
+ global $process_inspection_error;
+ $process_inspection_error = $message;
+ return null;
+}
+
+function last_process_inspection_error(): ?string {
+ global $process_inspection_error;
+ return $process_inspection_error;
+}
+
+function mark_process_inspection_retry( int $pid, string $message ): ?array {
+ global $process_inspection_retry_pid;
+ $process_inspection_retry_pid = $pid;
+ return process_inspection_failure( $message );
+}
+
+function process_inspection_retry_pid(): ?int {
+ global $process_inspection_retry_pid;
+ return $process_inspection_retry_pid;
+}
+
+function session_group_members( int $sid, int $pgid ): ?array {
+ global $process_inspection_retry_pid;
+ $process_inspection_retry_pid = null;
+ $snapshot = process_table_snapshot();
+ if ( null === $snapshot ) {
+ return process_inspection_failure( 'bounded process-table snapshot failed' );
+ }
+ $members = array();
+ foreach ( $snapshot as $row ) {
+ if ( $pgid !== $row['pgid'] ) {
+ continue;
+ }
+ $current_pgid = @posix_getpgid( $row['pid'] );
+ if ( false === $current_pgid ) {
+ if ( 3 === posix_get_last_error() ) {
+ if ( process_is_proven_absent( $row['pid'] ) ) {
+ continue;
+ }
+ return mark_process_inspection_retry( $row['pid'], 'getpgid transiently failed for PID ' . $row['pid'] . ' with errno 3' );
+ }
+ return process_inspection_failure( 'getpgid failed for PID ' . $row['pid'] . ' with errno ' . posix_get_last_error() );
+ }
+ $current_sid = @posix_getsid( $row['pid'] );
+ if ( false === $current_sid ) {
+ if ( 3 === posix_get_last_error() ) {
+ if ( process_is_proven_absent( $row['pid'] ) ) {
+ continue;
+ }
+ return mark_process_inspection_retry( $row['pid'], 'getsid transiently failed for PID ' . $row['pid'] . ' with errno 3' );
+ }
+ return process_inspection_failure( 'getsid failed for PID ' . $row['pid'] . ' with errno ' . posix_get_last_error() );
+ }
+ if ( $current_pgid !== $row['pgid'] ) {
+ return process_inspection_failure( 'PID ' . $row['pid'] . ' changed process group during inspection' );
+ }
+ if ( $sid === $current_sid ) {
+ $members[] = $row['pid'];
+ }
+ }
+ sort( $members );
+ return $members;
+}
+
+function session_members( int $sid ): ?array {
+ $snapshot = process_table_snapshot();
+ if ( null === $snapshot ) {
+ return process_inspection_failure( 'bounded process-table snapshot failed' );
+ }
+ $members = array();
+ foreach ( $snapshot as $row ) {
+ $current_pgid = @posix_getpgid( $row['pid'] );
+ if ( false === $current_pgid ) {
+ if ( 3 === posix_get_last_error() && process_is_proven_absent( $row['pid'] ) ) {
+ continue;
+ }
+ return process_inspection_failure( 'getpgid failed for PID ' . $row['pid'] . ' with errno ' . posix_get_last_error() );
+ }
+ $current_sid = @posix_getsid( $row['pid'] );
+ if ( false === $current_sid ) {
+ if ( 3 === posix_get_last_error() && process_is_proven_absent( $row['pid'] ) ) {
+ continue;
+ }
+ return process_inspection_failure( 'getsid failed for PID ' . $row['pid'] . ' with errno ' . posix_get_last_error() );
+ }
+ if ( $current_pgid !== $row['pgid'] ) {
+ return process_inspection_failure( 'PID ' . $row['pid'] . ' changed process group during inspection' );
+ }
+ if ( $sid === $current_sid ) {
+ $members[] = $row['pid'];
+ }
+ }
+ sort( $members );
+ return $members;
+}
+
+function atomic_state( string $root, array $state ): void {
+ if ( ! valid_state_schema( $state ) ) {
+ fail( 'Refusing to publish invalid oracle supervisor state.' );
+ }
+ $error = owner_error( $root, (string) ( $state['token'] ?? '' ) );
+ if ( null !== $error ) {
+ fail( $error );
+ }
+ $json = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ $temp = $root . DIRECTORY_SEPARATOR . STATE_TEMP . '-' . getmypid();
+ $handle = @fopen( $temp, 'xb' );
+ if ( false === $handle ) {
+ fail( 'Could not create oracle supervisor state temporary file.' );
+ }
+ try {
+ if ( strlen( $json ) !== fwrite( $handle, $json ) || ! fflush( $handle ) ) {
+ fail( 'Could not write complete oracle supervisor state.' );
+ }
+ if ( function_exists( 'fsync' ) && ! fsync( $handle ) ) {
+ fail( 'Could not sync oracle supervisor state.' );
+ }
+ } finally {
+ fclose( $handle );
+ }
+ chmod( $temp, 0600 );
+ if ( null !== owner_error( $root, (string) $state['token'] ) ) {
+ @unlink( $temp );
+ fail( 'Oracle ownership identity changed before state publication.' );
+ }
+ if ( ! rename( $temp, $root . DIRECTORY_SEPARATOR . STATE_FILE ) ) {
+ @unlink( $temp );
+ fail( 'Could not publish oracle supervisor state.' );
+ }
+ if ( null !== owner_error( $root, (string) $state['token'] ) ) {
+ fail( 'Oracle ownership identity changed during state publication.' );
+ }
+}
+
+function valid_process_document( $document ): bool {
+ return is_array( $document ) && exact_keys( $document, array( 'pid', 'pgid', 'sid', 'birth' ) ) &&
+ is_int( $document['pid'] ) && $document['pid'] > 1 &&
+ is_int( $document['pgid'] ) && $document['pgid'] > 1 &&
+ is_int( $document['sid'] ) && $document['sid'] > 1 &&
+ is_string( $document['birth'] ) && '' !== $document['birth'];
+}
+
+function valid_state_schema( $state ): bool {
+ if (
+ ! is_array( $state ) ||
+ ! exact_keys( $state, array( 'schemaVersion', 'token', 'root', 'rootIdentity', 'ownerIdentity', 'phase', 'supervisorPath', 'supervisorSha256', 'targetPath', 'targetSha256', 'supervisor', 'anchor', 'target', 'cleanupError' ) ) ||
+ 1 !== $state['schemaVersion'] ||
+ ! is_string( $state['token'] ) ||
+ ! is_string( $state['root'] ) ||
+ ! valid_inode_identity( $state['rootIdentity'] ) ||
+ ! valid_inode_identity( $state['ownerIdentity'] ) ||
+ ! in_array( $state['phase'], array( 'supervisor-ready', 'anchor-ready', 'gated', 'running', 'target-exit', 'cleaning', 'cleanup-failed', 'cleaned' ), true ) ||
+ ! is_string( $state['supervisorPath'] ) ||
+ ! is_string( $state['targetPath'] ) ||
+ 1 !== preg_match( '/^[0-9a-f]{64}$/', $state['supervisorSha256'] ) ||
+ 1 !== preg_match( '/^[0-9a-f]{64}$/', $state['targetSha256'] ) ||
+ ! valid_process_document( $state['supervisor'] ) ||
+ ( null !== $state['anchor'] && ! valid_process_document( $state['anchor'] ) ) ||
+ ( null !== $state['target'] && ! valid_process_document( $state['target'] ) ) ||
+ ( null !== $state['cleanupError'] && ! is_string( $state['cleanupError'] ) )
+ ) {
+ return false;
+ }
+ $supervisor = $state['supervisor'];
+ $anchor = $state['anchor'];
+ $target = $state['target'];
+ if ( $supervisor['pid'] !== $supervisor['sid'] ) {
+ return false;
+ }
+ if ( null !== $anchor && ( $anchor['pid'] !== $anchor['pgid'] || $anchor['sid'] !== $supervisor['sid'] || $anchor['pid'] === $supervisor['pid'] ) ) {
+ return false;
+ }
+ if ( null !== $target && ( null === $anchor || $target['sid'] !== $anchor['sid'] || $target['pgid'] !== $anchor['pgid'] || in_array( $target['pid'], array( $supervisor['pid'], $anchor['pid'] ), true ) ) ) {
+ return false;
+ }
+ $phase = $state['phase'];
+ if ( ( 'cleanup-failed' === $phase ) !== ( is_string( $state['cleanupError'] ) && '' !== $state['cleanupError'] ) ) {
+ return false;
+ }
+ if ( 'supervisor-ready' === $phase && ( null !== $anchor || null !== $target ) ) {
+ return false;
+ }
+ if ( 'anchor-ready' === $phase && ( null === $anchor || null !== $target ) ) {
+ return false;
+ }
+ if ( in_array( $phase, array( 'gated', 'running', 'target-exit', 'cleaning', 'cleanup-failed' ), true ) && ( null === $anchor || null === $target ) ) {
+ return false;
+ }
+ return 'cleaned' !== $phase || ( null === $anchor ? null === $target : true );
+}
+
+function read_state( string $root, string $token ): ?array {
+ global $ownership_identities;
+ if ( null !== owner_error( $root, $token ) ) {
+ return null;
+ }
+ $state_path = $root . DIRECTORY_SEPARATOR . STATE_FILE;
+ $state_before = @lstat( $state_path );
+ if (
+ false === $state_before ||
+ ( $state_before['mode'] & 0170000 ) !== 0100000 ||
+ ( $state_before['mode'] & 0777 ) !== 0600 ||
+ ( function_exists( 'posix_geteuid' ) && $state_before['uid'] !== posix_geteuid() )
+ ) {
+ return null;
+ }
+ try {
+ $text = read_exact_file( $state_path, 65536 );
+ $state = json_decode( $text, true, 64, JSON_THROW_ON_ERROR );
+ } catch ( \Throwable $error ) {
+ return null;
+ }
+ $state_after = @lstat( $state_path );
+ if (
+ null !== owner_error( $root, $token ) ||
+ ! stat_matches_identity( $state_after, identity_from_stat( $state_before ) )
+ ) {
+ return null;
+ }
+ return valid_state_schema( $state ) &&
+ $token === ( $state['token'] ?? null ) &&
+ ( $state['rootIdentity'] ?? null ) === $ownership_identities[ $root ]['root'] &&
+ ( $state['ownerIdentity'] ?? null ) === $ownership_identities[ $root ]['owner']
+ ? $state
+ : null;
+}
+
+function emit_control( $control, array $event ): void {
+ $json = json_encode( $event, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ if ( strlen( $json ) > CONTROL_MAX_BYTES || strlen( $json ) !== @fwrite( $control, $json ) || ! @fflush( $control ) ) {
+ fail( 'Could not emit complete oracle supervisor control event.' );
+ }
+}
+
+function parse_arguments( array $argv ): array {
+ $options = array();
+ $target_args = array();
+ $separator = array_search( '--', $argv, true );
+ if ( false === $separator ) {
+ fail( 'Oracle supervisor command is missing -- separator.' );
+ }
+ $prefix = array_slice( $argv, 1, $separator - 1 );
+ $target_args = array_slice( $argv, $separator + 1 );
+ if ( 0 !== count( $prefix ) % 2 ) {
+ fail( 'Oracle supervisor options must be name/value pairs.' );
+ }
+ for ( $index = 0; $index < count( $prefix ); $index += 2 ) {
+ $name = $prefix[ $index ];
+ if ( ! in_array( $name, array( '--root', '--token', '--root-dev', '--root-ino', '--owner-dev', '--owner-ino', '--target', '--target-sha256', '--supervisor-sha256' ), true ) || isset( $options[ $name ] ) ) {
+ fail( 'Unknown or duplicate oracle supervisor option.' );
+ }
+ $options[ $name ] = $prefix[ $index + 1 ];
+ }
+ foreach ( array( '--root', '--token', '--root-dev', '--root-ino', '--owner-dev', '--owner-ino', '--target', '--target-sha256', '--supervisor-sha256' ) as $required ) {
+ if ( ! is_string( $options[ $required ] ?? null ) || '' === $options[ $required ] ) {
+ fail( 'Missing oracle supervisor option: ' . $required );
+ }
+ }
+ if (
+ ! is_absolute_path( $options['--root'] ) ||
+ ! is_absolute_path( $options['--target'] ) ||
+ 1 !== preg_match( '/^[0-9a-f]{32}$/', $options['--token'] ) ||
+ 1 !== preg_match( '/^[0-9a-f]{64}$/', $options['--target-sha256'] ) ||
+ 1 !== preg_match( '/^[0-9a-f]{64}$/', $options['--supervisor-sha256'] )
+ ) {
+ fail( 'Malformed oracle supervisor identity option.' );
+ }
+ $inode_values = array();
+ foreach ( array( '--root-dev', '--root-ino', '--owner-dev', '--owner-ino' ) as $name ) {
+ $value = filter_var( $options[ $name ], FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) );
+ if ( false === $value ) {
+ fail( 'Malformed oracle supervisor inode identity option.' );
+ }
+ $inode_values[ $name ] = (int) $value;
+ }
+ return array(
+ 'root' => $options['--root'],
+ 'token' => $options['--token'],
+ 'target' => $options['--target'],
+ 'targetSha256' => $options['--target-sha256'],
+ 'supervisorSha256' => $options['--supervisor-sha256'],
+ 'rootIdentity' => array( 'dev' => $inode_values['--root-dev'], 'ino' => $inode_values['--root-ino'] ),
+ 'ownerIdentity' => array( 'dev' => $inode_values['--owner-dev'], 'ino' => $inode_values['--owner-ino'] ),
+ 'targetArgs' => $target_args,
+ );
+}
+
+function authenticate_supervisor( array $state ): ?array {
+ $recorded = $state['supervisor'] ?? null;
+ if ( ! is_array( $recorded ) ) {
+ return null;
+ }
+ $current = process_identity( (int) ( $recorded['pid'] ?? 0 ) );
+ if ( ! same_identity( $recorded, $current ) ) {
+ return null;
+ }
+ return identity_command_contains(
+ $current,
+ array( (string) ( $state['supervisorPath'] ?? '' ), (string) ( $state['root'] ?? '' ), (string) ( $state['token'] ?? '' ) )
+ ) ? $current : null;
+}
+
+function authenticate_anchor( array $state ): ?array {
+ $recorded = $state['anchor'] ?? null;
+ if ( ! is_array( $recorded ) ) {
+ return null;
+ }
+ $current = process_identity( (int) ( $recorded['pid'] ?? 0 ) );
+ if ( ! same_identity( $recorded, $current ) ) {
+ return null;
+ }
+ if (
+ (int) ( $state['supervisor']['sid'] ?? 0 ) !== $current['sid'] ||
+ (int) ( $recorded['pid'] ?? 0 ) !== $current['pgid'] ||
+ ! identity_command_contains(
+ $current,
+ array( (string) ( $state['supervisorPath'] ?? '' ), (string) ( $state['root'] ?? '' ), (string) ( $state['token'] ?? '' ) )
+ )
+ ) {
+ return null;
+ }
+ return $current;
+}
+
+function reap_direct_children( array $pids ): void {
+ foreach ( array_unique( array_map( 'intval', $pids ) ) as $pid ) {
+ if ( $pid > 1 ) {
+ @pcntl_waitpid( $pid, $ignored, WNOHANG );
+ }
+ }
+}
+
+function reap_all_direct_children(): void {
+ while ( pcntl_waitpid( -1, $ignored, WNOHANG ) > 0 ) {
+ // Drain every exited direct child owned by this supervisor.
+ }
+}
+
+function wait_for_group_absence( int $sid, int $pgid, int $microseconds, array $direct_children = array(), bool $reap_all = false ): bool {
+ $deadline = hrtime( true ) + ( $microseconds * 1000 );
+ do {
+ reap_direct_children( $direct_children );
+ if ( $reap_all ) {
+ reap_all_direct_children();
+ }
+ $members = session_group_members( $sid, $pgid );
+ if ( null === $members ) {
+ $retry_pid = process_inspection_retry_pid();
+ if ( null !== $retry_pid ) {
+ usleep( 10000 );
+ continue;
+ }
+ return false;
+ }
+ if ( array() === $members ) {
+ return true;
+ }
+ usleep( 10000 );
+ } while ( hrtime( true ) < $deadline );
+ reap_direct_children( $direct_children );
+ if ( $reap_all ) {
+ reap_all_direct_children();
+ }
+ $members = session_group_members( $sid, $pgid );
+ return is_array( $members ) && array() === $members;
+}
+
+/** Reauthenticate the anchor and wait for one complete snapshot containing it. */
+function wait_for_authenticated_group( array $state, ?int $expected_sid = null, ?int $expected_pgid = null, int $microseconds = KILL_GRACE_MICROSECONDS, bool $reap_all = false ): ?array {
+ $deadline = hrtime( true ) + ( $microseconds * 1000 );
+ do {
+ if ( $reap_all ) {
+ reap_all_direct_children();
+ }
+ $anchor = authenticate_anchor( $state );
+ if ( null === $anchor ) {
+ usleep( 10000 );
+ continue;
+ }
+ if (
+ ( null !== $expected_sid && $expected_sid !== $anchor['sid'] ) ||
+ ( null !== $expected_pgid && $expected_pgid !== $anchor['pgid'] )
+ ) {
+ return null;
+ }
+ $members = session_group_members( $anchor['sid'], $anchor['pgid'] );
+ if ( is_array( $members ) && in_array( $anchor['pid'], $members, true ) ) {
+ return array( 'anchor' => $anchor, 'members' => $members );
+ }
+ usleep( 10000 );
+ } while ( hrtime( true ) < $deadline );
+ return null;
+}
+
+function cleanup_anchored_group( string $root, string $token, bool $reap_all = false ): ?string {
+ $state = read_state( $root, $token );
+ if ( ! is_array( $state ) || ! in_array( $state['phase'] ?? null, array( 'anchor-ready', 'gated', 'running', 'target-exit', 'cleaning' ), true ) ) {
+ return 'Oracle supervisor state cannot authenticate an anchored target group.';
+ }
+ if ( $reap_all ) {
+ reap_all_direct_children();
+ }
+ $recorded = $state['anchor'] ?? null;
+ if ( ! is_array( $recorded ) ) {
+ return 'Oracle target group anchor identity does not match.';
+ }
+ $authenticated_group = wait_for_authenticated_group(
+ $state,
+ (int) ( $recorded['sid'] ?? 0 ),
+ (int) ( $recorded['pgid'] ?? 0 ),
+ KILL_GRACE_MICROSECONDS,
+ $reap_all
+ );
+ if ( null === $authenticated_group ) {
+ $current = process_identity( (int) ( $recorded['pid'] ?? 0 ) );
+ if (
+ null === $current &&
+ wait_for_group_absence(
+ (int) ( $recorded['sid'] ?? 0 ),
+ (int) ( $recorded['pgid'] ?? 0 ),
+ KILL_GRACE_MICROSECONDS,
+ array( (int) ( $recorded['pid'] ?? 0 ), (int) ( $state['target']['pid'] ?? 0 ) ),
+ $reap_all
+ )
+ ) {
+ return null;
+ }
+ $members = session_group_members( (int) ( $recorded['sid'] ?? 0 ), (int) ( $recorded['pgid'] ?? 0 ) );
+ if ( null === $members ) {
+ return 'Oracle target group absence could not be inspected.';
+ }
+ return 'Oracle target group anchor identity does not match.';
+ }
+ $anchor = $authenticated_group['anchor'];
+ $sid = $anchor['sid'];
+ $pgid = $anchor['pgid'];
+ if ( ! @posix_kill( -$pgid, SIGTERM ) ) {
+ if (
+ 3 === posix_get_last_error() &&
+ wait_for_group_absence( $sid, $pgid, KILL_GRACE_MICROSECONDS, array( $anchor['pid'], (int) ( $state['target']['pid'] ?? 0 ) ), $reap_all )
+ ) {
+ return null;
+ }
+ return 'Could not signal authenticated oracle target group with SIGTERM.';
+ }
+ usleep( TERM_GRACE_MICROSECONDS );
+ $state = read_state( $root, $token );
+ if ( ! is_array( $state ) || ! in_array( $state['phase'] ?? null, array( 'anchor-ready', 'gated', 'running', 'target-exit', 'cleaning' ), true ) ) {
+ return 'Oracle target group anchor changed before SIGKILL.';
+ }
+ $authenticated_group = wait_for_authenticated_group( $state, $sid, $pgid, KILL_GRACE_MICROSECONDS, $reap_all );
+ if ( null === $authenticated_group ) {
+ if ( wait_for_group_absence( $sid, $pgid, KILL_GRACE_MICROSECONDS, array( $anchor['pid'], (int) ( $state['target']['pid'] ?? 0 ) ), $reap_all ) ) {
+ return null;
+ }
+ return 'Oracle target group lost its anchor before SIGKILL.';
+ }
+ $anchor = $authenticated_group['anchor'];
+ if ( ! @posix_kill( -$pgid, SIGKILL ) ) {
+ if (
+ 3 === posix_get_last_error() &&
+ wait_for_group_absence( $sid, $pgid, KILL_GRACE_MICROSECONDS, array( $anchor['pid'], (int) ( $state['target']['pid'] ?? 0 ) ), $reap_all )
+ ) {
+ return null;
+ }
+ return 'Could not signal authenticated oracle target group with SIGKILL.';
+ }
+ $direct_children = array( $anchor['pid'], (int) ( $state['target']['pid'] ?? 0 ) );
+ if ( ! wait_for_group_absence( $sid, $pgid, KILL_GRACE_MICROSECONDS, $direct_children, $reap_all ) ) {
+ $survivors = session_group_members( $sid, $pgid );
+ return null === $survivors
+ ? 'Authenticated oracle target group absence could not be inspected after SIGKILL: ' . ( last_process_inspection_error() ?? 'unknown inspection failure' )
+ : 'Authenticated oracle target group survived SIGKILL: ' . implode( ',', $survivors );
+ }
+ $current_anchor = process_identity( $anchor['pid'] );
+ if ( null !== $current_anchor && same_identity( $anchor, $current_anchor ) ) {
+ return 'Authenticated oracle target anchor survived SIGKILL.';
+ }
+ return null;
+}
+
+function wait_for_session_to_contain_only( int $sid, int $pid, int $microseconds ): bool {
+ $deadline = hrtime( true ) + ( $microseconds * 1000 );
+ do {
+ reap_all_direct_children();
+ $members = session_members( $sid );
+ if ( null === $members ) {
+ return false;
+ }
+ if ( array( $pid ) === $members ) {
+ return true;
+ }
+ usleep( 10000 );
+ } while ( hrtime( true ) < $deadline );
+ reap_all_direct_children();
+ $members = session_members( $sid );
+ return is_array( $members ) && array( $pid ) === $members;
+}
+
+function remove_owned_root( string $root, string $token ): bool {
+ global $ownership_identities;
+ if ( ! is_dir( $root ) ) {
+ return true;
+ }
+ if ( null !== owner_error( $root, $token ) ) {
+ return false;
+ }
+ $entries = scandir( $root );
+ if ( ! is_array( $entries ) ) {
+ return false;
+ }
+ foreach ( $entries as $entry ) {
+ if ( '.' === $entry || '..' === $entry ) {
+ continue;
+ }
+ $path = $root . DIRECTORY_SEPARATOR . $entry;
+ $stat = @lstat( $path );
+ if ( false === $stat || ( $stat['mode'] & 0170000 ) !== 0100000 ) {
+ return false;
+ }
+ }
+ foreach ( $entries as $entry ) {
+ if ( '.' === $entry || '..' === $entry || OWNER_FILE === $entry ) {
+ continue;
+ }
+ if ( null !== owner_error( $root, $token ) || ! @unlink( $root . DIRECTORY_SEPARATOR . $entry ) ) {
+ return false;
+ }
+ }
+ if ( null !== owner_error( $root, $token ) || ! @unlink( $root . DIRECTORY_SEPARATOR . OWNER_FILE ) ) {
+ return false;
+ }
+ $root_stat = @lstat( $root );
+ if ( ! stat_matches_identity( $root_stat, $ownership_identities[ $root ]['root'] ) ) {
+ return false;
+ }
+ return @rmdir( $root );
+}
+
+function wait_for_gate_byte( $gate, string $expected, int $timeout_seconds = 60 ): bool {
+ stream_set_blocking( $gate, false );
+ $deadline = hrtime( true ) + ( $timeout_seconds * 1000000000 );
+ while ( hrtime( true ) < $deadline ) {
+ $read = array( $gate );
+ $write = null;
+ $except = null;
+ $selected = @stream_select( $read, $write, $except, 0, 100000 );
+ if ( false === $selected ) {
+ return false;
+ }
+ if ( 0 === $selected ) {
+ continue;
+ }
+ $byte = fread( $gate, 1 );
+ return $expected === $byte;
+ }
+ return false;
+}
+
+/** Return alive, shutdown, dead, or invalid for the authenticated owner pipe. */
+function owner_control_status( $owner, string &$buffer, string $token, int $wait_microseconds = 0 ): string {
+ $expected = json_encode(
+ array( 'schemaVersion' => 1, 'command' => 'shutdown', 'token' => $token ),
+ JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
+ ) . "\n";
+ if ( false !== strpos( $buffer, "\n" ) ) {
+ return hash_equals( $expected, $buffer ) ? 'shutdown' : 'invalid';
+ }
+ $read = array( $owner );
+ $write = null;
+ $except = null;
+ $seconds = intdiv( $wait_microseconds, 1000000 );
+ $microseconds = $wait_microseconds % 1000000;
+ $selected = @stream_select( $read, $write, $except, $seconds, $microseconds );
+ if ( false === $selected ) {
+ return 'invalid';
+ }
+ if ( 0 === $selected ) {
+ return feof( $owner ) ? 'dead' : 'alive';
+ }
+ $chunk = fread( $owner, 4096 );
+ if ( false === $chunk ) {
+ return 'invalid';
+ }
+ if ( '' === $chunk ) {
+ return feof( $owner ) ? 'dead' : 'alive';
+ }
+ $buffer .= $chunk;
+ if ( strlen( $buffer ) > CONTROL_MAX_BYTES ) {
+ return 'invalid';
+ }
+ $newline = strpos( $buffer, "\n" );
+ if ( false === $newline ) {
+ return 'alive';
+ }
+ return hash_equals( $expected, $buffer ) ? 'shutdown' : 'invalid';
+}
+
+function child_exit_code( int $status ): int {
+ if ( pcntl_wifexited( $status ) ) {
+ return pcntl_wexitstatus( $status );
+ }
+ if ( pcntl_wifsignaled( $status ) ) {
+ return 128 + pcntl_wtermsig( $status );
+ }
+ return 125;
+}
+
+function pause_at_marker( string $environment_name, $owner = null, ?string &$owner_buffer = null, ?string $token = null ): void {
+ $marker = getenv( $environment_name );
+ if ( ! is_string( $marker ) || '' === $marker ) {
+ return;
+ }
+ @file_put_contents( $marker, getmypid() . "\n", LOCK_EX );
+ while ( file_exists( $marker ) ) {
+ if ( is_resource( $owner ) && is_string( $owner_buffer ) && is_string( $token ) ) {
+ $status = owner_control_status( $owner, $owner_buffer, $token, 50000 );
+ if ( 'alive' !== $status ) {
+ return;
+ }
+ } else {
+ usleep( 50000 );
+ }
+ }
+}
+
+/**
+ * Best-effort fail-closed cleanup after main() unwinds and closes every gate.
+ *
+ * This routine removes the ownership root only after the last authenticated
+ * published phase proves that no target process can remain.
+ */
+function cleanup_after_supervisor_exception( array $argv, string $message ): ?string {
+ try {
+ $options = parse_arguments( $argv );
+ } catch ( \Throwable $error ) {
+ return 'Could not recover authenticated supervisor arguments: ' . $error->getMessage();
+ }
+ $root = $options['root'];
+ $token = $options['token'];
+ register_ownership_identity( $root, $options['rootIdentity'], $options['ownerIdentity'] );
+ $state = read_state( $root, $token );
+ if ( ! is_array( $state ) ) {
+ return 'Could not recover authenticated supervisor state.';
+ }
+ $supervisor = authenticate_supervisor( $state );
+ if ( null === $supervisor || getmypid() !== $supervisor['pid'] ) {
+ return 'Could not authenticate the failing supervisor process.';
+ }
+ $phase = $state['phase'] ?? null;
+ if ( 'supervisor-ready' === $phase ) {
+ if ( ! wait_for_session_to_contain_only( $supervisor['sid'], $supervisor['pid'], KILL_GRACE_MICROSECONDS ) ) {
+ return 'Unpublished oracle session members remained after gate closure.';
+ }
+ } elseif ( in_array( $phase, array( 'anchor-ready', 'gated', 'running', 'target-exit', 'cleaning' ), true ) ) {
+ $cleanup_error = cleanup_anchored_group( $root, $token, true );
+ if ( null !== $cleanup_error ) {
+ return $cleanup_error;
+ }
+ } elseif ( 'cleaned' === $phase ) {
+ $anchor = $state['anchor'] ?? null;
+ if ( is_array( $anchor ) ) {
+ $current = process_identity( (int) ( $anchor['pid'] ?? 0 ) );
+ $members = session_group_members( (int) ( $anchor['sid'] ?? 0 ), (int) ( $anchor['pgid'] ?? 0 ) );
+ if ( null !== $current || null === $members || array() !== $members ) {
+ return 'Cleaned oracle state could not prove target group absence.';
+ }
+ }
+ } else {
+ return 'Failing supervisor phase is not safe for automatic cleanup.';
+ }
+ $state['phase'] = 'cleaned';
+ $state['cleanupError'] = null;
+ try {
+ atomic_state( $root, $state );
+ } catch ( \Throwable $error ) {
+ return 'Could not publish cleaned state after supervisor failure: ' . $error->getMessage();
+ }
+ if ( ! remove_owned_root( $root, $token ) ) {
+ return 'Could not remove authenticated ownership root after supervisor failure.';
+ }
+ return null;
+}
+
+function main( array $argv ): int {
+ if (
+ ! function_exists( 'pcntl_fork' ) ||
+ ! function_exists( 'pcntl_waitpid' ) ||
+ ! function_exists( 'posix_setsid' ) ||
+ ! function_exists( 'posix_setpgid' ) ||
+ ! function_exists( 'posix_getsid' ) ||
+ ! function_exists( 'posix_getpgid' ) ||
+ ! function_exists( 'posix_kill' ) ||
+ ! defined( 'SIGKILL' )
+ ) {
+ fail( 'Oracle supervision requires POSIX and PCNTL.' );
+ }
+ $options = parse_arguments( $argv );
+ pcntl_async_signals( true );
+ if ( defined( 'SIGPIPE' ) ) {
+ pcntl_signal( SIGPIPE, SIG_IGN );
+ }
+ $root = $options['root'];
+ $token = $options['token'];
+ $target = $options['target'];
+ register_ownership_identity( $root, $options['rootIdentity'], $options['ownerIdentity'] );
+ $supervisor_path = realpath( __FILE__ );
+ if (
+ null !== owner_error( $root, $token ) ||
+ false === $supervisor_path ||
+ $supervisor_path !== __FILE__ ||
+ ! hash_equals( $options['supervisorSha256'], hash_file( 'sha256', __FILE__ ) ?: '' ) ||
+ ! hash_equals( $options['targetSha256'], hash_file( 'sha256', $target ) ?: '' )
+ ) {
+ fail( 'Oracle supervisor private execution identity is invalid.' );
+ }
+ $control = @fopen( 'php://fd/3', 'wb' );
+ if ( false === $control ) {
+ fail( 'Oracle supervisor control descriptor is unavailable.' );
+ }
+ stream_set_write_buffer( $control, 0 );
+ stream_set_blocking( STDIN, false );
+ $owner_buffer = '';
+ if ( -1 === posix_setsid() ) {
+ fail( 'Oracle supervisor could not create its private session.' );
+ }
+ $supervisor_identity = process_identity( getmypid() );
+ if ( null === $supervisor_identity || getmypid() !== $supervisor_identity['sid'] ) {
+ fail( 'Oracle supervisor session identity is unavailable.' );
+ }
+ $base_state = array(
+ 'schemaVersion' => SCHEMA_VERSION,
+ 'token' => $token,
+ 'root' => $root,
+ 'rootIdentity' => $options['rootIdentity'],
+ 'ownerIdentity' => $options['ownerIdentity'],
+ 'phase' => 'supervisor-ready',
+ 'supervisorPath' => $supervisor_path,
+ 'supervisorSha256' => $options['supervisorSha256'],
+ 'targetPath' => $target,
+ 'targetSha256' => $options['targetSha256'],
+ 'supervisor' => identity_document( $supervisor_identity ),
+ 'anchor' => null,
+ 'target' => null,
+ 'cleanupError' => null,
+ );
+ atomic_state( $root, $base_state );
+ emit_control( $control, array( 'schemaVersion' => 1, 'event' => 'supervisor-ready', 'token' => $token, 'supervisor' => $base_state['supervisor'] ) );
+
+ $anchor_gate = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0 );
+ $anchor_ready = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0 );
+ if ( false === $anchor_gate || false === $anchor_ready ) {
+ fail( 'Could not create oracle anchor gates.' );
+ }
+ $anchor_pid = pcntl_fork();
+ if ( -1 === $anchor_pid ) {
+ fail( 'Could not fork oracle target group anchor.' );
+ }
+ if ( 0 === $anchor_pid ) {
+ fclose( $anchor_gate[0] );
+ fclose( $anchor_ready[0] );
+ fclose( $control );
+ fclose( STDIN );
+ if ( ! posix_setpgid( 0, 0 ) ) {
+ exit( 125 );
+ }
+ pcntl_async_signals( true );
+ pcntl_signal( SIGTERM, static function (): void {} );
+ pcntl_signal( SIGHUP, static function (): void {} );
+ pcntl_signal( SIGINT, static function (): void {} );
+ $identity = process_identity( getmypid() );
+ if ( null === $identity ) {
+ exit( 125 );
+ }
+ $ready = json_encode( identity_document( $identity ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ if ( strlen( $ready ) !== fwrite( $anchor_ready[1], $ready ) || ! fflush( $anchor_ready[1] ) ) {
+ exit( 125 );
+ }
+ fclose( $anchor_ready[1] );
+ if ( ! wait_for_gate_byte( $anchor_gate[1], 'A' ) ) {
+ exit( 126 );
+ }
+ fclose( $anchor_gate[1] );
+ while ( true ) {
+ usleep( 100000 );
+ }
+ }
+ fclose( $anchor_gate[1] );
+ fclose( $anchor_ready[1] );
+ stream_set_timeout( $anchor_ready[0], 5 );
+ $anchor_line = fgets( $anchor_ready[0], CONTROL_MAX_BYTES + 1 );
+ fclose( $anchor_ready[0] );
+ $anchor_document = validate_child_identity_line( $anchor_line, $anchor_pid, $supervisor_identity['sid'], $anchor_pid, $supervisor_path, $root, $token );
+ if ( null === $anchor_document ) {
+ fclose( $anchor_gate[0] );
+ fail( 'Oracle target group anchor did not report valid identity.' );
+ }
+ pause_at_marker( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_ANCHOR_READY', STDIN, $owner_buffer, $token );
+ $state = $base_state;
+ $state['phase'] = 'anchor-ready';
+ $state['anchor'] = $anchor_document;
+ atomic_state( $root, $state );
+ $owner_status = owner_control_status( STDIN, $owner_buffer, $token );
+ if ( 'alive' !== $owner_status ) {
+ fail( 'Oracle owner disappeared or sent invalid control before anchor release.' );
+ }
+ if ( 1 !== fwrite( $anchor_gate[0], 'A' ) || ! fflush( $anchor_gate[0] ) ) {
+ fclose( $anchor_gate[0] );
+ fail( 'Could not release authenticated oracle anchor.' );
+ }
+ fclose( $anchor_gate[0] );
+ emit_control( $control, array( 'schemaVersion' => 1, 'event' => 'anchor-ready', 'token' => $token, 'anchor' => $anchor_document ) );
+
+ $target_gate = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0 );
+ $target_ready = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0 );
+ if ( false === $target_gate || false === $target_ready ) {
+ fail( 'Could not create oracle target gates.' );
+ }
+ $target_pid = pcntl_fork();
+ if ( -1 === $target_pid ) {
+ fail( 'Could not fork oracle target.' );
+ }
+ if ( 0 === $target_pid ) {
+ fclose( $target_gate[0] );
+ fclose( $target_ready[0] );
+ fclose( $control );
+ fclose( STDIN );
+ if ( ! posix_setpgid( 0, $anchor_pid ) ) {
+ exit( 125 );
+ }
+ $identity = process_identity( getmypid() );
+ if ( null === $identity ) {
+ exit( 125 );
+ }
+ $ready = json_encode( identity_document( $identity ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ if ( strlen( $ready ) !== fwrite( $target_ready[1], $ready ) || ! fflush( $target_ready[1] ) ) {
+ exit( 125 );
+ }
+ fclose( $target_ready[1] );
+ if ( ! wait_for_gate_byte( $target_gate[1], 'T' ) ) {
+ exit( 126 );
+ }
+ fclose( $target_gate[1] );
+ pcntl_exec( $target, $options['targetArgs'] );
+ exit( 125 );
+ }
+ fclose( $target_gate[1] );
+ fclose( $target_ready[1] );
+ stream_set_timeout( $target_ready[0], 5 );
+ $target_line = fgets( $target_ready[0], CONTROL_MAX_BYTES + 1 );
+ fclose( $target_ready[0] );
+ $target_document = validate_child_identity_line( $target_line, $target_pid, $supervisor_identity['sid'], $anchor_pid, $supervisor_path, $root, $token );
+ if ( null === $target_document ) {
+ fclose( $target_gate[0] );
+ fail( 'Oracle target did not report valid gated identity.' );
+ }
+ $state['phase'] = 'gated';
+ $state['target'] = $target_document;
+ atomic_state( $root, $state );
+ pause_at_marker( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_GATED', STDIN, $owner_buffer, $token );
+ $owner_status = owner_control_status( STDIN, $owner_buffer, $token );
+ if ( 'alive' !== $owner_status ) {
+ fail( 'Oracle owner disappeared or sent invalid control before target release.' );
+ }
+ if ( 1 !== fwrite( $target_gate[0], 'T' ) || ! fflush( $target_gate[0] ) ) {
+ fclose( $target_gate[0] );
+ fail( 'Could not release authenticated oracle target.' );
+ }
+ fclose( $target_gate[0] );
+ $state['phase'] = 'running';
+ atomic_state( $root, $state );
+ emit_control( $control, array( 'schemaVersion' => 1, 'event' => 'running', 'token' => $token, 'target' => $target_document ) );
+
+ pcntl_async_signals( true );
+ $signal_shutdown = false;
+ pcntl_signal( SIGTERM, static function () use ( &$signal_shutdown ): void { $signal_shutdown = true; } );
+ pcntl_signal( SIGINT, static function () use ( &$signal_shutdown ): void { $signal_shutdown = true; } );
+ pcntl_signal( SIGHUP, static function () use ( &$signal_shutdown ): void { $signal_shutdown = true; } );
+ $owner_dead = false;
+ $intentional_shutdown = false;
+ $owner_invalid = false;
+ $target_status = null;
+ while ( true ) {
+ $wait = pcntl_waitpid( $target_pid, $wait_status, WNOHANG );
+ if ( $target_pid === $wait ) {
+ $target_status = $wait_status;
+ break;
+ }
+ if ( $signal_shutdown ) {
+ $intentional_shutdown = true;
+ break;
+ }
+ $owner_status = owner_control_status( STDIN, $owner_buffer, $token, 50000 );
+ if ( 'shutdown' === $owner_status ) {
+ $intentional_shutdown = true;
+ break;
+ }
+ if ( 'dead' === $owner_status ) {
+ $owner_dead = true;
+ break;
+ }
+ if ( 'invalid' === $owner_status ) {
+ $owner_invalid = true;
+ break;
+ }
+ }
+ $state['phase'] = null === $target_status ? 'cleaning' : 'target-exit';
+ atomic_state( $root, $state );
+ $cleanup_error = cleanup_anchored_group( $root, $token, true );
+ pcntl_waitpid( $target_pid, $ignored_target, WNOHANG );
+ pcntl_waitpid( $anchor_pid, $ignored_anchor, WNOHANG );
+ if ( null !== $cleanup_error ) {
+ $state['phase'] = 'cleanup-failed';
+ $state['cleanupError'] = $cleanup_error;
+ atomic_state( $root, $state );
+ emit_control( $control, array( 'schemaVersion' => 1, 'event' => 'cleanup-failed', 'token' => $token, 'error' => $cleanup_error ) );
+ return 125;
+ }
+ $state['phase'] = 'cleaned';
+ $state['cleanupError'] = null;
+ atomic_state( $root, $state );
+ emit_control( $control, array( 'schemaVersion' => 1, 'event' => 'cleaned', 'token' => $token, 'ownerDead' => $owner_dead ) );
+ pause_at_marker( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_CLEANED', STDIN, $owner_buffer, $token );
+ if ( ! remove_owned_root( $root, $token ) ) {
+ return 125;
+ }
+ if ( null !== $target_status && ! $intentional_shutdown && ! $owner_dead ) {
+ return child_exit_code( $target_status );
+ }
+ return $owner_invalid ? 125 : ( $owner_dead ? 124 : 143 );
+}
+
+if ( realpath( $_SERVER['SCRIPT_FILENAME'] ?? '' ) === __FILE__ ) {
+ try {
+ exit( main( $argv ) );
+ } catch ( \Throwable $error ) {
+ $cleanup_error = cleanup_after_supervisor_exception( $argv, $error->getMessage() );
+ $message = 'Oracle process supervisor failed: ' . $error->getMessage();
+ if ( null !== $cleanup_error ) {
+ $message .= '; fail-closed cleanup retained evidence: ' . $cleanup_error;
+ }
+ fwrite( STDERR, $message . "\n" );
+ exit( 125 );
+ }
+}
diff --git a/tools/html-api-fuzz/replay.php b/tools/html-api-fuzz/replay.php
index dadd798f47dff..e9e789a95b498 100755
--- a/tools/html-api-fuzz/replay.php
+++ b/tools/html-api-fuzz/replay.php
@@ -7,8 +7,8 @@
$store_path = \HtmlApiFuzz\option_string( $options, 'store', null );
$stored_replay_value = null;
if ( ( null === $replay_path && null === $store_path ) || \HtmlApiFuzz\option_bool( $options, 'help', false ) ) {
- echo "Usage: php tools/html-api-fuzz/replay.php --replay path/to/replay.json [--output-dir DIR] [--payload-policy POLICY] [--memory-limit LIMIT] [--timeout-ms N] [--worker-script PATH] [--dom-oracle php-dom|lexbor-source] [--lexbor-oracle-bin PATH] [--allow-oracle-mismatch]\n";
- echo " php tools/html-api-fuzz/replay.php --store path/to/results.sqlite (--id N|--seed N) [--output-dir DIR] [--payload-policy POLICY] [--dom-oracle php-dom|lexbor-source] [--lexbor-oracle-bin PATH] [--allow-oracle-mismatch]\n";
+ echo "Usage: php tools/html-api-fuzz/replay.php --replay path/to/replay.json [--output-dir DIR] [--payload-policy POLICY] [--memory-limit LIMIT] [--timeout-ms N] [--worker-script PATH] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch]\n";
+ echo " php tools/html-api-fuzz/replay.php --store path/to/results.sqlite (--id N|--seed N) [--output-dir DIR] [--payload-policy POLICY] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch]\n";
echo "The --store form reproduces a failure whose seed directory was pruned, from the replay stored in the lane's results.sqlite.\n";
exit( ( null === $replay_path && null === $store_path ) ? 1 : 0 );
}
@@ -157,6 +157,9 @@
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'lexbor-oracle-bin', null ) && is_string( $recorded_options['lexborOracleBin'] ?? null ) ) {
$oracle_options['lexbor-oracle-bin'] = $recorded_options['lexborOracleBin'];
}
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ) && is_string( $recorded_options['html5everOracleBin'] ?? null ) ) {
+ $oracle_options['html5ever-oracle-bin'] = $recorded_options['html5everOracleBin'];
+}
$stored_oracle_timeout_ms = $recorded_options['oracleTimeoutMs'] ?? null;
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && is_numeric( $stored_oracle_timeout_ms ) ) {
$oracle_options['oracle-timeout-ms'] = (string) (int) $stored_oracle_timeout_ms;
@@ -165,7 +168,8 @@
$oracle_worker_args = $oracle_renderer->worker_args();
$current_oracle = $oracle_renderer->metadata();
$oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $replay['oracle'] ?? null, $current_oracle );
-if ( ! empty( $oracle_mismatches ) && ! \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false ) ) {
+$allow_oracle_mismatch = \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false );
+if ( ! empty( $oracle_mismatches ) && ! $allow_oracle_mismatch ) {
fwrite( STDERR, 'Oracle identity mismatch: ' . implode( '; ', $oracle_mismatches ) . ".\n" );
fwrite( STDERR, "Pass --allow-oracle-mismatch only for a deliberate diagnostic comparison.\n" );
exit( 1 );
@@ -286,15 +290,53 @@
if ( ! is_array( $output_replay ) ) {
$output_replay = $replay;
}
+$worker_oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $current_oracle, is_array( $result['oracle'] ?? null ) ? $result['oracle'] : array() );
+$worker_oracle_drift = ! empty( $worker_oracle_mismatches );
+if ( $worker_oracle_drift ) {
+ $result['ok'] = false;
+ $result['status'] = 'oracle-identity-drift';
+ $result['failureClass'] = 'oracle-identity-drift';
+ $result['failureSnippet'] = implode( '; ', $worker_oracle_mismatches );
+ $result['sourceOracle'] = $current_oracle;
+ $result['actualOracle'] = $result['oracle'] ?? null;
+ $result['oracleIdentityMismatches'] = $worker_oracle_mismatches;
+ unset( $result['signature'], $result['oracleFinding'], $result['comparison'] );
+ $drift_signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $drift_signature ) {
+ $result['signature'] = $drift_signature;
+ }
+}
+if ( ! empty( $oracle_mismatches ) ) {
+ if ( $worker_oracle_drift ) {
+ $result['requestedSourceOracle'] = $replay['oracle'] ?? null;
+ $result['requestedActualOracle'] = $current_oracle;
+ $result['requestedOracleIdentityMismatches'] = $oracle_mismatches;
+ } else {
+ $result['sourceOracle'] = $replay['oracle'] ?? null;
+ $result['actualOracle'] = $current_oracle;
+ $result['oracleIdentityMismatches'] = $oracle_mismatches;
+ }
+}
+\HtmlApiFuzz\write_json_file_atomic( $output_dir . '/result.json', $result );
if ( is_array( $output_replay ) && is_array( $original_generator ) ) {
$output_replay['originalGenerator'] = $original_generator;
}
if ( is_array( $output_replay ) ) {
$output_replay['sourceReplay'] = $source_replay;
$output_options = is_array( $output_replay['options'] ?? null ) ? $output_replay['options'] : array();
- unset( $output_options['domOracle'], $output_options['lexborOracleBin'], $output_options['oracleTimeoutMs'] );
+ unset( $output_options['domOracle'], $output_options['lexborOracleBin'], $output_options['html5everOracleBin'], $output_options['oracleTimeoutMs'] );
$output_replay['options'] = array_merge( $output_options, $effective_policy, $oracle_renderer->replay_options() );
$output_replay['oracle'] = $current_oracle;
+ if ( ! empty( $oracle_mismatches ) ) {
+ $output_replay['sourceOracle'] = $replay['oracle'] ?? null;
+ $output_replay['actualOracle'] = $current_oracle;
+ $output_replay['oracleIdentityMismatches'] = $oracle_mismatches;
+ }
+ if ( $worker_oracle_drift ) {
+ $output_replay['workerSourceOracle'] = $current_oracle;
+ $output_replay['workerActualOracle'] = $result['oracle'] ?? null;
+ $output_replay['workerOracleIdentityMismatches'] = $worker_oracle_mismatches;
+ }
$output_replay['result'] = array(
'ok' => $result['ok'] ?? false,
'status' => $result['status'] ?? 'missing-result',
diff --git a/tools/html-api-fuzz/runner.php b/tools/html-api-fuzz/runner.php
index 50f115909c2f2..ce51a72cfd4de 100755
--- a/tools/html-api-fuzz/runner.php
+++ b/tools/html-api-fuzz/runner.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/lib/autoload.php';
function html_api_fuzz_runner_usage(): void {
- echo "Usage: php tools/html-api-fuzz/runner.php [--output-dir DIR] [--start-seed N] [--seed-stride N] [--max-seeds N] [--duration-seconds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source] [--lexbor-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--stop-file PATH]\n";
+ echo "Usage: php tools/html-api-fuzz/runner.php [--output-dir DIR] [--start-seed N] [--seed-stride N] [--max-seeds N] [--duration-seconds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--stop-file PATH]\n";
echo "Use --duration-seconds 0 with --max-seeds 0 for an indefinite run.\n";
echo "Create the stop file (default OUTPUT_DIR/STOP) to stop gracefully: the current batch finishes and no new batch starts.\n";
echo "Oracle findings are recorded separately from failures; pass --triage-oracle-findings to watcher.php to process them.\n";
diff --git a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
index a1a09f9e6bb9d..51e2788564cf0 100755
--- a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
+++ b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
@@ -428,12 +428,30 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
$evidence_dir = $work_dir . '-evidence';
$evidence_body = 'byte-exact crash evidence
';
$oracle_started = $work_dir . '-oracle-started';
+ $hanging_oracle_dir = $work_dir . '-hanging-oracle';
+ $hanging_oracle = $hanging_oracle_dir . '/hanging-lexbor-oracle.php';
+ \HtmlApiFuzz\ensure_dir( $hanging_oracle_dir );
+ html_api_fuzz_commoncrawl_smoke_assert( copy( __DIR__ . '/fixtures/hanging-lexbor-oracle.php', $hanging_oracle ), 'Expected a private hanging-oracle fixture copy.' );
+ html_api_fuzz_commoncrawl_smoke_assert( chmod( $hanging_oracle, 0500 ), 'Expected the hanging-oracle fixture to be executable.' );
+ \HtmlApiFuzz\write_json_file(
+ $hanging_oracle_dir . '/build-manifest.json',
+ array(
+ 'kind' => 'html-api-fuzz-lexbor-build',
+ 'requestedRef' => 'test-hanging-oracle',
+ 'resolvedCommit' => str_repeat( '0', 40 ),
+ 'upstream' => 'https://github.com/lexbor/lexbor.git',
+ 'builtAt' => gmdate( 'c' ),
+ 'binarySha256' => hash_file( 'sha256', $hanging_oracle ),
+ 'compiler' => 'test fixture',
+ 'cmake' => 'test fixture',
+ )
+ );
putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $evidence_dir );
putenv( 'HTML_API_CC_WORKER_SCRIPT' );
putenv( 'HTML_API_CC_ORACLE=lexbor-source' );
- putenv( 'HTML_API_FUZZ_LEXBOR_ORACLE=' . __DIR__ . '/fixtures/hanging-lexbor-oracle.php' );
+ putenv( 'HTML_API_FUZZ_LEXBOR_ORACLE=' . $hanging_oracle );
putenv( 'HTML_API_FUZZ_TEST_ORACLE_STARTED=' . $oracle_started );
- putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=1000' );
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=3000' );
putenv( 'HTML_API_CC_ORACLE_TIMEOUT_MS=5000' );
$evidence_runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
$evidence = $evidence_runner->analyze_document(
@@ -474,6 +492,7 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
\HtmlApiFuzz\remove_dir_recursive( $work_dir );
\HtmlApiFuzz\remove_dir_recursive( $timeout_dir );
\HtmlApiFuzz\remove_dir_recursive( $evidence_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $hanging_oracle_dir );
\HtmlApiFuzz\remove_dir_recursive( $fatal_dir );
\HtmlApiFuzz\remove_dir_recursive( $original_state_dir );
\HtmlApiFuzz\remove_dir_recursive( $replay_state_dir );
diff --git a/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
new file mode 100644
index 0000000000000..9924b2b0232b7
--- /dev/null
+++ b/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
@@ -0,0 +1,247 @@
+#!/usr/bin/env php
+T Hello
';
+ $source_cases = array(
+ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE => array( 'binary' => $lexbor_binary, 'option' => 'lexborOracleBin' ),
+ \HtmlApiFuzz\OracleRenderer::KIND_HTML5EVER_SOURCE => array( 'binary' => $html5ever_binary, 'option' => 'html5everOracleBin' ),
+ );
+ $replays = array();
+ $metadata_by_kind = array();
+
+ putenv( 'HTML_API_FUZZ_LEXBOR_ORACLE=' . $lexbor_binary );
+ putenv( 'HTML_API_FUZZ_HTML5EVER_ORACLE=' . $html5ever_binary );
+ putenv( 'HTML_API_CC_RETAIN_ALL=1' );
+ putenv( 'HTML_API_CC_REQUIRE_UTF8=1' );
+ putenv( 'HTML_API_CC_CHECKS=baseline' );
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=30000' );
+ putenv( 'HTML_API_CC_ORACLE_TIMEOUT_MS=10000' );
+ putenv( 'HTML_API_CC_MAX_INPUT_BYTES=4096' );
+ putenv( 'HTML_API_CC_MAX_TOKENS=500' );
+ putenv( 'HTML_API_CC_MAX_NODES=500' );
+ putenv( 'HTML_API_CC_MAX_DEPTH=100' );
+ putenv( 'HTML_API_CC_MAX_TREE_BYTES=1048576' );
+
+ foreach ( $source_cases as $kind => $case ) {
+ $oracle = \HtmlApiFuzz\OracleRenderer::from_options(
+ array(
+ 'dom-oracle' => $kind,
+ 'lexbor-oracle-bin' => $lexbor_binary,
+ 'html5ever-oracle-bin' => $html5ever_binary,
+ 'oracle-timeout-ms' => '10000',
+ )
+ );
+ $metadata = $oracle->metadata();
+ html_api_fuzz_cc_sources_assert( true === ( $metadata['available'] ?? false ), "Expected {$kind} availability: " . (string) ( $metadata['error'] ?? '' ) );
+ $metadata_by_kind[ $kind ] = $metadata;
+ $output_dir = $work_dir . '/' . $kind;
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $output_dir );
+ putenv( 'HTML_API_CC_ORACLE=' . $kind );
+ putenv( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256=' . \HtmlApiFuzz\OracleRenderer::identity_sha256( $metadata ) );
+ if ( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === $kind ) {
+ putenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT=' . $metadata['identity']['lexborCommit'] );
+ } else {
+ putenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT' );
+ }
+ $runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ $summary = $runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:source-oracle-document',
+ 'https://example.com/source-oracle',
+ 200,
+ 'text/html; charset=UTF-8',
+ 'UTF-8',
+ $body,
+ 'fixture:source-oracle'
+ )
+ );
+ html_api_fuzz_cc_sources_assert( true === ( $summary['ok'] ?? null ), "Expected the exact retained document to pass {$kind}." );
+ html_api_fuzz_cc_sources_assert( true === ( $summary['differentialCovered'] ?? null ), "Expected {$kind} differential coverage." );
+ html_api_fuzz_cc_sources_assert( true === ( $summary['artifactsRetained'] ?? null ), "Expected retained {$kind} artifacts." );
+ html_api_fuzz_cc_sources_assert( $metadata === ( $summary['oracle'] ?? null ), "Expected normalized {$kind} summary identity." );
+ $artifact_dir = $summary['artifactDir'] ?? null;
+ html_api_fuzz_cc_sources_assert( is_string( $artifact_dir ) && is_file( $artifact_dir . '/.complete' ), "Expected complete {$kind} artifact publication." );
+ html_api_fuzz_cc_sources_assert( $body === file_get_contents( $artifact_dir . '/input.bin' ), "Expected byte-exact {$kind} input retention." );
+ $replay = \HtmlApiFuzz\read_json_file( $artifact_dir . '/replay.json' );
+ $configuration = \HtmlApiFuzz\read_json_file( $output_dir . '/configuration.json' );
+ html_api_fuzz_cc_sources_assert( $metadata === ( $replay['oracle'] ?? null ), "Expected normalized {$kind} replay identity." );
+ html_api_fuzz_cc_sources_assert( $body === base64_decode( $replay['inputBase64'] ?? '', true ), "Expected exact {$kind} replay bytes." );
+ html_api_fuzz_cc_sources_assert( $kind === ( $replay['options']['domOracle'] ?? null ), "Expected {$kind} replay selection." );
+ html_api_fuzz_cc_sources_assert( $case['binary'] === ( $replay['options'][ $case['option'] ] ?? null ), "Expected {$kind} replay binary path." );
+ $irrelevant_option = 'lexborOracleBin' === $case['option'] ? 'html5everOracleBin' : 'lexborOracleBin';
+ html_api_fuzz_cc_sources_assert( ! array_key_exists( $irrelevant_option, $replay['options'] ), "Expected no stale {$irrelevant_option} for {$kind}." );
+ html_api_fuzz_cc_sources_assert( \HtmlApiFuzz\OracleRenderer::identity_sha256( $metadata ) === ( $configuration['oracleIdentitySha256'] ?? null ), "Expected pinned {$kind} configuration identity." );
+ html_api_fuzz_cc_sources_assert( $metadata === ( $configuration['oracle'] ?? null ), "Expected normalized {$kind} configuration metadata." );
+ $replays[ $kind ] = $replay;
+ }
+
+ $source_replay = $replays[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ];
+ $tampered = $source_replay;
+ $tampered['oracle']['identity']['binarySha256'] = str_repeat( '0', 64 );
+ $tampered_path = $work_dir . '/tampered-replay.json';
+ \HtmlApiFuzz\write_json_file_atomic( $tampered_path, $tampered );
+ $rejected_replay_dir = $work_dir . '/tampered-replay-output';
+ $rejected_replay = html_api_fuzz_cc_sources_process(
+ array( dirname( __DIR__ ) . '/replay.php', '--replay', $tampered_path, '--output-dir', $rejected_replay_dir )
+ );
+ html_api_fuzz_cc_sources_assert( 1 === $rejected_replay['code'] && ! is_dir( $rejected_replay_dir ), 'Expected replay identity rejection before output creation or claim.' );
+ $rejected_minimize_dir = $work_dir . '/tampered-minimize-output';
+ $rejected_minimize = html_api_fuzz_cc_sources_process(
+ array( dirname( __DIR__ ) . '/minimize.php', '--replay', $tampered_path, '--output-dir', $rejected_minimize_dir, '--any-failure', '--max-attempts', '1' )
+ );
+ html_api_fuzz_cc_sources_assert( 1 === $rejected_minimize['code'] && ! is_dir( $rejected_minimize_dir ), 'Expected minimizer identity rejection before output creation.' );
+
+ $allowed_replay_dir = $work_dir . '/allowed-replay-output';
+ $allowed_replay = html_api_fuzz_cc_sources_process(
+ array( dirname( __DIR__ ) . '/replay.php', '--replay', $tampered_path, '--output-dir', $allowed_replay_dir, '--allow-oracle-mismatch' )
+ );
+ html_api_fuzz_cc_sources_assert( 0 === $allowed_replay['code'], 'Expected deliberately allowed diagnostic replay.' );
+ $allowed_replay_manifest = \HtmlApiFuzz\read_json_file( $allowed_replay_dir . '/replay.json' );
+ $allowed_replay_result = \HtmlApiFuzz\read_json_file( $allowed_replay_dir . '/result.json' );
+ html_api_fuzz_cc_sources_assert( $tampered['oracle'] === ( $allowed_replay_manifest['sourceOracle'] ?? null ), 'Expected allowed replay source oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ] === ( $allowed_replay_manifest['actualOracle'] ?? null ), 'Expected allowed replay actual oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( ! empty( $allowed_replay_manifest['oracleIdentityMismatches'] ?? array() ), 'Expected allowed replay mismatch reasons.' );
+ html_api_fuzz_cc_sources_assert( $tampered['oracle'] === ( $allowed_replay_result['sourceOracle'] ?? null ), 'Expected result-level allowed replay source provenance.' );
+
+ $allowed_minimize_dir = $work_dir . '/allowed-minimize-output';
+ $allowed_minimize = html_api_fuzz_cc_sources_process(
+ array( dirname( __DIR__ ) . '/minimize.php', '--replay', $tampered_path, '--output-dir', $allowed_minimize_dir, '--allow-oracle-mismatch', '--any-failure', '--max-attempts', '1' ),
+ 120000
+ );
+ html_api_fuzz_cc_sources_assert( 1 === $allowed_minimize['code'], 'Expected allowed minimization to finish without falsely finding a failure.' );
+ $minimize_summary = \HtmlApiFuzz\read_json_file( $allowed_minimize_dir . '/minimize-result.json' );
+ html_api_fuzz_cc_sources_assert( false === ( $minimize_summary['ok'] ?? null ), 'Expected passing final verification not to satisfy any-failure minimization.' );
+ html_api_fuzz_cc_sources_assert( $tampered['oracle'] === ( $minimize_summary['sourceOracle'] ?? null ), 'Expected minimizer source oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ] === ( $minimize_summary['actualOracle'] ?? null ), 'Expected minimizer actual oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( ! empty( $minimize_summary['oracleIdentityMismatches'] ?? array() ), 'Expected minimizer mismatch reasons.' );
+
+ $php_oracle = array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ 'phpVersion' => PHP_VERSION,
+ 'phpVersionId' => PHP_VERSION_ID,
+ 'phpSapi' => PHP_SAPI,
+ 'zendVersion' => zend_version(),
+ 'libxmlVersion' => defined( 'LIBXML_DOTTED_VERSION' ) ? LIBXML_DOTTED_VERSION : null,
+ 'domHtmlDocument' => true,
+ ),
+ 'error' => null,
+ );
+ $drift_worker = $work_dir . '/signed-drift-worker.php';
+ $drift_worker_source = <<<'PHP'
+#!/usr/bin/env php
+ 1,
+ 'kind' => 'html-api-fuzz-worker-result',
+ 'createdAt' => gmdate( 'c' ),
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'failureSnippet' => 'pre-signed stale worker failure',
+ 'seed' => (int) ( $options['seed'] ?? 1 ),
+ 'profile' => $options['profile'] ?? 'commoncrawl',
+ 'mode' => $options['mode'] ?? 'full-document',
+ 'checks' => $options['checks'] ?? 'baseline',
+ 'inputSha1' => sha1( $input ),
+ 'inputLength' => strlen( $input ),
+ 'oracle' => $oracle,
+ 'signature' => array( 'hash' => 'stale-signed-hash', 'familyKey' => 'stale-signed-family' ),
+);
+file_put_contents( $output . '/result.json', json_encode( $result, JSON_UNESCAPED_SLASHES ) . "\n" );
+file_put_contents( $output . '/replay.json', json_encode( array( 'kind' => 'html-api-fuzz-replay', 'inputBase64' => base64_encode( $input ), 'oracle' => $oracle, 'signature' => $result['signature'] ), JSON_UNESCAPED_SLASHES ) . "\n" );
+exit( 2 );
+PHP;
+ html_api_fuzz_cc_sources_assert( strlen( $drift_worker_source ) === file_put_contents( $drift_worker, $drift_worker_source ), 'Expected signed drift worker fixture.' );
+ chmod( $drift_worker, 0500 );
+ putenv( 'HTML_API_FUZZ_TEST_DRIFT_ORACLE=' . base64_encode( json_encode( $php_oracle, JSON_UNESCAPED_SLASHES ) ) );
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $work_dir . '/worker-drift' );
+ putenv( 'HTML_API_CC_ORACLE=' . \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE );
+ putenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT=' . $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ]['identity']['lexborCommit'] );
+ putenv( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256=' . \HtmlApiFuzz\OracleRenderer::identity_sha256( $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ] ) );
+ putenv( 'HTML_API_CC_WORKER_SCRIPT=' . $drift_worker );
+ $drift_runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ $drift_summary = $drift_runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput( 'urn:uuid:signed-drift', 'https://example.com/drift', 200, 'text/html', 'UTF-8', $body, 'fixture:signed-drift' )
+ );
+ html_api_fuzz_cc_sources_assert( 'oracle-identity-drift' === ( $drift_summary['failureClass'] ?? null ), 'Expected Common Crawl worker identity drift.' );
+ html_api_fuzz_cc_sources_assert( 'stale-signed-hash' !== ( $drift_summary['signature']['hash'] ?? null ), 'Expected stale worker signature invalidation and recomputation.' );
+ html_api_fuzz_cc_sources_assert( $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ] === ( $drift_summary['sourceOracle'] ?? null ), 'Expected drift source oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( $php_oracle === ( $drift_summary['actualOracle'] ?? null ), 'Expected drift actual oracle provenance.' );
+ html_api_fuzz_cc_sources_assert( ! empty( $drift_summary['oracleIdentityMismatches'] ?? array() ), 'Expected worker drift mismatch reasons.' );
+ $drift_replay = \HtmlApiFuzz\read_json_file( $drift_summary['artifactDir'] . '/replay.json' );
+ html_api_fuzz_cc_sources_assert( $metadata_by_kind[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ] === ( $drift_replay['sourceOracle'] ?? null ), 'Expected drift replay source oracle.' );
+ html_api_fuzz_cc_sources_assert( $php_oracle === ( $drift_replay['actualOracle'] ?? null ), 'Expected drift replay actual oracle.' );
+
+ foreach (
+ array(
+ 'CC_ANALYZER_OUTPUT_DIR', 'HTML_API_CC_ORACLE', 'HTML_API_CC_EXPECT_LEXBOR_COMMIT', 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256',
+ 'HTML_API_CC_WORKER_SCRIPT', 'HTML_API_FUZZ_LEXBOR_ORACLE', 'HTML_API_FUZZ_HTML5EVER_ORACLE', 'HTML_API_CC_RETAIN_ALL',
+ 'HTML_API_CC_REQUIRE_UTF8', 'HTML_API_CC_CHECKS', 'HTML_API_CC_PROCESS_TIMEOUT_MS', 'HTML_API_CC_ORACLE_TIMEOUT_MS',
+ 'HTML_API_CC_MAX_INPUT_BYTES', 'HTML_API_CC_MAX_TOKENS', 'HTML_API_CC_MAX_NODES', 'HTML_API_CC_MAX_DEPTH',
+ 'HTML_API_CC_MAX_TREE_BYTES', 'HTML_API_FUZZ_TEST_DRIFT_ORACLE',
+ ) as $environment_name
+ ) {
+ putenv( $environment_name );
+ }
+ \HtmlApiFuzz\remove_dir_recursive( $work_dir );
+ html_api_fuzz_cc_sources_assert( ! is_dir( $work_dir ), 'Expected Common Crawl source smoke cleanup.' );
+
+ echo "OK commoncrawl-source-oracles-smoke\n";
+}
diff --git a/tools/html-api-fuzz/tests/generator-policy-smoke.php b/tools/html-api-fuzz/tests/generator-policy-smoke.php
index af19276605c39..3e83c5836fa70 100644
--- a/tools/html-api-fuzz/tests/generator-policy-smoke.php
+++ b/tools/html-api-fuzz/tests/generator-policy-smoke.php
@@ -158,31 +158,83 @@ function html_api_fuzz_smoke_rm_tree( string $path ): void {
}
$lexbor_identity = array(
+ 'schemaVersion' => 1,
'kind' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
- 'lexborCommit' => str_repeat( 'a', 40 ),
- 'binarySha256' => str_repeat( 'b', 64 ),
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'binarySha256' => str_repeat( 'b', 64 ),
+ 'lexborCommit' => str_repeat( 'a', 40 ),
+ 'lexborVersion' => 'test-version',
+ 'build' => array(
+ 'kind' => 'html-api-fuzz-lexbor-build',
+ 'requestedRef' => 'test-ref',
+ 'resolvedCommit' => str_repeat( 'a', 40 ),
+ 'upstream' => 'https://github.com/lexbor/lexbor.git',
+ 'compiler' => 'test-cc',
+ 'cmake' => 'test-cmake',
+ ),
+ ),
+ 'error' => null,
+);
+$php_identity = array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ 'phpVersion' => PHP_VERSION,
+ 'phpVersionId' => PHP_VERSION_ID,
+ 'phpSapi' => PHP_SAPI,
+ 'zendVersion' => zend_version(),
+ 'libxmlVersion' => defined( 'LIBXML_DOTTED_VERSION' ) ? LIBXML_DOTTED_VERSION : null,
+ 'domHtmlDocument' => true,
+ ),
+ 'error' => null,
);
html_api_fuzz_smoke_assert( array() === \HtmlApiFuzz\OracleRenderer::identity_mismatches( $lexbor_identity, $lexbor_identity ), 'Matching Lexbor identities should be replayable.' );
-html_api_fuzz_smoke_assert( array() === \HtmlApiFuzz\OracleRenderer::identity_mismatches( array( 'kind' => 'php-dom' ), array( 'kind' => 'php-dom' ) ), 'Matching PHP DOM kinds should be replayable.' );
+html_api_fuzz_smoke_assert( array() === \HtmlApiFuzz\OracleRenderer::identity_mismatches( $php_identity, $php_identity ), 'Matching PHP DOM identities should be replayable.' );
$oracle_identity_mismatch_cases = array(
'missing metadata' => null,
'missing recorded kind' => array(),
'kind difference' => array_merge( $lexbor_identity, array( 'kind' => 'php-dom' ) ),
- 'missing commit' => array_diff_key( $lexbor_identity, array( 'lexborCommit' => true ) ),
- 'malformed commit' => array_merge( $lexbor_identity, array( 'lexborCommit' => 'not-a-commit' ) ),
- 'different commit' => array_merge( $lexbor_identity, array( 'lexborCommit' => str_repeat( 'c', 40 ) ) ),
- 'missing binary hash' => array_diff_key( $lexbor_identity, array( 'binarySha256' => true ) ),
- 'malformed binary hash' => array_merge( $lexbor_identity, array( 'binarySha256' => 'not-a-hash' ) ),
- 'different binary hash' => array_merge( $lexbor_identity, array( 'binarySha256' => str_repeat( 'd', 64 ) ) ),
);
+$missing_commit = $lexbor_identity;
+unset( $missing_commit['identity']['lexborCommit'] );
+$oracle_identity_mismatch_cases['missing commit'] = $missing_commit;
+$malformed_commit = $lexbor_identity;
+$malformed_commit['identity']['lexborCommit'] = 'not-a-commit';
+$oracle_identity_mismatch_cases['malformed commit'] = $malformed_commit;
+$different_commit = $lexbor_identity;
+$different_commit['identity']['lexborCommit'] = str_repeat( 'c', 40 );
+$different_commit['identity']['build']['resolvedCommit'] = str_repeat( 'c', 40 );
+$oracle_identity_mismatch_cases['different commit'] = $different_commit;
+$missing_binary_hash = $lexbor_identity;
+unset( $missing_binary_hash['identity']['binarySha256'] );
+$oracle_identity_mismatch_cases['missing binary hash'] = $missing_binary_hash;
+$malformed_binary_hash = $lexbor_identity;
+$malformed_binary_hash['identity']['binarySha256'] = 'not-a-hash';
+$oracle_identity_mismatch_cases['malformed binary hash'] = $malformed_binary_hash;
+$different_binary_hash = $lexbor_identity;
+$different_binary_hash['identity']['binarySha256'] = str_repeat( 'd', 64 );
+$oracle_identity_mismatch_cases['different binary hash'] = $different_binary_hash;
foreach ( $oracle_identity_mismatch_cases as $identity_label => $recorded_identity ) {
html_api_fuzz_smoke_assert( ! empty( \HtmlApiFuzz\OracleRenderer::identity_mismatches( $recorded_identity, $lexbor_identity ) ), "Expected {$identity_label} to reject oracle identity." );
}
-$unavailable_lexbor_identity = array_merge( $lexbor_identity, array( 'available' => false ) );
+$unavailable_lexbor_identity = array(
+ 'schemaVersion' => 1,
+ 'kind' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'available' => false,
+ 'identity' => null,
+ 'error' => 'test oracle unavailable',
+);
html_api_fuzz_smoke_assert( ! empty( \HtmlApiFuzz\OracleRenderer::identity_mismatches( $lexbor_identity, $unavailable_lexbor_identity ) ), 'Unavailable current Lexbor should reject oracle identity.' );
$unverified_lexbor_identity = array_merge( $lexbor_identity, array( 'versionError' => 'bad manifest' ) );
html_api_fuzz_smoke_assert( ! empty( \HtmlApiFuzz\OracleRenderer::identity_mismatches( $lexbor_identity, $unverified_lexbor_identity ) ), 'Unverified current Lexbor should reject oracle identity.' );
-$malformed_current_lexbor = array_merge( $lexbor_identity, array( 'binarySha256' => 'bad' ) );
+$malformed_current_lexbor = $lexbor_identity;
+$malformed_current_lexbor['identity']['binarySha256'] = 'bad';
html_api_fuzz_smoke_assert( ! empty( \HtmlApiFuzz\OracleRenderer::identity_mismatches( $lexbor_identity, $malformed_current_lexbor ) ), 'Malformed current Lexbor identity should be rejected.' );
$valid = null;
diff --git a/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
index 7a05dfc952421..14b1f94e03bdc 100755
--- a/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
+++ b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
@@ -27,10 +27,11 @@ function html_api_fuzz_lexbor_smoke_assert( bool $condition, string $message ):
);
$metadata = $oracle->metadata();
html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $metadata['kind'] ?? null ), 'Expected Lexbor source oracle metadata.' );
-html_api_fuzz_lexbor_smoke_assert( is_string( $metadata['lexborCommit'] ?? null ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $metadata['lexborCommit'] ), 'Expected the resolved Lexbor commit in oracle metadata.' );
-html_api_fuzz_lexbor_smoke_assert( is_string( $metadata['binarySha256'] ?? null ) && 64 === strlen( $metadata['binarySha256'] ), 'Expected the oracle binary SHA-256.' );
-html_api_fuzz_lexbor_smoke_assert( ( $metadata['lexborCommit'] ?? null ) === ( $metadata['buildManifest']['resolvedCommit'] ?? null ), 'Expected build manifest and binary commit agreement.' );
-html_api_fuzz_lexbor_smoke_assert( ( $metadata['binarySha256'] ?? null ) === ( $metadata['buildManifest']['binarySha256'] ?? null ), 'Expected build manifest and binary hash agreement.' );
+html_api_fuzz_lexbor_smoke_assert( array( 'schemaVersion', 'kind', 'available', 'identity', 'error' ) === array_keys( $metadata ), 'Expected the normalized exact oracle metadata envelope.' );
+html_api_fuzz_lexbor_smoke_assert( true === ( $metadata['available'] ?? null ) && null === ( $metadata['error'] ?? null ), 'Expected Lexbor source oracle availability.' );
+html_api_fuzz_lexbor_smoke_assert( is_string( $metadata['identity']['lexborCommit'] ?? null ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $metadata['identity']['lexborCommit'] ), 'Expected the resolved Lexbor commit in oracle metadata.' );
+html_api_fuzz_lexbor_smoke_assert( is_string( $metadata['identity']['binarySha256'] ?? null ) && 64 === strlen( $metadata['identity']['binarySha256'] ), 'Expected the oracle binary SHA-256.' );
+html_api_fuzz_lexbor_smoke_assert( ( $metadata['identity']['lexborCommit'] ?? null ) === ( $metadata['identity']['build']['resolvedCommit'] ?? null ), 'Expected build manifest and binary commit agreement.' );
$limits = array(
'maxTokens' => 200,
@@ -144,7 +145,7 @@ function html_api_fuzz_lexbor_smoke_assert( bool $condition, string $message ):
$worker_replay_372 = \HtmlApiFuzz\read_json_file( $work_dir . '/issue-372/replay.json' );
html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $worker_replay_372['options']['domOracle'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle kind.' );
html_api_fuzz_lexbor_smoke_assert( $binary === ( $worker_replay_372['options']['lexborOracleBin'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle binary.' );
-html_api_fuzz_lexbor_smoke_assert( ( $metadata['lexborCommit'] ?? null ) === ( $worker_replay_372['oracle']['lexborCommit'] ?? null ), 'Expected replay metadata to preserve the Lexbor source commit.' );
+html_api_fuzz_lexbor_smoke_assert( ( $metadata['identity']['lexborCommit'] ?? null ) === ( $worker_replay_372['oracle']['identity']['lexborCommit'] ?? null ), 'Expected replay metadata to preserve the Lexbor source commit.' );
$replay_dir = $work_dir . '/issue-372-replay';
$proc = \HtmlApiFuzz\run_php_process(
@@ -154,6 +155,8 @@ function html_api_fuzz_lexbor_smoke_assert( bool $condition, string $message ):
$work_dir . '/issue-372/replay.json',
'--output-dir',
$replay_dir,
+ '--timeout-ms',
+ '10000',
),
\HtmlApiFuzz\repo_root(),
10000,
@@ -165,10 +168,11 @@ function html_api_fuzz_lexbor_smoke_assert( bool $condition, string $message ):
$identity_mismatch_replays = array();
$hash_mismatch_replay = $worker_replay_372;
-$hash_mismatch_replay['oracle']['binarySha256'] = str_repeat( '0', 64 );
+$hash_mismatch_replay['oracle']['identity']['binarySha256'] = str_repeat( '0', 64 );
$identity_mismatch_replays['binary-hash'] = $hash_mismatch_replay;
$commit_mismatch_replay = $worker_replay_372;
-$commit_mismatch_replay['oracle']['lexborCommit'] = str_repeat( '0', 40 );
+$commit_mismatch_replay['oracle']['identity']['lexborCommit'] = str_repeat( '0', 40 );
+$commit_mismatch_replay['oracle']['identity']['build']['resolvedCommit'] = str_repeat( '0', 40 );
$identity_mismatch_replays['lexbor-commit'] = $commit_mismatch_replay;
$kind_mismatch_replay = $worker_replay_372;
$kind_mismatch_replay['oracle']['kind'] = \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM;
@@ -213,7 +217,10 @@ function html_api_fuzz_lexbor_smoke_assert( bool $condition, string $message ):
html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM === ( $kind_change_replay['options']['domOracle'] ?? null ), 'Expected allowed mismatch output to record current oracle selection.' );
html_api_fuzz_lexbor_smoke_assert( ! array_key_exists( 'lexborOracleBin', $kind_change_replay['options'] ), 'Expected allowed kind change to remove stale Lexbor binary option.' );
html_api_fuzz_lexbor_smoke_assert( ! array_key_exists( 'oracleTimeoutMs', $kind_change_replay['options'] ), 'Expected allowed kind change to remove stale oracle timeout option.' );
-html_api_fuzz_lexbor_smoke_assert( ( $metadata['lexborCommit'] ?? null ) === ( $kind_change_replay['sourceReplay']['oracle']['lexborCommit'] ?? null ), 'Expected allowed mismatch to preserve source oracle identity.' );
+html_api_fuzz_lexbor_smoke_assert( ( $metadata['identity']['lexborCommit'] ?? null ) === ( $kind_change_replay['sourceReplay']['oracle']['identity']['lexborCommit'] ?? null ), 'Expected allowed mismatch to preserve source oracle identity.' );
+html_api_fuzz_lexbor_smoke_assert( $worker_replay_372['oracle'] === ( $kind_change_replay['sourceOracle'] ?? null ), 'Expected allowed mismatch provenance to preserve the source oracle.' );
+html_api_fuzz_lexbor_smoke_assert( ( $kind_change_replay['oracle'] ?? null ) === ( $kind_change_replay['actualOracle'] ?? null ), 'Expected allowed mismatch provenance to preserve the actual oracle.' );
+html_api_fuzz_lexbor_smoke_assert( ! empty( $kind_change_replay['oracleIdentityMismatches'] ?? array() ), 'Expected allowed mismatch provenance to record mismatch reasons.' );
$kind_change_again_dir = $work_dir . '/kind-change-again';
$kind_change_again_proc = \HtmlApiFuzz\run_php_process(
diff --git a/tools/html-api-fuzz/tests/oracle-renderer-protocol-smoke.php b/tools/html-api-fuzz/tests/oracle-renderer-protocol-smoke.php
new file mode 100644
index 0000000000000..94689318427d0
--- /dev/null
+++ b/tools/html-api-fuzz/tests/oracle-renderer-protocol-smoke.php
@@ -0,0 +1,383 @@
+#!/usr/bin/env php
+ 1 === preg_match( '/^html-api-fuzz-oracle-[0-9a-f]{32}$/D', basename( $root ) )
+ )
+ );
+ sort( $roots, SORT_STRING );
+ return $roots;
+}
+
+function html_api_fuzz_protocol_wait_until( callable $condition, float $seconds, $message ): void {
+ $deadline = microtime( true ) + $seconds;
+ do {
+ if ( $condition() ) {
+ return;
+ }
+ usleep( 20000 );
+ } while ( microtime( true ) < $deadline );
+ html_api_fuzz_protocol_fail( is_callable( $message ) ? (string) $message() : (string) $message );
+}
+
+function html_api_fuzz_protocol_expect_strict_rejection( string $json, string $label ): void {
+ try {
+ \HtmlApiFuzz\StrictJsonParser::decode( $json );
+ } catch ( RuntimeException $error ) {
+ return;
+ }
+ html_api_fuzz_protocol_fail( "Expected strict JSON rejection for {$label}." );
+}
+
+function html_api_fuzz_protocol_expect_infrastructure( \HtmlApiFuzz\OracleRenderer $renderer, string $case, array $limits, string $label ): array {
+ putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=' . $case );
+ $result = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+ html_api_fuzz_protocol_assert( 'oracle-renderer-error' === ( $result['failureClass'] ?? null ), "Expected {$label} to be an oracle renderer failure." );
+ html_api_fuzz_protocol_assert( true === ( $result['infrastructure'] ?? null ), "Expected {$label} to be marked as infrastructure." );
+ html_api_fuzz_protocol_assert( true === ( $result['process']['cleanupVerified'] ?? null ), "Expected {$label} cleanup verification." );
+ return $result;
+}
+
+function html_api_fuzz_protocol_kill_owner_at_phase( string $helper, string $binary, string $environment_name, string $work_dir ): void {
+ $before = html_api_fuzz_protocol_roots();
+ $marker = $work_dir . '/phase-' . strtolower( str_replace( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_', '', $environment_name ) );
+ @unlink( $marker );
+ putenv( $environment_name . '=' . $marker );
+ putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=ok' );
+ $spec = array(
+ 0 => array( 'pipe', 'r' ),
+ 1 => array( 'file', '/dev/null', 'a' ),
+ 2 => array( 'file', '/dev/null', 'a' ),
+ );
+ $process = proc_open( array( PHP_BINARY, $helper, $binary ), $spec, $pipes, \HtmlApiFuzz\repo_root(), null, array( 'bypass_shell' => true ) );
+ html_api_fuzz_protocol_assert( is_resource( $process ), "Expected owner helper for {$environment_name}." );
+ $status = proc_get_status( $process );
+ $pid = (int) ( $status['pid'] ?? 0 );
+ html_api_fuzz_protocol_assert( $pid > 1, "Expected owner PID for {$environment_name}." );
+ html_api_fuzz_protocol_wait_until(
+ static fn (): bool => is_file( $marker ) && count( array_diff( html_api_fuzz_protocol_roots(), $before ) ) > 0,
+ 5.0,
+ "Expected {$environment_name} marker and ownership root."
+ );
+ html_api_fuzz_protocol_assert( posix_kill( $pid, SIGKILL ), "Expected to kill owner at {$environment_name}." );
+ fclose( $pipes[0] );
+ html_api_fuzz_protocol_wait_until(
+ static fn (): bool => $before === html_api_fuzz_protocol_roots(),
+ 15.0,
+ "Expected owner-EOF cleanup at {$environment_name}."
+ );
+ proc_close( $process );
+ @unlink( $marker );
+ putenv( $environment_name );
+}
+
+$initial_roots = html_api_fuzz_protocol_roots();
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-protocol-' . getmypid();
+\HtmlApiFuzz\ensure_dir( $work_dir );
+$binary = $work_dir . '/fake-lexbor-oracle.php';
+$fake_source = <<<'PHP'
+#!/usr/bin/env php
+ 'lexbor-source',
+ 'lexborCommit' => '0000000000000000000000000000000000000000',
+ 'lexborVersion' => 'protocol-test',
+);
+if ( in_array( '--version', $argv, true ) ) {
+ echo json_encode( array( 'status' => 'ok', 'oracle' => $oracle ), JSON_UNESCAPED_SLASHES ) . "\n";
+ exit( 0 );
+}
+$case = getenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE' ) ?: 'ok';
+$ok = static function ( string $tree = "\n", int $nodes = 0 ) use ( $oracle ): string {
+ return json_encode(
+ array( 'status' => 'ok', 'oracle' => $oracle, 'tree' => $tree, 'treeBase64' => base64_encode( $tree ), 'nodeCount' => $nodes ),
+ JSON_UNESCAPED_SLASHES
+ );
+};
+$error = static function ( string $failure_class ) use ( $oracle ): string {
+ return json_encode( array( 'status' => 'error', 'oracle' => $oracle, 'nodeCount' => 0, 'failureClass' => $failure_class, 'error' => 'test error' ), JSON_UNESCAPED_SLASHES );
+};
+switch ( $case ) {
+ case 'ok':
+ echo $ok() . "\n";
+ exit( 0 );
+ case 'unsupported':
+ echo json_encode( array( 'status' => 'unsupported', 'oracle' => $oracle, 'nodeCount' => 0, 'failureClass' => 'oracle-unsupported', 'unsupported' => array( 'message' => 'test unsupported' ) ), JSON_UNESCAPED_SLASHES ) . "\n";
+ exit( 0 );
+ case 'renderer-error':
+ echo $error( 'oracle-renderer-error' ) . "\n";
+ exit( 1 );
+ case 'mutation-infrastructure':
+ $input_index = array_search( '--input', $argv, true );
+ $input = false === $input_index ? '' : ( file_get_contents( $argv[ $input_index + 1 ] ?? '' ) ?: '' );
+ if ( false !== strpos( $input, 'data-fuzz' ) ) {
+ echo $error( 'oracle-renderer-error' ) . "\n";
+ exit( 1 );
+ }
+ $tree = base64_decode( getenv( 'HTML_API_FUZZ_TEST_BASELINE_TREE_BASE64' ) ?: '', true );
+ echo $ok( false === $tree ? '' : $tree, 2 ) . "\n";
+ exit( 0 );
+ case 'manifest-drift-during-render':
+ $manifest_path = getenv( 'HTML_API_FUZZ_TEST_MANIFEST_PATH' );
+ $manifest = file_get_contents( $manifest_path );
+ file_put_contents( $manifest_path, $manifest . " " );
+ echo $ok() . "\n";
+ exit( 0 );
+ case 'node-limit':
+ echo $error( 'node-limit-exceeded' ) . "\n";
+ exit( 1 );
+ case 'ok-wrong-exit':
+ echo $ok() . "\n";
+ exit( 1 );
+ case 'error-wrong-exit':
+ echo $error( 'oracle-parse-error' ) . "\n";
+ exit( 0 );
+ case 'bad-outcome':
+ echo $error( 'oracle-cli-error' ) . "\n";
+ exit( 1 );
+ case 'base64-mismatch':
+ $payload = json_decode( $ok( "tree\n", 1 ), true );
+ $payload['treeBase64'] = base64_encode( "other\n" );
+ echo json_encode( $payload, JSON_UNESCAPED_SLASHES ) . "\n";
+ exit( 0 );
+ case 'extra-key':
+ $payload = json_decode( $ok(), true );
+ $payload['extra'] = true;
+ echo json_encode( $payload, JSON_UNESCAPED_SLASHES ) . "\n";
+ exit( 0 );
+ case 'duplicate-nested':
+ $json = $ok();
+ $json = preg_replace( '/"kind":"lexbor-source"/', '"kind":"lexbor-source","kind":"lexbor-source"', $json, 1 );
+ echo $json . "\n";
+ exit( 0 );
+ case 'trailing':
+ echo $ok() . " {}\n";
+ exit( 0 );
+ case 'invalid-utf8':
+ echo "{\"status\":\"\xFF\"}\n";
+ exit( 0 );
+ case 'invalid-escape':
+ echo '{"status":"bad\q"}' . "\n";
+ exit( 0 );
+ case 'invalid-number':
+ echo str_replace( '"nodeCount":0', '"nodeCount":01', $ok() ) . "\n";
+ exit( 0 );
+ case 'deep-json':
+ echo '{"x":' . str_repeat( '[', 70 ) . '0' . str_repeat( ']', 70 ) . '}';
+ exit( 0 );
+ case 'stdout-overflow':
+ echo str_repeat( 'x', 67108865 );
+ exit( 0 );
+ case 'stderr-overflow':
+ fwrite( STDERR, str_repeat( 'e', 1048577 ) );
+ echo $ok() . "\n";
+ exit( 0 );
+ case 'escaped-tree':
+ echo $ok( str_repeat( '"', 16777216 ), 1 ) . "\n";
+ exit( 0 );
+ case 'timeout':
+ while ( true ) {
+ usleep( 100000 );
+ }
+ case 'fork-heartbeat':
+ $marker = getenv( 'HTML_API_FUZZ_TEST_HEARTBEAT' );
+ $pid_file = getenv( 'HTML_API_FUZZ_TEST_HEARTBEAT_PID' );
+ $pid = pcntl_fork();
+ if ( 0 === $pid ) {
+ file_put_contents( $pid_file, getmypid() . "\n" );
+ while ( true ) {
+ file_put_contents( $marker, microtime( true ) . "\n", FILE_APPEND );
+ usleep( 20000 );
+ }
+ }
+ echo $ok() . "\n";
+ exit( 0 );
+ case 'kill-supervisor':
+ $marker = getenv( 'HTML_API_FUZZ_TEST_HEARTBEAT' );
+ $pid_file = getenv( 'HTML_API_FUZZ_TEST_HEARTBEAT_PID' );
+ file_put_contents( $pid_file, getmypid() . "\n" );
+ posix_kill( posix_getppid(), SIGKILL );
+ while ( true ) {
+ file_put_contents( $marker, microtime( true ) . "\n", FILE_APPEND );
+ usleep( 20000 );
+ }
+}
+exit( 2 );
+PHP;
+html_api_fuzz_protocol_assert( strlen( $fake_source ) === file_put_contents( $binary, $fake_source ), 'Expected fake source oracle publication.' );
+html_api_fuzz_protocol_assert( chmod( $binary, 0500 ), 'Expected executable fake source oracle.' );
+\HtmlApiFuzz\write_json_file(
+ $work_dir . '/build-manifest.json',
+ array(
+ 'kind' => 'html-api-fuzz-lexbor-build',
+ 'requestedRef' => 'protocol-test',
+ 'resolvedCommit' => str_repeat( '0', 40 ),
+ 'upstream' => 'https://github.com/lexbor/lexbor.git',
+ 'builtAt' => gmdate( 'c' ),
+ 'binarySha256' => hash_file( 'sha256', $binary ),
+ 'compiler' => 'protocol-test',
+ 'cmake' => 'protocol-test',
+ )
+);
+
+html_api_fuzz_protocol_assert( array( 'a' => array( 'b' => 1 ) ) === \HtmlApiFuzz\StrictJsonParser::decode( '{"a":{"b":1}}' ), 'Expected valid strict JSON parsing.' );
+html_api_fuzz_protocol_expect_strict_rejection( '{"a":{"x":1,"x":2}}', 'duplicate nested keys' );
+html_api_fuzz_protocol_expect_strict_rejection( '{} {}', 'trailing values' );
+html_api_fuzz_protocol_expect_strict_rejection( "{\"x\":\"\xFF\"}", 'invalid UTF-8' );
+html_api_fuzz_protocol_expect_strict_rejection( '{"x":"bad\q"}', 'invalid escape' );
+html_api_fuzz_protocol_expect_strict_rejection( '{"x":01}', 'invalid number' );
+html_api_fuzz_protocol_expect_strict_rejection( str_repeat( '[', 70 ) . '0' . str_repeat( ']', 70 ), 'excessive nesting' );
+
+$limits = array( 'maxNodes' => 20, 'maxDepth' => 20, 'maxTreeBytes' => 16777216 );
+$renderer = \HtmlApiFuzz\OracleRenderer::from_options(
+ array(
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ 'oracle-timeout-ms' => '10000',
+ )
+);
+$metadata = $renderer->metadata();
+html_api_fuzz_protocol_assert( array( 'schemaVersion', 'kind', 'available', 'identity', 'error' ) === array_keys( $metadata ), 'Expected normalized exact source metadata.' );
+html_api_fuzz_protocol_assert( true === $metadata['available'] && null === $metadata['error'], 'Expected fake source oracle availability: ' . (string) ( $metadata['error'] ?? '' ) );
+html_api_fuzz_protocol_assert( hash_file( 'sha256', $binary ) === $metadata['identity']['binarySha256'], 'Expected binary identity pinning.' );
+
+$manifest_path = $work_dir . '/build-manifest.json';
+$manifest_raw = file_get_contents( $manifest_path );
+$changed_manifest = \HtmlApiFuzz\StrictJsonParser::decode( $manifest_raw );
+$changed_manifest['builtAt'] .= '-changed-after-metadata';
+\HtmlApiFuzz\write_json_file_atomic( $manifest_path, $changed_manifest );
+$manifest_drift = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( 'oracle-renderer-error' === ( $manifest_drift['failureClass'] ?? null ) && true === ( $manifest_drift['infrastructure'] ?? null ), 'Expected every render to reject a valid manifest whose non-normalized builtAt field changed after metadata.' );
+\HtmlApiFuzz\write_file_atomic( $manifest_path, $manifest_raw );
+
+putenv( 'HTML_API_FUZZ_TEST_MANIFEST_PATH=' . $manifest_path );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=manifest-drift-during-render' );
+$manifest_drift_after_launch = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( 'oracle-renderer-error' === ( $manifest_drift_after_launch['failureClass'] ?? null ) && true === ( $manifest_drift_after_launch['infrastructure'] ?? null ), 'Expected post-invocation identity validation to reject manifest drift during rendering.' );
+html_api_fuzz_protocol_assert( true === ( $manifest_drift_after_launch['process']['cleanupVerified'] ?? null ), 'Expected verified cleanup before reporting post-invocation manifest drift.' );
+\HtmlApiFuzz\write_file_atomic( $manifest_path, $manifest_raw );
+putenv( 'HTML_API_FUZZ_TEST_MANIFEST_PATH' );
+
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=ok' );
+$ok = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $ok['status'] ?? null ) && "\n" === ( $ok['tree'] ?? null ), 'Expected exact ok outcome.' );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=unsupported' );
+$unsupported = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_UNSUPPORTED === ( $unsupported['status'] ?? null ) && 'oracle-unsupported' === ( $unsupported['failureClass'] ?? null ), 'Expected exact unsupported outcome.' );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=renderer-error' );
+$renderer_error = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( true === ( $renderer_error['infrastructure'] ?? null ) && 'oracle-renderer-error' === ( $renderer_error['failureClass'] ?? null ), 'Expected validated renderer errors to remain infrastructure.' );
+
+$worker_input = 'x
';
+$worker_limits = array_merge( array( 'maxTokens' => 2000 ), $limits );
+$wordpress_baseline = \HtmlApiFuzz\TreeRenderer::render_wordpress( $worker_input, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $worker_limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $wordpress_baseline['status'] ?? null ), 'Expected a WordPress baseline tree for mutation infrastructure propagation.' );
+putenv( 'HTML_API_FUZZ_TEST_BASELINE_TREE_BASE64=' . base64_encode( $wordpress_baseline['tree'] ) );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=mutation-infrastructure' );
+$mutation_infrastructure = \HtmlApiFuzz\Worker::evaluate_input( $worker_input, 1, 'replay', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, null, 'body', null, 'input-base64', $worker_limits, false, $renderer, 'full' );
+html_api_fuzz_protocol_assert( 'mutation-oracle-render-error' === ( $mutation_infrastructure['failureClass'] ?? null ), 'Expected the mutation renderer failure class.' );
+html_api_fuzz_protocol_assert( true === ( $mutation_infrastructure['mutation']['oracleInfrastructure'] ?? null ), 'Expected nested mutation infrastructure evidence.' );
+html_api_fuzz_protocol_assert( true === ( $mutation_infrastructure['oracleInfrastructure'] ?? null ), 'Expected mutation infrastructure to propagate to the top-level worker result.' );
+putenv( 'HTML_API_FUZZ_TEST_BASELINE_TREE_BASE64' );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=node-limit' );
+$node_limit = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_ERROR === ( $node_limit['status'] ?? null ) && 'node-limit-exceeded' === ( $node_limit['failureClass'] ?? null ) && empty( $node_limit['infrastructure'] ), 'Expected a validated node-limit outcome.' );
+
+foreach ( array( 'bad-outcome', 'base64-mismatch', 'extra-key', 'duplicate-nested', 'trailing', 'invalid-utf8', 'invalid-escape', 'invalid-number', 'deep-json', 'ok-wrong-exit', 'error-wrong-exit' ) as $case ) {
+ html_api_fuzz_protocol_expect_infrastructure( $renderer, $case, $limits, $case );
+}
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=escaped-tree' );
+$escaped = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $escaped['status'] ?? null ) && 16777216 === strlen( $escaped['tree'] ?? '' ), 'Expected exact 16 MiB high-escape tree transport at the decoded tree limit.' );
+$stdout_overflow = html_api_fuzz_protocol_expect_infrastructure( $renderer, 'stdout-overflow', $limits, '64 MiB plus one stdout' );
+html_api_fuzz_protocol_assert( true === ( $stdout_overflow['process']['stdoutOverflow'] ?? null ), 'Expected the exact stdout capture cap to trip.' );
+$stderr_overflow = html_api_fuzz_protocol_expect_infrastructure( $renderer, 'stderr-overflow', $limits, '1 MiB plus one stderr' );
+html_api_fuzz_protocol_assert( true === ( $stderr_overflow['process']['stderrOverflow'] ?? null ), 'Expected the exact stderr capture cap to trip.' );
+$timeout_property = new ReflectionProperty( \HtmlApiFuzz\OracleRenderer::class, 'timeout_ms' );
+$timeout_property->setValue( $renderer, 500 );
+$timed_out = html_api_fuzz_protocol_expect_infrastructure( $renderer, 'timeout', $limits, 'oracle timeout' );
+html_api_fuzz_protocol_assert( true === ( $timed_out['process']['timedOut'] ?? null ), 'Expected the oracle deadline to trip.' );
+$timeout_property->setValue( $renderer, 10000 );
+
+$heartbeat = $work_dir . '/fork-heartbeat';
+$heartbeat_pid = $work_dir . '/fork-heartbeat-pid';
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT=' . $heartbeat );
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT_PID=' . $heartbeat_pid );
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE=fork-heartbeat' );
+$forked = $renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $forked['status'] ?? null ), 'Expected a leader exit with a forked descendant to retain its validated outcome.' );
+html_api_fuzz_protocol_wait_until( static fn (): bool => is_file( $heartbeat_pid ), 2.0, 'Expected forked heartbeat PID evidence.' );
+$forked_pid = (int) file_get_contents( $heartbeat_pid );
+$heartbeat_before = is_file( $heartbeat ) ? file_get_contents( $heartbeat ) : null;
+usleep( 100000 );
+$heartbeat_after = is_file( $heartbeat ) ? file_get_contents( $heartbeat ) : null;
+html_api_fuzz_protocol_assert( $forked_pid > 1 && ! posix_kill( $forked_pid, 0 ), 'Expected forked target descendant cleanup.' );
+html_api_fuzz_protocol_assert( $heartbeat_before === $heartbeat_after, 'Expected forked target heartbeat to stop.' );
+
+$sentinel = proc_open( array( PHP_BINARY, '-r', 'while (true) { usleep(100000); }' ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'file', '/dev/null', 'a' ), 2 => array( 'file', '/dev/null', 'a' ) ), $sentinel_pipes, \HtmlApiFuzz\repo_root(), null, array( 'bypass_shell' => true ) );
+html_api_fuzz_protocol_assert( is_resource( $sentinel ), 'Expected unrelated sentinel process.' );
+$sentinel_pid = (int) ( proc_get_status( $sentinel )['pid'] ?? 0 );
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT=' . $work_dir . '/killed-supervisor-heartbeat' );
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT_PID=' . $work_dir . '/killed-supervisor-pid' );
+$killed_supervisor = html_api_fuzz_protocol_expect_infrastructure( $renderer, 'kill-supervisor', $limits, 'target-killed supervisor' );
+html_api_fuzz_protocol_assert( $sentinel_pid > 1 && posix_kill( $sentinel_pid, 0 ), 'Expected unrelated sentinel to survive authenticated fallback cleanup.' );
+$killed_target_pid = (int) file_get_contents( $work_dir . '/killed-supervisor-pid' );
+$killed_heartbeat_before = file_get_contents( $work_dir . '/killed-supervisor-heartbeat' );
+usleep( 100000 );
+$killed_heartbeat_after = file_get_contents( $work_dir . '/killed-supervisor-heartbeat' );
+html_api_fuzz_protocol_assert( $killed_target_pid > 1 && ! posix_kill( $killed_target_pid, 0 ), 'Expected target cleanup after it killed the supervisor.' );
+html_api_fuzz_protocol_assert( $killed_heartbeat_before === $killed_heartbeat_after, 'Expected target heartbeat to stop after supervisor fallback cleanup.' );
+proc_terminate( $sentinel, SIGTERM );
+fclose( $sentinel_pipes[0] );
+proc_close( $sentinel );
+
+$helper = $work_dir . '/renderer-owner-helper.php';
+$helper_source = '"lexbor-source","lexbor-oracle-bin"=>$argv[1],"oracle-timeout-ms"=>"10000")); $renderer->metadata();';
+html_api_fuzz_protocol_assert( strlen( $helper_source ) === file_put_contents( $helper, $helper_source ), 'Expected renderer owner helper publication.' );
+foreach ( array( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_ANCHOR_READY', 'HTML_API_FUZZ_TEST_PAUSE_AFTER_GATED', 'HTML_API_FUZZ_TEST_PAUSE_AFTER_CLEANED' ) as $phase_environment ) {
+ html_api_fuzz_protocol_kill_owner_at_phase( $helper, $binary, $phase_environment, $work_dir );
+}
+
+$shutdown_marker = $work_dir . '/shutdown-while-gated';
+$short_renderer = \HtmlApiFuzz\OracleRenderer::from_options(
+ array(
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ 'oracle-timeout-ms' => '10000',
+ )
+);
+html_api_fuzz_protocol_assert( true === ( $short_renderer->metadata()['available'] ?? false ), 'Expected the shutdown fixture identity probe.' );
+$timeout_property->setValue( $short_renderer, 1500 );
+putenv( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_GATED=' . $shutdown_marker );
+$shutdown_result = $short_renderer->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_protocol_assert( true === ( $shutdown_result['process']['timedOut'] ?? null ), 'Expected authenticated shutdown while the target gate is paused.' );
+html_api_fuzz_protocol_assert( is_file( $shutdown_marker ), 'Expected shutdown-while-paused marker evidence.' );
+@unlink( $shutdown_marker );
+putenv( 'HTML_API_FUZZ_TEST_PAUSE_AFTER_GATED' );
+
+putenv( 'HTML_API_FUZZ_TEST_FAKE_ORACLE_CASE' );
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT' );
+putenv( 'HTML_API_FUZZ_TEST_HEARTBEAT_PID' );
+html_api_fuzz_protocol_assert( $initial_roots === html_api_fuzz_protocol_roots(), 'Expected no private oracle ownership roots to survive.' );
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+html_api_fuzz_protocol_assert( ! is_dir( $work_dir ), 'Expected protocol smoke cleanup.' );
+
+echo "OK oracle-renderer-protocol-smoke\n";
diff --git a/tools/html-api-fuzz/tests/result-store-smoke.php b/tools/html-api-fuzz/tests/result-store-smoke.php
index 6b9f5aa9b52c7..aa4343c53468b 100644
--- a/tools/html-api-fuzz/tests/result-store-smoke.php
+++ b/tools/html-api-fuzz/tests/result-store-smoke.php
@@ -20,14 +20,72 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
$store = new \HtmlApiFuzz\ResultStore( $db_path );
$php_oracle = array(
- 'kind' => 'php-dom',
- 'phpVersion' => PHP_VERSION,
+ 'schemaVersion' => 1,
+ 'kind' => 'php-dom',
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => 'php-dom',
+ 'phpVersion' => PHP_VERSION,
+ 'phpVersionId' => PHP_VERSION_ID,
+ 'phpSapi' => PHP_SAPI,
+ 'zendVersion' => zend_version(),
+ 'libxmlVersion' => defined( 'LIBXML_DOTTED_VERSION' ) ? LIBXML_DOTTED_VERSION : null,
+ 'domHtmlDocument' => true,
+ ),
+ 'error' => null,
);
$lexbor_oracle = array(
+ 'schemaVersion' => 1,
'kind' => 'lexbor-source',
- 'lexborVersion' => '2.10.0',
- 'lexborCommit' => '481c444261a132190a3fb746d6d2f60824af3717',
- 'binary' => '/tmp/lexbor-tree-oracle',
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => 'lexbor-source',
+ 'binarySha256' => str_repeat( 'b', 64 ),
+ 'lexborVersion' => '2.10.0',
+ 'lexborCommit' => '481c444261a132190a3fb746d6d2f60824af3717',
+ 'build' => array(
+ 'kind' => 'html-api-fuzz-lexbor-build',
+ 'requestedRef' => 'test',
+ 'resolvedCommit' => '481c444261a132190a3fb746d6d2f60824af3717',
+ 'upstream' => 'https://github.com/lexbor/lexbor.git',
+ 'compiler' => 'test-cc',
+ 'cmake' => 'test-cmake',
+ ),
+ ),
+ 'error' => null,
+);
+$html5ever_oracle = array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html5ever-source',
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html5ever-source',
+ 'binarySha256' => str_repeat( 'c', 64 ),
+ 'html5everVersion' => '0.35.0',
+ 'html5everChecksum' => str_repeat( 'd', 64 ),
+ 'markup5everRcdomVersion' => '0.35.0+unofficial',
+ 'markup5everRcdomChecksum' => str_repeat( 'e', 64 ),
+ 'rustToolchain' => '1.88.0',
+ 'cargoLockSha256' => str_repeat( 'f', 64 ),
+ 'buildIdentity' => str_repeat( '1', 64 ),
+ 'build' => array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html-api-fuzz-html5ever-build',
+ 'publicationProtocol' => 'manifest-last-v1',
+ 'cargoTomlSha256' => str_repeat( '2', 64 ),
+ 'cargoLockSha256' => str_repeat( 'f', 64 ),
+ 'rustToolchainSha256' => str_repeat( '3', 64 ),
+ 'sourceSha256' => str_repeat( '4', 64 ),
+ 'rustc' => 'rustc test',
+ 'cargo' => 'cargo test',
+ 'html5ever' => array( 'version' => '0.35.0', 'checksum' => str_repeat( 'd', 64 ) ),
+ 'markup5everRcdom' => array( 'version' => '0.35.0+unofficial', 'checksum' => str_repeat( 'e', 64 ) ),
+ ),
+ ),
+ 'error' => null,
);
$pass_summary = array(
@@ -53,6 +111,11 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
'workerTimedOut' => false,
);
$pass_id = $store->record_attempt( $pass_summary );
+$html5ever_summary = $pass_summary;
+$html5ever_summary['seed'] = 15;
+$html5ever_summary['inputSha1'] = sha1( 'html5ever-pass' );
+$html5ever_summary['oracle'] = $html5ever_oracle;
+$html5ever_id = $store->record_attempt( $html5ever_summary );
$failure_summary = array(
'kind' => 'failure',
@@ -167,13 +230,13 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
);
$oracle_id = $store->record_attempt( $oracle_summary, $oracle_result, $oracle_replay );
-html_api_fuzz_smoke_assert( 5 === $store->count_attempts(), 'Expected five recorded attempts.' );
+html_api_fuzz_smoke_assert( 6 === $store->count_attempts(), 'Expected six recorded attempts.' );
html_api_fuzz_smoke_assert( array( 12 ) === $store->retained_seeds( 'abc123def456' ), 'Expected seed 12 as the retained exemplar for the signature.' );
html_api_fuzz_smoke_assert( array() === $store->retained_seeds( 'unseen' ), 'Expected no retained exemplars for an unseen signature.' );
html_api_fuzz_smoke_assert( array( 14 ) === $store->oracle_retained_seeds( 'oracle-abc123' ), 'Expected seed 14 as the retained exemplar for the oracle signature.' );
html_api_fuzz_smoke_assert( $store->seed_artifacts_retained( 12 ), 'Expected seed 12 to be marked as retained.' );
html_api_fuzz_smoke_assert( ! $store->seed_artifacts_retained( 13 ), 'Expected seed 13 not to be marked as retained.' );
-html_api_fuzz_smoke_assert( 5 === $store->max_id(), 'Expected max id of five.' );
+html_api_fuzz_smoke_assert( 6 === $store->max_id(), 'Expected max id of six.' );
$stored_replay = $store->replay_for_seed( 13 );
html_api_fuzz_smoke_assert( is_array( $stored_replay ) && base64_encode( 'new replay ' ) === ( $stored_replay['inputBase64'] ?? null ), 'Expected seed replay lookup to return the most recent replay for compatibility.' );
@@ -201,7 +264,7 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
// Reopen read-only as the watcher does and confirm persistence.
$reader = new \HtmlApiFuzz\ResultStore( $db_path, true );
-html_api_fuzz_smoke_assert( 5 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
+html_api_fuzz_smoke_assert( 6 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
html_api_fuzz_smoke_assert( 3 === count( $reader->failures_after( 0, $reader->max_id() ) ), 'Expected failures to persist across reopen.' );
html_api_fuzz_smoke_assert( 1 === count( $reader->oracle_findings_after( 0, $reader->max_id() ) ), 'Expected oracle findings to persist across reopen.' );
$reader->close();
@@ -215,7 +278,8 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_signature_hash = 'oracle-abc123' AND oracle_artifacts_retained = 1" ), 'Expected oracle retention to use its own budget flag.' );
html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 11 AND oracle_kind = 'php-dom' AND oracle_version = '" . SQLite3::escapeString( PHP_VERSION ) . "'" ), 'Expected passing rows to keep PHP DOM oracle metadata in scalar columns.' );
html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_kind = 'lexbor-source' AND oracle_version = '2.10.0' AND oracle_commit = '481c444261a132190a3fb746d6d2f60824af3717'" ), 'Expected Lexbor oracle metadata to be queryable for failure rows.' );
-html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_binary = '/tmp/lexbor-tree-oracle'" ), 'Expected Lexbor oracle binary to be stored in a scalar column.' );
+html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_binary = '" . str_repeat( 'b', 64 ) . "'" ), 'Expected the Lexbor oracle binary hash to be stored in a scalar column.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 15 AND oracle_kind = 'html5ever-source' AND oracle_version = '0.35.0' AND oracle_commit = '" . str_repeat( '1', 64 ) . "' AND oracle_binary = '" . str_repeat( 'c', 64 ) . "'" ), 'Expected html5ever identity columns to use version, build identity, and binary hash.' );
$raw->close();
$future_db_path = $work_dir . '/future.sqlite';
diff --git a/tools/html-api-fuzz/worker.php b/tools/html-api-fuzz/worker.php
index a59848d151184..157c2246b0dd8 100755
--- a/tools/html-api-fuzz/worker.php
+++ b/tools/html-api-fuzz/worker.php
@@ -23,9 +23,16 @@ function html_api_fuzz_worker_fatal_result( array $options, Throwable $e, ?strin
try {
$fallback['oracle'] = \HtmlApiFuzz\OracleRenderer::from_options( $options )->metadata();
} catch ( Throwable $oracle_error ) {
+ $oracle_kind = \HtmlApiFuzz\option_string( $options, 'dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM );
+ if ( ! in_array( $oracle_kind, \HtmlApiFuzz\OracleRenderer::kinds(), true ) ) {
+ $oracle_kind = \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM;
+ }
$fallback['oracle'] = array(
- 'kind' => \HtmlApiFuzz\option_string( $options, 'dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM ),
- 'error' => $oracle_error->getMessage(),
+ 'schemaVersion' => 1,
+ 'kind' => $oracle_kind,
+ 'available' => false,
+ 'identity' => null,
+ 'error' => $oracle_error->getMessage(),
);
}
From 91fc6c44624aa8fc0042606f5aa123f6696d0b39 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Thu, 16 Jul 2026 06:47:43 +0200
Subject: [PATCH 011/149] oracles: integrate pinned Chrome adapter
---
tools/html-api-fuzz/README.md | 93 +-
tools/html-api-fuzz/launcher.php | 24 +-
.../lib/ChromeOracleRenderer.php | 940 ++++++++++++++++++
tools/html-api-fuzz/lib/CommonCrawlRunner.php | 145 +--
tools/html-api-fuzz/lib/OracleRenderer.php | 165 ++-
tools/html-api-fuzz/lib/ResultStore.php | 24 +-
tools/html-api-fuzz/lib/Worker.php | 97 +-
tools/html-api-fuzz/lib/autoload.php | 1 +
tools/html-api-fuzz/minimize.php | 395 +++++---
tools/html-api-fuzz/replay.php | 78 +-
tools/html-api-fuzz/runner.php | 32 +-
.../tests/chrome-oracle-adapter-smoke.php | 648 ++++++++++++
.../tests/commoncrawl-analysis-smoke.php | 9 +
.../commoncrawl-source-oracles-smoke.php | 42 +-
.../tests/generator-policy-smoke.php | 16 +
.../tests/result-store-smoke.php | 35 +-
tools/html-api-fuzz/worker.php | 35 +-
17 files changed, 2492 insertions(+), 287 deletions(-)
create mode 100644 tools/html-api-fuzz/lib/ChromeOracleRenderer.php
create mode 100755 tools/html-api-fuzz/tests/chrome-oracle-adapter-smoke.php
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
index 0005a12ee543f..f12e0d38f5467 100644
--- a/tools/html-api-fuzz/README.md
+++ b/tools/html-api-fuzz/README.md
@@ -6,7 +6,9 @@ using an html5lib-style textual tree, and separately checks a set of API
invariants described under “Invariants” below. The default oracle is PHP's
`Dom\HTMLDocument`, preserving the historical behavior.
-No browser, Playwright, Node, or `wp-env` is involved.
+The PHP DOM and source-built adapters need no browser. The optional
+`chrome-cdp` adapter uses a pinned Chrome-for-Testing build through a direct,
+dependency-free Node/CDP client; it does not use Playwright or `wp-env`.
## Requirements
@@ -18,15 +20,18 @@ No browser, Playwright, Node, or `wp-env` is involved.
- Optional source-built html5ever oracle: the pinned Rust toolchain installed by
`tools/html-api-fuzz/oracles/html5ever/install-rust.sh`, or matching Rust and
Cargo 1.88.0 executables.
+- Optional Chrome oracle: Node.js plus the exact Chrome-for-Testing archive
+ installed and verified by `tools/html-api-fuzz/oracles/chrome/install.sh`.
## Common Crawl with cc-analyzer
`commoncrawl-analysis.php` is an analysis callback for `cc-analyzer.phar`. It
accepts the analyzer's `CcAnalyzer\Analysis\HtmlAnalysisInput` value object,
runs the raw response body through `WP_HTML_Processor` in full-document mode,
-compares the resulting tree with the selected source oracle, and records the
-Common Crawl provenance needed to locate or replay the document. Lexbor is the
-default; `html5ever-source` is an equally supported independent adapter.
+compares the resulting tree with the selected oracle, and records the Common
+Crawl provenance needed to locate or replay the document. Lexbor is the
+default; `html5ever-source` and `chrome-cdp` are equally supported independent
+adapters.
Build the oracle from the current upstream `master` first:
@@ -41,6 +46,12 @@ tools/html-api-fuzz/oracles/html5ever/install-rust.sh
tools/html-api-fuzz/oracles/html5ever/build.sh
```
+Or install the pinned Chrome-for-Testing oracle:
+
+```sh
+tools/html-api-fuzz/oracles/chrome/install.sh
+```
+
The build resolves the moving ref, uses commit-keyed build/install directories,
refuses a dirty Lexbor checkout, and writes `build/build-manifest.json` with the
requested ref, resolved commit, upstream URL, build time, compiler, CMake
@@ -85,34 +96,57 @@ identity or cleanup state is an infrastructure failure and retains evidence
instead of signaling an unverified PID. Source stdout is capped at exactly
64 MiB and stderr at 1 MiB; either overflow is an infrastructure failure.
+The Chrome adapter instead keeps one authenticated `node ... --serve` process
+for the lifetime of one renderer, so a Worker's baseline and mutation renders
+reuse the same exact browser. It verifies the checked-in version/checksum
+manifests, Chrome executable, oracle script, fragment-context file, actual Node
+executable, live Chrome version, and CDP protocol version before accepting any
+tree. Request JSON is strict, duplicate keys and trailing frames are rejected,
+input is canonical base64, response frames are capped at 24 MiB, stderr at
+1 MiB, and decoded trees at 16 MiB. Accepted input is capped at exactly 2 MiB;
+2 MiB plus one byte becomes `input-byte-limit-exceeded` without sending a
+render frame. Shutdown drains and reaps the exact service and verifies that its
+private runtime/profile root disappeared. A transport failure never retries
+the current input in PHP; the Node client may perform its one authenticated
+recovery only when it observes a dead CDP session.
+
The Common Crawl adapter uses Lexbor by default. Its environment is:
- `CC_ANALYZER_OUTPUT_DIR`: shared output directory for this analyzer run. If
absent, a unique directory is created under `artifacts/html-api-commoncrawl`.
- `HTML_API_CC_RUN_ID`: explicit immutable run identifier. When the analyzer
provides an output directory, the default is a stable hash of that path.
-- `HTML_API_CC_ORACLE`: `lexbor-source` (default), `html5ever-source`, or the
- `php-dom` testing/debug escape hatch.
+- `HTML_API_CC_ORACLE`: `lexbor-source` (default), `html5ever-source`,
+ `chrome-cdp`, or the `php-dom` testing/debug escape hatch.
- `HTML_API_FUZZ_LEXBOR_ORACLE`: non-default Lexbor oracle binary path.
- `HTML_API_FUZZ_HTML5EVER_ORACLE`: non-default html5ever oracle binary path.
+- `HTML_API_FUZZ_CHROME_ORACLE`: non-default Chrome oracle script path.
+- `HTML_API_FUZZ_CHROME_EXECUTABLE`: non-default pinned Chrome executable.
+- `HTML_API_FUZZ_NODE_BIN`: Node command or executable path.
+- `HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS`: browser startup deadline; default
+ `35000`. `HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS` is the Common Crawl-specific
+ override.
- `HTML_API_CC_EXPECT_LEXBOR_COMMIT`: optional exact commit assertion; startup
fails if the selected Lexbor executable reports another commit. Setting this
while another oracle is selected also fails.
- `HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256`: optional SHA-256 of the complete
normalized selected-oracle metadata envelope. This generic pin works for
- PHP DOM, Lexbor, and html5ever.
-- `HTML_API_CC_ORACLE_TIMEOUT_MS`: source-oracle subprocess timeout; default
- `10000`.
-- `HTML_API_CC_PROCESS_TIMEOUT_MS`: whole-document worker timeout; default
- `30000`.
+ PHP DOM, Lexbor, html5ever, and Chrome.
+- `HTML_API_CC_ORACLE_TIMEOUT_MS`: per-render oracle timeout; default `10000`.
+- `HTML_API_CC_PROCESS_TIMEOUT_MS`: explicit whole-document worker timeout.
+ Without it, non-Chrome oracles use `30000`; Chrome budgets startup plus four
+ render calls plus 10 seconds of cleanup and 5 seconds of PHP headroom. With
+ the defaults here that is `90000` ms. An explicit shorter value remains an
+ exact diagnostic override and is recorded in every replay.
- `HTML_API_CC_MEMORY_LIMIT`: PHP memory limit for each worker; default `256M`.
- `HTML_API_CC_MAX_INPUT_BYTES`: largest response body to analyze; default
- `2097152`, or `0` for unlimited.
+ `2097152`, or `0` for unlimited with non-Chrome oracles. Chrome retains its
+ authenticated transport ceiling of 2 MiB.
- `HTML_API_CC_MAX_TOKENS`, `HTML_API_CC_MAX_NODES`,
`HTML_API_CC_MAX_DEPTH`, and `HTML_API_CC_MAX_TREE_BYTES`: bounded work and
tree-output ceilings; defaults are `50000`, `50000`, `512`, and `16777216`.
- `HTML_API_CC_CHECKS`: `baseline`, `full`, or `sampled` (default). Baseline
- performs the WordPress/Lexbor differential; full also runs API invariants,
+ performs the WordPress/selected-oracle differential; full also runs API invariants,
mutation differential, and normalize preservation.
- `HTML_API_CC_FULL_SAMPLE_PERCENT`: deterministic full-check share when using
`sampled`; default `1`.
@@ -166,6 +200,9 @@ build identity, and executable SHA-256. Use `--allow-oracle-mismatch` only for a
deliberate diagnostic comparison; outputs retain `sourceOracle`,
`actualOracle`, and the exact mismatch reasons. A second identity change while
a Worker is running is recorded separately and invalidates any stale signature.
+Chrome replay additionally restores and validates the exact script, Chrome and
+Node paths, render deadline, startup deadline, and complete durable identity;
+runtime PIDs, profiles, and debugging endpoints are deliberately excluded.
Transport charset, content type, target URI, WARC record ID, analyzer state
key, and source range are metadata only. The comparison intentionally feeds
@@ -224,6 +261,21 @@ Use `--html5ever-oracle-bin PATH` or
adapters add process-supervision overhead, so give `runner.php` a whole-worker
`--timeout-ms` budget large enough to cover the configured oracle deadline.
+Install and run against the pinned direct-CDP Chrome oracle:
+
+```sh
+tools/html-api-fuzz/oracles/chrome/install.sh
+php tools/html-api-fuzz/worker.php --seed 1 --dom-oracle chrome-cdp --output-dir artifacts/html-api-fuzz/seed-1-chrome
+php tools/html-api-fuzz/runner.php --max-seeds 100 --dom-oracle chrome-cdp
+```
+
+Use `--chrome-oracle-script`, `--chrome-executable`, `--node-bin`, and
+`--chrome-startup-timeout-ms` (or their `HTML_API_FUZZ_*` environment
+equivalents) for explicit locations/deadlines. When `--timeout-ms` is omitted,
+runner, launcher, replay, and minimization use the shared Chrome budget formula:
+`startup + (1 baseline or 4 full/sampled renders) × oracle timeout + 15000`.
+The ordinary defaults are 52.5 seconds for baseline and 60 seconds for full.
+
Run indefinitely:
```sh
@@ -307,7 +359,8 @@ in the watcher/minimizer triage path. This bucket includes tag/tree token
ceilings (`tag-token-limit-exceeded`, `mutation-token-limit-exceeded`,
`wordpress-token-limit-exceeded`) and oracle node ceilings
(`node-limit-exceeded`, recorded as `dom-node-limit-exceeded` in historical
-signature facts). Process timeouts, PHP fatal errors, and memory failures are
+signature facts), plus the Chrome adapter's exact 2 MiB input ceiling. Process
+timeouts, PHP fatal errors, and memory failures are
separate failures and are also in scope for triage.
## Execution Model
@@ -349,11 +402,13 @@ The runner writes:
replay JSON documents; the replay embeds the input as base64, so a pruned
failure can be reproduced with `replay.php --store results.sqlite --seed N`.
`signature_hash` and `family_key` are indexed columns for grouping
- failures without `json_extract`. `oracle_kind`, `oracle_version`,
- `oracle_commit`, and `oracle_binary` record which oracle generated the
- summary, including for passing rows whose JSON payloads are pruned.
- `oracle_commit` is the Lexbor commit or html5ever build identity, while
- `oracle_binary` is the executable SHA-256 rather than a host path. The
+ failures without `json_extract`. `oracle_kind`, `oracle_identity_sha256`,
+ `oracle_version`, `oracle_commit`, and `oracle_binary` record which oracle
+ generated the summary, including for passing rows whose JSON payloads are
+ pruned. `oracle_identity_sha256` groups the complete normalized durable
+ identity. `oracle_commit` is the Lexbor commit, html5ever build identity, or
+ Chrome script SHA-256, while `oracle_binary` is the source binary or Chrome
+ executable SHA-256 rather than a host path. The
watcher tails these stores
incrementally by row id. (`summary.ndjson` files from older runs are still
scanned.) Durability is `synchronous=NORMAL`: an OS crash (not a process
diff --git a/tools/html-api-fuzz/launcher.php b/tools/html-api-fuzz/launcher.php
index 133e794367c15..211be8f9076c8 100755
--- a/tools/html-api-fuzz/launcher.php
+++ b/tools/html-api-fuzz/launcher.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/lib/autoload.php';
function html_api_fuzz_launcher_usage(): void {
- echo "Usage: php tools/html-api-fuzz/launcher.php [--lanes N] [--output-dir DIR] [--duration-seconds N] [--max-seeds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--watcher] [--triage-oracle-findings]\n";
+ echo "Usage: php tools/html-api-fuzz/launcher.php [--lanes N] [--output-dir DIR] [--duration-seconds N] [--max-seeds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source|chrome-cdp] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH|--chrome-oracle-script PATH] [--chrome-executable PATH] [--node-bin PATH] [--chrome-startup-timeout-ms N] [--max-keep-per-signature N] [--keep-all-artifacts] [--watcher] [--triage-oracle-findings]\n";
echo "Create OUTPUT_DIR/STOP (see stop.php) to stop all lanes gracefully: each finishes its current batch and exits.\n";
echo "--max-keep-per-signature is applied per lane; a signature seen in every lane keeps up to N x lanes exemplar directories.\n";
echo "--triage-oracle-findings passes oracle findings to the watcher/minimizer when --watcher is used.\n";
@@ -102,6 +102,9 @@ function html_api_fuzz_launcher_close_lane( array &$lane ): array {
html_api_fuzz_launcher_usage();
exit( 0 );
}
+if ( array_key_exists( 'timeout-ms', $options ) && true === $options['timeout-ms'] ) {
+ throw new InvalidArgumentException( 'Expected --timeout-ms to have a value.' );
+}
$repo_root = \HtmlApiFuzz\repo_root();
$output_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', $repo_root . '/artifacts/html-api-fuzz/launch-' . \HtmlApiFuzz\timestamp() );
@@ -109,6 +112,7 @@ function html_api_fuzz_launcher_close_lane( array &$lane ): array {
$start_seed = \HtmlApiFuzz\option_int( $options, 'start-seed', 1 );
$max_seeds = \HtmlApiFuzz\option_int( $options, 'max-seeds', 0 );
$duration_seconds = \HtmlApiFuzz\option_float( $options, 'duration-seconds', 60.0 );
+$timeout_explicit = array_key_exists( 'timeout-ms', $options );
$timeout_ms = \HtmlApiFuzz\option_int( $options, 'timeout-ms', 2500 );
$profile = \HtmlApiFuzz\option_string( $options, 'profile', 'auto' );
$mode = \HtmlApiFuzz\option_string( $options, 'mode', 'auto' );
@@ -142,9 +146,20 @@ function html_api_fuzz_launcher_close_lane( array &$lane ): array {
? \HtmlApiFuzz\git_metadata()
: \HtmlApiFuzz\git_metadata_from_base64( \HtmlApiFuzz\option_string( $options, 'git-metadata-base64' ) );
$git_metadata_base64 = \HtmlApiFuzz\git_metadata_base64( $git_metadata );
-$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $options );
-$oracle_metadata = $oracle_renderer->metadata();
-$oracle_worker_args = $oracle_renderer->worker_args();
+$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $options );
+$oracle_setup = \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $oracle_renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ) use ( $timeout_explicit, $timeout_ms ): array {
+ return array(
+ 'metadata' => $renderer->metadata(),
+ 'workerArgs' => $renderer->worker_args(),
+ 'timeoutMs' => $timeout_explicit ? $timeout_ms : $renderer->recommended_process_timeout_ms( 'full', 2500 ),
+ );
+ }
+);
+$oracle_metadata = $oracle_setup['metadata'];
+$oracle_worker_args = $oracle_setup['workerArgs'];
+$timeout_ms = $oracle_setup['timeoutMs'];
$state = array(
'schemaVersion' => 1,
@@ -161,6 +176,7 @@ function html_api_fuzz_launcher_close_lane( array &$lane ): array {
'maxInputBytes' => $max_input_bytes > 0 ? $max_input_bytes : null,
'git' => $git_metadata,
'oracle' => $oracle_metadata,
+ 'processTimeoutMs' => $timeout_ms,
'finished' => false,
'laneResults' => array(),
);
diff --git a/tools/html-api-fuzz/lib/ChromeOracleRenderer.php b/tools/html-api-fuzz/lib/ChromeOracleRenderer.php
new file mode 100644
index 0000000000000..21136c0cb4f7c
--- /dev/null
+++ b/tools/html-api-fuzz/lib/ChromeOracleRenderer.php
@@ -0,0 +1,940 @@
+script = $script;
+ $this->chrome_executable = $chrome_executable ?? '';
+ $this->node_executable = $node_binary;
+ $this->render_timeout_ms = $render_timeout_ms;
+ $this->startup_timeout_ms = $startup_timeout_ms;
+
+ self::$instances[] = \WeakReference::create( $this );
+ if ( ! self::$shutdown_registered ) {
+ register_shutdown_function( array( self::class, 'shutdown_all' ) );
+ self::$shutdown_registered = true;
+ }
+ }
+
+ public static function shutdown_all(): void {
+ foreach ( self::$instances as $reference ) {
+ $instance = $reference->get();
+ if ( $instance instanceof self ) {
+ $instance->close_silently();
+ }
+ }
+ self::$instances = array();
+ }
+
+ public function __destruct() {
+ $this->close_silently();
+ }
+
+ public function startup_timeout_ms(): int {
+ return $this->startup_timeout_ms;
+ }
+
+ public function render_timeout_ms(): int {
+ return $this->render_timeout_ms;
+ }
+
+ public function script(): string {
+ return $this->script;
+ }
+
+ public function chrome_executable(): string {
+ return $this->chrome_executable;
+ }
+
+ public function node_executable(): string {
+ return $this->node_executable;
+ }
+
+ public function recommended_process_timeout_ms( string $checks ): int {
+ $render_calls = 'baseline' === $checks ? 1 : 4;
+ if ( $this->render_timeout_ms > intdiv( PHP_INT_MAX, $render_calls ) ) {
+ throw new \OverflowException( 'Chrome Worker render-call budget exceeds the platform integer range.' );
+ }
+ $parts = array( $this->startup_timeout_ms, $render_calls * $this->render_timeout_ms, self::CLEANUP_TIMEOUT_MS, self::CLEANUP_HEADROOM_MS );
+ $total = 0;
+ foreach ( $parts as $part ) {
+ if ( $part < 0 || $total > PHP_INT_MAX - $part ) {
+ throw new \OverflowException( 'Chrome Worker timeout recommendation exceeds the platform integer range.' );
+ }
+ $total += $part;
+ }
+ return $total;
+ }
+
+ public function metadata(): array {
+ if ( null !== $this->metadata ) {
+ return $this->metadata;
+ }
+ try {
+ $this->prepare_paths();
+ $this->ensure_service();
+ $this->metadata = array(
+ 'schemaVersion' => 1,
+ 'kind' => OracleRenderer::KIND_CHROME_CDP,
+ 'available' => true,
+ 'identity' => $this->verified_identity,
+ 'error' => null,
+ );
+ } catch ( \Throwable $error ) {
+ $cleanup_error = $this->stop_service( false );
+ $this->metadata = array(
+ 'schemaVersion' => 1,
+ 'kind' => OracleRenderer::KIND_CHROME_CDP,
+ 'available' => false,
+ 'identity' => null,
+ 'error' => $error->getMessage() . ( null === $cleanup_error ? '' : '; cleanup failed: ' . $cleanup_error ),
+ );
+ }
+ return $this->metadata;
+ }
+
+ public function render( string $html, string $mode, array $limits, string $fragment_context ): array {
+ $metadata = $this->metadata();
+ if ( true !== $metadata['available'] ) {
+ return $this->infrastructure_result( 'Chrome CDP oracle is unavailable: ' . (string) $metadata['error'] );
+ }
+ if ( strlen( $html ) > self::MAX_INPUT_BYTES ) {
+ return array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'failureClass' => 'input-byte-limit-exceeded',
+ 'error' => 'Chrome oracle input exceeded the exact 2 MiB byte limit.',
+ 'nodeCount' => 0,
+ 'oracle' => $metadata,
+ );
+ }
+ $max_nodes = self::positive_limit( $limits, 'maxNodes', 3000, 100000 );
+ $max_depth = self::positive_limit( $limits, 'maxDepth', 512, 1024 );
+ $max_tree_bytes = self::positive_limit( $limits, 'maxTreeBytes', 16777216, 16777216 );
+ $started_at = hrtime( true );
+ try {
+ $this->ensure_service();
+ $before = $this->local_snapshot();
+ $request = array(
+ 'command' => 'render',
+ 'htmlBase64' => base64_encode( $html ),
+ 'mode' => $mode,
+ 'maxNodes' => $max_nodes,
+ 'maxDepth' => $max_depth,
+ 'maxTreeBytes' => $max_tree_bytes,
+ );
+ if ( Generator::MODE_FRAGMENT_BODY === $mode ) {
+ $request['context'] = $fragment_context;
+ }
+ $response = $this->request(
+ $request,
+ $this->render_timeout_ms
+ );
+ $after = $this->local_snapshot();
+ if ( ! hash_equals( self::canonical_json( $before ), self::canonical_json( $after ) ) ) {
+ throw new \RuntimeException( 'Chrome oracle trust inputs changed during rendering.' );
+ }
+ $result = $this->validate_render_response( $response, $max_nodes, $max_tree_bytes );
+ $result['oracle'] = $metadata;
+ $result['process'] = array(
+ 'durationMs' => round( max( 0, hrtime( true ) - $started_at ) / 1000000, 3 ),
+ 'persistent' => true,
+ );
+ return $result;
+ } catch ( \Throwable $error ) {
+ $cleanup_error = $this->stop_service( false );
+ return $this->infrastructure_result(
+ $error->getMessage() . ( null === $cleanup_error ? '' : '; cleanup failed: ' . $cleanup_error ),
+ round( max( 0, hrtime( true ) - $started_at ) / 1000000, 3 )
+ );
+ }
+ }
+
+ public function close(): void {
+ $error = $this->stop_service( true );
+ if ( null !== $error ) {
+ throw new \RuntimeException( $error );
+ }
+ }
+
+ private function close_silently(): void {
+ try {
+ $this->close();
+ } catch ( \Throwable $ignored ) {
+ // Explicit owners surface cleanup failures before completing work.
+ }
+ }
+
+ private function ensure_service(): void {
+ if ( is_resource( $this->process ) ) {
+ $status = proc_get_status( $this->process );
+ if ( $status['running'] ) {
+ return;
+ }
+ $cleanup_error = $this->stop_service( false );
+ if ( null !== $cleanup_error ) {
+ throw new \RuntimeException( 'Dead Chrome service cleanup failed: ' . $cleanup_error );
+ }
+ }
+
+ $before = $this->local_snapshot();
+ $command = array( $this->node_executable, $this->script, '--serve', '--engine', 'chrome', '--chrome-executable', $this->chrome_executable );
+ $spec = array(
+ 0 => array( 'pipe', 'r' ),
+ 1 => array( 'pipe', 'w' ),
+ 2 => array( 'pipe', 'w' ),
+ );
+ $this->process = @proc_open( $command, $spec, $this->pipes, repo_root(), null, array( 'bypass_shell' => true ) );
+ if ( ! is_resource( $this->process ) ) {
+ $this->process = null;
+ $this->pipes = array();
+ throw new \RuntimeException( 'Could not start the Chrome oracle service.' );
+ }
+ foreach ( $this->pipes as $pipe ) {
+ stream_set_blocking( $pipe, false );
+ }
+ stream_set_write_buffer( $this->pipes[0], 0 );
+ $this->stdout = '';
+ $this->stderr_tail = '';
+ $this->stderr_bytes = 0;
+ $this->stderr_overflow = false;
+ $this->observed_exit_code = null;
+
+ $response = $this->request( array( 'command' => 'version' ), $this->startup_timeout_ms );
+ if ( ! self::exact_keys( $response, array( 'id', 'status', 'oracle' ) ) || 'ok' !== $response['status'] ) {
+ throw new \RuntimeException( 'Chrome service returned an invalid version response.' );
+ }
+ $identity = $this->validate_oracle( $response['oracle'], true );
+ if ( null !== $this->verified_identity && ! hash_equals( self::canonical_json( $this->verified_identity ), self::canonical_json( $identity ) ) ) {
+ throw new \RuntimeException( 'Restarted Chrome service identity differs from its verified identity.' );
+ }
+ $this->verified_identity = $identity;
+ $after = $this->local_snapshot();
+ if ( ! hash_equals( self::canonical_json( $before ), self::canonical_json( $after ) ) ) {
+ throw new \RuntimeException( 'Chrome oracle trust inputs changed during startup.' );
+ }
+ }
+
+ private function request( array $request, int $timeout_ms, bool $allow_exit_after_response = false ): array {
+ if ( ! is_resource( $this->process ) || 3 !== count( $this->pipes ) ) {
+ throw new \RuntimeException( 'Chrome oracle service is not running.' );
+ }
+ $this->drain_available();
+ if ( '' !== $this->stdout ) {
+ throw new \RuntimeException( 'Chrome oracle emitted an unsolicited response frame.' );
+ }
+ if ( $this->stderr_overflow ) {
+ throw new \RuntimeException( 'Chrome oracle stderr exceeded 1 MiB.' );
+ }
+
+ $request['id'] = ++$this->request_id;
+ $encoded = json_encode( $request, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ) . "\n";
+ if ( strlen( $encoded ) > 4194304 ) {
+ throw new \RuntimeException( 'Chrome request frame exceeded 4 MiB.' );
+ }
+ $offset = 0;
+ $deadline = microtime( true ) + ( $timeout_ms / 1000 );
+ while ( true ) {
+ if ( microtime( true ) >= $deadline ) {
+ throw new \RuntimeException( 'Chrome oracle request timed out.' );
+ }
+ $read = array( $this->pipes[1], $this->pipes[2] );
+ $write = $offset < strlen( $encoded ) ? array( $this->pipes[0] ) : array();
+ $except = array();
+ $remaining_us = max( 1, min( 20000, (int) floor( ( $deadline - microtime( true ) ) * 1000000 ) ) );
+ @stream_select( $read, $write, $except, 0, $remaining_us );
+ if ( ! empty( $write ) ) {
+ $written = @fwrite( $this->pipes[0], substr( $encoded, $offset, 65536 ) );
+ if ( false === $written ) {
+ throw new \RuntimeException( 'Could not write the Chrome oracle request.' );
+ }
+ $offset += $written;
+ }
+ $this->drain_available();
+ if ( $this->stderr_overflow ) {
+ throw new \RuntimeException( 'Chrome oracle stderr exceeded 1 MiB.' );
+ }
+ $newline = strpos( $this->stdout, "\n" );
+ if ( false !== $newline ) {
+ $line = substr( $this->stdout, 0, $newline );
+ $trailing = substr( $this->stdout, $newline + 1 );
+ $this->stdout = '';
+ if ( '' !== $trailing ) {
+ throw new \RuntimeException( 'Chrome oracle emitted trailing response bytes.' );
+ }
+ $decoded = StrictJsonParser::decode( $line );
+ if ( ! is_array( $decoded ) || ( $decoded['id'] ?? null ) !== $request['id'] ) {
+ throw new \RuntimeException( 'Chrome oracle response id or schema is invalid.' );
+ }
+ $status = proc_get_status( $this->process );
+ $this->remember_exit_code( $status );
+ if ( ! $allow_exit_after_response && ! $status['running'] ) {
+ throw new \RuntimeException( 'Chrome oracle service exited while returning a response.' );
+ }
+ return $decoded;
+ }
+ $status = proc_get_status( $this->process );
+ $this->remember_exit_code( $status );
+ if ( ! $status['running'] ) {
+ throw new \RuntimeException( 'Chrome oracle service exited before returning a response.' );
+ }
+ }
+ }
+
+ private function drain_available(): void {
+ if ( ! isset( $this->pipes[1], $this->pipes[2] ) ) {
+ return;
+ }
+ while ( true ) {
+ $chunk = @fread( $this->pipes[1], 65536 );
+ if ( false === $chunk || '' === $chunk ) {
+ break;
+ }
+ $this->stdout .= $chunk;
+ if ( strlen( $this->stdout ) > self::MAX_FRAME_BYTES ) {
+ throw new \RuntimeException( 'Chrome oracle stdout frame exceeded 24 MiB.' );
+ }
+ }
+ while ( true ) {
+ $chunk = @fread( $this->pipes[2], 65536 );
+ if ( false === $chunk || '' === $chunk ) {
+ break;
+ }
+ $this->stderr_bytes += strlen( $chunk );
+ $this->stderr_tail = substr( $this->stderr_tail . $chunk, -self::MAX_STDERR_BYTES );
+ if ( $this->stderr_bytes > self::MAX_STDERR_BYTES ) {
+ $this->stderr_overflow = true;
+ }
+ }
+ }
+
+ private function validate_render_response( array $response, int $max_nodes, int $max_tree_bytes ): array {
+ $status = $response['status'] ?? null;
+ if ( ! is_string( $status ) || ! is_array( $response['oracle'] ?? null ) ) {
+ throw new \RuntimeException( 'Chrome result is missing its status or oracle identity.' );
+ }
+ $identity = $this->validate_oracle( $response['oracle'], 'ok' === $status || 'unsupported' === $status || 'limit' === $status );
+ if ( ! hash_equals( self::canonical_json( $this->verified_identity ), self::canonical_json( $identity ) ) ) {
+ throw new \RuntimeException( 'Chrome render identity differs from its verified identity.' );
+ }
+ if ( 'ok' === $status ) {
+ if ( ! self::exact_keys( $response, array( 'id', 'status', 'oracle', 'treeBase64', 'treeBytes', 'treeSha256', 'nodeCount' ) ) ) {
+ throw new \RuntimeException( 'Chrome ok result has an invalid exact schema.' );
+ }
+ $tree = is_string( $response['treeBase64'] ) ? base64_decode( $response['treeBase64'], true ) : false;
+ if (
+ false === $tree ||
+ base64_encode( $tree ) !== $response['treeBase64'] ||
+ ! is_int( $response['treeBytes'] ) || strlen( $tree ) !== $response['treeBytes'] || strlen( $tree ) > $max_tree_bytes ||
+ ! is_string( $response['treeSha256'] ) || ! hash_equals( hash( 'sha256', $tree ), $response['treeSha256'] ) ||
+ ! is_int( $response['nodeCount'] ) || $response['nodeCount'] < 0 || $response['nodeCount'] > $max_nodes ||
+ 1 !== preg_match( '//u', $tree )
+ ) {
+ throw new \RuntimeException( 'Chrome canonical tree bytes are invalid.' );
+ }
+ return array( 'status' => TreeRenderer::STATUS_OK, 'tree' => $tree, 'nodeCount' => $response['nodeCount'] );
+ }
+ if ( 'unsupported' === $status ) {
+ if (
+ ! self::exact_keys( $response, array( 'id', 'status', 'failureClass', 'unsupported', 'oracle' ) ) ||
+ 'invalid-utf8' !== $response['failureClass'] ||
+ ! is_array( $response['unsupported'] ) ||
+ ! self::exact_keys( $response['unsupported'], array( 'reason' ) ) ||
+ 'invalid-utf8' !== $response['unsupported']['reason']
+ ) {
+ throw new \RuntimeException( 'Chrome unsupported result has an invalid exact schema.' );
+ }
+ return array(
+ 'status' => TreeRenderer::STATUS_UNSUPPORTED,
+ 'failureClass' => 'invalid-utf8',
+ 'unsupported' => array( 'message' => 'Chrome rejected invalid UTF-8 input.' ),
+ 'nodeCount' => 0,
+ );
+ }
+ if ( 'limit' === $status || ( 'error' === $status && array_key_exists( 'nodeCount', $response ) ) ) {
+ if (
+ ! self::exact_keys( $response, array( 'id', 'status', 'failureClass', 'error', 'nodeCount', 'treeBytes', 'oracle' ) ) ||
+ ! is_string( $response['failureClass'] ) ||
+ ! is_string( $response['error'] ) ||
+ ! is_int( $response['nodeCount'] ) || $response['nodeCount'] < 0 || $response['nodeCount'] > $max_nodes + 1 ||
+ ! is_int( $response['treeBytes'] ) || $response['treeBytes'] < 0 || $response['treeBytes'] > $max_tree_bytes
+ ) {
+ throw new \RuntimeException( 'Chrome non-success renderer result has an invalid exact schema.' );
+ }
+ $limits = array( 'node-limit-exceeded', 'depth-limit-exceeded', 'tree-byte-limit-exceeded' );
+ if ( 'limit' === $status && ! in_array( $response['failureClass'], $limits, true ) ) {
+ throw new \RuntimeException( 'Chrome returned an untrusted resource-limit class.' );
+ }
+ if ( 'error' === $status && 'oracle-renderer-error' !== $response['failureClass'] ) {
+ throw new \RuntimeException( 'Chrome returned an untrusted renderer error class.' );
+ }
+ $result = array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'failureClass' => $response['failureClass'],
+ 'error' => $response['error'],
+ 'nodeCount' => $response['nodeCount'],
+ );
+ if ( 'error' === $status ) {
+ $result['infrastructure'] = true;
+ }
+ return $result;
+ }
+ if (
+ 'error' !== $status ||
+ ! self::exact_keys( $response, array( 'id', 'status', 'failureClass', 'error', 'oracle' ) ) ||
+ ! is_string( $response['failureClass'] ) ||
+ ! in_array( $response['failureClass'], array( 'oracle-infrastructure-failure', 'oracle-infrastructure-timeout', 'oracle-evaluation-timeout', 'oracle-renderer-error', 'protocol-error' ), true ) ||
+ ! is_string( $response['error'] )
+ ) {
+ throw new \RuntimeException( 'Chrome infrastructure result has an invalid exact schema.' );
+ }
+ return array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'failureClass' => 'oracle-renderer-error',
+ 'error' => $response['error'],
+ 'infrastructure' => true,
+ );
+ }
+
+ private function validate_oracle( $oracle, bool $require_available, bool $validate_transport = true ): array {
+ if ( ! is_array( $oracle ) || ! self::exact_keys( $oracle, array( 'kind', 'engine', 'available', 'identity', 'transport' ) ) ) {
+ throw new \RuntimeException( 'Chrome oracle metadata envelope has an invalid exact schema.' );
+ }
+ if (
+ OracleRenderer::KIND_CHROME_CDP !== $oracle['kind'] ||
+ 'chrome' !== $oracle['engine'] ||
+ ! is_bool( $oracle['available'] ) ||
+ ( $require_available && true !== $oracle['available'] ) ||
+ ! is_array( $oracle['identity'] ) ||
+ ! is_array( $oracle['transport'] )
+ ) {
+ throw new \RuntimeException( 'Chrome oracle metadata envelope values are invalid.' );
+ }
+ $local = $this->local_snapshot();
+ $identity = $oracle['identity'];
+ $identity_keys = array(
+ 'schemaVersion', 'kind', 'platform', 'pinnedChromeVersion', 'chromeArchiveSha256',
+ 'expectedChromeExecutableSha256', 'chromeExecutableSha256', 'oracleScriptSha256',
+ 'fragmentContextsSha256', 'fragmentContexts', 'nodeExecutableSha256', 'nodeVersion',
+ 'chromeVersion', 'cdpProtocolVersion',
+ );
+ if ( ! self::exact_keys( $identity, $identity_keys ) ) {
+ throw new \RuntimeException( 'Chrome durable identity has an invalid exact schema.' );
+ }
+ $expected = array(
+ 'schemaVersion' => 1,
+ 'kind' => OracleRenderer::KIND_CHROME_CDP,
+ 'platform' => $local['platform'],
+ 'pinnedChromeVersion' => $local['pinnedChromeVersion'],
+ 'chromeArchiveSha256' => $local['chromeArchiveSha256'],
+ 'expectedChromeExecutableSha256' => $local['expectedChromeExecutableSha256'],
+ 'chromeExecutableSha256' => $local['chromeExecutableSha256'],
+ 'oracleScriptSha256' => $local['oracleScriptSha256'],
+ 'fragmentContextsSha256' => $local['fragmentContextsSha256'],
+ 'fragmentContexts' => $local['fragmentContexts'],
+ 'nodeExecutableSha256' => $local['nodeExecutableSha256'],
+ 'nodeVersion' => $identity['nodeVersion'] ?? null,
+ 'chromeVersion' => $local['pinnedChromeVersion'],
+ 'cdpProtocolVersion' => $identity['cdpProtocolVersion'] ?? null,
+ );
+ if (
+ ! is_string( $expected['nodeVersion'] ) || 1 !== preg_match( '/^v[0-9]+(?:\.[0-9]+){2}(?:[-+][0-9A-Za-z.-]+)?$/D', $expected['nodeVersion'] ) ||
+ ! is_string( $expected['cdpProtocolVersion'] ) || '' === $expected['cdpProtocolVersion'] ||
+ ! hash_equals( self::canonical_json( $expected ), self::canonical_json( $identity ) )
+ ) {
+ throw new \RuntimeException( 'Chrome durable identity does not match local trust anchors.' );
+ }
+ if ( $validate_transport ) {
+ $this->validate_transport( $oracle['transport'] );
+ }
+ return $expected;
+ }
+
+ private function validate_transport( array $transport ): void {
+ $keys = array(
+ 'replayExcluded', 'ownerPid', 'chromeExecutablePath', 'oracleScriptPath', 'nodeExecutablePath',
+ 'runtimeRoot', 'profilePath', 'debugEndpoint', 'supervisorPid', 'browserPid', 'browserInstance',
+ );
+ if ( ! self::exact_keys( $transport, $keys ) ) {
+ throw new \RuntimeException( 'Chrome transport metadata has an invalid exact schema.' );
+ }
+ $status = is_resource( $this->process ) ? proc_get_status( $this->process ) : array();
+ $owner_pid = (int) ( $status['pid'] ?? 0 );
+ $base = realpath( sys_get_temp_dir() );
+ $root = $transport['runtimeRoot'] ?? null;
+ $resolved_root = is_string( $root ) ? realpath( $root ) : false;
+ if (
+ true !== $transport['replayExcluded'] ||
+ $owner_pid < 2 || $owner_pid !== $transport['ownerPid'] ||
+ $this->chrome_executable !== ( realpath( $transport['chromeExecutablePath'] ?? '' ) ?: null ) ||
+ $this->script !== ( realpath( $transport['oracleScriptPath'] ?? '' ) ?: null ) ||
+ $this->node_executable !== ( realpath( $transport['nodeExecutablePath'] ?? '' ) ?: null ) ||
+ ! is_string( $base ) || ! is_string( $root ) || ! is_string( $resolved_root ) || 1 !== preg_match( '#^' . preg_quote( $base, '#' ) . '/html-api-fuzz-chrome-' . $owner_pid . '-[0-9a-f]{32}$#D', $resolved_root ) ||
+ $root . '/profile' !== ( $transport['profilePath'] ?? null ) ||
+ ! is_string( $transport['debugEndpoint'] ) || 1 !== preg_match( '#^ws://127\.0\.0\.1:[0-9]+/#', $transport['debugEndpoint'] ) ||
+ ! is_int( $transport['supervisorPid'] ) || $transport['supervisorPid'] < 2 ||
+ ! is_int( $transport['browserPid'] ) || $transport['browserPid'] < 2 ||
+ ! is_int( $transport['browserInstance'] ) || $transport['browserInstance'] < 1
+ ) {
+ throw new \RuntimeException( 'Chrome transport metadata values are invalid.' );
+ }
+ $stat = @lstat( $root );
+ if ( false === $stat || ( $stat['mode'] & 0170000 ) !== 0040000 || ( $stat['mode'] & 0777 ) !== 0700 || ( function_exists( 'posix_geteuid' ) && $stat['uid'] !== posix_geteuid() ) ) {
+ throw new \RuntimeException( 'Chrome runtime root ownership is invalid.' );
+ }
+ foreach ( $this->runtime_roots as $previous ) {
+ if ( $previous !== $root && false !== @lstat( $previous ) ) {
+ throw new \RuntimeException( 'A replaced Chrome runtime root still exists.' );
+ }
+ }
+ $this->runtime_roots[ $root ] = $root;
+ }
+
+ private function prepare_paths(): void {
+ $this->script = self::resolve_regular_file( $this->script, false );
+ $this->node_executable = self::resolve_node_executable( $this->node_executable );
+ $local = $this->local_snapshot( true );
+ if ( '' === $this->chrome_executable ) {
+ $trust_dir = repo_root() . '/tools/html-api-fuzz/oracles/chrome';
+ $install_root = getenv( 'HTML_API_FUZZ_CHROME_INSTALL_ROOT' );
+ if ( ! is_string( $install_root ) || '' === $install_root ) {
+ $install_root = $trust_dir . '/.chrome-for-testing';
+ }
+ $archive_dir = str_starts_with( $local['platform'], 'mac-' ) ? 'chrome-' . $local['platform'] : 'chrome-linux64';
+ $relative = str_starts_with( $local['platform'], 'mac-' )
+ ? 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ : 'chrome';
+ $this->chrome_executable = $install_root . '/' . $local['pinnedChromeVersion'] . '/' . $local['platform'] . '/' . $archive_dir . '/' . $relative;
+ }
+ $this->chrome_executable = self::resolve_regular_file( $this->chrome_executable, true );
+ $this->local_snapshot();
+ }
+
+ private function local_snapshot( bool $without_chrome = false ): array {
+ $trust_dir = repo_root() . '/tools/html-api-fuzz/oracles/chrome';
+ $version_raw = self::read_stable_file( $trust_dir . '/VERSION', 128 );
+ if ( 1 !== preg_match( '/^[0-9]+(?:\.[0-9]+){3}\n$/D', $version_raw ) ) {
+ throw new \RuntimeException( 'Chrome VERSION is not canonical.' );
+ }
+ $version = substr( $version_raw, 0, -1 );
+ $platform = self::platform();
+ $archive_key = 'chrome-' . $version . '-' . $platform . '.zip';
+ $archive_hash = self::manifest_digest( $trust_dir . '/SHA256SUMS', $archive_key );
+ $executable_hash = self::manifest_digest( $trust_dir . '/EXECUTABLE_SHA256SUMS', $platform . '.executable' );
+ $contexts_raw = self::read_stable_file( repo_root() . '/tools/html-api-fuzz/oracles/fragment-contexts.json', 65536 );
+ $contexts = StrictJsonParser::decode( $contexts_raw );
+ if ( ! is_array( $contexts ) || $contexts !== Generator::fragment_contexts() ) {
+ throw new \RuntimeException( 'Chrome fragment contexts differ from the generator contexts.' );
+ }
+ $snapshot = array(
+ 'platform' => $platform,
+ 'pinnedChromeVersion' => $version,
+ 'chromeArchiveSha256' => $archive_hash,
+ 'expectedChromeExecutableSha256' => $executable_hash,
+ 'oracleScriptSha256' => self::hash_stable_file( $this->script, false ),
+ 'fragmentContextsSha256' => hash( 'sha256', $contexts_raw ),
+ 'fragmentContexts' => $contexts,
+ 'nodeExecutableSha256' => self::hash_stable_file( $this->node_executable, true ),
+ 'rawTrustSha256' => array(
+ 'VERSION' => hash( 'sha256', $version_raw ),
+ 'SHA256SUMS' => hash( 'sha256', self::read_stable_file( $trust_dir . '/SHA256SUMS', 65536 ) ),
+ 'EXECUTABLE_SHA256SUMS' => hash( 'sha256', self::read_stable_file( $trust_dir . '/EXECUTABLE_SHA256SUMS', 65536 ) ),
+ ),
+ );
+ if ( ! $without_chrome ) {
+ $actual = self::hash_stable_file( $this->chrome_executable, true );
+ if ( ! hash_equals( $executable_hash, $actual ) ) {
+ throw new \RuntimeException( 'Chrome executable does not match its checked-in SHA-256.' );
+ }
+ $snapshot['chromeExecutableSha256'] = $actual;
+ }
+ return $snapshot;
+ }
+
+ private function stop_service( bool $require_acknowledgement ): ?string {
+ $deadline = microtime( true ) + ( self::CLEANUP_TIMEOUT_MS / 1000 );
+ if ( ! is_resource( $this->process ) ) {
+ $this->process = null;
+ $this->pipes = array();
+ return $this->wait_for_runtime_roots_absent( $deadline );
+ }
+ $errors = array();
+ $status = proc_get_status( $this->process );
+ $this->remember_exit_code( $status );
+ $acknowledged = false;
+ if ( $status['running'] ) {
+ try {
+ $remaining_ms = max( 1, (int) floor( ( $deadline - microtime( true ) ) * 1000 ) );
+ $response = $this->request( array( 'command' => 'shutdown' ), min( 2000, $remaining_ms ), true );
+ if (
+ ! self::exact_keys( $response, array( 'id', 'status', 'oracle', 'shutdown' ) ) ||
+ 'ok' !== $response['status'] || true !== $response['shutdown']
+ ) {
+ throw new \RuntimeException( 'Chrome shutdown acknowledgement is invalid.' );
+ }
+ $this->validate_oracle( $response['oracle'], false, false );
+ $acknowledged = true;
+ } catch ( \Throwable $error ) {
+ if ( $require_acknowledgement ) {
+ $errors[] = $error->getMessage();
+ }
+ }
+ }
+ if ( $require_acknowledgement && ! $acknowledged && empty( $errors ) ) {
+ $errors[] = 'Chrome oracle service exited before its shutdown acknowledgement.';
+ }
+ if ( isset( $this->pipes[0] ) && is_resource( $this->pipes[0] ) ) {
+ fclose( $this->pipes[0] );
+ }
+ $status = $this->wait_for_exit( min( $deadline, microtime( true ) + 5.0 ) );
+ if ( $status['running'] ) {
+ @proc_terminate( $this->process, SIGTERM );
+ $status = $this->wait_for_exit( min( $deadline, microtime( true ) + 2.0 ) );
+ }
+ if ( $status['running'] ) {
+ @proc_terminate( $this->process, SIGKILL );
+ $status = $this->wait_for_exit( $deadline );
+ }
+ if ( $status['running'] ) {
+ $errors[] = 'Exact Chrome oracle child survived SIGKILL deadline.';
+ }
+ try {
+ $this->drain_available();
+ } catch ( \Throwable $error ) {
+ $errors[] = $error->getMessage();
+ }
+ foreach ( array( 1, 2 ) as $descriptor ) {
+ if ( isset( $this->pipes[ $descriptor ] ) && is_resource( $this->pipes[ $descriptor ] ) ) {
+ fclose( $this->pipes[ $descriptor ] );
+ }
+ }
+ $close_code = @proc_close( $this->process );
+ $this->remember_exit_code( $status );
+ $observed_code = null !== $this->observed_exit_code ? $this->observed_exit_code : $close_code;
+ if ( $acknowledged && 0 !== $observed_code ) {
+ $errors[] = 'Acknowledged Chrome oracle service exited nonzero.';
+ }
+ if ( '' !== $this->stdout ) {
+ $errors[] = 'Chrome oracle emitted bytes after its shutdown acknowledgement.';
+ }
+ $this->process = null;
+ $this->pipes = array();
+ $this->stdout = '';
+ $this->observed_exit_code = null;
+ if ( $this->stderr_overflow ) {
+ $errors[] = 'Chrome oracle stderr exceeded 1 MiB.';
+ }
+ $root_error = $this->wait_for_runtime_roots_absent( $deadline );
+ if ( null !== $root_error ) {
+ $errors[] = $root_error;
+ }
+ return empty( $errors ) ? null : implode( '; ', array_unique( $errors ) );
+ }
+
+ private function wait_for_exit( float $deadline ): array {
+ $status = proc_get_status( $this->process );
+ $this->remember_exit_code( $status );
+ while ( $status['running'] && microtime( true ) < $deadline ) {
+ try {
+ $this->drain_available();
+ } catch ( \Throwable $ignored ) {
+ $this->stderr_overflow = true;
+ }
+ $remaining_us = (int) floor( ( $deadline - microtime( true ) ) * 1000000 );
+ if ( $remaining_us > 0 ) {
+ usleep( min( 10000, $remaining_us ) );
+ }
+ $status = proc_get_status( $this->process );
+ $this->remember_exit_code( $status );
+ }
+ return $status;
+ }
+
+ private function remember_exit_code( array $status ): void {
+ if ( ! $status['running'] && is_int( $status['exitcode'] ?? null ) && $status['exitcode'] >= 0 ) {
+ $this->observed_exit_code = $status['exitcode'];
+ }
+ }
+
+ private function wait_for_runtime_roots_absent( float $deadline ): ?string {
+ foreach ( $this->runtime_roots as $root ) {
+ while ( true ) {
+ clearstatcache( true, $root );
+ if ( false === @lstat( $root ) ) {
+ continue 2;
+ }
+ $remaining_us = (int) floor( ( $deadline - microtime( true ) ) * 1000000 );
+ if ( $remaining_us <= 0 ) {
+ break;
+ }
+ usleep( min( 10000, $remaining_us ) );
+ }
+ return 'Authenticated Chrome runtime root survived cleanup: ' . $root;
+ }
+ $this->runtime_roots = array();
+ return null;
+ }
+
+ private function infrastructure_result( string $message, ?float $duration_ms = null ): array {
+ $result = array(
+ 'status' => TreeRenderer::STATUS_ERROR,
+ 'failureClass' => 'oracle-renderer-error',
+ 'error' => $message,
+ 'infrastructure' => true,
+ 'oracle' => $this->metadata ?? array(
+ 'schemaVersion' => 1,
+ 'kind' => OracleRenderer::KIND_CHROME_CDP,
+ 'available' => false,
+ 'identity' => null,
+ 'error' => $message,
+ ),
+ );
+ if ( null !== $duration_ms ) {
+ $result['process'] = array(
+ 'durationMs' => $duration_ms,
+ 'persistent' => true,
+ 'stderrTail' => $this->stderr_tail,
+ );
+ }
+ return $result;
+ }
+
+ private static function platform(): string {
+ $machine = strtolower( php_uname( 'm' ) );
+ if ( 'Darwin' === PHP_OS_FAMILY && in_array( $machine, array( 'arm64', 'aarch64' ), true ) ) {
+ return 'mac-arm64';
+ }
+ if ( 'Darwin' === PHP_OS_FAMILY && in_array( $machine, array( 'x86_64', 'amd64' ), true ) ) {
+ return 'mac-x64';
+ }
+ if ( 'Linux' === PHP_OS_FAMILY && in_array( $machine, array( 'x86_64', 'amd64' ), true ) ) {
+ return 'linux64';
+ }
+ throw new \RuntimeException( 'Chrome is unsupported on ' . PHP_OS_FAMILY . '/' . $machine . '.' );
+ }
+
+ private static function manifest_digest( string $path, string $key ): string {
+ $matches = array();
+ foreach ( preg_split( '/\r?\n/', self::read_stable_file( $path, 65536 ) ) as $line ) {
+ $line = trim( $line );
+ if ( '' === $line || str_starts_with( $line, '#' ) ) {
+ continue;
+ }
+ $fields = preg_split( '/\s+/', $line );
+ if ( 2 === count( $fields ) && $key === $fields[1] ) {
+ $matches[] = $fields[0];
+ }
+ }
+ if ( 1 !== count( $matches ) || 1 !== preg_match( '/^[0-9a-f]{64}$/D', $matches[0] ) ) {
+ throw new \RuntimeException( 'Chrome checksum manifest is missing one exact ' . $key . ' record.' );
+ }
+ return $matches[0];
+ }
+
+ private static function resolve_executable( string $command ): string {
+ if ( str_contains( $command, DIRECTORY_SEPARATOR ) ) {
+ $path = str_starts_with( $command, DIRECTORY_SEPARATOR ) ? $command : repo_root() . DIRECTORY_SEPARATOR . $command;
+ return self::resolve_regular_file( $path, true );
+ }
+ foreach ( explode( PATH_SEPARATOR, (string) getenv( 'PATH' ) ) as $directory ) {
+ if ( '' === $directory ) {
+ continue;
+ }
+ $path = $directory . DIRECTORY_SEPARATOR . $command;
+ if ( is_file( $path ) && is_executable( $path ) ) {
+ return self::resolve_regular_file( $path, true );
+ }
+ }
+ throw new \RuntimeException( 'Could not resolve executable command: ' . $command );
+ }
+
+ private static function resolve_node_executable( string $command ): string {
+ $launch = str_contains( $command, DIRECTORY_SEPARATOR )
+ ? self::resolve_executable( $command )
+ : $command;
+ $spec = array(
+ 0 => array( 'pipe', 'r' ),
+ 1 => array( 'pipe', 'w' ),
+ 2 => array( 'pipe', 'w' ),
+ );
+ $process = @proc_open( array( $launch, '-p', 'process.execPath' ), $spec, $pipes, repo_root(), null, array( 'bypass_shell' => true ) );
+ if ( ! is_resource( $process ) ) {
+ throw new \RuntimeException( 'Could not probe the selected Node command.' );
+ }
+ fclose( $pipes[0] );
+ stream_set_blocking( $pipes[1], false );
+ stream_set_blocking( $pipes[2], false );
+ $stdout = '';
+ $stderr = '';
+ $deadline = microtime( true ) + 5.0;
+ $status = proc_get_status( $process );
+ while ( $status['running'] && microtime( true ) < $deadline ) {
+ $stdout .= (string) stream_get_contents( $pipes[1] );
+ $stderr .= (string) stream_get_contents( $pipes[2] );
+ if ( strlen( $stdout ) > 4096 || strlen( $stderr ) > 4096 ) {
+ break;
+ }
+ usleep( 10000 );
+ $status = proc_get_status( $process );
+ }
+ if ( $status['running'] ) {
+ @proc_terminate( $process, SIGTERM );
+ usleep( 100000 );
+ $status = proc_get_status( $process );
+ if ( $status['running'] ) {
+ @proc_terminate( $process, SIGKILL );
+ }
+ }
+ $stdout .= (string) stream_get_contents( $pipes[1] );
+ $stderr .= (string) stream_get_contents( $pipes[2] );
+ fclose( $pipes[1] );
+ fclose( $pipes[2] );
+ $code = proc_close( $process );
+ $path = rtrim( $stdout, "\r\n" );
+ if ( 0 !== $code || '' === $path || str_contains( $path, "\n" ) || strlen( $path ) > 4096 || '' !== trim( $stderr ) ) {
+ throw new \RuntimeException( 'Selected Node command did not return one quiet process.execPath.' );
+ }
+ return self::resolve_regular_file( $path, true );
+ }
+
+ private static function resolve_regular_file( string $path, bool $executable ): string {
+ clearstatcache( true, $path );
+ $resolved = realpath( $path );
+ $stat = false === $resolved ? false : @lstat( $resolved );
+ if ( false === $resolved || false === $stat || ( $stat['mode'] & 0170000 ) !== 0100000 || ( $executable && ! is_executable( $resolved ) ) ) {
+ throw new \RuntimeException( 'Required Chrome identity path is not a trusted regular file: ' . $path );
+ }
+ return $resolved;
+ }
+
+ private static function read_stable_file( string $path, int $maximum ): string {
+ $resolved = self::resolve_regular_file( $path, false );
+ $handle = @fopen( $resolved, 'rb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( 'Could not open Chrome identity file.' );
+ }
+ $before = fstat( $handle );
+ $contents = '';
+ try {
+ while ( ! feof( $handle ) ) {
+ $chunk = fread( $handle, min( 65536, $maximum + 1 - strlen( $contents ) ) );
+ if ( false === $chunk ) {
+ throw new \RuntimeException( 'Could not read Chrome identity file.' );
+ }
+ $contents .= $chunk;
+ if ( strlen( $contents ) > $maximum ) {
+ throw new \RuntimeException( 'Chrome identity file exceeded its byte limit.' );
+ }
+ }
+ $after = fstat( $handle );
+ } finally {
+ fclose( $handle );
+ }
+ clearstatcache( true, $resolved );
+ $path_after = @lstat( $resolved );
+ self::assert_same_stat( $before, $after, $path_after, 'read' );
+ return $contents;
+ }
+
+ private static function hash_stable_file( string $path, bool $executable ): string {
+ $resolved = self::resolve_regular_file( $path, $executable );
+ $handle = @fopen( $resolved, 'rb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( 'Could not open Chrome identity file for hashing.' );
+ }
+ $before = fstat( $handle );
+ $hash = hash_init( 'sha256' );
+ try {
+ while ( ! feof( $handle ) ) {
+ $chunk = fread( $handle, 1048576 );
+ if ( false === $chunk ) {
+ throw new \RuntimeException( 'Could not hash Chrome identity file.' );
+ }
+ if ( '' !== $chunk ) {
+ hash_update( $hash, $chunk );
+ }
+ }
+ $after = fstat( $handle );
+ } finally {
+ fclose( $handle );
+ }
+ clearstatcache( true, $resolved );
+ $path_after = @lstat( $resolved );
+ self::assert_same_stat( $before, $after, $path_after, 'hash' );
+ return hash_final( $hash );
+ }
+
+ private static function assert_same_stat( $before, $after, $path_after, string $operation ): void {
+ foreach ( array( 'dev', 'ino', 'mode', 'uid', 'size', 'mtime', 'ctime' ) as $field ) {
+ if (
+ ! is_array( $before ) || ! is_array( $after ) || ! is_array( $path_after ) ||
+ ( $before[ $field ] ?? null ) !== ( $after[ $field ] ?? null ) ||
+ ( $after[ $field ] ?? null ) !== ( $path_after[ $field ] ?? null )
+ ) {
+ throw new \RuntimeException( 'Chrome identity file changed during ' . $operation . '.' );
+ }
+ }
+ }
+
+ private static function positive_limit( array $limits, string $key, int $default, int $maximum ): int {
+ $value = $limits[ $key ] ?? $default;
+ if ( ! is_int( $value ) || $value < 1 || $value > $maximum ) {
+ throw new \InvalidArgumentException( $key . ' must be between 1 and ' . $maximum . ' for Chrome.' );
+ }
+ return $value;
+ }
+
+ private static function canonical_json( $value ): string {
+ return json_encode( self::canonicalize( $value ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR );
+ }
+
+ private static function canonicalize( $value ) {
+ if ( ! is_array( $value ) ) {
+ return $value;
+ }
+ if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
+ ksort( $value, SORT_STRING );
+ }
+ foreach ( $value as $key => $item ) {
+ $value[ $key ] = self::canonicalize( $item );
+ }
+ return $value;
+ }
+
+ private static function exact_keys( array $value, array $keys ): bool {
+ $actual = array_keys( $value );
+ sort( $actual, SORT_STRING );
+ sort( $keys, SORT_STRING );
+ return $actual === $keys;
+ }
+}
diff --git a/tools/html-api-fuzz/lib/CommonCrawlRunner.php b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
index 91931e72850f7..872916d3be2b8 100644
--- a/tools/html-api-fuzz/lib/CommonCrawlRunner.php
+++ b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
@@ -94,74 +94,88 @@ public static function from_environment(): self {
if ( is_string( $html5ever_oracle_bin ) && '' !== $html5ever_oracle_bin ) {
$oracle_options['html5ever-oracle-bin'] = $html5ever_oracle_bin;
}
-
- $oracle = OracleRenderer::from_options( $oracle_options );
- $metadata = $oracle->metadata();
- if ( true !== ( $metadata['available'] ?? false ) ) {
- throw new \RuntimeException(
- "Selected {$oracle_kind} oracle is unavailable: " . (string) ( $metadata['error'] ?? 'unknown identity error' )
- );
- }
- $expected_commit = getenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT' );
- if (
- is_string( $expected_commit ) && '' !== $expected_commit &&
- (
- OracleRenderer::KIND_LEXBOR_SOURCE !== $oracle_kind ||
- $expected_commit !== ( $metadata['identity']['lexborCommit'] ?? null )
- )
- ) {
- throw new \RuntimeException( 'Lexbor oracle commit does not match HTML_API_CC_EXPECT_LEXBOR_COMMIT.' );
- }
- $expected_identity_sha256 = getenv( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256' );
- if ( is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 && 1 !== preg_match( '/^[0-9a-fA-F]{64}$/', $expected_identity_sha256 ) ) {
- throw new \InvalidArgumentException( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256 must be a SHA-256 hex digest.' );
- }
- if (
- is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 &&
- ! hash_equals( strtolower( $expected_identity_sha256 ), OracleRenderer::identity_sha256( $metadata ) )
- ) {
- throw new \RuntimeException( 'Oracle identity does not match HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256.' );
+ $common_chrome_startup_timeout = getenv( 'HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS' );
+ if ( is_string( $common_chrome_startup_timeout ) && '' !== $common_chrome_startup_timeout ) {
+ $oracle_options['chrome-startup-timeout-ms'] = (string) self::environment_int( 'HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS', ChromeOracleRenderer::DEFAULT_STARTUP_TIMEOUT_MS, 1 );
}
- $checks = self::environment_string( 'HTML_API_CC_CHECKS', 'sampled' );
- if ( ! in_array( $checks, array( 'baseline', 'full', 'sampled' ), true ) ) {
- throw new \InvalidArgumentException( 'HTML_API_CC_CHECKS must be baseline, full, or sampled.' );
- }
- $full_sample_percent = self::environment_int( 'HTML_API_CC_FULL_SAMPLE_PERCENT', 1, 0 );
- if ( $full_sample_percent > 100 ) {
- throw new \InvalidArgumentException( 'HTML_API_CC_FULL_SAMPLE_PERCENT must be at most 100.' );
- }
- $memory_limit = self::environment_string( 'HTML_API_CC_MEMORY_LIMIT', '256M' );
- if ( ! preg_match( '/^[1-9][0-9]*[KMG]?$/i', $memory_limit ) ) {
- throw new \InvalidArgumentException( 'HTML_API_CC_MEMORY_LIMIT must be a positive PHP memory limit such as 256M.' );
- }
- $worker_script = self::environment_string( 'HTML_API_CC_WORKER_SCRIPT', repo_root() . '/tools/html-api-fuzz/worker.php' );
- if ( ! is_file( $worker_script ) ) {
- throw new \RuntimeException( 'HTML_API_CC_WORKER_SCRIPT does not exist.' );
- }
- if ( ! function_exists( 'posix_kill' ) || ! function_exists( 'posix_setsid' ) || ! function_exists( 'pcntl_exec' ) ) {
- throw new \RuntimeException( 'Common Crawl worker isolation requires the POSIX and PCNTL PHP extensions.' );
- }
-
- return new self(
- $output_dir,
+ $oracle = OracleRenderer::from_options( $oracle_options );
+ return OracleRenderer::with_explicit_close(
$oracle,
- array(
- 'maxTokens' => self::environment_int( 'HTML_API_CC_MAX_TOKENS', self::DEFAULT_MAX_TOKENS, 1 ),
- 'maxNodes' => self::environment_int( 'HTML_API_CC_MAX_NODES', self::DEFAULT_MAX_NODES, 1 ),
- 'maxDepth' => self::environment_int( 'HTML_API_CC_MAX_DEPTH', self::DEFAULT_MAX_DEPTH, 1 ),
- 'maxTreeBytes' => self::environment_int( 'HTML_API_CC_MAX_TREE_BYTES', self::DEFAULT_MAX_TREE_BYTES, 1 ),
- ),
- self::environment_int( 'HTML_API_CC_MAX_INPUT_BYTES', self::DEFAULT_MAX_INPUT_BYTES, 0 ),
- self::environment_int( 'HTML_API_CC_MAX_KEEP_PER_SIGNATURE', self::DEFAULT_KEEP_PER_SIGNATURE, 1 ),
- self::environment_int( 'HTML_API_CC_PROCESS_TIMEOUT_MS', self::DEFAULT_PROCESS_TIMEOUT_MS, 1 ),
- self::environment_bool( 'HTML_API_CC_REQUIRE_UTF8', true ),
- self::environment_bool( 'HTML_API_CC_RETAIN_ALL', false ),
- $checks,
- $full_sample_percent,
- $memory_limit,
- $worker_script,
- $run_id
+ static function ( OracleRenderer $oracle ) use ( $oracle_kind, $output_dir, $run_id ): self {
+ $metadata = $oracle->metadata();
+ if ( true !== ( $metadata['available'] ?? false ) ) {
+ throw new \RuntimeException(
+ "Selected {$oracle_kind} oracle is unavailable: " . (string) ( $metadata['error'] ?? 'unknown identity error' )
+ );
+ }
+ $expected_commit = getenv( 'HTML_API_CC_EXPECT_LEXBOR_COMMIT' );
+ if (
+ is_string( $expected_commit ) && '' !== $expected_commit &&
+ (
+ OracleRenderer::KIND_LEXBOR_SOURCE !== $oracle_kind ||
+ $expected_commit !== ( $metadata['identity']['lexborCommit'] ?? null )
+ )
+ ) {
+ throw new \RuntimeException( 'Lexbor oracle commit does not match HTML_API_CC_EXPECT_LEXBOR_COMMIT.' );
+ }
+ $expected_identity_sha256 = getenv( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256' );
+ if ( is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 && 1 !== preg_match( '/^[0-9a-fA-F]{64}$/', $expected_identity_sha256 ) ) {
+ throw new \InvalidArgumentException( 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256 must be a SHA-256 hex digest.' );
+ }
+ if (
+ is_string( $expected_identity_sha256 ) && '' !== $expected_identity_sha256 &&
+ ! hash_equals( strtolower( $expected_identity_sha256 ), OracleRenderer::identity_sha256( $metadata ) )
+ ) {
+ throw new \RuntimeException( 'Oracle identity does not match HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256.' );
+ }
+
+ $checks = self::environment_string( 'HTML_API_CC_CHECKS', 'sampled' );
+ if ( ! in_array( $checks, array( 'baseline', 'full', 'sampled' ), true ) ) {
+ throw new \InvalidArgumentException( 'HTML_API_CC_CHECKS must be baseline, full, or sampled.' );
+ }
+ $full_sample_percent = self::environment_int( 'HTML_API_CC_FULL_SAMPLE_PERCENT', 1, 0 );
+ if ( $full_sample_percent > 100 ) {
+ throw new \InvalidArgumentException( 'HTML_API_CC_FULL_SAMPLE_PERCENT must be at most 100.' );
+ }
+ $memory_limit = self::environment_string( 'HTML_API_CC_MEMORY_LIMIT', '256M' );
+ if ( ! preg_match( '/^[1-9][0-9]*[KMG]?$/i', $memory_limit ) ) {
+ throw new \InvalidArgumentException( 'HTML_API_CC_MEMORY_LIMIT must be a positive PHP memory limit such as 256M.' );
+ }
+ $worker_script = self::environment_string( 'HTML_API_CC_WORKER_SCRIPT', repo_root() . '/tools/html-api-fuzz/worker.php' );
+ if ( ! is_file( $worker_script ) ) {
+ throw new \RuntimeException( 'HTML_API_CC_WORKER_SCRIPT does not exist.' );
+ }
+ if ( ! function_exists( 'posix_kill' ) || ! function_exists( 'posix_setsid' ) || ! function_exists( 'pcntl_exec' ) ) {
+ throw new \RuntimeException( 'Common Crawl worker isolation requires the POSIX and PCNTL PHP extensions.' );
+ }
+
+ $process_timeout_environment = getenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS' );
+ $process_timeout_ms = is_string( $process_timeout_environment ) && '' !== $process_timeout_environment
+ ? self::environment_int( 'HTML_API_CC_PROCESS_TIMEOUT_MS', self::DEFAULT_PROCESS_TIMEOUT_MS, 1 )
+ : $oracle->recommended_process_timeout_ms( $checks, self::DEFAULT_PROCESS_TIMEOUT_MS );
+
+ return new self(
+ $output_dir,
+ $oracle,
+ array(
+ 'maxTokens' => self::environment_int( 'HTML_API_CC_MAX_TOKENS', self::DEFAULT_MAX_TOKENS, 1 ),
+ 'maxNodes' => self::environment_int( 'HTML_API_CC_MAX_NODES', self::DEFAULT_MAX_NODES, 1 ),
+ 'maxDepth' => self::environment_int( 'HTML_API_CC_MAX_DEPTH', self::DEFAULT_MAX_DEPTH, 1 ),
+ 'maxTreeBytes' => self::environment_int( 'HTML_API_CC_MAX_TREE_BYTES', self::DEFAULT_MAX_TREE_BYTES, 1 ),
+ ),
+ self::environment_int( 'HTML_API_CC_MAX_INPUT_BYTES', self::DEFAULT_MAX_INPUT_BYTES, 0 ),
+ self::environment_int( 'HTML_API_CC_MAX_KEEP_PER_SIGNATURE', self::DEFAULT_KEEP_PER_SIGNATURE, 1 ),
+ $process_timeout_ms,
+ self::environment_bool( 'HTML_API_CC_REQUIRE_UTF8', true ),
+ self::environment_bool( 'HTML_API_CC_RETAIN_ALL', false ),
+ $checks,
+ $full_sample_percent,
+ $memory_limit,
+ $worker_script,
+ $run_id
+ );
+ }
);
}
@@ -256,6 +270,7 @@ private function worker_args( string $staging_dir, int $seed, string $checks ):
'--max-depth', (string) $this->limits['maxDepth'],
'--max-tree-bytes', (string) $this->limits['maxTreeBytes'],
'--checks', $checks,
+ '--process-timeout-ms', (string) $this->process_timeout_ms,
'--git-metadata-base64', git_metadata_base64( $this->git_metadata ),
);
return array_merge( $args, $this->oracle->worker_args() );
diff --git a/tools/html-api-fuzz/lib/OracleRenderer.php b/tools/html-api-fuzz/lib/OracleRenderer.php
index 9bdf39ab46d71..aebdfc385157d 100644
--- a/tools/html-api-fuzz/lib/OracleRenderer.php
+++ b/tools/html-api-fuzz/lib/OracleRenderer.php
@@ -187,6 +187,7 @@ class OracleRenderer {
public const KIND_PHP_DOM = 'php-dom';
public const KIND_LEXBOR_SOURCE = 'lexbor-source';
public const KIND_HTML5EVER_SOURCE = 'html5ever-source';
+ public const KIND_CHROME_CDP = 'chrome-cdp';
private const METADATA_SCHEMA_VERSION = 1;
private const DEFAULT_TIMEOUT_MS = 2500;
@@ -199,19 +200,26 @@ class OracleRenderer {
private string $kind;
private ?string $source_binary;
private int $timeout_ms;
+ private ?ChromeOracleRenderer $chrome_renderer;
private ?array $metadata = null;
private ?string $source_manifest_sha256 = null;
- private function __construct( string $kind, ?string $source_binary = null, int $timeout_ms = self::DEFAULT_TIMEOUT_MS ) {
+ private function __construct( string $kind, ?string $source_binary = null, int $timeout_ms = self::DEFAULT_TIMEOUT_MS, ?ChromeOracleRenderer $chrome_renderer = null ) {
$this->kind = $kind;
$this->source_binary = $source_binary;
$this->timeout_ms = $timeout_ms;
+ $this->chrome_renderer = $chrome_renderer;
}
public static function from_options( array $options ): self {
+ foreach ( array( 'dom-oracle', 'lexbor-oracle-bin', 'html5ever-oracle-bin', 'chrome-oracle-script', 'chrome-executable', 'node-bin', 'oracle-timeout-ms', 'chrome-startup-timeout-ms' ) as $value_option ) {
+ if ( array_key_exists( $value_option, $options ) && true === $options[ $value_option ] ) {
+ throw new \InvalidArgumentException( "Expected --{$value_option} to have a value." );
+ }
+ }
$kind = option_string( $options, 'dom-oracle', self::KIND_PHP_DOM );
if ( ! in_array( $kind, self::kinds(), true ) ) {
- throw new \InvalidArgumentException( 'Expected --dom-oracle to be php-dom, lexbor-source, or html5ever-source.' );
+ throw new \InvalidArgumentException( 'Expected --dom-oracle to be php-dom, lexbor-source, html5ever-source, or chrome-cdp.' );
}
$source_binary = null;
@@ -232,11 +240,53 @@ public static function from_options( array $options ): self {
throw new \InvalidArgumentException( 'Expected --oracle-timeout-ms to be positive.' );
}
- return new self( $kind, $source_binary, $timeout_ms );
+ $chrome_renderer = null;
+ if ( self::KIND_CHROME_CDP === $kind ) {
+ $script = option_string( $options, 'chrome-oracle-script', getenv( 'HTML_API_FUZZ_CHROME_ORACLE' ) ?: null );
+ if ( array_key_exists( 'chrome-oracle-script', $options ) && '' === $script ) {
+ throw new \InvalidArgumentException( 'Expected --chrome-oracle-script to be non-empty.' );
+ }
+ if ( null === $script || '' === $script ) {
+ $script = repo_root() . '/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js';
+ }
+ $chrome_executable = option_string( $options, 'chrome-executable', getenv( 'HTML_API_FUZZ_CHROME_EXECUTABLE' ) ?: null );
+ if ( array_key_exists( 'chrome-executable', $options ) && '' === $chrome_executable ) {
+ throw new \InvalidArgumentException( 'Expected --chrome-executable to be non-empty.' );
+ }
+ $node_binary = option_string( $options, 'node-bin', getenv( 'HTML_API_FUZZ_NODE_BIN' ) ?: 'node' );
+ if ( null === $node_binary || '' === $node_binary ) {
+ throw new \InvalidArgumentException( 'Expected --node-bin to be non-empty.' );
+ }
+ $startup_timeout_ms = self::chrome_startup_timeout_from_options( $options );
+ $chrome_renderer = new ChromeOracleRenderer( $script, $chrome_executable, $node_binary, $timeout_ms, $startup_timeout_ms );
+ }
+
+ return new self( $kind, $source_binary, $timeout_ms, $chrome_renderer );
}
public static function kinds(): array {
- return array( self::KIND_PHP_DOM, self::KIND_LEXBOR_SOURCE, self::KIND_HTML5EVER_SOURCE );
+ return array( self::KIND_PHP_DOM, self::KIND_LEXBOR_SOURCE, self::KIND_HTML5EVER_SOURCE, self::KIND_CHROME_CDP );
+ }
+
+ private static function chrome_startup_timeout_from_options( array $options ): int {
+ if ( array_key_exists( 'chrome-startup-timeout-ms', $options ) ) {
+ $value = option_int( $options, 'chrome-startup-timeout-ms', ChromeOracleRenderer::DEFAULT_STARTUP_TIMEOUT_MS );
+ } else {
+ $environment = getenv( 'HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS' );
+ if ( false === $environment || '' === $environment ) {
+ $value = ChromeOracleRenderer::DEFAULT_STARTUP_TIMEOUT_MS;
+ } else {
+ $parsed = filter_var( $environment, FILTER_VALIDATE_INT );
+ if ( false === $parsed ) {
+ throw new \InvalidArgumentException( 'Expected HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS to be an integer.' );
+ }
+ $value = (int) $parsed;
+ }
+ }
+ if ( $value < 1 ) {
+ throw new \InvalidArgumentException( 'Expected --chrome-startup-timeout-ms to be positive.' );
+ }
+ return $value;
}
public function kind(): string {
@@ -272,6 +322,13 @@ public function metadata(): array {
);
return $this->metadata;
}
+ if ( self::KIND_CHROME_CDP === $this->kind ) {
+ if ( null === $this->chrome_renderer ) {
+ throw new \LogicException( 'Chrome renderer is missing.' );
+ }
+ $this->metadata = $this->chrome_renderer->metadata();
+ return $this->metadata;
+ }
try {
$this->metadata = $this->source_metadata();
@@ -326,6 +383,11 @@ public function replay_options(): array {
$options['lexborOracleBin'] = $this->source_binary;
} elseif ( self::KIND_HTML5EVER_SOURCE === $this->kind && null !== $this->source_binary ) {
$options['html5everOracleBin'] = $this->source_binary;
+ } elseif ( self::KIND_CHROME_CDP === $this->kind && null !== $this->chrome_renderer ) {
+ $options['chromeOracleScript'] = $this->chrome_renderer->script();
+ $options['chromeExecutable'] = $this->chrome_renderer->chrome_executable();
+ $options['nodeBin'] = $this->chrome_renderer->node_executable();
+ $options['chromeStartupTimeoutMs'] = $this->chrome_renderer->startup_timeout_ms();
}
if ( self::DEFAULT_TIMEOUT_MS !== $this->timeout_ms ) {
$options['oracleTimeoutMs'] = $this->timeout_ms;
@@ -341,6 +403,17 @@ public function worker_args(): array {
} elseif ( self::KIND_HTML5EVER_SOURCE === $this->kind && null !== $this->source_binary ) {
$args[] = '--html5ever-oracle-bin';
$args[] = $this->source_binary;
+ } elseif ( self::KIND_CHROME_CDP === $this->kind && null !== $this->chrome_renderer ) {
+ $args[] = '--chrome-oracle-script';
+ $args[] = $this->chrome_renderer->script();
+ if ( '' !== $this->chrome_renderer->chrome_executable() ) {
+ $args[] = '--chrome-executable';
+ $args[] = $this->chrome_renderer->chrome_executable();
+ }
+ $args[] = '--node-bin';
+ $args[] = $this->chrome_renderer->node_executable();
+ $args[] = '--chrome-startup-timeout-ms';
+ $args[] = (string) $this->chrome_renderer->startup_timeout_ms();
}
if ( self::DEFAULT_TIMEOUT_MS !== $this->timeout_ms ) {
$args[] = '--oracle-timeout-ms';
@@ -355,6 +428,12 @@ public function render( string $html, string $mode, array $limits = array(), str
$result['oracle'] = $this->metadata();
return $result;
}
+ if ( self::KIND_CHROME_CDP === $this->kind ) {
+ if ( null === $this->chrome_renderer ) {
+ throw new \LogicException( 'Chrome renderer is missing.' );
+ }
+ return $this->chrome_renderer->render( $html, $mode, $limits, $fragment_context );
+ }
$metadata = $this->metadata();
if ( true !== $metadata['available'] ) {
@@ -405,6 +484,62 @@ public function render( string $html, string $mode, array $limits = array(), str
}
}
+ public function close(): void {
+ if ( null !== $this->chrome_renderer ) {
+ $this->chrome_renderer->close();
+ }
+ }
+
+ /**
+ * Run an owner's complete renderer lifetime and always surface cleanup.
+ *
+ * @return mixed
+ */
+ public static function with_explicit_close( self $renderer, callable $operation ) {
+ $value = null;
+ $operation_error = null;
+ try {
+ $value = $operation( $renderer );
+ } catch ( \Throwable $error ) {
+ $operation_error = $error;
+ }
+
+ $cleanup_error = null;
+ try {
+ $renderer->close();
+ } catch ( \Throwable $error ) {
+ $cleanup_error = $error;
+ }
+
+ if ( null !== $operation_error ) {
+ if ( null !== $cleanup_error ) {
+ throw new \RuntimeException(
+ $operation_error->getMessage() . '; oracle cleanup failed: ' . $cleanup_error->getMessage(),
+ 0,
+ $operation_error
+ );
+ }
+ throw $operation_error;
+ }
+ if ( null !== $cleanup_error ) {
+ throw $cleanup_error;
+ }
+
+ return $value;
+ }
+
+ public function recommended_process_timeout_ms( string $checks, int $non_chrome_fallback ): int {
+ if ( $non_chrome_fallback < 1 ) {
+ throw new \InvalidArgumentException( 'Expected the non-Chrome process timeout fallback to be positive.' );
+ }
+ if ( ! in_array( $checks, array( 'baseline', 'full', 'sampled' ), true ) ) {
+ $checks = 'unknown';
+ }
+ return null === $this->chrome_renderer
+ ? $non_chrome_fallback
+ : $this->chrome_renderer->recommended_process_timeout_ms( $checks );
+ }
+
private function source_metadata(): array {
if ( ! is_string( $this->source_binary ) || '' === $this->source_binary ) {
throw new \RuntimeException( 'Source oracle binary path is missing.' );
@@ -1455,6 +1590,28 @@ private static function metadata_validation_error( $metadata ): ?string {
self::nonempty_string( $build['compiler'] ) && self::nonempty_string( $build['cmake'] )
? null : 'Lexbor identity is invalid';
}
+ if ( self::KIND_CHROME_CDP === $metadata['kind'] ) {
+ $keys = array(
+ 'schemaVersion', 'kind', 'platform', 'pinnedChromeVersion', 'chromeArchiveSha256',
+ 'expectedChromeExecutableSha256', 'chromeExecutableSha256', 'oracleScriptSha256',
+ 'fragmentContextsSha256', 'fragmentContexts', 'nodeExecutableSha256', 'nodeVersion',
+ 'chromeVersion', 'cdpProtocolVersion',
+ );
+ return self::exact_keys( $identity, $keys ) &&
+ in_array( $identity['platform'], array( 'mac-arm64', 'mac-x64', 'linux64' ), true ) &&
+ self::matches( $identity['pinnedChromeVersion'], '/^[0-9]+(?:\.[0-9]+){3}$/D' ) &&
+ self::matches( $identity['chromeArchiveSha256'], '/^[0-9a-f]{64}$/D' ) &&
+ self::matches( $identity['expectedChromeExecutableSha256'], '/^[0-9a-f]{64}$/D' ) &&
+ $identity['expectedChromeExecutableSha256'] === $identity['chromeExecutableSha256'] &&
+ self::matches( $identity['oracleScriptSha256'], '/^[0-9a-f]{64}$/D' ) &&
+ self::matches( $identity['fragmentContextsSha256'], '/^[0-9a-f]{64}$/D' ) &&
+ $identity['fragmentContexts'] === Generator::fragment_contexts() &&
+ self::matches( $identity['nodeExecutableSha256'], '/^[0-9a-f]{64}$/D' ) &&
+ self::matches( $identity['nodeVersion'], '/^v[0-9]+(?:\.[0-9]+){2}(?:[-+][0-9A-Za-z.-]+)?$/D' ) &&
+ $identity['pinnedChromeVersion'] === $identity['chromeVersion'] &&
+ self::nonempty_string( $identity['cdpProtocolVersion'] )
+ ? null : 'Chrome CDP identity is invalid';
+ }
$build = $identity['build'] ?? null;
$identity_keys = array( 'schemaVersion', 'kind', 'binarySha256', 'html5everVersion', 'html5everChecksum', 'markup5everRcdomVersion', 'markup5everRcdomChecksum', 'rustToolchain', 'cargoLockSha256', 'buildIdentity', 'build' );
$build_keys = array( 'schemaVersion', 'kind', 'publicationProtocol', 'cargoTomlSha256', 'cargoLockSha256', 'rustToolchainSha256', 'sourceSha256', 'rustc', 'cargo', 'html5ever', 'markup5everRcdom' );
diff --git a/tools/html-api-fuzz/lib/ResultStore.php b/tools/html-api-fuzz/lib/ResultStore.php
index 4a2b52c46de56..b37851a29144f 100644
--- a/tools/html-api-fuzz/lib/ResultStore.php
+++ b/tools/html-api-fuzz/lib/ResultStore.php
@@ -50,6 +50,7 @@ private function create_schema(): void {
oracle_signature_hash TEXT,
oracle_family_key TEXT,
oracle_kind TEXT,
+ oracle_identity_sha256 TEXT,
oracle_version TEXT,
oracle_commit TEXT,
oracle_binary TEXT,
@@ -76,19 +77,21 @@ private function create_schema(): void {
$this->ensure_column( 'attempts', 'oracle_signature_hash', 'TEXT' );
$this->ensure_column( 'attempts', 'oracle_family_key', 'TEXT' );
$this->ensure_column( 'attempts', 'oracle_kind', 'TEXT' );
+ $this->ensure_column( 'attempts', 'oracle_identity_sha256', 'TEXT' );
$this->ensure_column( 'attempts', 'oracle_version', 'TEXT' );
$this->ensure_column( 'attempts', 'oracle_commit', 'TEXT' );
$this->ensure_column( 'attempts', 'oracle_binary', 'TEXT' );
$this->ensure_column( 'attempts', 'failure_artifacts_retained', 'INTEGER' );
$this->ensure_column( 'attempts', 'oracle_artifacts_retained', 'INTEGER' );
- if ( (int) $this->db->querySingle( 'PRAGMA user_version' ) < 2 ) {
- $this->db->exec( 'PRAGMA user_version = 2' );
+ if ( (int) $this->db->querySingle( 'PRAGMA user_version' ) < 3 ) {
+ $this->db->exec( 'PRAGMA user_version = 3' );
}
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_signature_hash ON attempts ( signature_hash )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_family_key ON attempts ( family_key )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_oracle_signature_hash ON attempts ( oracle_signature_hash )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_oracle_family_key ON attempts ( oracle_family_key )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_oracle_kind ON attempts ( oracle_kind )' );
+ $this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_oracle_identity_sha256 ON attempts ( oracle_identity_sha256 )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_ok ON attempts ( ok )' );
$this->db->exec( 'CREATE INDEX IF NOT EXISTS attempts_seed ON attempts ( seed )' );
}
@@ -134,7 +137,7 @@ public function record_attempt( array $summary, ?array $result = null, ?array $r
'INSERT INTO attempts (
created_at, seed, ok, status, failure_class, signature_hash, family_key,
oracle_finding_class, oracle_finding_type, oracle_suspected_owner, oracle_signature_hash, oracle_family_key,
- oracle_kind, oracle_version, oracle_commit, oracle_binary,
+ oracle_kind, oracle_identity_sha256, oracle_version, oracle_commit, oracle_binary,
profile, mode, payload_policy, input_source, input_sha1, input_length,
duration_ms, worker_code, worker_timed_out, artifacts_retained,
failure_artifacts_retained, oracle_artifacts_retained,
@@ -142,7 +145,7 @@ public function record_attempt( array $summary, ?array $result = null, ?array $r
) VALUES (
:created_at, :seed, :ok, :status, :failure_class, :signature_hash, :family_key,
:oracle_finding_class, :oracle_finding_type, :oracle_suspected_owner, :oracle_signature_hash, :oracle_family_key,
- :oracle_kind, :oracle_version, :oracle_commit, :oracle_binary,
+ :oracle_kind, :oracle_identity_sha256, :oracle_version, :oracle_commit, :oracle_binary,
:profile, :mode, :payload_policy, :input_source, :input_sha1, :input_length,
:duration_ms, :worker_code, :worker_timed_out, :artifacts_retained,
:failure_artifacts_retained, :oracle_artifacts_retained,
@@ -203,11 +206,18 @@ public function record_attempt( array $summary, ?array $result = null, ?array $r
$statement->bindValue( ':oracle_family_key', $oracle_family_key, null === $oracle_family_key ? SQLITE3_NULL : SQLITE3_TEXT );
$oracle_kind = $oracle['kind'] ?? null;
$statement->bindValue( ':oracle_kind', $oracle_kind, null === $oracle_kind ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_version = $oracle_identity['lexborVersion'] ?? $oracle_identity['html5everVersion'] ?? $oracle_identity['phpVersion'] ?? null;
+ $oracle_identity_sha256 = null;
+ try {
+ $oracle_identity_sha256 = is_array( $oracle ) ? OracleRenderer::identity_sha256( $oracle ) : null;
+ } catch ( \Throwable $ignored ) {
+ $oracle_identity_sha256 = null;
+ }
+ $statement->bindValue( ':oracle_identity_sha256', $oracle_identity_sha256, null === $oracle_identity_sha256 ? SQLITE3_NULL : SQLITE3_TEXT );
+ $oracle_version = $oracle_identity['lexborVersion'] ?? $oracle_identity['html5everVersion'] ?? $oracle_identity['phpVersion'] ?? $oracle_identity['pinnedChromeVersion'] ?? null;
$statement->bindValue( ':oracle_version', $oracle_version, null === $oracle_version ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_commit = $oracle_identity['lexborCommit'] ?? $oracle_identity['buildIdentity'] ?? null;
+ $oracle_commit = $oracle_identity['lexborCommit'] ?? $oracle_identity['buildIdentity'] ?? $oracle_identity['oracleScriptSha256'] ?? null;
$statement->bindValue( ':oracle_commit', $oracle_commit, null === $oracle_commit ? SQLITE3_NULL : SQLITE3_TEXT );
- $oracle_binary = $oracle_identity['binarySha256'] ?? null;
+ $oracle_binary = $oracle_identity['binarySha256'] ?? $oracle_identity['chromeExecutableSha256'] ?? null;
$statement->bindValue( ':oracle_binary', $oracle_binary, null === $oracle_binary ? SQLITE3_NULL : SQLITE3_TEXT );
$statement->bindValue( ':profile', $summary['profile'] ?? null, null === ( $summary['profile'] ?? null ) ? SQLITE3_NULL : SQLITE3_TEXT );
$statement->bindValue( ':mode', $summary['mode'] ?? null, null === ( $summary['mode'] ?? null ) ? SQLITE3_NULL : SQLITE3_TEXT );
diff --git a/tools/html-api-fuzz/lib/Worker.php b/tools/html-api-fuzz/lib/Worker.php
index 42cd7d7f843ad..e2bf7cd1d4e0f 100644
--- a/tools/html-api-fuzz/lib/Worker.php
+++ b/tools/html-api-fuzz/lib/Worker.php
@@ -86,34 +86,80 @@ public static function run( array $options ): array {
throw new \InvalidArgumentException( 'Expected --checks to be baseline or full.' );
}
$fail_unsupported = option_bool( $options, 'fail-unsupported', false );
- $oracle_renderer = OracleRenderer::from_options( $options );
- $oracle_metadata = $oracle_renderer->metadata();
-
+ $process_timeout_ms = option_int( $options, 'process-timeout-ms', 0 );
+ if ( array_key_exists( 'process-timeout-ms', $options ) && $process_timeout_ms < 1 ) {
+ throw new \InvalidArgumentException( 'Expected --process-timeout-ms to be positive.' );
+ }
+ $oracle_renderer = OracleRenderer::from_options( $options );
+ $oracle_metadata = null;
+ $replay = null;
+ $result = null;
+ $operation_error = null;
$replay_path = $output_dir . DIRECTORY_SEPARATOR . 'replay.json';
$result_path = $output_dir . DIRECTORY_SEPARATOR . 'result.json';
$input_path = $output_dir . DIRECTORY_SEPARATOR . 'input.bin';
- $source_input_path = option_string( $options, 'input-file', null );
- if ( null === $source_input_path || ! self::same_file( $source_input_path, $input_path ) ) {
- write_file_atomic( $input_path, $input );
- }
-
- $replay = self::base_replay( $seed, $profile, $mode, $payload_policy, $fragment_context, $generator_parameters, $input_source, $input, $output_dir, $limits, $fail_unsupported, $git_metadata, $oracle_metadata, $oracle_renderer->replay_options(), $checks );
- write_json_file_atomic( $replay_path, $replay );
+ try {
+ $oracle_metadata = $oracle_renderer->metadata();
+ $source_input_path = option_string( $options, 'input-file', null );
+ if ( null === $source_input_path || ! self::same_file( $source_input_path, $input_path ) ) {
+ write_file_atomic( $input_path, $input );
+ }
- $result = self::evaluate_input(
- $input,
- $seed,
- $profile,
- $mode,
- $payload_policy,
- $fragment_context,
- $generator_parameters,
- $input_source,
- $limits,
- $fail_unsupported,
- $oracle_renderer,
- $checks
- );
+ $replay = self::base_replay( $seed, $profile, $mode, $payload_policy, $fragment_context, $generator_parameters, $input_source, $input, $output_dir, $limits, $fail_unsupported, $git_metadata, $oracle_metadata, $oracle_renderer->replay_options(), $checks );
+ if ( $process_timeout_ms > 0 ) {
+ $replay['options']['processTimeoutMs'] = $process_timeout_ms;
+ }
+ write_json_file_atomic( $replay_path, $replay );
+
+ $result = self::evaluate_input(
+ $input,
+ $seed,
+ $profile,
+ $mode,
+ $payload_policy,
+ $fragment_context,
+ $generator_parameters,
+ $input_source,
+ $limits,
+ $fail_unsupported,
+ $oracle_renderer,
+ $checks
+ );
+ if ( ! is_array( $result ) || ! is_array( $replay ) || ! is_array( $oracle_metadata ) ) {
+ throw new \LogicException( 'Worker evaluation did not produce a result.' );
+ }
+ } catch ( \Throwable $error ) {
+ $operation_error = $error;
+ }
+ $cleanup_error = null;
+ try {
+ $oracle_renderer->close();
+ } catch ( \Throwable $error ) {
+ $cleanup_error = $error;
+ }
+ if ( null !== $operation_error ) {
+ $message = $operation_error->getMessage();
+ if ( null !== $cleanup_error ) {
+ $message .= '; oracle cleanup failed: ' . $cleanup_error->getMessage();
+ }
+ throw new \RuntimeException( $message, 0, $operation_error );
+ }
+ if ( null !== $cleanup_error ) {
+ $result['ok'] = false;
+ $result['status'] = 'failed';
+ $result['failureClass'] = 'oracle-renderer-error';
+ $result['failureSnippet'] = 'Oracle cleanup failed: ' . $cleanup_error->getMessage();
+ $result['oracleInfrastructure'] = true;
+ $result['oracleCleanup'] = array(
+ 'ok' => false,
+ 'error' => $cleanup_error->getMessage(),
+ );
+ unset( $result['signature'], $result['oracleFinding'] );
+ $cleanup_signature = Signature::from_result( $result );
+ if ( null !== $cleanup_signature ) {
+ $result['signature'] = $cleanup_signature;
+ }
+ }
$result['paths'] = array(
'outputDir' => $output_dir,
'inputPath' => $input_path,
@@ -210,6 +256,7 @@ public static function evaluate_input( string $input, int $seed, string $profile
'error' => $e->getMessage(),
'throwable' => get_class( $e ),
'failureClass' => 'oracle-renderer-error',
+ 'infrastructure' => true,
'oracle' => $oracle_metadata,
);
}
@@ -759,7 +806,7 @@ private static function tag_invariant_failure_class( array $tag_result ): string
}
private static function is_resource_limit_failure( ?string $failure_class ): bool {
- return in_array( $failure_class, array( 'token-limit-exceeded', 'node-limit-exceeded', 'depth-limit-exceeded', 'tree-byte-limit-exceeded', 'resource-limit' ), true );
+ return in_array( $failure_class, array( 'input-byte-limit-exceeded', 'token-limit-exceeded', 'node-limit-exceeded', 'depth-limit-exceeded', 'tree-byte-limit-exceeded', 'resource-limit' ), true );
}
private static function stage_elapsed_ms( int $started_at ): float {
diff --git a/tools/html-api-fuzz/lib/autoload.php b/tools/html-api-fuzz/lib/autoload.php
index ca719235eab68..5d609a961b99a 100644
--- a/tools/html-api-fuzz/lib/autoload.php
+++ b/tools/html-api-fuzz/lib/autoload.php
@@ -7,6 +7,7 @@
require_once __DIR__ . '/Generator.php';
require_once __DIR__ . '/TreeRenderer.php';
require_once __DIR__ . '/OracleRenderer.php';
+require_once __DIR__ . '/ChromeOracleRenderer.php';
require_once __DIR__ . '/TagInvariants.php';
require_once __DIR__ . '/Signature.php';
require_once __DIR__ . '/OracleFinding.php';
diff --git a/tools/html-api-fuzz/minimize.php b/tools/html-api-fuzz/minimize.php
index 72ee7cc9b76b8..bc9e7b8bffa4c 100755
--- a/tools/html-api-fuzz/minimize.php
+++ b/tools/html-api-fuzz/minimize.php
@@ -46,6 +46,7 @@ function html_api_fuzz_min_worker_options( string $candidate, array $base, strin
'output-dir' => $output_dir,
'max-tokens' => (string) $base['maxTokens'],
'max-nodes' => (string) $base['maxNodes'],
+ 'process-timeout-ms' => (string) $base['processTimeoutMs'],
);
if ( null !== $base['gitMetadataBase64'] ) {
$options['git-metadata-base64'] = $base['gitMetadataBase64'];
@@ -120,6 +121,8 @@ function html_api_fuzz_min_process_test( string $candidate, array $base, string
(string) $base['maxTokens'],
'--max-nodes',
(string) $base['maxNodes'],
+ '--process-timeout-ms',
+ (string) $base['processTimeoutMs'],
);
if ( null !== $base['gitMetadataBase64'] ) {
$args[] = '--git-metadata-base64';
@@ -268,18 +271,139 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
return 'process';
}
+function html_api_fuzz_min_reduce( string $input, array $base, string $output_dir, int $max_attempts, int $timeout_ms, bool $any_failure ): array {
+ $attempt_count = 0;
+ $probe_stats = array(
+ 'durationMs' => 0,
+ 'maxDurationMs' => 0,
+ 'accepted' => 0,
+ );
+ $current = $input;
+
+ /*
+ * Phase 1: markup-aligned segment deletion. Splitting on tag boundaries is
+ * token-naive (rawtext contents split incorrectly), but unsound candidates
+ * simply fail the signature check; aligned deletions converge far faster on
+ * HTML than blind byte chunks.
+ */
+ $progress = true;
+ while ( $progress && $attempt_count < $max_attempts ) {
+ $progress = false;
+ preg_match_all( '/<[^>]*>?|[^<]+/s', $current, $matches );
+ $segments = $matches[0];
+ if ( count( $segments ) < 2 ) {
+ break;
+ }
+ for ( $i = count( $segments ) - 1; $i >= 0 && $attempt_count < $max_attempts; $i-- ) {
+ $candidate_segments = $segments;
+ unset( $candidate_segments[ $i ] );
+ $candidate = implode( '', $candidate_segments );
+ if ( $candidate === $current || '' === $candidate ) {
+ continue;
+ }
+ ++$attempt_count;
+ $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
+ html_api_fuzz_min_record_probe( $probe_stats, $test );
+ if ( $test['accepted'] ) {
+ $current = $candidate;
+ $progress = true;
+ break;
+ }
+ }
+ }
+
+ // Phase 2: byte-chunk deletion for reductions that cross tag boundaries.
+ $chunks = 2;
+ while ( strlen( $current ) > 0 && $attempt_count < $max_attempts ) {
+ $length = strlen( $current );
+ $chunk_size = (int) ceil( $length / $chunks );
+ $changed = false;
+
+ for ( $offset = 0; $offset < $length && $attempt_count < $max_attempts; $offset += $chunk_size ) {
+ $candidate = substr( $current, 0, $offset ) . substr( $current, min( $length, $offset + $chunk_size ) );
+ if ( $candidate === $current ) {
+ continue;
+ }
+ ++$attempt_count;
+ $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
+ html_api_fuzz_min_record_probe( $probe_stats, $test );
+ if ( $test['accepted'] ) {
+ $current = $candidate;
+ $chunks = max( 2, $chunks - 1 );
+ $changed = true;
+ break;
+ }
+ }
+
+ if ( ! $changed ) {
+ if ( $chunks >= $length ) {
+ break;
+ }
+ $chunks = min( $length, $chunks * 2 );
+ }
+ }
+
+ /*
+ * Phase 3: per-byte canonicalization. Deletion is tried first; replacements
+ * never grow the input. After a deletion the same index holds the next byte,
+ * so stay in place; after a substitution move on.
+ */
+ $simple_replacements = array( '', 'a', ' ', "\n" );
+ for ( $i = 0; $i < strlen( $current ) && $attempt_count < $max_attempts; ++$i ) {
+ foreach ( $simple_replacements as $replacement ) {
+ $candidate = substr( $current, 0, $i ) . $replacement . substr( $current, $i + 1 );
+ if ( $candidate === $current ) {
+ continue;
+ }
+ ++$attempt_count;
+ $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
+ html_api_fuzz_min_record_probe( $probe_stats, $test );
+ if ( $test['accepted'] ) {
+ $current = $candidate;
+ if ( '' === $replacement ) {
+ --$i;
+ }
+ break;
+ }
+ }
+ }
+
+ return array(
+ 'current' => $current,
+ 'attemptCount' => $attempt_count,
+ 'probeStats' => $probe_stats,
+ );
+}
+
$options = \HtmlApiFuzz\parse_cli_options( $argv );
$replay_path = \HtmlApiFuzz\option_string( $options, 'replay', $options['_'][0] ?? null );
if ( null === $replay_path || \HtmlApiFuzz\option_bool( $options, 'help', false ) ) {
- echo "Usage: php tools/html-api-fuzz/minimize.php --replay path/to/replay.json [--output-dir DIR] [--target-kind failure|oracle-finding --target-hash HASH] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch] [--probe-mode auto|in-process|process] [--keep-candidate-artifacts]\n";
+ echo "Usage: php tools/html-api-fuzz/minimize.php --replay path/to/replay.json [--output-dir DIR] [--target-kind failure|oracle-finding --target-hash HASH] [--dom-oracle php-dom|lexbor-source|html5ever-source|chrome-cdp] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH|--chrome-oracle-script PATH] [--chrome-executable PATH] [--node-bin PATH] [--chrome-startup-timeout-ms N] [--allow-oracle-mismatch] [--probe-mode auto|in-process|process] [--keep-candidate-artifacts]\n";
exit( null === $replay_path ? 1 : 0 );
}
+$explicit_timeout_ms = null;
+if ( array_key_exists( 'timeout-ms', $options ) ) {
+ $explicit_timeout_value = $options['timeout-ms'];
+ $parsed_explicit_timeout = ( is_int( $explicit_timeout_value ) || is_string( $explicit_timeout_value ) )
+ ? filter_var( $explicit_timeout_value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_explicit_timeout ) {
+ fwrite( STDERR, "Invalid --timeout-ms: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ $explicit_timeout_ms = (int) $parsed_explicit_timeout;
+}
$replay = \HtmlApiFuzz\read_json_file( $replay_path );
if ( ! $replay || ! array_key_exists( 'inputBase64', $replay ) ) {
fwrite( STDERR, "Invalid replay file: {$replay_path}\n" );
exit( 1 );
}
+if ( array_key_exists( 'options', $replay ) && ! is_array( $replay['options'] ) ) {
+ fwrite( STDERR, "Invalid replay options: expected an object.\n" );
+ exit( 1 );
+}
+$recorded_options = is_array( $replay['options'] ?? null ) ? $replay['options'] : array();
$target = html_api_fuzz_min_target( $replay, $options );
$target_hash = $target['hash'];
@@ -308,147 +432,143 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ) && is_string( $replay['options']['html5everOracleBin'] ?? null ) ) {
$oracle_options['html5ever-oracle-bin'] = $replay['options']['html5everOracleBin'];
}
-$stored_oracle_timeout_ms = $replay['options']['oracleTimeoutMs'] ?? null;
-if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && is_numeric( $stored_oracle_timeout_ms ) ) {
- $oracle_options['oracle-timeout-ms'] = (string) (int) $stored_oracle_timeout_ms;
-}
-$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
-$current_oracle = $oracle_renderer->metadata();
-$oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $replay['oracle'] ?? null, $current_oracle );
-if ( ! empty( $oracle_mismatches ) && ! \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false ) ) {
- fwrite( STDERR, 'Oracle identity mismatch: ' . implode( '; ', $oracle_mismatches ) . ".\n" );
- fwrite( STDERR, "Pass --allow-oracle-mismatch only for a deliberate diagnostic minimization.\n" );
- exit( 1 );
-}
-\HtmlApiFuzz\ensure_dir( $output_dir );
-$probe_mode = html_api_fuzz_min_probe_mode( $options );
-$base = array(
- 'mode' => $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
- 'profile' => $replay['profile'] ?? 'replay',
- 'payloadPolicy' => \HtmlApiFuzz\normalize_payload_policy_label( $replay['payloadPolicy'] ?? null )
- ?? \HtmlApiFuzz\normalize_payload_policy_label( $replay['generator']['payloadPolicy'] ?? null ),
- 'fragmentContext' => is_string( $replay['fragmentContext'] ?? null ) ? $replay['fragmentContext'] : 'body',
- 'originalGenerator' => $original_generator,
- 'seed' => (int) ( $replay['seed'] ?? 1 ),
- 'targetHash' => $target_hash,
- 'targetKind' => $target['kind'] ?? 'failure',
- 'sourceReplay' => $source_replay,
- 'oracle' => $current_oracle,
- 'sourceOracle' => $replay['oracle'] ?? null,
- 'oracleIdentityMismatches' => $oracle_mismatches,
- 'oracleRenderer' => $oracle_renderer,
- 'oracleOptions' => array(
- 'dom-oracle' => \HtmlApiFuzz\option_string( $oracle_options, 'dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM ),
- 'lexbor-oracle-bin' => \HtmlApiFuzz\option_string( $oracle_options, 'lexbor-oracle-bin', null ),
- 'html5ever-oracle-bin' => \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ),
- 'oracle-timeout-ms' => \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ),
- ),
- 'oracleWorkerArgs' => $oracle_renderer->worker_args(),
- 'gitMetadataBase64' => \HtmlApiFuzz\git_metadata_base64( \HtmlApiFuzz\git_metadata() ),
- 'failUnsupported' => (bool) ( $replay['options']['failUnsupported'] ?? ( 'unsupported' === ( $replay['result']['failureClass'] ?? null ) ) ),
- 'maxTokens' => (int) ( $replay['limits']['maxTokens'] ?? 2000 ),
- 'maxNodes' => (int) ( $replay['limits']['maxNodes'] ?? 3000 ),
- 'probeMode' => $probe_mode,
- 'keepCandidateArtifacts' => \HtmlApiFuzz\option_bool( $options, 'keep-candidate-artifacts', false ),
-);
-$timeout_ms = \HtmlApiFuzz\option_int( $options, 'timeout-ms', 2500 );
-$max_attempts = \HtmlApiFuzz\option_int( $options, 'max-attempts', 600 );
-$attempt_count = 0;
-$probe_stats = array(
- 'durationMs' => 0,
- 'maxDurationMs' => 0,
- 'accepted' => 0,
-);
-
-$current = $input;
-
-/*
- * Phase 1: markup-aligned segment deletion. Splitting on tag boundaries is
- * token-naive (rawtext contents split incorrectly), but unsound candidates
- * simply fail the signature check; aligned deletions converge far faster on
- * HTML than blind byte chunks.
- */
-$progress = true;
-while ( $progress && $attempt_count < $max_attempts ) {
- $progress = false;
- preg_match_all( '/<[^>]*>?|[^<]+/s', $current, $matches );
- $segments = $matches[0];
- if ( count( $segments ) < 2 ) {
- break;
- }
- for ( $i = count( $segments ) - 1; $i >= 0 && $attempt_count < $max_attempts; $i-- ) {
- $candidate_segments = $segments;
- unset( $candidate_segments[ $i ] );
- $candidate = implode( '', $candidate_segments );
- if ( $candidate === $current || '' === $candidate ) {
- continue;
- }
- ++$attempt_count;
- $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
- html_api_fuzz_min_record_probe( $probe_stats, $test );
- if ( $test['accepted'] ) {
- $current = $candidate;
- $progress = true;
- break;
- }
+foreach ( array( 'chromeOracleScript' => 'chrome-oracle-script', 'chromeExecutable' => 'chrome-executable', 'nodeBin' => 'node-bin' ) as $recorded_name => $option_name ) {
+ if ( ! array_key_exists( $recorded_name, $recorded_options ) ) {
+ continue;
}
-}
-
-// Phase 2: byte-chunk deletion for reductions that cross tag boundaries.
-$chunks = 2;
-while ( strlen( $current ) > 0 && $attempt_count < $max_attempts ) {
- $length = strlen( $current );
- $chunk_size = (int) ceil( $length / $chunks );
- $changed = false;
-
- for ( $offset = 0; $offset < $length && $attempt_count < $max_attempts; $offset += $chunk_size ) {
- $candidate = substr( $current, 0, $offset ) . substr( $current, min( $length, $offset + $chunk_size ) );
- if ( $candidate === $current ) {
- continue;
- }
- ++$attempt_count;
- $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
- html_api_fuzz_min_record_probe( $probe_stats, $test );
- if ( $test['accepted'] ) {
- $current = $candidate;
- $chunks = max( 2, $chunks - 1 );
- $changed = true;
- break;
- }
+ if ( ! is_string( $recorded_options[ $recorded_name ] ) || '' === $recorded_options[ $recorded_name ] ) {
+ fwrite( STDERR, "Invalid recorded {$recorded_name}: expected a non-empty string.\n" );
+ exit( 1 );
}
-
- if ( ! $changed ) {
- if ( $chunks >= $length ) {
- break;
- }
- $chunks = min( $length, $chunks * 2 );
+ if ( null === \HtmlApiFuzz\option_string( $oracle_options, $option_name, null ) ) {
+ $oracle_options[ $option_name ] = $recorded_options[ $recorded_name ];
}
}
-
-/*
- * Phase 3: per-byte canonicalization. Deletion is tried first; replacements
- * never grow the input. After a deletion the same index holds the next byte,
- * so stay in place; after a substitution move on.
- */
-$simple_replacements = array( '', 'a', ' ', "\n" );
-for ( $i = 0; $i < strlen( $current ) && $attempt_count < $max_attempts; ++$i ) {
- foreach ( $simple_replacements as $replacement ) {
- $candidate = substr( $current, 0, $i ) . $replacement . substr( $current, $i + 1 );
- if ( $candidate === $current ) {
- continue;
- }
- ++$attempt_count;
- $test = html_api_fuzz_min_test( $candidate, $base, $output_dir, $attempt_count, $timeout_ms, $any_failure );
- html_api_fuzz_min_record_probe( $probe_stats, $test );
- if ( $test['accepted'] ) {
- $current = $candidate;
- if ( '' === $replacement ) {
- --$i;
+if ( array_key_exists( 'chromeStartupTimeoutMs', $recorded_options ) ) {
+ $recorded_chrome_startup = $recorded_options['chromeStartupTimeoutMs'];
+ $parsed_chrome_startup = ( is_int( $recorded_chrome_startup ) || is_string( $recorded_chrome_startup ) )
+ ? filter_var( $recorded_chrome_startup, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_chrome_startup ) {
+ fwrite( STDERR, "Invalid recorded chromeStartupTimeoutMs: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'chrome-startup-timeout-ms', null ) ) {
+ $oracle_options['chrome-startup-timeout-ms'] = (string) (int) $parsed_chrome_startup;
+ }
+}
+$stored_oracle_timeout_ms = null;
+if ( array_key_exists( 'oracleTimeoutMs', $recorded_options ) ) {
+ $recorded_oracle_timeout = $recorded_options['oracleTimeoutMs'];
+ $parsed_oracle_timeout = ( is_int( $recorded_oracle_timeout ) || is_string( $recorded_oracle_timeout ) )
+ ? filter_var( $recorded_oracle_timeout, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_oracle_timeout ) {
+ fwrite( STDERR, "Invalid recorded oracleTimeoutMs: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ $stored_oracle_timeout_ms = (int) $parsed_oracle_timeout;
+}
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && null !== $stored_oracle_timeout_ms ) {
+ $oracle_options['oracle-timeout-ms'] = (string) $stored_oracle_timeout_ms;
+}
+$probe_mode = html_api_fuzz_min_probe_mode( $options );
+$keep_candidate_artifacts = \HtmlApiFuzz\option_bool( $options, 'keep-candidate-artifacts', false );
+$recorded_timeout_present = array_key_exists( 'processTimeoutMs', $recorded_options );
+$recorded_timeout_ms = null;
+if ( $recorded_timeout_present ) {
+ $recorded_timeout_value = $recorded_options['processTimeoutMs'];
+ $parsed_timeout = ( is_int( $recorded_timeout_value ) || is_string( $recorded_timeout_value ) )
+ ? filter_var( $recorded_timeout_value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_timeout ) {
+ fwrite( STDERR, "Invalid recorded processTimeoutMs: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ $recorded_timeout_ms = (int) $parsed_timeout;
+}
+$parent_oracle_used = 'in-process' === $probe_mode && ! $keep_candidate_artifacts;
+$max_attempts = \HtmlApiFuzz\option_int( $options, 'max-attempts', 600 );
+$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
+try {
+ $setup = \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $oracle_renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ) use ( $replay, $options, $explicit_timeout_ms, $recorded_timeout_ms, $probe_mode, $keep_candidate_artifacts, $original_generator, $target_hash, $target, $source_replay, $parent_oracle_used, $input, $output_dir, $max_attempts, $any_failure ): array {
+ $current_oracle = $renderer->metadata();
+ $current_oracle_options = $renderer->replay_options();
+ $oracle_worker_args = $renderer->worker_args();
+ $oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $replay['oracle'] ?? null, $current_oracle );
+ if ( ! empty( $oracle_mismatches ) && ! \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false ) ) {
+ throw new RuntimeException(
+ 'Oracle identity mismatch: ' . implode( '; ', $oracle_mismatches ) . ".\n" .
+ 'Pass --allow-oracle-mismatch only for a deliberate diagnostic minimization.'
+ );
}
- break;
+ if ( null !== $explicit_timeout_ms ) {
+ $timeout_ms = $explicit_timeout_ms;
+ } elseif ( null !== $recorded_timeout_ms ) {
+ $timeout_ms = $recorded_timeout_ms;
+ } else {
+ $timeout_ms = $renderer->recommended_process_timeout_ms( 'full', 2500 );
+ }
+ $base = array(
+ 'mode' => $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => $replay['profile'] ?? 'replay',
+ 'payloadPolicy' => \HtmlApiFuzz\normalize_payload_policy_label( $replay['payloadPolicy'] ?? null )
+ ?? \HtmlApiFuzz\normalize_payload_policy_label( $replay['generator']['payloadPolicy'] ?? null ),
+ 'fragmentContext' => is_string( $replay['fragmentContext'] ?? null ) ? $replay['fragmentContext'] : 'body',
+ 'originalGenerator' => $original_generator,
+ 'seed' => (int) ( $replay['seed'] ?? 1 ),
+ 'targetHash' => $target_hash,
+ 'targetKind' => $target['kind'] ?? 'failure',
+ 'sourceReplay' => $source_replay,
+ 'oracle' => $current_oracle,
+ 'sourceOracle' => $replay['oracle'] ?? null,
+ 'oracleIdentityMismatches' => $oracle_mismatches,
+ 'oracleRenderer' => $renderer,
+ 'oracleOptions' => array(
+ 'dom-oracle' => $current_oracle_options['domOracle'] ?? \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ 'lexbor-oracle-bin' => $current_oracle_options['lexborOracleBin'] ?? null,
+ 'html5ever-oracle-bin' => $current_oracle_options['html5everOracleBin'] ?? null,
+ 'chrome-oracle-script' => $current_oracle_options['chromeOracleScript'] ?? null,
+ 'chrome-executable' => $current_oracle_options['chromeExecutable'] ?? null,
+ 'node-bin' => $current_oracle_options['nodeBin'] ?? null,
+ 'chrome-startup-timeout-ms' => isset( $current_oracle_options['chromeStartupTimeoutMs'] ) ? (string) $current_oracle_options['chromeStartupTimeoutMs'] : null,
+ 'oracle-timeout-ms' => isset( $current_oracle_options['oracleTimeoutMs'] ) ? (string) $current_oracle_options['oracleTimeoutMs'] : null,
+ ),
+ 'oracleWorkerArgs' => $oracle_worker_args,
+ 'gitMetadataBase64' => \HtmlApiFuzz\git_metadata_base64( \HtmlApiFuzz\git_metadata() ),
+ 'failUnsupported' => (bool) ( $replay['options']['failUnsupported'] ?? ( 'unsupported' === ( $replay['result']['failureClass'] ?? null ) ) ),
+ 'maxTokens' => (int) ( $replay['limits']['maxTokens'] ?? 2000 ),
+ 'maxNodes' => (int) ( $replay['limits']['maxNodes'] ?? 3000 ),
+ 'processTimeoutMs' => $timeout_ms,
+ 'probeMode' => $probe_mode,
+ 'keepCandidateArtifacts' => $keep_candidate_artifacts,
+ );
+ \HtmlApiFuzz\ensure_dir( $output_dir );
+ $reduction = $parent_oracle_used
+ ? html_api_fuzz_min_reduce( $input, $base, $output_dir, $max_attempts, $timeout_ms, $any_failure )
+ : null;
+ unset( $base['oracleRenderer'] );
+ return array(
+ 'base' => $base,
+ 'timeoutMs' => $timeout_ms,
+ 'reduction' => $reduction,
+ );
}
- }
+ );
+} catch ( Throwable $error ) {
+ fwrite( STDERR, $error->getMessage() . "\n" );
+ exit( 1 );
}
+$base = $setup['base'];
+$timeout_ms = $setup['timeoutMs'];
+$reduction = is_array( $setup['reduction'] )
+ ? $setup['reduction']
+ : html_api_fuzz_min_reduce( $input, $base, $output_dir, $max_attempts, $timeout_ms, $any_failure );
+$current = $reduction['current'];
+$attempt_count = $reduction['attemptCount'];
+$probe_stats = $reduction['probeStats'];
$final_dir = $output_dir . '/minimized';
\HtmlApiFuzz\ensure_dir( $final_dir );
@@ -470,6 +590,8 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
(string) $base['maxTokens'],
'--max-nodes',
(string) $base['maxNodes'],
+ '--process-timeout-ms',
+ (string) $timeout_ms,
);
if ( null !== $base['gitMetadataBase64'] ) {
$args[] = '--git-metadata-base64';
@@ -514,6 +636,8 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
}
if ( is_array( $final_replay ) ) {
$final_replay['sourceReplay'] = $base['sourceReplay'];
+ $final_replay['options'] = is_array( $final_replay['options'] ?? null ) ? $final_replay['options'] : array();
+ $final_replay['options']['processTimeoutMs'] = $timeout_ms;
if ( ! empty( $final_identity_mismatches ) ) {
$final_replay['oracle'] = $base['oracle'];
$final_replay['finalActualOracle'] = $final_result['oracle'] ?? null;
@@ -552,6 +676,7 @@ function html_api_fuzz_min_probe_mode( array $options ): string {
'minimizedLength' => strlen( $current ),
'attempts' => $attempt_count,
'probeMode' => $base['probeMode'],
+ 'processTimeoutMs' => $timeout_ms,
'candidateArtifactsRetained' => 'process' === $base['probeMode'] || $base['keepCandidateArtifacts'],
'probeTiming' => array(
'totalDurationMs' => $probe_stats['durationMs'],
diff --git a/tools/html-api-fuzz/replay.php b/tools/html-api-fuzz/replay.php
index e9e789a95b498..9b750449bd408 100755
--- a/tools/html-api-fuzz/replay.php
+++ b/tools/html-api-fuzz/replay.php
@@ -7,8 +7,8 @@
$store_path = \HtmlApiFuzz\option_string( $options, 'store', null );
$stored_replay_value = null;
if ( ( null === $replay_path && null === $store_path ) || \HtmlApiFuzz\option_bool( $options, 'help', false ) ) {
- echo "Usage: php tools/html-api-fuzz/replay.php --replay path/to/replay.json [--output-dir DIR] [--payload-policy POLICY] [--memory-limit LIMIT] [--timeout-ms N] [--worker-script PATH] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch]\n";
- echo " php tools/html-api-fuzz/replay.php --store path/to/results.sqlite (--id N|--seed N) [--output-dir DIR] [--payload-policy POLICY] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--allow-oracle-mismatch]\n";
+ echo "Usage: php tools/html-api-fuzz/replay.php --replay path/to/replay.json [--output-dir DIR] [--payload-policy POLICY] [--memory-limit LIMIT] [--timeout-ms N] [--worker-script PATH] [--dom-oracle php-dom|lexbor-source|html5ever-source|chrome-cdp] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH|--chrome-oracle-script PATH] [--chrome-executable PATH] [--node-bin PATH] [--chrome-startup-timeout-ms N] [--allow-oracle-mismatch]\n";
+ echo " php tools/html-api-fuzz/replay.php --store path/to/results.sqlite (--id N|--seed N) [--output-dir DIR] [--payload-policy POLICY] [--dom-oracle php-dom|lexbor-source|html5ever-source|chrome-cdp] [oracle options] [--allow-oracle-mismatch]\n";
echo "The --store form reproduces a failure whose seed directory was pruned, from the replay stored in the lane's results.sqlite.\n";
exit( ( null === $replay_path && null === $store_path ) ? 1 : 0 );
}
@@ -89,8 +89,9 @@
fwrite( STDERR, "Expected --memory-limit or replay memoryLimit to be -1 or a positive PHP limit such as 256M.\n" );
exit( 1 );
}
+$recorded_timeout_present = array_key_exists( 'processTimeoutMs', $recorded_options );
$recorded_timeout_ms = 2500;
-if ( array_key_exists( 'processTimeoutMs', $recorded_options ) ) {
+if ( $recorded_timeout_present ) {
$recorded_timeout_value = $recorded_options['processTimeoutMs'];
$parsed_timeout = ( is_int( $recorded_timeout_value ) || is_string( $recorded_timeout_value ) )
? filter_var( $recorded_timeout_value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
@@ -101,6 +102,7 @@
}
$recorded_timeout_ms = (int) $parsed_timeout;
}
+$timeout_explicit = array_key_exists( 'timeout-ms', $options );
$timeout_ms = \HtmlApiFuzz\option_int( $options, 'timeout-ms', $recorded_timeout_ms );
if ( $timeout_ms < 1 ) {
fwrite( STDERR, "Expected --timeout-ms or replay processTimeoutMs to be positive.\n" );
@@ -160,13 +162,65 @@
if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'html5ever-oracle-bin', null ) && is_string( $recorded_options['html5everOracleBin'] ?? null ) ) {
$oracle_options['html5ever-oracle-bin'] = $recorded_options['html5everOracleBin'];
}
-$stored_oracle_timeout_ms = $recorded_options['oracleTimeoutMs'] ?? null;
-if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && is_numeric( $stored_oracle_timeout_ms ) ) {
- $oracle_options['oracle-timeout-ms'] = (string) (int) $stored_oracle_timeout_ms;
+foreach ( array( 'chromeOracleScript' => 'chrome-oracle-script', 'chromeExecutable' => 'chrome-executable', 'nodeBin' => 'node-bin' ) as $recorded_name => $option_name ) {
+ if ( ! array_key_exists( $recorded_name, $recorded_options ) ) {
+ continue;
+ }
+ if ( ! is_string( $recorded_options[ $recorded_name ] ) || '' === $recorded_options[ $recorded_name ] ) {
+ fwrite( STDERR, "Invalid recorded {$recorded_name}: expected a non-empty string.\n" );
+ exit( 1 );
+ }
+ if ( null === \HtmlApiFuzz\option_string( $oracle_options, $option_name, null ) ) {
+ $oracle_options[ $option_name ] = $recorded_options[ $recorded_name ];
+ }
+}
+if ( array_key_exists( 'chromeStartupTimeoutMs', $recorded_options ) ) {
+ $recorded_chrome_startup = $recorded_options['chromeStartupTimeoutMs'];
+ $parsed_chrome_startup = ( is_int( $recorded_chrome_startup ) || is_string( $recorded_chrome_startup ) )
+ ? filter_var( $recorded_chrome_startup, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_chrome_startup ) {
+ fwrite( STDERR, "Invalid recorded chromeStartupTimeoutMs: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'chrome-startup-timeout-ms', null ) ) {
+ $oracle_options['chrome-startup-timeout-ms'] = (string) (int) $parsed_chrome_startup;
+ }
}
-$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
-$oracle_worker_args = $oracle_renderer->worker_args();
-$current_oracle = $oracle_renderer->metadata();
+$stored_oracle_timeout_ms = null;
+if ( array_key_exists( 'oracleTimeoutMs', $recorded_options ) ) {
+ $recorded_oracle_timeout = $recorded_options['oracleTimeoutMs'];
+ $parsed_oracle_timeout = ( is_int( $recorded_oracle_timeout ) || is_string( $recorded_oracle_timeout ) )
+ ? filter_var( $recorded_oracle_timeout, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) )
+ : false;
+ if ( false === $parsed_oracle_timeout ) {
+ fwrite( STDERR, "Invalid recorded oracleTimeoutMs: expected a positive integer.\n" );
+ exit( 1 );
+ }
+ $stored_oracle_timeout_ms = (int) $parsed_oracle_timeout;
+}
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && null !== $stored_oracle_timeout_ms ) {
+ $oracle_options['oracle-timeout-ms'] = (string) $stored_oracle_timeout_ms;
+}
+$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
+$oracle_setup = \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $oracle_renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ) use ( $timeout_explicit, $recorded_timeout_present, $timeout_ms, $checks ): array {
+ return array(
+ 'metadata' => $renderer->metadata(),
+ 'workerArgs' => $renderer->worker_args(),
+ 'replayOptions' => $renderer->replay_options(),
+ 'timeoutMs' => ! $timeout_explicit && ! $recorded_timeout_present
+ ? $renderer->recommended_process_timeout_ms( $checks, 2500 )
+ : $timeout_ms,
+ );
+ }
+);
+$current_oracle = $oracle_setup['metadata'];
+$oracle_worker_args = $oracle_setup['workerArgs'];
+$current_oracle_replay_options = $oracle_setup['replayOptions'];
+$timeout_ms = $oracle_setup['timeoutMs'];
+$effective_policy['processTimeoutMs'] = $timeout_ms;
$oracle_mismatches = \HtmlApiFuzz\OracleRenderer::identity_mismatches( $replay['oracle'] ?? null, $current_oracle );
$allow_oracle_mismatch = \HtmlApiFuzz\option_bool( $options, 'allow-oracle-mismatch', false );
if ( ! empty( $oracle_mismatches ) && ! $allow_oracle_mismatch ) {
@@ -224,6 +278,8 @@
(string) \HtmlApiFuzz\option_int( $options, 'max-tree-bytes', (int) ( $replay['limits']['maxTreeBytes'] ?? 16777216 ) ),
'--checks',
$checks,
+ '--process-timeout-ms',
+ (string) $timeout_ms,
'--git-metadata-base64',
$git_metadata_base64,
);
@@ -324,8 +380,8 @@
if ( is_array( $output_replay ) ) {
$output_replay['sourceReplay'] = $source_replay;
$output_options = is_array( $output_replay['options'] ?? null ) ? $output_replay['options'] : array();
- unset( $output_options['domOracle'], $output_options['lexborOracleBin'], $output_options['html5everOracleBin'], $output_options['oracleTimeoutMs'] );
- $output_replay['options'] = array_merge( $output_options, $effective_policy, $oracle_renderer->replay_options() );
+ unset( $output_options['domOracle'], $output_options['lexborOracleBin'], $output_options['html5everOracleBin'], $output_options['chromeOracleScript'], $output_options['chromeExecutable'], $output_options['nodeBin'], $output_options['chromeStartupTimeoutMs'], $output_options['oracleTimeoutMs'] );
+ $output_replay['options'] = array_merge( $output_options, $effective_policy, $current_oracle_replay_options );
$output_replay['oracle'] = $current_oracle;
if ( ! empty( $oracle_mismatches ) ) {
$output_replay['sourceOracle'] = $replay['oracle'] ?? null;
diff --git a/tools/html-api-fuzz/runner.php b/tools/html-api-fuzz/runner.php
index ce51a72cfd4de..dd1fe440baa30 100755
--- a/tools/html-api-fuzz/runner.php
+++ b/tools/html-api-fuzz/runner.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/lib/autoload.php';
function html_api_fuzz_runner_usage(): void {
- echo "Usage: php tools/html-api-fuzz/runner.php [--output-dir DIR] [--start-seed N] [--seed-stride N] [--max-seeds N] [--duration-seconds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH] [--max-keep-per-signature N] [--keep-all-artifacts] [--stop-file PATH]\n";
+ echo "Usage: php tools/html-api-fuzz/runner.php [--output-dir DIR] [--start-seed N] [--seed-stride N] [--max-seeds N] [--duration-seconds N] [--payload-policy POLICY] [--max-input-bytes N] [--dom-oracle php-dom|lexbor-source|html5ever-source|chrome-cdp] [--lexbor-oracle-bin PATH|--html5ever-oracle-bin PATH|--chrome-oracle-script PATH] [--chrome-executable PATH] [--node-bin PATH] [--chrome-startup-timeout-ms N] [--max-keep-per-signature N] [--keep-all-artifacts] [--stop-file PATH]\n";
echo "Use --duration-seconds 0 with --max-seeds 0 for an indefinite run.\n";
echo "Create the stop file (default OUTPUT_DIR/STOP) to stop gracefully: the current batch finishes and no new batch starts.\n";
echo "Oracle findings are recorded separately from failures; pass --triage-oracle-findings to watcher.php to process them.\n";
@@ -55,6 +55,9 @@ function html_api_fuzz_runner_validate_runtime_options( int $seed_stride, int $m
html_api_fuzz_runner_usage();
exit( 0 );
}
+if ( array_key_exists( 'timeout-ms', $options ) && true === $options['timeout-ms'] ) {
+ throw new InvalidArgumentException( 'Expected --timeout-ms to have a value.' );
+}
$repo_root = \HtmlApiFuzz\repo_root();
$output_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', $repo_root . '/artifacts/html-api-fuzz/run-' . \HtmlApiFuzz\timestamp() );
@@ -62,6 +65,7 @@ function html_api_fuzz_runner_validate_runtime_options( int $seed_stride, int $m
$seed_stride = \HtmlApiFuzz\option_int( $options, 'seed-stride', 1 );
$max_seeds = \HtmlApiFuzz\option_int( $options, 'max-seeds', 0 );
$duration_seconds = \HtmlApiFuzz\option_float( $options, 'duration-seconds', 60.0 );
+$timeout_explicit = array_key_exists( 'timeout-ms', $options );
$timeout_ms = \HtmlApiFuzz\option_int( $options, 'timeout-ms', 2500 );
$stop_on_failure = \HtmlApiFuzz\option_bool( $options, 'stop-on-failure', false );
$profile = \HtmlApiFuzz\option_string( $options, 'profile', 'auto' );
@@ -99,9 +103,20 @@ function html_api_fuzz_runner_validate_runtime_options( int $seed_stride, int $m
? \HtmlApiFuzz\git_metadata()
: \HtmlApiFuzz\git_metadata_from_base64( \HtmlApiFuzz\option_string( $options, 'git-metadata-base64' ) );
$git_metadata_base64 = \HtmlApiFuzz\git_metadata_base64( $git_metadata );
-$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $options );
-$oracle_metadata = $oracle_renderer->metadata();
-$oracle_worker_args = $oracle_renderer->worker_args();
+$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $options );
+$oracle_setup = \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $oracle_renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ) use ( $timeout_explicit, $timeout_ms ): array {
+ return array(
+ 'metadata' => $renderer->metadata(),
+ 'workerArgs' => $renderer->worker_args(),
+ 'timeoutMs' => $timeout_explicit ? $timeout_ms : $renderer->recommended_process_timeout_ms( 'full', 2500 ),
+ );
+ }
+);
+$oracle_metadata = $oracle_setup['metadata'];
+$oracle_worker_args = $oracle_setup['workerArgs'];
+$timeout_ms = $oracle_setup['timeoutMs'];
$state = array(
'schemaVersion' => 1,
@@ -119,6 +134,7 @@ function html_api_fuzz_runner_validate_runtime_options( int $seed_stride, int $m
'maxInputBytes' => $max_input_bytes > 0 ? $max_input_bytes : null,
'git' => $git_metadata,
'oracle' => $oracle_metadata,
+ 'processTimeoutMs' => $timeout_ms,
'maxKeepPerSignature' => $max_keep_per_signature,
'keepAllArtifacts' => $keep_all_artifacts,
'stopFile' => $stop_file,
@@ -143,7 +159,7 @@ function html_api_fuzz_runner_validate_runtime_options( int $seed_stride, int $m
$seed = $start_seed;
$count = 0;
-function html_api_fuzz_runner_worker_args( int $seed, string $output_dir, string $profile, string $mode, string $payload_policy, int $max_tokens, int $max_nodes, string $git_metadata_base64, bool $fail_unsupported, int $max_input_bytes, int $corpus_percent, int $batch_count, int $seed_stride, array $oracle_worker_args ): array {
+function html_api_fuzz_runner_worker_args( int $seed, string $output_dir, string $profile, string $mode, string $payload_policy, int $max_tokens, int $max_nodes, string $git_metadata_base64, bool $fail_unsupported, int $max_input_bytes, int $corpus_percent, int $batch_count, int $seed_stride, int $process_timeout_ms, array $oracle_worker_args ): array {
$args = array(
__DIR__ . '/worker.php',
'--seed',
@@ -162,6 +178,8 @@ function html_api_fuzz_runner_worker_args( int $seed, string $output_dir, string
(string) $max_nodes,
'--git-metadata-base64',
$git_metadata_base64,
+ '--process-timeout-ms',
+ (string) $process_timeout_ms,
);
if ( $batch_count > 1 ) {
$args[] = '--batch-count';
@@ -241,7 +259,7 @@ function html_api_fuzz_runner_read_json_or_null( string $path ) {
$batch_keep_log = false;
\HtmlApiFuzz\ensure_dir( dirname( $batch_log ) );
\HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'batch-start', 'seeds' => $batch_seeds, 'logPath' => $batch_log ) );
- $batch_args = html_api_fuzz_runner_worker_args( $batch_seeds[0], $output_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, $batch_count, $seed_stride, $oracle_worker_args );
+ $batch_args = html_api_fuzz_runner_worker_args( $batch_seeds[0], $output_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, $batch_count, $seed_stride, $timeout_ms, $oracle_worker_args );
$batch_proc = \HtmlApiFuzz\run_php_process( $batch_args, $repo_root, $timeout_ms * $batch_count, $batch_log );
$pending_batch = $batch_seeds;
}
@@ -256,7 +274,7 @@ function html_api_fuzz_runner_read_json_or_null( string $path ) {
if ( null === $result ) {
// Isolation fallback: re-run this seed in its own process.
$batch_keep_log = true;
- $args = html_api_fuzz_runner_worker_args( $current_seed, $attempt_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, 1, $seed_stride, $oracle_worker_args );
+ $args = html_api_fuzz_runner_worker_args( $current_seed, $attempt_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, 1, $seed_stride, $timeout_ms, $oracle_worker_args );
$proc = \HtmlApiFuzz\run_php_process( $args, $repo_root, $timeout_ms, $log_path );
$result = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/result.json' );
}
diff --git a/tools/html-api-fuzz/tests/chrome-oracle-adapter-smoke.php b/tools/html-api-fuzz/tests/chrome-oracle-adapter-smoke.php
new file mode 100755
index 0000000000000..a92b291c3b9e6
--- /dev/null
+++ b/tools/html-api-fuzz/tests/chrome-oracle-adapter-smoke.php
@@ -0,0 +1,648 @@
+#!/usr/bin/env php
+ is_file( $path ), 5.0, "Expected {$label} service state." );
+ $state = json_decode( (string) file_get_contents( $path ), true );
+ html_api_fuzz_chrome_adapter_assert( is_array( $state ) && is_string( $state['runtimeRoot'] ?? null ), "Expected valid {$label} service state." );
+ html_api_fuzz_chrome_adapter_wait(
+ static fn (): bool => ! file_exists( $state['runtimeRoot'] ),
+ 10.0,
+ "Expected explicit {$label} runtime cleanup."
+ );
+ return $state;
+}
+
+function html_api_fuzz_chrome_adapter_renderer( string $script, array $real_options, string $case = 'ok', int $render_timeout_ms = 10000, int $startup_timeout_ms = 5000 ): \HtmlApiFuzz\OracleRenderer {
+ putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE=' . $case );
+ return \HtmlApiFuzz\OracleRenderer::from_options(
+ array(
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP,
+ 'chrome-oracle-script' => $script,
+ 'chrome-executable' => $real_options['chromeExecutable'],
+ 'node-bin' => $real_options['nodeBin'],
+ 'oracle-timeout-ms' => (string) $render_timeout_ms,
+ 'chrome-startup-timeout-ms' => (string) $startup_timeout_ms,
+ )
+ );
+}
+
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-chrome-adapter-' . getmypid();
+\HtmlApiFuzz\ensure_dir( $work_dir );
+$fake_script = $work_dir . '/fake-chrome-client.js';
+$fake_source = <<<'JS'
+#!/usr/bin/env node
+'use strict';
+const crypto = require( 'crypto' );
+const fs = require( 'fs' );
+const os = require( 'os' );
+const path = require( 'path' );
+const readline = require( 'readline' );
+
+const repo = process.env.HTML_API_FUZZ_TEST_REPO_ROOT;
+const trust = path.join( repo, 'tools/html-api-fuzz/oracles/chrome' );
+const versionRaw = fs.readFileSync( path.join( trust, 'VERSION' ) );
+const version = versionRaw.toString( 'utf8' ).trim();
+const contextsRaw = fs.readFileSync( path.join( repo, 'tools/html-api-fuzz/oracles/fragment-contexts.json' ) );
+const contexts = JSON.parse( contextsRaw );
+const platform = 'darwin' === process.platform ? ( 'arm64' === process.arch ? 'mac-arm64' : 'mac-x64' ) : 'linux64';
+const digest = ( file ) => crypto.createHash( 'sha256' ).update( fs.readFileSync( file ) ).digest( 'hex' );
+const manifestDigest = ( file, key ) => {
+ const rows = fs.readFileSync( file, 'utf8' ).split( /\r?\n/ ).map( ( line ) => line.trim().split( /\s+/ ) ).filter( ( row ) => 2 === row.length && key === row[ 1 ] );
+ if ( 1 !== rows.length ) throw new Error( 'missing manifest row ' + key );
+ return rows[ 0 ][ 0 ];
+};
+const executableIndex = process.argv.indexOf( '--chrome-executable' );
+const executable = fs.realpathSync( process.argv[ executableIndex + 1 ] );
+const script = fs.realpathSync( __filename );
+const node = fs.realpathSync( process.execPath );
+const root = path.join( os.tmpdir(), 'html-api-fuzz-chrome-' + process.pid + '-' + crypto.randomBytes( 16 ).toString( 'hex' ) );
+const profile = path.join( root, 'profile' );
+fs.mkdirSync( profile, { recursive: true, mode: 0o700 } );
+fs.chmodSync( root, 0o700 );
+const lifecycle = ( event ) => {
+ if ( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE ) {
+ fs.appendFileSync( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE, JSON.stringify( { event, pid: process.pid, runtimeRoot: root } ) + '\n' );
+ }
+};
+lifecycle( 'start' );
+const executableSha = manifestDigest( path.join( trust, 'EXECUTABLE_SHA256SUMS' ), platform + '.executable' );
+if ( digest( executable ) !== executableSha ) throw new Error( 'fake executable trust mismatch' );
+const identity = {
+ schemaVersion: 1,
+ kind: 'chrome-cdp',
+ platform,
+ pinnedChromeVersion: version,
+ chromeArchiveSha256: manifestDigest( path.join( trust, 'SHA256SUMS' ), 'chrome-' + version + '-' + platform + '.zip' ),
+ expectedChromeExecutableSha256: executableSha,
+ chromeExecutableSha256: executableSha,
+ oracleScriptSha256: digest( script ),
+ fragmentContextsSha256: crypto.createHash( 'sha256' ).update( contextsRaw ).digest( 'hex' ),
+ fragmentContexts: contexts,
+ nodeExecutableSha256: digest( node ),
+ nodeVersion: process.version,
+ chromeVersion: version,
+ cdpProtocolVersion: '1.3',
+};
+const oracle = () => ( {
+ kind: 'chrome-cdp',
+ engine: 'chrome',
+ available: true,
+ identity,
+ transport: {
+ replayExcluded: true,
+ ownerPid: process.pid,
+ chromeExecutablePath: executable,
+ oracleScriptPath: script,
+ nodeExecutablePath: node,
+ runtimeRoot: root,
+ profilePath: profile,
+ debugEndpoint: 'ws://127.0.0.1:9222/devtools/browser/fake',
+ supervisorPid: process.pid,
+ browserPid: process.pid,
+ browserInstance: 1,
+ },
+} );
+let cleaned = false;
+const cleanup = () => {
+ if ( cleaned ) return;
+ cleaned = true;
+ try { fs.rmSync( root, { recursive: true, force: true } ); } catch ( error ) {}
+ lifecycle( 'cleanup' );
+};
+const startupCase = process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE || 'ok';
+if ( 'cleanup-deadline' === startupCase ) {
+ setInterval( () => {}, 1000 );
+ for ( const signal of [ 'SIGTERM', 'SIGINT' ] ) process.on( signal, () => {} );
+ process.stdin.on( 'end', () => {} );
+} else {
+ for ( const signal of [ 'SIGTERM', 'SIGINT' ] ) process.on( signal, () => { cleanup(); process.exit( 0 ); } );
+ process.stdin.on( 'end', () => { cleanup(); process.exit( 0 ); } );
+}
+process.on( 'exit', cleanup );
+if ( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE ) {
+ fs.writeFileSync( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE, JSON.stringify( { pid: process.pid, runtimeRoot: root } ) + '\n' );
+}
+const send = ( value ) => process.stdout.write( JSON.stringify( value ) + '\n' );
+const rl = readline.createInterface( { input: process.stdin, crlfDelay: Infinity } );
+rl.on( 'line', ( line ) => {
+ const request = JSON.parse( line );
+ const testCase = process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE || 'ok';
+ if ( 'version' === request.command ) {
+ const respond = () => send( { id: request.id, status: 'ok', oracle: oracle() } );
+ if ( 'startup-timeout' === testCase ) setTimeout( respond, 1000 );
+ else if ( 'startup-slower-than-render' === testCase ) setTimeout( respond, 250 );
+ else respond();
+ return;
+ }
+ if ( 'shutdown' === request.command ) {
+ if ( 'cleanup-deadline' === testCase ) return;
+ const responseId = 'shutdown-wrong-id' === testCase ? request.id + 1 : request.id;
+ const response = JSON.stringify( { id: responseId, status: 'ok', oracle: oracle(), shutdown: true } ) + '\n';
+ lifecycle( 'shutdown-ack' );
+ if ( 'immediate-shutdown' === testCase ) {
+ process.stdout.write( response, () => { cleanup(); process.exit( 0 ); } );
+ } else {
+ process.stdout.write( response );
+ setTimeout( () => { cleanup(); process.exit( 0 ); }, 50 );
+ }
+ return;
+ }
+ if ( 'render' !== request.command ) return;
+ const once = process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_ONCE;
+ if ( ( 'render-timeout-once' === testCase || 'render-death-once' === testCase ) && once && ! fs.existsSync( once ) ) {
+ fs.writeFileSync( once, String( process.pid ) );
+ if ( 'render-death-once' === testCase ) {
+ cleanup();
+ process.exit( 7 );
+ }
+ return;
+ }
+ const input = Buffer.from( request.htmlBase64, 'base64' );
+ if ( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_RENDER_LOG ) {
+ fs.appendFileSync( process.env.HTML_API_FUZZ_TEST_CHROME_ADAPTER_RENDER_LOG, JSON.stringify( { pid: process.pid, bytes: input.length } ) + '\n' );
+ }
+ const tree = 'tree-16m' === testCase ? Buffer.alloc( 16 * 1024 * 1024, 0x78 ) : Buffer.from( '\n' );
+ const result = {
+ id: request.id,
+ status: 'ok',
+ oracle: oracle(),
+ treeBase64: tree.toString( 'base64' ),
+ treeBytes: tree.length,
+ treeSha256: crypto.createHash( 'sha256' ).update( tree ).digest( 'hex' ),
+ nodeCount: 1,
+ };
+ if ( 'extra-key' === testCase ) result.extra = true;
+ if ( 'bad-tree-hash' === testCase ) result.treeSha256 = '0'.repeat( 64 );
+ if ( 'wrong-id' === testCase ) result.id++;
+ if ( 'wrong-identity' === testCase ) result.oracle.identity.nodeVersion += '-forged!';
+ if ( 'missing-field' === testCase ) delete result.nodeCount;
+ if ( 'malformed-json' === testCase ) {
+ process.stdout.write( '{\n' );
+ return;
+ }
+ if ( 'missing-response' === testCase ) return;
+ if ( 'duplicate-key' === testCase ) {
+ process.stdout.write( JSON.stringify( result ).replace( '"status":"ok"', '"status":"ok","status":"ok"' ) + '\n' );
+ return;
+ }
+ if ( 'trailing-frame' === testCase ) {
+ process.stdout.write( JSON.stringify( result ) + '\n{}\n' );
+ return;
+ }
+ if ( 'stderr-overflow' === testCase ) process.stderr.write( 'e'.repeat( 1024 * 1024 + 1 ) );
+ send( result );
+} );
+JS;
+html_api_fuzz_chrome_adapter_assert( strlen( $fake_source ) === file_put_contents( $fake_script, $fake_source ), 'Expected fake Chrome client publication.' );
+html_api_fuzz_chrome_adapter_assert( chmod( $fake_script, 0500 ), 'Expected executable fake Chrome client.' );
+putenv( 'HTML_API_FUZZ_TEST_REPO_ROOT=' . \HtmlApiFuzz\repo_root() );
+
+$real = \HtmlApiFuzz\OracleRenderer::from_options(
+ array(
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP,
+ 'oracle-timeout-ms' => '10000',
+ )
+);
+$real_metadata = $real->metadata();
+html_api_fuzz_chrome_adapter_assert( true === ( $real_metadata['available'] ?? false ), 'Expected pinned real Chrome availability: ' . (string) ( $real_metadata['error'] ?? '' ) );
+html_api_fuzz_chrome_adapter_assert( array() === \HtmlApiFuzz\OracleRenderer::identity_mismatches( $real_metadata, $real_metadata ), 'Expected durable Chrome identity self-match.' );
+$real_options = $real->replay_options();
+html_api_fuzz_chrome_adapter_assert( 90000 === $real->recommended_process_timeout_ms( 'full', 2500 ), 'Expected Common Crawl-style 10-second render budget.' );
+$default_policy = \HtmlApiFuzz\OracleRenderer::from_options( array( 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP ) );
+html_api_fuzz_chrome_adapter_assert( 60000 === $default_policy->recommended_process_timeout_ms( 'full', 2500 ), 'Expected default full Chrome Worker timeout.' );
+html_api_fuzz_chrome_adapter_assert( 52500 === $default_policy->recommended_process_timeout_ms( 'baseline', 2500 ), 'Expected default baseline Chrome Worker timeout.' );
+$default_policy->close();
+$real_full = $real->render( 'adapter
', \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT, array( 'maxNodes' => 3000, 'maxDepth' => 512, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $real_full['status'] ?? null ), 'Expected real Chrome full-document render.' );
+$fragment_limits = array( 'maxNodes' => 3000, 'maxDepth' => 512, 'maxTreeBytes' => 16777216 );
+foreach ( \HtmlApiFuzz\Generator::fragment_contexts() as $context ) {
+ $real_fragment = $real->render( 'x', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $fragment_limits, $context );
+ html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $real_fragment['status'] ?? null ), "Expected real Chrome fragment render in {$context}." );
+}
+$real_invalid = $real->render( "\xFF", \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 3000, 'maxDepth' => 512, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_UNSUPPORTED === ( $real_invalid['status'] ?? null ) && 'invalid-utf8' === ( $real_invalid['failureClass'] ?? null ), 'Expected invalid UTF-8 to remain an explicit unsupported outcome.' );
+$real->close();
+
+$render_log = $work_dir . '/render.ndjson';
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_RENDER_LOG=' . $render_log );
+$fake = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options );
+$fake_metadata = $fake->metadata();
+html_api_fuzz_chrome_adapter_assert( true === ( $fake_metadata['available'] ?? false ), 'Expected authenticated fake service metadata.' );
+$two_mib = str_repeat( 'a', 2 * 1024 * 1024 );
+$accepted = $fake->render( $two_mib, \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $accepted['status'] ?? null ), 'Expected exact 2 MiB Chrome input acceptance.' );
+$rejected = $fake->render( $two_mib . 'b', \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( 'input-byte-limit-exceeded' === ( $rejected['failureClass'] ?? null ), 'Expected 2 MiB plus one local resource result.' );
+$second = $fake->render( ' reuse
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $second['status'] ?? null ), 'Expected persistent fake service reuse.' );
+$fake->close();
+$render_rows = array_values( array_filter( explode( "\n", trim( (string) file_get_contents( $render_log ) ) ) ) );
+html_api_fuzz_chrome_adapter_assert( 2 === count( $render_rows ), 'Expected oversized input rejection without a render frame.' );
+$render_pids = array_map( static fn ( string $row ): int => (int) ( json_decode( $row, true )['pid'] ?? 0 ), $render_rows );
+html_api_fuzz_chrome_adapter_assert( 1 === count( array_unique( $render_pids ) ), 'Expected baseline and later render to reuse one Node service.' );
+$worker_resource = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $two_mib . 'b' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'checks' => 'baseline',
+ 'output-dir' => $work_dir . '/worker-input-limit',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP,
+ 'chrome-oracle-script' => $fake_script,
+ 'chrome-executable' => $real_options['chromeExecutable'],
+ 'node-bin' => $real_options['nodeBin'],
+ 'oracle-timeout-ms' => '10000',
+ 'chrome-startup-timeout-ms' => '5000',
+ 'process-timeout-ms' => '30000',
+ )
+);
+html_api_fuzz_chrome_adapter_assert( 'resource-limit' === ( $worker_resource['failureClass'] ?? null ) && 'resource-limit' === ( $worker_resource['status'] ?? null ), 'Expected Worker to classify the Chrome input ceiling as a resource limit.' );
+$resource_render_rows = array_values( array_filter( explode( "\n", trim( (string) file_get_contents( $render_log ) ) ) ) );
+html_api_fuzz_chrome_adapter_assert( 2 === count( $resource_render_rows ), 'Expected Worker input-limit classification without a render frame.' );
+
+$large = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'tree-16m' );
+$large_tree = $large->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 16777216 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( 16777216 === strlen( $large_tree['tree'] ?? '' ), 'Expected exact 16 MiB decoded tree transport.' );
+$large->close();
+
+foreach ( array( 'extra-key', 'bad-tree-hash', 'duplicate-key', 'trailing-frame', 'wrong-id', 'wrong-identity', 'missing-field', 'malformed-json' ) as $case ) {
+ $hostile = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, $case );
+ $result = $hostile->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+ html_api_fuzz_chrome_adapter_assert( 'oracle-renderer-error' === ( $result['failureClass'] ?? null ) && true === ( $result['infrastructure'] ?? false ), "Expected {$case} to fail as infrastructure." );
+ $hostile->close();
+}
+$missing = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'missing-response', 100, 1000 );
+$missing_result = $missing->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( 'oracle-renderer-error' === ( $missing_result['failureClass'] ?? null ) && false !== strpos( (string) ( $missing_result['error'] ?? '' ), 'timed out' ), 'Expected a missing render response to time out as infrastructure.' );
+$missing->close();
+
+$original_fake_source = (string) file_get_contents( $fake_script );
+$trust_drift = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options );
+html_api_fuzz_chrome_adapter_assert( true === ( $trust_drift->metadata()['available'] ?? false ), 'Expected trust-drift service startup.' );
+html_api_fuzz_chrome_adapter_assert( chmod( $fake_script, 0700 ), 'Expected temporary fake-script write permission.' );
+html_api_fuzz_chrome_adapter_assert( false !== file_put_contents( $fake_script, "\n// local trust drift\n", FILE_APPEND ), 'Expected local trust drift mutation.' );
+$trust_drift_result = $trust_drift->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+html_api_fuzz_chrome_adapter_assert( strlen( $original_fake_source ) === file_put_contents( $fake_script, $original_fake_source ) && chmod( $fake_script, 0500 ), 'Expected fake-script trust restoration.' );
+html_api_fuzz_chrome_adapter_assert( 'oracle-renderer-error' === ( $trust_drift_result['failureClass'] ?? null ) && true === ( $trust_drift_result['infrastructure'] ?? false ), 'Expected local script trust drift to fail closed.' );
+$trust_drift->close();
+
+foreach ( array( 'render-timeout-once', 'render-death-once' ) as $restart_case ) {
+ $once = $work_dir . '/' . $restart_case . '.once';
+ putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_ONCE=' . $once );
+ $restart = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, $restart_case, 100, 1000 );
+ $first_restart = $restart->render( 'first
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+ $second_restart = $restart->render( 'second
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+ html_api_fuzz_chrome_adapter_assert( 'oracle-renderer-error' === ( $first_restart['failureClass'] ?? null ) && true === ( $first_restart['infrastructure'] ?? false ), "Expected {$restart_case} first render to fail as infrastructure." );
+ html_api_fuzz_chrome_adapter_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $second_restart['status'] ?? null ), "Expected {$restart_case} later render to restart successfully." );
+ $restart->close();
+ @unlink( $once );
+}
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_ONCE' );
+
+$slow_start = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'startup-slower-than-render', 50, 1000 );
+html_api_fuzz_chrome_adapter_assert( true === ( $slow_start->metadata()['available'] ?? false ), 'Expected startup to use its independent longer budget.' );
+$slow_start->close();
+
+$immediate_shutdown = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'immediate-shutdown' );
+html_api_fuzz_chrome_adapter_assert( true === ( $immediate_shutdown->metadata()['available'] ?? false ), 'Expected immediate-shutdown service startup.' );
+$immediate_shutdown->close();
+
+$dead_before_close_state = $work_dir . '/dead-before-close-state.json';
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $dead_before_close_state );
+$dead_before_close = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options );
+html_api_fuzz_chrome_adapter_assert( true === ( $dead_before_close->metadata()['available'] ?? false ), 'Expected dead-before-close service startup.' );
+html_api_fuzz_chrome_adapter_wait( static fn (): bool => is_file( $dead_before_close_state ), 5.0, 'Expected dead-before-close service state.' );
+$dead_before_close_owner = json_decode( (string) file_get_contents( $dead_before_close_state ), true );
+html_api_fuzz_chrome_adapter_assert( is_array( $dead_before_close_owner ) && posix_kill( (int) $dead_before_close_owner['pid'], SIGTERM ), 'Expected explicit service termination before close.' );
+html_api_fuzz_chrome_adapter_wait(
+ static fn (): bool => ! file_exists( (string) $dead_before_close_owner['runtimeRoot'] ),
+ 5.0,
+ 'Expected dead service to finish its own signal cleanup.'
+);
+$dead_close_failed = false;
+try {
+ $dead_before_close->close();
+} catch ( RuntimeException $error ) {
+ $dead_close_failed = false !== strpos( $error->getMessage(), 'shutdown acknowledgement' );
+}
+html_api_fuzz_chrome_adapter_assert( $dead_close_failed, 'Expected normal close to reject a service that died before acknowledgement.' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE' );
+
+$cleanup_deadline_state = $work_dir . '/cleanup-deadline-state.json';
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $cleanup_deadline_state );
+$cleanup_deadline = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'cleanup-deadline', 1000, 1000 );
+$cleanup_deadline_metadata = $cleanup_deadline->metadata();
+html_api_fuzz_chrome_adapter_assert( true === ( $cleanup_deadline_metadata['available'] ?? false ), 'Expected cleanup-deadline service startup.' );
+html_api_fuzz_chrome_adapter_wait( static fn (): bool => is_file( $cleanup_deadline_state ), 5.0, 'Expected cleanup-deadline service state.' );
+$cleanup_deadline_owner = json_decode( (string) file_get_contents( $cleanup_deadline_state ), true );
+$cleanup_started = microtime( true );
+$cleanup_deadline_failed = false;
+try {
+ $cleanup_deadline->close();
+} catch ( RuntimeException $error ) {
+ $cleanup_deadline_failed = false !== strpos( $error->getMessage(), 'runtime root survived cleanup' );
+}
+$cleanup_elapsed = microtime( true ) - $cleanup_started;
+html_api_fuzz_chrome_adapter_assert( $cleanup_deadline_failed, 'Expected the uncooperative service to fail verified cleanup.' );
+html_api_fuzz_chrome_adapter_assert( $cleanup_elapsed <= 10.75, 'Expected the complete cleanup path to honor its absolute 10-second budget.' );
+if ( is_array( $cleanup_deadline_owner ) && is_string( $cleanup_deadline_owner['runtimeRoot'] ?? null ) ) {
+ \HtmlApiFuzz\remove_dir_recursive( $cleanup_deadline_owner['runtimeRoot'] );
+}
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE' );
+
+$stderr_hostile = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'stderr-overflow' );
+$stderr_result = $stderr_hostile->render( 'x
', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, array( 'maxNodes' => 10, 'maxDepth' => 10, 'maxTreeBytes' => 1024 ), 'body' );
+$stderr_close_failed = false;
+try {
+ $stderr_hostile->close();
+} catch ( RuntimeException $error ) {
+ $stderr_close_failed = false !== strpos( $error->getMessage(), 'stderr exceeded 1 MiB' );
+}
+html_api_fuzz_chrome_adapter_assert(
+ ( 'oracle-renderer-error' === ( $stderr_result['failureClass'] ?? null ) && true === ( $stderr_result['infrastructure'] ?? false ) ) || $stderr_close_failed,
+ 'Expected stderr overflow to surface during render or mandatory close.'
+);
+$worker_stderr = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'checks' => 'baseline',
+ 'output-dir' => $work_dir . '/worker-stderr-overflow',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP,
+ 'chrome-oracle-script' => $fake_script,
+ 'chrome-executable' => $real_options['chromeExecutable'],
+ 'node-bin' => $real_options['nodeBin'],
+ 'oracle-timeout-ms' => '10000',
+ 'chrome-startup-timeout-ms' => '5000',
+ 'process-timeout-ms' => '30000',
+ )
+);
+html_api_fuzz_chrome_adapter_assert( 'oracle-renderer-error' === ( $worker_stderr['failureClass'] ?? null ) && true === ( $worker_stderr['oracleInfrastructure'] ?? false ), 'Expected Worker to surface mandatory Chrome cleanup failure.' );
+html_api_fuzz_chrome_adapter_assert( false === ( $worker_stderr['oracleCleanup']['ok'] ?? true ) && is_array( $worker_stderr['signature'] ?? null ), 'Expected replayable cleanup evidence and a recomputed signature.' );
+
+$startup = html_api_fuzz_chrome_adapter_renderer( $fake_script, $real_options, 'startup-timeout', 10000, 100 );
+$startup_metadata = $startup->metadata();
+html_api_fuzz_chrome_adapter_assert( false === ( $startup_metadata['available'] ?? true ) && false !== strpos( (string) ( $startup_metadata['error'] ?? '' ), 'timed out' ), 'Expected independent startup deadline enforcement.' );
+$startup->close();
+
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE=ok' );
+$overflow = (string) PHP_INT_MAX;
+$owner_cli_options = array(
+ '--dom-oracle', 'chrome-cdp',
+ '--chrome-oracle-script', $fake_script,
+ '--chrome-executable', $real_options['chromeExecutable'],
+ '--node-bin', $real_options['nodeBin'],
+ '--oracle-timeout-ms', '1000',
+ '--chrome-startup-timeout-ms', $overflow,
+);
+foreach ( array( 'runner.php' => 'runner', 'launcher.php' => 'launcher' ) as $owner_script => $owner_label ) {
+ $state_path = $work_dir . '/' . $owner_label . '-owner-state.json';
+ putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $state_path );
+ $args = array_merge(
+ array( dirname( __DIR__ ) . '/' . $owner_script, '--output-dir', $work_dir . '/' . $owner_label . '-owner-output', '--max-seeds', '1' ),
+ $owner_cli_options
+ );
+ $owner_proc = \HtmlApiFuzz\run_php_process( $args, \HtmlApiFuzz\repo_root(), 10000, $work_dir . '/' . $owner_label . '-owner.log', 1048576, true );
+ html_api_fuzz_chrome_adapter_assert( false === ( $owner_proc['timedOut'] ?? true ) && 0 !== ( $owner_proc['code'] ?? 0 ), "Expected {$owner_label} post-start timeout overflow." );
+ html_api_fuzz_chrome_adapter_assert_state_clean( $state_path, $owner_label );
+}
+
+$commoncrawl_state = $work_dir . '/commoncrawl-owner-state.json';
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $commoncrawl_state );
+putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $work_dir . '/commoncrawl-owner-output' );
+putenv( 'HTML_API_CC_ORACLE=chrome-cdp' );
+putenv( 'HTML_API_CC_CHECKS=invalid-after-start' );
+putenv( 'HTML_API_FUZZ_CHROME_ORACLE=' . $fake_script );
+putenv( 'HTML_API_FUZZ_CHROME_EXECUTABLE=' . $real_options['chromeExecutable'] );
+putenv( 'HTML_API_FUZZ_NODE_BIN=' . $real_options['nodeBin'] );
+putenv( 'HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS=1000' );
+$commoncrawl_failed = false;
+try {
+ \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+} catch ( Throwable $error ) {
+ $commoncrawl_failed = false !== strpos( $error->getMessage(), 'HTML_API_CC_CHECKS' );
+}
+html_api_fuzz_chrome_adapter_assert( $commoncrawl_failed, 'Expected Common Crawl validation failure after Chrome startup.' );
+html_api_fuzz_chrome_adapter_assert_state_clean( $commoncrawl_state, 'Common Crawl owner' );
+foreach ( array( 'CC_ANALYZER_OUTPUT_DIR', 'HTML_API_CC_ORACLE', 'HTML_API_CC_CHECKS', 'HTML_API_FUZZ_CHROME_ORACLE', 'HTML_API_FUZZ_CHROME_EXECUTABLE', 'HTML_API_FUZZ_NODE_BIN', 'HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS' ) as $name ) {
+ putenv( $name );
+}
+
+$worker_owner_state = $work_dir . '/worker-owner-state.json';
+$worker_owner_dir = $work_dir . '/worker-owner-output';
+\HtmlApiFuzz\ensure_dir( $worker_owner_dir . '/input.bin' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $worker_owner_state );
+$worker_publication_failed = false;
+set_error_handler( static fn (): bool => true );
+try {
+ try {
+ \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'checks' => 'baseline',
+ 'output-dir' => $worker_owner_dir,
+ 'dom-oracle' => 'chrome-cdp',
+ 'chrome-oracle-script' => $fake_script,
+ 'chrome-executable' => $real_options['chromeExecutable'],
+ 'node-bin' => $real_options['nodeBin'],
+ 'oracle-timeout-ms' => '1000',
+ 'chrome-startup-timeout-ms' => '1000',
+ )
+ );
+ } catch ( Throwable $error ) {
+ $worker_publication_failed = false !== strpos( $error->getMessage(), 'atomically publish' );
+ }
+} finally {
+ restore_error_handler();
+}
+html_api_fuzz_chrome_adapter_assert( $worker_publication_failed, 'Expected Worker publication failure after Chrome startup.' );
+html_api_fuzz_chrome_adapter_assert_state_clean( $worker_owner_state, 'Worker owner' );
+
+$worker_cli_lifecycle = $work_dir . '/worker-cli-fatal-lifecycle.ndjson';
+$worker_cli_dir = $work_dir . '/worker-cli-fatal-output';
+\HtmlApiFuzz\ensure_dir( $worker_cli_dir . '/input.bin' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE=shutdown-wrong-id' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE=' . $worker_cli_lifecycle );
+$worker_cli_fatal = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/worker.php',
+ '--input-base64', base64_encode( 'x
' ),
+ '--profile', 'replay',
+ '--mode', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ '--checks', 'baseline',
+ '--output-dir', $worker_cli_dir,
+ '--dom-oracle', 'chrome-cdp',
+ '--chrome-oracle-script', $fake_script,
+ '--chrome-executable', $real_options['chromeExecutable'],
+ '--node-bin', $real_options['nodeBin'],
+ '--oracle-timeout-ms', '1000',
+ '--chrome-startup-timeout-ms', '1000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 15000,
+ $work_dir . '/worker-cli-fatal.log',
+ 1048576,
+ true
+);
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE' );
+html_api_fuzz_chrome_adapter_assert( false === ( $worker_cli_fatal['timedOut'] ?? true ) && 1 === ( $worker_cli_fatal['code'] ?? 0 ), 'Expected bounded Worker CLI fatal fallback.' );
+$worker_cli_fatal_result = json_decode( (string) file_get_contents( $worker_cli_dir . '/result.json' ), true );
+html_api_fuzz_chrome_adapter_assert( false === ( $worker_cli_fatal_result['oracleCleanup']['ok'] ?? true ), 'Expected the Worker CLI fatal result to surface fallback cleanup failure.' );
+$worker_cli_rows = array_values( array_filter( array_map( static fn ( string $line ) => json_decode( $line, true ), explode( "\n", trim( (string) @file_get_contents( $worker_cli_lifecycle ) ) ) ), 'is_array' ) );
+$worker_cli_starts = array_values( array_filter( $worker_cli_rows, static fn ( array $row ): bool => 'start' === ( $row['event'] ?? null ) ) );
+html_api_fuzz_chrome_adapter_assert( 2 === count( $worker_cli_starts ), 'Expected one primary and one fatal-fallback Chrome service.' );
+foreach ( $worker_cli_starts as $row ) {
+ html_api_fuzz_chrome_adapter_assert( ! file_exists( (string) ( $row['runtimeRoot'] ?? '' ) ), 'Expected explicit cleanup of every Worker CLI fatal-path Chrome service.' );
+}
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE=ok' );
+
+$owner_replay = array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 1,
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'fragmentContext' => 'body',
+ 'inputBase64' => base64_encode( 'x
' ),
+ 'limits' => array( 'maxTokens' => 100, 'maxNodes' => 100, 'maxDepth' => 100, 'maxTreeBytes' => 1024 ),
+ 'options' => array(
+ 'domOracle' => 'chrome-cdp',
+ 'chromeOracleScript' => $fake_script,
+ 'chromeExecutable' => $real_options['chromeExecutable'],
+ 'nodeBin' => $real_options['nodeBin'],
+ 'oracleTimeoutMs' => 1000,
+ 'chromeStartupTimeoutMs'=> 1000,
+ ),
+ 'oracle' => $fake_metadata,
+);
+$replay_fixture = $work_dir . '/owner-replay.json';
+\HtmlApiFuzz\write_json_file( $replay_fixture, $owner_replay );
+$replay_owner_state = $work_dir . '/replay-owner-state.json';
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $replay_owner_state );
+$replay_proc = \HtmlApiFuzz\run_php_process(
+ array_merge(
+ array( dirname( __DIR__ ) . '/replay.php', '--replay', $replay_fixture, '--output-dir', $work_dir . '/replay-owner-output' ),
+ $owner_cli_options
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $work_dir . '/replay-owner.log',
+ 1048576,
+ true
+);
+html_api_fuzz_chrome_adapter_assert( false === ( $replay_proc['timedOut'] ?? true ) && 0 !== ( $replay_proc['code'] ?? 0 ), 'Expected replay post-start timeout overflow.' );
+html_api_fuzz_chrome_adapter_assert_state_clean( $replay_owner_state, 'replay owner' );
+
+$minimize_lifecycle = $work_dir . '/minimize-lifecycle.ndjson';
+$minimize_override_replay = $owner_replay;
+$minimize_override_replay['options']['chromeStartupTimeoutMs'] = PHP_INT_MAX;
+$minimize_override_path = $work_dir . '/minimize-explicit-timeout-replay.json';
+\HtmlApiFuzz\write_json_file( $minimize_override_path, $minimize_override_replay );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE=' . $minimize_lifecycle );
+$minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay', $minimize_override_path,
+ '--output-dir', $work_dir . '/minimize-owner-output',
+ '--probe-mode', 'in-process',
+ '--max-attempts', '0',
+ '--any-failure',
+ '--timeout-ms', '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 30000,
+ $work_dir . '/minimize-owner.log',
+ 1048576,
+ true
+);
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_LIFECYCLE' );
+html_api_fuzz_chrome_adapter_assert( false === ( $minimize_proc['timedOut'] ?? true ) && 0 === ( $minimize_proc['code'] ?? 1 ), 'Expected the explicit minimizer timeout to bypass recommendation overflow.' );
+$lifecycle_rows = array_values( array_filter( array_map( static fn ( string $line ) => json_decode( $line, true ), explode( "\n", trim( (string) @file_get_contents( $minimize_lifecycle ) ) ) ), 'is_array' ) );
+$start_indexes = array_keys( array_filter( $lifecycle_rows, static fn ( array $row ): bool => 'start' === ( $row['event'] ?? null ) ) );
+$ack_indexes = array_keys( array_filter( $lifecycle_rows, static fn ( array $row ): bool => 'shutdown-ack' === ( $row['event'] ?? null ) ) );
+html_api_fuzz_chrome_adapter_assert( 2 === count( $start_indexes ) && 2 === count( $ack_indexes ), 'Expected exactly one parent and one final-Worker Chrome service.' );
+html_api_fuzz_chrome_adapter_assert( $ack_indexes[0] < $start_indexes[1], 'Expected parent Chrome shutdown acknowledgement before final Worker startup.' );
+html_api_fuzz_chrome_adapter_assert( $lifecycle_rows[ $start_indexes[0] ]['pid'] !== $lifecycle_rows[ $start_indexes[1] ]['pid'], 'Expected distinct bounded parent and final Worker services.' );
+foreach ( $lifecycle_rows as $row ) {
+ if ( 'start' === ( $row['event'] ?? null ) ) {
+ html_api_fuzz_chrome_adapter_assert( ! file_exists( (string) ( $row['runtimeRoot'] ?? '' ) ), 'Expected every minimizer Chrome runtime root removed.' );
+ }
+}
+
+$pause_marker = $work_dir . '/real-handshake-pause.json';
+$pause_helper = $work_dir . '/pause-helper.php';
+$pause_source = '"chrome-cdp","chrome-oracle-script"=>$argv[1],"chrome-executable"=>$argv[2],"node-bin"=>$argv[3],"chrome-startup-timeout-ms"=>"35000"]); $r->metadata();';
+html_api_fuzz_chrome_adapter_assert( strlen( $pause_source ) === file_put_contents( $pause_helper, $pause_source ), 'Expected paused metadata helper publication.' );
+putenv( 'HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_CDP_HANDSHAKE=' . $pause_marker );
+$paused_process = \HtmlApiFuzz\run_php_process(
+ array( $pause_helper, $real_options['chromeOracleScript'], $real_options['chromeExecutable'], $real_options['nodeBin'] ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $work_dir . '/paused-metadata.log',
+ 1048576,
+ true
+);
+putenv( 'HTML_API_FUZZ_CHROME_TEST_PAUSE_AFTER_CDP_HANDSHAKE' );
+html_api_fuzz_chrome_adapter_assert( true === ( $paused_process['timedOut'] ?? false ), 'Expected an explicit outer timeout during the real CDP handshake pause.' );
+html_api_fuzz_chrome_adapter_assert( is_file( $pause_marker ), 'Expected authenticated paused Chrome process evidence.' );
+$paused_state = json_decode( (string) file_get_contents( $pause_marker ), true );
+@unlink( $pause_marker );
+html_api_fuzz_chrome_adapter_wait(
+ static fn (): bool => is_array( $paused_state ) && ! file_exists( (string) ( $paused_state['runtimeRoot'] ?? '' ) ) && ! file_exists( (string) ( $paused_state['profilePath'] ?? '' ) ),
+ 15.0,
+ 'Expected outer-timeout cleanup of the paused real Chrome runtime and profile.'
+);
+
+$owner_state = $work_dir . '/owner-state.json';
+$owner_helper = $work_dir . '/owner-helper.php';
+$owner_source = '"chrome-cdp","chrome-oracle-script"=>$argv[1],"chrome-executable"=>$argv[2],"node-bin"=>$argv[3],"chrome-startup-timeout-ms"=>"5000"]); $m=$r->metadata(); if (!($m["available"]??false)) { exit(2); } while (true) { usleep(100000); }';
+html_api_fuzz_chrome_adapter_assert( strlen( $owner_source ) === file_put_contents( $owner_helper, $owner_source ), 'Expected owner helper publication.' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE=ok' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE=' . $owner_state );
+$owner = proc_open( array( PHP_BINARY, $owner_helper, $fake_script, $real_options['chromeExecutable'], $real_options['nodeBin'] ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'file', '/dev/null', 'a' ), 2 => array( 'file', '/dev/null', 'a' ) ), $owner_pipes, \HtmlApiFuzz\repo_root(), null, array( 'bypass_shell' => true ) );
+html_api_fuzz_chrome_adapter_assert( is_resource( $owner ), 'Expected owner helper process.' );
+$owner_pid = (int) ( proc_get_status( $owner )['pid'] ?? 0 );
+html_api_fuzz_chrome_adapter_wait( static fn (): bool => is_file( $owner_state ), 5.0, 'Expected fake service owner state.' );
+$owned = json_decode( (string) file_get_contents( $owner_state ), true );
+html_api_fuzz_chrome_adapter_assert( posix_kill( $owner_pid, SIGKILL ), 'Expected owner termination.' );
+fclose( $owner_pipes[0] );
+html_api_fuzz_chrome_adapter_wait(
+ static fn (): bool => ! posix_kill( (int) $owned['pid'], 0 ) && ! file_exists( (string) $owned['runtimeRoot'] ),
+ 10.0,
+ 'Expected Node EOF cleanup after abrupt PHP owner death.'
+);
+proc_close( $owner );
+
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_STATE' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_RENDER_LOG' );
+putenv( 'HTML_API_FUZZ_TEST_CHROME_ADAPTER_CASE' );
+putenv( 'HTML_API_FUZZ_TEST_REPO_ROOT' );
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+echo "PASS: Chrome oracle adapter smoke checks\n";
diff --git a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
index 51e2788564cf0..d0ac20eede611 100755
--- a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
+++ b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
@@ -208,6 +208,15 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
$invalid_memory = $timeout_replay;
$invalid_memory['options']['memoryLimit'] = null;
$malformed_policy_cases['invalid-memory'] = $invalid_memory;
+ $fractional_chrome_startup = $timeout_replay;
+ $fractional_chrome_startup['options']['chromeStartupTimeoutMs'] = 1.5;
+ $malformed_policy_cases['fractional-chrome-startup'] = $fractional_chrome_startup;
+ $fractional_oracle_timeout = $timeout_replay;
+ $fractional_oracle_timeout['options']['oracleTimeoutMs'] = 1.5;
+ $malformed_policy_cases['fractional-oracle-timeout'] = $fractional_oracle_timeout;
+ $empty_chrome_script = $timeout_replay;
+ $empty_chrome_script['options']['chromeOracleScript'] = '';
+ $malformed_policy_cases['empty-chrome-script'] = $empty_chrome_script;
foreach ( $malformed_policy_cases as $case_name => $malformed_policy_replay ) {
$malformed_policy_path = $timeout_dir . '/malformed-policy-' . $case_name . '.json';
$malformed_policy_output = $timeout_dir . '/malformed-policy-' . $case_name;
diff --git a/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
index 9924b2b0232b7..a9ec9e3d92a5f 100644
--- a/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
+++ b/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
@@ -48,6 +48,7 @@ function html_api_fuzz_cc_sources_process( array $arguments, int $timeout_ms = 6
$source_cases = array(
\HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE => array( 'binary' => $lexbor_binary, 'option' => 'lexborOracleBin' ),
\HtmlApiFuzz\OracleRenderer::KIND_HTML5EVER_SOURCE => array( 'binary' => $html5ever_binary, 'option' => 'html5everOracleBin' ),
+ \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP => array( 'option' => 'chromeOracleScript' ),
);
$replays = array();
$metadata_by_kind = array();
@@ -66,6 +67,15 @@ function html_api_fuzz_cc_sources_process( array $arguments, int $timeout_ms = 6
putenv( 'HTML_API_CC_MAX_TREE_BYTES=1048576' );
foreach ( $source_cases as $kind => $case ) {
+ if ( \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP === $kind ) {
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS' );
+ putenv( 'HTML_API_CC_CHECKS=sampled' );
+ putenv( 'HTML_API_CC_FULL_SAMPLE_PERCENT=0' );
+ } else {
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=30000' );
+ putenv( 'HTML_API_CC_CHECKS=baseline' );
+ putenv( 'HTML_API_CC_FULL_SAMPLE_PERCENT' );
+ }
$oracle = \HtmlApiFuzz\OracleRenderer::from_options(
array(
'dom-oracle' => $kind,
@@ -76,6 +86,8 @@ function html_api_fuzz_cc_sources_process( array $arguments, int $timeout_ms = 6
);
$metadata = $oracle->metadata();
html_api_fuzz_cc_sources_assert( true === ( $metadata['available'] ?? false ), "Expected {$kind} availability: " . (string) ( $metadata['error'] ?? '' ) );
+ $oracle_replay_options = $oracle->replay_options();
+ $oracle->close();
$metadata_by_kind[ $kind ] = $metadata;
$output_dir = $work_dir . '/' . $kind;
putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $output_dir );
@@ -110,15 +122,37 @@ function html_api_fuzz_cc_sources_process( array $arguments, int $timeout_ms = 6
html_api_fuzz_cc_sources_assert( $metadata === ( $replay['oracle'] ?? null ), "Expected normalized {$kind} replay identity." );
html_api_fuzz_cc_sources_assert( $body === base64_decode( $replay['inputBase64'] ?? '', true ), "Expected exact {$kind} replay bytes." );
html_api_fuzz_cc_sources_assert( $kind === ( $replay['options']['domOracle'] ?? null ), "Expected {$kind} replay selection." );
- html_api_fuzz_cc_sources_assert( $case['binary'] === ( $replay['options'][ $case['option'] ] ?? null ), "Expected {$kind} replay binary path." );
- $irrelevant_option = 'lexborOracleBin' === $case['option'] ? 'html5everOracleBin' : 'lexborOracleBin';
- html_api_fuzz_cc_sources_assert( ! array_key_exists( $irrelevant_option, $replay['options'] ), "Expected no stale {$irrelevant_option} for {$kind}." );
+ $expected_path = $case['binary'] ?? $oracle_replay_options[ $case['option'] ] ?? null;
+ html_api_fuzz_cc_sources_assert( $expected_path === ( $replay['options'][ $case['option'] ] ?? null ), "Expected {$kind} replay executable/script path." );
+ foreach ( array( 'lexborOracleBin', 'html5everOracleBin' ) as $source_option ) {
+ if ( $source_option !== $case['option'] ) {
+ html_api_fuzz_cc_sources_assert( ! array_key_exists( $source_option, $replay['options'] ), "Expected no stale {$source_option} for {$kind}." );
+ }
+ }
+ if ( \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP === $kind ) {
+ html_api_fuzz_cc_sources_assert( is_string( $replay['options']['chromeExecutable'] ?? null ), 'Expected durable Chrome executable replay path.' );
+ html_api_fuzz_cc_sources_assert( is_string( $replay['options']['nodeBin'] ?? null ), 'Expected durable Node replay path.' );
+ html_api_fuzz_cc_sources_assert( 35000 === ( $replay['options']['chromeStartupTimeoutMs'] ?? null ), 'Expected effective Chrome startup timeout.' );
+ html_api_fuzz_cc_sources_assert( 90000 === ( $replay['options']['processTimeoutMs'] ?? null ), 'Expected sampled Common Crawl Chrome process budget.' );
+ }
html_api_fuzz_cc_sources_assert( \HtmlApiFuzz\OracleRenderer::identity_sha256( $metadata ) === ( $configuration['oracleIdentitySha256'] ?? null ), "Expected pinned {$kind} configuration identity." );
html_api_fuzz_cc_sources_assert( $metadata === ( $configuration['oracle'] ?? null ), "Expected normalized {$kind} configuration metadata." );
$replays[ $kind ] = $replay;
}
$source_replay = $replays[ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE ];
+ $chrome_replay = $replays[ \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP ];
+ foreach ( array( 'chromeStartupTimeoutMs' => 1.5, 'chromeExecutable' => '' ) as $field => $invalid_value ) {
+ $invalid_chrome = $chrome_replay;
+ $invalid_chrome['options'][ $field ] = $invalid_value;
+ $invalid_chrome_path = $work_dir . '/invalid-chrome-' . $field . '.json';
+ $invalid_chrome_output = $work_dir . '/invalid-chrome-' . $field;
+ \HtmlApiFuzz\write_json_file_atomic( $invalid_chrome_path, $invalid_chrome );
+ $invalid_chrome_process = html_api_fuzz_cc_sources_process(
+ array( dirname( __DIR__ ) . '/replay.php', '--replay', $invalid_chrome_path, '--output-dir', $invalid_chrome_output )
+ );
+ html_api_fuzz_cc_sources_assert( 1 === $invalid_chrome_process['code'] && ! is_dir( $invalid_chrome_output ), "Expected invalid recorded {$field} rejection before output creation." );
+ }
$tampered = $source_replay;
$tampered['oracle']['identity']['binarySha256'] = str_repeat( '0', 64 );
$tampered_path = $work_dir . '/tampered-replay.json';
@@ -233,7 +267,7 @@ function html_api_fuzz_cc_sources_process( array $arguments, int $timeout_ms = 6
array(
'CC_ANALYZER_OUTPUT_DIR', 'HTML_API_CC_ORACLE', 'HTML_API_CC_EXPECT_LEXBOR_COMMIT', 'HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256',
'HTML_API_CC_WORKER_SCRIPT', 'HTML_API_FUZZ_LEXBOR_ORACLE', 'HTML_API_FUZZ_HTML5EVER_ORACLE', 'HTML_API_CC_RETAIN_ALL',
- 'HTML_API_CC_REQUIRE_UTF8', 'HTML_API_CC_CHECKS', 'HTML_API_CC_PROCESS_TIMEOUT_MS', 'HTML_API_CC_ORACLE_TIMEOUT_MS',
+ 'HTML_API_CC_REQUIRE_UTF8', 'HTML_API_CC_CHECKS', 'HTML_API_CC_FULL_SAMPLE_PERCENT', 'HTML_API_CC_PROCESS_TIMEOUT_MS', 'HTML_API_CC_ORACLE_TIMEOUT_MS',
'HTML_API_CC_MAX_INPUT_BYTES', 'HTML_API_CC_MAX_TOKENS', 'HTML_API_CC_MAX_NODES', 'HTML_API_CC_MAX_DEPTH',
'HTML_API_CC_MAX_TREE_BYTES', 'HTML_API_FUZZ_TEST_DRIFT_ORACLE',
) as $environment_name
diff --git a/tools/html-api-fuzz/tests/generator-policy-smoke.php b/tools/html-api-fuzz/tests/generator-policy-smoke.php
index 3e83c5836fa70..5faf277a47b6f 100644
--- a/tools/html-api-fuzz/tests/generator-policy-smoke.php
+++ b/tools/html-api-fuzz/tests/generator-policy-smoke.php
@@ -157,6 +157,22 @@ function html_api_fuzz_smoke_rm_tree( string $path ): void {
@rmdir( $path );
}
+html_api_fuzz_smoke_assert( in_array( \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP, \HtmlApiFuzz\OracleRenderer::kinds(), true ), 'Chrome CDP should be a generic oracle kind.' );
+putenv( 'HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS=1234' );
+$chrome_policy = \HtmlApiFuzz\OracleRenderer::from_options( array( 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP ) );
+html_api_fuzz_smoke_assert( 1234 === ( $chrome_policy->replay_options()['chromeStartupTimeoutMs'] ?? null ), 'Chrome startup timeout environment policy should be recorded.' );
+html_api_fuzz_smoke_assert( 26234 === $chrome_policy->recommended_process_timeout_ms( 'full', 2500 ), 'Chrome process policy should budget startup, four renders, cleanup, and headroom.' );
+$chrome_policy->close();
+$chrome_cli_policy = \HtmlApiFuzz\OracleRenderer::from_options( array( 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP, 'chrome-startup-timeout-ms' => '2345' ) );
+html_api_fuzz_smoke_assert( 2345 === ( $chrome_cli_policy->replay_options()['chromeStartupTimeoutMs'] ?? null ), 'Chrome startup CLI policy should override its environment.' );
+$chrome_cli_policy->close();
+putenv( 'HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS=not-an-integer' );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static fn () => \HtmlApiFuzz\OracleRenderer::from_options( array( 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP ) ),
+ 'Invalid Chrome startup timeout environment policy should fail closed.'
+);
+putenv( 'HTML_API_FUZZ_CHROME_STARTUP_TIMEOUT_MS' );
+
$lexbor_identity = array(
'schemaVersion' => 1,
'kind' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
diff --git a/tools/html-api-fuzz/tests/result-store-smoke.php b/tools/html-api-fuzz/tests/result-store-smoke.php
index aa4343c53468b..173d7f69e7764 100644
--- a/tools/html-api-fuzz/tests/result-store-smoke.php
+++ b/tools/html-api-fuzz/tests/result-store-smoke.php
@@ -87,6 +87,28 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
),
'error' => null,
);
+$chrome_oracle = array(
+ 'schemaVersion' => 1,
+ 'kind' => 'chrome-cdp',
+ 'available' => true,
+ 'identity' => array(
+ 'schemaVersion' => 1,
+ 'kind' => 'chrome-cdp',
+ 'platform' => 'mac-arm64',
+ 'pinnedChromeVersion' => '150.0.7871.114',
+ 'chromeArchiveSha256' => str_repeat( '5', 64 ),
+ 'expectedChromeExecutableSha256' => str_repeat( '6', 64 ),
+ 'chromeExecutableSha256' => str_repeat( '6', 64 ),
+ 'oracleScriptSha256' => str_repeat( '7', 64 ),
+ 'fragmentContextsSha256' => str_repeat( '8', 64 ),
+ 'fragmentContexts' => \HtmlApiFuzz\Generator::fragment_contexts(),
+ 'nodeExecutableSha256' => str_repeat( '9', 64 ),
+ 'nodeVersion' => 'v22.23.0',
+ 'chromeVersion' => '150.0.7871.114',
+ 'cdpProtocolVersion' => '1.3',
+ ),
+ 'error' => null,
+);
$pass_summary = array(
'kind' => 'attempt',
@@ -229,14 +251,19 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
'oracleFinding' => $oracle_summary['oracleFinding'],
);
$oracle_id = $store->record_attempt( $oracle_summary, $oracle_result, $oracle_replay );
+$chrome_summary = $pass_summary;
+$chrome_summary['seed'] = 16;
+$chrome_summary['inputSha1'] = sha1( 'chrome-pass' );
+$chrome_summary['oracle'] = $chrome_oracle;
+$chrome_id = $store->record_attempt( $chrome_summary );
-html_api_fuzz_smoke_assert( 6 === $store->count_attempts(), 'Expected six recorded attempts.' );
+html_api_fuzz_smoke_assert( 7 === $store->count_attempts(), 'Expected seven recorded attempts.' );
html_api_fuzz_smoke_assert( array( 12 ) === $store->retained_seeds( 'abc123def456' ), 'Expected seed 12 as the retained exemplar for the signature.' );
html_api_fuzz_smoke_assert( array() === $store->retained_seeds( 'unseen' ), 'Expected no retained exemplars for an unseen signature.' );
html_api_fuzz_smoke_assert( array( 14 ) === $store->oracle_retained_seeds( 'oracle-abc123' ), 'Expected seed 14 as the retained exemplar for the oracle signature.' );
html_api_fuzz_smoke_assert( $store->seed_artifacts_retained( 12 ), 'Expected seed 12 to be marked as retained.' );
html_api_fuzz_smoke_assert( ! $store->seed_artifacts_retained( 13 ), 'Expected seed 13 not to be marked as retained.' );
-html_api_fuzz_smoke_assert( 6 === $store->max_id(), 'Expected max id of six.' );
+html_api_fuzz_smoke_assert( 7 === $store->max_id(), 'Expected max id of seven.' );
$stored_replay = $store->replay_for_seed( 13 );
html_api_fuzz_smoke_assert( is_array( $stored_replay ) && base64_encode( 'new replay ' ) === ( $stored_replay['inputBase64'] ?? null ), 'Expected seed replay lookup to return the most recent replay for compatibility.' );
@@ -264,7 +291,7 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
// Reopen read-only as the watcher does and confirm persistence.
$reader = new \HtmlApiFuzz\ResultStore( $db_path, true );
-html_api_fuzz_smoke_assert( 6 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
+html_api_fuzz_smoke_assert( 7 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
html_api_fuzz_smoke_assert( 3 === count( $reader->failures_after( 0, $reader->max_id() ) ), 'Expected failures to persist across reopen.' );
html_api_fuzz_smoke_assert( 1 === count( $reader->oracle_findings_after( 0, $reader->max_id() ) ), 'Expected oracle findings to persist across reopen.' );
$reader->close();
@@ -280,6 +307,8 @@ function html_api_fuzz_smoke_assert( bool $condition, string $message ): void {
html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_kind = 'lexbor-source' AND oracle_version = '2.10.0' AND oracle_commit = '481c444261a132190a3fb746d6d2f60824af3717'" ), 'Expected Lexbor oracle metadata to be queryable for failure rows.' );
html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_binary = '" . str_repeat( 'b', 64 ) . "'" ), 'Expected the Lexbor oracle binary hash to be stored in a scalar column.' );
html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 15 AND oracle_kind = 'html5ever-source' AND oracle_version = '0.35.0' AND oracle_commit = '" . str_repeat( '1', 64 ) . "' AND oracle_binary = '" . str_repeat( 'c', 64 ) . "'" ), 'Expected html5ever identity columns to use version, build identity, and binary hash.' );
+$chrome_identity_sha256 = \HtmlApiFuzz\OracleRenderer::identity_sha256( $chrome_oracle );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 16 AND oracle_kind = 'chrome-cdp' AND oracle_identity_sha256 = '" . $chrome_identity_sha256 . "' AND oracle_version = '150.0.7871.114' AND oracle_commit = '" . str_repeat( '7', 64 ) . "' AND oracle_binary = '" . str_repeat( '6', 64 ) . "'" ), 'Expected Chrome attempts to retain an exact durable identity grouping and scalar identity fields.' );
$raw->close();
$future_db_path = $work_dir . '/future.sqlite';
diff --git a/tools/html-api-fuzz/worker.php b/tools/html-api-fuzz/worker.php
index 157c2246b0dd8..05b088b6c92c5 100755
--- a/tools/html-api-fuzz/worker.php
+++ b/tools/html-api-fuzz/worker.php
@@ -20,20 +20,49 @@ function html_api_fuzz_worker_fatal_result( array $options, Throwable $e, ?strin
'payloadPolicy' => \HtmlApiFuzz\option_string( $options, 'payload-policy', null ),
'inputSource' => \HtmlApiFuzz\option_string( $options, 'input-file', null ) ? 'input-file' : ( \HtmlApiFuzz\option_string( $options, 'input-base64', null ) ? 'input-base64' : 'generated' ),
);
+ $oracle_renderer = null;
+ $oracle_metadata = null;
+ $oracle_error = null;
try {
- $fallback['oracle'] = \HtmlApiFuzz\OracleRenderer::from_options( $options )->metadata();
- } catch ( Throwable $oracle_error ) {
+ $oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $options );
+ $oracle_metadata = $oracle_renderer->metadata();
+ } catch ( Throwable $error ) {
+ $oracle_error = $error;
+ }
+ $oracle_cleanup_error = null;
+ if ( $oracle_renderer instanceof \HtmlApiFuzz\OracleRenderer ) {
+ try {
+ $oracle_renderer->close();
+ } catch ( Throwable $error ) {
+ $oracle_cleanup_error = $error;
+ }
+ }
+ if ( is_array( $oracle_metadata ) ) {
+ $fallback['oracle'] = $oracle_metadata;
+ } else {
$oracle_kind = \HtmlApiFuzz\option_string( $options, 'dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM );
if ( ! in_array( $oracle_kind, \HtmlApiFuzz\OracleRenderer::kinds(), true ) ) {
$oracle_kind = \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM;
}
+ $oracle_message = null !== $oracle_error ? $oracle_error->getMessage() : 'Oracle metadata was not produced.';
+ if ( null !== $oracle_cleanup_error ) {
+ $oracle_message .= '; oracle cleanup failed: ' . $oracle_cleanup_error->getMessage();
+ }
$fallback['oracle'] = array(
'schemaVersion' => 1,
'kind' => $oracle_kind,
'available' => false,
'identity' => null,
- 'error' => $oracle_error->getMessage(),
+ 'error' => $oracle_message,
+ );
+ }
+ if ( null !== $oracle_cleanup_error ) {
+ $fallback['oracleInfrastructure'] = true;
+ $fallback['oracleCleanup'] = array(
+ 'ok' => false,
+ 'error' => $oracle_cleanup_error->getMessage(),
);
+ $fallback['failureSnippet'] .= '; fallback oracle cleanup failed: ' . $oracle_cleanup_error->getMessage();
}
if ( null !== $output_dir ) {
From ccb5be5be13c720ec6516a6c8d23e9ce788e1299 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Thu, 16 Jul 2026 09:32:47 +0200
Subject: [PATCH 012/149] fuzz: verify shared Common Crawl corpus
---
tools/html-api-fuzz/README.md | 58 +-
tools/html-api-fuzz/commoncrawl-batch.php | 123 ++
.../lib/CommonCrawlBatchCoordinator.php | 1319 +++++++++++++++++
tools/html-api-fuzz/lib/CommonCrawlRunner.php | 181 ++-
tools/html-api-fuzz/lib/Support.php | 94 +-
tools/html-api-fuzz/lib/autoload.php | 1 +
.../tests/commoncrawl-analysis-smoke.php | 56 +
.../commoncrawl-batch-coordinator-smoke.php | 476 ++++++
.../tests/fixtures/fake-cc-analyzer.php | 280 ++++
9 files changed, 2568 insertions(+), 20 deletions(-)
create mode 100755 tools/html-api-fuzz/commoncrawl-batch.php
create mode 100644 tools/html-api-fuzz/lib/CommonCrawlBatchCoordinator.php
create mode 100755 tools/html-api-fuzz/tests/commoncrawl-batch-coordinator-smoke.php
create mode 100755 tools/html-api-fuzz/tests/fixtures/fake-cc-analyzer.php
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
index f12e0d38f5467..65b1f5ee5a7c5 100644
--- a/tools/html-api-fuzz/README.md
+++ b/tools/html-api-fuzz/README.md
@@ -69,13 +69,65 @@ Then point cc-analyzer's HTML analysis-file argument at the absolute path to:
tools/html-api-fuzz/commoncrawl-analysis.php
```
+Fetch a named batch exactly once, with an explicit crawl, then run that cached
+batch through all three required oracles with the checked coordinator. The
+coordinator never fetches or substitutes documents:
+
+```sh
+PHAR=/absolute/path/to/cc-analyzer.phar
+WORKSPACE=/absolute/path/to/cc-workspace
+BATCH=wp-html-api-canary-2026-30
+
+php "$PHAR" --workspace "$WORKSPACE" batch fetch "$BATCH" \
+ --crawl CC-MAIN-2026-30 --limit 20 --progress text --output json
+
+php tools/html-api-fuzz/commoncrawl-batch.php \
+ --cc-analyzer "$PHAR" --workspace "$WORKSPACE" --batch "$BATCH" \
+ --output-dir /absolute/new/path/shared-corpus-canary \
+ --batch-timeout-ms 1800000 --process-timeout-ms 90000 \
+ --lexbor-oracle-bin tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle \
+ --html5ever-oracle-bin tools/html-api-fuzz/oracles/html5ever/build/html5ever-tree-oracle \
+ --chrome-oracle-script tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js \
+ --chrome-executable /absolute/path/to/pinned/chrome \
+ --node-bin /absolute/path/to/node --retain-all
+```
+
+The output path must not exist. It is claimed once and contains separate
+`lexbor-source/`, `html5ever-source/`, and `chrome-cdp/` directories. Each gets
+its own immutable configuration, stdout and stderr evidence, append-only
+summary, exact sorted `replacement-environment.json`, complete marker, and
+file-hash seal. `batch-manifest.json` hashes the
+cached bytes and records every ordered record ID, state key, target, input
+range, SHA-256, and length. `shared-corpus.json` is published only after each
+run reports the exact batch count and the three ordered
+`(record ID, SHA-256, length)` vectors match. Missing, duplicate, partial,
+reordered, changed-cache, identity-drift, or trust-drift runs fail and retain
+their partial evidence without a success marker.
+
+Coordinator children receive a recorded replacement environment, not the
+caller's ambient `HTML_API_*`, `NODE_OPTIONS`, PHP configuration overrides, or
+loader-injection variables. It probes PHP and every oracle in that environment
+and brackets each trust capture with live cache reads. It rechecks the PHAR,
+PHP/ini runtime, complete executed repository code set, Git state, normalized
+oracle identities, every raw oracle trust file's path/type/mode/bytes, and
+cached bodies immediately before and after every run. Before sealing, it
+rereads the on-disk batch
+manifest, batch stdout, configurations, summaries, and environments. The
+sealed `shared-corpus.payload.json` is written first. After all validation and
+sealing, the coordinator publishes `shared-corpus.json`, then the per-run
+markers, and finally the root `.complete` as the sole authoritative completion
+marker and last fallible operation. Consumers must require that root marker;
+an interrupted directory may contain an otherwise valid manifest or seal but
+is not complete. A successful directory is write-once; use a new output path
+for every canary or full run.
+
The PHAR owns crawl selection, ranges, checkpoints, and concurrency. The
callback boundary is the same as other cc-analyzer HTML analyses: the file
returns a `static function (HtmlAnalysisInput $document): void` closure. Use
`php /path/to/cc-analyzer.phar --help` for the binary's crawl-specific command
-and argument names. This checkout does not contain `cc-analyzer.phar`, so the
-real PHAR CLI boundary is deliberately not claimed as tested; the included
-smoke test exercises a value object matching the observed callback contract.
+and argument names. The committed coordinator smoke exercises the same batch
+schemas and callback contract with an isolated fake analyzer. Real-PHAR runs
+are a separate external gate and are not claimed by that code-only smoke.
Each accepted document runs in a separate PHP child with its own memory and
wall-clock limit. The callback writes `input.bin` and an initial replay before
diff --git a/tools/html-api-fuzz/commoncrawl-batch.php b/tools/html-api-fuzz/commoncrawl-batch.php
new file mode 100755
index 0000000000000..22df7d1254e40
--- /dev/null
+++ b/tools/html-api-fuzz/commoncrawl-batch.php
@@ -0,0 +1,123 @@
+#!/usr/bin/env php
+ $value ) {
+ if ( ! is_string( $name ) || ! is_string( $value ) ) {
+ unset( $environment[ $name ] );
+ }
+ }
+ ksort( $environment );
+ return $environment;
+}
+
+function html_api_fuzz_batch_git_output( array $arguments ): string {
+ $result = \HtmlApiFuzz\run_git_command( $arguments, 30000, \HtmlApiFuzz\repo_root() );
+ if ( 0 !== ( $result['code'] ?? null ) || true === ( $result['timedOut'] ?? false ) ) {
+ throw new RuntimeException( 'Sanitized Git probe command failed: git ' . implode( ' ', $arguments ) );
+ }
+ return (string) $result['stdout'];
+}
+
+$options = \HtmlApiFuzz\parse_cli_options( $argv );
+
+try {
+ if ( array_key_exists( 'internal-runtime-probe', $options ) ) {
+ $scanned = php_ini_scanned_files();
+ $scanned_files = array();
+ if ( is_string( $scanned ) && '' !== trim( $scanned ) ) {
+ foreach ( preg_split( '/\s*,\s*/', trim( $scanned ) ) ?: array() as $path ) {
+ if ( '' !== $path ) {
+ $scanned_files[] = $path;
+ }
+ }
+ }
+ $extensions = get_loaded_extensions();
+ sort( $extensions );
+ echo \HtmlApiFuzz\json_encode_safe( array(
+ 'phpVersion' => PHP_VERSION,
+ 'phpBinary' => PHP_BINARY,
+ 'loadedIni' => php_ini_loaded_file() ?: null,
+ 'scannedIni' => $scanned_files,
+ 'extensions' => $extensions,
+ 'environment' => html_api_fuzz_batch_environment(),
+ ) ) . "\n";
+ exit( 0 );
+ }
+
+ if ( array_key_exists( 'internal-git-probe', $options ) ) {
+ $commit = trim( html_api_fuzz_batch_git_output( array( 'rev-parse', 'HEAD' ) ) );
+ $branch = trim( html_api_fuzz_batch_git_output( array( 'branch', '--show-current' ) ) );
+ $status = html_api_fuzz_batch_git_output( array( 'status', '--porcelain=v1', '-z', '--untracked-files=all' ) );
+ $pathspecs = \HtmlApiFuzz\CommonCrawlBatchCoordinator::executed_code_pathspecs();
+ $index = html_api_fuzz_batch_git_output( array_merge( array( 'ls-files', '-s', '-z', '--' ), $pathspecs ) );
+ $tracked = array();
+ foreach ( explode( "\0", $index ) as $record ) {
+ if ( '' === $record ) {
+ continue;
+ }
+ if ( 1 !== preg_match( '/^([0-7]{6}) [0-9a-f]{40} [0-3]\t(.+)$/s', $record, $match ) ) {
+ throw new RuntimeException( 'Git index probe returned an invalid record.' );
+ }
+ $tracked[] = array( 'mode' => $match[1], 'path' => $match[2] );
+ }
+ echo \HtmlApiFuzz\json_encode_safe( array(
+ 'commit' => $commit,
+ 'branch' => '' === $branch ? null : $branch,
+ 'statusBase64' => base64_encode( $status ),
+ 'tracked' => $tracked,
+ 'environment' => html_api_fuzz_batch_environment(),
+ ) ) . "\n";
+ exit( 0 );
+ }
+
+ if ( array_key_exists( 'internal-oracle-probe', $options ) ) {
+ $encoded = \HtmlApiFuzz\option_string( $options, 'internal-oracle-probe', null );
+ if ( null === $encoded ) {
+ throw new InvalidArgumentException( 'Internal oracle probe requires an encoded option object.' );
+ }
+ $json = base64_decode( $encoded, true );
+ if ( false === $json ) {
+ throw new InvalidArgumentException( 'Internal oracle probe options are not valid base64.' );
+ }
+ $oracle_options = \HtmlApiFuzz\StrictJsonParser::decode( $json );
+ if ( ! is_array( $oracle_options ) ) {
+ throw new InvalidArgumentException( 'Internal oracle probe options must be an object.' );
+ }
+ $allowed = array( 'dom-oracle', 'lexbor-oracle-bin', 'html5ever-oracle-bin', 'chrome-oracle-script', 'chrome-executable', 'node-bin', 'oracle-timeout-ms', 'chrome-startup-timeout-ms' );
+ foreach ( $oracle_options as $name => $value ) {
+ if ( ! in_array( $name, $allowed, true ) || ! is_string( $value ) || '' === $value ) {
+ throw new InvalidArgumentException( 'Internal oracle probe contains an unexpected option.' );
+ }
+ }
+ $renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
+ $result = \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ): array {
+ return array(
+ 'metadata' => $renderer->metadata(),
+ 'replayOptions' => $renderer->replay_options(),
+ 'workerArgs' => $renderer->worker_args(),
+ 'environment' => html_api_fuzz_batch_environment(),
+ );
+ }
+ );
+ echo \HtmlApiFuzz\json_encode_safe( $result ) . "\n";
+ exit( 0 );
+ }
+
+ if ( \HtmlApiFuzz\option_bool( $options, 'help', false ) ) {
+ echo "Usage: php tools/html-api-fuzz/commoncrawl-batch.php --cc-analyzer PATH --workspace DIR --batch NAME --output-dir NEW_DIR --batch-timeout-ms N --chrome-executable PATH --node-bin PATH [--lexbor-oracle-bin PATH] [--html5ever-oracle-bin PATH] [--chrome-oracle-script PATH] [--process-timeout-ms N] [--oracle-timeout-ms N] [--chrome-startup-timeout-ms N] [--checks baseline|full|sampled] [--retain-all] [--require-utf8]\n";
+ exit( 0 );
+ }
+
+ $result = \HtmlApiFuzz\CommonCrawlBatchCoordinator::run( $options );
+ echo \HtmlApiFuzz\json_encode_safe( $result ) . "\n";
+ exit( 0 );
+} catch ( Throwable $error ) {
+ fwrite( STDERR, $error->getMessage() . "\n" );
+ exit( 1 );
+}
diff --git a/tools/html-api-fuzz/lib/CommonCrawlBatchCoordinator.php b/tools/html-api-fuzz/lib/CommonCrawlBatchCoordinator.php
new file mode 100644
index 0000000000000..3e30c5366b434
--- /dev/null
+++ b/tools/html-api-fuzz/lib/CommonCrawlBatchCoordinator.php
@@ -0,0 +1,1319 @@
+phar_path = self::existing_file_option( $options, 'cc-analyzer' );
+ $this->workspace = self::existing_directory_option( $options, 'workspace' );
+ $this->batch_name = self::required_string_option( $options, 'batch' );
+ if ( 1 !== preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/', $this->batch_name ) ) {
+ throw new \InvalidArgumentException( 'Batch name contains unsafe characters.' );
+ }
+ $this->output_dir = self::new_path_option( $options, 'output-dir' );
+ $this->coordinator_id = option_string( $options, 'coordinator-id', 'shared-' . $this->batch_name . '-' . timestamp() );
+ if ( null === $this->coordinator_id || 1 !== preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/', $this->coordinator_id ) ) {
+ throw new \InvalidArgumentException( 'Coordinator ID contains unsafe characters.' );
+ }
+
+ $this->batch_timeout_ms = self::required_positive_option( $options, 'batch-timeout-ms' );
+ $this->document_timeout_ms = self::positive_option( $options, 'process-timeout-ms', 90000 );
+ $this->oracle_timeout_ms = self::positive_option( $options, 'oracle-timeout-ms', 10000 );
+ $this->chrome_startup_timeout_ms = self::positive_option( $options, 'chrome-startup-timeout-ms', ChromeOracleRenderer::DEFAULT_STARTUP_TIMEOUT_MS );
+ if ( $this->chrome_startup_timeout_ms > PHP_INT_MAX - $this->oracle_timeout_ms || $this->chrome_startup_timeout_ms + $this->oracle_timeout_ms > PHP_INT_MAX - 20000 ) {
+ throw new \OverflowException( 'Oracle probe timeout exceeds the platform integer range.' );
+ }
+ $probe_budget = $this->chrome_startup_timeout_ms + $this->oracle_timeout_ms + 20000;
+ $this->probe_timeout_ms = max( 60000, $probe_budget );
+ $this->checks = option_string( $options, 'checks', 'baseline' );
+ if ( ! in_array( $this->checks, array( 'baseline', 'full', 'sampled' ), true ) ) {
+ throw new \InvalidArgumentException( '--checks must be baseline, full, or sampled.' );
+ }
+ $this->memory_limit = option_string( $options, 'memory-limit', '256M' );
+ if ( null === $this->memory_limit || 1 !== preg_match( '/^[1-9][0-9]*[KMG]?$/i', $this->memory_limit ) ) {
+ throw new \InvalidArgumentException( '--memory-limit must be a positive PHP limit such as 256M.' );
+ }
+ $this->retain_all = self::strict_bool_option( $options, 'retain-all', false );
+ $this->require_utf8 = self::strict_bool_option( $options, 'require-utf8', false );
+ $this->limits = array(
+ 'maxInputBytes' => self::positive_option( $options, 'max-input-bytes', 2097152, 0 ),
+ 'maxTokens' => self::positive_option( $options, 'max-tokens', 50000 ),
+ 'maxNodes' => self::positive_option( $options, 'max-nodes', 50000 ),
+ 'maxDepth' => self::positive_option( $options, 'max-depth', 512 ),
+ 'maxTreeBytes' => self::positive_option( $options, 'max-tree-bytes', 16777216 ),
+ );
+
+ $lexbor = self::resolved_executable( option_string( $options, 'lexbor-oracle-bin', repo_root() . '/tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle' ), 'Lexbor oracle' );
+ $html5ever = self::resolved_executable( option_string( $options, 'html5ever-oracle-bin', repo_root() . '/tools/html-api-fuzz/oracles/html5ever/build/html5ever-tree-oracle' ), 'html5ever oracle' );
+ $chrome_script = self::resolved_file( option_string( $options, 'chrome-oracle-script', repo_root() . '/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js' ), 'Chrome oracle script' );
+ $chrome_executable = self::resolved_executable( self::required_string_option( $options, 'chrome-executable' ), 'Chrome executable' );
+ $node = self::resolved_executable( self::required_string_option( $options, 'node-bin' ), 'Node executable' );
+ $this->oracle_options = array(
+ OracleRenderer::KIND_LEXBOR_SOURCE => array(
+ 'dom-oracle' => OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $lexbor,
+ 'oracle-timeout-ms' => (string) $this->oracle_timeout_ms,
+ ),
+ OracleRenderer::KIND_HTML5EVER_SOURCE => array(
+ 'dom-oracle' => OracleRenderer::KIND_HTML5EVER_SOURCE,
+ 'html5ever-oracle-bin' => $html5ever,
+ 'oracle-timeout-ms' => (string) $this->oracle_timeout_ms,
+ ),
+ OracleRenderer::KIND_CHROME_CDP => array(
+ 'dom-oracle' => OracleRenderer::KIND_CHROME_CDP,
+ 'chrome-oracle-script' => $chrome_script,
+ 'chrome-executable' => $chrome_executable,
+ 'node-bin' => $node,
+ 'oracle-timeout-ms' => (string) $this->oracle_timeout_ms,
+ 'chrome-startup-timeout-ms' => (string) $this->chrome_startup_timeout_ms,
+ ),
+ );
+
+ $this->callback_path = self::resolved_file( dirname( __DIR__ ) . '/commoncrawl-analysis.php', 'Common Crawl callback' );
+ $this->worker_path = self::resolved_file( dirname( __DIR__ ) . '/worker.php', 'Worker' );
+ $this->coordinator_script = self::resolved_file( dirname( __DIR__ ) . '/commoncrawl-batch.php', 'Coordinator script' );
+ $php_binary = self::resolved_executable( PHP_BINARY, 'PHP executable' );
+ $path_parts = array_values( array_unique( array( dirname( $php_binary ), dirname( $node ), '/usr/bin', '/bin' ) ) );
+ $this->base_environment = array(
+ 'PATH' => implode( PATH_SEPARATOR, $path_parts ),
+ 'HOME' => $this->output_dir . '/sandbox-home',
+ 'TMPDIR' => $this->output_dir . '/sandbox-tmp',
+ 'LANG' => 'C',
+ 'LC_ALL' => 'C',
+ 'TZ' => 'UTC',
+ // macOS injects this into a child when it is absent; pin it instead.
+ '__CF_USER_TEXT_ENCODING' => sprintf( '0x%X:0x0:0x0', posix_getuid() ),
+ );
+ }
+
+ public static function run( array $options ): array {
+ $coordinator = new self( $options );
+ $coordinator->claim_output();
+ try {
+ $result = $coordinator->execute();
+ $payload = self::strict_json_file( $coordinator->output_dir . '/shared-corpus.payload.json' );
+ if ( self::canonical_json( $result ) !== self::canonical_json( $payload ) ) {
+ throw new \RuntimeException( 'Sealed shared-corpus payload changed before publication.' );
+ }
+ write_json_file_atomic( $coordinator->output_dir . '/coordinator-state.json', array(
+ 'kind' => 'html-api-commoncrawl-coordinator-state',
+ 'status' => 'sealed',
+ 'coordinatorId' => $coordinator->coordinator_id,
+ 'sharedCorpus' => $coordinator->output_dir . '/shared-corpus.json',
+ 'completionMarker' => $coordinator->output_dir . '/.complete',
+ ) );
+ write_json_file_atomic( $coordinator->output_dir . '/shared-corpus.json', $result );
+ foreach ( self::REQUIRED_KINDS as $kind ) {
+ write_file_atomic( $coordinator->output_dir . '/' . $kind . '/.complete', "complete\n" );
+ }
+ write_json_file_atomic( $coordinator->output_dir . '/coordinator-state.json', array(
+ 'kind' => 'html-api-commoncrawl-coordinator-state',
+ 'status' => 'published',
+ 'coordinatorId' => $coordinator->coordinator_id,
+ 'sharedCorpus' => $coordinator->output_dir . '/shared-corpus.json',
+ 'completionMarker' => $coordinator->output_dir . '/.complete',
+ ) );
+ // This is the sole authoritative completion marker and the final
+ // fallible operation. No earlier artifact means the root completed.
+ write_file_atomic( $coordinator->output_dir . '/.complete', "complete\n" );
+ return $result;
+ } catch ( \Throwable $error ) {
+ $success_paths = array(
+ $coordinator->output_dir . '/.complete',
+ $coordinator->output_dir . '/shared-corpus.json',
+ );
+ foreach ( self::REQUIRED_KINDS as $kind ) {
+ $success_paths[] = $coordinator->output_dir . '/' . $kind . '/.complete';
+ }
+ foreach ( $success_paths as $success_path ) {
+ if ( is_file( $success_path ) || is_link( $success_path ) ) {
+ @unlink( $success_path );
+ }
+ }
+ write_json_file_atomic( $coordinator->output_dir . '/coordinator-state.json', array(
+ 'kind' => 'html-api-commoncrawl-coordinator-state',
+ 'status' => 'failed',
+ 'coordinatorId' => $coordinator->coordinator_id,
+ 'error' => $error->getMessage(),
+ 'throwable' => get_class( $error ),
+ ) );
+ throw $error;
+ }
+ }
+
+ private function claim_output(): void {
+ if ( file_exists( $this->output_dir ) || is_link( $this->output_dir ) ) {
+ throw new \RuntimeException( 'Coordinator output directory already exists; use a fresh path.' );
+ }
+ if ( ! mkdir( $this->output_dir, 0700, false ) ) {
+ throw new \RuntimeException( 'Could not create coordinator output directory.' );
+ }
+ ensure_dir( $this->base_environment['HOME'] );
+ ensure_dir( $this->base_environment['TMPDIR'] );
+ write_json_file_atomic( $this->output_dir . '/coordinator-state.json', array(
+ 'kind' => 'html-api-commoncrawl-coordinator-state',
+ 'status' => 'running',
+ 'coordinatorId' => $this->coordinator_id,
+ 'batchName' => $this->batch_name,
+ ) );
+ }
+
+ private function execute(): array {
+ $preflight_dir = $this->output_dir . '/preflight';
+ ensure_dir( $preflight_dir );
+ $entry_phar = self::file_identity( $this->phar_path );
+ $info_process = $this->run_analyzer_command(
+ array( 'batch', 'info', $this->batch_name, '--output', 'json' ),
+ min( $this->batch_timeout_ms, 60000 ),
+ $preflight_dir . '/batch-info.stdout',
+ $preflight_dir . '/batch-info.stderr'
+ );
+ if ( self::canonical_json( $entry_phar ) !== self::canonical_json( self::file_identity( $this->phar_path ) ) ) {
+ throw new \RuntimeException( 'cc-analyzer PHAR changed during batch info.' );
+ }
+ $info = self::validate_batch_info( self::decode_process_json( $info_process, 'batch info' ), $this->batch_name );
+ $cache_path = realpath( $info['cachePath'] );
+ if ( false === $cache_path || ! is_file( $cache_path ) ) {
+ throw new \RuntimeException( 'Batch info cache path is not a readable regular file.' );
+ }
+ $verify_process = $this->run_analyzer_command(
+ array( 'batch', 'verify', $this->batch_name, '--output', 'json' ),
+ min( $this->batch_timeout_ms, 60000 ),
+ $preflight_dir . '/batch-verify.stdout',
+ $preflight_dir . '/batch-verify.stderr'
+ );
+ if ( self::canonical_json( $entry_phar ) !== self::canonical_json( self::file_identity( $this->phar_path ) ) ) {
+ throw new \RuntimeException( 'cc-analyzer PHAR changed during batch verify.' );
+ }
+ $verification = self::validate_batch_verification( self::decode_process_json( $verify_process, 'batch verify' ), $this->batch_name, $info['documentCount'] );
+ $manifest = self::load_batch_manifest( $cache_path, $info );
+ write_json_file_atomic( $this->output_dir . '/batch-manifest.json', $manifest );
+
+ $invocation = array( PHP_BINARY, $this->phar_path, '--workspace', $this->workspace, 'batch', 'run', $this->batch_name, $this->callback_path, '--output', 'json' );
+ $initial_trust = $this->capture_trust_bundle( $this->base_environment, $cache_path, $info, $manifest, 'during initial trust capture' );
+ write_json_file_atomic( $this->output_dir . '/preflight.json', array(
+ 'kind' => 'html-api-commoncrawl-preflight',
+ 'coordinatorId' => $this->coordinator_id,
+ 'info' => $info,
+ 'verification' => $verification,
+ 'manifestSha256' => $manifest['batchManifestSha256'],
+ 'corpusFingerprint' => $manifest['corpusFingerprint'],
+ 'invocation' => $invocation,
+ 'baseEnvironment' => $this->base_environment,
+ 'trust' => $initial_trust,
+ ) );
+
+ $runs = array();
+ foreach ( self::REQUIRED_KINDS as $kind ) {
+ $run_dir = $this->output_dir . '/' . $kind;
+ if ( file_exists( $run_dir ) || is_link( $run_dir ) ) {
+ throw new \RuntimeException( "Oracle output directory already exists: {$kind}" );
+ }
+ ensure_dir( $run_dir );
+ $environment = $this->run_environment( $kind, $run_dir, $cache_path, $manifest, $invocation, $initial_trust );
+ $environment_evidence = $this->write_environment_evidence( $run_dir, $environment );
+ $before = $this->capture_trust_bundle( $environment, $cache_path, $info, $manifest, "immediately before {$kind}" );
+ self::assert_same_trust( $initial_trust, $before, "before {$kind}" );
+
+ $process = $this->run_analyzer_command(
+ array( 'batch', 'run', $this->batch_name, $this->callback_path, '--output', 'json' ),
+ $this->batch_timeout_ms,
+ $run_dir . '/batch-run.stdout',
+ $run_dir . '/batch-run.stderr',
+ $environment
+ );
+ $batch_result = self::validate_batch_run_result(
+ self::decode_process_json( $process, "{$kind} batch run" ),
+ $manifest['batch'],
+ $this->callback_path,
+ $initial_trust['callback']['sha256']
+ );
+ $after = $this->capture_trust_bundle( $environment, $cache_path, $info, $manifest, "immediately after {$kind}" );
+ self::assert_same_trust( $initial_trust, $after, "after {$kind}" );
+ $provenance = $this->provenance_from_environment( $environment );
+ $summary = self::verify_run_output( $run_dir, $manifest, $environment['HTML_API_CC_RUN_ID'], $kind, $initial_trust['oracles'][ $kind ]['identitySha256'], $provenance, $environment );
+ $runs[ $kind ] = array(
+ 'kind' => $kind,
+ 'outputDir' => $run_dir,
+ 'batchResult' => $batch_result,
+ 'process' => self::compact_process( $process ),
+ 'summary' => $summary,
+ 'provenance' => $provenance,
+ 'environment' => $environment,
+ 'environmentEvidence' => $environment_evidence,
+ );
+ }
+
+ $final_trust = $this->capture_trust_bundle( $this->base_environment, $cache_path, $info, $manifest, 'during final trust capture' );
+ self::assert_same_trust( $initial_trust, $final_trust, 'before final sealing' );
+ $disk_manifest = self::strict_json_file( $this->output_dir . '/batch-manifest.json' );
+ self::assert_same_manifest( $manifest, $disk_manifest, 'in on-disk batch-manifest.json before sealing' );
+
+ foreach ( $runs as $kind => &$run ) {
+ $final_batch_result = self::validate_batch_run_result(
+ self::strict_json_file( $run['outputDir'] . '/batch-run.stdout' ),
+ $manifest['batch'],
+ $this->callback_path,
+ $initial_trust['callback']['sha256']
+ );
+ if ( self::canonical_json( $run['batchResult'] ) !== self::canonical_json( $final_batch_result ) ) {
+ throw new \RuntimeException( "{$kind} batch stdout changed after verification." );
+ }
+ $run['summary'] = self::verify_run_output(
+ $run['outputDir'],
+ $manifest,
+ $run['environment']['HTML_API_CC_RUN_ID'],
+ $kind,
+ $initial_trust['oracles'][ $kind ]['identitySha256'],
+ $run['provenance'],
+ $run['environment']
+ );
+ $environment_identity = self::file_identity( $run['environmentEvidence']['path'] );
+ if ( ! hash_equals( $run['environmentEvidence']['sha256'], $environment_identity['sha256'] ) ) {
+ throw new \RuntimeException( "{$kind} replacement environment file changed after publication." );
+ }
+ $seal = self::seal_directory( $run['outputDir'], array( 'run-seal.json', '.complete' ) );
+ write_json_file_atomic( $run['outputDir'] . '/run-seal.json', $seal );
+ $run['sealSha256'] = self::file_identity( $run['outputDir'] . '/run-seal.json' )['sha256'];
+ }
+ unset( $run );
+ $vectors = array_map( static fn ( array $run ): array => $run['summary']['vector'], $runs );
+ $first_vector = reset( $vectors );
+ foreach ( $vectors as $kind => $vector ) {
+ if ( self::canonical_json( $first_vector ) !== self::canonical_json( $vector ) ) {
+ throw new \RuntimeException( "Shared corpus vector differs for {$kind}." );
+ }
+ }
+ self::assert_same_manifest( $manifest, self::load_batch_manifest( $cache_path, $info ), 'in the final pre-seal cache read' );
+
+ $result = array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html-api-commoncrawl-shared-corpus',
+ 'coordinatorId' => $this->coordinator_id,
+ 'batchName' => $this->batch_name,
+ 'crawlId' => $manifest['batch']['crawlId'],
+ 'documentCount' => $manifest['batch']['documentCount'],
+ 'batchManifestSha256' => $manifest['batchManifestSha256'],
+ 'corpusFingerprint' => $manifest['corpusFingerprint'],
+ 'invocation' => $invocation,
+ 'trust' => $initial_trust,
+ 'orderedCorpus' => $first_vector,
+ 'runs' => $runs,
+ );
+ write_json_file_atomic( $this->output_dir . '/shared-corpus.payload.json', $result );
+ $root_seal = self::seal_directory( $this->output_dir, array( 'coordinator-seal.json', 'coordinator-state.json', '.complete' ) );
+ write_json_file_atomic( $this->output_dir . '/coordinator-seal.json', $root_seal );
+ return $result;
+ }
+
+ private function run_analyzer_command( array $arguments, int $timeout_ms, string $stdout_path, string $stderr_path, ?array $environment = null ): array {
+ $process = run_php_process(
+ array_merge( array( $this->phar_path, '--workspace', $this->workspace ), $arguments ),
+ repo_root(),
+ $timeout_ms,
+ null,
+ 1048576,
+ true,
+ $environment ?? $this->base_environment,
+ true,
+ $stdout_path,
+ $stderr_path
+ );
+ self::assert_process_success( $process, 'cc-analyzer command' );
+ return $process;
+ }
+
+ private function capture_trust_bundle( array $oracle_environment, string $cache_path, array $info, array $expected_manifest, string $when ): array {
+ $manifest_before = self::load_batch_manifest( $cache_path, $info );
+ self::assert_same_manifest( $expected_manifest, $manifest_before, "before {$when}" );
+ $files_before = $this->trust_file_identities();
+ $runtime_before = $this->runtime_probe( $this->base_environment );
+ $code_before = self::repository_code_identity( $this->git_probe( $this->base_environment ) );
+ $oracles = array();
+ foreach ( self::REQUIRED_KINDS as $kind ) {
+ $probe = $this->oracle_probe( $kind, $oracle_environment );
+ $oracles[ $kind ] = array(
+ 'metadata' => $probe['metadata'],
+ 'identitySha256' => OracleRenderer::identity_sha256( $probe['metadata'] ),
+ 'replayOptions' => $probe['replayOptions'],
+ 'workerArgs' => $probe['workerArgs'],
+ );
+ }
+ $runtime_after = $this->runtime_probe( $this->base_environment );
+ $code_after = self::repository_code_identity( $this->git_probe( $this->base_environment ) );
+ $files_after = $this->trust_file_identities();
+ $manifest_after = self::load_batch_manifest( $cache_path, $info );
+ self::assert_same_manifest( $expected_manifest, $manifest_after, "after {$when}" );
+ if ( self::canonical_json( $files_before ) !== self::canonical_json( $files_after ) || self::canonical_json( $runtime_before ) !== self::canonical_json( $runtime_after ) || self::canonical_json( $code_before ) !== self::canonical_json( $code_after ) ) {
+ throw new \RuntimeException( "Trust inputs changed {$when}." );
+ }
+ $bundle = array(
+ 'phpBinary' => $files_after['phpBinary'],
+ 'phar' => $files_after['phar'],
+ 'callback' => $files_after['callback'],
+ 'worker' => $files_after['worker'],
+ 'oracleTrustFiles' => $files_after['oracleTrustFiles'],
+ 'runtime' => $runtime_after,
+ 'repository' => $code_after,
+ 'oracles' => $oracles,
+ 'batchManifestSha256' => $manifest_after['batchManifestSha256'],
+ 'corpusFingerprint' => $manifest_after['corpusFingerprint'],
+ );
+ $bundle['trustBundleSha256'] = hash( 'sha256', self::canonical_json( $bundle ) );
+ return $bundle;
+ }
+
+ private function trust_file_identities(): array {
+ $lexbor_binary = $this->oracle_options[ OracleRenderer::KIND_LEXBOR_SOURCE ]['lexbor-oracle-bin'];
+ $html5ever_binary = $this->oracle_options[ OracleRenderer::KIND_HTML5EVER_SOURCE ]['html5ever-oracle-bin'];
+ $html5ever_root = dirname( dirname( $html5ever_binary ) );
+ $chrome_root = repo_root() . '/tools/html-api-fuzz/oracles/chrome';
+ $paths = array(
+ 'lexborBinary' => $lexbor_binary,
+ 'lexborBuildManifest' => dirname( $lexbor_binary ) . '/build-manifest.json',
+ 'html5everBinary' => $html5ever_binary,
+ 'html5everBuildManifest' => dirname( $html5ever_binary ) . '/build-manifest.json',
+ 'html5everCargoToml' => $html5ever_root . '/Cargo.toml',
+ 'html5everCargoLock' => $html5ever_root . '/Cargo.lock',
+ 'html5everRustToolchain' => $html5ever_root . '/rust-toolchain.toml',
+ 'html5everSource' => $html5ever_root . '/src/main.rs',
+ 'chromeScript' => $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['chrome-oracle-script'],
+ 'chromeExecutable' => $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['chrome-executable'],
+ 'nodeExecutable' => $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['node-bin'],
+ 'chromeVersion' => $chrome_root . '/VERSION',
+ 'chromeArchiveChecksums' => $chrome_root . '/SHA256SUMS',
+ 'chromeExecutableChecksums' => $chrome_root . '/EXECUTABLE_SHA256SUMS',
+ 'fragmentContexts' => repo_root() . '/tools/html-api-fuzz/oracles/fragment-contexts.json',
+ );
+ $identities = array();
+ foreach ( $paths as $name => $path ) {
+ $identities[ $name ] = self::file_identity( $path );
+ }
+ return array(
+ 'phpBinary' => self::file_identity( PHP_BINARY ),
+ 'phar' => self::file_identity( $this->phar_path ),
+ 'callback' => self::file_identity( $this->callback_path ),
+ 'worker' => self::file_identity( $this->worker_path ),
+ 'oracleTrustFiles' => $identities,
+ );
+ }
+
+ private function write_environment_evidence( string $run_dir, array $environment ): array {
+ ksort( $environment );
+ $evidence = array(
+ 'kind' => 'html-api-commoncrawl-replacement-environment',
+ 'environment' => $environment,
+ 'environmentSha256' => hash( 'sha256', self::canonical_json( $environment ) ),
+ );
+ $path = $run_dir . '/replacement-environment.json';
+ write_json_file_atomic( $path, $evidence );
+ return array(
+ 'path' => $path,
+ 'sha256' => self::file_identity( $path )['sha256'],
+ 'environmentSha256' => $evidence['environmentSha256'],
+ );
+ }
+
+ private function runtime_probe( array $environment ): array {
+ $process = run_php_process(
+ array( $this->coordinator_script, '--internal-runtime-probe' ),
+ repo_root(),
+ min( $this->probe_timeout_ms, 60000 ),
+ null,
+ 1048576,
+ true,
+ $environment,
+ true
+ );
+ $probe = self::decode_process_json( $process, 'sanitized PHP runtime probe' );
+ self::assert_exact_keys( $probe, array( 'phpVersion', 'phpBinary', 'loadedIni', 'scannedIni', 'extensions', 'environment' ), 'runtime probe' );
+ self::assert_environment_echo( $probe, $environment, 'runtime probe' );
+ if ( ! is_string( $probe['phpVersion'] ) || '' === $probe['phpVersion'] || ! is_string( $probe['phpBinary'] ) || '' === $probe['phpBinary'] || ( null !== $probe['loadedIni'] && ( ! is_string( $probe['loadedIni'] ) || '' === $probe['loadedIni'] ) ) || ! is_array( $probe['scannedIni'] ) || ! is_array( $probe['extensions'] ) ) {
+ throw new \RuntimeException( 'Runtime probe returned invalid types.' );
+ }
+ foreach ( array_merge( $probe['scannedIni'], $probe['extensions'] ) as $item ) {
+ if ( ! is_string( $item ) || '' === $item ) {
+ throw new \RuntimeException( 'Runtime probe returned an invalid ini or extension entry.' );
+ }
+ }
+ if ( realpath( (string) $probe['phpBinary'] ) !== realpath( PHP_BINARY ) ) {
+ throw new \RuntimeException( 'Runtime probe used a different PHP binary.' );
+ }
+ $ini = array();
+ $ini_paths = array_merge( is_string( $probe['loadedIni'] ) && '' !== $probe['loadedIni'] ? array( $probe['loadedIni'] ) : array(), is_array( $probe['scannedIni'] ) ? $probe['scannedIni'] : array() );
+ foreach ( $ini_paths as $path ) {
+ $ini[] = self::file_identity( self::resolved_file( is_string( $path ) ? $path : null, 'PHP ini file' ) );
+ }
+ $identity = array(
+ 'phpVersion' => $probe['phpVersion'],
+ 'phpBinary' => realpath( PHP_BINARY ),
+ 'iniFiles' => $ini,
+ 'extensions' => $probe['extensions'],
+ );
+ $identity['runtimeSha256'] = hash( 'sha256', self::canonical_json( $identity ) );
+ return $identity;
+ }
+
+ private function git_probe( array $environment ): array {
+ $process = run_php_process(
+ array( $this->coordinator_script, '--internal-git-probe' ),
+ repo_root(),
+ 60000,
+ null,
+ 16777216,
+ true,
+ $environment,
+ true
+ );
+ $probe = self::decode_process_json( $process, 'sanitized Git probe' );
+ self::assert_exact_keys( $probe, array( 'commit', 'branch', 'statusBase64', 'tracked', 'environment' ), 'Git probe' );
+ self::assert_environment_echo( $probe, $environment, 'Git probe' );
+ if ( ! is_string( $probe['commit'] ) || 1 !== preg_match( '/^[0-9a-f]{40}$/', $probe['commit'] ) || ( null !== $probe['branch'] && ! is_string( $probe['branch'] ) ) || ! is_string( $probe['statusBase64'] ) || ! is_array( $probe['tracked'] ) ) {
+ throw new \RuntimeException( 'Git probe returned invalid repository identity.' );
+ }
+ return $probe;
+ }
+
+ private function oracle_probe( string $kind, array $environment ): array {
+ $encoded = base64_encode( self::canonical_json( $this->oracle_options[ $kind ] ) );
+ $process = run_php_process(
+ array( $this->coordinator_script, '--internal-oracle-probe', $encoded ),
+ repo_root(),
+ $this->probe_timeout_ms,
+ null,
+ 1048576,
+ true,
+ $environment,
+ true
+ );
+ $probe = self::decode_process_json( $process, "{$kind} oracle probe" );
+ self::assert_exact_keys( $probe, array( 'metadata', 'replayOptions', 'workerArgs', 'environment' ), "{$kind} oracle probe" );
+ self::assert_environment_echo( $probe, $environment, "{$kind} oracle probe" );
+ if ( ! is_array( $probe['metadata'] ?? null ) || true !== ( $probe['metadata']['available'] ?? false ) || $kind !== ( $probe['metadata']['kind'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} oracle preflight is unavailable or inconsistent." );
+ }
+ if ( ! is_array( $probe['replayOptions'] ?? null ) || ! is_array( $probe['workerArgs'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} oracle probe omitted replay or Worker options." );
+ }
+ return $probe;
+ }
+
+ private function run_environment( string $kind, string $run_dir, string $cache_path, array $manifest, array $invocation, array $trust ): array {
+ $environment = $this->base_environment;
+ $environment['CC_ANALYZER_OUTPUT_DIR'] = $run_dir;
+ $environment['HTML_API_CC_RUN_ID'] = $this->coordinator_id . '-' . $kind;
+ $environment['HTML_API_CC_ORACLE'] = $kind;
+ $environment['HTML_API_CC_EXPECT_ORACLE_IDENTITY_SHA256'] = $trust['oracles'][ $kind ]['identitySha256'];
+ $environment['HTML_API_CC_ORACLE_TIMEOUT_MS'] = (string) $this->oracle_timeout_ms;
+ $environment['HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS'] = (string) $this->chrome_startup_timeout_ms;
+ $environment['HTML_API_CC_PROCESS_TIMEOUT_MS'] = (string) $this->document_timeout_ms;
+ $environment['HTML_API_CC_CHECKS'] = $this->checks;
+ $environment['HTML_API_CC_FULL_SAMPLE_PERCENT'] = '0';
+ $environment['HTML_API_CC_MEMORY_LIMIT'] = $this->memory_limit;
+ $environment['HTML_API_CC_MAX_INPUT_BYTES'] = (string) $this->limits['maxInputBytes'];
+ $environment['HTML_API_CC_MAX_TOKENS'] = (string) $this->limits['maxTokens'];
+ $environment['HTML_API_CC_MAX_NODES'] = (string) $this->limits['maxNodes'];
+ $environment['HTML_API_CC_MAX_DEPTH'] = (string) $this->limits['maxDepth'];
+ $environment['HTML_API_CC_MAX_TREE_BYTES'] = (string) $this->limits['maxTreeBytes'];
+ $environment['HTML_API_CC_MAX_KEEP_PER_SIGNATURE'] = '3';
+ $environment['HTML_API_CC_REQUIRE_UTF8'] = $this->require_utf8 ? '1' : '0';
+ $environment['HTML_API_CC_RETAIN_ALL'] = $this->retain_all ? '1' : '0';
+ $environment['HTML_API_CC_WORKER_SCRIPT'] = $this->worker_path;
+ $environment['HTML_API_FUZZ_LEXBOR_ORACLE'] = $this->oracle_options[ OracleRenderer::KIND_LEXBOR_SOURCE ]['lexbor-oracle-bin'];
+ $environment['HTML_API_FUZZ_HTML5EVER_ORACLE'] = $this->oracle_options[ OracleRenderer::KIND_HTML5EVER_SOURCE ]['html5ever-oracle-bin'];
+ $environment['HTML_API_FUZZ_CHROME_ORACLE'] = $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['chrome-oracle-script'];
+ $environment['HTML_API_FUZZ_CHROME_EXECUTABLE'] = $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['chrome-executable'];
+ $environment['HTML_API_FUZZ_NODE_BIN'] = $this->oracle_options[ OracleRenderer::KIND_CHROME_CDP ]['node-bin'];
+ $environment['HTML_API_CC_COORDINATOR_ID'] = $this->coordinator_id;
+ $environment['HTML_API_CC_BATCH_NAME'] = $this->batch_name;
+ $environment['HTML_API_CC_CACHE_PATH'] = $cache_path;
+ $environment['HTML_API_CC_BATCH_MANIFEST_PATH'] = $this->output_dir . '/batch-manifest.json';
+ $environment['HTML_API_CC_BATCH_MANIFEST_SHA256'] = $manifest['batchManifestSha256'];
+ $environment['HTML_API_CC_CORPUS_FINGERPRINT'] = $manifest['corpusFingerprint'];
+ $environment['HTML_API_CC_DOCUMENT_COUNT'] = (string) $manifest['batch']['documentCount'];
+ $environment['HTML_API_CC_CRAWL_ID'] = $manifest['batch']['crawlId'];
+ $environment['HTML_API_CC_PHAR_PATH'] = $this->phar_path;
+ $environment['HTML_API_CC_PHAR_SHA256'] = $trust['phar']['sha256'];
+ $environment['HTML_API_CC_INVOCATION_BASE64'] = base64_encode( self::canonical_json( $invocation ) );
+ $environment['HTML_API_CC_CALLBACK_SHA256'] = $trust['callback']['sha256'];
+ $environment['HTML_API_CC_WORKER_SHA256'] = $trust['worker']['sha256'];
+ $environment['HTML_API_CC_REPOSITORY_COMMIT'] = $trust['repository']['commit'];
+ $environment['HTML_API_CC_REPOSITORY_DIRTY'] = $trust['repository']['dirty'] ? '1' : '0';
+ $environment['HTML_API_CC_REPOSITORY_CODE_SHA256'] = $trust['repository']['codeSha256'];
+ $environment['HTML_API_CC_REPOSITORY_STATE_SHA256'] = $trust['repository']['stateSha256'];
+ $environment['HTML_API_CC_RUNTIME_SHA256'] = $trust['runtime']['runtimeSha256'];
+ $environment['HTML_API_CC_TRUST_BUNDLE_SHA256'] = $trust['trustBundleSha256'];
+ ksort( $environment );
+ return $environment;
+ }
+
+ private function provenance_from_environment( array $environment ): array {
+ $invocation = StrictJsonParser::decode( base64_decode( $environment['HTML_API_CC_INVOCATION_BASE64'], true ) );
+ return array(
+ 'id' => $environment['HTML_API_CC_COORDINATOR_ID'],
+ 'batchName' => $environment['HTML_API_CC_BATCH_NAME'],
+ 'cachePath' => $environment['HTML_API_CC_CACHE_PATH'],
+ 'batchManifestPath' => $environment['HTML_API_CC_BATCH_MANIFEST_PATH'],
+ 'batchManifestSha256' => $environment['HTML_API_CC_BATCH_MANIFEST_SHA256'],
+ 'corpusFingerprint' => $environment['HTML_API_CC_CORPUS_FINGERPRINT'],
+ 'documentCount' => (int) $environment['HTML_API_CC_DOCUMENT_COUNT'],
+ 'crawlId' => $environment['HTML_API_CC_CRAWL_ID'],
+ 'pharPath' => $environment['HTML_API_CC_PHAR_PATH'],
+ 'pharSha256' => $environment['HTML_API_CC_PHAR_SHA256'],
+ 'invocation' => $invocation,
+ 'callbackSha256' => $environment['HTML_API_CC_CALLBACK_SHA256'],
+ 'workerSha256' => $environment['HTML_API_CC_WORKER_SHA256'],
+ 'repositoryCommit' => $environment['HTML_API_CC_REPOSITORY_COMMIT'],
+ 'repositoryDirty' => '1' === $environment['HTML_API_CC_REPOSITORY_DIRTY'],
+ 'repositoryCodeSha256' => $environment['HTML_API_CC_REPOSITORY_CODE_SHA256'],
+ 'repositoryStateSha256' => $environment['HTML_API_CC_REPOSITORY_STATE_SHA256'],
+ 'runtimeSha256' => $environment['HTML_API_CC_RUNTIME_SHA256'],
+ 'trustBundleSha256' => $environment['HTML_API_CC_TRUST_BUNDLE_SHA256'],
+ );
+ }
+
+ public static function load_batch_manifest( string $cache_path, array $info ): array {
+ $db = new \SQLite3( $cache_path, SQLITE3_OPEN_READONLY );
+ $db->busyTimeout( 5000 );
+ try {
+ self::assert_table_columns( $db, 'document_batches', array( 'name', 'source_mode', 'crawl_selection', 'crawl_id', 'url_pattern', 'requested_limit', 'state_path', 'cache_path', 'status', 'created_at', 'updated_at', 'completed_at' ) );
+ self::assert_table_columns( $db, 'document_batch_documents', array( 'batch_name', 'sequence', 'input_state_key', 'input_url', 'record_id', 'target_uri', 'response_code', 'byte_length', 'checksum', 'range_start', 'range_length', 'cached_at' ) );
+ self::assert_table_columns( $db, 'cached_documents', array( 'input_state_key', 'input_url', 'record_id', 'target_uri', 'response_code', 'content_type', 'transport_charset', 'response_headers_json', 'body', 'byte_length', 'range_start', 'range_length', 'fetched_at', 'last_used_at' ) );
+ $statement = $db->prepare( 'SELECT * FROM document_batches WHERE name = :name LIMIT 1' );
+ $statement->bindValue( ':name', $info['name'], SQLITE3_TEXT );
+ $batch_row = $statement->execute()->fetchArray( SQLITE3_ASSOC );
+ if ( ! is_array( $batch_row ) ) {
+ throw new \RuntimeException( 'Named batch disappeared from the document cache.' );
+ }
+ foreach ( array( 'name', 'source_mode', 'crawl_selection', 'crawl_id', 'url_pattern', 'state_path', 'cache_path', 'status', 'created_at', 'updated_at' ) as $field ) {
+ if ( ! is_string( $batch_row[ $field ] ?? null ) ) {
+ throw new \RuntimeException( "Batch database metadata {$field} has an invalid type." );
+ }
+ }
+ if ( ! is_int( $batch_row['requested_limit'] ?? null ) || $batch_row['requested_limit'] < 1 || ! is_string( $batch_row['completed_at'] ?? null ) || '' === $batch_row['completed_at'] ) {
+ throw new \RuntimeException( 'Batch database limit or completion metadata is invalid.' );
+ }
+ $batch = array(
+ 'name' => $batch_row['name'],
+ 'sourceMode' => $batch_row['source_mode'],
+ 'crawlSelection' => $batch_row['crawl_selection'],
+ 'crawlId' => $batch_row['crawl_id'],
+ 'urlPattern' => $batch_row['url_pattern'],
+ 'requestedLimit' => $batch_row['requested_limit'],
+ 'statePath' => $batch_row['state_path'],
+ 'cachePath' => realpath( $batch_row['cache_path'] ) ?: $batch_row['cache_path'],
+ 'status' => $batch_row['status'],
+ 'documentCount' => (int) $info['documentCount'],
+ 'byteCount' => (int) $info['byteCount'],
+ 'createdAt' => $batch_row['created_at'],
+ 'updatedAt' => $batch_row['updated_at'],
+ 'completedAt' => $batch_row['completed_at'],
+ );
+ foreach ( array( 'name', 'sourceMode', 'crawlSelection', 'crawlId', 'urlPattern', 'requestedLimit', 'statePath', 'status', 'documentCount', 'byteCount', 'createdAt', 'updatedAt', 'completedAt' ) as $key ) {
+ if ( $batch[ $key ] !== $info[ $key ] ) {
+ throw new \RuntimeException( "Batch database metadata {$key} does not match batch info." );
+ }
+ }
+ if ( 'ready' !== $batch['status'] || realpath( $cache_path ) !== realpath( $batch['cachePath'] ) || realpath( $info['cachePath'] ) !== realpath( $batch['cachePath'] ) ) {
+ throw new \RuntimeException( 'Batch database cache path does not match batch info.' );
+ }
+
+ $query = $db->prepare(
+ 'SELECT d.*, c.input_url AS cache_input_url, c.target_uri AS cache_target_uri,
+ c.response_code AS cache_response_code, c.content_type, c.transport_charset,
+ c.body, c.byte_length AS cache_byte_length, c.range_start AS cache_range_start,
+ c.range_length AS cache_range_length
+ FROM document_batch_documents d
+ LEFT JOIN cached_documents c
+ ON c.input_state_key = d.input_state_key AND c.record_id = d.record_id
+ WHERE d.batch_name = :name ORDER BY d.sequence ASC'
+ );
+ $query->bindValue( ':name', $info['name'], SQLITE3_TEXT );
+ $rows = $query->execute();
+ $documents = array();
+ $seen_identity = array();
+ $seen_vector = array();
+ $byte_count = 0;
+ while ( $row = $rows->fetchArray( SQLITE3_ASSOC ) ) {
+ $sequence = count( $documents ) + 1;
+ if ( ! is_int( $row['sequence'] ?? null ) || $sequence !== $row['sequence'] ) {
+ throw new \RuntimeException( 'Batch document sequence is duplicated or non-contiguous.' );
+ }
+ foreach ( array( 'input_state_key', 'input_url', 'record_id', 'target_uri', 'checksum', 'cached_at' ) as $field ) {
+ if ( ! is_string( $row[ $field ] ) || '' === $row[ $field ] ) {
+ throw new \RuntimeException( "Batch manifest field {$field} is empty or invalid." );
+ }
+ }
+ if ( ! is_string( $row['body'] ?? null ) ) {
+ throw new \RuntimeException( "Batch cache body is missing at sequence {$sequence}." );
+ }
+ foreach ( array( 'response_code', 'cache_response_code' ) as $field ) {
+ if ( ! is_int( $row[ $field ] ?? null ) || $row[ $field ] < 100 || $row[ $field ] > 599 ) {
+ throw new \RuntimeException( "Batch HTTP response metadata is invalid at sequence {$sequence}." );
+ }
+ }
+ foreach ( array( 'byte_length', 'cache_byte_length' ) as $field ) {
+ if ( ! is_int( $row[ $field ] ?? null ) || $row[ $field ] < 0 ) {
+ throw new \RuntimeException( "Batch byte-length metadata is invalid at sequence {$sequence}." );
+ }
+ }
+ foreach ( array( 'range_start', 'cache_range_start' ) as $field ) {
+ if ( null !== $row[ $field ] && ( ! is_int( $row[ $field ] ) || $row[ $field ] < 0 ) ) {
+ throw new \RuntimeException( "Batch range-start metadata is invalid at sequence {$sequence}." );
+ }
+ }
+ foreach ( array( 'range_length', 'cache_range_length' ) as $field ) {
+ if ( null !== $row[ $field ] && ( ! is_int( $row[ $field ] ) || $row[ $field ] < 1 ) ) {
+ throw new \RuntimeException( "Batch range-length metadata is invalid at sequence {$sequence}." );
+ }
+ }
+ if ( ! is_string( $row['content_type'] ?? null ) || 1 !== preg_match( '/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+\/[!#$%&\'*+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[^\r\n]*)?$/D', $row['content_type'] ) ) {
+ throw new \RuntimeException( "Batch content-type metadata is invalid at sequence {$sequence}." );
+ }
+ if ( null !== $row['transport_charset'] && ( ! is_string( $row['transport_charset'] ) || '' === trim( $row['transport_charset'] ) || preg_match( '/[\x00-\x20\x7f]/', $row['transport_charset'] ) ) ) {
+ throw new \RuntimeException( "Batch transport-charset metadata is invalid at sequence {$sequence}." );
+ }
+ $length = strlen( $row['body'] );
+ $sha256 = hash( 'sha256', $row['body'] );
+ if ( 1 !== preg_match( '/^[0-9a-f]{64}$/', $row['checksum'] ) || ! hash_equals( $row['checksum'], $sha256 ) || $length !== $row['byte_length'] || $length !== $row['cache_byte_length'] ) {
+ throw new \RuntimeException( "Batch cache body identity mismatch at sequence {$sequence}." );
+ }
+ foreach ( array( 'input_url' => 'cache_input_url', 'target_uri' => 'cache_target_uri', 'response_code' => 'cache_response_code', 'range_start' => 'cache_range_start', 'range_length' => 'cache_range_length' ) as $manifest_field => $cache_field ) {
+ if ( $row[ $manifest_field ] !== $row[ $cache_field ] ) {
+ throw new \RuntimeException( "Batch/cache metadata differs for {$manifest_field} at sequence {$sequence}." );
+ }
+ }
+ $range_start = $row['range_start'];
+ $range_length = $row['range_length'];
+ if ( ( null === $range_start ) !== ( null === $range_length ) || ( null !== $range_start && ( $range_start < 0 || $range_length < 1 ) ) ) {
+ throw new \RuntimeException( "Batch range metadata is invalid at sequence {$sequence}." );
+ }
+ $identity_key = $row['input_state_key'] . "\0" . $row['record_id'];
+ $vector_key = $row['record_id'] . "\0" . $sha256 . "\0" . $length;
+ if ( isset( $seen_identity[ $identity_key ] ) || isset( $seen_vector[ $vector_key ] ) ) {
+ throw new \RuntimeException( "Batch contains a duplicate document at sequence {$sequence}." );
+ }
+ $seen_identity[ $identity_key ] = true;
+ $seen_vector[ $vector_key ] = true;
+ $documents[] = array(
+ 'sequence' => $sequence,
+ 'inputStateKey' => $row['input_state_key'],
+ 'inputUrl' => $row['input_url'],
+ 'recordId' => $row['record_id'],
+ 'targetUri' => $row['target_uri'],
+ 'responseCode' => $row['response_code'],
+ 'contentType' => $row['content_type'],
+ 'transportCharset' => $row['transport_charset'],
+ 'inputLength' => $length,
+ 'inputSha256' => $sha256,
+ 'rangeStart' => $range_start,
+ 'rangeLength' => $range_length,
+ 'cachedAt' => $row['cached_at'],
+ );
+ $byte_count += $length;
+ }
+ if ( count( $documents ) !== $batch['documentCount'] || $byte_count !== $batch['byteCount'] || 0 === count( $documents ) ) {
+ throw new \RuntimeException( 'Batch manifest count or byte total is incomplete.' );
+ }
+ $stable = array( 'batch' => $batch, 'documents' => $documents );
+ $vector = array_map( static fn ( array $document ): array => array( 'recordId' => $document['recordId'], 'inputSha256' => $document['inputSha256'], 'inputLength' => $document['inputLength'] ), $documents );
+ return array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html-api-commoncrawl-batch-manifest',
+ 'batch' => $batch,
+ 'documents' => $documents,
+ 'batchManifestSha256' => hash( 'sha256', self::canonical_json( $stable ) ),
+ 'corpusFingerprint' => hash( 'sha256', self::canonical_json( $vector ) ),
+ );
+ } finally {
+ $db->close();
+ }
+ }
+
+ public static function verify_run_output( string $run_dir, array $manifest, string $run_id, string $kind, string $identity_sha256, array $provenance, ?array $expected_environment = null ): array {
+ $summary_path = $run_dir . '/commoncrawl-summary.ndjson';
+ $text = @file_get_contents( $summary_path );
+ if ( false === $text || '' === $text || "\n" !== substr( $text, -1 ) ) {
+ throw new \RuntimeException( "{$kind} summary is missing or not newline-terminated." );
+ }
+ $lines = explode( "\n", substr( $text, 0, -1 ) );
+ if ( count( $lines ) !== count( $manifest['documents'] ) ) {
+ throw new \RuntimeException( "{$kind} summary is partial or contains extra documents." );
+ }
+ $vector = array();
+ $statuses = array();
+ $config_hash = null;
+ $seen = array();
+ foreach ( $lines as $index => $line ) {
+ if ( '' === $line ) {
+ throw new \RuntimeException( "{$kind} summary contains a blank record." );
+ }
+ $record = StrictJsonParser::decode( $line );
+ if ( ! is_array( $record ) ) {
+ throw new \RuntimeException( "{$kind} summary record is not an object." );
+ }
+ $expected = $manifest['documents'][ $index ];
+ $common = is_array( $record['commonCrawl'] ?? null ) ? $record['commonCrawl'] : array();
+ foreach ( array( 'recordId', 'inputStateKey', 'targetUri', 'rangeStart', 'rangeLength' ) as $field ) {
+ if ( ( $common[ $field ] ?? null ) !== $expected[ $field ] ) {
+ throw new \RuntimeException( "{$kind} summary metadata/order differs at sequence " . ( $index + 1 ) . "." );
+ }
+ }
+ if ( ( $record['inputSha256'] ?? null ) !== $expected['inputSha256'] || ( $record['inputLength'] ?? null ) !== $expected['inputLength'] || ( $common['inputSha256'] ?? null ) !== $expected['inputSha256'] || ( $common['byteLength'] ?? null ) !== $expected['inputLength'] ) {
+ throw new \RuntimeException( "{$kind} summary input identity differs at sequence " . ( $index + 1 ) . "." );
+ }
+ $key = $expected['recordId'] . "\0" . $expected['inputSha256'] . "\0" . $expected['inputLength'];
+ if ( isset( $seen[ $key ] ) ) {
+ throw new \RuntimeException( "{$kind} summary contains a duplicate document." );
+ }
+ $seen[ $key ] = true;
+ if ( $run_id !== ( $record['runId'] ?? null ) || self::canonical_json( $provenance ) !== self::canonical_json( $record['coordinator'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} summary run/coordinator provenance differs." );
+ }
+ if ( ( $record['repo']['commit'] ?? null ) !== $provenance['repositoryCommit'] || ( $record['repo']['dirty'] ?? null ) !== $provenance['repositoryDirty'] ) {
+ throw new \RuntimeException( "{$kind} summary repository provenance differs." );
+ }
+ if ( null === $config_hash ) {
+ $config_hash = $record['configHash'] ?? null;
+ } elseif ( $config_hash !== ( $record['configHash'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} summary mixes configurations." );
+ }
+ if ( ! is_array( $record['oracle'] ?? null ) || ! hash_equals( $identity_sha256, OracleRenderer::identity_sha256( $record['oracle'] ) ) ) {
+ throw new \RuntimeException( "{$kind} summary oracle identity differs." );
+ }
+ if ( in_array( $record['failureClass'] ?? null, array( 'commoncrawl-callback-error', 'worker-input-identity-drift', 'oracle-identity-drift' ), true ) ) {
+ throw new \RuntimeException( "{$kind} run contains coordinator-critical identity/callback failure." );
+ }
+ $status = (string) ( $record['status'] ?? 'unknown' );
+ $statuses[ $status ] = 1 + (int) ( $statuses[ $status ] ?? 0 );
+ $vector[] = array( 'recordId' => $expected['recordId'], 'inputSha256' => $expected['inputSha256'], 'inputLength' => $expected['inputLength'] );
+ }
+ $configuration = self::strict_json_file( $run_dir . '/configuration.json' );
+ self::assert_exact_keys( $configuration, array( 'kind', 'runId', 'repo', 'phpVersion', 'oracle', 'oracleIdentitySha256', 'limits', 'maxInputBytes', 'maxKeepPerSignature', 'processTimeoutMs', 'memoryLimit', 'checks', 'fullSamplePercent', 'requireUtf8', 'retainAll', 'workerScript', 'inputSemantics', 'ccAnalyzer', 'configHash', 'createdAt' ), "{$kind} configuration" );
+ $claimed_config_hash = $configuration['configHash'] ?? null;
+ $hash_material = $configuration;
+ unset( $hash_material['configHash'], $hash_material['createdAt'] );
+ $computed_config_hash = hash( 'sha256', self::canonical_json( $hash_material ) );
+ if ( ! is_string( $claimed_config_hash ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', $claimed_config_hash ) || ! hash_equals( $computed_config_hash, $claimed_config_hash ) || $run_id !== ( $configuration['runId'] ?? null ) || $config_hash !== $claimed_config_hash || self::canonical_json( $provenance ) !== self::canonical_json( $configuration['ccAnalyzer']['coordinator'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} configuration does not match its summaries." );
+ }
+ if ( 'html-api-commoncrawl-configuration' !== $configuration['kind'] || ! is_string( $configuration['createdAt'] ) || '' === $configuration['createdAt'] || 'raw cc-analyzer response body; no charset transcoding' !== $configuration['inputSemantics'] || ! is_array( $configuration['repo'] ) || ! is_array( $configuration['ccAnalyzer'] ) ) {
+ throw new \RuntimeException( "{$kind} configuration identity fields are invalid." );
+ }
+ if ( ( $configuration['repo']['commit'] ?? null ) !== $provenance['repositoryCommit'] || ( $configuration['repo']['dirty'] ?? null ) !== $provenance['repositoryDirty'] ) {
+ throw new \RuntimeException( "{$kind} configuration repository provenance differs." );
+ }
+ if ( ! is_array( $configuration['oracle'] ?? null ) || ! hash_equals( $identity_sha256, OracleRenderer::identity_sha256( $configuration['oracle'] ) ) || ! is_string( $configuration['oracleIdentitySha256'] ) || ! hash_equals( $identity_sha256, $configuration['oracleIdentitySha256'] ) ) {
+ throw new \RuntimeException( "{$kind} configuration oracle identity differs." );
+ }
+ if ( null !== $expected_environment ) {
+ ksort( $expected_environment );
+ $environment_evidence = self::strict_json_file( $run_dir . '/replacement-environment.json' );
+ self::assert_exact_keys( $environment_evidence, array( 'kind', 'environment', 'environmentSha256' ), "{$kind} replacement environment" );
+ $environment_sha256 = hash( 'sha256', self::canonical_json( $expected_environment ) );
+ if ( 'html-api-commoncrawl-replacement-environment' !== $environment_evidence['kind'] || self::canonical_json( $expected_environment ) !== self::canonical_json( $environment_evidence['environment'] ?? null ) || ! is_string( $environment_evidence['environmentSha256'] ) || ! hash_equals( $environment_sha256, $environment_evidence['environmentSha256'] ) ) {
+ throw new \RuntimeException( "{$kind} replacement environment evidence differs." );
+ }
+ $expected_configuration = array(
+ 'limits' => array(
+ 'maxTokens' => (int) $expected_environment['HTML_API_CC_MAX_TOKENS'],
+ 'maxNodes' => (int) $expected_environment['HTML_API_CC_MAX_NODES'],
+ 'maxDepth' => (int) $expected_environment['HTML_API_CC_MAX_DEPTH'],
+ 'maxTreeBytes' => (int) $expected_environment['HTML_API_CC_MAX_TREE_BYTES'],
+ ),
+ 'maxInputBytes' => (int) $expected_environment['HTML_API_CC_MAX_INPUT_BYTES'],
+ 'maxKeepPerSignature' => (int) $expected_environment['HTML_API_CC_MAX_KEEP_PER_SIGNATURE'],
+ 'processTimeoutMs' => (int) $expected_environment['HTML_API_CC_PROCESS_TIMEOUT_MS'],
+ 'memoryLimit' => $expected_environment['HTML_API_CC_MEMORY_LIMIT'],
+ 'checks' => $expected_environment['HTML_API_CC_CHECKS'],
+ 'fullSamplePercent' => (int) $expected_environment['HTML_API_CC_FULL_SAMPLE_PERCENT'],
+ 'requireUtf8' => '1' === $expected_environment['HTML_API_CC_REQUIRE_UTF8'],
+ 'retainAll' => '1' === $expected_environment['HTML_API_CC_RETAIN_ALL'],
+ 'workerScript' => realpath( $expected_environment['HTML_API_CC_WORKER_SCRIPT'] ),
+ );
+ foreach ( $expected_configuration as $field => $expected ) {
+ if ( $expected !== ( $configuration[ $field ] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} configuration field {$field} differs from the replacement environment." );
+ }
+ }
+ if ( PHP_VERSION !== ( $configuration['phpVersion'] ?? null ) ) {
+ throw new \RuntimeException( "{$kind} configuration PHP version differs." );
+ }
+ }
+ return array(
+ 'count' => count( $vector ),
+ 'configHash' => $config_hash,
+ 'statuses' => $statuses,
+ 'vector' => $vector,
+ 'corpusFingerprint' => hash( 'sha256', self::canonical_json( $vector ) ),
+ );
+ }
+
+ private static function repository_code_identity( array $git ): array {
+ $status = base64_decode( (string) $git['statusBase64'], true );
+ if ( false === $status ) {
+ throw new \RuntimeException( 'Git status probe is not valid base64.' );
+ }
+ $tracked = array();
+ $required = array_fill_keys( array_merge( self::REQUIRED_TOOL_DEPENDENCIES, self::WORDPRESS_DEPENDENCIES ), false );
+ foreach ( $git['tracked'] as $entry ) {
+ if ( ! is_array( $entry ) || ! is_string( $entry['path'] ?? null ) || ! is_string( $entry['mode'] ?? null ) ) {
+ throw new \RuntimeException( 'Git tracked-file probe is malformed.' );
+ }
+ $path = $entry['path'];
+ if ( 0 !== strpos( $path, 'tools/html-api-fuzz/' ) && ! array_key_exists( $path, $required ) ) {
+ throw new \RuntimeException( 'Git probe returned an unexpected executed-code path.' );
+ }
+ $absolute = repo_root() . '/' . $path;
+ if ( is_link( $absolute ) || ! is_file( $absolute ) ) {
+ throw new \RuntimeException( "Executed repository code is missing or symlinked: {$path}" );
+ }
+ if ( isset( $required[ $path ] ) ) {
+ $required[ $path ] = true;
+ }
+ $identity = self::file_identity( $absolute );
+ $tracked[] = array(
+ 'path' => $path,
+ 'mode' => $entry['mode'],
+ 'actualMode' => $identity['mode'],
+ 'bytes' => $identity['bytes'],
+ 'sha256' => $identity['sha256'],
+ );
+ }
+ foreach ( $required as $path => $present ) {
+ if ( ! $present ) {
+ throw new \RuntimeException( "Required executed-code dependency is not tracked: {$path}" );
+ }
+ }
+ usort( $tracked, static fn ( array $a, array $b ): int => strcmp( $a['path'], $b['path'] ) );
+ return array(
+ 'commit' => $git['commit'],
+ 'branch' => is_string( $git['branch'] ) ? $git['branch'] : null,
+ 'dirty' => '' !== $status,
+ 'stateSha256' => hash( 'sha256', $status ),
+ 'codeSha256' => hash( 'sha256', self::canonical_json( $tracked ) ),
+ 'trackedFiles' => $tracked,
+ );
+ }
+
+ private static function validate_batch_info( $value, string $batch_name ): array {
+ $keys = array( 'name', 'sourceMode', 'crawlSelection', 'crawlId', 'urlPattern', 'requestedLimit', 'statePath', 'cachePath', 'status', 'documentCount', 'byteCount', 'runCount', 'createdAt', 'updatedAt', 'completedAt' );
+ self::assert_exact_keys( $value, $keys, 'batch info' );
+ foreach ( array( 'name', 'sourceMode', 'crawlSelection', 'crawlId', 'statePath', 'cachePath', 'status', 'createdAt', 'updatedAt' ) as $key ) {
+ if ( ! is_string( $value[ $key ] ) || '' === $value[ $key ] ) {
+ throw new \RuntimeException( "Batch info {$key} is invalid." );
+ }
+ }
+ if ( ! is_string( $value['urlPattern'] ) || ! is_string( $value['completedAt'] ) || '' === $value['completedAt'] ) {
+ throw new \RuntimeException( 'Batch info URL pattern or completion time is invalid.' );
+ }
+ foreach ( array( 'requestedLimit', 'documentCount' ) as $key ) {
+ if ( ! is_int( $value[ $key ] ) || $value[ $key ] < 1 ) {
+ throw new \RuntimeException( "Batch info {$key} is invalid." );
+ }
+ }
+ foreach ( array( 'byteCount', 'runCount' ) as $key ) {
+ if ( ! is_int( $value[ $key ] ) || $value[ $key ] < 0 ) {
+ throw new \RuntimeException( "Batch info {$key} is invalid." );
+ }
+ }
+ if ( $batch_name !== $value['name'] || 'ready' !== $value['status'] ) {
+ throw new \RuntimeException( 'Batch is not the requested ready batch.' );
+ }
+ return $value;
+ }
+
+ private static function validate_batch_verification( $value, string $batch_name, int $count ): array {
+ self::assert_exact_keys( $value, array( 'batchName', 'documentCount', 'missingCount', 'missingDocuments' ), 'batch verification' );
+ if ( ! is_string( $value['batchName'] ) || ! is_int( $value['documentCount'] ) || ! is_int( $value['missingCount'] ) || ! is_array( $value['missingDocuments'] ) || $batch_name !== $value['batchName'] || $count !== $value['documentCount'] || 0 !== $value['missingCount'] || array() !== $value['missingDocuments'] ) {
+ throw new \RuntimeException( 'Batch verification reports missing or inconsistent cached documents.' );
+ }
+ return $value;
+ }
+
+ private static function validate_batch_run_result( $value, array $expected_batch, string $callback_path, string $callback_sha256 ): array {
+ self::assert_exact_keys( $value, array( 'batch', 'run', 'documentsAnalyzed' ), 'batch run result' );
+ $batch = self::validate_batch_info( $value['batch'], $expected_batch['name'] );
+ foreach ( $expected_batch as $key => $expected ) {
+ if ( ! array_key_exists( $key, $batch ) ) {
+ throw new \RuntimeException( "Batch run result omitted batch metadata {$key}." );
+ }
+ if ( 'cachePath' === $key ) {
+ if ( false === realpath( $expected ) || realpath( $expected ) !== realpath( $batch[ $key ] ) ) {
+ throw new \RuntimeException( 'Batch run result changed batch metadata cachePath.' );
+ }
+ continue;
+ }
+ if ( $expected !== $batch[ $key ] ) {
+ throw new \RuntimeException( "Batch run result changed batch metadata {$key}." );
+ }
+ }
+ self::assert_exact_keys( $value['run'], array( 'batchName', 'runId', 'analysisScript', 'analysisScriptHash', 'documentsAnalyzed', 'startedAt', 'completedAt' ), 'batch run record' );
+ $run = $value['run'];
+ $count = $expected_batch['documentCount'];
+ if (
+ ! is_int( $value['documentsAnalyzed'] ) ||
+ ! is_string( $run['batchName'] ) ||
+ ! is_int( $run['runId'] ) || $run['runId'] < 1 ||
+ ! is_string( $run['analysisScript'] ) ||
+ ! is_string( $run['analysisScriptHash'] ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', $run['analysisScriptHash'] ) ||
+ ! is_int( $run['documentsAnalyzed'] ) ||
+ ! is_string( $run['startedAt'] ) || '' === $run['startedAt'] ||
+ ! is_string( $run['completedAt'] ) || '' === $run['completedAt'] ||
+ $count !== $value['documentsAnalyzed'] ||
+ $expected_batch['name'] !== $run['batchName'] ||
+ $callback_path !== $run['analysisScript'] ||
+ $count !== $run['documentsAnalyzed'] ||
+ ! hash_equals( $callback_sha256, $run['analysisScriptHash'] )
+ ) {
+ throw new \RuntimeException( 'Batch run result is partial or inconsistent.' );
+ }
+ return $value;
+ }
+
+ private static function assert_table_columns( \SQLite3 $db, string $table, array $expected ): void {
+ $result = $db->query( "PRAGMA table_info({$table})" );
+ $columns = array();
+ while ( $row = $result->fetchArray( SQLITE3_ASSOC ) ) {
+ $columns[] = $row['name'];
+ }
+ if ( $columns !== $expected ) {
+ throw new \RuntimeException( "Unsupported cc-analyzer SQLite schema for {$table}." );
+ }
+ }
+
+ private static function assert_process_success( array $process, string $label ): void {
+ if ( 0 !== ( $process['code'] ?? null ) || true === ( $process['timedOut'] ?? false ) || true === ( $process['stdoutTruncated'] ?? false ) || true === ( $process['stderrTruncated'] ?? false ) || true === ( $process['processGroupCleanupFailed'] ?? false ) ) {
+ throw new \RuntimeException( "{$label} failed, timed out, truncated output, or leaked a process group." );
+ }
+ }
+
+ private static function decode_process_json( array $process, string $label ) {
+ self::assert_process_success( $process, $label );
+ $stdout = (string) ( $process['stdout'] ?? '' );
+ if ( '' === $stdout || "\n" !== substr( $stdout, -1 ) ) {
+ throw new \RuntimeException( "{$label} did not emit one newline-terminated JSON value." );
+ }
+ return StrictJsonParser::decode( substr( $stdout, 0, -1 ) );
+ }
+
+ private static function assert_environment_echo( array &$probe, array $environment, string $label ): void {
+ $actual = $probe['environment'] ?? null;
+ $expected = $environment;
+ ksort( $expected );
+ if ( ! is_array( $actual ) || self::canonical_json( $expected ) !== self::canonical_json( $actual ) ) {
+ throw new \RuntimeException( "{$label} did not receive the exact sanitized replacement environment." );
+ }
+ unset( $probe['environment'] );
+ }
+
+ private static function assert_same_trust( array $expected, array $actual, string $when ): void {
+ if ( self::canonical_json( $expected ) !== self::canonical_json( $actual ) ) {
+ throw new \RuntimeException( "Global trust bundle changed {$when}." );
+ }
+ }
+
+ private static function assert_same_manifest( array $expected, array $actual, string $when ): void {
+ if ( self::canonical_json( $expected ) !== self::canonical_json( $actual ) ) {
+ throw new \RuntimeException( "Cached batch corpus changed {$when}." );
+ }
+ }
+
+ private static function assert_exact_keys( $value, array $expected, string $label ): void {
+ if ( ! is_array( $value ) ) {
+ throw new \RuntimeException( "{$label} must be a JSON object." );
+ }
+ $actual = array_keys( $value );
+ sort( $actual );
+ sort( $expected );
+ if ( $actual !== $expected ) {
+ throw new \RuntimeException( "{$label} has an unexpected JSON schema." );
+ }
+ }
+
+ private static function compact_process( array $process ): array {
+ return array(
+ 'command' => $process['command'] ?? null,
+ 'code' => $process['code'] ?? null,
+ 'durationMs' => $process['durationMs'] ?? null,
+ 'stdoutLogPath' => $process['stdoutLogPath'] ?? null,
+ 'stderrLogPath' => $process['stderrLogPath'] ?? null,
+ 'processGroupIsolated' => $process['processGroupIsolated'] ?? false,
+ 'processGroupCleanupFailed' => $process['processGroupCleanupFailed'] ?? false,
+ );
+ }
+
+ private static function strict_json_file( string $path ) {
+ $text = @file_get_contents( $path );
+ if ( false === $text || '' === $text || "\n" !== substr( $text, -1 ) ) {
+ throw new \RuntimeException( "JSON file is missing or incomplete: {$path}" );
+ }
+ return StrictJsonParser::decode( substr( $text, 0, -1 ) );
+ }
+
+ private static function file_identity( string $path ): array {
+ clearstatcache( true, $path );
+ $resolved = realpath( $path );
+ if ( false === $resolved || ! is_file( $resolved ) || is_link( $path ) ) {
+ throw new \RuntimeException( "Trust input is missing, non-regular, or symlinked: {$path}" );
+ }
+ $handle = @fopen( $resolved, 'rb' );
+ if ( false === $handle ) {
+ throw new \RuntimeException( "Trust input is unreadable: {$path}" );
+ }
+ $before = fstat( $handle );
+ $context = hash_init( 'sha256' );
+ $bytes = hash_update_stream( $context, $handle );
+ $sha256 = hash_final( $context );
+ $after = fstat( $handle );
+ $closed = fclose( $handle );
+ clearstatcache( true, $resolved );
+ $later = @lstat( $resolved );
+ $stable_fields = array( 'dev', 'ino', 'mode', 'size', 'mtime', 'ctime' );
+ if ( ! is_array( $before ) || ! is_array( $after ) || ! is_array( $later ) || false === $bytes || ! $closed || $bytes !== $before['size'] || 1 !== preg_match( '/^[0-9a-f]{64}$/', $sha256 ) ) {
+ throw new \RuntimeException( "Could not read a complete trust identity: {$path}" );
+ }
+ foreach ( $stable_fields as $field ) {
+ if ( ! array_key_exists( $field, $before ) || $before[ $field ] !== $after[ $field ] || $before[ $field ] !== $later[ $field ] ) {
+ throw new \RuntimeException( "Trust input changed while hashing: {$path}" );
+ }
+ }
+ return array(
+ 'path' => $resolved,
+ 'mode' => sprintf( '%06o', $before['mode'] & 0177777 ),
+ 'bytes' => $bytes,
+ 'sha256' => $sha256,
+ );
+ }
+
+ private static function seal_directory( string $directory, array $excluded ): array {
+ $rows = array();
+ $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ) );
+ foreach ( $iterator as $file ) {
+ $path = $file->getPathname();
+ $relative = substr( $path, strlen( $directory ) + 1 );
+ if ( in_array( $relative, $excluded, true ) ) {
+ continue;
+ }
+ if ( $file->isLink() || ! $file->isFile() ) {
+ throw new \RuntimeException( "Cannot seal non-regular evidence: {$relative}" );
+ }
+ $identity = self::file_identity( $path );
+ $rows[] = array( 'path' => $relative, 'mode' => $identity['mode'], 'bytes' => $identity['bytes'], 'sha256' => $identity['sha256'] );
+ }
+ usort( $rows, static fn ( array $a, array $b ): int => strcmp( $a['path'], $b['path'] ) );
+ return array(
+ 'kind' => 'html-api-commoncrawl-evidence-seal',
+ 'files' => $rows,
+ 'filesSha256' => hash( 'sha256', self::canonical_json( $rows ) ),
+ );
+ }
+
+ private static function canonical_json( $value ): string {
+ $canonical = self::canonicalize( $value );
+ $json = json_encode( $canonical, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE );
+ if ( false === $json ) {
+ throw new \RuntimeException( 'Could not encode canonical coordinator JSON.' );
+ }
+ return $json;
+ }
+
+ private static function canonicalize( $value ) {
+ if ( ! is_array( $value ) ) {
+ return $value;
+ }
+ if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
+ ksort( $value );
+ }
+ foreach ( $value as $key => $item ) {
+ $value[ $key ] = self::canonicalize( $item );
+ }
+ return $value;
+ }
+
+ private static function existing_file_option( array $options, string $name ): string {
+ return self::resolved_file( self::required_string_option( $options, $name ), "--{$name}" );
+ }
+
+ private static function existing_directory_option( array $options, string $name ): string {
+ $value = self::required_string_option( $options, $name );
+ $resolved = realpath( $value );
+ if ( false === $resolved || ! is_dir( $resolved ) || is_link( $value ) ) {
+ throw new \InvalidArgumentException( "--{$name} must resolve to a non-symlink directory." );
+ }
+ return $resolved;
+ }
+
+ private static function new_path_option( array $options, string $name ): string {
+ $value = self::required_string_option( $options, $name );
+ if ( file_exists( $value ) || is_link( $value ) ) {
+ throw new \InvalidArgumentException( "--{$name} must not already exist." );
+ }
+ $parent = realpath( dirname( $value ) );
+ if ( false === $parent || ! is_dir( $parent ) ) {
+ throw new \InvalidArgumentException( "--{$name} parent directory does not exist." );
+ }
+ return $parent . DIRECTORY_SEPARATOR . basename( $value );
+ }
+
+ private static function required_string_option( array $options, string $name ): string {
+ if ( ! array_key_exists( $name, $options ) || true === $options[ $name ] || ! is_string( $options[ $name ] ) || '' === $options[ $name ] ) {
+ throw new \InvalidArgumentException( "Expected --{$name} with a non-empty value." );
+ }
+ return $options[ $name ];
+ }
+
+ private static function required_positive_option( array $options, string $name ): int {
+ if ( ! array_key_exists( $name, $options ) || true === $options[ $name ] ) {
+ throw new \InvalidArgumentException( "Expected --{$name} with a positive integer value." );
+ }
+ return self::positive_option( $options, $name, 0 );
+ }
+
+ private static function positive_option( array $options, string $name, int $default, int $minimum = 1 ): int {
+ $value = option_int( $options, $name, $default );
+ if ( $value < $minimum ) {
+ throw new \InvalidArgumentException( "--{$name} must be at least {$minimum}." );
+ }
+ return $value;
+ }
+
+ private static function strict_bool_option( array $options, string $name, bool $default ): bool {
+ if ( ! array_key_exists( $name, $options ) ) {
+ return $default;
+ }
+ if ( true === $options[ $name ] ) {
+ return true;
+ }
+ $value = filter_var( $options[ $name ], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
+ if ( null === $value ) {
+ throw new \InvalidArgumentException( "--{$name} must be a boolean value." );
+ }
+ return $value;
+ }
+
+ private static function validate_options( array $options ): void {
+ $value_options = array(
+ 'cc-analyzer', 'workspace', 'batch', 'output-dir', 'coordinator-id',
+ 'batch-timeout-ms', 'process-timeout-ms', 'oracle-timeout-ms', 'chrome-startup-timeout-ms',
+ 'checks', 'memory-limit', 'max-input-bytes', 'max-tokens', 'max-nodes', 'max-depth', 'max-tree-bytes',
+ 'lexbor-oracle-bin', 'html5ever-oracle-bin', 'chrome-oracle-script', 'chrome-executable', 'node-bin',
+ );
+ $flag_options = array( 'retain-all', 'require-utf8' );
+ if ( ! is_array( $options['_'] ?? null ) || array() !== $options['_'] ) {
+ throw new \InvalidArgumentException( 'Coordinator does not accept positional arguments.' );
+ }
+ foreach ( $options as $name => $value ) {
+ if ( '_' === $name ) {
+ continue;
+ }
+ if ( ! in_array( $name, $value_options, true ) && ! in_array( $name, $flag_options, true ) ) {
+ throw new \InvalidArgumentException( "Unknown coordinator option --{$name}." );
+ }
+ if ( in_array( $name, $value_options, true ) && true === $value ) {
+ throw new \InvalidArgumentException( "Coordinator option --{$name} requires a value." );
+ }
+ }
+ }
+
+ private static function resolved_file( ?string $path, string $label ): string {
+ if ( null === $path || '' === $path ) {
+ throw new \InvalidArgumentException( "{$label} path is required." );
+ }
+ $resolved = realpath( $path );
+ if ( false === $resolved || ! is_file( $resolved ) || is_link( $path ) ) {
+ throw new \InvalidArgumentException( "{$label} must resolve to a non-symlink regular file." );
+ }
+ return $resolved;
+ }
+
+ private static function resolved_executable( ?string $path, string $label ): string {
+ $resolved = self::resolved_file( $path, $label );
+ if ( ! is_executable( $resolved ) ) {
+ throw new \InvalidArgumentException( "{$label} is not executable." );
+ }
+ return $resolved;
+ }
+}
diff --git a/tools/html-api-fuzz/lib/CommonCrawlRunner.php b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
index 872916d3be2b8..d60918f0046f3 100644
--- a/tools/html-api-fuzz/lib/CommonCrawlRunner.php
+++ b/tools/html-api-fuzz/lib/CommonCrawlRunner.php
@@ -34,6 +34,7 @@ class CommonCrawlRunner {
private array $git_metadata;
private string $run_id;
private string $configuration_hash;
+ private ?array $coordinator_provenance;
private function __construct(
string $output_dir,
@@ -48,7 +49,8 @@ private function __construct(
int $full_sample_percent,
string $memory_limit,
string $worker_script,
- string $run_id
+ string $run_id,
+ ?array $coordinator_provenance
) {
$this->output_dir = $output_dir;
$this->oracle = $oracle;
@@ -64,6 +66,7 @@ private function __construct(
$this->worker_script = $worker_script;
$this->git_metadata = git_metadata( 1000, null, false );
$this->run_id = $run_id;
+ $this->coordinator_provenance = $coordinator_provenance;
ensure_dir( $this->output_dir );
$this->initialize_configuration();
@@ -99,10 +102,11 @@ public static function from_environment(): self {
$oracle_options['chrome-startup-timeout-ms'] = (string) self::environment_int( 'HTML_API_CC_CHROME_STARTUP_TIMEOUT_MS', ChromeOracleRenderer::DEFAULT_STARTUP_TIMEOUT_MS, 1 );
}
+ $coordinator_provenance = self::coordinator_provenance_from_environment();
$oracle = OracleRenderer::from_options( $oracle_options );
return OracleRenderer::with_explicit_close(
$oracle,
- static function ( OracleRenderer $oracle ) use ( $oracle_kind, $output_dir, $run_id ): self {
+ static function ( OracleRenderer $oracle ) use ( $oracle_kind, $output_dir, $run_id, $coordinator_provenance ): self {
$metadata = $oracle->metadata();
if ( true !== ( $metadata['available'] ?? false ) ) {
throw new \RuntimeException(
@@ -173,7 +177,8 @@ static function ( OracleRenderer $oracle ) use ( $oracle_kind, $output_dir, $run
$full_sample_percent,
$memory_limit,
$worker_script,
- $run_id
+ $run_id,
+ $coordinator_provenance
);
}
);
@@ -189,8 +194,9 @@ public function analyze_document( object $document ): array {
return $this->record_callback_error( $metadata, new \InvalidArgumentException( 'cc-analyzer document body must be a string.' ), $started_at );
}
- $metadata['byteLength'] = strlen( $body );
- $metadata['inputSha1'] = sha1( $body );
+ $metadata['byteLength'] = strlen( $body );
+ $metadata['inputSha1'] = sha1( $body );
+ $metadata['inputSha256'] = hash( 'sha256', $body );
$metadata['utf8Valid'] = 1 === preg_match( '//u', $body );
if ( $this->max_input_bytes > 0 && strlen( $body ) > $this->max_input_bytes ) {
return $this->record_skip( $metadata, 'skipped-input-too-large', $started_at );
@@ -206,9 +212,11 @@ public function analyze_document( object $document ): array {
$this->persist_initial_input( $staging_dir, $body, $metadata, $seed, $checks );
$process = run_php_process( $this->worker_args( $staging_dir, $seed, $checks ), repo_root(), $this->process_timeout_ms, $staging_dir . '/worker.log', 1048576, true );
$result = $this->load_or_synthesize_result( $staging_dir, $process, $body, $seed, $checks );
+ $result = $this->enforce_worker_input_identity( $result, $body );
$result = $this->enforce_worker_oracle_identity( $result );
$result['profile'] = 'commoncrawl';
$result['inputSource'] = 'commoncrawl';
+ $result['inputSha256'] = $metadata['inputSha256'];
$result['commonCrawl'] = $metadata;
$result['run'] = $this->run_metadata();
$result['repo'] = $this->git_metadata;
@@ -256,6 +264,30 @@ private function enforce_worker_oracle_identity( array $result ): array {
return $result;
}
+ private function enforce_worker_input_identity( array $result, string $body ): array {
+ $expected = array(
+ 'inputSha1' => sha1( $body ),
+ 'inputLength' => strlen( $body ),
+ );
+ $actual = array(
+ 'inputSha1' => $result['inputSha1'] ?? null,
+ 'inputLength' => $result['inputLength'] ?? null,
+ );
+ if ( $expected === $actual ) {
+ return $result;
+ }
+
+ $result['ok'] = false;
+ $result['status'] = 'worker-input-identity-drift';
+ $result['failureClass'] = 'worker-input-identity-drift';
+ $result['failureSnippet'] = 'Worker result input identity did not match the parent-persisted Common Crawl body.';
+ $result['workerInfrastructure'] = true;
+ $result['expectedInputIdentity'] = $expected;
+ $result['actualInputIdentity'] = $actual;
+ unset( $result['signature'], $result['oracleFinding'], $result['comparison'] );
+ return $result;
+ }
+
private function worker_args( string $staging_dir, int $seed, string $checks ): array {
$args = array(
'-d', 'memory_limit=' . $this->memory_limit,
@@ -317,6 +349,7 @@ private function persist_initial_input( string $staging_dir, string $body, array
'inputSource' => 'commoncrawl',
'inputBase64' => base64_encode( $body ),
'inputSha1' => sha1( $body ),
+ 'inputSha256' => hash( 'sha256', $body ),
'inputLength' => strlen( $body ),
'inputPreview' => preview_bytes( $body ),
'limits' => $this->limits,
@@ -347,6 +380,7 @@ private function load_or_synthesize_result( string $staging_dir, array $process,
'mode' => Generator::MODE_FULL_DOCUMENT,
'inputSource' => 'commoncrawl',
'inputSha1' => sha1( $body ),
+ 'inputSha256' => hash( 'sha256', $body ),
'inputLength' => strlen( $body ),
'checks' => $checks,
'oracle' => $this->oracle->metadata(),
@@ -401,6 +435,9 @@ private function publish_staging( string $staging_dir, array &$result, array $me
$replay['repoCommit'] = $this->git_metadata['commit'] ?? null;
$replay['repoDirty'] = $this->git_metadata['dirty'] ?? null;
$replay['commonCrawl'] = $metadata;
+ $replay['inputSha1'] = $metadata['inputSha1'] ?? $replay['inputSha1'] ?? null;
+ $replay['inputSha256'] = $metadata['inputSha256'] ?? null;
+ $replay['inputLength'] = $metadata['byteLength'] ?? $replay['inputLength'] ?? null;
$replay['oracle'] = $this->oracle->metadata();
if ( 'oracle-identity-drift' === ( $result['failureClass'] ?? null ) ) {
$replay['sourceOracle'] = $result['sourceOracle'] ?? $this->oracle->metadata();
@@ -497,6 +534,7 @@ private function record_callback_error( array $metadata, \Throwable $throwable,
'mode' => Generator::MODE_FULL_DOCUMENT,
'inputSource' => 'commoncrawl',
'inputSha1' => $metadata['inputSha1'] ?? ( null === $body ? null : sha1( $body ) ),
+ 'inputSha256' => $metadata['inputSha256'] ?? ( null === $body ? null : hash( 'sha256', $body ) ),
'inputLength' => $metadata['byteLength'] ?? ( null === $body ? null : strlen( $body ) ),
'oracle' => $this->oracle->metadata(),
'commonCrawl' => $metadata,
@@ -521,8 +559,9 @@ private function summary_from_result( array $result, array $metadata, ?string $a
'differentialCovered' => is_array( $result['comparison'] ?? null ),
'seed' => $result['seed'] ?? null,
'checks' => $result['checks'] ?? $this->checks,
- 'inputSha1' => $result['inputSha1'] ?? $metadata['inputSha1'] ?? null,
- 'inputLength' => $result['inputLength'] ?? $metadata['byteLength'] ?? null,
+ 'inputSha1' => $metadata['inputSha1'] ?? null,
+ 'inputSha256' => $metadata['inputSha256'] ?? null,
+ 'inputLength' => $metadata['byteLength'] ?? null,
'signature' => $result['signature'] ?? null,
'oracleFinding' => $result['oracleFinding'] ?? null,
'oracle' => $result['oracle'] ?? $this->oracle->metadata(),
@@ -548,6 +587,7 @@ private function base_summary(): array {
'profile' => 'commoncrawl',
'mode' => Generator::MODE_FULL_DOCUMENT,
'inputSource' => 'commoncrawl',
+ 'coordinator' => $this->coordinator_provenance,
);
}
@@ -671,6 +711,7 @@ private function initialize_configuration(): void {
'version' => getenv( 'CC_ANALYZER_VERSION' ) ?: null,
'crawl' => getenv( 'CC_ANALYZER_CRAWL' ) ?: null,
'invocation' => getenv( 'CC_ANALYZER_INVOCATION' ) ?: null,
+ 'coordinator' => $this->coordinator_provenance,
),
);
$this->configuration_hash = hash( 'sha256', json_encode( self::canonicalize( $configuration ), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) );
@@ -703,6 +744,7 @@ private function run_metadata(): array {
'runId' => $this->run_id,
'configHash' => $this->configuration_hash,
'outputDir' => $this->output_dir,
+ 'coordinator' => $this->coordinator_provenance,
);
}
@@ -762,6 +804,131 @@ private static function elapsed_ms( int $started_at ): int {
return (int) round( max( 0, hrtime( true ) - $started_at ) / 1000000 );
}
+ private static function coordinator_provenance_from_environment(): ?array {
+ $variables = array(
+ 'id' => 'HTML_API_CC_COORDINATOR_ID',
+ 'batchName' => 'HTML_API_CC_BATCH_NAME',
+ 'cachePath' => 'HTML_API_CC_CACHE_PATH',
+ 'batchManifestPath' => 'HTML_API_CC_BATCH_MANIFEST_PATH',
+ 'batchManifestSha256' => 'HTML_API_CC_BATCH_MANIFEST_SHA256',
+ 'corpusFingerprint' => 'HTML_API_CC_CORPUS_FINGERPRINT',
+ 'documentCount' => 'HTML_API_CC_DOCUMENT_COUNT',
+ 'crawlId' => 'HTML_API_CC_CRAWL_ID',
+ 'pharPath' => 'HTML_API_CC_PHAR_PATH',
+ 'pharSha256' => 'HTML_API_CC_PHAR_SHA256',
+ 'invocationBase64' => 'HTML_API_CC_INVOCATION_BASE64',
+ 'callbackSha256' => 'HTML_API_CC_CALLBACK_SHA256',
+ 'workerSha256' => 'HTML_API_CC_WORKER_SHA256',
+ 'repositoryCommit' => 'HTML_API_CC_REPOSITORY_COMMIT',
+ 'repositoryDirty' => 'HTML_API_CC_REPOSITORY_DIRTY',
+ 'repositoryCodeSha256' => 'HTML_API_CC_REPOSITORY_CODE_SHA256',
+ 'repositoryStateSha256'=> 'HTML_API_CC_REPOSITORY_STATE_SHA256',
+ 'runtimeSha256' => 'HTML_API_CC_RUNTIME_SHA256',
+ 'trustBundleSha256' => 'HTML_API_CC_TRUST_BUNDLE_SHA256',
+ );
+ $values = array();
+ $present = 0;
+ foreach ( $variables as $key => $name ) {
+ $value = getenv( $name );
+ if ( false !== $value && '' !== $value ) {
+ ++$present;
+ $values[ $key ] = (string) $value;
+ }
+ }
+ if ( 0 === $present ) {
+ return null;
+ }
+ if ( count( $variables ) !== $present ) {
+ throw new \InvalidArgumentException( 'Coordinator provenance environment must be supplied as one complete block.' );
+ }
+
+ foreach ( array( 'id', 'batchName' ) as $key ) {
+ if ( 1 !== preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/', $values[ $key ] ) ) {
+ throw new \InvalidArgumentException( "Coordinator provenance {$key} is invalid." );
+ }
+ }
+ foreach ( array( 'batchManifestSha256', 'corpusFingerprint', 'pharSha256', 'callbackSha256', 'workerSha256', 'repositoryCodeSha256', 'repositoryStateSha256', 'runtimeSha256', 'trustBundleSha256' ) as $key ) {
+ if ( 1 !== preg_match( '/^[0-9a-f]{64}$/', $values[ $key ] ) ) {
+ throw new \InvalidArgumentException( "Coordinator provenance {$key} must be a lowercase SHA-256 digest." );
+ }
+ }
+ if ( 1 !== preg_match( '/^[0-9a-f]{40}$/', $values['repositoryCommit'] ) ) {
+ throw new \InvalidArgumentException( 'Coordinator repository commit must be a lowercase Git object ID.' );
+ }
+ if ( ! in_array( $values['repositoryDirty'], array( '0', '1' ), true ) ) {
+ throw new \InvalidArgumentException( 'Coordinator repository dirty state must be 0 or 1.' );
+ }
+ $document_count = filter_var( $values['documentCount'], FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) );
+ if ( false === $document_count ) {
+ throw new \InvalidArgumentException( 'Coordinator document count must be a positive integer.' );
+ }
+ foreach ( array( 'crawlId', 'cachePath', 'batchManifestPath', 'pharPath' ) as $key ) {
+ if ( '' === trim( $values[ $key ] ) ) {
+ throw new \InvalidArgumentException( "Coordinator provenance {$key} must be non-empty." );
+ }
+ }
+ $cache_path = realpath( $values['cachePath'] );
+ $manifest_path = realpath( $values['batchManifestPath'] );
+ $phar_path = realpath( $values['pharPath'] );
+ if ( false === $cache_path || ! is_file( $cache_path ) || false === $manifest_path || ! is_file( $manifest_path ) || false === $phar_path || ! is_file( $phar_path ) ) {
+ throw new \RuntimeException( 'Coordinator provenance paths must resolve to regular files.' );
+ }
+ if ( ! hash_equals( $values['pharSha256'], hash_file( 'sha256', $phar_path ) ?: '' ) ) {
+ throw new \RuntimeException( 'Coordinator PHAR hash changed before Common Crawl callback initialization.' );
+ }
+ $callback_path = dirname( __DIR__ ) . '/commoncrawl-analysis.php';
+ if ( ! hash_equals( $values['callbackSha256'], hash_file( 'sha256', $callback_path ) ?: '' ) ) {
+ throw new \RuntimeException( 'Coordinator callback hash does not match commoncrawl-analysis.php.' );
+ }
+ $worker_path = realpath( self::environment_string( 'HTML_API_CC_WORKER_SCRIPT', dirname( __DIR__ ) . '/worker.php' ) );
+ if ( false === $worker_path || ! hash_equals( $values['workerSha256'], hash_file( 'sha256', $worker_path ) ?: '' ) ) {
+ throw new \RuntimeException( 'Coordinator Worker hash does not match the selected Worker script.' );
+ }
+ if ( 'batch' !== getenv( 'CC_ANALYZER_MODE' ) || $values['batchName'] !== getenv( 'CC_ANALYZER_BATCH' ) ) {
+ throw new \RuntimeException( 'Coordinator provenance requires the matching cc-analyzer batch callback mode.' );
+ }
+ $cc_manifest = realpath( (string) getenv( 'CC_ANALYZER_MANIFEST' ) );
+ if ( false === $cc_manifest || $cache_path !== $cc_manifest ) {
+ throw new \RuntimeException( 'Coordinator cache path does not match CC_ANALYZER_MANIFEST.' );
+ }
+
+ $invocation_json = base64_decode( $values['invocationBase64'], true );
+ if ( false === $invocation_json ) {
+ throw new \InvalidArgumentException( 'Coordinator invocation is not valid base64.' );
+ }
+ $invocation = StrictJsonParser::decode( $invocation_json );
+ if ( ! is_array( $invocation ) || array_keys( $invocation ) !== range( 0, count( $invocation ) - 1 ) || empty( $invocation ) ) {
+ throw new \InvalidArgumentException( 'Coordinator invocation must be a non-empty JSON string list.' );
+ }
+ foreach ( $invocation as $argument ) {
+ if ( ! is_string( $argument ) || '' === $argument || false !== strpos( $argument, "\0" ) ) {
+ throw new \InvalidArgumentException( 'Coordinator invocation contains an invalid argument.' );
+ }
+ }
+
+ return array(
+ 'id' => $values['id'],
+ 'batchName' => $values['batchName'],
+ 'cachePath' => $cache_path,
+ 'batchManifestPath' => $manifest_path,
+ 'batchManifestSha256' => $values['batchManifestSha256'],
+ 'corpusFingerprint' => $values['corpusFingerprint'],
+ 'documentCount' => (int) $document_count,
+ 'crawlId' => $values['crawlId'],
+ 'pharPath' => $phar_path,
+ 'pharSha256' => $values['pharSha256'],
+ 'invocation' => $invocation,
+ 'callbackSha256' => $values['callbackSha256'],
+ 'workerSha256' => $values['workerSha256'],
+ 'repositoryCommit' => $values['repositoryCommit'],
+ 'repositoryDirty' => '1' === $values['repositoryDirty'],
+ 'repositoryCodeSha256' => $values['repositoryCodeSha256'],
+ 'repositoryStateSha256'=> $values['repositoryStateSha256'],
+ 'runtimeSha256' => $values['runtimeSha256'],
+ 'trustBundleSha256' => $values['trustBundleSha256'],
+ );
+ }
+
private static function environment_string( string $name, string $default ): string {
$value = getenv( $name );
return false === $value || '' === $value ? $default : (string) $value;
diff --git a/tools/html-api-fuzz/lib/Support.php b/tools/html-api-fuzz/lib/Support.php
index 19a13072c5b4c..9c6dfbd9b30a3 100644
--- a/tools/html-api-fuzz/lib/Support.php
+++ b/tools/html-api-fuzz/lib/Support.php
@@ -433,10 +433,45 @@ function cleanup_isolated_process_group( int $process_group ): array {
);
}
-function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?string $log_path = null, int $max_capture_bytes = 1048576, bool $isolate_process_group = false ): array {
+function run_php_process(
+ array $script_args,
+ string $cwd,
+ int $timeout_ms,
+ ?string $log_path = null,
+ int $max_capture_bytes = 1048576,
+ bool $isolate_process_group = false,
+ ?array $environment = null,
+ bool $replace_environment = false,
+ ?string $stdout_log_path = null,
+ ?string $stderr_log_path = null
+): array {
if ( $max_capture_bytes < 1 ) {
throw new \InvalidArgumentException( 'Process capture limit must be positive.' );
}
+ if ( $timeout_ms < 1 ) {
+ throw new \InvalidArgumentException( 'Process timeout must be positive.' );
+ }
+ $log_paths = array_values( array_filter( array( $log_path, $stdout_log_path, $stderr_log_path ), static fn ( $path ): bool => null !== $path ) );
+ if ( count( $log_paths ) !== count( array_unique( $log_paths ) ) ) {
+ throw new \InvalidArgumentException( 'Process combined, stdout, and stderr logs must use distinct paths.' );
+ }
+ $validated_environment = array();
+ foreach ( $environment ?? array() as $name => $value ) {
+ if ( ! is_string( $name ) || 1 !== preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $name ) ) {
+ throw new \InvalidArgumentException( 'Process environment contains an invalid variable name.' );
+ }
+ if ( ! is_string( $value ) || false !== strpos( $value, "\0" ) ) {
+ throw new \InvalidArgumentException( "Process environment value for {$name} must be a NUL-free string." );
+ }
+ $validated_environment[ $name ] = $value;
+ }
+ $process_environment = null;
+ if ( $replace_environment ) {
+ $process_environment = $validated_environment;
+ } elseif ( null !== $environment ) {
+ $inherited = getenv();
+ $process_environment = array_merge( is_array( $inherited ) ? $inherited : array(), $validated_environment );
+ }
$target_command = array_merge( array( PHP_BINARY ), $script_args );
$command = $target_command;
if ( $isolate_process_group ) {
@@ -455,6 +490,8 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
);
$log = null;
+ $stdout_log = null;
+ $stderr_log = null;
if ( null !== $log_path ) {
ensure_dir( dirname( $log_path ) );
$log = fopen( $log_path, 'wb' );
@@ -462,12 +499,39 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
throw new \RuntimeException( "Could not open process log: {$log_path}" );
}
}
+ foreach ( array( 'stdout' => $stdout_log_path, 'stderr' => $stderr_log_path ) as $stream_name => $stream_path ) {
+ if ( null === $stream_path ) {
+ continue;
+ }
+ ensure_dir( dirname( $stream_path ) );
+ $stream_log = fopen( $stream_path, 'wb' );
+ if ( false === $stream_log ) {
+ if ( is_resource( $log ) ) {
+ fclose( $log );
+ }
+ if ( is_resource( $stdout_log ) ) {
+ fclose( $stdout_log );
+ }
+ throw new \RuntimeException( "Could not open process {$stream_name} log: {$stream_path}" );
+ }
+ if ( 'stdout' === $stream_name ) {
+ $stdout_log = $stream_log;
+ } else {
+ $stderr_log = $stream_log;
+ }
+ }
- $process = proc_open( $command, $spec, $pipes, $cwd );
+ $process = proc_open( $command, $spec, $pipes, $cwd, $process_environment, array( 'bypass_shell' => true ) );
if ( ! is_resource( $process ) ) {
if ( is_resource( $log ) ) {
fclose( $log );
}
+ if ( is_resource( $stdout_log ) ) {
+ fclose( $stdout_log );
+ }
+ if ( is_resource( $stderr_log ) ) {
+ fclose( $stderr_log );
+ }
throw new \RuntimeException( 'Could not start PHP subprocess.' );
}
@@ -486,7 +550,7 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
$process_group = false;
$log_write_failed = false;
- $drain = static function ( $pipe, string &$capture, bool &$truncated ) use ( $max_capture_bytes, $log, &$log_write_failed ): void {
+ $drain = static function ( $pipe, string &$capture, bool &$truncated, $stream_log ) use ( $max_capture_bytes, $log, &$log_write_failed ): void {
$chunk = stream_get_contents( $pipe );
if ( false === $chunk || '' === $chunk ) {
return;
@@ -494,6 +558,9 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
if ( is_resource( $log ) && strlen( $chunk ) !== fwrite( $log, $chunk ) ) {
$log_write_failed = true;
}
+ if ( is_resource( $stream_log ) && strlen( $chunk ) !== fwrite( $stream_log, $chunk ) ) {
+ $log_write_failed = true;
+ }
$capture .= $chunk;
if ( strlen( $capture ) > $max_capture_bytes ) {
$capture = substr( $capture, -$max_capture_bytes );
@@ -502,8 +569,8 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
};
while ( true ) {
- $drain( $pipes[1], $stdout, $stdout_truncated );
- $drain( $pipes[2], $stderr, $stderr_truncated );
+ $drain( $pipes[1], $stdout, $stdout_truncated, $stdout_log );
+ $drain( $pipes[2], $stderr, $stderr_truncated, $stderr_log );
$status = proc_get_status( $process );
if ( $isolate_process_group && $process_pid > 0 && function_exists( 'posix_getpgid' ) ) {
@@ -535,13 +602,18 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
usleep( 10000 );
}
- $drain( $pipes[1], $stdout, $stdout_truncated );
- $drain( $pipes[2], $stderr, $stderr_truncated );
+ $drain( $pipes[1], $stdout, $stdout_truncated, $stdout_log );
+ $drain( $pipes[2], $stderr, $stderr_truncated, $stderr_log );
fclose( $pipes[1] );
fclose( $pipes[2] );
- if ( is_resource( $log ) ) {
- fflush( $log );
- fclose( $log );
+ foreach ( array( $log, $stdout_log, $stderr_log ) as $evidence_log ) {
+ if ( is_resource( $evidence_log ) ) {
+ $flushed = fflush( $evidence_log );
+ $closed = fclose( $evidence_log );
+ if ( ! $flushed || ! $closed ) {
+ $log_write_failed = true;
+ }
+ }
}
$exit_code = proc_close( $process );
@@ -571,6 +643,8 @@ function run_php_process( array $script_args, string $cwd, int $timeout_ms, ?str
'stderrTruncated' => $stderr_truncated,
'output' => $output,
'logPath' => $log_path,
+ 'stdoutLogPath' => $stdout_log_path,
+ 'stderrLogPath' => $stderr_log_path,
'processGroupIsolated' => $process_group,
'processGroupCleanupFailed' => $process_group_cleanup_failed,
);
diff --git a/tools/html-api-fuzz/lib/autoload.php b/tools/html-api-fuzz/lib/autoload.php
index 5d609a961b99a..f86909c0c70ae 100644
--- a/tools/html-api-fuzz/lib/autoload.php
+++ b/tools/html-api-fuzz/lib/autoload.php
@@ -14,3 +14,4 @@
require_once __DIR__ . '/ResultStore.php';
require_once __DIR__ . '/Worker.php';
require_once __DIR__ . '/CommonCrawlRunner.php';
+require_once __DIR__ . '/CommonCrawlBatchCoordinator.php';
diff --git a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
index d0ac20eede611..6d841e0295ffd 100755
--- a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
+++ b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
@@ -91,6 +91,13 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
html_api_fuzz_commoncrawl_smoke_assert( is_file( $passing['artifactDir'] . '/.complete' ), 'Expected an atomically published completion marker.' );
html_api_fuzz_commoncrawl_smoke_assert( is_string( $passing['runId'] ?? null ), 'Expected every summary to identify its run.' );
html_api_fuzz_commoncrawl_smoke_assert( is_string( $passing['configHash'] ?? null ), 'Expected every summary to identify its configuration.' );
+ $passing_body = 'T Hello
';
+ html_api_fuzz_commoncrawl_smoke_assert( hash( 'sha256', $passing_body ) === ( $passing['inputSha256'] ?? null ), 'Expected parent-computed SHA-256 in the summary.' );
+ html_api_fuzz_commoncrawl_smoke_assert( hash( 'sha256', $passing_body ) === ( $passing['commonCrawl']['inputSha256'] ?? null ), 'Expected parent-computed SHA-256 in Common Crawl metadata.' );
+ $passing_replay = json_decode( (string) file_get_contents( $passing['artifactDir'] . '/replay.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( hash( 'sha256', $passing_body ) === ( $passing_replay['inputSha256'] ?? null ), 'Expected retained replay SHA-256.' );
+ $legacy_configuration = json_decode( (string) file_get_contents( $work_dir . '/configuration.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( null === ( $legacy_configuration['ccAnalyzer']['coordinator'] ?? null ), 'Expected direct legacy mode without coordinator provenance.' );
html_api_fuzz_commoncrawl_smoke_assert( 'skipped-invalid-utf8' === ( $skipped['status'] ?? null ), 'Expected invalid UTF-8 to be skipped by default policy.' );
html_api_fuzz_commoncrawl_smoke_assert( false === ( $skipped['differentialCovered'] ?? null ), 'Expected no differential coverage for a skipped fixture.' );
@@ -127,6 +134,53 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
html_api_fuzz_commoncrawl_smoke_assert( $config_mismatch, 'Expected configuration mismatch detection.' );
putenv( 'HTML_API_CC_MAX_TOKENS' );
+ // Coordinator provenance is all-or-none even for direct adapter construction.
+ $partial_provenance_dir = $work_dir . '-partial-provenance';
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $partial_provenance_dir );
+ putenv( 'HTML_API_CC_COORDINATOR_ID=partial-provenance' );
+ $partial_provenance_failed = false;
+ try {
+ \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ } catch ( \InvalidArgumentException $error ) {
+ $partial_provenance_failed = false !== strpos( $error->getMessage(), 'complete block' );
+ }
+ putenv( 'HTML_API_CC_COORDINATOR_ID' );
+ html_api_fuzz_commoncrawl_smoke_assert( $partial_provenance_failed, 'Expected partial coordinator provenance rejection.' );
+
+ // A result that lies about its input is retained as Worker infrastructure
+ // evidence before any oracle-result identity can be trusted.
+ $input_drift_dir = $work_dir . '-input-drift';
+ $input_drift_worker = $work_dir . '-input-drift-worker.php';
+ $input_drift_source = <<<'PHP'
+ 'php-dom' ) );
+$oracle = \HtmlApiFuzz\OracleRenderer::with_explicit_close( $renderer, static fn ( $r ) => $r->metadata() );
+\HtmlApiFuzz\write_json_file_atomic( $output . '/result.json', array(
+ 'ok' => true, 'status' => 'passed', 'inputSha1' => str_repeat( '0', 40 ), 'inputLength' => 1,
+ 'oracle' => $oracle, 'comparison' => array( 'ok' => true ), 'wordpress' => array(), 'dom' => array(),
+) );
+PHP;
+ $input_drift_source = str_replace( '__AUTOLOAD__', var_export( dirname( __DIR__ ) . '/lib/autoload.php', true ), $input_drift_source );
+ \HtmlApiFuzz\write_file_atomic( $input_drift_worker, $input_drift_source );
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $input_drift_dir );
+ putenv( 'HTML_API_CC_WORKER_SCRIPT=' . $input_drift_worker );
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=5000' );
+ $input_drift_runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ $input_drift = $input_drift_runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:input-drift', 'https://example.com/input-drift', 200, 'text/html', 'UTF-8', 'parent bytes
', 'fixture:input-drift'
+ )
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-input-identity-drift' === ( $input_drift['failureClass'] ?? null ) && true === ( $input_drift['artifactsRetained'] ?? false ), 'Expected retained Worker input identity drift.' );
+ $input_drift_result = json_decode( (string) file_get_contents( $input_drift['artifactDir'] . '/result.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $input_drift_result['workerInfrastructure'] ?? false ) && is_array( $input_drift_result['expectedInputIdentity'] ?? null ), 'Expected explicit Worker input drift evidence.' );
+ @unlink( $input_drift_worker );
+
// A hung parser child must become a replayable finding, not hang the callback.
$timeout_dir = $work_dir . '-timeout';
$original_state_dir = $work_dir . '-descendant-original';
@@ -499,6 +553,8 @@ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $lab
require_once dirname( __DIR__ ) . '/lib/autoload.php';
\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $partial_provenance_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $input_drift_dir );
\HtmlApiFuzz\remove_dir_recursive( $timeout_dir );
\HtmlApiFuzz\remove_dir_recursive( $evidence_dir );
\HtmlApiFuzz\remove_dir_recursive( $hanging_oracle_dir );
diff --git a/tools/html-api-fuzz/tests/commoncrawl-batch-coordinator-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-batch-coordinator-smoke.php
new file mode 100755
index 0000000000000..08c5beb7fca90
--- /dev/null
+++ b/tools/html-api-fuzz/tests/commoncrawl-batch-coordinator-smoke.php
@@ -0,0 +1,476 @@
+#!/usr/bin/env php
+exec( 'PRAGMA journal_mode = DELETE' );
+ $db->exec( 'CREATE TABLE document_batches (
+ name TEXT PRIMARY KEY, source_mode TEXT NOT NULL, crawl_selection TEXT NOT NULL,
+ crawl_id TEXT NOT NULL, url_pattern TEXT NOT NULL, requested_limit INTEGER NOT NULL,
+ state_path TEXT NOT NULL, cache_path TEXT NOT NULL, status TEXT NOT NULL,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT
+ )' );
+ $db->exec( 'CREATE TABLE document_batch_documents (
+ batch_name TEXT NOT NULL, sequence INTEGER NOT NULL, input_state_key TEXT NOT NULL,
+ input_url TEXT NOT NULL, record_id TEXT NOT NULL, target_uri TEXT NOT NULL,
+ response_code INTEGER NOT NULL, byte_length INTEGER NOT NULL, checksum TEXT NOT NULL,
+ range_start INTEGER, range_length INTEGER, cached_at TEXT NOT NULL,
+ PRIMARY KEY (batch_name, input_state_key, record_id), UNIQUE (batch_name, sequence)
+ )' );
+ $db->exec( 'CREATE TABLE cached_documents (
+ input_state_key TEXT NOT NULL, input_url TEXT NOT NULL, record_id TEXT NOT NULL,
+ target_uri TEXT NOT NULL, response_code INTEGER NOT NULL, content_type TEXT NOT NULL,
+ transport_charset TEXT, response_headers_json TEXT NOT NULL, body BLOB NOT NULL,
+ byte_length INTEGER NOT NULL, range_start INTEGER, range_length INTEGER,
+ fetched_at TEXT NOT NULL, last_used_at TEXT NOT NULL,
+ PRIMARY KEY (input_state_key, record_id)
+ )' );
+ $db->exec( 'CREATE TABLE document_batch_runs (
+ run_id INTEGER PRIMARY KEY AUTOINCREMENT, batch_name TEXT NOT NULL,
+ analysis_script TEXT NOT NULL, analysis_script_hash TEXT,
+ documents_analyzed INTEGER NOT NULL, started_at TEXT NOT NULL, completed_at TEXT NOT NULL
+ )' );
+ $now = '2026-07-15T00:00:00+00:00';
+ $documents = array(
+ array(
+ 'input_state_key' => 'crawl-data/CC-MAIN-2026-30/segments/a.warc.gz#0:100',
+ 'input_url' => 'https://data.commoncrawl.org/crawl-data/a.warc.gz',
+ 'record_id' => 'urn:uuid:batch-one',
+ 'target_uri' => 'https://example.com/one',
+ 'response_code' => 200,
+ 'content_type' => 'text/html',
+ 'transport_charset' => 'UTF-8',
+ 'body' => 'one alpha',
+ 'range_start' => 0,
+ 'range_length' => 100,
+ ),
+ array(
+ 'input_state_key' => 'crawl-data/CC-MAIN-2026-30/segments/b.warc.gz#100:200',
+ 'input_url' => 'https://data.commoncrawl.org/crawl-data/b.warc.gz',
+ 'record_id' => 'urn:uuid:batch-two',
+ 'target_uri' => 'https://example.com/two',
+ 'response_code' => 200,
+ 'content_type' => 'text/html',
+ 'transport_charset' => null,
+ 'body' => '
',
+ 'range_start' => 100,
+ 'range_length' => 200,
+ ),
+ );
+ $batch_insert = $db->prepare( 'INSERT INTO document_batches VALUES (:name, :source, :selection, :crawl, :url, :limit, :state, :cache, :status, :created, :updated, :completed)' );
+ foreach ( array(
+ ':name' => $batch, ':source' => 'url-index', ':selection' => 'CC-MAIN-2026-30',
+ ':crawl' => 'CC-MAIN-2026-30', ':url' => '', ':limit' => count( $documents ),
+ ':state' => $workspace . '/state.sqlite', ':cache' => $path, ':status' => 'ready',
+ ':created' => $now, ':updated' => $now, ':completed' => $now,
+ ) as $name => $value ) {
+ $batch_insert->bindValue( $name, $value, is_int( $value ) ? SQLITE3_INTEGER : SQLITE3_TEXT );
+ }
+ $batch_insert->execute();
+ foreach ( $documents as $index => $document ) {
+ $manifest = $db->prepare( 'INSERT INTO document_batch_documents VALUES (:batch, :sequence, :state, :url, :record, :target, :response, :bytes, :checksum, :start, :length, :cached)' );
+ $cache = $db->prepare( 'INSERT INTO cached_documents VALUES (:state, :url, :record, :target, :response, :type, :charset, :headers, :body, :bytes, :start, :length, :fetched, :used)' );
+ $values = array(
+ ':batch' => $batch, ':sequence' => $index + 1, ':state' => $document['input_state_key'],
+ ':url' => $document['input_url'], ':record' => $document['record_id'], ':target' => $document['target_uri'],
+ ':response' => $document['response_code'], ':bytes' => strlen( $document['body'] ),
+ ':checksum' => hash( 'sha256', $document['body'] ), ':start' => $document['range_start'],
+ ':length' => $document['range_length'], ':cached' => $now, ':type' => $document['content_type'],
+ ':charset' => $document['transport_charset'], ':headers' => '[]', ':body' => $document['body'],
+ ':fetched' => $now, ':used' => $now,
+ );
+ foreach ( $values as $name => $value ) {
+ $type = null === $value ? SQLITE3_NULL : ( is_int( $value ) ? SQLITE3_INTEGER : SQLITE3_TEXT );
+ if ( in_array( $name, array( ':batch', ':sequence', ':state', ':url', ':record', ':target', ':response', ':bytes', ':checksum', ':start', ':length', ':cached' ), true ) ) {
+ $manifest->bindValue( $name, $value, $type );
+ }
+ if ( in_array( $name, array( ':state', ':url', ':record', ':target', ':response', ':type', ':charset', ':headers', ':body', ':bytes', ':start', ':length', ':fetched', ':used' ), true ) ) {
+ $cache->bindValue( $name, $value, ':body' === $name ? SQLITE3_BLOB : $type );
+ }
+ }
+ $manifest->execute();
+ $cache->execute();
+ }
+ $db->close();
+ return array( 'path' => $path, 'documents' => $documents );
+}
+
+function html_api_fuzz_batch_chrome_options(): array {
+ $renderer = \HtmlApiFuzz\OracleRenderer::from_options( array( 'dom-oracle' => 'chrome-cdp', 'oracle-timeout-ms' => '10000' ) );
+ return \HtmlApiFuzz\OracleRenderer::with_explicit_close(
+ $renderer,
+ static function ( \HtmlApiFuzz\OracleRenderer $renderer ): array {
+ $metadata = $renderer->metadata();
+ html_api_fuzz_batch_assert( true === ( $metadata['available'] ?? false ), 'Pinned Chrome must be installed for coordinator smoke.' );
+ return $renderer->replay_options();
+ }
+ );
+}
+
+function html_api_fuzz_batch_failure_scenario( array $base_args, string $root, string $batch, string $name, array $control, array $overrides = array(), int $timeout_ms = 900000 ): array {
+ $workspace = $root . '/' . $name . '-workspace';
+ html_api_fuzz_batch_create_workspace( $workspace, $batch );
+ file_put_contents( $workspace . '/control.json', json_encode( $control, JSON_THROW_ON_ERROR ) . "\n" );
+ $phar = $root . '/' . $name . '-cc-analyzer.phar';
+ html_api_fuzz_batch_assert( copy( __DIR__ . '/fixtures/fake-cc-analyzer.php', $phar ), "Expected {$name} fake cc-analyzer copy." );
+ $output = $root . '/' . $name . '-output';
+ $arguments = html_api_fuzz_batch_replace_arg( $base_args, '--cc-analyzer', $phar );
+ $arguments = html_api_fuzz_batch_replace_arg( $arguments, '--workspace', $workspace );
+ $arguments = html_api_fuzz_batch_replace_arg( $arguments, '--output-dir', $output );
+ foreach ( $overrides as $option => $value ) {
+ $arguments = html_api_fuzz_batch_replace_arg( $arguments, $option, $value );
+ }
+ $process = \HtmlApiFuzz\run_php_process( $arguments, \HtmlApiFuzz\repo_root(), $timeout_ms );
+ html_api_fuzz_batch_assert( 0 !== ( $process['code'] ?? 0 ) || true === ( $process['timedOut'] ?? false ), "Expected {$name} coordinator failure." );
+ html_api_fuzz_batch_assert( ! is_file( $output . '/.complete' ) && ! is_file( $output . '/shared-corpus.json' ), "Expected no shared success for {$name}." );
+ foreach ( array( 'lexbor-source', 'html5ever-source', 'chrome-cdp' ) as $kind ) {
+ html_api_fuzz_batch_assert( ! is_file( $output . '/' . $kind . '/.complete' ), "Expected no surviving {$kind} completion marker for {$name}." );
+ }
+ return array( 'process' => $process, 'workspace' => $workspace, 'phar' => $phar, 'output' => $output, 'arguments' => $arguments );
+}
+
+$work_dir = sys_get_temp_dir() . '/html-api-commoncrawl-coordinator-' . getmypid();
+\HtmlApiFuzz\ensure_dir( $work_dir );
+$workspace = $work_dir . '/workspace';
+$batch_name = 'coordinator-smoke';
+$fixture = html_api_fuzz_batch_create_workspace( $workspace, $batch_name );
+$fake_phar = $work_dir . '/cc-analyzer.phar';
+html_api_fuzz_batch_assert( copy( __DIR__ . '/fixtures/fake-cc-analyzer.php', $fake_phar ), 'Expected fake cc-analyzer copy.' );
+$chrome = html_api_fuzz_batch_chrome_options();
+$output = $work_dir . '/shared-output';
+$args = array(
+ dirname( __DIR__ ) . '/commoncrawl-batch.php',
+ '--cc-analyzer', $fake_phar,
+ '--workspace', $workspace,
+ '--batch', $batch_name,
+ '--output-dir', $output,
+ '--batch-timeout-ms', '180000',
+ '--process-timeout-ms', '90000',
+ '--oracle-timeout-ms', '10000',
+ '--chrome-startup-timeout-ms', '35000',
+ '--lexbor-oracle-bin', dirname( __DIR__ ) . '/oracles/lexbor/build/lexbor-tree-oracle',
+ '--html5ever-oracle-bin', dirname( __DIR__ ) . '/oracles/html5ever/build/html5ever-tree-oracle',
+ '--chrome-oracle-script', $chrome['chromeOracleScript'],
+ '--chrome-executable', $chrome['chromeExecutable'],
+ '--node-bin', $chrome['nodeBin'],
+ '--retain-all',
+);
+$hostile = array(
+ 'NODE_OPTIONS' => '--definitely-invalid-coordinator-option',
+ 'PHPRC' => '/definitely/missing/php.ini',
+ 'PHP_INI_SCAN_DIR' => '/definitely/missing/conf.d',
+ 'LD_BIND_NOW' => '1',
+ 'DYLD_LIBRARY_PATH' => '/definitely/missing/dylibs',
+ 'HTML_API_CC_UNEXPECTED' => 'must-not-leak',
+ 'HTML_API_FUZZ_UNEXPECTED' => 'must-not-leak',
+ 'HTML_API_CC_CHECKS' => 'bogus-inherited-value',
+);
+$process = \HtmlApiFuzz\run_php_process( $args, \HtmlApiFuzz\repo_root(), 1800000, $work_dir . '/coordinator.log', 1048576, true, $hostile );
+html_api_fuzz_batch_assert(
+ false === ( $process['timedOut'] ?? true ) && 0 === ( $process['code'] ?? 1 ),
+ 'Expected successful shared-corpus coordinator: ' . json_encode( array(
+ 'code' => $process['code'] ?? null,
+ 'timedOut' => $process['timedOut'] ?? null,
+ 'cleanupFailed' => $process['processGroupCleanupFailed'] ?? null,
+ 'stderr' => $process['stderr'] ?? null,
+ ), JSON_UNESCAPED_SLASHES )
+);
+$shared = json_decode( (string) file_get_contents( $output . '/shared-corpus.json' ), true );
+html_api_fuzz_batch_assert( is_array( $shared ) && 2 === ( $shared['documentCount'] ?? null ), 'Expected two-document shared-corpus manifest.' );
+html_api_fuzz_batch_assert( array( 'lexbor-source', 'html5ever-source', 'chrome-cdp' ) === array_keys( $shared['runs'] ?? array() ), 'Expected all three required oracle runs.' );
+$vectors = array();
+foreach ( $shared['runs'] as $kind => $run ) {
+ $run_dir = $output . '/' . $kind;
+ html_api_fuzz_batch_assert( is_file( $run_dir . '/batch-run.stdout' ) && is_file( $run_dir . '/batch-run.stderr' ), "Expected separate {$kind} stdout/stderr evidence." );
+ html_api_fuzz_batch_assert( is_file( $run_dir . '/run-seal.json' ) && is_file( $run_dir . '/.complete' ), "Expected sealed {$kind} run." );
+ $observed = json_decode( (string) file_get_contents( $run_dir . '/observed-environment.json' ), true );
+ $replacement = json_decode( (string) file_get_contents( $run_dir . '/replacement-environment.json' ), true );
+ html_api_fuzz_batch_assert( ( $run['environment'] ?? null ) === $observed && $observed === ( $replacement['environment'] ?? null ), "Expected exact durable replacement environment evidence for {$kind}." );
+ foreach ( array_keys( $hostile ) as $name ) {
+ if ( 'HTML_API_CC_CHECKS' === $name ) {
+ html_api_fuzz_batch_assert( 'baseline' === ( $observed[ $name ] ?? null ), "Expected explicit coordinator {$name} to replace the hostile parent value." );
+ continue;
+ }
+ html_api_fuzz_batch_assert( ! array_key_exists( $name, $observed ), "Expected hostile {$name} to be absent from {$kind} batch tree." );
+ }
+ $vectors[] = $run['summary']['vector'];
+}
+html_api_fuzz_batch_assert( $vectors[0] === $vectors[1] && $vectors[1] === $vectors[2], 'Expected exact identical ordered vectors.' );
+html_api_fuzz_batch_assert( is_file( $output . '/coordinator-seal.json' ) && is_file( $output . '/.complete' ), 'Expected completed sealed coordinator root.' );
+$coordinator_state = json_decode( (string) file_get_contents( $output . '/coordinator-state.json' ), true );
+html_api_fuzz_batch_assert( 'published' === ( $coordinator_state['status'] ?? null ) && realpath( $output . '/.complete' ) === realpath( (string) ( $coordinator_state['completionMarker'] ?? '' ) ), 'Expected published state to defer authoritative completion to the root marker.' );
+$root_seal = json_decode( (string) file_get_contents( $output . '/coordinator-seal.json' ), true );
+$root_seal_paths = array_column( is_array( $root_seal['files'] ?? null ) ? $root_seal['files'] : array(), 'path' );
+foreach ( array( 'preflight/batch-info.stdout', 'lexbor-source/run-seal.json', 'html5ever-source/run-seal.json', 'chrome-cdp/run-seal.json', 'shared-corpus.payload.json' ) as $sealed_path ) {
+ html_api_fuzz_batch_assert( in_array( $sealed_path, $root_seal_paths, true ), "Expected coordinator root seal to cover {$sealed_path}." );
+}
+
+foreach ( array(
+ 'unknown option' => array( '--unexpected-coordinator-option' ),
+ 'missing value' => array( '--max-depth' ),
+ 'invalid boolean' => array( '--retain-all=maybe' ),
+) as $label => $invalid_arguments ) {
+ $invalid_process = \HtmlApiFuzz\run_php_process( array_merge( $args, $invalid_arguments ), \HtmlApiFuzz\repo_root(), 30000 );
+ html_api_fuzz_batch_assert( 0 !== ( $invalid_process['code'] ?? 0 ), "Expected {$label} rejection." );
+}
+
+foreach ( array( 'malformed-info', 'malformed-verify', 'nonzero-info', 'truncate-info', 'timeout-info' ) as $mode ) {
+ file_put_contents( $workspace . '/control.json', json_encode( array( 'mode' => $mode ), JSON_THROW_ON_ERROR ) . "\n" );
+ $failure_output = $work_dir . '/' . $mode;
+ $failure_args = html_api_fuzz_batch_replace_arg( $args, '--output-dir', $failure_output );
+ if ( 'timeout-info' === $mode ) {
+ $failure_args = html_api_fuzz_batch_replace_arg( $failure_args, '--batch-timeout-ms', '100' );
+ }
+ $failure_process = \HtmlApiFuzz\run_php_process( $failure_args, \HtmlApiFuzz\repo_root(), 30000 );
+ html_api_fuzz_batch_assert( 0 !== ( $failure_process['code'] ?? 0 ), "Expected {$mode} rejection." );
+ html_api_fuzz_batch_assert( ! is_file( $failure_output . '/.complete' ) && ! is_file( $failure_output . '/shared-corpus.json' ), "Expected no shared success for {$mode}." );
+}
+file_put_contents( $workspace . '/control.json', "{}\n" );
+
+$cache_drift = html_api_fuzz_batch_failure_scenario( $args, $work_dir, $batch_name, 'live-cache-drift', array( 'mode' => 'mutate-cache', 'triggerRun' => 1 ) );
+$cache_drift_error = (string) ( $cache_drift['process']['stderr'] ?? '' );
+html_api_fuzz_batch_assert( false !== strpos( $cache_drift_error, 'Batch cache body identity mismatch' ) || false !== strpos( $cache_drift_error, 'Cached batch corpus changed' ), 'Expected live cache mutation to fail a post-run cache read.' );
+
+$raw_lexbor_root = $work_dir . '/raw-lexbor';
+$raw_lexbor_build = $raw_lexbor_root . '/build';
+\HtmlApiFuzz\ensure_dir( $raw_lexbor_build );
+$raw_lexbor_binary = $raw_lexbor_build . '/lexbor-tree-oracle';
+$raw_lexbor_manifest = $raw_lexbor_build . '/build-manifest.json';
+html_api_fuzz_batch_assert( copy( dirname( __DIR__ ) . '/oracles/lexbor/build/lexbor-tree-oracle', $raw_lexbor_binary ) && chmod( $raw_lexbor_binary, 0500 ), 'Expected private Lexbor binary copy.' );
+html_api_fuzz_batch_assert( copy( dirname( __DIR__ ) . '/oracles/lexbor/build/build-manifest.json', $raw_lexbor_manifest ), 'Expected private Lexbor manifest copy.' );
+$raw_oracle_drift = html_api_fuzz_batch_failure_scenario(
+ $args,
+ $work_dir,
+ $batch_name,
+ 'raw-oracle-drift',
+ array( 'mode' => 'mutate-trust-file', 'triggerRun' => 1, 'mutationPath' => $raw_lexbor_manifest ),
+ array( '--lexbor-oracle-bin' => $raw_lexbor_binary )
+);
+html_api_fuzz_batch_assert( false !== strpos( (string) ( $raw_oracle_drift['process']['stderr'] ?? '' ), 'Global trust bundle changed after lexbor-source.' ), 'Expected raw build-manifest byte drift to fail despite normalized Lexbor identity.' );
+
+$mode_oracle_drift = html_api_fuzz_batch_failure_scenario(
+ $args,
+ $work_dir,
+ $batch_name,
+ 'raw-oracle-mode-drift',
+ array( 'mode' => 'chmod-trust-file', 'triggerRun' => 1, 'mutationPath' => $raw_lexbor_manifest ),
+ array( '--lexbor-oracle-bin' => $raw_lexbor_binary )
+);
+html_api_fuzz_batch_assert( false !== strpos( (string) ( $mode_oracle_drift['process']['stderr'] ?? '' ), 'Global trust bundle changed after lexbor-source.' ), 'Expected raw trust-file permission drift to fail with unchanged bytes.' );
+
+$final_drift = html_api_fuzz_batch_failure_scenario( $args, $work_dir, $batch_name, 'final-run-phar-drift', array( 'mode' => 'self-change', 'triggerRun' => 3 ) );
+html_api_fuzz_batch_assert( false !== strpos( (string) ( $final_drift['process']['stderr'] ?? '' ), 'Global trust bundle changed after chrome-cdp.' ), 'Expected final-run PHAR mutation to fail the Chrome post-run trust comparison.' );
+
+$prior_evidence_tamper = html_api_fuzz_batch_failure_scenario( $args, $work_dir, $batch_name, 'prior-evidence-tamper', array( 'mode' => 'tamper-previous-evidence', 'triggerRun' => 2 ) );
+html_api_fuzz_batch_assert( false !== strpos( (string) ( $prior_evidence_tamper['process']['stderr'] ?? '' ), 'lexbor-source summary is partial or contains extra documents.' ), 'Expected final disk revalidation to reject evidence changed by a later analyzer run.' );
+
+$publication_failure = html_api_fuzz_batch_failure_scenario( $args, $work_dir, $batch_name, 'completion-publication-failure', array( 'mode' => 'block-root-complete', 'triggerRun' => 3 ) );
+$failed_state = json_decode( (string) file_get_contents( $publication_failure['output'] . '/coordinator-state.json' ), true );
+html_api_fuzz_batch_assert( 'failed' === ( $failed_state['status'] ?? null ), 'Expected a failed state when the authoritative root completion marker cannot be published.' );
+
+$manifest = json_decode( (string) file_get_contents( $output . '/batch-manifest.json' ), true );
+$lexbor = $shared['runs']['lexbor-source'];
+$identity = $shared['trust']['oracles']['lexbor-source']['identitySha256'];
+$validate_batch_result = Closure::bind(
+ static fn ( $value, $expected_batch, $callback_path, $callback_sha256 ) => \HtmlApiFuzz\CommonCrawlBatchCoordinator::validate_batch_run_result( $value, $expected_batch, $callback_path, $callback_sha256 ),
+ null,
+ \HtmlApiFuzz\CommonCrawlBatchCoordinator::class
+);
+$valid_batch_result = json_decode( (string) file_get_contents( $output . '/lexbor-source/batch-run.stdout' ), true );
+$callback_path = realpath( dirname( __DIR__ ) . '/commoncrawl-analysis.php' );
+$callback_sha256 = $shared['trust']['callback']['sha256'];
+foreach ( array( 'outer-schema', 'batch-metadata', 'callback-hash', 'document-count', 'run-schema' ) as $variant ) {
+ $invalid = $valid_batch_result;
+ if ( 'outer-schema' === $variant ) {
+ $invalid['unexpected'] = true;
+ } elseif ( 'batch-metadata' === $variant ) {
+ $invalid['batch']['crawlId'] = 'CC-MAIN-WRONG';
+ } elseif ( 'callback-hash' === $variant ) {
+ $invalid['run']['analysisScriptHash'] = str_repeat( '0', 64 );
+ } elseif ( 'document-count' === $variant ) {
+ ++$invalid['documentsAnalyzed'];
+ ++$invalid['run']['documentsAnalyzed'];
+ } elseif ( 'run-schema' === $variant ) {
+ $invalid['run']['unexpected'] = true;
+ }
+ html_api_fuzz_batch_expect_failure(
+ static fn () => $validate_batch_result( $invalid, $manifest['batch'], $callback_path, $callback_sha256 ),
+ "Expected {$variant} batch-result rejection."
+ );
+}
+
+$assert_trust = Closure::bind(
+ static fn ( $expected, $actual, $when ) => \HtmlApiFuzz\CommonCrawlBatchCoordinator::assert_same_trust( $expected, $actual, $when ),
+ null,
+ \HtmlApiFuzz\CommonCrawlBatchCoordinator::class
+);
+$trust_mutations = array(
+ 'runtime-before-lexbor' => array( 'runtime', 'runtimeSha256' ),
+ 'repository-after-html5ever' => array( 'repository', 'codeSha256' ),
+ 'raw-oracle-before-chrome' => array( 'oracleTrustFiles', 'lexborBuildManifest', 'sha256' ),
+ 'raw-oracle-mode-after-chrome' => array( 'oracleTrustFiles', 'lexborBuildManifest', 'mode' ),
+ 'oracle-final' => array( 'oracles', 'chrome-cdp', 'identitySha256' ),
+);
+foreach ( $trust_mutations as $label => $path ) {
+ $changed = $shared['trust'];
+ $cursor =& $changed;
+ foreach ( array_slice( $path, 0, -1 ) as $key ) {
+ $cursor =& $cursor[ $key ];
+ }
+ $leaf = $path[ count( $path ) - 1 ];
+ $cursor[ $leaf ] = 'mode' === $leaf ? '100600' : str_repeat( '0', 64 );
+ unset( $cursor );
+ html_api_fuzz_batch_expect_failure( static fn () => $assert_trust( $shared['trust'], $changed, $label ), "Expected {$label} trust rejection." );
+}
+
+$assert_process = Closure::bind(
+ static fn ( $process, $label ) => \HtmlApiFuzz\CommonCrawlBatchCoordinator::assert_process_success( $process, $label ),
+ null,
+ \HtmlApiFuzz\CommonCrawlBatchCoordinator::class
+);
+html_api_fuzz_batch_expect_failure(
+ static fn () => $assert_process( array( 'code' => 0, 'timedOut' => false, 'stdoutTruncated' => false, 'stderrTruncated' => false, 'processGroupCleanupFailed' => true ), 'cleanup fixture' ),
+ 'Expected process-group cleanup failure rejection.'
+);
+
+$canonical_json = Closure::bind(
+ static fn ( $value ) => \HtmlApiFuzz\CommonCrawlBatchCoordinator::canonical_json( $value ),
+ null,
+ \HtmlApiFuzz\CommonCrawlBatchCoordinator::class
+);
+$variant_root = $work_dir . '/summary-variants';
+\HtmlApiFuzz\ensure_dir( $variant_root );
+foreach ( array( 'partial', 'duplicate', 'reorder', 'wrong-hash', 'wrong-config', 'wrong-setting', 'wrong-setting-rehashed', 'wrong-environment' ) as $variant ) {
+ $variant_dir = $variant_root . '/' . $variant;
+ \HtmlApiFuzz\ensure_dir( $variant_dir );
+ $summary_lines = file( $output . '/lexbor-source/commoncrawl-summary.ndjson', FILE_IGNORE_NEW_LINES );
+ $configuration = json_decode( (string) file_get_contents( $output . '/lexbor-source/configuration.json' ), true );
+ if ( 'partial' === $variant ) {
+ array_pop( $summary_lines );
+ } elseif ( 'duplicate' === $variant ) {
+ $summary_lines[] = $summary_lines[0];
+ } elseif ( 'reorder' === $variant ) {
+ $summary_lines = array_reverse( $summary_lines );
+ } elseif ( 'wrong-hash' === $variant ) {
+ $row = json_decode( $summary_lines[0], true );
+ $row['inputSha256'] = str_repeat( '0', 64 );
+ $summary_lines[0] = json_encode( $row, JSON_UNESCAPED_SLASHES );
+ } elseif ( 'wrong-config' === $variant ) {
+ $configuration['ccAnalyzer']['coordinator']['runtimeSha256'] = str_repeat( '0', 64 );
+ } elseif ( 'wrong-setting' === $variant ) {
+ $configuration['checks'] = 'full';
+ } elseif ( 'wrong-setting-rehashed' === $variant ) {
+ $configuration['checks'] = 'full';
+ $hash_material = $configuration;
+ unset( $hash_material['configHash'], $hash_material['createdAt'] );
+ $configuration['configHash'] = hash( 'sha256', $canonical_json( $hash_material ) );
+ foreach ( $summary_lines as &$summary_line ) {
+ $summary_record = json_decode( $summary_line, true );
+ $summary_record['configHash'] = $configuration['configHash'];
+ $summary_line = json_encode( $summary_record, JSON_UNESCAPED_SLASHES );
+ }
+ unset( $summary_line );
+ }
+ file_put_contents( $variant_dir . '/commoncrawl-summary.ndjson', implode( "\n", $summary_lines ) . "\n" );
+ file_put_contents( $variant_dir . '/configuration.json', json_encode( $configuration, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" );
+ $environment_evidence = json_decode( (string) file_get_contents( $output . '/lexbor-source/replacement-environment.json' ), true );
+ if ( 'wrong-environment' === $variant ) {
+ $environment_evidence['environment']['HTML_API_CC_CHECKS'] = 'full';
+ }
+ file_put_contents( $variant_dir . '/replacement-environment.json', json_encode( $environment_evidence, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" );
+ html_api_fuzz_batch_expect_failure(
+ static fn () => \HtmlApiFuzz\CommonCrawlBatchCoordinator::verify_run_output( $variant_dir, $manifest, $lexbor['provenance']['id'] . '-lexbor-source', 'lexbor-source', $identity, $lexbor['provenance'], $lexbor['environment'] ),
+ "Expected {$variant} shared-summary rejection."
+ );
+}
+
+$database_info = array_merge( $manifest['batch'], array( 'runCount' => 3 ) );
+$db = new SQLite3( $fixture['path'] );
+$original_body = $fixture['documents'][0]['body'];
+$db->exec( "UPDATE cached_documents SET body = body || 'x' WHERE record_id = 'urn:uuid:batch-one'" );
+$db->close();
+html_api_fuzz_batch_expect_failure(
+ static fn () => \HtmlApiFuzz\CommonCrawlBatchCoordinator::load_batch_manifest( $fixture['path'], $database_info ),
+ 'Expected cache body checksum rejection.'
+);
+$db = new SQLite3( $fixture['path'] );
+$restore = $db->prepare( 'UPDATE cached_documents SET body = :body WHERE record_id = :record' );
+$restore->bindValue( ':body', $original_body, SQLITE3_BLOB );
+$restore->bindValue( ':record', 'urn:uuid:batch-one', SQLITE3_TEXT );
+$restore->execute();
+$db->close();
+foreach ( array(
+ 'HTTP response range' => array(
+ "UPDATE document_batch_documents SET response_code = 99 WHERE record_id = 'urn:uuid:batch-one'; UPDATE cached_documents SET response_code = 99 WHERE record_id = 'urn:uuid:batch-one'",
+ "UPDATE document_batch_documents SET response_code = 200 WHERE record_id = 'urn:uuid:batch-one'; UPDATE cached_documents SET response_code = 200 WHERE record_id = 'urn:uuid:batch-one'",
+ ),
+ 'HTTP response type' => array(
+ "UPDATE document_batch_documents SET response_code = 'oops' WHERE record_id = 'urn:uuid:batch-one'; UPDATE cached_documents SET response_code = 'oops' WHERE record_id = 'urn:uuid:batch-one'",
+ "UPDATE document_batch_documents SET response_code = 200 WHERE record_id = 'urn:uuid:batch-one'; UPDATE cached_documents SET response_code = 200 WHERE record_id = 'urn:uuid:batch-one'",
+ ),
+ 'content type' => array(
+ "UPDATE cached_documents SET content_type = '' WHERE record_id = 'urn:uuid:batch-one'",
+ "UPDATE cached_documents SET content_type = 'text/html' WHERE record_id = 'urn:uuid:batch-one'",
+ ),
+ 'transport charset' => array(
+ "UPDATE cached_documents SET transport_charset = '' WHERE record_id = 'urn:uuid:batch-one'",
+ "UPDATE cached_documents SET transport_charset = 'UTF-8' WHERE record_id = 'urn:uuid:batch-one'",
+ ),
+ 'batch requested-limit type' => array(
+ "UPDATE document_batches SET requested_limit = 'oops' WHERE name = 'coordinator-smoke'",
+ "UPDATE document_batches SET requested_limit = 2 WHERE name = 'coordinator-smoke'",
+ ),
+) as $label => $queries ) {
+ $db = new SQLite3( $fixture['path'] );
+ html_api_fuzz_batch_assert( $db->exec( $queries[0] ), "Expected {$label} fixture mutation." );
+ $db->close();
+ html_api_fuzz_batch_expect_failure(
+ static fn () => \HtmlApiFuzz\CommonCrawlBatchCoordinator::load_batch_manifest( $fixture['path'], $database_info ),
+ "Expected invalid {$label} metadata rejection."
+ );
+ $db = new SQLite3( $fixture['path'] );
+ html_api_fuzz_batch_assert( $db->exec( $queries[1] ), "Expected {$label} fixture restoration." );
+ $db->close();
+}
+$db = new SQLite3( $fixture['path'] );
+$db->exec( "UPDATE document_batch_documents SET sequence = 3 WHERE record_id = 'urn:uuid:batch-two'" );
+$db->close();
+html_api_fuzz_batch_expect_failure(
+ static fn () => \HtmlApiFuzz\CommonCrawlBatchCoordinator::load_batch_manifest( $fixture['path'], $database_info ),
+ 'Expected gapped sequence rejection.'
+);
+
+$preexisting = $work_dir . '/preexisting';
+\HtmlApiFuzz\ensure_dir( $preexisting );
+$preexisting_args = html_api_fuzz_batch_replace_arg( $args, '--output-dir', $preexisting );
+$preexisting_process = \HtmlApiFuzz\run_php_process( $preexisting_args, \HtmlApiFuzz\repo_root(), 30000 );
+html_api_fuzz_batch_assert( 0 !== ( $preexisting_process['code'] ?? 0 ), 'Expected pre-existing output rejection.' );
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+echo "OK commoncrawl-batch-coordinator-smoke\n";
diff --git a/tools/html-api-fuzz/tests/fixtures/fake-cc-analyzer.php b/tools/html-api-fuzz/tests/fixtures/fake-cc-analyzer.php
new file mode 100755
index 0000000000000..61ce06778a919
--- /dev/null
+++ b/tools/html-api-fuzz/tests/fixtures/fake-cc-analyzer.php
@@ -0,0 +1,280 @@
+#!/usr/bin/env php
+inputStateKey = (string) $row['input_state_key'];
+ $this->inputUrl = (string) $row['input_url'];
+ $this->recordId = (string) $row['record_id'];
+ $this->targetUri = (string) $row['target_uri'];
+ $this->responseCode = (int) $row['response_code'];
+ $this->contentType = (string) $row['content_type'];
+ $this->transportCharset = null === $row['transport_charset'] ? null : (string) $row['transport_charset'];
+ $this->responseHeaders = array();
+ $this->body = (string) $row['body'];
+ $this->rangeStart = null === $row['range_start'] ? null : (int) $row['range_start'];
+ $this->rangeLength = null === $row['range_length'] ? null : (int) $row['range_length'];
+ }
+ }
+}
+
+namespace {
+ function fake_cc_fail( string $message ): void {
+ fwrite( STDERR, $message . "\n" );
+ exit( 1 );
+ }
+
+ function fake_cc_json( $value ): void {
+ echo json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+ }
+
+ function fake_cc_database( string $workspace ): \SQLite3 {
+ $path = $workspace . '/documents.sqlite';
+ if ( ! is_file( $path ) ) {
+ fake_cc_fail( 'Fixture documents.sqlite is missing.' );
+ }
+ $db = new \SQLite3( $path, SQLITE3_OPEN_READWRITE );
+ $db->busyTimeout( 5000 );
+ return $db;
+ }
+
+ function fake_cc_control( string $workspace ): array {
+ $path = $workspace . '/control.json';
+ if ( ! is_file( $path ) ) {
+ return array();
+ }
+ $value = json_decode( (string) file_get_contents( $path ), true );
+ return is_array( $value ) ? $value : array();
+ }
+
+ function fake_cc_batch( \SQLite3 $db, string $name ): array {
+ $statement = $db->prepare(
+ 'SELECT b.*, COUNT(d.record_id) AS document_count,
+ COALESCE(SUM(d.byte_length), 0) AS byte_count,
+ (SELECT COUNT(*) FROM document_batch_runs r WHERE r.batch_name = b.name) AS run_count
+ FROM document_batches b LEFT JOIN document_batch_documents d ON d.batch_name = b.name
+ WHERE b.name = :name GROUP BY b.name LIMIT 1'
+ );
+ $statement->bindValue( ':name', $name, SQLITE3_TEXT );
+ $row = $statement->execute()->fetchArray( SQLITE3_ASSOC );
+ if ( ! is_array( $row ) ) {
+ fake_cc_fail( 'Fixture batch not found.' );
+ }
+ return array(
+ 'name' => (string) $row['name'],
+ 'sourceMode' => (string) $row['source_mode'],
+ 'crawlSelection' => (string) $row['crawl_selection'],
+ 'crawlId' => (string) $row['crawl_id'],
+ 'urlPattern' => (string) $row['url_pattern'],
+ 'requestedLimit' => (int) $row['requested_limit'],
+ 'statePath' => (string) $row['state_path'],
+ 'cachePath' => (string) $row['cache_path'],
+ 'status' => (string) $row['status'],
+ 'documentCount' => (int) $row['document_count'],
+ 'byteCount' => (int) $row['byte_count'],
+ 'runCount' => (int) $row['run_count'],
+ 'createdAt' => (string) $row['created_at'],
+ 'updatedAt' => (string) $row['updated_at'],
+ 'completedAt' => null === $row['completed_at'] ? null : (string) $row['completed_at'],
+ );
+ }
+
+ $args = $argv;
+ array_shift( $args );
+ $workspace = null;
+ if ( '--workspace' === ( $args[0] ?? null ) ) {
+ $workspace = $args[1] ?? null;
+ $args = array_slice( $args, 2 );
+ }
+ if ( ! is_string( $workspace ) || ! is_dir( $workspace ) ) {
+ fake_cc_fail( 'Expected fixture --workspace.' );
+ }
+ if ( 'batch' !== ( $args[0] ?? null ) ) {
+ fake_cc_fail( 'Fixture supports only batch commands.' );
+ }
+ $operation = $args[1] ?? '';
+ $batch_name = $args[2] ?? '';
+ $db = fake_cc_database( $workspace );
+ $control = fake_cc_control( $workspace );
+ $mode = is_string( $control['mode'] ?? null ) ? $control['mode'] : 'ok';
+
+ if ( 'info' === $operation ) {
+ if ( 'nonzero-info' === $mode ) {
+ fake_cc_fail( 'Forced fixture info failure.' );
+ }
+ if ( 'timeout-info' === $mode ) {
+ usleep( 5000000 );
+ }
+ if ( 'truncate-info' === $mode ) {
+ echo str_repeat( 'x', 2 * 1024 * 1024 );
+ exit( 0 );
+ }
+ if ( 'malformed-info' === $mode ) {
+ echo "{broken info\n";
+ exit( 0 );
+ }
+ fake_cc_json( fake_cc_batch( $db, $batch_name ) );
+ exit( 0 );
+ }
+
+ if ( 'verify' === $operation ) {
+ if ( 'malformed-verify' === $mode ) {
+ echo "[]\n";
+ exit( 0 );
+ }
+ $statement = $db->prepare(
+ 'SELECT COUNT(*) FROM document_batch_documents d
+ LEFT JOIN cached_documents c ON c.input_state_key = d.input_state_key AND c.record_id = d.record_id
+ WHERE d.batch_name = :name AND c.record_id IS NULL'
+ );
+ $statement->bindValue( ':name', $batch_name, SQLITE3_TEXT );
+ $missing = (int) $statement->execute()->fetchArray( SQLITE3_NUM )[0];
+ $batch = fake_cc_batch( $db, $batch_name );
+ fake_cc_json( array( 'batchName' => $batch_name, 'documentCount' => $batch['documentCount'], 'missingCount' => $missing, 'missingDocuments' => array() ) );
+ exit( 0 === $missing ? 0 : 1 );
+ }
+
+ if ( 'run' !== $operation ) {
+ fake_cc_fail( 'Unknown fixture batch operation.' );
+ }
+ $analysis_script = $args[3] ?? null;
+ if ( ! is_string( $analysis_script ) || ! is_file( $analysis_script ) ) {
+ fake_cc_fail( 'Fixture analysis callback is missing.' );
+ }
+ $run_index = fake_cc_batch( $db, $batch_name )['runCount'] + 1;
+ $trigger_run = is_int( $control['triggerRun'] ?? null ) ? $control['triggerRun'] : 1;
+ $triggered = $run_index === $trigger_run;
+ foreach ( array( 'NODE_OPTIONS', 'PHPRC', 'PHP_INI_SCAN_DIR', 'LD_BIND_NOW', 'DYLD_LIBRARY_PATH', 'HTML_API_CC_UNEXPECTED', 'HTML_API_FUZZ_UNEXPECTED' ) as $forbidden ) {
+ if ( false !== getenv( $forbidden ) ) {
+ fake_cc_fail( "Inherited forbidden environment variable: {$forbidden}" );
+ }
+ }
+ if ( $triggered && 'nonzero' === $mode ) {
+ fake_cc_fail( 'Forced fixture nonzero exit.' );
+ }
+ if ( $triggered && 'timeout' === $mode ) {
+ usleep( 5000000 );
+ }
+ if ( $triggered && 'truncate' === $mode ) {
+ fwrite( STDERR, str_repeat( 'x', 2 * 1024 * 1024 ) );
+ }
+
+ $output_dir = getenv( 'CC_ANALYZER_OUTPUT_DIR' );
+ if ( ! is_string( $output_dir ) || '' === $output_dir ) {
+ fake_cc_fail( 'Fixture output directory environment is missing.' );
+ }
+ file_put_contents( $output_dir . '/observed-environment.json', json_encode( getenv(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n" );
+ putenv( 'CC_ANALYZER_MODE=batch' );
+ putenv( 'CC_ANALYZER_BATCH=' . $batch_name );
+ putenv( 'CC_ANALYZER_MANIFEST=' . $workspace . '/documents.sqlite' );
+ $_ENV['CC_ANALYZER_MODE'] = 'batch';
+ $_ENV['CC_ANALYZER_BATCH'] = $batch_name;
+ $_ENV['CC_ANALYZER_MANIFEST'] = $workspace . '/documents.sqlite';
+ $callback = require $analysis_script;
+ if ( ! is_callable( $callback ) ) {
+ fake_cc_fail( 'Fixture analysis script did not return a callback.' );
+ }
+ $query = $db->prepare(
+ 'SELECT c.* FROM document_batch_documents d
+ JOIN cached_documents c ON c.input_state_key = d.input_state_key AND c.record_id = d.record_id
+ WHERE d.batch_name = :name ORDER BY d.sequence ASC'
+ );
+ $query->bindValue( ':name', $batch_name, SQLITE3_TEXT );
+ $result = $query->execute();
+ $documents = array();
+ while ( $row = $result->fetchArray( SQLITE3_ASSOC ) ) {
+ $documents[] = $row;
+ }
+ if ( $triggered && 'reorder' === $mode ) {
+ $documents = array_reverse( $documents );
+ }
+ if ( $triggered && 'partial' === $mode ) {
+ array_pop( $documents );
+ }
+ if ( $triggered && 'duplicate' === $mode && ! empty( $documents ) ) {
+ array_splice( $documents, 1, 0, array( $documents[0] ) );
+ }
+ $analyzed = 0;
+ foreach ( $documents as $index => $row ) {
+ if ( $triggered && 'wrong-body' === $mode && 0 === $index ) {
+ $row['body'] .= 'changed';
+ }
+ $callback( new \CcAnalyzer\Analysis\HtmlAnalysisInput( $row ) );
+ ++$analyzed;
+ }
+ if ( $triggered && 'mutate-cache' === $mode ) {
+ $db->exec( "UPDATE cached_documents SET body = body || 'x' WHERE rowid = (SELECT MIN(rowid) FROM cached_documents)" );
+ }
+ if ( $triggered && 'self-change' === $mode ) {
+ file_put_contents( __FILE__, "\n", FILE_APPEND );
+ }
+ if ( $triggered && 'mutate-trust-file' === $mode ) {
+ $mutation_path = $control['mutationPath'] ?? null;
+ if ( ! is_string( $mutation_path ) || ! is_file( $mutation_path ) || false === file_put_contents( $mutation_path, "\n", FILE_APPEND ) ) {
+ fake_cc_fail( 'Could not mutate the requested fixture trust file.' );
+ }
+ }
+ if ( $triggered && 'chmod-trust-file' === $mode ) {
+ $mutation_path = $control['mutationPath'] ?? null;
+ if ( ! is_string( $mutation_path ) || ! is_file( $mutation_path ) || ! chmod( $mutation_path, 0600 ) ) {
+ fake_cc_fail( 'Could not change the requested fixture trust-file mode.' );
+ }
+ }
+ if ( $triggered && 'tamper-previous-evidence' === $mode ) {
+ $previous_summary = dirname( $output_dir ) . '/lexbor-source/commoncrawl-summary.ndjson';
+ if ( ! is_file( $previous_summary ) || false === file_put_contents( $previous_summary, "{}\n", FILE_APPEND ) ) {
+ fake_cc_fail( 'Could not tamper with prior-run evidence.' );
+ }
+ }
+ if ( $triggered && 'block-root-complete' === $mode ) {
+ $blocked_marker = dirname( $output_dir ) . '/.complete';
+ if ( ! mkdir( $blocked_marker, 0700 ) ) {
+ fake_cc_fail( 'Could not block the coordinator root completion marker.' );
+ }
+ }
+ $started = gmdate( 'c' );
+ $callback_hash = hash_file( 'sha256', $analysis_script );
+ $insert = $db->prepare(
+ 'INSERT INTO document_batch_runs (batch_name, analysis_script, analysis_script_hash, documents_analyzed, started_at, completed_at)
+ VALUES (:batch, :script, :hash, :count, :started, :completed)'
+ );
+ $insert->bindValue( ':batch', $batch_name, SQLITE3_TEXT );
+ $insert->bindValue( ':script', realpath( $analysis_script ), SQLITE3_TEXT );
+ $insert->bindValue( ':hash', $triggered && 'wrong-callback-hash' === $mode ? str_repeat( '0', 64 ) : $callback_hash, SQLITE3_TEXT );
+ $insert->bindValue( ':count', $triggered && 'wrong-count' === $mode ? $analyzed + 1 : $analyzed, SQLITE3_INTEGER );
+ $insert->bindValue( ':started', $started, SQLITE3_TEXT );
+ $insert->bindValue( ':completed', gmdate( 'c' ), SQLITE3_TEXT );
+ $insert->execute();
+ $run_id = $db->lastInsertRowID();
+ if ( $triggered && 'malformed-run' === $mode ) {
+ echo "{broken run\n";
+ exit( 0 );
+ }
+ $batch = fake_cc_batch( $db, $batch_name );
+ $reported_count = $triggered && 'wrong-count' === $mode ? $analyzed + 1 : $analyzed;
+ fake_cc_json( array(
+ 'batch' => $batch,
+ 'run' => array(
+ 'batchName' => $batch_name,
+ 'runId' => $run_id,
+ 'analysisScript' => realpath( $analysis_script ),
+ 'analysisScriptHash' => $triggered && 'wrong-callback-hash' === $mode ? str_repeat( '0', 64 ) : $callback_hash,
+ 'documentsAnalyzed' => $reported_count,
+ 'startedAt' => $started,
+ 'completedAt' => gmdate( 'c' ),
+ ),
+ 'documentsAnalyzed' => $reported_count,
+ ) );
+}
From 0a4d1d0ad959d27dc5ba9119e647f8df6b7eb277 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Thu, 16 Jul 2026 10:18:29 +0200
Subject: [PATCH 013/149] docs: record Common Crawl external gate
---
tools/html-api-fuzz/README.md | 60 ++++++++++++++++++++++++++++++++---
1 file changed, 56 insertions(+), 4 deletions(-)
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
index 65b1f5ee5a7c5..704a8576de5b9 100644
--- a/tools/html-api-fuzz/README.md
+++ b/tools/html-api-fuzz/README.md
@@ -76,10 +76,11 @@ coordinator never fetches or substitutes documents:
```sh
PHAR=/absolute/path/to/cc-analyzer.phar
WORKSPACE=/absolute/path/to/cc-workspace
-BATCH=wp-html-api-canary-2026-30
+BATCH=wp-html-api-canary-2026-25
php "$PHAR" --workspace "$WORKSPACE" batch fetch "$BATCH" \
- --crawl CC-MAIN-2026-30 --limit 20 --progress text --output json
+ --crawl CC-MAIN-2026-25 --limit 20 --duration 5m \
+ --cache-max-bytes 134217728 --progress text --output json
php tools/html-api-fuzz/commoncrawl-batch.php \
--cc-analyzer "$PHAR" --workspace "$WORKSPACE" --batch "$BATCH" \
@@ -89,9 +90,16 @@ php tools/html-api-fuzz/commoncrawl-batch.php \
--html5ever-oracle-bin tools/html-api-fuzz/oracles/html5ever/build/html5ever-tree-oracle \
--chrome-oracle-script tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js \
--chrome-executable /absolute/path/to/pinned/chrome \
- --node-bin /absolute/path/to/node --retain-all
+ --node-bin /absolute/path/to/non-symlink/node --retain-all
```
+Every executable passed to the coordinator must be a direct regular file. In
+particular, a version-manager shim such as Volta's `~/.volta/bin/node` may be a
+symlink and is rejected. Pass the resolved, non-symlink Node executable itself;
+`node -p 'process.execPath'` reports the executable running Node. Verify that
+reported path with `test -f`, `test -x`, and `test ! -L` before starting the
+write-once coordinator output.
+
The output path must not exist. It is claimed once and contains separate
`lexbor-source/`, `html5ever-source/`, and `chrome-cdp/` directories. Each gets
its own immutable configuration, stdout and stderr evidence, append-only
@@ -127,7 +135,41 @@ returns a `static function (HtmlAnalysisInput $document): void` closure. Use
`php /path/to/cc-analyzer.phar --help` for the binary's crawl-specific command
and argument names. The committed coordinator smoke exercises the same batch
schemas and callback contract with an isolated fake analyzer. Real-PHAR runs
-are a separate external gate and are not claimed by that code-only smoke.
+remain a separate external gate and are not claimed by that code-only smoke.
+
+On 2026-07-16, a bounded real-PHAR canary was also completed against
+`CC-MAIN-2026-25`. The PHAR SHA-256 was
+`898d17d53674b70cd573f598f31d46d92df4f237d3292a9b6ff8c2e5c0c50cde`.
+One fetch produced 5 documents and 247070 bytes; its sealed batch-manifest
+SHA-256 was
+`a37aa5a1a16d7865e1ad5968db30634125dee1514ddae2e5b6e470eef375638e`
+and its corpus fingerprint was
+`82e37f25a00daca8782f6f7062d8329f04bae1303c8f3a7e0d26691996fd6fa2`.
+Lexbor, html5ever, and Chrome each consumed the same ordered five-document
+vector and each reported 3 `passed` and 2 `unsupported` results. One retained
+artifact per oracle was then replayed exactly once; all three replays reproduced
+`unsupported` for the identical 17129-byte input with SHA-256
+`baa6de008c6530fd7828e9c18e274a9fb60f2fe96e17bf0899c8bb9f16626306`,
+without a timeout, oracle mismatch, or process-group cleanup failure. The
+ignored local evidence is under
+`artifacts/html-api-fuzz/external-gates/20260716-ccb5be5be1-real-phar/`.
+This was a five-document canary, not a complete-crawl or full-corpus claim.
+
+The observed post-gate disk footprint was 180964 KiB for `.cache/lexbor`,
+653164 KiB for `.cache/html5ever`, 529780 KiB for the pinned Chrome directory,
+and 5512 KiB for the canary evidence. Budget at least 1.4 GiB for those local
+build/install caches before allowing for fetched bodies and three runs of
+output. A larger run needs a fresh batch name and output path plus deliberately
+larger fetch limit, duration, cache ceiling, coordinator timeout, and disk
+budget. Use the PHAR's help for its unbounded-fetch semantics, and do not use
+`--retain-all` at large scale unless retaining every input three times is
+intentional.
+
+The downloads and generated evidence are intentionally uncommitted:
+`.cache/lexbor`, `.cache/html5ever`, both source-oracle `build/` directories,
+the Chrome `.chrome-for-testing/` installation, and `artifacts/` are ignored.
+Preserve or archive an external-gate workspace separately when its audit trail
+is needed; do not force-add it to Git.
Each accepted document runs in a separate PHP child with its own memory and
wall-clock limit. The callback writes `input.bin` and an initial replay before
@@ -238,6 +280,16 @@ php tools/html-api-fuzz/replay.php \
--replay artifacts/html-api-commoncrawl/findings/SIGNATURE/DOCUMENT/replay.json
```
+A coordinator-retained artifact uses the same replay command and lives below
+the selected oracle's sealed run directory. Always write the replay to a new
+output directory:
+
+```sh
+php tools/html-api-fuzz/replay.php \
+ --replay /absolute/path/shared-corpus/ORACLE/findings/SIGNATURE/DOCUMENT/replay.json \
+ --output-dir /absolute/new/path/replay-ORACLE
+```
+
Replay uses the finding's recorded Worker script, PHP memory limit, whole-worker
timeout, check pipeline, and oracle timeout by default. It applies the same
process-group isolation and synthesizes the same timeout/OOM/crash result when
From 2459eed5574683a94d17d499b8823d2d72a6427e Mon Sep 17 00:00:00 2001
From: Adam Silverstein
Date: Thu, 23 Jul 2026 15:33:51 +0000
Subject: [PATCH 014/149] Comments: allow the Notes @mention chip markup in
comment content.
The Notes @mention completer stores a mention as a non-interactive chip, `@Name `, the `user-N` class token carrying the mentioned user's ID. Fix an issue where the default comment kses allowlist does not permit `span`, so for users without the `unfiltered_html` capability the mention markup is stripped when the note is saved.
See related Gutenberg pull requests: https://github.com/WordPress/gutenberg/pull/79604 and https://github.com/WordPress/gutenberg/pull/80528.
Props mamaduka, westonruter, t-hamano, luisdavid01, vedantere.
Fixes #65622.
git-svn-id: https://develop.svn.wordpress.org/trunk@62832 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/default-filters.php | 5 +
src/wp-includes/kses.php | 83 +++++++++++
tests/phpunit/tests/kses.php | 221 ++++++++++++++++++++++++++++
3 files changed, 309 insertions(+)
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index d9a05c829646a..b2790c7d43ec9 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -310,6 +310,11 @@
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 );
add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 );
+
+// Note mention chips in comment content: allow `span` through comment kses,
+// then reduce its classes to the mention tokens right after `wp_filter_kses`.
+add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_span', 10, 2 );
+add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11 );
add_filter( 'comment_email', 'antispambot' );
add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' );
add_filter( 'option_category_base', '_wp_filter_taxonomy_base' );
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index 37d457a3e18a2..46cd2c4576c03 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -1131,6 +1131,89 @@ function wp_kses_allowed_html( $context = '' ) {
}
}
+/**
+ * Allows the note mention chip markup in comment content.
+ *
+ * The notes `@` mention completer stores a mention as a chip carrying the
+ * mentioned user's ID in a class token:
+ * `@Name `. The default comment
+ * allowlist does not allow `span` at all, so for users without
+ * `unfiltered_html` the mention would be stripped on save.
+ *
+ * The allowance is deliberately narrow and always on: `span` is a
+ * semantics-free element and _wp_kses_sanitize_note_mention_classes()
+ * reduces its `class` to the two mention tokens right after kses runs, so
+ * regular (including anonymous) commenters gain nothing beyond the inert
+ * mention markup itself.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param array> $allowed The allowed tags structure for the context.
+ * @param string $context The kses context.
+ * @return array> Modified allowed tags structure.
+ */
+function _wp_kses_allow_note_mention_span( $allowed, $context ): array {
+ if ( ! is_array( $allowed ) ) {
+ $allowed = array();
+ }
+ if ( 'pre_comment_content' !== $context ) {
+ return $allowed;
+ }
+
+ if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) {
+ $allowed['span'] = array();
+ }
+
+ $allowed['span']['class'] = true;
+
+ return $allowed;
+}
+
+/**
+ * Reduces `span` classes in comment content to the note mention tokens.
+ *
+ * _wp_kses_allow_note_mention_span() lets `class` through kses on `span` so
+ * the mention chip survives, but `class` is an open-ended styling and
+ * scripting hook, so this companion pass - running right after
+ * `wp_filter_kses` at priority 10 - strips every class token except the two
+ * the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only
+ * comment tag allowed to carry `class` at all, so walking `span` tags covers
+ * the entire allowance.
+ *
+ * The pass only applies while the restrictive comment allowlist is active:
+ * users with `unfiltered_html` are filtered through `wp_filter_post_kses`
+ * (or not at all), where arbitrary classes are already permitted, and
+ * narrowing their markup here would restrict what core allows them to post.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param string $content Slashed comment content, already filtered by kses.
+ * @return string Slashed comment content with span classes reduced.
+ */
+function _wp_kses_sanitize_note_mention_classes( $content ): string {
+ if ( ! is_string( $content ) ) {
+ $content = '';
+ }
+ if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) {
+ return $content;
+ }
+
+ $processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) );
+
+ while ( $processor->next_tag( 'SPAN' ) ) {
+ foreach ( $processor->class_list() as $token ) {
+ if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) {
+ // Removing the last class also removes the attribute itself.
+ $processor->remove_class( $token );
+ }
+ }
+ }
+
+ return wp_slash( $processor->get_updated_html() );
+}
+
/**
* You add any KSES hooks here.
*
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index 59353a2b7a20c..1afd7e0884a64 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() {
$this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) );
}
+ /**
+ * Tests that the comment content context allows only the mention span beyond the defaults.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() {
+ global $allowedtags;
+
+ $allowed = wp_kses_allowed_html( 'pre_comment_content' );
+
+ $this->assertSame(
+ array( 'class' => true ),
+ $allowed['span'],
+ 'The mention span should be allowed in comment content.'
+ );
+
+ unset( $allowed['span'] );
+ $this->assertSame(
+ $allowedtags,
+ $allowed,
+ 'Nothing beyond the mention span should be allowed on top of the default comment tags.'
+ );
+ }
+
+ /**
+ * Tests that a note mention survives content sanitization of a `note` comment.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_markup_survives_note_content_sanitization() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $content = 'Hello @admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) );
+ }
+
+ /**
+ * Tests that the mention markup also survives in regular comment content.
+ *
+ * The allowance is always on rather than scoped per comment type: the
+ * mention markup is inert, so uniform sanitization avoids stateful
+ * arming and disarming of kses filters around each note write.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_markup_survives_regular_comment_content_sanitization() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
+
+ $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) );
+ }
+
+ /**
+ * Tests that span classes are reduced to the two mention tokens.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertSame(
+ 'Hello @admin !',
+ wp_unslash( $filtered['comment_content'] ),
+ 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.'
+ );
+ }
+
+ /**
+ * Tests that class tokens are reduced on spans regardless of tag-name casing.
+ *
+ * kses preserves tag-name casing, so the class reduction must match `SPAN`
+ * case-insensitively rather than bail on a `@admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertEqualHTML(
+ 'Hello @admin !',
+ wp_unslash( $filtered['comment_content'] ),
+ '',
+ 'Class tokens should be reduced on spans regardless of tag-name casing.'
+ );
+ }
+
+ /**
+ * Tests that the class attribute is removed when no mention tokens remain.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_class_attribute_removed_when_no_tokens_remain() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello there !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
+
+ // Markup-equivalence assertion: the HTML API's whitespace handling
+ // when removing the final attribute is not part of its contract.
+ $this->assertEqualHTML(
+ 'Hello there !',
+ wp_unslash( $filtered['comment_content'] ),
+ '',
+ 'A span with no valid mention tokens should lose its class attribute entirely.'
+ );
+ }
+
+ /**
+ * Tests that only the `class` attribute is allowed on mention spans.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_note_mention_allows_only_class_on_mention_spans() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertSame(
+ 'Hello @admin !',
+ wp_unslash( $filtered['comment_content'] ),
+ 'Attributes beyond `class` should be stripped from spans.'
+ );
+ }
+
+ /**
+ * Tests that `class` is still stripped from links in comment content.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_class_is_still_stripped_from_links_in_comment_content() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ /*
+ * The href is external to the test site so that wp_rel_ugc() - which
+ * applies to notes like any other comment - deterministically appends
+ * `rel="nofollow ugc"`.
+ */
+ $content = 'Hello @admin !';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertSame(
+ 'Hello @admin !',
+ wp_unslash( $filtered['comment_content'] ),
+ 'The class allowance is scoped to spans; links keep the default sanitization.'
+ );
+ }
+
+ /**
+ * Tests that the class reduction is skipped while the restrictive comment kses is inactive.
+ *
+ * Users with `unfiltered_html` are filtered through `wp_filter_post_kses`
+ * (or not at all), where arbitrary classes are permitted; the mention
+ * class reduction must not narrow what they can post.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive() {
+ // kses_init() hooks wp_filter_kses by default in the test
+ // environment, so detach it to simulate the unfiltered_html setup.
+ // The test framework restores filters after each test.
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $content = 'Hello there !';
+
+ $this->assertSame(
+ wp_slash( $content ),
+ _wp_kses_sanitize_note_mention_classes( wp_slash( $content ) ),
+ 'Span classes should be left untouched when wp_filter_kses is not active.'
+ );
+ }
+
+ /**
+ * Builds a complete commentdata array for wp_filter_comment().
+ *
+ * @param 'note'|'comment' $comment_type The comment type.
+ * @param string $content The comment content.
+ * @return array{
+ * comment_content: string,
+ * ...
+ * }
+ */
+ private function get_mention_commentdata( string $comment_type, string $content ): array {
+ return array(
+ 'comment_content' => $content,
+ 'comment_type' => $comment_type,
+ 'comment_author' => 'admin',
+ 'comment_author_IP' => '127.0.0.1',
+ 'comment_author_url' => 'http://example.org',
+ 'comment_author_email' => 'admin@example.org',
+ 'comment_agent' => '',
+ );
+ }
+
public function test_hyphenated_tag() {
$content = 'Alot of hyphens. ';
$custom_tags = array(
From b0e62d8e0ae405ab3f36a87d4181885e2de2e0e8 Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Thu, 23 Jul 2026 16:18:56 +0000
Subject: [PATCH 015/149] Docs: Require view config filter callbacks to return
the container.
Corrects the get_entity_view_config_{$kind}_{$name} filter docblock: callbacks must return the container they receive. Also fixes a doubled "the" in the same paragraph.
Follow-up to [62825].
Props jorgefilipecosta, oandregal.
Fixes #65577.
git-svn-id: https://develop.svn.wordpress.org/trunk@62833 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-view-config-data.php | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php
index 99ea816aee96e..b2e107608ac84 100644
--- a/src/wp-includes/class-wp-view-config-data.php
+++ b/src/wp-includes/class-wp-view-config-data.php
@@ -159,10 +159,12 @@ public function apply_filters( $kind, $name ) {
* individual list members.
*
* A change that declares an unsupported schema version is rejected and does
- * not alter anything. Callbacks mutate the container in place, so there is no
- * need to return it; any returned value is ignored. Callbacks must not replace
- * the container with a different value, as later callbacks receive whatever the
- * the previous one returned.
+ * not alter anything. As with any filter, each callback's return value is
+ * passed to the next callback as `$data`, so callbacks must return the
+ * container they received: a callback that returns nothing, or any other
+ * value, hands that result to every callback hooked at a later priority
+ * instead of the container. Since the write methods return the container,
+ * a callback can end with `return $data->merge( $patch, $version );`.
*
* @since 7.1.0
*
From 5fd5a8eec5cfb3e1cd74695dabb5c6b93666dcf2 Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Thu, 23 Jul 2026 16:37:54 +0000
Subject: [PATCH 016/149] View config: reject shape-mismatched merges, define
empty-array semantics, strip nulls from appended members.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes three silent data-loss defects in WP_View_Config_Data's merge engine:
- An associative patch value over a list (or a non-empty list over an associative value) discarded the whole current value. It is now rejected with _doing_it_wrong() and the current value is kept.
- An empty array under merge() wiped associative values but no-oped on lists. It is now a no-op for both shapes — clear a list with replace() and an empty list, reset a key with null.
- A list member appended by merge() kept nested nulls that every other write path drops. Appended members now go through strip_nulls().
Follow-up to [62825].
Props jorgefilipecosta, oandregal.
See #65577.
git-svn-id: https://develop.svn.wordpress.org/trunk@62834 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-view-config-data.php | 63 ++++-
tests/phpunit/tests/view-config-data.php | 227 ++++++++++++++++++
2 files changed, 287 insertions(+), 3 deletions(-)
diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php
index b2e107608ac84..8c3d255fab82d 100644
--- a/src/wp-includes/class-wp-view-config-data.php
+++ b/src/wp-includes/class-wp-view-config-data.php
@@ -40,7 +40,10 @@
* key by key (an associative array merges member by member, a nested `null`
* deletes just that leaf, a scalar replaces just that value), while `set()`
* swaps the whole value. A nested `null` deletes just the leaf it names in
- * every case. Each patch also declares the configuration schema
+ * every case. A patch value whose shape does not match the current value —
+ * an associative array where a list lives, or the reverse — is rejected with
+ * a notice rather than merged, and an empty array under `merge()` is a
+ * no-op. Each patch also declares the configuration schema
* version it was written against (currently 1), so a future WordPress release
* that changes the configuration shape can migrate existing patches forward
* instead of breaking them.
@@ -308,6 +311,12 @@ public function remove( array $spec, int $version ) {
* stops inheriting core's future additions to it — but it's useful when a
* contributor needs to pin a list to an exact set of members.
*
+ * The shape rule applies here too: a patch value whose shape does not match
+ * the current value — an associative array where a list lives, or a
+ * non-empty list where an associative value lives — is rejected with a
+ * notice and leaves the current value unchanged. An empty array is exempt,
+ * so replacing a list with an empty list still clears it.
+ *
* A patch that declares an unsupported schema version is rejected and does
* not change anything.
*
@@ -354,6 +363,13 @@ public function replace( array $patch, int $version ) {
* - default_layouts will be updated so that newField is appended to the badgeFields.
* - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'.
*
+ * A patch value only merges into a current value of the same shape: an
+ * associative array where a list lives, or a non-empty list where an
+ * associative value lives, is rejected with a notice and leaves the current
+ * value unchanged. An empty array merges nothing and is a no-op — clear a
+ * list with replace() and an empty list, or reset a key to its default with
+ * a top-level `null`.
+ *
* A patch that declares an unsupported schema version is rejected and does
* not change anything.
*
@@ -477,6 +493,15 @@ private function strip_nulls( $value ) {
* $replace_lists flag is carried down through associative nesting so that,
* under replace(), every list reached along the way is swapped wholesale.
*
+ * An array in $incoming only merges into a current value of the same shape.
+ * A non-empty mismatch — an associative array where a list lives, or a
+ * non-empty list where an associative value lives — is reported with
+ * _doing_it_wrong() and leaves the current value unchanged, so a malformed
+ * patch cannot silently destroy configuration. An empty array is
+ * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is
+ * spelled replace() with an empty list, and resetting a key is spelled
+ * `null`.
+ *
* @since 7.1.0
*
* @param mixed $current The current value.
@@ -493,6 +518,18 @@ private function merge_properties( $current, $incoming, $replace_lists ) {
// Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0).
if ( array_is_list( $incoming ) ) {
+ // A non-empty list only lands where a list (or nothing) lives, under
+ // merge() and replace() alike. An empty array is shape-ambiguous and
+ // exempt, so replace() with an empty list can still clear a list.
+ if ( array() !== $incoming && is_array( $current ) && ! array_is_list( $current ) && array() !== $current ) {
+ _doing_it_wrong(
+ __METHOD__,
+ esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ),
+ '7.1.0'
+ );
+ return $current;
+ }
+
// replace() takes an incoming list as-is; merge() merges it by member identity.
if ( $replace_lists ) {
// As-is except for nulls: a list swapped in wholesale has no
@@ -500,6 +537,13 @@ private function merge_properties( $current, $incoming, $replace_lists ) {
// set()), so a null member is dropped rather than stored.
return $this->strip_nulls( $incoming );
}
+
+ // An empty list has no members to merge, and an empty array is
+ // shape-ambiguous, so merging one is a no-op rather than a reset.
+ if ( array() === $incoming ) {
+ return $current;
+ }
+
return $this->merge_list_by_identity(
is_array( $current ) && array_is_list( $current ) ? $current : array(),
$incoming
@@ -507,6 +551,15 @@ private function merge_properties( $current, $incoming, $replace_lists ) {
}
// Consider any other array as associative (keys are strings).
+ if ( is_array( $current ) && array_is_list( $current ) && array() !== $current ) {
+ _doing_it_wrong(
+ __METHOD__,
+ esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ),
+ '7.1.0'
+ );
+ return $current;
+ }
+
$result = is_array( $current ) && ! array_is_list( $current ) ? $current : array();
foreach ( $incoming as $key => $value ) {
// A null patch value deletes the property.
@@ -603,7 +656,9 @@ private function remove_list_member( array $members, $identity ) {
* A member of the incoming list whose identity matches one already present
* merges into it in place, keeping its position; an unmatched member is
* appended to the end, except a literal `null`, which carries no identity
- * and holds nothing to merge and so is dropped. A matched member's contents
+ * and holds nothing to merge and so is dropped. An appended member has no
+ * existing leaf for a nested `null` to delete (the same rationale as set()),
+ * so its nulls are stripped rather than stored. A matched member's contents
* merge recursively with the same rules (merge_properties), so the
* identity-aware merge applies at
* any nesting level: each key named by the patch is substituted while the
@@ -639,7 +694,9 @@ private function merge_list_by_identity( array $current, array $incoming ) {
}
}
if ( null === $index ) {
- $result[] = $item;
+ // An appended member has no existing leaf for a nested null to
+ // delete, so nulls are dropped rather than stored.
+ $result[] = $this->strip_nulls( $item );
continue;
}
diff --git a/tests/phpunit/tests/view-config-data.php b/tests/phpunit/tests/view-config-data.php
index 1d56adb786644..68940ad6c2d5c 100644
--- a/tests/phpunit/tests/view-config-data.php
+++ b/tests/phpunit/tests/view-config-data.php
@@ -1721,6 +1721,233 @@ public function test_merge_rejects_unknown_key() {
$this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), self::read_config( $data ) );
}
+ /**
+ * merge() rejects an associative patch value where a list lives: the shapes
+ * do not line up, so merging would have to guess what the string keys mean.
+ * The current list survives untouched instead of being discarded.
+ *
+ * @ticket 65577
+ *
+ * @covers ::merge
+ */
+ public function test_merge_rejects_associative_patch_over_a_list() {
+ $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' );
+
+ $data = new WP_View_Config_Data(
+ array(
+ 'view_list' => array(
+ array(
+ 'slug' => 'all',
+ 'title' => 'All items',
+ ),
+ ),
+ )
+ );
+ $before = self::read_config( $data );
+
+ // The pre-7.1 slug-keyed shape, not the documented list of members.
+ $data->merge(
+ array(
+ 'view_list' => array(
+ 'published' => array( 'title' => 'Live' ),
+ ),
+ ),
+ 1
+ );
+
+ $this->assertSame( $before, self::read_config( $data ) );
+ }
+
+ /**
+ * merge() rejects a non-empty list patch value where an associative value
+ * lives, the mirror of the associative-over-list mismatch: the current map
+ * survives untouched instead of being discarded.
+ *
+ * @ticket 65577
+ *
+ * @covers ::merge
+ */
+ public function test_merge_rejects_list_patch_over_an_associative_value() {
+ $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' );
+
+ $data = new WP_View_Config_Data(
+ array(
+ 'default_view' => array(
+ 'sort' => array(
+ 'field' => 'title',
+ 'direction' => 'asc',
+ ),
+ ),
+ )
+ );
+ $before = self::read_config( $data );
+
+ $data->merge(
+ array(
+ 'default_view' => array(
+ 'sort' => array( 'title', 'asc' ),
+ ),
+ ),
+ 1
+ );
+
+ $this->assertSame( $before, self::read_config( $data ) );
+ }
+
+ /**
+ * An empty array under merge() is a no-op for both shapes: it has no
+ * members to merge, and being shape-ambiguous it must not reset the
+ * current value either. Clearing a list is spelled replace() with an
+ * empty list; resetting a key is spelled null.
+ *
+ * @ticket 65577
+ *
+ * @covers ::merge
+ */
+ public function test_merge_empty_array_is_a_noop() {
+ $data = new WP_View_Config_Data(
+ array(
+ 'default_view' => array(
+ 'filters' => array(
+ array(
+ 'field' => 'author',
+ 'operator' => 'isAny',
+ ),
+ ),
+ 'sort' => array(
+ 'field' => 'title',
+ 'direction' => 'asc',
+ ),
+ ),
+ )
+ );
+ $before = self::read_config( $data );
+
+ $data->merge(
+ array(
+ 'default_view' => array(
+ 'filters' => array(),
+ 'sort' => array(),
+ ),
+ ),
+ 1
+ );
+
+ $this->assertSame( $before, self::read_config( $data ) );
+ }
+
+ /**
+ * A nested null deletes just the leaf it names in every case, including
+ * inside a list member that did not exist yet: an appended member has no
+ * existing leaf to delete, so its nulls are dropped rather than stored
+ * (the same rationale as set() and the lists replace() swaps in).
+ *
+ * @ticket 65577
+ *
+ * @covers ::merge
+ */
+ public function test_merge_appended_member_drops_nested_nulls() {
+ $data = new WP_View_Config_Data(
+ array(
+ 'view_list' => array(
+ array(
+ 'slug' => 'all',
+ 'title' => 'All items',
+ ),
+ ),
+ )
+ );
+ $data->merge(
+ array(
+ 'view_list' => array(
+ array(
+ 'slug' => 'mine',
+ 'view' => array( 'filters' => null ),
+ ),
+ ),
+ ),
+ 1
+ );
+
+ $this->assertSame(
+ array(
+ 'view_list' => array(
+ array(
+ 'slug' => 'all',
+ 'title' => 'All items',
+ ),
+ array(
+ 'slug' => 'mine',
+ 'view' => array(),
+ ),
+ ),
+ ),
+ self::read_config( $data )
+ );
+ }
+
+ /**
+ * replace() rejects a non-empty list patch value where an associative value
+ * lives, the same rule merge() enforces: a list in the patch replaces the
+ * current list wholesale, but it cannot land where a map lives. The current
+ * map survives untouched instead of being discarded.
+ *
+ * @ticket 65577
+ *
+ * @covers ::replace
+ */
+ public function test_replace_rejects_list_patch_over_an_associative_value() {
+ $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' );
+
+ $data = new WP_View_Config_Data(
+ array(
+ 'default_view' => array(
+ 'sort' => array(
+ 'field' => 'title',
+ 'direction' => 'asc',
+ ),
+ ),
+ )
+ );
+ $before = self::read_config( $data );
+
+ $data->replace(
+ array(
+ 'default_view' => array(
+ 'sort' => array( 'title', 'asc' ),
+ ),
+ ),
+ 1
+ );
+
+ $this->assertSame( $before, self::read_config( $data ) );
+ }
+
+ /**
+ * An empty array is exempt from the shape guard, so replace() with an
+ * empty list stays the documented way to clear a list.
+ *
+ * @ticket 65577
+ *
+ * @covers ::replace
+ */
+ public function test_replace_empty_list_still_clears_a_list() {
+ $data = new WP_View_Config_Data(
+ array(
+ 'view_list' => array(
+ array(
+ 'slug' => 'all',
+ 'title' => 'All items',
+ ),
+ ),
+ )
+ );
+
+ $data->replace( array( 'view_list' => array() ), 1 );
+
+ $this->assertSame( array( 'view_list' => array() ), self::read_config( $data ) );
+ }
+
/**
* merge() treats a scalar list member as its own identity: an incoming
From 78e0b517ad73b95dbfb5f81f52f5ac0a375950d4 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Thu, 23 Jul 2026 20:14:22 +0000
Subject: [PATCH 017/149] Code Quality: Preserve `string[]` input type in
`wp_parse_list()` return.
This prevents unintentional widening of a `string[]` input to a `scalar[]` output, since strings are scalars.
Follow-up to r62797.
See #64898.
git-svn-id: https://develop.svn.wordpress.org/trunk@62835 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/functions.php | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index dca95d69b69fe..9a688b9866ce0 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -5034,9 +5034,13 @@ function wp_parse_args( $args, $defaults = array() ) {
* @since 5.1.0
*
* @param mixed[]|string $input_list List of values.
- * @return array Array of values. A string is split into a list, while an array
+ * @return array Array of scalar values. A string is split into a list, while an array
* keeps its keys, so the result is not necessarily a list.
- * @phpstan-return ( $input_list is string ? list : array )
+ * @phpstan-return (
+ * $input_list is string ? list : (
+ * $input_list is array ? array : array
+ * )
+ * )
*/
function wp_parse_list( $input_list ): array {
if ( ! is_array( $input_list ) ) {
From 4f0a5c704a04228ff924747c3f9ad3c2489aba16 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Thu, 23 Jul 2026 20:37:50 +0000
Subject: [PATCH 018/149] Code Quality: Document that `sanitize_key()` returns
`lowercase-string`.
This is a narrower PHPStan type compared to just `string`.
See #64898.
git-svn-id: https://develop.svn.wordpress.org/trunk@62836 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/formatting.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index 8f8af4a082196..74a28109b6536 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -2186,6 +2186,7 @@ function sanitize_user( $username, $strict = false ) {
*
* @param string $key String key.
* @return string Sanitized key.
+ * @phpstan-return lowercase-string
*/
function sanitize_key( $key ) {
$sanitized_key = '';
From 8f7c6bc192161722fcc4769aaacfcc350dd3a7a9 Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Thu, 23 Jul 2026 20:41:20 +0000
Subject: [PATCH 019/149] Docs: Correct the type for
`WP_Screen::$_screen_settings`.
This reflects the property's initial `null` state prior to initialization.
Follow-up to [55693], [61300].
Props Chouby, arkaprabhachowdhury, SergeyBiryukov.
Fixes #56607.
git-svn-id: https://develop.svn.wordpress.org/trunk@62837 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/class-wp-screen.php | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-screen.php b/src/wp-admin/includes/class-wp-screen.php
index ab7dfef77f67c..b0b689d412edb 100644
--- a/src/wp-admin/includes/class-wp-screen.php
+++ b/src/wp-admin/includes/class-wp-screen.php
@@ -89,7 +89,7 @@ final class WP_Screen {
* have a `$parent_base` of 'edit'.
*
* @since 3.3.0
- * @var string|null
+ * @var ?string
*/
public $parent_base;
@@ -99,7 +99,7 @@ final class WP_Screen {
* Some `$parent_file` values are 'edit.php?post_type=page', 'edit.php', and 'options-general.php'.
*
* @since 3.3.0
- * @var string|null
+ * @var ?string
*/
public $parent_file;
@@ -186,7 +186,7 @@ final class WP_Screen {
* Stores the 'screen_settings' section of screen options.
*
* @since 3.3.0
- * @var string
+ * @var ?string
*/
private $_screen_settings;
From 0029901b39ca9389114f1c59240504e99598daa7 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Thu, 23 Jul 2026 20:59:29 +0000
Subject: [PATCH 020/149] Administration: Use post title column as table header
in post lists.
The `select` column has been the `th` with row scope for post list tables since at least 2010. This results in a row name for screen readers that is based on the checkbox input and its label, which can be an empty value when that input is not available.
Move the `th` to the post title column, change the select column to `td`, and add `aria-label` to the `th` to provide a simplified row name to supporting screen readers.
Styles are additive, to retain support for custom list table implementations.
Developed in https://github.com/WordPress/wordpress-develop/pull/9761
Props afercia, abcd95, ozgursar, nikunj8866, joedolson.
Fixes #32892.
git-svn-id: https://develop.svn.wordpress.org/trunk@62838 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/common.css | 6 ++-
src/wp-admin/css/forms.css | 3 +-
src/wp-admin/css/list-tables.css | 37 +++++++++----
src/wp-admin/includes/class-wp-list-table.php | 53 +++++++++++++++----
.../class-wp-ms-themes-list-table.php | 8 +--
.../includes/class-wp-plugins-list-table.php | 6 +--
.../includes/class-wp-posts-list-table.php | 22 +++++++-
.../includes/class-wp-users-list-table.php | 14 +++--
8 files changed, 114 insertions(+), 35 deletions(-)
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index c2ab1c31b5c34..88c1c0d6ac0d1 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -514,6 +514,7 @@ code {
}
.widefat th,
+.widefat tbody td.check-column,
.widefat thead td,
.widefat tfoot td {
text-align: left;
@@ -521,6 +522,7 @@ code {
font-size: 14px;
}
+.widefat td.check-column input,
.widefat th input,
.updates-table td input,
.widefat thead td input,
@@ -536,12 +538,14 @@ code {
vertical-align: top;
}
-.widefat tbody th.check-column {
+.widefat tbody th.check-column,
+.widefat tbody td.check-column {
padding: 9px 0 22px;
}
.widefat thead td.check-column,
.widefat tbody th.check-column,
+.widefat tbody td.check-column,
.updates-table tbody td.check-column,
.widefat tfoot td.check-column {
padding: 11px 0 0 3px;
diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css
index c17d038c5d2c6..dd19e1ba8070a 100644
--- a/src/wp-admin/css/forms.css
+++ b/src/wp-admin/css/forms.css
@@ -1967,7 +1967,8 @@ table.form-table td .updated p {
margin-left: 0;
}
- .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) td.column-primary:not(.check-column) {
+ .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) td.column-primary:not(.check-column),
+ .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) th.column-primary:not(.check-column) {
display: table-cell;
}
diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
index 731168f97fc8d..46c2002e3e3a1 100644
--- a/src/wp-admin/css/list-tables.css
+++ b/src/wp-admin/css/list-tables.css
@@ -222,11 +222,13 @@
background-color: #fcf9e8;
}
-#the-comment-list .unapproved th.check-column {
+#the-comment-list .unapproved th.check-column,
+#the-comment-list .unapproved td.check-column {
border-left: 4px solid #d63638;
}
-#the-comment-list .unapproved th.check-column input {
+#the-comment-list .unapproved th.check-column input,
+#the-comment-list .unapproved td.check-column input {
margin-left: 4px;
}
@@ -1221,11 +1223,13 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before {
------------------------------------------------------------------------------*/
.plugins tbody th.check-column,
+.plugins tbody td.check-column,
.plugins tbody {
padding: 8px 0 0 2px;
}
-.plugins tbody th.check-column input[type=checkbox] {
+.plugins tbody th.check-column input[type=checkbox],
+.plugins tbody td.check-column input[type=checkbox] {
margin-top: 4px;
}
@@ -1235,7 +1239,8 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before {
.plugins thead td.check-column,
.plugins tfoot td.check-column,
-.plugins .inactive th.check-column {
+.plugins .inactive th.check-column,
+.plugins .inactive td.check-column {
padding-left: 6px;
}
@@ -1325,6 +1330,7 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before {
}
.plugins .active th.check-column,
+.plugins .active td.check-column,
.plugin-update-tr.active td {
border-left: 4px solid var(--wp-admin-theme-color);
}
@@ -1410,7 +1416,8 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before {
text-decoration: underline;
}
-.plugins tr.paused th.check-column {
+.plugins tr.paused th.check-column,
+.plugins tr.paused td.check-column {
border-left: 4px solid #b32d2e;
}
@@ -1909,7 +1916,8 @@ div.action-links,
}
.wp-list-table th.column-primary ~ th,
- .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column) {
+ .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column),
+ .wp-list-table tr:not(.inline-edit-row):not(.no-items) th.column-primary ~ td:not(.check-column) {
display: none;
}
@@ -1918,7 +1926,8 @@ div.action-links,
}
/* Checkboxes need to show */
- .wp-list-table tr th.check-column {
+ .wp-list-table tr th.check-column,
+ .wp-list-table tr td.check-column {
display: table-cell;
}
@@ -1936,11 +1945,13 @@ div.action-links,
width: auto !important; /* needs to override some columns that are more specifically targeted */
}
- .wp-list-table td.column-primary {
+ .wp-list-table td.column-primary,
+ .wp-list-table th.column-primary {
padding-right: 50px; /* space for toggle button */
}
- .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column) {
+ .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column),
+ .wp-list-table tr:not(.inline-edit-row):not(.no-items) th.column-primary ~ td:not(.check-column) {
padding: 3px 8px 3px 35%;
}
@@ -2269,12 +2280,14 @@ div.action-links,
}
.plugins tr.active + tr.inactive th.check-column,
+ .plugins tr.active + tr.inactive td.check-column,
.plugins tr.active + tr.inactive td.column-description,
.plugins .plugin-update-tr:before {
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.1);
}
.plugins tr.active + tr.inactive th.check-column,
+ .plugins tr.active + tr.inactive td.check-column,
.plugins tr.active + tr.inactive td {
border-top: none;
}
@@ -2309,13 +2322,15 @@ div.action-links,
line-height: 1.5;
}
- .plugins tbody th.check-column {
+ .plugins tbody th.check-column,
+ .plugins tbody td.check-column {
padding: 8px 0 0 5px;
}
.plugins thead td.check-column,
.plugins tfoot td.check-column,
- .plugins .inactive th.check-column {
+ .plugins .inactive th.check-column,
+ .plugins .inactive td.check-column {
padding-left: 9px;
}
diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php
index d32f08a438c60..b78af78abc03e 100644
--- a/src/wp-admin/includes/class-wp-list-table.php
+++ b/src/wp-admin/includes/class-wp-list-table.php
@@ -1767,6 +1767,26 @@ protected function column_default( $item, $column_name ) {}
*/
protected function column_cb( $item ) {}
+ /**
+ * Returns a clean, human-readable label for the primary column's row header.
+ *
+ * Used as the `aria-label` attribute value on the `` element,
+ * giving screen readers a concise cell name instead of computing it from
+ * the full cell content (which may include row action links, excerpts, etc.).
+ *
+ * Subclasses should override this method to return the item's primary
+ * identifier (e.g. post title, plugin name, username). Return an empty string
+ * to omit the attribute.
+ *
+ * @since 6.9.0
+ *
+ * @param object|array $item The current item.
+ * @return string The aria-label value, or an empty string.
+ */
+ protected function get_primary_column_aria_label( $item ) {
+ return '';
+ }
+
/**
* Generates the columns for a single row of the table.
*
@@ -1796,9 +1816,9 @@ protected function single_row_columns( $item ) {
$attributes = "class='$classes' $data";
if ( 'cb' === $column_name ) {
- echo ' ';
+ echo ' ';
echo $this->column_cb( $item );
- echo '';
+ echo ' ';
} elseif ( method_exists( $this, '_column_' . $column_name ) ) {
echo call_user_func(
array( $this, '_column_' . $column_name ),
@@ -1807,16 +1827,29 @@ protected function single_row_columns( $item ) {
$data,
$primary
);
- } elseif ( method_exists( $this, 'column_' . $column_name ) ) {
- echo "";
- echo call_user_func( array( $this, 'column_' . $column_name ), $item );
- echo $this->handle_row_actions( $item, $column_name, $primary );
- echo ' ';
} else {
- echo "";
- echo $this->column_default( $item, $column_name );
+ $is_primary = ( $primary === $column_name );
+ $tag = $is_primary ? 'th' : 'td';
+ $scope = $is_primary ? ' scope="row"' : '';
+
+ $aria_label = '';
+ if ( $is_primary ) {
+ $label = $this->get_primary_column_aria_label( $item );
+ if ( '' !== $label ) {
+ $aria_label = ' aria-label="' . esc_attr( $label ) . '"';
+ }
+ }
+
+ echo "<$tag $attributes$scope$aria_label>";
+
+ if ( method_exists( $this, 'column_' . $column_name ) ) {
+ echo call_user_func( array( $this, 'column_' . $column_name ), $item );
+ } else {
+ echo $this->column_default( $item, $column_name );
+ }
+
echo $this->handle_row_actions( $item, $column_name, $primary );
- echo ' ';
+ echo "$tag>";
}
}
}
diff --git a/src/wp-admin/includes/class-wp-ms-themes-list-table.php b/src/wp-admin/includes/class-wp-ms-themes-list-table.php
index a0fca2fd60fe4..81c35414d9053 100644
--- a/src/wp-admin/includes/class-wp-ms-themes-list-table.php
+++ b/src/wp-admin/includes/class-wp-ms-themes-list-table.php
@@ -940,11 +940,11 @@ public function single_row_columns( $item ) {
switch ( $column_name ) {
case 'cb':
- echo '';
+ echo ' ';
$this->column_cb( $item );
- echo '';
+ echo ' ';
break;
case 'name':
@@ -966,11 +966,11 @@ public function single_row_columns( $item ) {
}
}
- echo "" . $item->display( 'Name' ) . $active_theme_label . ' ';
+ echo "" . $item->display( 'Name' ) . $active_theme_label . ' ';
$this->column_name( $item );
- echo '
';
+ echo '';
break;
case 'description':
diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php
index 08b2e982e702f..d8945e103064e 100644
--- a/src/wp-admin/includes/class-wp-plugins-list-table.php
+++ b/src/wp-admin/includes/class-wp-plugins-list-table.php
@@ -1233,12 +1233,12 @@ public function single_row( $item ) {
switch ( $column_name ) {
case 'cb':
- echo "$checkbox ";
+ echo "$checkbox ";
break;
case 'name':
- echo "$plugin_name ";
+ echo "$plugin_name ";
echo $this->row_actions( $actions, true );
- echo '';
+ echo ' ';
break;
case 'description':
$classes = 'column-description desc';
diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php
index 7522f8561ba44..3795495d6d21a 100644
--- a/src/wp-admin/includes/class-wp-posts-list-table.php
+++ b/src/wp-admin/includes/class-wp-posts-list-table.php
@@ -1114,10 +1114,28 @@ public function column_cb( $item ) {
* @param string $primary
*/
protected function _column_title( $post, $classes, $data, $primary ) {
- echo '';
+ $aria_label = $this->get_primary_column_aria_label( $post );
+ $aria_attr = ( '' !== $aria_label ) ? ' aria-label="' . esc_attr( $aria_label ) . '"' : '';
+ echo ' ';
echo $this->column_title( $post );
echo $this->handle_row_actions( $post, 'title', $primary );
- echo '';
+ echo ' ';
+ }
+
+ /**
+ * Returns a clean label for the primary (title) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the post title as the row header name,
+ * preventing them from computing the name from the full cell content
+ * (which includes row action links, post states, and possibly an excerpt).
+ *
+ * @since 6.9.0
+ *
+ * @param WP_Post $item The current post object.
+ * @return string The post title, or 'no title' if no title.
+ */
+ protected function get_primary_column_aria_label( $item ) {
+ return isset( $item->post_title ) && ! empty( $item->post_title ) ? $item->post_title : __( 'no title' );
}
/**
diff --git a/src/wp-admin/includes/class-wp-users-list-table.php b/src/wp-admin/includes/class-wp-users-list-table.php
index 9a8709b438e05..dd54b200bafaf 100644
--- a/src/wp-admin/includes/class-wp-users-list-table.php
+++ b/src/wp-admin/includes/class-wp-users-list-table.php
@@ -563,9 +563,16 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0
$attributes = "class='$classes' $data";
if ( 'cb' === $column_name ) {
- $row .= "$checkbox ";
+ $row .= "$checkbox ";
} else {
- $row .= "";
+ $is_primary = ( $primary === $column_name );
+ $tag = $is_primary ? 'th' : 'td';
+ $scope = $is_primary ? ' scope="row"' : '';
+ $aria_label = '';
+ if ( $is_primary ) {
+ $aria_label = ' aria-label="' . esc_attr( $user_object->user_login ) . '"';
+ }
+ $row .= "<$tag $attributes$scope$aria_label>";
switch ( $column_name ) {
case 'username':
$row .= "$avatar $edit";
@@ -628,7 +635,8 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0
if ( $primary === $column_name ) {
$row .= $this->row_actions( $actions );
}
- $row .= ' ';
+ $tag = ( $primary === $column_name ) ? 'th' : 'td';
+ $row .= "$tag>";
}
}
$row .= ' ';
From 2d1c511ed5ea456ad77e5de19a4548a7caa44641 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Thu, 23 Jul 2026 21:41:40 +0000
Subject: [PATCH 021/149] Administration: Update bulk edit validity checks &
CSS.
Omitted to update the scripting validating bulk edit selections when changing the list table `th`. Add overlooked CSS to set post title `th` to `vertical-align: top`. Follow up to [62838].
Developed in https://github.com/WordPress/wordpress-develop/pull/12666
Props joedolson, tobiasbg.
Fixes #32892.
git-svn-id: https://develop.svn.wordpress.org/trunk@62839 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/_enqueues/admin/inline-edit-post.js | 4 ++--
src/wp-admin/css/common.css | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/js/_enqueues/admin/inline-edit-post.js b/src/js/_enqueues/admin/inline-edit-post.js
index 6e9f4e9f20503..36ffaf18ef778 100644
--- a/src/js/_enqueues/admin/inline-edit-post.js
+++ b/src/js/_enqueues/admin/inline-edit-post.js
@@ -191,7 +191,7 @@ window.wp = window.wp || {};
*/
setBulk : function(){
var te = '', type = this.type, c = true;
- var checkedPosts = $( 'tbody th.check-column input[type="checkbox"]:checked' );
+ var checkedPosts = $( 'tbody .check-column input[type="checkbox"]:checked' );
var categories = {};
this.revert();
@@ -207,7 +207,7 @@ window.wp = window.wp || {};
*
* Get the selected posts based on the checked checkboxes in the post table.
*/
- $( 'tbody th.check-column input[type="checkbox"]' ).each( function() {
+ $( 'tbody .check-column input[type="checkbox"]' ).each( function() {
// If the checkbox for a post is selected, add the post to the edit list.
if ( $(this).prop('checked') ) {
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index 88c1c0d6ac0d1..c286b8fa9ae0c 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -501,6 +501,7 @@ code {
border-bottom-width: 0;
}
+.widefat th,
.widefat td {
vertical-align: top;
}
From baf6c2bbd6cccab264501d6a0d52bd535dee86fb Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 24 Jul 2026 02:24:38 +0000
Subject: [PATCH 022/149] Media: Accessibility: Fix labels in scale tool.
The field for setting width in the media editor scale inputs had an incorrect label. Additionally, both the width and height labels had extraneous adjectives describing the fields. These are not necessary given the `fieldset` and `legend` providing context.
Change the 'width' label from 'scale height' to 'Width'. Change the 'height' label from 'scale height' to 'Height'.
Props csmcneill, nilambar, tusharaddweb, khokansardar, mukesh27, joedolson, afercia.
Fixes #65685.
git-svn-id: https://develop.svn.wordpress.org/trunk@62840 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/image-edit.php | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php
index a9ddc55e1bf96..a192ef0000c17 100644
--- a/src/wp-admin/includes/image-edit.php
+++ b/src/wp-admin/includes/image-edit.php
@@ -151,12 +151,17 @@ function wp_image_editor( $post_id, $msg = false ) {
×
-
+
+
+
, 'scale')" class="button button-primary">
From 58b28ff78834dbec24a29acc2def1753361aadcf Mon Sep 17 00:00:00 2001
From: Andrew Serong
Date: Fri, 24 Jul 2026 02:42:23 +0000
Subject: [PATCH 023/149] REST API: Enforce multisite upload limits when
sideloading media from a URL.
The attachments controller's URL-based creation path, `create_item_from_url()`, passed the downloaded file to `media_handle_sideload()` without running `check_upload_size()`. Unlike the multipart and raw-body upload paths, it did not enforce the multisite maximum file size or the site's upload space quota.
Run `check_upload_size()` on the downloaded file before sideloading it, for parity with the other upload paths, and remove the temporary file when the check fails.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12670
Follow-up to [62659].
Props andrewserong, ramonopoly.
Fixes #65517.
git-svn-id: https://develop.svn.wordpress.org/trunk@62841 602fd350-edb4-49c9-b593-d223f7449a82
---
.../class-wp-rest-attachments-controller.php | 8 +++
.../rest-api/rest-attachments-controller.php | 64 +++++++++++++++++++
2 files changed, 72 insertions(+)
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
index 609b133ca4cfc..6e06f1563c50c 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
@@ -641,6 +641,14 @@ protected function create_item_from_url( $request ) {
'tmp_name' => $tmp_file,
);
+ $size_check = self::check_upload_size( $file_array );
+ if ( is_wp_error( $size_check ) ) {
+ if ( file_exists( $tmp_file ) ) {
+ wp_delete_file( $tmp_file );
+ }
+ return $size_check;
+ }
+
$attachment_id = media_handle_sideload( $file_array, $post_id );
if ( is_wp_error( $attachment_id ) ) {
diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php
index 268ac019c3bc9..90899df850d47 100644
--- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php
@@ -5330,6 +5330,70 @@ public function test_create_item_from_url_returns_error_on_download_failure() {
$this->assertSame( 500, $response->get_status() );
}
+ /**
+ * Verifies that the URL sideload path enforces the multisite maximum file
+ * size, for parity with the multipart and raw-body upload paths.
+ *
+ * @ticket 65517
+ * @group multisite
+ * @group ms-required
+ *
+ * @covers WP_REST_Attachments_Controller::create_item_from_url
+ * @covers WP_REST_Attachments_Controller::check_upload_size
+ */
+ public function test_create_item_from_url_exceeds_multisite_max_filesize() {
+ $this->enable_client_side_media_processing();
+
+ wp_set_current_user( self::$superadmin_id );
+ update_site_option( 'fileupload_maxk', 1 );
+ update_site_option( 'upload_space_check_disabled', false );
+
+ // Ensure ample space is available so the file-size limit is what rejects it.
+ add_filter( 'pre_get_space_used', '__return_zero' );
+ add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_param( 'url', 'https://example.com/too-big.jpg' );
+ $request->set_param( 'generate_sub_sizes', false );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
+
+ $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 );
+ }
+
+ /**
+ * Verifies that the URL sideload path enforces the multisite site upload
+ * space quota, for parity with the multipart and raw-body upload paths.
+ *
+ * @ticket 65517
+ * @group multisite
+ * @group ms-required
+ *
+ * @covers WP_REST_Attachments_Controller::create_item_from_url
+ * @covers WP_REST_Attachments_Controller::check_upload_size
+ */
+ public function test_create_item_from_url_exceeds_multisite_site_upload_space() {
+ $this->enable_client_side_media_processing();
+
+ wp_set_current_user( self::$superadmin_id );
+ add_filter( 'get_space_allowed', '__return_zero' );
+ update_site_option( 'upload_space_check_disabled', false );
+
+ add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
+ $request->set_param( 'url', 'https://example.com/no-space.jpg' );
+ $request->set_param( 'generate_sub_sizes', false );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
+
+ $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 );
+ }
+
/**
* Verifies that a URL with no usable path bails with a 400 before any
* download is attempted, rather than handing an empty filename to the
From 53119d1e58a3ab1460c36ca719bf73829275381c Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Fri, 24 Jul 2026 12:40:43 +0000
Subject: [PATCH 024/149] Plugins: Remove redundant type casting in
`wp_filter_build_unique_id()`.
The `(object)` type casting was preceded by an `is_object()` check and can be safely removed.
The `isset()` language construct is enough to check for an array when detecting malformed callbacks, so the `(array)` type casting is not required.
Removing the type casting results in an additional performance improvement up to ~8% for the function.
Follow-up to [62408].
See #58291, #64898.
git-svn-id: https://develop.svn.wordpress.org/trunk@62842 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/plugin.php | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php
index 55459c0dd96c8..f64b584374c8e 100644
--- a/src/wp-includes/plugin.php
+++ b/src/wp-includes/plugin.php
@@ -1005,10 +1005,9 @@ function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string
}
if ( is_object( $callback ) ) {
- return (string) spl_object_id( (object) $callback );
+ return (string) spl_object_id( $callback );
}
- $callback = (array) $callback;
if ( ! isset( $callback[1] ) || ! is_string( $callback[1] ) ) {
return null;
}
From d88c95d9da1123ff6bf5d2042922b3c2cf8615db Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 24 Jul 2026 15:35:47 +0000
Subject: [PATCH 025/149] Administration: Improve UI when adding or removing
tags.
Improve AJAX interactions in the user interface when adding or removing tags by exposing the default `No tags found` row and removing bulk actions and search when the last tag is removed, and by showing bulk actions when tags are added, and incrementing item counts when adding or deleting.
Developed in https://github.com/WordPress/wordpress-develop/pull/8761
Props sainathpoojary, sirlouen, rishabhwp, yashjawale, wildworks, madhavishah01, khokansardar, joedolson.
Fixes #63372.
git-svn-id: https://develop.svn.wordpress.org/trunk@62843 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/_enqueues/admin/tags.js | 57 ++++++++++++++++++-
src/wp-admin/includes/class-wp-list-table.php | 14 +++--
2 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/src/js/_enqueues/admin/tags.js b/src/js/_enqueues/admin/tags.js
index ff7761adb8d3e..88e38b6926309 100644
--- a/src/js/_enqueues/admin/tags.js
+++ b/src/js/_enqueues/admin/tags.js
@@ -59,8 +59,10 @@ jQuery( function($) {
nextFocus = prevFocus;
}
}
-
- tr.fadeOut('normal', function(){ tr.remove(); });
+ tr.fadeOut('normal', function() {
+ tr.remove();
+ updateTableNavCount();
+ });
/**
* Removes the term from the parent box and the tag cloud.
@@ -73,7 +75,7 @@ jQuery( function($) {
$('a.tag-link-' + data.match(/tag_ID=(\d+)/)[1]).remove();
nextFocus.trigger( 'focus' );
message = wp.i18n.__( 'The selected tag has been deleted.' );
-
+
} else if ( '-1' == r ) {
message = wp.i18n.__( 'Sorry, you are not allowed to do that.' );
$('#ajax-response').empty().append('');
@@ -103,6 +105,53 @@ jQuery( function($) {
tr.find( ':input, a' ).prop( 'disabled', false ).removeAttr( 'tabindex' );
}
+ /**
+ * Updates the item count and table navigation after a tag is added or removed.
+ *
+ * Tags are added and removed client-side, but the item count, the `.tablenav`
+ * regions, the search box, and the empty-state row are otherwise only
+ * reconciled by PHP on a full page reload. This keeps them in sync.
+ *
+ * @param {string} [action] Pass 'add' when a tag was added. Any other value,
+ * including none, is treated as a removal.
+ *
+ * @return {void}
+ */
+ function updateTableNavCount( action ) {
+ var $displayingNum = $( '.tablenav-pages .displaying-num' ),
+ currentCount = parseInt( $displayingNum.first().text().replace( /[^0-9]/g, '' ), 10 ) || 0,
+ itemCount = ( 'add' === action ) ? currentCount + 1 : Math.max( currentCount - 1, 0 ),
+ formattedCount = itemCount.toLocaleString();
+
+ $displayingNum.text(
+ wp.i18n.sprintf(
+ /* translators: %s: Number of items. */
+ wp.i18n._n( '%s item', '%s items', itemCount ),
+ formattedCount
+ )
+ );
+
+ if ( itemCount < 1 ) {
+ // No tags remain: show the empty-state row and hide the navigation.
+ var $list = $( '#the-list' );
+
+ if ( ! $list.find( 'tr.no-items' ).length ) {
+ var colspan = $list.closest( 'table' ).find( 'thead > tr' ).first().children( ':not(.hidden)' ).length;
+ $list.append(
+ '' +
+ wp.i18n.__( 'No tags found.' ) +
+ ' '
+ );
+ }
+ $( '.tablenav > *' ).hide();
+ $( 'p.search-box' ).hide();
+ } else {
+ $( '#the-list' ).find( 'tr.no-items' ).remove();
+ $( '.tablenav > *' ).show();
+ $( 'p.search-box' ).show();
+ }
+ }
+
/**
* Adds a deletion confirmation when removing a tag.
*
@@ -192,6 +241,8 @@ jQuery( function($) {
}
$('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):visible, textarea:visible', form).val('');
+
+ updateTableNavCount( 'add' );
});
return false;
diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php
index b78af78abc03e..6ebae0e98eb86 100644
--- a/src/wp-admin/includes/class-wp-list-table.php
+++ b/src/wp-admin/includes/class-wp-list-table.php
@@ -1029,6 +1029,8 @@ protected function get_items_per_page( $option, $default_value = 20 ) {
*/
protected function pagination( $which ) {
if ( empty( $this->_pagination_args['total_items'] ) ) {
+ // translators: Number is a fixed value. This is default text when no items are found.
+ echo '' . __( '0 items' ) . '
';
return;
}
@@ -1685,12 +1687,16 @@ protected function display_tablenav( $which ) {
?>
- has_items() ) : ?>
-
+ has_items() ) {
+ $visibility = '';
+ }
+ ?>
+
bulk_actions( $which ); ?>
- extra_tablenav( $which );
$this->pagination( $which );
?>
From 5830b7cfcaa698f5136ffa0b19e8b555d800eef1 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 24 Jul 2026 15:47:25 +0000
Subject: [PATCH 026/149] Privacy: More accurate admin notices when saving.
When saving Privacy Policy page settings, show a notice that indicates there is no Privacy Policy page set instead of "Privacy Policy page updated successfully" if no page is selected. Differentiate between removing the current page and saving settings with no changes.
Developed in https://github.com/WordPress/wordpress-develop/pull/12247
Props anveshika, audrasjb, masteradhoc, micahele, adrianduffell, pedrofigueroa1989, joedolson.
Fixes #59276.
git-svn-id: https://develop.svn.wordpress.org/trunk@62844 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/options-privacy.php | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/wp-admin/options-privacy.php b/src/wp-admin/options-privacy.php
index 4205967acb3a8..739c8edab1cda 100644
--- a/src/wp-admin/options-privacy.php
+++ b/src/wp-admin/options-privacy.php
@@ -51,12 +51,15 @@ static function ( $body_class ) {
check_admin_referer( $action );
if ( 'set-privacy-page' === $action ) {
- $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0;
+ $previous_privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' );
+ $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0;
update_option( 'wp_page_for_privacy_policy', $privacy_policy_page_id );
- $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' );
+ $privacy_page_message_type = 'success';
if ( $privacy_policy_page_id ) {
+ $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' );
+
/*
* Don't always link to the menu customizer:
*
@@ -75,9 +78,16 @@ static function ( $body_class ) {
esc_url( add_query_arg( 'autofocus[panel]', 'nav_menus', admin_url( 'customize.php' ) ) )
);
}
+ } elseif ( $previous_privacy_policy_page_id ) {
+ // A previously set Privacy Policy page was cleared.
+ $privacy_page_updated_message = __( 'Privacy Policy page removed.' );
+ } else {
+ // No Privacy Policy page was set before, and none is set now.
+ $privacy_page_updated_message = __( 'No Privacy Policy page is currently set.' );
+ $privacy_page_message_type = 'info';
}
- add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, 'success' );
+ add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, $privacy_page_message_type );
} elseif ( 'create-privacy-page' === $action ) {
if ( ! class_exists( 'WP_Privacy_Policy_Content' ) ) {
From 6d1d03da1ec1266e5681e5cd8e36275798e728bb Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 24 Jul 2026 18:57:29 +0000
Subject: [PATCH 027/149] Privacy: Delete Privacy Policy setting when page
deleted.
If the privacy page is deleted but the setting is left intact, an admin_hook would fire on every admin screen attempting to notify about changes in the privacy policy. With the page deleted, this database read is never cached, since it returns no results.
Add a `before_delete_post` hook to reset setting if the post is deleted. Add a guard to reset the option on the privacy screen to cover edge cases.
Developed in https://github.com/WordPress/wordpress-develop/pull/11443, https://github.com/WordPress/wordpress-develop/pull/11520
Props johnjamesjacoby, masteradhoc, westonruter, nimeshatxecurify, mukesh27, joedolson.
Fixes #56694.
git-svn-id: https://develop.svn.wordpress.org/trunk@62845 602fd350-edb4-49c9-b593-d223f7449a82
---
.../class-wp-privacy-policy-content.php | 6 +
src/wp-includes/default-filters.php | 1 +
src/wp-includes/post.php | 16 ++
.../wpPrivacyResetPolicyPageForPost.php | 172 ++++++++++++++++++
4 files changed, 195 insertions(+)
create mode 100644 tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php
diff --git a/src/wp-admin/includes/class-wp-privacy-policy-content.php b/src/wp-admin/includes/class-wp-privacy-policy-content.php
index 2f7ec2108d22f..141f073cd260c 100644
--- a/src/wp-admin/includes/class-wp-privacy-policy-content.php
+++ b/src/wp-admin/includes/class-wp-privacy-policy-content.php
@@ -329,6 +329,12 @@ public static function notice( $post = null ) {
$current_screen = get_current_screen();
$policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' );
+ // If the privacy policy page has been deleted, reset the option and bail.
+ if ( $policy_page_id && ! get_post( $policy_page_id ) ) {
+ update_option( 'wp_page_for_privacy_policy', 0 );
+ return;
+ }
+
if ( 'post' !== $current_screen->base || $policy_page_id !== $post->ID ) {
return;
}
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index b2790c7d43ec9..66504d37ad84d 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -590,6 +590,7 @@
add_action( 'init', 'create_initial_post_types', 0 ); // Highest priority.
add_action( 'admin_menu', '_add_post_type_submenus' );
add_action( 'before_delete_post', '_reset_front_page_settings_for_post' );
+add_action( 'before_delete_post', '_reset_privacy_policy_page_for_post' );
add_action( 'wp_trash_post', '_reset_front_page_settings_for_post' );
add_action( 'change_locale', 'create_initial_post_types' );
diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php
index 3813176140bb4..da3abfbd7d61c 100644
--- a/src/wp-includes/post.php
+++ b/src/wp-includes/post.php
@@ -4052,6 +4052,22 @@ function _reset_front_page_settings_for_post( $post_id ) {
unstick_post( $post->ID );
}
+/**
+ * Resets the Privacy Policy page ID option when the Privacy Policy page
+ * is permanently deleted, to prevent uncached database queries for a
+ * non-existent page.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param int $post_id The ID of the post being deleted.
+ */
+function _reset_privacy_policy_page_for_post( int $post_id ): void {
+ if ( 'page' === get_post_type( $post_id ) && ( (int) get_option( 'wp_page_for_privacy_policy' ) === $post_id ) ) {
+ update_option( 'wp_page_for_privacy_policy', 0 );
+ }
+}
+
/**
* Moves a post or page to the Trash
*
diff --git a/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php
new file mode 100644
index 0000000000000..50ce04cb1bd44
--- /dev/null
+++ b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php
@@ -0,0 +1,172 @@
+post->create( array( 'post_type' => 'page' ) );
+ assert( is_int( $page_id ) );
+ $this->policy_page_id = $page_id;
+ update_option( 'wp_page_for_privacy_policy', $this->policy_page_id );
+ }
+
+ public function tear_down(): void {
+ delete_option( 'wp_page_for_privacy_policy' );
+ parent::tear_down();
+ }
+
+ /**
+ * Tests that trashing the Privacy Policy page does NOT reset the option,
+ * so that restoring from trash preserves the assignment.
+ *
+ * @ticket 56694
+ */
+ public function test_trashing_privacy_policy_page_does_not_reset_option(): void {
+ wp_trash_post( $this->policy_page_id );
+
+ $this->assertSame(
+ $this->policy_page_id,
+ (int) get_option( 'wp_page_for_privacy_policy' ),
+ 'Trashing the Privacy Policy page should not reset wp_page_for_privacy_policy.'
+ );
+ }
+
+ /**
+ * Tests that permanently deleting the Privacy Policy page resets the option to 0.
+ *
+ * @ticket 56694
+ */
+ public function test_deleting_privacy_policy_page_resets_option(): void {
+ wp_delete_post( $this->policy_page_id, true );
+
+ $this->assertSame( 0, (int) get_option( 'wp_page_for_privacy_policy' ) );
+ }
+
+ /**
+ * Tests that trashing a different page does not change the option.
+ *
+ * @ticket 56694
+ */
+ public function test_trashing_a_different_page_does_not_reset_option(): void {
+ $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+ $this->assertIsInt( $other_page_id );
+ wp_trash_post( $other_page_id );
+
+ $this->assertSame(
+ $this->policy_page_id,
+ (int) get_option( 'wp_page_for_privacy_policy' ),
+ 'Trashing an unrelated page should not reset wp_page_for_privacy_policy.'
+ );
+ }
+
+ /**
+ * Tests that deleting a non-page post type does not change the option.
+ *
+ * @ticket 56694
+ */
+ public function test_deleting_non_page_post_type_does_not_reset_option(): void {
+ $post_id = self::factory()->post->create( array( 'post_type' => 'post' ) );
+ $this->assertIsInt( $post_id );
+ wp_delete_post( $post_id, true );
+
+ $this->assertSame(
+ $this->policy_page_id,
+ (int) get_option( 'wp_page_for_privacy_policy' ),
+ 'Deleting a non-page post should not reset wp_page_for_privacy_policy.'
+ );
+ }
+
+ /**
+ * Tests that WP_Privacy_Policy_Content::notice() resets the option to 0
+ * when the stored ID points to a page that no longer exists.
+ *
+ * @ticket 56694
+ *
+ * @covers WP_Privacy_Policy_Content::notice
+ */
+ public function test_notice_self_heals_when_policy_page_does_not_exist(): void {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-privacy-policy-content.php';
+
+ update_option( 'wp_page_for_privacy_policy', 99999 );
+
+ $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ $this->assertIsInt( $user_id );
+ wp_set_current_user( $user_id );
+ if ( is_multisite() ) {
+ grant_super_admin( $user_id );
+ }
+ set_current_screen( 'post' );
+
+ $post = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) );
+ $this->assertInstanceOf( WP_Post::class, $post );
+ WP_Privacy_Policy_Content::notice( $post );
+
+ $this->assertSame(
+ 0,
+ (int) get_option( 'wp_page_for_privacy_policy' ),
+ 'notice() should reset the option to 0 when the stored page does not exist.'
+ );
+ }
+
+ /**
+ * Tests that _reset_privacy_policy_page_for_post() does not call
+ * update_option() when wp_page_for_privacy_policy is already 0.
+ *
+ * @ticket 56694
+ */
+ public function test_no_update_option_when_policy_page_already_zero(): void {
+ update_option( 'wp_page_for_privacy_policy', 0 );
+
+ $call_count = 0;
+ add_filter(
+ 'pre_update_option_wp_page_for_privacy_policy',
+ static function ( $value ) use ( &$call_count ) {
+ ++$call_count;
+ return $value;
+ }
+ );
+
+ $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+ $this->assertIsInt( $other_page_id );
+ wp_delete_post( $other_page_id, true );
+
+ $this->assertSame(
+ 0,
+ $call_count,
+ 'update_option() should not be called when wp_page_for_privacy_policy is already 0.'
+ );
+ }
+
+ /**
+ * Tests that untrashing the Privacy Policy page preserves the option,
+ * confirming the trash/restore cycle keeps the assignment intact.
+ *
+ * @ticket 56694
+ */
+ public function test_untrashing_privacy_policy_page_preserves_option(): void {
+ wp_trash_post( $this->policy_page_id );
+ wp_untrash_post( $this->policy_page_id );
+
+ $this->assertSame(
+ $this->policy_page_id,
+ (int) get_option( 'wp_page_for_privacy_policy' ),
+ 'Untrashing the Privacy Policy page should preserve wp_page_for_privacy_policy.'
+ );
+ }
+}
From c6d10e9441880426be4330e75457507c7ef40477 Mon Sep 17 00:00:00 2001
From: Adam Silverstein
Date: Fri, 24 Jul 2026 22:12:57 +0000
Subject: [PATCH 028/149] Editor: Sync the REST index preload field list with
core-data.
Sync the REST index preload field list in the post and site editors with the list the client requests, fixing a mismatch that left the preloaded response unused and logged a console warning on every editor load.
Follow-up to [61703], [62806].
Props wildworks, westonruter.
Fixes #65699.
git-svn-id: https://develop.svn.wordpress.org/trunk@62846 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/edit-form-blocks.php | 14 ++++++++------
src/wp-admin/site-editor.php | 12 +++++++-----
2 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php
index 44fd623fa5ad2..a6d198ff15343 100644
--- a/src/wp-admin/edit-form-blocks.php
+++ b/src/wp-admin/edit-form-blocks.php
@@ -85,19 +85,21 @@ static function ( $classes ) {
'/wp/v2/global-styles/' . WP_Theme_JSON_Resolver::get_user_global_styles_post_id() . '?context=' . $global_styles_endpoint_context,
// Used by getBlockPatternCategories in useBlockEditorSettings.
'/wp/v2/block-patterns/categories',
- // @see packages/core-data/src/entities.js
+ /**
+ * The preloaded URL must exactly match the request the client makes,
+ * including the field order.
+ * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entities.js
+ */
'/?_fields=' . implode(
',',
array(
'description',
'gmt_offset',
'home',
+ 'image_max_bit_depth',
'image_sizes',
'image_size_threshold',
- 'image_output_formats',
- 'jpeg_interlaced',
- 'png_interlaced',
- 'gif_interlaced',
+ 'image_strip_meta',
'name',
'site_icon',
'site_icon_url',
@@ -109,7 +111,7 @@ static function ( $classes ) {
'show_on_front',
)
),
- $paths[] = add_query_arg(
+ add_query_arg(
'slug',
// @link https://github.com/WordPress/gutenberg/blob/e093fefd041eb6cc4a4e7f67b92ab54fd75c8858/packages/core-data/src/private-selectors.ts#L244-L254
$template_lookup_slug,
diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php
index 9a8268c3392d7..4289f89f7102c 100644
--- a/src/wp-admin/site-editor.php
+++ b/src/wp-admin/site-editor.php
@@ -211,19 +211,21 @@ static function ( $classes ) {
array( '/wp/v2/settings', 'OPTIONS' ),
// Used by getBlockPatternCategories in useBlockEditorSettings.
'/wp/v2/block-patterns/categories',
- // @see packages/core-data/src/entities.js
+ /**
+ * The preloaded URL must exactly match the request the client makes,
+ * including the field order.
+ * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entities.js
+ */
'/?_fields=' . implode(
',',
array(
'description',
'gmt_offset',
'home',
+ 'image_max_bit_depth',
'image_sizes',
'image_size_threshold',
- 'image_output_formats',
- 'jpeg_interlaced',
- 'png_interlaced',
- 'gif_interlaced',
+ 'image_strip_meta',
'name',
'site_icon',
'site_icon_url',
From 749e622f7380686b71e9c9610161b38f8f7a6458 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 24 Jul 2026 22:31:58 +0000
Subject: [PATCH 029/149] Privacy: Fix type inconsistencies in data erasure
inline notices.
Additional messages can be inserted by plugins using the `wp_privacy_personal_data_erasers` filter, and are generated as list items (`li`) inside the notice markup. However, `li` was not targeted with notice-specific styling, and inherited the list item styles from the containing table.
Add additional styles targeting list items inside notices in list tables to equalize font sizes and styling.
Developed in https://github.com/WordPress/wordpress-develop/pull/12021
Props kimannwall, masteradhoc, joedolson.
Fixes #53611.
git-svn-id: https://develop.svn.wordpress.org/trunk@62847 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/common.css | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index c286b8fa9ae0c..e1e5ee3181330 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -523,6 +523,13 @@ code {
font-size: 14px;
}
+.widefat td .notice ul li,
+.widefat th .notice ul li {
+ font-size: 13px;
+ line-height: 1.54;
+ margin: 0.5em 0;
+}
+
.widefat td.check-column input,
.widefat th input,
.updates-table td input,
From 3300b8b51acdcd53df7dd5f2cbabe6593a475cf1 Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Sat, 25 Jul 2026 02:37:01 +0000
Subject: [PATCH 030/149] Administration: Make the Events widget no-JS notice
translatable.
The no-JavaScript fallback notice in `wp_print_community_events_markup()` wrapped its string in bare parentheses instead of `__()`, so it was never translated. Wrap it in `__()` to match the surrounding notices.
Follow-up to [56599].
Props hbhalodia, mukesh27, wildworks.
Fixes #65705.
git-svn-id: https://develop.svn.wordpress.org/trunk@62848 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/dashboard.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index a0b0ac6c77239..e74c31750513d 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -1377,7 +1377,7 @@ function wp_dashboard_events_news() {
* @since 4.8.0
*/
function wp_print_community_events_markup() {
- $community_events_notice = '' . ( 'This widget requires JavaScript.' ) . '
';
+ $community_events_notice = '' . __( 'This widget requires JavaScript.' ) . '
';
$community_events_notice .= '';
$community_events_notice .= '';
From 07b1f8b1d25db182d1ac4c2529d97e3d0cb04aea Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Sat, 25 Jul 2026 20:43:21 +0000
Subject: [PATCH 031/149] Docs: Fix typo in a comment in
`wp_dashboard_rss_control()`.
Follow-up to [6705].
Props mukesh27.
See #64896.
git-svn-id: https://develop.svn.wordpress.org/trunk@62849 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/dashboard.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index e74c31750513d..5fdbaf7a4fa40 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -1294,7 +1294,7 @@ function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) {
$widget_options[ $widget_id ] = wp_widget_rss_process( $_POST['widget-rss'][ $number ] );
$widget_options[ $widget_id ]['number'] = $number;
- // Title is optional. If black, fill it if possible.
+ // Title is optional. If blank, fill it if possible.
if ( ! $widget_options[ $widget_id ]['title'] && isset( $_POST['widget-rss'][ $number ]['title'] ) ) {
$rss = fetch_feed( $widget_options[ $widget_id ]['url'] );
if ( is_wp_error( $rss ) ) {
From 05d3b3cec4e761cbc0dc9b39d37116af2397950e Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Sun, 26 Jul 2026 15:04:51 +0000
Subject: [PATCH 032/149] KSES: Allow the autofocus attribute on dialog
elements.
First step to allow the usage of the autofocus attribute for native dialog elements. More work will follow to add a context-aware mechanism to KSES and allow the attribute on dialog element children.
Props westonruter, joedolson, mukesh27, afercia.
Fixes #65491.
git-svn-id: https://develop.svn.wordpress.org/trunk@62850 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/kses.php | 7 ++++---
tests/phpunit/tests/kses.php | 12 ++++++++++++
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index 46cd2c4576c03..d68021c3a8b30 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -158,9 +158,10 @@
'popover' => true,
),
'dialog' => array(
- 'closedby' => true,
- 'open' => true,
- 'popover' => true,
+ 'closedby' => true,
+ 'open' => true,
+ 'popover' => true,
+ 'autofocus' => true,
),
'dl' => array(),
'dt' => array(),
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index 1afd7e0884a64..f560d88403524 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -2183,6 +2183,18 @@ public function test_wp_kses_main_tag_standard_attributes() {
$this->assertEqualHTML( $html, wp_kses_post( $html ) );
}
+ /**
+ * Tests that the autofocus attribute is allowed on dialog elements and removed from other focusable elements.
+ *
+ * @ticket 65491
+ */
+ public function test_wp_kses_dialog_autofocus_attribute() {
+ $html = 'Content Button Some content
';
+ $expected = 'Content Button Some content
';
+
+ $this->assertEqualHTML( $expected, wp_kses_post( $html ) );
+ }
+
/**
* Test that Invoker Commands API attributes are preserved on buttons in post content.
*
From 17d5f5f09e9d78f6ef65355206c50601dcf637ae Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Sun, 26 Jul 2026 20:53:31 +0000
Subject: [PATCH 033/149] Media: Restore the label of the 'Filter by date'
select in the Media grid.
Fixes a typo after [62326] that prevented the 'Filter by date' select label from rendering.
Props joedolson, mirmpro, afercia.
Fixes #65711.
git-svn-id: https://develop.svn.wordpress.org/trunk@62851 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/media/views/attachments/browser.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js
index 26218ea2fa1ae..5533110d815f9 100644
--- a/src/js/media/views/attachments/browser.js
+++ b/src/js/media/views/attachments/browser.js
@@ -223,7 +223,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro
this.toolbar.set( 'filters', Filters.render() );
}
}
-
+
/*
* Feels odd to bring the global media library switcher into the Attachment browser view.
* Is this a use case for doAction( 'add:toolbar-items:attachments-browser', this.toolbar );
@@ -241,7 +241,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro
}).render() );
// DateFilter is a , a label element needs to be rendered before.
- this.toolbar.set( 'dateFilter', new wp.media.view.Label({
+ this.toolbar.set( 'dateFilterLabel', new wp.media.view.Label({
value: l10n.filterByDate,
attributes: {
'for': 'media-attachment-date-filters'
From ca28cbbad9bc6b4a585c89b32f02162f916fa1bb Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Sun, 26 Jul 2026 22:51:14 +0000
Subject: [PATCH 034/149] Tests: Improve On This Day dashboard widget tests.
Includes:
* Adding the missing `@ticket 65116` annotation to each test method, so the tests are discoverable via the ticket group as required by the core test conventions.
* Moving the repeated author user creation into shared `wpSetUpBeforeClass()` fixtures.
* Renaming the test class to match the `wp_dashboard_on_this_day()` function name.
No production code is touched and no test assertions change; this only reduces duplicated setup and the number of users created per test run.
Developed in https://github.com/WordPress/wordpress-develop/pull/12634.
Follow-up to [62681].
Props mukesh27, westonruter, joedolson, SergeyBiryukov.
See #65116, #64894.
git-svn-id: https://develop.svn.wordpress.org/trunk@62852 602fd350-edb4-49c9-b593-d223f7449a82
---
...OnThisDay.php => wpDashboardOnThisDay.php} | 127 +++++++++++-------
1 file changed, 77 insertions(+), 50 deletions(-)
rename tests/phpunit/tests/admin/{wpOnThisDay.php => wpDashboardOnThisDay.php} (79%)
diff --git a/tests/phpunit/tests/admin/wpOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
similarity index 79%
rename from tests/phpunit/tests/admin/wpOnThisDay.php
rename to tests/phpunit/tests/admin/wpDashboardOnThisDay.php
index 61aef6401683a..471324f9648ec 100644
--- a/tests/phpunit/tests/admin/wpOnThisDay.php
+++ b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
@@ -4,9 +4,32 @@
*
* @group admin
*/
-class Tests_Admin_wpOnThisDay extends WP_UnitTestCase {
+class Tests_Admin_wpDashboardOnThisDay extends WP_UnitTestCase {
+
+ protected static int $user_id;
+
+ protected static int $other_user_id;
+
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php';
+
+ self::$user_id = $factory->user->create(
+ array(
+ 'display_name' => 'Current Writer',
+ 'role' => 'author',
+ )
+ );
+ self::$other_user_id = $factory->user->create(
+ array(
+ 'display_name' => 'Guest Writer',
+ 'role' => 'author',
+ )
+ );
+ }
+
+ public static function wpTearDownAfterClass() {
+ self::delete_user( self::$user_id );
+ self::delete_user( self::$other_user_id );
}
public function tear_down() {
@@ -38,11 +61,11 @@ private function set_up_dashboard_screen() {
* @return int Post ID.
*/
private function create_matching_post(
- $author_id,
- $title = 'A memory from last year',
- $years_ago = 1,
- $time = '12:00:00'
- ) {
+ int $author_id,
+ string $title = 'A memory from last year',
+ int $years_ago = 1,
+ string $time = '12:00:00'
+ ): int {
$post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time;
return self::factory()->post->create(
@@ -64,7 +87,7 @@ private function create_matching_post(
* @param int $day_offset Number of days from today's prior-year calendar day.
* @return int Post ID.
*/
- private function create_nearby_post( $author_id, $title = 'Almost a memory', $day_offset = 1 ) {
+ private function create_nearby_post( int $author_id, string $title = 'Almost a memory', int $day_offset = 1 ): int {
$post_date = current_datetime()
->modify( '-1 year' )
->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' )
@@ -87,18 +110,19 @@ private function create_nearby_post( $author_id, $title = 'Almost a memory', $da
* @param string $date Date string.
* @return array Date query clause.
*/
- private static function get_date_query_clause( $date ) {
+ private static function get_date_query_clause( string $date ): array {
return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) );
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day_setup
*/
public function test_setup_always_registers_widget_and_postbox_class_filter() {
$this->set_up_dashboard_screen();
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
wp_dashboard_on_this_day_setup();
@@ -115,36 +139,38 @@ public function test_setup_always_registers_widget_and_postbox_class_filter() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day_postbox_classes
*/
public function test_postbox_classes_hides_widget_without_matching_posts() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
$this->assertContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) );
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day_postbox_classes
*/
public function test_postbox_classes_does_not_hide_widget_with_matching_posts() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
- $this->create_matching_post( $user_id );
+ wp_set_current_user( self::$user_id );
+ $this->create_matching_post( self::$user_id );
$this->assertNotContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) );
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day_setup
*/
public function test_setup_adds_dashboard_widget_with_matching_post_from_another_author() {
$this->set_up_dashboard_screen();
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- $other_user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
- $this->create_matching_post( $other_user_id );
+ wp_set_current_user( self::$user_id );
+ $this->create_matching_post( self::$other_user_id );
wp_dashboard_on_this_day_setup();
@@ -154,6 +180,8 @@ public function test_setup_adds_dashboard_widget_with_matching_post_from_another
}
/**
+ * @ticket 65116
+ *
* @covers ::_wp_dashboard_on_this_day_date_query_clause
*/
public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() {
@@ -176,6 +204,8 @@ public function test_get_date_query_clause_includes_february_29_on_february_28_i
}
/**
+ * @ticket 65116
+ *
* @covers ::_wp_dashboard_on_this_day_date_query_clause
*/
public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() {
@@ -191,6 +221,8 @@ public function test_get_date_query_clause_does_not_include_february_29_on_febru
}
/**
+ * @ticket 65116
+ *
* @covers ::_wp_dashboard_on_this_day_date_query_clause
*/
public function test_get_date_query_clause_matches_february_29_on_leap_day() {
@@ -206,11 +238,12 @@ public function test_get_date_query_clause_matches_february_29_on_leap_day() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_outputs_placeholder_without_matching_posts() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
ob_start();
wp_dashboard_on_this_day();
@@ -221,12 +254,13 @@ public function test_widget_outputs_placeholder_without_matching_posts() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_ignores_nearby_prior_year_posts() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
- $this->create_nearby_post( $user_id );
+ wp_set_current_user( self::$user_id );
+ $this->create_nearby_post( self::$user_id );
ob_start();
wp_dashboard_on_this_day();
@@ -237,12 +271,13 @@ public function test_widget_ignores_nearby_prior_year_posts() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_uses_singular_copy_for_a_single_post() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
- $this->create_matching_post( $user_id );
+ wp_set_current_user( self::$user_id );
+ $this->create_matching_post( self::$user_id );
ob_start();
wp_dashboard_on_this_day();
@@ -252,25 +287,15 @@ public function test_widget_uses_singular_copy_for_a_single_post() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_labels_posts_from_other_authors() {
- $user_id = self::factory()->user->create(
- array(
- 'display_name' => 'Current Writer',
- 'role' => 'author',
- )
- );
- $other_user_id = self::factory()->user->create(
- array(
- 'display_name' => 'Guest Writer',
- 'role' => 'author',
- )
- );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
- $this->create_matching_post( $user_id, 'A note from me' );
- $this->create_matching_post( $other_user_id, 'A note from someone else' );
+ $this->create_matching_post( self::$user_id, 'A note from me' );
+ $this->create_matching_post( self::$other_user_id, 'A note from someone else' );
ob_start();
wp_dashboard_on_this_day();
@@ -284,15 +309,16 @@ public function test_widget_labels_posts_from_other_authors() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_groups_posts_by_year() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
- $this->create_matching_post( $user_id, 'Pretending to meditate', 1, '12:00:00' );
- $this->create_matching_post( $user_id, 'Slow internet and good books', 1, '11:00:00' );
- $this->create_matching_post( $user_id, 'Late-night shipping log', 2, '12:00:00' );
+ $this->create_matching_post( self::$user_id, 'Pretending to meditate', 1, '12:00:00' );
+ $this->create_matching_post( self::$user_id, 'Slow internet and good books', 1, '11:00:00' );
+ $this->create_matching_post( self::$user_id, 'Late-night shipping log', 2, '12:00:00' );
ob_start();
wp_dashboard_on_this_day();
@@ -310,15 +336,16 @@ public function test_widget_groups_posts_by_year() {
}
/**
+ * @ticket 65116
+ *
* @covers ::wp_dashboard_on_this_day
* @covers ::wp_dashboard_on_this_day_get_posts
*/
public function test_widget_limits_posts_to_ten() {
- $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
- wp_set_current_user( $user_id );
+ wp_set_current_user( self::$user_id );
for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) {
- $this->create_matching_post( $user_id, 'Anniversary post ' . $years_ago, $years_ago );
+ $this->create_matching_post( self::$user_id, 'Anniversary post ' . $years_ago, $years_ago );
}
ob_start();
From c213aec5a6881cb8235497b2d26e78dd31c2de70 Mon Sep 17 00:00:00 2001
From: ramonopoly
Date: Mon, 27 Jul 2026 04:09:33 +0000
Subject: [PATCH 035/149] Theme JSON: Level block-level preset class
specificity with :where()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Block-level preset classes are now wrapped in `:where()` so they keep the same `0-1-0` specificity as root-level preset classes.
A palette defined at block level (via the `wp_theme_json_data_theme` filter or `settings.blocks`) emits `p.has-x-color` at `0-1-1` specificity, while a root-level palette emits `.has-x-color` at `0-1-0`. Responsive style states emit their rules at `0-1-0`. Both use `!important`, so specificity decides the winner: the block-level preset out-ranks the state rule, and a Desktop palette colour overrides the Mobile colour at every viewport width.
Wrapping the block selector in `:where()` contributes zero specificity while keeping the same scoping, so the preset ties the state rule and loses on source order, matching root-level behaviour. Palette values are unaffected because they flow through the scoped `--wp--preset--color--*` variable, not the class.
Developed in: [https://github.com/WordPress/wordpress-develop/pull/12705](https://github.com/WordPress/wordpress-develop/pull/12705)
Props awetz583, ramonopoly, andrewserong.
Fixes #65724.
git-svn-id: https://develop.svn.wordpress.org/trunk@62853 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-theme-json.php | 15 ++++++++++++---
tests/phpunit/tests/theme/wpThemeJson.php | 6 ++++--
2 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php
index 212c52023a621..82b8e89de509c 100644
--- a/src/wp-includes/class-wp-theme-json.php
+++ b/src/wp-includes/class-wp-theme-json.php
@@ -2432,7 +2432,7 @@ protected function get_layout_styles( $block_metadata, $options = array() ) {
* background: value;
* }
*
- * p.has-value-gradient-background {
+ * :where(p).has-value-gradient-background {
* background: value;
* }
*
@@ -2613,6 +2613,7 @@ static function ( $carry, $element ) {
* @since 5.8.0
* @since 5.9.0 Added the `$origins` parameter.
* @since 6.6.0 Added check for root CSS properties selector.
+ * @since 7.1.0 Wraps block-level preset classes in `:where()` to match root-level specificity.
*
* @param array $settings Settings to process.
* @param string $selector Selector wrapping the classes.
@@ -2639,8 +2640,16 @@ protected static function compute_preset_classes( $settings, $selector, $origins
$css_var = static::replace_slug_in_string( $preset_metadata['css_vars'], $slug );
$class_name = static::replace_slug_in_string( $class, $slug );
- // $selector is often empty, so we can save ourselves the `append_to_selector()` call then.
- $new_selector = '' === $selector ? $class_name : static::append_to_selector( $selector, $class_name );
+ /*
+ * $selector is often empty (root-level presets), in which case the
+ * bare class is used. For block-level presets the block selector is
+ * wrapped in `:where()` so the class keeps the same 0-1-0 specificity
+ * as a root-level preset. Without this, block-level palette rules
+ * (e.g. `p.has-x-color`) out-rank equally-important rules that also
+ * target the same property at 0-1-0, such as per-instance responsive
+ * state styles.
+ */
+ $new_selector = '' === $selector ? $class_name : ':where(' . $selector . ')' . $class_name;
$stylesheet .= static::to_ruleset(
$new_selector,
array(
diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php
index e61d618fed286..130bd5ae9d323 100644
--- a/tests/phpunit/tests/theme/wpThemeJson.php
+++ b/tests/phpunit/tests/theme/wpThemeJson.php
@@ -805,6 +805,7 @@ public function test_get_stylesheet_renders_enabled_protected_properties() {
/**
* @ticket 52991
* @ticket 54336
+ * @ticket 65724
*/
public function test_get_stylesheet_preset_classes_work_with_compounded_selectors() {
$theme_json = new WP_Theme_JSON(
@@ -828,7 +829,7 @@ public function test_get_stylesheet_preset_classes_work_with_compounded_selector
);
$this->assertSame(
- '.wp-block-heading.has-white-color{color: var(--wp--preset--color--white) !important;}.wp-block-heading.has-white-background-color{background-color: var(--wp--preset--color--white) !important;}.wp-block-heading.has-white-border-color{border-color: var(--wp--preset--color--white) !important;}',
+ ':where(.wp-block-heading).has-white-color{color: var(--wp--preset--color--white) !important;}:where(.wp-block-heading).has-white-background-color{background-color: var(--wp--preset--color--white) !important;}:where(.wp-block-heading).has-white-border-color{border-color: var(--wp--preset--color--white) !important;}',
$theme_json->get_stylesheet( array( 'presets' ) )
);
}
@@ -894,6 +895,7 @@ public function test_get_stylesheet_preset_css_vars_use_feature_selector() {
* @ticket 58550
* @ticket 60936
* @ticket 61165
+ * @ticket 65724
*/
public function test_get_stylesheet_preset_rules_come_after_block_rules() {
$theme_json = new WP_Theme_JSON(
@@ -926,7 +928,7 @@ public function test_get_stylesheet_preset_rules_come_after_block_rules() {
);
$styles = ':root :where(.wp-block-group){color: red;}';
- $presets = '.wp-block-group.has-grey-color{color: var(--wp--preset--color--grey) !important;}.wp-block-group.has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}.wp-block-group.has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}';
+ $presets = ':where(.wp-block-group).has-grey-color{color: var(--wp--preset--color--grey) !important;}:where(.wp-block-group).has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}:where(.wp-block-group).has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}';
$variables = '.wp-block-group{--wp--preset--color--grey: grey;}';
$all = $variables . $styles . $presets;
From 227acbc24c0a2862d5bcfe7150e9579bafaf84ff Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Mon, 27 Jul 2026 15:07:56 +0000
Subject: [PATCH 036/149] Filesystem API: Correct permissions comparison in
`WP_Filesystem_Direct::chmod()`.
The `& 0777` mask should be used to strip the filetype bits from the `fileperms( $file )` value so that the current permission bits can be used for comparison with the requested mode.
This commit removes the extra `| 0644` part of the mask, which altered the permission bits read from the file to a minimum floor before comparing them with the requested mode, resulting in an inaccurate comparison.
Follow-up to [61601].
Props jeremyfelt, softglaze, SergeyBiryukov.
Fixes #65695.
git-svn-id: https://develop.svn.wordpress.org/trunk@62854 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/class-wp-filesystem-direct.php | 2 +-
.../filesystem/wpFilesystemDirect/chmod.php | 32 +++++++++++++++++++
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php
index dad8e329b2421..33aa14ce47cb9 100644
--- a/src/wp-admin/includes/class-wp-filesystem-direct.php
+++ b/src/wp-admin/includes/class-wp-filesystem-direct.php
@@ -176,7 +176,7 @@ public function chmod( $file, $mode = false, $recursive = false ) {
}
if ( ! $recursive || ! $this->is_dir( $file ) ) {
- $current_mode = fileperms( $file ) & 0777 | 0644;
+ $current_mode = fileperms( $file ) & 0777;
/*
* fileperms() populates the stat cache, so have to clear it
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
index fef30631730e0..816614c7ff5e8 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
@@ -101,4 +101,36 @@ public function test_should_change_mode_recursively(): void {
'The mode was not applied to a file in a nested subdirectory.'
);
}
+
+ /**
+ * Tests that `WP_Filesystem_Direct::chmod()` uses the correct mask for comparing permissions.
+ *
+ * The `& 0777` mask should be used to strip the filetype bits from the `fileperms( $file )` value
+ * so that the current permission bits can be used for comparison with the requested mode.
+ *
+ * @ticket 65695
+ */
+ public function test_should_use_correct_permission_mask_for_files(): void {
+ if ( self::is_windows() ) {
+ $this->markTestSkipped( 'chmod() does not support octal modes on Windows.' );
+ }
+
+ $file = self::$file_structure['visible_file']['path'];
+
+ // Set the initial permissions.
+ self::$filesystem->chmod( $file, 0600 );
+
+ $this->assertTrue(
+ self::$filesystem->chmod( $file, 0644 ),
+ 'chmod() did not report success.'
+ );
+
+ clearstatcache();
+
+ $this->assertSame(
+ '644',
+ self::$filesystem->getchmod( $file ),
+ 'The requested mode was not applied to the file.'
+ );
+ }
}
From 940e7a357729f7683a11658fc841a42711321a5e Mon Sep 17 00:00:00 2001
From: John Blackbourn
Date: Mon, 27 Jul 2026 15:35:45 +0000
Subject: [PATCH 037/149] Build/Test tools: Remove tag triggers for all
workflows.
Tags in the `wordpress-develop` repo originate from the corresponding svn tag. This means the target commit for the tag must already exist on `wordpress-develop` and therefore its full workflow has already run, usually just minutes prior to the tag being pushed.
This change eliminates this unnecessary duplicate workflow run and reduces bottlenecks experienced during releases.
Developed in https://github.com/WordPress/wordpress-develop/pull/12717
Props lancewillett, johnbillion
See #64893
git-svn-id: https://develop.svn.wordpress.org/trunk@62855 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/coding-standards.yml | 5 -----
.github/workflows/end-to-end-tests.yml | 5 -----
.github/workflows/javascript-tests.yml | 5 -----
.github/workflows/javascript-type-checking.yml | 3 ---
.github/workflows/performance.yml | 5 -----
.github/workflows/php-compatibility.yml | 5 -----
.github/workflows/phpstan-static-analysis.yml | 3 ---
.github/workflows/phpunit-tests.yml | 3 ---
.github/workflows/test-build-processes.yml | 3 ---
.github/workflows/upgrade-develop-testing.yml | 3 ---
10 files changed, 40 deletions(-)
diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
index 195d4ce0883fc..809abd1da9230 100644
--- a/.github/workflows/coding-standards.yml
+++ b/.github/workflows/coding-standards.yml
@@ -8,11 +8,6 @@ on:
- trunk
- '3.[89]'
- '[4-9].[0-9]'
- tags:
- - '3.[89]'
- - '3.[89].[0-9]+'
- - '[4-9].[0-9]'
- - '[4-9].[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml
index 28092ae468b91..890314f25be49 100644
--- a/.github/workflows/end-to-end-tests.yml
+++ b/.github/workflows/end-to-end-tests.yml
@@ -7,11 +7,6 @@ on:
- trunk
- '5.[3-9]'
- '[6-9].[0-9]'
- tags:
- - '5.[3-9]'
- - '5.[3-9].[0-9]+'
- - '[6-9]+.[0-9]'
- - '[6-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml
index df5aab9288c49..8d96c826d7a57 100644
--- a/.github/workflows/javascript-tests.yml
+++ b/.github/workflows/javascript-tests.yml
@@ -7,11 +7,6 @@ on:
- trunk
- '3.[89]'
- '[4-9].[0-9]'
- tags:
- - '3.[89]'
- - '3.[89].[0-9]+'
- - '[4-9].[0-9]'
- - '[4-9].[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/javascript-type-checking.yml b/.github/workflows/javascript-type-checking.yml
index 4327a815d620c..2572bb4c2fe36 100644
--- a/.github/workflows/javascript-type-checking.yml
+++ b/.github/workflows/javascript-type-checking.yml
@@ -6,9 +6,6 @@ on:
branches:
- trunk
- '[7-9].[0-9]'
- tags:
- - '[7-9].[0-9]'
- - '[7-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
index d64bd37c91f7b..3690e6822512f 100644
--- a/.github/workflows/performance.yml
+++ b/.github/workflows/performance.yml
@@ -7,11 +7,6 @@ on:
- trunk
- '6.[2-9]'
- '[7-9].[0-9]'
- tags:
- - '6.[2-9]'
- - '6.[2-9].[0-9]+'
- - '[7-9].[0-9]'
- - '[7-9].[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml
index 48c881a3df485..9e30670cdae7e 100644
--- a/.github/workflows/php-compatibility.yml
+++ b/.github/workflows/php-compatibility.yml
@@ -7,11 +7,6 @@ on:
- trunk
- '5.[5-9]'
- '[6-9].[0-9]'
- tags:
- - '5.[5-9]'
- - '5.[5-9].[0-9]+'
- - '[6-9].[0-9]'
- - '[6-9].[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml
index e09fbc44ce9c2..62061a83a2688 100644
--- a/.github/workflows/phpstan-static-analysis.yml
+++ b/.github/workflows/phpstan-static-analysis.yml
@@ -6,9 +6,6 @@ on:
branches:
- trunk
- '[7-9].[0-9]'
- tags:
- - '[7-9].[0-9]'
- - '[7-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml
index 81afd70449141..9bac013d94493 100644
--- a/.github/workflows/phpunit-tests.yml
+++ b/.github/workflows/phpunit-tests.yml
@@ -6,9 +6,6 @@ on:
- trunk
- '3.[7-9]'
- '[4-9].[0-9]'
- tags:
- - '[0-9]+.[0-9]'
- - '[0-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/test-build-processes.yml b/.github/workflows/test-build-processes.yml
index f52e31177b27c..d344c5371bad5 100644
--- a/.github/workflows/test-build-processes.yml
+++ b/.github/workflows/test-build-processes.yml
@@ -6,9 +6,6 @@ on:
- trunk
- '3.[7-9]'
- '[4-9].[0-9]'
- tags:
- - '[0-9]+.[0-9]'
- - '[0-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
diff --git a/.github/workflows/upgrade-develop-testing.yml b/.github/workflows/upgrade-develop-testing.yml
index 54623834c4bc4..831b470beb418 100644
--- a/.github/workflows/upgrade-develop-testing.yml
+++ b/.github/workflows/upgrade-develop-testing.yml
@@ -9,9 +9,6 @@ on:
- trunk
- '6.[8-9]'
- '[7-9].[0-9]'
- tags:
- - '[0-9]+.[0-9]'
- - '[0-9]+.[0-9].[0-9]+'
paths:
# Any change to a source PHP file should run checks.
- 'src/**.php'
From d0a30472b0d256b9d68a804f515389ffb8809713 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 20:50:55 +0000
Subject: [PATCH 038/149] Build/Test Tools: Retry Docker image pulls in PHPUnit
workflows.
Transient Docker registry failures (Docker Hub / GHCR pull timeouts and "premature close" errors) intermittently fail the PHPUnit jobs. Wrap the image pulls in a bounded retry with backoff.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12703
Props barry, adrianmoldovanwp
Fixes #65722
git-svn-id: https://develop.svn.wordpress.org/trunk@62857 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/reusable-phpunit-tests-v3.yml | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml
index dfe678e82a0da..d22eb8f5aea84 100644
--- a/.github/workflows/reusable-phpunit-tests-v3.yml
+++ b/.github/workflows/reusable-phpunit-tests-v3.yml
@@ -180,6 +180,22 @@ jobs:
run: |
docker -v
+ - name: Pull Docker images (with retry)
+ run: |
+ for attempt in 1 2 3; do
+ if npm run env:pull; then
+ break
+ fi
+
+ if [ "$attempt" -eq 3 ]; then
+ echo "npm run env:pull failed after $attempt attempts."
+ exit 1
+ fi
+
+ echo "npm run env:pull failed (attempt $attempt); retrying..."
+ sleep $(( attempt * 10 ))
+ done
+
- name: Start Docker environment
run: |
npm run env:start
From 51741671a9e87ed406600290c33d8a767308ce39 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 21:28:41 +0000
Subject: [PATCH 039/149] Build/Test Tools: Trim the PHPUnit matrix to boundary
PHP versions.
Test only the highest and lowest supported PHP version of each major (7.4, 8.0, 8.5) on push and pull request events; the weekly scheduled run still exercises every supported version.
Version-specific failures cluster at the boundaries of each major, so the intermediate minors add job count without adding coverage. Cuts base PHPUnit combinations from roughly 168 to 72 per CI run for both time and cost savings, and a much better developer experience in core GitHub repos.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12719
Props johnbillion, adrianmoldovanwp
Fixes #65736, see #64083
git-svn-id: https://develop.svn.wordpress.org/trunk@62858 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/phpunit-tests.yml | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml
index 9bac013d94493..d7dd363ca066a 100644
--- a/.github/workflows/phpunit-tests.yml
+++ b/.github/workflows/phpunit-tests.yml
@@ -71,7 +71,8 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu-24.04 ]
- php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ]
+ # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major.
+ php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }}
db-type: [ 'mysql' ]
db-version: [ '5.7', '8.0', '8.4', '9.7' ]
tests-domain: [ 'example.org' ]
@@ -150,7 +151,8 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu-24.04 ]
- php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ]
+ # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major.
+ php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }}
db-type: [ 'mariadb' ]
db-version: [ '5.5', '10.3', '10.5', '10.6', '10.11', '11.4', '11.8' ]
multisite: [ false, true ]
@@ -204,7 +206,8 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu-24.04 ]
- php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ]
+ # The scheduled run tests every supported PHP version. Other events test the highest and lowest of each major.
+ php: ${{ github.event_name == 'schedule' && fromJSON('["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]') || fromJSON('["7.4","8.0","8.5"]') }}
db-type: [ 'mysql', 'mariadb' ]
db-version: [ '12.1' ]
multisite: [ false, true ]
From d9f0c4208dfee20837276e362cbd828d180ba9b8 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 21:45:30 +0000
Subject: [PATCH 040/149] Build/Test Tools: Fetch and verify the Gutenberg
build once per run.
Each matrix job independently streams the same ~36 MB Gutenberg archive from the GitHub Container Registry, so any one flaky stream can redden a run. Add a reusable prepare-gutenberg workflow that fetches and verifies the build once (SHA-256 and size) and uploads it as a run artifact; the PHPUnit callers consume the shared artifact and no longer contact the registry. The prep job runs only when a consumer runs, so runs that skip PHPUnit do not pay for it. Test coverage is unchanged.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12701
Props adrianmoldovanwp, lucasbustamante
Fixes #65721
git-svn-id: https://develop.svn.wordpress.org/trunk@62859 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/phpunit-tests.yml | 32 +-
.../workflows/reusable-phpunit-tests-v3.yml | 25 ++
.github/workflows/test-coverage.yml | 20 +-
tools/gutenberg/download.js | 362 ++++++++++++++----
tools/gutenberg/utils.js | 153 ++++++--
5 files changed, 481 insertions(+), 111 deletions(-)
diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml
index d7dd363ca066a..a9be5d8565fe3 100644
--- a/.github/workflows/phpunit-tests.yml
+++ b/.github/workflows/phpunit-tests.yml
@@ -34,6 +34,7 @@ on:
# Confirm any changes to relevant workflow files.
- '.github/workflows/phpunit-tests.yml'
- '.github/workflows/reusable-phpunit-tests-*.yml'
+ - '.github/workflows/reusable-prepare-gutenberg.yml'
workflow_dispatch:
# Once weekly On Sundays at 00:00 UTC.
schedule:
@@ -51,6 +52,20 @@ concurrency:
permissions: {}
jobs:
+ # Downloads and verifies the Gutenberg build once for all PHPUnit jobs.
+ #
+ # This condition is the union of the conditions on the jobs that need it, reduced.
+ # The org matrices require `WordPress/wordpress-develop` or a pull request, and the
+ # fork matrix requires a pull request, so `wordpress-develop` or a pull request
+ # covers every case. Keep it in step with those jobs: a narrower condition orphans
+ # them, because a job that needs a skipped job is skipped too, and a broader one
+ # downloads a build that nothing consumes.
+ prepare-gutenberg:
+ uses: ./.github/workflows/reusable-prepare-gutenberg.yml
+ permissions:
+ contents: read
+ if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }}
+
#
# Creates a PHPUnit test job for each PHP/MySQL combination.
#
@@ -61,6 +76,7 @@ jobs:
test-with-mysql:
name: PHP ${{ matrix.php }}
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
secrets:
@@ -128,6 +144,8 @@ jobs:
phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }}
tests-domain: ${{ matrix.tests-domain }}
report: ${{ matrix.report || false }}
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
#
# Creates a PHPUnit test job for each PHP/MariaDB combination.
@@ -141,6 +159,7 @@ jobs:
test-with-mariadb:
name: PHP ${{ matrix.php }}
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
secrets:
@@ -181,6 +200,8 @@ jobs:
memcached: ${{ matrix.memcached }}
phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }}
report: false
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
#
# Creates PHPUnit test jobs to test MariaDB and MySQL innovation releases.
@@ -196,6 +217,7 @@ jobs:
test-innovation-releases:
name: PHP ${{ matrix.php }}
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
secrets:
@@ -228,6 +250,8 @@ jobs:
memcached: ${{ matrix.memcached }}
phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }}
report: false
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
#
# Runs the HTML API test group.
@@ -240,6 +264,7 @@ jobs:
html-api-test-groups:
name: ${{ matrix.label }}
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
secrets:
@@ -260,6 +285,8 @@ jobs:
db-type: ${{ matrix.db-type }}
db-version: ${{ matrix.db-version }}
phpunit-test-groups: ${{ matrix.phpunit-test-groups }}
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
#
# Runs unit tests for forks.
@@ -271,6 +298,7 @@ jobs:
limited-matrix-for-forks:
name: PHP ${{ matrix.php }}
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
secrets:
@@ -320,6 +348,8 @@ jobs:
db-type: ${{ matrix.db-type }}
memcached: ${{ matrix.memcached || false }}
phpunit-test-groups: ${{ matrix.phpunit-test-groups || '' }}
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
slack-notifications:
name: Slack Notifications
@@ -327,7 +357,7 @@ jobs:
permissions:
actions: read
contents: read
- needs: [ test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ]
+ needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ]
if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }}
with:
calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }}
diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml
index d22eb8f5aea84..8c9a2aa9703c7 100644
--- a/.github/workflows/reusable-phpunit-tests-v3.yml
+++ b/.github/workflows/reusable-phpunit-tests-v3.yml
@@ -72,6 +72,16 @@ on:
required: false
type: boolean
default: false
+ gutenberg-artifact:
+ description: 'The name of a same-workflow artifact containing the prepared Gutenberg build. Optional: callers that omit it download Gutenberg per job.'
+ required: false
+ type: string
+ default: ''
+ gutenberg-sha:
+ description: 'The immutable Gutenberg source SHA verified by the calling workflow.'
+ required: false
+ type: string
+ default: ''
secrets:
CODECOV_TOKEN:
description: 'The Codecov token required for uploading reports.'
@@ -101,6 +111,7 @@ jobs:
# Performs the following steps:
# - Sets environment variables.
# - Checks out the repository.
+ # - Downloads the prepared Gutenberg build provided by the calling workflow.
# - Sets up Node.js.
# - Sets up PHP.
# - Installs Composer dependencies.
@@ -135,6 +146,17 @@ jobs:
show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
persist-credentials: false
+ # Branches >= 5.9 call this workflow at @trunk without the producer job, so they
+ # pass no artifact. They skip this step and fall back to a per-job download.
+ - name: Download prepared Gutenberg build
+ if: inputs.gutenberg-artifact != ''
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ # Without a run ID, this action reads only from the caller's current workflow run.
+ name: ${{ inputs.gutenberg-artifact }}
+ path: gutenberg
+ digest-mismatch: error
+
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -166,6 +188,9 @@ jobs:
- name: Build WordPress
run: npm run build:dev
+ env:
+ # The producer resolved this once. Matrix jobs must not re-resolve mutable GHCR tags.
+ GUTENBERG_EXPECTED_SHA: ${{ inputs.gutenberg-sha }}
- name: General debug information
run: |
diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml
index d6fe09904a925..ffc650ec370fb 100644
--- a/.github/workflows/test-coverage.yml
+++ b/.github/workflows/test-coverage.yml
@@ -8,6 +8,10 @@ on:
paths:
- '.github/workflows/test-coverage.yml'
- '.github/workflows/reusable-phpunit-tests-v3.yml'
+ - '.github/workflows/reusable-prepare-gutenberg.yml'
+ - 'tools/gutenberg/**'
+ - 'package*.json'
+ - '.nvmrc'
- 'docker-compose.yml'
- 'phpunit.xml.dist'
- 'tests/phpunit/multisite.xml'
@@ -17,6 +21,10 @@ on:
paths:
- '.github/workflows/test-coverage.yml'
- '.github/workflows/reusable-phpunit-tests-v3.yml'
+ - '.github/workflows/reusable-prepare-gutenberg.yml'
+ - 'tools/gutenberg/**'
+ - 'package*.json'
+ - '.nvmrc'
- 'docker-compose.yml'
- 'phpunit.xml.dist'
- 'tests/phpunit/multisite.xml'
@@ -42,12 +50,20 @@ env:
PUPPETEER_SKIP_DOWNLOAD: true
jobs:
+ # Downloads and verifies the Gutenberg build once for all coverage jobs.
+ prepare-gutenberg:
+ uses: ./.github/workflows/reusable-prepare-gutenberg.yml
+ permissions:
+ contents: read
+ if: ${{ github.repository == 'WordPress/wordpress-develop' }}
+
#
# Creates a PHPUnit test jobs for generating code coverage reports.
#
test-coverage-report:
name: ${{ matrix.multisite && 'Multisite' || 'Single site' }} report
uses: ./.github/workflows/reusable-phpunit-tests-v3.yml
+ needs: prepare-gutenberg
permissions:
contents: read
if: ${{ github.repository == 'WordPress/wordpress-develop' }}
@@ -60,6 +76,8 @@ jobs:
php: '8.3'
multisite: ${{ matrix.multisite }}
coverage-report: ${{ matrix.coverage-report }}
+ gutenberg-artifact: gutenberg-build
+ gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -69,7 +87,7 @@ jobs:
permissions:
actions: read
contents: read
- needs: [ test-coverage-report ]
+ needs: [ prepare-gutenberg, test-coverage-report ]
if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }}
with:
calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }}
diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js
index fd76c6c7a7836..97df476161412 100644
--- a/tools/gutenberg/download.js
+++ b/tools/gutenberg/download.js
@@ -1,11 +1,14 @@
#!/usr/bin/env node
+/* global AbortSignal */
/**
* Download Gutenberg Repository Script.
*
* This script downloads a pre-built Gutenberg tar.gz artifact from the GitHub
- * Container Registry and extracts it into the ./gutenberg directory. Any
- * existing gutenberg directory is removed before extraction.
+ * Container Registry and extracts it into the ./gutenberg directory. The
+ * archive is downloaded and verified (SHA-256 and manifest size) before
+ * extraction; the existing gutenberg directory is then removed and replaced
+ * with the extracted contents.
*
* The artifact is identified by the "gutenberg.sha" value in the root
* package.json, which is used as the OCI tag for the gutenberg-wp-develop-build
@@ -20,10 +23,13 @@
*/
const { spawn } = require( 'child_process' );
+const crypto = require( 'crypto' );
const fs = require( 'fs' );
+const os = require( 'os' );
+const path = require( 'path' );
const { Readable } = require( 'stream' );
const { pipeline } = require( 'stream/promises' );
-const zlib = require( 'zlib' );
+const { Transform } = require( 'stream' );
const {
gutenbergDir,
readGutenbergConfig,
@@ -31,6 +37,257 @@ const {
fetchManifest,
} = require( './utils' );
+const MAX_DOWNLOAD_ATTEMPTS = 3;
+const RETRY_DELAY_MS = 2000;
+const DOWNLOAD_TIMEOUT_MS = 120000;
+
+/**
+ * Convert bytes into a readable string for download diagnostics.
+ *
+ * @param {number} bytes Number of bytes.
+ * @return {string} Formatted byte count.
+ */
+function formatBytes( bytes ) {
+ return `${ ( bytes / 1024 / 1024 ).toFixed( 2 ) } MiB (${ bytes } bytes)`;
+}
+
+/**
+ * Wait before retrying a failed download.
+ *
+ * @param {number} milliseconds Time to wait in milliseconds.
+ * @return {Promise} Resolves after the requested delay.
+ */
+function delay( milliseconds ) {
+ return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) );
+}
+
+/**
+ * Create an error that retains the HTTP status code for retry decisions.
+ *
+ * @param {string} message Error message.
+ * @param {number} status HTTP status code.
+ * @return {Error & { status: number }} Error with status code.
+ */
+function createHttpError( message, status ) {
+ const error = /** @type {Error & { status: number }} */ ( new Error( message ) );
+ error.status = status;
+ return error;
+}
+
+/**
+ * Determine whether a failed download might succeed on a later attempt.
+ *
+ * @param {Error & { status?: number }} error Download error.
+ * @return {boolean} Whether the error is retryable.
+ */
+function isRetryableDownloadError( error ) {
+ return ! error.status || error.status === 408 || error.status === 429 || error.status >= 500;
+}
+
+/**
+ * Extract a SHA-256 hash from an OCI layer digest.
+ *
+ * @param {string} digest OCI layer digest.
+ * @return {string} Expected SHA-256 hash.
+ * @throws {Error} If the digest is not a SHA-256 digest.
+ */
+function getExpectedSha256( digest ) {
+ const match = /^sha256:([a-f0-9]{64})$/i.exec( digest );
+ if ( ! match ) {
+ throw new Error( `Unsupported OCI layer digest: ${ digest }` );
+ }
+
+ return match[ 1 ].toLowerCase();
+}
+
+/**
+ * Download a blob to disk and verify its SHA-256 digest and byte count.
+ *
+ * @param {string} url Blob URL.
+ * @param {string} token Bearer token for GHCR.
+ * @param {string} digest OCI layer digest.
+ * @param {number|undefined} expectedSize Expected layer size from the manifest.
+ * @param {string} destination Path where the compressed blob is written.
+ * @return {Promise} Resolves after the download is verified.
+ */
+async function downloadAndVerifyBlob( url, token, digest, expectedSize, destination ) {
+ const expectedSha256 = getExpectedSha256( digest );
+ const response = await fetch( url, {
+ headers: {
+ Authorization: `Bearer ${ token }`,
+ },
+ signal: AbortSignal.timeout( DOWNLOAD_TIMEOUT_MS ),
+ } );
+
+ console.log(
+ ` Response: ${ response.status } ${ response.statusText } from ${ new URL( response.url ).hostname }`
+ );
+
+ if ( ! response.ok ) {
+ throw createHttpError(
+ `Failed to download blob: ${ response.status } ${ response.statusText }`,
+ response.status
+ );
+ }
+
+ if ( ! response.body ) {
+ throw new Error( 'Blob response has no body' );
+ }
+
+ const contentLength = Number( response.headers.get( 'content-length' ) );
+ if ( Number.isFinite( contentLength ) && contentLength > 0 ) {
+ console.log( ` Content-Length: ${ formatBytes( contentLength ) }` );
+ }
+ if ( expectedSize ) {
+ console.log( ` Manifest size: ${ formatBytes( expectedSize ) }` );
+ }
+
+ let downloadedBytes = 0;
+ const hash = crypto.createHash( 'sha256' );
+ const meter = new Transform( {
+ transform( chunk, _encoding, callback ) {
+ downloadedBytes += chunk.length;
+ hash.update( chunk );
+ callback( null, chunk );
+ },
+ } );
+
+ try {
+ await pipeline(
+ Readable.fromWeb(
+ /** @type {import('stream/web').ReadableStream} */ ( response.body )
+ ),
+ meter,
+ fs.createWriteStream( destination )
+ );
+ } catch ( error ) {
+ throw new Error(
+ `Download interrupted after ${ formatBytes( downloadedBytes ) }: ${ /** @type {Error} */ ( error ).message }`
+ );
+ }
+
+ if ( expectedSize && downloadedBytes !== expectedSize ) {
+ throw new Error(
+ `Downloaded ${ formatBytes( downloadedBytes ) }, but manifest size was ${ formatBytes( expectedSize ) }`
+ );
+ }
+
+ const actualSha256 = hash.digest( 'hex' );
+ if ( actualSha256 !== expectedSha256 ) {
+ throw new Error(
+ `SHA-256 mismatch: expected ${ expectedSha256 } but received ${ actualSha256 }`
+ );
+ }
+
+ console.log( `✅ Downloaded ${ formatBytes( downloadedBytes ) } and verified SHA-256` );
+}
+
+/**
+ * Download a blob with bounded retries and remove incomplete files between attempts.
+ *
+ * The GHCR bearer token can age out during a long retry window. If a 401 is
+ * encountered, a fresh token is fetched once (via `fetchGhcrToken`) and the
+ * download is retried with it.
+ *
+ * @param {string} url Blob URL.
+ * @param {string} token Bearer token for GHCR.
+ * @param {string} digest OCI layer digest.
+ * @param {number|undefined} expectedSize Expected layer size from the manifest.
+ * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io, used to refresh an expired token.
+ * @return {Promise} Path to the verified compressed blob.
+ */
+async function downloadBlobWithRetries( url, token, digest, expectedSize, ghcrRepo ) {
+ const destination = path.join(
+ os.tmpdir(),
+ `wordpress-gutenberg-${ process.pid }.tar.gz`
+ );
+
+ let currentToken = token;
+ let hasRefetchedToken = false;
+
+ for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) {
+ console.log( `\n📥 Download attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }...` );
+ fs.rmSync( destination, { force: true } );
+
+ try {
+ await downloadAndVerifyBlob(
+ url,
+ currentToken,
+ digest,
+ expectedSize,
+ destination
+ );
+ return destination;
+ } catch ( error ) {
+ const downloadError = /** @type {Error & { status?: number }} */ ( error );
+ fs.rmSync( destination, { force: true } );
+ console.error( `❌ Download attempt ${ attempt } failed: ${ downloadError.message }` );
+
+ if (
+ downloadError.status === 401 &&
+ ! hasRefetchedToken &&
+ attempt < MAX_DOWNLOAD_ATTEMPTS
+ ) {
+ hasRefetchedToken = true;
+ console.log( ' Bearer token may have expired mid-retry; fetching a fresh token...' );
+ currentToken = await fetchGhcrToken( ghcrRepo );
+
+ console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` );
+ await delay( RETRY_DELAY_MS );
+ continue;
+ }
+
+ if (
+ attempt === MAX_DOWNLOAD_ATTEMPTS ||
+ ! isRetryableDownloadError( downloadError )
+ ) {
+ throw downloadError;
+ }
+
+ console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` );
+ await delay( RETRY_DELAY_MS );
+ }
+ }
+
+ throw new Error( 'Download failed without an error' );
+}
+
+/**
+ * Extract a verified archive directly into the Gutenberg directory, removing
+ * any existing directory first.
+ *
+ * @param {string} archivePath Path to the verified compressed blob.
+ * @param {string} expectedSha Expected immutable Gutenberg source SHA.
+ * @return {Promise} Resolves after extraction completes.
+ */
+async function extractVerifiedArchive( archivePath, expectedSha ) {
+ fs.rmSync( gutenbergDir, { recursive: true, force: true } );
+ fs.mkdirSync( gutenbergDir, { recursive: true } );
+
+ const tar = spawn( 'tar', [ '-xzf', archivePath, '-C', gutenbergDir ], {
+ stdio: [ 'ignore', 'inherit', 'inherit' ],
+ } );
+
+ await new Promise( ( resolve, reject ) => {
+ tar.on( 'close', ( code ) => {
+ if ( code !== 0 ) {
+ reject( new Error( `tar exited with code ${ code }` ) );
+ return;
+ }
+ resolve( undefined );
+ } );
+ tar.on( 'error', reject );
+ } );
+
+ const extractedHashPath = path.join( gutenbergDir, '.gutenberg-hash' );
+ const extractedSha = fs.readFileSync( extractedHashPath, 'utf8' ).trim();
+ if ( extractedSha !== expectedSha ) {
+ throw new Error(
+ `Extracted Gutenberg SHA mismatch: expected ${ expectedSha } but found ${ extractedSha }`
+ );
+ }
+}
+
/**
* Resolve the manifest to use for downloading.
*
@@ -43,7 +300,7 @@ const {
*
* @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config
* @param {string} token
- * @return {Promise<{ manifest: Record, resolvedRef: string }>}
+ * @return {Promise<{ manifest: Record, resolvedRef: string, expectedSha: string }>}
*/
async function resolveDownloadManifest( config, token ) {
const { ref, ghcrRepo, isMutable } = config;
@@ -51,27 +308,26 @@ async function resolveDownloadManifest( config, token ) {
const initialManifest = await fetchManifest( ref, ghcrRepo, token );
if ( ! isMutable ) {
- return { manifest: initialManifest, resolvedRef: ref };
+ return { manifest: initialManifest, resolvedRef: ref, expectedSha: ref };
}
const revision =
initialManifest?.annotations?.[ 'org.opencontainers.image.revision' ];
- if ( ! revision ) {
- console.log(
- `ℹ️ No image.revision annotation on "${ ref }"; using mutable tag for download.`
+ if ( ! revision || ! /^[a-f0-9]{40}$/i.test( revision ) ) {
+ throw new Error(
+ `Manifest for mutable ref "${ ref }" has no valid org.opencontainers.image.revision SHA`
);
- return { manifest: initialManifest, resolvedRef: ref };
}
try {
const immutableManifest = await fetchManifest( revision, ghcrRepo, token );
- return { manifest: immutableManifest, resolvedRef: revision };
+ return { manifest: immutableManifest, resolvedRef: revision, expectedSha: revision };
} catch ( error ) {
if ( /** @type {{ status?: number }} */ ( error ).status === 404 ) {
console.log(
`ℹ️ Immutable SHA tag ${ revision } unavailable; falling back to mutable tag "${ ref }".`
);
- return { manifest: initialManifest, resolvedRef: ref };
+ return { manifest: initialManifest, resolvedRef: ref, expectedSha: revision };
}
throw error;
}
@@ -116,9 +372,9 @@ async function main() {
// Step 2: Resolve the manifest to use for download.
console.log( `\n📋 Fetching manifest for ${ config.ref }...` );
- let manifest, resolvedRef;
+ let manifest, resolvedRef, expectedSha;
try {
- ( { manifest, resolvedRef } = await resolveDownloadManifest(
+ ( { manifest, resolvedRef, expectedSha } = await resolveDownloadManifest(
config,
token
) );
@@ -130,78 +386,36 @@ async function main() {
process.exit( 1 );
}
- const digest = manifest?.layers?.[ 0 ]?.digest;
+ const layer = manifest?.layers?.[ 0 ];
+ const digest = layer?.digest;
if ( ! digest ) {
console.error( '❌ No layer digest found in manifest' );
process.exit( 1 );
}
console.log( `✅ Blob digest: ${ digest }` );
- // Remove existing gutenberg directory so the extraction is clean.
- if ( fs.existsSync( gutenbergDir ) ) {
- console.log( '\n🗑️ Removing existing gutenberg directory...' );
- fs.rmSync( gutenbergDir, { recursive: true, force: true } );
- }
-
- fs.mkdirSync( gutenbergDir, { recursive: true } );
-
- /*
- * Step 3: Stream the blob directly through gunzip into tar, writing
- * into ./gutenberg with no temporary file on disk.
- */
- console.log( `\n📥 Downloading and extracting artifact...` );
+ // Step 3: Download and verify the compressed blob before extraction.
+ let archivePath;
try {
- const response = await fetch( `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, {
- headers: {
- Authorization: `Bearer ${ token }`,
- },
- } );
- if ( ! response.ok ) {
- throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` );
- }
- if ( ! response.body ) {
- throw new Error( 'Blob response has no body' );
- }
-
- /*
- * Spawn tar to read from stdin and extract into gutenbergDir.
- * `tar` is available on macOS, Linux, and Windows 10+.
- */
- const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], {
- stdio: [ 'pipe', 'inherit', 'inherit' ],
- } );
-
- /** @type {Promise} */
- const tarDone = new Promise( ( resolve, reject ) => {
- tar.on( 'close', ( code ) => {
- if ( code !== 0 ) {
- reject( new Error( `tar exited with code ${ code }` ) );
- } else {
- resolve();
- }
- } );
- tar.on( 'error', reject );
- } );
-
- /*
- * Pipe: fetch body → gunzip → tar stdin.
- * Decompressing in Node keeps the pipeline error handling
- * consistent and means tar only sees plain tar data on stdin.
- */
- await pipeline(
- Readable.fromWeb(
- /** @type {import('stream/web').ReadableStream} */ ( response.body )
- ),
- zlib.createGunzip(),
- tar.stdin,
+ archivePath = await downloadBlobWithRetries(
+ `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`,
+ token,
+ digest,
+ layer.size,
+ config.ghcrRepo
);
- await tarDone;
-
- console.log( '✅ Download and extraction complete' );
+ console.log( '\n📦 Extracting verified artifact...' );
+ await extractVerifiedArchive( archivePath, expectedSha );
+ console.log( '✅ Extraction complete' );
} catch ( error ) {
console.error( '❌ Download/extraction failed:', /** @type {Error} */ ( error ).message );
- process.exit( 1 );
+ process.exitCode = 1;
+ return;
+ } finally {
+ if ( archivePath ) {
+ fs.rmSync( archivePath, { force: true } );
+ }
}
console.log( '\n✅ Gutenberg download complete!' );
diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js
index 3ba95199578b4..b43e760735efe 100644
--- a/tools/gutenberg/utils.js
+++ b/tools/gutenberg/utils.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+/* global AbortSignal */
/**
* Gutenberg build utilities.
@@ -26,6 +27,73 @@ const SHA_PATTERN = /^[a-f0-9]{40}$/i;
const MANIFEST_ACCEPT = 'application/vnd.oci.image.manifest.v1+json';
+// Retry/timeout settings for the token and manifest requests. These run
+// before the blob download and are now a single point of failure for the
+// whole build, so they share the blob download's attempt count and backoff
+// (see MAX_DOWNLOAD_ATTEMPTS and RETRY_DELAY_MS in download.js).
+const MAX_METADATA_ATTEMPTS = 3;
+const RETRY_DELAY_MS = 2000;
+const METADATA_TIMEOUT_MS = 30000;
+
+/**
+ * Wait before retrying a failed metadata request.
+ *
+ * @param {number} milliseconds Time to wait in milliseconds.
+ * @return {Promise} Resolves after the requested delay.
+ */
+function delay( milliseconds ) {
+ return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) );
+}
+
+/**
+ * Create an error that retains the HTTP status code for retry decisions.
+ *
+ * @param {string} message Error message.
+ * @param {number} status HTTP status code.
+ * @return {Error & { status: number }} Error with status code.
+ */
+function createHttpError( message, status ) {
+ const error = /** @type {Error & { status: number }} */ ( new Error( message ) );
+ error.status = status;
+ return error;
+}
+
+/**
+ * Determine whether a failed metadata request might succeed on a later attempt.
+ *
+ * @param {Error & { status?: number }} error Request error.
+ * @return {boolean} Whether the error is retryable.
+ */
+function isRetryableMetadataError( error ) {
+ return ! error.status || error.status === 408 || error.status === 429 || error.status >= 500;
+}
+
+/**
+ * Run a metadata request with bounded retries, matching the blob download's
+ * retry semantics. Non-retryable errors (e.g. a 404) are thrown immediately.
+ *
+ * @param {string} description Human-readable label for retry log messages.
+ * @param {() => Promise} request Function that performs one request attempt.
+ * @return {Promise} Resolves with the request's result.
+ */
+async function withMetadataRetries( description, request ) {
+ for ( let attempt = 1; attempt <= MAX_METADATA_ATTEMPTS; attempt++ ) {
+ try {
+ return await request();
+ } catch ( error ) {
+ const requestError = /** @type {Error & { status?: number }} */ ( error );
+ if ( attempt === MAX_METADATA_ATTEMPTS || ! isRetryableMetadataError( requestError ) ) {
+ throw requestError;
+ }
+ console.error( `❌ ${ description } attempt ${ attempt } failed: ${ requestError.message }` );
+ console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` );
+ await delay( RETRY_DELAY_MS );
+ }
+ }
+
+ throw new Error( `${ description } failed without an error` );
+}
+
/**
* Read Gutenberg configuration from package.json.
*
@@ -64,19 +132,23 @@ function readGutenbergConfig() {
* @return {Promise} The bearer token.
*/
async function fetchGhcrToken( ghcrRepo ) {
- const response = await fetch(
- `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io`
- );
- if ( ! response.ok ) {
- throw new Error(
- `Failed to fetch GHCR token: ${ response.status } ${ response.statusText }`
+ return withMetadataRetries( 'Fetch GHCR token', async () => {
+ const response = await fetch(
+ `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io`,
+ { signal: AbortSignal.timeout( METADATA_TIMEOUT_MS ) }
);
- }
- const data = await response.json();
- if ( ! data.token ) {
- throw new Error( 'No token in GHCR response' );
- }
- return data.token;
+ if ( ! response.ok ) {
+ throw createHttpError(
+ `Failed to fetch GHCR token: ${ response.status } ${ response.statusText }`,
+ response.status
+ );
+ }
+ const data = await response.json();
+ if ( ! data.token ) {
+ throw new Error( 'No token in GHCR response' );
+ }
+ return data.token;
+ } );
}
/**
@@ -88,25 +160,25 @@ async function fetchGhcrToken( ghcrRepo ) {
* @return {Promise>} Parsed manifest JSON.
*/
async function fetchManifest( ref, ghcrRepo, token ) {
- const response = await fetch(
- `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`,
- {
- headers: {
- Authorization: `Bearer ${ token }`,
- Accept: MANIFEST_ACCEPT,
- },
- }
- );
- if ( ! response.ok ) {
- const error = /** @type {Error & { status?: number }} */ (
- new Error(
- `Failed to fetch manifest for "${ ref }": ${ response.status } ${ response.statusText }`
- )
+ return withMetadataRetries( `Fetch manifest for "${ ref }"`, async () => {
+ const response = await fetch(
+ `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`,
+ {
+ headers: {
+ Authorization: `Bearer ${ token }`,
+ Accept: MANIFEST_ACCEPT,
+ },
+ signal: AbortSignal.timeout( METADATA_TIMEOUT_MS ),
+ }
);
- error.status = response.status;
- throw error;
- }
- return response.json();
+ if ( ! response.ok ) {
+ throw createHttpError(
+ `Failed to fetch manifest for "${ ref }": ${ response.status } ${ response.statusText }`,
+ response.status
+ );
+ }
+ return response.json();
+ } );
}
/**
@@ -121,6 +193,17 @@ async function fetchManifest( ref, ghcrRepo, token ) {
* @return {Promise} The expected SHA.
*/
async function resolveExpectedSha( { ref, ghcrRepo, isMutable } ) {
+ const workflowSha = process.env.GUTENBERG_EXPECTED_SHA;
+ if ( workflowSha ) {
+ if ( ! SHA_PATTERN.test( workflowSha ) ) {
+ throw new Error(
+ `GUTENBERG_EXPECTED_SHA must be a 40-character Git SHA, received "${ workflowSha }"`
+ );
+ }
+
+ return workflowSha;
+ }
+
if ( ! isMutable ) {
return ref;
}
@@ -157,11 +240,11 @@ function downloadGutenberg() {
/**
* Verify that the installed Gutenberg version matches the expected SHA.
*
- * For SHA refs, the expected SHA is the configured value. For mutable refs,
- * the expected SHA is whatever the mutable tag currently points to in GHCR
- * (read from the manifest's image.revision annotation). The installed
- * `.gutenberg-hash` is compared against the expected SHA; on mismatch, a
- * fresh download is triggered.
+ * A calling workflow may supply GUTENBERG_EXPECTED_SHA after resolving a build
+ * once. This avoids re-resolving a mutable tag in every matrix job. Otherwise,
+ * SHA refs use the configured value and mutable refs resolve their current
+ * image.revision annotation. The installed `.gutenberg-hash` is compared
+ * against the expected SHA; on mismatch, a fresh download is triggered.
*/
async function verifyGutenbergVersion() {
console.log( '\n🔍 Verifying Gutenberg version...' );
From 764502dd1beee21395e404a6f8c7eefac436b6b5 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 21:48:07 +0000
Subject: [PATCH 041/149] Docs: Document how to run the QUnit test suite.
Add a README section for running the JavaScript QUnit suite from a fresh checkout.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12699
Props mukesh27, adrianmoldovanwp
Fixes #65719
git-svn-id: https://develop.svn.wordpress.org/trunk@62860 602fd350-edb4-49c9-b593-d223f7449a82
---
README.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/README.md b/README.md
index bb6d06c034651..eb88e407cb612 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,17 @@ npm run test:php -- --filter
npm run test:php -- --group
```
+To run the JavaScript (QUnit) tests:
+
+```
+npm run grunt qunit:compiled
+```
+
+`qunit:compiled` builds first, then runs the suite. The QUnit runner loads
+scripts from the built `build/` directory, so a plain `npm run grunt qunit`
+requires a completed `npm run build` first without a build, every test fails
+with a `jQuery is not defined` error.
+
#### To lint the workflow files
GitHub Actions workflows operate in a privileged software supply chain environment, therefore all workflow files must adhere to a high degree of quality and security standards.
From a331245a404bdd2129926ccfd2232d89a6145b27 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 21:52:24 +0000
Subject: [PATCH 042/149] Build/Test Tools: Pin Zod to fix the JSDoc lint
export error.
A clean `npm ci` resolves Zod to 3.23.8, so `zod/v4/core` fails with ERR_PACKAGE_PATH_NOT_EXPORTED and breaks the JSDoc lint run. Pin Zod to 3.25.1 with a package.json override and update the lockfile. 3.25.1 is the smallest release that ships the required export with a CommonJS target; 3.25.0 declares the export but omits the CommonJS build. All Zod consumers now resolve to 3.25.1. (Now that's a lotta Zod!)
Developed in: https://github.com/WordPress/wordpress-develop/pull/12704
Props mukesh27, adrianmoldovanwp
Fixes #65723
git-svn-id: https://develop.svn.wordpress.org/trunk@62861 602fd350-edb4-49c9-b593-d223f7449a82
---
package-lock.json | 17 ++++-------------
package.json | 3 +++
2 files changed, 7 insertions(+), 13 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9bb47a3281b6d..78985e8f955c0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14237,16 +14237,6 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
- "node_modules/eslint-plugin-react-hooks/node_modules/zod": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
- "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
"node_modules/eslint-plugin-react/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
@@ -33728,10 +33718,11 @@
}
},
"node_modules/zod": {
- "version": "3.23.8",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
- "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.1.tgz",
+ "integrity": "sha512-bkxUGQiqWDTXHSgqtevYDri5ee2GPC9szPct4pqpzLEpswgDQmuseDz81ZF0AnNu1xsmnBVmbtv/t/WeUIHlpg==",
"dev": true,
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index a4aff38cf3dd7..ef9fe61733a85 100644
--- a/package.json
+++ b/package.json
@@ -112,6 +112,9 @@
"whatwg-fetch": "3.6.20",
"wicg-inert": "3.1.3"
},
+ "overrides": {
+ "zod": "3.25.1"
+ },
"scripts": {
"build": "grunt build",
"build:dev": "grunt build --dev",
From 80fb78da3153431dee7e5d66e7463e71fda31ec7 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Mon, 27 Jul 2026 23:13:13 +0000
Subject: [PATCH 043/149] Build/Test Tools: Add the missing
reusable-prepare-gutenberg.yml workflow.
[62859] added the callers of this reusable workflow (phpunit-tests.yml, reusable-phpunit-tests-v3.yml, and test-coverage.yml) but not the reusable-prepare-gutenberg.yml file they depend on, so those workflows reference a reusable workflow that does not exist and fail to load. Add the file: it resolves the Gutenberg build once per run, verifies it (SHA-256 and size), and uploads it as a run artifact the PHPUnit callers consume.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12724
Follow-up to [62859]
See #65721
git-svn-id: https://develop.svn.wordpress.org/trunk@62862 602fd350-edb4-49c9-b593-d223f7449a82
---
.../workflows/reusable-prepare-gutenberg.yml | 60 +++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 .github/workflows/reusable-prepare-gutenberg.yml
diff --git a/.github/workflows/reusable-prepare-gutenberg.yml b/.github/workflows/reusable-prepare-gutenberg.yml
new file mode 100644
index 0000000000000..c5e1f904a9680
--- /dev/null
+++ b/.github/workflows/reusable-prepare-gutenberg.yml
@@ -0,0 +1,60 @@
+##
+# A reusable workflow that downloads and verifies the Gutenberg build once per
+# calling workflow run.
+##
+name: Prepare Gutenberg build
+
+on:
+ workflow_call:
+ outputs:
+ gutenberg-sha:
+ description: 'The immutable Gutenberg source SHA verified by this workflow.'
+ value: ${{ jobs.prepare-gutenberg.outputs.gutenberg-sha }}
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions should be configured at the job level.
+permissions: {}
+
+jobs:
+ prepare-gutenberg:
+ name: Gutenberg
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ gutenberg-sha: ${{ steps.download.outputs.gutenberg-sha }}
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Resolve the Gutenberg ref from the exact commit that started this workflow run.
+ ref: ${{ github.sha }}
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version-file: '.nvmrc'
+
+ - name: Download and verify Gutenberg build
+ id: download
+ run: |
+ node tools/gutenberg/download.js
+ gutenberg_sha="$(tr -d '\n' < gutenberg/.gutenberg-hash)"
+ if [[ ! "$gutenberg_sha" =~ ^[a-fA-F0-9]{40}$ ]]; then
+ echo "Expected a 40-character Gutenberg SHA, received: $gutenberg_sha" >&2
+ exit 1
+ fi
+ echo "gutenberg-sha=$gutenberg_sha" >> "$GITHUB_OUTPUT"
+
+ - name: Upload Gutenberg build
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: gutenberg-build
+ path: gutenberg/
+ if-no-files-found: error
+ include-hidden-files: true
+ retention-days: 1
From 0ccf72e952e5dbafcc80896c28f626725263a7e5 Mon Sep 17 00:00:00 2001
From: ramonopoly
Date: Tue, 28 Jul 2026 05:39:16 +0000
Subject: [PATCH 044/149] ``` Layout: avoid resolving global settings for
blocks with no layout output
This commit moves the global settings lookup in `wp_render_layout_support_flag()` below an early return for blocks with no layout support and no style attribute. Resolving settings on a cold cache queries the user's `wp_global_styles` post, which fires `the_posts`; a callback on that hook that renders blocks re-enters the filter and recurses without a base case.
Developed in: [https://github.com/WordPress/wordpress-develop/pull/12729](https://github.com/WordPress/wordpress-develop/pull/12729)
Props ramonopoly, andrewserong.
Fixes #65741.
git-svn-id: https://develop.svn.wordpress.org/trunk@62864 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/block-supports/layout.php | 21 +++++-
tests/phpunit/tests/block-supports/layout.php | 64 +++++++++++++++++++
2 files changed, 82 insertions(+), 3 deletions(-)
diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php
index 896f6d25543cf..3be4f07b055ea 100644
--- a/src/wp-includes/block-supports/layout.php
+++ b/src/wp-includes/block-supports/layout.php
@@ -955,9 +955,24 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false
function wp_render_layout_support_flag( $block_content, $block ) {
static $global_styles = null;
- $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
- $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false );
- $style_attr = $block['attrs']['style'] ?? array();
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false );
+ $style_attr = $block['attrs']['style'] ?? array();
+ /*
+ * A block with no layout support and no style attribute at all cannot
+ * produce layout output, so return before resolving global settings.
+ *
+ * Resolving settings is not read-only: on a cold cache it queries the
+ * user's `wp_global_styles` post, which fires `the_posts`. A callback on
+ * that hook that renders blocks re-enters this filter, and the content it
+ * renders at that point is the global styles post itself, which parses to a
+ * single block with no name and no attributes. Without this return that
+ * block resolves settings again and the recursion has no base case.
+ */
+ if ( ! $block_supports_layout && empty( $style_attr ) ) {
+ return $block_content;
+ }
+
$global_settings = wp_get_global_settings();
$viewport_settings = $global_settings['viewport'] ?? null;
$responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings );
diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php
index c9a8dd34b1371..7ad00f7cc364c 100644
--- a/tests/phpunit/tests/block-supports/layout.php
+++ b/tests/phpunit/tests/block-supports/layout.php
@@ -1124,4 +1124,68 @@ public function test_layout_support_flag_with_non_string_class_name() {
'Layout support should render the expected markup when className is not a string'
);
}
+
+ /**
+ * Tests that layout support returns early, without resolving global settings,
+ * for a block that cannot produce any layout output.
+ *
+ * Resolving global settings reads the user's `wp_global_styles` post with a
+ * `WP_Query`, which fires `the_posts`. A callback on that hook that renders
+ * blocks re-enters this filter, so the bail-out has to happen before the
+ * lookup or the recursion has no base case.
+ *
+ * @ticket 65741
+ *
+ * @covers ::wp_render_layout_support_flag
+ */
+ public function test_layout_support_flag_returns_early_before_resolving_global_settings() {
+ $user_data_resolutions = 0;
+ add_filter(
+ 'wp_theme_json_data_user',
+ static function ( $theme_json ) use ( &$user_data_resolutions ) {
+ ++$user_data_resolutions;
+ return $theme_json;
+ }
+ );
+
+ // A block with no layout support and no child layout, as produced by
+ // parsing content that has no block delimiters.
+ $block_content = 'Not a block.
';
+ $block = array(
+ 'blockName' => null,
+ 'attrs' => array(),
+ );
+
+ // Start from a cold cache, as on a front-end request.
+ wp_clean_theme_json_cache();
+
+ $this->assertSame(
+ $block_content,
+ wp_render_layout_support_flag( $block_content, $block ),
+ 'Block content should be returned unchanged when the block has no layout support.'
+ );
+ $this->assertSame(
+ 0,
+ $user_data_resolutions,
+ 'Global settings should not be resolved for a block that cannot produce layout output.'
+ );
+
+ // A block that does support layout still resolves global settings, which
+ // confirms the assertion above is not passing because of a warm cache.
+ wp_clean_theme_json_cache();
+
+ wp_render_layout_support_flag(
+ '
',
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array( 'layout' => array( 'type' => 'constrained' ) ),
+ )
+ );
+
+ $this->assertGreaterThan(
+ 0,
+ $user_data_resolutions,
+ 'Global settings should still be resolved for a block that supports layout.'
+ );
+ }
}
From 3a9e9012fe0f45835815341b7e331f17b1e6f23e Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Tue, 28 Jul 2026 09:59:14 +0000
Subject: [PATCH 045/149] Login and Registration: Correct focus color for
links.
The hover color for links on the login screen was updated to use the admin color scheme CSS variables, but the focus color was left hardcoded. Update the focus color to use the same variable, so it matches hover and follows the selected admin color scheme.
Follow-up to [61681], [62113].
Props dhruvang21, hasnainashfaq, nimeshatxecurify, shailu25, softglaze, sumitsingh, vedantere, wildworks.
Fixes #64953.
git-svn-id: https://develop.svn.wordpress.org/trunk@62865 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/login.css | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/css/login.css b/src/wp-admin/css/login.css
index 04049843f1c37..4765e604a938d 100644
--- a/src/wp-admin/css/login.css
+++ b/src/wp-admin/css/login.css
@@ -32,7 +32,7 @@ a:active {
}
a:focus {
- color: #043959;
+ color: var(--wp-admin-theme-color-darker-20);
box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
@@ -339,7 +339,7 @@ p {
.login #nav a:focus,
.login #backtoblog a:focus,
.login h1 a:focus {
- color: #043959;
+ color: var(--wp-admin-theme-color-darker-20);
}
.login .privacy-policy-page-link {
From 133c5823f48ef1365f4d7fef5328beac90bb9eba Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Tue, 28 Jul 2026 10:32:39 +0000
Subject: [PATCH 046/149] Users: Add a user-ID-based `id` to network user rows.
Each row rendered by `WP_MS_Users_List_Table::display_rows()` now carries an `id="user-{ID}"` attribute, matching the markup `WP_Users_List_Table::single_row()` already emits on the per-site Users screen.
Props alexodiy, dd32, johnjamesjacoby, manhar, mukesh27, sabernhardt, sergeybiryukov, westonruter, wildworks.
Fixes #65102.
git-svn-id: https://develop.svn.wordpress.org/trunk@62866 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/class-wp-ms-users-list-table.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-admin/includes/class-wp-ms-users-list-table.php b/src/wp-admin/includes/class-wp-ms-users-list-table.php
index b4cbf8457a0b4..cc24dc6dcff3e 100644
--- a/src/wp-admin/includes/class-wp-ms-users-list-table.php
+++ b/src/wp-admin/includes/class-wp-ms-users-list-table.php
@@ -502,7 +502,7 @@ public function display_rows() {
}
?>
-
+
single_row_columns( $user ); ?>
Date: Tue, 28 Jul 2026 10:53:50 +0000
Subject: [PATCH 047/149] Bundled Themes: Add missing package details to block
themes.
This adds the `@package`, `@subpackage`, and `@since` tags to the block pattern files of Twenty Twenty-Two, Twenty Twenty-Three, and Twenty Twenty-Four, and corrects Twenty Twenty-Four's `functions.php` to use the `@package WordPress` form consistent with the other bundled themes.
Props poena, sabernhardt, shailu25, viralsampat, wildworks.
Fixes #62437.
git-svn-id: https://develop.svn.wordpress.org/trunk@62867 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-content/themes/twentytwentyfour/functions.php | 3 ++-
.../themes/twentytwentyfour/patterns/banner-hero.php | 5 +++++
.../patterns/banner-project-description.php | 6 ++++++
.../patterns/cta-content-image-on-right.php | 5 +++++
.../themes/twentytwentyfour/patterns/cta-pricing.php | 5 +++++
.../themes/twentytwentyfour/patterns/cta-rsvp.php | 5 +++++
.../twentytwentyfour/patterns/cta-services-image-left.php | 5 +++++
.../twentytwentyfour/patterns/cta-subscribe-centered.php | 5 +++++
.../twentytwentyfour/patterns/footer-centered-logo-nav.php | 5 +++++
.../twentytwentyfour/patterns/footer-colophon-3-col.php | 5 +++++
src/wp-content/themes/twentytwentyfour/patterns/footer.php | 5 +++++
.../twentytwentyfour/patterns/gallery-full-screen-image.php | 5 +++++
.../patterns/gallery-offset-images-grid-2-col.php | 6 ++++++
.../patterns/gallery-offset-images-grid-3-col.php | 6 ++++++
.../patterns/gallery-offset-images-grid-4-col.php | 5 +++++
.../twentytwentyfour/patterns/gallery-project-layout.php | 5 +++++
.../themes/twentytwentyfour/patterns/hidden-404.php | 5 +++++
.../themes/twentytwentyfour/patterns/hidden-comments.php | 5 +++++
.../themes/twentytwentyfour/patterns/hidden-no-results.php | 6 ++++++
.../twentytwentyfour/patterns/hidden-portfolio-hero.php | 5 +++++
.../themes/twentytwentyfour/patterns/hidden-post-meta.php | 5 +++++
.../twentytwentyfour/patterns/hidden-post-navigation.php | 5 +++++
.../twentytwentyfour/patterns/hidden-posts-heading.php | 6 ++++++
.../themes/twentytwentyfour/patterns/hidden-search.php | 5 +++++
.../themes/twentytwentyfour/patterns/hidden-sidebar.php | 6 ++++++
.../twentytwentyfour/patterns/page-about-business.php | 5 +++++
.../themes/twentytwentyfour/patterns/page-home-blogging.php | 5 +++++
.../themes/twentytwentyfour/patterns/page-home-business.php | 5 +++++
.../patterns/page-home-portfolio-gallery.php | 5 +++++
.../twentytwentyfour/patterns/page-home-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/page-newsletter-landing.php | 5 +++++
.../twentytwentyfour/patterns/page-portfolio-overview.php | 5 +++++
.../themes/twentytwentyfour/patterns/page-rsvp-landing.php | 5 +++++
.../themes/twentytwentyfour/patterns/posts-1-col.php | 5 +++++
.../themes/twentytwentyfour/patterns/posts-3-col.php | 5 +++++
.../themes/twentytwentyfour/patterns/posts-grid-2-col.php | 5 +++++
.../twentytwentyfour/patterns/posts-images-only-3-col.php | 5 +++++
.../patterns/posts-images-only-offset-4-col.php | 5 +++++
.../themes/twentytwentyfour/patterns/posts-list.php | 5 +++++
.../themes/twentytwentyfour/patterns/team-4-col.php | 5 +++++
.../twentytwentyfour/patterns/template-archive-blogging.php | 5 +++++
.../patterns/template-archive-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/template-home-blogging.php | 5 +++++
.../twentytwentyfour/patterns/template-home-business.php | 5 +++++
.../twentytwentyfour/patterns/template-home-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/template-index-blogging.php | 5 +++++
.../twentytwentyfour/patterns/template-index-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/template-search-blogging.php | 5 +++++
.../twentytwentyfour/patterns/template-search-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/template-single-portfolio.php | 5 +++++
.../twentytwentyfour/patterns/testimonial-centered.php | 5 +++++
.../twentytwentyfour/patterns/text-alternating-images.php | 5 +++++
.../patterns/text-centered-statement-small.php | 5 +++++
.../twentytwentyfour/patterns/text-centered-statement.php | 5 +++++
.../themes/twentytwentyfour/patterns/text-faq.php | 5 +++++
.../twentytwentyfour/patterns/text-feature-grid-3-col.php | 5 +++++
.../twentytwentyfour/patterns/text-project-details.php | 5 +++++
.../patterns/text-title-left-image-right.php | 5 +++++
.../themes/twentytwentythree/patterns/call-to-action.php | 6 ++++++
.../themes/twentytwentythree/patterns/footer-default.php | 6 ++++++
.../themes/twentytwentythree/patterns/hidden-404.php | 6 ++++++
.../themes/twentytwentythree/patterns/hidden-comments.php | 6 ++++++
.../themes/twentytwentythree/patterns/hidden-heading.php | 6 ++++++
.../themes/twentytwentythree/patterns/hidden-no-results.php | 6 ++++++
.../themes/twentytwentythree/patterns/post-meta.php | 6 ++++++
.../themes/twentytwentytwo/inc/block-patterns.php | 2 ++
.../inc/patterns/footer-about-title-logo.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/footer-blog.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/footer-dark.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/footer-default.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/footer-logo.php | 5 +++++
.../inc/patterns/footer-navigation-copyright.php | 5 +++++
.../twentytwentytwo/inc/patterns/footer-navigation.php | 5 +++++
.../inc/patterns/footer-query-images-title-citation.php | 5 +++++
.../inc/patterns/footer-query-title-citation.php | 5 +++++
.../inc/patterns/footer-social-copyright.php | 5 +++++
.../inc/patterns/footer-title-tagline-social.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-divider-dark.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-divider-light.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-featured-posts.php | 5 +++++
.../inc/patterns/general-image-with-caption.php | 5 +++++
.../inc/patterns/general-large-list-names.php | 5 +++++
.../inc/patterns/general-layered-images-with-duotone.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-list-events.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-pricing-table.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-subscribe.php | 5 +++++
.../inc/patterns/general-two-images-text.php | 5 +++++
.../inc/patterns/general-video-header-details.php | 5 +++++
.../twentytwentytwo/inc/patterns/general-video-trailer.php | 5 +++++
.../inc/patterns/general-wide-image-intro-buttons.php | 5 +++++
.../inc/patterns/header-centered-logo-black-background.php | 5 +++++
.../twentytwentytwo/inc/patterns/header-centered-logo.php | 5 +++++
.../patterns/header-centered-title-navigation-social.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/header-default.php | 5 +++++
.../inc/patterns/header-image-background-overlay.php | 5 +++++
.../inc/patterns/header-image-background.php | 5 +++++
.../twentytwentytwo/inc/patterns/header-large-dark.php | 5 +++++
.../inc/patterns/header-logo-navigation-gray-background.php | 5 +++++
.../inc/patterns/header-logo-navigation-offset-tagline.php | 5 +++++
.../header-logo-navigation-social-black-background.php | 5 +++++
.../twentytwentytwo/inc/patterns/header-small-dark.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/header-stacked.php | 5 +++++
.../inc/patterns/header-text-only-green-background.php | 5 +++++
.../inc/patterns/header-text-only-salmon-background.php | 5 +++++
.../header-text-only-with-tagline-black-background.php | 5 +++++
.../inc/patterns/header-title-and-button.php | 5 +++++
.../inc/patterns/header-title-navigation-social.php | 5 +++++
.../twentytwentytwo/inc/patterns/header-with-tagline.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/hidden-404.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/hidden-bird.php | 5 +++++
.../inc/patterns/hidden-heading-and-bird.php | 5 +++++
.../inc/patterns/page-about-large-image-and-buttons.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-links-dark.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-links.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-media-left.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-media-right.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-simple-dark.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-about-solid-color.php | 5 +++++
.../inc/patterns/page-layout-image-and-text.php | 5 +++++
.../inc/patterns/page-layout-image-text-and-video.php | 5 +++++
.../inc/patterns/page-layout-two-columns.php | 5 +++++
.../inc/patterns/page-sidebar-blog-posts-right.php | 5 +++++
.../inc/patterns/page-sidebar-blog-posts.php | 5 +++++
.../inc/patterns/page-sidebar-grid-posts.php | 5 +++++
.../twentytwentytwo/inc/patterns/page-sidebar-poster.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/query-default.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/query-grid.php | 5 +++++
.../twentytwentytwo/inc/patterns/query-image-grid.php | 5 +++++
.../twentytwentytwo/inc/patterns/query-irregular-grid.php | 5 +++++
.../twentytwentytwo/inc/patterns/query-large-titles.php | 5 +++++
.../twentytwentytwo/inc/patterns/query-simple-blog.php | 5 +++++
.../themes/twentytwentytwo/inc/patterns/query-text-grid.php | 5 +++++
132 files changed, 667 insertions(+), 1 deletion(-)
diff --git a/src/wp-content/themes/twentytwentyfour/functions.php b/src/wp-content/themes/twentytwentyfour/functions.php
index 06d60695e9041..5dd34dd34c470 100644
--- a/src/wp-content/themes/twentytwentyfour/functions.php
+++ b/src/wp-content/themes/twentytwentyfour/functions.php
@@ -4,7 +4,8 @@
*
* @link https://developer.wordpress.org/themes/basics/theme-functions/
*
- * @package Twenty Twenty-Four
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
* @since Twenty Twenty-Four 1.0
*/
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php b/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php
index 9971884d6399e..dd5704a77e698 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/banner-hero.php
@@ -5,7 +5,12 @@
* Categories: banner, call-to-action, featured
* Viewport width: 1400
* Description: A hero section with a title, a paragraph, a CTA button, and an image.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php b/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php
index 7ddeea7aa1721..32e9025cfb34c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/banner-project-description.php
@@ -5,8 +5,14 @@
* Categories: featured, banner, about, portfolio
* Viewport width: 1400
* Description: Project description section with title, paragraph, and an image.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php
index 49bceb2eb96ac..88c6dbc13fb22 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-content-image-on-right.php
@@ -5,7 +5,12 @@
* Categories: call-to-action, banner
* Viewport width: 1400
* Description: A title, paragraph, two CTA buttons, and an image for a general CTA section.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php
index 4ba39374342de..ba03a11f5cffc 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-pricing.php
@@ -5,7 +5,12 @@
* Categories: call-to-action, services
* Viewport width: 1400
* Description: A pricing section with a title, a paragraph and three pricing levels.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php
index a12386949b1a8..30c59ffc06d8d 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-rsvp.php
@@ -5,7 +5,12 @@
* Categories: call-to-action, featured
* Viewport width: 1100
* Description: A large RSVP heading sideways, a description, and a CTA button.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php
index b6315b0cbd30b..9b6cdf486d43d 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-services-image-left.php
@@ -5,7 +5,12 @@
* Categories: call-to-action, banner, featured, services
* Viewport width: 1400
* Description: An image, title, paragraph and a CTA button to describe services.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php b/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php
index c11b9133510bb..fbb1b641c84e5 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/cta-subscribe-centered.php
@@ -5,7 +5,12 @@
* Categories: call-to-action
* Keywords: newsletter, subscribe, button
* Description: Subscribers CTA section with a title, a paragraph and a CTA button.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php b/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php
index 39258764fc9e4..13b827418bf7a 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/footer-centered-logo-nav.php
@@ -5,7 +5,12 @@
* Categories: footer
* Block Types: core/template-part/footer
* Description: A footer section with a centered logo, navigation, and WordPress credits.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php
index e0de63b6f97ee..6dbbe880dc2fe 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/footer-colophon-3-col.php
@@ -5,7 +5,12 @@
* Categories: footer
* Block Types: core/template-part/footer
* Description: A footer section with a colophon and 3 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/footer.php b/src/wp-content/themes/twentytwentyfour/patterns/footer.php
index 4b8aeb2797883..b653e8c287027 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/footer.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/footer.php
@@ -5,7 +5,12 @@
* Categories: footer
* Block Types: core/template-part/footer
* Description: A footer section with a colophon and 4 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php
index dc9cc5d339ba2..a22f6d5899940 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-full-screen-image.php
@@ -4,7 +4,12 @@
* Slug: twentytwentyfour/gallery-full-screen-image
* Categories: gallery, portfolio
* Description: A cover image section that covers the entire width.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php
index 8e2222efe74cd..53e7996262b45 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-2-col.php
@@ -6,8 +6,14 @@
* Keywords: project, images, media, masonry, columns
* Viewport width: 1400
* Description: A gallery section with 2 columns and offset images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php
index 5f32f7f861c05..a24bffc8f0f67 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-3-col.php
@@ -6,8 +6,14 @@
* Keywords: project, images, media, masonry, columns
* Viewport width: 1400
* Description: A gallery section with 3 columns and offset images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php
index df9341d0457fb..c999ae84f0a11 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-offset-images-grid-4-col.php
@@ -6,7 +6,12 @@
* Keywords: project, images, media, masonry, columns
* Viewport width: 1400
* Description: A gallery section with 4 columns and offset images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php b/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php
index 143d61bd7a3f7..ea0d17fcea529 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/gallery-project-layout.php
@@ -5,7 +5,12 @@
* Categories: gallery, featured, portfolio
* Viewport width: 1600
* Description: A gallery section with a project layout with 2 images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php
index d033a13f44d1b..7900e75c2ce17 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-404.php
@@ -3,7 +3,12 @@
* Title: 404
* Slug: twentytwentyfour/hidden-404
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php
index bd106684624c2..ffbea0bb411f0 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-comments.php
@@ -3,7 +3,12 @@
* Title: Comments
* Slug: twentytwentyfour/hidden-comments
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php
index 00bbf9761bf7f..bb9242c263a7a 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-no-results.php
@@ -3,8 +3,14 @@
* Title: No results
* Slug: twentytwentyfour/hidden-no-results
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php
index 1af3e7dbd17c1..467456f0e700c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-portfolio-hero.php
@@ -3,7 +3,12 @@
* Title: Portfolio hero
* Slug: twentytwentyfour/hidden-portfolio-hero
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php
index add4a1c8f3716..fc103c84aa3cd 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-meta.php
@@ -3,7 +3,12 @@
* Title: Post meta
* Slug: twentytwentyfour/hidden-post-meta
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php
index be2dcb28b7f28..2113fd89ced44 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-post-navigation.php
@@ -3,7 +3,12 @@
* Title: Post navigation
* Slug: twentytwentyfour/hidden-post-navigation
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php
index 9b2811568ab6a..03a67a4bbe0ff 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-posts-heading.php
@@ -4,8 +4,14 @@
* Slug: twentytwentyfour/hidden-posts-heading
* Categories: hidden
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.3
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php
index e4aaa64ece726..a0c9a4d16704a 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-search.php
@@ -3,7 +3,12 @@
* Title: Search
* Slug: twentytwentyfour/hidden-search
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php b/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php
index abcb01b7bf0ba..093d60c3fe818 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/hidden-sidebar.php
@@ -3,8 +3,14 @@
* Title: Sidebar
* Slug: twentytwentyfour/hidden-sidebar
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php b/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php
index 7b662c243b98d..78741b9779022 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-about-business.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A business about page with a hero section, a text section, a services section, a team section, a clients section, a FAQ section, and a CTA section.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php
index bd08b6e04317b..905b8151dcb49 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-blogging.php
@@ -7,7 +7,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A blogging home page with a hero section, a text section, a blog section, and a CTA section.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php
index 3f2c748e78553..8a665c5366dbc 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-business.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A business home page with a hero section, a text section, a services section, a team section, a clients section, a FAQ section, and a CTA section.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php
index 6c64adebe8dbd..f68471d36d040 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio-gallery.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A portfolio home page that features a gallery.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php
index c03d1b13d57c3..45b1f05c80411 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-home-portfolio.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A portfolio home page with a description and a 4-column post section with only feature images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php b/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php
index 6ad69ff20ece9..2233bf3ba3a15 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-newsletter-landing.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1100
* Description: A block with a newsletter subscription CTA for a landing page.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php b/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php
index 6aee66b1a610a..c6d87b4971119 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-portfolio-overview.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1400
* Description: A full portfolio page with a section for project description, project details, a full screen image, and a gallery section with two images.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php b/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php
index 6ec67323ee79f..5e0dfd0196e9b 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/page-rsvp-landing.php
@@ -8,7 +8,12 @@
* Post Types: page, wp_template
* Viewport width: 1100
* Description: A large RSVP heading sideways, a description, and a CTA button.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php
index f2bbaa1efd323..f672bd7bbbd77 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-1-col.php
@@ -5,7 +5,12 @@
* Categories: query
* Block Types: core/query
* Description: A list of posts, 1 column.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php
index 855615471b5e9..9c0f85e9d32e8 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-3-col.php
@@ -5,7 +5,12 @@
* Categories: query
* Block Types: core/query
* Description: A list of posts, 3 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php
index d6a584f6f6c3b..600f7e29642c7 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-grid-2-col.php
@@ -5,7 +5,12 @@
* Categories: query
* Block Types: core/query
* Description: A grid of posts featuring the first post, 2 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php
index 7aee49301e524..c28906224a05c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-3-col.php
@@ -5,7 +5,12 @@
* Categories: query
* Block Types: core/query
* Description: A list of posts with featured images only, 3 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php
index 2cbcaff47e574..b6de9f474d7f4 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-images-only-offset-4-col.php
@@ -4,7 +4,12 @@
* Slug: twentytwentyfour/posts-images-only-offset-4-col
* Categories: posts
* Description: A list of posts with featured images only, 4 columns.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php b/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php
index e298a544a324b..4a424b78af9da 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/posts-list.php
@@ -5,7 +5,12 @@
* Categories: query, posts
* Block Types: core/query
* Description: A list of posts without images, 1 column.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php b/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php
index 86a8bfac2b362..62406b2ae51e9 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/team-4-col.php
@@ -5,7 +5,12 @@
* Categories: team, about
* Viewport width: 1400
* Description: A team section, with a heading, a paragraph, and 4 columns for team members.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php
index 6d193a4cdd390..f5409a60053f8 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-blogging.php
@@ -5,7 +5,12 @@
* Template Types: archive, category, tag, author, date
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php
index aa347ad1c2140..8db5343bb3c3a 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-archive-portfolio.php
@@ -5,7 +5,12 @@
* Template Types: archive
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php
index 4a96d748004fb..515f3e6ad778f 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-blogging.php
@@ -5,7 +5,12 @@
* Template Types: front-page, index, home
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php
index e84a5aca903c0..a214262ed494c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-business.php
@@ -5,7 +5,12 @@
* Template Types: front-page, home
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php
index 4fc4f072ba3c9..fe8a895ba8595 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-home-portfolio.php
@@ -5,7 +5,12 @@
* Template Types: front-page, home
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php
index b810186324aaf..c53ce0fc31a7b 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-index-blogging.php
@@ -5,7 +5,12 @@
* Template Types: index, home
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php
index 7b78323d2b13c..2c9417debb3d8 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-index-portfolio.php
@@ -5,7 +5,12 @@
* Template Types: index
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php b/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php
index ec9a29b8860c6..6bc185efc22a1 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-search-blogging.php
@@ -5,7 +5,12 @@
* Template Types: search
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php
index 1c60eb18e2127..d6643b2fe4a53 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-search-portfolio.php
@@ -5,7 +5,12 @@
* Template Types: search
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php b/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php
index ba54d58110ce3..7934310cbe9b6 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/template-single-portfolio.php
@@ -5,7 +5,12 @@
* Template Types: posts, single
* Viewport width: 1400
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php b/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php
index 1bac825e9e5d3..2c250f6b9f37c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/testimonial-centered.php
@@ -6,7 +6,12 @@
* Categories: testimonials, text
* Viewport width: 1300
* Description: A centered testimonial section with an avatar, name, and job title.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php b/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php
index 6ee225137dfa5..4f9f758965d39 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-alternating-images.php
@@ -5,7 +5,12 @@
* Categories: text, about
* Viewport width: 1400
* Description: A text section, then a two-column section with text in one column and an image in the other.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php
index c558518823965..c7240750f741c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement-small.php
@@ -6,7 +6,12 @@
* Keywords: mission, introduction
* Viewport width: 1200
* Description: A centered italic text statement with compact padding.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php
index ca909ca773c09..4c1ce23844a52 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-centered-statement.php
@@ -6,7 +6,12 @@
* Keywords: mission, introduction
* Viewport width: 1400
* Description: A centered text statement with a large amount of padding on all sides.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php b/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php
index 3e0c3aeecb1e7..891bfe3782527 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-faq.php
@@ -6,7 +6,12 @@
* Keywords: faq, about, frequently asked
* Viewport width: 1400
* Description: A FAQ section with a large FAQ heading and a group of questions and answers.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php b/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php
index 03310f564f214..67fa4c6c1750d 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-feature-grid-3-col.php
@@ -5,7 +5,12 @@
* Categories: text, about
* Viewport width: 1400
* Description: A feature grid of 2 rows and 3 columns with headings and text.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php b/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php
index 9116a974a3466..c95004776502c 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-project-details.php
@@ -5,7 +5,12 @@
* Categories: text, portfolio
* Viewport width: 1400
* Description: A text-only section for project details.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php b/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php
index cfcbf6dfc1260..ca32e5fb95fe0 100644
--- a/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php
+++ b/src/wp-content/themes/twentytwentyfour/patterns/text-title-left-image-right.php
@@ -5,7 +5,12 @@
* Categories: banner, about, featured
* Viewport width: 1400
* Description: A title, a paragraph and a CTA button on the left with an image on the right.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Four
+ * @since Twenty Twenty-Four 1.0
*/
+
?>
diff --git a/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php b/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php
index 15427904bd22b..ca89451d26787 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/call-to-action.php
@@ -6,8 +6,14 @@
* Keywords: Call to action
* Block Types: core/buttons
* Description: Left-aligned text with a CTA button and a separator.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/footer-default.php b/src/wp-content/themes/twentytwentythree/patterns/footer-default.php
index e3827248be5a5..cd02cde2629ac 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/footer-default.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/footer-default.php
@@ -5,8 +5,14 @@
* Categories: footer
* Block Types: core/template-part/footer
* Description: Footer with site title and powered by WordPress.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php
index ae7dbb14ca089..7c38c9b3452c1 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-404.php
@@ -3,8 +3,14 @@
* Title: Hidden 404
* Slug: twentytwentythree/hidden-404
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php
index 84d4a78779e1c..285116db20e5d 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-comments.php
@@ -3,8 +3,14 @@
* Title: Hidden Comments
* Slug: twentytwentythree/hidden-comments
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php
index 542f9bed1a947..8147dc62a4519 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-heading.php
@@ -3,8 +3,14 @@
* Title: Hidden Heading for Homepage
* Slug: twentytwentythree/hidden-heading
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.6
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php b/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php
index d3f7ae0586556..9c22497100ad7 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/hidden-no-results.php
@@ -3,8 +3,14 @@
* Title: Hidden No Results Content
* Slug: twentytwentythree/hidden-no-results-content
* Inserter: no
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentythree/patterns/post-meta.php b/src/wp-content/themes/twentytwentythree/patterns/post-meta.php
index a1a92264d5718..2282058318a5a 100644
--- a/src/wp-content/themes/twentytwentythree/patterns/post-meta.php
+++ b/src/wp-content/themes/twentytwentythree/patterns/post-meta.php
@@ -6,8 +6,14 @@
* Keywords: post meta
* Block Types: core/template-part/post-meta
* Description: Post meta information with separator on the top.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Three
+ * @since Twenty Twenty-Three 1.0
*/
+
?>
+
diff --git a/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php b/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php
index f47e26e5dd6c9..e9def1eb63614 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/block-patterns.php
@@ -2,6 +2,8 @@
/**
* Twenty Twenty-Two: Block Patterns
*
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Two
* @since Twenty Twenty-Two 1.0
*/
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php
index dab9b04a86e85..8ddd949231733 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-about-title-logo.php
@@ -1,7 +1,12 @@
__( 'Footer with text, title, and logo', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php
index c1306cbb833e0..c6e7a45a32cf9 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-blog.php
@@ -1,7 +1,12 @@
__( 'Blog footer', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php
index b2ec3626fbaf8..35928ff581f84 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-dark.php
@@ -1,7 +1,12 @@
__( 'Dark footer with title and citation', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php
index 3682643084326..1fbc4ed53825a 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-default.php
@@ -1,7 +1,12 @@
__( 'Default footer', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php
index caa44e8c34900..16211476f2bad 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-logo.php
@@ -1,7 +1,12 @@
__( 'Footer with logo and citation', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php
index d0b554249d4fd..e9ef59eaff0bc 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation-copyright.php
@@ -1,7 +1,12 @@
__( 'Footer with navigation and copyright', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php
index 79792c730df0a..5ff93771c58d4 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-navigation.php
@@ -1,7 +1,12 @@
__( 'Footer with navigation and citation', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php
index a79c1d44cd51d..12e92ede9e12f 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-images-title-citation.php
@@ -1,7 +1,12 @@
__( 'Footer with query, featured images, title, and citation', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php
index 13bb43df5c34a..f3bb899d9de3b 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-query-title-citation.php
@@ -1,7 +1,12 @@
__( 'Footer with query, title, and citation', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php
index 0c7e4ae5178e6..9119f7f203e2a 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-social-copyright.php
@@ -1,7 +1,12 @@
__( 'Footer with social links and copyright', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php
index 84d888b22d40d..30fa6b5d54475 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/footer-title-tagline-social.php
@@ -1,7 +1,12 @@
__( 'Footer with title, tagline, and social links on a dark background', 'twentytwentytwo' ),
'categories' => array( 'footer' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php
index 3255e9d7067aa..42a9072fa5ea7 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-dark.php
@@ -1,7 +1,12 @@
__( 'Divider with image and color (dark)', 'twentytwentytwo' ),
'categories' => array( 'featured' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php
index a29b8252d7be1..a7d18dd55fc19 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-divider-light.php
@@ -1,7 +1,12 @@
__( 'Divider with image and color (light)', 'twentytwentytwo' ),
'categories' => array( 'featured' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php
index 55cbdfd92d87b..711b532dbaede 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-featured-posts.php
@@ -1,7 +1,12 @@
__( 'Featured posts', 'twentytwentytwo' ),
'categories' => array( 'featured', 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php
index 0870da3951855..b681930180589 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-image-with-caption.php
@@ -1,7 +1,12 @@
__( 'Image with caption', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns', 'gallery' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php
index d7bd8168fa6a8..13db435ff9674 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-large-list-names.php
@@ -1,7 +1,12 @@
__( 'Large list of names', 'twentytwentytwo' ),
'categories' => array( 'featured', 'text' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php
index 629a0a2a12a7f..8a865b29e2fda 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-layered-images-with-duotone.php
@@ -1,7 +1,12 @@
__( 'Layered images with duotone', 'twentytwentytwo' ),
'categories' => array( 'featured', 'gallery' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php
index af680dc2cdf4b..b2c7317e8bdcf 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-list-events.php
@@ -1,7 +1,12 @@
__( 'List of events', 'twentytwentytwo' ),
'categories' => array( 'featured', 'text' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php
index b385eddf25f23..21ad117669de0 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-pricing-table.php
@@ -1,7 +1,12 @@
__( 'Pricing table', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns', 'buttons' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php
index 34137cd20a8d8..bd7194f464ad3 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-subscribe.php
@@ -1,7 +1,12 @@
__( 'Subscribe callout', 'twentytwentytwo' ),
'categories' => array( 'featured', 'buttons' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php
index 5534b6ffba701..7390b16fd7ce5 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-two-images-text.php
@@ -1,7 +1,12 @@
__( 'Two images with text', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns', 'gallery' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php
index 3b15a5206616e..21d96e0125664 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-header-details.php
@@ -1,7 +1,12 @@
__( 'Video with header and details', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php
index 22ed24860b6ad..6cb15b5c5d5c5 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-video-trailer.php
@@ -1,7 +1,12 @@
__( 'Video trailer', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php
index 373b0f48bb1cc..4bb7be79b19bb 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/general-wide-image-intro-buttons.php
@@ -1,7 +1,12 @@
__( 'Wide image with introduction and buttons', 'twentytwentytwo' ),
'categories' => array( 'featured', 'columns' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php
index b10ed5b9b6350..14c51664f8d34 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo-black-background.php
@@ -1,7 +1,12 @@
__( 'Header with centered logo and background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php
index a116c390867ff..6b6d132aa5ae1 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-logo.php
@@ -1,7 +1,12 @@
__( 'Header with centered logo', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php
index cb1b310e110d8..3f042864dbdf3 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-centered-title-navigation-social.php
@@ -1,7 +1,12 @@
__( 'Centered header with navigation, social links, and background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php
index ff25793275a3c..1ab974d3ef893 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-default.php
@@ -1,7 +1,12 @@
__( 'Default header', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php
index 55422aa41bf31..aa03e9922ca56 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background-overlay.php
@@ -1,7 +1,12 @@
__( 'Header with image background and overlay', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php
index 4afd36c551ace..fcbb3c4e4b923 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-image-background.php
@@ -1,7 +1,12 @@
__( 'Header with image background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php
index 5e4fea69ed5d4..dec8d972eef10 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-large-dark.php
@@ -1,7 +1,12 @@
__( 'Large header with dark background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php
index 3ddea755f02c2..ec71a58392491 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-gray-background.php
@@ -1,7 +1,12 @@
__( 'Logo and navigation header with background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php
index 299586f0ece4f..c9e539e0b4950 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-offset-tagline.php
@@ -1,7 +1,12 @@
__( 'Logo, navigation, and offset tagline Header', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php
index c9b0f886d1dda..1fcd253ae1c7e 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-logo-navigation-social-black-background.php
@@ -1,7 +1,12 @@
__( 'Logo, navigation, and social links header with background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php
index 832f414220921..6db5cef7eb7f8 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-small-dark.php
@@ -1,7 +1,12 @@
__( 'Small header with dark background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php
index bd36bbf4b9fad..3b8c7a48133b5 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-stacked.php
@@ -1,7 +1,12 @@
__( 'Logo and navigation header', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php
index 468a696dd709c..5b1a7507dd2f2 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-green-background.php
@@ -1,7 +1,12 @@
__( 'Text-only header with background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php
index 4a4913ec472cf..5cdc16e7cd4ad 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-salmon-background.php
@@ -1,7 +1,12 @@
__( 'Text-only header with background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php
index d529fe851dcff..f73c60b69d8b3 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-text-only-with-tagline-black-background.php
@@ -1,7 +1,12 @@
__( 'Text-only header with tagline and background', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php
index 5c780697dabe5..ccb27a87cb64f 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-and-button.php
@@ -1,7 +1,12 @@
__( 'Title and button header', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php
index 384cda5368337..cf74bfd2540d5 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-title-navigation-social.php
@@ -1,7 +1,12 @@
__( 'Title, navigation, and social links header', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php
index 40e633a2682a6..477a138473928 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/header-with-tagline.php
@@ -1,7 +1,12 @@
__( 'Header with tagline', 'twentytwentytwo' ),
'categories' => array( 'header' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php
index 9cfd50d56e0c1..959ae2bb8ab5d 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-404.php
@@ -1,7 +1,12 @@
__( '404 content', 'twentytwentytwo' ),
'inserter' => false,
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php
index cfc9db579450a..6da806d3996b0 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-bird.php
@@ -4,7 +4,12 @@
*
* This pattern is used only to reference a dynamic image URL.
* It does not appear in the inserter.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Two
+ * @since Twenty Twenty-Two 1.0
*/
+
return array(
'title' => __( 'Heading and bird image', 'twentytwentytwo' ),
'inserter' => false,
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php
index d3f1788ac56d4..d7e2ec77c29b8 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/hidden-heading-and-bird.php
@@ -5,7 +5,12 @@
* This pattern is used only for translation
* and to reference a dynamic image URL. It does
* not appear in the inserter.
+ *
+ * @package WordPress
+ * @subpackage Twenty_Twenty_Two
+ * @since Twenty Twenty-Two 1.0
*/
+
return array(
'title' => __( 'Heading and bird image', 'twentytwentytwo' ),
'inserter' => false,
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php
index 52dbd0e2eb895..e1aaa1c6d3911 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-large-image-and-buttons.php
@@ -1,7 +1,12 @@
__( 'About page with large image and buttons', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages', 'buttons' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php
index 943524f49db49..7c1a8cab38a0f 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links-dark.php
@@ -1,7 +1,12 @@
__( 'About page links (dark)', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages', 'buttons' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php
index 3cf27173db9cd..841d872ee7f04 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-links.php
@@ -1,7 +1,12 @@
__( 'About page links', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages', 'buttons' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php
index f5c769a6ca801..0dd5f7fac8eae 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-left.php
@@ -1,7 +1,12 @@
__( 'About page with media on the left', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php
index 8f291150e17a1..7559aded1f7a7 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-media-right.php
@@ -1,7 +1,12 @@
__( 'About page with media on the right', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php
index fe0715965962b..50ede062d8c0a 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-simple-dark.php
@@ -1,7 +1,12 @@
__( 'Simple dark about page', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php
index 6c07725dbaf7c..983a743919c78 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-about-solid-color.php
@@ -1,7 +1,12 @@
__( 'About page on solid color background', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php
index 20e08e01ff03c..4b35a161487ab 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-and-text.php
@@ -1,7 +1,12 @@
__( 'Page layout with image and text', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php
index 0ee87f11ff8bc..c2beb20df437f 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-image-text-and-video.php
@@ -1,7 +1,12 @@
__( 'Page layout with image, text and video', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php
index b24c9c57dc0d6..e32ced62f584e 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-layout-two-columns.php
@@ -1,7 +1,12 @@
__( 'Page layout with two columns', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php
index 070aef04b8622..afa82baad3889 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts-right.php
@@ -1,7 +1,12 @@
__( 'Blog posts with right sidebar', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php
index e535a81a76a0b..7107b28a4a120 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-blog-posts.php
@@ -1,7 +1,12 @@
__( 'Blog posts with left sidebar', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php
index b4aa6a560a0a6..4bd63ffef9ba8 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-grid-posts.php
@@ -1,7 +1,12 @@
__( 'Grid of posts with left sidebar', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php
index 9cbcef2a7a97e..035c9c8104717 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/page-sidebar-poster.php
@@ -1,7 +1,12 @@
__( 'Poster with right sidebar', 'twentytwentytwo' ),
'categories' => array( 'twentytwentytwo_pages' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php
index 7cebaccf75f5d..ed41f2f2c2807 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-default.php
@@ -1,7 +1,12 @@
__( 'Default posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php
index f695eba354047..c1eeb9e2dadef 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-grid.php
@@ -1,7 +1,12 @@
__( 'Grid of posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php
index e5672e3c1cfe9..4c0b0e26049ae 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-image-grid.php
@@ -1,7 +1,12 @@
__( 'Grid of image posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php
index c5da08ab749b6..ca67baa7d6097 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-irregular-grid.php
@@ -1,7 +1,12 @@
__( 'Irregular grid of posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php
index 6ff8afadfe15d..2732a9766fbd1 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-large-titles.php
@@ -1,7 +1,12 @@
__( 'Large post titles', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php
index 4b089846dd292..4921ecba65f48 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-simple-blog.php
@@ -1,7 +1,12 @@
__( 'Simple blog posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
diff --git a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php
index a8f84d3cf2a91..b1fdab67b0003 100644
--- a/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php
+++ b/src/wp-content/themes/twentytwentytwo/inc/patterns/query-text-grid.php
@@ -1,7 +1,12 @@
__( 'Text-based grid of posts', 'twentytwentytwo' ),
'categories' => array( 'query' ),
From fd04681823670ddf190f326db39c60e99113e76f Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Tue, 28 Jul 2026 13:26:01 +0000
Subject: [PATCH 048/149] Administration: Add aria-label attributes to row
headers in more list tables.
Follow-up to [62838].
Provides screen readers with a cleaner name as the row header name, preventing them from computing the name from the full cell content.
Developed in https://github.com/WordPress/wordpress-develop/pull/12683
Props afercia, mukesh27, joedolson.
Fixes #32892.
git-svn-id: https://develop.svn.wordpress.org/trunk@62868 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/class-wp-links-list-table.php | 18 ++++++++++++++++++
src/wp-admin/includes/class-wp-list-table.php | 2 +-
.../includes/class-wp-media-list-table.php | 19 +++++++++++++++++++
.../includes/class-wp-ms-sites-list-table.php | 19 +++++++++++++++++++
.../includes/class-wp-ms-users-list-table.php | 15 +++++++++++++++
.../includes/class-wp-posts-list-table.php | 4 ++--
...rivacy-data-export-requests-list-table.php | 15 +++++++++++++++
...ivacy-data-removal-requests-list-table.php | 15 +++++++++++++++
.../includes/class-wp-terms-list-table.php | 16 ++++++++++++++++
9 files changed, 120 insertions(+), 3 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-links-list-table.php b/src/wp-admin/includes/class-wp-links-list-table.php
index de116ecf944bc..ae1f50175c35b 100644
--- a/src/wp-admin/includes/class-wp-links-list-table.php
+++ b/src/wp-admin/includes/class-wp-links-list-table.php
@@ -364,4 +364,22 @@ protected function handle_row_actions( $item, $column_name, $primary ) {
return $this->row_actions( $actions );
}
+
+ /**
+ * Returns a clean label for the primary (Name) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the link name as the row header name,
+ * preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param object $link The current link object.
+ * @return string The link name.
+ */
+ protected function get_primary_column_aria_label( $link ) {
+ $link_name = html_entity_decode( $link->link_name, ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $link_name = wp_strip_all_tags( $link_name );
+
+ return $link_name;
+ }
}
diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php
index 6ebae0e98eb86..5e6bcdb0d237c 100644
--- a/src/wp-admin/includes/class-wp-list-table.php
+++ b/src/wp-admin/includes/class-wp-list-table.php
@@ -1784,7 +1784,7 @@ protected function column_cb( $item ) {}
* identifier (e.g. post title, plugin name, username). Return an empty string
* to omit the attribute.
*
- * @since 6.9.0
+ * @since 7.1.0
*
* @param object|array $item The current item.
* @return string The aria-label value, or an empty string.
diff --git a/src/wp-admin/includes/class-wp-media-list-table.php b/src/wp-admin/includes/class-wp-media-list-table.php
index a14b498ea525b..feac4753d3e92 100644
--- a/src/wp-admin/includes/class-wp-media-list-table.php
+++ b/src/wp-admin/includes/class-wp-media-list-table.php
@@ -932,4 +932,23 @@ protected function handle_row_actions( $item, $column_name, $primary ) {
return $this->row_actions( $actions );
}
+
+ /**
+ * Returns a clean label for the primary (File) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the attachment title as the row header
+ * name, preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_Post $post The current WP_Post object.
+ * @return string The attachment title.
+ */
+ protected function get_primary_column_aria_label( $post ) {
+ // The title may contain HTML. The printed aria-label uses esc_attr() later.
+ $attachment_title = html_entity_decode( _draft_or_post_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $attachment_title = wp_strip_all_tags( $attachment_title );
+
+ return $attachment_title;
+ }
}
diff --git a/src/wp-admin/includes/class-wp-ms-sites-list-table.php b/src/wp-admin/includes/class-wp-ms-sites-list-table.php
index f4a937962ed19..1ffa24ea24106 100644
--- a/src/wp-admin/includes/class-wp-ms-sites-list-table.php
+++ b/src/wp-admin/includes/class-wp-ms-sites-list-table.php
@@ -884,4 +884,23 @@ protected function handle_row_actions( $item, $column_name, $primary ) {
return $this->row_actions( $actions );
}
+
+ /**
+ * Returns a clean label for the primary (URL) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the site title as the row header name,
+ * preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param array $blog The current site properties array.
+ * @return string The site title, or the site URL (domain + path) if the title is empty.
+ */
+ protected function get_primary_column_aria_label( $blog ) {
+ $blog_name = html_entity_decode( (string) get_blog_option( $blog['blog_id'], 'blogname', '' ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $blog_name = wp_strip_all_tags( $blog_name );
+
+ // Fall back to the blog URL and path if the blog name is empty.
+ return '' !== $blog_name ? $blog_name : untrailingslashit( $blog['domain'] . $blog['path'] );
+ }
}
diff --git a/src/wp-admin/includes/class-wp-ms-users-list-table.php b/src/wp-admin/includes/class-wp-ms-users-list-table.php
index cc24dc6dcff3e..145299bcc26cb 100644
--- a/src/wp-admin/includes/class-wp-ms-users-list-table.php
+++ b/src/wp-admin/includes/class-wp-ms-users-list-table.php
@@ -564,4 +564,19 @@ protected function handle_row_actions( $item, $column_name, $primary ) {
return $this->row_actions( $actions );
}
+
+ /**
+ * Returns a clean label for the primary (Username) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the user login as the row header name,
+ * preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_User $user The current WP_User object.
+ * @return string The user login.
+ */
+ protected function get_primary_column_aria_label( $user ) {
+ return $user->user_login;
+ }
}
diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php
index 3795495d6d21a..8a319986766b8 100644
--- a/src/wp-admin/includes/class-wp-posts-list-table.php
+++ b/src/wp-admin/includes/class-wp-posts-list-table.php
@@ -1129,7 +1129,7 @@ protected function _column_title( $post, $classes, $data, $primary ) {
* preventing them from computing the name from the full cell content
* (which includes row action links, post states, and possibly an excerpt).
*
- * @since 6.9.0
+ * @since 7.1.0
*
* @param WP_Post $item The current post object.
* @return string The post title, or 'no title' if no title.
@@ -1215,7 +1215,7 @@ public function column_title( $post ) {
/* translators: %s: Parent post title. */
esc_html( sprintf( __( 'Child of %s' ), wp_strip_all_tags( $parent_title ) ) )
);
- $hierarchy_nolink = sprintf(
+ $hierarchy_nolink = sprintf(
' (%2$s) ',
esc_attr( $hierarchy_id ),
/* translators: %s: Parent post title. */
diff --git a/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php b/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php
index aa68c84649e2f..c7ce460f75a70 100644
--- a/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php
+++ b/src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php
@@ -157,4 +157,19 @@ public function column_next_steps( $item ) {
break;
}
}
+
+ /**
+ * Returns a clean label for the primary (Requester) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the item email as the row header name,
+ * preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_User_Request $item Item being shown.
+ * @return string The user request item email.
+ */
+ protected function get_primary_column_aria_label( $item ) {
+ return $item->email;
+ }
}
diff --git a/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php b/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php
index 716535160f12d..36d8ba5384590 100644
--- a/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php
+++ b/src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php
@@ -164,4 +164,19 @@ public function column_next_steps( $item ) {
break;
}
}
+
+ /**
+ * Returns a clean label for the primary (Requester) column's row header `aria-label`.
+ *
+ * Provides screen readers with just the item email as the row header name,
+ * preventing them from computing the name from the full cell content.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_User_Request $item Item being shown.
+ * @return string The user request item email.
+ */
+ protected function get_primary_column_aria_label( $item ) {
+ return $item->email;
+ }
}
diff --git a/src/wp-admin/includes/class-wp-terms-list-table.php b/src/wp-admin/includes/class-wp-terms-list-table.php
index 75e37e45d5c4f..561888fed8394 100644
--- a/src/wp-admin/includes/class-wp-terms-list-table.php
+++ b/src/wp-admin/includes/class-wp-terms-list-table.php
@@ -751,4 +751,20 @@ public function inline_edit() {
name;
+ }
}
From 6e37bf1b525d6e4b099f50672cb6531b53454725 Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Tue, 28 Jul 2026 15:38:05 +0000
Subject: [PATCH 049/149] Themes: Fix inconsistent navigation to the previous
theme in the Theme browser.
Prevents unintended 'wrap-around' behavior in the Theme browser overlay when navigating to the previous theme while already on the first theme in the collection. The navigation now stops at the first theme. This is consistent with the navigation to the next theme, which stops at the last theme.
Developed in https://github.com/WordPress/wordpress-develop/pull/12696
Props joedolson, afercia.
Fixes #65715.
git-svn-id: https://develop.svn.wordpress.org/trunk@62869 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/_enqueues/wp/theme.js | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/js/_enqueues/wp/theme.js b/src/js/_enqueues/wp/theme.js
index 56107ee475057..9bb2c648c4128 100644
--- a/src/js/_enqueues/wp/theme.js
+++ b/src/js/_enqueues/wp/theme.js
@@ -1334,12 +1334,20 @@ themes.view.Themes = wp.Backbone.View.extend({
*/
previous: function( args ) {
var self = this,
- model, previousModel;
+ model, previousModel, index;
// Get the current theme.
model = self.collection.get( args[0] );
+
+ index = self.collection.indexOf( model );
+
+ // Bail early if the current theme is the first one or the model does not exist.
+ if ( index <= 0 ) {
+ return;
+ }
+
// Find the previous model within the collection.
- previousModel = self.collection.at( self.collection.indexOf( model ) - 1 );
+ previousModel = self.collection.at( index - 1 );
if ( previousModel !== undefined ) {
From f3e0f6b2a8c2a7609bf36db57c2c9f8d26d0246a Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Tue, 28 Jul 2026 16:23:21 +0000
Subject: [PATCH 050/149] Tests: Use null coalescing operator over `isset()`
checks.
This commit replaces verbose `isset()`-and-return blocks with the `??` operator in the object cache helper and font-face test dataset.
Follow-up to [40561], [56500], [59260].
Props Soean, mukesh27.
See #64894.
git-svn-id: https://develop.svn.wordpress.org/trunk@62870 602fd350-edb4-49c9-b593-d223f7449a82
---
tests/phpunit/includes/object-cache.php | 6 +-----
.../font-face/wp-font-face-tests-dataset.php | 18 +++---------------
2 files changed, 4 insertions(+), 20 deletions(-)
diff --git a/tests/phpunit/includes/object-cache.php b/tests/phpunit/includes/object-cache.php
index 142a310f6f26c..14224f967c767 100644
--- a/tests/phpunit/includes/object-cache.php
+++ b/tests/phpunit/includes/object-cache.php
@@ -2364,11 +2364,7 @@ public function add_non_persistent_groups( $groups ) {
public function get_from_runtime_cache( $key, $group ) {
$derived_key = $this->buildKey( $key, $group );
- if ( isset( $this->cache[ $derived_key ] ) ) {
- return $this->cache[ $derived_key ];
- }
-
- return false;
+ return $this->cache[ $derived_key ] ?? false;
}
/**
diff --git a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php
index d410acb7c4124..c0d7f9e328016 100644
--- a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php
+++ b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php
@@ -308,11 +308,7 @@ public function get_expected_fonts_for_fonts_block_theme( $key = '' ) {
);
}
- if ( isset( $data[ $key ] ) ) {
- return $data[ $key ];
- }
-
- return $data;
+ return $data[ $key ] ?? $data;
}
public static function get_custom_font_families( $key = '' ) {
@@ -397,11 +393,7 @@ public static function get_custom_font_families( $key = '' ) {
);
}
- if ( isset( $data[ $key ] ) ) {
- return $data[ $key ];
- }
-
- return $data;
+ return $data[ $key ] ?? $data;
}
public static function get_custom_style_variations( $key = '' ) {
@@ -488,10 +480,6 @@ public static function get_custom_style_variations( $key = '' ) {
);
}
- if ( isset( $data[ $key ] ) ) {
- return $data[ $key ];
- }
-
- return $data;
+ return $data[ $key ] ?? $data;
}
}
From 7981ab89fe316455a01c17d843e4089834974bee Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Tue, 28 Jul 2026 16:33:51 +0000
Subject: [PATCH 051/149] Build/Test Tools: Copy local environment
configuration synchronously.
On a fresh checkout, `start.js` copied `.env.example` asynchronously and
then immediately loaded `.env`, so the read could win the race and leave
configuration unset for that process. Copy synchronously, guarded by an
existsSync check, so `.env` exists before `dotenv.config()` runs and an
existing file is left untouched. Dropping the swallow-all callback also
lets a real copy error surface instead of being logged as "already
exists".
Developed in: https://github.com/WordPress/wordpress-develop/pull/12697
Props jonsurrell, lucasbustamante, mukesh27, adrianmoldovanwp.
See #65716.
git-svn-id: https://develop.svn.wordpress.org/trunk@62871 602fd350-edb4-49c9-b593-d223f7449a82
---
tools/local-env/scripts/start.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js
index 66559d4c10b85..b796b70c68bd1 100644
--- a/tools/local-env/scripts/start.js
+++ b/tools/local-env/scripts/start.js
@@ -4,12 +4,12 @@ const dotenv = require( 'dotenv' );
const dotenvExpand = require( 'dotenv-expand' );
const { execSync, spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );
-const { constants, copyFile } = require( 'node:fs' );
+const { copyFileSync, existsSync } = require( 'node:fs' );
// Copy the default .env file when one is not present.
-copyFile( '.env.example', '.env', constants.COPYFILE_EXCL, () => {
- console.log( '.env file already exists. .env.example was not copied.' );
-});
+if ( ! existsSync( '.env' ) ) {
+ copyFileSync( '.env.example', '.env' );
+}
dotenvExpand.expand( dotenv.config() );
From d5169cdfa69084ede33bf03947b7cd3fb265220d Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Tue, 28 Jul 2026 17:38:01 +0000
Subject: [PATCH 052/149] Build/Test Tools: Update the WordPress Coding
Standards to 3.4.1.
Bump the `wp-coding-standards/wpcs` dev dependency constraint from `~3.4.0` to `~3.4.1` to pick up the latest patch release.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12723
Props garyj.
Fixes #65739.
git-svn-id: https://develop.svn.wordpress.org/trunk@62874 602fd350-edb4-49c9-b593-d223f7449a82
---
composer.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/composer.json b/composer.json
index fe2963b7a3574..7c8ec9b4d2789 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
"require-dev": {
"composer/ca-bundle": "1.5.12",
"squizlabs/php_codesniffer": "3.13.5",
- "wp-coding-standards/wpcs": "~3.4.0",
+ "wp-coding-standards/wpcs": "~3.4.1",
"phpcompatibility/phpcompatibility-wp": "~2.1.3",
"phpstan/phpstan": "2.2.5",
"phpstan/phpstan-phpunit": "2.0.18",
From ef7eb039bdb23e5787122fabce6d8e8fef170cf8 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Tue, 28 Jul 2026 17:52:21 +0000
Subject: [PATCH 053/149] Administration: Fix focus/hover styles on collapse
menu button.
The new `:focus` and `:hover` styles for the admin menu were only applied to links, and not extended to the collapse menu trigger, which is a `button`. Additionally, the entire hover/focus state change was missing in some alternate admin color schemes.
Add shape-based focus and hover states to collapse menu button to match the rest of the admin menu and add expected color changes across all admin schemes.
Developed in https://github.com/WordPress/wordpress-develop/pull/12722
Props afercia, iamchitti, dhruvang21, fushar, habiburdev, joedolson.
Fixes #65726.
git-svn-id: https://develop.svn.wordpress.org/trunk@62875 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/admin-menu.css | 6 ++++--
src/wp-admin/css/colors/_admin.scss | 5 +++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/wp-admin/css/admin-menu.css b/src/wp-admin/css/admin-menu.css
index 2b665f583484f..3747613282be1 100644
--- a/src/wp-admin/css/admin-menu.css
+++ b/src/wp-admin/css/admin-menu.css
@@ -470,12 +470,14 @@ ul#adminmenu > li.current > a.current:after {
cursor: pointer;
}
-#collapse-button:hover {
+#collapse-button:hover,
+#collapse-button:focus {
color: #72aee6;
+ box-shadow: inset 4px 0 0 0 currentColor;
+ transition: box-shadow .1s linear;
}
#collapse-button:focus {
- color: #72aee6;
/* Only visible in Windows High Contrast mode */
outline: 1px solid transparent;
outline-offset: -1px;
diff --git a/src/wp-admin/css/colors/_admin.scss b/src/wp-admin/css/colors/_admin.scss
index 313666e3ded73..2d10323c2749d 100644
--- a/src/wp-admin/css/colors/_admin.scss
+++ b/src/wp-admin/css/colors/_admin.scss
@@ -375,12 +375,13 @@ ul#adminmenu > li.current > a.current:after {
/* Admin Menu: collapse button */
#collapse-button {
- color: variables.$menu-collapse-text;
+ color: variables.$menu-collapse-text;
}
#collapse-button:hover,
#collapse-button:focus {
- color: variables.$menu-submenu-focus-text;
+ color: variables.$menu-highlight-text;
+ background: variables.$menu-highlight-background;
}
/* Admin Bar */
From 5b9b41fb3c11a03b92d762470cdce1efa7967cf5 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Tue, 28 Jul 2026 20:05:34 +0000
Subject: [PATCH 054/149] Accessibility: In modal collections use alt plus
arrow for navigation.
The theme browsers and the media attachment browser supported left and right arrow keys to trigger navigation. These interfered with screen reader reading commands, which also use the left and right arrow keys, forcing navigation instead of allowing the screen reader user to read additional content.
Require that the `alt` key is also pressed to trigger navigation. Also add debounced screen reader announcements to notify users of the new modal context.
Developed in https://github.com/WordPress/wordpress-develop/pull/11560
Props joedolson, sukhendu2002, afercia.
Fixes #63760.
git-svn-id: https://develop.svn.wordpress.org/trunk@62878 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/_enqueues/wp/customize/controls.js | 20 +++++-
src/js/_enqueues/wp/theme.js | 68 ++++++++++++++-----
src/js/media/views/frame/edit-attachments.js | 56 ++++++++++++---
src/wp-admin/theme-install.php | 2 +
src/wp-admin/themes.php | 9 ++-
src/wp-admin/upload.php | 2 +-
.../class-wp-customize-manager.php | 2 +-
src/wp-includes/media.php | 2 +
tests/qunit/wp-admin/js/theme.js | 59 ++++++++++++++--
9 files changed, 183 insertions(+), 37 deletions(-)
diff --git a/src/js/_enqueues/wp/customize/controls.js b/src/js/_enqueues/wp/customize/controls.js
index a5846d45f687c..00123f9141c30 100644
--- a/src/js/_enqueues/wp/customize/controls.js
+++ b/src/js/_enqueues/wp/customize/controls.js
@@ -1701,6 +1701,7 @@
filtersHeight: 0,
headerContainer: null,
updateCountDebounced: null,
+ announceThemeDebounced: null,
/**
* wp.customize.ThemesSection
@@ -1724,6 +1725,13 @@
section.$body = $( document.body );
api.Section.prototype.initialize.call( section, id, options );
section.updateCountDebounced = _.debounce( section.updateCount, 500 );
+ section.announceThemeDebounced = _.debounce( function( name ) {
+ if ( ! name ) {
+ return;
+ }
+
+ wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', name ) );
+ }, 500 );
},
/**
@@ -1777,13 +1785,20 @@
return;
}
+ // Require the alt key for arrow events.
+ if ( 27 !== event.keyCode && ! event.altKey ) {
+ return;
+ }
+
// Pressing the right arrow key fires a theme:next event.
if ( 39 === event.keyCode ) {
+ event.preventDefault(); // Prevent browser from triggering history shortcuts.
section.nextTheme();
}
// Pressing the left arrow key fires a theme:previous event.
if ( 37 === event.keyCode ) {
+ event.preventDefault(); // Prevent browser from triggering history shortcuts.
section.previousTheme();
}
@@ -2602,7 +2617,8 @@
section.$body.addClass( 'modal-open' );
section.containFocus( section.overlay );
section.updateLimits();
- wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) );
+
+ section.announceThemeDebounced( theme.name );
if ( callback ) {
callback();
}
@@ -2620,6 +2636,8 @@
section.$body.removeClass( 'modal-open' );
section.overlay.fadeOut( 'fast' );
api.control( section.params.action + '_theme_' + section.currentTheme ).container.find( '.theme' ).focus();
+ // Cancel any pending navigation announcement.
+ section.announceThemeDebounced.cancel();
},
/**
diff --git a/src/js/_enqueues/wp/theme.js b/src/js/_enqueues/wp/theme.js
index 9bb2c648c4128..cfcea1fcc58b7 100644
--- a/src/js/_enqueues/wp/theme.js
+++ b/src/js/_enqueues/wp/theme.js
@@ -16,6 +16,30 @@ themes = wp.themes = wp.themes || {};
themes.data = _wpThemeSettings;
l10n = themes.data.l10n;
+/**
+ * Announces to screen readers the theme shown after previous/next navigation.
+ *
+ * @since 7.1.0
+ *
+ * @param {Object} model The theme model.
+ * @return {void}
+ */
+themes.announceThemeDebounced = _.debounce( function( model ) {
+ var name;
+
+ if ( ! model ) {
+ return;
+ }
+
+ name = model.get( 'name' ) || model.get( 'id' );
+
+ if ( ! name ) {
+ return;
+ }
+
+ wp.a11y.speak( l10n.themeViewed.replace( '%s', name ) );
+}, 500 );
+
// Shortcut for isInstall check.
themes.isInstall = !! themes.data.settings.isInstall;
@@ -549,6 +573,7 @@ themes.view.Theme = wp.Backbone.View.extend({
preview.render();
this.setNavButtonsState();
$( '.next-theme' ).trigger( 'focus' );
+ themes.announceThemeDebounced( self.current );
})
.listenTo( preview, 'theme:previous', function() {
@@ -579,6 +604,7 @@ themes.view.Theme = wp.Backbone.View.extend({
preview.render();
this.setNavButtonsState();
$( '.previous-theme' ).trigger( 'focus' );
+ themes.announceThemeDebounced( self.current );
});
this.listenTo( preview, 'preview:close', function() {
@@ -769,6 +795,9 @@ themes.view.Details = wp.Backbone.View.extend({
}
});
}
+
+ // Cancel any pending navigation announcement.
+ themes.announceThemeDebounced.cancel();
},
// Handles .disabled classes for next/previous buttons.
@@ -909,7 +938,7 @@ themes.view.Preview = themes.view.Details.extend({
'click .devices button': 'previewDevice',
'click .previous-theme': 'previousTheme',
'click .next-theme': 'nextTheme',
- 'keyup': 'keyEvent',
+ 'keydown': 'keyEvent',
'click .theme-install': 'installTheme'
},
@@ -967,6 +996,9 @@ themes.view.Preview = themes.view.Details.extend({
this.trigger( 'preview:close' );
this.undelegateEvents();
this.unbind();
+
+ // Cancel any pending navigation announcement.
+ themes.announceThemeDebounced.cancel();
return false;
},
@@ -1012,18 +1044,20 @@ themes.view.Preview = themes.view.Details.extend({
this.close();
}
- // Return if Ctrl + Shift or Shift key pressed
- if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) {
+ // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
+ if ( ! event.altKey ) {
return;
}
// The right arrow key, next theme.
if ( event.keyCode === 39 ) {
- _.once( this.nextTheme() );
+ event.preventDefault();
+ this.nextTheme();
}
// The left arrow key, previous theme.
if ( event.keyCode === 37 ) {
+ event.preventDefault();
this.previousTheme();
}
},
@@ -1111,7 +1145,7 @@ themes.view.Themes = wp.Backbone.View.extend({
} );
// Bind keyboard events.
- $( 'body' ).on( 'keyup', function( event ) {
+ $( 'body' ).on( 'keydown.wp-themes', function( event ) {
if ( ! self.overlay ) {
return;
}
@@ -1121,25 +1155,27 @@ themes.view.Themes = wp.Backbone.View.extend({
return;
}
- // Return if Ctrl + Shift or Shift key pressed
- if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) {
+ // Pressing the escape key fires a theme:collapse event.
+ if ( event.keyCode === 27 ) {
+ self.overlay.collapse( event );
+ }
+
+ // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
+ if ( ! event.altKey ) {
return;
}
- // Pressing the right arrow key fires a theme:next event.
+ // Pressing Alt + right arrow key fires a theme:next event.
if ( event.keyCode === 39 ) {
+ event.preventDefault();
self.overlay.nextTheme();
}
- // Pressing the left arrow key fires a theme:previous event.
+ // Pressing Alt + left arrow key fires a theme:previous event.
if ( event.keyCode === 37 ) {
+ event.preventDefault();
self.overlay.previousTheme();
}
-
- // Pressing the escape key fires a theme:collapse event.
- if ( event.keyCode === 27 ) {
- self.overlay.collapse( event );
- }
});
},
@@ -1322,7 +1358,7 @@ themes.view.Themes = wp.Backbone.View.extend({
// Trigger a route update for the current model.
self.theme.trigger( 'theme:expand', nextModel.cid );
-
+ themes.announceThemeDebounced( nextModel );
}
},
@@ -1357,7 +1393,7 @@ themes.view.Themes = wp.Backbone.View.extend({
// Trigger a route update for the current model.
self.theme.trigger( 'theme:expand', previousModel.cid );
-
+ themes.announceThemeDebounced( previousModel );
}
},
diff --git a/src/js/media/views/frame/edit-attachments.js b/src/js/media/views/frame/edit-attachments.js
index 250f1b5214665..f6bb4b8afa2cc 100644
--- a/src/js/media/views/frame/edit-attachments.js
+++ b/src/js/media/views/frame/edit-attachments.js
@@ -1,5 +1,6 @@
var Frame = wp.media.view.Frame,
MediaFrame = wp.media.view.MediaFrame,
+ l10n = wp.media.view.l10n,
$ = jQuery,
EditAttachments;
@@ -33,6 +34,30 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta
'click .right': 'nextMediaItem'
},
+ /**
+ * Announces to screen readers the attachment shown after previous/next navigation.
+ *
+ * @since 7.1.0
+ *
+ * @param {Object} model The attachment model.
+ * @return {void}
+ */
+ announceMediaItemDebounced: _.debounce( function( model ) {
+ var title;
+
+ if ( ! model ) {
+ return;
+ }
+
+ title = model.get( 'title' ) || model.get( 'filename' ) || model.get( 'id' );
+
+ if ( ! title ) {
+ return;
+ }
+
+ wp.a11y.speak( l10n.mediaItemViewed.replace( '%s', title ) );
+ }, 500 ),
+
initialize: function() {
Frame.prototype.initialize.apply( this, arguments );
@@ -96,6 +121,8 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta
// Move focus back to the original item in the grid if possible.
$( 'li.attachment[data-id="' + this.model.get( 'id' ) +'"]' ).trigger( 'focus' );
this.resetRoute();
+ // Cancel any pending navigation announcement.
+ this.announceMediaItemDebounced.cancel();
}, this ) );
// Set this frame as the modal's content.
@@ -202,26 +229,34 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta
* Click handler to switch to the previous media item.
*/
previousMediaItem: function() {
+ var model;
+
if ( ! this.hasPrevious() ) {
return;
}
- this.trigger( 'refresh', this.library.at( this.getCurrentIndex() - 1 ) );
+ model = this.library.at( this.getCurrentIndex() - 1 );
+ this.trigger( 'refresh', model );
// Move focus to the Previous button. When there are no more items, to the Next button.
this.focusNavButton( this.hasPrevious() ? '.left' : '.right' );
+ this.announceMediaItemDebounced( model );
},
/**
* Click handler to switch to the next media item.
*/
nextMediaItem: function() {
+ var model;
+
if ( ! this.hasNext() ) {
return;
}
- this.trigger( 'refresh', this.library.at( this.getCurrentIndex() + 1 ) );
+ model = this.library.at( this.getCurrentIndex() + 1 );
+ this.trigger( 'refresh', model );
// Move focus to the Next button. When there are no more items, to the Previous button.
this.focusNavButton( this.hasNext() ? '.right' : '.left' );
+ this.announceMediaItemDebounced( model );
},
/**
@@ -247,25 +282,28 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta
return ( this.getCurrentIndex() - 1 ) > -1;
},
/**
- * Respond to the keyboard events: right arrow, left arrow, except when
- * focus is in a textarea or input field.
+ * Respond to the keyboard events: Alt + right arrow, Alt + left arrow,
+ * except when focus is in a form field. Requires the Alt modifier key to
+ * avoid interfering with screen reader navigation.
*/
keyEvent: function( event ) {
- if ( ( 'INPUT' === event.target.nodeName || 'TEXTAREA' === event.target.nodeName ) && ! event.target.disabled ) {
+ if ( ( 'INPUT' === event.target.nodeName || 'TEXTAREA' === event.target.nodeName || 'SELECT' === event.target.nodeName ) && ! event.target.disabled ) {
return;
}
- // Return if Ctrl + Shift or Shift key pressed
- if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) {
+ // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
+ if ( ! event.altKey ) {
return;
}
- // The right arrow key.
+ // Alt + right arrow key.
if ( 39 === event.keyCode ) {
+ event.preventDefault();
this.nextMediaItem();
}
- // The left arrow key.
+ // Alt + left arrow key.
if ( 37 === event.keyCode ) {
+ event.preventDefault();
this.previousMediaItem();
}
},
diff --git a/src/wp-admin/theme-install.php b/src/wp-admin/theme-install.php
index fc24334abff85..8e6fc5d1eea2c 100644
--- a/src/wp-admin/theme-install.php
+++ b/src/wp-admin/theme-install.php
@@ -67,6 +67,8 @@
/* translators: %d: Number of themes. */
'themesFound' => __( 'Number of Themes found: %d' ),
'noThemesFound' => __( 'No themes found. Try a different search.' ),
+ /* translators: %s: Theme name. */
+ 'themeViewed' => __( 'Theme details: %s' ),
'collapseSidebar' => __( 'Collapse Sidebar' ),
'expandSidebar' => __( 'Expand Sidebar' ),
/* translators: Hidden accessibility text. */
diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php
index a9f24765ce742..ac2cd4a9824cb 100644
--- a/src/wp-admin/themes.php
+++ b/src/wp-admin/themes.php
@@ -131,9 +131,10 @@
if ( current_user_can( 'switch_themes' ) ) {
$help_overview = '' . __( 'This screen is used for managing your installed themes. Aside from the default theme(s) included with your WordPress installation, themes are designed and developed by third parties.' ) . '
' .
'' . __( 'From this screen you can:' ) . '
' .
- '' . __( 'Hover or tap to see Activate and Live Preview buttons' ) . ' ' .
- '' . __( 'Click on the theme to see the theme name, version, author, description, tags, and the Delete link' ) . ' ' .
- '' . __( 'Click Customize for the active theme or Live Preview for any other theme to see a live preview' ) . ' ' .
+ '' . __( 'Hover or tap to see Activate and Live Preview buttons.' ) . ' ' .
+ '' . __( 'Click Customize for the active theme or Live Preview for any other theme to see a live preview.' ) . ' ' .
+ '' . __( 'Click on a theme to open the Theme Details dialog and see the theme name, version, author, description, tags, and the Delete link.' ) . ' ' .
+ '' . __( 'Use the buttons at the top of the dialog, or alt/option plus the left or right arrow keys on your keyboard, to navigate between themes quickly.' ) . ' ' .
'' . __( 'The active theme is displayed highlighted as the first theme.' ) . '
' .
'' . __( 'The search for installed themes will search for terms in their name, description, author, or tag.' ) . ' ' . __( 'The search results will be updated as you type.' ) . '
';
@@ -236,6 +237,8 @@
/* translators: %d: Number of themes. */
'themesFound' => __( 'Number of Themes found: %d' ),
'noThemesFound' => __( 'No themes found. Try a different search.' ),
+ /* translators: %s: Theme name. */
+ 'themeViewed' => __( 'Theme details: %s' ),
),
)
);
diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php
index 1f42a287e4957..7cf0f6fe10108 100644
--- a/src/wp-admin/upload.php
+++ b/src/wp-admin/upload.php
@@ -190,7 +190,7 @@ function () {
'title' => __( 'Attachment Details' ),
'content' =>
'' . __( 'Clicking an item will display an Attachment Details dialog, which allows you to preview media and make quick edits. Any changes you make to the attachment details will be automatically saved.' ) . '
' .
- '' . __( 'Use the arrow buttons at the top of the dialog, or the left and right arrow keys on your keyboard, to navigate between media items quickly.' ) . '
' .
+ '' . __( 'Use the buttons at the top of the dialog, or alt/option plus the left or right arrow keys on your keyboard, to navigate between media items quickly.' ) . '
' .
'' . __( 'You can also delete individual items and access the extended edit screen from the details dialog.' ) . '
',
)
);
diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php
index c2198acf20f66..e298b04efcf90 100644
--- a/src/wp-includes/class-wp-customize-manager.php
+++ b/src/wp-includes/class-wp-customize-manager.php
@@ -4959,7 +4959,7 @@ public function customize_pane_settings() {
/* translators: %d: Number of themes being displayed, which cannot currently consider singular vs. plural forms. */
'announceThemeCount' => __( 'Displaying %d themes' ),
/* translators: %s: Theme name. */
- 'announceThemeDetails' => __( 'Showing details for theme: %s' ),
+ 'announceThemeDetails' => __( 'Theme details: %s' ),
),
);
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 456684d08e221..1bbe0bcc0ffe9 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -5189,6 +5189,8 @@ function wp_enqueue_media( $args = array() ) {
'mediaFound' => __( 'Number of media items found: %d' ),
'noMedia' => __( 'No media items found.' ),
'noMediaTryNewSearch' => __( 'No media items found. Try a different search.' ),
+ /* translators: %s: Media item title or file name. */
+ 'mediaItemViewed' => __( 'Viewing media item: %s' ),
// Library Details.
'attachmentDetails' => __( 'Attachment details' ),
diff --git a/tests/qunit/wp-admin/js/theme.js b/tests/qunit/wp-admin/js/theme.js
index c17a5d59d41f9..d82b26e1521db 100644
--- a/tests/qunit/wp-admin/js/theme.js
+++ b/tests/qunit/wp-admin/js/theme.js
@@ -12,16 +12,18 @@
nextTheme: function() { nextCalled++; },
previousTheme: function() { prevCalled++; },
keyEvent: function( event ) {
- if ( event.shiftKey || event.ctrlKey || event.altKey || event.metaKey ) {
+ if ( event.shiftKey || event.ctrlKey || event.metaKey ) {
return;
}
// Right arrow
- if ( event.keyCode === 39 ) {
+ if ( event.altKey && event.keyCode === 39 ) {
+ event.preventDefault();
this.nextTheme();
}
// Left arrow
- else if ( event.keyCode === 37 ) {
+ else if ( event.altKey && event.keyCode === 37 ) {
+ event.preventDefault();
this.previousTheme();
}
}
@@ -34,28 +36,70 @@
themePreview = createThemePreview();
});
- QUnit.test( 'Arrow keys without modifiers', function( assert ) {
+ QUnit.test( 'Arrow keys with Alt modifier', function( assert ) {
// Right arrow
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 39,
+ altKey: true,
shiftKey: false,
ctrlKey: false
}) );
- assert.equal( nextCalled, 1, 'Right arrow triggers nextTheme' );
+ assert.equal( nextCalled, 1, 'Alt + Right arrow triggers nextTheme' );
// Left arrow
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 37,
+ altKey: true,
shiftKey: false,
ctrlKey: false
}) );
- assert.equal( prevCalled, 1, 'Left arrow triggers previousTheme' );
+ assert.equal( prevCalled, 1, 'Alt + Left arrow triggers previousTheme' );
} );
+ QUnit.test( 'Arrow keys without Alt do nothing', function( assert ) {
+ // Right arrow without Alt - should NOT call nextTheme
+ themePreview.keyEvent( $.Event( 'keydown', {
+ keyCode: 39,
+ altKey: false,
+ shiftKey: false,
+ ctrlKey: false
+ }) );
+ assert.equal( nextCalled, 0, 'Right arrow without Alt does nothing' );
+
+ // Left arrow without Alt - should NOT call previousTheme
+ themePreview.keyEvent( $.Event( 'keydown', {
+ keyCode: 37,
+ altKey: false,
+ shiftKey: false,
+ ctrlKey: false
+ }) );
+ assert.equal( prevCalled, 0, 'Left arrow without Alt does nothing' );
+ } );
+
+ QUnit.test( 'PreventDefault is called for arrow keys with Alt', function( assert ) {
+ // This test would need to check if preventDefault was called
+ var event = $.Event( 'keydown', {
+ keyCode: 39,
+ altKey: true,
+ shiftKey: false,
+ ctrlKey: false
+ });
+
+ // Mock the preventDefault method to track if it's called
+ var preventDefaultCalled = false;
+ event.preventDefault = function() {
+ preventDefaultCalled = true;
+ };
+
+ themePreview.keyEvent( event );
+ assert.ok( preventDefaultCalled, 'preventDefault is called for arrow keys with Alt' );
+ });
+
QUnit.test( 'Shift+Arrow keys do nothing', function( assert ) {
// Shift + Right
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 39,
+ altKey: false,
shiftKey: true,
ctrlKey: false
}) );
@@ -64,6 +108,7 @@
// Shift + Left
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 37,
+ altKey: false,
shiftKey: true,
ctrlKey: false
}) );
@@ -74,6 +119,7 @@
// Ctrl + Right
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 39,
+ altKey: false,
ctrlKey: true,
shiftKey: false
}) );
@@ -82,6 +128,7 @@
// Ctrl + Left
themePreview.keyEvent( $.Event( 'keydown', {
keyCode: 37,
+ altKey: false,
ctrlKey: true,
shiftKey: false
}) );
From 4949ae98bd13f672b90bcf7bfc5965846b717374 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Tue, 28 Jul 2026 21:12:12 +0000
Subject: [PATCH 055/149] Twenty Twenty One: Only filter untitled posts on the
front end.
Prevent the `the_title` filter used to alter untitled posts from running in the admin. Allows the excerpt feature added in [62685] to work on sites using Twenty Twenty One. No change on the front end.
Developed in https://github.com/WordPress/wordpress-develop/pull/12688
Props joedolson, khokansardar, shailu25, sabernhardt, luminuu, mirmpro.
Fixes #65710.
git-svn-id: https://develop.svn.wordpress.org/trunk@62889 602fd350-edb4-49c9-b593-d223f7449a82
---
.../themes/twentytwentyone/inc/template-functions.php | 7 ++++++-
tests/e2e/specs/dashboard.test.js | 6 ++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/wp-content/themes/twentytwentyone/inc/template-functions.php b/src/wp-content/themes/twentytwentyone/inc/template-functions.php
index 529564c295319..f141b99a8cf8f 100644
--- a/src/wp-content/themes/twentytwentyone/inc/template-functions.php
+++ b/src/wp-content/themes/twentytwentyone/inc/template-functions.php
@@ -182,12 +182,17 @@ function twenty_twenty_one_continue_reading_link() {
* Adds a title to posts and pages that are missing titles.
*
* @since Twenty Twenty-One 1.0
+ * @since Twenty Twenty-One 2.9 Only applies the filter on the front end.
*
* @param string $title The title.
* @return string
*/
function twenty_twenty_one_post_title( $title ) {
- return '' === $title ? esc_html_x( 'Untitled', 'Added to posts and pages that are missing titles', 'twentytwentyone' ) : $title;
+ if ( is_admin() ) {
+ return $title;
+ }
+
+ return '' === $title ? esc_html_x( 'Untitled', 'Added on the front end to posts and pages that are missing titles', 'twentytwentyone' ) : $title;
}
}
add_filter( 'the_title', 'twenty_twenty_one_post_title' );
diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js
index 9d290e58a9d50..0481d306f2e0b 100644
--- a/tests/e2e/specs/dashboard.test.js
+++ b/tests/e2e/specs/dashboard.test.js
@@ -149,11 +149,9 @@ test.describe( 'Quick Draft', () => {
await saveDraftButton.click();
// Check that the new draft title appears in the 'Your Recent Drafts' section.
- // This test relies on Twenty Twenty-One being the active theme.
- // Twenty Twenty-One alters the default post title from "(no title)" to "Untitled".
await expect(
page.locator( '.drafts .draft-title' ).first().getByRole( 'link' )
- ).toHaveText( 'Untitled' );
+ ).toHaveText( '(no title)' );
await expect(
page.locator( '.drafts .draft-content' ).first()
@@ -164,6 +162,6 @@ test.describe( 'Quick Draft', () => {
await expect(
page.locator( '.type-post.status-draft .title' ).first()
- ).toContainText( 'Untitled' );
+ ).toContainText( '(no title)' );
} );
} );
From c5690fd7e8721f60f7c40db81debbbf149e87d68 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Tue, 28 Jul 2026 22:06:30 +0000
Subject: [PATCH 056/149] Build/Test Tools: Allow the PHPUnit runner to be set
by a repository variable.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Change `runs-on` in the reusable PHPUnit workflow to `${{ vars.PHPUNIT_RUNNER || inputs.os }}`. With the variable unset (the default), jobs run exactly as before.
When a maintainer sets `PHPUNIT_RUNNER` to a runner label, the PHPUnit matrices run on that runner instead—useful for directing them to a dedicated runner during high-load release windows, without editing the workflow.
No input is added or made required, so every branch that calls this workflow at `@trunk` stays compatible.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12745
Props barry.
Fixes #65749.
git-svn-id: https://develop.svn.wordpress.org/trunk@62891 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/reusable-phpunit-tests-v3.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml
index 8c9a2aa9703c7..4ce4e65b0ba12 100644
--- a/.github/workflows/reusable-phpunit-tests-v3.yml
+++ b/.github/workflows/reusable-phpunit-tests-v3.yml
@@ -129,7 +129,7 @@ jobs:
# - Submit the test results to the WordPress.org host test results.
phpunit-tests:
name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }}
- runs-on: ${{ inputs.os }}
+ runs-on: ${{ vars.PHPUNIT_RUNNER || inputs.os }}
timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }}
permissions:
contents: read
From d99627d980b961abfa4564756e9c0c5b8e88c063 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Wed, 29 Jul 2026 01:36:12 +0000
Subject: [PATCH 057/149] Media: Use correct fallback `aria-label` for untitled
images.
In the media library, the `aria-label` for an untitled image fell back to "uploading...", the string used during the upload processing. Change the values so that images only use "uploading..." during the uploading process, but otherwise use "(no title)", like other untitled items.
Developed in https://github.com/WordPress/wordpress-develop/pull/12136
Props jamieburchell, presskopp, khokansardar, joedolson, tusharaddweb, ankitkumarshah, chillifish, masteradhoc, cbravobernal.
Fixes #65438, see #64883.
git-svn-id: https://develop.svn.wordpress.org/trunk@62892 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/media/views/attachment.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/js/media/views/attachment.js b/src/js/media/views/attachment.js
index 4ecda8347b00a..5761c959ea481 100644
--- a/src/js/media/views/attachment.js
+++ b/src/js/media/views/attachment.js
@@ -18,10 +18,20 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{
template: wp.template('attachment'),
attributes: function() {
+ var ariaLabel = this.model.get( 'title' );
+
+ if ( ! ariaLabel ) {
+ if ( this.model.get( 'uploading' ) ) {
+ ariaLabel = wp.i18n.__( 'uploading…' );
+ } else {
+ ariaLabel = wp.i18n.__( '(no title)' );
+ }
+ }
+
return {
'tabIndex': 0,
'role': 'checkbox',
- 'aria-label': this.model.get( 'title' ) || wp.i18n.__( 'uploading…' ),
+ 'aria-label': ariaLabel,
'aria-checked': false,
'data-id': this.model.get( 'id' )
};
From d7de2666f61fc03562c5112c2e77d2bd08f685d5 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 03:02:41 +0000
Subject: [PATCH 058/149] Bundled Themes: Fix hook docblocks and filter
arguments.
* Add the missing variable name to `@param` tags that document a type and a description but no name, in Twenty Ten, Twenty Eleven, and Twenty Twenty-One.
* Pass the documented `$post` argument to the `the_permalink` filter in Twenty Eleven, Twenty Thirteen, and Twenty Fifteen. The filter gained that parameter in 4.4, but these call sites were never updated, so a callback registered for two arguments received only one.
* Point Twenty Eleven's `widget_title` reference comment at `wp-includes/widgets/class-wp-widget-pages.php`, since `wp-includes/default-widgets.php` no longer exists. Add the reference comments that were missing altogether for `the_permalink` in Twenty Thirteen and Twenty Fifteen, and for `widget_title` in Twenty Fourteen.
These issues were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r25625, r25627, r33843, r35002, r51304, r60077.
See #65376, #64896.
git-svn-id: https://develop.svn.wordpress.org/trunk@62893 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-content/themes/twentyeleven/author.php | 2 +-
src/wp-content/themes/twentyeleven/content-status.php | 2 +-
src/wp-content/themes/twentyeleven/functions.php | 2 +-
src/wp-content/themes/twentyeleven/image.php | 2 +-
src/wp-content/themes/twentyeleven/inc/widgets.php | 2 +-
src/wp-content/themes/twentyeleven/tag.php | 2 +-
src/wp-content/themes/twentyfifteen/inc/template-tags.php | 3 ++-
src/wp-content/themes/twentyfourteen/inc/widgets.php | 3 ++-
src/wp-content/themes/twentyten/functions.php | 4 ++--
src/wp-content/themes/twentythirteen/functions.php | 3 ++-
src/wp-content/themes/twentytwentyone/functions.php | 2 +-
11 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/src/wp-content/themes/twentyeleven/author.php b/src/wp-content/themes/twentyeleven/author.php
index ad2117324f571..23ca4310c1317 100644
--- a/src/wp-content/themes/twentyeleven/author.php
+++ b/src/wp-content/themes/twentyeleven/author.php
@@ -57,7 +57,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param int The height and width avatar dimension in pixels. Default 60.
+ * @param int $size The height and width avatar dimension in pixels. Default 60.
*/
$author_bio_avatar_size = apply_filters( 'twentyeleven_author_bio_avatar_size', 60 );
echo get_avatar( get_the_author_meta( 'user_email' ), $author_bio_avatar_size );
diff --git a/src/wp-content/themes/twentyeleven/content-status.php b/src/wp-content/themes/twentyeleven/content-status.php
index 15484232cd0d6..c76760b321111 100644
--- a/src/wp-content/themes/twentyeleven/content-status.php
+++ b/src/wp-content/themes/twentyeleven/content-status.php
@@ -39,7 +39,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param int The height and width avatar dimensions in pixels. Default 65.
+ * @param int $size The height and width avatar dimensions in pixels. Default 65.
*/
echo get_avatar( get_the_author_meta( 'ID' ), apply_filters( 'twentyeleven_status_avatar', 65 ) );
?>
diff --git a/src/wp-content/themes/twentyeleven/functions.php b/src/wp-content/themes/twentyeleven/functions.php
index 6434507effd7a..251d2807a41e8 100644
--- a/src/wp-content/themes/twentyeleven/functions.php
+++ b/src/wp-content/themes/twentyeleven/functions.php
@@ -689,7 +689,7 @@ function twentyeleven_get_first_url() {
}
/** This filter is documented in wp-includes/link-template.php */
- return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
/**
diff --git a/src/wp-content/themes/twentyeleven/image.php b/src/wp-content/themes/twentyeleven/image.php
index 54bfb17498fe7..1bab57b581259 100644
--- a/src/wp-content/themes/twentyeleven/image.php
+++ b/src/wp-content/themes/twentyeleven/image.php
@@ -99,7 +99,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param int The width for the image attachment size in pixels. Default 848.
+ * @param int $size The width for the image attachment size in pixels. Default 848.
*/
$attachment_size = apply_filters( 'twentyeleven_attachment_size', 848 );
echo wp_get_attachment_image( $post->ID, array( $attachment_size, 1024 ) );
diff --git a/src/wp-content/themes/twentyeleven/inc/widgets.php b/src/wp-content/themes/twentyeleven/inc/widgets.php
index 4e82cdf6a055e..72411d01a8064 100644
--- a/src/wp-content/themes/twentyeleven/inc/widgets.php
+++ b/src/wp-content/themes/twentyeleven/inc/widgets.php
@@ -70,7 +70,7 @@ public function widget( $args, $instance ) {
ob_start();
- /** This filter is documented in wp-includes/default-widgets.php */
+ /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */
$args['title'] = apply_filters( 'widget_title', empty( $instance['title'] ) ? __( 'Ephemera', 'twentyeleven' ) : $instance['title'], $instance, $this->id_base );
if ( ! isset( $instance['number'] ) ) {
diff --git a/src/wp-content/themes/twentyeleven/tag.php b/src/wp-content/themes/twentyeleven/tag.php
index 23517622f0cb6..966dc7c603a93 100644
--- a/src/wp-content/themes/twentyeleven/tag.php
+++ b/src/wp-content/themes/twentyeleven/tag.php
@@ -30,7 +30,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param string The default tag description.
+ * @param string $tag_archive_meta The default tag description.
*/
echo apply_filters( 'tag_archive_meta', '' . $tag_description . '
' );
}
diff --git a/src/wp-content/themes/twentyfifteen/inc/template-tags.php b/src/wp-content/themes/twentyfifteen/inc/template-tags.php
index 7f39cdc194a7c..f77e13250965f 100644
--- a/src/wp-content/themes/twentyfifteen/inc/template-tags.php
+++ b/src/wp-content/themes/twentyfifteen/inc/template-tags.php
@@ -246,7 +246,8 @@ function twentyfifteen_post_thumbnail() {
function twentyfifteen_get_link_url() {
$has_url = get_url_in_content( get_the_content() );
- return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ /** This filter is documented in wp-includes/link-template.php */
+ return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
endif;
diff --git a/src/wp-content/themes/twentyfourteen/inc/widgets.php b/src/wp-content/themes/twentyfourteen/inc/widgets.php
index 8ffac4cd83057..36846daa84860 100644
--- a/src/wp-content/themes/twentyfourteen/inc/widgets.php
+++ b/src/wp-content/themes/twentyfourteen/inc/widgets.php
@@ -113,7 +113,8 @@ public function widget( $args, $instance ) {
$number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 2;
$title = ! empty( $instance['title'] ) ? $instance['title'] : $format_string;
- $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
+ /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */
+ $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$ephemera = new WP_Query(
array(
diff --git a/src/wp-content/themes/twentyten/functions.php b/src/wp-content/themes/twentyten/functions.php
index 6d3e505670bc7..8cfd93bbf6faa 100644
--- a/src/wp-content/themes/twentyten/functions.php
+++ b/src/wp-content/themes/twentyten/functions.php
@@ -170,7 +170,7 @@ function twentyten_setup() {
*
* @since Twenty Ten 1.0
*
- * @param int The default header image width in pixels. Default 940.
+ * @param int $width The default header image width in pixels. Default 940.
*/
'width' => apply_filters( 'twentyten_header_image_width', 940 ),
/**
@@ -178,7 +178,7 @@ function twentyten_setup() {
*
* @since Twenty Ten 1.0
*
- * @param int The default header image height in pixels. Default 198.
+ * @param int $height The default header image height in pixels. Default 198.
*/
'height' => apply_filters( 'twentyten_header_image_height', 198 ),
// Support flexible heights.
diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php
index 6cbbb1c7adf94..9aa4fca7871cf 100644
--- a/src/wp-content/themes/twentythirteen/functions.php
+++ b/src/wp-content/themes/twentythirteen/functions.php
@@ -739,7 +739,8 @@ function twentythirteen_get_link_url() {
$content = get_the_content();
$has_url = get_url_in_content( $content );
- return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ /** This filter is documented in wp-includes/link-template.php */
+ return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
if ( ! function_exists( 'twentythirteen_excerpt_more' ) && ! is_admin() ) :
diff --git a/src/wp-content/themes/twentytwentyone/functions.php b/src/wp-content/themes/twentytwentyone/functions.php
index a020e62bf00c5..13e5db52031b7 100644
--- a/src/wp-content/themes/twentytwentyone/functions.php
+++ b/src/wp-content/themes/twentytwentyone/functions.php
@@ -581,7 +581,7 @@ function twentytwentyone_the_html_classes() {
*
* @since Twenty Twenty-One 1.0
*
- * @param string The list of classes. Default empty string.
+ * @param string $classes The list of classes. Default empty string.
*/
$classes = apply_filters( 'twentytwentyone_html_classes', '' );
if ( ! $classes ) {
From 2f137d1fed1b611daf22b7a5303c9c852f80905c Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 08:00:07 +0000
Subject: [PATCH 059/149] HTTP API: Pass the missing URL to the
`https_local_ssl_verify` filter.
The filter has documented a `$url` parameter since 5.1.0, when r42682 added it to the transports in `WP_Http_Streams` and `WP_Http_Curl`. However, this new parameter was not passed to all instances of the filter being applied, resulting in a possible fatal error if a callback is expecting it.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r28781, r42682, r46230, r46231, r54043, r58128.
See #65376, #42186, #47957, #56041, #58281.
git-svn-id: https://develop.svn.wordpress.org/trunk@62894 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/class-wp-automatic-updater.php | 8 ++++----
src/wp-admin/includes/class-wp-site-health.php | 13 +++++++------
src/wp-admin/includes/file.php | 9 +++++----
src/wp-includes/cron.php | 6 ++++--
4 files changed, 20 insertions(+), 16 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-automatic-updater.php b/src/wp-admin/includes/class-wp-automatic-updater.php
index 2facbeb1d522f..cd9426c6ef88b 100644
--- a/src/wp-admin/includes/class-wp-automatic-updater.php
+++ b/src/wp-admin/includes/class-wp-automatic-updater.php
@@ -1785,9 +1785,6 @@ protected function has_fatal_error() {
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in the loopback request.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -1804,7 +1801,10 @@ protected function has_fatal_error() {
$needle_start = "###### wp_scraping_result_start:$scrape_key ######";
$needle_end = "###### wp_scraping_result_end:$scrape_key ######";
$url = add_query_arg( $scrape_params, home_url( '/' ) );
- $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
+
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+ $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
if ( is_wp_error( $response ) ) {
if ( $is_debug ) {
diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php
index 9eb4c8525a942..2ddfaadc4b39d 100644
--- a/src/wp-admin/includes/class-wp-site-health.php
+++ b/src/wp-admin/includes/class-wp-site-health.php
@@ -2212,9 +2212,6 @@ public function get_test_rest_availability() {
'Cache-Control' => 'no-cache',
'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ),
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -2230,6 +2227,9 @@ public function get_test_rest_availability() {
$url
);
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
$r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
if ( is_wp_error( $r ) ) {
@@ -3279,8 +3279,6 @@ public function can_perform_loopback() {
$headers = array(
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
@@ -3289,6 +3287,9 @@ public function can_perform_loopback() {
$url = site_url( 'wp-cron.php' );
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
/*
* A post request is used for the wp-cron.php loopback test to cause the file
* to finish early without triggering cron jobs. This has two benefits:
@@ -3611,7 +3612,7 @@ public function get_page_cache_headers(): array {
private function check_for_page_caching() {
/** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, home_url( '/' ) );
$headers = array();
diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php
index d7c771444ac98..8c0015020f35d 100644
--- a/src/wp-admin/includes/file.php
+++ b/src/wp-admin/includes/file.php
@@ -541,9 +541,6 @@ function wp_edit_theme_plugin_file( $args ) {
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -583,7 +580,11 @@ function wp_edit_theme_plugin_file( $args ) {
session_write_close();
}
- $url = add_query_arg( $scrape_params, $url );
+ $url = add_query_arg( $scrape_params, $url );
+
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
$r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
$body = wp_remote_retrieve_body( $r );
$scrape_result_position = strpos( $body, $needle_start );
diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php
index 88ca63f148e12..3fb6a29cb8dc7 100644
--- a/src/wp-includes/cron.php
+++ b/src/wp-includes/cron.php
@@ -958,6 +958,8 @@ function spawn_cron( $gmt_time = 0 ) {
$doing_wp_cron = sprintf( '%.22F', $gmt_time );
set_transient( 'doing_cron', $doing_wp_cron );
+ $cron_url = add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) );
+
/**
* Filters the cron request arguments.
*
@@ -982,13 +984,13 @@ function spawn_cron( $gmt_time = 0 ) {
$cron_request = apply_filters(
'cron_request',
array(
- 'url' => add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ),
+ 'url' => $cron_url,
'key' => $doing_wp_cron,
'args' => array(
'timeout' => 0.01,
'blocking' => false,
/** This filter is documented in wp-includes/class-wp-http-streams.php */
- 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
+ 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),
),
$doing_wp_cron
From 64051135e91ddbcda1a29aa58f0e92b461588042 Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Wed, 29 Jul 2026 12:07:38 +0000
Subject: [PATCH 060/149] Code Modernization: Use the null coalescing operator
for `isset()` return checks.
This commit replaces a handful of verbose `isset()` guard patterns with the null coalescing operator (`??`):
{{{
#!php
partials[ $id ] ) ) {
- return $this->partials[ $id ];
- } else {
- return null;
- }
+ return $this->partials[ $id ] ?? null;
}
/**
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index 9a688b9866ce0..f5002a45de1e8 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -1445,11 +1445,7 @@ function get_status_header_desc( $code ) {
);
}
- if ( isset( $wp_header_to_desc[ $code ] ) ) {
- return $wp_header_to_desc[ $code ];
- } else {
- return '';
- }
+ return $wp_header_to_desc[ $code ] ?? '';
}
/**
diff --git a/src/wp-includes/http.php b/src/wp-includes/http.php
index 8280f424934dd..19aec80581a44 100644
--- a/src/wp-includes/http.php
+++ b/src/wp-includes/http.php
@@ -855,9 +855,5 @@ function _wp_translate_php_url_constant_to_key( $constant ) {
PHP_URL_FRAGMENT => 'fragment',
);
- if ( isset( $translation[ $constant ] ) ) {
- return $translation[ $constant ];
- } else {
- return false;
- }
+ return $translation[ $constant ] ?? false;
}
From 571c279fac7651c84fac28d3bc85a598011b0b6d Mon Sep 17 00:00:00 2001
From: wildworks
Date: Wed, 29 Jul 2026 12:30:02 +0000
Subject: [PATCH 061/149] General: Bump the pinned hash for Gutenberg to
`fd715a6`.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This updates the pinned commit hash of the Gutenberg repository from `4997026b75c922d8a6f77a03d72ed7cad04c7073` to `fd715a6833679d098d9fee84b642f8f1bc27341b`.
A full list of changes included in this commit can be found on GitHub:
https://github.com/WordPress/gutenberg/compare/4997026b75c922d8a6f77a03d72ed7cad04c7073...fd715a6833679d098d9fee84b642f8f1bc27341b
- Update view config API versioning (https://github.com/WordPress/gutenberg/pull/80319)
- Perf Tests: Fix 'Selecting blocks' metric reporting 0 ms (https://github.com/WordPress/gutenberg/pull/80524)
- Notes: Register the inline note format at import time (https://github.com/WordPress/gutenberg/pull/80576)
- Media: Stop forcing crossorigin on IMG tags in media templates (https://github.com/WordPress/gutenberg/pull/80532)
- GradientPicker: select by slug so two presets sharing a gradient keep their identity (https://github.com/WordPress/gutenberg/pull/80554)
- Media Editor: Show a loading state while the cropped file loads (https://github.com/WordPress/gutenberg/pull/80460)
- Remove default paragraph from tab-panel template (https://github.com/WordPress/gutenberg/pull/80565)
- Global Styles: Resolve link element styles in block inspector controls for blocks that are links (https://github.com/WordPress/gutenberg/pull/80607)
- Media REST API: Backport sideload from url path upload size check (https://github.com/WordPress/gutenberg/pull/80659)
- Rich text: remove tabIndex from editable elements again to fix shift+click selection (https://github.com/WordPress/gutenberg/pull/80651)
- Gallery: make dynamic mode conversion a single undo level (https://github.com/WordPress/gutenberg/pull/80665)
- Background image control: Remove duplicated focus ring (https://github.com/WordPress/gutenberg/pull/80671)
- Detach core's note mention kses filter in the baseline strip test (https://github.com/WordPress/gutenberg/pull/80656)
- wp-build: sync the page template preload field list with core-data (https://github.com/WordPress/gutenberg/pull/80648)
- Read the contentEditable attribute in ownsSelection, not isContentEditable (https://github.com/WordPress/gutenberg/pull/80549)
- Writing flow: extend block selections with shift+arrow when there is no native selection (https://github.com/WordPress/gutenberg/pull/80687)
- Notes: Capture the target block before saving a block-level note (https://github.com/WordPress/gutenberg/pull/80690)
- Theme JSON: Level block-level preset class specificity with :where() (https://github.com/WordPress/gutenberg/pull/80657)
- Notes: Sync the sidebar selection to the inline marker under the caret (https://github.com/WordPress/gutenberg/pull/80610)
- Writing flow: use isMultiSelecting for shift+click (https://github.com/WordPress/gutenberg/pull/80286) (https://github.com/WordPress/gutenberg/pull/80726)
- Block supports: Return from layout support before resolving global settings (https://github.com/WordPress/gutenberg/pull/80771)
- Notes: Report save success consistently from note actions (https://github.com/WordPress/gutenberg/pull/80748)
- Dynamic Gallery: Rename toolbar button to Detach and add a modal explaining what will happen (https://github.com/WordPress/gutenberg/pull/80727) (https://github.com/WordPress/gutenberg/pull/80774)
- ToolsPanel: Migrate styles to an SCSS Module (https://github.com/WordPress/gutenberg/pull/80445) (https://github.com/WordPress/gutenberg/pull/80800)
- Add a responsiveEditingEnabled editor setting to hide the Responsive styles option (https://github.com/WordPress/gutenberg/pull/80814)
- iOS: remove jumping hack, add typewriter (https://github.com/WordPress/gutenberg/pull/74596)
- Writing flow: stop the page scrolling on caret moves within blocks taller than the viewport (https://github.com/WordPress/gutenberg/pull/80708)
- Global Styles: Put the inheritance UI behind a Gutenberg experiment (… (https://github.com/WordPress/gutenberg/pull/80818)
- Notes: Cancel in-flight hover highlight when focus leaves a note thread (https://github.com/WordPress/gutenberg/pull/80752)
- Block Editor: Try to fix typing performance regression (https://github.com/WordPress/gutenberg/pull/80507)
- List Block: Preserve ordered type on indent (https://github.com/WordPress/gutenberg/pull/75353)
- Make editableRoot a private block setting Symbol, not a public support (https://github.com/WordPress/gutenberg/pull/80820)
- Fix cursor position during forward delete of empty blocks (https://github.com/WordPress/gutenberg/pull/80827)
- Navigation: Fixes `aria-expanded` not updating on hover submenu inside overlay (https://github.com/WordPress/gutenberg/pull/80828)
- Remove redundant @jest-environment jsdom pragma and lint against it (https://github.com/WordPress/gutenberg/pull/80676)
- View config: reject shape-mismatched merges, define empty-array semantics, strip nulls from appended members (https://github.com/WordPress/gutenberg/pull/80829)
- Editor: leave undo to the browser in fields that handle their own undo (https://github.com/WordPress/gutenberg/pull/80768)
- Fix: New route-based admin pages are empty when no JS (https://github.com/WordPress/gutenberg/pull/80839)
Props wildworks.
See #65529.
git-svn-id: https://develop.svn.wordpress.org/trunk@62896 602fd350-edb4-49c9-b593-d223f7449a82
---
package.json | 2 +-
.../assets/script-loader-packages.php | 22 +++++++++----------
.../assets/script-modules-packages.php | 4 ++--
src/wp-includes/blocks/blocks-json.php | 1 -
src/wp-includes/blocks/navigation.php | 2 +-
src/wp-includes/blocks/paragraph/block.json | 1 -
.../pages/font-library/page-wp-admin.php | 15 +++++++------
.../build/pages/font-library/page.php | 5 +++--
.../options-connectors/page-wp-admin.php | 15 +++++++------
.../build/pages/options-connectors/page.php | 5 +++--
10 files changed, 37 insertions(+), 35 deletions(-)
diff --git a/package.json b/package.json
index ef9fe61733a85..d854406d50d7e 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"url": "https://develop.svn.wordpress.org/trunk"
},
"gutenberg": {
- "sha": "4997026b75c922d8a6f77a03d72ed7cad04c7073",
+ "sha": "fd715a6833679d098d9fee84b642f8f1bc27341b",
"ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build"
},
"engines": {
diff --git a/src/wp-includes/assets/script-loader-packages.php b/src/wp-includes/assets/script-loader-packages.php
index 7420056b8553b..47dd96b9b3485 100644
--- a/src/wp-includes/assets/script-loader-packages.php
+++ b/src/wp-includes/assets/script-loader-packages.php
@@ -104,7 +104,7 @@
'wp-url',
'wp-warning'
),
- 'version' => '77626afea4a1cac03204'
+ 'version' => 'b1292aac86a5d819f737'
),
'block-library.js' => array(
'dependencies' => array(
@@ -150,7 +150,7 @@
'import' => 'dynamic'
)
),
- 'version' => 'd24e08348f91bcfce1b7'
+ 'version' => '97b70e2d8d72d9b83b4e'
),
'block-serialization-default-parser.js' => array(
'dependencies' => array(
@@ -183,7 +183,7 @@
'wp-shortcode',
'wp-warning'
),
- 'version' => 'dc4bdf700024000fd427'
+ 'version' => '524509cfc84da30a4133'
),
'commands.js' => array(
'dependencies' => array(
@@ -224,7 +224,7 @@
'wp-theme',
'wp-warning'
),
- 'version' => 'd54375c07776a218ee99'
+ 'version' => 'd5254b2fdf63282d09f7'
),
'compose.js' => array(
'dependencies' => array(
@@ -306,7 +306,7 @@
'wp-theme',
'wp-widgets'
),
- 'version' => 'f28ae391ffd39b8db426'
+ 'version' => '05ff2e24b332f5dc0ea1'
),
'data.js' => array(
'dependencies' => array(
@@ -346,7 +346,7 @@
'dependencies' => array(
'wp-deprecated'
),
- 'version' => '22d969bde5c7182cdd2f'
+ 'version' => 'e13e9a880cb4f091f98e'
),
'dom-ready.js' => array(
'dependencies' => array(
@@ -396,7 +396,7 @@
'import' => 'static'
)
),
- 'version' => '4aeb2f3aa372be39adb2'
+ 'version' => '823ecf7905c05ce03022'
),
'edit-site.js' => array(
'dependencies' => array(
@@ -446,7 +446,7 @@
'import' => 'static'
)
),
- 'version' => 'c33d508cfc124b1b3e2d'
+ 'version' => '64590e045eedae65347d'
),
'edit-widgets.js' => array(
'dependencies' => array(
@@ -487,7 +487,7 @@
'import' => 'static'
)
),
- 'version' => 'b6608ebdd73ddae5a250'
+ 'version' => '9d38df85a4b408821722'
),
'editor.js' => array(
'dependencies' => array(
@@ -537,7 +537,7 @@
'import' => 'static'
)
),
- 'version' => 'cf691bc72eeac5643913'
+ 'version' => '2f1a5efaa6f78167e6c7'
),
'element.js' => array(
'dependencies' => array(
@@ -794,7 +794,7 @@
'wp-keycodes',
'wp-private-apis'
),
- 'version' => '1c4b61567c93d486f1dc'
+ 'version' => '9f145f4a11c41d022c83'
),
'router.js' => array(
'dependencies' => array(
diff --git a/src/wp-includes/assets/script-modules-packages.php b/src/wp-includes/assets/script-modules-packages.php
index 90e2c2a1f32fb..fc6e0c98dd365 100644
--- a/src/wp-includes/assets/script-modules-packages.php
+++ b/src/wp-includes/assets/script-modules-packages.php
@@ -76,7 +76,7 @@
'import' => 'static'
)
),
- 'version' => '96a846e1d7b789c39ab9'
+ 'version' => '1bf28ded04f9f188bdcb'
),
'block-library/playlist/view.js' => array(
'dependencies' => array(
@@ -315,7 +315,7 @@
'wp-private-apis',
'wp-style-engine'
),
- 'version' => '9d008e280440935933bc'
+ 'version' => '0e40b71e65fda1397a4b'
),
'route/index.js' => array(
'dependencies' => array(
diff --git a/src/wp-includes/blocks/blocks-json.php b/src/wp-includes/blocks/blocks-json.php
index 422eca780bfd3..d37e7583dd027 100644
--- a/src/wp-includes/blocks/blocks-json.php
+++ b/src/wp-includes/blocks/blocks-json.php
@@ -4812,7 +4812,6 @@
'full'
),
'splitting' => true,
- 'editableRoot' => true,
'anchor' => true,
'className' => false,
'__experimentalBorder' => array(
diff --git a/src/wp-includes/blocks/navigation.php b/src/wp-includes/blocks/navigation.php
index 802909a39e648..fcb8f5f97af8e 100644
--- a/src/wp-includes/blocks/navigation.php
+++ b/src/wp-includes/blocks/navigation.php
@@ -1250,7 +1250,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut
)
) ) {
$tags->set_attribute( 'data-wp-on--click', 'actions.toggleMenuOnClick' );
- $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isMenuOpen' );
+ $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isSubmenuOpen' );
// The `aria-expanded` attribute for SSR is already added in the submenu block.
}
// Add directives to the submenu.
diff --git a/src/wp-includes/blocks/paragraph/block.json b/src/wp-includes/blocks/paragraph/block.json
index 1b6ad873c6b66..556c2870557f7 100644
--- a/src/wp-includes/blocks/paragraph/block.json
+++ b/src/wp-includes/blocks/paragraph/block.json
@@ -29,7 +29,6 @@
"supports": {
"align": [ "wide", "full" ],
"splitting": true,
- "editableRoot": true,
"anchor": true,
"className": false,
"__experimentalBorder": {
diff --git a/src/wp-includes/build/pages/font-library/page-wp-admin.php b/src/wp-includes/build/pages/font-library/page-wp-admin.php
index 0ec524abcf6dd..bbca5fa7964fb 100644
--- a/src/wp-includes/build/pages/font-library/page-wp-admin.php
+++ b/src/wp-includes/build/pages/font-library/page-wp-admin.php
@@ -87,9 +87,10 @@ function wp_get_font_library_wp_admin_menu_items() {
*/
function wp_font_library_wp_admin_preload_data() {
// Define paths to preload - same for all pages
- // Please also change packages/core-data/src/entities.js when changing this.
+ // This must exactly match the _fields list in packages/core-data/src/entities.js,
+ // same fields in the same order, or the preload is never consumed.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
@@ -269,23 +270,23 @@ function wp_font_library_wp_admin_render_page() {
#wpwrap {
overflow-y: auto;
}
- body {
+ body.js {
background: #fff;
}
/* Reset wp-admin padding */
- #wpcontent {
+ body.js #wpcontent {
padding-inline-start: 0;
}
- #wpbody-content {
+ body.js #wpbody-content {
padding-bottom: 0;
}
/* Hide legacy admin elements */
- #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
+ body.js #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
display: none;
}
- #wpfooter {
+ body.js #wpfooter {
display: none;
}
diff --git a/src/wp-includes/build/pages/font-library/page.php b/src/wp-includes/build/pages/font-library/page.php
index dae179c60987c..0aaec2d145acf 100644
--- a/src/wp-includes/build/pages/font-library/page.php
+++ b/src/wp-includes/build/pages/font-library/page.php
@@ -88,9 +88,10 @@ function wp_get_font_library_menu_items() {
*/
function wp_font_library_preload_data() {
// Define paths to preload - same for all pages
- // Please also change packages/core-data/src/entities.js when changing this.
+ // This must exactly match the _fields list in packages/core-data/src/entities.js,
+ // same fields in the same order, or the preload is never consumed.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
diff --git a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php
index e32cf496549a8..3b11db178f809 100644
--- a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php
+++ b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php
@@ -87,9 +87,10 @@ function wp_get_options_connectors_wp_admin_menu_items() {
*/
function wp_options_connectors_wp_admin_preload_data() {
// Define paths to preload - same for all pages
- // Please also change packages/core-data/src/entities.js when changing this.
+ // This must exactly match the _fields list in packages/core-data/src/entities.js,
+ // same fields in the same order, or the preload is never consumed.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
@@ -269,23 +270,23 @@ function wp_options_connectors_wp_admin_render_page() {
#wpwrap {
overflow-y: auto;
}
- body {
+ body.js {
background: #fff;
}
/* Reset wp-admin padding */
- #wpcontent {
+ body.js #wpcontent {
padding-inline-start: 0;
}
- #wpbody-content {
+ body.js #wpbody-content {
padding-bottom: 0;
}
/* Hide legacy admin elements */
- #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
+ body.js #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
display: none;
}
- #wpfooter {
+ body.js #wpfooter {
display: none;
}
diff --git a/src/wp-includes/build/pages/options-connectors/page.php b/src/wp-includes/build/pages/options-connectors/page.php
index 1ece3b8003e97..7695969c7c060 100644
--- a/src/wp-includes/build/pages/options-connectors/page.php
+++ b/src/wp-includes/build/pages/options-connectors/page.php
@@ -88,9 +88,10 @@ function wp_get_options_connectors_menu_items() {
*/
function wp_options_connectors_preload_data() {
// Define paths to preload - same for all pages
- // Please also change packages/core-data/src/entities.js when changing this.
+ // This must exactly match the _fields list in packages/core-data/src/entities.js,
+ // same fields in the same order, or the preload is never consumed.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
From 6a10fe99d3e32be27183df812bff8f631e5cc38b Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Wed, 29 Jul 2026 13:45:33 +0000
Subject: [PATCH 062/149] Administration: Lower specificity of hover and focus
states on disabled tooltips.
Core tooltips can accept an existing button as an argument. When applying to an existing button, they should use the existing focus and hover states of that button. The styles for tooltips on disabled controls had higher specificity than the styles for active controls.
Lower the specificity of the styles applied to disabled buttons with tooltips to avoid overriding existing styles. Follow up to [62816].
Developed in https://github.com/WordPress/wordpress-develop/pull/12712
Props wildworks, khokansardar, mirmpro, joedolson.
Fixes #65727.
git-svn-id: https://develop.svn.wordpress.org/trunk@62897 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/wp-tooltip.css | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/wp-admin/css/wp-tooltip.css b/src/wp-admin/css/wp-tooltip.css
index f09a1317cdea8..e72c51a72781f 100644
--- a/src/wp-admin/css/wp-tooltip.css
+++ b/src/wp-admin/css/wp-tooltip.css
@@ -28,7 +28,11 @@
color: var(--wp-admin-theme-color, #3858e9);
}
-.wp-tooltip:not(:has(button[aria-disabled="true"],button[disabled])) .wp-tooltip__toggle:focus,
+/*
+ * `:where()` keeps the wrapper out of the specificity total, so buttons
+ * passed into a tooltip keep control of their own focus ring.
+ */
+:where(.wp-tooltip:not(:has(button[aria-disabled="true"],button[disabled]))) .wp-tooltip__toggle:focus,
.wp-tooltip .wp-tooltip__close:focus {
outline: 2px solid transparent;
box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
From 564cce691ae74274668955ce42aee2d067093ed1 Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Wed, 29 Jul 2026 15:08:27 +0000
Subject: [PATCH 063/149] WordPress 7.1 Beta 4.
git-svn-id: https://develop.svn.wordpress.org/trunk@62898 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/version.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php
index 2246c53246e53..5a2a588516be0 100644
--- a/src/wp-includes/version.php
+++ b/src/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.1-beta3-62828-src';
+$wp_version = '7.1-beta4-src';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
From 04b55ddc399b6a83b06ab363723e7c2ab483e3fe Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Wed, 29 Jul 2026 15:26:26 +0000
Subject: [PATCH 064/149] Post WordPress 7.1 Beta 4 version bump.
git-svn-id: https://develop.svn.wordpress.org/trunk@62899 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/version.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php
index 5a2a588516be0..121a44ba90167 100644
--- a/src/wp-includes/version.php
+++ b/src/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.1-beta4-src';
+$wp_version = '7.1-beta4-62899-src';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
From c47451ab9d00ceec42eebe28b22140f9900e9d4c Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 18:24:09 +0000
Subject: [PATCH 065/149] Meta: Add Aki Hamano to the mailmap file.
Discussed in https://wordpress.slack.com/archives/C18723MQ8/p1785328839730629.
Follow-up to r62896, r58899.
See #61864.
git-svn-id: https://develop.svn.wordpress.org/trunk@62924 602fd350-edb4-49c9-b593-d223f7449a82
---
.mailmap | 1 +
1 file changed, 1 insertion(+)
diff --git a/.mailmap b/.mailmap
index bbcf17b3ec9d4..dd6623258ff4d 100644
--- a/.mailmap
+++ b/.mailmap
@@ -11,6 +11,7 @@ Aaron Jorbin
Adam Zieliński
Adam Zieliński
+Aki Hamano
Alex King
Alex Shiels
André
From 2853d4298711a8a9174037cb020d85e0a2c62423 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 18:38:25 +0000
Subject: [PATCH 066/149] Taxonomy: Pass the missing `$args` to several term
hooks.
The `edit_terms`, `edited_terms`, `edit_term_taxonomy`, `edited_term_taxonomy`, and `term_id_filter` hooks have documented an `$args` parameter since 6.1.0, when r53627 added it. However, this new parameter was not passed to all instances of the hooks being fired, resulting in a possible fatal error if a callback is expecting it.
In `wp_insert_term()` and `wp_update_term()` the function's own `$args` is passed. In `_update_post_term_count()` and `_update_generic_term_count()` there is no such array, since the actions fire while recounting rather than while a term is being edited, so an empty array is passed.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r31525, r53627.
See #65376, #55441.
git-svn-id: https://develop.svn.wordpress.org/trunk@62925 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/taxonomy.php | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php
index 493d303068cf3..29317f0a8bf9b 100644
--- a/src/wp-includes/taxonomy.php
+++ b/src/wp-includes/taxonomy.php
@@ -2632,11 +2632,11 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) {
$slug = sanitize_title( $slug, $term_id );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_terms', $term_id, $taxonomy );
+ do_action( 'edit_terms', $term_id, $taxonomy, $args );
$wpdb->update( $wpdb->terms, compact( 'slug' ), compact( 'term_id' ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_terms', $term_id, $taxonomy );
+ do_action( 'edited_terms', $term_id, $taxonomy, $args );
}
/** @var numeric-string|null $tt_id */
@@ -3494,7 +3494,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) {
do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args );
/** This filter is documented in wp-includes/taxonomy.php */
- $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id );
+ $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args );
clean_term_cache( $term_id, $taxonomy );
@@ -4249,11 +4249,11 @@ function _update_post_term_count( $terms, $taxonomy ) {
do_action( 'update_term_count', $tt_id, $taxonomy->name, $count );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name );
+ do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name, array() );
$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $tt_id ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name );
+ do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name, array() );
}
}
@@ -4279,11 +4279,11 @@ function _update_generic_term_count( $terms, $taxonomy ) {
do_action( 'update_term_count', $term, $taxonomy->name, $count );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_term_taxonomy', $term, $taxonomy->name );
+ do_action( 'edit_term_taxonomy', $term, $taxonomy->name, array() );
$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $term ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_term_taxonomy', $term, $taxonomy->name );
+ do_action( 'edited_term_taxonomy', $term, $taxonomy->name, array() );
}
}
From 6126d6a77dfb173d16a6fe313b9dc32a73ed02ae Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 18:54:20 +0000
Subject: [PATCH 067/149] Menus: Pass the missing `$menu_data` to the
`wp_update_nav_menu` action.
The action has documented a `$menu_data` parameter since 3.0.0, when it was introduced in `wp_update_nav_menu_object()`. However, this parameter was not passed to all instances of the action being fired, resulting in a possible fatal error if a callback is expecting it.
Neither of these call sites has menu data to supply: `wp_nav_menu_update_menu_items()` fires the action after updating a menu's items, and `WP_REST_Menus_Controller::handle_auto_add()` after updating the `nav_menu_options` option. Both now pass an empty array to satisfy the documented signature.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r15104, r23441, r52079.
See #65376, #40878.
git-svn-id: https://develop.svn.wordpress.org/trunk@62926 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/nav-menu.php | 2 +-
.../rest-api/endpoints/class-wp-rest-menus-controller.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php
index 70263a2034807..f26d63d528e78 100644
--- a/src/wp-admin/includes/nav-menu.php
+++ b/src/wp-admin/includes/nav-menu.php
@@ -1509,7 +1509,7 @@ function wp_nav_menu_update_menu_items( $nav_menu_selected_id, $nav_menu_selecte
wp_defer_term_counting( false );
/** This action is documented in wp-includes/nav-menu.php */
- do_action( 'wp_update_nav_menu', $nav_menu_selected_id );
+ do_action( 'wp_update_nav_menu', $nav_menu_selected_id, array() );
/* translators: %s: Nav menu title. */
$message = sprintf( __( '%s has been updated.' ), '' . $nav_menu_selected_title . ' ' );
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
index 3947bfd6107ce..706e36fb6cc66 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
@@ -453,7 +453,7 @@ protected function handle_auto_add( $menu_id, $request ) {
$update = update_option( 'nav_menu_options', $nav_menu_option );
/** This action is documented in wp-includes/nav-menu.php */
- do_action( 'wp_update_nav_menu', $menu_id );
+ do_action( 'wp_update_nav_menu', $menu_id, array() );
return $update;
}
From 258355300666058b0851f4bfd50b9a596c2cd22c Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 19:05:27 +0000
Subject: [PATCH 068/149] REST API: Pass the missing `$is_update` param to the
`wp_creating_autosave` action.
The action has documented an `$is_update` parameter since 6.4.0, when r56714 added it. However, this new parameter was not passed to all instances of the action being fired, resulting in a possible fatal error if a callback is expecting it.
`WP_REST_Autosaves_Controller::create_post_autosave()` fires the action on the branch that overwrites an author's existing autosave, mirroring `wp_create_post_autosave()`, so `true` is passed.
This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r44126, r56714.
See #65376.
git-svn-id: https://develop.svn.wordpress.org/trunk@62927 602fd350-edb4-49c9-b593-d223f7449a82
---
.../rest-api/endpoints/class-wp-rest-autosaves-controller.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
index 65ca4e0018cb6..c0d58160467b2 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
@@ -425,7 +425,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) {
$new_autosave['post_author'] = $user_id;
/** This action is documented in wp-admin/includes/post.php */
- do_action( 'wp_creating_autosave', $new_autosave );
+ do_action( 'wp_creating_autosave', $new_autosave, true );
// wp_update_post() expects escaped array.
$revision_id = wp_update_post( wp_slash( $new_autosave ) );
From 0ffd942598361a7a85cd12b91772bb82cd1be5c1 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 19:37:56 +0000
Subject: [PATCH 069/149] Comments, REST API: Drop undocumented args from
content filters.
This removes the undocumented post parameter from the `the_content` and `the_excerpt` filters.
The `the_content` filter briefly took a post ID during 3.6 development: r24301 added one, and r24598 removed it again before release on the grounds that it "only worsens the function prototype". However, `do_trackbacks()` was left passing `$post->ID`. The `the_excerpt` filter has never taken a second parameter, yet `WP_REST_Revisions_Controller::prepare_excerpt_response()` has passed the `WP_Post` object since r38832.
In both cases the extra value is absent from the documented signature and from every other invocation of the filter, so no callback can rely on receiving it. A survey of the plugin directory found no callback that requires the extra argument; those that declare one give it a default and fall back to the current post. Drop the arguments so the calls match their documentation. The `$post` parameter of `prepare_excerpt_response()` is left in place, since it is part of the method's overridable signature.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r24301, r24598, r38832.
See #65376.
git-svn-id: https://develop.svn.wordpress.org/trunk@62928 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/comment.php | 2 +-
.../rest-api/endpoints/class-wp-rest-revisions-controller.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index b3738c24ec9df..ed7478f5ed104 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -3149,7 +3149,7 @@ function do_trackbacks( $post ) {
if ( empty( $post->post_excerpt ) ) {
/** This filter is documented in wp-includes/post-template.php */
- $excerpt = apply_filters( 'the_content', $post->post_content, $post->ID );
+ $excerpt = apply_filters( 'the_content', $post->post_content );
} else {
/** This filter is documented in wp-includes/post-template.php */
$excerpt = apply_filters( 'the_excerpt', $post->post_excerpt );
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
index 73a888d6eac48..f4c5cb483d105 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
@@ -911,7 +911,7 @@ public function get_collection_params() {
protected function prepare_excerpt_response( $excerpt, $post ) {
/** This filter is documented in wp-includes/post-template.php */
- $excerpt = apply_filters( 'the_excerpt', $excerpt, $post );
+ $excerpt = apply_filters( 'the_excerpt', $excerpt );
if ( empty( $excerpt ) ) {
return '';
From 54ea90b6cee0154da7897ef50f06d4f546d05d3b Mon Sep 17 00:00:00 2001
From: Jonathan Desrosiers
Date: Wed, 29 Jul 2026 19:47:51 +0000
Subject: [PATCH 070/149] Security: Update `composer/ca-bundle` to version
`1.5.13`.
This removes 2 certificates.
See #64969.
git-svn-id: https://develop.svn.wordpress.org/trunk@62929 602fd350-edb4-49c9-b593-d223f7449a82
---
composer.json | 2 +-
src/wp-includes/certificates/ca-bundle.crt | 53 ++--------------------
2 files changed, 4 insertions(+), 51 deletions(-)
diff --git a/composer.json b/composer.json
index 7c8ec9b4d2789..5505c9136c263 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,7 @@
"ext-ssh2": "*"
},
"require-dev": {
- "composer/ca-bundle": "1.5.12",
+ "composer/ca-bundle": "1.5.13",
"squizlabs/php_codesniffer": "3.13.5",
"wp-coding-standards/wpcs": "~3.4.1",
"phpcompatibility/phpcompatibility-wp": "~2.1.3",
diff --git a/src/wp-includes/certificates/ca-bundle.crt b/src/wp-includes/certificates/ca-bundle.crt
index 3e158b864a6e4..26ec7f7baabf9 100644
--- a/src/wp-includes/certificates/ca-bundle.crt
+++ b/src/wp-includes/certificates/ca-bundle.crt
@@ -1,7 +1,7 @@
##
## Bundle of CA Root Certificates
##
-## Certificate data from Mozilla as of: Thu May 14 03:12:02 2026 GMT
+## Certificate data from Mozilla as of: Thu Jul 16 03:12:01 2026 GMT
##
## Find updated versions here: https://curl.se/docs/caextract.html
##
@@ -13,39 +13,13 @@
## It contains the certificates in PEM format and therefore
## can be directly used with curl / libcurl / php_curl, or with
## an Apache+mod_ssl webserver for SSL client authentication.
-## Just configure this file as the SSLCACertificateFile.
+## Configure this file as the SSLCACertificateFile.
##
## Conversion done with mk-ca-bundle.pl version 1.33.
-## SHA256: 77130ef91213772844561fbd3aa31d413b25c2ac7f576fea3bc3bbff7ef93489
+## SHA256: e57912808daef7b2b0fa4df2ccf17e47aeaf26c839a38f85c76003ebafd866bd
##
-Entrust Root Certification Authority
-====================================
------BEGIN CERTIFICATE-----
-MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV
-BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw
-b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG
-A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0
-MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu
-MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu
-Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v
-dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
-ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz
-A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww
-Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68
-j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN
-rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw
-DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1
-MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH
-hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA
-A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM
-Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa
-v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS
-W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0
-tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8
------END CERTIFICATE-----
-
COMODO ECC Certification Authority
==================================
-----BEGIN CERTIFICATE-----
@@ -2689,27 +2663,6 @@ It6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzXxeSDwWrruoBa3lwtcHb4yOWHh8qgnaHl
IhInD0Q9HWzq1MKLL295q39QpsQZp6F6t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X
-----END CERTIFICATE-----
-SecureSign Root CA12
-====================
------BEGIN CERTIFICATE-----
-MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQELBQAwUTELMAkG
-A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT
-ZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgwNTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJ
-BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU
-U2VjdXJlU2lnbiBSb290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3
-emhFKxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mtp7JIKwcc
-J/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zdJ1M3s6oYwlkm7Fsf0uZl
-fO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gurFzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBF
-EaCeVESE99g2zvVQR9wsMJvuwPWW0v4JhscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1Uef
-NzFJM3IFTQy2VYzxV4+Kh9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P
-AQH/BAQDAgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsFAAOC
-AQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6LdmmQOmFxv3Y67ilQi
-LUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJmBClnW8Zt7vPemVV2zfrPIpyMpce
-mik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPS
-vWKErI4cqc1avTc7bgoitPQV55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhga
-aaI5gdka9at/yOPiZwud9AzqVN/Ssq+xIvEg37xEHA==
------END CERTIFICATE-----
-
SecureSign Root CA14
====================
-----BEGIN CERTIFICATE-----
From ad63e00f9ccd045e9eaef442291376520ecc3f37 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 19:48:11 +0000
Subject: [PATCH 071/149] Networks and Sites: Pass the missing `$site_id` to
bulk actions.
The `handle_network_bulk_actions-{$screen}` filter has documented a `$site_id` parameter since 4.7.0, when r38647 and r38957 introduced it. However, this parameter was not passed to all instances of the filter being applied, resulting in a possible fatal error if a callback is expecting it.
The Network Themes and Network Users screens act on the network as a whole rather than on a single site, so there is no site ID to supply and `0` is passed. This matches `wp-admin/network/sites.php`, where the value already falls back to `0` when the request carries no site.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r38647, r38957.
See #65376, #16031.
git-svn-id: https://develop.svn.wordpress.org/trunk@62930 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/network/themes.php | 2 +-
src/wp-admin/network/users.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/network/themes.php b/src/wp-admin/network/themes.php
index 763a13712a59b..8a27669f73b67 100644
--- a/src/wp-admin/network/themes.php
+++ b/src/wp-admin/network/themes.php
@@ -293,7 +293,7 @@
check_admin_referer( 'bulk-themes' );
/** This action is documented in wp-admin/network/site-themes.php */
- $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
wp_safe_redirect( $referer );
exit;
diff --git a/src/wp-admin/network/users.php b/src/wp-admin/network/users.php
index 3e18aae9673a4..144f98d0f90d8 100644
--- a/src/wp-admin/network/users.php
+++ b/src/wp-admin/network/users.php
@@ -158,7 +158,7 @@
$user_ids = (array) $_POST['allusers'];
/** This action is documented in wp-admin/network/site-themes.php */
- $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
wp_safe_redirect( $sendback );
exit;
From 5d9452be6c55110a4c29afe38532dda2bd827583 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 20:26:30 +0000
Subject: [PATCH 072/149] Plugins: Pass the missing `$network_wide` to
`activate_{$plugin}`.
The action has documented a `$network_wide` parameter since r16011, which passed the flag to the de/activation hooks in `activate_plugin()` and `deactivate_plugins()`. However, the error scraping path in `wp-admin/plugins.php` re-fires the action to reproduce a fatal error, and was left passing no arguments at all. A callback registered through `register_activation_hook()` that declares the parameter therefore raised an `ArgumentCountError` while the error was being scraped.
This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r9352, r16011.
See #65376, #14170.
git-svn-id: https://develop.svn.wordpress.org/trunk@62931 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/plugins.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php
index 6a359c822fac1..5c65801631869 100644
--- a/src/wp-admin/plugins.php
+++ b/src/wp-admin/plugins.php
@@ -193,7 +193,7 @@
// Go back to "sandbox" scope so we get the same errors as before.
plugin_sandbox_scrape( $plugin );
/** This action is documented in wp-admin/includes/plugin.php */
- do_action( "activate_{$plugin}" );
+ do_action( "activate_{$plugin}", is_network_admin() );
exit;
case 'deactivate':
From 4c7091d4a72033ddfba329a1678917bc93a9307c Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 20:43:59 +0000
Subject: [PATCH 073/149] Plugins: Pass the missing `$paged` to
`install_plugins_upload`.
The `install_plugins_{$tab}` action has documented a `$paged` parameter since 2.7.0, when r8540 introduced the Add Plugins screen. Since r38172 the Add Plugins screen also fires the `upload` instance of it directly, so that the upload form can be printed on every tab rather than only its own, and that call was left passing no arguments. On the upload tab a callback received `$paged`; on every other tab the callback received nothing, raising an `ArgumentCountError` if it declared the parameter.
Also declare `$tab` and `$paged` as globals after `WP_Plugin_Install_List_Table::prepare_items()` populates them, documenting the side effect that the rest of the file has always relied on.
This call site was surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r8540, r37221, r38172.
See #65376.
git-svn-id: https://develop.svn.wordpress.org/trunk@62932 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/plugin-install.php | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/wp-admin/plugin-install.php b/src/wp-admin/plugin-install.php
index 5c8be143bf332..395e55b8eca30 100644
--- a/src/wp-admin/plugin-install.php
+++ b/src/wp-admin/plugin-install.php
@@ -40,6 +40,15 @@
$wp_list_table->prepare_items();
+/**
+ * WP_Plugin_Install_List_Table::prepare_items() populates these globals, which
+ * are used throughout the rest of this file.
+ *
+ * @global string $tab The current tab of the Install Plugins screen.
+ * @global int $paged The current page number of the plugins list.
+ */
+global $tab, $paged;
+
$total_pages = $wp_list_table->get_pagination_arg( 'total_pages' );
if ( $pagenum > $total_pages && $total_pages > 0 ) {
@@ -169,7 +178,7 @@
Date: Wed, 29 Jul 2026 21:18:13 +0000
Subject: [PATCH 074/149] XML-RPC: Pass the missing args to the `enable_xmlrpc`
filters.
The `option_{$option}` filter has documented an `$option` parameter since 4.4.0, when r33738 added it, and `pre_option_{$option}` has documented both `$option` and, since 4.9.0, `$default_value`, added in r41013. However, `WP_XMLRPC_Server::set_is_enabled()` applies the `enable_xmlrpc` instances of these filters directly, in order to respect callbacks left over from before the option was deprecated in 3.5.0, and those calls were left passing only the value. A callback declaring the later parameters therefore raised an `ArgumentCountError` here, even though the very same callback works when the filter is reached through `get_option()`.
The option name and the default are passed explicitly, since there is no `$option` variable in scope. The default matches what the filter would receive from `get_option( 'enable_xmlrpc', false )`.
Also add the standard reference comments pointing at the canonical docblocks in `wp-includes/option.php`.
These call sites were surfaced by the PHPStan extensions under development for this ticket, which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r21804, r33738, r41013.
See #65376, #28402, #41254.
git-svn-id: https://develop.svn.wordpress.org/trunk@62933 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-xmlrpc-server.php | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php
index b20c79b4c9201..7d64d3f46c019 100644
--- a/src/wp-includes/class-wp-xmlrpc-server.php
+++ b/src/wp-includes/class-wp-xmlrpc-server.php
@@ -191,9 +191,11 @@ private function set_is_enabled() {
* Respect old get_option() filters left for back-compat when the 'enable_xmlrpc'
* option was deprecated in 3.5.0. Use the {@see 'xmlrpc_enabled'} hook instead.
*/
- $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false );
+ /** This filter is documented in wp-includes/option.php */
+ $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false, 'enable_xmlrpc', false );
if ( false === $is_enabled ) {
- $is_enabled = apply_filters( 'option_enable_xmlrpc', true );
+ /** This filter is documented in wp-includes/option.php */
+ $is_enabled = apply_filters( 'option_enable_xmlrpc', true, 'enable_xmlrpc' );
}
/**
From 42fcdc84357e17be83cf1555c87042961e13e83c Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 21:31:15 +0000
Subject: [PATCH 075/149] Docs: Attach three hook doc comments to their filter
calls.
Hook documentation is located by adjacency: the docblock must be the last thing before the line containing the `apply_filters()` or `do_action()` call, as established in r46088. In three places it was not, so the comment documented nothing.
* In `wp-admin/includes/ms.php` the reference comment for `users_have_additional_content` was opened with `/*` rather than `/**`, making it an ordinary block comment instead of a docblock.
* In `wp-admin/includes/template.php` the reference comment for `editable_slug` sat in the middle of a string concatenation, attached to no statement at all; the filter is now applied on its own line and the result interpolated.
* In `wp-includes/abilities-api/class-wp-ability.php` the sentinel assignment sat between the `wp_pre_execute_ability` docblock and the call it documents, so the docblock attached to the assignment; the assignment is hoisted above the docblock.
None of this changes behavior. The filters run with the same arguments, in the same order, and produce the same output.
These comments were surfaced by the PHPStan extensions under development for this ticket, which resolve a hook's documentation the same way the parser behind the developer handbook does, and which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r27237, r46088, r62397, r62688.
See #65376, #64896.
git-svn-id: https://develop.svn.wordpress.org/trunk@62934 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/ms.php | 2 +-
src/wp-admin/includes/template.php | 8 +++++---
src/wp-includes/abilities-api/class-wp-ability.php | 5 +++--
3 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/wp-admin/includes/ms.php b/src/wp-admin/includes/ms.php
index 50066bf14d18c..669c198fe9528 100644
--- a/src/wp-admin/includes/ms.php
+++ b/src/wp-admin/includes/ms.php
@@ -939,7 +939,7 @@ function confirm_delete_users( $users ) {
if ( is_array( $blog_users ) && ! empty( $blog_users ) ) {
$user_site = "{$details->blogname} ";
switch_to_blog( $details->userblog_id );
- /* This filter is documented in wp-admin/users.php */
+ /** This filter is documented in wp-admin/users.php */
$user_has_content = (bool) apply_filters( 'users_have_additional_content', false, array( $delete_user->ID ) );
if ( ! $user_has_content ) {
diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php
index bafd5f0904769..418bfd7def697 100644
--- a/src/wp-admin/includes/template.php
+++ b/src/wp-admin/includes/template.php
@@ -316,11 +316,13 @@ function get_inline_data( $post ) {
$title = esc_textarea( trim( $post->post_title ) );
+ /** This filter is documented in wp-admin/edit-tag-form.php */
+ $editable_slug = apply_filters( 'editable_slug', $post->post_name, $post );
+
echo '
-
' . $title . '
' .
- /** This filter is documented in wp-admin/edit-tag-form.php */
- '
' . apply_filters( 'editable_slug', $post->post_name, $post ) . '
+
' . $title . '
+
' . $editable_slug . '
' . $post->post_author . '
' . esc_html( $post->ping_status ) . '
diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php
index 2127417a887b9..9efefc6713f5a 100644
--- a/src/wp-includes/abilities-api/class-wp-ability.php
+++ b/src/wp-includes/abilities-api/class-wp-ability.php
@@ -782,6 +782,8 @@ public function execute( $input = null ) {
*/
do_action( 'wp_ability_invoked', $this->name, $input, $this );
+ $pre_execute_sentinel = new WP_Filter_Sentinel();
+
/**
* Filters whether to short-circuit ability execution.
*
@@ -804,8 +806,7 @@ public function execute( $input = null ) {
* @param mixed $input The raw input passed to `execute()`.
* @param WP_Ability $ability The ability instance.
*/
- $pre_execute_sentinel = new WP_Filter_Sentinel();
- $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
+ $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
if ( $pre !== $pre_execute_sentinel ) {
return $pre;
}
From f39043352835f48c0eb7e43ce4472a6dfe6380c2 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Wed, 29 Jul 2026 22:01:23 +0000
Subject: [PATCH 076/149] Docs: Correct the type of the
`image_editor_output_format` map.
The `$output_format` array maps a source mime type to a destination mime type, so its keys are mime type strings. Since r51221 it has been documented as `string[]` (which typically means an integer-keyed array of strings) expanded by a hash-notation block whose `@type string ...$0` is the notation core uses for numerically indexed members. Both halves therefore described a list, and nothing in the documentation conveyed that the key is the source mime type, which is the entire point of the array.
Replace both with `array`, which states the key and value types directly and needs no nested block. The `@return` of `wp_get_image_editor_output_format()` gets the same correction, having inherited `string[]` when r58849 moved the canonical docblock into `wp-includes/media.php`.
This mismatch was surfaced by the PHPStan extensions under development for this ticket, which read hook docblocks in order to type the return of `apply_filters()`, and which are committed separately.
Developed as subset of https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r50943, r51221, r58849.
See #65376, #64896.
git-svn-id: https://develop.svn.wordpress.org/trunk@62935 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/media.php | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 1bbe0bcc0ffe9..7d0a0b5d0e737 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -6534,7 +6534,7 @@ function wp_high_priority_element_flag( $value = null ): bool {
*
* @param string $filename Path to the image.
* @param string $mime_type The source image mime type.
- * @return string[] An array of mime type mappings.
+ * @return array An array of mime type mappings.
*/
function wp_get_image_editor_output_format( $filename, $mime_type ) {
$output_format = array(
@@ -6556,14 +6556,10 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) {
* @since 6.7.0 The default was changed from an empty array to an array
* containing the HEIC/HEIF images mime types.
*
- * @param string[] $output_format {
- * An array of mime type mappings. Maps a source mime type to a new
- * destination mime type. By default maps HEIC/HEIF input to JPEG output.
- *
- * @type string ...$0 The new mime type.
- * }
- * @param string $filename Path to the image.
- * @param string $mime_type The source image mime type.
+ * @param array $output_format An array of mime type mappings. Maps a source mime type to a new
+ * destination mime type. By default maps HEIC/HEIF input to JPEG output.
+ * @param string $filename Path to the image.
+ * @param string $mime_type The source image mime type.
*/
return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type );
}
From de03056b3e363f754a1d273470bbba82bb7a786c Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Wed, 29 Jul 2026 23:16:51 +0000
Subject: [PATCH 077/149] Build/Test Tools: Don't attempt to upload a SARIF
file to GitHub Code Scanning on private repositories.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12695
Props johnbillion.
Fixes #64893.
git-svn-id: https://develop.svn.wordpress.org/trunk@62936 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/reusable-workflow-lint.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/reusable-workflow-lint.yml b/.github/workflows/reusable-workflow-lint.yml
index 5be73442a006d..6ae6b0df10968 100644
--- a/.github/workflows/reusable-workflow-lint.yml
+++ b/.github/workflows/reusable-workflow-lint.yml
@@ -44,6 +44,8 @@ jobs:
zizmor:
name: Zizmor
runs-on: ubuntu-24.04
+ # GitHub Code Security is not enabled on any of the private mirrors.
+ if: ${{ github.event.repository.private == false }}
permissions:
security-events: write
actions: read
From 123f966508e95eecd120ff31fe369d322bca0542 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Wed, 29 Jul 2026 23:21:57 +0000
Subject: [PATCH 078/149] Build/Test Tools: Add a start period to the MySQL
healthcheck.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12753
Props adrianmoldovanwp.
Fixes #65752.
git-svn-id: https://develop.svn.wordpress.org/trunk@62937 602fd350-edb4-49c9-b593-d223f7449a82
---
docker-compose.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index cc2ed8d94975e..7ab6ae8c9b4d5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -87,6 +87,7 @@ services:
timeout: 5s
interval: 5s
retries: 10
+ start_period: 60s
##
# The WP CLI container.
From 157c2fc7770b33c5089950757aa851865b5caa70 Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Wed, 29 Jul 2026 23:26:54 +0000
Subject: [PATCH 079/149] Build/Test Tools: Wait for the database before
running WP-CLI commands.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12734
Props adrianmoldovanwp.
Fixes #65742.
git-svn-id: https://develop.svn.wordpress.org/trunk@62938 602fd350-edb4-49c9-b593-d223f7449a82
---
tools/local-env/scripts/install.js | 79 ++++++++++++++++++++++++++++--
1 file changed, 74 insertions(+), 5 deletions(-)
diff --git a/tools/local-env/scripts/install.js b/tools/local-env/scripts/install.js
index 038ecc3a67d5e..0578545c11fec 100644
--- a/tools/local-env/scripts/install.js
+++ b/tools/local-env/scripts/install.js
@@ -9,8 +9,20 @@ const local_env_utils = require( './utils' );
dotenvExpand.expand( dotenv.config() );
-// Create wp-config.php.
-wp_cli( `config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --force --config-file="wp-config.php"` );
+// Create wp-config.php. This verifies the database connection, so retrying it doubles as the
+// readiness probe: the mysql healthcheck pings the container's own socket, which the temporary
+// server used to initialise a cold volume answers before the real server listens on TCP.
+wp_cli_retry(
+ `config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --force --config-file="wp-config.php"`,
+ {
+ // Initialising a cold database volume takes well over the 3 second web server timeout
+ // used below, and longer still on a loaded CI runner.
+ timeout: 120000,
+ waiting: 'Waiting for the database to accept connections...',
+ failure: 'The database did not accept connections',
+ hint: `Check the container logs with 'npm run env:logs mysql'.`,
+ }
+);
// Add the debug settings to wp-config.php.
// Windows requires this to be done as an additional step, rather than using the --extra-php option in the previous step.
@@ -56,8 +68,65 @@ wait_on( {
/**
* Runs WP-CLI commands in the Docker environment.
*
- * @param {string} cmd The WP-CLI command to run.
+ * @param {string} cmd The WP-CLI command to run.
+ * @param {string} stdio How to handle the command's output. Defaults to 'inherit'.
*/
-function wp_cli( cmd ) {
- execSync( `npm --silent run env:cli -- ${cmd} --path=/var/www/${process.env.LOCAL_DIR}`, { stdio: 'inherit' } );
+function wp_cli( cmd, stdio = 'inherit' ) {
+ return execSync( `npm --silent run env:cli -- ${cmd} --path=/var/www/${process.env.LOCAL_DIR}`, { stdio } );
+}
+
+/**
+ * Runs a WP-CLI command, retrying it until it succeeds or the timeout is reached.
+ *
+ * Exits with an error when the timeout is reached, or as soon as the failure is one that
+ * retrying cannot fix.
+ *
+ * @param {string} cmd The WP-CLI command to run.
+ * @param {Object} options
+ * @param {number} options.timeout How long to keep retrying for, in milliseconds.
+ * @param {string} options.waiting Message shown when the command has to be retried.
+ * @param {string} options.failure Reason reported when the timeout is reached.
+ * @param {string} options.hint Suggested next step when the timeout is reached.
+ */
+function wp_cli_retry( cmd, { timeout, waiting, failure, hint } ) {
+ const interval = 2000;
+ const deadline = Date.now() + timeout;
+ let notified = false;
+
+ for ( ;; ) {
+ try {
+ process.stdout.write( wp_cli( cmd, 'pipe' ) );
+ return;
+ } catch ( err ) {
+ // `stderr` and `stdout` are buffers, which are truthy even when empty, so use the
+ // first one that actually captured something.
+ const output = [ err.stderr, err.stdout, err.message ]
+ .map( ( value ) => ( value ? value.toString().trim() : '' ) )
+ .find( ( value ) => value !== '' ) || 'No output was captured.';
+
+ // Retrying only helps while the environment is still starting up. A missing container
+ // means it was never started, so there is nothing to wait for.
+ if ( output.includes( 'is not running' ) ) {
+ console.error( output );
+ console.error( `Error: It appears the development environment has not been started.` );
+ console.error( `Did you forget to do 'npm run env:start'?` );
+ process.exit( 1 );
+ }
+
+ if ( ! notified ) {
+ notified = true;
+ console.log( waiting );
+ }
+
+ if ( Date.now() >= deadline ) {
+ console.error( output );
+ console.error( `Error: ${ failure } within ${ timeout / 1000 } seconds.` );
+ console.error( hint );
+ process.exit( 1 );
+ }
+
+ // Sleep synchronously, so the retries stay in front of the commands that follow.
+ Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, interval );
+ }
+ }
}
From 7404a517b056c2dac93b58ac4927b3d0fe54bc66 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Thu, 30 Jul 2026 04:45:34 +0000
Subject: [PATCH 080/149] Build/Test Tools: Verify hook docblocks statically.
Add PHPStan extensions that read the docblock documenting each hook where the hook is fired. The value `apply_filters()` returns is typed from the first `@param` that docblock documents rather than `mixed`; core's `/** This filter is documented in */` convention is resolved, so a hook documented elsewhere is analyzed against its canonical docblock, including a dynamic canonical name such as `"{$type}_template_hierarchy"`; and two rules require every hook invocation to be documented, and to pass as many arguments as its documentation describes.
These conventions were previously enforced by review alone. A reference comment could name a file that no longer documents the hook, and a call site could pass fewer arguments than documented, which raises an `ArgumentCountError` in a callback registered for the documented count, or more, which drops the extra argument and leaves the documentation wrong. Both are now reported where they occur. The hook issues this surfaced in core were fixed in preceding commits.
The generated `src/wp-includes/build` tree is excluded from analysis, as its sources live in the Gutenberg plugin.
Developed in https://github.com/WordPress/wordpress-develop/pull/12022.
Follow-up to r61699, r62292, r62893, r62894, r62925, r62926, r62927, r62928, r62930, r62931, r62932, r62933, r62934, r62935.
Props westonruter, szepeviktor, khokansardar.
See #64898.
Fixes #65376.
git-svn-id: https://develop.svn.wordpress.org/trunk@62939 602fd350-edb4-49c9-b593-d223f7449a82
---
...tersDynamicFunctionReturnTypeExtension.php | 95 ++
tests/phpstan/HookDocBlock.php | 946 ++++++++++++++++++
.../HookDocsResultCacheMetaExtension.php | 99 ++
tests/phpstan/HookDocsVisitor.php | 122 +++
tests/phpstan/HookDocumentationRule.php | 166 +++
tests/phpstan/HookParamCountRule.php | 218 ++++
tests/phpstan/README.md | 32 +
tests/phpstan/base.neon | 50 +
8 files changed, 1728 insertions(+)
create mode 100644 tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php
create mode 100644 tests/phpstan/HookDocBlock.php
create mode 100644 tests/phpstan/HookDocsResultCacheMetaExtension.php
create mode 100644 tests/phpstan/HookDocsVisitor.php
create mode 100644 tests/phpstan/HookDocumentationRule.php
create mode 100644 tests/phpstan/HookParamCountRule.php
diff --git a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php
new file mode 100644
index 0000000000000..4d708aa7757df
--- /dev/null
+++ b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php
@@ -0,0 +1,95 @@
+hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Determines whether this extension applies to the given function.
+ *
+ * @param FunctionReflection $functionReflection Function being analyzed.
+ * @return bool
+ */
+ public function isFunctionSupported( FunctionReflection $functionReflection ): bool {
+ return in_array( $functionReflection->getName(), HookDocBlock::FILTER_FUNCTIONS, true );
+ }
+
+ /**
+ * Resolves the return type of the filter call from its preceding docblock.
+ *
+ * @link https://developer.wordpress.org/reference/functions/apply_filters/
+ * @link https://developer.wordpress.org/reference/functions/apply_filters_deprecated/
+ * @link https://developer.wordpress.org/reference/functions/apply_filters_ref_array/
+ *
+ * @param FunctionReflection $functionReflection Function being analyzed.
+ * @param FuncCall $functionCall The function call node.
+ * @param Scope $scope Analysis scope.
+ * @return Type
+ * @throws ShouldNotHappenException
+ */
+ public function getTypeFromFunctionCall( FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope ): Type {
+ $default = new MixedType();
+ $resolved_php_doc = $this->hookDocBlock->getNullableHookDocBlock( $functionCall, $scope );
+
+ if ( null === $resolved_php_doc ) {
+ return $default;
+ }
+
+ // The first `@param` describes the value being filtered.
+ $params = $resolved_php_doc->getParamTags();
+ $param = reset( $params );
+
+ return false === $param ? $default : $param->getType();
+ }
+}
diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php
new file mode 100644
index 0000000000000..6d7cca2f69f11
--- /dev/null
+++ b/tests/phpstan/HookDocBlock.php
@@ -0,0 +1,946 @@
+,
+ * patterns: list,
+ * }
+ * @phpstan-type HookNameMatcher array{
+ * kind: 'literal'|'pattern',
+ * value: string,
+ * literal: string,
+ * }
+ * @phpstan-type HookDocumentationProblem array{
+ * path: non-empty-string,
+ * hook: string,
+ * problem: self::PROBLEM_FILE_MISSING|self::PROBLEM_HOOK_MISSING,
+ * }
+ * @phpstan-type HookDocumentation array{
+ * kind: 'inline'|'reference',
+ * resolved: ResolvedPhpDocBlock|null,
+ * paramCount: int<0, max>|null,
+ * problem: HookDocumentationProblem|null,
+ * }
+ */
+class HookDocBlock {
+
+ /**
+ * Hook functions that carry a documenting docblock for their first argument.
+ */
+ public const HOOK_FUNCTIONS = array(
+ 'apply_filters',
+ 'apply_filters_deprecated',
+ 'apply_filters_ref_array',
+ 'do_action',
+ 'do_action_deprecated',
+ 'do_action_ref_array',
+ );
+
+ /**
+ * Hook functions that filter a value, and so always pass at least one argument.
+ */
+ public const FILTER_FUNCTIONS = array(
+ 'apply_filters',
+ 'apply_filters_deprecated',
+ 'apply_filters_ref_array',
+ );
+
+ /**
+ * Directories, relative to the WordPress root, scanned for reference comments
+ * when hashing the docblocks that call sites can inherit.
+ *
+ * @see HookDocBlock::getScannedFiles()
+ */
+ private const SCANNED_DIRECTORIES = array(
+ 'wp-admin',
+ 'wp-includes',
+ 'wp-content/themes',
+ );
+
+ /**
+ * Problem code: the referenced file does not exist.
+ */
+ public const PROBLEM_FILE_MISSING = 'fileMissing';
+
+ /**
+ * Problem code: the hook is not documented in the referenced file.
+ */
+ public const PROBLEM_HOOK_MISSING = 'hookMissing';
+
+ /**
+ * Pattern matching WordPress core's "documented elsewhere" reference comment.
+ * Captures the referenced root-relative file path.
+ */
+ private const REFERENCE_PATTERN = '#This (?:filter|action) is documented in (\S+)#';
+
+ /**
+ * File type mapper used to resolve docblocks in scope.
+ *
+ * @var FileTypeMapper
+ */
+ protected FileTypeMapper $fileTypeMapper;
+
+ /**
+ * In-memory cache of parsed hook documentation, keyed by absolute file path.
+ *
+ * @var array
+ */
+ private array $fileHookDocs = array();
+
+ /**
+ * Absolute path to the WordPress root that reference comment paths resolve against.
+ *
+ * @var string
+ */
+ private string $wordpressRoot;
+
+ /**
+ * Canonical form of the WordPress root, with symlinks and dot segments resolved,
+ * against which a candidate path is tested for being inside the tree.
+ *
+ * @var string
+ */
+ private string $canonicalWordpressRoot;
+
+ /**
+ * Constructor.
+ *
+ * @param FileTypeMapper $file_type_mapper File type mapper.
+ * @param string|null $wordpress_root Absolute path to the WordPress root that
+ * "documented in " paths are relative to.
+ * Defaults to the `src` directory of this checkout.
+ */
+ public function __construct( FileTypeMapper $file_type_mapper, ?string $wordpress_root = null ) {
+ $this->fileTypeMapper = $file_type_mapper;
+ $this->wordpressRoot = rtrim( $wordpress_root ?? dirname( __DIR__, 2 ) . '/src', '/' );
+
+ $canonical_root = realpath( $this->wordpressRoot );
+ $this->canonicalWordpressRoot = false === $canonical_root ? $this->wordpressRoot : $canonical_root;
+ }
+
+ /**
+ * Returns a hash of every hook docblock that a call site can inherit through a
+ * "documented elsewhere" reference comment.
+ *
+ * Those docblocks are read with plain file I/O, so PHPStan's dependency graph
+ * does not know that the referencing files depend on them: editing a canonical
+ * docblock re-analyzes only the file it lives in, leaving the cached results of
+ * every referencing file in place. Folding this hash into the result cache key
+ * invalidates the cache when an inheritable docblock changes, and only then.
+ *
+ * @see HookDocsResultCacheMetaExtension
+ *
+ * @return non-falsy-string
+ */
+ public function getReferencedHookDocsHash(): string {
+ $docs = array();
+
+ foreach ( $this->getScannedFiles() as $file ) {
+ $code = file_get_contents( $file );
+
+ if ( false === $code || ! str_contains( $code, 'is documented in' ) ) {
+ continue;
+ }
+
+ if ( ! preg_match_all( self::REFERENCE_PATTERN, $code, $matches ) ) {
+ continue;
+ }
+
+ foreach ( $matches[1] as $reference_path ) {
+ $target = $this->resolveReferencePath( $file, $reference_path );
+
+ if ( null === $target ) {
+ continue;
+ }
+
+ // Key on the path relative to the WordPress root so the hash does not
+ // depend on where the checkout lives.
+ $key = $this->getRootRelativePath( $target );
+
+ if ( ! isset( $docs[ $key ] ) ) {
+ $docs[ $key ] = $this->getHookDocs( $target );
+ }
+ }
+ }
+
+ ksort( $docs );
+
+ return md5( (string) json_encode( $docs ) );
+ }
+
+ /**
+ * Returns the files scanned for reference comments: the WordPress directories
+ * that can contain them, plus the PHP files at the root of the install.
+ *
+ * `wp-content/plugins` is deliberately not scanned. A checkout may have plugins
+ * carrying large `vendor` and `node_modules` trees, and core's reference comments
+ * only ever point within core.
+ *
+ * @return list Absolute file paths.
+ */
+ private function getScannedFiles(): array {
+ $files = array();
+
+ foreach ( self::SCANNED_DIRECTORIES as $directory ) {
+ $path = $this->wordpressRoot . '/' . $directory;
+
+ if ( ! is_dir( $path ) ) {
+ continue;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS )
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( $file instanceof SplFileInfo && $file->isFile() && 'php' === strtolower( $file->getExtension() ) ) {
+ $files[] = $file->getPathname();
+ }
+ }
+ }
+
+ $root_files = glob( $this->wordpressRoot . '/*.php' );
+
+ if ( is_array( $root_files ) ) {
+ $files = array_merge( $files, $root_files );
+ }
+
+ return $files;
+ }
+
+ /**
+ * Expresses an absolute path relative to the WordPress root when it sits inside
+ * it, so that hashes do not depend on the checkout location.
+ *
+ * Paths reached through a reference comment are canonical, so the canonical root is
+ * what they are relative to.
+ *
+ * @param string $path Absolute path.
+ * @return string
+ */
+ private function getRootRelativePath( string $path ): string {
+ $prefix = $this->canonicalWordpressRoot . '/';
+
+ return str_starts_with( $path, $prefix ) ? substr( $path, strlen( $prefix ) ) : $path;
+ }
+
+ /**
+ * Resolves the documentation for a hook call: the docblock written above it, or
+ * the canonical docblock a "documented elsewhere" comment points at.
+ *
+ * This is the one entry point the rules and the return type extension share, so
+ * all three necessarily agree on what documents a given call.
+ *
+ * A reference that cannot be resolved to a canonical docblock leaves `resolved`
+ * and `paramCount` null rather than falling back to the reference comment itself,
+ * so an unresolved reference is never mistaken for a hook documented with no
+ * parameters. `problem` says why it could not be resolved, when the reason is one
+ * worth reporting.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return HookDocumentation|null Null when no docblock precedes the call.
+ * @throws ShouldNotHappenException
+ */
+ public function getHookDoc( FuncCall $function_call, Scope $scope ): ?array {
+ $comment = self::getNullableNodeComment( $function_call );
+
+ if ( null === $comment ) {
+ return null;
+ }
+
+ $text = $comment->getText();
+
+ // A docblock written at the call site documents the hook in place.
+ if ( ! preg_match( self::REFERENCE_PATTERN, $text, $matches ) ) {
+ $resolved = $this->resolveInlineDocBlock( $text, $scope );
+
+ return array(
+ 'kind' => 'inline',
+ 'resolved' => $resolved,
+ 'paramCount' => self::countParamTags( $resolved ),
+ 'problem' => null,
+ );
+ }
+
+ $hook_doc = array(
+ 'kind' => 'reference',
+ 'resolved' => null,
+ 'paramCount' => null,
+ 'problem' => null,
+ );
+
+ // Without an identifiable hook name there is nothing to look up in the
+ // referenced file, and so nothing to report either.
+ $matcher = self::getHookNameMatcher( $function_call );
+ if ( null === $matcher ) {
+ return $hook_doc;
+ }
+
+ $reference_path = $matches[1];
+ $target_file = $this->resolveReferencePath( $scope->getFile(), $reference_path );
+
+ // The referenced file could not be located up the directory tree.
+ if ( null === $target_file ) {
+ $hook_doc['problem'] = array(
+ 'path' => $reference_path,
+ 'hook' => self::getHookNameDisplay( $function_call ),
+ 'problem' => self::PROBLEM_FILE_MISSING,
+ );
+
+ return $hook_doc;
+ }
+
+ $doc_text = $this->findHookDoc( $target_file, $matcher );
+
+ if ( null === $doc_text ) {
+ $hook_doc['problem'] = array(
+ 'path' => $reference_path,
+ 'hook' => self::getHookNameDisplay( $function_call ),
+ 'problem' => self::PROBLEM_HOOK_MISSING,
+ );
+
+ return $hook_doc;
+ }
+
+ // Resolve the canonical docblock in the global namespace, with no file
+ // context. Hook docblocks describe global/plain types (e.g. string[],
+ // WP_REST_Response), so the referenced file's `use` imports are not needed.
+ // Passing the referenced file here would also re-enter PHPStan's name-scope
+ // builder while that file is itself being analyzed, which makes
+ // getResolvedPhpDoc return an empty docblock (NameScopeAlreadyBeingCreated).
+ $resolved = $this->fileTypeMapper->getResolvedPhpDoc( null, null, null, null, $doc_text );
+
+ $hook_doc['resolved'] = $resolved;
+ $hook_doc['paramCount'] = self::countParamTags( $resolved );
+
+ return $hook_doc;
+ }
+
+ /**
+ * Resolves the docblock preceding the given function call, if any.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes
+ * the call or a reference cannot be resolved.
+ * @throws ShouldNotHappenException
+ */
+ public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock {
+ $hook_doc = $this->getHookDoc( $function_call, $scope );
+
+ return null === $hook_doc ? null : $hook_doc['resolved'];
+ }
+
+ /**
+ * Resolves a docblock written at a call site, in the scope of that site.
+ *
+ * @param string $text Docblock text.
+ * @param Scope $scope Analysis scope.
+ * @return ResolvedPhpDocBlock
+ * @throws ShouldNotHappenException
+ */
+ private function resolveInlineDocBlock( string $text, Scope $scope ): ResolvedPhpDocBlock {
+ $class_reflection = $scope->getClassReflection();
+ $trait_reflection = $scope->getTraitReflection();
+
+ return $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null,
+ ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null,
+ $scope->getFunctionName(),
+ $text
+ );
+ }
+
+ /**
+ * Counts the `@param` tags a resolved docblock declares.
+ *
+ * ResolvedPhpDocBlock::getParamTags() is keyed by parameter name, so two tags
+ * documenting the same name — a copy-and-paste slip — collapse into a single
+ * entry. That undercounts, which both reports a hook passing the documented
+ * number of arguments as a mismatch and hides a hook that genuinely passes too
+ * few. The parsed docblock nodes list every tag, so they are counted instead.
+ *
+ * Tags PHPStan cannot parse as a `@param` — one missing its variable name, say —
+ * are still left out, so a malformed tag continues to surface rather than passing
+ * for documentation of a parameter.
+ *
+ * @param ResolvedPhpDocBlock $resolved_php_doc Resolved docblock.
+ * @return int<0, max>
+ */
+ private static function countParamTags( ResolvedPhpDocBlock $resolved_php_doc ): int {
+ $count = 0;
+
+ foreach ( $resolved_php_doc->getPhpDocNodes() as $php_doc_node ) {
+ $count += count( $php_doc_node->getParamTagValues() );
+ }
+
+ return $count;
+ }
+
+ /**
+ * Determines whether a filter call resolves to a docblock that documents no
+ * parameters.
+ *
+ * A filter always passes at least the value being filtered, so such a docblock
+ * does not document the hook. It is either hook documentation with its `@param`
+ * tags missing, or an unrelated annotation — typically a `@var` block — that
+ * happens to sit immediately above the call.
+ *
+ * This holds wherever the docblock was found, so no argument count is worth
+ * comparing against it. HookDocumentationRule reports it only for a docblock
+ * written at the call itself: a hook documented elsewhere is fixed where its
+ * canonical docblock lives, rather than once per site inheriting it.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param HookDocumentation $hook_doc Documentation resolved for the call.
+ * @return bool
+ */
+ public static function isFilterMissingParamDocs( FuncCall $function_call, array $hook_doc ): bool {
+ if ( 0 !== $hook_doc['paramCount'] ) {
+ return false;
+ }
+
+ return $function_call->name instanceof Name
+ && in_array( $function_call->name->toString(), self::FILTER_FUNCTIONS, true );
+ }
+
+ /**
+ * Determines whether a hook call's name can be identified well enough to
+ * require or locate documentation.
+ *
+ * Calls whose hook name carries no literal text (e.g. the generic
+ * `apply_filters_ref_array( $hook_name, $args )` forwarders in plugin.php)
+ * cannot be meaningfully documented at the call site and are excluded.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @return bool
+ */
+ public static function hasIdentifiableHookName( FuncCall $function_call ): bool {
+ $args = $function_call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return false;
+ }
+
+ $value = $args[0]->value;
+ if ( $value instanceof String_ ) {
+ return true;
+ }
+
+ return null !== self::buildHookNamePattern( $value );
+ }
+
+ /**
+ * Returns the canonical docblock text for a hook documented in the given file.
+ *
+ * @param string $file Absolute path to the file declaring the hook.
+ * @param HookNameMatcher $matcher Hook name matcher from getHookNameMatcher().
+ * @return string|null Docblock text, or null when no documented invocation is found.
+ */
+ private function findHookDoc( string $file, array $matcher ): ?string {
+ $docs = $this->getHookDocs( $file );
+
+ if ( 'literal' === $matcher['kind'] ) {
+ $name = $matcher['value'];
+
+ if ( isset( $docs['exact'][ $name ] ) ) {
+ return $docs['exact'][ $name ];
+ }
+
+ // A literal name may be an instance of a dynamic canonical hook
+ // (e.g. "index_template_hierarchy" matching "{$type}_template_hierarchy").
+ // The most specifically anchored match wins, so a name is not attributed
+ // to a loosely anchored hook that merely happens to match it as well.
+ $best = null;
+ $anchor_len = -1;
+ foreach ( $docs['patterns'] as $pattern ) {
+ $literal_len = strlen( $pattern['literal'] );
+
+ if ( $literal_len <= $anchor_len || ! self::isAnchorableLiteral( $pattern['literal'] ) ) {
+ continue;
+ }
+
+ if ( preg_match( $pattern['regex'], $name ) ) {
+ $best = $pattern['text'];
+ $anchor_len = $literal_len;
+ }
+ }
+
+ return $best;
+ }
+
+ // A dynamic referencing name matches the same dynamic canonical (identical
+ // regex), or a literal canonical the pattern covers.
+ $regex = $matcher['value'];
+
+ foreach ( $docs['patterns'] as $pattern ) {
+ if ( $pattern['regex'] === $regex ) {
+ return $pattern['text'];
+ }
+ }
+
+ // Covering a literal canonical is only meaningful for a pattern anchored
+ // specifically enough to identify a hook.
+ if ( ! self::isAnchorableLiteral( $matcher['literal'] ) ) {
+ return null;
+ }
+
+ foreach ( $docs['exact'] as $name => $text ) {
+ if ( preg_match( $regex, $name ) ) {
+ return $text;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the hook documentation declared by a file, parsing each file at most
+ * once per process.
+ *
+ * @param string $file Absolute path to the file.
+ * @return HookDocs
+ */
+ private function getHookDocs( string $file ): array {
+ if ( ! isset( $this->fileHookDocs[ $file ] ) ) {
+ $this->fileHookDocs[ $file ] = self::loadHookDocs( $file );
+ }
+
+ return $this->fileHookDocs[ $file ];
+ }
+
+ /**
+ * Reads and parses the hook documentation declared by a file.
+ *
+ * @param string $file Absolute path to the file.
+ * @return HookDocs
+ */
+ private static function loadHookDocs( string $file ): array {
+ $empty = array(
+ 'exact' => array(),
+ 'patterns' => array(),
+ );
+
+ if ( ! is_file( $file ) || ! is_readable( $file ) ) {
+ return $empty;
+ }
+
+ $code = file_get_contents( $file );
+ if ( false === $code ) {
+ return $empty;
+ }
+
+ return self::parseHookDocs( $code );
+ }
+
+ /**
+ * Collects the canonical docblock text for each hook invocation documented in
+ * the given PHP source.
+ *
+ * A docblock is treated as canonical when it is not itself a "documented
+ * elsewhere" reference, so referencing call sites do not count as the source
+ * of documentation. Hooks with a literal name are indexed exactly; hooks with
+ * a dynamic name that contains literal text are indexed as a regex, alongside
+ * that literal text, which findHookDoc() uses to rank how specifically a pattern
+ * identifies a hook.
+ *
+ * @see HookDocBlock::findHookDoc()
+ *
+ * @param string $code PHP source code.
+ * @return HookDocs
+ */
+ private static function parseHookDocs( string $code ): array {
+ $docs = array(
+ 'exact' => array(),
+ 'patterns' => array(),
+ );
+
+ // Source that cannot be parsed documents nothing this can read, and must not stop
+ // the analysis: a file is temporarily incomplete while it is being edited, and may
+ // use syntax the host PHP version does not know. Collecting the parse errors
+ // rather than throwing keeps the hooks documented ahead of the error wherever the
+ // parser can recover, and yields none where it cannot.
+ $parser = ( new ParserFactory() )->createForHostVersion();
+
+ try {
+ $stmts = $parser->parse( $code, new Collecting() );
+ } catch ( PhpParserError $parse_error ) {
+ return $docs;
+ }
+
+ if ( null === $stmts ) {
+ return $docs;
+ }
+
+ // Propagate each docblock down to the nested hook-call node.
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor( new HookDocsVisitor() );
+ $stmts = $traverser->traverse( $stmts );
+
+ $seen = array();
+ $calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class );
+ foreach ( $calls as $call ) {
+ if ( ! $call instanceof FuncCall || ! $call->name instanceof Name ) {
+ continue;
+ }
+
+ if ( ! in_array( $call->name->toString(), self::HOOK_FUNCTIONS, true ) ) {
+ continue;
+ }
+
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ continue;
+ }
+
+ $doc = $call->getAttribute( 'latestDocComment' );
+
+ // Skip reference comments so only the canonical documentation counts.
+ if ( ! $doc instanceof Doc || preg_match( self::REFERENCE_PATTERN, $doc->getText() ) ) {
+ continue;
+ }
+
+ $name_expr = $args[0]->value;
+
+ if ( $name_expr instanceof String_ ) {
+ if ( ! isset( $docs['exact'][ $name_expr->value ] ) ) {
+ $docs['exact'][ $name_expr->value ] = $doc->getText();
+ }
+ continue;
+ }
+
+ $pattern = self::buildHookNamePattern( $name_expr );
+ if ( null !== $pattern && ! isset( $seen[ $pattern['regex'] ] ) ) {
+ $seen[ $pattern['regex'] ] = true;
+ $docs['patterns'][] = array(
+ 'regex' => $pattern['regex'],
+ 'literal' => $pattern['literal'],
+ 'text' => $doc->getText(),
+ );
+ }
+ }
+
+ return $docs;
+ }
+
+ /**
+ * Builds an anchored regex matching a dynamic hook name expression, together
+ * with the literal text it is anchored on, or null when the expression carries
+ * no literal text at all.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return array{
+ * regex: non-falsy-string,
+ * literal: non-empty-string,
+ * }|null
+ */
+ private static function buildHookNamePattern( Expr $expr ): ?array {
+ $parts = self::hookNameRegexParts( $expr );
+ if ( null === $parts || '' === $parts[1] ) {
+ return null;
+ }
+
+ return array(
+ 'regex' => '#^' . $parts[0] . '$#',
+ 'literal' => $parts[1],
+ );
+ }
+
+ /**
+ * Determines whether the literal text of a dynamic hook name identifies a hook
+ * specifically enough to resolve documentation through it.
+ *
+ * A name whose literal text is nothing but separators — e.g. taxonomy.php's
+ * `"{$taxonomy}_{$field}"`, which becomes `#^.+_.+$#` — matches almost any hook
+ * name, so honoring it would attribute a call to an unrelated hook's
+ * documentation and hide a genuinely broken reference comment. The hook is still
+ * required to be documented at its own call site; only its use as the
+ * documentation *source* for a differently named hook is refused.
+ *
+ * @param string $literal Concatenated literal text of a hook name expression.
+ * @return bool
+ */
+ private static function isAnchorableLiteral( string $literal ): bool {
+ return '' !== trim( $literal, "-_ \t\n\r\0\x0B" );
+ }
+
+ /**
+ * Recursively converts a hook name expression into a regex fragment and the
+ * literal text that fragment is anchored on.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return array{
+ * 0: string,
+ * 1: string,
+ * }|null Fragment and its literal text, or null if unsupported.
+ */
+ private static function hookNameRegexParts( Expr $expr ): ?array {
+ if ( $expr instanceof String_ ) {
+ return array( preg_quote( $expr->value, '#' ), $expr->value );
+ }
+
+ if ( $expr instanceof Concat ) {
+ $left = self::hookNameRegexParts( $expr->left );
+ $right = self::hookNameRegexParts( $expr->right );
+ if ( null === $left || null === $right ) {
+ return null;
+ }
+ return array( $left[0] . $right[0], $left[1] . $right[1] );
+ }
+
+ if ( $expr instanceof InterpolatedString ) {
+ $fragment = '';
+ $literal = '';
+ foreach ( $expr->parts as $part ) {
+ if ( $part instanceof InterpolatedStringPart ) {
+ $fragment .= preg_quote( $part->value, '#' );
+ $literal .= $part->value;
+ } else {
+ $fragment .= '.+';
+ }
+ }
+ return array( $fragment, $literal );
+ }
+
+ // Variables, property fetches, etc.: a wildcard with no literal anchor.
+ return array( '.+', '' );
+ }
+
+ /**
+ * Resolves a WordPress-root-relative reference path against the file
+ * containing the reference comment.
+ *
+ * The reference comment names the exact file (e.g. "wp-includes/media.php"), so
+ * resolution proceeds in two steps:
+ *
+ * 1. Walk up from the current file's directory, as far as the WordPress root,
+ * until the relative path resolves to a real file inside the tree. This works
+ * regardless of where in the tree the referencing file lives (core, a bundled
+ * theme, the install root, ...), and also resolves the sibling references used
+ * by the bundled themes (e.g. "author.php"). The walk stops at the root because
+ * a path that only resolves above the tree under analysis is a coincidence
+ * rather than the file the comment names.
+ * 2. Fall back to the WordPress root. Step 1 assumes the analysed file sits in
+ * its real location, which does not hold when an IDE runs PHPStan against a
+ * temporary copy of the editor buffer. Without this fallback, every
+ * reference comment in such a copy is reported as naming a missing file.
+ *
+ * Only the single named file is ever tested; no directory is enumerated.
+ *
+ * @param string $current_file Absolute path to the file with the reference comment.
+ * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php").
+ * @return string|null Absolute path to the referenced file, or null when it cannot be located.
+ */
+ private function resolveReferencePath( string $current_file, string $reference_path ): ?string {
+ $reference_path = ltrim( $reference_path, '/' );
+ $dir = dirname( $current_file );
+
+ while ( $dir === $this->wordpressRoot || str_starts_with( $dir, $this->wordpressRoot . '/' ) ) {
+ $target = $this->resolveWithinRoot( $dir . '/' . $reference_path );
+ if ( null !== $target ) {
+ return $target;
+ }
+
+ // The root has just been tested, so the walk is done.
+ if ( $dir === $this->wordpressRoot ) {
+ return null;
+ }
+
+ $dir = dirname( $dir );
+ }
+
+ // The file holding the comment is not in the tree, so resolve against the root.
+ return $this->resolveWithinRoot( $this->wordpressRoot . '/' . $reference_path );
+ }
+
+ /**
+ * Canonicalizes a candidate path, accepting it only when it is a file inside the
+ * WordPress tree.
+ *
+ * A reference path is usually a plain relative path, but the convention is also
+ * written with dot segments relative to the file holding the comment: WooCommerce
+ * references `../wc-user-functions.php` and MainWP `../widgets/…`, so those have to
+ * keep resolving. WordPress core has never used that form, which is exactly why
+ * rejecting dot segments outright would look harmless here and break those plugins.
+ *
+ * Canonicalizing the candidate and requiring the result to be inside the tree keeps
+ * them working while ensuring a reference cannot reach a file outside it. That
+ * matters because whatever resolution finds is then read and parsed.
+ *
+ * @param string $candidate Absolute candidate path, possibly containing dot segments.
+ * @return string|null Canonical path, or null when it is not a file inside the tree.
+ */
+ private function resolveWithinRoot( string $candidate ): ?string {
+ if ( ! is_file( $candidate ) ) {
+ return null;
+ }
+
+ $canonical = realpath( $candidate );
+
+ if ( false === $canonical ) {
+ return null;
+ }
+
+ return str_starts_with( $canonical, $this->canonicalWordpressRoot . '/' ) ? $canonical : null;
+ }
+
+ /**
+ * Returns a matcher describing a hook call's name: a literal string to look up
+ * exactly, or a regex for a dynamic name (e.g. "{$type}_template_hierarchy").
+ *
+ * @param FuncCall $call Hook function call node.
+ * @return HookNameMatcher|null Null when the name carries no identifiable text
+ * (e.g. a bare variable).
+ */
+ private static function getHookNameMatcher( FuncCall $call ): ?array {
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return null;
+ }
+
+ $expr = $args[0]->value;
+
+ if ( $expr instanceof String_ ) {
+ return array(
+ 'kind' => 'literal',
+ 'value' => $expr->value,
+ 'literal' => $expr->value,
+ );
+ }
+
+ $pattern = self::buildHookNamePattern( $expr );
+ if ( null !== $pattern ) {
+ return array(
+ 'kind' => 'pattern',
+ 'value' => $pattern['regex'],
+ 'literal' => $pattern['literal'],
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Renders a hook name expression to a readable string for diagnostics, e.g.
+ * "default_option_{$option}".
+ *
+ * @param FuncCall $call Hook function call node.
+ * @return string
+ */
+ public static function getHookNameDisplay( FuncCall $call ): string {
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return '';
+ }
+
+ return self::renderHookName( $args[0]->value );
+ }
+
+ /**
+ * Recursively renders a hook name expression to a readable string.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return string
+ */
+ private static function renderHookName( Expr $expr ): string {
+ if ( $expr instanceof String_ ) {
+ return $expr->value;
+ }
+
+ if ( $expr instanceof Concat ) {
+ return self::renderHookName( $expr->left ) . self::renderHookName( $expr->right );
+ }
+
+ if ( $expr instanceof InterpolatedString ) {
+ $out = '';
+ foreach ( $expr->parts as $part ) {
+ if ( $part instanceof InterpolatedStringPart ) {
+ $out .= $part->value;
+ } elseif ( $part instanceof Variable && is_string( $part->name ) ) {
+ $out .= '{$' . $part->name . '}';
+ } else {
+ $out .= '{...}';
+ }
+ }
+ return $out;
+ }
+
+ if ( $expr instanceof Variable && is_string( $expr->name ) ) {
+ return '$' . $expr->name;
+ }
+
+ return '...';
+ }
+
+ /**
+ * Returns the docblock attached to the node by HookDocsVisitor, if present.
+ *
+ * @param FuncCall $node Function call node.
+ * @return Doc|null
+ */
+ private static function getNullableNodeComment( FuncCall $node ): ?Doc {
+ /** @var Doc|null $doc */
+ $doc = $node->getAttribute( 'latestDocComment' );
+ return $doc;
+ }
+}
diff --git a/tests/phpstan/HookDocsResultCacheMetaExtension.php b/tests/phpstan/HookDocsResultCacheMetaExtension.php
new file mode 100644
index 0000000000000..ff7b3a9fa068b
--- /dev/null
+++ b/tests/phpstan/HookDocsResultCacheMetaExtension.php
@@ -0,0 +1,99 @@
+ *\/` convention and read with plain
+ * file I/O. A referencing file has no symbol dependency on its reference target,
+ * so editing a canonical docblock re-analyzes only the file that docblock lives
+ * in; every call site inheriting it keeps its cached result.
+ * 2. The tooling's own source files. PHPStan hashes its configuration, but changing
+ * a rule's logic does not invalidate results that rule already produced.
+ *
+ * Both are folded into the result cache key here, so cached results are discarded
+ * when — and only when — an inheritable docblock or the tooling itself changes.
+ *
+ * @see HookDocBlock::getReferencedHookDocsHash()
+ *
+ * @package WordPress
+ */
+
+declare(strict_types=1);
+
+namespace WordPress\PHPStan;
+
+use PHPStan\Analyser\ResultCache\ResultCacheMetaExtension;
+
+/**
+ * Invalidates the result cache when hook documentation read from another file, or
+ * the tooling reading it, changes.
+ */
+final class HookDocsResultCacheMetaExtension implements ResultCacheMetaExtension {
+
+ /**
+ * Hook docblock resolver.
+ *
+ * @var HookDocBlock
+ */
+ private HookDocBlock $hookDocBlock;
+
+ /**
+ * Constructor.
+ *
+ * @param HookDocBlock $hook_doc_block Hook docblock resolver.
+ */
+ public function __construct( HookDocBlock $hook_doc_block ) {
+ $this->hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Returns the key identifying this metadata source.
+ *
+ * @return non-empty-string
+ */
+ public function getKey(): string {
+ return 'wordpressHookDocs';
+ }
+
+ /**
+ * Returns a hash of the inheritable hook documentation and of the tooling that
+ * reads it.
+ *
+ * @return non-falsy-string
+ */
+ public function getHash(): string {
+ return md5( self::getToolingHash() . '|' . $this->hookDocBlock->getReferencedHookDocsHash() );
+ }
+
+ /**
+ * Hashes the PHPStan extension sources in this directory.
+ *
+ * @return non-falsy-string
+ */
+ private static function getToolingHash(): string {
+ $files = glob( __DIR__ . '/*.php' );
+
+ if ( ! is_array( $files ) ) {
+ $files = array();
+ }
+
+ sort( $files );
+
+ $parts = array();
+
+ foreach ( $files as $file ) {
+ $contents = file_get_contents( $file );
+
+ if ( false === $contents ) {
+ continue;
+ }
+
+ $parts[] = basename( $file ) . ':' . md5( $contents );
+ }
+
+ return md5( implode( '|', $parts ) );
+ }
+}
diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php
new file mode 100644
index 0000000000000..a025c1127f402
--- /dev/null
+++ b/tests/phpstan/HookDocsVisitor.php
@@ -0,0 +1,122 @@
+
+ */
+ private array $stack = array();
+
+ /**
+ * Resets state before traversing a new set of nodes.
+ *
+ * @param Node[] $nodes Nodes about to be traversed.
+ * @return Node[]|null
+ */
+ public function beforeTraverse( array $nodes ): ?array {
+ $this->latestDocComment = null;
+ $this->stack = array();
+
+ return null;
+ }
+
+ /**
+ * Tracks the applicable docblock and attaches it to function-call nodes.
+ *
+ * @param Node $node Node being entered.
+ * @return Node|null
+ */
+ public function enterNode( Node $node ): ?Node {
+ $doc = $node->getDocComment();
+
+ if ( null !== $doc ) {
+ // A docblock here documents this node and everything nested within it.
+ $this->stack[] = array( $node, $this->latestDocComment );
+ $this->latestDocComment = $doc;
+ } elseif ( $node instanceof Stmt ) {
+ // A new statement without its own docblock starts an undocumented scope
+ // for its subtree, so a preceding docblock does not carry into it.
+ $this->stack[] = array( $node, $this->latestDocComment );
+ $this->latestDocComment = null;
+ }
+
+ // Attributes are retained for as long as a parsed file is held in memory, so
+ // the docblock is recorded only where it can be read: on a function call, and
+ // only when there is one to record. Readers cannot tell an absent attribute
+ // from a null one, so skipping the write costs them nothing.
+ if ( null !== $this->latestDocComment && $node instanceof FuncCall ) {
+ $node->setAttribute( 'latestDocComment', $this->latestDocComment );
+ }
+
+ return null;
+ }
+
+ /**
+ * Restores the docblock that applied before this node was entered, bounding a
+ * docblock's reach to the node that introduced it.
+ *
+ * @param Node $node Node being left.
+ * @return Node|null
+ */
+ public function leaveNode( Node $node ): ?Node {
+ $top = end( $this->stack );
+
+ if ( false !== $top && $top[0] === $node ) {
+ $this->latestDocComment = $top[1];
+ array_pop( $this->stack );
+ }
+
+ return null;
+ }
+}
diff --git a/tests/phpstan/HookDocumentationRule.php b/tests/phpstan/HookDocumentationRule.php
new file mode 100644
index 0000000000000..7eead6a371321
--- /dev/null
+++ b/tests/phpstan/HookDocumentationRule.php
@@ -0,0 +1,166 @@
+ *\/` reference comment.
+ *
+ * When a reference comment is used, the referenced file must exist and must
+ * actually document a hook of the same name; otherwise an error is reported.
+ *
+ * @package WordPress
+ */
+
+declare(strict_types=1);
+
+namespace WordPress\PHPStan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Name;
+use PHPStan\Analyser\Scope;
+use PHPStan\Rules\IdentifierRuleError;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+use PHPStan\ShouldNotHappenException;
+
+/**
+ * Reports undocumented hooks and broken "documented elsewhere" references.
+ *
+ * @implements Rule
+ */
+class HookDocumentationRule implements Rule {
+
+ /**
+ * Hook docblock resolver.
+ *
+ * @var HookDocBlock
+ */
+ private HookDocBlock $hookDocBlock;
+
+ /**
+ * Constructor.
+ *
+ * @param HookDocBlock $hook_doc_block Hook docblock resolver.
+ */
+ public function __construct( HookDocBlock $hook_doc_block ) {
+ $this->hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Returns the node type this rule processes.
+ *
+ * @return string
+ */
+ public function getNodeType(): string {
+ return FuncCall::class;
+ }
+
+ /**
+ * Processes a function call node.
+ *
+ * @param Node $node Function call node.
+ * @param Scope $scope Analysis scope.
+ * @return list
+ * @throws ShouldNotHappenException
+ */
+ public function processNode( Node $node, Scope $scope ): array {
+ if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) {
+ return array();
+ }
+
+ if ( ! in_array( $node->name->toString(), HookDocBlock::HOOK_FUNCTIONS, true ) ) {
+ return array();
+ }
+
+ // Skip calls whose hook name carries no literal text, i.e. a bare variable
+ // such as the generic apply_filters_ref_array( $hook_name, $args )
+ // re-dispatch in plugin.php. There is no concrete hook to document or look
+ // up. Calls naming a hook literally (e.g. apply_filters_ref_array( 'the_posts',
+ // ... )) or dynamically with literal text (e.g. "{$type}_template_hierarchy")
+ // remain subject to the documentation requirement.
+ if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) {
+ return array();
+ }
+
+ $function_name = $node->name->toString();
+ $hook_doc = $this->hookDocBlock->getHookDoc( $node, $scope );
+
+ // No preceding docblock at all: the hook is undocumented.
+ if ( null === $hook_doc ) {
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" is not preceded by a docblock documenting the hook, nor by a "This filter/action is documented in " reference comment.',
+ $function_name,
+ HookDocBlock::getHookNameDisplay( $node )
+ )
+ )
+ ->identifier( 'wordpress.hookDocMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ // An inline docblock documents the hook in place, provided it describes the
+ // value being filtered.
+ if ( 'reference' !== $hook_doc['kind'] ) {
+ if ( ! HookDocBlock::isFilterMissingParamDocs( $node, $hook_doc ) ) {
+ return array();
+ }
+
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" is preceded by a docblock that documents no parameters. A filter is documented with a `@param` tag for the value being filtered, plus one for each further argument passed.',
+ $function_name,
+ HookDocBlock::getHookNameDisplay( $node )
+ )
+ )
+ ->identifier( 'wordpress.hookDocNoParams' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ // A reference comment must point at a file that documents this hook.
+ $problem = $hook_doc['problem'];
+ if ( null === $problem ) {
+ return array();
+ }
+
+ if ( HookDocBlock::PROBLEM_FILE_MISSING === $problem['problem'] ) {
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" references documentation in "%s", but no such file exists in the tree being analyzed.',
+ $function_name,
+ $problem['hook'],
+ $problem['path']
+ )
+ )
+ ->identifier( 'wordpress.hookDocReferenceFileMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" references documentation in "%s", but no documented "%s" hook is found there.',
+ $function_name,
+ $problem['hook'],
+ $problem['path'],
+ $problem['hook']
+ )
+ )
+ ->identifier( 'wordpress.hookDocReferenceHookMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+}
diff --git a/tests/phpstan/HookParamCountRule.php b/tests/phpstan/HookParamCountRule.php
new file mode 100644
index 0000000000000..4cebe197a7af4
--- /dev/null
+++ b/tests/phpstan/HookParamCountRule.php
@@ -0,0 +1,218 @@
+"
+ * reference is checked against its canonical docblock.
+ *
+ * @package WordPress
+ */
+
+declare(strict_types=1);
+
+namespace WordPress\PHPStan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Name;
+use PHPStan\Analyser\Scope;
+use PHPStan\Rules\IdentifierRuleError;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+use PHPStan\ShouldNotHappenException;
+use PHPStan\Type\Constant\ConstantIntegerType;
+
+/**
+ * Reports hook invocations whose argument count does not match the number of
+ * documented parameters.
+ *
+ * @implements Rule
+ */
+class HookParamCountRule implements Rule {
+
+ /**
+ * Hook functions that receive the hook arguments as variadic parameters.
+ */
+ private const VARIADIC_FUNCTIONS = array(
+ 'apply_filters',
+ 'do_action',
+ );
+
+ /**
+ * Hook functions that receive the hook arguments as an array in their second
+ * parameter.
+ */
+ private const ARRAY_ARG_FUNCTIONS = array(
+ 'apply_filters_ref_array',
+ 'apply_filters_deprecated',
+ 'do_action_ref_array',
+ 'do_action_deprecated',
+ );
+
+ /**
+ * Hook docblock resolver.
+ *
+ * @var HookDocBlock
+ */
+ private HookDocBlock $hookDocBlock;
+
+ /**
+ * Constructor.
+ *
+ * @param HookDocBlock $hook_doc_block Hook docblock resolver.
+ */
+ public function __construct( HookDocBlock $hook_doc_block ) {
+ $this->hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Returns the node type this rule processes.
+ *
+ * @return string
+ */
+ public function getNodeType(): string {
+ return FuncCall::class;
+ }
+
+ /**
+ * Processes a function call node.
+ *
+ * @param Node $node Function call node.
+ * @param Scope $scope Analysis scope.
+ * @return list
+ * @throws ShouldNotHappenException
+ */
+ public function processNode( Node $node, Scope $scope ): array {
+ if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) {
+ return array();
+ }
+
+ $function_name = $node->name->toString();
+ $is_variadic = in_array( $function_name, self::VARIADIC_FUNCTIONS, true );
+ if ( ! $is_variadic && ! in_array( $function_name, self::ARRAY_ARG_FUNCTIONS, true ) ) {
+ return array();
+ }
+
+ // Without an identifiable hook name there is nothing to document or look up.
+ if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) {
+ return array();
+ }
+
+ // Only compare against documentation that actually resolves. Missing docs and
+ // unresolvable/broken references (reported by HookDocumentationRule) leave the
+ // documented count unknown, and are skipped rather than compared against a
+ // bogus zero count.
+ $hook_doc = $this->hookDocBlock->getHookDoc( $node, $scope );
+ if ( null === $hook_doc || null === $hook_doc['paramCount'] ) {
+ return array();
+ }
+
+ // A filter whose docblock documents no parameters is not documented at all,
+ // which HookDocumentationRule reports. Comparing counts as well would report
+ // one defect twice.
+ if ( HookDocBlock::isFilterMissingParamDocs( $node, $hook_doc ) ) {
+ return array();
+ }
+
+ $documented = $hook_doc['paramCount'];
+
+ $provided = $is_variadic
+ ? self::countVariadicArguments( $node, $scope )
+ : self::countArrayArguments( $node, $scope );
+
+ // The provided count could not be determined statically; skip rather than
+ // guess (e.g. arguments spread from a variable of unknown size).
+ if ( null === $provided || $provided === $documented ) {
+ return array();
+ }
+
+ $hook_name = HookDocBlock::getHookNameDisplay( $node );
+
+ // An action documented without any `@param` tag reads better as a statement
+ // about its docblock than as a count of zero.
+ $message = 0 === $documented
+ ? sprintf(
+ '%s() for hook "%s" provides %d argument%s, but its docblock documents no parameters.',
+ $function_name,
+ $hook_name,
+ $provided,
+ 1 === $provided ? '' : 's'
+ )
+ : sprintf(
+ '%s() for hook "%s" provides %d argument%s, but the hook is documented with %d parameter%s.',
+ $function_name,
+ $hook_name,
+ $provided,
+ 1 === $provided ? '' : 's',
+ $documented,
+ 1 === $documented ? '' : 's'
+ );
+
+ return array(
+ RuleErrorBuilder::message( $message )
+ ->identifier( 'wordpress.hookParamCountMismatch' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ /**
+ * Counts the arguments a variadic hook call passes after the hook name.
+ *
+ * @param FuncCall $node Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return int|null Argument count, or null when it cannot be determined statically.
+ */
+ private static function countVariadicArguments( FuncCall $node, Scope $scope ): ?int {
+ $args = $node->getArgs();
+ $count = 0;
+
+ // Skip index 0, the hook name.
+ for ( $i = 1, $len = count( $args ); $i < $len; $i++ ) {
+ $arg = $args[ $i ];
+
+ if ( $arg->unpack ) {
+ $size = $scope->getType( $arg->value )->getArraySize();
+ if ( ! $size instanceof ConstantIntegerType ) {
+ return null;
+ }
+ $count += $size->getValue();
+ continue;
+ }
+
+ ++$count;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Counts the arguments a hook call passes via its array argument.
+ *
+ * @param FuncCall $node Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return int|null Argument count, or null when it cannot be determined statically.
+ */
+ private static function countArrayArguments( FuncCall $node, Scope $scope ): ?int {
+ $args = $node->getArgs();
+ if ( ! isset( $args[1] ) ) {
+ return null;
+ }
+
+ $size = $scope->getType( $args[1]->value )->getArraySize();
+ if ( ! $size instanceof ConstantIntegerType ) {
+ return null;
+ }
+
+ return $size->getValue();
+ }
+}
diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md
index 967c4a9d0195d..036f4b98432e3 100644
--- a/tests/phpstan/README.md
+++ b/tests/phpstan/README.md
@@ -49,6 +49,38 @@ You can create a local copy at `phpstan.neon` to override the default configurat
For more information about configuring PHPStan, see the [PHPStan documentation's Config reference](https://phpstan.org/config-reference).
+## WordPress-specific extensions
+
+This directory also contains extensions that teach PHPStan conventions specific to WordPress. They are registered in [`base.neon`](base.neon), so they apply to the default configuration and to any local override of it.
+
+### Global variables in function docblocks
+
+Core documents the globals a function uses with `@global Type $varname`. `GlobalDocBlockVisitor` bridges that convention to PHPStan's variable type resolution, so those globals are typed rather than `mixed` inside the function.
+
+### Hook documentation
+
+The remaining extensions read the docblock documenting a hook where the hook is fired, which is where WordPress documents its hooks. They cover `apply_filters()`, `do_action()` and their `_deprecated` and `_ref_array` variants.
+
+- **The value a filter returns is typed from its documentation.** `apply_filters()` returns the type of the first `@param` its docblock documents, rather than `mixed`. This assumes callbacks honor the documented type; one that returns something else is treated as the unusual case.
+- **Hooks documented elsewhere are resolved.** Core's `/** This filter is documented in */` convention is followed, in its action form as well, so a hook documented in another file is analyzed against its canonical docblock. A dynamic canonical name such as `"{$type}_template_hierarchy"` is matched against the literal name used at the referencing site.
+- **Two rules check the documentation itself**: that a hook is documented at all, and that it is fired with as many arguments as its documentation describes.
+
+Calls whose hook name contains no literal text, such as the `apply_filters_ref_array( $hook_name, $args )` re-dispatch in `plugin.php`, name no concrete hook and are skipped.
+
+One consequence worth knowing: because a hook's documentation may live in a different file than the call inheriting it, editing a hook docblock in a file that reference comments point at discards PHPStan's result cache. Every call site inheriting that docblock has to be analyzed again, and PHPStan cannot infer that dependency on its own.
+
+### Errors these rules report
+
+These identifiers are specific to WordPress, and can be ignored or baselined like any other error, as described [below](#ignoring-and-baselining-errors).
+
+| Identifier | What it means |
+| --- | --- |
+| `wordpress.hookDocMissing` | The hook is fired with neither a docblock documenting it nor a reference comment. Document it, or point at wherever it is documented. |
+| `wordpress.hookDocNoParams` | A filter's docblock documents no parameters. A filter always passes at least the value being filtered, so document that value with `@param`, plus one for each further argument. This also fires when an unrelated docblock, such as a `@var` annotation, happens to sit immediately above the call. |
+| `wordpress.hookDocReferenceFileMissing` | A reference comment names a file that does not exist in the tree being analyzed. The path is resolved relative to the file holding the comment and to the WordPress root; one that resolves outside the tree counts as missing, since the analysis does not read it. |
+| `wordpress.hookDocReferenceHookMissing` | The referenced file exists, but documents no hook of that name. Either the reference is stale, or the canonical docblock has moved. |
+| `wordpress.hookParamCountMismatch` | The call passes a different number of arguments than the docblock documents `@param` tags for. Passing fewer risks an `ArgumentCountError` in a callback registered for the documented count; passing more silently drops the extra argument and leaves the documentation misleading. |
+
## Ignoring and baselining errors
As we adopt PHPStan iteratively, you may be faced with false positives due to legacy code, or code that is not worth changing at this time.
diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon
index 0210e0618d785..71c0fa6ab6cdd 100644
--- a/tests/phpstan/base.neon
+++ b/tests/phpstan/base.neon
@@ -12,6 +12,48 @@ services:
tags:
- phpstan.parser.richParserNodeVisitor
+ # Attaches the docblock documenting a hook to the hook's call, so that the return
+ # type extension and the rules below can all read it.
+ -
+ class: WordPress\PHPStan\HookDocsVisitor
+ tags:
+ - phpstan.parser.richParserNodeVisitor
+
+ # Resolves a hook call's documentation, whether written at the call or inherited
+ # through the "This filter is documented in " convention.
+ -
+ class: WordPress\PHPStan\HookDocBlock
+
+ # Types the return value of apply_filters() (and variants) from the `@param` type
+ # documented for the value being filtered. Adapted from szepeviktor/phpstan-wordpress.
+ -
+ class: WordPress\PHPStan\ApplyFiltersDynamicFunctionReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicFunctionReturnTypeExtension
+
+ # Enforces that every hook invocation is preceded by a documenting docblock or
+ # a valid "This filter is documented in " reference comment.
+ -
+ class: WordPress\PHPStan\HookDocumentationRule
+ tags:
+ - phpstan.rules.rule
+
+ # Enforces that a hook invocation passes as many arguments as its documentation
+ # (inline or referenced) describes.
+ -
+ class: WordPress\PHPStan\HookParamCountRule
+ tags:
+ - phpstan.rules.rule
+
+ # Docblocks inherited from another file, and the sources above that read them,
+ # are invisible to PHPStan's dependency graph, so both are folded into the result
+ # cache key. Without this, editing a canonical hook docblock leaves the cached
+ # results of every call site inheriting it in place.
+ -
+ class: WordPress\PHPStan\HookDocsResultCacheMetaExtension
+ tags:
+ - phpstan.resultCacheMetaExtension
+
parameters:
# Cache is stored locally, so it's available for CI.
tmpDir: ../../.cache
@@ -99,6 +141,12 @@ parameters:
- ../../src/wp-trackback.php
- ../../src/xmlrpc.php
- GlobalDocBlockVisitor.php
+ - HookDocsVisitor.php
+ - HookDocBlock.php
+ - ApplyFiltersDynamicFunctionReturnTypeExtension.php
+ - HookDocumentationRule.php
+ - HookParamCountRule.php
+ - HookDocsResultCacheMetaExtension.php
bootstrapFiles:
- bootstrap.php
scanFiles:
@@ -119,6 +167,8 @@ parameters:
- ../../src/wp-admin/load-styles.php
# These files are autogenerated by tools/gutenberg/copy.js.
- ../../src/wp-includes/blocks
+ # Generated output from the Gutenberg plugin's wp-build templates.
+ - ../../src/wp-includes/build
# Third-party libraries.
- ../../src/wp-admin/includes/class-ftp-pure.php
- ../../src/wp-admin/includes/class-ftp-sockets.php
From 109f43b103522d5c78a9d41cdaf112ac961caf55 Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Thu, 30 Jul 2026 07:27:17 +0000
Subject: [PATCH 081/149] Media: Fix focus style and first added item dotted
outline in the Media grid.
With Infinite scrolling disabled, after clicking 'Load more', new added items in the Media grid had the focus style and the dotted outline for the first added item partially cut-off. The root cause is that all the added items use a background color and each item sits 'on top' of the previous one thus overlying the previous item box-shadow and outline on the right and bottom edges.
Fixes the focus style by making the box-shadow `inset`. Also makes the focus style consistent between the Media grid in the Media Library and the one in the Media dialog.
Fixes the dotted outline for the first added item by using a negative outline-offset.
Improves color contrast ratio of the dotted outline.
Props afercia.
Fixes #65755.
git-svn-id: https://develop.svn.wordpress.org/trunk@62940 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/media.css | 3 ++-
src/wp-includes/css/media-views.css | 11 +++++++----
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css
index dbce2c705995c..73e01d70ecf1d 100644
--- a/src/wp-admin/css/media.css
+++ b/src/wp-admin/css/media.css
@@ -528,7 +528,8 @@ border color while dragging a file over the uploader drop area */
.media-frame.mode-grid .attachment:focus,
.media-frame.mode-grid .selected.attachment:focus,
.media-frame.mode-grid .attachment.details:focus {
- box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
+ /* Keep this box-shadow consistent between media.css and media-views.css. */
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
outline-offset: -6px;
diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css
index 835b04a8bf6bf..64d559a6d3b80 100644
--- a/src/wp-includes/css/media-views.css
+++ b/src/wp-includes/css/media-views.css
@@ -1031,9 +1031,8 @@ select#media-attachment-date-filters {
.wp-core-ui .attachment:focus,
.wp-core-ui .selected.attachment:focus,
.wp-core-ui .attachment.details:focus {
- box-shadow:
- inset 0 0 2px 3px #fff,
- inset 0 0 0 7px var(--wp-admin-theme-color, #3858e9);
+ /* Keep this box-shadow consistent between media.css and media-views.css. */
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
outline-offset: -6px;
@@ -1399,7 +1398,11 @@ select#media-attachment-date-filters {
}
.attachment.new-media {
- outline: 2px dotted #c3c4c7;
+ /* Dotted outline for the first added item when Infinite scrolling is disabled. */
+ /* Contrast ratio must be at least 3:1 against the #dcdcde background for added items when Infinite scrolling is disabled. */
+ outline: 2px dotted #757575;
+ /* Prevent the border from being obscured by adjacent items. */
+ outline-offset: -2px;
}
/**
From 968994285529578aa8660c09a9f769afb2ca5e35 Mon Sep 17 00:00:00 2001
From: Nik Tsekouras
Date: Thu, 30 Jul 2026 10:08:06 +0000
Subject: [PATCH 082/149] Editor: Fix template `date` and `modified` REST API
values for file-based templates.
File-based templates that have never been customized have no underlying post, so their `date` and `modified` properties are empty. `mysql_to_rfc3339()` returns `false` for such values, which matches neither the documented `string` type nor anything a client can format. The templates controller now returns `null` for both fields in that case, and the `modified` schema is widened to allow it.
Ports the changes from the Gutenberg plugin. See https://github.com/WordPress/gutenberg/pull/80733.
Follow-up to [62571].
Props ntsekouras, mamaduka, tyxla.
Fixes #65728.
git-svn-id: https://develop.svn.wordpress.org/trunk@62941 602fd350-edb4-49c9-b593-d223f7449a82
---
.../class-wp-rest-templates-controller.php | 22 +++++++++++++++----
.../rest-api/wpRestTemplatesController.php | 20 +++++++++++++++++
2 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php
index b821ca09453e3..b6691c588ca7d 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php
@@ -668,6 +668,8 @@ protected function prepare_item_for_database( $request ) {
* @since 5.9.0 Renamed `$template` to `$item` to match parent class for PHP 8 named parameter support.
* @since 6.3.0 Added `modified` property to the response.
* @since 7.1.0 Added `date` property to the response.
+ * @since 7.1.0 The `modified` property is `null` for templates that have no
+ * modification date.
*
* @param WP_Block_Template $item Template instance.
* @param WP_REST_Request $request Request object.
@@ -776,11 +778,23 @@ public function prepare_item_for_response( $item, $request ) {
}
if ( rest_is_field_included( 'modified', $fields ) ) {
- $data['modified'] = mysql_to_rfc3339( $template->modified );
+ /*
+ * File-backed templates have no modification date, and `mysql_to_rfc3339()`
+ * returns `false` for an empty or malformed value, which the schema does
+ * not allow. Return `null` in that case.
+ */
+ $modified = mysql_to_rfc3339( $template->modified );
+ $data['modified'] = false !== $modified ? $modified : null;
}
if ( rest_is_field_included( 'date', $fields ) ) {
- $data['date'] = mysql_to_rfc3339( $template->date );
+ /*
+ * File-backed templates have no date, and `mysql_to_rfc3339()` returns
+ * `false` for an empty or malformed value, which the schema does not
+ * allow. Return `null` in that case.
+ */
+ $date = mysql_to_rfc3339( $template->date );
+ $data['date'] = false !== $date ? $date : null;
}
if ( rest_is_field_included( 'author_text', $fields ) ) {
@@ -1154,7 +1168,7 @@ public function get_item_schema() {
),
'modified' => array(
'description' => __( "The date the template was last modified, in the site's timezone." ),
- 'type' => 'string',
+ 'type' => array( 'string', 'null' ),
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
@@ -1177,7 +1191,7 @@ public function get_item_schema() {
'user',
),
),
- 'date' => array(
+ 'date' => array(
'description' => __( "The date the template was published, in the site's timezone." ),
'type' => array( 'string', 'null' ),
'format' => 'date-time',
diff --git a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php
index 42eed8dfa9c35..e8ff29beaac33 100644
--- a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php
+++ b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php
@@ -638,6 +638,26 @@ public function test_get_item_from_registry() {
$this->assertSame( 404, $response->get_status(), 'Fetching an unregistered template should return 404.' );
}
+ /**
+ * A file-backed template has no publication or modification date, which should
+ * be exposed as `null` rather than the `false` returned by `mysql_to_rfc3339()`.
+ *
+ * @ticket 65728
+ * @covers WP_REST_Templates_Controller::prepare_item_for_response
+ */
+ public function test_get_item_dates_are_null_for_file_backed_template() {
+ wp_set_current_user( self::$admin_id );
+ switch_theme( 'block-theme' );
+
+ $request = new WP_REST_Request( 'GET', '/wp/v2/templates/block-theme//page-home' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status(), 'Fetching a file-backed template should return 200.' );
+ $this->assertNull( $data['date'], 'The date should be null for a file-backed template.' );
+ $this->assertNull( $data['modified'], 'The modified date should be null for a file-backed template.' );
+ }
+
/**
* @ticket 54507
* @dataProvider data_sanitize_template_id
From c2a8f06b23267d35a2934594ea7504b29b7ad59b Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Thu, 30 Jul 2026 13:57:41 +0000
Subject: [PATCH 083/149] Administration: Improve consistency of the focus
style across the admin.
After the admin reskin in WordPress 7.0, the focus style for various elements in the admin was slightly inconsistent especially regarding the overall border plus box-shadow thickness.
This change aims to fix most of the cases found and it's a consistency improvement over what it's in WordPress 7.0.
Developed in https://github.com/WordPress/wordpress-develop/pull/12601
Props iamchitti, afercia.
Fixes #65645.
git-svn-id: https://develop.svn.wordpress.org/trunk@62942 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/edit.css | 1 +
src/wp-admin/css/forms.css | 5 +++--
src/wp-admin/css/list-tables.css | 7 +++++++
src/wp-includes/css/media-views.css | 3 +--
4 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/wp-admin/css/edit.css b/src/wp-admin/css/edit.css
index e96cf4692fc03..c02a15aa3995a 100644
--- a/src/wp-admin/css/edit.css
+++ b/src/wp-admin/css/edit.css
@@ -1390,6 +1390,7 @@ div.tabs-panel-inactive {
}
div.tabs-panel-active:focus {
+ border-color: var(--wp-admin-theme-color);
box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css
index dd19e1ba8070a..3747fb5028484 100644
--- a/src/wp-admin/css/forms.css
+++ b/src/wp-admin/css/forms.css
@@ -658,7 +658,7 @@ fieldset label,
background: transparent;
border-color: var(--wp-admin-theme-color);
border-radius: 2px;
- box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
}
@@ -736,7 +736,8 @@ fieldset label,
#pass1:focus,
#pass1-text:focus {
- box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color);
+ border-color: var(--wp-admin-theme-color, #3858e9);
/* Only visible in Windows High Contrast mode */
outline: 2px solid transparent;
}
diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
index 46c2002e3e3a1..ccb8c92bfbe2a 100644
--- a/src/wp-admin/css/list-tables.css
+++ b/src/wp-admin/css/list-tables.css
@@ -769,6 +769,8 @@ th.sorted a span {
text-align: center;
line-height: 1.84615384;
text-decoration: none;
+ /* This border is needed for the focus style, which will change the border color. */
+ border: 1px solid transparent;
}
.view-switch a:before {
@@ -780,6 +782,11 @@ th.sorted a span {
-moz-osx-font-smoothing: grayscale;
}
+.view-switch a:focus {
+ /* This focus style already inherits box-shadow and outline from the regular links focus style. */
+ border-color: var(--wp-admin-theme-color, #3858e9);
+}
+
.view-switch a:hover:before,
.view-switch a:focus:before {
color: #787c82;
diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css
index 64d559a6d3b80..228ca0ee40160 100644
--- a/src/wp-includes/css/media-views.css
+++ b/src/wp-includes/css/media-views.css
@@ -136,8 +136,7 @@
.media-frame textarea:focus,
.media-frame select:focus {
border-color: var(--wp-admin-theme-color, #3858e9);
- /* Expand border by 0.5px for total 1.5px effect */
- box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color, #3858e9);
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
outline: 2px solid transparent;
}
From b64c2acd2e00e89daeab0e2cf92701d14eb5141f Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Thu, 30 Jul 2026 14:54:34 +0000
Subject: [PATCH 084/149] Administration: Fix the alignment of the 'Update to'
button in the 'At a Glance' Dashboard widget.
Updates the CSS for the 'At a Glance' dashboard widget to use a flexbox layout so the Core update button stays within the widget container and better aligns to the version text.
Also, fixes the DOM order and the visual order so that they match.
Developed in https://github.com/WordPress/wordpress-develop/pull/12715
Props khokansardar, joedolson, afercia.
Fixes #65733.
git-svn-id: https://develop.svn.wordpress.org/trunk@62943 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/dashboard.css | 16 ++++++++++++----
src/wp-admin/includes/update.php | 30 ++++++++++++++----------------
2 files changed, 26 insertions(+), 20 deletions(-)
diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css
index 1f1207606439e..860fe9696b873 100644
--- a/src/wp-admin/css/dashboard.css
+++ b/src/wp-admin/css/dashboard.css
@@ -724,11 +724,19 @@ body #dashboard-widgets .postbox form .submit {
margin: 0;
}
+#dashboard_right_now #wp-version-message {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+#dashboard_right_now #wp-version {
+ flex: 1;
+}
+
#dashboard_right_now #wp-version-message .button {
- float: right;
- position: relative;
- top: -5px;
- margin-left: 5px;
+ margin-left: auto;
}
#dashboard_right_now p.search-engines-info {
diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php
index f5aeea835bd12..b0e998264fe06 100644
--- a/src/wp-admin/includes/update.php
+++ b/src/wp-admin/includes/update.php
@@ -362,21 +362,6 @@ function update_right_now_message() {
$theme_name = sprintf( '%1$s ', $theme_name );
}
- $msg = '';
-
- if ( current_user_can( 'update_core' ) ) {
- $cur = get_preferred_from_update_core();
-
- if ( isset( $cur->response ) && 'upgrade' === $cur->response ) {
- $msg .= sprintf(
- '%s ',
- network_admin_url( 'update-core.php' ),
- /* translators: %s: WordPress version number, or 'Latest' string. */
- sprintf( __( 'Update to %s' ), $cur->current ? $cur->current : __( 'Latest' ) )
- );
- }
- }
-
/* translators: 1: Version number, 2: Theme name. */
$content = __( 'WordPress %1$s running %2$s theme.' );
@@ -391,7 +376,20 @@ function update_right_now_message() {
*/
$content = apply_filters( 'update_right_now_text', $content );
- $msg .= sprintf( '' . $content . ' ', get_bloginfo( 'version', 'display' ), $theme_name );
+ $msg = sprintf( '' . $content . ' ', get_bloginfo( 'version', 'display' ), $theme_name );
+
+ if ( current_user_can( 'update_core' ) ) {
+ $cur = get_preferred_from_update_core();
+
+ if ( isset( $cur->response ) && 'upgrade' === $cur->response ) {
+ $msg .= sprintf(
+ '%s ',
+ network_admin_url( 'update-core.php' ),
+ /* translators: %s: WordPress version number, or 'Latest' string. */
+ sprintf( __( 'Update to %s' ), $cur->current ? $cur->current : __( 'Latest' ) )
+ );
+ }
+ }
echo "$msg
";
}
From 9d33f696d32f1c0238b298a44316f1a7533257f1 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Thu, 30 Jul 2026 17:04:25 +0000
Subject: [PATCH 085/149] Interactivity API: Fix a fatal error when binding a
non-scalar value.
The `WP_Interactivity_API::data_wp_bind_processor()` method passed the evaluated reference straight to `WP_HTML_Tag_Processor::set_attribute()`, which is typed `string|bool`, so a non-scalar value raised a `TypeError` inside the escaping functions: in `strtr()` for an ordinary attribute, and in `esc_url()` for a URI one. This is reachable from post content alone, with no plugin code, because `data-wp-context` can supply an array inline. Such a value is now rejected with `_doing_it_wrong()` and the attribute is left unset, as a `null` value already did, rather than taking down the whole page render.
An object is resolved by round-tripping it through `wp_json_encode()` and `json_decode()`, which is how the client store itself is built, so the two cannot disagree about what it serializes to. `__toString()` is never consulted: an object whose string form differed from its JSON form used to render a value the client immediately overwrote, and one implementing only `JsonSerializable` fataled even though the store was already correct. Numbers are formatted by that same encoder rather than cast, which avoids both the locale-dependent decimal separator a float cast produces before PHP 8.0 and the rounding to `precision` where the store uses `serialize_precision`. `INF` and `NAN` are scalars but JSON can represent neither, and there the cost is the page's entire state rather than one attribute, so they are rejected with a message of their own.
Parsing of directive attributes is hardened. The code touching the `::data_wp_bind_processor()` method is brought to PHPStan level 10 without ignores, including the use of more specific types where available.
Developed in https://github.com/WordPress/wordpress-develop/pull/12725.
Follow-up to r57563, r61020, r62070.
Props westonruter, dmsnell, luisherranz, darerodz.
See #64898.
Fixes #65740.
git-svn-id: https://develop.svn.wordpress.org/trunk@62944 602fd350-edb4-49c9-b593-d223f7449a82
---
.../html-api/class-wp-html-tag-processor.php | 2 +
.../class-wp-interactivity-api.php | 229 +++++++-
.../wpInteractivityAPI-wp-bind.php | 549 +++++++++++++++++-
.../interactivity-api/wpInteractivityAPI.php | 106 ++++
4 files changed, 857 insertions(+), 29 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php
index ace3e14bea565..48d2de84c86ba 100644
--- a/src/wp-includes/html-api/class-wp-html-tag-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php
@@ -718,6 +718,7 @@ class WP_HTML_Tag_Processor {
*
* @since 6.2.0
* @var WP_HTML_Attribute_Token[]
+ * @phpstan-var array
*/
private $attributes = array();
@@ -2957,6 +2958,7 @@ private function get_decoded_attribute_value( WP_HTML_Attribute_Token $attribute
*
* @param string $prefix Prefix of requested attribute names.
* @return array|null List of attribute names, or `null` when no tag opener is matched.
+ * @phpstan-return list|null
*/
public function get_attribute_names_with_prefix( $prefix ): ?array {
if (
diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
index a04d62e54924c..62bfc29cfc03f 100644
--- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
+++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
@@ -17,9 +17,19 @@ final class WP_Interactivity_API {
* Holds the mapping of directive attribute names to their processor methods.
*
* @since 6.5.0
- * @var array
+ * @var array
+ * @phpstan-var array{
+ * 'data-wp-interactive': 'data_wp_interactive_processor',
+ * 'data-wp-router-region': 'data_wp_router_region_processor',
+ * 'data-wp-context': 'data_wp_context_processor',
+ * 'data-wp-bind': 'data_wp_bind_processor',
+ * 'data-wp-class': 'data_wp_class_processor',
+ * 'data-wp-style': 'data_wp_style_processor',
+ * 'data-wp-text': 'data_wp_text_processor',
+ * 'data-wp-each': 'data_wp_each_processor',
+ * }
*/
- private static $directive_processors = array(
+ private static array $directive_processors = array(
'data-wp-interactive' => 'data_wp_interactive_processor',
'data-wp-router-region' => 'data_wp_router_region_processor',
'data-wp-context' => 'data_wp_context_processor',
@@ -99,8 +109,17 @@ final class WP_Interactivity_API {
*
* This is only available during directive processing, otherwise it is `null`.
*
+ * An entry is the namespace the directive defined. It is `false` instead when
+ * the directive did not define a usable one — the attribute was empty, or its
+ * JSON held no `namespace`, or the namespace did not match the accepted
+ * characters — and no enclosing `data-wp-interactive` was in effect to inherit
+ * from. An entry is pushed either way, because one is popped for every closing
+ * tag regardless of what the directive contained, so `false` is what stands in
+ * for "no namespace here" and keeps the stack balanced.
+ *
* @since 6.6.0
- * @var array|null
+ * @var array|null
+ * @phpstan-var list|null
*/
private $namespace_stack = null;
@@ -752,32 +771,54 @@ private function evaluate( $entry ) {
/**
* Parse the directive name to extract the following parts:
- * - Prefix: The main directive name without "data-wp-".
+ * - Prefix: The main directive name without "data-wp-". It cannot begin with a hyphen.
* - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
* - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
*
* This function has an equivalent version for the client side.
- * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.:
+ * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts:
+ *
+ * An empty suffix or unique ID is normalized to null, but the string "0" is preserved. The
+ * client's `|| null` discards only the empty string, since every non-empty string is truthy in
+ * JavaScript. Do not use empty() for these checks: it would discard "0" and diverge from the
+ * client.
*
- * See examples in the function unit tests `test_parse_directive_name`.
+ * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs.
*
* @since 6.9.0
*
* @param string $directive_name The directive attribute name.
- * @return array An array containing the directive prefix, optional suffix, and optional unique ID.
+ * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed.
+ * @phpstan-return array{
+ * prefix: non-empty-string,
+ * suffix: non-empty-string|null,
+ * unique_id: non-empty-string|null,
+ * }|null
*/
private function parse_directive_name( string $directive_name ): ?array {
// Remove the first 8 characters (assumes "data-wp-" prefix)
- $name = substr( $directive_name, 8 );
+ $name = (string) substr( $directive_name, 8 );
- // Check for invalid characters (anything not a-z, 0-9, -, or _)
- if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) {
+ // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _).
+ if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) {
return null;
}
- // Find the first occurrence of '--' to separate the prefix
+ // Find the first occurrence of '--' to separate the prefix.
$suffix_index = strpos( $name, '--' );
+ /*
+ * A prefix cannot begin with a hyphen, so a name which does is not a directive at all. This
+ * covers both a lone leading hyphen, as in "data-wp--bind", and a leading double hyphen, as
+ * in "data-wp---foo", where treating the hyphens as a suffix separator would instead leave
+ * the prefix empty. It also covers "data-wp----unique-id", where only a unique ID is supplied
+ * without any prefix or suffix.
+ */
+ if ( 0 === $suffix_index || '-' === $name[0] ) {
+ return null;
+ }
+
+ // Without a '--' the whole name is the prefix. (This naturally also means there is no unique ID after '---'.)
if ( false === $suffix_index ) {
return array(
'prefix' => $name,
@@ -790,33 +831,34 @@ private function parse_directive_name( string $directive_name ): ?array {
$remaining = substr( $name, $suffix_index );
// If remaining starts with '---' but not '----', it's a unique_id
- if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) {
+ if ( 3 === strspn( $remaining, '-' ) ) {
+ $unique_id = (string) substr( $remaining, 3 );
return array(
'prefix' => $prefix,
'suffix' => null,
- 'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null,
+ 'unique_id' => '' === $unique_id ? null : $unique_id,
);
}
// Otherwise, remove the first two dashes for a potential suffix
- $suffix = substr( $remaining, 2 );
+ $suffix = (string) substr( $remaining, 2 );
// Look for '---' in the suffix for a unique_id
$unique_id_index = strpos( $suffix, '---' );
if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) {
- $unique_id = substr( $suffix, $unique_id_index + 3 );
- $suffix = substr( $suffix, 0, $unique_id_index );
+ $unique_id = (string) substr( $suffix, $unique_id_index + 3 );
+ $suffix = (string) substr( $suffix, 0, $unique_id_index );
return array(
'prefix' => $prefix,
- 'suffix' => empty( $suffix ) ? null : $suffix,
- 'unique_id' => empty( $unique_id ) ? null : $unique_id,
+ 'suffix' => '' === $suffix ? null : $suffix,
+ 'unique_id' => '' === $unique_id ? null : $unique_id,
);
}
return array(
'prefix' => $prefix,
- 'suffix' => empty( $suffix ) ? null : $suffix,
+ 'suffix' => '' === $suffix ? null : $suffix,
'unique_id' => null,
);
}
@@ -846,6 +888,7 @@ private function parse_directive_name( string $directive_name ): ?array {
* @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
* @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
* second item.
+ * @phpstan-return array{ 0: string|null, 1: mixed }
*/
private function extract_directive_value( $directive_value, $default_namespace = null ): array {
if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
@@ -878,17 +921,46 @@ private function extract_directive_value( $directive_value, $default_namespace =
* @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
* @param string $prefix The directive prefix to filter by.
* @return array An array of entries containing the directive namespace, value, suffix, and unique ID.
+ * @phpstan-return list
*/
- private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) {
+ private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array {
$directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix );
- $entries = array();
+ if ( null === $directive_attributes ) {
+ return array();
+ }
+
+ $entries = array();
foreach ( $directive_attributes as $attribute_name ) {
- [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name );
+ $parsed_directive = $this->parse_directive_name( $attribute_name );
+ if ( null === $parsed_directive ) {
+ continue;
+ }
+
+ [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive;
// Ensure it is the desired directive.
if ( $prefix !== $attr_prefix ) {
continue;
}
- list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) );
+ $attribute_value = $p->get_attribute( $attribute_name );
+ if ( null === $attribute_value ) {
+ continue;
+ }
+ /*
+ * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a
+ * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a
+ * string names a store, so anything else counts as no default namespace at all.
+ */
+ $default_namespace = array_last( $this->namespace_stack ?? array() );
+ if ( ! is_string( $default_namespace ) ) {
+ $default_namespace = null;
+ }
+
+ list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace );
$entries[] = array(
'namespace' => $namespace,
'value' => $value,
@@ -1002,6 +1074,15 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc
continue;
}
+ /*
+ * A context with no namespace has nothing to be stored under, so the inherited context is left as it
+ * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP
+ * 8.5 deprecates, and would store the context where no reference can address it anyway.
+ */
+ if ( null === $entry['namespace'] ) {
+ continue;
+ }
+
$context = array_replace_recursive(
$context,
array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
@@ -1017,16 +1098,19 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc
* associated reference.
*
* @since 6.5.0
+ * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the
+ * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to
+ * WP_HTML_Tag_Processor::set_attribute().
*
- * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
- * @param string $mode Whether the processing is entering or exiting the tag.
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param string $mode Whether the processing is entering or exiting the tag.
*/
- private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
+ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void {
if ( 'enter' === $mode ) {
$entries = $this->get_directive_entries( $p, 'bind' );
foreach ( $entries as $entry ) {
if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
- continue;
+ continue;
}
// Skip if the suffix is an event handler.
@@ -1045,6 +1129,97 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
$result = $this->evaluate( $entry );
+ /*
+ * An object is resolved to whatever it serializes to. When the reference points to a value stored
+ * in state or context, that is the value the client receives for it when the store is hydrated.
+ * A derived state closure is never serialized, so there the client value comes from the derived
+ * state's client-side implementation instead; the resolution is still applied so that both origins
+ * behave the same. Round-tripping through the JSON encoder rather than calling
+ * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's,
+ * including for an object which serializes to another serializable object. When the encoding fails
+ * the object is left in place, to be reported as a usage error below. Note that it rarely does
+ * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from
+ * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form
+ * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the
+ * client is sent for it as well.
+ *
+ * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked
+ * at all: a binding must not be able to abort the render. An exception escaping here would leave
+ * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call
+ * on this instance, so the object is treated as one which failed to encode.
+ */
+ if ( is_object( $result ) ) {
+ try {
+ $encoded = wp_json_encode( $result );
+ } catch ( Throwable $e ) {
+ $encoded = false;
+ }
+ if ( false !== $encoded ) {
+ $result = json_decode( $encoded );
+ }
+ }
+
+ /*
+ * Only a value which can be sent to the client may be stored in an attribute value. Strings and
+ * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage
+ * error.
+ *
+ * An object which does not serialize to a scalar is rejected even when it defines `__toString()`,
+ * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string
+ * representation is not what the client evaluates this reference to, whether that is the form
+ * serialized into the store or the return value of a derived state's client-side implementation,
+ * so the two could disagree once the directive is evaluated during hydration.
+ */
+ if ( null !== $result ) {
+ if ( ! is_scalar( $result ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The attribute name. */
+ __( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean.' ),
+ esc_html( $entry['suffix'] )
+ ),
+ '7.1.0'
+ );
+ $result = null;
+ } elseif ( is_int( $result ) || is_float( $result ) ) {
+ /*
+ * A number is formatted by the JSON encoder rather than cast to string, so that the
+ * attribute value matches the number the client receives for this same reference. Casting
+ * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the
+ * encoder's `serialize_precision`.
+ *
+ * This closes the cases which differ in practice, not every one. A float written in
+ * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript
+ * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript
+ * can represent exactly is rounded once it reaches the client. Casting diverged on all
+ * three as well, so none is a regression.
+ */
+ $encoded = wp_json_encode( $result );
+ if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) {
+ /*
+ * The encoder only rejects INF and NAN, of which JSON can represent neither. When such
+ * a value is stored in state, the store itself also fails to encode in its entirety,
+ * and the client is sent an empty script tag in place of all of its state; only
+ * removing the value from the state resolves that. A derived state closure returning
+ * one never reaches the store, so there only the binding itself is affected.
+ */
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The attribute name. */
+ __( 'Attempted to bind a non-finite number to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string.' ),
+ esc_html( $entry['suffix'] )
+ ),
+ '7.1.0'
+ );
+ $result = null;
+ } else {
+ $result = $encoded;
+ }
+ }
+ }
+
if (
null !== $result &&
(
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
index 02fc0d09293f7..e80930357b6fc 100644
--- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
@@ -45,9 +45,9 @@ public function set_up() {
* Invokes the `process_directives` method of WP_Interactivity_API class.
*
* @param string $html The HTML that needs to be processed.
- * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML.
+ * @return array{ 0: WP_HTML_Tag_Processor, 1: string } An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML.
*/
- private function process_directives( $html ) {
+ private function process_directives( string $html ): array {
$new_html = $this->interactivity->process_directives( $html );
$p = new WP_HTML_Tag_Processor( $new_html );
$p->next_tag();
@@ -93,6 +93,50 @@ public function test_wp_bind_sets_number_value() {
$this->assertSame( '100', $p->get_attribute( 'width' ) );
}
+ /**
+ * Tests that a float value is formatted as a string when set as an attribute
+ * via `data-wp-bind`.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_sets_float_value() {
+ $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) );
+
+ $html = 'Text
';
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) );
+ $this->assertSame( 'Text
', $new_html );
+ }
+
+ /**
+ * Tests that a float value is not formatted with the locale's decimal separator.
+ *
+ * Casting a float to string is locale-dependent before PHP 8.0, whereas the
+ * client receives the number from the JSON-encoded store, which never is.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_sets_float_value_independently_of_the_locale() {
+ $previous_locale = setlocale( LC_NUMERIC, '0' ); // Passing "0" queries the current setting without changing it.
+ if ( false === setlocale( LC_NUMERIC, 'de_DE.UTF-8', 'de_DE', 'de_DE@euro', 'German' ) ) {
+ $this->markTestSkipped( 'No locale with a comma decimal separator is available.' );
+ }
+
+ try {
+ $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) );
+ } finally {
+ setlocale( LC_NUMERIC, false === $previous_locale ? 'C' : $previous_locale );
+ }
+ }
+
/**
* Tests that true strings are set properly as attribute values.
*
@@ -444,4 +488,505 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() {
list($p) = $this->process_directives( $html );
$this->assertSame( 'some-id', $p->get_attribute( 'id' ) );
}
+
+ /**
+ * Data provider for float values which JSON cannot represent.
+ *
+ * @return array Data provider.
+ */
+ public function data_non_finite_values(): array {
+ return array(
+ 'INF' => array( 'value' => INF ),
+ '-INF' => array( 'value' => -INF ),
+ 'NAN' => array( 'value' => NAN ),
+ );
+ }
+
+ /**
+ * Tests that `data-wp-bind` rejects INF and NAN.
+ *
+ * These are scalars, but a store holding one fails to encode in its
+ * entirety, so the client is sent no state at all rather than a value which
+ * merely disagrees with the server.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @dataProvider data_non_finite_values
+ *
+ * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor
+ *
+ * @param float $value Non-finite value to bind.
+ */
+ public function test_wp_bind_rejects_non_finite_value( $value ) {
+ $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $value ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( 'data-ratio' ), 'Expected no attribute to have been set for a value JSON cannot represent.' );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-finite number to the "data-ratio" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string. (This message was added in version 7.1.0.)',
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-finite value message.'
+ );
+ }
+
+ /**
+ * Data provider for values a bound object may serialize to.
+ *
+ * @return array Data provider.
+ */
+ public function data_json_serializable_values(): array {
+ return array(
+ 'string' => array(
+ 'value' => 'serialized-form',
+ 'expected' => 'serialized-form',
+ ),
+ 'integer' => array(
+ 'value' => 42,
+ 'expected' => '42',
+ ),
+ 'float' => array(
+ 'value' => 1.5,
+ 'expected' => '1.5',
+ ),
+ /*
+ * The JSON encoder keeps resolving a serializable object which serializes to another one, so the
+ * value bound on the server has to follow it all the way down to match what the client receives.
+ */
+ 'nested' => array(
+ 'value' => $this->get_json_serializable( 'serialized-form' ),
+ 'expected' => 'serialized-form',
+ ),
+ );
+ }
+
+ /**
+ * Tests that an object is bound as whatever it serializes to for the client.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @dataProvider data_json_serializable_values
+ *
+ * @param mixed $value Value the object serializes to.
+ * @param string $expected Expected attribute value.
+ */
+ public function test_wp_bind_sets_json_serializable_value( $value, string $expected ) {
+ $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( $value ) ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertSame( $expected, $p->get_attribute( 'id' ) );
+ }
+
+ /**
+ * Tests that the bound attribute value matches what the client is sent.
+ *
+ * This is what makes serializable objects safe to bind: the value rendered
+ * into the attribute is the same one the client store is hydrated with, so
+ * evaluating the directive again in the browser is a no-op.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_json_serializable_value_matches_the_client_store() {
+ $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( 'serialized-form' ) ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+
+ $data = $this->interactivity->filter_script_module_interactivity_data( array() );
+ $encoded = wp_json_encode( $data['state'] );
+ $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' );
+
+ $this->assertSame( 'serialized-form', $p->get_attribute( 'id' ) );
+ $this->assertStringContainsString(
+ '"serializable":"serialized-form"',
+ $encoded,
+ 'Expected the rendered attribute value to match the value sent to the client.'
+ );
+ }
+
+ /**
+ * Tests that a bound object which cannot be serialized does not abort the render.
+ *
+ * `JsonSerializable::jsonSerialize()` is arbitrary code, so resolving an object
+ * through the JSON encoder can throw. A binding must not be able to take down the
+ * page, which is the whole point of checking the value at all. An exception
+ * escaping the directive processor would also leave the context and namespace
+ * stacks unrestored, breaking every later `process_directives()` call on the same
+ * instance.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor
+ */
+ public function test_wp_bind_rejects_object_which_fails_to_serialize() {
+ $unserializable = new class() implements JsonSerializable {
+ /**
+ * Fails to produce a value for the client.
+ *
+ * @return mixed Never returns.
+ * @throws RuntimeException Always.
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ throw new RuntimeException( 'This object cannot be serialized.' );
+ }
+ };
+
+ $this->interactivity->state(
+ 'myPlugin',
+ array(
+ 'unserializable' => $unserializable,
+ 'id' => 'some-id',
+ )
+ );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for an object which cannot be serialized.' );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)',
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.'
+ );
+
+ // The stacks are restored for the next render only if no exception escaped.
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertSame( 'some-id', $p->get_attribute( 'id' ), 'Expected a later render on the same instance to be unaffected.' );
+ }
+
+ /**
+ * Tests that an object serializing to a value JSON cannot represent is rejected.
+ *
+ * The encoding does not fail here the way it does for a bare INF. When
+ * `json_encode()` rejects the value, `wp_json_encode()` retries with a plain
+ * object rebuilt from the public properties, which discards `jsonSerialize()`
+ * entirely and encodes to `{}`. The object therefore resolves to something
+ * non-scalar and is reported as such, rather than with the non-finite message.
+ *
+ * The store is rebuilt the same way, so the client is sent `{}` for this
+ * reference. The two still agree that there is no usable value here.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor
+ */
+ public function test_wp_bind_rejects_object_serializing_to_a_non_finite_value() {
+ $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $this->get_json_serializable( INF ) ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set.' );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)',
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected the non-scalar message, since the object resolves to an empty object rather than failing to encode.'
+ );
+
+ $data = $this->interactivity->filter_script_module_interactivity_data( array() );
+ $encoded = wp_json_encode( $data['state'] );
+ $this->assertIsString( $encoded, 'Expected the client state to still be encodable as JSON.' );
+ $this->assertStringContainsString(
+ '"nonFinite":{}',
+ $encoded,
+ 'Expected the client to be sent the same empty object the server resolved.'
+ );
+ }
+
+ /**
+ * Tests that an object serializing to null removes the attribute quietly.
+ *
+ * The object is resolved before the null check, so it reaches it as the null
+ * the client will receive, and is treated the same as a null value would be.
+ * There is nothing to report: null is a value the client can be sent.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_removes_attribute_for_object_serializing_to_null() {
+ $this->interactivity->state( 'myPlugin', array( 'nothing' => $this->get_json_serializable( null ) ) );
+
+ $html = 'Text
';
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' );
+ $this->assertEqualHTML( 'Text
', $new_html );
+ $this->assertSame(
+ array(),
+ $this->caught_doing_it_wrong,
+ 'Expected an object serializing to null to be treated as a null value, without reporting a usage error.'
+ );
+ }
+
+ /**
+ * Tests that an object serializing to a boolean keeps the boolean attribute
+ * semantics of the value it serializes to.
+ *
+ * Resolving the object first means the checks below it do not have to know an
+ * object was ever involved, so the existing handling composes. This asserts
+ * that it does.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_applies_boolean_semantics_to_object_serializing_to_a_boolean() {
+ $this->interactivity->state(
+ 'myPlugin',
+ array(
+ 'yes' => $this->get_json_serializable( true ),
+ 'no' => $this->get_json_serializable( false ),
+ )
+ );
+
+ // True sets a bare boolean attribute.
+ $html = 'Text
';
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertTrue( $p->get_attribute( 'hidden' ) );
+ $this->assertSame( 'Text
', $new_html );
+
+ // False removes it.
+ $html = 'Text
';
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( 'hidden' ) );
+ $this->assertEqualHTML( 'Text
', $new_html );
+
+ // On a `data-` or `aria-` attribute it becomes the string Preact would write.
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertSame( 'true', $p->get_attribute( 'data-open' ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+ $this->assertSame( 'false', $p->get_attribute( 'aria-hidden' ) );
+ }
+
+ /**
+ * Tests that a bound number is written the same way the client store writes it.
+ *
+ * This is the invariant the number formatting exists for. A cast would round to
+ * `precision` where the store uses `serialize_precision`, so both are rendered
+ * by the same encoder instead of being compared after the fact.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_number_value_matches_the_client_store() {
+ $this->interactivity->state( 'myPlugin', array( 'ratio' => 1 / 3 ) );
+
+ $html = 'Text
';
+ list($p) = $this->process_directives( $html );
+
+ $data = $this->interactivity->filter_script_module_interactivity_data( array() );
+ $encoded = wp_json_encode( $data['state'] );
+ $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' );
+
+ $expected = wp_json_encode( 1 / 3 );
+ $this->assertSame( $expected, $p->get_attribute( 'data-ratio' ) );
+ $this->assertStringContainsString(
+ '"ratio":' . $expected,
+ $encoded,
+ 'Expected the rendered attribute value to match the number sent to the client.'
+ );
+ }
+
+ /**
+ * Creates an object which serializes to the given value for the client.
+ *
+ * @param mixed $value Value the object serializes to.
+ * @return JsonSerializable Object serializing to `$value`.
+ */
+ private function get_json_serializable( $value ): JsonSerializable {
+ return new class( $value ) implements JsonSerializable {
+ /**
+ * Value the object serializes to.
+ *
+ * @var mixed
+ */
+ private $value;
+
+ /**
+ * Constructor.
+ *
+ * @param mixed $value Value the object serializes to.
+ */
+ public function __construct( $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Returns the value for JSON serialization.
+ *
+ * @return mixed Value the client receives.
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->value;
+ }
+ };
+ }
+
+ /**
+ * Data provider for values which cannot be stored in an attribute value.
+ *
+ * WP_HTML_Tag_Processor::set_attribute() escapes an ordinary attribute with
+ * strtr() and one of the URI attributes listed by wp_kses_uri_attributes()
+ * with esc_url(). Neither should be reached with a non-scalar value, so each
+ * value is paired with one attribute at a time: a regression in one of those
+ * paths then cannot be masked by the other failing first.
+ *
+ * @return array Data provider.
+ */
+ public function data_non_scalar_values(): array {
+ $values = array(
+ 'list' => array( 'a', 'b' ),
+ 'associative array' => array( 'a' => 'b' ),
+ 'empty array' => array(),
+ 'object' => new stdClass(),
+ 'stringable object' => new class() {
+ /**
+ * Returns the string representation.
+ *
+ * @return string String representation.
+ */
+ public function __toString() {
+ return 'stringified';
+ }
+ },
+ 'stringable object serializing to an array' => new class() implements JsonSerializable {
+ /**
+ * Returns the string representation.
+ *
+ * @return string String representation.
+ */
+ public function __toString() {
+ return 'stringified';
+ }
+
+ /**
+ * Returns the value for JSON serialization.
+ *
+ * @return array Value the client receives.
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return array( 'not' => 'the string representation' );
+ }
+ },
+ 'object serializing to an array' => $this->get_json_serializable( array( 'a', 'b' ) ),
+ );
+
+ $attributes = array(
+ 'ordinary attribute' => array(
+ 'tag_name' => 'div',
+ 'attribute' => 'id',
+ 'existing_value' => 'other-id',
+ ),
+ 'URI attribute' => array(
+ 'tag_name' => 'a',
+ 'attribute' => 'href',
+ 'existing_value' => 'https://example.com/',
+ ),
+ );
+
+ $data = array();
+ foreach ( $values as $value_label => $value ) {
+ foreach ( $attributes as $attribute_label => $attribute ) {
+ $data[ "$value_label in $attribute_label" ] = array( 'value' => $value ) + $attribute;
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Tests that `data-wp-bind` rejects non-scalar values instead of passing
+ * them along to WP_HTML_Tag_Processor::set_attribute().
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @dataProvider data_non_scalar_values
+ *
+ * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor
+ *
+ * @param mixed $value Non-scalar value to bind.
+ * @param string $tag_name Tag name to bind the value on.
+ * @param string $attribute Attribute name to bind the value to.
+ * @param string $existing_value Pre-existing value for the bound attribute. Unused, as the attribute is absent here.
+ */
+ public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) {
+ unset( $existing_value ); // The bound attribute is absent here, so there is no pre-existing value to remove.
+
+ $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) );
+
+ $html = sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text%1$s>', $tag_name, $attribute );
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( $attribute ), "Expected no $attribute attribute to have been set for a non-scalar value." );
+ $this->assertSame( $html, $new_html, 'Expected the markup to be left unchanged.' );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => sprintf(
+ 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)',
+ $attribute
+ ),
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.'
+ );
+ }
+
+ /**
+ * Tests that `data-wp-bind` removes a pre-existing attribute when the
+ * evaluated value is non-scalar.
+ *
+ * @ticket 65740
+ *
+ * @covers ::process_directives
+ *
+ * @dataProvider data_non_scalar_values
+ *
+ * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor
+ *
+ * @param mixed $value Non-scalar value to bind.
+ * @param string $tag_name Tag name to bind the value on.
+ * @param string $attribute Attribute name to bind the value to.
+ * @param string $existing_value Pre-existing value for the bound attribute.
+ */
+ public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) {
+ $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) );
+
+ $html = sprintf( '<%1$s %2$s="%3$s" data-wp-bind--%2$s="myPlugin::state.nonScalar">Text%1$s>', $tag_name, $attribute, $existing_value );
+ list($p, $new_html) = $this->process_directives( $html );
+ $this->assertNull( $p->get_attribute( $attribute ), "Expected the pre-existing $attribute attribute to have been removed." );
+ $this->assertEqualHTML( sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text%1$s>', $tag_name, $attribute ), $new_html );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => sprintf(
+ 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean. (This message was added in version 7.1.0.)',
+ $attribute
+ ),
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.'
+ );
+ }
}
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
index 20b249bd8c44e..430fe606f9277 100644
--- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
@@ -845,6 +845,7 @@ public function test_extract_directive_value_invalid_json() {
* name.
*
* @ticket 64106
+ * @ticket 64898
*
* @covers ::parse_directive_name
*/
@@ -890,6 +891,53 @@ public function test_parse_directive_name() {
$this->assertNull( $result['suffix'] );
$this->assertNull( $result['unique_id'] );
+ /*
+ * Should keep a "0" prefix, suffix, and unique ID rather than discarding it as empty. The
+ * client's `parseDirectiveName` normalizes with `|| null`, which discards only the empty
+ * string, because a non-empty string such as "0" is truthy in JavaScript. Using empty()
+ * here would discard "0" and diverge from the client.
+ */
+ $this->assertSame(
+ array(
+ 'prefix' => 'test',
+ 'suffix' => null,
+ 'unique_id' => '0',
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---0' )
+ );
+ $this->assertSame(
+ array(
+ 'prefix' => 'test',
+ 'suffix' => '0',
+ 'unique_id' => null,
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0' )
+ );
+ $this->assertSame(
+ array(
+ 'prefix' => 'test',
+ 'suffix' => '0',
+ 'unique_id' => 'unique-id',
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0---unique-id' )
+ );
+ $this->assertSame(
+ array(
+ 'prefix' => 'test',
+ 'suffix' => 'suffix',
+ 'unique_id' => '0',
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--suffix---0' )
+ );
+ $this->assertSame(
+ array(
+ 'prefix' => '0',
+ 'suffix' => 'suffix',
+ 'unique_id' => null,
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-0--suffix' )
+ );
+
// Should handle only dashes (4 or more dashes).
$result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----' );
$this->assertSame( 'test', $result['prefix'] );
@@ -919,12 +967,50 @@ public function test_parse_directive_name() {
$this->assertSame( 'test', $result['prefix'] );
$this->assertNull( $result['suffix'] );
$this->assertSame( 'unique-id--wrong-suffix', $result['unique_id'] );
+
+ // Should reject a name containing characters a directive name cannot contain.
+ $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-test.suffix' ) );
+
+ /*
+ * Should reject a name which is nothing but the prefix, rather than returning an empty
+ * prefix. The client's `parseDirectiveName` returns `{ prefix: '' }` here instead, but
+ * neither an empty prefix nor null matches a registered directive, so the outcome is the
+ * same on both sides: the attribute is ignored.
+ */
+ $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-' ) );
+
+ /*
+ * Should reject a name whose prefix would begin with a hyphen, which the directive syntax
+ * does not allow. Neither reading of such a name is meaningful: treating the hyphens as a
+ * suffix separator leaves the prefix empty, and treating them as part of the prefix names
+ * a directive which cannot be registered. The client's `parseDirectiveName` still splits
+ * `data-wp---foo` into `{ prefix: '', suffix: 'foo' }`, but as with an empty name, no
+ * result here matches a registered directive, so the attribute is ignored on both sides
+ * either way.
+ */
+ $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp--bind' ) );
+ $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp---foo' ) );
+ $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp----foo' ) );
+
+ /*
+ * Should still accept a suffix which begins with hyphens, since only the prefix is
+ * constrained. Here the prefix is "style" and the suffix is "--var".
+ */
+ $this->assertSame(
+ array(
+ 'prefix' => 'style',
+ 'suffix' => '--var',
+ 'unique_id' => null,
+ ),
+ $parse_directive_name->invoke( $this->interactivity, 'data-wp-style----var' )
+ );
}
/**
* Tests the ability to get the valid entries of a specific directive in an HTML element.
*
* @ticket 64106
+ * @ticket 64898
*
* @covers ::get_directive_entries
*/
@@ -1139,6 +1225,26 @@ function ( $d ) {
$results
)
);
+
+ /*
+ * Should skip an attribute whose directive name cannot be parsed. Such a name is still
+ * matched by the prefix search, so it reaches here and has to be filtered out rather than
+ * destructured.
+ */
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $this->assertSame(
+ array(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'kept',
+ 'suffix' => 'valid',
+ 'unique_id' => null,
+ ),
+ ),
+ $get_directive_entries->invoke( $this->interactivity, $p, 'test' )
+ );
}
/**
From 4eaba0e05450ebc41b36b45b2dc32ffbc868b5ca Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Thu, 30 Jul 2026 19:18:52 +0000
Subject: [PATCH 086/149] Code Modernization: Use null coalescing operator
instead of ternaries.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This change modernizes `WP_REST_View_Config_Controller` by replacing several `isset( $x ) ? $x : $default` ternary expressions with the equivalent null coalescing operator (`??`). The null coalescing operator uses the same `isset()` semantics — it returns the fallback when the key is either missing or `null`, so the behavior is identical to the previous code.
Developed in https://github.com/WordPress/wordpress-develop/pull/12296.
Follow-up to [61403], [62547].
Props Soean.
See #64897.
git-svn-id: https://develop.svn.wordpress.org/trunk@62949 602fd350-edb4-49c9-b593-d223f7449a82
---
.../endpoints/class-wp-rest-view-config-controller.php | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
index 34cd1572526c6..6b23f35cbaf47 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
@@ -193,10 +193,10 @@ protected function cast_empty_objects( $value, $schema ) {
}
if ( isset( $schema['oneOf'] ) || isset( $schema['anyOf'] ) ) {
- $branches = isset( $schema['oneOf'] ) ? $schema['oneOf'] : $schema['anyOf'];
+ $branches = $schema['oneOf'] ?? $schema['anyOf'];
if ( array() === $value ) {
foreach ( $branches as $branch ) {
- if ( is_array( $branch ) && in_array( 'object', (array) ( isset( $branch['type'] ) ? $branch['type'] : array() ), true ) ) {
+ if ( is_array( $branch ) && in_array( 'object', (array) ( $branch['type'] ?? array() ), true ) ) {
return (object) array();
}
}
@@ -204,7 +204,7 @@ protected function cast_empty_objects( $value, $schema ) {
return $value;
}
- $types = (array) ( isset( $schema['type'] ) ? $schema['type'] : array() );
+ $types = (array) ( $schema['type'] ?? array() );
if ( in_array( 'array', $types, true ) && isset( $schema['items'] ) ) {
foreach ( $value as $index => $item ) {
From 9f69d0e91e4b2c4c1c942f1f90c0de1e748f6a44 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Thu, 30 Jul 2026 20:27:52 +0000
Subject: [PATCH 087/149] Media: Align date filters to left when type filters
not present.
The grid layout added in [62326] didn't take into consideration the possibility that the type filter may not be present, leaving the date filter oddly positioned if it was.
Adjust the grid so that the date filters are conditionally switched to a different part of the grid if previous filters are not present.
Developed in https://github.com/WordPress/wordpress-develop/pull/11885
Props katag9k, yogeshbhutkar, wildworks, mukesh27, masteradhoc, r1k0, mohamedahamed, khushalsains, joedolson.
Fixes #65276.
git-svn-id: https://develop.svn.wordpress.org/trunk@62950 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/css/media-views.css | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css
index 228ca0ee40160..d89261a33fd0d 100644
--- a/src/wp-includes/css/media-views.css
+++ b/src/wp-includes/css/media-views.css
@@ -350,10 +350,18 @@ select#media-attachment-filters {
}
label[for="media-attachment-date-filters"] {
- grid-area: 1 / 2 / 2 / 3;
+ grid-area: 1 / 1 / 2 / 2;
}
select#media-attachment-date-filters {
+ grid-area: 2 / 1 / 3 / 2;
+}
+
+select#media-attachment-filters ~ label[for="media-attachment-date-filters"] {
+ grid-area: 1 / 2 / 2 / 3;
+}
+
+select#media-attachment-filters ~ select#media-attachment-date-filters {
grid-area: 2 / 2 / 3 / 3;
}
From ce2eb54348a4f15578e303bee490e71dbd06903c Mon Sep 17 00:00:00 2001
From: Lance Willett
Date: Thu, 30 Jul 2026 20:48:48 +0000
Subject: [PATCH 088/149] Build/Test Tools: Forward multisite settings in the
fork test matrix.
The limited matrix job for forks declared multisite include rows but never forwarded multisite or phpunit-config parameters to the reusable workflow, so those rows fell back to single-site defaults and duplicated existing single-site jobs.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12693
Props adrianmoldovanwp.
Fixes #65712.
git-svn-id: https://develop.svn.wordpress.org/trunk@62951 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/phpunit-tests.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml
index a9be5d8565fe3..2b9bbfe891640 100644
--- a/.github/workflows/phpunit-tests.yml
+++ b/.github/workflows/phpunit-tests.yml
@@ -346,7 +346,9 @@ jobs:
php: ${{ matrix.php }}
db-version: ${{ matrix.db-version }}
db-type: ${{ matrix.db-type }}
+ multisite: ${{ matrix.multisite }}
memcached: ${{ matrix.memcached || false }}
+ phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }}
phpunit-test-groups: ${{ matrix.phpunit-test-groups || '' }}
gutenberg-artifact: gutenberg-build
gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }}
From 593166d816dbec24a7a0780fa7b675e32e504b7b Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Fri, 31 Jul 2026 06:08:13 +0000
Subject: [PATCH 089/149] REST API: Restore the global post after preparing a
revision.
`WP_REST_Revisions_Controller::prepare_item_for_response()` set the global `$post` to the revision being prepared and called `setup_postdata()`, but never restored the previous value, so the change persisted for the remainder of the request. `WP_REST_Autosaves_Controller` delegates to this method and the block editor preloads the autosaves endpoint on every load, so a post with a pending autosave could leave the global `$post` pointing at the autosave revision while `edit-form-blocks.php` built the editor bootstrap, initializing the editor with the wrong post and rewriting the URL to it.
The previous global `$post` is now captured before `setup_postdata()` and restored on every return path, including the early return for HEAD requests.
Developed in https://github.com/WordPress/wordpress-develop/pull/12248.
Follow-up to r40601, r59899.
Props micahele, gusgomezpg, westonruter, wildworks, dhrupo.
See #40626, #43502.
Fixes #65495.
git-svn-id: https://develop.svn.wordpress.org/trunk@62952 602fd350-edb4-49c9-b593-d223f7449a82
---
.../class-wp-rest-revisions-controller.php | 73 +++-
.../rest-api/rest-autosaves-controller.php | 66 +++-
.../rest-api/rest-revisions-controller.php | 367 +++++++++++++++++-
3 files changed, 465 insertions(+), 41 deletions(-)
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
index f4c5cb483d105..0121298105ebf 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
@@ -595,14 +595,29 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul
*
* @since 4.7.0
* @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
+ * @since 7.1.0 The global post is now restored to its previous value before returning.
*
- * @global WP_Post $post Global post object.
+ * @global WP_Post|null $post Global post object.
*
* @param WP_Post $item Post revision object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $item, $request ) {
+ /*
+ * Save the previous global post so it can be restored before returning.
+ * Preparing the revision sets up the global post and post data, which
+ * must not leak into the rest of the request (e.g. the autosaves endpoint
+ * is preloaded in the block editor, where a leaked global post can cause
+ * the editor to be initialized with the wrong post).
+ *
+ * Note that $post is intentionally not declared as a global here. It must
+ * remain local to this method so that a filter which reassigns the global
+ * post while the response is being prepared (for example on 'the_content')
+ * cannot change which post the remaining fields are read from.
+ */
+ $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null;
+
// Restores the more descriptive, specific name for use within this method.
$post = $item;
@@ -613,7 +628,11 @@ public function prepare_item_for_response( $item, $request ) {
// Don't prepare the response body for HEAD requests.
if ( $request->is_method( 'HEAD' ) ) {
/** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php */
- return apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );
+ $response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );
+
+ $this->restore_post_data( $previous_post );
+
+ return $response;
}
$fields = $this->get_fields_for_response( $request );
@@ -717,7 +736,55 @@ public function prepare_item_for_response( $item, $request ) {
* @param WP_Post $post The original revision object.
* @param WP_REST_Request $request Request used to generate the response.
*/
- return apply_filters( 'rest_prepare_revision', $response, $post, $request );
+ $response = apply_filters( 'rest_prepare_revision', $response, $post, $request );
+
+ $this->restore_post_data( $previous_post );
+
+ return $response;
+ }
+
+ /**
+ * Restores the global post to its previous value after preparing a revision.
+ *
+ * Preparing a revision overwrites the global post and post data via
+ * setup_postdata(). This restores the global post that was in place
+ * beforehand so the change does not leak into the rest of the request.
+ *
+ * Only the global post is guaranteed to be restored. When there was no
+ * previous global post and the main query has no post either, which is the
+ * usual state during a REST request, wp_reset_postdata() has nothing to
+ * restore from, so the remaining globals set by setup_postdata() (such as
+ * $id, $authordata and $pages) are left describing the revision. Clearing
+ * those would mean unsetting each one by hand, which is beyond what is
+ * needed to keep the global post from leaking.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_Post|null $previous_post The global post to restore, or null if there was none.
+ */
+ private function restore_post_data( ?WP_Post $previous_post ): void {
+ if ( $previous_post ) {
+ $GLOBALS['post'] = $previous_post;
+ setup_postdata( $previous_post );
+ return;
+ }
+
+ /*
+ * There was no global post to restore, so clear the revision's post data.
+ * This runs before clearing the global post because wp_reset_postdata()
+ * repopulates it from the main query whenever that query has a post. Note
+ * that it is a no-op when the main query has no post, in which case only
+ * the global post below is cleared.
+ */
+ wp_reset_postdata();
+
+ /*
+ * Assigned rather than unset so that any `global $post` binding made before
+ * this request keeps pointing at the global. Unsetting removes the entry from
+ * the symbol table, which detaches those bindings, and a later write through
+ * one of them would no longer be visible to get_post().
+ */
+ $GLOBALS['post'] = null;
}
/**
diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php
index 7815f8ced23c9..179ce60e047df 100644
--- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php
@@ -9,21 +9,21 @@
* @group restapi
*/
class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controller_Testcase {
- protected static $post_id;
- protected static $page_id;
- protected static $draft_page_id;
+ protected static int $post_id;
+ protected static int $page_id;
+ protected static int $draft_page_id;
- protected static $autosave_post_id;
- protected static $autosave_page_id;
+ protected static int $autosave_post_id;
+ protected static int $autosave_page_id;
- protected static $editor_id;
- protected static $contributor_id;
+ protected static int $editor_id;
+ protected static int $contributor_id;
- protected static $parent_page_id;
- protected static $child_page_id;
- protected static $child_draft_page_id;
+ protected static int $parent_page_id;
+ protected static int $child_page_id;
+ protected static int $child_draft_page_id;
- private $post_autosave;
+ private WP_Post $post_autosave;
protected function set_post_data( $args = array() ) {
$defaults = array(
@@ -731,16 +731,46 @@ protected function check_get_autosave_response( $response, $autosave ) {
$this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $autosave->post_parent ), $links['parent'][0]['href'] );
}
- public function test_get_item_sets_up_postdata() {
+ /**
+ * The autosave's postdata should be set up while preparing the response,
+ * so rendered fields reflect the autosave, without leaking into the global
+ * post after the request completes.
+ *
+ * @ticket 65495
+ *
+ * @global int|null $id ID from the set up global post data.
+ *
+ * @covers WP_REST_Autosaves_Controller::prepare_item_for_response
+ */
+ public function test_get_item_sets_up_postdata_without_leaking_global_post() {
+ global $id;
+
+ // Populate the global $wp_query with the post and set it up.
wp_set_current_user( self::$editor_id );
- $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
- rest_get_server()->dispatch( $request );
+ query_posts( array( 'p' => self::$post_id ) );
+ the_post();
- $post = get_post();
- $parent_post_id = wp_is_post_revision( $post->ID );
+ // Assert initial state.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
- $this->assertSame( $post->ID, self::$autosave_post_id );
- $this->assertSame( $parent_post_id, self::$post_id );
+ // Make the request to get the autosave.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'title', $data );
+ $this->assertIsArray( $data['title'] );
+ $this->assertSame( get_the_title( self::$autosave_post_id ), $data['title']['rendered'], 'Expected the rendered title to reflect the autosave, proving postdata was set up during preparation.' );
+
+ // The global post is restored to the post that was set before the request.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
}
public function test_update_item_draft_page_with_parent() {
diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php
index 52011afcb9318..71b333fa39fd4 100644
--- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php
@@ -8,22 +8,24 @@
* @group restapi
*/
class WP_Test_REST_Revisions_Controller extends WP_Test_REST_Controller_Testcase {
- protected static $post_id;
- protected static $post_id_2;
- protected static $page_id;
+ protected static int $post_id;
+ protected static int $post_id_2;
+ protected static int $page_id;
- protected static $editor_id;
- protected static $contributor_id;
+ protected static int $editor_id;
+ protected static int $contributor_id;
+
+ private int $total_revisions;
- private $total_revisions;
private $revisions;
- private $revision_1;
- private $revision_id1;
- private $revision_2;
- private $revision_id2;
- private $revision_3;
- private $revision_id3;
- private $revision_2_1_id;
+
+ private WP_Post $revision_1;
+ private int $revision_id1;
+ private WP_Post $revision_2;
+ private int $revision_id2;
+ private WP_Post $revision_3;
+ private int $revision_id3;
+ private int $revision_2_1_id;
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$post_id = $factory->post->create();
@@ -266,6 +268,266 @@ public function test_get_item() {
$this->assertSame( self::$editor_id, $data['author'] );
}
+ /**
+ * Preparing a revision must not leak the revision into the global post.
+ *
+ * @ticket 65495
+ *
+ * @global int|null $id ID from the set up global post data.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_get_items_restores_global_post() {
+ global $id;
+
+ // Populate the global $wp_query with the post and set it up.
+ wp_set_current_user( self::$editor_id );
+ query_posts( array( 'p' => self::$post_id ) );
+ the_post();
+
+ // Assert initial state.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
+
+ // Capture the arguments the rest_prepare_revision filter receives.
+ $mock = new MockAction();
+ add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
+
+ // Make the request to get revisions.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ // The filter is passed each revision, not the global post restored afterwards.
+ $this->check_rest_prepare_revision_filter_args(
+ $mock,
+ array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ),
+ $request
+ );
+
+ // The global post is restored to the post that was set before the request.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
+ }
+
+ /**
+ * Preparing a revision for a HEAD request must also restore the global post.
+ *
+ * The collection endpoint short-circuits before preparing items for HEAD
+ * requests, so the single revision endpoint is used to reach the HEAD
+ * branch of prepare_item_for_response().
+ *
+ * @ticket 65495
+ *
+ * @global int|null $id ID from the set up global post data.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_get_item_head_request_restores_global_post() {
+ global $id;
+
+ // Populate the global $wp_query with the post and set it up.
+ wp_set_current_user( self::$editor_id );
+ query_posts( array( 'p' => self::$post_id ) );
+ the_post();
+
+ // Assert initial state.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
+
+ // Capture the arguments the rest_prepare_revision filter receives.
+ $mock = new MockAction();
+ add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
+
+ // Make the HEAD request to get a revision.
+ $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+
+ // The filter is passed the revision, not the global post restored afterwards.
+ $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
+
+ // The global post is restored to the post that was set before the request.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
+ }
+
+ /**
+ * When there is no global post before the request, none should be set afterwards.
+ *
+ * @ticket 65495
+ *
+ * @global WP_Query|null $wp_query The global WP_Query.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_get_items_without_global_post_leaves_it_unset() {
+ global $wp_query;
+
+ // Leave the global $wp_query without a post, so there is no post data to restore.
+ wp_set_current_user( self::$editor_id );
+ $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' );
+ $this->assertNull( get_post(), 'The global post should not have been initially set.' );
+
+ // Capture the arguments the rest_prepare_revision filter receives.
+ $mock = new MockAction();
+ add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
+
+ // Make the request to get a revision.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+
+ // The filter is passed each revision, even though there is no global post to restore.
+ $this->check_rest_prepare_revision_filter_args(
+ $mock,
+ array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ),
+ $request
+ );
+
+ /*
+ * Note: At this point, the global $id is still populated because there was no $wp_query->post to begin with,
+ * so WP_Query::reset_postdata() has nothing to set it to. It does not null out any globals when there is no post.
+ */
+ $this->assertNull( get_post(), 'The global post should not be set when there was none before the request.' );
+ }
+
+ /**
+ * A main query with a post must not cause a global post to be set where there was none.
+ *
+ * The restore calls wp_reset_postdata() to clear the revision's post data, and that
+ * repopulates the global post from the main query. The global post must be unset
+ * afterwards so the request does not introduce one that was not there before.
+ *
+ * @ticket 65495
+ *
+ * @global WP_Query|null $wp_query The global WP_Query.
+ * @global int|null $id ID from the set up global post data.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_get_items_without_global_post_leaves_it_unset_when_main_query_has_post() {
+ global $wp_query, $id;
+
+ /*
+ * Populate the main query with the post, but do not run the loop, so the main
+ * query has a post to restore from while the global post remains unset.
+ */
+ wp_set_current_user( self::$editor_id );
+ query_posts( array( 'p' => self::$post_id ) );
+
+ // Assert initial state.
+ $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' );
+ $this->assertInstanceOf( WP_Post::class, $wp_query->post, 'The main query should have a post for wp_reset_postdata() to restore from.' );
+ $this->assertSame( self::$post_id, $wp_query->post->ID, 'The main query should have the parent post before the request.' );
+ $this->assertNull( get_post(), 'The global post should not have been initially set.' );
+
+ // Make the request to get revisions.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+
+ $this->assertNull( get_post(), 'The global post should not be set when there was none before the request, even though the main query has a post.' );
+ $this->assertSame( self::$post_id, $id, 'The remaining post data should be reset to the main query post rather than left on the revision.' );
+ }
+
+ /**
+ * A filter that reassigns the global post must not change the revision being prepared.
+ *
+ * The revision is held in a method-local variable rather than the global post, so a
+ * filter which swaps the global out mid-preparation (as a plugin running a secondary
+ * loop on 'the_content' may do) cannot retarget the fields prepared after it.
+ *
+ * @ticket 65495
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_prepare_item_for_response_is_unaffected_by_a_filter_reassigning_the_global_post() {
+ wp_set_current_user( self::$editor_id );
+
+ $decoy_post = get_post( self::$post_id_2 );
+ $this->assertInstanceOf( WP_Post::class, $decoy_post );
+ $this->assertNotSame( $this->revision_1->post_excerpt, $decoy_post->post_excerpt, 'The decoy post must have a different excerpt for this test to be meaningful.' );
+
+ // Simulate a plugin that leaves a different post in the global while filtering the content.
+ add_filter(
+ 'the_content',
+ static function ( $content ) use ( $decoy_post ) {
+ $GLOBALS['post'] = $decoy_post;
+ return $content;
+ }
+ );
+
+ // Capture the post the rest_prepare_revision filter receives.
+ $mock = new MockAction();
+ add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
+
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ // Fields prepared after 'the_content' still come from the revision.
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'excerpt', $data );
+ $this->assertIsArray( $data['excerpt'] );
+ $this->assertSame( $this->revision_id1, $data['id'], 'The prepared id should be the revision, not the post left in the global by the filter.' );
+ $this->assertSame( $this->revision_1->post_excerpt, $data['excerpt']['raw'], 'The prepared excerpt should come from the revision, not the post left in the global by the filter.' );
+
+ // The filter is still passed the revision.
+ $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
+ }
+
+ /**
+ * Clearing the global post must not detach an existing `global $post` binding.
+ *
+ * `global $post` binds a caller to the global symbol table entry. Unsetting that
+ * entry detaches the binding, so a caller which sets the post after the request
+ * would be writing somewhere get_post() can no longer see. Note that the caller's
+ * own `global $post` is what creates the entry as null, which is why there is no
+ * previous global post to restore here.
+ *
+ * @ticket 65495
+ *
+ * @global WP_Post|null $post Global post object.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_prepare_item_for_response_does_not_detach_an_existing_global_post_binding() {
+ // Bind to the global post the way a caller does before dispatching a request.
+ global $post;
+
+ wp_set_current_user( self::$editor_id );
+ $this->assertNull( $post, 'The global post should not be set before the request.' );
+
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
+ $request->set_param( 'context', 'edit' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+
+ // The caller sets the global post, expecting template tags to pick it up.
+ $post = get_post( self::$post_id );
+
+ $global_post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $global_post, 'get_post() should see the post set through the binding after the request.' );
+ $this->assertSame( self::$post_id, $global_post->ID, 'get_post() should return the post the caller set, not a stale or detached value.' );
+ }
+
/**
* @dataProvider data_readable_http_methods
* @ticket 56481
@@ -601,6 +863,33 @@ public function additional_field_update_callback( $value, $post, $field_name ) {
update_post_meta( $post->ID, $field_name, $value );
}
+ /**
+ * Checks the arguments the rest_prepare_revision filter received.
+ *
+ * The filter must be passed the revision that was prepared. Restoring the
+ * global post afterwards must not replace it with the previous global post.
+ *
+ * @param MockAction $mock Mock registered on the rest_prepare_revision filter.
+ * @param int[] $revision_ids Expected revision IDs, in the order the filter is expected to fire.
+ * @param WP_REST_Request $request Request the revisions were prepared for.
+ */
+ private function check_rest_prepare_revision_filter_args( MockAction $mock, array $revision_ids, WP_REST_Request $request ): void {
+ $filter_args = $mock->get_args();
+
+ $this->assertCount( count( $revision_ids ), $filter_args, 'The rest_prepare_revision filter should fire once per prepared revision.' );
+
+ foreach ( $filter_args as $index => $args ) {
+ $call = 'Filter call ' . $index . ': ';
+
+ $this->assertCount( 3, $args, $call . 'the filter should receive three arguments.' );
+ $this->assertInstanceOf( WP_REST_Response::class, $args[0], $call . 'the first argument should be the response.' );
+ $this->assertInstanceOf( WP_Post::class, $args[1], $call . 'the second argument should be a post object.' );
+ $this->assertSame( 'revision', $args[1]->post_type, $call . 'the second argument should be a revision, not the restored global post.' );
+ $this->assertSame( $revision_ids[ $index ], $args[1]->ID, $call . 'the second argument should be the revision that was prepared.' );
+ $this->assertSame( $request, $args[2], $call . 'the third argument should be the request.' );
+ }
+ }
+
protected function check_get_revision_response( $response, $revision ) {
if ( $response instanceof WP_REST_Response ) {
$links = $response->get_links();
@@ -639,16 +928,54 @@ protected function check_get_revision_response( $response, $revision ) {
$this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $revision->post_parent ), $links['parent'][0]['href'] );
}
- public function test_get_item_sets_up_postdata() {
+ /**
+ * The revision's postdata should be set up while preparing the response,
+ * so rendered fields reflect the revision, without leaking into the global
+ * post after the request completes.
+ *
+ * @ticket 65495
+ *
+ * @global int|null $id ID from the set up global post data.
+ *
+ * @covers WP_REST_Revisions_Controller::prepare_item_for_response
+ */
+ public function test_get_item_sets_up_postdata_without_leaking_global_post() {
+ global $id;
+
+ // Populate the global $wp_query with the post and set it up.
wp_set_current_user( self::$editor_id );
- $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
- rest_get_server()->dispatch( $request );
+ query_posts( array( 'p' => self::$post_id ) );
+ the_post();
+
+ // Assert initial state.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
+
+ // Capture the arguments the rest_prepare_revision filter receives.
+ $mock = new MockAction();
+ add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
+
+ // Make the request to get a revision.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
- $post = get_post();
- $parent_post_id = wp_is_post_revision( $post->ID );
+ // The filter is passed the revision, not the global post restored afterwards.
+ $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
- $this->assertSame( $post->ID, $this->revision_id1 );
- $this->assertSame( $parent_post_id, self::$post_id );
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'title', $data );
+ $this->assertIsArray( $data['title'] );
+ $this->assertSame( get_the_title( $this->revision_id1 ), $data['title']['rendered'], 'Expected the rendered title to reflect the revision, proving postdata was set up during preparation.' );
+
+ // The global post is restored to the post that was set before the request.
+ $post = get_post();
+ $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
+ $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
+ $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
}
/**
From 5313494922d2e60450b088041384c766f2fc8102 Mon Sep 17 00:00:00 2001
From: Marin Atanasov
Date: Fri, 31 Jul 2026 08:41:49 +0000
Subject: [PATCH 090/149] Toolbar: Serve the site icon over HTTPS on SSL admin
requests.
`wp_get_attachment_url()` intentionally limits its scheme correction to the front end, skipping it in the admin and on `wp-login.php`. Consequently, on a site whose `siteurl` option is still stored with `http://` but which is served over TLS, `get_site_icon_url()` returned an `http://` URL, and browsers blocked the site icon as mixed content in the admin bar and as the admin favicon.
Upgrade the attachment URL to `https://` when the current request is served over SSL.
Includes a unit test asserting both the upgrade on an SSL admin request and the absence of a downgrade on a non-SSL one.
Developed in https://github.com/WordPress/wordpress-develop/pull/12655.
Props hbhalodia, wildworks, mukesh27, tyxla, westonruter, youknowriad, fushar, mirmpro.
Fixes #65696.
git-svn-id: https://develop.svn.wordpress.org/trunk@62953 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/general-template.php | 2 +-
tests/phpunit/tests/general/template.php | 43 ++++++++++++++++++++++++
2 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php
index 29c30137e173d..22a1d3e307d3e 100644
--- a/src/wp-includes/general-template.php
+++ b/src/wp-includes/general-template.php
@@ -1182,7 +1182,7 @@ function get_site_icon_url( $size = 512, $url = '', $blog_id = 0 ) {
}
$attachment_url = wp_get_attachment_image_url( $site_icon_id, $size_data );
if ( $attachment_url ) {
- $url = $attachment_url;
+ $url = is_ssl() ? set_url_scheme( $attachment_url, 'https' ) : $attachment_url;
}
}
diff --git a/tests/phpunit/tests/general/template.php b/tests/phpunit/tests/general/template.php
index 00b683971a6a0..a83b69dc5d556 100644
--- a/tests/phpunit/tests/general/template.php
+++ b/tests/phpunit/tests/general/template.php
@@ -138,6 +138,49 @@ public function test_get_site_icon_url_returns_fallback_when_attachment_url_fail
$this->assertSame( $fallback, $url, 'Fallback URL should be returned when attachment URL lookup fails.' );
}
+ /**
+ * Ensures the site icon URL scheme is upgraded for the current request, but never downgraded.
+ *
+ * The site icon is display chrome that also renders in wp-admin and on the
+ * login screen, where wp_get_attachment_image_url() does not correct the scheme.
+ *
+ * On an HTTPS request with an http:// siteurl the icon must still be served
+ * over HTTPS to avoid a broken, mixed-content image.
+ *
+ * @ticket 65696
+ *
+ * @group site_icon
+ *
+ * @covers ::get_site_icon_url
+ *
+ * @requires function imagejpeg
+ */
+ public function test_get_site_icon_url_scheme() {
+ $this->set_site_icon();
+
+ set_current_screen( 'dashboard' );
+ $this->assertTrue( is_admin(), 'Test should run in the admin context.' );
+ $this->assertFalse( is_ssl(), 'Baseline request should not be detected as SSL.' );
+ $this->assertStringStartsWith( 'http://', get_site_icon_url(), 'Baseline icon URL should use the HTTP scheme.' );
+
+ $_SERVER['HTTPS'] = 'on';
+ $this->assertTrue( is_ssl(), 'Request should now be detected as SSL.' );
+ $this->assertStringStartsWith( 'https://', get_site_icon_url(), 'Site icon URL should use the HTTPS scheme on an SSL admin request.' );
+
+ add_filter(
+ 'upload_dir',
+ static function ( $uploads ) {
+ $uploads['url'] = set_url_scheme( $uploads['url'], 'https' );
+ $uploads['baseurl'] = set_url_scheme( $uploads['baseurl'], 'https' );
+ return $uploads;
+ }
+ );
+
+ unset( $_SERVER['HTTPS'] );
+ $this->assertFalse( is_ssl(), 'Request should no longer be detected as SSL.' );
+ $this->assertStringStartsWith( 'https://', get_site_icon_url(), 'Site icon URL should preserve the HTTPS scheme on a non-SSL request.' );
+ }
+
/**
* @group site_icon
* @covers ::site_icon_url
From e4cb98dda77ae3586b7a28ba46618ea319f06d41 Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Fri, 31 Jul 2026 10:20:25 +0000
Subject: [PATCH 091/149] Administration: Add no-JS fallbacks to route-based
pages.
The Fonts and Connectors screens are rendered entirely by JavaScript, so they were completely empty when JavaScript is disabled. Both screens now render the page heading and an error notice, shown only when JavaScript is unavailable.
Props afercia, hbhalodia, wildworks.
Fixes #65690.
git-svn-id: https://develop.svn.wordpress.org/trunk@62954 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/font-library.php | 18 +++++++++++++++++-
src/wp-admin/options-connectors.php | 18 +++++++++++++++++-
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/font-library.php b/src/wp-admin/font-library.php
index abc2ea4f4da70..6129211f45545 100644
--- a/src/wp-admin/font-library.php
+++ b/src/wp-admin/font-library.php
@@ -28,10 +28,26 @@
}
// Set the page title
-$title = _x( 'Fonts', 'Font Library admin page title' );
+$title = _x( 'Fonts', 'Font Library admin page title' );
+$js_required_message = __( 'The Fonts screen requires JavaScript. Please enable JavaScript in your browser settings to install and manage fonts.' );
require_once ABSPATH . 'wp-admin/admin-header.php';
+?>
+
+
+ 'error',
+ 'additional_classes' => array( 'hide-if-js' ),
+ )
+ );
+ ?>
+
+
+
+
+ 'error',
+ 'additional_classes' => array( 'hide-if-js' ),
+ )
+ );
+ ?>
+
+
Date: Fri, 31 Jul 2026 12:18:25 +0000
Subject: [PATCH 092/149] Media: Fix media modal positioning and spacing on
mobile with infinite scroll.
On small screens, the attachment grid in the media modal collapsed to a single
narrow column when infinite scrolling was enabled. The responsive rules for
size and position of attachments grid, toolbar, and wrapper did not account for
the taller media toolbar present when the "load more" button is absent.
Add a `.attachments-browser:not(.has-load-more)` selector to the affected mobile
breakpoints and adjust the toolbar height and grid offset so the attachment grid
fills the modal width and displays multiple items per row, matching the layout
used when infinite scroll is disabled.
Props tyxla, mukesh27, joedolson.
See https://core.trac.wordpress.org/ticket/65564.
git-svn-id: https://develop.svn.wordpress.org/trunk@62955 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/css/media-views.css | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css
index d89261a33fd0d..089baaed6c7ab 100644
--- a/src/wp-includes/css/media-views.css
+++ b/src/wp-includes/css/media-views.css
@@ -2635,6 +2635,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters {
}
.attachments-browser .attachments,
+ .attachments-browser:not(.has-load-more) .attachments,
.attachments-browser .uploader-inline,
.attachments-browser .media-toolbar,
.attachments-browser .attachments-wrapper,
@@ -2643,13 +2644,14 @@ select#media-attachment-filters ~ select#media-attachment-date-filters {
}
.attachments-browser .media-toolbar {
- height: 74px;
+ height: 117px;
}
.attachments-browser .attachments,
+ .attachments-browser:not(.has-load-more) .attachments,
.attachments-browser .uploader-inline,
.media-frame-content .attachments-browser .attachments-wrapper {
- top: 90px;
+ top: 131px;
}
.media-sidebar .setting,
@@ -2956,6 +2958,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters {
}
.attachments-browser .attachments,
+ .attachments-browser:not(.has-load-more) .attachments,
.attachments-browser .uploader-inline,
.attachments-browser .media-toolbar,
.media-frame-content .attachments-browser .attachments-wrapper {
From d82f45c86eaddd59ac6484e3be6b0f8fb4f2a35d Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Fri, 31 Jul 2026 13:08:01 +0000
Subject: [PATCH 093/149] Coding Standards: Use `array_last()` to get the last
array element.
This commit replaces `$array[ count( $array ) - 1 ]` constructs with the `array_last()` function (added in PHP 8.5 and polyfilled as of WordPress 6.9) for improved readability.
Follow-up to [60672].
Props Soean, westonruter.
See #64897.
git-svn-id: https://develop.svn.wordpress.org/trunk@62956 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/ajax-actions.php | 2 +-
src/wp-includes/class-wp-block-parser.php | 2 +-
src/wp-includes/class-wp-query.php | 10 ++++++----
src/wp-includes/class-wp-theme-json.php | 4 ++--
src/wp-includes/pomo/plural-forms.php | 4 ++--
src/wp-trackback.php | 2 +-
6 files changed, 13 insertions(+), 11 deletions(-)
diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php
index a04de73bd64e4..3cda30f0d523f 100644
--- a/src/wp-admin/includes/ajax-actions.php
+++ b/src/wp-admin/includes/ajax-actions.php
@@ -128,7 +128,7 @@ function wp_ajax_ajax_tag_search() {
if ( str_contains( $search, ',' ) ) {
$search = explode( ',', $search );
- $search = $search[ count( $search ) - 1 ];
+ $search = array_last( $search );
}
$search = trim( $search );
diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php
index 8c619a7b47f2c..ea66e3b51d38d 100644
--- a/src/wp-includes/class-wp-block-parser.php
+++ b/src/wp-includes/class-wp-block-parser.php
@@ -342,7 +342,7 @@ public function add_freeform( $length = null ) {
* @param int|null $last_offset Last byte offset into document if continuing form earlier output.
*/
public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) {
- $parent = $this->stack[ count( $this->stack ) - 1 ];
+ $parent = $this->stack[ array_key_last( $this->stack ) ];
$parent->block->innerBlocks[] = (array) $block;
$html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset );
diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php
index 244cf84e9b810..228691d26d12b 100644
--- a/src/wp-includes/class-wp-query.php
+++ b/src/wp-includes/class-wp-query.php
@@ -2430,11 +2430,13 @@ public function get_posts() {
if ( '' !== $query_vars['author_name'] ) {
if ( str_contains( $query_vars['author_name'], '/' ) ) {
- $query_vars['author_name'] = explode( '/', $query_vars['author_name'] );
- if ( $query_vars['author_name'][ count( $query_vars['author_name'] ) - 1 ] ) {
- $query_vars['author_name'] = $query_vars['author_name'][ count( $query_vars['author_name'] ) - 1 ]; // No trailing slash.
+ $author_name_parts = explode( '/', $query_vars['author_name'] );
+ $last_part = array_last( $author_name_parts );
+
+ if ( $last_part ) {
+ $query_vars['author_name'] = $last_part; // No trailing slash.
} else {
- $query_vars['author_name'] = $query_vars['author_name'][ count( $query_vars['author_name'] ) - 2 ]; // There was a trailing slash.
+ $query_vars['author_name'] = $author_name_parts[ count( $author_name_parts ) - 2 ]; // There was a trailing slash.
}
}
$query_vars['author_name'] = sanitize_title_for_query( $query_vars['author_name'] );
diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php
index 82b8e89de509c..7e2cb54731b1f 100644
--- a/src/wp-includes/class-wp-theme-json.php
+++ b/src/wp-includes/class-wp-theme-json.php
@@ -3994,7 +3994,7 @@ public function get_styles_for_block( $block_metadata ) {
*/
$is_processing_element = in_array( 'elements', $block_metadata['path'], true );
- $current_element = $is_processing_element ? $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ] : null;
+ $current_element = $is_processing_element ? array_last( $block_metadata['path'] ) : null;
$element_pseudo_allowed = array();
@@ -4688,7 +4688,7 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme
* Get a reference to element name from path.
* $metadata['path'] = array( 'styles', 'elements', 'link' );
*/
- $current_element = $metadata['path'][ count( $metadata['path'] ) - 1 ];
+ $current_element = array_last( $metadata['path'] );
/*
* $output is stripped of pseudo selectors. Re-add and process them
diff --git a/src/wp-includes/pomo/plural-forms.php b/src/wp-includes/pomo/plural-forms.php
index a604334e88c20..cc31471a0b88d 100644
--- a/src/wp-includes/pomo/plural-forms.php
+++ b/src/wp-includes/pomo/plural-forms.php
@@ -128,7 +128,7 @@ protected function parse( $str ) {
case ')':
$found = false;
while ( ! empty( $stack ) ) {
- $o2 = $stack[ count( $stack ) - 1 ];
+ $o2 = array_last( $stack );
if ( '(' !== $o2 ) {
$output[] = array( 'op', array_pop( $stack ) );
continue;
@@ -163,7 +163,7 @@ protected function parse( $str ) {
}
while ( ! empty( $stack ) ) {
- $o2 = $stack[ count( $stack ) - 1 ];
+ $o2 = array_last( $stack );
// Ternary is right-associative in C.
if ( '?:' === $operator || '?' === $operator ) {
diff --git a/src/wp-trackback.php b/src/wp-trackback.php
index 76c2a1d4a7285..c666c13e8b823 100644
--- a/src/wp-trackback.php
+++ b/src/wp-trackback.php
@@ -50,7 +50,7 @@ function trackback_response( $error = 0, $error_message = '' ) {
if ( ! isset( $_GET['tb_id'] ) || ! $_GET['tb_id'] ) {
$post_id = explode( '/', $_SERVER['REQUEST_URI'] );
- $post_id = (int) $post_id[ count( $post_id ) - 1 ];
+ $post_id = (int) array_last( $post_id );
}
$trackback_url = isset( $_POST['url'] ) ? sanitize_url( $_POST['url'] ) : '';
From d98ad954cf09b8bbd00c9d1818532e733a44cbc5 Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Fri, 31 Jul 2026 14:15:27 +0000
Subject: [PATCH 094/149] Tests: Correct `@param` tags in various test methods.
Some `@param` tags did not match their method signatures. This corrects the names and types, and removes the tags for parameters that no longer exist.
Two `@dataProvider` annotations also had no effect. The one on `test_prepare_item()` is removed, and `test_get_items_search_type_post_subtype_invalid()` now accepts `$method` and passes it to the request.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12208
Props Soean, mukesh27, wildworks.
See #64894.
git-svn-id: https://develop.svn.wordpress.org/trunk@62957 602fd350-edb4-49c9-b593-d223f7449a82
---
tests/phpunit/tests/admin/includesFile.php | 4 ++--
tests/phpunit/tests/dependencies/scripts.php | 5 ++---
.../tests/filesystem/wpFilesystemDirect/delete.php | 2 +-
.../tests/filesystem/wpFilesystemDirect/isDir.php | 2 --
tests/phpunit/tests/functions/allowedProtocols.php | 4 ++--
tests/phpunit/tests/functions/doEnclose.php | 4 ++--
tests/phpunit/tests/image/meta.php | 4 ++--
tests/phpunit/tests/multisite/updateBlogDetails.php | 2 +-
tests/phpunit/tests/post.php | 2 --
.../privacy/wpPrivacyProcessPersonalDataExportPage.php | 9 ++++-----
.../tests/rest-api/rest-block-type-controller.php | 3 ---
.../phpunit/tests/rest-api/rest-comments-controller.php | 2 +-
tests/phpunit/tests/rest-api/rest-search-controller.php | 5 +++--
.../rest-api/wpRestBlockPatternCategoriesController.php | 2 --
tests/phpunit/tests/rewrite.php | 2 +-
15 files changed, 21 insertions(+), 31 deletions(-)
diff --git a/tests/phpunit/tests/admin/includesFile.php b/tests/phpunit/tests/admin/includesFile.php
index ab2e82cf1dc8e..fab002c5e5e2c 100644
--- a/tests/phpunit/tests/admin/includesFile.php
+++ b/tests/phpunit/tests/admin/includesFile.php
@@ -399,8 +399,8 @@ public function mock_http_request( $response, $parsed_args, $url ) {
* @covers ::download_url
* @ticket 54738
*
- * @param string $filter A callback containing a fake Content-Type header.
- * @param string $ext The expected file extension to match.
+ * @param callable $filter A callback containing a fake Content-Type header.
+ * @param string $extension The expected file extension to match.
*/
public function test_download_url_should_use_the_content_type_header_to_set_extension_of_a_file_if_extension_was_not_determined( $filter, $extension ) {
add_filter( 'pre_http_request', $filter );
diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php
index bbf301fe424cd..1fabd1dbe4f35 100644
--- a/tests/phpunit/tests/dependencies/scripts.php
+++ b/tests/phpunit/tests/dependencies/scripts.php
@@ -557,9 +557,8 @@ public function get_data_to_filter_eligible_strategies() {
*
* @dataProvider get_data_to_filter_eligible_strategies
*
- * @param callable $set_up Set up.
- * @param bool $async_only Async only.
- * @param bool $expected Expected return value.
+ * @param callable $set_up Set up.
+ * @param string[] $expected Expected return value.
*/
public function test_filter_eligible_strategies( $set_up, $expected ) {
$handle = $set_up();
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php
index 4afe54890a5f3..2bc3087f42663 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/delete.php
@@ -69,7 +69,7 @@ public function test_should_delete_a_directory_with_contents() {
*
* @dataProvider data_should_delete_a_file
*
- * @param string $key The key for the file in `self::$filesystem_structure`.
+ * @param string $file The key for the file in `self::$file_structure`.
*/
public function test_should_delete_a_file( $file ) {
$file = self::$file_structure[ $file ]['path'] . $file;
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php
index 1a367851f6a78..c23e301485c15 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/isDir.php
@@ -35,8 +35,6 @@ public function test_should_determine_that_a_path_is_a_directory() {
* @dataProvider data_should_determine_that_a_path_is_not_a_directory
*
* @param string $path The path to check.
- * @param string $type The type of resource. Accepts 'f' or 'd'.
- * Used to invert $expected due to data provider setup.
*/
public function test_should_determine_that_a_path_is_not_a_directory( $path ) {
$this->assertFalse( self::$filesystem->is_dir( self::$file_structure['test_dir']['path'] . $path ) );
diff --git a/tests/phpunit/tests/functions/allowedProtocols.php b/tests/phpunit/tests/functions/allowedProtocols.php
index 4d4ecf5320e14..63ca3ab0945ce 100644
--- a/tests/phpunit/tests/functions/allowedProtocols.php
+++ b/tests/phpunit/tests/functions/allowedProtocols.php
@@ -27,8 +27,8 @@ public function test_allowed_protocol_has_an_example() {
* @depends test_allowed_protocol_has_an_example
* @dataProvider data_example_urls
*
- * @param string The scheme.
- * @param string Example URL.
+ * @param string $protocol The scheme.
+ * @param string $url Example URL.
*/
public function test_allowed_protocols( $protocol, $url ) {
$this->assertSame( $url, esc_url( $url, $protocol ) );
diff --git a/tests/phpunit/tests/functions/doEnclose.php b/tests/phpunit/tests/functions/doEnclose.php
index 6d36fd373a779..425b28c9dd6d0 100644
--- a/tests/phpunit/tests/functions/doEnclose.php
+++ b/tests/phpunit/tests/functions/doEnclose.php
@@ -221,8 +221,8 @@ public function test_function_enclosure_links_should_be_filterable() {
*
* @since 5.3.0
*
- * @param array $post_links An array of enclosure links.
- * @param int $post_id Post ID.
+ * @param array $enclosure_links An array of enclosure links.
+ * @param int $post_id Post ID.
* @return array An array of enclosure links.
*/
public function filter_enclosure_links( $enclosure_links, $post_id ) {
diff --git a/tests/phpunit/tests/image/meta.php b/tests/phpunit/tests/image/meta.php
index babd3a94bb3b8..b6a1849fae9ae 100644
--- a/tests/phpunit/tests/image/meta.php
+++ b/tests/phpunit/tests/image/meta.php
@@ -184,8 +184,8 @@ public function test_exif_keywords() {
* @ticket 52826
* @ticket 52922
*
- * @param string Stream's URI.
- * @param array Expected metadata.
+ * @param string $file Stream's URI.
+ * @param array $expected Expected metadata.
*/
public function test_stream( $file, $expected ) {
$actual = wp_read_image_metadata( $file );
diff --git a/tests/phpunit/tests/multisite/updateBlogDetails.php b/tests/phpunit/tests/multisite/updateBlogDetails.php
index 62c0f7b355cd7..078d421dfc520 100644
--- a/tests/phpunit/tests/multisite/updateBlogDetails.php
+++ b/tests/phpunit/tests/multisite/updateBlogDetails.php
@@ -53,7 +53,7 @@ public function test_update_blog_details() {
*
* @param string $flag The name of the flag being set or unset on a site.
* @param string $flag_value '0' or '1'. The value of the flag being set.
- * @param string $action The hook expected to fire for the flag name and flag combination.
+ * @param string $hook The hook expected to fire for the flag name and flag combination.
*
* @dataProvider data_flag_hooks
*/
diff --git a/tests/phpunit/tests/post.php b/tests/phpunit/tests/post.php
index e609fa0d3003d..842502a971cba 100644
--- a/tests/phpunit/tests/post.php
+++ b/tests/phpunit/tests/post.php
@@ -668,8 +668,6 @@ public function data_stick_post_with_unexpected_sticky_posts_option() {
*
* @ticket 52007
* @covers ::stick_post
- *
- * @param mixed $stick Value to pass to stick_post().
*/
public function test_stick_post_removes_duplicate_post_ids_when_adding_new_value() {
update_option( 'sticky_posts', array( 1, 1, 2, 2 ) );
diff --git a/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php b/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php
index 5410f851f4b2a..fb610a3d2cfd5 100644
--- a/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php
+++ b/tests/phpunit/tests/privacy/wpPrivacyProcessPersonalDataExportPage.php
@@ -358,7 +358,7 @@ public function data_send_as_email_options() {
*
* @dataProvider data_send_as_email_options
*
- * @param bool Whether the final results of the export should be emailed to the user.
+ * @param bool $send_as_email Whether the final results of the export should be emailed to the user.
*/
public function test_send_error_when_invalid_request_id( $send_as_email ) {
$response = array(
@@ -388,7 +388,7 @@ public function test_send_error_when_invalid_request_id( $send_as_email ) {
*
* @dataProvider data_send_as_email_options
*
- * @param bool Whether the final results of the export should be emailed to the user.
+ * @param bool $send_as_email Whether the final results of the export should be emailed to the user.
*/
public function test_send_error_when_invalid_request_action_name( $send_as_email ) {
$response = array(
@@ -420,8 +420,7 @@ public function test_send_error_when_invalid_request_action_name( $send_as_email
*
* @dataProvider data_send_as_email_options
*
- * @param bool Whether the final results of the export should be emailed to the user.
- *
+ * @param bool $send_as_email Whether the final results of the export should be emailed to the user.
*/
public function test_raw_data_post_meta( $send_as_email ) {
$this->assertEmpty( get_post_meta( self::$request_id, '_export_data_raw', true ) );
@@ -461,7 +460,7 @@ public function test_raw_data_post_meta( $send_as_email ) {
*
* @dataProvider data_send_as_email_options
*
- * @param bool Whether the final results of the export should be emailed to the user.
+ * @param bool $send_as_email Whether the final results of the export should be emailed to the user.
*/
public function test_add_post_meta_with_groups_data_only_available_when_export_file_generated( $send_as_email ) {
// Adds post meta when processing data, given the first exporter on the first page and send as email.
diff --git a/tests/phpunit/tests/rest-api/rest-block-type-controller.php b/tests/phpunit/tests/rest-api/rest-block-type-controller.php
index 7ba693286c993..3cf8c5244d77c 100644
--- a/tests/phpunit/tests/rest-api/rest-block-type-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-block-type-controller.php
@@ -750,11 +750,8 @@ public function test_get_item_no_permission( $method ) {
}
/**
- * @dataProvider data_readable_http_methods
* @ticket 47620
* @ticket 56481
- *
- * @param string $method HTTP method to use.
*/
public function test_prepare_item() {
$registry = new WP_Block_Type_Registry();
diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php
index 8542bcd42af24..7162b278839d5 100644
--- a/tests/phpunit/tests/rest-api/rest-comments-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php
@@ -3646,7 +3646,7 @@ public static function data_head_request_with_specified_fields_returns_success_r
/**
* Create a test post with note.
*
- * @param int $user_id Post author's user ID.
+ * @param string $role User role to assign the post author.
* @return int Post ID.
*/
protected function create_test_post_with_note( $role ) {
diff --git a/tests/phpunit/tests/rest-api/rest-search-controller.php b/tests/phpunit/tests/rest-api/rest-search-controller.php
index e4235fd699798..c996e49147a0f 100644
--- a/tests/phpunit/tests/rest-api/rest-search-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-search-controller.php
@@ -360,13 +360,14 @@ public function test_get_items_search_type_invalid( $method ) {
*
* @param string $method HTTP method to use.
*/
- public function test_get_items_search_type_post_subtype_invalid() {
+ public function test_get_items_search_type_post_subtype_invalid( $method ) {
$response = $this->do_request_with_params(
array(
'per_page' => 100,
'type' => 'post',
'subtype' => 'invalid',
- )
+ ),
+ $method
);
$this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
diff --git a/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php
index 408021b039d31..8962b330a2a9d 100644
--- a/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php
+++ b/tests/phpunit/tests/rest-api/wpRestBlockPatternCategoriesController.php
@@ -141,8 +141,6 @@ public function test_get_items_with_head_request_should_not_prepare_block_patter
/**
* @ticket 56481
- *
- * @param string $path The path to test.
*/
public function test_head_request_with_specified_fields_returns_success_response() {
wp_set_current_user( self::$admin_id );
diff --git a/tests/phpunit/tests/rewrite.php b/tests/phpunit/tests/rewrite.php
index 2bb7254abfcef..24c7e4e1459fc 100644
--- a/tests/phpunit/tests/rewrite.php
+++ b/tests/phpunit/tests/rewrite.php
@@ -167,7 +167,7 @@ public function test_url_to_postid_of_http_site_when_current_site_uses_https() {
* @param string $url The complete home URL including scheme and path.
* @param string $path Path relative to the home URL. Blank string if no path is specified.
* @param string|null $orig_scheme Scheme to give the home URL context.
- * @param int|null $blog_id Site ID, or null for the current site.
+ * @param int|null $_blog_id Site ID, or null for the current site.
* @return string The complete home URL including scheme and path.
*/
public function filter_http_home_url( $url, $path, $orig_scheme, $_blog_id ) {
From 084583df611a5bd5495c1306e90ed9de573dead2 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Fri, 31 Jul 2026 15:26:15 +0000
Subject: [PATCH 095/149] Administration: Fix list table mobile viewports.
Follow up to [62838]. The changes to move column headers to the post title column did not account for the responsive tables in mobile viewports.
Modernizes layout of responsive tables to use `flex` layout. Visual change expected, aligning the toggled hidden cells under the full row, including the checkbox, rather than only under the title column. Update several other styles to fix alignment changes caused by the `th` adjustment.
Developed in https://github.com/WordPress/wordpress-develop/pull/12737
Props afragen, khokansardar, mirmpro, habiburdev, sabernhardt, afercia, shailu25, joedolson.
Fixes #65743.
git-svn-id: https://develop.svn.wordpress.org/trunk@62958 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/common.css | 8 +++-
src/wp-admin/css/list-tables.css | 76 +++++++++++++++++++++++++++-----
2 files changed, 71 insertions(+), 13 deletions(-)
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index e1e5ee3181330..980702d31fb80 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -501,7 +501,7 @@ code {
border-bottom-width: 0;
}
-.widefat th,
+.widefat tbody th,
.widefat td {
vertical-align: top;
}
@@ -520,6 +520,12 @@ code {
.widefat tfoot td {
text-align: left;
line-height: 1.3em;
+}
+
+.widefat th:not(tbody th),
+.widefat tbody td.check-column,
+.widefat thead td,
+.widefat tfoot td {
font-size: 14px;
}
diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
index ccb8c92bfbe2a..8b03e3c08d353 100644
--- a/src/wp-admin/css/list-tables.css
+++ b/src/wp-admin/css/list-tables.css
@@ -353,6 +353,8 @@ table.fixed {
padding-left: 3px;
}
+th.column-title strong,
+th.plugin-title strong,
td.column-title strong,
td.plugin-title strong {
display: block;
@@ -360,6 +362,8 @@ td.plugin-title strong {
font-size: 14px;
}
+th.column-title p,
+th.plugin-title p,
td.column-title p,
td.plugin-title p {
margin: 6px 0;
@@ -1928,32 +1932,79 @@ div.action-links,
display: none;
}
- .wp-list-table thead th.column-primary {
- width: 100%;
+ .wp-list-table tr {
+ display: flex;
+ flex-wrap: wrap;
}
- /* Checkboxes need to show */
- .wp-list-table tr th.check-column,
- .wp-list-table tr td.check-column {
- display: table-cell;
+ .wp-list-table thead th {
+ align-content: center;
}
- .wp-list-table .check-column {
+ .wp-list-table td.check-column,
+ .wp-list-table th.check-column {
+ flex: 0 0 2.5em;
width: 2.5em;
}
+ .wp-list-table .column-primary a {
+ text-wrap: wrap;
+ }
+
+ .wp-list-table td.column-primary,
+ .wp-list-table th.column-primary {
+ flex: 1 1 0;
+ }
+
+ .wp-list-table tr td:nth-child(n+3) {
+ flex: 0 1 100%;
+ }
+
+ .plugins tr.active.plugin-update-tr + tr.inactive th,
+ .plugins tr.active.plugin-update-tr + tr.inactive td,
+ .plugins tr.active + tr.inactive th,
+ .plugins tr.active + tr.inactive td,
+ .plugins .inactive td,
+ .plugins .inactive th,
+ .plugins .active td,
+ .plugins .active th,
+ .plugin-install #the-list td,
+ .upgrade .plugins td,
+ .upgrade .plugins th {
+ box-shadow: none;
+ }
+
+ .plugins tr {
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.1);
+ }
+
+ .plugins .active th.check-column,
+ .plugins .active td.check-column,
+ .plugin-update-tr.active td {
+ border-left: 4px solid transparent;
+ }
+
+ .plugins .active th.check-column input,
+ .plugins .active td.check-column input {
+ margin-left: 0px;
+ }
+
+ .plugins tr.active,
+ .plugin-update-tr.active {
+ border-left: 4px solid var(--wp-admin-theme-color);
+ }
+
.wp-list-table .column-primary .toggle-row {
display: block;
}
+ .wp-list-table tbody th,
.wp-list-table tr:not(.inline-edit-row):not(.no-items) td:not(.check-column) {
position: relative;
- clear: both;
- width: auto !important; /* needs to override some columns that are more specifically targeted */
}
.wp-list-table td.column-primary,
- .wp-list-table th.column-primary {
+ .wp-list-table tbody th.column-primary {
padding-right: 50px; /* space for toggle button */
}
@@ -1962,7 +2013,7 @@ div.action-links,
padding: 3px 8px 3px 35%;
}
- .wp-list-table tr:not(.inline-edit-row):not(.no-items) td:not(.column-primary)::before {
+ .wp-list-table tr:not(.inline-edit-row):not(.no-items) td:not(.check-column)::before {
position: absolute;
left: 10px; /* match padding of regular table cell */
display: block;
@@ -1973,6 +2024,7 @@ div.action-links,
text-overflow: ellipsis;
}
+ .wp-list-table .is-expanded th:not(.hidden),
.wp-list-table .is-expanded td:not(.hidden) {
display: block !important;
overflow: hidden; /* clearfix */
@@ -2238,7 +2290,7 @@ div.action-links,
}
/* Plugin/Theme Management */
- #wpbody-content .wp-list-table.plugins td {
+ #wpbody-content .wp-list-table.plugins tbody td {
display: block;
width: auto;
padding: 10px 9px; /* reset from other list tables that have a label at this width */
From 91138e1b858b72f5f158b66f9d920bc95fb1f9cb Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Fri, 31 Jul 2026 19:44:07 +0000
Subject: [PATCH 096/149] Media: Fix Media grid toolbar height and
'button-link' styling when Bulk editing.
Updates Media Library grid toolbar styling to prevent content shifting and normalizes button appearance when switching modes (e.g., Bulk Select mode) by stabilizing toolbar height and removing unintended button sizing.
Also, adjusts the buttons styling so that 'button-links' for destructive actions use the intended red color and prevents them from being styled as standard buttons.
Developed in https://github.com/WordPress/wordpress-develop/pull/12714
Props afercia, joedolson, shailu25, khokansardar, ozgursar.
Fixes #65732.
git-svn-id: https://develop.svn.wordpress.org/trunk@62959 602fd350-edb4-49c9-b593-d223f7449a82
---
src/js/media/views/attachments/browser.js | 1 +
src/js/media/views/button/delete-selected.js | 3 +++
src/js/media/views/button/select-mode-toggle.js | 4 ++--
src/wp-admin/css/colors/_admin.scss | 14 ++++++++------
src/wp-admin/css/media.css | 1 +
src/wp-includes/css/buttons.css | 1 +
6 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js
index 5533110d815f9..82b7359eb832a 100644
--- a/src/js/media/views/attachments/browser.js
+++ b/src/js/media/views/attachments/browser.js
@@ -328,6 +328,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro
text: l10n.deletePermanently,
controller: this.controller,
priority: -55,
+ size: '',
click: function() {
var removed = [],
destroy = [],
diff --git a/src/js/media/views/button/delete-selected.js b/src/js/media/views/button/delete-selected.js
index b5ad9f8d3b14f..ddb30f66a452a 100644
--- a/src/js/media/views/button/delete-selected.js
+++ b/src/js/media/views/button/delete-selected.js
@@ -40,7 +40,10 @@ DeleteSelected = Button.extend(/** @lends wp.media.view.DeleteSelectedButton.pro
},
render: function() {
+ // Set size silently before calling base render to avoid nested renders.
+ this.model.set( 'size', '', { silent: true } );
Button.prototype.render.apply( this, arguments );
+
if ( this.controller.isModeActive( 'select' ) ) {
this.$el.addClass( 'delete-selected-button' );
} else {
diff --git a/src/js/media/views/button/select-mode-toggle.js b/src/js/media/views/button/select-mode-toggle.js
index 858c0e16cb2e4..9bd21b50346c7 100644
--- a/src/js/media/views/button/select-mode-toggle.js
+++ b/src/js/media/views/button/select-mode-toggle.js
@@ -40,7 +40,7 @@ SelectModeToggle = Button.extend(/** @lends wp.media.view.SelectModeToggle.proto
render: function() {
Button.prototype.render.apply( this, arguments );
- this.$el.addClass( 'select-mode-toggle-button button-compact' );
+ this.$el.addClass( 'select-mode-toggle-button' );
return this;
},
@@ -52,7 +52,7 @@ SelectModeToggle = Button.extend(/** @lends wp.media.view.SelectModeToggle.proto
// @todo The Frame should be doing all of this.
if ( this.controller.isModeActive( 'select' ) ) {
this.model.set( {
- size: 'large',
+ size: '',
text: l10n.cancel
} );
children.not( '.spinner, .media-button' ).hide();
diff --git a/src/wp-admin/css/colors/_admin.scss b/src/wp-admin/css/colors/_admin.scss
index 2d10323c2749d..366f1b86b4f16 100644
--- a/src/wp-admin/css/colors/_admin.scss
+++ b/src/wp-admin/css/colors/_admin.scss
@@ -65,7 +65,8 @@ span.wp-media-buttons-icon:before {
.media-modal .delete-attachment,
.media-modal .trash-attachment,
.media-modal .untrash-attachment,
-.wp-core-ui .button-link-delete {
+/* Needs higher specificity to override the default button-link. */
+.wp-core-ui .button-link.button-link-delete {
color: tokens.$alert-red;
}
@@ -75,9 +76,10 @@ span.wp-media-buttons-icon:before {
.media-modal .delete-attachment:focus,
.media-modal .trash-attachment:focus,
.media-modal .untrash-attachment:focus,
-.wp-core-ui .button-link-delete:hover,
-.wp-core-ui .button-link-delete:focus {
- color: color.adjust(tokens.$alert-red, $lightness: 10%);
+/* Needs higher specificity to override the default button-link. */
+.wp-core-ui .button-link.button-link-delete:hover,
+.wp-core-ui .button-link.button-link-delete:focus {
+ color: color.adjust(tokens.$alert-red, $lightness: -10%);
}
/* Forms */
@@ -156,9 +158,9 @@ input[type="radio"]:focus {
/* Core UI */
.wp-core-ui {
-
/* Default button - theme color border and text (matches secondary) */
- .button {
+ /* Exclude .button-link without increasing specificity. */
+ .button:where(:not(.button-link)) {
@include mixins.button-secondary();
}
diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css
index 73e01d70ecf1d..5a033b98ba350 100644
--- a/src/wp-admin/css/media.css
+++ b/src/wp-admin/css/media.css
@@ -558,6 +558,7 @@ border color while dragging a file over the uploader drop area */
.media-frame.mode-grid .media-toolbar {
margin-bottom: 15px;
height: auto;
+ min-height: 80px;
}
.media-frame.mode-grid .media-toolbar label:not(.media-search-input-label) {
diff --git a/src/wp-includes/css/buttons.css b/src/wp-includes/css/buttons.css
index 967970a9ba461..57d5754483199 100644
--- a/src/wp-includes/css/buttons.css
+++ b/src/wp-includes/css/buttons.css
@@ -223,6 +223,7 @@ TABLE OF CONTENTS:
.wp-core-ui .button[aria-disabled="true"],
.wp-core-ui .button-secondary[aria-disabled="true"] {
+ color: #8a8a8a !important;
cursor: default;
}
From ca644847def89cb8159831332377bc9b13fc9f41 Mon Sep 17 00:00:00 2001
From: Dennis Snell
Date: Fri, 31 Jul 2026 20:01:54 +0000
Subject: [PATCH 097/149] HTML API: Respect enqueued updates in
`get_attribute_names_with_prefix()`.
Trac ticket: Core-64567.
Previously, `get_attribute_names_with_prefix()` was overlooking enqueued attribute and class name updates which occurred before `get_updated_html()` had been called. This resulted in reporting stale data which might overlook attributes which were added, and might report attributes which were removed.
In this patch, the method now examines the enqueued updates to determine if any of them introduce or remove attributes. It also examines enqueued class updates to ensure that if the `class` attribute would be added or removed because of them that it will also be properly reported.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12757
Discussed in: https://core.trac.wordpress.org/ticket/64567
Follow-up to [55203].
Props anupkankale, irozum, itzmekhokan, luisherranz, motylanogha, phpbits, sachinrajcp123, whaze.
See #64567.
git-svn-id: https://develop.svn.wordpress.org/trunk@62960 602fd350-edb4-49c9-b593-d223f7449a82
---
.../html-api/class-wp-html-tag-processor.php | 40 +++-
.../tests/html-api/wpHtmlTagProcessor.php | 208 ++++++++++++++++++
2 files changed, 244 insertions(+), 4 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php
index 48d2de84c86ba..ba33bea28506c 100644
--- a/src/wp-includes/html-api/class-wp-html-tag-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php
@@ -753,7 +753,7 @@ class WP_HTML_Tag_Processor {
* );
*
* @since 6.2.0
- * @var bool[]
+ * @var array
*/
private $classname_updates = array();
@@ -810,7 +810,7 @@ class WP_HTML_Tag_Processor {
* );
*
* @since 6.2.0
- * @var WP_HTML_Text_Replacement[]
+ * @var array
*/
protected $lexical_updates = array();
@@ -2970,13 +2970,45 @@ public function get_attribute_names_with_prefix( $prefix ): ?array {
$comparable = strtolower( $prefix );
+ /*
+ * For the `class` attribute, ensure that enqueued class changes from
+ * `add_class` and `remove_class` are flushed into attribute updates.
+ */
+ $has_class = isset( $this->attributes['class'] );
+ if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {
+ foreach ( $this->classname_updates as $update ) {
+ if (
+ ( $has_class && self::REMOVE_CLASS === $update ) ||
+ ( ! $has_class && self::ADD_CLASS === $update )
+ ) {
+ $this->class_name_updates_to_attributes_updates();
+ break;
+ }
+ }
+ }
+
+ $additions = array();
+ $removals = array();
+ foreach ( $this->lexical_updates as $update_name => $update ) {
+ if ( is_int( $update_name ) || 'modifiable text' === $update_name ) {
+ continue;
+ }
+
+ if ( '' === $update->text ) {
+ $removals[ $update_name ] = true;
+ } elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) {
+ $additions[] = $update_name;
+ }
+ }
+
$matches = array();
foreach ( array_keys( $this->attributes ) as $attr_name ) {
- if ( str_starts_with( $attr_name, $comparable ) ) {
+ if ( str_starts_with( $attr_name, $comparable ) && ! isset( $removals[ $attr_name ] ) ) {
$matches[] = $attr_name;
}
}
- return $matches;
+
+ return empty( $additions ) ? $matches : array_merge( $additions, $matches );
}
/**
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
index 84d90a84190fc..66e01dbdbed3e 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
@@ -464,6 +464,214 @@ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_
);
}
+ /**
+ * Ensures that a new attribute added via set_attribute() is reported by
+ * get_attribute_names_with_prefix() immediately after being added.
+ *
+ * @ticket 64567
+ *
+ * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_immediately_reflects_new_attributes() {
+ $processor = new WP_HTML_Tag_Processor( '