', 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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