\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/oracles/html5ever/Cargo.lock b/tools/html-api-fuzz/oracles/html5ever/Cargo.lock
new file mode 100644
index 0000000000000..ec920f6504aab
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/Cargo.lock
@@ -0,0 +1,310 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "html5ever-tree-oracle"
+version = "0.1.0"
+dependencies = [
+ "html5ever",
+ "markup5ever_rcdom",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.39.0+unofficial"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "web_atoms"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "xml5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ab627f34ff61b80d756180d556f9c68801d836d271b3b8c094504ceca69d221"
+dependencies = [
+ "log",
+ "markup5ever",
+]
diff --git a/tools/html-api-fuzz/oracles/html5ever/Cargo.toml b/tools/html-api-fuzz/oracles/html5ever/Cargo.toml
new file mode 100644
index 0000000000000..cefaa8b1bf496
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "html5ever-tree-oracle"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[dependencies]
+html5ever = "=0.39.0"
+# Cargo ignores SemVer build metadata in version requirements. The lockfile
+# resolves this exact requirement to 0.39.0+unofficial and pins its checksum.
+markup5ever_rcdom = "=0.39.0"
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+strip = "symbols"
diff --git a/tools/html-api-fuzz/oracles/html5ever/README.md b/tools/html-api-fuzz/oracles/html5ever/README.md
new file mode 100644
index 0000000000000..73b09cedafe2e
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/README.md
@@ -0,0 +1,58 @@
+# Source-built html5ever tree oracle
+
+This standalone Rust binary parses HTML with pinned `html5ever` 0.39.0 and
+`markup5ever_rcdom` 0.39.0+unofficial, then renders the fuzzer's canonical
+html5lib-style tree. `Cargo.lock` pins every transitive crate and its registry
+checksum. `rust-toolchain.toml` pins Rust 1.88.0. The CLI metadata exposes the
+two direct crate checksums, complete lockfile hash, and build identity.
+
+Install the pinned local Rust toolchain and build:
+
+```sh
+tools/html-api-fuzz/oracles/html5ever/install-rust.sh
+tools/html-api-fuzz/oracles/html5ever/build.sh
+tools/html-api-fuzz/oracles/html5ever/smoke.sh
+```
+
+The installer supports arm64/x86-64 macOS and glibc Linux, installs below
+`.cache/html5ever/rust`, and does not edit shell startup files. A preinstalled
+`cargo` also works. The build script automatically finds the local install and
+requires both rustc and Cargo 1.88.0. The rustup 1.28.2
+bootstrap executable is verified against `RUSTUP_SHA256SUMS` before it is made
+executable. Those checked-in digests come from the publisher's HTTPS SHA-256
+sidecars, so a fresh checksum is not trusted alongside each download.
+
+The build uses Cargo's locked mode and derives a build identity from the exact
+checked-in `Cargo.toml`, `Cargo.lock`, toolchain file, and Rust source bytes. It
+publishes `build/html5ever-tree-oracle`, then atomically renames
+`build/build-manifest.json` last as the pair's commit marker. The manifest
+records those input hashes, the direct crate pins/checksums, Rust and Cargo
+identities, build time, and the executable SHA-256. Consumers must read the
+manifest before and after recomputing the executable hash and probing
+`--version`, reject a changing manifest, and require all three identities to
+agree. An interrupted publication therefore fails closed instead of exposing
+an accepted mixed pair. The build and lock identities are required compile-time
+inputs; missing inputs cannot compile, and malformed identities report the
+oracle unavailable instead of producing a successful version response.
+
+CLI:
+
+```text
+html5ever-tree-oracle --mode full-document|fragment-* --input PATH [--context TAG] [--max-nodes N] [--max-depth N] [--max-tree-bytes N]
+html5ever-tree-oracle --version
+```
+
+Fragment modes use html5ever's context-aware fragment parser. Supported
+contexts match the fuzzer: `body`, `div`, `p`, `td`, `tr`, `table`, `caption`,
+`colgroup`, `select`, `option`, `template`, `title`, `textarea`, `script`,
+`style`, `svg`, and `math`. The result is one JSON object containing `status`,
+`oracle`, `tree`, `treeBase64`, and `nodeCount` on success. Parsing always has
+scripting disabled. Input is read as exact bytes; invalid UTF-8 is reported as
+an explicit `unsupported` result because html5ever's UTF-8 sink would otherwise
+replace invalid byte sequences. It is never silently transcoded. Node, depth,
+and tree-output byte limits fail with structured resource-limit classes.
+
+`smoke.sh` exercises every fragment context, canonical output, scripting mode,
+invalid-byte handling, resource limits, and manifest/binary identity. The
+network-free `tests/html5ever-install-integrity-smoke.sh` proves corrupt cached
+bootstrap bytes are rejected before chmod or execution.
diff --git a/tools/html-api-fuzz/oracles/html5ever/RUSTUP_SHA256SUMS b/tools/html-api-fuzz/oracles/html5ever/RUSTUP_SHA256SUMS
new file mode 100644
index 0000000000000..007aed5bb225e
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/RUSTUP_SHA256SUMS
@@ -0,0 +1,5 @@
+# rustup-init 1.28.2 official platform binaries.
+20ef5516c31b1ac2290084199ba77dbbcaa1406c45c1d978ca68558ef5964ef5 rustup-init-1.28.2-aarch64-apple-darwin
+9c331076f62b4d0edeae63d9d1c9442d5fe39b37b05025ec8d41c5ed35486496 rustup-init-1.28.2-x86_64-apple-darwin
+e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c rustup-init-1.28.2-aarch64-unknown-linux-gnu
+20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c rustup-init-1.28.2-x86_64-unknown-linux-gnu
diff --git a/tools/html-api-fuzz/oracles/html5ever/build.sh b/tools/html-api-fuzz/oracles/html5ever/build.sh
new file mode 100755
index 0000000000000..d58bad893837d
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/build.sh
@@ -0,0 +1,169 @@
+#!/usr/bin/env sh
+set -eu
+
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+target_dir="${HTML5EVER_TARGET_DIR:-$repo_root/.cache/html5ever/target}"
+oracle_build_dir="$script_dir/build"
+oracle_bin="$oracle_build_dir/html5ever-tree-oracle"
+manifest_path="$oracle_build_dir/build-manifest.json"
+rust_root="${HTML5EVER_RUST_ROOT:-$repo_root/.cache/html5ever/rust}"
+
+if [ -z "${CARGO_HOME:-}" ] && [ -z "${RUSTUP_HOME:-}" ] && [ -x "$rust_root/cargo/bin/cargo" ]; then
+ CARGO_HOME="$rust_root/cargo"
+ RUSTUP_HOME="$rust_root/rustup"
+ export CARGO_HOME RUSTUP_HOME
+ PATH="$CARGO_HOME/bin:$PATH"
+ export PATH
+fi
+
+cargo_bin="${CARGO:-cargo}"
+rustc_bin="${RUSTC:-rustc}"
+if ! command -v "$cargo_bin" >/dev/null 2>&1; then
+ printf '%s\n' 'cargo is required. Run ./install-rust.sh, then add the printed bin directory to PATH.' >&2
+ exit 1
+fi
+if ! command -v "$rustc_bin" >/dev/null 2>&1; then
+ printf '%s\n' 'rustc is required. Run ./install-rust.sh, then add the printed bin directory to PATH.' >&2
+ exit 1
+fi
+
+rustc_version="$("$rustc_bin" --version)"
+case "$rustc_version" in
+ 'rustc 1.88.0 ('*')') ;;
+ *)
+ printf '%s\n' "Expected rustc 1.88.0; got: $rustc_version" >&2
+ exit 1
+ ;;
+esac
+cargo_version="$("$cargo_bin" --version)"
+case "$cargo_version" in
+ 'cargo 1.88.0 ('*')') ;;
+ *)
+ printf '%s\n' "Expected cargo 1.88.0; got: $cargo_version" >&2
+ exit 1
+ ;;
+esac
+
+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
+ printf '%s\n' 'sha256sum or shasum is required.' >&2
+ return 1
+ fi
+}
+
+sha256_stdin() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum | awk '{ print $1 }'
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 | awk '{ print $1 }'
+ else
+ printf '%s\n' 'sha256sum or shasum is required.' >&2
+ return 1
+ fi
+}
+
+cargo_toml_sha256="$(sha256_file "$script_dir/Cargo.toml")"
+cargo_lock_sha256="$(sha256_file "$script_dir/Cargo.lock")"
+rust_toolchain_sha256="$(sha256_file "$script_dir/rust-toolchain.toml")"
+source_sha256="$(sha256_file "$script_dir/src/main.rs")"
+build_identity="$(
+ printf '%s\n' \
+ "Cargo.toml $cargo_toml_sha256" \
+ "Cargo.lock $cargo_lock_sha256" \
+ "rust-toolchain.toml $rust_toolchain_sha256" \
+ "src/main.rs $source_sha256" |
+ sha256_stdin
+)"
+
+mkdir -p "$oracle_build_dir"
+tmp_bin="$oracle_build_dir/.html5ever-tree-oracle.$$"
+tmp_manifest="$oracle_build_dir/.build-manifest.json.$$"
+trap 'rm -f "$tmp_bin" "$tmp_manifest"' EXIT HUP INT TERM
+
+cd "$script_dir"
+HTML_API_FUZZ_HTML5EVER_BUILD_IDENTITY="$build_identity" \
+HTML_API_FUZZ_HTML5EVER_CARGO_LOCK_SHA256="$cargo_lock_sha256" \
+CARGO_TARGET_DIR="$target_dir" "$cargo_bin" build \
+ --manifest-path "$script_dir/Cargo.toml" \
+ --release \
+ --locked
+
+cp "$target_dir/release/html5ever-tree-oracle" "$tmp_bin"
+chmod 0755 "$tmp_bin"
+binary_sha256="$(sha256_file "$tmp_bin")"
+version_json="$("$tmp_bin" --version)"
+
+php -r '
+function package_from_lock( string $lock, string $name ): array {
+ if ( ! preg_match_all( "/\\[\\[package\\]\\]\\s*(.*?)(?=\\n\\[\\[package\\]\\]|\\z)/s", $lock, $packages ) ) {
+ throw new RuntimeException( "Malformed Cargo.lock." );
+ }
+ foreach ( $packages[1] as $package ) {
+ if ( ! 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( "Incomplete locked identity for {$name}." );
+ }
+ return array( "version" => $version[1], "checksum" => $checksum[1] );
+ }
+ throw new RuntimeException( "Missing {$name} in Cargo.lock." );
+}
+
+$lock = file_get_contents( $argv[1] );
+$version = json_decode( $argv[2], true, 512, JSON_THROW_ON_ERROR );
+$html5ever = package_from_lock( $lock, "html5ever" );
+$rcdom = package_from_lock( $lock, "markup5ever_rcdom" );
+$oracle = $version["oracle"] ?? null;
+if (
+ "ok" !== ( $version["status"] ?? null ) ||
+ ! is_array( $oracle ) ||
+ "html5ever-source" !== ( $oracle["kind"] ?? null ) ||
+ true !== ( $oracle["available"] ?? null ) ||
+ $argv[3] !== ( $oracle["buildIdentity"] ?? null ) ||
+ $argv[4] !== ( $oracle["cargoLockSha256"] ?? null ) ||
+ $html5ever["version"] !== ( $oracle["html5everVersion"] ?? null ) ||
+ $html5ever["checksum"] !== ( $oracle["html5everChecksum"] ?? null ) ||
+ $rcdom["version"] !== ( $oracle["markup5everRcdomVersion"] ?? null ) ||
+ $rcdom["checksum"] !== ( $oracle["markup5everRcdomChecksum"] ?? null ) ||
+ "1.88.0" !== ( $oracle["rustToolchain"] ?? null )
+) {
+ throw new RuntimeException( "Built html5ever binary identity does not match its checked-in inputs." );
+}
+$manifest = array(
+ "schemaVersion" => 1,
+ "kind" => "html-api-fuzz-html5ever-build",
+ "publicationProtocol" => "manifest-last-v1",
+ "builtAt" => gmdate( "c" ),
+ "buildIdentity" => $argv[3],
+ "cargoTomlSha256" => $argv[5],
+ "cargoLockSha256" => $argv[4],
+ "rustToolchainSha256" => $argv[6],
+ "sourceSha256" => $argv[7],
+ "rustc" => $argv[8],
+ "cargo" => $argv[9],
+ "html5ever" => $html5ever,
+ "markup5everRcdom" => $rcdom,
+ "binarySha256" => $argv[10],
+);
+$json = json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n";
+if ( strlen( $json ) !== file_put_contents( $argv[11], $json ) ) {
+ throw new RuntimeException( "Could not write html5ever build manifest." );
+}
+' "$script_dir/Cargo.lock" "$version_json" "$build_identity" "$cargo_lock_sha256" "$cargo_toml_sha256" "$rust_toolchain_sha256" "$source_sha256" "$rustc_version" "$cargo_version" "$binary_sha256" "$tmp_manifest"
+
+# The manifest is the commit marker. Readers accept a build only when the
+# manifest is unchanged across executable hashing and identity probing. A
+# crash before this second rename therefore leaves a fail-closed pair.
+mv "$tmp_bin" "$oracle_bin"
+mv "$tmp_manifest" "$manifest_path"
+trap - EXIT HUP INT TERM
+
+printf '%s\n' "$oracle_bin"
diff --git a/tools/html-api-fuzz/oracles/html5ever/install-rust.sh b/tools/html-api-fuzz/oracles/html5ever/install-rust.sh
new file mode 100755
index 0000000000000..c97cb88b19e96
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/install-rust.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env sh
+set -eu
+
+# rustup and rustc are both pinned. The downloaded rustup-init is checked
+# against a checked-in SHA-256 obtained from the publisher's sidecar.
+rustup_version='1.28.2'
+toolchain='1.88.0'
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+checksum_manifest="$script_dir/RUSTUP_SHA256SUMS"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+install_root="${HTML5EVER_RUST_ROOT:-$repo_root/.cache/html5ever/rust}"
+cargo_home="${CARGO_HOME:-$install_root/cargo}"
+rustup_home="${RUSTUP_HOME:-$install_root/rustup}"
+
+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) target='x86_64-unknown-linux-gnu' ;;
+ *)
+ printf 'Unsupported rustup host: %s-%s\n' "$(uname -s)" "$(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+archive_url="https://static.rust-lang.org/rustup/archive/$rustup_version/$target/rustup-init"
+download_dir="$install_root/downloads"
+rustup_init="$download_dir/rustup-init-$rustup_version-$target"
+archive_name="rustup-init-$rustup_version-$target"
+
+mkdir -p "$download_dir" "$cargo_home" "$rustup_home"
+
+if [ ! -f "$checksum_manifest" ]; then
+ printf 'Missing rustup checksum manifest: %s\n' "$checksum_manifest" >&2
+ exit 1
+fi
+
+entry_count="$(awk -v name="$archive_name" '$2 == name { count++ } END { print count + 0 }' "$checksum_manifest")"
+if [ "$entry_count" -ne 1 ]; then
+ printf 'Expected exactly one checksum for %s; found %s.\n' "$archive_name" "$entry_count" >&2
+ exit 1
+fi
+
+entry_field_count="$(awk -v name="$archive_name" '$2 == name { print NF }' "$checksum_manifest")"
+expected="$(awk -v name="$archive_name" '$2 == name { print $1 }' "$checksum_manifest")"
+if [ "$entry_field_count" -ne 2 ] || [ "${#expected}" -ne 64 ]; then
+ printf 'Malformed checksum entry for %s.\n' "$archive_name" >&2
+ exit 1
+fi
+case "$expected" in
+ *[!0-9a-f]*)
+ printf 'Malformed checksum entry for %s.\n' "$archive_name" >&2
+ exit 1
+ ;;
+esac
+
+if [ ! -f "$rustup_init" ]; then
+ partial="$rustup_init.partial.$$"
+ trap 'rm -f "$partial"' EXIT HUP INT TERM
+ curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
+ --retry 3 "$archive_url" --output "$partial"
+ mv "$partial" "$rustup_init"
+ trap - EXIT HUP INT TERM
+fi
+
+if command -v sha256sum >/dev/null 2>&1; then
+ actual="$(sha256sum "$rustup_init" | awk '{ print $1 }')"
+elif command -v shasum >/dev/null 2>&1; then
+ actual="$(shasum -a 256 "$rustup_init" | awk '{ print $1 }')"
+else
+ printf '%s\n' 'sha256sum or shasum is required.' >&2
+ exit 1
+fi
+
+if [ "$actual" != "$expected" ]; then
+ printf '%s\n' "rustup-init SHA-256 mismatch: expected $expected, got $actual" >&2
+ exit 1
+fi
+
+chmod 0755 "$rustup_init"
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" RUSTUP_VERSION="$rustup_version" \
+ "$rustup_init" -y --no-modify-path --profile minimal --default-toolchain none
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \
+ "$cargo_home/bin/rustup" set auto-self-update disable
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \
+ "$cargo_home/bin/rustup" toolchain install "$toolchain" --profile minimal --no-self-update
+
+printf '%s\n' "$cargo_home/bin"
diff --git a/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml b/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml
new file mode 100644
index 0000000000000..55b200d5b2903
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.88.0"
+profile = "minimal"
diff --git a/tools/html-api-fuzz/oracles/html5ever/smoke.sh b/tools/html-api-fuzz/oracles/html5ever/smoke.sh
new file mode 100755
index 0000000000000..f9b5638528a46
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/smoke.sh
@@ -0,0 +1,384 @@
+#!/usr/bin/env sh
+set -eu
+
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+binary="${HTML5EVER_ORACLE_BIN:-$script_dir/build/html5ever-tree-oracle}"
+manifest="$(dirname "$binary")/build-manifest.json"
+rust_root="${HTML5EVER_RUST_ROOT:-$repo_root/.cache/html5ever/rust}"
+
+if [ -z "${CARGO_HOME:-}" ] && [ -z "${RUSTUP_HOME:-}" ] && [ -x "$rust_root/cargo/bin/cargo" ]; then
+ CARGO_HOME="$rust_root/cargo"
+ RUSTUP_HOME="$rust_root/rustup"
+ export CARGO_HOME RUSTUP_HOME
+ PATH="$CARGO_HOME/bin:$PATH"
+ export PATH
+fi
+cargo_bin="${CARGO:-cargo}"
+
+if [ ! -x "$binary" ]; then
+ printf '%s\n' "Missing oracle binary: $binary. Run ./build.sh first." >&2
+ exit 1
+fi
+if [ ! -f "$manifest" ]; then
+ printf '%s\n' "Missing build manifest: $manifest. Run ./build.sh first." >&2
+ exit 1
+fi
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/html5ever-oracle-smoke.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM
+
+expect_cli_error() {
+ name="$1"
+ shift
+ set +e
+ "$binary" "$@" >"$tmp_dir/cli-$name.json" 2>"$tmp_dir/cli-$name.err"
+ status=$?
+ set -e
+ if [ "$status" -eq 0 ]; then
+ printf '%s\n' "Expected CLI case $name to fail." >&2
+ exit 1
+ fi
+ php -r '
+ $result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+ if ( "error" !== ( $result["status"] ?? null ) || "oracle-cli-error" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "malformed CLI result" );
+ }
+ ' "$tmp_dir/cli-$name.json"
+}
+
+verify_build_pair() {
+ php -r '
+ $before = file_get_contents( $argv[2] );
+ $manifest = json_decode( $before, true, 512, JSON_THROW_ON_ERROR );
+ if ( "manifest-last-v1" !== ( $manifest["publicationProtocol"] ?? null ) ) {
+ throw new Exception( "publication protocol" );
+ }
+ $binary_hash = hash_file( "sha256", $argv[1] );
+ if ( ! is_string( $binary_hash ) || $binary_hash !== ( $manifest["binarySha256"] ?? null ) ) {
+ throw new Exception( "binary hash mismatch" );
+ }
+ $output = array();
+ $status = 0;
+ exec( escapeshellarg( $argv[1] ) . " --version", $output, $status );
+ if ( 0 !== $status ) {
+ throw new Exception( "version probe failed" );
+ }
+ $after = file_get_contents( $argv[2] );
+ if ( ! hash_equals( $before, $after ) ) {
+ throw new Exception( "manifest changed during verification" );
+ }
+ $version = json_decode( implode( "\n", $output ), true, 512, JSON_THROW_ON_ERROR );
+ $oracle = $version["oracle"] ?? null;
+ if (
+ "ok" !== ( $version["status"] ?? null ) ||
+ ! is_array( $oracle ) ||
+ ( $manifest["buildIdentity"] ?? null ) !== ( $oracle["buildIdentity"] ?? null ) ||
+ ( $manifest["cargoLockSha256"] ?? null ) !== ( $oracle["cargoLockSha256"] ?? null ) ||
+ ( $manifest["html5ever"]["version"] ?? null ) !== ( $oracle["html5everVersion"] ?? null ) ||
+ ( $manifest["html5ever"]["checksum"] ?? null ) !== ( $oracle["html5everChecksum"] ?? null ) ||
+ ( $manifest["markup5everRcdom"]["version"] ?? null ) !== ( $oracle["markup5everRcdomVersion"] ?? null ) ||
+ ( $manifest["markup5everRcdom"]["checksum"] ?? null ) !== ( $oracle["markup5everRcdomChecksum"] ?? null )
+ ) {
+ throw new Exception( "manifest/version mismatch" );
+ }
+ ' "$1" "$2"
+}
+
+printf '%s' 'x' >"$tmp_dir/cli-input.html"
+expect_cli_error unknown-option --unknown
+expect_cli_error missing-value --mode
+expect_cli_error missing-mode --input "$tmp_dir/cli-input.html"
+expect_cli_error missing-input --mode full-document
+expect_cli_error zero-nodes --mode full-document --input "$tmp_dir/cli-input.html" --max-nodes 0
+expect_cli_error noninteger-nodes --mode full-document --input "$tmp_dir/cli-input.html" --max-nodes nope
+expect_cli_error zero-depth --mode full-document --input "$tmp_dir/cli-input.html" --max-depth 0
+expect_cli_error noninteger-depth --mode full-document --input "$tmp_dir/cli-input.html" --max-depth nope
+expect_cli_error zero-tree-bytes --mode full-document --input "$tmp_dir/cli-input.html" --max-tree-bytes 0
+expect_cli_error noninteger-tree-bytes --mode full-document --input "$tmp_dir/cli-input.html" --max-tree-bytes nope
+expect_cli_error version-trailing --version --unknown
+expect_cli_error help-operational --help --mode full-document
+expect_cli_error help-and-version --help --version
+expect_cli_error duplicate-version --version --version
+expect_cli_error duplicate-mode --mode full-document --mode full-document --input "$tmp_dir/cli-input.html"
+
+"$binary" --mode fragment-body --context unsupported-context --input "$tmp_dir/cli-input.html" >"$tmp_dir/unsupported-context.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "unsupported" !== ( $result["status"] ?? null ) || "oracle-unsupported" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "unsupported context protocol" );
+}
+' "$tmp_dir/unsupported-context.json"
+
+fake_cargo="$tmp_dir/fake-cargo"
+cat >"$fake_cargo" <<'EOF'
+#!/bin/sh
+if [ "${1:-}" = '--version' ]; then
+ printf '%s\n' 'cargo 9.9.9 (wrong)'
+ exit 0
+fi
+printf '%s\n' invoked >"$HTML_API_FUZZ_WRONG_CARGO_LOG"
+exit 97
+EOF
+chmod 0755 "$fake_cargo"
+if CARGO="$fake_cargo" HTML_API_FUZZ_WRONG_CARGO_LOG="$tmp_dir/wrong-cargo-invoked" \
+ "$script_dir/build.sh" >"$tmp_dir/wrong-cargo.out" 2>"$tmp_dir/wrong-cargo.err"; then
+ printf '%s\n' 'Build accepted the wrong Cargo version.' >&2
+ exit 1
+fi
+if [ -e "$tmp_dir/wrong-cargo-invoked" ]; then
+ printf '%s\n' 'Build reached the wrong Cargo executable after its version check.' >&2
+ exit 1
+fi
+grep -q 'Expected cargo 1.88.0' "$tmp_dir/wrong-cargo.err"
+
+printf '%s' '
x hi&
' >"$tmp_dir/document.html"
+"$binary" --mode full-document --max-nodes 100 --input "$tmp_dir/document.html" >"$tmp_dir/document.json"
+
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "ok" !== ( $result["status"] ?? null ) ) { throw new Exception( "full document status" ); }
+if ( "html5ever-source" !== ( $result["oracle"]["kind"] ?? null ) ) { throw new Exception( "oracle kind" ); }
+if ( true !== ( $result["oracle"]["available"] ?? null ) ) { throw new Exception( "oracle availability" ); }
+if ( "0.39.0" !== ( $result["oracle"]["html5everVersion"] ?? null ) ) { throw new Exception( "html5ever pin" ); }
+if ( "1.88.0" !== ( $result["oracle"]["rustToolchain"] ?? null ) ) { throw new Exception( "Rust pin" ); }
+if ( base64_decode( $result["treeBase64"], true ) !== $result["tree"] ) { throw new Exception( "tree base64" ); }
+if ( ! str_starts_with( $result["tree"], "\n\n" ) ) { throw new Exception( "doctype/document tree" ); }
+if ( false === strpos( $result["tree"], " a=\"1\"\n b=\"2\"\n \"hi&\"" ) ) { throw new Exception( "canonical attributes/text" ); }
+' "$tmp_dir/document.json"
+
+printf '%s' 'y ' >"$tmp_dir/noscript-document.html"
+"$binary" --mode full-document --max-nodes 100 --input "$tmp_dir/noscript-document.html" >"$tmp_dir/noscript-document.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = " \n \n name=\"x\"\n \n \n \n \"y\"\n";
+if ( false === strpos( $result["tree"] ?? "", $expected ) ) { throw new Exception( "document scripting mode" ); }
+' "$tmp_dir/noscript-document.json"
+
+printf '%s' 'x ' >"$tmp_dir/noscript-fragment.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/noscript-fragment.html" >"$tmp_dir/noscript-fragment.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n \n \"x\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "fragment scripting mode" ); }
+' "$tmp_dir/noscript-fragment.json"
+
+printf '%s' 'x' >"$tmp_dir/context.html"
+for context in body div p td tr table caption colgroup select option template title textarea script style svg math; do
+ "$binary" --mode fragment-body --context "$context" --max-nodes 100 --input "$tmp_dir/context.html" >"$tmp_dir/context-$context.json"
+ php -r '
+ $result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+ $expected = "colgroup" === $argv[2] ? "\n" : "\"x\"\n\n";
+ if ( "ok" !== ( $result["status"] ?? null ) || $expected !== ( $result["tree"] ?? null ) ) {
+ throw new Exception( "context coverage: " . $argv[2] );
+ }
+ ' "$tmp_dir/context-$context.json" "$context"
+done
+
+: >"$tmp_dir/empty.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/empty.html" >"$tmp_dir/empty.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\n" !== ( $result["tree"] ?? null ) || 0 !== ( $result["nodeCount"] ?? null ) ) { throw new Exception( "empty fragment contract" ); }
+' "$tmp_dir/empty.json"
+
+printf '%s' ' x y' >"$tmp_dir/fragment.html"
+"$binary" --mode fragment-body --context table --max-nodes 100 --input "$tmp_dir/fragment.html" >"$tmp_dir/fragment.json"
+
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "ok" !== ( $result["status"] ?? null ) ) { throw new Exception( "fragment status" ); }
+$expected = " \n \n \n \"x\"\n \n \"y\"\n\n";
+if ( $expected !== $result["tree"] ) { throw new Exception( "contextual table fragment: " . $result["tree"] ); }
+' "$tmp_dir/fragment.json"
+
+printf '%s' 'a b' >"$tmp_dir/select.html"
+"$binary" --mode fragment-select --context select --max-nodes 100 --input "$tmp_dir/select.html" >"$tmp_dir/select.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = " \n \"a\"\n \n \"b\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "contextual select fragment" ); }
+' "$tmp_dir/select.json"
+
+printf '%s' '&' >"$tmp_dir/title.html"
+"$binary" --mode fragment-title --context title --max-nodes 100 --input "$tmp_dir/title.html" >"$tmp_dir/title.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\"&\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "RCDATA title context" ); }
+' "$tmp_dir/title.json"
+
+printf '%s' '&' >"$tmp_dir/script.html"
+"$binary" --mode fragment-script --context script --max-nodes 100 --input "$tmp_dir/script.html" >"$tmp_dir/script.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\"&\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "raw-text script context" ); }
+' "$tmp_dir/script.json"
+
+printf '%s' 'x ' >"$tmp_dir/template-fragment.html"
+"$binary" --mode fragment-template --context template --max-nodes 100 --input "$tmp_dir/template-fragment.html" >"$tmp_dir/template-fragment.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\n \"x\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "template fragment context" ); }
+' "$tmp_dir/template-fragment.json"
+
+printf '%s' 'x
' >"$tmp_dir/template-document.html"
+"$binary" --mode full-document --max-nodes 100 --input "$tmp_dir/template-document.html" >"$tmp_dir/template-document.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( false === strpos( $result["tree"] ?? "", " \n content\n \n \"x\"\n" ) ) { throw new Exception( "template content marker/tree" ); }
+if ( ! str_ends_with( $result["tree"] ?? "", "\n\n" ) ) { throw new Exception( "canonical trailing newline" ); }
+' "$tmp_dir/template-document.json"
+
+printf '%s' ' ' >"$tmp_dir/svg.html"
+"$binary" --mode fragment-svg --context svg --max-nodes 100 --input "$tmp_dir/svg.html" >"$tmp_dir/svg.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n xlink href=\"q\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "SVG fragment namespace" ); }
+' "$tmp_dir/svg.json"
+
+printf '%s' 'x ' >"$tmp_dir/math.html"
+"$binary" --mode fragment-math --context math --max-nodes 100 --input "$tmp_dir/math.html" >"$tmp_dir/math.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n \"x\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "MathML fragment namespace" ); }
+' "$tmp_dir/math.json"
+
+printf '%s' ' ' >"$tmp_dir/namespaces.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/namespaces.html" >"$tmp_dir/namespaces.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n a=\"0\"\n xlink href=\"y\"\n xml lang=\"x\"\n xmlns xlink=\"z\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "attribute namespace/order contract" ); }
+' "$tmp_dir/namespaces.json"
+
+printf '%s' '
' >"$tmp_dir/adjusted-svg.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/adjusted-svg.html" >"$tmp_dir/adjusted-svg.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$tree = $result["tree"] ?? "";
+foreach ( array( "\n", "\n", " attributeName=\"x\"\n", "\n", " gradientUnits=\"userSpaceOnUse\"\n" ) as $expected ) {
+ if ( false === strpos( $tree, $expected ) ) { throw new Exception( "adjusted SVG name: " . $expected ); }
+}
+' "$tmp_dir/adjusted-svg.json"
+
+printf '%s' 'x ' >"$tmp_dir/limit.html"
+"$binary" --mode fragment-body --context body --max-nodes 1 --input "$tmp_dir/limit.html" >"$tmp_dir/limit.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "error" !== ( $result["status"] ?? null ) ) { throw new Exception( "node limit status" ); }
+if ( "node-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) { throw new Exception( "node limit failure class" ); }
+if ( ! is_int( $result["nodeCount"] ?? null ) || $result["nodeCount"] < 0 ) { throw new Exception( "node limit count" ); }
+if ( true !== ( $result["oracle"]["available"] ?? null ) ) { throw new Exception( "node limit oracle availability" ); }
+' "$tmp_dir/limit.json"
+
+php -r 'file_put_contents( $argv[1], str_repeat( "", 12 ) );' "$tmp_dir/deep.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --max-depth 3 --input "$tmp_dir/deep.html" >"$tmp_dir/deep.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "error" !== ( $result["status"] ?? null ) || "depth-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "depth limit" );
+}
+' "$tmp_dir/deep.json"
+
+printf '%s' '
tree output must exceed twenty bytes
' >"$tmp_dir/tree-bytes.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --max-tree-bytes 20 --input "$tmp_dir/tree-bytes.html" >"$tmp_dir/tree-bytes.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "error" !== ( $result["status"] ?? null ) || "tree-byte-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "tree byte limit" );
+}
+' "$tmp_dir/tree-bytes.json"
+
+php -r 'file_put_contents( $argv[1], "
" . str_repeat( "x", 1048576 ) . "
" );' "$tmp_dir/large-text.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --max-tree-bytes 64 --input "$tmp_dir/large-text.html" >"$tmp_dir/large-text.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "error" !== ( $result["status"] ?? null ) || "tree-byte-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "large text scalar limit" );
+}
+' "$tmp_dir/large-text.json"
+
+php -r 'file_put_contents( $argv[1], "
" );' "$tmp_dir/large-attribute.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --max-tree-bytes 64 --input "$tmp_dir/large-attribute.html" >"$tmp_dir/large-attribute.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "error" !== ( $result["status"] ?? null ) || "tree-byte-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "large attribute scalar limit" );
+}
+' "$tmp_dir/large-attribute.json"
+
+php -r 'file_put_contents( $argv[1], "
\xFF
" );' "$tmp_dir/invalid-utf8.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/invalid-utf8.html" >"$tmp_dir/invalid-utf8.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "unsupported" !== ( $result["status"] ?? null ) || "oracle-unsupported" !== ( $result["failureClass"] ?? null ) ) {
+ throw new Exception( "invalid UTF-8 was not explicitly unsupported" );
+}
+if ( false === strpos( $result["unsupported"]["message"] ?? "", "refusing lossy transcoding" ) ) {
+ throw new Exception( "invalid UTF-8 diagnostic" );
+}
+' "$tmp_dir/invalid-utf8.json"
+
+"$binary" --version >"$tmp_dir/version.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "ok" !== ( $result["status"] ?? null ) ) { throw new Exception( "version status" ); }
+if ( true !== ( $result["oracle"]["available"] ?? null ) ) { throw new Exception( "version availability" ); }
+if ( "0.39.0+unofficial" !== ( $result["oracle"]["markup5everRcdomVersion"] ?? null ) ) { throw new Exception( "rcdom pin" ); }
+if ( "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" !== ( $result["oracle"]["html5everChecksum"] ?? null ) ) { throw new Exception( "html5ever checksum" ); }
+if ( "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7" !== ( $result["oracle"]["markup5everRcdomChecksum"] ?? null ) ) { throw new Exception( "rcdom checksum" ); }
+$manifest = json_decode( file_get_contents( $argv[2] ), true, 512, JSON_THROW_ON_ERROR );
+$binary_hash = hash_file( "sha256", $argv[3] );
+$lock_hash = hash_file( "sha256", $argv[4] );
+if ( ! is_string( $binary_hash ) || $binary_hash !== ( $manifest["binarySha256"] ?? null ) ) { throw new Exception( "binary manifest hash" ); }
+if ( ! is_string( $lock_hash ) || $lock_hash !== ( $manifest["cargoLockSha256"] ?? null ) ) { throw new Exception( "lock manifest hash" ); }
+if ( $lock_hash !== ( $result["oracle"]["cargoLockSha256"] ?? null ) ) { throw new Exception( "embedded lock hash" ); }
+if ( ( $manifest["buildIdentity"] ?? null ) !== ( $result["oracle"]["buildIdentity"] ?? null ) ) { throw new Exception( "embedded build identity" ); }
+if ( ! is_string( $result["oracle"]["buildIdentity"] ?? null ) || 1 !== preg_match( "/^[0-9a-f]{64}$/", $result["oracle"]["buildIdentity"] ) ) { throw new Exception( "build identity format" ); }
+' "$tmp_dir/version.json" "$manifest" "$binary" "$script_dir/Cargo.lock"
+
+build_identity="$(php -r '$v=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $v["oracle"]["buildIdentity"];' "$tmp_dir/version.json")"
+cargo_lock_sha256="$(php -r '$v=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $v["oracle"]["cargoLockSha256"];' "$tmp_dir/version.json")"
+set +e
+(
+ unset HTML_API_FUZZ_HTML5EVER_BUILD_IDENTITY HTML_API_FUZZ_HTML5EVER_CARGO_LOCK_SHA256
+ CARGO_TARGET_DIR="$repo_root/.cache/html5ever/target" "$cargo_bin" check \
+ --manifest-path "$script_dir/Cargo.toml" --locked
+) >"$tmp_dir/missing-identity.out" 2>"$tmp_dir/missing-identity.err"
+missing_identity_status=$?
+set -e
+if [ "$missing_identity_status" -eq 0 ]; then
+ printf '%s\n' 'A build without injected identity unexpectedly compiled.' >&2
+ exit 1
+fi
+grep -q 'HTML_API_FUZZ_HTML5EVER_BUILD_IDENTITY' "$tmp_dir/missing-identity.err"
+grep -q 'not defined at compile time' "$tmp_dir/missing-identity.err"
+
+HTML_API_FUZZ_HTML5EVER_BUILD_IDENTITY="$build_identity" \
+HTML_API_FUZZ_HTML5EVER_CARGO_LOCK_SHA256="$cargo_lock_sha256" \
+CARGO_TARGET_DIR="$repo_root/.cache/html5ever/target" "$cargo_bin" test \
+ --manifest-path "$script_dir/Cargo.toml" --locked
+
+verify_build_pair "$binary" "$manifest"
+cp "$binary" "$tmp_dir/tampered-binary"
+cp "$manifest" "$tmp_dir/tampered-binary-manifest.json"
+printf '%s' 'tamper' >>"$tmp_dir/tampered-binary"
+if verify_build_pair "$tmp_dir/tampered-binary" "$tmp_dir/tampered-binary-manifest.json" >/dev/null 2>&1; then
+ printf '%s\n' 'Tampered binary/manifest pair was accepted.' >&2
+ exit 1
+fi
+
+cp "$binary" "$tmp_dir/tampered-manifest-binary"
+php -r '
+$manifest = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$manifest["binarySha256"] = str_repeat( "0", 64 );
+file_put_contents( $argv[2], json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n" );
+' "$manifest" "$tmp_dir/tampered-manifest.json"
+if verify_build_pair "$tmp_dir/tampered-manifest-binary" "$tmp_dir/tampered-manifest.json" >/dev/null 2>&1; then
+ printf '%s\n' 'Tampered manifest/binary pair was accepted.' >&2
+ exit 1
+fi
+
+printf '%s\n' 'OK html5ever-oracle-smoke'
diff --git a/tools/html-api-fuzz/oracles/html5ever/src/main.rs b/tools/html-api-fuzz/oracles/html5ever/src/main.rs
new file mode 100644
index 0000000000000..9d9c8c4d33ece
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/src/main.rs
@@ -0,0 +1,785 @@
+use html5ever::tendril::TendrilSink;
+use html5ever::tree_builder::TreeBuilderOpts;
+use html5ever::{parse_document, parse_fragment, ParseOpts, QualName};
+use markup5ever_rcdom::{Handle, NodeData, RcDom};
+use std::env;
+use std::fs;
+use std::io::Cursor;
+use std::process::ExitCode;
+
+const HTML5EVER_VERSION: &str = "0.39.0";
+const HTML5EVER_CHECKSUM: &str =
+ "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8";
+const RCDOM_VERSION: &str = "0.39.0+unofficial";
+const RCDOM_CHECKSUM: &str =
+ "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7";
+const RUST_TOOLCHAIN: &str = "1.88.0";
+const BUILD_IDENTITY: &str = env!("HTML_API_FUZZ_HTML5EVER_BUILD_IDENTITY");
+const CARGO_LOCK_SHA256: &str = env!("HTML_API_FUZZ_HTML5EVER_CARGO_LOCK_SHA256");
+const HTML_NS: &str = "http://www.w3.org/1999/xhtml";
+const SVG_NS: &str = "http://www.w3.org/2000/svg";
+const MATH_NS: &str = "http://www.w3.org/1998/Math/MathML";
+const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
+const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
+const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/";
+
+#[derive(Default)]
+struct Options {
+ mode: Option
,
+ context: Option,
+ input: Option,
+ max_nodes: usize,
+ max_depth: usize,
+ max_tree_bytes: usize,
+ help: bool,
+ version: bool,
+}
+
+struct RenderState {
+ tree: String,
+ node_count: usize,
+ max_nodes: usize,
+ max_depth: usize,
+ max_tree_bytes: usize,
+}
+
+#[derive(Debug)]
+struct OracleError {
+ failure_class: &'static str,
+ message: String,
+ node_count: usize,
+}
+
+fn main() -> ExitCode {
+ if !identity_is_valid(BUILD_IDENTITY, CARGO_LOCK_SHA256) {
+ print_error(
+ "oracle-identity-error",
+ "Embedded html5ever build identity is missing or malformed.",
+ 0,
+ );
+ return ExitCode::FAILURE;
+ }
+
+ let options = match parse_args(env::args().skip(1)) {
+ Ok(options) => options,
+ Err(message) => {
+ print_error("oracle-cli-error", &message, 0);
+ return ExitCode::FAILURE;
+ }
+ };
+
+ if options.help {
+ print_usage();
+ return ExitCode::SUCCESS;
+ }
+ if options.version {
+ println!(
+ "{{\"status\":\"ok\",\"oracle\":{}}}",
+ oracle_json()
+ );
+ return ExitCode::SUCCESS;
+ }
+
+ let input_path = options.input.as_deref().expect("validated input path");
+ let input = match fs::read(input_path) {
+ Ok(input) => input,
+ Err(error) => {
+ print_error(
+ "oracle-cli-error",
+ &format!("Could not read input file: {error}"),
+ 0,
+ );
+ return ExitCode::FAILURE;
+ }
+ };
+
+ match render(&options, &input) {
+ Ok((tree, node_count)) => {
+ print_ok(&tree, node_count);
+ ExitCode::SUCCESS
+ }
+ Err(error) => {
+ print_error(error.failure_class, &error.message, error.node_count);
+ ExitCode::SUCCESS
+ }
+ }
+}
+
+fn parse_args(args: impl Iterator- ) -> Result
{
+ let mut options = Options {
+ max_nodes: 3000,
+ max_depth: 512,
+ max_tree_bytes: 16_777_216,
+ ..Options::default()
+ };
+ let mut args = args.peekable();
+ let mut max_nodes_set = false;
+ let mut max_depth_set = false;
+ let mut max_tree_bytes_set = false;
+
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "--help" | "-h" => {
+ if options.help {
+ return Err("Duplicate --help option.".to_string());
+ }
+ options.help = true;
+ }
+ "--version" => {
+ if options.version {
+ return Err("Duplicate --version option.".to_string());
+ }
+ options.version = true;
+ }
+ "--mode" => set_once(&mut options.mode, next_value(&mut args, "--mode")?, "--mode")?,
+ "--context" => set_once(
+ &mut options.context,
+ next_value(&mut args, "--context")?,
+ "--context",
+ )?,
+ "--input" => set_once(
+ &mut options.input,
+ next_value(&mut args, "--input")?,
+ "--input",
+ )?,
+ "--max-nodes" => {
+ if max_nodes_set {
+ return Err("Duplicate --max-nodes option.".to_string());
+ }
+ max_nodes_set = true;
+ let value = next_value(&mut args, "--max-nodes")?;
+ options.max_nodes = value.parse::().map_err(|_| {
+ "Expected --max-nodes to be a positive integer.".to_string()
+ })?;
+ if options.max_nodes == 0 {
+ return Err("Expected --max-nodes to be a positive integer.".to_string());
+ }
+ }
+ "--max-depth" => {
+ if max_depth_set {
+ return Err("Duplicate --max-depth option.".to_string());
+ }
+ max_depth_set = true;
+ let value = next_value(&mut args, "--max-depth")?;
+ options.max_depth = value.parse::().map_err(|_| {
+ "Expected --max-depth to be a positive integer.".to_string()
+ })?;
+ if options.max_depth == 0 {
+ return Err("Expected --max-depth to be a positive integer.".to_string());
+ }
+ }
+ "--max-tree-bytes" => {
+ if max_tree_bytes_set {
+ return Err("Duplicate --max-tree-bytes option.".to_string());
+ }
+ max_tree_bytes_set = true;
+ let value = next_value(&mut args, "--max-tree-bytes")?;
+ options.max_tree_bytes = value.parse::().map_err(|_| {
+ "Expected --max-tree-bytes to be a positive integer.".to_string()
+ })?;
+ if options.max_tree_bytes == 0 {
+ return Err("Expected --max-tree-bytes to be a positive integer.".to_string());
+ }
+ }
+ _ => return Err(format!("Unknown option: {arg}")),
+ }
+ }
+
+ if options.help || options.version {
+ if options.help && options.version {
+ return Err("--help and --version cannot be combined.".to_string());
+ }
+ if options.mode.is_some()
+ || options.context.is_some()
+ || options.input.is_some()
+ || max_nodes_set
+ || max_depth_set
+ || max_tree_bytes_set
+ {
+ let flag = if options.help { "--help" } else { "--version" };
+ return Err(format!("{flag} cannot be combined with parse options."));
+ }
+ return Ok(options);
+ }
+
+ let mode = options
+ .mode
+ .as_deref()
+ .ok_or_else(|| "Missing --mode.".to_string())?;
+ if mode != "full-document" && !mode.starts_with("fragment-") {
+ return Err("Expected --mode full-document or fragment-*.".to_string());
+ }
+ if options.input.is_none() {
+ return Err("Missing --input.".to_string());
+ }
+
+ Ok(options)
+}
+
+fn set_once(slot: &mut Option, value: String, option: &str) -> Result<(), String> {
+ if slot.is_some() {
+ return Err(format!("Duplicate {option} option."));
+ }
+ *slot = Some(value);
+ Ok(())
+}
+
+fn next_value(
+ args: &mut std::iter::Peekable>,
+ option: &str,
+) -> Result {
+ args.next()
+ .ok_or_else(|| format!("Missing value for {option}."))
+}
+
+fn print_usage() {
+ println!(
+ "Usage: html5ever-tree-oracle --mode full-document|fragment-* --input PATH [--context TAG] [--max-nodes N] [--max-depth N] [--max-tree-bytes N]"
+ );
+}
+
+fn render(options: &Options, input: &[u8]) -> Result<(String, usize), OracleError> {
+ if std::str::from_utf8(input).is_err() {
+ return Err(OracleError {
+ failure_class: "oracle-unsupported",
+ message: "html5ever source input is not valid UTF-8; refusing lossy transcoding."
+ .to_string(),
+ node_count: 0,
+ });
+ }
+ let mode = options.mode.as_deref().expect("validated mode");
+ let (dom, is_fragment) = if mode == "full-document" {
+ (parse_document_bytes(input)?, false)
+ } else {
+ let context = options
+ .context
+ .as_deref()
+ .or_else(|| mode.strip_prefix("fragment-"))
+ .unwrap_or("body");
+ (parse_fragment_bytes(input, context)?, true)
+ };
+
+ let mut state = RenderState {
+ tree: String::new(),
+ node_count: 0,
+ max_nodes: options.max_nodes,
+ max_depth: options.max_depth,
+ max_tree_bytes: options.max_tree_bytes,
+ };
+ if is_fragment {
+ render_fragment_children(&dom, &mut state)?;
+ } else {
+ render_children(&dom.document, 0, &mut state)?;
+ }
+ append_output(&mut state, "\n")?;
+ Ok((state.tree, state.node_count))
+}
+
+fn render_fragment_children(dom: &RcDom, state: &mut RenderState) -> Result<(), OracleError> {
+ // RcDom attaches fragment output below a synthetic HTML document element.
+ // That implementation detail is not part of the fragment result and must
+ // not affect either the canonical tree or its node count.
+ let container = dom
+ .document
+ .children
+ .borrow()
+ .iter()
+ .find(|node| {
+ matches!(
+ &node.data,
+ NodeData::Element { name, .. }
+ if name.ns.as_ref() == HTML_NS && name.local.as_ref() == "html"
+ )
+ })
+ .cloned();
+
+ match container {
+ Some(container) => render_children(&container, 0, state),
+ None => render_children(&dom.document, 0, state),
+ }
+}
+
+fn parse_document_bytes(input: &[u8]) -> Result {
+ parse_document(RcDom::default(), scripting_disabled_parse_options())
+ .from_utf8()
+ .read_from(&mut Cursor::new(input))
+ .map_err(|error| OracleError {
+ failure_class: "oracle-parse-error",
+ message: format!("html5ever could not read the input: {error}"),
+ node_count: 0,
+ })
+}
+
+fn parse_fragment_bytes(input: &[u8], context: &str) -> Result {
+ let context_name = context_qual_name(context).ok_or_else(|| OracleError {
+ failure_class: "oracle-unsupported",
+ message: format!("Unsupported fragment context: {context}"),
+ node_count: 0,
+ })?;
+
+ parse_fragment(
+ RcDom::default(),
+ scripting_disabled_parse_options(),
+ context_name,
+ Vec::new(),
+ false,
+ )
+ .from_utf8()
+ .read_from(&mut Cursor::new(input))
+ .map_err(|error| OracleError {
+ failure_class: "oracle-parse-error",
+ message: format!("html5ever could not read the fragment: {error}"),
+ node_count: 0,
+ })
+}
+
+fn scripting_disabled_parse_options() -> ParseOpts {
+ ParseOpts {
+ tree_builder: TreeBuilderOpts {
+ scripting_enabled: false,
+ ..TreeBuilderOpts::default()
+ },
+ ..ParseOpts::default()
+ }
+}
+
+fn context_qual_name(context: &str) -> Option {
+ let lower = context.to_ascii_lowercase();
+ let namespace = match lower.as_str() {
+ "body" | "div" | "p" | "td" | "tr" | "table" | "caption" | "colgroup"
+ | "select" | "option" | "template" | "title" | "textarea" | "script"
+ | "style" => HTML_NS,
+ "svg" => SVG_NS,
+ "math" => MATH_NS,
+ _ => return None,
+ };
+
+ Some(QualName::new(
+ None,
+ namespace.into(),
+ lower.as_str().into(),
+ ))
+}
+
+fn render_children(
+ parent: &Handle,
+ indent: usize,
+ state: &mut RenderState,
+) -> Result<(), OracleError> {
+ for child in parent.children.borrow().iter() {
+ render_node(child, indent, state)?;
+ }
+ Ok(())
+}
+
+fn render_node(node: &Handle, indent: usize, state: &mut RenderState) -> Result<(), OracleError> {
+ state.node_count = state.node_count.saturating_add(1);
+ if state.node_count > state.max_nodes {
+ return Err(OracleError {
+ failure_class: "node-limit-exceeded",
+ message: "DOM node limit exceeded.".to_string(),
+ node_count: state.node_count,
+ });
+ }
+ if indent > state.max_depth {
+ return Err(OracleError {
+ failure_class: "depth-limit-exceeded",
+ message: "DOM tree depth limit exceeded.".to_string(),
+ node_count: state.node_count,
+ });
+ }
+
+ match &node.data {
+ NodeData::Document => render_children(node, indent, state),
+ NodeData::Doctype {
+ name,
+ public_id,
+ system_id,
+ } => {
+ append_output(state, "\n")?;
+ Ok(())
+ }
+ NodeData::Text { contents } => {
+ let contents = contents.borrow();
+ if !contents.is_empty() {
+ push_indent(indent, state)?;
+ append_output(state, "\"")?;
+ append_escaped_scalar(state, contents.as_ref())?;
+ append_output(state, "\"\n")?;
+ }
+ Ok(())
+ }
+ NodeData::Comment { contents } => {
+ push_indent(indent, state)?;
+ append_output(state, "\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/oracles/lexbor/README.md b/tools/html-api-fuzz/oracles/lexbor/README.md
new file mode 100644
index 0000000000000..6b5c0cb3bb43d
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/README.md
@@ -0,0 +1,74 @@
+# Lexbor Source Oracle
+
+This directory contains a standalone oracle binary for comparing the WordPress
+HTML API against a source-built Lexbor checkout instead of PHP's bundled
+`Dom\HTMLDocument` runtime.
+
+Build upstream `master`:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+The script clones Lexbor under `.cache/lexbor/[/source`, resolves the ref,
+and builds/installs the static library under
+`.cache/lexbor/commits/]/`. It refuses a dirty source checkout,
+then writes:
+
+```text
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle
+tools/html-api-fuzz/oracles/lexbor/build/build-manifest.json
+```
+
+Use it from the fuzzer by selecting the non-default oracle:
+
+```sh
+php tools/html-api-fuzz/worker.php \
+ --seed 1 \
+ --dom-oracle lexbor-source \
+ --output-dir artifacts/html-api-fuzz/seed-1-lexbor
+
+php tools/html-api-fuzz/runner.php \
+ --max-seeds 100 \
+ --dom-oracle lexbor-source
+```
+
+Pass `--lexbor-oracle-bin PATH` or set `HTML_API_FUZZ_LEXBOR_ORACLE` when the
+binary is not at the default build path above. Replays preserve the selected
+oracle and binary path.
+
+The binary records the resolved Lexbor commit in its JSON metadata, even when
+building from a moving ref such as `master`. The manifest additionally records
+the requested ref, upstream URL, timestamp, compiler, CMake version, and binary
+SHA-256. The PHP adapter includes the manifest and verifies the executable hash
+in run/replay metadata.
+
+Use a different checkout or commit when bisecting upstream behavior:
+
+```sh
+LEXBOR_SOURCE_DIR=/path/to/lexbor \
+LEXBOR_COMMIT=481c444261a132190a3fb746d6d2f60824af3717 \
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+Direct CLI examples:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle \
+ --mode full-document \
+ --max-nodes 3000 \
+ --max-depth 512 \
+ --max-tree-bytes 16777216 \
+ --input /path/to/input.bin
+
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle \
+ --mode fragment-body \
+ --context body \
+ --max-nodes 3000 \
+ --input /path/to/input.bin
+```
+
+The oracle returns JSON with `status`, `oracle` metadata, `tree`,
+`treeBase64`, and `nodeCount`. The `treeBase64` field is the exact
+html5lib-style tree bytes consumed by the PHP adapter; `tree` is the same tree
+as a JSON-safe display string. Neither field is serialized HTML.
diff --git a/tools/html-api-fuzz/oracles/lexbor/build.sh b/tools/html-api-fuzz/oracles/lexbor/build.sh
new file mode 100755
index 0000000000000..286a9278ce0f1
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/build.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env sh
+set -eu
+
+requested_ref="${LEXBOR_COMMIT:-master}"
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+cache_root="${LEXBOR_CACHE_DIR:-$repo_root/.cache/lexbor}"
+cache_dir="$cache_root/$requested_ref"
+source_dir="${LEXBOR_SOURCE_DIR:-$cache_dir/source}"
+oracle_build_dir="$script_dir/build"
+oracle_bin="$oracle_build_dir/lexbor-tree-oracle"
+manifest_path="$oracle_build_dir/build-manifest.json"
+upstream_url="https://github.com/lexbor/lexbor.git"
+
+if [ ! -d "$source_dir/.git" ]; then
+ mkdir -p "$(dirname "$source_dir")"
+ git clone "$upstream_url" "$source_dir"
+fi
+
+if [ -n "$(git -C "$source_dir" status --porcelain --untracked-files=normal)" ]; then
+ printf '%s\n' "Refusing to build from a dirty Lexbor source checkout: $source_dir" >&2
+ exit 1
+fi
+
+git -C "$source_dir" fetch --tags origin
+
+checkout_ref="$requested_ref"
+if git -C "$source_dir" rev-parse --verify --quiet "origin/$requested_ref^{commit}" >/dev/null; then
+ checkout_ref="origin/$requested_ref"
+fi
+git -C "$source_dir" checkout --detach "$checkout_ref"
+commit="$(git -C "$source_dir" rev-parse HEAD)"
+if [ -n "$(git -C "$source_dir" status --porcelain --untracked-files=normal)" ]; then
+ printf '%s\n' "Refusing to build from a dirty Lexbor source checkout: $source_dir" >&2
+ exit 1
+fi
+
+commit_cache="$cache_root/commits/$commit"
+build_dir="${LEXBOR_BUILD_DIR:-$commit_cache/build}"
+install_dir="${LEXBOR_INSTALL_DIR:-$commit_cache/install}"
+
+cmake -S "$source_dir" -B "$build_dir" \
+ -DLEXBOR_BUILD_SHARED=OFF \
+ -DLEXBOR_BUILD_STATIC=ON \
+ -DLEXBOR_BUILD_SEPARATELY=OFF \
+ -DLEXBOR_BUILD_EXAMPLES=OFF \
+ -DLEXBOR_BUILD_TESTS=OFF \
+ -DLEXBOR_BUILD_UTILS=OFF \
+ -DCMAKE_INSTALL_PREFIX="$install_dir"
+
+cmake --build "$build_dir" --target lexbor_static
+cmake --install "$build_dir" --prefix "$install_dir"
+
+mkdir -p "$oracle_build_dir"
+
+cc ${CFLAGS:-} \
+ -std=c99 \
+ -Wall \
+ -Wextra \
+ -Werror \
+ -I"$install_dir/include" \
+ -DHTML_API_FUZZ_LEXBOR_COMMIT="\"$commit\"" \
+ "$script_dir/lexbor-tree-oracle.c" \
+ "$install_dir/lib/liblexbor_static.a" \
+ -o "$oracle_bin" \
+ ${LDFLAGS:-}
+
+binary_sha256="$(shasum -a 256 "$oracle_bin" | awk '{print $1}')"
+compiler_version="$(cc --version 2>/dev/null | sed -n '1p')"
+cmake_version="$(cmake --version 2>/dev/null | sed -n '1p')"
+php -r '
+$manifest = array(
+ "kind" => "html-api-fuzz-lexbor-build",
+ "requestedRef" => $argv[1],
+ "resolvedCommit" => $argv[2],
+ "upstream" => $argv[3],
+ "builtAt" => gmdate("c"),
+ "binarySha256" => $argv[4],
+ "compiler" => $argv[5],
+ "cmake" => $argv[6],
+);
+$json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+if (false === $json || strlen($json) + 1 !== file_put_contents($argv[7], $json . "\n")) {
+ fwrite(STDERR, "Could not write Lexbor build manifest.\n");
+ exit(1);
+}
+' "$requested_ref" "$commit" "$upstream_url" "$binary_sha256" "$compiler_version" "$cmake_version" "$manifest_path"
+
+printf '%s\n' "$oracle_bin"
diff --git a/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c b/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c
new file mode 100644
index 0000000000000..89bbc28537d39
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c
@@ -0,0 +1,1296 @@
+/*
+ * Source-built Lexbor tree oracle for the HTML API fuzzer.
+ *
+ * Parses one input with Lexbor and emits a JSON result whose "tree" field uses
+ * the same html5lib-style text format as HtmlApiFuzz\TreeRenderer.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#ifndef HTML_API_FUZZ_LEXBOR_COMMIT
+#define HTML_API_FUZZ_LEXBOR_COMMIT "unknown"
+#endif
+
+typedef struct {
+ char *data;
+ size_t length;
+ size_t capacity;
+ bool failed;
+} buffer_t;
+
+typedef struct {
+ char *sort_name;
+ char *render_name;
+ char *value;
+} attr_record_t;
+
+typedef enum {
+ ORACLE_OK,
+ ORACLE_UNSUPPORTED,
+ ORACLE_ERROR,
+} oracle_status_t;
+
+typedef struct {
+ oracle_status_t status;
+ const char *failure_class;
+ const char *message;
+ buffer_t tree;
+ size_t node_count;
+ size_t max_nodes;
+ size_t max_depth;
+ size_t max_tree_bytes;
+} render_ctx_t;
+
+typedef struct {
+ const char *mode;
+ const char *context;
+ const char *input_path;
+ size_t max_nodes;
+ size_t max_depth;
+ size_t max_tree_bytes;
+ bool show_help;
+ bool show_version;
+} cli_options_t;
+
+static void buffer_init(buffer_t *buf);
+static void buffer_destroy(buffer_t *buf);
+static bool buffer_reserve(buffer_t *buf, size_t extra);
+static bool buffer_append_mem(buffer_t *buf, const char *data, size_t len);
+static bool buffer_append_cstr(buffer_t *buf, const char *data);
+static bool buffer_append_char(buffer_t *buf, char ch);
+static bool buffer_append_repeat(buffer_t *buf, const char *data, size_t len, size_t count);
+static char *buffer_take_cstr(buffer_t *buf);
+static bool append_escaped_scalar(buffer_t *buf, const lxb_char_t *data, size_t len, bool scrub);
+static bool append_json_string(buffer_t *buf, const char *data, size_t len);
+static bool append_json_base64(buffer_t *buf, const char *data, size_t len);
+static bool append_tree_line_indent(buffer_t *buf, int indent_level);
+static bool append_display_element_name(buffer_t *buf, lxb_dom_element_t *element);
+static bool append_escaped_display_element_name(buffer_t *buf, lxb_dom_element_t *element);
+static bool append_display_attribute_name(buffer_t *buf, lxb_dom_attr_t *attr);
+static int compare_attr_records(const void *a_ptr, const void *b_ptr);
+static bool render_attributes(render_ctx_t *ctx, lxb_dom_element_t *element, int indent_level);
+static void destroy_attr_records(attr_record_t *records, size_t count);
+static void render_node(render_ctx_t *ctx, lxb_dom_node_t *node, int indent_level);
+static void render_children(render_ctx_t *ctx, lxb_dom_node_t *first, int indent_level);
+static bool read_file(const char *path, lxb_char_t **data, size_t *len, const char **message);
+static bool parse_size(const char *value, size_t *out);
+static bool parse_args(int argc, char **argv, cli_options_t *options, const char **message);
+static void print_usage(FILE *stream);
+static void print_version(void);
+static void print_result(render_ctx_t *ctx);
+static void print_cli_error(const char *message);
+static bool context_to_tag(const char *context, lxb_tag_id_t *tag_id, lxb_ns_id_t *ns_id);
+static void render_full_document(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len);
+static void render_fragment(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len, const char *context);
+
+static void
+buffer_init(buffer_t *buf)
+{
+ buf->data = NULL;
+ buf->length = 0;
+ buf->capacity = 0;
+ buf->failed = false;
+}
+
+static void
+buffer_destroy(buffer_t *buf)
+{
+ free(buf->data);
+ buffer_init(buf);
+}
+
+static bool
+buffer_reserve(buffer_t *buf, size_t extra)
+{
+ size_t needed;
+ size_t next_capacity;
+ char *next;
+
+ if (buf->failed) {
+ return false;
+ }
+
+ if (extra > SIZE_MAX - buf->length - 1) {
+ buf->failed = true;
+ return false;
+ }
+
+ needed = buf->length + extra + 1;
+ if (needed <= buf->capacity) {
+ return true;
+ }
+
+ next_capacity = buf->capacity == 0 ? 256 : buf->capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > SIZE_MAX / 2) {
+ next_capacity = needed;
+ break;
+ }
+ next_capacity *= 2;
+ }
+
+ next = (char *) realloc(buf->data, next_capacity);
+ if (next == NULL) {
+ buf->failed = true;
+ return false;
+ }
+
+ buf->data = next;
+ buf->capacity = next_capacity;
+ buf->data[buf->length] = '\0';
+ return true;
+}
+
+static bool
+buffer_append_mem(buffer_t *buf, const char *data, size_t len)
+{
+ if (!buffer_reserve(buf, len)) {
+ return false;
+ }
+
+ if (len > 0) {
+ memcpy(buf->data + buf->length, data, len);
+ buf->length += len;
+ }
+
+ buf->data[buf->length] = '\0';
+ return true;
+}
+
+static bool
+buffer_append_cstr(buffer_t *buf, const char *data)
+{
+ return buffer_append_mem(buf, data, strlen(data));
+}
+
+static bool
+buffer_append_char(buffer_t *buf, char ch)
+{
+ return buffer_append_mem(buf, &ch, 1);
+}
+
+static bool
+buffer_append_repeat(buffer_t *buf, const char *data, size_t len, size_t count)
+{
+ size_t i;
+
+ for (i = 0; i < count; i++) {
+ if (!buffer_append_mem(buf, data, len)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static char *
+buffer_take_cstr(buffer_t *buf)
+{
+ char *data;
+
+ if (!buffer_reserve(buf, 0)) {
+ return NULL;
+ }
+
+ data = buf->data;
+ buf->data = NULL;
+ buf->length = 0;
+ buf->capacity = 0;
+ return data;
+}
+
+static bool
+append_escaped_byte(buffer_t *buf, unsigned char byte)
+{
+ char hex[5];
+
+ switch (byte) {
+ case '\n':
+ return buffer_append_cstr(buf, "\\n");
+ case '\r':
+ return buffer_append_cstr(buf, "\\r");
+ case '\t':
+ return buffer_append_cstr(buf, "\\t");
+ case '\0':
+ return buffer_append_cstr(buf, "\\0");
+ case '\\':
+ return buffer_append_cstr(buf, "\\\\");
+ case '"':
+ return buffer_append_cstr(buf, "\\\"");
+ default:
+ if (byte < 0x20 || byte == 0x7f) {
+ snprintf(hex, sizeof(hex), "\\x%02X", byte);
+ return buffer_append_cstr(buf, hex);
+ }
+ return buffer_append_char(buf, (char) byte);
+ }
+}
+
+static bool
+append_escaped_scalar(buffer_t *buf, const lxb_char_t *data, size_t len, bool scrub)
+{
+ size_t i;
+ static const char replacement[] = "\xEF\xBF\xBD";
+
+ if (data == NULL) {
+ len = 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+
+ if (scrub) {
+ if (byte == '\0') {
+ if (!buffer_append_mem(buf, replacement, sizeof(replacement) - 1)) {
+ return false;
+ }
+ continue;
+ }
+
+ if (byte == '\r') {
+ if (i + 1 < len && data[i + 1] == '\n') {
+ i++;
+ }
+ byte = '\n';
+ }
+ }
+
+ if (!append_escaped_byte(buf, byte)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool
+append_json_string(buffer_t *buf, const char *data, size_t len)
+{
+ size_t i;
+ char hex[7];
+ static const char replacement[] = "\\uFFFD";
+
+ if (!buffer_append_char(buf, '"')) {
+ return false;
+ }
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+
+ switch (byte) {
+ case '"':
+ if (!buffer_append_cstr(buf, "\\\"")) {
+ return false;
+ }
+ break;
+ case '\\':
+ if (!buffer_append_cstr(buf, "\\\\")) {
+ return false;
+ }
+ break;
+ case '\b':
+ if (!buffer_append_cstr(buf, "\\b")) {
+ return false;
+ }
+ break;
+ case '\f':
+ if (!buffer_append_cstr(buf, "\\f")) {
+ return false;
+ }
+ break;
+ case '\n':
+ if (!buffer_append_cstr(buf, "\\n")) {
+ return false;
+ }
+ break;
+ case '\r':
+ if (!buffer_append_cstr(buf, "\\r")) {
+ return false;
+ }
+ break;
+ case '\t':
+ if (!buffer_append_cstr(buf, "\\t")) {
+ return false;
+ }
+ break;
+ default:
+ if (byte < 0x20) {
+ snprintf(hex, sizeof(hex), "\\u%04X", byte);
+ if (!buffer_append_cstr(buf, hex)) {
+ return false;
+ }
+ } else if (byte < 0x80) {
+ if (!buffer_append_char(buf, (char) byte)) {
+ return false;
+ }
+ } else {
+ size_t sequence_len = 0;
+ bool valid = false;
+
+ if (byte >= 0xC2 && byte <= 0xDF) {
+ sequence_len = 2;
+ } else if (byte >= 0xE0 && byte <= 0xEF) {
+ sequence_len = 3;
+ } else if (byte >= 0xF0 && byte <= 0xF4) {
+ sequence_len = 4;
+ }
+
+ if (sequence_len > 0 && i + sequence_len <= len) {
+ unsigned char b1 = sequence_len > 1 ? (unsigned char) data[i + 1] : 0;
+ unsigned char b2 = sequence_len > 2 ? (unsigned char) data[i + 2] : 0;
+ unsigned char b3 = sequence_len > 3 ? (unsigned char) data[i + 3] : 0;
+ valid = true;
+ if (sequence_len >= 2 && (b1 < 0x80 || b1 > 0xBF)) {
+ valid = false;
+ }
+ if (sequence_len >= 3 && (b2 < 0x80 || b2 > 0xBF)) {
+ valid = false;
+ }
+ if (sequence_len >= 4 && (b3 < 0x80 || b3 > 0xBF)) {
+ valid = false;
+ }
+ if (byte == 0xE0 && b1 < 0xA0) {
+ valid = false;
+ }
+ if (byte == 0xED && b1 > 0x9F) {
+ valid = false;
+ }
+ if (byte == 0xF0 && b1 < 0x90) {
+ valid = false;
+ }
+ if (byte == 0xF4 && b1 > 0x8F) {
+ valid = false;
+ }
+ }
+
+ if (valid) {
+ if (!buffer_append_mem(buf, data + i, sequence_len)) {
+ return false;
+ }
+ i += sequence_len - 1;
+ } else if (!buffer_append_cstr(buf, replacement)) {
+ return false;
+ }
+ }
+ break;
+ }
+ }
+
+ return buffer_append_char(buf, '"');
+}
+
+static bool
+append_json_base64(buffer_t *buf, const char *data, size_t len)
+{
+ static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t i;
+
+ if (!buffer_append_char(buf, '"')) {
+ return false;
+ }
+
+ for (i = 0; i < len; i += 3) {
+ unsigned int b0 = (unsigned char) data[i];
+ unsigned int b1 = i + 1 < len ? (unsigned char) data[i + 1] : 0;
+ unsigned int b2 = i + 2 < len ? (unsigned char) data[i + 2] : 0;
+ char encoded[4];
+
+ encoded[0] = alphabet[b0 >> 2];
+ encoded[1] = alphabet[((b0 & 0x03) << 4) | (b1 >> 4)];
+ encoded[2] = i + 1 < len ? alphabet[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
+ encoded[3] = i + 2 < len ? alphabet[b2 & 0x3F] : '=';
+
+ if (!buffer_append_mem(buf, encoded, sizeof(encoded))) {
+ return false;
+ }
+ }
+
+ return buffer_append_char(buf, '"');
+}
+
+static bool
+append_tree_line_indent(buffer_t *buf, int indent_level)
+{
+ return buffer_append_repeat(buf, " ", 2, (size_t) indent_level);
+}
+
+static bool
+append_ascii_lower(buffer_t *buf, const lxb_char_t *data, size_t len)
+{
+ size_t i;
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+ if (byte >= 'A' && byte <= 'Z') {
+ byte = (unsigned char) tolower(byte);
+ }
+ if (!buffer_append_char(buf, (char) byte)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool
+append_display_element_name(buffer_t *buf, lxb_dom_element_t *element)
+{
+ size_t len = 0;
+ const lxb_char_t *name;
+ lxb_ns_id_t ns = lxb_dom_element_ns_id(element);
+
+ name = lxb_dom_element_local_name(element, &len);
+ if (ns == LXB_NS_HTML) {
+ return append_ascii_lower(buf, name, len);
+ }
+
+ if (ns == LXB_NS_SVG) {
+ name = lxb_dom_element_qualified_name(element, &len);
+ return buffer_append_cstr(buf, "svg ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_MATH) {
+ return buffer_append_cstr(buf, "math ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ name = lxb_dom_element_qualified_name(element, &len);
+ return buffer_append_mem(buf, (const char *) name, len);
+}
+
+static bool
+append_escaped_display_element_name(buffer_t *buf, lxb_dom_element_t *element)
+{
+ buffer_t display;
+ bool ok;
+
+ buffer_init(&display);
+ ok = append_display_element_name(&display, element)
+ && append_escaped_scalar(buf, (const lxb_char_t *) display.data, display.length, false);
+ buffer_destroy(&display);
+
+ return ok;
+}
+
+static bool
+append_display_attribute_name(buffer_t *buf, lxb_dom_attr_t *attr)
+{
+ size_t len = 0;
+ const lxb_char_t *name;
+ lxb_ns_id_t ns = (lxb_ns_id_t) lxb_dom_interface_node(attr)->ns;
+
+ if (ns == LXB_NS_XLINK) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xlink ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_XML) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xml ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_XMLNS) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xmlns ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ name = lxb_dom_attr_qualified_name(attr, &len);
+ return buffer_append_mem(buf, (const char *) name, len);
+}
+
+static int
+compare_display_names(const char *a, const char *b)
+{
+ bool a_has_colon = strchr(a, ':') != NULL;
+ bool b_has_colon = strchr(b, ':') != NULL;
+ bool a_has_space = strchr(a, ' ') != NULL;
+ bool b_has_space = strchr(b, ' ') != NULL;
+ int compared;
+
+ if (a_has_colon != b_has_colon) {
+ return a_has_colon ? 1 : -1;
+ }
+
+ if (a_has_space != b_has_space) {
+ return a_has_space ? 1 : -1;
+ }
+
+ compared = strcmp(a, b);
+ if (compared < 0) {
+ return -1;
+ }
+ if (compared > 0) {
+ return 1;
+ }
+ return 0;
+}
+
+static int
+compare_attr_records(const void *a_ptr, const void *b_ptr)
+{
+ const attr_record_t *a = (const attr_record_t *) a_ptr;
+ const attr_record_t *b = (const attr_record_t *) b_ptr;
+ int compared = compare_display_names(a->sort_name, b->sort_name);
+
+ if (compared != 0) {
+ return compared;
+ }
+
+ return compare_display_names(a->render_name, b->render_name);
+}
+
+static bool
+render_attributes(render_ctx_t *ctx, lxb_dom_element_t *element, int indent_level)
+{
+ lxb_dom_attr_t *attr;
+ attr_record_t *records = NULL;
+ size_t count = 0;
+ size_t index = 0;
+ size_t i;
+ bool ok = false;
+
+ for (attr = lxb_dom_element_first_attribute(element); attr != NULL; attr = lxb_dom_element_next_attribute(attr)) {
+ count++;
+ }
+
+ if (count == 0) {
+ return true;
+ }
+
+ records = (attr_record_t *) calloc(count, sizeof(attr_record_t));
+ if (records == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not allocate attribute records.";
+ return false;
+ }
+
+ for (attr = lxb_dom_element_first_attribute(element); attr != NULL; attr = lxb_dom_element_next_attribute(attr)) {
+ buffer_t display;
+ buffer_t sort;
+ buffer_t render;
+ buffer_t value;
+ size_t value_len = 0;
+ const lxb_char_t *value_data;
+
+ buffer_init(&display);
+ buffer_init(&sort);
+ buffer_init(&render);
+ buffer_init(&value);
+
+ value_data = lxb_dom_attr_value(attr, &value_len);
+ if (
+ !append_display_attribute_name(&display, attr) ||
+ !append_escaped_scalar(&sort, (const lxb_char_t *) display.data, display.length, true) ||
+ !append_escaped_scalar(&render, (const lxb_char_t *) display.data, display.length, false) ||
+ !append_escaped_scalar(&value, value_data, value_len, false)
+ ) {
+ buffer_destroy(&display);
+ buffer_destroy(&sort);
+ buffer_destroy(&render);
+ buffer_destroy(&value);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render attributes.";
+ goto cleanup;
+ }
+
+ records[index].sort_name = buffer_take_cstr(&sort);
+ records[index].render_name = buffer_take_cstr(&render);
+ records[index].value = buffer_take_cstr(&value);
+
+ buffer_destroy(&display);
+ buffer_destroy(&sort);
+ buffer_destroy(&render);
+ buffer_destroy(&value);
+
+ if (records[index].sort_name == NULL || records[index].render_name == NULL || records[index].value == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not store attribute records.";
+ goto cleanup;
+ }
+
+ index++;
+ }
+
+ qsort(records, count, sizeof(attr_record_t), compare_attr_records);
+
+ for (i = 0; i < count; i++) {
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_cstr(&ctx->tree, records[i].render_name) ||
+ !buffer_append_cstr(&ctx->tree, "=\"") ||
+ !buffer_append_cstr(&ctx->tree, records[i].value) ||
+ !buffer_append_cstr(&ctx->tree, "\"\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not append attribute lines.";
+ goto cleanup;
+ }
+ }
+
+ ok = true;
+
+cleanup:
+ destroy_attr_records(records, count);
+ return ok;
+}
+
+static void
+destroy_attr_records(attr_record_t *records, size_t count)
+{
+ size_t i;
+
+ if (records == NULL) {
+ return;
+ }
+
+ for (i = 0; i < count; i++) {
+ free(records[i].sort_name);
+ free(records[i].render_name);
+ free(records[i].value);
+ }
+
+ free(records);
+}
+
+static bool
+increment_node_count(render_ctx_t *ctx)
+{
+ ctx->node_count++;
+ if (ctx->node_count > ctx->max_nodes) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "node-limit-exceeded";
+ ctx->message = "DOM node limit exceeded.";
+ return false;
+ }
+
+ return true;
+}
+
+static void
+render_node(render_ctx_t *ctx, lxb_dom_node_t *node, int indent_level)
+{
+ if (ctx->status != ORACLE_OK || node == NULL) {
+ return;
+ }
+ if (indent_level < 0 || (size_t) indent_level > ctx->max_depth) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "depth-limit-exceeded";
+ ctx->message = "DOM tree depth limit exceeded.";
+ return;
+ }
+
+ if (!increment_node_count(ctx)) {
+ return;
+ }
+
+ switch (node->type) {
+ case LXB_DOM_NODE_TYPE_DOCUMENT_TYPE: {
+ lxb_dom_document_type_t *doctype = lxb_dom_interface_document_type(node);
+ size_t name_len = 0;
+ size_t public_len = 0;
+ size_t system_len = 0;
+ const lxb_char_t *name = lxb_dom_document_type_name(doctype, &name_len);
+ const lxb_char_t *public_id = lxb_dom_document_type_public_id(doctype, &public_len);
+ const lxb_char_t *system_id = lxb_dom_document_type_system_id(doctype, &system_len);
+
+ if (
+ !buffer_append_cstr(&ctx->tree, "tree, name, name_len, false)
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render doctype.";
+ return;
+ }
+
+ if (public_len > 0 || system_len > 0) {
+ if (
+ !buffer_append_cstr(&ctx->tree, " \"") ||
+ !append_escaped_scalar(&ctx->tree, public_id, public_len, false) ||
+ !buffer_append_cstr(&ctx->tree, "\" \"") ||
+ !append_escaped_scalar(&ctx->tree, system_id, system_len, false) ||
+ !buffer_append_char(&ctx->tree, '"')
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render doctype identifiers.";
+ return;
+ }
+ }
+
+ if (!buffer_append_cstr(&ctx->tree, ">\n")) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not finish doctype.";
+ }
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_ELEMENT: {
+ lxb_dom_element_t *element = lxb_dom_interface_element(node);
+
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_char(&ctx->tree, '<') ||
+ !append_escaped_display_element_name(&ctx->tree, element) ||
+ !buffer_append_cstr(&ctx->tree, ">\n") ||
+ !render_attributes(ctx, element, indent_level + 1)
+ ) {
+ if (ctx->status == ORACLE_OK) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render element.";
+ }
+ return;
+ }
+
+ if (node->local_name == LXB_TAG_TEMPLATE && node->ns == LXB_NS_HTML) {
+ lxb_html_template_element_t *template_element = lxb_html_interface_template(node);
+ if (!append_tree_line_indent(&ctx->tree, indent_level + 1) || !buffer_append_cstr(&ctx->tree, "content\n")) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render template content marker.";
+ return;
+ }
+ if (template_element->content != NULL) {
+ render_children(ctx, template_element->content->node.first_child, indent_level + 2);
+ }
+ return;
+ }
+
+ render_children(ctx, node->first_child, indent_level + 1);
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_TEXT:
+ case LXB_DOM_NODE_TYPE_CDATA_SECTION: {
+ lxb_dom_character_data_t *character_data = lxb_dom_interface_character_data(node);
+ if (character_data->data.length == 0) {
+ return;
+ }
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_char(&ctx->tree, '"') ||
+ !append_escaped_scalar(&ctx->tree, character_data->data.data, character_data->data.length, false) ||
+ !buffer_append_cstr(&ctx->tree, "\"\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render text.";
+ }
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_COMMENT: {
+ lxb_dom_character_data_t *character_data = lxb_dom_interface_character_data(node);
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_cstr(&ctx->tree, "\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render comment.";
+ }
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_PROCESSING_INSTRUCTION: {
+ lxb_dom_processing_instruction_t *pi = lxb_dom_interface_processing_instruction(node);
+ lxb_dom_character_data_t *character_data = lxb_dom_interface_character_data(node);
+ size_t target_len = 0;
+ const lxb_char_t *target = lxb_dom_processing_instruction_target(pi, &target_len);
+
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_cstr(&ctx->tree, "") ||
+ !append_escaped_scalar(&ctx->tree, target, target_len, false) ||
+ !buffer_append_char(&ctx->tree, ' ') ||
+ !append_escaped_scalar(&ctx->tree, character_data->data.data, character_data->data.length, false) ||
+ !buffer_append_cstr(&ctx->tree, "?>\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render processing instruction.";
+ }
+ return;
+ }
+
+ default:
+ return;
+ }
+}
+
+static void
+render_children(render_ctx_t *ctx, lxb_dom_node_t *first, int indent_level)
+{
+ lxb_dom_node_t *child;
+
+ for (child = first; child != NULL && ctx->status == ORACLE_OK; child = child->next) {
+ render_node(ctx, child, indent_level);
+ if (ctx->status == ORACLE_OK && ctx->tree.length > ctx->max_tree_bytes) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "tree-byte-limit-exceeded";
+ ctx->message = "Rendered tree byte limit exceeded.";
+ }
+ }
+}
+
+static bool
+read_file(const char *path, lxb_char_t **data, size_t *len, const char **message)
+{
+ FILE *file;
+ long size;
+ size_t read_len;
+ lxb_char_t *bytes;
+
+ file = fopen(path, "rb");
+ if (file == NULL) {
+ *message = strerror(errno);
+ return false;
+ }
+
+ if (fseek(file, 0, SEEK_END) != 0) {
+ fclose(file);
+ *message = "Could not seek input file.";
+ return false;
+ }
+
+ size = ftell(file);
+ if (size < 0) {
+ fclose(file);
+ *message = "Could not determine input size.";
+ return false;
+ }
+
+ if (fseek(file, 0, SEEK_SET) != 0) {
+ fclose(file);
+ *message = "Could not rewind input file.";
+ return false;
+ }
+
+ bytes = (lxb_char_t *) malloc((size_t) size + 1);
+ if (bytes == NULL) {
+ fclose(file);
+ *message = "Could not allocate input buffer.";
+ return false;
+ }
+
+ read_len = fread(bytes, 1, (size_t) size, file);
+ if (read_len != (size_t) size || ferror(file)) {
+ free(bytes);
+ fclose(file);
+ *message = "Could not read input file.";
+ return false;
+ }
+
+ fclose(file);
+ bytes[read_len] = '\0';
+ *data = bytes;
+ *len = read_len;
+ return true;
+}
+
+static bool
+parse_size(const char *value, size_t *out)
+{
+ char *end = NULL;
+ unsigned long parsed;
+
+ errno = 0;
+ parsed = strtoul(value, &end, 10);
+ if (errno != 0 || end == value || *end != '\0' || parsed == 0) {
+ return false;
+ }
+
+ *out = (size_t) parsed;
+ return true;
+}
+
+static bool
+parse_args(int argc, char **argv, cli_options_t *options, const char **message)
+{
+ int i;
+
+ options->mode = NULL;
+ options->context = "body";
+ options->input_path = NULL;
+ options->max_nodes = 3000;
+ options->max_depth = 512;
+ options->max_tree_bytes = 16777216;
+ options->show_help = false;
+ options->show_version = false;
+
+ for (i = 1; i < argc; i++) {
+ const char *arg = argv[i];
+
+ if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
+ options->show_help = true;
+ return true;
+ }
+ if (strcmp(arg, "--version") == 0) {
+ options->show_version = true;
+ return true;
+ }
+
+ if (i + 1 >= argc) {
+ *message = "Missing option value.";
+ return false;
+ }
+
+ if (strcmp(arg, "--mode") == 0) {
+ options->mode = argv[++i];
+ } else if (strcmp(arg, "--context") == 0) {
+ options->context = argv[++i];
+ } else if (strcmp(arg, "--input") == 0) {
+ options->input_path = argv[++i];
+ } else if (strcmp(arg, "--max-nodes") == 0) {
+ if (!parse_size(argv[++i], &options->max_nodes)) {
+ *message = "Expected --max-nodes to be a positive integer.";
+ return false;
+ }
+ } else if (strcmp(arg, "--max-depth") == 0) {
+ if (!parse_size(argv[++i], &options->max_depth)) {
+ *message = "Expected --max-depth to be a positive integer.";
+ return false;
+ }
+ } else if (strcmp(arg, "--max-tree-bytes") == 0) {
+ if (!parse_size(argv[++i], &options->max_tree_bytes)) {
+ *message = "Expected --max-tree-bytes to be a positive integer.";
+ return false;
+ }
+ } else {
+ *message = "Unknown option.";
+ return false;
+ }
+ }
+
+ if (options->mode == NULL) {
+ *message = "Missing --mode.";
+ return false;
+ }
+ if (strcmp(options->mode, "full-document") != 0 && strcmp(options->mode, "fragment-body") != 0) {
+ *message = "Expected --mode full-document or fragment-body.";
+ return false;
+ }
+ if (options->input_path == NULL) {
+ *message = "Missing --input.";
+ return false;
+ }
+
+ return true;
+}
+
+static void
+print_usage(FILE *stream)
+{
+ fprintf(
+ stream,
+ "Usage: lexbor-tree-oracle --mode full-document|fragment-body --input PATH [--context TAG] [--max-nodes N] [--max-depth N] [--max-tree-bytes N]\n"
+ );
+}
+
+static void
+print_version(void)
+{
+ printf(
+ "{\"status\":\"ok\",\"oracle\":{\"kind\":\"lexbor-source\",\"lexborCommit\":\"%s\",\"lexborVersion\":\"%s\"}}\n",
+ HTML_API_FUZZ_LEXBOR_COMMIT,
+ LXB_HTML_VERSION_STRING
+ );
+}
+
+static void
+print_result(render_ctx_t *ctx)
+{
+ buffer_t json;
+ const char *status_text = ctx->status == ORACLE_OK
+ ? "ok"
+ : (ctx->status == ORACLE_UNSUPPORTED ? "unsupported" : "error");
+
+ buffer_init(&json);
+ buffer_append_cstr(&json, "{\n \"status\": ");
+ append_json_string(&json, status_text, strlen(status_text));
+ buffer_append_cstr(&json, ",\n \"oracle\": {\n \"kind\": \"lexbor-source\",\n \"lexborCommit\": ");
+ append_json_string(&json, HTML_API_FUZZ_LEXBOR_COMMIT, strlen(HTML_API_FUZZ_LEXBOR_COMMIT));
+ buffer_append_cstr(&json, ",\n \"lexborVersion\": ");
+ append_json_string(&json, LXB_HTML_VERSION_STRING, strlen(LXB_HTML_VERSION_STRING));
+ buffer_append_cstr(&json, "\n }");
+
+ if (ctx->status == ORACLE_OK) {
+ if (ctx->tree.length == 0) {
+ buffer_append_char(&ctx->tree, '\n');
+ } else {
+ if (ctx->tree.data[ctx->tree.length - 1] != '\n') {
+ buffer_append_char(&ctx->tree, '\n');
+ }
+ buffer_append_char(&ctx->tree, '\n');
+ }
+ buffer_append_cstr(&json, ",\n \"tree\": ");
+ append_json_string(&json, ctx->tree.data == NULL ? "" : ctx->tree.data, ctx->tree.length);
+ buffer_append_cstr(&json, ",\n \"treeBase64\": ");
+ append_json_base64(&json, ctx->tree.data == NULL ? "" : ctx->tree.data, ctx->tree.length);
+ }
+
+ buffer_append_cstr(&json, ",\n \"nodeCount\": ");
+ {
+ char count[32];
+ snprintf(count, sizeof(count), "%zu", ctx->node_count);
+ buffer_append_cstr(&json, count);
+ }
+
+ if (ctx->failure_class != NULL) {
+ buffer_append_cstr(&json, ",\n \"failureClass\": ");
+ append_json_string(&json, ctx->failure_class, strlen(ctx->failure_class));
+ }
+
+ if (ctx->message != NULL) {
+ const char *key = ctx->status == ORACLE_UNSUPPORTED ? "unsupported" : "error";
+ buffer_append_cstr(&json, ",\n \"");
+ buffer_append_cstr(&json, key);
+ if (ctx->status == ORACLE_UNSUPPORTED) {
+ buffer_append_cstr(&json, "\": {\n \"message\": ");
+ append_json_string(&json, ctx->message, strlen(ctx->message));
+ buffer_append_cstr(&json, "\n }");
+ } else {
+ buffer_append_cstr(&json, "\": ");
+ append_json_string(&json, ctx->message, strlen(ctx->message));
+ }
+ }
+
+ buffer_append_cstr(&json, "\n}\n");
+
+ if (json.failed) {
+ fputs("{\"status\":\"error\",\"failureClass\":\"oracle-renderer-error\",\"error\":\"Could not encode JSON result.\"}\n", stdout);
+ } else {
+ fwrite(json.data, 1, json.length, stdout);
+ }
+
+ buffer_destroy(&json);
+}
+
+static void
+print_cli_error(const char *message)
+{
+ render_ctx_t ctx;
+
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-cli-error";
+ ctx.message = message;
+ ctx.node_count = 0;
+ ctx.max_nodes = 0;
+ ctx.max_depth = 0;
+ ctx.max_tree_bytes = 0;
+ buffer_init(&ctx.tree);
+ print_result(&ctx);
+ buffer_destroy(&ctx.tree);
+}
+
+static bool
+context_to_tag(const char *context, lxb_tag_id_t *tag_id, lxb_ns_id_t *ns_id)
+{
+ *ns_id = LXB_NS_HTML;
+
+ if (strcmp(context, "body") == 0) {
+ *tag_id = LXB_TAG_BODY;
+ } else if (strcmp(context, "div") == 0) {
+ *tag_id = LXB_TAG_DIV;
+ } else if (strcmp(context, "p") == 0) {
+ *tag_id = LXB_TAG_P;
+ } else if (strcmp(context, "td") == 0) {
+ *tag_id = LXB_TAG_TD;
+ } else if (strcmp(context, "tr") == 0) {
+ *tag_id = LXB_TAG_TR;
+ } else if (strcmp(context, "table") == 0) {
+ *tag_id = LXB_TAG_TABLE;
+ } else if (strcmp(context, "caption") == 0) {
+ *tag_id = LXB_TAG_CAPTION;
+ } else if (strcmp(context, "colgroup") == 0) {
+ *tag_id = LXB_TAG_COLGROUP;
+ } else if (strcmp(context, "select") == 0) {
+ *tag_id = LXB_TAG_SELECT;
+ } else if (strcmp(context, "option") == 0) {
+ *tag_id = LXB_TAG_OPTION;
+ } else if (strcmp(context, "template") == 0) {
+ *tag_id = LXB_TAG_TEMPLATE;
+ } else if (strcmp(context, "title") == 0) {
+ *tag_id = LXB_TAG_TITLE;
+ } else if (strcmp(context, "textarea") == 0) {
+ *tag_id = LXB_TAG_TEXTAREA;
+ } else if (strcmp(context, "script") == 0) {
+ *tag_id = LXB_TAG_SCRIPT;
+ } else if (strcmp(context, "style") == 0) {
+ *tag_id = LXB_TAG_STYLE;
+ } else if (strcmp(context, "svg") == 0) {
+ *tag_id = LXB_TAG_SVG;
+ *ns_id = LXB_NS_SVG;
+ } else if (strcmp(context, "math") == 0) {
+ *tag_id = LXB_TAG_MATH;
+ *ns_id = LXB_NS_MATH;
+ } else {
+ return false;
+ }
+
+ return true;
+}
+
+static void
+render_full_document(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len)
+{
+ lxb_status_t status;
+ lxb_html_document_t *document = lxb_html_document_create();
+
+ if (document == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor document.";
+ return;
+ }
+
+ status = lxb_html_document_parse(document, input, input_len);
+ if (status != LXB_STATUS_OK) {
+ lxb_html_document_destroy(document);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-parse-error";
+ ctx->message = "Lexbor could not parse the input.";
+ return;
+ }
+
+ render_children(ctx, lxb_dom_interface_node(document)->first_child, 0);
+ lxb_html_document_destroy(document);
+}
+
+static void
+render_fragment(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len, const char *context)
+{
+ lxb_status_t status;
+ lxb_html_parser_t *parser = NULL;
+ lxb_html_document_t *document = NULL;
+ lxb_dom_node_t *fragment = NULL;
+ lxb_tag_id_t tag_id;
+ lxb_ns_id_t ns_id;
+
+ if (!context_to_tag(context, &tag_id, &ns_id)) {
+ ctx->status = ORACLE_UNSUPPORTED;
+ ctx->failure_class = "oracle-unsupported";
+ ctx->message = "Unsupported fragment context.";
+ return;
+ }
+
+ parser = lxb_html_parser_create();
+ if (parser == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor parser.";
+ return;
+ }
+
+ status = lxb_html_parser_init(parser);
+ if (status != LXB_STATUS_OK) {
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not initialize Lexbor parser.";
+ return;
+ }
+
+ document = lxb_html_document_create();
+ if (document == NULL) {
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor document.";
+ return;
+ }
+
+ fragment = lxb_html_parse_fragment_by_tag_id(parser, document, tag_id, ns_id, input, input_len);
+ if (fragment == NULL || lxb_html_parser_status(parser) != LXB_STATUS_OK) {
+ lxb_html_document_destroy(document);
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-parse-error";
+ ctx->message = "Lexbor could not parse the fragment.";
+ return;
+ }
+
+ render_children(ctx, fragment->first_child, 0);
+ lxb_html_document_destroy(document);
+ lxb_html_parser_destroy(parser);
+}
+
+int
+main(int argc, char **argv)
+{
+ cli_options_t options;
+ const char *message = NULL;
+ lxb_char_t *input = NULL;
+ size_t input_len = 0;
+ render_ctx_t ctx;
+
+ if (!parse_args(argc, argv, &options, &message)) {
+ print_cli_error(message);
+ return EXIT_FAILURE;
+ }
+
+ if (options.show_help) {
+ print_usage(stdout);
+ return EXIT_SUCCESS;
+ }
+
+ if (options.show_version) {
+ print_version();
+ return EXIT_SUCCESS;
+ }
+
+ buffer_init(&ctx.tree);
+ ctx.status = ORACLE_OK;
+ ctx.failure_class = NULL;
+ ctx.message = NULL;
+ ctx.node_count = 0;
+ ctx.max_nodes = options.max_nodes;
+ ctx.max_depth = options.max_depth;
+ ctx.max_tree_bytes = options.max_tree_bytes;
+
+ if (!read_file(options.input_path, &input, &input_len, &message)) {
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-cli-error";
+ ctx.message = message;
+ print_result(&ctx);
+ buffer_destroy(&ctx.tree);
+ return EXIT_FAILURE;
+ }
+
+ if (strcmp(options.mode, "full-document") == 0) {
+ render_full_document(&ctx, input, input_len);
+ } else {
+ render_fragment(&ctx, input, input_len, options.context);
+ }
+
+ if (ctx.tree.failed && ctx.status == ORACLE_OK) {
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-renderer-error";
+ ctx.message = "Could not allocate tree output.";
+ }
+
+ print_result(&ctx);
+ free(input);
+ buffer_destroy(&ctx.tree);
+
+ return ctx.status == ORACLE_OK || ctx.status == ORACLE_UNSUPPORTED ? EXIT_SUCCESS : EXIT_FAILURE;
+}
diff --git a/tools/html-api-fuzz/process-group.php b/tools/html-api-fuzz/process-group.php
new file mode 100755
index 0000000000000..b8b89ca7642b8
--- /dev/null
+++ b/tools/html-api-fuzz/process-group.php
@@ -0,0 +1,19 @@
+#!/usr/bin/env php
+= 0 ? $store->replay_for_attempt_id( $store_id ) : $store->replay_for_seed( $store_seed );
+ $store->close();
+ } catch ( \Throwable $e ) {
+ fwrite( STDERR, "Could not read store {$store_path}: {$e->getMessage()}\n" );
+ exit( 1 );
+ }
+ if ( null === $store_replay ) {
+ fwrite( STDERR, ( $store_id >= 0 ? "No stored replay for id {$store_id}" : "No stored replay for seed {$store_seed}" ) . " in {$store_path}.\n" );
+ exit( 1 );
+ }
+ $store_label = $store_id >= 0 ? 'id-' . $store_id : 'seed-' . $store_seed;
+ $replay_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', dirname( $store_path ) . '/replay-' . $store_label . '-' . \HtmlApiFuzz\timestamp() );
+ $replay_path = $replay_dir . '/source-replay.json';
+ $stored_replay_value = $store_replay;
+ $options['output-dir'] = $replay_dir;
+}
+
+$replay = is_array( $stored_replay_value ) ? $stored_replay_value : \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();
+
+$output_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', dirname( $replay_path ) . '/replay-' . \HtmlApiFuzz\timestamp() );
+$input = base64_decode( $replay['inputBase64'], true );
+if ( false === $input ) {
+ fwrite( STDERR, "Invalid base64 input in replay file: {$replay_path}\n" );
+ exit( 1 );
+}
+$recorded_memory_limit = null;
+if ( array_key_exists( 'memoryLimit', $recorded_options ) ) {
+ $recorded_memory_limit = $recorded_options['memoryLimit'];
+ if ( ! is_string( $recorded_memory_limit ) || ! preg_match( '/^(?:-1|[1-9][0-9]*[KMG]?)$/i', $recorded_memory_limit ) ) {
+ fwrite( STDERR, "Invalid recorded memoryLimit: expected -1 or a positive PHP limit such as 256M.\n" );
+ exit( 1 );
+ }
+}
+$memory_limit = \HtmlApiFuzz\option_string( $options, 'memory-limit', $recorded_memory_limit );
+if ( null === $memory_limit ) {
+ $probe = \HtmlApiFuzz\run_php_process(
+ array( '-r', 'echo ini_get("memory_limit");' ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ if ( 0 !== $probe['code'] || $probe['timedOut'] ) {
+ fwrite( STDERR, "Could not resolve the default child PHP memory limit.\n" );
+ exit( 1 );
+ }
+ $memory_limit = trim( $probe['stdout'] );
+}
+if ( ! preg_match( '/^(?:-1|[1-9][0-9]*[KMG]?)$/i', $memory_limit ) ) {
+ 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 ( $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;
+}
+$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" );
+ exit( 1 );
+}
+$recorded_worker_script = __DIR__ . '/worker.php';
+if ( array_key_exists( 'workerScript', $recorded_options ) ) {
+ if ( ! is_string( $recorded_options['workerScript'] ) || '' === $recorded_options['workerScript'] ) {
+ fwrite( STDERR, "Invalid recorded workerScript: expected a non-empty string.\n" );
+ exit( 1 );
+ }
+ $recorded_worker_script = $recorded_options['workerScript'];
+}
+$worker_script = \HtmlApiFuzz\option_string( $options, 'worker-script', $recorded_worker_script );
+if ( is_string( $worker_script ) && ! str_starts_with( $worker_script, DIRECTORY_SEPARATOR ) ) {
+ $worker_script = \HtmlApiFuzz\repo_root() . DIRECTORY_SEPARATOR . $worker_script;
+}
+if ( ! is_string( $worker_script ) || ! is_file( $worker_script ) ) {
+ fwrite( STDERR, "Recorded or selected Worker script does not exist.\n" );
+ exit( 1 );
+}
+$worker_script = realpath( $worker_script ) ?: $worker_script;
+$recorded_checks = 'full';
+if ( array_key_exists( 'checks', $recorded_options ) ) {
+ if ( ! is_string( $recorded_options['checks'] ) || ! in_array( $recorded_options['checks'], array( 'baseline', 'full' ), true ) ) {
+ fwrite( STDERR, "Invalid recorded checks: expected baseline or full.\n" );
+ exit( 1 );
+ }
+ $recorded_checks = $recorded_options['checks'];
+}
+$checks = \HtmlApiFuzz\option_string( $options, 'checks', $recorded_checks );
+if ( ! in_array( $checks, array( 'baseline', 'full' ), true ) ) {
+ fwrite( STDERR, "Expected --checks or replay checks to be baseline or full.\n" );
+ exit( 1 );
+}
+$effective_policy = array(
+ 'workerScript' => $worker_script,
+ 'memoryLimit' => $memory_limit,
+ 'processTimeoutMs' => $timeout_ms,
+ 'checks' => $checks,
+);
+$payload_policy = \HtmlApiFuzz\option_string( $options, 'payload-policy', null );
+if ( null === $payload_policy ) {
+ $payload_policy = \HtmlApiFuzz\normalize_payload_policy_label( $replay['payloadPolicy'] ?? null )
+ ?? \HtmlApiFuzz\normalize_payload_policy_label( $replay['generator']['payloadPolicy'] ?? null );
+}
+$original_generator = is_array( $replay['generator'] ?? null ) ? $replay['generator'] : ( $replay['originalGenerator'] ?? null );
+$source_replay = \HtmlApiFuzz\replay_source_metadata( $replay_path, $replay );
+$git_metadata_base64 = \HtmlApiFuzz\git_metadata_base64( \HtmlApiFuzz\git_metadata() );
+$oracle_options = $options;
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'dom-oracle', null ) ) {
+ $oracle_options['dom-oracle'] = $recorded_options['domOracle'] ?? $replay['oracle']['kind'] ?? \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM;
+}
+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'];
+}
+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;
+ }
+}
+$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 ) {
+ fwrite( STDERR, 'Oracle identity mismatch: ' . implode( '; ', $oracle_mismatches ) . ".\n" );
+ fwrite( STDERR, "Pass --allow-oracle-mismatch only for a deliberate diagnostic comparison.\n" );
+ exit( 1 );
+}
+
+\HtmlApiFuzz\ensure_dir( $output_dir );
+$claim_path = $output_dir . '/.replay-attempt';
+$claim = @fopen( $claim_path, 'x' );
+if ( false === $claim ) {
+ fwrite( STDERR, "Replay output directory is already claimed: {$output_dir}\n" );
+ exit( 1 );
+}
+fclose( $claim );
+$conflicts = array( 'input.bin', 'result.json', 'replay.json', 'worker.log' );
+if ( is_array( $stored_replay_value ) ) {
+ $conflicts[] = 'source-replay.json';
+}
+foreach ( $conflicts as $conflict ) {
+ if ( file_exists( $output_dir . '/' . $conflict ) ) {
+ @unlink( $claim_path );
+ fwrite( STDERR, "Replay output directory contains stale {$conflict}: {$output_dir}\n" );
+ exit( 1 );
+ }
+}
+if ( is_array( $stored_replay_value ) ) {
+ \HtmlApiFuzz\write_json_file_atomic( $replay_path, $stored_replay_value );
+}
+$input_path = $output_dir . '/input.bin';
+\HtmlApiFuzz\write_file_atomic( $input_path, $input );
+
+$args = array(
+ '-d',
+ 'memory_limit=' . $memory_limit,
+ $worker_script,
+ '--input-file',
+ $input_path,
+ '--mode',
+ $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ '--profile',
+ $replay['profile'] ?? 'replay',
+ '--seed',
+ (string) ( $replay['seed'] ?? 1 ),
+ '--output-dir',
+ $output_dir,
+ '--max-tokens',
+ (string) \HtmlApiFuzz\option_int( $options, 'max-tokens', (int) ( $replay['limits']['maxTokens'] ?? 2000 ) ),
+ '--max-nodes',
+ (string) \HtmlApiFuzz\option_int( $options, 'max-nodes', (int) ( $replay['limits']['maxNodes'] ?? 3000 ) ),
+ '--max-depth',
+ (string) \HtmlApiFuzz\option_int( $options, 'max-depth', (int) ( $replay['limits']['maxDepth'] ?? 512 ) ),
+ '--max-tree-bytes',
+ (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,
+);
+if ( null !== $payload_policy ) {
+ $args[] = '--payload-policy';
+ $args[] = $payload_policy;
+}
+$fragment_context = $replay['fragmentContext'] ?? null;
+if ( is_string( $fragment_context ) && 'body' !== $fragment_context ) {
+ $args[] = '--fragment-context';
+ $args[] = $fragment_context;
+}
+if ( \HtmlApiFuzz\option_bool( $options, 'fail-unsupported', (bool) ( $recorded_options['failUnsupported'] ?? false ) ) ) {
+ $args[] = '--fail-unsupported';
+}
+foreach ( $oracle_worker_args as $arg ) {
+ $args[] = $arg;
+}
+
+$proc = \HtmlApiFuzz\run_php_process( $args, \HtmlApiFuzz\repo_root(), $timeout_ms, $output_dir . '/worker.log', 1048576, true );
+$result = null;
+$output_replay = null;
+try {
+ $result = \HtmlApiFuzz\read_json_file( $output_dir . '/result.json' );
+} catch ( \Throwable $ignored ) {
+ $result = null;
+}
+try {
+ $output_replay = \HtmlApiFuzz\read_json_file( $output_dir . '/replay.json' );
+} catch ( \Throwable $ignored ) {
+ $output_replay = null;
+}
+if ( $proc['processGroupCleanupFailed'] ?? false ) {
+ $result = null;
+}
+if ( ! is_array( $result ) ) {
+ $result = \HtmlApiFuzz\synthesize_worker_process_failure(
+ $proc,
+ array(
+ 'seed' => (int) ( $replay['seed'] ?? 1 ),
+ 'profile' => (string) ( $replay['profile'] ?? 'replay' ),
+ 'mode' => (string) ( $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY ),
+ 'payloadPolicy' => $replay['payloadPolicy'] ?? null,
+ 'fragmentContext' => (string) ( $replay['fragmentContext'] ?? 'body' ),
+ 'inputSource' => 'replay',
+ 'inputSha1' => sha1( $input ),
+ 'inputLength' => strlen( $input ),
+ 'checks' => $checks,
+ 'oracle' => $current_oracle,
+ )
+ );
+ $signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $signature ) {
+ $result['signature'] = $signature;
+ }
+ $result['paths'] = array(
+ 'outputDir' => $output_dir,
+ 'inputPath' => $input_path,
+ 'replayPath' => $output_dir . '/replay.json',
+ 'resultPath' => $output_dir . '/result.json',
+ );
+ \HtmlApiFuzz\write_json_file_atomic( $output_dir . '/result.json', $result );
+}
+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['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;
+ $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',
+ 'failureClass' => $result['failureClass'] ?? null,
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ 'oracle' => $result['oracle'] ?? null,
+ 'resultPath' => $output_dir . '/result.json',
+ );
+ \HtmlApiFuzz\write_json_file_atomic( $output_dir . '/replay.json', $output_replay );
+}
+echo \HtmlApiFuzz\json_encode_safe(
+ array(
+ 'ok' => $result['ok'] ?? false,
+ 'status' => $result['status'] ?? 'missing-result',
+ 'result' => $output_dir . '/result.json',
+ 'replay' => $output_dir . '/replay.json',
+ 'worker' => array(
+ 'code' => $proc['code'],
+ 'timedOut' => $proc['timedOut'],
+ 'durationMs' => $proc['durationMs'],
+ 'logPath' => $proc['logPath'],
+ 'processGroupIsolated' => $proc['processGroupIsolated'],
+ 'processGroupCleanupFailed' => $proc['processGroupCleanupFailed'],
+ ),
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ )
+) . "\n";
+exit( ( $result['ok'] ?? false ) ? 0 : 2 );
diff --git a/tools/html-api-fuzz/runner.php b/tools/html-api-fuzz/runner.php
new file mode 100755
index 0000000000000..dd1fe440baa30
--- /dev/null
+++ b/tools/html-api-fuzz/runner.php
@@ -0,0 +1,529 @@
+#!/usr/bin/env php
+ $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,
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'startedAt' => gmdate( 'c' ),
+ 'updatedAt' => gmdate( 'c' ),
+ 'outputDir' => $output_dir,
+ 'cwd' => getcwd() ?: null,
+ 'startSeed' => $start_seed,
+ 'seedStride' => $seed_stride,
+ 'nextSeed' => $start_seed,
+ 'profile' => $profile,
+ 'mode' => $mode,
+ 'payloadPolicy' => $payload_policy,
+ '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,
+ // Longest legitimate silence between state writes: one full batch worker
+ // run. The watcher floors its dead-runner presumption on this.
+ 'batchBudgetMs' => $timeout_ms * $batch_size,
+ 'successes' => 0,
+ 'failures' => 0,
+ 'unsupported' => 0,
+ 'oracleParseErrors' => 0,
+ 'oracleUnsupported' => 0,
+ 'oracleTolerated' => 0,
+ 'oracleFindings' => 0,
+ 'stopReason' => null,
+);
+\HtmlApiFuzz\write_json_file( $state_path, $state );
+\HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'runner-start', 'outputDir' => $output_dir, 'git' => $git_metadata, 'oracle' => $oracle_metadata ) );
+file_put_contents( $runner_log, '[' . gmdate( 'c' ) . "] runner started outputDir={$output_dir}\n", FILE_APPEND );
+
+$has_deadline = $duration_seconds > 0;
+$deadline = $has_deadline ? microtime( true ) + $duration_seconds : null;
+$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, int $process_timeout_ms, array $oracle_worker_args ): array {
+ $args = array(
+ __DIR__ . '/worker.php',
+ '--seed',
+ (string) $seed,
+ '--profile',
+ $profile,
+ '--mode',
+ $mode,
+ '--payload-policy',
+ $payload_policy,
+ '--output-dir',
+ $output_dir,
+ '--max-tokens',
+ (string) $max_tokens,
+ '--max-nodes',
+ (string) $max_nodes,
+ '--git-metadata-base64',
+ $git_metadata_base64,
+ '--process-timeout-ms',
+ (string) $process_timeout_ms,
+ );
+ if ( $batch_count > 1 ) {
+ $args[] = '--batch-count';
+ $args[] = (string) $batch_count;
+ $args[] = '--seed-stride';
+ $args[] = (string) $seed_stride;
+ }
+ if ( $fail_unsupported ) {
+ $args[] = '--fail-unsupported';
+ }
+ if ( $max_input_bytes > 0 ) {
+ $args[] = '--max-input-bytes';
+ $args[] = (string) $max_input_bytes;
+ }
+ $args[] = '--corpus-mutate-percent';
+ $args[] = (string) $corpus_percent;
+ foreach ( $oracle_worker_args as $arg ) {
+ $args[] = $arg;
+ }
+ return $args;
+}
+
+/**
+ * A batch worker killed mid-write can leave truncated JSON behind; such a
+ * file must behave like a missing one so the seed takes the isolation
+ * fallback instead of fataling the lane.
+ */
+function html_api_fuzz_runner_read_json_or_null( string $path ) {
+ try {
+ return \HtmlApiFuzz\read_json_file( $path );
+ } catch ( \RuntimeException $e ) {
+ return null;
+ }
+}
+
+$pending_batch = array();
+$batch_log = null;
+$batch_keep_log = false;
+
+// Already-computed batch results are always processed; the deadline and seed
+// budget gate only the formation of new batches.
+while ( array() !== $pending_batch || ( ( ! $has_deadline || microtime( true ) < $deadline ) && ( 0 === $max_seeds || $count < $max_seeds ) ) ) {
+ if ( array() === $pending_batch ) {
+ /*
+ * Graceful stop: the already-computed batch above has fully drained
+ * and is recorded; honor a stop request (or a stop-on-failure from
+ * inside the batch) before committing to a new batch.
+ */
+ if ( null !== $state['stopReason'] ) {
+ break;
+ }
+ if ( is_file( $stop_file ) ) {
+ $state['stopReason'] = 'stop-requested';
+ \HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'stop-requested', 'stopFile' => $stop_file ) );
+ break;
+ }
+
+ /*
+ * Run a batch of seeds in one worker process: per-seed process spawns
+ * dominate wall-clock otherwise. Seeds missing a result.json after
+ * the batch (the batch process died or timed out mid-way) are re-run
+ * individually below.
+ */
+ $batch_count = $batch_size;
+ if ( 0 !== $max_seeds ) {
+ $batch_count = min( $batch_count, $max_seeds - $count );
+ }
+ $batch_count = max( 1, $batch_count );
+ $batch_seeds = array();
+ for ( $i = 0; $i < $batch_count; $i++ ) {
+ $batch_seeds[] = $seed + ( $i * $seed_stride );
+ }
+
+ // Batch logs live outside the prunable seed directories; clean
+ // batches drop theirs once fully recorded (see end of loop).
+ $batch_log = $output_dir . '/logs/batch-' . $batch_seeds[0] . '.log';
+ $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, $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;
+ }
+
+ $current_seed = array_shift( $pending_batch );
+ $attempt_dir = $output_dir . '/seed-' . $current_seed . '/primary';
+ \HtmlApiFuzz\ensure_dir( $attempt_dir );
+ $log_path = $attempt_dir . '/worker.log';
+
+ $result = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/result.json' );
+ $proc = $batch_proc;
+ 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, $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' );
+ }
+
+ if ( null === $result ) {
+ $replay = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/replay.json' );
+ $result = array(
+ 'ok' => false,
+ 'status' => $proc['timedOut'] ? 'timeout' : 'worker-failed',
+ 'failureClass' => $proc['timedOut'] ? 'timeout' : 'worker-failed',
+ 'failureSnippet' => substr( $proc['output'], -2000 ),
+ 'seed' => $current_seed,
+ 'profile' => is_array( $replay ) ? ( $replay['profile'] ?? $profile ) : $profile,
+ 'mode' => is_array( $replay ) ? ( $replay['mode'] ?? $mode ) : $mode,
+ 'payloadPolicy' => is_array( $replay ) ? ( $replay['payloadPolicy'] ?? $payload_policy ) : $payload_policy,
+ 'generator' => is_array( $replay ) ? ( $replay['generator'] ?? null ) : null,
+ 'inputSource' => is_array( $replay ) ? ( $replay['inputSource'] ?? null ) : null,
+ 'inputSha1' => is_array( $replay ) ? ( $replay['inputSha1'] ?? null ) : null,
+ 'inputLength' => is_array( $replay ) ? ( $replay['inputLength'] ?? null ) : null,
+ 'oracle' => is_array( $replay ) ? ( $replay['oracle'] ?? $oracle_metadata ) : $oracle_metadata,
+ 'paths' => array(
+ 'outputDir' => $attempt_dir,
+ 'resultPath' => $attempt_dir . '/result.json',
+ 'replayPath' => $attempt_dir . '/replay.json',
+ ),
+ );
+ $signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $signature ) {
+ $result['signature'] = $signature;
+ }
+ \HtmlApiFuzz\write_json_file( $attempt_dir . '/result.json', $result );
+ }
+
+ $result['seed'] = $result['seed'] ?? $current_seed;
+ $result['profile'] = $result['profile'] ?? $profile;
+ $result['mode'] = $result['mode'] ?? $mode;
+ $result['payloadPolicy'] = $result['payloadPolicy'] ?? $payload_policy;
+ $result['paths'] = $result['paths'] ?? array(
+ 'outputDir' => $attempt_dir,
+ 'resultPath' => $attempt_dir . '/result.json',
+ 'replayPath' => $attempt_dir . '/replay.json',
+ );
+ if ( ! ( $result['ok'] ?? false ) && empty( $result['signature'] ) ) {
+ $signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $signature ) {
+ $result['signature'] = $signature;
+ }
+ \HtmlApiFuzz\write_json_file( $attempt_dir . '/result.json', $result );
+ }
+
+ $attempt_ok = (bool) ( $result['ok'] ?? false );
+ $has_oracle_finding = is_array( $result['oracleFinding'] ?? null );
+
+ /*
+ * Artifact retention: every attempt is regenerable from its seed, so seed
+ * directories are kept only for failures and oracle findings, and only
+ * until their signature has enough exemplars on disk. Everything else
+ * lives in the SQLite store (failures and oracle findings include their
+ * result and replay JSON there, so a pruned finding remains reproducible).
+ */
+ $retain_artifacts = $keep_all_artifacts;
+ $retain_failure_artifacts = $keep_all_artifacts && ! $attempt_ok;
+ $retain_oracle_artifacts = $keep_all_artifacts && $has_oracle_finding;
+ $replay = null;
+ if ( ! $attempt_ok || $has_oracle_finding ) {
+ $replay_path = $attempt_dir . '/replay.json';
+ $replay = html_api_fuzz_runner_read_json_or_null( $replay_path );
+
+ if ( ! $keep_all_artifacts ) {
+ $retention_targets = array();
+ if ( ! $attempt_ok ) {
+ $retention_targets[] = array(
+ 'hash' => $result['signature']['hash'] ?? null,
+ 'kind' => 'failure',
+ );
+ }
+ if ( $has_oracle_finding ) {
+ $retention_targets[] = array(
+ 'hash' => $result['oracleFinding']['signature']['hash'] ?? null,
+ 'kind' => 'oracle',
+ );
+ }
+
+ if ( ! is_array( $replay ) ) {
+ // Without a replay document the files are the only reproduction.
+ $retain_failure_artifacts = ! $attempt_ok;
+ $retain_oracle_artifacts = $has_oracle_finding;
+ } else {
+ /*
+ * Count exemplar directories still on disk rather than rows
+ * ever written: a restarted runner re-records seeds, and row
+ * counting would saturate the cap without keeping anything.
+ */
+ foreach ( $retention_targets as $target ) {
+ $signature_hash = $target['hash'];
+ if ( null === $signature_hash ) {
+ if ( 'oracle' === $target['kind'] ) {
+ $retain_oracle_artifacts = true;
+ } else {
+ $retain_failure_artifacts = true;
+ }
+ continue;
+ }
+ $retained_seeds = 'oracle' === $target['kind']
+ ? $result_store->oracle_retained_seeds( $signature_hash )
+ : $result_store->retained_seeds( $signature_hash );
+ $retained_on_disk = 0;
+ foreach ( $retained_seeds as $retained_seed ) {
+ if ( is_dir( $output_dir . '/seed-' . $retained_seed ) ) {
+ ++$retained_on_disk;
+ }
+ }
+ if ( $retained_on_disk < $max_keep_per_signature ) {
+ if ( 'oracle' === $target['kind'] ) {
+ $retain_oracle_artifacts = true;
+ } else {
+ $retain_failure_artifacts = true;
+ }
+ }
+ }
+ }
+ $retain_artifacts = $retain_failure_artifacts || $retain_oracle_artifacts;
+ }
+
+ if ( $retain_artifacts ) {
+ // Over-cap repeats of a known signature do not justify keeping
+ // their batch log; retained exemplars and re-runs do.
+ $batch_keep_log = true;
+ }
+
+ if ( is_array( $replay ) ) {
+ $replay['result'] = array(
+ 'ok' => $result['ok'] ?? false,
+ 'status' => $result['status'] ?? 'unknown',
+ 'failureClass' => $result['failureClass'] ?? null,
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ 'oracle' => $result['oracle'] ?? $replay['oracle'] ?? $oracle_metadata,
+ 'resultPath' => $retain_artifacts ? $attempt_dir . '/result.json' : null,
+ );
+ $replay['oracle'] = $replay['oracle'] ?? $result['oracle'] ?? $oracle_metadata;
+ $replay['signature'] = $result['signature'] ?? null;
+ $replay['oracleFinding'] = $result['oracleFinding'] ?? null;
+ if ( $retain_artifacts ) {
+ \HtmlApiFuzz\write_json_file( $replay_path, $replay );
+ }
+ }
+ }
+
+ $summary = array(
+ 'kind' => $attempt_ok ? ( $has_oracle_finding ? 'oracle-finding' : 'attempt' ) : 'failure',
+ 'ok' => $attempt_ok,
+ 'status' => $result['status'] ?? 'unknown',
+ 'failureClass' => $result['failureClass'] ?? null,
+ 'seed' => $current_seed,
+ 'profile' => $result['profile'] ?? $profile,
+ 'mode' => $result['mode'] ?? $mode,
+ 'payloadPolicy' => $result['payloadPolicy'] ?? $payload_policy,
+ 'generator' => $result['generator'] ?? null,
+ 'inputSource' => $result['inputSource'] ?? null,
+ 'inputSha1' => $result['inputSha1'] ?? null,
+ 'inputLength' => $result['inputLength'] ?? null,
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ 'oracle' => $result['oracle'] ?? $oracle_metadata,
+ 'artifactsRetained' => $retain_artifacts,
+ 'failureArtifactsRetained' => $retain_failure_artifacts,
+ 'oracleArtifactsRetained' => $retain_oracle_artifacts,
+ 'resultPath' => $retain_artifacts ? $attempt_dir . '/result.json' : null,
+ 'replayPath' => $retain_artifacts ? $attempt_dir . '/replay.json' : null,
+ // Batch-executed seeds have no per-seed worker.log; point at a log
+ // that exists (isolation re-run log, else the shared batch log).
+ 'logPath' => $retain_artifacts ? ( is_file( $log_path ) ? $log_path : $batch_log ) : null,
+ 'durationMs' => $proc['durationMs'],
+ 'workerCode' => $proc['code'],
+ 'workerTimedOut'=> $proc['timedOut'],
+ );
+ $attempt_id = $result_store->record_attempt(
+ $summary,
+ ( $attempt_ok && ! $has_oracle_finding ) ? null : $result,
+ ( ( $attempt_ok && ! $has_oracle_finding ) || ! is_array( $replay ) ) ? null : $replay
+ );
+ if ( ! $retain_artifacts && is_array( $replay ) && ( ! $attempt_ok || $has_oracle_finding ) ) {
+ // The stored copy outlives the pruned files; its replay command must
+ // point at this exact row, not just at a seed that a later restart may
+ // record again with different input.
+ $replay['command'] = array(
+ 'program' => PHP_BINARY,
+ 'args' => array(
+ 'tools/html-api-fuzz/replay.php',
+ '--store',
+ $output_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME,
+ '--id',
+ (string) $attempt_id,
+ ),
+ 'cwd' => $repo_root,
+ );
+ $result_store->update_replay_for_attempt( $attempt_id, $replay );
+ }
+ if ( ! $retain_artifacts && ! $result_store->seed_artifacts_retained( $current_seed ) ) {
+ // The retained check covers earlier rows: a re-run of a previously
+ // retained seed must not delete the exemplar directory they cite.
+ \HtmlApiFuzz\remove_dir_recursive( $output_dir . '/seed-' . $current_seed );
+ }
+
+ if ( $summary['ok'] ) {
+ if ( $has_oracle_finding ) {
+ ++$state['oracleFindings'];
+ }
+ if ( 'unsupported' === $summary['status'] ) {
+ ++$state['unsupported'];
+ } elseif ( 'oracle-parse-error' === $summary['status'] ) {
+ // Inputs the selected oracle cannot parse receive no differential
+ // coverage; track the loss per class so long runs surface how
+ // much of the input space the oracle gives up on.
+ ++$state['oracleParseErrors'];
+ } elseif ( 'oracle-unsupported' === $summary['status'] ) {
+ ++$state['oracleUnsupported'];
+ } elseif ( 'oracle-tolerated' === $summary['status'] ) {
+ ++$state['oracleTolerated'];
+ } else {
+ ++$state['successes'];
+ }
+ } else {
+ ++$state['failures'];
+ if ( $stop_on_failure ) {
+ $state['stopReason'] = 'stop-on-failure';
+ }
+ }
+
+ $seed += $seed_stride;
+ ++$count;
+ $state['nextSeed'] = $seed;
+ $state['updatedAt'] = gmdate( 'c' );
+ \HtmlApiFuzz\write_json_file( $state_path, $state );
+
+ // A stop-on-failure stop reason is honored at the top of the loop, after
+ // the rest of the batch has been recorded and pruned. Under
+ // --keep-all-artifacts every recorded logPath must keep existing.
+ if ( array() === $pending_batch && ! $batch_keep_log && ! $keep_all_artifacts && null !== $batch_log ) {
+ @unlink( $batch_log );
+ }
+}
+
+if ( null === $state['stopReason'] ) {
+ $state['stopReason'] = ( 0 !== $max_seeds && $count >= $max_seeds ) ? 'max-seeds' : 'duration-elapsed';
+}
+$state['updatedAt'] = gmdate( 'c' );
+\HtmlApiFuzz\write_json_file( $state_path, $state );
+\HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'runner-stop', 'stopReason' => $state['stopReason'], 'nextSeed' => $seed ) );
+$result_store->close();
+echo \HtmlApiFuzz\json_encode_safe( $state ) . "\n";
diff --git a/tools/html-api-fuzz/start-continuous-run-tmux.sh b/tools/html-api-fuzz/start-continuous-run-tmux.sh
new file mode 100755
index 0000000000000..f7c4b78f73d9e
--- /dev/null
+++ b/tools/html-api-fuzz/start-continuous-run-tmux.sh
@@ -0,0 +1,166 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
+
+SESSION="${SESSION:-html-api-fuzz-$(date -u +%Y%m%dT%H%M%SZ)}"
+RUN_DIR="${RUN_DIR:-artifacts/html-api-fuzz/run-$(date -u +%Y%m%dT%H%M%SZ)}"
+TRIAGE_DIR="${TRIAGE_DIR:-$RUN_DIR/triage}"
+
+LANES="${LANES:-4}"
+MIN_FREE_GB="${MIN_FREE_GB:-25}"
+MAX_INPUT_BYTES="${MAX_INPUT_BYTES:-4096}"
+PAYLOAD_POLICY="${PAYLOAD_POLICY:-}"
+MAX_MINIMIZE="${MAX_MINIMIZE:-1}"
+WATCHER_INTERVAL_SECONDS="${WATCHER_INTERVAL_SECONDS:-10}"
+ORCHESTRATOR_INTERVAL_SECONDS="${ORCHESTRATOR_INTERVAL_SECONDS:-120}"
+ORCHESTRATOR_MAX_CONCURRENT="${ORCHESTRATOR_MAX_CONCURRENT:-1}"
+ORCHESTRATOR_MODE="${ORCHESTRATOR_MODE:-classify}"
+if [[ -z "${ORCHESTRATOR_SANDBOX+x}" ]]; then
+ if [[ "$ORCHESTRATOR_MODE" == "fix" ]]; then
+ ORCHESTRATOR_SANDBOX="workspace-write"
+ else
+ ORCHESTRATOR_SANDBOX="read-only"
+ fi
+fi
+CODEX_BIN="${CODEX_BIN:-codex}"
+CODEX_MODEL="${CODEX_MODEL:-}"
+
+if ! command -v tmux >/dev/null 2>&1; then
+ echo "tmux is required." >&2
+ exit 1
+fi
+
+if ! command -v php >/dev/null 2>&1; then
+ echo "php is required." >&2
+ exit 1
+fi
+
+if tmux has-session -t "$SESSION" 2>/dev/null; then
+ echo "tmux session already exists: $SESSION" >&2
+ exit 1
+fi
+
+available_kb="$(df -Pk "$REPO_ROOT" | awk 'NR == 2 { print $4 }')"
+required_kb="$(( MIN_FREE_GB * 1024 * 1024 ))"
+if (( available_kb < required_kb )); then
+ echo "Refusing to start: only $(( available_kb / 1024 / 1024 )) GiB free under $REPO_ROOT; need at least ${MIN_FREE_GB} GiB." >&2
+ echo "Free space or lower MIN_FREE_GB if this is intentional." >&2
+ exit 1
+fi
+
+shell_join() {
+ local out=""
+ local arg
+ for arg in "$@"; do
+ printf -v arg "%q" "$arg"
+ out+=" $arg"
+ done
+ printf "%s" "${out# }"
+}
+
+pane_command() {
+ local label="$1"
+ shift
+
+ local command
+ command="$(shell_join "$@")"
+ printf "export RUN_DIR=%q TRIAGE_DIR=%q; echo '[%s] starting'; echo '[%s] RUN_DIR=%s'; %s; status=\$?; echo; echo '[%s] exited with status '\$status; exec \"\${SHELL:-/bin/zsh}\" -l" \
+ "$RUN_DIR" \
+ "$TRIAGE_DIR" \
+ "$label" \
+ "$label" \
+ "$RUN_DIR" \
+ "$command" \
+ "$label"
+}
+
+launcher_command=(
+ php tools/html-api-fuzz/launcher.php
+ --lanes "$LANES"
+ --duration-seconds 0
+ --max-seeds 0
+ --output-dir "$RUN_DIR"
+)
+
+if [[ "$MAX_INPUT_BYTES" != "0" ]]; then
+ launcher_command+=( --max-input-bytes "$MAX_INPUT_BYTES" )
+fi
+
+if [[ "$PAYLOAD_POLICY" != "" ]]; then
+ launcher_command+=( --payload-policy "$PAYLOAD_POLICY" )
+fi
+
+watcher_command=(
+ php tools/html-api-fuzz/watcher.php
+ --run-dir "$RUN_DIR"
+ --state-dir "$TRIAGE_DIR"
+ --interval-seconds "$WATCHER_INTERVAL_SECONDS"
+ --max-minimize "$MAX_MINIMIZE"
+)
+
+orchestrator_command=(
+ php tools/html-api-fuzz/codex-triage-orchestrator.php
+ --triage-dir "$TRIAGE_DIR"
+ --diagnostics-dir "$RUN_DIR/diagnostics"
+ --repo-root "$REPO_ROOT"
+ --codex-bin "$CODEX_BIN"
+ --mode "$ORCHESTRATOR_MODE"
+ --sandbox "$ORCHESTRATOR_SANDBOX"
+ --max-concurrent "$ORCHESTRATOR_MAX_CONCURRENT"
+ --interval-seconds "$ORCHESTRATOR_INTERVAL_SECONDS"
+)
+
+if [[ "$CODEX_MODEL" != "" ]]; then
+ orchestrator_command+=( --model "$CODEX_MODEL" )
+fi
+
+launcher_pane="$(
+ tmux new-session -d -P -F '#{pane_id}' -s "$SESSION" -n fuzz -c "$REPO_ROOT" \
+ "$(pane_command launcher "${launcher_command[@]}")"
+)"
+
+for _ in {1..30}; do
+ if [[ -f "$REPO_ROOT/$RUN_DIR/launcher-state.json" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+if [[ ! -f "$REPO_ROOT/$RUN_DIR/launcher-state.json" ]]; then
+ echo "Launcher did not create $RUN_DIR/launcher-state.json within 30 seconds." >&2
+ echo "Inspect with: tmux attach -t $SESSION" >&2
+ exit 1
+fi
+
+watcher_pane="$(
+ tmux split-window -P -F '#{pane_id}' -t "$launcher_pane" -h -c "$REPO_ROOT" \
+ "$(pane_command watcher "${watcher_command[@]}")"
+)"
+
+for _ in {1..30}; do
+ if [[ -f "$REPO_ROOT/$TRIAGE_DIR/state.json" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+if [[ ! -f "$REPO_ROOT/$TRIAGE_DIR/state.json" ]]; then
+ echo "Watcher did not create $TRIAGE_DIR/state.json within 30 seconds." >&2
+ echo "Inspect with: tmux attach -t $SESSION" >&2
+ exit 1
+fi
+
+tmux split-window -P -F '#{pane_id}' -t "$watcher_pane" -v -c "$REPO_ROOT" \
+ "$(pane_command orchestrator "${orchestrator_command[@]}")" >/dev/null
+
+tmux select-layout -t "$SESSION" tiled >/dev/null
+tmux set-environment -t "$SESSION" RUN_DIR "$RUN_DIR"
+tmux set-environment -t "$SESSION" TRIAGE_DIR "$TRIAGE_DIR"
+
+echo "session=$SESSION"
+echo "run_dir=$RUN_DIR"
+echo "triage_dir=$TRIAGE_DIR"
+echo "attach=tmux attach -t $SESSION"
+tmux list-panes -t "$SESSION" -F '#{pane_index}: #{pane_current_command} dead=#{pane_dead}'
diff --git a/tools/html-api-fuzz/stop.php b/tools/html-api-fuzz/stop.php
new file mode 100644
index 0000000000000..9246d46e94d0b
--- /dev/null
+++ b/tools/html-api-fuzz/stop.php
@@ -0,0 +1,390 @@
+#!/usr/bin/env php
+ 2 && ':' === $path[1] && ( '/' === $path[2] || '\\' === $path[2] ) ) );
+}
+
+function html_api_fuzz_stop_add_advertised_stop_file( array &$stop_files, array &$warnings, string $stop_file, array $state ): void {
+ if ( '' === $stop_file ) {
+ $warnings[] = 'runner stopFile is empty; wrote the run-dir stop file, but the exact watched file may be unknown.';
+ return;
+ }
+
+ if ( html_api_fuzz_stop_path_is_absolute( $stop_file ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ return;
+ }
+
+ if ( is_string( $state['cwd'] ?? null ) && '' !== $state['cwd'] && html_api_fuzz_stop_path_is_absolute( $state['cwd'] ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $state['cwd'], DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $stop_file );
+ return;
+ }
+
+ // Older or malformed runner state did not record an absolute cwd; the
+ // exact relative path is unknowable from another process.
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ $warnings[] = 'relative runner stopFile has no recorded absolute cwd; wrote a caller-cwd candidate, but the exact watched file may be unknown.';
+}
+
+function html_api_fuzz_stop_state_is_stale( array $state, float $stale_seconds, int $fallback_mtime = 0 ): bool {
+ $updated_at = strtotime( (string) ( $state['updatedAt'] ?? '' ) );
+ if ( false === $updated_at && $fallback_mtime > 0 ) {
+ $updated_at = $fallback_mtime;
+ }
+
+ return false !== $updated_at && ( time() - $updated_at ) > $stale_seconds;
+}
+
+function html_api_fuzz_stop_runner_is_active( array $state, float $stale_seconds, int $state_mtime ): bool {
+ if ( ! array_key_exists( 'stopReason', $state ) || null !== $state['stopReason'] ) {
+ return false;
+ }
+
+ $runner_stale_seconds = max( $stale_seconds, 2.0 * ( (int) ( $state['batchBudgetMs'] ?? 0 ) ) / 1000.0 );
+
+ return ! html_api_fuzz_stop_state_is_stale( $state, $runner_stale_seconds, $state_mtime );
+}
+
+function html_api_fuzz_stop_state_looks_runner_like( array $state ): bool {
+ $kind = $state['kind'] ?? null;
+ if ( 'html-api-fuzz-runner-state' === $kind ) {
+ return true;
+ }
+ if ( 'html-api-fuzz-launcher-state' === $kind ) {
+ return false;
+ }
+
+ return array_key_exists( 'stopFile', $state ) || array_key_exists( 'stopReason', $state ) || array_key_exists( 'batchBudgetMs', $state );
+}
+
+function html_api_fuzz_stop_read_state_with_retry( string $state_path, int $attempts = 3 ) {
+ $last_exception = null;
+ for ( $i = 0; $i < $attempts; ++$i ) {
+ try {
+ return \HtmlApiFuzz\read_json_file( $state_path );
+ } catch ( \RuntimeException $e ) {
+ $last_exception = $e;
+ usleep( 50000 );
+ }
+ }
+
+ if ( null !== $last_exception ) {
+ throw $last_exception;
+ }
+
+ return null;
+}
+
+/**
+ * Describes one candidate run directory: whether any of its runners or its
+ * launcher still looks unfinished, and how recently its state files changed.
+ * Directory mtimes are useless here — lanes write into subdirectories.
+ */
+function html_api_fuzz_stop_inspect_run_dir( string $path, float $stale_seconds ): ?array {
+ $default_stop_file = rtrim( $path, DIRECTORY_SEPARATOR ) . '/STOP';
+ $state_paths = array_merge(
+ is_file( $path . '/launcher-state.json' ) ? array( $path . '/launcher-state.json' ) : array(),
+ is_file( $path . '/state.json' ) ? array( $path . '/state.json' ) : array(),
+ glob( $path . '/lane-*/state.json' ) ?: array()
+ );
+ if ( array() === $state_paths ) {
+ return null;
+ }
+
+ $active = false;
+ $latest_mtime = 0;
+ $stop_files = array();
+ $warnings = array();
+ foreach ( $state_paths as $state_path ) {
+ $mtime = filemtime( $state_path );
+ $state_mtime = false !== $mtime ? (int) $mtime : 0;
+ if ( false !== $mtime ) {
+ $latest_mtime = max( $latest_mtime, (int) $mtime );
+ }
+ try {
+ $state = html_api_fuzz_stop_read_state_with_retry( $state_path );
+ } catch ( \RuntimeException $e ) {
+ // Mid-write or corrupt state: the advertised stop file is unknowable.
+ $is_stale = 0 !== $state_mtime && ( time() - $state_mtime ) > $stale_seconds;
+ if ( ! $is_stale ) {
+ $active = true;
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = "could not read {$state_path}; writing only the run-dir stop file for that state.";
+ continue;
+ }
+ if ( ! is_array( $state ) ) {
+ $is_stale = 0 !== $state_mtime && ( time() - $state_mtime ) > $stale_seconds;
+ if ( ! $is_stale ) {
+ $active = true;
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = "could not read {$state_path}; writing only the run-dir stop file for that state.";
+ continue;
+ }
+ $kind = $state['kind'] ?? null;
+ if ( 'html-api-fuzz-launcher-state' === $kind && false === ( $state['finished'] ?? null ) && ! html_api_fuzz_stop_state_is_stale( $state, $stale_seconds, $state_mtime ) ) {
+ $active = true;
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ if ( html_api_fuzz_stop_state_looks_runner_like( $state ) ) {
+ if ( 'html-api-fuzz-runner-state' !== $kind ) {
+ $warnings[] = "runner-like state {$state_path} has missing or unknown kind; treating it as runner state.";
+ }
+ $runner_active = html_api_fuzz_stop_runner_is_active( $state, $stale_seconds, $state_mtime );
+ $runner_unknown = ! array_key_exists( 'stopReason', $state );
+ if ( array_key_exists( 'stopFile', $state ) && is_string( $state['stopFile'] ) ) {
+ html_api_fuzz_stop_add_advertised_stop_file( $stop_files, $warnings, $state['stopFile'], $state );
+ } elseif ( $runner_active || $runner_unknown ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = 'runner stopFile is missing or malformed; wrote the run-dir stop file, but the exact watched file may be unknown.';
+ } else {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ if ( $runner_active ) {
+ $active = true;
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ }
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+
+ return array(
+ 'path' => $path,
+ 'active' => $active,
+ 'mtime' => $latest_mtime,
+ 'stopFiles' => $stop_files,
+ 'warnings' => $warnings,
+ );
+}
+
+/**
+ * The most recently active unfinished run, falling back to the most recently
+ * active run of any state.
+ */
+function html_api_fuzz_stop_candidate_is_better( array $candidate, ?array $best ): bool {
+ if ( null === $best ) {
+ return true;
+ }
+ if ( $candidate['active'] !== $best['active'] ) {
+ return $candidate['active'];
+ }
+ if ( $candidate['mtime'] !== $best['mtime'] ) {
+ return $candidate['mtime'] > $best['mtime'];
+ }
+
+ return strcmp( $candidate['path'], $best['path'] ) > 0;
+}
+
+function html_api_fuzz_stop_latest_run_dir( string $artifacts_dir, float $stale_seconds ): ?array {
+ $items = @scandir( $artifacts_dir );
+ if ( false === $items ) {
+ return null;
+ }
+
+ $best = null;
+ foreach ( $items as $item ) {
+ if ( '.' === $item || '..' === $item ) {
+ continue;
+ }
+ $path = $artifacts_dir . DIRECTORY_SEPARATOR . $item;
+ if ( ! is_dir( $path ) ) {
+ continue;
+ }
+ $candidate = html_api_fuzz_stop_inspect_run_dir( $path, $stale_seconds );
+ if ( null === $candidate ) {
+ continue;
+ }
+ if ( html_api_fuzz_stop_candidate_is_better( $candidate, $best ) ) {
+ $best = $candidate;
+ }
+ }
+
+ return $best;
+}
+
+$options = \HtmlApiFuzz\parse_cli_options( $argv );
+if ( \HtmlApiFuzz\option_bool( $options, 'help', false ) || \HtmlApiFuzz\option_bool( $options, 'h', false ) ) {
+ html_api_fuzz_stop_usage();
+ exit( 0 );
+}
+
+$run_dir = \HtmlApiFuzz\option_string( $options, 'run-dir', $options['_'][0] ?? null );
+if ( array_key_exists( 'run-dir', $options ) && ( true === $options['run-dir'] || null === $run_dir || '' === $run_dir ) ) {
+ fwrite( STDERR, "Expected --run-dir to be a non-empty path.\n" );
+ exit( 1 );
+}
+$stop_file_override = \HtmlApiFuzz\option_string( $options, 'stop-file', null );
+if ( array_key_exists( 'stop-file', $options ) && ( true === $options['stop-file'] || null === $stop_file_override || '' === $stop_file_override ) ) {
+ fwrite( STDERR, "Expected --stop-file to be a non-empty path.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'stop-stale-seconds', $options ) && true === $options['stop-stale-seconds'] ) {
+ fwrite( STDERR, "Expected --stop-stale-seconds to be numeric.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'stop-stale-seconds', $options ) && ! is_numeric( $options['stop-stale-seconds'] ) ) {
+ fwrite( STDERR, "Expected --stop-stale-seconds to be numeric.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'artifacts-dir', $options ) && ( true === $options['artifacts-dir'] || '' === $options['artifacts-dir'] ) ) {
+ fwrite( STDERR, "Expected --artifacts-dir to be a non-empty path.\n" );
+ exit( 1 );
+}
+if ( null === $run_dir && null !== $stop_file_override && array_key_exists( 'artifacts-dir', $options ) ) {
+ fwrite( STDERR, "Pass --run-dir with --artifacts-dir --stop-file, or pass only --stop-file to write a known stop file directly.\n" );
+ exit( 1 );
+}
+
+$looks_finished = false;
+$stale_seconds = max( 10.0, \HtmlApiFuzz\option_float( $options, 'stop-stale-seconds', 120.0 ) );
+$candidate = null;
+$direct_stop_file_only = null === $run_dir && null !== $stop_file_override;
+if ( null === $run_dir && ! $direct_stop_file_only ) {
+ $artifacts_dir = \HtmlApiFuzz\option_string( $options, 'artifacts-dir', \HtmlApiFuzz\repo_root() . '/artifacts/html-api-fuzz' );
+ $candidate = html_api_fuzz_stop_latest_run_dir( $artifacts_dir, $stale_seconds );
+ if ( null === $candidate ) {
+ fwrite( STDERR, "No run directory found under {$artifacts_dir}; pass --run-dir.\n" );
+ exit( 1 );
+ }
+ $run_dir = $candidate['path'];
+ $looks_finished = ! $candidate['active'];
+ if ( $looks_finished ) {
+ fwrite( STDERR, "Warning: no unfinished run found; targeting {$run_dir}, which already looks stopped.\n" );
+ }
+}
+
+if ( null !== $run_dir && ! is_dir( $run_dir ) ) {
+ fwrite( STDERR, "Not a directory: {$run_dir}\n" );
+ exit( 1 );
+}
+
+if ( null !== $run_dir && null === $candidate ) {
+ $candidate = html_api_fuzz_stop_inspect_run_dir( $run_dir, $stale_seconds );
+ if ( null === $candidate && null === $stop_file_override ) {
+ $candidate = array(
+ 'path' => $run_dir,
+ 'active' => true,
+ 'mtime' => 0,
+ 'stopFiles' => array(),
+ 'warnings' => array( 'no run state found; writing only the run-dir stop file.' ),
+ );
+ }
+}
+
+$warnings = array();
+foreach ( $candidate['warnings'] ?? array() as $warning ) {
+ if ( is_string( $warning ) ) {
+ $warnings[] = $warning;
+ }
+}
+
+$stop_files = array();
+if ( null !== $stop_file_override ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file_override );
+}
+foreach ( $candidate['stopFiles'] ?? array() as $stop_file ) {
+ if ( is_string( $stop_file ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ }
+}
+if ( array() === $stop_files && null !== $run_dir ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP' );
+}
+if ( null !== $run_dir ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP' );
+}
+if ( array() === $stop_files ) {
+ fwrite( STDERR, "No stop file could be determined.\n" );
+ exit( 1 );
+}
+
+$write_stop_files = $stop_files;
+$primary_stop_file = $stop_files[0];
+if ( null !== $run_dir ) {
+ $run_stop_file = rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP';
+ $primary_stop_file = $run_stop_file;
+ $write_stop_files = array( $run_stop_file );
+ foreach ( $stop_files as $stop_file ) {
+ html_api_fuzz_stop_add_stop_file( $write_stop_files, $stop_file );
+ }
+}
+
+$already = true;
+foreach ( $write_stop_files as $stop_file ) {
+ $already = $already && is_file( $stop_file );
+}
+
+$write_failures = array();
+foreach ( $write_stop_files as $stop_file ) {
+ if ( is_file( $stop_file ) ) {
+ continue;
+ }
+ try {
+ \HtmlApiFuzz\write_json_file(
+ $stop_file,
+ array(
+ 'kind' => 'html-api-fuzz-stop-request',
+ 'requestedAt' => gmdate( 'c' ),
+ )
+ );
+ } catch ( \Throwable $e ) {
+ $write_failures[] = "Could not write {$stop_file}: {$e->getMessage()}";
+ continue;
+ }
+ // write_json_file does not check the file_put_contents result; the file's
+ // existence is the truth condition the runner acts on.
+ if ( ! is_file( $stop_file ) ) {
+ $write_failures[] = "Could not write {$stop_file}.";
+ }
+}
+if ( array() !== $write_failures ) {
+ foreach ( $write_failures as $failure ) {
+ fwrite( STDERR, "{$failure}\n" );
+ }
+ exit( 1 );
+}
+
+$ok = array() === $warnings;
+foreach ( $warnings as $warning ) {
+ fwrite( STDERR, "Warning: {$warning}\n" );
+}
+
+echo \HtmlApiFuzz\json_encode_safe(
+ array(
+ 'ok' => $ok,
+ 'runDir' => $run_dir,
+ 'stopFile' => $primary_stop_file,
+ 'stopFiles' => $write_stop_files,
+ 'alreadyRequested' => $already,
+ 'looksFinished' => $looks_finished,
+ 'warnings' => $warnings,
+ )
+) . "\n";
+exit( $ok ? 0 : 2 );
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'
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/codex-triage-orchestrator-smoke.php b/tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php
new file mode 100755
index 0000000000000..dd835dee19a17
--- /dev/null
+++ b/tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php
@@ -0,0 +1,179 @@
+#!/usr/bin/env php
+ array(
+ 123456 => array(
+ 'status' => 'minimized',
+ 'minimizeOutputDir' => $triage,
+ ),
+ "bad\nhash" => array(
+ 'status' => 'minimized',
+ 'minimizeOutputDir' => $triage,
+ ),
+ ),
+ )
+);
+html_api_fuzz_codex_test_assert( 1 === count( $candidates ), 'Numeric signature hashes should be accepted and unsafe hashes skipped.' );
+html_api_fuzz_codex_test_assert( '123456' === $candidates[0][0], 'Numeric signature hash should be cast to a string.' );
+
+$signature = array(
+ 'failureClass' => 'tree-mismatch',
+ 'status' => 'minimized',
+ 'resultPath' => '/etc/passwd',
+ 'minimizeResult' => $run_dir . '/minimize-result.json',
+ 'minimizeOutputDir'=> $run_dir,
+);
+file_put_contents( $run_dir . '/minimize-result.json', "{}\n" );
+$prompt = html_api_fuzz_codex_prompt_for_signature(
+ array(
+ 'triageDir' => $triage,
+ 'diagnosticsDir' => $diag,
+ 'repoRoot' => $repo_root,
+ 'runDir' => $run_dir,
+ 'mode' => 'classify',
+ ),
+ '123456',
+ $signature
+);
+html_api_fuzz_codex_test_assert( false === strpos( $prompt, '/etc/passwd' ), 'Prompt should not include artifacts outside the run or triage directories.' );
+html_api_fuzz_codex_test_assert( false !== strpos( $prompt, $run_dir . '/minimize-result.json' ), 'Prompt should include expected run artifacts.' );
+
+html_api_fuzz_codex_test_expect_exception(
+ static function () use ( $triage, $diag, $repo_root, $run_dir, $signature ): void {
+ $unsafe = $signature;
+ $unsafe['failureClass'] = "tree\nmismatch";
+ html_api_fuzz_codex_prompt_for_signature(
+ array(
+ 'triageDir' => $triage,
+ 'diagnosticsDir' => $diag,
+ 'repoRoot' => $repo_root,
+ 'runDir' => $run_dir,
+ 'mode' => 'classify',
+ ),
+ '123456',
+ $unsafe
+ );
+ },
+ 'Prompt metadata with control characters should be rejected.'
+);
+
+$signature_dir = $diag . '/123456';
+\HtmlApiFuzz\ensure_dir( $signature_dir );
+file_put_contents( $signature_dir . '/claim.json', json_encode( array( 'pid' => getmypid(), 'claimedAtUnix' => 0 ) ) . "\n" );
+$claimed = html_api_fuzz_codex_claim_signature( $diag, '123456', $signature, 1 );
+html_api_fuzz_codex_test_assert( null !== $claimed, 'Stale claims should be reclaimed even when the old PID is alive.' );
+html_api_fuzz_codex_test_assert( 1 === count( glob( $signature_dir . '/claim.stale.*.json' ) ), 'Stale claim should be archived.' );
+
+file_put_contents( $signature_dir . '/done.json', "null\n" );
+$claimed = html_api_fuzz_codex_claim_signature( $diag, '123456', $signature, 1 );
+html_api_fuzz_codex_test_assert( null !== $claimed, 'Malformed done metadata should not be treated as a successful completed job.' );
+html_api_fuzz_codex_test_assert( 1 <= count( glob( $signature_dir . '/done.failed.*.json' ) ), 'Malformed done metadata should be archived for retry.' );
+
+/*
+ * Main-loop STOP handling: with a STOP file in the run directory (resolved
+ * from the watcher state's runDir), the orchestrator must exit cleanly
+ * without launching anything.
+ */
+\HtmlApiFuzz\write_json_file(
+ $triage . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-triage-state',
+ 'runDir' => $run_dir,
+ 'signatures' => array(),
+ )
+);
+file_put_contents( $run_dir . '/STOP', "{}\n" );
+$stop_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/codex-triage-orchestrator.php',
+ '--triage-dir',
+ $triage,
+ '--diagnostics-dir',
+ $diag,
+ '--repo-root',
+ $repo_root,
+ '--codex-bin',
+ 'false',
+ ),
+ $repo_root,
+ 30000
+);
+html_api_fuzz_codex_test_assert( 0 === $stop_proc['code'] && ! $stop_proc['timedOut'], 'Orchestrator should exit cleanly when a STOP file is present.' );
+html_api_fuzz_codex_test_assert( false !== strpos( $stop_proc['output'], 'stop requested' ), 'Orchestrator should report the stop request.' );
+html_api_fuzz_codex_test_assert( false === strpos( $stop_proc['output'], 'launched ' ), 'Orchestrator should not launch jobs after a stop request.' );
+
+echo "codex triage orchestrator smoke tests passed\n";
diff --git a/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
new file mode 100755
index 0000000000000..6d841e0295ffd
--- /dev/null
+++ b/tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
@@ -0,0 +1,581 @@
+#!/usr/bin/env php
+body );
+ }
+ }
+}
+
+namespace {
+ function html_api_fuzz_commoncrawl_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ fwrite( STDERR, "FAIL: {$message}\n" );
+ exit( 1 );
+ }
+ }
+
+ function html_api_fuzz_assert_descendant_stopped( string $state_dir, string $label ): void {
+ $pid = (int) file_get_contents( $state_dir . '/descendant-pid' );
+ html_api_fuzz_commoncrawl_smoke_assert( $pid > 1, "Expected {$label} descendant PID." );
+ $heartbeat_path = $state_dir . '/descendant-heartbeat';
+ $heartbeat_before = is_file( $heartbeat_path ) ? file_get_contents( $heartbeat_path ) : null;
+ $deadline = microtime( true ) + 1.0;
+ while ( posix_kill( $pid, 0 ) && microtime( true ) < $deadline ) {
+ usleep( 10000 );
+ }
+ usleep( 100000 );
+ $heartbeat_after = is_file( $heartbeat_path ) ? file_get_contents( $heartbeat_path ) : null;
+ html_api_fuzz_commoncrawl_smoke_assert( ! posix_kill( $pid, 0 ), "Expected {$label} descendant process to be gone." );
+ html_api_fuzz_commoncrawl_smoke_assert( $heartbeat_before === $heartbeat_after, "Expected {$label} descendant heartbeat to stop." );
+ }
+
+ $work_dir = sys_get_temp_dir() . '/html-api-fuzz-commoncrawl-' . getmypid();
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $work_dir );
+ putenv( 'HTML_API_CC_ORACLE=php-dom' );
+ putenv( 'HTML_API_CC_REQUIRE_UTF8=1' );
+ putenv( 'HTML_API_CC_RETAIN_ALL=1' );
+ putenv( 'HTML_API_CC_MAX_INPUT_BYTES=4096' );
+
+ /** @var callable $analysis */
+ $analysis = require dirname( __DIR__ ) . '/commoncrawl-analysis.php';
+
+ $analysis(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:passing-document',
+ 'https://example.com/',
+ 200,
+ 'text/html; charset=UTF-8',
+ 'UTF-8',
+ 'T Hello
',
+ 'fixture:1'
+ )
+ );
+ $analysis(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:invalid-utf8',
+ 'https://example.com/invalid',
+ 200,
+ 'text/html',
+ null,
+ "bad \xFF byte
",
+ 'fixture:2'
+ )
+ );
+
+ $summary_path = $work_dir . '/commoncrawl-summary.ndjson';
+ html_api_fuzz_commoncrawl_smoke_assert( is_file( $summary_path ), 'Expected the summary stream.' );
+ $lines = file( $summary_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
+ html_api_fuzz_commoncrawl_smoke_assert( is_array( $lines ) && 2 === count( $lines ), 'Expected two Common Crawl summaries.' );
+ $passing = json_decode( $lines[0], true );
+ $skipped = json_decode( $lines[1], true );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $passing['ok'] ?? null ), 'Expected the UTF-8 fixture to pass.' );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $passing['differentialCovered'] ?? null ), 'Expected differential coverage for the UTF-8 fixture.' );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $passing['artifactsRetained'] ?? null ), 'Expected retain-all to preserve the passing fixture.' );
+ html_api_fuzz_commoncrawl_smoke_assert( is_file( $passing['artifactDir'] . '/input.bin' ), 'Expected a retained byte-exact input.' );
+ html_api_fuzz_commoncrawl_smoke_assert( is_file( $passing['artifactDir'] . '/replay.json' ), 'Expected a retained replay manifest.' );
+ 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.' );
+
+ $coverage = json_decode( (string) file_get_contents( $work_dir . '/coverage.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === ( $coverage['total'] ?? null ), 'Expected run-level coverage counters.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 1 === ( $coverage['covered'] ?? null ), 'Expected one differentially covered document.' );
+
+ file_put_contents( $work_dir . '/coverage.json', '{corrupt snapshot' );
+ $duplicate_runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ $duplicate = $duplicate_runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:passing-document',
+ 'https://example.com/',
+ 200,
+ 'text/html; charset=UTF-8',
+ 'UTF-8',
+ 'T Hello
',
+ 'fixture:1'
+ )
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( $passing['artifactDir'] === $duplicate['artifactDir'], 'Expected duplicate input publication to be idempotent.' );
+ $recovered_coverage = json_decode( (string) file_get_contents( $work_dir . '/coverage.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( 3 === ( $recovered_coverage['total'] ?? null ), 'Expected corrupt coverage snapshot recovery from the append-only summary.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === ( $recovered_coverage['covered'] ?? null ), 'Expected rebuilt differential coverage count.' );
+
+ // Reusing a run directory with different semantics must fail loudly.
+ putenv( 'HTML_API_CC_MAX_TOKENS=4097' );
+ $config_mismatch = false;
+ try {
+ \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ } catch ( \RuntimeException $e ) {
+ $config_mismatch = false !== strpos( $e->getMessage(), 'configuration mismatch' );
+ }
+ 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';
+ $original_memory_file = $work_dir . '-memory-original';
+ \HtmlApiFuzz\ensure_dir( $original_state_dir );
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $timeout_dir );
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=150' );
+ putenv( 'HTML_API_CC_ORACLE_TIMEOUT_MS=50' );
+ putenv( 'HTML_API_CC_WORKER_SCRIPT=' . __DIR__ . '/fixtures/commoncrawl-timeout-worker.php' );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $original_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $original_memory_file );
+ $timeout_runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ $timeout = $timeout_runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:timeout-document',
+ 'https://example.com/timeout',
+ 200,
+ 'text/html',
+ 'UTF-8',
+ 'persist me before starting the worker
',
+ 'fixture:timeout'
+ )
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-timeout' === ( $timeout['failureClass'] ?? null ), 'Expected a supervised worker timeout.' );
+ html_api_fuzz_commoncrawl_smoke_assert( is_file( $timeout['artifactDir'] . '/input.bin' ), 'Expected timeout input retention.' );
+ html_api_fuzz_commoncrawl_smoke_assert( is_file( $timeout['artifactDir'] . '/.complete' ), 'Expected timeout finding atomic publication.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'persist me before starting the worker
' === file_get_contents( $timeout['artifactDir'] . '/input.bin' ), 'Expected byte-exact timeout input.' );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $timeout['process']['stdoutTruncated'] ?? null ), 'Expected bounded capture of noisy worker output.' );
+ html_api_fuzz_commoncrawl_smoke_assert( true === ( $timeout['process']['processGroupIsolated'] ?? null ), 'Expected process-group isolation.' );
+ html_api_fuzz_assert_descendant_stopped( $original_state_dir, 'original timeout' );
+ html_api_fuzz_commoncrawl_smoke_assert( '256M' === file_get_contents( $original_memory_file ), 'Expected original Worker to receive configured memory limit.' );
+
+ // A Worker can crash immediately after forking. Its detached descendant is
+ // still in the isolated group and must be reaped on the natural-exit path.
+ $fast_crash_state_dir = $work_dir . '-descendant-fast-crash';
+ \HtmlApiFuzz\ensure_dir( $fast_crash_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $fast_crash_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_CRASH_AFTER_FORK=1' );
+ $fast_crash_process = \HtmlApiFuzz\run_php_process(
+ array(
+ __DIR__ . '/fixtures/commoncrawl-timeout-worker.php',
+ '--output-dir', $timeout_dir . '/fast-crash-worker',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $timeout_dir . '/fast-crash-worker.log',
+ 1048576,
+ true
+ );
+ putenv( 'HTML_API_FUZZ_TEST_CRASH_AFTER_FORK' );
+ html_api_fuzz_commoncrawl_smoke_assert( 42 === $fast_crash_process['code'] && false === $fast_crash_process['timedOut'], 'Expected deterministic fast Worker crash.' );
+ html_api_fuzz_commoncrawl_smoke_assert( true === $fast_crash_process['processGroupIsolated'], 'Expected fast-crash process-group isolation to be observed.' );
+ html_api_fuzz_commoncrawl_smoke_assert( false === $fast_crash_process['processGroupCleanupFailed'], 'Expected fast-crash process group cleanup to be verified.' );
+ html_api_fuzz_assert_descendant_stopped( $fast_crash_state_dir, 'fast-crash' );
+ $fast_crash_group = (int) file_get_contents( $fast_crash_state_dir . '/process-group-id' );
+ html_api_fuzz_commoncrawl_smoke_assert( $fast_crash_group > 1 && ! posix_kill( -$fast_crash_group, 0 ), 'Expected no surviving fast-crash process group.' );
+
+ $timeout_replay = json_decode( (string) file_get_contents( $timeout['artifactDir'] . '/replay.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( is_string( $timeout_replay['inputBase64'] ?? null ), 'Expected timeout replay to embed exact input.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'php-dom' === ( $timeout_replay['options']['domOracle'] ?? null ), 'Expected timeout replay to preserve its oracle.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 150 === ( $timeout_replay['options']['processTimeoutMs'] ?? null ), 'Expected timeout replay to record process timeout.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '256M' === ( $timeout_replay['options']['memoryLimit'] ?? null ), 'Expected timeout replay to record memory limit.' );
+ $malformed_policy_cases = array();
+ $malformed_options = $timeout_replay;
+ $malformed_options['options'] = 'not-an-object';
+ $malformed_policy_cases['non-array-options'] = $malformed_options;
+ $fractional_timeout = $timeout_replay;
+ $fractional_timeout['options']['processTimeoutMs'] = 1.5;
+ $malformed_policy_cases['fractional-timeout'] = $fractional_timeout;
+ $null_worker = $timeout_replay;
+ $null_worker['options']['workerScript'] = null;
+ $malformed_policy_cases['null-worker'] = $null_worker;
+ $empty_worker = $timeout_replay;
+ $empty_worker['options']['workerScript'] = '';
+ $malformed_policy_cases['empty-worker'] = $empty_worker;
+ $invalid_checks = $timeout_replay;
+ $invalid_checks['options']['checks'] = 1;
+ $malformed_policy_cases['invalid-checks'] = $invalid_checks;
+ $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;
+ \HtmlApiFuzz\write_json_file_atomic( $malformed_policy_path, $malformed_policy_replay );
+ $malformed_policy_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $malformed_policy_path,
+ '--output-dir', $malformed_policy_output,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 1 === $malformed_policy_proc['code'], "Expected {$case_name} recorded policy to be rejected." );
+ html_api_fuzz_commoncrawl_smoke_assert( ! is_dir( $malformed_policy_output ), "Expected {$case_name} rejection before output creation or claim." );
+ }
+ foreach ( array( 'memory-limit', 'timeout-ms', 'worker-script', 'checks' ) as $bare_option ) {
+ $bare_option_output = $timeout_dir . '/bare-option-' . $bare_option;
+ $bare_option_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $timeout['artifactDir'] . '/replay.json',
+ '--output-dir', $bare_option_output,
+ '--' . $bare_option,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 1 === $bare_option_proc['code'], "Expected bare --{$bare_option} to be rejected." );
+ html_api_fuzz_commoncrawl_smoke_assert( ! is_dir( $bare_option_output ), "Expected bare --{$bare_option} rejection before output creation or claim." );
+ }
+ $replay_state_dir = $work_dir . '-descendant-replay';
+ $replay_memory_file = $work_dir . '-memory-replay';
+ \HtmlApiFuzz\ensure_dir( $replay_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $replay_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $replay_memory_file );
+ $replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $timeout['artifactDir'] . '/replay.json',
+ '--output-dir', $timeout_dir . '/replayed',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $timeout_dir . '/replay-command.log'
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $replay_proc['code'], 'Expected replay to reproduce the recorded timeout without CLI overrides.' );
+ $replayed_timeout = \HtmlApiFuzz\read_json_file( $timeout_dir . '/replayed/result.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-timeout' === ( $replayed_timeout['failureClass'] ?? null ), 'Expected replayed timeout failure class.' );
+ html_api_fuzz_commoncrawl_smoke_assert( ( $timeout['signature']['hash'] ?? null ) === ( $replayed_timeout['signature']['hash'] ?? null ), 'Expected replayed timeout signature.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '256M' === file_get_contents( $replay_memory_file ), 'Expected replay to consume recorded memory limit.' );
+ html_api_fuzz_assert_descendant_stopped( $replay_state_dir, 'replay timeout' );
+ $replayed_manifest = \HtmlApiFuzz\read_json_file( $timeout_dir . '/replayed/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( 150 === ( $replayed_manifest['options']['processTimeoutMs'] ?? null ), 'Expected replay output to preserve the effective timeout.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '256M' === ( $replayed_manifest['options']['memoryLimit'] ?? null ), 'Expected replay output to preserve the effective memory limit.' );
+
+ // Never reuse an output directory containing evidence from another attempt.
+ $stale_replay_dir = $timeout_dir . '/stale-replay';
+ \HtmlApiFuzz\ensure_dir( $stale_replay_dir );
+ \HtmlApiFuzz\write_file_atomic( $stale_replay_dir . '/result.json', "stale evidence\n" );
+ $stale_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $timeout['artifactDir'] . '/replay.json',
+ '--output-dir', $stale_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 1 === $stale_replay_proc['code'], 'Expected replay to reject a stale output directory.' );
+ html_api_fuzz_commoncrawl_smoke_assert( "stale evidence\n" === file_get_contents( $stale_replay_dir . '/result.json' ), 'Expected replay not to clobber stale evidence.' );
+ html_api_fuzz_commoncrawl_smoke_assert( ! is_file( $stale_replay_dir . '/.replay-attempt' ), 'Expected a rejected stale directory not to remain claimed.' );
+
+ // A Worker can die halfway through JSON publication. Replay must replace both
+ // malformed artifacts with the canonical supervised-process result.
+ $corrupt_state_dir = $work_dir . '-descendant-corrupt-replay';
+ $corrupt_memory_file = $work_dir . '-memory-corrupt-replay';
+ $corrupt_replay_dir = $timeout_dir . '/corrupt-replay';
+ \HtmlApiFuzz\ensure_dir( $corrupt_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $corrupt_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $corrupt_memory_file );
+ putenv( 'HTML_API_FUZZ_TEST_CORRUPT_OUTPUT=1' );
+ $corrupt_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $timeout['artifactDir'] . '/replay.json',
+ '--output-dir', $corrupt_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ putenv( 'HTML_API_FUZZ_TEST_CORRUPT_OUTPUT' );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $corrupt_replay_proc['code'], 'Expected malformed Worker artifacts to become a replayed timeout.' );
+ $corrupt_result = \HtmlApiFuzz\read_json_file( $corrupt_replay_dir . '/result.json' );
+ $corrupt_manifest = \HtmlApiFuzz\read_json_file( $corrupt_replay_dir . '/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-timeout' === ( $corrupt_result['failureClass'] ?? null ), 'Expected malformed result JSON to be replaced with the canonical timeout.' );
+ html_api_fuzz_commoncrawl_smoke_assert( ( $timeout['signature']['hash'] ?? null ) === ( $corrupt_result['signature']['hash'] ?? null ), 'Expected malformed result recovery to preserve the timeout signature.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 150 === ( $corrupt_manifest['options']['processTimeoutMs'] ?? null ), 'Expected malformed replay JSON recovery to preserve effective policy.' );
+ html_api_fuzz_assert_descendant_stopped( $corrupt_state_dir, 'malformed-output replay' );
+
+ // Diagnostic overrides become the concrete policy recorded by the replay.
+ // Replaying that output without overrides must consume the same policy.
+ $override_state_dir_1 = $work_dir . '-descendant-override-1';
+ $override_memory_file_1 = $work_dir . '-memory-override-1';
+ $override_replay_dir_1 = $timeout_dir . '/override-replay-1';
+ \HtmlApiFuzz\ensure_dir( $override_state_dir_1 );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $override_state_dir_1 );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $override_memory_file_1 );
+ $override_replay_proc_1 = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $timeout['artifactDir'] . '/replay.json',
+ '--output-dir', $override_replay_dir_1,
+ '--memory-limit', '192M',
+ '--timeout-ms', '180',
+ '--checks', 'full',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $override_replay_proc_1['code'], 'Expected replay with explicit policy overrides to reproduce the timeout.' );
+ $override_manifest_1 = \HtmlApiFuzz\read_json_file( $override_replay_dir_1 . '/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( '192M' === file_get_contents( $override_memory_file_1 ), 'Expected Worker to receive the memory override.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '192M' === ( $override_manifest_1['options']['memoryLimit'] ?? null ), 'Expected replay output to record the memory override.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 180 === ( $override_manifest_1['options']['processTimeoutMs'] ?? null ), 'Expected replay output to record the timeout override.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'full' === ( $override_manifest_1['options']['checks'] ?? null ), 'Expected replay output to record the check override.' );
+ html_api_fuzz_commoncrawl_smoke_assert( realpath( __DIR__ . '/fixtures/commoncrawl-timeout-worker.php' ) === ( $override_manifest_1['options']['workerScript'] ?? null ), 'Expected replay output to record the resolved Worker path.' );
+ html_api_fuzz_assert_descendant_stopped( $override_state_dir_1, 'override replay' );
+
+ $override_state_dir_2 = $work_dir . '-descendant-override-2';
+ $override_memory_file_2 = $work_dir . '-memory-override-2';
+ $override_replay_dir_2 = $timeout_dir . '/override-replay-2';
+ \HtmlApiFuzz\ensure_dir( $override_state_dir_2 );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $override_state_dir_2 );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $override_memory_file_2 );
+ $override_replay_proc_2 = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $override_replay_dir_1 . '/replay.json',
+ '--output-dir', $override_replay_dir_2,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $override_replay_proc_2['code'], 'Expected replay-of-replay to consume the prior effective policy.' );
+ $override_manifest_2 = \HtmlApiFuzz\read_json_file( $override_replay_dir_2 . '/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( '192M' === file_get_contents( $override_memory_file_2 ), 'Expected replay-of-replay to consume the recorded memory override.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '192M' === ( $override_manifest_2['options']['memoryLimit'] ?? null ), 'Expected replay-of-replay to retain the effective memory policy.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 180 === ( $override_manifest_2['options']['processTimeoutMs'] ?? null ), 'Expected replay-of-replay to retain the effective timeout policy.' );
+ html_api_fuzz_commoncrawl_smoke_assert( 'full' === ( $override_manifest_2['options']['checks'] ?? null ), 'Expected replay-of-replay to retain the effective check policy.' );
+ html_api_fuzz_assert_descendant_stopped( $override_state_dir_2, 'replay-of-replay' );
+
+ // A replay moved between checkouts may name a Worker that no longer exists.
+ // Its valid recorded path must remain replaceable by an explicit override.
+ $moved_worker_replay = $timeout_replay;
+ $moved_worker_replay['options']['workerScript'] = '/checkout-that-moved/tools/html-api-fuzz/worker.php';
+ $moved_worker_path = $timeout_dir . '/moved-worker-replay.json';
+ $moved_worker_dir = $timeout_dir . '/moved-worker-replay';
+ $moved_worker_state_dir = $work_dir . '-descendant-moved-worker';
+ \HtmlApiFuzz\write_json_file_atomic( $moved_worker_path, $moved_worker_replay );
+ \HtmlApiFuzz\ensure_dir( $moved_worker_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $moved_worker_state_dir );
+ $moved_worker_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $moved_worker_path,
+ '--output-dir', $moved_worker_dir,
+ '--worker-script', __DIR__ . '/fixtures/commoncrawl-timeout-worker.php',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $moved_worker_proc['code'], 'Expected an explicit Worker override to replace a valid stale recorded path.' );
+ $moved_worker_manifest = \HtmlApiFuzz\read_json_file( $moved_worker_dir . '/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( realpath( __DIR__ . '/fixtures/commoncrawl-timeout-worker.php' ) === ( $moved_worker_manifest['options']['workerScript'] ?? null ), 'Expected moved replay to record its effective Worker override.' );
+ html_api_fuzz_assert_descendant_stopped( $moved_worker_state_dir, 'moved replay Worker override' );
+
+ // Legacy manifests have no recorded memory limit. Resolve the actual child
+ // PHP default under its inherited configuration instead of inventing one.
+ $legacy_timeout_replay = $timeout_replay;
+ unset( $legacy_timeout_replay['options']['memoryLimit'] );
+ $legacy_timeout_path = $timeout_dir . '/legacy-timeout-replay.json';
+ \HtmlApiFuzz\write_json_file_atomic( $legacy_timeout_path, $legacy_timeout_replay );
+ $legacy_php_ini_dir = $work_dir . '-legacy-php-ini';
+ $legacy_state_dir = $work_dir . '-descendant-legacy-replay';
+ $legacy_memory_file = $work_dir . '-memory-legacy-replay';
+ $legacy_replay_dir = $timeout_dir . '/legacy-replay';
+ \HtmlApiFuzz\ensure_dir( $legacy_php_ini_dir );
+ \HtmlApiFuzz\ensure_dir( $legacy_state_dir );
+ \HtmlApiFuzz\write_file_atomic( $legacy_php_ini_dir . '/php.ini', "memory_limit=384M\n" );
+ $previous_phprc = getenv( 'PHPRC' );
+ putenv( 'PHPRC=' . $legacy_php_ini_dir . '/php.ini' );
+ putenv( 'HTML_API_FUZZ_TEST_DESCENDANT_STATE_DIR=' . $legacy_state_dir );
+ putenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER=' . $legacy_memory_file );
+ $legacy_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $legacy_timeout_path,
+ '--output-dir', $legacy_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ if ( false === $previous_phprc ) {
+ putenv( 'PHPRC' );
+ } else {
+ putenv( 'PHPRC=' . $previous_phprc );
+ }
+ html_api_fuzz_commoncrawl_smoke_assert( 2 === $legacy_replay_proc['code'], 'Expected legacy replay to use the inherited child PHP policy.' );
+ $legacy_manifest = \HtmlApiFuzz\read_json_file( $legacy_replay_dir . '/replay.json' );
+ html_api_fuzz_commoncrawl_smoke_assert( '384M' === file_get_contents( $legacy_memory_file ), 'Expected legacy replay Worker to receive the probed child PHP memory limit.' );
+ html_api_fuzz_commoncrawl_smoke_assert( '384M' === ( $legacy_manifest['options']['memoryLimit'] ?? null ), 'Expected legacy replay output to record the probed child PHP memory limit.' );
+ html_api_fuzz_assert_descendant_stopped( $legacy_state_dir, 'legacy replay' );
+
+ // Kill the real Worker while its Lexbor descendant is hung. Worker receives
+ // pending/input.bin as both source and destination; it must not truncate the
+ // parent's only crash evidence, and its replay replacement must be atomic.
+ $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=' . $hanging_oracle );
+ putenv( 'HTML_API_FUZZ_TEST_ORACLE_STARTED=' . $oracle_started );
+ 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(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:crash-evidence',
+ 'https://example.com/evidence',
+ 200,
+ 'text/html',
+ 'UTF-8',
+ $evidence_body,
+ 'fixture:evidence'
+ )
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-timeout' === ( $evidence['failureClass'] ?? null ), 'Expected the real Worker to be killed while its oracle hangs.' );
+ html_api_fuzz_commoncrawl_smoke_assert( "started\n" === file_get_contents( $oracle_started ), 'Expected proof that Worker reached the hanging oracle before timeout.' );
+ html_api_fuzz_commoncrawl_smoke_assert( $evidence_body === file_get_contents( $evidence['artifactDir'] . '/input.bin' ), 'Expected killed Worker to preserve exact parent input.' );
+ $evidence_replay = json_decode( (string) file_get_contents( $evidence['artifactDir'] . '/replay.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( is_array( $evidence_replay ), 'Expected killed Worker to leave valid replay JSON.' );
+ html_api_fuzz_commoncrawl_smoke_assert( $evidence_body === base64_decode( $evidence_replay['inputBase64'] ?? '', true ), 'Expected killed Worker replay to preserve exact input.' );
+
+ // The CLI catch/fallback path is also a worker result publication path.
+ $fatal_dir = $work_dir . '-fatal-worker';
+ $fatal_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/worker.php',
+ '--input-base64', 'not-valid-base64%',
+ '--output-dir', $fatal_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000
+ );
+ html_api_fuzz_commoncrawl_smoke_assert( 1 === $fatal_proc['code'], 'Expected forced Worker fatal fallback.' );
+ $fatal_result = json_decode( (string) file_get_contents( $fatal_dir . '/result.json' ), true );
+ html_api_fuzz_commoncrawl_smoke_assert( 'worker-fatal' === ( $fatal_result['status'] ?? null ), 'Expected complete fatal fallback result JSON.' );
+ html_api_fuzz_commoncrawl_smoke_assert( array() === glob( $fatal_dir . '/.result.json.tmp-*' ), 'Expected no abandoned fatal-result publication temp file.' );
+
+ 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 );
+ \HtmlApiFuzz\remove_dir_recursive( $fatal_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $original_state_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $replay_state_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $fast_crash_state_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $corrupt_state_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $override_state_dir_1 );
+ \HtmlApiFuzz\remove_dir_recursive( $override_state_dir_2 );
+ \HtmlApiFuzz\remove_dir_recursive( $moved_worker_state_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $legacy_php_ini_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $legacy_state_dir );
+ @unlink( $oracle_started );
+ @unlink( $original_memory_file );
+ @unlink( $replay_memory_file );
+ @unlink( $corrupt_memory_file );
+ @unlink( $override_memory_file_1 );
+ @unlink( $override_memory_file_2 );
+ @unlink( $legacy_memory_file );
+ html_api_fuzz_commoncrawl_smoke_assert( ! is_dir( $work_dir ), 'Expected smoke artifacts to be cleaned up.' );
+
+ echo "OK commoncrawl-analysis-smoke\n";
+}
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/commoncrawl-concurrency-smoke.php b/tools/html-api-fuzz/tests/commoncrawl-concurrency-smoke.php
new file mode 100644
index 0000000000000..54474001a0ba5
--- /dev/null
+++ b/tools/html-api-fuzz/tests/commoncrawl-concurrency-smoke.php
@@ -0,0 +1,333 @@
+#!/usr/bin/env php
+ 1 ) {
+ $observed_pids[ $child['pid'] ] = true;
+ }
+ }
+ $own_group = function_exists( 'posix_getpgrp' ) ? posix_getpgrp() : -1;
+ foreach ( $control_dirs as $control_dir ) {
+ foreach ( glob( $control_dir . '/worker-groups/*.json' ) ?: array() as $marker_path ) {
+ try {
+ $marker = \HtmlApiFuzz\read_json_file( $marker_path );
+ } catch ( Throwable $ignored ) {
+ continue;
+ }
+ $pid = is_int( $marker['pid'] ?? null ) ? $marker['pid'] : 0;
+ $pgid = is_int( $marker['pgid'] ?? null ) ? $marker['pgid'] : 0;
+ if ( $pid > 1 ) {
+ $observed_pids[ $pid ] = true;
+ }
+ if ( $pgid > 1 && $pgid !== $own_group ) {
+ $nested_groups[ $pgid ] = true;
+ }
+ }
+ }
+}
+
+function html_api_fuzz_poll_children( array &$children, array $control_dirs, array &$observed_pids, array &$nested_groups ): bool {
+ html_api_fuzz_record_processes( $children, $control_dirs, $observed_pids, $nested_groups );
+ $all_finished = true;
+ foreach ( $children as &$child ) {
+ if ( null !== $child['exitCode'] || ! is_resource( $child['process'] ) ) {
+ continue;
+ }
+ $status = proc_get_status( $child['process'] );
+ if ( $status['running'] ) {
+ $all_finished = false;
+ } else {
+ $child['exitCode'] = (int) $status['exitcode'];
+ }
+ }
+ unset( $child );
+ return $all_finished;
+}
+
+function html_api_fuzz_live_nested_groups( array $nested_groups ): array {
+ return array_filter(
+ array_keys( $nested_groups ),
+ static fn( int $process_group ): bool => $process_group > 1 && @posix_kill( -$process_group, 0 )
+ );
+}
+
+function html_api_fuzz_stop_children( array &$children, array $control_dirs, array &$observed_pids, array &$nested_groups ): void {
+ $outers_finished = html_api_fuzz_poll_children( $children, $control_dirs, $observed_pids, $nested_groups );
+ $live_groups = html_api_fuzz_live_nested_groups( $nested_groups );
+ if ( $outers_finished && empty( $live_groups ) ) {
+ return;
+ }
+ if ( $outers_finished ) {
+ foreach ( $live_groups as $process_group ) {
+ @posix_kill( -$process_group, SIGKILL );
+ }
+ } else {
+ foreach ( $children as $child ) {
+ if ( null === $child['exitCode'] && ( $child['pid'] ?? 0 ) > 1 ) {
+ @posix_kill( -$child['pid'], SIGTERM );
+ }
+ }
+ }
+ $grace_deadline = microtime( true ) + 7.0;
+ while ( microtime( true ) < $grace_deadline ) {
+ $outers_finished = html_api_fuzz_poll_children( $children, $control_dirs, $observed_pids, $nested_groups );
+ $live_groups = html_api_fuzz_live_nested_groups( $nested_groups );
+ if ( $outers_finished && empty( $live_groups ) ) {
+ return;
+ }
+ usleep( 20000 );
+ }
+ html_api_fuzz_record_processes( $children, $control_dirs, $observed_pids, $nested_groups );
+ foreach ( html_api_fuzz_live_nested_groups( $nested_groups ) as $process_group ) {
+ @posix_kill( -$process_group, SIGKILL );
+ }
+ foreach ( $children as $child ) {
+ if ( null === $child['exitCode'] && ( $child['pid'] ?? 0 ) > 1 ) {
+ @posix_kill( -$child['pid'], SIGKILL );
+ }
+ }
+ $kill_deadline = microtime( true ) + 2.0;
+ while ( microtime( true ) < $kill_deadline ) {
+ $outers_finished = html_api_fuzz_poll_children( $children, $control_dirs, $observed_pids, $nested_groups );
+ if ( $outers_finished && empty( html_api_fuzz_live_nested_groups( $nested_groups ) ) ) {
+ break;
+ }
+ usleep( 20000 );
+ }
+}
+
+function html_api_fuzz_close_children( array &$children ): void {
+ foreach ( $children as &$child ) {
+ if ( is_resource( $child['process'] ) ) {
+ $close_code = proc_close( $child['process'] );
+ if ( null === $child['exitCode'] && $close_code >= 0 ) {
+ $child['exitCode'] = $close_code;
+ }
+ }
+ $child['process'] = null;
+ }
+ unset( $child );
+}
+
+function html_api_fuzz_spawn_writers( string $output_dir, string $control_dir, string $mode, int $writer_count, int $unique_count, array &$all_children ): void {
+ \HtmlApiFuzz\ensure_dir( $control_dir . '/logs' );
+ $fixture = __DIR__ . '/fixtures/commoncrawl-concurrent-writer.php';
+ $launcher = dirname( __DIR__ ) . '/process-group.php';
+ for ( $writer_id = 0; $writer_id < $writer_count; ++$writer_id ) {
+ $stdout_path = $control_dir . '/logs/writer-' . $writer_id . '.out';
+ $stderr_path = $control_dir . '/logs/writer-' . $writer_id . '.err';
+ $process = proc_open(
+ array(
+ PHP_BINARY, $launcher, PHP_BINARY, $fixture,
+ '--writer', $output_dir, $control_dir, (string) $writer_id,
+ (string) $writer_count, (string) $unique_count, $mode,
+ ),
+ array(
+ 0 => array( 'file', '/dev/null', 'r' ),
+ 1 => array( 'file', $stdout_path, 'wb' ),
+ 2 => array( 'file', $stderr_path, 'wb' ),
+ ),
+ $pipes,
+ \HtmlApiFuzz\repo_root()
+ );
+ html_api_fuzz_concurrency_assert( is_resource( $process ), "Could not start concurrent {$mode} writer {$writer_id}." );
+ $status = proc_get_status( $process );
+ $all_children[] = array(
+ 'process' => $process,
+ 'pid' => (int) ( $status['pid'] ?? 0 ),
+ 'exitCode' => null,
+ 'stderrPath' => $stderr_path,
+ );
+ }
+}
+
+function html_api_fuzz_run_writers( string $output_dir, string $control_dir, string $mode, int $writer_count, int $unique_count, array &$all_children, array &$control_dirs, array &$observed_pids, array &$nested_groups ): void {
+ $control_dirs[ $control_dir ] = $control_dir;
+ $offset = count( $all_children );
+ html_api_fuzz_spawn_writers( $output_dir, $control_dir, $mode, $writer_count, $unique_count, $all_children );
+ $ready_deadline = microtime( true ) + 10.0;
+ while ( $writer_count !== count( glob( $control_dir . '/ready-*' ) ?: array() ) ) {
+ html_api_fuzz_poll_children( $all_children, $control_dirs, $observed_pids, $nested_groups );
+ foreach ( array_slice( $all_children, $offset ) as $child ) {
+ html_api_fuzz_concurrency_assert( null === $child['exitCode'], "Concurrent {$mode} writer exited before the start barrier." );
+ }
+ html_api_fuzz_concurrency_assert( microtime( true ) < $ready_deadline, "Timed out waiting for concurrent {$mode} writers." );
+ usleep( 10000 );
+ }
+ \HtmlApiFuzz\write_file_atomic( $control_dir . '/go', "go\n" );
+
+ $run_deadline = microtime( true ) + 45.0;
+ while ( true ) {
+ $finished = html_api_fuzz_poll_children( $all_children, $control_dirs, $observed_pids, $nested_groups );
+ $run_children_finished = true;
+ foreach ( array_slice( $all_children, $offset ) as $child ) {
+ if ( null === $child['exitCode'] ) {
+ $run_children_finished = false;
+ break;
+ }
+ }
+ if ( $finished || $run_children_finished ) {
+ break;
+ }
+ html_api_fuzz_concurrency_assert( microtime( true ) < $run_deadline, "Timed out running concurrent {$mode} writers." );
+ usleep( 10000 );
+ }
+ for ( $index = $offset; $index < count( $all_children ); ++$index ) {
+ $child = $all_children[ $index ];
+ $error = is_file( $child['stderrPath'] ) ? trim( (string) file_get_contents( $child['stderrPath'] ) ) : '';
+ html_api_fuzz_concurrency_assert( 0 === $child['exitCode'], "Concurrent {$mode} writer failed with {$child['exitCode']}: {$error}" );
+ }
+}
+
+function html_api_fuzz_complete_artifacts( string $output_dir ): array {
+ $artifacts = array();
+ $findings = $output_dir . '/findings';
+ if ( ! is_dir( $findings ) ) {
+ return $artifacts;
+ }
+ $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $findings, FilesystemIterator::SKIP_DOTS ) );
+ foreach ( $iterator as $file ) {
+ if ( $file->isFile() && '.complete' === $file->getFilename() ) {
+ $artifacts[] = dirname( $file->getPathname() );
+ }
+ }
+ sort( $artifacts );
+ return $artifacts;
+}
+
+function html_api_fuzz_validate_concurrent_run( string $output_dir, string $control_dir, string $mode, int $expected_attempts, int $expected_artifacts ): void {
+ $summary_path = $output_dir . '/commoncrawl-summary.ndjson';
+ $lines = file( $summary_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
+ html_api_fuzz_concurrency_assert( is_array( $lines ) && $expected_attempts === count( $lines ), "Expected {$expected_attempts} complete {$mode} summaries." );
+ $summaries = array();
+ foreach ( $lines as $line ) {
+ $summary = json_decode( $line, true );
+ html_api_fuzz_concurrency_assert( JSON_ERROR_NONE === json_last_error() && is_array( $summary ), "Expected valid complete {$mode} NDJSON records." );
+ $summaries[] = $summary;
+ }
+
+ $configuration = \HtmlApiFuzz\read_json_file( $output_dir . '/configuration.json' );
+ html_api_fuzz_concurrency_assert( is_array( $configuration ), "Expected valid {$mode} configuration." );
+ $config_hash = $configuration['configHash'] ?? null;
+ $run_id = $configuration['runId'] ?? null;
+ $artifact_paths = array();
+ $signature_hashes = array();
+ $shared_artifacts = array();
+ foreach ( $summaries as $summary ) {
+ html_api_fuzz_concurrency_assert( $config_hash === ( $summary['configHash'] ?? null ) && $run_id === ( $summary['runId'] ?? null ), "Expected one {$mode} run identity." );
+ if ( 'pass' === $mode ) {
+ html_api_fuzz_concurrency_assert( true === ( $summary['ok'] ?? null ) && 'passed' === ( $summary['status'] ?? null ), 'Expected passing concurrent summary.' );
+ } else {
+ html_api_fuzz_concurrency_assert( false === ( $summary['ok'] ?? null ) && 'worker-crash' === ( $summary['failureClass'] ?? null ), 'Expected deterministic concurrent failure summary.' );
+ $signature_hashes[ $summary['signature']['hash'] ?? '' ] = true;
+ }
+ if ( is_string( $summary['artifactDir'] ?? null ) ) {
+ $artifact_paths[ $summary['artifactDir'] ] = true;
+ }
+ if ( 'urn:uuid:concurrent-shared' === ( $summary['commonCrawl']['recordId'] ?? null ) ) {
+ $shared_artifacts[ $summary['artifactDir'] ?? '' ] = true;
+ }
+ }
+ if ( 'pass' === $mode ) {
+ html_api_fuzz_concurrency_assert( 4 === count( array_filter( $summaries, static fn( array $summary ): bool => 'urn:uuid:concurrent-shared' === ( $summary['commonCrawl']['recordId'] ?? null ) ) ), 'Expected four shared-document attempts.' );
+ html_api_fuzz_concurrency_assert( 1 === count( $shared_artifacts ) && ! isset( $shared_artifacts[''] ), 'Expected shared attempts to publish one idempotent artifact.' );
+ } else {
+ html_api_fuzz_concurrency_assert( 1 === count( $signature_hashes ) && ! isset( $signature_hashes[''] ), 'Expected one deterministic failure signature.' );
+ html_api_fuzz_concurrency_assert( $expected_artifacts === count( array_filter( $summaries, static fn( array $summary ): bool => is_string( $summary['artifactDir'] ?? null ) ) ), 'Expected only capped failures to retain artifacts.' );
+ }
+
+ $artifacts = html_api_fuzz_complete_artifacts( $output_dir );
+ html_api_fuzz_concurrency_assert( $expected_artifacts === count( $artifacts ), "Expected {$expected_artifacts} complete {$mode} artifacts." );
+ html_api_fuzz_concurrency_assert( $expected_artifacts === count( $artifact_paths ), "Expected summary/artifact cardinality agreement for {$mode}." );
+ foreach ( $artifacts as $artifact ) {
+ html_api_fuzz_concurrency_assert( isset( $artifact_paths[ $artifact ] ), "Expected {$mode} artifact to be referenced by a summary." );
+ html_api_fuzz_concurrency_assert( is_file( $artifact . '/input.bin' ) && is_file( $artifact . '/.complete' ), "Expected complete {$mode} artifact evidence." );
+ html_api_fuzz_concurrency_assert( is_array( \HtmlApiFuzz\read_json_file( $artifact . '/result.json' ) ), "Expected valid {$mode} result JSON." );
+ html_api_fuzz_concurrency_assert( is_array( \HtmlApiFuzz\read_json_file( $artifact . '/replay.json' ) ), "Expected valid {$mode} replay JSON." );
+ }
+
+ $coverage = \HtmlApiFuzz\read_json_file( $output_dir . '/coverage.json' );
+ clearstatcache( true, $summary_path );
+ html_api_fuzz_concurrency_assert( is_array( $coverage ), "Expected valid {$mode} coverage snapshot." );
+ html_api_fuzz_concurrency_assert( $expected_attempts === ( $coverage['total'] ?? null ), "Expected exact {$mode} coverage total." );
+ html_api_fuzz_concurrency_assert( ( 'pass' === $mode ? $expected_attempts : 0 ) === ( $coverage['covered'] ?? null ), "Expected exact {$mode} covered count." );
+ html_api_fuzz_concurrency_assert( ( 'cap' === $mode ? $expected_attempts : 0 ) === ( $coverage['failures'] ?? null ), "Expected exact {$mode} failure count." );
+ $status = 'pass' === $mode ? 'passed' : 'crashed';
+ html_api_fuzz_concurrency_assert( $expected_attempts === ( $coverage['statuses'][ $status ] ?? null ), "Expected exact {$mode} status count." );
+ html_api_fuzz_concurrency_assert( filesize( $summary_path ) === ( $coverage['summaryBytes'] ?? null ), "Expected {$mode} coverage cursor at summary EOF." );
+ html_api_fuzz_concurrency_assert( $config_hash === ( $coverage['configHash'] ?? null ) && $run_id === ( $coverage['runId'] ?? null ), "Expected {$mode} coverage identity." );
+ $worker_markers = glob( $control_dir . '/worker-groups/*.json' ) ?: array();
+ html_api_fuzz_concurrency_assert( $expected_attempts === count( $worker_markers ), "Expected one recorded {$mode} Worker group per attempt." );
+ $real_worker_markers = glob( $control_dir . '/real-workers/*.json' ) ?: array();
+ html_api_fuzz_concurrency_assert( ( 'pass' === $mode ? $expected_attempts : 0 ) === count( $real_worker_markers ), "Expected exact {$mode} real-Worker marker count." );
+ foreach ( $real_worker_markers as $marker_path ) {
+ $marker = \HtmlApiFuzz\read_json_file( $marker_path );
+ html_api_fuzz_concurrency_assert( '128M' === ( $marker['memoryLimit'] ?? null ), 'Expected real Worker to retain the configured 128M memory limit.' );
+ html_api_fuzz_concurrency_assert( ( $marker['pid'] ?? null ) === ( $marker['pgid'] ?? null ), 'Expected real Worker to remain its nested process-group leader.' );
+ }
+
+ $pending = $output_dir . '/pending';
+ $pending_entries = is_dir( $pending ) ? array_values( array_diff( scandir( $pending ) ?: array(), array( '.', '..' ) ) ) : array();
+ html_api_fuzz_concurrency_assert( array() === $pending_entries, "Expected empty {$mode} pending directory." );
+ $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $output_dir, FilesystemIterator::SKIP_DOTS ) );
+ foreach ( $iterator as $file ) {
+ html_api_fuzz_concurrency_assert( false === strpos( $file->getFilename(), '.tmp-' ), "Expected no abandoned {$mode} atomic temp files." );
+ }
+}
+
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-commoncrawl-concurrency-' . getmypid();
+$all_children = array();
+$control_dirs = array();
+$observed_pids = array();
+$nested_groups = array();
+$exit_code = 0;
+$failure_message = null;
+try {
+ html_api_fuzz_run_writers( $work_dir . '/pass', $work_dir . '/pass-control', 'pass', 4, 3, $all_children, $control_dirs, $observed_pids, $nested_groups );
+ html_api_fuzz_validate_concurrent_run( $work_dir . '/pass', $work_dir . '/pass-control', 'pass', 16, 13 );
+ html_api_fuzz_run_writers( $work_dir . '/cap', $work_dir . '/cap-control', 'cap', 4, 3, $all_children, $control_dirs, $observed_pids, $nested_groups );
+ html_api_fuzz_validate_concurrent_run( $work_dir . '/cap', $work_dir . '/cap-control', 'cap', 12, 2 );
+} catch ( Throwable $throwable ) {
+ $exit_code = 1;
+ $failure_message = $throwable->getMessage();
+} finally {
+ html_api_fuzz_stop_children( $all_children, $control_dirs, $observed_pids, $nested_groups );
+ html_api_fuzz_close_children( $all_children );
+ $gone_deadline = microtime( true ) + 2.0;
+ do {
+ $alive_pids = array_filter( array_keys( $observed_pids ), static fn( int $pid ): bool => $pid > 1 && @posix_kill( $pid, 0 ) );
+ $alive_groups = html_api_fuzz_live_nested_groups( $nested_groups );
+ if ( empty( $alive_pids ) && empty( $alive_groups ) ) {
+ break;
+ }
+ usleep( 20000 );
+ } while ( microtime( true ) < $gone_deadline );
+ if ( ! empty( $alive_pids ) || ! empty( $alive_groups ) ) {
+ $exit_code = 1;
+ $survivors = array();
+ if ( ! empty( $alive_pids ) ) {
+ $survivors[] = 'PIDs ' . implode( ', ', $alive_pids );
+ }
+ if ( ! empty( $alive_groups ) ) {
+ $survivors[] = 'groups ' . implode( ', ', $alive_groups );
+ }
+ $failure_message = ( $failure_message ? $failure_message . '; ' : '' ) . 'Concurrent fixture or Worker descendants survived cleanup: ' . implode( '; ', $survivors );
+ } else {
+ \HtmlApiFuzz\remove_dir_recursive( $work_dir );
+ }
+}
+
+if ( 0 !== $exit_code ) {
+ fwrite( STDERR, 'FAIL: ' . $failure_message . "\n" );
+ exit( $exit_code );
+}
+html_api_fuzz_concurrency_assert( ! is_dir( $work_dir ), 'Expected concurrent smoke artifacts to be cleaned up.' );
+echo "OK commoncrawl-concurrency-smoke\n";
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..a9ec9e3d92a5f
--- /dev/null
+++ b/tools/html-api-fuzz/tests/commoncrawl-source-oracles-smoke.php
@@ -0,0 +1,281 @@
+#!/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' ),
+ \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP => array( 'option' => 'chromeOracleScript' ),
+ );
+ $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 ) {
+ 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,
+ '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'] ?? '' ) );
+ $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 );
+ 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." );
+ $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';
+ \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_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
+ ) {
+ 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/fixtures/commoncrawl-concurrent-writer.php b/tools/html-api-fuzz/tests/fixtures/commoncrawl-concurrent-writer.php
new file mode 100644
index 0000000000000..88ca387f7f13f
--- /dev/null
+++ b/tools/html-api-fuzz/tests/fixtures/commoncrawl-concurrent-writer.php
@@ -0,0 +1,212 @@
+#!/usr/bin/env php
+body );
+ }
+ }
+}
+
+namespace {
+ require_once dirname( __DIR__, 2 ) . '/lib/autoload.php';
+
+ $worker_mode = getenv( 'HTML_API_FUZZ_CONCURRENT_WORKER_MODE' );
+ $worker_control = getenv( 'HTML_API_FUZZ_CONCURRENT_WORKER_CONTROL' );
+ if ( '1' === getenv( 'HTML_API_FUZZ_CONCURRENT_REAL_WORKER' ) ) {
+ if ( ! is_string( $worker_control ) || '' === $worker_control ) {
+ fwrite( STDERR, "Missing concurrent real-Worker control directory.\n" );
+ exit( 71 );
+ }
+ $pid = getmypid();
+ \HtmlApiFuzz\write_json_file_atomic(
+ $worker_control . '/real-workers/' . $pid . '.json',
+ array(
+ 'pid' => $pid,
+ 'pgid' => posix_getpgid( $pid ),
+ 'memoryLimit' => ini_get( 'memory_limit' ),
+ )
+ );
+ return;
+ }
+ if ( in_array( $worker_mode, array( 'pass-proxy', 'failure' ), true ) ) {
+ if ( ! is_string( $worker_control ) || '' === $worker_control ) {
+ fwrite( STDERR, "Missing concurrent Worker control directory.\n" );
+ exit( 71 );
+ }
+ $pid = getmypid();
+ \HtmlApiFuzz\write_json_file_atomic(
+ $worker_control . '/worker-groups/' . $pid . '.json',
+ array(
+ 'pid' => $pid,
+ 'pgid' => posix_getpgid( $pid ),
+ 'mode' => $worker_mode,
+ )
+ );
+ if ( 'failure' === $worker_mode ) {
+ fwrite( STDERR, "deterministic concurrent worker failure\n" );
+ exit( 42 );
+ }
+
+ $memory_limit = (string) ini_get( 'memory_limit' );
+ if ( ! preg_match( '/^(?:-1|[1-9][0-9]*[KMG]?)$/i', $memory_limit ) ) {
+ fwrite( STDERR, "Invalid inherited Worker memory limit.\n" );
+ exit( 71 );
+ }
+ putenv( 'HTML_API_FUZZ_CONCURRENT_REAL_WORKER=1' );
+ $real_worker = dirname( __DIR__, 2 ) . '/worker.php';
+ $args = array(
+ '-d', 'memory_limit=' . $memory_limit,
+ '-d', 'auto_prepend_file=' . __FILE__,
+ $real_worker,
+ );
+ $args = array_merge( $args, array_slice( $argv, 1 ) );
+ pcntl_exec( PHP_BINARY, $args );
+ fwrite( STDERR, "Could not execute real concurrent Worker.\n" );
+ exit( 71 );
+ }
+
+ if ( 8 !== $argc || '--writer' !== $argv[1] ) {
+ fwrite( STDERR, "Usage: commoncrawl-concurrent-writer.php --writer OUTPUT CONTROL ID WRITERS UNIQUE pass|cap\n" );
+ exit( 64 );
+ }
+
+ $output_dir = $argv[2];
+ $control_dir = $argv[3];
+ $writer_id = filter_var( $argv[4], FILTER_VALIDATE_INT );
+ $writer_count = filter_var( $argv[5], FILTER_VALIDATE_INT );
+ $unique_count = filter_var( $argv[6], FILTER_VALIDATE_INT );
+ $mode = $argv[7];
+ if (
+ false === $writer_id || $writer_id < 0 ||
+ false === $writer_count || $writer_count < 1 ||
+ false === $unique_count || $unique_count < 1 ||
+ ! in_array( $mode, array( 'pass', 'cap' ), true )
+ ) {
+ fwrite( STDERR, "Invalid concurrent writer arguments.\n" );
+ exit( 64 );
+ }
+
+ $stop_requested = false;
+ if ( ! function_exists( 'pcntl_async_signals' ) || ! function_exists( 'pcntl_signal' ) ) {
+ fwrite( STDERR, "Concurrent writer requires PCNTL signal handling.\n" );
+ exit( 69 );
+ }
+ pcntl_async_signals( true );
+ $record_stop = static function () use ( &$stop_requested ): void {
+ $stop_requested = true;
+ };
+ pcntl_signal( SIGTERM, $record_stop );
+ pcntl_signal( SIGINT, $record_stop );
+
+ $wait_until = static function ( callable $ready, string $label ) use ( &$stop_requested ): void {
+ $deadline = microtime( true ) + 20.0;
+ while ( ! $ready() ) {
+ if ( $stop_requested ) {
+ exit( 143 );
+ }
+ if ( microtime( true ) >= $deadline ) {
+ fwrite( STDERR, "Timed out waiting for {$label}.\n" );
+ exit( 70 );
+ }
+ usleep( 10000 );
+ }
+ };
+
+ \HtmlApiFuzz\ensure_dir( $control_dir );
+ \HtmlApiFuzz\write_file_atomic( $control_dir . '/pid-' . $writer_id, (string) getmypid() . "\n" );
+ \HtmlApiFuzz\write_file_atomic( $control_dir . '/ready-' . $writer_id, "ready\n" );
+ $wait_until( static fn(): bool => is_file( $control_dir . '/go' ), 'start barrier' );
+
+ putenv( 'CC_ANALYZER_OUTPUT_DIR=' . $output_dir );
+ putenv( 'HTML_API_CC_RUN_ID=concurrent-' . $mode );
+ putenv( 'HTML_API_CC_ORACLE=php-dom' );
+ putenv( 'HTML_API_CC_ORACLE_TIMEOUT_MS=2000' );
+ putenv( 'HTML_API_CC_PROCESS_TIMEOUT_MS=5000' );
+ putenv( 'HTML_API_CC_MEMORY_LIMIT=128M' );
+ putenv( 'HTML_API_CC_CHECKS=baseline' );
+ putenv( 'HTML_API_CC_FULL_SAMPLE_PERCENT=0' );
+ putenv( 'HTML_API_CC_REQUIRE_UTF8=1' );
+ 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=64' );
+ putenv( 'HTML_API_CC_MAX_TREE_BYTES=1048576' );
+ putenv( 'HTML_API_CC_RETAIN_ALL=' . ( 'pass' === $mode ? '1' : '0' ) );
+ putenv( 'HTML_API_CC_MAX_KEEP_PER_SIGNATURE=' . ( 'pass' === $mode ? '64' : '2' ) );
+ putenv( 'CC_ANALYZER_VERSION' );
+ putenv( 'CC_ANALYZER_CRAWL' );
+ putenv( 'CC_ANALYZER_INVOCATION' );
+ putenv( 'HTML_API_CC_WORKER_SCRIPT=' . __FILE__ );
+ putenv( 'HTML_API_FUZZ_CONCURRENT_WORKER_MODE=' . ( 'cap' === $mode ? 'failure' : 'pass-proxy' ) );
+ putenv( 'HTML_API_FUZZ_CONCURRENT_WORKER_CONTROL=' . $control_dir );
+ putenv( 'HTML_API_FUZZ_CONCURRENT_REAL_WORKER' );
+
+ $runner = \HtmlApiFuzz\CommonCrawlRunner::from_environment();
+ for ( $document_id = 0; $document_id < $unique_count; ++$document_id ) {
+ $body = 'concurrent ' . $mode . ' ' . $writer_id . ' ' . $document_id . '
';
+ $summary = $runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:concurrent-' . $mode . '-' . $writer_id . '-' . $document_id,
+ 'https://example.com/concurrent/' . $mode . '/' . $writer_id . '/' . $document_id,
+ 200,
+ 'text/html; charset=UTF-8',
+ 'UTF-8',
+ $body,
+ 'fixture:' . $mode . ':' . $writer_id . ':' . $document_id
+ )
+ );
+ $expected = 'pass' === $mode
+ ? true === ( $summary['ok'] ?? null ) && 'passed' === ( $summary['status'] ?? null )
+ : false === ( $summary['ok'] ?? null ) && 'worker-crash' === ( $summary['failureClass'] ?? null );
+ if ( ! $expected ) {
+ fwrite( STDERR, "Unexpected {$mode} summary for writer {$writer_id}, document {$document_id}.\n" );
+ exit( 2 );
+ }
+ if ( $stop_requested ) {
+ exit( 143 );
+ }
+ }
+
+ if ( 'pass' === $mode ) {
+ \HtmlApiFuzz\write_file_atomic( $control_dir . '/shared-ready-' . $writer_id, "ready\n" );
+ $wait_until(
+ static fn(): bool => $writer_count === count( glob( $control_dir . '/shared-ready-*' ) ?: array() ),
+ 'shared-document barrier'
+ );
+ $shared = $runner->analyze_document(
+ new \CcAnalyzer\Analysis\HtmlAnalysisInput(
+ 'urn:uuid:concurrent-shared',
+ 'https://example.com/concurrent/shared',
+ 200,
+ 'text/html; charset=UTF-8',
+ 'UTF-8',
+ 'same document from every writer
',
+ 'fixture:shared:' . $writer_id
+ )
+ );
+ if ( true !== ( $shared['ok'] ?? null ) || 'passed' !== ( $shared['status'] ?? null ) ) {
+ fwrite( STDERR, "Unexpected shared summary for writer {$writer_id}.\n" );
+ exit( 2 );
+ }
+ }
+
+ if ( $stop_requested ) {
+ exit( 143 );
+ }
+ \HtmlApiFuzz\write_file_atomic( $control_dir . '/done-' . $writer_id, "done\n" );
+ exit( 0 );
+}
diff --git a/tools/html-api-fuzz/tests/fixtures/commoncrawl-timeout-worker.php b/tools/html-api-fuzz/tests/fixtures/commoncrawl-timeout-worker.php
new file mode 100755
index 0000000000000..721699b4e8d66
--- /dev/null
+++ b/tools/html-api-fuzz/tests/fixtures/commoncrawl-timeout-worker.php
@@ -0,0 +1,68 @@
+#!/usr/bin/env php
+ array( 'file', $null, 'r' ), 1 => array( 'file', $null, 'w' ), 2 => array( 'file', $null, 'w' ) ),
+ $pipes
+ );
+ if ( is_resource( $child ) ) {
+ $status = proc_get_status( $child );
+ file_put_contents( $state_dir . '/descendant-pid', (string) ( $status['pid'] ?? 0 ) );
+ }
+}
+$memory_marker = getenv( 'HTML_API_FUZZ_TEST_MEMORY_MARKER' );
+if ( is_string( $memory_marker ) && '' !== $memory_marker ) {
+ file_put_contents( $memory_marker, (string) ini_get( 'memory_limit' ) );
+}
+if ( '1' === getenv( 'HTML_API_FUZZ_TEST_CORRUPT_OUTPUT' ) && is_string( $output_dir ) ) {
+ file_put_contents( $output_dir . '/result.json', '{corrupt result' );
+ file_put_contents( $output_dir . '/replay.json', '{corrupt replay' );
+}
+fwrite( STDOUT, str_repeat( 'x', 2 * 1024 * 1024 ) );
+usleep( 500000 );
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,
+ ) );
+}
diff --git a/tools/html-api-fuzz/tests/fixtures/hanging-lexbor-oracle.php b/tools/html-api-fuzz/tests/fixtures/hanging-lexbor-oracle.php
new file mode 100755
index 0000000000000..6e86ad3bdefac
--- /dev/null
+++ b/tools/html-api-fuzz/tests/fixtures/hanging-lexbor-oracle.php
@@ -0,0 +1,19 @@
+#!/usr/bin/env php
+' . str_repeat( '<' . $tag . '>', 4 ) ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:same-tag-distinct-attrs':
+ foreach ( $formatting_tags as $tag ) {
+ if ( false !== strpos( $input, '<' . $tag . ' data-af="0"><' . $tag . ' data-af="1"><' . $tag . ' data-af="2"><' . $tag . ' data-af="3">' ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:same-tag-matching-attrs':
+ foreach ( $formatting_tags as $tag ) {
+ foreach ( $attr_signatures as $attrs ) {
+ if ( false !== strpos( $input, '
' . str_repeat( '<' . $tag . $attrs . '>', 4 ) ) ) {
+ return true;
+ }
+ }
+ }
+ return false;
+
+ case 'active-formatting:mixed-formatting':
+ foreach ( $attr_signatures as $attrs ) {
+ $quoted_attrs = preg_quote( $attrs, '~' );
+ if ( 1 === preg_match( '~
[^<>]* [^<>]*
~', $input ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:marker-boundary':
+ foreach ( $formatting_tags as $outer_tag ) {
+ foreach ( $attr_signatures as $outer_attrs ) {
+ $outer_cluster = preg_quote( str_repeat( '<' . $outer_tag . $outer_attrs . '>', 4 ), '~' );
+ $outer_extra = preg_quote( '<' . $outer_tag . $outer_attrs . '>', '~' );
+ foreach ( $formatting_tags as $inner_tag ) {
+ foreach ( $attr_signatures as $inner_attrs ) {
+ $inner_cluster = preg_quote( str_repeat( '<' . $inner_tag . $inner_attrs . '>', 4 ), '~' );
+ $inner_extra = preg_quote( '<' . $inner_tag . $inner_attrs . '>', '~' );
+ if ( 1 === preg_match( '~
' . $outer_cluster . '(?:' . $outer_extra . ')*[^<>]*
[^<>]*' . $inner_cluster . '(?:' . $inner_extra . ')*[^<>]*
[^<>]*
[^<>]*
~', $input ) ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ return false;
+}
+
+function html_api_fuzz_smoke_dom_drops_bare_xlink_local_name_after_xlink(): bool {
+ if ( ! class_exists( 'Dom\\HTMLDocument' ) ) {
+ return false;
+ }
+
+ $previous = libxml_use_internal_errors( true );
+ try {
+ $document = Dom\HTMLDocument::createFromString( ' ', LIBXML_NOERROR );
+ $svg = $document->getElementsByTagName( 'svg' )->item( 0 );
+ $drops = null !== $svg && $svg->hasAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ) && ! $svg->hasAttribute( 'href' );
+ } catch ( Throwable $e ) {
+ $drops = false;
+ }
+ libxml_clear_errors();
+ libxml_use_internal_errors( $previous );
+
+ return $drops;
+}
+
+function html_api_fuzz_smoke_dom_reparents_heading_after_mathml_text_integration_point( string $encoding = 'text/html' ): bool {
+ $fixture = 'x
';
+ $wp = \HtmlApiFuzz\TreeRenderer::render_wordpress(
+ $fixture,
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 200, 'maxNodes' => 200 )
+ );
+ $dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ $fixture,
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 200, 'maxNodes' => 200 )
+ );
+ if ( \HtmlApiFuzz\TreeRenderer::STATUS_OK !== ( $wp['status'] ?? null ) || \HtmlApiFuzz\TreeRenderer::STATUS_OK !== ( $dom['status'] ?? null ) ) {
+ return false;
+ }
+ $comparison = \HtmlApiFuzz\TreeRenderer::compare_trees( $wp['tree'], $dom['tree'], $wp['domOracleLineTolerances'] ?? array() );
+
+ return false === ( $comparison['ok'] ?? true );
+}
+
+function html_api_fuzz_smoke_rm_tree( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+ if ( is_file( $path ) || is_link( $path ) ) {
+ @unlink( $path );
+ return;
+ }
+ foreach ( scandir( $path ) ?: array() as $item ) {
+ if ( '.' === $item || '..' === $item ) {
+ continue;
+ }
+ html_api_fuzz_smoke_rm_tree( $path . DIRECTORY_SEPARATOR . $item );
+ }
+ @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,
+ '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( $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 = $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(
+ '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 = $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;
+for ( $truncation_seed = 1; $truncation_seed <= 64; $truncation_seed++ ) {
+ $candidate = \HtmlApiFuzz\Generator::generate(
+ $truncation_seed,
+ 'balanced',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'valid-utf8',
+ 64
+ );
+ html_api_fuzz_smoke_assert( 'valid-utf8' === $candidate['payloadPolicy'], 'valid-utf8 policy should be resolved.' );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $candidate['input'] ), 'valid-utf8 policy should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( strlen( $candidate['input'] ) <= 64, 'max-input-bytes should cap generated input.' );
+ if ( true === $candidate['parameters']['truncated'] ) {
+ $valid = $candidate;
+ break;
+ }
+}
+html_api_fuzz_smoke_assert( null !== $valid, 'max-input-bytes smoke should exercise truncation within the seed budget.' );
+html_api_fuzz_smoke_assert( in_array( 'generator:truncated', $valid['parameters']['features'], true ), 'truncation should be recorded as a feature.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === $valid['parameters']['payloadPolicy'], 'parameters should include payload policy.' );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function (): void {
+ \HtmlApiFuzz\Generator::generate( 1, 'balanced', 'bogus-mode', 'valid-utf8' );
+ },
+ 'invalid generator mode should throw.'
+);
+html_api_fuzz_smoke_assert( ! in_array( 'invalid-byte-heavy', \HtmlApiFuzz\Generator::payload_policies(), true ), 'invalid-byte-heavy should not be selectable for generated inputs.' );
+html_api_fuzz_smoke_assert( in_array( 'invalid-byte-heavy', \HtmlApiFuzz\Generator::payload_policy_labels(), true ), 'invalid-byte-heavy should remain a recognized replay metadata label.' );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function (): void {
+ \HtmlApiFuzz\Generator::generate( 1, 'attributes-entities', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'invalid-byte-heavy' );
+ },
+ 'invalid-byte-heavy policy should be rejected for generated inputs.'
+);
+
+foreach ( \HtmlApiFuzz\Generator::payload_policies() as $payload_policy ) {
+ foreach ( \HtmlApiFuzz\Generator::profiles() as $profile ) {
+ foreach ( \HtmlApiFuzz\Generator::modes() as $mode ) {
+ for ( $seed = 1; $seed <= 8; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, $profile, $mode, $payload_policy, 4096 );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "{$payload_policy}/{$profile}/{$mode}/{$seed} should produce valid UTF-8 bytes." );
+ html_api_fuzz_smoke_assert( ! in_array( 'payload:invalid-byte', $generated['parameters']['features'], true ), "{$payload_policy}/{$profile}/{$mode}/{$seed} should not record invalid-byte generation." );
+ }
+ }
+ }
+}
+$found_non_whitespace_c0_control = false;
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'balanced', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', 4096 );
+ if ( html_api_fuzz_smoke_has_non_whitespace_c0_control( $generated['input'] ) ) {
+ $found_non_whitespace_c0_control = true;
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), 'non-whitespace C0 control sample should still be valid UTF-8.' );
+ break;
+ }
+}
+html_api_fuzz_smoke_assert( $found_non_whitespace_c0_control, 'generated valid UTF-8 payloads should retain non-whitespace C0 control coverage.' );
+
+$required_generator_features = array(
+ 'charref:text',
+ 'charref:attr',
+ 'charref:rcdata',
+ 'charref:text:named-semicolon',
+ 'charref:text:named-missing-semicolon-legacy',
+ 'charref:text:named-missing-semicolon-invalid',
+ 'charref:text:numeric-valid',
+ 'charref:text:numeric-invalid',
+ 'charref:attr:named-semicolon',
+ 'charref:attr:named-missing-semicolon-legacy',
+ 'charref:attr:named-missing-semicolon-invalid',
+ 'charref:attr:numeric-valid',
+ 'charref:attr:numeric-invalid',
+ 'charref:rcdata:named-semicolon',
+ 'charref:rcdata:invalid',
+ 'charref:rcdata:numeric-valid',
+ 'charref:rcdata:numeric-invalid',
+ 'charref:leading-zero',
+ 'attr:weird-name',
+ 'attr:weird-spacing',
+ 'attr:malformed',
+ 'ascii:syntax-char',
+ 'ascii:syntax-ampersand',
+ 'ascii:syntax-less-than',
+ 'ascii:syntax-greater-than',
+ 'ascii:syntax-double-quote',
+ 'ascii:syntax-single-quote',
+ 'ascii:syntax-equals',
+ 'payload:short-ascii',
+ 'payload:empty-ascii',
+ 'payload:medium-ascii',
+ 'payload:ascii-length-0',
+ 'payload:ascii-length-1',
+ 'payload:ascii-length-2',
+ 'payload:ascii-length-3',
+ 'payload:ascii-length-4',
+ 'payload:ascii-length-5',
+ 'payload:ascii-length-6',
+ 'payload:ascii-length-7',
+ 'payload:ascii-length-8',
+ 'payload:ascii-length-9',
+ 'payload:ascii-length-10',
+ 'tag:unusual-name',
+ 'tag:invalid-name',
+ 'tag:alpha-invalid-name',
+ 'tag:alpha-weird-name',
+ 'tag:bogus-open-name',
+ 'tag:weird-spacing',
+ 'attr:duplicate',
+ 'select',
+ 'select:option',
+ 'select:optgroup',
+ 'select:breaker',
+ 'select:nested',
+ 'adoption-agency-pattern',
+ 'adoption:misnested-closers',
+ 'adoption:reconstruction',
+ 'adoption:noahs-ark',
+ 'active-formatting-reconstruction-pattern',
+ 'active-formatting:four-plus-same-tag',
+ 'active-formatting:four-plus-same-signature',
+ 'active-formatting:same-tag-empty-attrs',
+ 'active-formatting:same-tag-distinct-attrs',
+ 'active-formatting:same-tag-matching-attrs',
+ 'active-formatting:mixed-formatting',
+ 'active-formatting:marker-boundary',
+ 'auto-closing-chain',
+ 'special-closers',
+ 'foreign:breakout',
+ 'foreign:annotation-xml-encoding-variant',
+ 'foreign:cdata',
+ 'foreign:case-mangled-name',
+ 'plaintext',
+);
+$found_generator_features = array_fill_keys( $required_generator_features, false );
+$all_generator_features_found = false;
+foreach ( array( 'attributes-entities', 'rawtext-rcdata', 'text-fragment', 'incomplete-malformed', 'balanced', 'select', 'formatting-adoption', 'foreign-content' ) as $feature_profile ) {
+ for ( $seed = 1; $seed <= 128; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, $feature_profile, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "{$feature_profile}/{$seed} feature-coverage sample should produce valid UTF-8 bytes." );
+ $features = $generated['parameters']['features'];
+ if ( in_array( 'generator:truncated', $features, true ) || in_array( 'generator:hard-truncated', $features, true ) ) {
+ continue;
+ }
+ foreach ( $features as $feature ) {
+ if ( array_key_exists( $feature, $found_generator_features ) ) {
+ $found_generator_features[ $feature ] = true;
+ }
+ }
+ if ( ! in_array( false, $found_generator_features, true ) ) {
+ $all_generator_features_found = true;
+ break 2;
+ }
+ }
+}
+html_api_fuzz_smoke_assert( $all_generator_features_found, 'generated samples should cover all required generator features before exhausting the smoke seed budget.' );
+foreach ( $found_generator_features as $feature => $found ) {
+ html_api_fuzz_smoke_assert( $found, "generated samples should cover {$feature}." );
+}
+
+$required_active_formatting_shapes = array(
+ 'active-formatting:same-tag-empty-attrs',
+ 'active-formatting:same-tag-distinct-attrs',
+ 'active-formatting:same-tag-matching-attrs',
+ 'active-formatting:mixed-formatting',
+ 'active-formatting:marker-boundary',
+);
+$found_active_formatting_shapes = array_fill_keys( $required_active_formatting_shapes, false );
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'formatting-adoption', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "formatting-adoption/{$seed} active-formatting shape sample should produce valid UTF-8 bytes." );
+ foreach ( $required_active_formatting_shapes as $feature ) {
+ if ( ! in_array( $feature, $generated['parameters']['features'], true ) ) {
+ continue;
+ }
+ html_api_fuzz_smoke_assert(
+ html_api_fuzz_smoke_has_active_formatting_shape( $generated['input'], $feature ),
+ "generated samples that record {$feature} should emit the corresponding active-formatting byte shape."
+ );
+ $found_active_formatting_shapes[ $feature ] = true;
+ }
+ if ( ! in_array( false, $found_active_formatting_shapes, true ) ) {
+ break;
+ }
+}
+foreach ( $found_active_formatting_shapes as $feature => $found ) {
+ html_api_fuzz_smoke_assert( $found, "formatting-adoption generation should emit {$feature} byte shapes." );
+}
+
+$required_comment_forms = array(
+ 'comment:ordinary-simple' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:empty' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:space' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:short-empty-end' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:short-hyphen-end' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:nested-hyphens' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:malformed-bang-ending' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:malformed-greater-than-ending' => array(
+ 'example' => '/s', $generated['input'], $matches ) ) {
+ foreach ( $matches[1] as $comment ) {
+ html_api_fuzz_smoke_note_syntax_chars( $comment, $found_syntax_contexts['comment content'] );
+ }
+ }
+ if ( preg_match_all( '~\s[-A-Za-z0-9_:.]+\s*=\s*(?:"([^"]*)"|\'([^\']*)\')~', $generated['input'], $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $attribute ) {
+ html_api_fuzz_smoke_note_syntax_chars( ( $attribute[1] ?? '' ) . ( $attribute[2] ?? '' ), $found_syntax_contexts['quoted attribute value'] );
+ }
+ }
+ }
+}
+foreach ( $found_text_fragment_lengths as $length => $found ) {
+ html_api_fuzz_smoke_assert( $found, "text-fragment generation should cover exact {$length}-byte inputs." );
+}
+html_api_fuzz_smoke_assert( $found_medium_text_fragment, 'text-fragment generation should cover medium-sized inputs.' );
+for ( $seed = 1; $seed <= 16; ++$seed ) {
+ $stress_text_fragment = \HtmlApiFuzz\Generator::generate( $seed, 'text-fragment', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'stress-long', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $stress_text_fragment['input'] ), 'text-fragment stress-long generation should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( strlen( $stress_text_fragment['input'] ) >= 64, 'text-fragment stress-long generation should honor the lower long-input bound.' );
+ html_api_fuzz_smoke_assert( strlen( $stress_text_fragment['input'] ) <= 1024, 'text-fragment stress-long generation should honor the upper long-input bound.' );
+ html_api_fuzz_smoke_assert( in_array( 'input:long', $stress_text_fragment['parameters']['features'], true ), 'text-fragment stress-long generation should record the long-input feature.' );
+}
+foreach ( $found_syntax_contexts as $context => $found_chars ) {
+ foreach ( $found_chars as $char => $found ) {
+ html_api_fuzz_smoke_assert( $found, "{$context} should expose syntax character {$char} in final generated HTML." );
+ }
+}
+
+$found_resource_stress = false;
+$found_resource_stress_long = false;
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'auto', 'auto', 'auto', 4096 );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), 'auto generation should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( ! in_array( 'payload:invalid-byte', $generated['parameters']['features'], true ), 'auto generation should not record invalid-byte payload features.' );
+ if ( ! in_array( $generated['profile'], array( 'attributes-entities', 'incomplete-malformed' ), true ) ) {
+ html_api_fuzz_smoke_assert( ! in_array( 'attr:malformed', $generated['parameters']['features'], true ), 'auto generation should keep malformed attributes in targeted profiles.' );
+ html_api_fuzz_smoke_assert( ! in_array( 'tag:weird-syntax', $generated['parameters']['features'], true ), 'auto generation should keep weird tag syntax in targeted profiles.' );
+ }
+ if ( 'resource-stress' === $generated['profile'] ) {
+ $found_resource_stress = true;
+ }
+ if ( 'stress-long' === $generated['payloadPolicy'] ) {
+ html_api_fuzz_smoke_assert( 'resource-stress' === $generated['profile'], 'auto stress-long payloads should stay in the resource-stress profile.' );
+ $found_resource_stress_long = true;
+ }
+}
+html_api_fuzz_smoke_assert( $found_resource_stress, 'auto generation should retain the resource-stress bucket.' );
+html_api_fuzz_smoke_assert( $found_resource_stress_long, 'resource-stress auto generation should retain stress-long payload coverage.' );
+for ( $seed = 1; $seed <= 64; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'balanced', 'auto', 'auto', 4096 );
+ html_api_fuzz_smoke_assert( 'stress-long' !== $generated['payloadPolicy'], 'non-resource explicit profiles should not auto-resolve stress-long.' );
+}
+
+$tmp = tempnam( sys_get_temp_dir(), 'html-api-fuzz-policy-smoke-' );
+if ( false === $tmp ) {
+ html_api_fuzz_smoke_fail( 'Could not create temp path.' );
+}
+@unlink( $tmp );
+\HtmlApiFuzz\ensure_dir( $tmp );
+register_shutdown_function( 'html_api_fuzz_smoke_rm_tree', $tmp );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function () use ( $tmp ): void {
+ \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'payload-policy' => 'valid-ut8',
+ 'output-dir' => $tmp . '/invalid-policy',
+ )
+ );
+ },
+ 'invalid direct-input payload policy should throw.'
+);
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function () use ( $tmp ): void {
+ \HtmlApiFuzz\Worker::run(
+ array(
+ 'seed' => '1',
+ 'profile' => 'balanced',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $tmp . '/generated-invalid-heavy',
+ )
+ );
+ },
+ 'generated worker inputs should reject legacy invalid-byte-heavy policy.'
+);
+
+$worker_dir = $tmp . '/worker';
+\HtmlApiFuzz\Worker::run(
+ array(
+ 'seed' => '17',
+ 'profile' => 'balanced',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'valid-utf8',
+ 'max-input-bytes' => '2048',
+ 'output-dir' => $worker_dir,
+ )
+);
+$worker_replay = \HtmlApiFuzz\read_json_file( $worker_dir . '/replay.json' );
+$worker_result = \HtmlApiFuzz\read_json_file( $worker_dir . '/result.json' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_replay['payloadPolicy'] ?? null ), 'replay should persist top-level payload policy.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_replay['generator']['payloadPolicy'] ?? null ), 'replay generator parameters should persist payload policy.' );
+html_api_fuzz_smoke_assert( 'generated' === ( $worker_replay['inputSource'] ?? null ), 'generated replay should record generated input source.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_result['payloadPolicy'] ?? null ), 'result should persist payload policy.' );
+html_api_fuzz_smoke_assert( ! empty( $worker_result['generator']['features'] ?? array() ), 'result should persist non-empty generator features.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['generator']['features'] ?? null ) === ( $worker_result['generator']['features'] ?? null ), 'result and replay should persist the same generator features.' );
+$git_metadata = \HtmlApiFuzz\git_metadata();
+html_api_fuzz_smoke_assert( array_key_exists( 'available', $git_metadata ), 'git metadata should report availability.' );
+html_api_fuzz_smoke_assert( array_key_exists( 'dirty', $git_metadata ), 'git metadata should report dirty state.' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( 1 === preg_match( '/^[0-9a-f]{7,}$/', $git_metadata['commit'] ?? '' ), 'git metadata should include a full hex commit hash.' );
+ html_api_fuzz_smoke_assert( ( $git_metadata['commit'] ?? null ) === ( $worker_replay['repoCommit'] ?? null ), 'replay should persist the current commit hash.' );
+ html_api_fuzz_smoke_assert( ( $git_metadata['dirty'] ?? null ) === ( $worker_replay['repoDirty'] ?? null ), 'replay should persist the tracked-file dirty flag.' );
+}
+$ancestor_repo = $tmp . '/ancestor-repo';
+$ancestor_child = $ancestor_repo . '/child';
+\HtmlApiFuzz\ensure_dir( $ancestor_child );
+$ancestor_init = \HtmlApiFuzz\run_git_command( array( 'init' ), 1000, $ancestor_repo );
+if ( 0 === $ancestor_init['code'] ) {
+ $ancestor_child_metadata = \HtmlApiFuzz\git_metadata( 1000, $ancestor_child );
+ html_api_fuzz_smoke_assert( false === ( $ancestor_child_metadata['available'] ?? null ), 'git metadata should not report an ancestor repository as the current repo.' );
+}
+$dirty_repo = $tmp . '/dirty-repo';
+\HtmlApiFuzz\ensure_dir( $dirty_repo );
+$dirty_init = \HtmlApiFuzz\run_git_command( array( 'init' ), 1000, $dirty_repo );
+if ( 0 === $dirty_init['code'] ) {
+ file_put_contents( $dirty_repo . '/tracked.txt', "clean\n" );
+ $dirty_add = \HtmlApiFuzz\run_git_command( array( 'add', 'tracked.txt' ), 1000, $dirty_repo );
+ $dirty_commit = \HtmlApiFuzz\run_git_command(
+ array(
+ '-c',
+ 'user.email=html-api-fuzz@example.invalid',
+ '-c',
+ 'user.name=HTML API Fuzz',
+ '-c',
+ 'commit.gpgsign=false',
+ 'commit',
+ '--no-gpg-sign',
+ '--no-verify',
+ '-m',
+ 'initial',
+ ),
+ 1000,
+ $dirty_repo
+ );
+ html_api_fuzz_smoke_assert( 0 === $dirty_add['code'], 'temp git repo should stage the tracked dirty fixture.' );
+ html_api_fuzz_smoke_assert( 0 === $dirty_commit['code'], 'temp git repo should commit the tracked dirty fixture.' );
+ $clean_repo_metadata = \HtmlApiFuzz\git_metadata( 1000, $dirty_repo, false );
+ html_api_fuzz_smoke_assert( true === ( $clean_repo_metadata['available'] ?? null ), 'temp git repo metadata should be available.' );
+ html_api_fuzz_smoke_assert( false === ( $clean_repo_metadata['dirty'] ?? null ), 'clean tracked temp git repo should report dirty false.' );
+ file_put_contents( $dirty_repo . '/tracked.txt', "dirty\n" );
+ $dirty_repo_metadata = \HtmlApiFuzz\git_metadata( 1000, $dirty_repo, false );
+ html_api_fuzz_smoke_assert( true === ( $dirty_repo_metadata['dirty'] ?? null ), 'modified tracked temp git repo should report dirty true.' );
+}
+$fake_git_root = $tmp . '/fake-git-root';
+$fake_git_bin = $tmp . '/fake-git-bin';
+\HtmlApiFuzz\ensure_dir( $fake_git_root );
+\HtmlApiFuzz\ensure_dir( $fake_git_bin );
+$fake_git = $fake_git_bin . '/git';
+file_put_contents(
+ $fake_git,
+ "#!/bin/sh\n" .
+ "if [ \"\$1\" = \"-C\" ]; then root=\"\$2\"; shift 2; else root=\"\$PWD\"; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"--show-toplevel\" ]; then printf '%s\\n' \"\$root\"; exit 0; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"HEAD\" ]; then printf '%s\\n' abcdef1234567890abcdef1234567890abcdef12; exit 0; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"--short=12\" ]; then printf '%s\\n' abcdef123456; exit 0; fi\n" .
+ "if [ \"\$1\" = \"branch\" ] && [ \"\$2\" = \"--show-current\" ]; then printf '%s\\n' main; exit 0; fi\n" .
+ "if [ \"\$1\" = \"show\" ]; then printf '%s\\n' 2026-01-01T00:00:00+00:00; exit 0; fi\n" .
+ "if [ \"\$1\" = \"diff\" ]; then exit 2; fi\n" .
+ "exit 1\n"
+);
+chmod( $fake_git, 0755 );
+$old_path = getenv( 'PATH' );
+putenv( 'PATH=' . $fake_git_bin . PATH_SEPARATOR . ( false === $old_path ? '' : $old_path ) );
+$unknown_dirty_metadata = \HtmlApiFuzz\git_metadata( 1000, $fake_git_root, false );
+if ( false === $old_path ) {
+ putenv( 'PATH' );
+} else {
+ putenv( 'PATH=' . $old_path );
+}
+html_api_fuzz_smoke_assert( true === ( $unknown_dirty_metadata['available'] ?? null ), 'git metadata should remain available when only dirty detection fails.' );
+html_api_fuzz_smoke_assert( array_key_exists( 'dirty', $unknown_dirty_metadata ) && null === $unknown_dirty_metadata['dirty'], 'dirty detection failures should report dirty null.' );
+
+$replay_cli_dir = $tmp . '/replay-cli';
+$replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $worker_dir . '/replay.json',
+ '--output-dir',
+ $replay_cli_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $replay_proc['timedOut'] && in_array( $replay_proc['code'], array( 0, 2 ), true ), 'replay CLI should complete.' );
+$replay_cli_replay = \HtmlApiFuzz\read_json_file( $replay_cli_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $replay_cli_replay['generator'] ?? null ), 'replay CLI output should not invent immediate generator metadata.' );
+html_api_fuzz_smoke_assert( 'input-file' === ( $replay_cli_replay['inputSource'] ?? null ), 'replay CLI output should record immediate input source.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['generator'] ?? null ) === ( $replay_cli_replay['originalGenerator'] ?? null ), 'replay CLI output should preserve original generator metadata.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['repoCommit'] ?? null ) === ( $replay_cli_replay['sourceReplay']['repoCommit'] ?? null ), 'replay CLI output should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['repoDirty'] ?? null ) === ( $replay_cli_replay['sourceReplay']['repoDirty'] ?? null ), 'replay CLI output should preserve source replay dirty metadata.' );
+
+$legacy_replay = $worker_replay;
+$legacy_replay['payloadPolicy'] = 'replay';
+if ( isset( $legacy_replay['generator']['payloadPolicy'] ) ) {
+ $legacy_replay['generator']['payloadPolicy'] = 'replay';
+}
+$legacy_replay_path = $tmp . '/legacy-payload-policy-replay.json';
+\HtmlApiFuzz\write_json_file( $legacy_replay_path, $legacy_replay );
+$legacy_replay_dir = $tmp . '/legacy-replay-cli';
+$legacy_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $legacy_replay_path,
+ '--output-dir',
+ $legacy_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/legacy-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $legacy_replay_proc['timedOut'] && in_array( $legacy_replay_proc['code'], array( 0, 2 ), true ), 'legacy replay payload policy labels should not make replay fatal.' );
+$legacy_replay_cli_replay = \HtmlApiFuzz\read_json_file( $legacy_replay_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $legacy_replay_cli_replay['payloadPolicy'] ?? null ), 'legacy replay payload policy labels should be treated as unlabeled direct input.' );
+html_api_fuzz_smoke_assert( 'replay' === ( $legacy_replay_cli_replay['originalGenerator']['payloadPolicy'] ?? null ), 'legacy replay should preserve original generator metadata.' );
+
+$legacy_invalid_replay = $worker_replay;
+$legacy_invalid_replay['payloadPolicy'] = 'invalid-byte-heavy';
+if ( isset( $legacy_invalid_replay['generator']['payloadPolicy'] ) ) {
+ $legacy_invalid_replay['generator']['payloadPolicy'] = 'invalid-byte-heavy';
+}
+$legacy_invalid_replay_path = $tmp . '/legacy-invalid-payload-policy-replay.json';
+\HtmlApiFuzz\write_json_file( $legacy_invalid_replay_path, $legacy_invalid_replay );
+$legacy_invalid_replay_dir = $tmp . '/legacy-invalid-replay-cli';
+$legacy_invalid_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $legacy_invalid_replay_path,
+ '--output-dir',
+ $legacy_invalid_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/legacy-invalid-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $legacy_invalid_replay_proc['timedOut'] && in_array( $legacy_invalid_replay_proc['code'], array( 0, 2 ), true ), 'legacy invalid-byte-heavy replay payload policy label should not make replay fatal.' );
+$legacy_invalid_replay_cli_replay = \HtmlApiFuzz\read_json_file( $legacy_invalid_replay_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $legacy_invalid_replay_cli_replay['payloadPolicy'] ?? null ), 'legacy invalid-byte-heavy replay payload policy label should be preserved as direct-input metadata.' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $legacy_invalid_replay_cli_replay['originalGenerator']['payloadPolicy'] ?? null ), 'legacy invalid-byte-heavy replay should preserve original generator metadata.' );
+
+$invalid_byte_replay_source_dir = $tmp . '/invalid-byte-replay-source';
+$invalid_byte_replay_source = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( '' . str_repeat( 'a', 220 ) . "\xC0" . '
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $invalid_byte_replay_source_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+);
+html_api_fuzz_smoke_assert( 'encoding-mismatch' === ( $invalid_byte_replay_source['failureClass'] ?? null ), 'invalid-byte replay fixture should be a real invalid-byte failure.' );
+
+$invalid_byte_replay_cli_dir = $tmp . '/invalid-byte-replay-cli';
+$invalid_byte_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_replay_cli_dir,
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/invalid-byte-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_replay_proc['timedOut'] && 2 === $invalid_byte_replay_proc['code'], 'real invalid-byte replay should complete as a replayed failure.' );
+$invalid_byte_replay_cli_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_replay_cli_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $invalid_byte_replay_cli_replay['payloadPolicy'] ?? null ), 'real invalid-byte replay should preserve legacy payload policy metadata.' );
+
+$invalid_byte_exact_minimize_dir = $tmp . '/invalid-byte-exact-minimize';
+$invalid_byte_exact_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_exact_minimize_dir,
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-exact-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_exact_minimize_proc['timedOut'] && 0 === $invalid_byte_exact_minimize_proc['code'], 'exact-signature invalid-byte replay should minimize.' );
+$invalid_byte_exact_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_exact_minimize_dir . '/minimize-result.json' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_exact_minimize_result['ok'] ?? null ), 'exact-signature invalid-byte minimization should preserve the target signature.' );
+html_api_fuzz_smoke_assert( 'process' === ( $invalid_byte_exact_minimize_result['probeMode'] ?? null ), 'auto exact-signature minimization should use timeout-enforced process probes by default.' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_exact_minimize_result['candidateArtifactsRetained'] ?? null ), 'auto process minimization should report retained candidate artifacts.' );
+html_api_fuzz_smoke_assert( 1 === ( $invalid_byte_exact_minimize_result['attempts'] ?? null ), 'exact-signature smoke minimization should run the requested single probe.' );
+html_api_fuzz_smoke_assert( is_array( $invalid_byte_exact_minimize_result['probeTiming'] ?? null ), 'exact-signature minimization should report probe timing.' );
+html_api_fuzz_smoke_assert( is_dir( $invalid_byte_exact_minimize_dir . '/candidates' ), 'auto process minimization should retain per-candidate artifact directories.' );
+
+$invalid_byte_in_process_minimize_dir = $tmp . '/invalid-byte-in-process-minimize';
+$invalid_byte_in_process_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_in_process_minimize_dir,
+ '--probe-mode',
+ 'in-process',
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-in-process-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_in_process_minimize_proc['timedOut'] && 0 === $invalid_byte_in_process_minimize_proc['code'], 'explicit in-process invalid-byte replay should minimize.' );
+$invalid_byte_in_process_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_in_process_minimize_dir . '/minimize-result.json' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_in_process_minimize_result['ok'] ?? null ), 'explicit in-process minimization should preserve the target signature.' );
+html_api_fuzz_smoke_assert( 'in-process' === ( $invalid_byte_in_process_minimize_result['probeMode'] ?? null ), 'explicit in-process minimization should use in-process probes.' );
+html_api_fuzz_smoke_assert( false === ( $invalid_byte_in_process_minimize_result['candidateArtifactsRetained'] ?? null ), 'in-process minimization should not retain candidate artifacts by default.' );
+html_api_fuzz_smoke_assert( ! is_dir( $invalid_byte_in_process_minimize_dir . '/candidates' ), 'in-process minimization should avoid per-candidate artifact directories by default.' );
+
+$invalid_byte_minimize_dir = $tmp . '/invalid-byte-minimize';
+$invalid_byte_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_minimize_dir,
+ '--any-failure',
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_minimize_proc['timedOut'] && 0 === $invalid_byte_minimize_proc['code'], 'real invalid-byte replay should remain minimizable.' );
+$invalid_byte_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_minimize_dir . '/minimize-result.json' );
+$invalid_byte_minimize_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_minimize_result['minimizedReplay'] ?? '' );
+$invalid_byte_source_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_replay_source_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'process' === ( $invalid_byte_minimize_result['probeMode'] ?? null ), 'any-failure minimization should use process probes by default.' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_minimize_result['candidateArtifactsRetained'] ?? null ), 'process-mode minimization should report retained candidate artifacts.' );
+html_api_fuzz_smoke_assert( is_dir( $invalid_byte_minimize_dir . '/candidates' ), 'process-mode minimization should retain per-candidate artifact directories.' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $invalid_byte_minimize_result['payloadPolicy'] ?? null ), 'invalid-byte minimization should preserve legacy payload policy metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoCommit'] ?? null ) === ( $invalid_byte_minimize_result['sourceReplay']['repoCommit'] ?? null ), 'minimize result should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoDirty'] ?? null ) === ( $invalid_byte_minimize_result['sourceReplay']['repoDirty'] ?? null ), 'minimize result should preserve source replay dirty metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoCommit'] ?? null ) === ( $invalid_byte_minimize_replay['sourceReplay']['repoCommit'] ?? null ), 'minimized replay should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoDirty'] ?? null ) === ( $invalid_byte_minimize_replay['sourceReplay']['repoDirty'] ?? null ), 'minimized replay should preserve source replay dirty metadata.' );
+
+$bad_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--payload-policy',
+ 'valid-ut8',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/bad-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/bad-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $bad_runner_proc['code'], 'runner CLI should reject invalid payload policy before starting workers.' );
+
+$legacy_invalid_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--payload-policy',
+ 'invalid-byte-heavy',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/legacy-invalid-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/legacy-invalid-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $legacy_invalid_runner_proc['code'], 'runner CLI should reject legacy invalid-byte-heavy generation policy.' );
+
+$legacy_invalid_launcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--payload-policy',
+ 'invalid-byte-heavy',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ '--output-dir',
+ $tmp . '/legacy-invalid-launcher',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/legacy-invalid-launcher.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $legacy_invalid_launcher_proc['code'], 'launcher CLI should reject legacy invalid-byte-heavy generation policy.' );
+
+$metadata_runner_dir = $tmp . '/metadata-runner';
+$metadata_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ // Passing seed directories are pruned by default; this run asserts on
+ // the on-disk replay document.
+ '--keep-all-artifacts',
+ '--output-dir',
+ $metadata_runner_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 15000,
+ $tmp . '/metadata-runner.log'
+);
+html_api_fuzz_smoke_assert( ! $metadata_runner_proc['timedOut'] && 0 === $metadata_runner_proc['code'], 'runner metadata smoke run should complete.' );
+$metadata_runner_state = \HtmlApiFuzz\read_json_file( $metadata_runner_dir . '/state.json' );
+html_api_fuzz_smoke_assert( 'html-api-fuzz-runner-state' === ( $metadata_runner_state['kind'] ?? null ), 'runner state should be written.' );
+html_api_fuzz_smoke_assert( is_array( $metadata_runner_state['git'] ?? null ), 'runner state should include compact git metadata.' );
+$metadata_runner_events = \HtmlApiFuzz\read_ndjson_records( $metadata_runner_dir . '/events.ndjson' );
+html_api_fuzz_smoke_assert( is_array( $metadata_runner_events[0]['git'] ?? null ), 'runner start event should include compact git metadata.' );
+$metadata_runner_replay = \HtmlApiFuzz\read_json_file( $metadata_runner_dir . '/seed-1/primary/replay.json' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_runner_state['git']['commit'] ?? null ), 'runner state git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_runner_events[0]['git']['commit'] ?? null ), 'runner start event git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $metadata_runner_state['git']['commit'] === ( $metadata_runner_replay['repoCommit'] ?? null ), 'runner worker replay should use runner-provided git metadata.' );
+ html_api_fuzz_smoke_assert( $metadata_runner_state['git']['dirty'] === ( $metadata_runner_replay['repoDirty'] ?? null ), 'runner worker replay should use runner-provided dirty metadata.' );
+}
+
+$metadata_launcher_dir = $tmp . '/metadata-launcher';
+$metadata_launcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--lanes',
+ '1',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ // Passing seed directories are pruned by default; this run asserts on
+ // the on-disk replay document.
+ '--keep-all-artifacts',
+ // Non-default value pins the launcher-to-lane flag passthrough.
+ '--max-keep-per-signature',
+ '3',
+ '--output-dir',
+ $metadata_launcher_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/metadata-launcher.log'
+);
+html_api_fuzz_smoke_assert( ! $metadata_launcher_proc['timedOut'] && 0 === $metadata_launcher_proc['code'], 'launcher metadata smoke run should complete.' );
+$metadata_launcher_state = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/launcher-state.json' );
+html_api_fuzz_smoke_assert( 'html-api-fuzz-launcher-state' === ( $metadata_launcher_state['kind'] ?? null ), 'launcher state should be written.' );
+html_api_fuzz_smoke_assert( is_array( $metadata_launcher_state['git'] ?? null ), 'launcher state should include compact git metadata.' );
+$metadata_launcher_events = \HtmlApiFuzz\read_ndjson_records( $metadata_launcher_dir . '/events.ndjson' );
+html_api_fuzz_smoke_assert( is_array( $metadata_launcher_events[0]['git'] ?? null ), 'launcher start event should include compact git metadata.' );
+$metadata_launcher_lane_state = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/lane-00/state.json' );
+html_api_fuzz_smoke_assert( 3 === ( $metadata_launcher_lane_state['maxKeepPerSignature'] ?? null ), 'launcher should pass --max-keep-per-signature through to lanes.' );
+$metadata_launcher_replay = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/lane-00/seed-1/primary/replay.json' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_launcher_state['git']['commit'] ?? null ), 'launcher state git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_launcher_events[0]['git']['commit'] ?? null ), 'launcher start event git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $metadata_launcher_state['git']['commit'] === ( $metadata_launcher_replay['repoCommit'] ?? null ), 'launcher worker replay should use launcher-provided git metadata.' );
+ html_api_fuzz_smoke_assert( $metadata_launcher_state['git']['dirty'] === ( $metadata_launcher_replay['repoDirty'] ?? null ), 'launcher worker replay should use launcher-provided dirty metadata.' );
+}
+
+$launcher_oracle_watcher_dir = $tmp . '/launcher-oracle-watcher';
+$launcher_oracle_watcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--lanes',
+ '1',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ '--watcher',
+ '--no-minimize',
+ '--triage-oracle-findings',
+ '--output-dir',
+ $launcher_oracle_watcher_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 30000,
+ $tmp . '/launcher-oracle-watcher.log'
+);
+html_api_fuzz_smoke_assert( ! $launcher_oracle_watcher_proc['timedOut'] && 0 === $launcher_oracle_watcher_proc['code'], 'launcher oracle watcher passthrough run should complete.' );
+$launcher_oracle_watcher = json_decode( $launcher_oracle_watcher_proc['stdout'], true );
+html_api_fuzz_smoke_assert( 0 === ( $launcher_oracle_watcher['watcherResult']['code'] ?? null ), 'launcher oracle watcher should exit cleanly.' );
+$launcher_oracle_watcher_log = trim( (string) file_get_contents( $launcher_oracle_watcher['watcherResult']['logPath'] ?? '' ) );
+$launcher_oracle_watcher_scan = json_decode( $launcher_oracle_watcher_log, true );
+html_api_fuzz_smoke_assert( true === ( $launcher_oracle_watcher_scan['triageOracleFindings'] ?? null ), 'launcher should pass --triage-oracle-findings through to watcher.' );
+
+$bad_stride_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--seed-stride',
+ '0',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/bad-stride-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/bad-stride-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $bad_stride_proc['code'], 'runner CLI should reject non-positive seed strides before starting workers.' );
+
+$unlabeled_dir = $tmp . '/unlabeled-direct';
+$unlabeled_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'output-dir' => $unlabeled_dir,
+ )
+);
+$unlabeled_replay = \HtmlApiFuzz\read_json_file( $unlabeled_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_result['payloadPolicy'] ?? null ), 'unlabeled direct input result should leave payloadPolicy null.' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_replay['payloadPolicy'] ?? null ), 'unlabeled direct input replay should leave payloadPolicy null.' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_result['generator'] ?? null ), 'unlabeled direct input should not invent generator metadata.' );
+html_api_fuzz_smoke_assert( 'input-base64' === ( $unlabeled_replay['inputSource'] ?? null ), 'unlabeled direct input replay should record inputSource.' );
+html_api_fuzz_smoke_assert( 'idempotent' === ( $unlabeled_result['tagProcessor']['normalize']['status'] ?? null ), 'worker result should persist normalize() idempotence metadata.' );
+
+$normalize_idempotent = \HtmlApiFuzz\TagInvariants::check( 'One 
', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_idempotent['ok'] ?? null ), 'normalizable input should pass tag invariants.' );
+html_api_fuzz_smoke_assert( 'idempotent' === ( $normalize_idempotent['normalize']['status'] ?? null ), 'normalizable input should record an idempotent normalize() status.' );
+html_api_fuzz_smoke_assert( is_string( $normalize_idempotent['normalize']['normalizedSha1'] ?? null ), 'idempotent normalize() metadata should include the normalized hash.' );
+
+$normalize_unsupported = \HtmlApiFuzz\TagInvariants::check( '', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_unsupported['ok'] ?? null ), 'unsupported normalize() input should not fail unrelated tag invariants.' );
+html_api_fuzz_smoke_assert( 'unsupported' === ( $normalize_unsupported['normalize']['status'] ?? null ), 'unsupported normalize() input should be recorded without an idempotence failure.' );
+
+$normalize_not_idempotent = \HtmlApiFuzz\TagInvariants::check( ' ', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_not_idempotent['ok'] ?? null ), 'normalize() metadata should not fail unrelated tag invariants directly.' );
+if ( false === ( $normalize_not_idempotent['normalize']['ok'] ?? true ) ) {
+ html_api_fuzz_smoke_assert( 'normalize-not-idempotent' === ( $normalize_not_idempotent['normalize']['failure']['name'] ?? null ), 'non-idempotent normalize() input should report the normalize-not-idempotent invariant.' );
+ html_api_fuzz_smoke_assert( 'failed' === ( $normalize_not_idempotent['normalize']['status'] ?? null ), 'non-idempotent normalize() input should record failed normalize() metadata.' );
+ html_api_fuzz_smoke_assert( is_int( $normalize_not_idempotent['normalize']['firstDifference']['firstByteOffset'] ?? null ), 'non-idempotent normalize() metadata should include a first byte difference.' );
+}
+
+$resource_dir = $tmp . '/resource-limit';
+$resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( str_repeat( '', 12 ) ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $resource_dir,
+ 'max-tokens' => '1',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $resource_result['failureClass'] ?? null ), 'token ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $resource_result['status'] ?? null ), 'token ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( in_array( 'tag-token-limit-exceeded', $resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include concrete limit failure names.' );
+html_api_fuzz_smoke_assert( 'input-base64' === ( $resource_result['inputSource'] ?? null ), 'provided base64 input should record inputSource.' );
+html_api_fuzz_smoke_assert( null === ( $resource_result['generator'] ?? null ), 'provided input should not invent generator metadata.' );
+
+$normalize_resource_dir = $tmp . '/normalize-resource-limit';
+$normalize_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $normalize_resource_dir,
+ 'max-tokens' => '1',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $normalize_resource_result['failureClass'] ?? null ), 'tag token ceilings should not be masked by normalize() failures.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $normalize_resource_result['status'] ?? null ), 'tag token ceilings should retain resource-limit status when normalize() would otherwise fail.' );
+html_api_fuzz_smoke_assert( 'skipped-resource-limit' === ( $normalize_resource_result['tagProcessor']['normalize']['status'] ?? null ), 'normalize() idempotence should be skipped after tag resource limits.' );
+html_api_fuzz_smoke_assert( in_array( 'tag-token-limit-exceeded', $normalize_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should still include tag token limit failures when normalize() is skipped.' );
+
+$dom_resource_dir = $tmp . '/dom-resource-limit';
+$dom_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_resource_dir,
+ 'max-tokens' => '100',
+ 'max-nodes' => '1',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_resource_result['failureClass'] ?? null ), 'DOM node ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_resource_result['status'] ?? null ), 'DOM node ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( 'node-limit-exceeded' === ( $dom_resource_result['dom']['failureClass'] ?? null ), 'DOM result should preserve the concrete node limit failure.' );
+html_api_fuzz_smoke_assert( in_array( 'dom-node-limit-exceeded', $dom_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include DOM node limit failures.' );
+
+$dom_template_context_dir = $tmp . '/dom-template-context-sensitive-col';
+$dom_template_context_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_template_context_dir,
+ 'max-tokens' => '100',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( true === ( $dom_template_context_result['ok'] ?? null ), 'DOM template table-sensitive fallback should not be a failing tree mismatch.' );
+html_api_fuzz_smoke_assert( 'oracle-unsupported' === ( $dom_template_context_result['status'] ?? null ), 'DOM template table-sensitive fallback should be quarantined as oracle-unsupported.' );
+html_api_fuzz_smoke_assert( 'oracle-unsupported' === ( $dom_template_context_result['failureClass'] ?? null ), 'DOM template table-sensitive fallback should preserve the oracle-unsupported failure class.' );
+html_api_fuzz_smoke_assert( 'unsupported' === ( $dom_template_context_result['dom']['status'] ?? null ), 'DOM template table-sensitive fallback should preserve the DOM unsupported status.' );
+html_api_fuzz_smoke_assert( null === ( $dom_template_context_result['comparison'] ?? null ), 'DOM template table-sensitive fallback should not compare a lossy DOM tree.' );
+html_api_fuzz_smoke_assert( null === ( $dom_template_context_result['signature'] ?? null ), 'DOM template table-sensitive fallback should not produce a fuzz signature.' );
+html_api_fuzz_smoke_assert( 'oracle-limitation' === ( $dom_template_context_result['oracleFinding']['classification'] ?? null ), 'DOM template table-sensitive fallback should produce an oracle-limitation finding.' );
+html_api_fuzz_smoke_assert( 'dom-template-context-unsupported' === ( $dom_template_context_result['oracleFinding']['type'] ?? null ), 'DOM template table-sensitive fallback should identify the oracle finding type.' );
+html_api_fuzz_smoke_assert( str_starts_with( (string) ( $dom_template_context_result['oracleFinding']['signature']['hash'] ?? '' ), 'oracle-' ), 'DOM template table-sensitive fallback should produce an oracle finding signature.' );
+$dom_template_context_wp_tree = file_get_contents( $dom_template_context_result['wordpress']['treePath'] ?? '' );
+html_api_fuzz_smoke_assert( false !== $dom_template_context_wp_tree && false !== strpos( $dom_template_context_wp_tree, " \n" ), 'DOM template context regression should exercise WordPress preservation.' );
+
+$dom_nested_template_context_dir = $tmp . '/dom-nested-template-context-sensitive-col';
+$dom_nested_template_context_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_nested_template_context_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ )
+);
+html_api_fuzz_smoke_assert( true === ( $dom_nested_template_context_result['ok'] ?? null ), 'Nested DOM template table-sensitive fallback should not be a failing tree mismatch.' );
+html_api_fuzz_smoke_assert( 'oracle-unsupported' === ( $dom_nested_template_context_result['status'] ?? null ), 'Nested DOM template table-sensitive fallback should be quarantined as oracle-unsupported.' );
+html_api_fuzz_smoke_assert( null === ( $dom_nested_template_context_result['comparison'] ?? null ), 'Nested DOM template table-sensitive fallback should not compare a lossy DOM tree.' );
+html_api_fuzz_smoke_assert( null === ( $dom_nested_template_context_result['signature'] ?? null ), 'Nested DOM template table-sensitive fallback should not produce a fuzz signature.' );
+
+/*
+ * Template content that round-trips faithfully through body-context fragment
+ * parsing is rendered and counted: node ceilings still apply inside template
+ * content and must win over any other classification.
+ */
+$dom_template_context_resource_dir = $tmp . '/dom-template-context-resource-limit';
+$dom_template_context_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'a b ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_template_context_resource_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '3',
+ )
+);
+html_api_fuzz_smoke_assert( false === ( $dom_template_context_resource_result['ok'] ?? null ), 'DOM template rendering should not mask DOM node ceilings.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_template_context_resource_result['status'] ?? null ), 'DOM template node ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_template_context_resource_result['failureClass'] ?? null ), 'DOM template node ceilings should use resource-limit failure class.' );
+html_api_fuzz_smoke_assert( 'node-limit-exceeded' === ( $dom_template_context_resource_result['dom']['failureClass'] ?? null ), 'DOM template rendering should preserve the concrete DOM node ceiling.' );
+
+/*
+ * Template content that cannot round-trip is quarantined as unsupported even
+ * under a small node budget: the count of its (lossy) body-context parse is
+ * bounded, so quarantine stays deterministic for resource-stress inputs.
+ */
+$dom_template_context_quarantine_dir = $tmp . '/dom-template-context-quarantine-small-budget';
+$dom_template_context_quarantine_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_template_context_quarantine_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '3',
+ )
+);
+html_api_fuzz_smoke_assert( true === ( $dom_template_context_quarantine_result['ok'] ?? null ), 'Non-round-trippable DOM template content should be quarantined, not failed.' );
+html_api_fuzz_smoke_assert( 'oracle-unsupported' === ( $dom_template_context_quarantine_result['status'] ?? null ), 'Non-round-trippable DOM template content should be quarantined as oracle-unsupported.' );
+html_api_fuzz_smoke_assert( 'oracle-limitation' === ( $dom_template_context_quarantine_result['oracleFinding']['classification'] ?? null ), 'Non-round-trippable DOM template content should remain visible as an oracle limitation.' );
+
+$dom_template_table_context_dir = $tmp . '/dom-template-table-context';
+$dom_template_table_context_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_template_table_context_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ )
+);
+html_api_fuzz_smoke_assert( true === ( $dom_template_table_context_result['comparison']['ok'] ?? null ), 'DOM template table-contained content should compare cleanly.' );
+if ( false === ( $dom_template_table_context_result['tagProcessor']['normalize']['ok'] ?? true ) ) {
+ html_api_fuzz_smoke_assert( false === ( $dom_template_table_context_result['ok'] ?? null ), 'DOM template table-contained content should surface normalize() failures after comparison.' );
+ html_api_fuzz_smoke_assert( 'normalize-invariant-failed' === ( $dom_template_table_context_result['failureClass'] ?? null ), 'DOM template table-contained normalize() failures should be classified separately.' );
+} else {
+ html_api_fuzz_smoke_assert( true === ( $dom_template_table_context_result['ok'] ?? null ), 'DOM template fallback should compare table-contained content.' );
+ html_api_fuzz_smoke_assert( 'passed' === ( $dom_template_table_context_result['status'] ?? null ), 'DOM template fallback should not quarantine table-contained content.' );
+ html_api_fuzz_smoke_assert( null === ( $dom_template_table_context_result['signature'] ?? null ), 'DOM template table-contained content should not produce a fuzz signature.' );
+}
+
+$dom_template_foreign_context_dir = $tmp . '/dom-template-foreign-context';
+$dom_template_foreign_context_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_template_foreign_context_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ )
+);
+html_api_fuzz_smoke_assert( true === ( $dom_template_foreign_context_result['comparison']['ok'] ?? null ), 'DOM template foreign-content overlap should compare cleanly.' );
+if ( false === ( $dom_template_foreign_context_result['tagProcessor']['normalize']['ok'] ?? true ) ) {
+ html_api_fuzz_smoke_assert( false === ( $dom_template_foreign_context_result['ok'] ?? null ), 'DOM template foreign-content overlap should surface normalize() failures after comparison.' );
+ html_api_fuzz_smoke_assert( 'normalize-invariant-failed' === ( $dom_template_foreign_context_result['failureClass'] ?? null ), 'DOM template foreign-content normalize() failures should be classified separately.' );
+} else {
+ html_api_fuzz_smoke_assert( true === ( $dom_template_foreign_context_result['ok'] ?? null ), 'DOM template fallback should compare foreign-content tag names that overlap table names.' );
+ html_api_fuzz_smoke_assert( 'passed' === ( $dom_template_foreign_context_result['status'] ?? null ), 'DOM template fallback should not quarantine foreign-content tag names that overlap table names.' );
+ html_api_fuzz_smoke_assert( null === ( $dom_template_foreign_context_result['signature'] ?? null ), 'DOM template foreign-content overlap should not produce a fuzz signature.' );
+}
+
+$dom_oracle_needs_xlink_tolerance = html_api_fuzz_smoke_dom_drops_bare_xlink_local_name_after_xlink();
+$dom_oracle_xlink_fragment_wp = \HtmlApiFuzz\TreeRenderer::render_wordpress(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+$dom_oracle_xlink_fragment_dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_fragment_wp['status'] ?? null ), 'DOM XLink fragment fixture should render with WordPress.' );
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_fragment_dom['status'] ?? null ), 'DOM XLink fragment fixture should render with the DOM oracle.' );
+$dom_oracle_xlink_fragment_comparison = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ $dom_oracle_xlink_fragment_wp['tree'],
+ $dom_oracle_xlink_fragment_dom['tree'],
+ $dom_oracle_xlink_fragment_wp['domOracleLineTolerances'] ?? array()
+);
+html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_fragment_comparison['ok'] ?? null ), 'DOM XLink oracle limitation should compare after fragment tolerance.' );
+if ( $dom_oracle_needs_xlink_tolerance ) {
+ html_api_fuzz_smoke_assert( array( 1 ) === ( $dom_oracle_xlink_fragment_wp['domOracleLineTolerances'] ?? null ), 'DOM XLink fragment tolerance should identify only the dropped WordPress attribute line.' );
+} else {
+ html_api_fuzz_smoke_assert( array() === ( $dom_oracle_xlink_fragment_wp['domOracleLineTolerances'] ?? null ), 'Fixed DOM runtimes should not record XLink oracle tolerance lines.' );
+}
+
+$dom_oracle_xlink_worker_dir = $tmp . '/dom-oracle-xlink-worker';
+$dom_oracle_xlink_worker_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_oracle_xlink_worker_dir,
+ 'max-tokens' => '100',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_worker_result['dom']['status'] ?? null ), 'Worker should still render the DOM oracle when normalize() fails independently.' );
+html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_worker_result['comparison']['ok'] ?? null ), 'Worker should still compare DOM trees when normalize() fails independently.' );
+if ( $dom_oracle_needs_xlink_tolerance ) {
+ html_api_fuzz_smoke_assert( 'oracle-bug' === ( $dom_oracle_xlink_worker_result['oracleFinding']['classification'] ?? null ), 'Worker should preserve the XLink DOM tolerance as an oracle-bug finding.' );
+ html_api_fuzz_smoke_assert( 'dom-xlink-dropped-local-name-after-xlink' === ( $dom_oracle_xlink_worker_result['oracleFinding']['type'] ?? null ), 'Worker should identify the XLink oracle finding type.' );
+ html_api_fuzz_smoke_assert( 'https://github.com/lexbor/lexbor/issues/372' === ( $dom_oracle_xlink_worker_result['oracleFinding']['upstream']['issueUrl'] ?? null ), 'Worker should link the XLink oracle finding to the upstream Lexbor issue.' );
+} else {
+ html_api_fuzz_smoke_assert( null === ( $dom_oracle_xlink_worker_result['oracleFinding'] ?? null ), 'Fixed DOM runtimes should not produce an XLink oracle finding.' );
+}
+if ( false === ( $normalize_not_idempotent['normalize']['ok'] ?? true ) ) {
+ html_api_fuzz_smoke_assert( false === ( $dom_oracle_xlink_worker_result['ok'] ?? null ), 'Worker should fail when normalize() is non-idempotent.' );
+ html_api_fuzz_smoke_assert( 'normalize-invariant-failed' === ( $dom_oracle_xlink_worker_result['failureClass'] ?? null ), 'Worker should classify normalize() idempotence failures separately.' );
+ html_api_fuzz_smoke_assert( 'normalize-not-idempotent' === ( $dom_oracle_xlink_worker_result['signature']['facts']['invariant'] ?? null ), 'Normalize failure signature should record the concrete invariant.' );
+ html_api_fuzz_smoke_assert( ( $normalize_not_idempotent['normalize']['normalizedSha1'] ?? null ) === ( $dom_oracle_xlink_worker_result['signature']['facts']['normalizedSha1'] ?? null ), 'Normalize failure signature should include the normalized hash.' );
+ html_api_fuzz_smoke_assert( is_int( $dom_oracle_xlink_worker_result['signature']['facts']['firstByteOffset'] ?? null ), 'Normalize failure signature should include first-difference metadata.' );
+} else {
+ html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_worker_result['ok'] ?? null ), 'Worker should pass the XLink fixture once normalize() is idempotent.' );
+ html_api_fuzz_smoke_assert( ( $dom_oracle_needs_xlink_tolerance ? 'oracle-tolerated' : 'passed' ) === ( $dom_oracle_xlink_worker_result['status'] ?? null ), 'Worker should keep normal DOM oracle status once normalize() is idempotent.' );
+}
+
+$dom_oracle_mathml_heading_fixture = 'x
';
+$dom_oracle_needs_mathml_heading_tolerance = html_api_fuzz_smoke_dom_reparents_heading_after_mathml_text_integration_point();
+$dom_oracle_mathml_heading_dir = $tmp . '/dom-oracle-mathml-heading-worker';
+$dom_oracle_mathml_heading_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $dom_oracle_mathml_heading_fixture ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_oracle_mathml_heading_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ )
+);
+if ( $dom_oracle_needs_mathml_heading_tolerance ) {
+ html_api_fuzz_smoke_assert( true === ( $dom_oracle_mathml_heading_result['ok'] ?? null ), 'Known MathML heading DOM oracle bug should not be a WordPress failure.' );
+ html_api_fuzz_smoke_assert( 'oracle-tolerated' === ( $dom_oracle_mathml_heading_result['status'] ?? null ), 'Known MathML heading DOM oracle bug should be reported as oracle-tolerated.' );
+ html_api_fuzz_smoke_assert( null === ( $dom_oracle_mathml_heading_result['signature'] ?? null ), 'Known MathML heading DOM oracle bug should not produce a failure signature.' );
+ html_api_fuzz_smoke_assert( 'oracle-bug' === ( $dom_oracle_mathml_heading_result['oracleFinding']['classification'] ?? null ), 'Known MathML heading DOM oracle bug should produce an oracle-bug finding.' );
+ html_api_fuzz_smoke_assert( 'dom-mathml-heading-scope-reparenting' === ( $dom_oracle_mathml_heading_result['oracleFinding']['type'] ?? null ), 'Known MathML heading DOM oracle bug should identify the oracle finding type.' );
+ html_api_fuzz_smoke_assert( 'https://github.com/lexbor/lexbor/issues/373' === ( $dom_oracle_mathml_heading_result['oracleFinding']['upstream']['issueUrl'] ?? null ), 'Known MathML heading DOM oracle bug should link to the upstream Lexbor issue.' );
+ $dom_oracle_mathml_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $dom_oracle_mathml_heading_dir . '/replay.json',
+ '--output-dir',
+ $tmp . '/dom-oracle-mathml-heading-minimize',
+ '--max-attempts',
+ '0',
+ '--timeout-ms',
+ '2500',
+ '--any-failure',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 30000,
+ $tmp . '/dom-oracle-mathml-heading-minimize.log'
+ );
+ $dom_oracle_mathml_minimize = json_decode( $dom_oracle_mathml_minimize_proc['stdout'], true );
+ html_api_fuzz_smoke_assert( 0 === $dom_oracle_mathml_minimize_proc['code'] && ! $dom_oracle_mathml_minimize_proc['timedOut'], 'Minimizer should accept oracle finding replays as targets.' );
+ html_api_fuzz_smoke_assert( true === ( $dom_oracle_mathml_minimize['ok'] ?? null ), 'Minimizer should reproduce the MathML oracle finding target.' );
+ html_api_fuzz_smoke_assert( 'oracle-finding' === ( $dom_oracle_mathml_minimize['targetKind'] ?? null ), 'Minimizer should mark the MathML target as an oracle finding.' );
+ html_api_fuzz_smoke_assert( ( $dom_oracle_mathml_heading_result['oracleFinding']['signature']['hash'] ?? null ) === ( $dom_oracle_mathml_minimize['finalOracleHash'] ?? null ), 'Minimizer should report the final oracle finding hash.' );
+ $dom_oracle_mathml_mixed_replay = \HtmlApiFuzz\read_json_file( $dom_oracle_mathml_heading_dir . '/replay.json' );
+ $dom_oracle_mathml_mixed_replay['signature'] = array(
+ 'hash' => 'fake-failure-hash',
+ 'familyKey' => 'fake-failure-family',
+ );
+ $dom_oracle_mathml_mixed_replay_path = $tmp . '/dom-oracle-mathml-heading-mixed-replay.json';
+ \HtmlApiFuzz\write_json_file( $dom_oracle_mathml_mixed_replay_path, $dom_oracle_mathml_mixed_replay );
+ $dom_oracle_mathml_mixed_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $dom_oracle_mathml_mixed_replay_path,
+ '--output-dir',
+ $tmp . '/dom-oracle-mathml-heading-mixed-minimize',
+ '--max-attempts',
+ '0',
+ '--timeout-ms',
+ '2500',
+ '--any-failure',
+ '--target-kind',
+ 'oracle-finding',
+ '--target-hash',
+ $dom_oracle_mathml_heading_result['oracleFinding']['signature']['hash'],
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 30000,
+ $tmp . '/dom-oracle-mathml-heading-mixed-minimize.log'
+ );
+ $dom_oracle_mathml_mixed_minimize = json_decode( $dom_oracle_mathml_mixed_minimize_proc['stdout'], true );
+ html_api_fuzz_smoke_assert( 0 === $dom_oracle_mathml_mixed_minimize_proc['code'] && ! $dom_oracle_mathml_mixed_minimize_proc['timedOut'], 'Minimizer should accept explicit oracle targets on mixed replays.' );
+ html_api_fuzz_smoke_assert( true === ( $dom_oracle_mathml_mixed_minimize['ok'] ?? null ), 'Minimizer should reproduce the explicit oracle target on mixed replays.' );
+ html_api_fuzz_smoke_assert( ( $dom_oracle_mathml_heading_result['oracleFinding']['signature']['hash'] ?? null ) === ( $dom_oracle_mathml_mixed_minimize['finalOracleHash'] ?? null ), 'Mixed replay minimization should report the requested oracle finding hash.' );
+} else {
+ html_api_fuzz_smoke_assert( null === ( $dom_oracle_mathml_heading_result['oracleFinding'] ?? null ), 'Fixed DOM runtimes should not produce a MathML heading oracle finding.' );
+}
+
+$dom_oracle_needs_mathml_xhtml_tolerance = html_api_fuzz_smoke_dom_reparents_heading_after_mathml_text_integration_point( 'application/xhtml+xml' );
+$dom_oracle_mathml_xhtml_dir = $tmp . '/dom-oracle-mathml-xhtml-worker';
+$dom_oracle_mathml_xhtml_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_oracle_mathml_xhtml_dir,
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ )
+);
+if ( $dom_oracle_needs_mathml_xhtml_tolerance ) {
+ html_api_fuzz_smoke_assert( true === ( $dom_oracle_mathml_xhtml_result['ok'] ?? null ), 'MathML application/xhtml+xml DOM oracle bug should not be a WordPress failure.' );
+ html_api_fuzz_smoke_assert( 'dom-mathml-heading-scope-reparenting' === ( $dom_oracle_mathml_xhtml_result['oracleFinding']['type'] ?? null ), 'MathML application/xhtml+xml should use the MathML heading oracle finding type.' );
+} else {
+ html_api_fuzz_smoke_assert( null === ( $dom_oracle_mathml_xhtml_result['oracleFinding'] ?? null ), 'Fixed DOM runtimes should not produce a MathML XHTML oracle finding.' );
+}
+
+$dom_oracle_xlink_full_document_wp = \HtmlApiFuzz\TreeRenderer::render_wordpress(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+$dom_oracle_xlink_full_document_dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_full_document_wp['status'] ?? null ), 'DOM XLink full-document fixture should render with WordPress.' );
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_full_document_dom['status'] ?? null ), 'DOM XLink full-document fixture should render with the DOM oracle.' );
+$dom_oracle_xlink_full_document_comparison = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ $dom_oracle_xlink_full_document_wp['tree'],
+ $dom_oracle_xlink_full_document_dom['tree'],
+ $dom_oracle_xlink_full_document_wp['domOracleLineTolerances'] ?? array()
+);
+html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_full_document_comparison['ok'] ?? null ), 'DOM XLink oracle limitation should compare after full-document tolerance.' );
+html_api_fuzz_smoke_assert( $dom_oracle_needs_xlink_tolerance ? array( 4 ) === ( $dom_oracle_xlink_full_document_wp['domOracleLineTolerances'] ?? null ) : array() === ( $dom_oracle_xlink_full_document_wp['domOracleLineTolerances'] ?? null ), 'DOM XLink full-document fixture should record tolerance lines only while the runtime needs them.' );
+
+$dom_oracle_xlink_minimized_input = base64_decode( 'Pjx3cC14PjxiIHNyYz0i16oiPjxzdHJvbmcgZDw8PDw8PCI+YWFhYWFhYc6yPHN2ZyBwWDgxRzY4QndxPSJudWtyUSBhbXA7IiB4bGluazpocmVmIGhyZWYgdml0bGU+Ri1qOA==', true );
+html_api_fuzz_smoke_assert( false !== $dom_oracle_xlink_minimized_input, 'DOM XLink minimized fixture should decode.' );
+$dom_oracle_xlink_minimized_wp = \HtmlApiFuzz\TreeRenderer::render_wordpress(
+ $dom_oracle_xlink_minimized_input,
+ \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ array( 'maxTokens' => 2000, 'maxNodes' => 3000 )
+);
+$dom_oracle_xlink_minimized_dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ $dom_oracle_xlink_minimized_input,
+ \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ array( 'maxTokens' => 2000, 'maxNodes' => 3000 )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_minimized_wp['status'] ?? null ), 'DOM XLink minimized fixture should render with WordPress.' );
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_minimized_dom['status'] ?? null ), 'DOM XLink minimized fixture should render with the DOM oracle.' );
+$dom_oracle_xlink_minimized_comparison = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ $dom_oracle_xlink_minimized_wp['tree'],
+ $dom_oracle_xlink_minimized_dom['tree'],
+ $dom_oracle_xlink_minimized_wp['domOracleLineTolerances'] ?? array()
+);
+html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_minimized_comparison['ok'] ?? null ), 'DOM XLink minimized fixture comparison should pass.' );
+html_api_fuzz_smoke_assert( $dom_oracle_needs_xlink_tolerance ? 1 === count( $dom_oracle_xlink_minimized_wp['domOracleLineTolerances'] ?? array() ) : array() === ( $dom_oracle_xlink_minimized_wp['domOracleLineTolerances'] ?? null ), 'DOM XLink minimized fixture should record tolerance lines only while the runtime needs them.' );
+
+$dom_oracle_xlink_inverse_wp = \HtmlApiFuzz\TreeRenderer::render_wordpress(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+$dom_oracle_xlink_inverse_dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ ' ',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 100, 'maxNodes' => 100 )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_inverse_wp['status'] ?? null ), 'Bare attribute before XLink should render with WordPress.' );
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $dom_oracle_xlink_inverse_dom['status'] ?? null ), 'Bare attribute before XLink should render with the DOM oracle.' );
+$dom_oracle_xlink_inverse_comparison = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ $dom_oracle_xlink_inverse_wp['tree'],
+ $dom_oracle_xlink_inverse_dom['tree'],
+ $dom_oracle_xlink_inverse_wp['domOracleLineTolerances'] ?? array()
+);
+html_api_fuzz_smoke_assert( true === ( $dom_oracle_xlink_inverse_comparison['ok'] ?? null ), 'Bare attribute before XLink should remain comparable.' );
+html_api_fuzz_smoke_assert( array() === ( $dom_oracle_xlink_inverse_wp['domOracleLineTolerances'] ?? null ), 'Bare attribute before XLink should not record DOM oracle tolerance lines.' );
+
+$dom_oracle_xlink_resource_dom = \HtmlApiFuzz\TreeRenderer::render_dom(
+ 'x ',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ array( 'maxTokens' => 100, 'maxNodes' => 1 )
+);
+html_api_fuzz_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_ERROR === ( $dom_oracle_xlink_resource_dom['status'] ?? null ), 'DOM XLink node ceiling should fail the DOM renderer.' );
+html_api_fuzz_smoke_assert( 'node-limit-exceeded' === ( $dom_oracle_xlink_resource_dom['failureClass'] ?? null ), 'DOM XLink oracle tolerance should preserve the concrete DOM node limit failure.' );
+$dom_oracle_xlink_resource_worker_dir = $tmp . '/dom-oracle-xlink-resource-worker';
+$dom_oracle_xlink_resource_worker_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_oracle_xlink_resource_worker_dir,
+ 'max-tokens' => '100',
+ 'max-nodes' => '1',
+ )
+);
+html_api_fuzz_smoke_assert( false === ( $dom_oracle_xlink_resource_worker_result['ok'] ?? null ), 'DOM XLink node ceiling should remain a failing resource-limit result.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_oracle_xlink_resource_worker_result['failureClass'] ?? null ), 'DOM XLink node ceiling should preserve the resource-limit failure class.' );
+html_api_fuzz_smoke_assert( null === ( $dom_oracle_xlink_resource_worker_result['oracleFinding'] ?? null ), 'DOM XLink tolerance lines should not create an oracle finding when DOM comparison did not succeed.' );
+
+$wp_resource_dir = $tmp . '/wordpress-resource-limit';
+$wp_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $wp_resource_dir,
+ 'max-tokens' => '3',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $wp_resource_result['failureClass'] ?? null ), 'WordPress tree token ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $wp_resource_result['status'] ?? null ), 'WordPress tree token ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( 'token-limit-exceeded' === ( $wp_resource_result['wordpress']['failureClass'] ?? null ), 'WordPress result should preserve the concrete token limit failure.' );
+html_api_fuzz_smoke_assert( in_array( 'wordpress-token-limit-exceeded', $wp_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include WordPress token limit failures.' );
+
+$resource_watcher_run_dir = $tmp . '/resource-watcher-run';
+\HtmlApiFuzz\ensure_dir( $resource_watcher_run_dir );
+\HtmlApiFuzz\append_ndjson(
+ $resource_watcher_run_dir . '/summary.ndjson',
+ array(
+ 'ok' => false,
+ 'status' => $resource_result['status'] ?? null,
+ 'failureClass' => $resource_result['failureClass'] ?? null,
+ 'profile' => $resource_result['profile'] ?? null,
+ 'mode' => $resource_result['mode'] ?? null,
+ 'payloadPolicy' => $resource_result['payloadPolicy'] ?? null,
+ 'generator' => $resource_result['generator'] ?? null,
+ 'inputSource' => $resource_result['inputSource'] ?? null,
+ 'inputSha1' => $resource_result['inputSha1'] ?? null,
+ 'inputLength' => $resource_result['inputLength'] ?? null,
+ 'signature' => $resource_result['signature'] ?? null,
+ 'resultPath' => $resource_dir . '/result.json',
+ 'replayPath' => $resource_dir . '/replay.json',
+ )
+);
+$resource_watcher_state_dir = $tmp . '/resource-watcher-state';
+$resource_watcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/watcher.php',
+ '--run-dir',
+ $resource_watcher_run_dir,
+ '--state-dir',
+ $resource_watcher_state_dir,
+ '--once',
+ '--no-minimize',
+ '--max-minimize',
+ '1',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/resource-watcher.log'
+);
+html_api_fuzz_smoke_assert( ! $resource_watcher_proc['timedOut'] && 0 === $resource_watcher_proc['code'], 'watcher should process resource-limit summaries.' );
+$resource_watcher_state = \HtmlApiFuzz\read_json_file( $resource_watcher_state_dir . '/state.json' );
+$resource_watcher_hash = $resource_result['signature']['hash'] ?? null;
+$resource_watcher_record = is_string( $resource_watcher_hash ) ? ( $resource_watcher_state['signatures'][ $resource_watcher_hash ] ?? array() ) : array();
+html_api_fuzz_smoke_assert( 'queued' === ( $resource_watcher_record['status'] ?? null ), 'watcher should queue resource-limit signatures for minimization.' );
+html_api_fuzz_smoke_assert( ! isset( $resource_watcher_record['minimizeResult'] ), 'watcher --no-minimize should not start resource-limit minimization.' );
+$resource_watcher_second_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/watcher.php',
+ '--run-dir',
+ $resource_watcher_run_dir,
+ '--state-dir',
+ $resource_watcher_state_dir,
+ '--once',
+ '--no-minimize',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/resource-watcher-second.log'
+);
+$resource_watcher_second = json_decode( $resource_watcher_second_proc['stdout'], true );
+html_api_fuzz_smoke_assert( ! $resource_watcher_second_proc['timedOut'] && 0 === $resource_watcher_second_proc['code'], 'watcher should process a second scan.' );
+html_api_fuzz_smoke_assert( 0 === ( $resource_watcher_second['failuresSeen'] ?? null ), 'watcher should not reread already-scanned summary records.' );
+
+$encoding_dir = $tmp . '/encoding-mismatch';
+$encoding_input = '' . str_repeat( 'a', 220 ) . "\xC0" . '
';
+$encoding_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $encoding_input ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $encoding_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+);
+html_api_fuzz_smoke_assert( 'encoding-mismatch' === ( $encoding_result['failureClass'] ?? null ), 'invalid byte beyond hex preview should be classified as encoding-mismatch.' );
+html_api_fuzz_smoke_assert( null === ( $encoding_result['generator'] ?? null ), 'replayed invalid input should not invent generator metadata.' );
+$encoding_diff = $encoding_result['comparison']['firstDifference'] ?? array();
+html_api_fuzz_smoke_assert( ( $encoding_diff['firstByteOffset'] ?? 0 ) > 160, 'long encoding mismatch should exercise an offset beyond the leading hex preview.' );
+html_api_fuzz_smoke_assert( ( $encoding_diff['wordpressHex'] ?? null ) === ( $encoding_diff['domHex'] ?? null ), 'long encoding mismatch should show why leading hex previews alone are insufficient.' );
+html_api_fuzz_smoke_assert( false !== strpos( $encoding_diff['wordpressDiffHex'] ?? '', 'c0' ), 'long encoding mismatch should include the differing WordPress byte in the diff window.' );
+html_api_fuzz_smoke_assert( false !== strpos( $encoding_diff['domDiffHex'] ?? '', 'efbfbd' ), 'long encoding mismatch should include the differing DOM replacement bytes in the diff window.' );
+
+/*
+ * The encoding-mismatch classifier must not relabel structural differences:
+ * exercised directly because no live WordPress/DOM structural divergence is
+ * available to drive a worker-level fixture (parser fixes resolved them).
+ * Diffs carry the linesMatchAfterWordPressUtf8Scrub flag, computed by
+ * TreeRenderer::first_difference() on the full differing lines.
+ */
+$is_encoding_mismatch = new \ReflectionMethod( \HtmlApiFuzz\Worker::class, 'is_encoding_mismatch' );
+$invalid_input = "\xC0
";
+html_api_fuzz_smoke_assert(
+ false === $is_encoding_mismatch->invoke(
+ null,
+ $invalid_input,
+ array(
+ 'linesMatchAfterWordPressUtf8Scrub' => false,
+ )
+ ),
+ 'invalid bytes elsewhere should not relabel structural tree mismatches as encoding-mismatch.'
+);
+html_api_fuzz_smoke_assert(
+ true === $is_encoding_mismatch->invoke(
+ null,
+ $invalid_input,
+ array(
+ 'linesMatchAfterWordPressUtf8Scrub' => true,
+ )
+ ),
+ 'invalid-byte line differences explained by the UTF-8 scrub should classify as encoding-mismatch.'
+);
+html_api_fuzz_smoke_assert(
+ false === $is_encoding_mismatch->invoke(
+ null,
+ 'valid
',
+ array(
+ 'linesMatchAfterWordPressUtf8Scrub' => true,
+ )
+ ),
+ 'valid UTF-8 input should never classify as encoding-mismatch.'
+);
+
+$newline_scalar_cases = array(
+ 'text' => array(
+ 'input' => 'a' . "\n" . 'b' . "\xC0" . '
',
+ 'path' => '/p/#text',
+ ),
+ 'attr' => array(
+ 'input' => '
',
+ 'path' => '/p/@title',
+ ),
+ 'comment' => array(
+ 'input' => '
',
+ 'path' => '/p/#text',
+ ),
+);
+foreach ( $newline_scalar_cases as $name => $case ) {
+ $newline_scalar_dir = $tmp . '/newline-scalar-' . $name;
+ $newline_scalar_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $case['input'] ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $newline_scalar_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+ );
+ $newline_scalar_diff = $newline_scalar_result['comparison']['firstDifference'] ?? array();
+ html_api_fuzz_smoke_assert( 'encoding-mismatch' === ( $newline_scalar_result['failureClass'] ?? null ), $name . ' newline scalar invalid byte should be classified as encoding-mismatch.' );
+ html_api_fuzz_smoke_assert( $case['path'] === ( $newline_scalar_diff['path'] ?? null ), $name . ' newline scalar diff should preserve the logical tree path.' );
+ html_api_fuzz_smoke_assert( ! array_key_exists( 'wordpressLine', $newline_scalar_diff ), $name . ' newline scalar diff should not persist the full WordPress line.' );
+ html_api_fuzz_smoke_assert( ! array_key_exists( 'domLine', $newline_scalar_diff ), $name . ' newline scalar diff should not persist the full DOM line.' );
+ html_api_fuzz_smoke_assert( isset( $newline_scalar_diff['wordpressLinePreview'], $newline_scalar_diff['domLinePreview'] ), $name . ' newline scalar diff should persist bounded previews.' );
+ html_api_fuzz_smoke_assert( isset( $newline_scalar_diff['wordpressLineBytes'], $newline_scalar_diff['domLineBytes'] ), $name . ' newline scalar diff should persist line byte lengths.' );
+ html_api_fuzz_smoke_assert( isset( $newline_scalar_diff['wordpressLineSha1'], $newline_scalar_diff['domLineSha1'] ), $name . ' newline scalar diff should persist line hashes.' );
+ html_api_fuzz_smoke_assert( isset( $newline_scalar_diff['firstByteOffset'] ), $name . ' newline scalar diff should persist first differing byte offset.' );
+}
+
+$template_content_dir = $tmp . '/template-content-path';
+$template_content_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'a' . "\xC0" . '
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $template_content_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+);
+$template_content_diff = $template_content_result['comparison']['firstDifference'] ?? array();
+html_api_fuzz_smoke_assert( 'encoding-mismatch' === ( $template_content_result['failureClass'] ?? null ), 'template content invalid byte should be classified as encoding-mismatch.' );
+html_api_fuzz_smoke_assert( '/template/content/p/#text' === ( $template_content_diff['path'] ?? null ), 'template content descendants should include the content pseudo-node in diff paths.' );
+
+$quoted_attribute_dir = $tmp . '/quoted-attribute-name';
+$quoted_attribute_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( '
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $quoted_attribute_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+);
+$quoted_attribute_diff = $quoted_attribute_result['comparison']['firstDifference'] ?? array();
+html_api_fuzz_smoke_assert( 'encoding-mismatch' === ( $quoted_attribute_result['failureClass'] ?? null ), 'quoted attribute-name invalid byte should be classified as encoding-mismatch.' );
+html_api_fuzz_smoke_assert( '/p/@a\\"b' === ( $quoted_attribute_diff['path'] ?? null ), 'quoted attribute-name diff should preserve the attribute path.' );
+html_api_fuzz_smoke_assert( 'a\\"b=""' === ( $quoted_attribute_diff['wordpressNorm'] ?? null ), 'quoted attribute-name normalization should preserve the WordPress attribute name.' );
+html_api_fuzz_smoke_assert( 'a\\"b=""' === ( $quoted_attribute_diff['domNorm'] ?? null ), 'quoted attribute-name normalization should preserve the DOM attribute name.' );
+
+echo "OK\n";
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'
diff --git a/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
new file mode 100755
index 0000000000000..14b1f94e03bdc
--- /dev/null
+++ b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
@@ -0,0 +1,257 @@
+#!/usr/bin/env php
+ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+$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( 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,
+ 'maxNodes' => 200,
+ 'maxDepth' => 50,
+ 'maxTreeBytes' => 100000,
+);
+
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-lexbor-oracle-' . \HtmlApiFuzz\timestamp();
+\HtmlApiFuzz\ensure_dir( $work_dir );
+
+$empty_fragment = $oracle->render( '', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $empty_fragment['status'] ?? null ), 'Expected Lexbor to parse an empty fragment.' );
+html_api_fuzz_lexbor_smoke_assert( "\n" === ( $empty_fragment['tree'] ?? null ), 'Expected Lexbor empty fragment rendering to match the fuzzer tree newline contract.' );
+
+$deep_limits = $limits;
+$deep_limits['maxDepth'] = 3;
+$deep_html = str_repeat( '', 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['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(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $work_dir . '/issue-372/replay.json',
+ '--output-dir',
+ $replay_dir,
+ '--timeout-ms',
+ '10000',
+ ),
+ \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.' );
+
+$identity_mismatch_replays = array();
+$hash_mismatch_replay = $worker_replay_372;
+$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']['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;
+$identity_mismatch_replays['oracle-kind'] = $kind_mismatch_replay;
+foreach ( $identity_mismatch_replays as $mismatch_name => $mismatch_replay ) {
+ $mismatch_path = $work_dir . '/mismatch-' . $mismatch_name . '.json';
+ $mismatch_output = $work_dir . '/mismatch-' . $mismatch_name;
+ \HtmlApiFuzz\write_json_file_atomic( $mismatch_path, $mismatch_replay );
+ $mismatch_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $mismatch_path,
+ '--output-dir', $mismatch_output,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000
+ );
+ html_api_fuzz_lexbor_smoke_assert( 1 === $mismatch_proc['code'], "Expected {$mismatch_name} mismatch to reject replay." );
+ html_api_fuzz_lexbor_smoke_assert( ! is_dir( $mismatch_output ), "Expected {$mismatch_name} rejection before output creation." );
+}
+
+$kind_change_source = $worker_replay_372;
+$kind_change_source['options']['oracleTimeoutMs'] = 1234;
+$kind_change_path = $work_dir . '/kind-change-source.json';
+$kind_change_dir = $work_dir . '/kind-change';
+\HtmlApiFuzz\write_json_file_atomic( $kind_change_path, $kind_change_source );
+$kind_change_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $kind_change_path,
+ '--output-dir', $kind_change_dir,
+ '--dom-oracle', \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM,
+ '--oracle-timeout-ms', '2500',
+ '--allow-oracle-mismatch',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000
+);
+html_api_fuzz_lexbor_smoke_assert( 0 === $kind_change_proc['code'], 'Expected an explicitly allowed Lexbor-to-PHP-DOM diagnostic replay.' );
+$kind_change_replay = \HtmlApiFuzz\read_json_file( $kind_change_dir . '/replay.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM === ( $kind_change_replay['oracle']['kind'] ?? null ), 'Expected allowed mismatch output to record current oracle metadata.' );
+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['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(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay', $kind_change_dir . '/replay.json',
+ '--output-dir', $kind_change_again_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000
+);
+html_api_fuzz_lexbor_smoke_assert( 0 === $kind_change_again_proc['code'], 'Expected replay-of-replay to use the newly recorded PHP DOM oracle without an override.' );
+$kind_change_again = \HtmlApiFuzz\read_json_file( $kind_change_again_dir . '/result.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_PHP_DOM === ( $kind_change_again['oracle']['kind'] ?? null ), 'Expected replay-of-replay result to use PHP DOM.' );
+
+$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/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
new file mode 100644
index 0000000000000..173d7f69e7764
--- /dev/null
+++ b/tools/html-api-fuzz/tests/result-store-smoke.php
@@ -0,0 +1,327 @@
+#!/usr/bin/env php
+ 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',
+ '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,
+);
+$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',
+ '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 );
+$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',
+ '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 );
+$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( 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( 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.' );
+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( 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();
+
+// 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 = '" . 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';
+$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