From 74a48f3143e410aa7ad57085eaddec0511ef3117 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Sat, 20 Jun 2026 18:57:58 +0200 Subject: [PATCH 0001/1102] Add component fuzzing harness --- tools/component-fuzz/README.md | 76 + tools/component-fuzz/lib/FuzzContext.php | 223 ++ tools/component-fuzz/lib/Prng.php | 75 + tools/component-fuzz/lib/Support.php | 132 ++ tools/component-fuzz/lib/SurfaceRunner.php | 309 +++ tools/component-fuzz/lib/WpBootstrap.php | 158 ++ tools/component-fuzz/lib/autoload.php | 11 + tools/component-fuzz/lib/wp-stubs.php | 124 ++ tools/component-fuzz/notes/identity.md | 17 + tools/component-fuzz/notes/markup.md | 20 + tools/component-fuzz/notes/rest.md | 25 + tools/component-fuzz/runner.php | 38 + .../surfaces/ContentSurface.php | 1356 ++++++++++++ .../component-fuzz/surfaces/HooksSurface.php | 254 +++ .../surfaces/IdentitySurface.php | 912 ++++++++ tools/component-fuzz/surfaces/KsesSurface.php | 1967 +++++++++++++++++ .../component-fuzz/surfaces/MarkupSurface.php | 1627 ++++++++++++++ .../surfaces/NetworkMediaSurface.php | 1561 +++++++++++++ tools/component-fuzz/surfaces/RestSurface.php | 1474 ++++++++++++ .../component-fuzz/tests/bootstrap-smoke.php | 88 + 20 files changed, 10447 insertions(+) create mode 100644 tools/component-fuzz/README.md create mode 100644 tools/component-fuzz/lib/FuzzContext.php create mode 100644 tools/component-fuzz/lib/Prng.php create mode 100644 tools/component-fuzz/lib/Support.php create mode 100644 tools/component-fuzz/lib/SurfaceRunner.php create mode 100644 tools/component-fuzz/lib/WpBootstrap.php create mode 100644 tools/component-fuzz/lib/autoload.php create mode 100644 tools/component-fuzz/lib/wp-stubs.php create mode 100644 tools/component-fuzz/notes/identity.md create mode 100644 tools/component-fuzz/notes/markup.md create mode 100644 tools/component-fuzz/notes/rest.md create mode 100644 tools/component-fuzz/runner.php create mode 100644 tools/component-fuzz/surfaces/ContentSurface.php create mode 100644 tools/component-fuzz/surfaces/HooksSurface.php create mode 100644 tools/component-fuzz/surfaces/IdentitySurface.php create mode 100644 tools/component-fuzz/surfaces/KsesSurface.php create mode 100644 tools/component-fuzz/surfaces/MarkupSurface.php create mode 100644 tools/component-fuzz/surfaces/NetworkMediaSurface.php create mode 100644 tools/component-fuzz/surfaces/RestSurface.php create mode 100644 tools/component-fuzz/tests/bootstrap-smoke.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md new file mode 100644 index 0000000000000..6d60e6e8bea9d --- /dev/null +++ b/tools/component-fuzz/README.md @@ -0,0 +1,76 @@ +# Component Fuzzers + +Pure-PHP fuzzing harness for broad WordPress component surfaces. It follows the +same operating model as `tools/html-api-fuzz`: deterministic generation from a +seed, bounded inputs, structured replayable artifacts, and explicit invariants +instead of one-off example tests. + +The harness intentionally boots a no-DB subset of WordPress. Surface modules +therefore focus on public APIs that can be exercised without live inserts, +network requests, or a configured site. + +## Surfaces + +- `content`: slashing, metadata serialization, post and term field sanitization, + query variables, `WP_Date_Query`, title/class/key sanitizers. +- `identity`: usernames, emails, capabilities, text/comment filters, comment + cookies, options, password hashing/checking, parse helpers. +- `hooks`: filter/action priority ordering, accepted arguments, removal, + nested hook stack state, `current_filter()`, `doing_filter()`, `did_action()`. +- `kses`: KSES policies, protocol filtering, safe CSS, attribute parsing, + no-HTML filtering, strict/custom policy monotonicity. +- `markup`: block parse/serialize/render guards, shortcodes, text trimming, + excerpts, balanced tags, URL extraction, embed helpers. +- `network-media`: URL parsing/sanitization/validation, path normalization, + filename sanitization, filetype checks, unique filenames, sideload handling. +- `rest`: request normalization, parameter precedence, JSON bodies, route regexes, + schema sanitize/validate, permissions, HEAD/GET behavior. + +Some checks deliberately skip cases that would invoke DB-backed or dynamic block +rendering side effects. Skips are recorded in `results.ndjson` with a reason and +do not mask failures or PHP errors. + +## Commands + +List registered surfaces: + +```sh +php tools/component-fuzz/runner.php --list-surfaces +``` + +Run all surfaces for 25 generated cases each: + +```sh +php tools/component-fuzz/runner.php --seed 1 --iterations 25 +``` + +Run selected surfaces and stop on the first failure: + +```sh +php tools/component-fuzz/runner.php --surface kses,rest --seed 100 --iterations 200 --fail-fast +``` + +Each run writes `summary.json` and `results.ndjson` under +`artifacts/component-fuzz/run-...` unless `--output-dir` is provided. + +## Surface Contract + +Surface modules live in `tools/component-fuzz/surfaces/*Surface.php` and define: + +```php +namespace ComponentFuzz\Surfaces; + +final class ExampleSurface { + public const NAME = 'example'; + + public static function run( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + $ctx->pass( 'invariant-name', array( 'input' => '...' ) ), + ); + } +} +``` + +Each returned row should be a structured invariant result. Failures should carry +enough input preview and oracle detail to reproduce the case from the surface +name, seed, and iteration in `results.ndjson`. diff --git a/tools/component-fuzz/lib/FuzzContext.php b/tools/component-fuzz/lib/FuzzContext.php new file mode 100644 index 0000000000000..9f216bfd3e8c5 --- /dev/null +++ b/tools/component-fuzz/lib/FuzzContext.php @@ -0,0 +1,223 @@ +seed = $seed; + $this->surface = $surface; + $this->iteration = $iteration; + $this->prng = new Prng( $seed ); + } + + public function seed(): int { + return $this->seed; + } + + public function surface(): string { + return $this->surface; + } + + public function iteration(): int { + return $this->iteration; + } + + public function fork( string $label ): self { + return new self( Prng::mix_seed( $this->seed, $label ), $this->surface, $this->iteration ); + } + + public function int( int $min, int $max ): int { + return $this->prng->int( $min, $max ); + } + + public function bool( int $true_percent = 50 ): bool { + return $this->prng->bool( $true_percent ); + } + + public function choice( array $values ) { + return $this->prng->choice( $values ); + } + + public function weightedChoice( array $weighted_values ) { + return $this->prng->weighted_choice( $weighted_values ); + } + + public function bytes( int $min = 0, int $max = 64 ): string { + $length = $this->int( $min, $max ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= chr( $this->int( 0, 255 ) ); + } + return $out; + } + + public function ascii( int $min = 0, int $max = 64 ): string { + $length = $this->int( $min, $max ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= chr( $this->int( 32, 126 ) ); + } + return $out; + } + + public function text( int $min = 0, int $max = 64 ): string { + $pieces = array( + $this->ascii( $min, $max ), + $this->choice( array( '', 'é', '☃', 'مرحبا', '中文', "line\nbreak", "tab\tvalue" ) ), + $this->bool( 25 ) ? $this->bytes( 0, min( 12, $max ) ) : '', + ); + + return substr( implode( '', $pieces ), 0, $max ); + } + + public function identifier( int $min = 1, int $max = 16 ): string { + $first = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'; + $rest = $first . '0123456789-:'; + $len = $this->int( $min, $max ); + $out = $first[ $this->int( 0, strlen( $first ) - 1 ) ]; + for ( $i = 1; $i < $len; $i++ ) { + $out .= $rest[ $this->int( 0, strlen( $rest ) - 1 ) ]; + } + return $out; + } + + public function url(): string { + $scheme = $this->choice( array( 'http', 'https', 'ftp', 'mailto', 'data', 'javascript', 'file', 'irc', '' ) ); + $host = $this->choice( + array( + 'example.com', + 'localhost', + '127.0.0.1', + '[::1]', + 'xn--bcher-kva.example', + $this->identifier( 3, 12 ) . '.test', + ) + ); + $path = '/' . implode( '/', array_map( fn() => rawurlencode( $this->text( 0, 10 ) ), range( 1, $this->int( 0, 3 ) ) ) ); + $query = $this->bool() ? '?q=' . rawurlencode( $this->text( 0, 16 ) ) : ''; + $prefix = '' === $scheme ? '' : $scheme . ':'; + + if ( in_array( $scheme, array( 'mailto', 'javascript', 'data' ), true ) ) { + return $prefix . $this->text( 0, 48 ); + } + + return $prefix . '//' . $host . $path . $query; + } + + public function filename(): string { + $base = $this->choice( array( 'image', 'archive', 'index', '../escape', '..\\escape', 'résumé', $this->identifier( 1, 12 ) ) ); + $ext = $this->choice( array( 'jpg', 'png', 'php', 'txt', 'tar.gz', 'svg', 'webp', '', 'PhP' ) ); + $name = '' === $ext ? $base : $base . '.' . $ext; + if ( $this->bool( 20 ) ) { + $name .= chr( 0 ) . '.jpg'; + } + if ( $this->bool( 20 ) ) { + $name = str_replace( '/', '\\', $name ); + } + return $name; + } + + public function htmlFragment( int $max_depth = 3 ): string { + $tags = array( 'a', 'p', 'div', 'span', 'img', 'svg', 'math', 'script', 'style', 'template', 'button', 'form' ); + $out = ''; + $open = array(); + $count = $this->int( 1, 8 ); + for ( $i = 0; $i < $count; $i++ ) { + if ( count( $open ) > 0 && $this->bool( 25 ) ) { + $out .= ''; + continue; + } + + $tag = $this->choice( $tags ); + $out .= '<' . $tag; + foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $name = $this->choice( array( 'href', 'src', 'style', 'class', 'id', 'onclick', 'data-x', $this->identifier( 1, 10 ) ) ); + $value = 'href' === $name || 'src' === $name ? $this->url() : $this->text( 0, 24 ); + $out .= ' ' . $name . '="' . str_replace( '"', '"', $value ) . '"'; + } + $out .= $this->bool( 15 ) ? '/>' : '>'; + if ( ! str_ends_with( $out, '/>' ) && count( $open ) < $max_depth ) { + $open[] = $tag; + } + $out .= $this->text( 0, 32 ); + } + + while ( $open && $this->bool( 70 ) ) { + $out .= ''; + } + + return $out; + } + + public function jsonValue( int $depth = 0 ) { + if ( $depth > 3 ) { + return $this->choice( array( null, true, false, $this->int( -100, 100 ), $this->text( 0, 24 ) ) ); + } + + $type = $this->choice( array( 'null', 'bool', 'int', 'float', 'string', 'array', 'object' ) ); + if ( 'null' === $type ) { + return null; + } + if ( 'bool' === $type ) { + return $this->bool(); + } + if ( 'int' === $type ) { + return $this->int( -100000, 100000 ); + } + if ( 'float' === $type ) { + return $this->int( -100000, 100000 ) / max( 1, $this->int( 1, 1000 ) ); + } + if ( 'string' === $type ) { + return $this->text( 0, 48 ); + } + if ( 'array' === $type ) { + $out = array(); + foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $out[] = $this->jsonValue( $depth + 1 ); + } + return $out; + } + + $out = array(); + foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $out[ $this->identifier( 1, 8 ) ] = $this->jsonValue( $depth + 1 ); + } + return $out; + } + + public function result( string $invariant, bool $ok, array $data = array(), ?string $status = null ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => $this->surface, + 'invariant' => $invariant, + 'seed' => $this->seed, + 'iteration' => $this->iteration, + 'data' => $this->compact_data( $data ), + ); + } + + public function pass( string $invariant, array $data = array() ): array { + return $this->result( $invariant, true, $data, 'passed' ); + } + + public function fail( string $invariant, array $data = array() ): array { + return $this->result( $invariant, false, $data, 'failed' ); + } + + public function skip( string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return $this->result( $invariant, true, $data, 'skipped' ); + } + + private function compact_data( array $data ): array { + foreach ( $data as $key => $value ) { + $data[ $key ] = preview_value( $value ); + } + return $data; + } +} diff --git a/tools/component-fuzz/lib/Prng.php b/tools/component-fuzz/lib/Prng.php new file mode 100644 index 0000000000000..866856f1514c5 --- /dev/null +++ b/tools/component-fuzz/lib/Prng.php @@ -0,0 +1,75 @@ +state = 0 === $seed ? 0x9e3779b9 : ( $seed & 0x7fffffff ); + } + + public function next(): int { + $x = $this->state; + $x ^= ( $x << 13 ) & 0x7fffffff; + $x ^= ( $x >> 17 ); + $x ^= ( $x << 5 ) & 0x7fffffff; + $this->state = $x & 0x7fffffff; + + return $this->state; + } + + public function int( int $min, int $max ): int { + if ( $max < $min ) { + throw new \InvalidArgumentException( 'Invalid PRNG range.' ); + } + + if ( $max === $min ) { + return $min; + } + + return $min + ( $this->next() % ( $max - $min + 1 ) ); + } + + public function bool( int $true_percent = 50 ): bool { + return $this->int( 1, 100 ) <= $true_percent; + } + + public function choice( array $values ) { + if ( array() === $values ) { + throw new \InvalidArgumentException( 'Cannot choose from an empty array.' ); + } + + return $values[ array_keys( $values )[ $this->int( 0, count( $values ) - 1 ) ] ]; + } + + public function weighted_choice( array $weighted_values ) { + $total = 0; + foreach ( $weighted_values as $entry ) { + $total += max( 0, (int) $entry[0] ); + } + + if ( $total <= 0 ) { + throw new \InvalidArgumentException( 'Weighted choice needs positive weight.' ); + } + + $pick = $this->int( 1, $total ); + foreach ( $weighted_values as $entry ) { + $pick -= max( 0, (int) $entry[0] ); + if ( $pick <= 0 ) { + return $entry[1]; + } + } + + return $weighted_values[ array_key_last( $weighted_values ) ][1]; + } + + public function derive( string $label ): self { + return new self( self::mix_seed( $this->state, $label ) ); + } + + public static function mix_seed( int $seed, string $label ): int { + $hash = crc32( $label ); + return ( ( $seed * 1103515245 ) ^ $hash ^ 0x45d9f3b ) & 0x7fffffff; + } +} + diff --git a/tools/component-fuzz/lib/Support.php b/tools/component-fuzz/lib/Support.php new file mode 100644 index 0000000000000..0cdd8d91944eb --- /dev/null +++ b/tools/component-fuzz/lib/Support.php @@ -0,0 +1,132 @@ + $limit ) { + return substr( $printable, 0, $limit ) . '...'; + } + + return $printable; + } + + if ( is_array( $value ) ) { + $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + return false === $json ? '[array]' : preview_value( $json, $limit ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + return $value; +} + +function cli_options( array $argv ): array { + $options = array(); + $count = count( $argv ); + + for ( $i = 1; $i < $count; $i++ ) { + $arg = $argv[ $i ]; + if ( ! str_starts_with( $arg, '--' ) ) { + continue; + } + + $arg = substr( $arg, 2 ); + if ( str_contains( $arg, '=' ) ) { + list( $key, $value ) = explode( '=', $arg, 2 ); + $options[ $key ] = $value; + continue; + } + + $next = $argv[ $i + 1 ] ?? null; + if ( null !== $next && ! str_starts_with( $next, '--' ) ) { + $options[ $arg ] = $next; + $i++; + } else { + $options[ $arg ] = true; + } + } + + return $options; +} + +function option_string( array $options, string $key, ?string $default = null ): ?string { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + return (string) $options[ $key ]; +} + +function option_int( array $options, string $key, int $default ): int { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + return (int) $options[ $key ]; +} + +function option_bool( array $options, string $key, bool $default = false ): bool { + if ( ! array_key_exists( $key, $options ) ) { + return $default; + } + + $value = $options[ $key ]; + if ( true === $value ) { + return true; + } + + return in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); +} + diff --git a/tools/component-fuzz/lib/SurfaceRunner.php b/tools/component-fuzz/lib/SurfaceRunner.php new file mode 100644 index 0000000000000..dbb6ed1af22be --- /dev/null +++ b/tools/component-fuzz/lib/SurfaceRunner.php @@ -0,0 +1,309 @@ + */ + private array $surfaces; + + /** + * @param array $surfaces Map of surface names to class names. + */ + public function __construct( array $surfaces ) { + $this->surfaces = $surfaces; + } + + public function surface_names(): array { + return array_keys( $this->surfaces ); + } + + public function run( array $options ): array { + WpBootstrap::load(); + + $seed = option_int( $options, 'seed', 1 ); + $iterations = max( 1, option_int( $options, 'iterations', 25 ) ); + $output_dir = option_string( $options, 'output-dir', repo_root() . DIRECTORY_SEPARATOR . 'artifacts' . DIRECTORY_SEPARATOR . 'component-fuzz' . DIRECTORY_SEPARATOR . 'run-' . gmdate( 'Ymd\THis\Z' ) ); + $fail_fast = option_bool( $options, 'fail-fast', false ); + $surface_arg = option_string( $options, 'surface', 'all' ); + $selected = $this->select_surfaces( $surface_arg ); + + ensure_dir( $output_dir ); + $results_path = $output_dir . DIRECTORY_SEPARATOR . 'results.ndjson'; + $summary_path = $output_dir . DIRECTORY_SEPARATOR . 'summary.json'; + + $summary = array( + 'kind' => 'component-fuzz-summary', + 'createdAt' => gmdate( 'c' ), + 'seed' => $seed, + 'iterations' => $iterations, + 'surfaces' => array_keys( $selected ), + 'counts' => array( + 'passed' => 0, + 'failed' => 0, + 'skipped' => 0, + 'errored' => 0, + ), + 'failures' => array(), + ); + + foreach ( $selected as $surface => $class ) { + if ( ! class_exists( $class ) ) { + $row = $this->error_row( $seed, $surface, 0, 'surface-class-missing', "Class {$class} is not loaded." ); + append_ndjson( $results_path, $row ); + $this->record_row( $summary, $row ); + continue; + } + + for ( $i = 0; $i < $iterations; $i++ ) { + $case_seed = Prng::mix_seed( $seed + $i, $surface ); + $ctx = new FuzzContext( $case_seed, $surface, $i ); + $started = hrtime( true ); + $rows = $this->run_surface_case( $class, $ctx ); + foreach ( $rows as $row ) { + $row['durationMs'] = round( ( hrtime( true ) - $started ) / 1000000, 3 ); + append_ndjson( $results_path, $row ); + $this->record_row( $summary, $row ); + if ( $fail_fast && empty( $row['ok'] ) ) { + write_json_file( $summary_path, $summary ); + return $summary; + } + } + } + } + + write_json_file( $summary_path, $summary ); + return $summary; + } + + private function select_surfaces( string $surface_arg ): array { + if ( 'all' === $surface_arg ) { + return $this->surfaces; + } + + $selected = array(); + foreach ( array_filter( array_map( 'trim', explode( ',', $surface_arg ) ) ) as $name ) { + if ( ! isset( $this->surfaces[ $name ] ) ) { + throw new \InvalidArgumentException( "Unknown surface: {$name}" ); + } + $selected[ $name ] = $this->surfaces[ $name ]; + } + return $selected; + } + + private function run_surface_case( string $class, FuzzContext $ctx ): array { + $previous = set_error_handler( + static function ( int $severity, string $message, string $file, int $line ): bool { + if ( error_reporting() & $severity ) { + throw new \ErrorException( $message, 0, $severity, $file, $line ); + } + return false; + } + ); + + try { + $rows = $class::run( $ctx ); + } catch ( \Throwable $e ) { + $rows = array( + $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'throwable', $e->getMessage(), $e ), + ); + } finally { + restore_error_handler(); + if ( null !== $previous ) { + set_error_handler( $previous ); + } + } + + if ( ! is_array( $rows ) ) { + return array( + $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-return', 'Surface did not return an array.' ), + ); + } + + if ( $this->is_aggregate_result( $rows ) ) { + return $this->aggregate_to_rows( $rows, $ctx ); + } + + $normalized = array(); + foreach ( $rows as $row ) { + if ( ! is_array( $row ) ) { + $normalized[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-row', 'Surface returned a non-array row.' ); + continue; + } + $normalized[] = array_merge( + array( + 'ok' => true, + 'status' => 'passed', + 'surface' => $ctx->surface(), + 'invariant' => 'unspecified', + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => array(), + ), + $row + ); + } + + return $normalized; + } + + private function is_aggregate_result( array $result ): bool { + return isset( $result['surface'] ) + && ! array_is_list( $result ) + && ( + ( isset( $result['checks'] ) && is_array( $result['checks'] ) ) + || ( isset( $result['kind'] ) && 'component-fuzz-surface-result' === $result['kind'] ) + || ( array_key_exists( 'ok', $result ) && array_key_exists( 'failures', $result ) ) + ); + } + + private function aggregate_to_rows( array $result, FuzzContext $ctx ): array { + if ( ! isset( $result['checks'] ) || ! is_array( $result['checks'] ) ) { + return $this->compact_aggregate_to_rows( $result, $ctx ); + } + + $failures_by_check = array(); + foreach ( $result['failures'] ?? array() as $failure ) { + if ( ! is_array( $failure ) ) { + continue; + } + $check = $failure['check'] ?? 'aggregate'; + $failures_by_check[ $check ][] = $failure; + } + + $rows = array(); + foreach ( $result['checks'] as $check ) { + if ( ! is_array( $check ) ) { + $rows[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'invalid-aggregate-check', 'Aggregate surface returned a non-array check.' ); + continue; + } + + $name = (string) ( $check['name'] ?? 'aggregate-check' ); + $ok = (bool) ( $check['ok'] ?? false ); + $failures = $failures_by_check[ $name ] ?? array(); + unset( $check['name'], $check['ok'] ); + + $rows[] = array( + 'ok' => $ok, + 'status' => $ok ? 'passed' : 'failed', + 'surface' => (string) ( $result['surface'] ?? $ctx->surface() ), + 'invariant' => $name, + 'seed' => (int) ( $result['seed'] ?? $ctx->seed() ), + 'iteration' => $ctx->iteration(), + 'data' => array( + 'check' => $check, + 'failures' => array_slice( $failures, 0, 5 ), + ), + ); + } + + foreach ( $result['skipped'] ?? array() as $skipped ) { + if ( ! is_array( $skipped ) ) { + continue; + } + $rows[] = array( + 'ok' => true, + 'status' => 'skipped', + 'surface' => (string) ( $result['surface'] ?? $ctx->surface() ), + 'invariant' => (string) ( $skipped['name'] ?? 'skipped' ), + 'seed' => (int) ( $result['seed'] ?? $ctx->seed() ), + 'iteration' => $ctx->iteration(), + 'data' => array( + 'reason' => $skipped['reason'] ?? 'Skipped by surface.', + ), + ); + } + + if ( array() === $rows ) { + $rows[] = $this->error_row( $ctx->seed(), $ctx->surface(), $ctx->iteration(), 'empty-aggregate', 'Aggregate surface did not report any checks.' ); + } + + return $rows; + } + + private function compact_aggregate_to_rows( array $result, FuzzContext $ctx ): array { + $surface = (string) ( $result['surface'] ?? $ctx->surface() ); + $seed = (int) ( $result['seed'] ?? $ctx->seed() ); + $failures = array_values( array_filter( $result['failures'] ?? array(), 'is_array' ) ); + $rows = array(); + + if ( array() === $failures ) { + $rows[] = array( + 'ok' => true, + 'status' => 'passed', + 'surface' => $surface, + 'invariant' => $surface . '.aggregate', + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => array( + 'cases' => $result['cases'] ?? ( $result['caseCount'] ?? null ), + 'checks' => $result['checks'] ?? null, + 'assertions' => $result['assertions'] ?? null, + 'apiCalls' => $result['apiCalls'] ?? null, + 'failureCount' => $result['failureCount'] ?? count( $failures ), + 'coverage' => $result['coverage'] ?? ( $result['features'] ?? array() ), + ), + ); + } else { + foreach ( $failures as $failure ) { + $rows[] = array( + 'ok' => false, + 'status' => 'failed', + 'surface' => $surface, + 'invariant' => (string) ( $failure['invariant'] ?? $surface . '.aggregate-failure' ), + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => $failure, + ); + } + } + + foreach ( array_merge( $result['skips'] ?? array(), $result['skipped'] ?? array() ) as $skip ) { + $rows[] = array( + 'ok' => true, + 'status' => 'skipped', + 'surface' => $surface, + 'invariant' => is_array( $skip ) ? (string) ( $skip['name'] ?? $surface . '.skip' ) : (string) $skip, + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => array( + 'reason' => is_array( $skip ) ? ( $skip['reason'] ?? $skip ) : $skip, + ), + ); + } + + return $rows; + } + + private function error_row( int $seed, string $surface, int $iteration, string $invariant, string $message, ?\Throwable $throwable = null ): array { + $data = array( 'message' => $message ); + if ( null !== $throwable ) { + $data['throwable'] = get_class( $throwable ); + $data['file'] = $throwable->getFile(); + $data['line'] = $throwable->getLine(); + } + + return array( + 'ok' => false, + 'status' => 'errored', + 'surface' => $surface, + 'invariant' => $invariant, + 'seed' => $seed, + 'iteration' => $iteration, + 'data' => $data, + ); + } + + private function record_row( array &$summary, array $row ): void { + $status = $row['status'] ?? ( empty( $row['ok'] ) ? 'failed' : 'passed' ); + if ( 'skipped' === $status ) { + $summary['counts']['skipped']++; + } elseif ( 'errored' === $status ) { + $summary['counts']['errored']++; + $summary['failures'][] = $row; + } elseif ( empty( $row['ok'] ) ) { + $summary['counts']['failed']++; + $summary['failures'][] = $row; + } else { + $summary['counts']['passed']++; + } + } +} diff --git a/tools/component-fuzz/lib/WpBootstrap.php b/tools/component-fuzz/lib/WpBootstrap.php new file mode 100644 index 0000000000000..e0581788d81fa --- /dev/null +++ b/tools/component-fuzz/lib/WpBootstrap.php @@ -0,0 +1,158 @@ +_escape( $arg ) . "'", $query, 1 ); + } + return $query; + } + + public function suppress_errors( $suppress = true ) { + $previous = $this->suppress_errors; + $this->suppress_errors = (bool) $suppress; + return $previous; + } + + public function get_var( $query = null, $x = 0, $y = 0 ) { + return null; + } + + public function get_row( $query = null, $output = OBJECT, $y = 0 ) { + return null; + } + + public function get_results( $query = null, $output = OBJECT ) { + return array(); + } + + public function get_blog_prefix( $blog_id = null ) { + return 'wp_'; + } + } +} + +$GLOBALS['wpdb'] = $GLOBALS['wpdb'] ?? new Component_Fuzz_WPDB_Stub(); diff --git a/tools/component-fuzz/notes/identity.md b/tools/component-fuzz/notes/identity.md new file mode 100644 index 0000000000000..4413205f78495 --- /dev/null +++ b/tools/component-fuzz/notes/identity.md @@ -0,0 +1,17 @@ +# Identity surface + +`IdentitySurface` is a pure-PHP, DB-free fuzz surface for identity-adjacent WordPress helpers. + +It exercises: + +- usernames: `sanitize_user()` and `validate_username()` +- email: `sanitize_email()` and `is_email()` +- capability-like keys: `sanitize_key()` +- author URL parsing/sanitization: `sanitize_url()`, `esc_url_raw()`, `wp_parse_url()` +- comment-ish text helpers: `sanitize_text_field()`, `sanitize_textarea_field()`, `wp_strip_all_tags()`, `wp_filter_nohtml_kses()` +- comment cookie/comment array filtering: `sanitize_comment_cookies()` and `wp_filter_comment()` +- scalar option sanitization branches that avoid live DB fallback on success +- auth/password helpers: `wp_generate_password()`, `wp_hash_password()`, `wp_check_password()` +- parsing: `wp_parse_str()` + +The surface uses a deterministic SHA-256 PRNG seeded from common `FuzzContext` accessors when available. It installs scoped `pre_option_blog_charset`/`WPLANG` short-circuits when the filter API is available so formatting helpers do not need a live options table. Each case snapshots and restores touched global state, notably `$_COOKIE` and the current filter stack marker, and the outer run restores `wp_filter` after those temporary filters are removed. diff --git a/tools/component-fuzz/notes/markup.md b/tools/component-fuzz/notes/markup.md new file mode 100644 index 0000000000000..05187f7603074 --- /dev/null +++ b/tools/component-fuzz/notes/markup.md @@ -0,0 +1,20 @@ +# Markup surface notes + +`MarkupSurface` covers pure-PHP block, shortcode, and markup helper behavior with bounded generated inputs. + +Primary invariants: + +- `parse_blocks()` -> `serialize_blocks()` -> `parse_blocks()` preserves parsed block structure. +- `serialize_blocks()` equals the concatenation of `serialize_block()` for top-level parsed blocks. +- `has_blocks()` must be true for parser-confirmed named blocks; malformed `\n" . $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ]; + $excerpt = $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ]; + $comment_status = self::rng_choice( $rng, array( 'open', 'closed', 'registered_only', 'OPEN', '', "open\0closed" ) ); + $post_type = self::sanitize_key_fallback( self::rng_choice( $rng, array( 'post', 'page', 'attachment', 'custom-type', 'Bad Type!' ) ) ); + if ( '' === $post_type ) { + $post_type = 'post'; + } + + $posts[] = array( + 'ID' => self::rng_int( $rng, -5, 5000 ), + 'post_title' => $title, + 'post_name' => $slug, + 'post_content' => $content, + 'post_excerpt' => $excerpt, + 'post_status' => self::rng_choice( $rng, array( 'publish', 'draft', 'future', 'private', 'bad status', '' ) ), + 'post_password' => $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ], + 'post_type' => $post_type, + 'post_parent' => self::rng_int( $rng, -10, 250 ), + 'menu_order' => self::rng_int( $rng, -20, 20 ), + 'comment_status' => $comment_status, + 'ping_status' => self::rng_choice( $rng, array( 'open', 'closed', 'OPEN', '', "closed\topen" ) ), + 'post_content_filtered' => '', + 'ancestors' => array( '1', 2, '-3', 'bad', self::rng_int( $rng, -9, 9 ) ), + ); + + $taxonomy = self::sanitize_key_fallback( self::rng_choice( $rng, array( 'category', 'post_tag', 'genre', 'bad taxonomy!', '' ) ) ); + if ( '' === $taxonomy ) { + $taxonomy = 'category'; + } + $terms[] = array( + 'term_id' => self::rng_int( $rng, -4, 2000 ), + 'name' => $title, + 'slug' => $slug, + 'description' => $excerpt, + 'parent' => self::rng_int( $rng, -5, 30 ), + 'count' => self::rng_int( $rng, -10, 100 ), + 'term_group' => self::rng_int( $rng, -3, 9 ), + 'term_taxonomy_id' => self::rng_int( $rng, -7, 2000 ), + 'object_id' => self::rng_int( $rng, -7, 2000 ), + 'taxonomy' => $taxonomy, + ); + } + + $object_value = (object) array( + 'label' => $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ], + 'nested' => array( 'x' => 1 ), + ); + $metadata_values = array( + null, + true, + false, + 0, + -42, + 123456, + 1.25, + '', + $strings[0], + $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ], + 'a:0:{}', + array(), + array( 'title' => $strings[1], 'count' => 2, 'flags' => array( true, false, null ) ), + array( 'nested' => array( 'percent' => 'fran%c3%a7ois', 'bytes' => $strings[ self::rng_int( $rng, 0, count( $strings ) - 1 ) ] ) ), + $object_value, + array( 'object' => $object_value ), + ); + $class_strings = array( + 'alpha beta gamma', + " leading\tmultiple\nspaces ", + 'has%20percent bad', + "cap\x00name", + "cap\x80name", + ); + foreach ( self::generated_strings( $rng, 8 ) as $value ) { + $keys[] = $value; + } + + self::run_case( + $result, + 'capability_keys', + static function () use ( &$result, $keys ): void { + foreach ( $keys as $input ) { + $key = \sanitize_key( $input ); + self::check( $result, 'sanitize_key.string', is_string( $key ), $input, 'string', $key ); + self::check( $result, 'sanitize_key.allowed_chars', 1 === preg_match( '/\A[a-z0-9_-]*\z/', $key ), $input, 'lowercase alnum, underscore, dash', $key ); + self::check( $result, 'sanitize_key.idempotent', $key === \sanitize_key( $key ), $input, $key, \sanitize_key( $key ) ); + } + } + ); + } + + private static function exercise_urls( array &$result, array &$rng ): void { + if ( ! self::have_functions( array( 'sanitize_url' ), $result, 'urls' ) ) { + return; + } + + $urls = array( + 'https://example.com/path?x=1&y=2', + 'http://user:pass@example.com:8080/a[b]', + 'example.com/path with spaces', + '/relative/path?x=1', + '#fragment', + '?query=1', + 'mailto:user@example.com', + 'ftp://example.com/file.txt', + 'javascript:alert(1)', + 'data:text/html,x', + "https://example.com/\x80bad", + str_repeat( 'a', 120 ) . '.example/path', + ); + foreach ( self::generated_strings( $rng, 6 ) as $value ) { + $urls[] = 'https://example.test/' . rawurlencode( substr( $value, 0, 48 ) ); + } + + self::run_case( + $result, + 'urls', + static function () use ( &$result, $urls ): void { + foreach ( $urls as $input ) { + $sanitized = \sanitize_url( $input ); + self::check( $result, 'sanitize_url.string', is_string( $sanitized ), $input, 'string', $sanitized ); + self::check( $result, 'sanitize_url.idempotent', $sanitized === \sanitize_url( $sanitized ), $input, $sanitized, \sanitize_url( $sanitized ) ); + + if ( function_exists( 'esc_url_raw' ) ) { + self::check( $result, 'esc_url_raw.aliases_sanitize_url', \esc_url_raw( $input ) === $sanitized, $input, $sanitized, \esc_url_raw( $input ) ); + } + + if ( function_exists( 'wp_parse_url' ) && '' !== $sanitized ) { + $parsed = \wp_parse_url( $sanitized ); + self::check( $result, 'wp_parse_url.accepts_sanitize_url_output', false !== $parsed, $input, 'parseable sanitized URL', $parsed ); + } + } + } + ); + } + + private static function exercise_text_and_comment_helpers( array &$result, array &$rng ): void { + $inputs = self::string_corpus( $rng ); + foreach ( self::generated_strings( $rng, 8 ) as $input ) { + $inputs[] = $input; + } + + if ( self::have_functions( array( 'sanitize_text_field' ), $result, 'sanitize_text_field' ) ) { + self::run_case( + $result, + 'sanitize_text_field', + static function () use ( &$result, $inputs ): void { + foreach ( $inputs as $input ) { + $out = \sanitize_text_field( $input ); + self::check( $result, 'sanitize_text_field.string', is_string( $out ), $input, 'string', $out ); + self::check( $result, 'sanitize_text_field.idempotent', $out === \sanitize_text_field( $out ), $input, $out, \sanitize_text_field( $out ) ); + } + } + ); + } + + if ( self::have_functions( array( 'sanitize_textarea_field' ), $result, 'sanitize_textarea_field' ) ) { + self::run_case( + $result, + 'sanitize_textarea_field', + static function () use ( &$result, $inputs ): void { + foreach ( $inputs as $input ) { + $out = \sanitize_textarea_field( $input ); + self::check( $result, 'sanitize_textarea_field.string', is_string( $out ), $input, 'string', $out ); + self::check( $result, 'sanitize_textarea_field.idempotent', $out === \sanitize_textarea_field( $out ), $input, $out, \sanitize_textarea_field( $out ) ); + } + } + ); + } + + if ( self::have_functions( array( 'wp_strip_all_tags' ), $result, 'wp_strip_all_tags' ) ) { + self::run_case( + $result, + 'wp_strip_all_tags', + static function () use ( &$result, $inputs ): void { + foreach ( $inputs as $input ) { + $out = \wp_strip_all_tags( $input, true ); + self::check( $result, 'wp_strip_all_tags.string', is_string( $out ), $input, 'string', $out ); + self::check( $result, 'wp_strip_all_tags.idempotent', $out === \wp_strip_all_tags( $out, true ), $input, $out, \wp_strip_all_tags( $out, true ) ); + } + } + ); + } + + if ( self::have_functions( array( 'wp_filter_nohtml_kses' ), $result, 'wp_filter_nohtml_kses' ) ) { + self::run_case( + $result, + 'wp_filter_nohtml_kses', + static function () use ( &$result, $inputs ): void { + foreach ( $inputs as $input ) { + $slashed = addslashes( $input ); + $out = \wp_filter_nohtml_kses( $slashed ); + self::check( $result, 'wp_filter_nohtml_kses.string', is_string( $out ), $input, 'string', $out ); + self::check( $result, 'wp_filter_nohtml_kses.idempotent', $out === \wp_filter_nohtml_kses( $out ), $input, $out, \wp_filter_nohtml_kses( $out ) ); + } + } + ); + } + } + + private static function exercise_comment_cookies( array &$result, array &$rng ): void { + unset( $rng ); + + if ( ! defined( 'COOKIEHASH' ) ) { + $result['skips'][] = 'comment_cookies:missing_COOKIEHASH'; + return; + } + if ( ! self::have_functions( array( 'sanitize_comment_cookies', 'wp_unslash', 'esc_attr' ), $result, 'comment_cookies' ) ) { + return; + } + + $hash = (string) constant( 'COOKIEHASH' ); + $cases = array( + array( + 'comment_author_' . $hash => "Alice O\\'Reilly", + 'comment_author_email_' . $hash => ' Display Name ', + 'comment_author_url_' . $hash => 'example.com/path with spaces', + ), + array( + 'comment_author_' . $hash => "bad\x80bytes", + 'comment_author_email_' . $hash => "bad@@example.com", + 'comment_author_url_' . $hash => 'javascript:alert(1)', + ), + ); + + self::run_case( + $result, + 'comment_cookies', + static function () use ( &$result, $cases ): void { + foreach ( $cases as $cookies ) { + foreach ( $cookies as $name => $value ) { + $_COOKIE[ $name ] = $value; + } + + \sanitize_comment_cookies(); + $once = array(); + foreach ( array_keys( $cookies ) as $name ) { + $once[ $name ] = $_COOKIE[ $name ] ?? null; + self::check( $result, 'sanitize_comment_cookies.keeps_cookie_string', is_string( $once[ $name ] ), $cookies, 'string cookie value', $once[ $name ] ); + } + + \sanitize_comment_cookies(); + foreach ( array_keys( $cookies ) as $name ) { + self::check( $result, 'sanitize_comment_cookies.idempotent', $once[ $name ] === ( $_COOKIE[ $name ] ?? null ), $cookies, $once[ $name ], $_COOKIE[ $name ] ?? null ); + } + } + } + ); + } + + private static function exercise_comment_filtering( array &$result, array &$rng ): void { + if ( ! self::have_functions( array( 'wp_filter_comment' ), $result, 'wp_filter_comment' ) ) { + return; + } + + $comments = array( + self::comment_like_array( 'Alice A', 'alice@example.com', 'example.com/a b', "Hello \nWorld", '127.0.0.1', 'Agent/1.0' ), + self::comment_like_array( "Bad\x80Name", 'bad@@example.com', 'javascript:alert(1)', "Line\r\ncontent", '::1', "Agent\x00Two" ), + self::comment_like_array( self::random_string( $rng, 48 ), 'user@example.test', 'https://example.test/' . self::rand_int( $rng, 1, 999 ), self::random_string( $rng, 160 ), '192.0.2.1', 'Fuzzer/1.0' ), + ); + + self::run_case( + $result, + 'wp_filter_comment', + static function () use ( &$result, $comments ): void { + foreach ( $comments as $comment ) { + $out = \wp_filter_comment( $comment ); + self::check( $result, 'wp_filter_comment.array', is_array( $out ), $comment, 'array', $out ); + self::check( $result, 'wp_filter_comment.filtered_flag', true === ( $out['filtered'] ?? null ), $comment, true, $out['filtered'] ?? null ); + foreach ( array( 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_author_IP', 'comment_agent' ) as $key ) { + self::check( $result, 'wp_filter_comment.preserves_required_string_fields', array_key_exists( $key, $out ) && is_string( $out[ $key ] ), $comment, $key . ' string', $out[ $key ] ?? null ); + } + } + } + ); + } + + private static function exercise_options( array &$result, array &$rng ): void { + if ( ! self::have_functions( array( 'sanitize_option' ), $result, 'sanitize_option' ) ) { + return; + } + + $cases = self::option_cases( $rng ); + + self::run_case( + $result, + 'sanitize_option', + static function () use ( &$result, $cases ): void { + foreach ( $cases as $case ) { + $option = $case[0]; + $value = $case[1]; + $family = $case[2]; + + $sanitized = \sanitize_option( $option, $value ); + self::check( $result, 'sanitize_option.idempotent.' . $family, $sanitized === \sanitize_option( $option, $sanitized ), array( $option, $value ), $sanitized, \sanitize_option( $option, $sanitized ) ); + + if ( 'absint' === $family ) { + self::check( $result, 'sanitize_option.absint_type', is_int( $sanitized ) && $sanitized >= 0, array( $option, $value ), 'non-negative int', $sanitized ); + } elseif ( 'positive_or_minus_one_int' === $family ) { + self::check( $result, 'sanitize_option.posts_count_type', is_int( $sanitized ) && 0 !== $sanitized && $sanitized >= -1, array( $option, $value ), 'int >= -1 except 0', $sanitized ); + } elseif ( 'charset' === $family ) { + self::check( $result, 'sanitize_option.blog_charset_type', is_string( $sanitized ) && 1 === preg_match( '/\A[a-zA-Z0-9_-]*\z/', $sanitized ), array( $option, $value ), 'charset token string', $sanitized ); + } elseif ( 'blog_public' === $family ) { + self::check( $result, 'sanitize_option.blog_public_type', is_int( $sanitized ), array( $option, $value ), 'int', $sanitized ); + } elseif ( 'gmt_offset' === $family ) { + self::check( $result, 'sanitize_option.gmt_offset_type', is_string( $sanitized ) && 1 === preg_match( '/\A[0-9:.\-]*\z/', $sanitized ), array( $option, $value ), 'numeric offset string', $sanitized ); + } elseif ( 'status_string' === $family ) { + self::check( $result, 'sanitize_option.status_string_type', is_string( $sanitized ), array( $option, $value ), 'string', $sanitized ); + } + } + } + ); + } + + private static function exercise_passwords( array &$result, array &$rng ): void { + if ( self::have_functions( array( 'wp_generate_password' ), $result, 'wp_generate_password' ) ) { + $params = array( + array( 0, false, false ), + array( 1, false, false ), + array( self::rand_int( $rng, 2, 16 ), true, false ), + array( self::rand_int( $rng, 8, 32 ), true, true ), + ); + + self::run_case( + $result, + 'wp_generate_password', + static function () use ( &$result, $params ): void { + foreach ( $params as $param ) { + $length = $param[0]; + $special = $param[1]; + $extra = $param[2]; + $password = \wp_generate_password( $length, $special, $extra ); + $chars = self::password_chars( $special, $extra ); + + self::check( $result, 'wp_generate_password.string', is_string( $password ), $param, 'string', $password ); + self::check( $result, 'wp_generate_password.length', strlen( $password ) === $length, $param, $length, strlen( $password ) ); + self::check( $result, 'wp_generate_password.allowed_chars', '' === $password || 1 === preg_match( '/\A[' . preg_quote( $chars, '/' ) . ']*\z/', $password ), $param, 'generated character class', $password ); + } + } + ); + } + + if ( ! self::have_functions( array( 'wp_hash_password', 'wp_check_password' ), $result, 'password_hashing' ) ) { + return; + } + + $passwords = array( + 'password', + "inner\x00nul", + "unicod\xC3\xA9-pass", + substr( self::random_string( $rng, 32 ), 0, 32 ), + ); + + self::run_case( + $result, + 'password_hashing', + static function () use ( &$result, $passwords ): void { + foreach ( $passwords as $password ) { + $password = trim( substr( $password, 0, 64 ) ); + if ( '' === $password ) { + $password = 'x'; + } + + $hash = \wp_hash_password( $password ); + self::check( $result, 'wp_hash_password.string', is_string( $hash ), $password, 'string hash', $hash ); + self::check( $result, 'wp_check_password.round_trip', \wp_check_password( $password, $hash ), $password, true, false ); + + $wrong = $password . 'x'; + self::check( $result, 'wp_check_password.rejects_wrong_password', ! \wp_check_password( $wrong, $hash ), $password, false, true ); + } + } + ); + } + + private static function exercise_parse_helpers( array &$result, array &$rng ): void { + unset( $rng ); + + if ( self::have_functions( array( 'wp_parse_str' ), $result, 'wp_parse_str' ) ) { + $queries = array( + 'author=Alice&email=alice%40example.com&url=https%3A%2F%2Fexample.com%2F', + 'comment_author=A%26B&comment_author_email=bad%40%40example.com&empty=', + 'name%5Bfirst%5D=Alice&name%5Blast%5D=Example&cap=edit_posts', + ); + + self::run_case( + $result, + 'wp_parse_str', + static function () use ( &$result, $queries ): void { + foreach ( $queries as $query ) { + $parsed = array(); + \wp_parse_str( $query, $parsed ); + self::check( $result, 'wp_parse_str.array_result', is_array( $parsed ), $query, 'array', $parsed ); + self::check( $result, 'wp_parse_str.nonempty_for_nonempty_query', '' === $query || array() !== $parsed, $query, 'non-empty array', $parsed ); + } + } + ); + } + } + + private static function option_cases( array &$rng ): array { + $absint_options = array( + 'thumbnail_size_w', + 'thumbnail_size_h', + 'comment_max_links', + 'thread_comments_depth', + 'users_can_register', + 'start_of_week', + ); + $numeric_values = array( -10, '-7', 0, '0', 1, '42', '99 bottles', true, false, self::rand_int( $rng, -100, 100 ) ); + + $cases = array(); + foreach ( $absint_options as $option ) { + foreach ( $numeric_values as $value ) { + $cases[] = array( $option, $value, 'absint' ); + } + } + + foreach ( array( 'posts_per_page', 'posts_per_rss' ) as $option ) { + foreach ( array( -10, -1, 0, '0', '12', 'abc', self::rand_int( $rng, -50, 50 ) ) as $value ) { + $cases[] = array( $option, $value, 'positive_or_minus_one_int' ); + } + } + + foreach ( array( 'default_ping_status', 'default_comment_status' ) as $option ) { + foreach ( array( '', '0', 'open', 'closed', 'weird", + "jo\x00n", + "bad\x80\xFFbytes", + "line1\r\nline2\tline3", + "\x01control\x7Fchars", + "snowman-\xE2\x98\x83", + "emoji-\xF0\x9F\x98\x80", + "accent-gr\xC3\xA5", + str_repeat( 'A', self::MAX_STRING_BYTES ), + str_repeat( "x%41  \t", 180 ), + ); + } + + private static function generated_strings( array &$rng, int $count ): array { + $values = array(); + for ( $i = 0; $i < $count; ++$i ) { + $values[] = self::random_string( $rng, self::rand_int( $rng, 0, 256 ) ); + } + + return $values; + } + + private static function random_string( array &$rng, int $length ): string { + $atoms = array( + 'a', + 'Z', + '9', + '_', + '-', + '.', + '@', + ' ', + "\t", + "\n", + '', + '', + '&', + '%41', + "'", + '"', + '\\', + "\x00", + "\x1F", + "\x7F", + "\x80", + "\xFF", + "\xC3\xA9", + "\xE2\x98\x83", + "\xF0\x9F\x98\x80", + ); + + $out = ''; + while ( strlen( $out ) < $length ) { + $out .= $atoms[ self::rand_int( $rng, 0, count( $atoms ) - 1 ) ]; + } + + return substr( $out, 0, min( $length, self::MAX_STRING_BYTES ) ); + } + + private static function run_case( array &$result, string $case, callable $callback ): void { + ++$result['cases']; + $result['coverage'][] = $case; + + $before = self::snapshot_globals(); + try { + $callback(); + } catch ( \Throwable $e ) { + self::failure( + $result, + $case . '.no_throw', + $case, + 'case completed without throwing', + get_class( $e ) . ': ' . $e->getMessage() + ); + } + + self::restore_globals( $before ); + $after_restore = self::snapshot_globals(); + self::check( $result, $case . '.globals_restored', self::same_value( $before, $after_restore ), $case, $before, $after_restore ); + } + + private static function install_db_free_short_circuits(): void { + if ( ! function_exists( 'add_filter' ) ) { + return; + } + + \add_filter( + 'pre_option_blog_charset', + static function () { + return 'UTF-8'; + }, + 0, + 3 + ); + \add_filter( + 'pre_option_WPLANG', + static function () { + return ''; + }, + 0, + 3 + ); + \add_filter( + 'pre_site_option_WPLANG', + static function () { + return ''; + }, + 0, + 3 + ); + } + + private static function snapshot_globals( bool $include_filters = false ): array { + $snapshot = array( + '_COOKIE' => $_COOKIE, + 'wp_current_filter' => $GLOBALS['wp_current_filter'] ?? null, + ); + + if ( $include_filters ) { + $snapshot['wp_filter_exists'] = array_key_exists( 'wp_filter', $GLOBALS ); + $snapshot['wp_filter'] = $GLOBALS['wp_filter'] ?? null; + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + $_COOKIE = $snapshot['_COOKIE']; + if ( array_key_exists( 'wp_current_filter', $snapshot ) ) { + if ( null === $snapshot['wp_current_filter'] ) { + unset( $GLOBALS['wp_current_filter'] ); + } else { + $GLOBALS['wp_current_filter'] = $snapshot['wp_current_filter']; + } + } + if ( array_key_exists( 'wp_filter_exists', $snapshot ) ) { + if ( $snapshot['wp_filter_exists'] ) { + $GLOBALS['wp_filter'] = $snapshot['wp_filter']; + } else { + unset( $GLOBALS['wp_filter'] ); + } + } + } + + private static function check( array &$result, string $invariant, bool $ok, $input = null, $expected = null, $actual = null ): void { + ++$result['checks']; + if ( $ok ) { + return; + } + + self::failure( $result, $invariant, $input, $expected, $actual ); + } + + private static function failure( array &$result, string $invariant, $input, $expected, $actual = null ): void { + $result['failures'][] = array( + 'invariant' => $invariant, + 'input' => self::summarize( $input ), + 'expected' => self::summarize( $expected ), + 'actual' => self::summarize( $actual ), + ); + } + + private static function have_functions( array $functions, array &$result, string $label ): bool { + $missing = array(); + foreach ( $functions as $function ) { + if ( ! function_exists( $function ) ) { + $missing[] = $function; + } + } + + if ( array() === $missing ) { + return true; + } + + $result['skips'][] = $label . ':missing_functions:' . implode( ',', $missing ); + return false; + } + + private static function summarize( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( substr( $value, 0, self::SAMPLE_BYTES ) ), + ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 2 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 12 ) { + $out['...'] = count( $value ) - $i; + break; + } + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::summarize( $item, $depth + 1 ); + ++$i; + } + + return $out; + } + + if ( is_object( $value ) ) { + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function escape_bytes( string $value ): string { + $out = ''; + $len = strlen( $value ); + for ( $i = 0; $i < $len; ++$i ) { + $ord = ord( $value[ $i ] ); + if ( 0x5C === $ord ) { + $out .= '\\\\'; + } elseif ( $ord >= 0x20 && $ord <= 0x7E ) { + $out .= $value[ $i ]; + } elseif ( 0x0A === $ord ) { + $out .= '\\n'; + } elseif ( 0x0D === $ord ) { + $out .= '\\r'; + } elseif ( 0x09 === $ord ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $ord ); + } + } + + return $out; + } + + private static function same_value( $left, $right ): bool { + return serialize( $left ) === serialize( $right ); + } + + private static function context_seed( \ComponentFuzz\FuzzContext $ctx ): string { + foreach ( array( 'seed', 'getSeed', 'get_seed' ) as $method ) { + if ( is_callable( array( $ctx, $method ) ) ) { + try { + $value = $ctx->$method(); + if ( is_scalar( $value ) ) { + return (string) $value; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + } + } + + foreach ( array( 'option', 'getOption', 'param', 'getParam' ) as $method ) { + if ( is_callable( array( $ctx, $method ) ) ) { + try { + $value = $ctx->$method( 'seed', null ); + if ( is_scalar( $value ) ) { + return (string) $value; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + } + } + + try { + $ref = new \ReflectionObject( $ctx ); + if ( $ref->hasProperty( 'seed' ) ) { + $property = $ref->getProperty( 'seed' ); + if ( $property->isPublic() ) { + $value = $property->getValue( $ctx ); + if ( is_scalar( $value ) ) { + return (string) $value; + } + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return 'identity:0'; + } + + private static function make_rng( string $seed ): array { + return array( + 'seed' => $seed, + 'counter' => 0, + 'buffer' => '', + ); + } + + private static function rand_bytes( array &$rng, int $length ): string { + while ( strlen( $rng['buffer'] ) < $length ) { + $rng['buffer'] .= hash( 'sha256', $rng['seed'] . ':identity:' . $rng['counter'], true ); + ++$rng['counter']; + } + + $out = substr( $rng['buffer'], 0, $length ); + $rng['buffer'] = substr( $rng['buffer'], $length ); + return $out; + } + + private static function rand_uint32( array &$rng ): int { + $parts = unpack( 'Nvalue', self::rand_bytes( $rng, 4 ) ); + return (int) $parts['value']; + } + + private static function rand_int( array &$rng, int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + + return $min + ( self::rand_uint32( $rng ) % ( $max - $min + 1 ) ); + } +} diff --git a/tools/component-fuzz/surfaces/KsesSurface.php b/tools/component-fuzz/surfaces/KsesSurface.php new file mode 100644 index 0000000000000..7e5ac5d07417b --- /dev/null +++ b/tools/component-fuzz/surfaces/KsesSurface.php @@ -0,0 +1,1967 @@ + $missing ), + array( 'failureClass' => 'missing-wordpress-function' ) + ), + ); + } + + $results = array_merge( $results, self::check_allowed_html_contracts( $seed ) ); + + $rng = self::rng( $seed ); + for ( $case_index = 0; $case_index < $case_count; ++$case_index ) { + $case = self::generate_case( $rng, $case_index ); + $results = array_merge( $results, self::check_case( $seed, $case_index, $case ) ); + } + + return $results; + } + + private static function check_case( int $seed, int $case_index, array $case ): array { + $results = array(); + $results = array_merge( $results, self::check_wp_kses_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_wp_kses_post_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_bad_protocol_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_url_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_safecss_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_nohtml_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_hair_parse_invariants( $seed, $case_index, $case ) ); + $results = array_merge( $results, self::check_one_attr_invariants( $seed, $case_index, $case ) ); + + return $results; + } + + private static function check_allowed_html_contracts( int $seed ): array { + $results = array(); + + try { + $contexts = array( 'post', 'data', 'strip', 'entities' ); + $counts = array(); + foreach ( $contexts as $context ) { + $allowed = \wp_kses_allowed_html( $context ); + if ( ! is_array( $allowed ) ) { + $results[] = self::fail( + $seed, + null, + 'wp_kses_allowed_html.context-returns-array', + $context, + 'array', + gettype( $allowed ), + array( 'context' => $context ) + ); + continue; + } + $counts[ $context ] = count( $allowed ); + } + + if ( ! isset( $results[0] ) ) { + $results[] = self::pass( + $seed, + null, + 'wp_kses_allowed_html.context-returns-array', + implode( ',', $contexts ), + array( 'counts' => $counts ) + ); + } + + $strip = \wp_kses_allowed_html( 'strip' ); + if ( array() === $strip ) { + $results[] = self::pass( $seed, null, 'wp_kses_allowed_html.strip-is-empty-policy', 'strip' ); + } else { + $results[] = self::fail( + $seed, + null, + 'wp_kses_allowed_html.strip-is-empty-policy', + 'strip', + array(), + $strip + ); + } + + foreach ( array( 'post', 'data' ) as $context ) { + $violations = self::allowed_html_case_violations( \wp_kses_allowed_html( $context ) ); + if ( empty( $violations ) ) { + $results[] = self::pass( + $seed, + null, + 'wp_kses_allowed_html.lowercase-policy-keys', + $context, + array( 'context' => $context ) + ); + } else { + $results[] = self::fail( + $seed, + null, + 'wp_kses_allowed_html.lowercase-policy-keys', + $context, + 'lowercase tag and attribute keys', + $violations, + array( 'context' => $context ) + ); + } + } + + $explicit = array( + 'a' => array( + 'href' => true, + 'title' => true, + ), + ); + $actual = \wp_kses_allowed_html( $explicit ); + if ( $explicit === $actual ) { + $results[] = self::pass( $seed, null, 'wp_kses_allowed_html.explicit-policy-round-trip', '' ); + } else { + $results[] = self::fail( + $seed, + null, + 'wp_kses_allowed_html.explicit-policy-round-trip', + '', + $explicit, + $actual + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, null, 'wp_kses_allowed_html.contracts-no-throw', '', $e ); + } + + return $results; + } + + private static function check_wp_kses_invariants( int $seed, int $case_index, array $case ): array { + $results = array(); + $html = $case['html']; + $protocols = $case['protocols']; + $policy = self::strict_policy(); + + try { + $filtered = \wp_kses( $html, $policy, $protocols ); + $again = \wp_kses( $filtered, $policy, $protocols ); + + if ( $filtered === $again ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses.strict-policy-idempotent', $html, self::case_details( $case, $filtered ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses.strict-policy-idempotent', + $html, + $filtered, + $again, + self::case_details( $case ) + ); + } + + $control = self::raw_control_violation( $filtered ); + if ( null === $control ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses.strict-policy-no-raw-c0-controls', $html, self::case_details( $case, $filtered ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses.strict-policy-no-raw-c0-controls', + $html, + 'no raw C0 controls except tab, LF, and CR', + $control, + self::case_details( $case, $filtered ) + ); + } + + $violations = self::policy_violations( $filtered, $policy, $protocols ); + if ( empty( $violations ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses.strict-policy-enforced', $html, self::case_details( $case, $filtered ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses.strict-policy-enforced', + $html, + 'only tags, attributes, and URI protocols from the strict policy', + $violations, + self::case_details( $case, $filtered ) + ); + } + + $post_again = \wp_kses( $filtered, 'post', $protocols ); + if ( $post_again === $filtered ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses.policy-monotonicity-strict-output-survives-post', $html, self::case_details( $case, $filtered ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses.policy-monotonicity-strict-output-survives-post', + $html, + 'strict policy output remains unchanged under broader post policy', + array( + 'strictOutput' => self::preview( $filtered ), + 'postOutput' => self::preview( $post_again ), + ), + self::case_details( $case ) + ); + } + + $data_filtered = \wp_kses( $html, 'data', $protocols ); + $data_post = \wp_kses( $data_filtered, 'post', $protocols ); + if ( $data_post === $data_filtered ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses.policy-monotonicity-data-output-survives-post', $html, self::case_details( $case, $data_filtered ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses.policy-monotonicity-data-output-survives-post', + $html, + 'data policy output remains unchanged under broader post policy', + array( + 'dataOutput' => self::preview( $data_filtered ), + 'postOutput' => self::preview( $data_post ), + ), + self::case_details( $case ) + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses.core-invariants-no-throw', $html, $e, self::case_details( $case ) ); + } + + return $results; + } + + private static function check_wp_kses_post_invariants( int $seed, int $case_index, array $case ): array { + $results = array(); + $html = $case['html']; + + if ( ! function_exists( 'wp_kses_post' ) ) { + return array( self::skip( $seed, $case_index, 'wp_kses_post.available', 'wp_kses_post() is not loaded', $html ) ); + } + + try { + $post = \wp_kses_post( $html ); + $direct = \wp_kses( $html, 'post' ); + + if ( $post === $direct ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_post.equals-wp_kses-post-policy', $html, self::case_details( $case, $post ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_post.equals-wp_kses-post-policy', + $html, + 'direct wp_kses($html, "post") output', + array( + 'wpKsesPost' => self::preview( $post ), + 'wpKsesPostPolicy' => self::preview( $direct ), + ), + self::case_details( $case ) + ); + } + + $again = \wp_kses_post( $post ); + if ( $post === $again ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_post.idempotent', $html, self::case_details( $case, $post ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_post.idempotent', + $html, + $post, + $again, + self::case_details( $case ) + ); + } + + $violations = self::post_output_security_violations( $post, self::default_protocols() ); + if ( empty( $violations ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_post.uri-attributes-safe', $html, self::case_details( $case, $post ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_post.uri-attributes-safe', + $html, + 'no forbidden URI protocols in post-policy output attributes', + $violations, + self::case_details( $case, $post ) + ); + } + + $control = self::raw_control_violation( $post ); + if ( null === $control ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_post.no-raw-c0-controls', $html, self::case_details( $case, $post ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_post.no-raw-c0-controls', + $html, + 'no raw C0 controls except tab, LF, and CR', + $control, + self::case_details( $case, $post ) + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses_post.invariants-no-throw', $html, $e, self::case_details( $case ) ); + } + + return $results; + } + + private static function check_bad_protocol_invariants( int $seed, int $case_index, array $case ): array { + $results = array(); + $protocols = $case['protocols']; + + foreach ( $case['urls'] as $url_index => $url ) { + try { + $filtered = \wp_kses_bad_protocol( $url, $protocols ); + $again = \wp_kses_bad_protocol( $filtered, $protocols ); + $label = 'url#' . $url_index; + $details = array( + 'profile' => $case['profile'], + 'urlIndex' => $url_index, + 'protocols' => $protocols, + 'output' => self::preview( $filtered ), + ); + + if ( $filtered === $again ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_bad_protocol.idempotent', $url, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_bad_protocol.idempotent', + $url, + $filtered, + $again, + $details + ); + } + + $scheme = self::leading_scheme( $filtered ); + if ( null === $scheme || in_array( $scheme, self::lowercase_list( $protocols ), true ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_bad_protocol.allowed-leading-scheme-only', $url, $details + array( 'scheme' => $scheme, 'label' => $label ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_bad_protocol.allowed-leading-scheme-only', + $url, + 'empty, relative, or leading scheme in allowed protocols', + array( + 'scheme' => $scheme, + 'output' => self::preview( $filtered ), + ), + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses_bad_protocol.no-throw', $url, $e, self::case_details( $case ) ); + } + } + + return $results; + } + + private static function check_url_invariants( int $seed, int $case_index, array $case ): array { + $results = array(); + $protocols = $case['protocols']; + + if ( ! function_exists( 'esc_url' ) || ! function_exists( 'sanitize_url' ) ) { + return array( self::skip( $seed, $case_index, 'esc_url.sanitize_url.available', 'esc_url() or sanitize_url() is not loaded', implode( "\n", $case['urls'] ) ) ); + } + + foreach ( $case['urls'] as $url_index => $url ) { + try { + $display = \esc_url( $url, $protocols ); + $db = \esc_url( $url, $protocols, 'db' ); + $sanitize = \sanitize_url( $url, $protocols ); + $details = array( + 'profile' => $case['profile'], + 'urlIndex' => $url_index, + 'protocols' => $protocols, + 'display' => self::preview( $display ), + 'db' => self::preview( $db ), + 'sanitize' => self::preview( $sanitize ), + ); + + if ( $sanitize === $db ) { + $results[] = self::pass( $seed, $case_index, 'sanitize_url.equals-esc_url-db-context', $url, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'sanitize_url.equals-esc_url-db-context', + $url, + $db, + $sanitize, + $details + ); + } + + foreach ( array( 'esc_url' => $display, 'sanitize_url' => $sanitize ) as $function_name => $output ) { + $safe = '' === $output || self::url_is_safe( $output, $protocols ); + if ( $safe ) { + $results[] = self::pass( $seed, $case_index, $function_name . '.output-protocol-safe', $url, $details + array( 'function' => $function_name ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + $function_name . '.output-protocol-safe', + $url, + 'empty, relative, or allowed URL scheme', + array( + 'output' => self::preview( $output ), + 'scheme' => self::leading_scheme( $output ), + ), + $details + ); + } + + $control = self::raw_control_violation( $output ); + if ( null === $control ) { + $results[] = self::pass( $seed, $case_index, $function_name . '.no-raw-c0-controls', $url, $details + array( 'function' => $function_name ) ); + } else { + $results[] = self::fail( + $seed, + $case_index, + $function_name . '.no-raw-c0-controls', + $url, + 'no raw C0 controls except tab, LF, and CR', + $control, + $details + ); + } + } + + $sanitize_again = \sanitize_url( $sanitize, $protocols ); + if ( $sanitize === $sanitize_again ) { + $results[] = self::pass( $seed, $case_index, 'sanitize_url.idempotent', $url, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'sanitize_url.idempotent', + $url, + $sanitize, + $sanitize_again, + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'esc_url.sanitize_url.no-throw', $url, $e, self::case_details( $case ) ); + } + } + + return $results; + } + + private static function check_safecss_invariants( int $seed, int $case_index, array $case ): array { + $css = $case['css']; + if ( ! function_exists( 'safecss_filter_attr' ) ) { + return array( self::skip( $seed, $case_index, 'safecss_filter_attr.available', 'safecss_filter_attr() is not loaded', $css ) ); + } + + $results = array(); + try { + $filtered = \safecss_filter_attr( $css ); + $again = \safecss_filter_attr( $filtered ); + $details = self::case_details( $case, $filtered ); + + if ( $filtered === $again ) { + $results[] = self::pass( $seed, $case_index, 'safecss_filter_attr.idempotent', $css, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'safecss_filter_attr.idempotent', + $css, + $filtered, + $again, + $details + ); + } + + $violations = self::css_policy_violations( $filtered, self::default_protocols() ); + if ( empty( $violations ) ) { + $results[] = self::pass( $seed, $case_index, 'safecss_filter_attr.property-and-url-policy', $css, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'safecss_filter_attr.property-and-url-policy', + $css, + 'only safe CSS properties and URL protocols remain', + $violations, + $details + ); + } + + $control = self::raw_control_violation( $filtered ); + if ( null === $control ) { + $results[] = self::pass( $seed, $case_index, 'safecss_filter_attr.no-raw-c0-controls', $css, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'safecss_filter_attr.no-raw-c0-controls', + $css, + 'no raw C0 controls except tab, LF, and CR', + $control, + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'safecss_filter_attr.no-throw', $css, $e, self::case_details( $case ) ); + } + + return $results; + } + + private static function check_nohtml_invariants( int $seed, int $case_index, array $case ): array { + $html = $case['html']; + if ( ! function_exists( 'wp_filter_nohtml_kses' ) ) { + return array( self::skip( $seed, $case_index, 'wp_filter_nohtml_kses.available', 'wp_filter_nohtml_kses() is not loaded', $html ) ); + } + + $results = array(); + try { + $slashed = addslashes( $html ); + $filtered = \wp_filter_nohtml_kses( $slashed ); + $expected = addslashes( \wp_kses( stripslashes( $slashed ), 'strip' ) ); + $details = self::case_details( $case, $filtered ) + array( 'slashedInputPreview' => self::preview( $slashed ) ); + + if ( $expected === $filtered ) { + $results[] = self::pass( $seed, $case_index, 'wp_filter_nohtml_kses.definition-equivalence', $html, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_filter_nohtml_kses.definition-equivalence', + $html, + $expected, + $filtered, + $details + ); + } + + if ( ! self::contains_html_like_tag( stripslashes( $filtered ) ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_filter_nohtml_kses.no-html-like-tags', $html, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_filter_nohtml_kses.no-html-like-tags', + $html, + 'no HTML-like tags after unslashing output', + stripslashes( $filtered ), + $details + ); + } + + $again = \wp_filter_nohtml_kses( $filtered ); + if ( $filtered === $again ) { + $results[] = self::pass( $seed, $case_index, 'wp_filter_nohtml_kses.idempotent-on-slashed-output', $html, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_filter_nohtml_kses.idempotent-on-slashed-output', + $html, + $filtered, + $again, + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_filter_nohtml_kses.no-throw', $html, $e, self::case_details( $case ) ); + } + + return $results; + } + + private static function check_hair_parse_invariants( int $seed, int $case_index, array $case ): array { + $attrs = $case['attrs']; + $results = array(); + + if ( function_exists( 'wp_kses_hair_parse' ) ) { + try { + $parsed = \wp_kses_hair_parse( $attrs ); + $details = self::case_details( $case ) + array( + 'attributeInputPreview' => self::preview( $attrs ), + 'parsed' => self::compact_value( $parsed ), + ); + + if ( false === $parsed ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_hair_parse.rejects-or-round-trips', $attrs, $details + array( 'parserResult' => false ) ); + } elseif ( is_array( $parsed ) && implode( '', $parsed ) === $attrs ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_hair_parse.rejects-or-round-trips', $attrs, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_hair_parse.rejects-or-round-trips', + $attrs, + 'false or parsed segments that concatenate to the original attribute string', + $parsed, + $details + ); + } + + if ( is_array( $parsed ) ) { + $empty_segments = array(); + foreach ( $parsed as $segment_index => $segment ) { + if ( ! is_string( $segment ) || '' === $segment ) { + $empty_segments[] = array( + 'index' => $segment_index, + 'value' => $segment, + ); + } + } + if ( empty( $empty_segments ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_hair_parse.non-empty-string-segments', $attrs, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_hair_parse.non-empty-string-segments', + $attrs, + 'array of non-empty strings or an empty array for empty input', + $empty_segments, + $details + ); + } + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses_hair_parse.no-throw', $attrs, $e, self::case_details( $case ) ); + } + } else { + $results[] = self::skip( $seed, $case_index, 'wp_kses_hair_parse.available', 'wp_kses_hair_parse() is not loaded', $attrs ); + } + + if ( function_exists( 'wp_kses_hair' ) ) { + try { + $hair = \wp_kses_hair( $attrs, $case['protocols'] ); + $violations = self::hair_violations( $hair, $case['protocols'] ); + $details = self::case_details( $case ) + array( 'hair' => self::compact_value( $hair ) ); + if ( empty( $violations ) ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_hair.shape-and-uri-policy', $attrs, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_hair.shape-and-uri-policy', + $attrs, + 'structured attribute records with safe URI attribute values', + $violations, + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses_hair.no-throw', $attrs, $e, self::case_details( $case ) ); + } + } else { + $results[] = self::skip( $seed, $case_index, 'wp_kses_hair.available', 'wp_kses_hair() is not loaded', $attrs ); + } + + return $results; + } + + private static function check_one_attr_invariants( int $seed, int $case_index, array $case ): array { + if ( ! function_exists( 'wp_kses_one_attr' ) ) { + return array( self::skip( $seed, $case_index, 'wp_kses_one_attr.available', 'wp_kses_one_attr() is not loaded', $case['attrs'] ) ); + } + + $results = array(); + foreach ( self::interesting_attr_pieces( $case ) as $piece_index => $piece ) { + try { + $output = \wp_kses_one_attr( $piece, $case['tag'] ); + $details = self::case_details( $case, $output ) + array( 'pieceIndex' => $piece_index, 'tag' => $case['tag'] ); + $lower = strtolower( ltrim( $output ) ); + $bad_name = 0 === strpos( $lower, 'on' ); + $bad_uri = false; + + if ( 1 === preg_match( '/^\s*([A-Za-z0-9_:\.-]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))/s', $output, $matches ) ) { + $name = strtolower( $matches[1] ); + $value = ''; + for ( $i = 2; $i <= 4; ++$i ) { + if ( isset( $matches[ $i ] ) && '' !== $matches[ $i ] ) { + $value = $matches[ $i ]; + break; + } + } + if ( in_array( $name, self::uri_attributes(), true ) && ! self::url_is_safe( $value, self::default_protocols() ) ) { + $bad_uri = true; + } + } + + if ( ! $bad_name && ! $bad_uri ) { + $results[] = self::pass( $seed, $case_index, 'wp_kses_one_attr.event-and-uri-attribute-filtering', $piece, $details ); + } else { + $results[] = self::fail( + $seed, + $case_index, + 'wp_kses_one_attr.event-and-uri-attribute-filtering', + $piece, + 'no event handler attributes or forbidden URI protocols', + array( + 'output' => self::preview( $output ), + 'badName' => $bad_name, + 'badUri' => $bad_uri, + ), + $details + ); + } + } catch ( \Throwable $e ) { + $results[] = self::throwable_result( $seed, $case_index, 'wp_kses_one_attr.no-throw', $piece, $e, self::case_details( $case ) ); + } + } + + return $results; + } + + private static function generate_case( array &$rng, int $case_index ): array { + $corners = self::corner_cases(); + if ( isset( $corners[ $case_index ] ) ) { + $case = $corners[ $case_index ]; + if ( ! isset( $case['protocols'] ) ) { + $case['protocols'] = self::random_protocols( $rng ); + } + return $case; + } + + $profile = self::rng_weighted( + $rng, + array( + 'protocol-attributes' => 24, + 'css-url-values' => 18, + 'foreign-content' => 14, + 'malformed-markup' => 16, + 'nested-tags' => 18, + 'byte-edges' => 10, + ) + ); + + $protocols = self::random_protocols( $rng ); + $urls = array( + self::random_url( $rng ), + self::random_url( $rng ), + self::random_url( $rng ), + ); + $attrs = self::random_attr_list( $rng, $urls ); + $css = self::random_css( $rng, $urls ); + $tag = self::rng_choice( $rng, array( 'a', 'div', 'span', 'p', 'img', 'svg', 'math', 'form' ) ); + + switch ( $profile ) { + case 'foreign-content': + $html = '

text

'; + $html .= 'math link
'; + break; + case 'css-url-values': + $html = '
x
'; + break; + case 'malformed-markup': + $html = '

<'; + break; + case 'nested-tags': + $html = self::random_nodes( $rng, 3, $urls, $css ); + break; + case 'byte-edges': + $html = "\x00\x01" . '

bytes ' . self::random_text( $rng ) . "\x0B\x0C" . 'link
'; + break; + case 'protocol-attributes': + default: + $html = '<' . $tag . ' ' . $attrs . ' style="' . $css . '">payload ' . self::random_text( $rng ) . ''; + $html .= 'nested'; + break; + } + + return array( + 'profile' => $profile, + 'html' => $html, + 'urls' => $urls, + 'css' => $css, + 'attrs' => $attrs, + 'tag' => $tag, + 'protocols' => $protocols, + ); + } + + private static function corner_cases(): array { + return array( + array( + 'profile' => 'classic-xss', + 'html' => 'click', + 'urls' => array( 'javascript:alert(1)', 'https://example.com/a?b=1&c[]=2', '/relative/path?x[]=1' ), + 'css' => 'background-image:url(javascript:alert(1));color:red;behavior:url(#x);width:expression(alert(1))', + 'attrs' => 'href="javascript:alert(1)" onclick="evil()" style="background:url(javascript:alert(1));color:red" data-ok="1"', + 'tag' => 'a', + ), + array( + 'profile' => 'entity-protocols', + 'html' => 'entity
q
', + 'urls' => array( 'jav ascript:alert(1)', 'feed:javascript:alert(1)', 'http://example.org/' ), + 'css' => 'background:url(javascript:alert(1));margin-top:2px;Text-transform:uppercase', + 'attrs' => 'href="jav ascript:alert(1)" cite="feed:javascript:alert(1)" title="<Hello> & "World""', + 'tag' => 'a', + ), + array( + 'profile' => 'foreign-svg-math', + 'html' => 'math', + 'urls' => array( 'data:text/html,', 'vbscript:msgbox(1)', '//example.com/path?x[y]=1' ), + 'css' => 'fill:red;stroke-width:2;marker:url(javascript:alert(1));background-image:linear-gradient(red, blue)', + 'attrs' => 'xlink:href="javascript:alert(1)" xml:space="preserve" onload="evil()" fill="red"', + 'tag' => 'svg', + ), + array( + 'profile' => 'css-custom-properties', + 'html' => '
css
', + 'urls' => array( 'https://example.com/bg.png', 'javascript:alert(1)', 'mailto:?body=Hi%20there%0Aok' ), + 'css' => '--ok:10px;--bad:url(javascript:alert(1));background-image:url(https://example.com/bg.png);width:calc(100% - 1em);-moz-binding:url(x)', + 'attrs' => 'style="--bad:url(javascript:alert(1));background-image:url(https://example.com/bg.png);width:calc(100% - 1em)" class="has-style"', + 'tag' => 'div', + ), + array( + 'profile' => 'byte-and-null', + 'html' => "\x00\x01
Null \\0 slash zero & <script>
\x0E", + 'urls' => array( "\x00javascript:alert(1)", "java\x0Bscript:alert(1)", 'http://example.com/%0%0%0DAD' ), + 'css' => "color:red;\x00background-image:url(javascript:alert(1));margin-top:\x0B2px", + 'attrs' => "title=\"bad\x0Bthing\" href=\"\x00javascript:alert(1)\" data-x=\"\\0\"", + 'tag' => 'a', + ), + array( + 'profile' => 'malformed-comments', + 'html' => '

alertbad', + 'urls' => array( "java\t" . 'script:alert(1)', 'example.com?foo[bar]=baz', '?query[bad]=1' ), + 'css' => 'font-weight:bold;foo:bar;filter:url(javascript:alert(1));background:green url("foo.jpg") no-repeat fixed center', + 'attrs' => 'href=java' . "\t" . 'script:alert(1) title="x>y" data-x="" disabled', + 'tag' => 'a', + ), + array( + 'profile' => 'required-and-data-wildcard', + 'html' => '

text

', + 'urls' => array( '#fragment', 'ftp://example.com/file.txt', 'foo://example.com/custom' ), + 'css' => 'margin:10px 20px;padding:5px 10px;list-style-image:url(javascript:alert(1));white-space:pre-wrap', + 'attrs' => 'data-safe="1" data-evil.dot="2" aria-label="ok" onclick="evil()"', + 'tag' => 'p', + ), + array( + 'profile' => 'srcset-and-media', + 'html' => '', + 'urls' => array( 'https://example.com/v.mp4', 'data:image/svg+xml,', 'feed:feed:javascript:alert(1)' ), + 'css' => 'object-fit:cover;aspect-ratio:16/9;background-repeat:no-repeat;cursor:url(javascript:alert(1)), auto', + 'attrs' => 'poster="javascript:alert(1)" src="https://example.com/v.mp4" srcset="javascript:alert(1) 1x, https://example.com/a.png 2x"', + 'tag' => 'img', + ), + ); + } + + private static function random_nodes( array &$rng, int $depth, array $urls, string $css ): string { + if ( $depth <= 0 ) { + return self::random_text( $rng ); + } + + $out = ''; + $count = self::rng_int( $rng, 2, 5 ); + for ( $i = 0; $i < $count; ++$i ) { + if ( self::rng_chance( $rng, 25 ) ) { + $out .= self::random_text( $rng ); + continue; + } + + $tag = self::rng_choice( $rng, array( 'a', 'div', 'span', 'p', 'strong', 'em', 'code', 'blockquote', 'ul', 'li', 'table', 'tr', 'td', 'script', 'style', 'iframe', 'custom-element' ) ); + $attrs = self::random_attr_list( $rng, $urls ); + if ( in_array( $tag, array( 'script', 'style', 'iframe' ), true ) ) { + $out .= '<' . $tag . ' ' . $attrs . '>alert(1)'; + } elseif ( 'a' === $tag ) { + $out .= '' . self::random_nodes( $rng, $depth - 1, $urls, $css ) . ''; + } else { + $out .= '<' . $tag . ' ' . $attrs . ' style="' . $css . '">' . self::random_nodes( $rng, $depth - 1, $urls, $css ) . ''; + } + } + + return $out; + } + + private static function random_attr_list( array &$rng, array $urls ): string { + $names = array( + 'href', + 'src', + 'poster', + 'cite', + 'background', + 'title', + 'alt', + 'class', + 'id', + 'style', + 'onclick', + 'onload', + 'data-safe', + 'data-evil.dot', + 'aria-label', + 'xlink:href', + 'xml:space', + 'disabled', + '[shortcode]', + ); + + $parts = array(); + $count = self::rng_int( $rng, 3, 8 ); + for ( $i = 0; $i < $count; ++$i ) { + $name = self::rng_choice( $rng, $names ); + if ( in_array( $name, array( 'disabled', '[shortcode]' ), true ) && self::rng_chance( $rng, 55 ) ) { + $parts[] = $name; + continue; + } + + if ( in_array( strtolower( $name ), self::uri_attributes(), true ) || 'xlink:href' === strtolower( $name ) ) { + $value = self::rng_choice( $rng, $urls ); + } elseif ( 'style' === $name ) { + $value = self::random_css( $rng, $urls ); + } else { + $value = self::random_attr_value( $rng ); + } + + $quote = self::rng_choice( $rng, array( '"', "'", '' ) ); + if ( '' === $quote ) { + $value = preg_replace( '/\s+/', '', $value ); + $parts[] = $name . '=' . $value; + } else { + $parts[] = $name . '=' . $quote . str_replace( $quote, '', $value ) . $quote; + } + } + + return implode( ' ', $parts ); + } + + private static function random_attr_value( array &$rng ): string { + return self::rng_choice( + $rng, + array( + 'ok', + 'wide alignleft', + '<Hello> & "World"', + '<test>', + 'bad"value', + "bad'value", + "x>y", + "\x00nul\x0Bcontrol", + "\xC3\x28invalid-utf8", + '\\0 slash-zero', + ) + ); + } + + private static function random_css( array &$rng, array $urls ): string { + $allowed_props = array_keys( self::css_allowed_properties() ); + $disallowed_props = array( 'behavior', '-moz-binding', 'foo', 'Text-transform', 'list-style-image' ); + $items = array(); + $count = self::rng_int( $rng, 4, 9 ); + + for ( $i = 0; $i < $count; ++$i ) { + if ( self::rng_chance( $rng, 28 ) ) { + $prop = self::rng_choice( $rng, $disallowed_props ); + } else { + $prop = self::rng_choice( $rng, $allowed_props ); + } + + if ( self::rng_chance( $rng, 22 ) ) { + $prop = '--cf-' . self::rng_int( $rng, 1, 20 ); + } + + if ( in_array( strtolower( $prop ), self::css_url_properties(), true ) || self::rng_chance( $rng, 18 ) ) { + $value = 'url(' . self::rng_choice( $rng, $urls ) . ')'; + } else { + $value = self::rng_choice( + $rng, + array( + 'red', + '2px', + '10px 20px', + 'bold', + 'uppercase', + 'none', + 'block', + 'pre-wrap', + 'calc(100% - 1em)', + 'var(--wp--preset--spacing--20)', + 'repeat(2, minmax(0, 1fr))', + 'linear-gradient(red, blue)', + 'expression(alert(1))', + '2px}', + '\\2px', + ) + ); + } + + $items[] = $prop . ':' . $value; + } + + $items[] = 'background-image:url(javascript:alert(1))'; + $items[] = 'color:red'; + + return implode( ';', $items ); + } + + private static function random_url( array &$rng ): string { + return self::rng_choice( + $rng, + array( + 'http://example.com/path?x=1&y[]=2', + 'https://example.org/a%20b?one=1&two=2#frag', + 'mailto:?body=Hi%20there%0Aok', + 'ftp://example.com/file.txt', + '//example.com/protocol-relative?foo[bar]=baz', + '/relative/path?x[]=1', + '#fragment', + '?query[bad]=1', + 'example.com/bare?x[y]=1', + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + "java\t" . 'script:alert(1)', + 'jav ascript:alert(1)', + 'javascript:alert(1)', + 'feed:javascript:alert(1)', + 'feed:feed:javascript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + "\x00javascript:alert(1)", + 'http://[::FFFF::127.0.0.1]/?foo[bar]=baz', + 'foo://example.com/custom', + ) + ); + } + + private static function random_protocols( array &$rng ): array { + $sets = array( + self::default_protocols(), + array( 'http', 'https' ), + array( 'https', 'http' ), + array( 'http', 'https', 'mailto' ), + array( 'http', 'https', 'ftp', 'mailto', 'feed' ), + array( 'foo' ), + ); + + return self::rng_choice( $rng, $sets ); + } + + private static function random_text( array &$rng ): string { + return self::rng_choice( + $rng, + array( + 'plain text', + 'AT&T & not-an-entity &bogus;', + 'encoded <script> text', + "\x00nul\x01start\x0Bvertical-tab", + "\xC3\x28invalid utf8", + "\xE2\x98\x83 snowman", + 'quotes "\' and < >', + '\\0 slash-zero sequence', + ) + ); + } + + private static function strict_policy(): array { + return array( + 'a' => array( + 'href' => true, + 'title' => true, + 'rel' => true, + 'data-*' => true, + ), + 'br' => array(), + 'code' => array(), + 'em' => array(), + 'p' => array( + 'class' => true, + 'title' => true, + 'data-*' => true, + ), + 'span' => array( + 'class' => true, + 'title' => true, + 'data-*' => true, + ), + 'strong' => array(), + ); + } + + private static function post_output_security_violations( string $html, array $protocols ): array { + $allowed = function_exists( 'wp_kses_allowed_html' ) ? \wp_kses_allowed_html( 'post' ) : array(); + $policy_violations = is_array( $allowed ) ? self::policy_violations( $html, $allowed, $protocols, false ) : array(); + + return $policy_violations; + } + + private static function policy_violations( string $html, array $allowed_html, array $protocols, bool $tag_must_be_allowed = true ): array { + $violations = self::policy_violations_with_tag_processor( $html, $allowed_html, $protocols, $tag_must_be_allowed ); + if ( null !== $violations ) { + return $violations; + } + + return self::policy_violations_with_regex( $html, $allowed_html, $protocols, $tag_must_be_allowed ); + } + + private static function policy_violations_with_tag_processor( string $html, array $allowed_html, array $protocols, bool $tag_must_be_allowed ): ?array { + if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) { + return null; + } + + try { + $violations = array(); + $processor = new \WP_HTML_Tag_Processor( $html ); + while ( $processor->next_tag() ) { + $tag = strtolower( $processor->get_tag() ); + if ( $tag_must_be_allowed && ! isset( $allowed_html[ $tag ] ) ) { + $violations[] = array( + 'type' => 'tag', + 'tag' => $tag, + ); + continue; + } + + if ( ! method_exists( $processor, 'get_attribute_names_with_prefix' ) ) { + continue; + } + + $names = $processor->get_attribute_names_with_prefix( '' ); + if ( null === $names ) { + continue; + } + + foreach ( $names as $name ) { + $lower = strtolower( $name ); + if ( $tag_must_be_allowed && ! self::attribute_allowed_for_tag( $lower, $allowed_html[ $tag ] ?? array() ) ) { + $violations[] = array( + 'type' => 'attribute', + 'tag' => $tag, + 'attribute' => $name, + ); + } + + if ( 0 === strpos( $lower, 'on' ) ) { + $violations[] = array( + 'type' => 'event-attribute', + 'tag' => $tag, + 'attribute' => $name, + ); + } + + $value = $processor->get_attribute( $name ); + if ( is_string( $value ) && in_array( $lower, self::uri_attributes(), true ) && ! self::url_is_safe( $value, $protocols ) ) { + $violations[] = array( + 'type' => 'uri-protocol', + 'tag' => $tag, + 'attribute' => $name, + 'value' => self::preview( $value ), + 'scheme' => self::leading_scheme( $value ), + ); + } + } + } + + return $violations; + } catch ( \Throwable $e ) { + return null; + } + } + + private static function policy_violations_with_regex( string $html, array $allowed_html, array $protocols, bool $tag_must_be_allowed ): array { + $violations = array(); + if ( 1 !== preg_match_all( '/<\s*([A-Za-z0-9-]+)([^>]*)>/s', $html, $matches, PREG_SET_ORDER ) ) { + return $violations; + } + + foreach ( $matches as $match ) { + $tag = strtolower( $match[1] ); + $attrtext = $match[2]; + if ( $tag_must_be_allowed && ! isset( $allowed_html[ $tag ] ) ) { + $violations[] = array( + 'type' => 'tag', + 'tag' => $tag, + ); + continue; + } + + if ( 1 !== preg_match_all( '/([A-Za-z_:][A-Za-z0-9_:\.-]*)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+)))?/s', $attrtext, $attrs, PREG_SET_ORDER ) ) { + continue; + } + + foreach ( $attrs as $attr ) { + $name = strtolower( $attr[1] ); + $value = ''; + for ( $i = 2; $i <= 4; ++$i ) { + if ( isset( $attr[ $i ] ) && '' !== $attr[ $i ] ) { + $value = $attr[ $i ]; + break; + } + } + + if ( $tag_must_be_allowed && ! self::attribute_allowed_for_tag( $name, $allowed_html[ $tag ] ?? array() ) ) { + $violations[] = array( + 'type' => 'attribute', + 'tag' => $tag, + 'attribute' => $name, + ); + } + if ( 0 === strpos( $name, 'on' ) ) { + $violations[] = array( + 'type' => 'event-attribute', + 'tag' => $tag, + 'attribute' => $name, + ); + } + if ( in_array( $name, self::uri_attributes(), true ) && ! self::url_is_safe( $value, $protocols ) ) { + $violations[] = array( + 'type' => 'uri-protocol', + 'tag' => $tag, + 'attribute' => $name, + 'value' => self::preview( $value ), + 'scheme' => self::leading_scheme( $value ), + ); + } + } + } + + return $violations; + } + + private static function attribute_allowed_for_tag( string $name, $allowed_attrs ): bool { + if ( true === $allowed_attrs ) { + return true; + } + if ( ! is_array( $allowed_attrs ) ) { + return false; + } + if ( array_key_exists( $name, $allowed_attrs ) && false !== $allowed_attrs[ $name ] && '' !== $allowed_attrs[ $name ] ) { + return true; + } + if ( 0 === strpos( $name, 'data-' ) && ! empty( $allowed_attrs['data-*'] ) && 1 === preg_match( '/^data-[a-z0-9_-]+$/', $name ) ) { + return true; + } + + return false; + } + + private static function hair_violations( array $hair, array $protocols ): array { + $violations = array(); + foreach ( $hair as $key => $record ) { + if ( ! is_array( $record ) ) { + $violations[] = array( + 'type' => 'record-shape', + 'key' => $key, + ); + continue; + } + + foreach ( array( 'name', 'value', 'whole', 'vless' ) as $field ) { + if ( ! array_key_exists( $field, $record ) || ! is_string( $record[ $field ] ) ) { + $violations[] = array( + 'type' => 'record-field', + 'key' => $key, + 'field' => $field, + ); + } + } + + $name = strtolower( (string) ( $record['name'] ?? '' ) ); + if ( in_array( $name, self::uri_attributes(), true ) && ! self::url_is_safe( (string) ( $record['value'] ?? '' ), $protocols ) ) { + $violations[] = array( + 'type' => 'uri-protocol', + 'attribute' => $name, + 'value' => self::preview( (string) ( $record['value'] ?? '' ) ), + 'scheme' => self::leading_scheme( (string) ( $record['value'] ?? '' ) ), + ); + } + + } + + return $violations; + } + + private static function css_policy_violations( string $css, array $protocols ): array { + $violations = array(); + $allowed_props = self::css_allowed_properties(); + $disallowed_props = array_fill_keys( array( 'behavior', '-moz-binding', 'foo', 'text-transform-case-probe', 'list-style-image' ), true ); + + foreach ( explode( ';', $css ) as $item ) { + $item = trim( $item ); + if ( '' === $item || false === strpos( $item, ':' ) ) { + continue; + } + + $parts = explode( ':', $item, 2 ); + $prop = trim( $parts[0] ); + $value = trim( $parts[1] ); + $lower = strtolower( $prop ); + + if ( isset( $disallowed_props[ $lower ] ) ) { + $violations[] = array( + 'type' => 'disallowed-property', + 'property' => $prop, + ); + } + + $is_custom_property = 1 === preg_match( '/^--[A-Za-z0-9-_]+$/', $prop ); + if ( ! $is_custom_property && ! isset( $allowed_props[ $prop ] ) ) { + $violations[] = array( + 'type' => 'unknown-property', + 'property' => $prop, + ); + } + + if ( 1 === preg_match_all( '/url\(\s*(?:"([^"]*)"|\'([^\']*)\'|([^)]*))\s*\)/i', $value, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $url = ''; + for ( $i = 1; $i <= 3; ++$i ) { + if ( isset( $match[ $i ] ) && '' !== $match[ $i ] ) { + $url = trim( $match[ $i ] ); + break; + } + } + if ( '' !== $url && ! self::url_is_safe( $url, $protocols ) ) { + $violations[] = array( + 'type' => 'url-protocol', + 'property' => $prop, + 'value' => self::preview( $url ), + 'scheme' => self::leading_scheme( $url ), + ); + } + } + } + } + + if ( false !== strpos( $css, '/*' ) || false !== strpos( $css, '}' ) || false !== strpos( $css, '\\' ) ) { + $violations[] = array( + 'type' => 'unsafe-css-token', + ); + } + + return $violations; + } + + private static function css_allowed_properties(): array { + static $props = null; + if ( null !== $props ) { + return $props; + } + + $names = array( + 'background', + 'background-color', + 'background-image', + 'background-position', + 'background-repeat', + 'background-size', + 'background-attachment', + 'background-blend-mode', + 'border', + 'border-radius', + 'border-width', + 'border-color', + 'border-style', + 'border-right', + 'border-right-color', + 'border-right-style', + 'border-right-width', + 'border-bottom', + 'border-bottom-color', + 'border-bottom-left-radius', + 'border-bottom-right-radius', + 'border-bottom-style', + 'border-bottom-width', + 'border-left', + 'border-left-color', + 'border-left-style', + 'border-left-width', + 'border-top', + 'border-top-color', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-top-style', + 'border-top-width', + 'border-spacing', + 'border-collapse', + 'caption-side', + 'columns', + 'column-count', + 'column-fill', + 'column-gap', + 'column-rule', + 'column-span', + 'column-width', + 'display', + 'color', + 'filter', + 'font', + 'font-family', + 'font-size', + 'font-style', + 'font-variant', + 'font-weight', + 'letter-spacing', + 'line-height', + 'text-align', + 'text-decoration', + 'text-indent', + 'text-transform', + 'white-space', + 'height', + 'min-height', + 'max-height', + 'width', + 'min-width', + 'max-width', + 'margin', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-top', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'padding', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-top', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', + 'flex', + 'flex-basis', + 'flex-direction', + 'flex-flow', + 'flex-grow', + 'flex-shrink', + 'flex-wrap', + 'gap', + 'row-gap', + 'grid-template-columns', + 'grid-auto-columns', + 'grid-column-start', + 'grid-column-end', + 'grid-column', + 'grid-column-gap', + 'grid-template-rows', + 'grid-auto-rows', + 'grid-row-start', + 'grid-row-end', + 'grid-row', + 'grid-row-gap', + 'grid-gap', + 'justify-content', + 'justify-items', + 'justify-self', + 'align-content', + 'align-items', + 'align-self', + 'clear', + 'cursor', + 'direction', + 'float', + 'list-style-type', + 'object-fit', + 'object-position', + 'opacity', + 'overflow', + 'vertical-align', + 'writing-mode', + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'box-shadow', + 'aspect-ratio', + 'container-type', + 'fill', + 'fill-opacity', + 'fill-rule', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'color-interpolation', + 'color-interpolation-filters', + 'paint-order', + 'stop-color', + 'stop-opacity', + 'flood-color', + 'flood-opacity', + 'lighting-color', + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + 'clip-path', + 'clip-rule', + 'mask', + 'mask-type', + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'x', + 'y', + 'd', + 'alignment-baseline', + 'baseline-shift', + 'dominant-baseline', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'text-anchor', + 'unicode-bidi', + 'word-spacing', + 'font-size-adjust', + 'font-stretch', + 'color-rendering', + 'image-rendering', + 'shape-rendering', + 'text-rendering', + 'vector-effect', + 'transform', + 'transform-origin', + 'pointer-events', + 'visibility', + ); + + $props = array_fill_keys( $names, true ); + return $props; + } + + private static function css_url_properties(): array { + return array( + 'background', + 'background-image', + 'cursor', + 'filter', + ); + } + + private static function interesting_attr_pieces( array $case ): array { + $pieces = array( + 'href="' . $case['urls'][0] . '"', + 'onclick="evil()"', + 'style="' . $case['css'] . '"', + 'title="safe"', + ); + + if ( 1 === preg_match_all( '/(?:^|\s)((?:[^\s"\'=<>`]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s"\'=<>`]+))?)/', $case['attrs'], $matches ) ) { + foreach ( array_slice( $matches[1], 0, 4 ) as $piece ) { + $pieces[] = $piece; + } + } + + return array_values( array_unique( $pieces ) ); + } + + private static function allowed_html_case_violations( array $allowed_html ): array { + $violations = array(); + foreach ( $allowed_html as $tag => $attrs ) { + if ( is_string( $tag ) && strtolower( $tag ) !== $tag ) { + $violations[] = array( + 'type' => 'tag', + 'name' => $tag, + ); + } + if ( is_array( $attrs ) ) { + foreach ( $attrs as $attr => $_value ) { + if ( is_string( $attr ) && strtolower( $attr ) !== $attr ) { + $violations[] = array( + 'type' => 'attribute', + 'tag' => $tag, + 'name' => $attr, + ); + } + } + } + } + + return $violations; + } + + private static function url_is_safe( string $url, array $protocols ): bool { + $url = trim( $url ); + if ( '' === $url ) { + return true; + } + if ( '/' === $url[0] || '#' === $url[0] || '?' === $url[0] ) { + return true; + } + + $scheme = self::leading_scheme( $url ); + if ( null === $scheme ) { + return true; + } + + return in_array( $scheme, self::lowercase_list( $protocols ), true ); + } + + private static function leading_scheme( string $value ): ?string { + $decoded = self::decode_protocol_text( $value ); + $decoded = preg_replace( '/[\x00-\x20]+/', '', $decoded ); + if ( 1 === preg_match( '/^([A-Za-z][A-Za-z0-9+.-]*):/', $decoded, $matches ) ) { + return strtolower( $matches[1] ); + } + + return null; + } + + private static function decode_protocol_text( string $value ): string { + $decoded = $value; + for ( $i = 0; $i < 4; ++$i ) { + $previous = $decoded; + if ( function_exists( 'wp_kses_decode_entities' ) ) { + $decoded = \wp_kses_decode_entities( $decoded ); + } + $decoded = html_entity_decode( $decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + if ( $decoded === $previous ) { + break; + } + } + + return $decoded; + } + + private static function raw_control_violation( string $value ): ?array { + if ( 1 === preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value, $matches, PREG_OFFSET_CAPTURE ) ) { + return array( + 'offset' => $matches[0][1], + 'ord' => ord( $matches[0][0] ), + ); + } + + return null; + } + + private static function contains_html_like_tag( string $value ): bool { + return 1 === preg_match( '/<\s*\/?\s*[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?>/', $value ); + } + + private static function uri_attributes(): array { + if ( function_exists( 'wp_kses_uri_attributes' ) ) { + $attrs = \wp_kses_uri_attributes(); + if ( is_array( $attrs ) ) { + return self::lowercase_list( $attrs ); + } + } + + return self::FALLBACK_URI_ATTRIBUTES; + } + + private static function default_protocols(): array { + if ( function_exists( 'wp_allowed_protocols' ) ) { + $protocols = \wp_allowed_protocols(); + if ( is_array( $protocols ) && ! empty( $protocols ) ) { + return self::lowercase_list( $protocols ); + } + } + + return array( 'http', 'https', 'ftp', 'mailto', 'feed' ); + } + + private static function lowercase_list( array $values ): array { + $out = array(); + foreach ( $values as $value ) { + $out[] = strtolower( (string) $value ); + } + + return array_values( array_unique( $out ) ); + } + + private static function seed_from_context( $ctx ): int { + foreach ( array( 'seed', 'getSeed' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + return self::normalize_seed( $ctx->$method() ); + } catch ( \Throwable $e ) { + continue; + } + } + } + + foreach ( array( 'option', 'getOption', 'param', 'getParam' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + return self::normalize_seed( $ctx->$method( 'seed', 1 ) ); + } catch ( \Throwable $e ) { + continue; + } + } + } + + if ( property_exists( $ctx, 'seed' ) ) { + return self::normalize_seed( $ctx->seed ); + } + + return 1; + } + + private static function int_from_context( $ctx, string $name, int $fallback, int $min, int $max ): int { + $value = null; + foreach ( array( 'option', 'getOption', 'param', 'getParam' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + $value = $ctx->$method( $name, $fallback ); + break; + } catch ( \Throwable $e ) { + continue; + } + } + } + + if ( null === $value && property_exists( $ctx, $name ) ) { + $value = $ctx->$name; + } + if ( null === $value ) { + $value = $fallback; + } + + if ( ! is_numeric( $value ) ) { + $value = $fallback; + } + + return max( $min, min( $max, (int) $value ) ); + } + + private static function normalize_seed( $seed ): int { + if ( is_numeric( $seed ) ) { + return (int) $seed; + } + + $hash = substr( sha1( (string) $seed ), 0, 8 ); + return (int) hexdec( $hash ); + } + + private static function rng( int $seed ): array { + return array( + 'seed' => (string) $seed, + 'counter' => 0, + 'buffer' => '', + ); + } + + private static function rng_bytes( array &$rng, int $length ): string { + while ( strlen( $rng['buffer'] ) < $length ) { + $rng['buffer'] .= hash( 'sha256', $rng['seed'] . ':' . $rng['counter'], true ); + ++$rng['counter']; + } + + $out = substr( $rng['buffer'], 0, $length ); + $rng['buffer'] = substr( $rng['buffer'], $length ); + + return $out; + } + + private static function rng_uint32( array &$rng ): int { + $parts = unpack( 'Nvalue', self::rng_bytes( $rng, 4 ) ); + return (int) $parts['value']; + } + + private static function rng_int( array &$rng, int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + + return $min + ( self::rng_uint32( $rng ) % ( $max - $min + 1 ) ); + } + + private static function rng_chance( array &$rng, int $numerator, int $denominator = 100 ): bool { + return self::rng_int( $rng, 1, $denominator ) <= $numerator; + } + + private static function rng_choice( array &$rng, array $values ) { + return $values[ self::rng_int( $rng, 0, count( $values ) - 1 ) ]; + } + + private static function rng_weighted( array &$rng, array $weights ): string { + $total = array_sum( $weights ); + $pick = self::rng_int( $rng, 1, max( 1, (int) $total ) ); + $first = null; + foreach ( $weights as $value => $weight ) { + if ( null === $first ) { + $first = $value; + } + $pick -= $weight; + if ( $pick <= 0 ) { + return (string) $value; + } + } + + return (string) $first; + } + + private static function pass( int $seed, ?int $case_index, string $invariant, string $input, array $details = array() ): array { + $result = self::base_result( $seed, $case_index, $invariant, $input ); + $result['ok'] = true; + $result['status'] = 'passed'; + if ( ! empty( $details ) ) { + $result['details'] = self::compact_value( $details ); + } + + return $result; + } + + private static function skip( int $seed, ?int $case_index, string $invariant, string $reason, string $input = '' ): array { + $result = self::base_result( $seed, $case_index, $invariant, $input ); + $result['ok'] = true; + $result['status'] = 'skipped'; + $result['reason'] = $reason; + + return $result; + } + + private static function fail( int $seed, ?int $case_index, string $invariant, string $input, $expected, $actual, array $details = array() ): array { + $result = self::base_result( $seed, $case_index, $invariant, $input ); + $result['ok'] = false; + $result['status'] = 'failed'; + $result['failureClass'] = $details['failureClass'] ?? 'invariant-violation'; + $result['expected'] = self::compact_value( $expected ); + $result['actual'] = self::compact_value( $actual ); + unset( $details['failureClass'] ); + if ( ! empty( $details ) ) { + $result['details'] = self::compact_value( $details ); + } + + return $result; + } + + private static function throwable_result( int $seed, ?int $case_index, string $invariant, string $input, \Throwable $e, array $details = array() ): array { + return self::fail( + $seed, + $case_index, + $invariant, + $input, + 'no Throwable', + array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + ), + $details + array( 'failureClass' => 'throwable' ) + ); + } + + private static function base_result( int $seed, ?int $case_index, string $invariant, string $input ): array { + return array( + 'surface' => self::NAME, + 'seed' => $seed, + 'case' => $case_index, + 'invariant' => $invariant, + 'inputSha1' => sha1( $input ), + 'inputLength' => strlen( $input ), + 'inputPreview' => self::preview( $input ), + ); + } + + private static function case_details( array $case, ?string $output = null ): array { + $details = array( + 'profile' => $case['profile'], + 'tag' => $case['tag'], + 'protocols' => $case['protocols'], + ); + if ( null !== $output ) { + $details['outputSha1'] = sha1( $output ); + $details['outputLength'] = strlen( $output ); + $details['outputPreview'] = self::preview( $output ); + } + + return $details; + } + + private static function preview( string $value, int $limit = self::PREVIEW_BYTES ): string { + $slice = substr( $value, 0, $limit ); + $json = json_encode( $slice, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); + if ( false === $json ) { + $json = base64_encode( $slice ); + } + + return strlen( $value ) > $limit ? $json . '...' : $json; + } + + private static function compact_value( $value ) { + if ( is_string( $value ) ) { + return self::preview( $value, 400 ); + } + if ( is_array( $value ) ) { + $out = array(); + $count = 0; + foreach ( $value as $key => $item ) { + if ( $count >= 40 ) { + $out['__truncated__'] = count( $value ) - $count; + break; + } + $out[ $key ] = self::compact_value( $item ); + ++$count; + } + return $out; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/MarkupSurface.php b/tools/component-fuzz/surfaces/MarkupSurface.php new file mode 100644 index 0000000000000..b7657faa362d3 --- /dev/null +++ b/tools/component-fuzz/surfaces/MarkupSurface.php @@ -0,0 +1,1627 @@ + $profile, + 'features' => $features, + 'inputLength' => strlen( $input ), + 'inputSha1' => sha1( $input ), + 'inputPreview' => self::preview( $input ), + 'generator' => $generated, + ); + + $rows = array(); + foreach ( $checks as $invariant => $check ) { + $ok = ! empty( $check['ok'] ); + $status = (string) ( $check['status'] ?? ( $ok ? 'passed' : 'failed' ) ); + $data = $common + $check; + unset( $data['ok'], $data['status'] ); + + $rows[] = $ctx->result( (string) $invariant, $ok, $data, $status ); + } + + if ( empty( $rows ) ) { + $rows[] = $ctx->skip( 'markup.no-checks', 'No markup checks ran.', $common ); + } + + if ( ! empty( $failures ) ) { + $rows[] = $ctx->fail( + 'markup.failure-summary', + $common + array( + 'failureCount' => count( $failures ), + 'failures' => $failures, + ) + ); + } + + return $rows; + } + + private static function check_blocks( string $input, array &$checks, array &$failures ): void { + if ( ! function_exists( 'parse_blocks' ) ) { + self::skip( $checks, 'blocks.parse-serialize-stability', 'parse_blocks unavailable' ); + return; + } + + if ( ! function_exists( 'serialize_blocks' ) ) { + self::skip( $checks, 'blocks.parse-serialize-stability', 'serialize_blocks unavailable' ); + return; + } + + $parsed_call = self::call( + 'parse_blocks', + static function () use ( $input ) { + return \parse_blocks( $input ); + } + ); + + if ( ! $parsed_call['ok'] || ! is_array( $parsed_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'blocks.parse', $parsed_call, 'parse_blocks failed or returned a non-array value.' ); + return; + } + + $blocks = $parsed_call['value']; + + $serialized_call = self::call( + 'serialize_blocks', + static function () use ( $blocks ) { + return \serialize_blocks( $blocks ); + } + ); + + if ( ! $serialized_call['ok'] || ! is_string( $serialized_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'blocks.serialize', $serialized_call, 'serialize_blocks failed or returned a non-string value.' ); + return; + } + + $serialized = $serialized_call['value']; + $reparsed_call = self::call( + 'parse_blocks:serialized', + static function () use ( $serialized ) { + return \parse_blocks( $serialized ); + } + ); + + if ( ! $reparsed_call['ok'] || ! is_array( $reparsed_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'blocks.reparse-serialized', $reparsed_call, 'parse_blocks failed on serialized block output.' ); + return; + } + + $shape_before = self::block_tree_shape( $blocks ); + $shape_after = self::block_tree_shape( $reparsed_call['value'] ); + if ( $shape_before !== $shape_after ) { + self::fail( + $checks, + $failures, + 'blocks.parse-serialize-stability', + 'parse_blocks -> serialize_blocks -> parse_blocks changed the parsed block structure.', + array( + 'blockCountBefore' => count( $blocks ), + 'blockCountAfter' => count( $reparsed_call['value'] ), + 'beforeSha1' => sha1( self::json( $shape_before ) ), + 'afterSha1' => sha1( self::json( $shape_after ) ), + 'serializedLength' => strlen( $serialized ), + 'firstDifference' => self::first_value_difference( $shape_before, $shape_after ), + ) + ); + } else { + self::pass( + $checks, + 'blocks.parse-serialize-stability', + array( + 'blockCount' => count( $blocks ), + 'containsBlock' => self::blocks_contain_named_block( $blocks ), + 'serializedLength' => strlen( $serialized ), + 'durationMs' => $parsed_call['durationMs'] + $serialized_call['durationMs'] + $reparsed_call['durationMs'], + ) + ); + } + + self::check_serialize_block_join( $blocks, $serialized, $checks, $failures ); + self::check_has_blocks( $input, $blocks, $serialized, $checks, $failures ); + self::check_do_blocks( $serialized, $reparsed_call['value'], $checks, $failures ); + } + + private static function check_serialize_block_join( array $blocks, string $serialized, array &$checks, array &$failures ): void { + if ( ! function_exists( 'serialize_block' ) ) { + self::skip( $checks, 'blocks.serialize-block-join', 'serialize_block unavailable' ); + return; + } + + $pieces_call = self::call( + 'serialize_block:join', + static function () use ( $blocks ) { + $pieces = array(); + foreach ( $blocks as $block ) { + $pieces[] = \serialize_block( $block ); + } + return implode( '', $pieces ); + } + ); + + if ( ! $pieces_call['ok'] || ! is_string( $pieces_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'blocks.serialize-block-join', $pieces_call, 'serialize_block failed while serializing individual top-level blocks.' ); + return; + } + + if ( $serialized !== $pieces_call['value'] ) { + self::fail( + $checks, + $failures, + 'blocks.serialize-block-join', + 'serialize_blocks differed from the concatenation of serialize_block over top-level blocks.', + array( + 'serializeBlocksSha1' => sha1( $serialized ), + 'joinSha1' => sha1( $pieces_call['value'] ), + 'firstDifference' => self::first_string_difference( $serialized, $pieces_call['value'] ), + ) + ); + return; + } + + self::pass( + $checks, + 'blocks.serialize-block-join', + array( + 'blockCount' => count( $blocks ), + 'length' => strlen( $serialized ), + 'durationMs' => $pieces_call['durationMs'], + ) + ); + } + + private static function check_has_blocks( string $input, array $blocks, string $serialized, array &$checks, array &$failures ): void { + if ( ! function_exists( 'has_blocks' ) ) { + self::skip( $checks, 'blocks.has-blocks-agreement', 'has_blocks unavailable' ); + return; + } + + $input_has_call = self::call( + 'has_blocks:input', + static function () use ( $input ) { + return \has_blocks( $input ); + } + ); + $serialized_has_call = self::call( + 'has_blocks:serialized', + static function () use ( $serialized ) { + return \has_blocks( $serialized ); + } + ); + $serialized_parse_call = self::call( + 'parse_blocks:has_blocks_serialized', + static function () use ( $serialized ) { + return \parse_blocks( $serialized ); + } + ); + + if ( ! $input_has_call['ok'] || ! $serialized_has_call['ok'] || ! $serialized_parse_call['ok'] || ! is_array( $serialized_parse_call['value'] ) ) { + self::fail( + $checks, + $failures, + 'blocks.has-blocks-agreement', + 'has_blocks or parse_blocks failed while checking parser agreement.', + array( + 'inputHasBlocksCall' => self::call_summary( $input_has_call ), + 'serializedHasBlocksCall' => self::call_summary( $serialized_has_call ), + 'serializedParseCall' => self::call_summary( $serialized_parse_call ), + ) + ); + return; + } + + $parser_has_input = self::blocks_contain_named_block( $blocks ); + $parser_has_serialized = self::blocks_contain_named_block( $serialized_parse_call['value'] ); + $input_has = (bool) $input_has_call['value']; + $serialized_has = (bool) $serialized_has_call['value']; + + if ( $parser_has_input && ! $input_has ) { + self::fail( + $checks, + $failures, + 'blocks.has-blocks-agreement', + 'parse_blocks found a named block in the input, but has_blocks returned false.', + array( + 'parserHasInput' => $parser_has_input, + 'hasInput' => $input_has, + ) + ); + return; + } + + if ( $parser_has_serialized && ! $serialized_has ) { + self::fail( + $checks, + $failures, + 'blocks.has-blocks-agreement', + 'parse_blocks found a named block in serialized output, but has_blocks returned false.', + array( + 'parserHasSerialized' => $parser_has_serialized, + 'hasSerialized' => $serialized_has, + 'serializedSha1' => sha1( $serialized ), + ) + ); + return; + } + + self::pass( + $checks, + 'blocks.has-blocks-agreement', + array( + 'parserHasInput' => $parser_has_input, + 'hasInput' => $input_has, + 'parserHasSerialized' => $parser_has_serialized, + 'hasSerialized' => $serialized_has, + 'optimizedFalsePositive' => $serialized_has && ! $parser_has_serialized, + ) + ); + } + + private static function check_do_blocks( string $serialized, array $blocks, array &$checks, array &$failures ): void { + if ( ! function_exists( 'do_blocks' ) ) { + self::skip( $checks, 'blocks.do-blocks-deterministic', 'do_blocks unavailable' ); + return; + } + + if ( ! class_exists( 'WP_Block' ) ) { + self::skip( $checks, 'blocks.do-blocks-deterministic', 'WP_Block unavailable' ); + return; + } + + if ( ! self::blocks_are_safe_for_do_blocks( $blocks ) ) { + self::skip( $checks, 'blocks.do-blocks-deterministic', 'generated block names may invoke dynamic rendering' ); + return; + } + + $first_call = self::call( + 'do_blocks:first', + static function () use ( $serialized ) { + return \do_blocks( $serialized ); + } + ); + $second_call = self::call( + 'do_blocks:second', + static function () use ( $serialized ) { + return \do_blocks( $serialized ); + } + ); + + if ( ! $first_call['ok'] || ! $second_call['ok'] || ! is_string( $first_call['value'] ) || ! is_string( $second_call['value'] ) ) { + self::fail( + $checks, + $failures, + 'blocks.do-blocks-deterministic', + 'do_blocks failed or returned a non-string value.', + array( + 'firstCall' => self::call_summary( $first_call ), + 'secondCall' => self::call_summary( $second_call ), + ) + ); + return; + } + + if ( $first_call['value'] !== $second_call['value'] ) { + self::fail( + $checks, + $failures, + 'blocks.do-blocks-deterministic', + 'Two do_blocks calls on the same serialized static block content differed.', + array( + 'firstSha1' => sha1( $first_call['value'] ), + 'secondSha1' => sha1( $second_call['value'] ), + 'firstDifference' => self::first_string_difference( $first_call['value'], $second_call['value'] ), + ) + ); + return; + } + + self::pass( + $checks, + 'blocks.do-blocks-deterministic', + array( + 'outputLength' => strlen( $first_call['value'] ), + 'outputSha1' => sha1( $first_call['value'] ), + 'durationMs' => $first_call['durationMs'] + $second_call['durationMs'], + ) + ); + } + + private static function check_shortcodes( string $input, array &$checks, array &$failures ): void { + if ( function_exists( 'shortcode_parse_atts' ) ) { + self::check_shortcode_atts( $checks, $failures ); + } else { + self::skip( $checks, 'shortcodes.parse-atts-key-values', 'shortcode_parse_atts unavailable' ); + } + + if ( ! function_exists( 'get_shortcode_regex' ) ) { + self::skip( $checks, 'shortcodes.regex-bounded', 'get_shortcode_regex unavailable' ); + } + + if ( ! function_exists( 'add_shortcode' ) || ! function_exists( 'do_shortcode' ) || ! function_exists( 'strip_shortcodes' ) || ! function_exists( 'get_shortcode_regex' ) ) { + self::skip( $checks, 'shortcodes.local-callbacks', 'shortcode registry helpers unavailable' ); + return; + } + + $had_shortcode_tags = array_key_exists( 'shortcode_tags', $GLOBALS ); + $snapshot = $had_shortcode_tags ? $GLOBALS['shortcode_tags'] : null; + $calls = array(); + + try { + $GLOBALS['shortcode_tags'] = is_array( $GLOBALS['shortcode_tags'] ?? null ) ? $GLOBALS['shortcode_tags'] : array(); + + $callback = static function ( $atts, $content = null, $tag = '' ) use ( &$calls ) { + $atts = is_array( $atts ) ? self::sort_value( $atts ) : array(); + if ( null !== $content && function_exists( 'do_shortcode' ) ) { + $content = \do_shortcode( $content ); + } + $content = null === $content ? '' : (string) $content; + $calls[] = array( + 'tag' => (string) $tag, + 'attrsSha1' => sha1( self::json( $atts ) ), + 'contentSha1' => sha1( $content ), + ); + return '{cfz:' . (string) $tag . ':' . substr( sha1( self::json( $atts ) . "\0" . $content ), 0, 12 ) . '}'; + }; + + \add_shortcode( 'cfz_a', $callback ); + \add_shortcode( 'cfz_b', $callback ); + \add_shortcode( 'cfz_echo', $callback ); + + self::check_shortcode_regex_storm( $input, $checks, $failures ); + + $content = 'pre [cfz_a alpha="one two" beta=\'quo\\\'ted\' bare=word]inner [cfz_b nested="[x]" slash="a\\/b" /] tail[/cfz_a] [[cfz_echo escaped]] post [unknown x="1"]'; + $first_call = self::call( + 'do_shortcode:first', + static function () use ( $content ) { + return \do_shortcode( $content ); + } + ); + $first_calls = $calls; + $calls = array(); + $second_call = self::call( + 'do_shortcode:second', + static function () use ( $content ) { + return \do_shortcode( $content ); + } + ); + $second_calls = $calls; + + $strip_input = 'before [cfz_a x="1"]body [cfz_b /][/cfz_a] after'; + $strip_call = self::call( + 'strip_shortcodes', + static function () use ( $strip_input ) { + return \strip_shortcodes( $strip_input ); + } + ); + + if ( ! $first_call['ok'] || ! $second_call['ok'] || ! $strip_call['ok'] || ! is_string( $first_call['value'] ) || ! is_string( $second_call['value'] ) || ! is_string( $strip_call['value'] ) ) { + self::fail( + $checks, + $failures, + 'shortcodes.local-callbacks', + 'do_shortcode or strip_shortcodes failed with local dummy tags.', + array( + 'firstCall' => self::call_summary( $first_call ), + 'secondCall' => self::call_summary( $second_call ), + 'stripCall' => self::call_summary( $strip_call ), + ) + ); + } elseif ( $first_call['value'] !== $second_call['value'] || $first_calls !== $second_calls ) { + self::fail( + $checks, + $failures, + 'shortcodes.local-callbacks', + 'Local shortcode callbacks were not deterministic.', + array( + 'firstOutputSha1' => sha1( $first_call['value'] ), + 'secondOutputSha1' => sha1( $second_call['value'] ), + 'firstCalls' => $first_calls, + 'secondCalls' => $second_calls, + ) + ); + } elseif ( count( $first_calls ) < 2 ) { + self::fail( + $checks, + $failures, + 'shortcodes.local-callbacks', + 'Nested local shortcode callbacks did not execute.', + array( + 'calls' => $first_calls, + 'outputSha1' => sha1( $first_call['value'] ), + ) + ); + } elseif ( false !== strpos( $strip_call['value'], '[cfz_a' ) || false !== strpos( $strip_call['value'], '[cfz_b' ) ) { + self::fail( + $checks, + $failures, + 'shortcodes.local-callbacks', + 'strip_shortcodes left a registered dummy shortcode tag in the output.', + array( + 'strippedPreview' => self::preview( $strip_call['value'] ), + ) + ); + } else { + self::pass( + $checks, + 'shortcodes.local-callbacks', + array( + 'callbackCount' => count( $first_calls ), + 'outputSha1' => sha1( $first_call['value'] ), + 'stripSha1' => sha1( $strip_call['value'] ), + 'durationMs' => $first_call['durationMs'] + $second_call['durationMs'] + $strip_call['durationMs'], + ) + ); + } + } finally { + if ( $had_shortcode_tags ) { + $GLOBALS['shortcode_tags'] = $snapshot; + } else { + unset( $GLOBALS['shortcode_tags'] ); + } + } + + $restored = $had_shortcode_tags + ? ( array_key_exists( 'shortcode_tags', $GLOBALS ) && $GLOBALS['shortcode_tags'] === $snapshot ) + : ! array_key_exists( 'shortcode_tags', $GLOBALS ); + + if ( ! $restored ) { + self::fail( + $checks, + $failures, + 'shortcodes.registry-restored', + 'The global shortcode registry was not restored after local dummy shortcode checks.', + array() + ); + } else { + self::pass( $checks, 'shortcodes.registry-restored', array( 'hadRegistryBefore' => $had_shortcode_tags ) ); + } + } + + private static function check_shortcode_atts( array &$checks, array &$failures ): void { + $attr_text = 'Alpha="one" beta=\'two two\' gamma=three path="a\\\\b"'; + $call = self::call( + 'shortcode_parse_atts', + static function () use ( $attr_text ) { + return \shortcode_parse_atts( $attr_text ); + } + ); + + if ( ! $call['ok'] || ! is_array( $call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'shortcodes.parse-atts-key-values', $call, 'shortcode_parse_atts failed or returned a non-array value.' ); + return; + } + + $atts = $call['value']; + $expected = array( + 'alpha' => 'one', + 'beta' => 'two two', + 'gamma' => 'three', + 'path' => 'a\\b', + ); + + foreach ( $expected as $key => $value ) { + if ( ! array_key_exists( $key, $atts ) || $atts[ $key ] !== $value ) { + self::fail( + $checks, + $failures, + 'shortcodes.parse-atts-key-values', + 'shortcode_parse_atts lost or changed a simple generated key/value attribute.', + array( + 'attrText' => $attr_text, + 'expected' => $expected, + 'actual' => self::sort_value( $atts ), + ) + ); + return; + } + } + + self::pass( + $checks, + 'shortcodes.parse-atts-key-values', + array( + 'keys' => array_keys( $expected ), + 'actualSha1' => sha1( self::json( self::sort_value( $atts ) ) ), + 'durationMs' => $call['durationMs'], + ) + ); + } + + private static function check_shortcode_regex_storm( string $input, array &$checks, array &$failures ): void { + if ( ! function_exists( 'get_shortcode_regex' ) ) { + self::skip( $checks, 'shortcodes.regex-bounded', 'get_shortcode_regex unavailable' ); + return; + } + + $storm = self::bracket_storm( $input ); + $regex_call = self::call( + 'get_shortcode_regex/preg_match_all', + static function () use ( $storm ) { + $pattern = \get_shortcode_regex( array( 'cfz_a', 'cfz_b', 'cfz_echo' ) ); + $matches = array(); + $count = preg_match_all( '/' . $pattern . '/', $storm, $matches ); + return array( + 'count' => $count, + 'pregLastError' => function_exists( 'preg_last_error' ) ? preg_last_error() : 0, + ); + } + ); + + if ( ! $regex_call['ok'] || ! is_array( $regex_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'shortcodes.regex-bounded', $regex_call, 'Shortcode regex failed on a bounded bracket storm.' ); + return; + } + + if ( false === $regex_call['value']['count'] || 0 !== $regex_call['value']['pregLastError'] ) { + self::fail( + $checks, + $failures, + 'shortcodes.regex-bounded', + 'Shortcode regex returned a PCRE error on a bounded bracket storm.', + array( + 'pregLastError' => $regex_call['value']['pregLastError'], + 'stormLength' => strlen( $storm ), + ) + ); + return; + } + + if ( $regex_call['durationMs'] > 2000.0 ) { + self::fail( + $checks, + $failures, + 'shortcodes.regex-bounded', + 'Shortcode regex took too long on a bounded bracket storm.', + array( + 'durationMs' => $regex_call['durationMs'], + 'stormLength' => strlen( $storm ), + ) + ); + return; + } + + self::pass( + $checks, + 'shortcodes.regex-bounded', + array( + 'matchCount' => $regex_call['value']['count'], + 'stormLength' => strlen( $storm ), + 'durationMs' => $regex_call['durationMs'], + ) + ); + } + + private static function check_text_helpers( string $input, array &$checks, array &$failures ): void { + if ( function_exists( 'wp_strip_all_tags' ) ) { + $strip_call = self::call( + 'wp_strip_all_tags:idempotence', + static function () use ( $input ) { + $once = \wp_strip_all_tags( $input ); + $twice = \wp_strip_all_tags( $once ); + $compact_once = \wp_strip_all_tags( $input, true ); + $compact_twice = \wp_strip_all_tags( $compact_once, true ); + return array( $once, $twice, $compact_once, $compact_twice ); + } + ); + + if ( ! $strip_call['ok'] || ! is_array( $strip_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'markup.strip-all-tags-idempotent', $strip_call, 'wp_strip_all_tags failed during idempotence check.' ); + } elseif ( $strip_call['value'][0] !== $strip_call['value'][1] || $strip_call['value'][2] !== $strip_call['value'][3] ) { + self::fail( + $checks, + $failures, + 'markup.strip-all-tags-idempotent', + 'wp_strip_all_tags changed already stripped output.', + array( + 'onceSha1' => sha1( $strip_call['value'][0] ), + 'twiceSha1' => sha1( $strip_call['value'][1] ), + 'compactOnceSha1' => sha1( $strip_call['value'][2] ), + 'compactTwiceSha1'=> sha1( $strip_call['value'][3] ), + ) + ); + } else { + self::pass( + $checks, + 'markup.strip-all-tags-idempotent', + array( + 'outputLength' => strlen( $strip_call['value'][0] ), + 'outputSha1' => sha1( $strip_call['value'][0] ), + 'durationMs' => $strip_call['durationMs'], + ) + ); + } + } else { + self::skip( $checks, 'markup.strip-all-tags-idempotent', 'wp_strip_all_tags unavailable' ); + } + + if ( function_exists( 'force_balance_tags' ) && function_exists( 'wp_strip_all_tags' ) ) { + $balance_call = self::call( + 'force_balance_tags:sanitized-idempotence', + static function () use ( $input ) { + $balanced = \force_balance_tags( $input ); + $balanced_twice = \force_balance_tags( $balanced ); + return array( + $balanced, + $balanced_twice, + \wp_strip_all_tags( $balanced ), + \wp_strip_all_tags( $balanced_twice ), + ); + } + ); + + if ( ! $balance_call['ok'] || ! is_array( $balance_call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'markup.force-balance-sanitized-idempotent', $balance_call, 'force_balance_tags failed during sanitized idempotence check.' ); + } elseif ( $balance_call['value'][2] !== $balance_call['value'][3] ) { + self::fail( + $checks, + $failures, + 'markup.force-balance-sanitized-idempotent', + 'Sanitized force_balance_tags output changed after balancing a second time.', + array( + 'balancedSha1' => sha1( $balance_call['value'][0] ), + 'balancedTwiceSha1' => sha1( $balance_call['value'][1] ), + 'sanitizedSha1' => sha1( $balance_call['value'][2] ), + 'sanitizedTwiceSha1' => sha1( $balance_call['value'][3] ), + ) + ); + } else { + self::pass( + $checks, + 'markup.force-balance-sanitized-idempotent', + array( + 'balancedLength' => strlen( $balance_call['value'][0] ), + 'sanitizedSha1' => sha1( $balance_call['value'][2] ), + 'durationMs' => $balance_call['durationMs'], + ) + ); + } + } else { + self::skip( $checks, 'markup.force-balance-sanitized-idempotent', 'force_balance_tags or wp_strip_all_tags unavailable' ); + } + + self::check_excerpt_helpers( $input, $checks, $failures ); + } + + private static function check_excerpt_helpers( string $input, array &$checks, array &$failures ): void { + $ran = false; + + if ( function_exists( 'wp_html_excerpt' ) ) { + $ran = true; + $call = self::call( + 'wp_html_excerpt:deterministic', + static function () use ( $input ) { + return array( + \wp_html_excerpt( $input, 48, '...' ), + \wp_html_excerpt( $input, 48, '...' ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'markup.wp-html-excerpt-deterministic', 'wp_html_excerpt' ); + } + + if ( function_exists( 'wp_trim_words' ) && function_exists( 'wp_get_word_count_type' ) && function_exists( 'get_option' ) ) { + $ran = true; + $call = self::call( + 'wp_trim_words:deterministic', + static function () use ( $input ) { + return array( + \wp_trim_words( $input, 12, '...' ), + \wp_trim_words( $input, 12, '...' ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'markup.wp-trim-words-deterministic', 'wp_trim_words' ); + } elseif ( function_exists( 'wp_trim_words' ) ) { + self::skip( $checks, 'markup.wp-trim-words-deterministic', 'wp_trim_words dependencies unavailable' ); + } + + if ( function_exists( 'excerpt_remove_footnotes' ) ) { + $ran = true; + $footnote = 'A1B'; + $call = self::call( + 'excerpt_remove_footnotes', + static function () use ( $footnote ) { + return \excerpt_remove_footnotes( $footnote ); + } + ); + if ( ! $call['ok'] || ! is_string( $call['value'] ) ) { + self::fail_from_call( $checks, $failures, 'markup.excerpt-remove-footnotes', $call, 'excerpt_remove_footnotes failed.' ); + } elseif ( false !== strpos( $call['value'], 'data-fn=' ) ) { + self::fail( + $checks, + $failures, + 'markup.excerpt-remove-footnotes', + 'excerpt_remove_footnotes left generated footnote markup in place.', + array( 'outputPreview' => self::preview( $call['value'] ) ) + ); + } else { + self::pass( $checks, 'markup.excerpt-remove-footnotes', array( 'outputSha1' => sha1( $call['value'] ), 'durationMs' => $call['durationMs'] ) ); + } + } + + if ( function_exists( 'excerpt_remove_blocks' ) && function_exists( 'parse_blocks' ) && function_exists( 'render_block' ) && class_exists( 'WP_Block' ) ) { + $ran = true; + $block_excerpt = '

Excerpt text here.

'; + $call = self::call( + 'excerpt_remove_blocks:deterministic', + static function () use ( $block_excerpt ) { + return array( + \excerpt_remove_blocks( $block_excerpt ), + \excerpt_remove_blocks( $block_excerpt ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'markup.excerpt-remove-blocks-deterministic', 'excerpt_remove_blocks' ); + } elseif ( function_exists( 'excerpt_remove_blocks' ) ) { + self::skip( $checks, 'markup.excerpt-remove-blocks-deterministic', 'excerpt_remove_blocks dependencies unavailable' ); + } + + if ( function_exists( 'get_url_in_content' ) && class_exists( 'WP_HTML_Tag_Processor' ) ) { + $ran = true; + $url_input = '

x link

'; + $call = self::call( + 'get_url_in_content', + static function () use ( $url_input ) { + return \get_url_in_content( $url_input ); + } + ); + if ( ! $call['ok'] || ! is_string( $call['value'] ) || false === strpos( $call['value'], 'example.test/path' ) ) { + self::fail( + $checks, + $failures, + 'markup.get-url-in-content', + 'get_url_in_content did not return the generated anchor URL.', + array( + 'call' => self::call_summary( $call ), + 'value' => is_scalar( $call['value'] ?? null ) ? (string) $call['value'] : null, + ) + ); + } else { + self::pass( $checks, 'markup.get-url-in-content', array( 'urlSha1' => sha1( $call['value'] ), 'durationMs' => $call['durationMs'] ) ); + } + } + + if ( ! $ran ) { + self::skip( $checks, 'markup.excerpt-text-helpers', 'excerpt/text helper functions unavailable' ); + } + } + + private static function check_embed_helpers( array &$checks, array &$failures ): void { + self::load_wp_include( 'wp-includes/embed.php' ); + + if ( function_exists( 'wp_oembed_ensure_format' ) ) { + $call = self::call( + 'wp_oembed_ensure_format', + static function () { + return array( + \wp_oembed_ensure_format( 'json' ), + \wp_oembed_ensure_format( 'xml' ), + \wp_oembed_ensure_format( 'html' ), + ); + } + ); + if ( ! $call['ok'] || array( 'json', 'xml', 'json' ) !== $call['value'] ) { + self::fail( + $checks, + $failures, + 'embed.ensure-format', + 'wp_oembed_ensure_format did not preserve json/xml and coerce an unknown format to json.', + array( 'call' => self::call_summary( $call ), 'value' => $call['value'] ?? null ) + ); + } else { + self::pass( $checks, 'embed.ensure-format', array( 'durationMs' => $call['durationMs'] ) ); + } + } else { + self::skip( $checks, 'embed.ensure-format', 'wp_oembed_ensure_format unavailable' ); + } + + if ( function_exists( 'wp_embed_defaults' ) ) { + $call = self::call( + 'wp_embed_defaults', + static function () { + return array( + \wp_embed_defaults( 'https://example.test/video' ), + \wp_embed_defaults( 'https://example.test/video' ), + ); + } + ); + if ( ! $call['ok'] || ! is_array( $call['value'] ) || $call['value'][0] !== $call['value'][1] || empty( $call['value'][0]['width'] ) || empty( $call['value'][0]['height'] ) ) { + self::fail( + $checks, + $failures, + 'embed.defaults-deterministic', + 'wp_embed_defaults was not deterministic or did not return width/height.', + array( 'call' => self::call_summary( $call ), 'value' => $call['value'] ?? null ) + ); + } else { + self::pass( $checks, 'embed.defaults-deterministic', array( 'defaults' => $call['value'][0], 'durationMs' => $call['durationMs'] ) ); + } + } else { + self::skip( $checks, 'embed.defaults-deterministic', 'wp_embed_defaults unavailable' ); + } + + if ( function_exists( '_oembed_create_xml' ) && class_exists( 'SimpleXMLElement' ) && function_exists( 'wp_cache_get' ) && self::has_option_runtime() ) { + $data = array( + 'type' => 'rich', + 'version' => '1.0', + 'title' => 'Example', + 'author' => array( 'name' => 'Tester' ), + ); + $call = self::call( + '_oembed_create_xml:deterministic', + static function () use ( $data ) { + return array( + \_oembed_create_xml( $data ), + \_oembed_create_xml( $data ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'embed.create-xml-deterministic', '_oembed_create_xml' ); + } else { + self::skip( $checks, 'embed.create-xml-deterministic', '_oembed_create_xml, SimpleXMLElement, cache API, or option runtime unavailable' ); + } + + if ( function_exists( 'wp_filter_oembed_iframe_title_attribute' ) && function_exists( 'wp_cache_get' ) && self::has_option_runtime() ) { + $html = ''; + $data = (object) array( + 'type' => 'video', + 'title' => 'Example title', + ); + $call = self::call( + 'wp_filter_oembed_iframe_title_attribute', + static function () use ( $html, $data ) { + return array( + \wp_filter_oembed_iframe_title_attribute( $html, $data, 'https://example.test/watch' ), + \wp_filter_oembed_iframe_title_attribute( $html, $data, 'https://example.test/watch' ), + ); + } + ); + if ( ! $call['ok'] || ! is_array( $call['value'] ) || $call['value'][0] !== $call['value'][1] || false === strpos( $call['value'][0], 'title="Example title"' ) ) { + self::fail( + $checks, + $failures, + 'embed.iframe-title-deterministic', + 'wp_filter_oembed_iframe_title_attribute did not add a deterministic title attribute.', + array( 'call' => self::call_summary( $call ), 'valuePreview' => isset( $call['value'][0] ) ? self::preview( (string) $call['value'][0] ) : null ) + ); + } else { + self::pass( $checks, 'embed.iframe-title-deterministic', array( 'outputSha1' => sha1( $call['value'][0] ), 'durationMs' => $call['durationMs'] ) ); + } + } else { + self::skip( $checks, 'embed.iframe-title-deterministic', 'wp_filter_oembed_iframe_title_attribute, cache API, or option runtime unavailable' ); + } + + self::check_embed_shortcode_handlers( $checks, $failures ); + } + + private static function load_wp_include( string $relative_path ): bool { + $relative_path = ltrim( $relative_path, '/\\' ); + $candidates = array(); + + if ( defined( 'ABSPATH' ) ) { + $candidates[] = rtrim( ABSPATH, '/\\' ) . DIRECTORY_SEPARATOR . $relative_path; + } + + $candidates[] = dirname( __DIR__, 3 ) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . $relative_path; + $candidates = array_values( array_unique( $candidates ) ); + + foreach ( $candidates as $path ) { + if ( is_string( $path ) && is_readable( $path ) ) { + require_once $path; + return true; + } + } + + return false; + } + + private static function has_option_runtime(): bool { + return function_exists( 'get_option' ) + && isset( $GLOBALS['wpdb'] ) + && is_object( $GLOBALS['wpdb'] ) + && method_exists( $GLOBALS['wpdb'], 'suppress_errors' ); + } + + private static function check_embed_shortcode_handlers( array &$checks, array &$failures ): void { + $ran = false; + + if ( function_exists( 'wp_embed_handler_audio' ) ) { + $ran = true; + $call = self::call( + 'wp_embed_handler_audio:deterministic', + static function () { + return array( + \wp_embed_handler_audio( array(), array(), 'https://example.test/a.mp3?x=1', array() ), + \wp_embed_handler_audio( array(), array(), 'https://example.test/a.mp3?x=1', array() ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'embed.audio-handler-deterministic', 'wp_embed_handler_audio' ); + } + + if ( function_exists( 'wp_embed_handler_video' ) ) { + $ran = true; + $call = self::call( + 'wp_embed_handler_video:deterministic', + static function () { + $raw = array( 'width' => 640, 'height' => 360 ); + return array( + \wp_embed_handler_video( array(), array(), 'https://example.test/v.mp4?x=1', $raw ), + \wp_embed_handler_video( array(), array(), 'https://example.test/v.mp4?x=1', $raw ), + ); + } + ); + self::check_pair_determinism( $call, $checks, $failures, 'embed.video-handler-deterministic', 'wp_embed_handler_video' ); + } + + if ( ! $ran ) { + self::skip( $checks, 'embed.shortcode-handlers', 'audio/video embed handlers unavailable' ); + } + } + + private static function check_pair_determinism( array $call, array &$checks, array &$failures, string $name, string $api ): void { + if ( ! $call['ok'] || ! is_array( $call['value'] ) || count( $call['value'] ) < 2 || ! is_string( $call['value'][0] ) || ! is_string( $call['value'][1] ) ) { + self::fail_from_call( $checks, $failures, $name, $call, $api . ' failed or returned an unexpected value.' ); + return; + } + + if ( $call['value'][0] !== $call['value'][1] ) { + self::fail( + $checks, + $failures, + $name, + $api . ' returned different output for identical inputs.', + array( + 'firstSha1' => sha1( $call['value'][0] ), + 'secondSha1' => sha1( $call['value'][1] ), + 'firstDifference' => self::first_string_difference( $call['value'][0], $call['value'][1] ), + ) + ); + return; + } + + self::pass( + $checks, + $name, + array( + 'outputLength' => strlen( $call['value'][0] ), + 'outputSha1' => sha1( $call['value'][0] ), + 'durationMs' => $call['durationMs'], + ) + ); + } + + private static function generate_markup( array &$rng, int $max_bytes ): array { + $profile = self::rng_weighted( + $rng, + array( + 'mixed' => 28, + 'blocks' => 24, + 'shortcodes' => 18, + 'malformed' => 14, + 'bracket-storm' => 9, + 'html' => 7, + ) + ); + + $features = array( 'profile:' . $profile ); + + if ( 'blocks' === $profile ) { + $input = self::generate_block_document( $rng, self::rng_int( $rng, 2, 4 ), $features ); + } elseif ( 'shortcodes' === $profile ) { + $input = self::generate_shortcode_document( $rng, $features ); + } elseif ( 'malformed' === $profile ) { + $input = self::generate_malformed_document( $rng, $features ); + } elseif ( 'bracket-storm' === $profile ) { + $input = self::generate_bracket_storm_document( $rng, $features ); + } elseif ( 'html' === $profile ) { + $input = self::generate_html_fragment( $rng, $features ); + } else { + $input = self::generate_block_document( $rng, self::rng_int( $rng, 1, 3 ), $features ) + . "\n" + . self::generate_shortcode_document( $rng, $features ) + . "\n" + . self::generate_html_fragment( $rng, $features ); + } + + $input = self::trim_bytes( $input, min( $max_bytes, self::MAX_GENERATED_BYTES ) ); + sort( $features ); + + return array( + 'profile' => $profile, + 'input' => $input, + 'features' => array_values( array_unique( $features ) ), + 'byteLength'=> strlen( $input ), + 'inputSha1' => sha1( $input ), + ); + } + + private static function generate_block_document( array &$rng, int $depth, array &$features ): string { + $parts = array(); + $count = self::rng_int( $rng, 1, 4 ); + for ( $i = 0; $i < $count; ++$i ) { + if ( self::rng_chance( $rng, 18 ) ) { + $parts[] = self::generate_malformed_block_comment( $rng, $features ); + continue; + } + $parts[] = self::generate_block( $rng, $depth, $features ); + } + return implode( "\n", $parts ); + } + + private static function generate_block( array &$rng, int $depth, array &$features ): string { + $names = array( + 'core/paragraph', + 'core/group', + 'core/columns', + 'core/column', + 'core/html', + 'core/quote', + 'core/block', + 'core/pattern', + 'my-plugin/card', + 'acme/reusable-ish', + ); + $block_name = self::rng_choice( $rng, $names ); + $comment_name = 0 === strpos( $block_name, 'core/' ) && self::rng_chance( $rng, 70 ) ? substr( $block_name, 5 ) : $block_name; + if ( 'core/block' === $block_name || 'core/pattern' === $block_name ) { + $features[] = 'block:reusable-ish'; + } + + $attrs = self::generate_block_attrs( $rng, $block_name ); + $attr_json = empty( $attrs ) ? '' : ' ' . self::json( $attrs ); + + if ( $depth <= 0 || self::rng_chance( $rng, 35 ) ) { + $features[] = 'block:self-closing'; + return ''; + } + + $inner = self::generate_inner_html( $rng, $features ); + if ( self::rng_chance( $rng, 65 ) ) { + $features[] = 'block:nested'; + $inner .= "\n" . self::generate_block( $rng, $depth - 1, $features ) . "\n" . self::generate_inner_html( $rng, $features ); + } + + return '' . $inner . ''; + } + + private static function generate_block_attrs( array &$rng, string $block_name ): array { + $attrs = array(); + if ( self::rng_chance( $rng, 65 ) ) { + $attrs['align'] = self::rng_choice( $rng, array( 'wide', 'full', 'left', 'center' ) ); + } + if ( self::rng_chance( $rng, 55 ) ) { + $attrs['className'] = 'cfz-' . self::rng_int( $rng, 1, 999 ); + } + if ( self::rng_chance( $rng, 35 ) ) { + $attrs['meta'] = array( + 'seed' => self::rng_int( $rng, 1, 100000 ), + 'label' => self::rng_choice( $rng, array( 'plain', 'quote " mark', 'lt < gt > amp &' ) ), + ); + } + if ( 'core/block' === $block_name || self::rng_chance( $rng, 12 ) ) { + $attrs['ref'] = self::rng_int( $rng, 1, 5000 ); + } + return $attrs; + } + + private static function generate_inner_html( array &$rng, array &$features ): string { + $pieces = array( + '

Alpha bold ' . self::rng_int( $rng, 1, 99 ) . '

', + '
Nested
', + '[cfz_a alpha="one two"]short [cfz_b /][/cfz_a]', + 'link', + '1', + 'missing delimiter', + '

x

', + '

orphan closer

', + 'after', + '

x

', + ); + return self::rng_choice( $rng, $cases ); + } + + private static function generate_shortcode_document( array &$rng, array &$features ): string { + $features[] = 'shortcode:nested'; + $quoted = self::rng_choice( $rng, array( 'one two', 'a \\" quote', 'brackets [x]', 'slash \\\\ path' ) ); + $parts = array( + '[cfz_a alpha="' . $quoted . '" beta=\'single quoted\' bare=value]', + 'inner [cfz_b x="[nested]" escaped="a\\/b" /] tail', + '[/cfz_a]', + '[[cfz_echo escaped]]', + '[unknown attr="kept"]', + ); + + if ( self::rng_chance( $rng, 45 ) ) { + $features[] = 'shortcode:bracket-storm'; + $parts[] = self::generate_bracket_storm_document( $rng, $features ); + } + + return implode( ' ', $parts ); + } + + private static function generate_malformed_document( array &$rng, array &$features ): string { + $features[] = 'html:malformed'; + return self::generate_malformed_block_comment( $rng, $features ) + . "\n" + . '

broken

' + . "\n" + . '[cfz_a alpha="unterminated] [cfz_b / [[[ /]'; + } + + private static function generate_bracket_storm_document( array &$rng, array &$features ): string { + $features[] = 'shortcode:bracket-storm'; + $left = str_repeat( '[', self::rng_int( $rng, 16, 96 ) ); + $right = str_repeat( ']', self::rng_int( $rng, 16, 96 ) ); + $middle = str_repeat( '[cfz_a x="1"', self::rng_int( $rng, 2, 12 ) ); + return $left . $middle . str_repeat( '[/cfz_a]', self::rng_int( $rng, 1, 8 ) ) . $right; + } + + private static function generate_html_fragment( array &$rng, array &$features ): string { + $features[] = 'html:fragment'; + $tags = array( + '

Title

Words & entities

', + '

visible

', + 'Example', + '
', + '
  • one
  • two
', + ); + return self::rng_choice( $rng, $tags ); + } + + private static function bracket_storm( string $input ): string { + $seed = substr( $input, 0, 256 ); + return str_repeat( '[', 128 ) + . '[cfz_a alpha="one"]' + . $seed + . str_repeat( '[/cfz_a][', 48 ) + . str_repeat( ']', 128 ); + } + + private static function blocks_contain_named_block( array $blocks ): bool { + foreach ( $blocks as $block ) { + if ( is_array( $block ) && ! empty( $block['blockName'] ) ) { + return true; + } + if ( is_array( $block ) && ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && self::blocks_contain_named_block( $block['innerBlocks'] ) ) { + return true; + } + } + return false; + } + + private static function blocks_are_safe_for_do_blocks( array $blocks ): bool { + $allowed_core = array( + 'core/paragraph', + 'core/heading', + 'core/html', + 'core/group', + 'core/columns', + 'core/column', + 'core/list', + 'core/image', + 'core/quote', + 'core/freeform', + null, + ); + + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + return false; + } + $name = $block['blockName'] ?? null; + if ( is_string( $name ) && 0 === strpos( $name, 'core/' ) && ! in_array( $name, $allowed_core, true ) ) { + return false; + } + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && ! self::blocks_are_safe_for_do_blocks( $block['innerBlocks'] ) ) { + return false; + } + } + + return true; + } + + private static function block_tree_shape( array $blocks ): array { + $shape = array(); + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + $shape[] = array( 'invalid' => gettype( $block ) ); + continue; + } + + $inner_content = array(); + foreach ( (array) ( $block['innerContent'] ?? array() ) as $chunk ) { + $inner_content[] = is_string( $chunk ) ? array( 'textSha1' => sha1( $chunk ), 'length' => strlen( $chunk ) ) : null; + } + + $shape[] = array( + 'blockName' => $block['blockName'] ?? null, + 'attrs' => self::sort_value( is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array() ), + 'innerHTML' => array( + 'length' => is_string( $block['innerHTML'] ?? null ) ? strlen( $block['innerHTML'] ) : null, + 'sha1' => is_string( $block['innerHTML'] ?? null ) ? sha1( $block['innerHTML'] ) : null, + ), + 'innerContent' => $inner_content, + 'innerBlocks' => self::block_tree_shape( is_array( $block['innerBlocks'] ?? null ) ? $block['innerBlocks'] : array() ), + ); + } + return $shape; + } + + private static function call( string $label, callable $callback ): array { + $errors = array(); + $value = null; + $throwable = null; + $start = microtime( true ); + + set_error_handler( + static function ( $errno, $errstr, $errfile = '', $errline = 0 ) use ( &$errors ) { + $errors[] = array( + 'errno' => $errno, + 'message' => $errstr, + 'file' => is_string( $errfile ) ? basename( $errfile ) : '', + 'line' => $errline, + ); + return true; + } + ); + + try { + $value = $callback(); + } catch ( \Throwable $e ) { + $throwable = $e; + } finally { + restore_error_handler(); + } + + $duration_ms = ( microtime( true ) - $start ) * 1000.0; + + return array( + 'label' => $label, + 'ok' => null === $throwable && empty( $errors ), + 'value' => $value, + 'errors' => $errors, + 'throwable' => null === $throwable ? null : get_class( $throwable ), + 'message' => null === $throwable ? null : $throwable->getMessage(), + 'durationMs' => $duration_ms, + ); + } + + private static function pass( array &$checks, string $name, array $details = array() ): void { + $checks[ $name ] = array_merge( + array( + 'ok' => true, + 'status' => 'passed', + ), + $details + ); + } + + private static function skip( array &$checks, string $name, string $reason ): void { + $checks[ $name ] = array( + 'ok' => true, + 'status' => 'skipped', + 'reason' => $reason, + ); + } + + private static function fail( array &$checks, array &$failures, string $name, string $message, array $details ): void { + $failure = array_merge( + array( + 'name' => $name, + 'message' => $message, + ), + $details + ); + $checks[ $name ] = array_merge( + array( + 'ok' => false, + 'status' => 'failed', + 'message' => $message, + ), + $details + ); + $failures[] = $failure; + } + + private static function fail_from_call( array &$checks, array &$failures, string $name, array $call, string $message ): void { + self::fail( + $checks, + $failures, + $name, + $message, + array( + 'call' => self::call_summary( $call ), + ) + ); + } + + private static function call_summary( array $call ): array { + $value = $call['value'] ?? null; + $summary = array( + 'label' => $call['label'] ?? null, + 'ok' => $call['ok'] ?? false, + 'errors' => $call['errors'] ?? array(), + 'throwable' => $call['throwable'] ?? null, + 'message' => $call['message'] ?? null, + 'durationMs' => $call['durationMs'] ?? null, + 'valueType' => gettype( $value ), + ); + + if ( is_string( $value ) ) { + $summary['valueLength'] = strlen( $value ); + $summary['valueSha1'] = sha1( $value ); + } elseif ( is_array( $value ) ) { + $summary['valueCount'] = count( $value ); + $summary['valueSha1'] = sha1( self::json( self::sort_value( $value ) ) ); + } + + return $summary; + } + + private static function seed_from_context( \ComponentFuzz\FuzzContext $ctx ): int { + foreach ( array( 'seed', 'getSeed', 'iteration', 'getIteration' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + return self::normalize_seed( $ctx->$method() ); + } catch ( \Throwable $e ) { + // Try the next shape. + } + } + } + + if ( method_exists( $ctx, 'option' ) ) { + foreach ( array( 'seed', 'iteration' ) as $key ) { + try { + $value = $ctx->option( $key, null ); + if ( null !== $value ) { + return self::normalize_seed( $value ); + } + } catch ( \Throwable $e ) { + // Try the next shape. + } + } + } + + if ( isset( $ctx->seed ) ) { + return self::normalize_seed( $ctx->seed ); + } + + return 1; + } + + private static function input_from_context( \ComponentFuzz\FuzzContext $ctx ): ?string { + foreach ( array( 'input', 'getInput', 'payload', 'getPayload' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + $value = $ctx->$method(); + if ( is_string( $value ) ) { + return $value; + } + } catch ( \Throwable $e ) { + // Try the next shape. + } + } + } + + if ( method_exists( $ctx, 'option' ) ) { + foreach ( array( 'input', 'content', 'payload' ) as $key ) { + try { + $value = $ctx->option( $key, null ); + if ( is_string( $value ) ) { + return $value; + } + } catch ( \Throwable $e ) { + // Try the next shape. + } + } + } + + return null; + } + + private static function max_input_bytes_from_context( \ComponentFuzz\FuzzContext $ctx ): int { + $default = self::MAX_GENERATED_BYTES; + + foreach ( array( 'maxInputBytes', 'getMaxInputBytes' ) as $method ) { + if ( method_exists( $ctx, $method ) ) { + try { + $value = (int) $ctx->$method(); + return $value > 0 ? min( $value, 16384 ) : $default; + } catch ( \Throwable $e ) { + // Try the next shape. + } + } + } + + if ( method_exists( $ctx, 'option' ) ) { + try { + $value = (int) $ctx->option( 'max-input-bytes', $default ); + return $value > 0 ? min( $value, 16384 ) : $default; + } catch ( \Throwable $e ) { + return $default; + } + } + + return $default; + } + + private static function normalize_seed( $value ): int { + if ( is_int( $value ) ) { + return $value; + } + if ( is_numeric( $value ) ) { + return (int) $value; + } + return (int) sprintf( '%u', crc32( (string) $value ) ); + } + + private static function rng_create( int $seed ): array { + return array( + 'seed' => (string) $seed, + 'counter' => 0, + 'buffer' => '', + ); + } + + private static function rng_bytes( array &$rng, int $length ): string { + while ( strlen( $rng['buffer'] ) < $length ) { + $rng['buffer'] .= hash( 'sha256', $rng['seed'] . ':' . $rng['counter'], true ); + ++$rng['counter']; + } + + $out = substr( $rng['buffer'], 0, $length ); + $rng['buffer'] = substr( $rng['buffer'], $length ); + return $out; + } + + private static function rng_uint32( array &$rng ): int { + $parts = unpack( 'Nvalue', self::rng_bytes( $rng, 4 ) ); + return (int) $parts['value']; + } + + private static function rng_int( array &$rng, int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + return $min + ( self::rng_uint32( $rng ) % ( $max - $min + 1 ) ); + } + + private static function rng_chance( array &$rng, int $numerator, int $denominator = 100 ): bool { + return self::rng_int( $rng, 1, $denominator ) <= $numerator; + } + + private static function rng_choice( array &$rng, array $values ) { + return $values[ self::rng_int( $rng, 0, count( $values ) - 1 ) ]; + } + + private static function rng_weighted( array &$rng, array $weights ): string { + $total = array_sum( $weights ); + $pick = self::rng_int( $rng, 1, max( 1, (int) $total ) ); + foreach ( $weights as $value => $weight ) { + $pick -= $weight; + if ( $pick <= 0 ) { + return (string) $value; + } + } + foreach ( $weights as $value => $_weight ) { + return (string) $value; + } + return 'mixed'; + } + + private static function sort_value( $value ) { + if ( ! is_array( $value ) ) { + return $value; + } + + $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); + foreach ( $value as $key => $item ) { + $value[ $key ] = self::sort_value( $item ); + } + if ( ! $is_list ) { + ksort( $value ); + } + return $value; + } + + private static function first_value_difference( $left, $right ): array { + $left_json = self::json( self::sort_value( $left ) ); + $right_json = self::json( self::sort_value( $right ) ); + return self::first_string_difference( $left_json, $right_json ); + } + + private static function first_string_difference( string $left, string $right ): array { + $limit = min( strlen( $left ), strlen( $right ) ); + $pos = 0; + while ( $pos < $limit && $left[ $pos ] === $right[ $pos ] ) { + ++$pos; + } + + return array( + 'offset' => $pos, + 'left' => self::preview( substr( $left, max( 0, $pos - 40 ), 120 ) ), + 'right' => self::preview( substr( $right, max( 0, $pos - 40 ), 120 ) ), + ); + } + + private static function trim_bytes( string $input, int $max_bytes ): string { + if ( $max_bytes <= 0 || strlen( $input ) <= $max_bytes ) { + return $input; + } + return substr( $input, 0, $max_bytes ); + } + + private static function preview( string $input, int $limit = 180 ): string { + $slice = substr( $input, 0, $limit ); + $json = json_encode( $slice, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); + if ( false === $json ) { + $json = '"' . base64_encode( $slice ) . '"'; + } + return strlen( $input ) > $limit ? $json . '...' : $json; + } + + private static function json( $value ): string { + $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + return false === $json ? 'null' : $json; + } +} diff --git a/tools/component-fuzz/surfaces/NetworkMediaSurface.php b/tools/component-fuzz/surfaces/NetworkMediaSurface.php new file mode 100644 index 0000000000000..20e24d0cecd82 --- /dev/null +++ b/tools/component-fuzz/surfaces/NetworkMediaSurface.php @@ -0,0 +1,1561 @@ + self::NAME, + 'ok' => true, + 'seed' => $seed, + 'caseCount' => 0, + 'apiCalls' => 0, + 'assertions' => 0, + 'failureCount' => 0, + 'failures' => array(), + 'features' => array(), + 'skipped' => array(), + 'temporaryFiles' => array( + 'root' => null, + 'cleaned' => false, + ), + ); + + $temp_root = self::make_temp_root( $seed, $result ); + $result['temporaryFiles']['root'] = $temp_root; + + try { + self::exercise_url_apis( $rng, $result ); + self::exercise_path_apis( $rng, $result ); + self::exercise_filename_apis( $rng, $result ); + + if ( null === $temp_root ) { + self::skip_once( $result, 'temporary-files', 'Could not create an isolated directory under sys_get_temp_dir().' ); + } else { + self::exercise_filetype_and_unique_apis( $rng, $result, $temp_root ); + self::exercise_sideload_helpers( $rng, $result, $temp_root ); + } + } finally { + if ( null !== $temp_root ) { + self::remove_dir_recursive( $temp_root ); + $result['temporaryFiles']['cleaned'] = ! is_dir( $temp_root ); + } + } + + $result['ok'] = 0 === $result['failureCount']; + $features = array_keys( $result['features'] ); + sort( $features ); + $result['features'] = $features; + ksort( $result['skipped'] ); + + return $result; + } + + public static function filter_sideload_upload_dir( array $uploads ): array { + if ( null === self::$sideload_upload_root ) { + return $uploads; + } + + $subdir = isset( $uploads['subdir'] ) ? (string) $uploads['subdir'] : ''; + $base = rtrim( self::$sideload_upload_root, '/\\' ); + $url = 'http://example.test/component-fuzz-network'; + + $uploads['basedir'] = $base; + $uploads['baseurl'] = $url; + $uploads['path'] = $base . $subdir; + $uploads['url'] = $url . $subdir; + $uploads['error'] = false; + + return $uploads; + } + + private static function exercise_url_apis( array &$rng, array &$result ): void { + $urls = self::url_cases( $rng ); + + if ( ! function_exists( 'wp_parse_url' ) ) { + self::skip_once( $result, 'wp_parse_url', 'Function is unavailable.' ); + } + if ( ! function_exists( 'sanitize_url' ) ) { + self::skip_once( $result, 'sanitize_url', 'Function is unavailable.' ); + } + if ( ! function_exists( 'esc_url' ) ) { + self::skip_once( $result, 'esc_url', 'Function is unavailable.' ); + } + if ( ! function_exists( 'wp_http_validate_url' ) ) { + self::skip_once( $result, 'wp_http_validate_url', 'Function is unavailable.' ); + } + + foreach ( $urls as $url ) { + ++$result['caseCount']; + self::feature( $result, 'url' ); + + if ( function_exists( 'wp_parse_url' ) ) { + $full = self::call_api( + $result, + 'wp_parse_url', + $url, + static function () use ( $url ) { + return wp_parse_url( $url ); + } + ); + + if ( $full['ok'] ) { + $parts = $full['value']; + self::check_invariant( + $result, + false === $parts || is_array( $parts ), + 'wp_parse_url:return-shape', + $url, + array( 'actual' => $parts ) + ); + + self::check_parse_url_components( $url, $parts, $result ); + + if ( is_array( $parts ) && self::is_reconstructable_url_parts( $parts ) ) { + $rebuilt = self::rebuild_url( $parts ); + $round = self::call_api( + $result, + 'wp_parse_url.reconstructed', + $rebuilt, + static function () use ( $rebuilt ) { + return wp_parse_url( $rebuilt ); + } + ); + + if ( $round['ok'] ) { + self::check_invariant( + $result, + is_array( $round['value'] ) && self::same_url_parts( $parts, $round['value'] ), + 'wp_parse_url:reconstructed-components', + $url, + array( + 'rebuilt' => $rebuilt, + 'parsed' => $parts, + 'round' => $round['value'], + ) + ); + } + } + } + } + + if ( function_exists( 'sanitize_url' ) ) { + $sanitized = self::call_api( + $result, + 'sanitize_url', + $url, + static function () use ( $url ) { + return sanitize_url( $url ); + } + ); + + if ( $sanitized['ok'] ) { + self::check_invariant( + $result, + is_string( $sanitized['value'] ), + 'sanitize_url:return-string', + $url, + array( 'actual' => $sanitized['value'] ) + ); + + if ( is_string( $sanitized['value'] ) ) { + $again = self::call_api( + $result, + 'sanitize_url.idempotent', + $sanitized['value'], + static function () use ( $sanitized ) { + return sanitize_url( $sanitized['value'] ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $sanitized['value'], + 'sanitize_url:idempotent', + $url, + array( + 'first' => $sanitized['value'], + 'second' => $again['value'], + ) + ); + } + + self::check_invariant( + $result, + false === strpos( $sanitized['value'], chr( 0 ) ) && false === strpbrk( $sanitized['value'], "\r\n" ), + 'sanitize_url:no-nul-or-newline', + $url, + array( 'sanitized' => $sanitized['value'] ) + ); + } + } + } + + if ( function_exists( 'esc_url' ) ) { + $escaped = self::call_api( + $result, + 'esc_url', + $url, + static function () use ( $url ) { + return esc_url( $url ); + } + ); + + if ( $escaped['ok'] ) { + self::check_invariant( + $result, + is_string( $escaped['value'] ), + 'esc_url:return-string', + $url, + array( 'actual' => $escaped['value'] ) + ); + + if ( is_string( $escaped['value'] ) ) { + self::check_invariant( + $result, + false === strpos( $escaped['value'], chr( 0 ) ) && false === strpbrk( $escaped['value'], "\r\n" ), + 'esc_url:no-nul-or-newline', + $url, + array( 'escaped' => $escaped['value'] ) + ); + } + } + } + + if ( function_exists( 'wp_http_validate_url' ) ) { + if ( ! self::is_http_validate_probe_safe( $url ) ) { + self::feature( $result, 'wp_http_validate_url:dns-skipped' ); + continue; + } + + $validated = self::call_api( + $result, + 'wp_http_validate_url', + $url, + static function () use ( $url ) { + return wp_http_validate_url( $url ); + } + ); + + if ( $validated['ok'] ) { + self::check_invariant( + $result, + false === $validated['value'] || is_string( $validated['value'] ), + 'wp_http_validate_url:return-shape', + $url, + array( 'actual' => $validated['value'] ) + ); + + if ( is_string( $validated['value'] ) ) { + $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $validated['value'] ) : parse_url( $validated['value'] ); + self::check_invariant( + $result, + is_array( $parts ) + && isset( $parts['scheme'], $parts['host'] ) + && in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) + && ! isset( $parts['user'], $parts['pass'] ), + 'wp_http_validate_url:accepted-url-shape', + $url, + array( + 'accepted' => $validated['value'], + 'parts' => $parts, + ) + ); + } + } + } + } + } + + private static function check_parse_url_components( string $url, $parts, array &$result ): void { + $components = array( + PHP_URL_SCHEME => 'scheme', + PHP_URL_HOST => 'host', + PHP_URL_PORT => 'port', + PHP_URL_USER => 'user', + PHP_URL_PASS => 'pass', + PHP_URL_PATH => 'path', + PHP_URL_QUERY => 'query', + PHP_URL_FRAGMENT => 'fragment', + ); + + foreach ( $components as $component => $key ) { + $component_result = self::call_api( + $result, + 'wp_parse_url.component', + $url, + static function () use ( $url, $component ) { + return wp_parse_url( $url, $component ); + } + ); + + if ( ! $component_result['ok'] ) { + continue; + } + + $expected = false === $parts ? false : ( is_array( $parts ) && array_key_exists( $key, $parts ) ? $parts[ $key ] : null ); + self::check_invariant( + $result, + $component_result['value'] === $expected, + 'wp_parse_url:component-consistency', + $url, + array( + 'component' => $key, + 'expected' => $expected, + 'actual' => $component_result['value'], + 'parts' => $parts, + ) + ); + } + } + + private static function exercise_path_apis( array &$rng, array &$result ): void { + $paths = self::path_cases( $rng ); + + if ( ! function_exists( 'wp_normalize_path' ) ) { + self::skip_once( $result, 'wp_normalize_path', 'Function is unavailable.' ); + } + if ( ! function_exists( 'validate_file' ) ) { + self::skip_once( $result, 'validate_file', 'Function is unavailable.' ); + } + + foreach ( $paths as $path ) { + ++$result['caseCount']; + self::feature( $result, 'path' ); + + if ( function_exists( 'wp_normalize_path' ) ) { + $normalized = self::call_api( + $result, + 'wp_normalize_path', + $path, + static function () use ( $path ) { + return wp_normalize_path( $path ); + } + ); + + if ( $normalized['ok'] ) { + self::check_invariant( + $result, + is_string( $normalized['value'] ), + 'wp_normalize_path:return-string', + $path, + array( 'actual' => $normalized['value'] ) + ); + + if ( is_string( $normalized['value'] ) ) { + $again = self::call_api( + $result, + 'wp_normalize_path.idempotent', + $normalized['value'], + static function () use ( $normalized ) { + return wp_normalize_path( $normalized['value'] ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $normalized['value'], + 'wp_normalize_path:idempotent', + $path, + array( + 'first' => $normalized['value'], + 'second' => $again['value'], + ) + ); + } + + self::check_invariant( + $result, + false === strpos( $normalized['value'], '\\' ), + 'wp_normalize_path:no-backslashes', + $path, + array( 'normalized' => $normalized['value'] ) + ); + } + } + } + + if ( function_exists( 'validate_file' ) ) { + $plain = self::call_api( + $result, + 'validate_file', + $path, + static function () use ( $path ) { + return validate_file( $path ); + } + ); + + if ( $plain['ok'] ) { + $expected = self::expected_validate_file_code( $path, array() ); + self::check_invariant( + $result, + $plain['value'] === $expected, + 'validate_file:default-code', + $path, + array( + 'expected' => $expected, + 'actual' => $plain['value'], + ) + ); + } + + $allowed_files = array( 'safe/file.txt', 'folder/sub/file.php', 'safe/../' ); + $allowed = self::call_api( + $result, + 'validate_file.allowed', + $path, + static function () use ( $path, $allowed_files ) { + return validate_file( $path, $allowed_files ); + } + ); + + if ( $allowed['ok'] ) { + $expected = self::expected_validate_file_code( $path, $allowed_files ); + self::check_invariant( + $result, + $allowed['value'] === $expected, + 'validate_file:allowed-list-code', + $path, + array( + 'allowedFiles' => $allowed_files, + 'expected' => $expected, + 'actual' => $allowed['value'], + ) + ); + } + } + } + } + + private static function exercise_filename_apis( array &$rng, array &$result ): void { + $filenames = self::filename_cases( $rng ); + + if ( ! function_exists( 'sanitize_file_name' ) ) { + self::skip_once( $result, 'sanitize_file_name', 'Function is unavailable.' ); + } + if ( ! function_exists( 'wp_check_filetype' ) ) { + self::skip_once( $result, 'wp_check_filetype', 'Function is unavailable.' ); + } + + foreach ( $filenames as $filename ) { + ++$result['caseCount']; + self::feature( $result, 'filename' ); + + if ( function_exists( 'sanitize_file_name' ) ) { + $sanitized = self::call_api( + $result, + 'sanitize_file_name', + $filename, + static function () use ( $filename ) { + return sanitize_file_name( $filename ); + } + ); + + if ( $sanitized['ok'] ) { + self::check_invariant( + $result, + is_string( $sanitized['value'] ), + 'sanitize_file_name:return-string', + $filename, + array( 'actual' => $sanitized['value'] ) + ); + + if ( is_string( $sanitized['value'] ) ) { + $again = self::call_api( + $result, + 'sanitize_file_name.idempotent', + $sanitized['value'], + static function () use ( $sanitized ) { + return sanitize_file_name( $sanitized['value'] ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $sanitized['value'], + 'sanitize_file_name:idempotent', + $filename, + array( + 'first' => $sanitized['value'], + 'second' => $again['value'], + ) + ); + } + + self::check_invariant( + $result, + ! self::filename_has_forbidden_output_chars( $sanitized['value'] ), + 'sanitize_file_name:no-path-separator-or-nul', + $filename, + array( 'sanitized' => $sanitized['value'] ) + ); + } + } + } + + if ( function_exists( 'wp_check_filetype' ) ) { + $filetype = self::call_api( + $result, + 'wp_check_filetype', + $filename, + static function () use ( $filename ) { + return wp_check_filetype( $filename ); + } + ); + + if ( $filetype['ok'] ) { + self::check_filetype_shape( $filename, $filetype['value'], 'wp_check_filetype:shape', $result ); + + $again = self::call_api( + $result, + 'wp_check_filetype.repeat', + $filename, + static function () use ( $filename ) { + return wp_check_filetype( $filename ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $filetype['value'], + 'wp_check_filetype:repeat-stable', + $filename, + array( + 'first' => $filetype['value'], + 'second' => $again['value'], + ) + ); + } + } + + $lower = strtolower( $filename ); + $upper = strtoupper( $filename ); + $lower_result = self::call_api( + $result, + 'wp_check_filetype.lower', + $lower, + static function () use ( $lower ) { + return wp_check_filetype( $lower ); + } + ); + $upper_result = self::call_api( + $result, + 'wp_check_filetype.upper', + $upper, + static function () use ( $upper ) { + return wp_check_filetype( $upper ); + } + ); + + if ( $lower_result['ok'] && $upper_result['ok'] ) { + self::check_invariant( + $result, + self::filetype_case_signature( $lower_result['value'] ) === self::filetype_case_signature( $upper_result['value'] ), + 'wp_check_filetype:case-stable', + $filename, + array( + 'lowerInput' => $lower, + 'upperInput' => $upper, + 'lowerResult' => $lower_result['value'], + 'upperResult' => $upper_result['value'], + ) + ); + } + } + } + } + + private static function exercise_filetype_and_unique_apis( array &$rng, array &$result, string $temp_root ): void { + $filetype_dir = $temp_root . DIRECTORY_SEPARATOR . 'filetype'; + $unique_dir = $temp_root . DIRECTORY_SEPARATOR . 'unique'; + self::ensure_dir( $filetype_dir ); + self::ensure_dir( $unique_dir ); + + if ( ! function_exists( 'wp_check_filetype_and_ext' ) ) { + self::skip_once( $result, 'wp_check_filetype_and_ext', 'Function is unavailable.' ); + } else { + $local_files = array( + array( 'name' => 'notes.txt', 'bytes' => "plain text\n" ), + array( 'name' => 'fake-image.jpg', 'bytes' => "plain text pretending to be a jpeg\n" ), + array( 'name' => 'tiny.gif', 'bytes' => "GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;" ), + array( 'name' => 'document.pdf', 'bytes' => "%PDF-1.3\n1 0 obj\n<<>>\nendobj\n" ), + array( 'name' => 'vector.svg', 'bytes' => "\n" ), + ); + + foreach ( $local_files as $index => $local ) { + ++$result['caseCount']; + self::feature( $result, 'filetype-and-ext:local' ); + + $path = $filetype_dir . DIRECTORY_SEPARATOR . 'case-' . $index . '.bin'; + file_put_contents( $path, $local['bytes'] ); + + $checked = self::call_api( + $result, + 'wp_check_filetype_and_ext', + $local['name'], + static function () use ( $path, $local ) { + return wp_check_filetype_and_ext( $path, $local['name'] ); + } + ); + + if ( $checked['ok'] ) { + self::check_filetype_and_ext_shape( $local['name'], $checked['value'], 'wp_check_filetype_and_ext:shape', $result ); + + $again = self::call_api( + $result, + 'wp_check_filetype_and_ext.repeat', + $local['name'], + static function () use ( $path, $local ) { + return wp_check_filetype_and_ext( $path, $local['name'] ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $checked['value'], + 'wp_check_filetype_and_ext:repeat-stable', + $local['name'], + array( + 'first' => $checked['value'], + 'second' => $again['value'], + ) + ); + } + } + + $missing_path = $filetype_dir . DIRECTORY_SEPARATOR . 'missing-' . $index . '.bin'; + $lower_name = strtolower( $local['name'] ); + $upper_name = strtoupper( $local['name'] ); + $lower = self::call_api( + $result, + 'wp_check_filetype_and_ext.lower-missing', + $lower_name, + static function () use ( $missing_path, $lower_name ) { + return wp_check_filetype_and_ext( $missing_path, $lower_name ); + } + ); + $upper = self::call_api( + $result, + 'wp_check_filetype_and_ext.upper-missing', + $upper_name, + static function () use ( $missing_path, $upper_name ) { + return wp_check_filetype_and_ext( $missing_path, $upper_name ); + } + ); + + if ( $lower['ok'] && $upper['ok'] ) { + self::check_invariant( + $result, + self::filetype_case_signature( $lower['value'] ) === self::filetype_case_signature( $upper['value'] ), + 'wp_check_filetype_and_ext:case-stable-without-file-read', + $local['name'], + array( + 'lowerResult' => $lower['value'], + 'upperResult' => $upper['value'], + ) + ); + } + } + } + + if ( ! function_exists( 'wp_unique_filename' ) ) { + self::skip_once( $result, 'wp_unique_filename', 'Function is unavailable.' ); + return; + } + + $existing = array( + 'photo.jpg' => 'x', + 'photo-1.jpg' => 'x', + 'avatar-150x150.png' => 'x', + 'report.pdf' => 'x', + 'image.JPG' => 'x', + 'thumb-rotated.webp' => 'x', + 'thumb-rotated-1.webp' => 'x', + ); + + foreach ( $existing as $name => $bytes ) { + file_put_contents( $unique_dir . DIRECTORY_SEPARATOR . $name, $bytes ); + } + + $candidates = array_merge( + array( + 'photo.JPG', + 'photo.jpg', + 'avatar-150x150.PNG', + 'report.pdf', + 'new file.txt', + 'a/b.php.jpg', + 'thumb-rotated.WEBP', + 'archive.tar.gz', + ), + self::generated_unique_filename_cases( $rng ) + ); + + foreach ( $candidates as $candidate ) { + ++$result['caseCount']; + self::feature( $result, 'wp_unique_filename' ); + + $unique = self::call_api( + $result, + 'wp_unique_filename', + $candidate, + static function () use ( $unique_dir, $candidate ) { + return wp_unique_filename( $unique_dir, $candidate ); + } + ); + + if ( ! $unique['ok'] ) { + continue; + } + + self::check_invariant( + $result, + is_string( $unique['value'] ), + 'wp_unique_filename:return-string', + $candidate, + array( 'actual' => $unique['value'] ) + ); + + if ( ! is_string( $unique['value'] ) ) { + continue; + } + + self::check_invariant( + $result, + '' !== $unique['value'] && ! self::filename_has_forbidden_output_chars( $unique['value'] ), + 'wp_unique_filename:safe-basename', + $candidate, + array( 'unique' => $unique['value'] ) + ); + + self::check_invariant( + $result, + ! file_exists( $unique_dir . DIRECTORY_SEPARATOR . $unique['value'] ), + 'wp_unique_filename:non-conflicting', + $candidate, + array( 'unique' => $unique['value'] ) + ); + + if ( function_exists( 'sanitize_file_name' ) ) { + self::check_invariant( + $result, + sanitize_file_name( $unique['value'] ) === $unique['value'], + 'wp_unique_filename:sanitized-output', + $candidate, + array( 'unique' => $unique['value'] ) + ); + } + + $again = self::call_api( + $result, + 'wp_unique_filename.repeat', + $candidate, + static function () use ( $unique_dir, $candidate ) { + return wp_unique_filename( $unique_dir, $candidate ); + } + ); + + if ( $again['ok'] ) { + self::check_invariant( + $result, + $again['value'] === $unique['value'], + 'wp_unique_filename:repeat-stable', + $candidate, + array( + 'first' => $unique['value'], + 'second' => $again['value'], + ) + ); + } + } + } + + private static function exercise_sideload_helpers( array &$rng, array &$result, string $temp_root ): void { + unset( $rng ); + self::load_admin_file_helpers(); + + if ( ! function_exists( 'wp_handle_sideload' ) ) { + self::skip_once( $result, 'wp_handle_sideload', 'Function is unavailable.' ); + return; + } + if ( ! function_exists( 'add_filter' ) || ! function_exists( 'remove_filter' ) ) { + self::skip_once( $result, 'wp_handle_sideload', 'Filter API is unavailable.' ); + return; + } + + $upload_root = $temp_root . DIRECTORY_SEPARATOR . 'sideload-uploads'; + $tmp_root = $temp_root . DIRECTORY_SEPARATOR . 'sideload-tmp'; + self::ensure_dir( $upload_root ); + self::ensure_dir( $tmp_root ); + + $cases = array( + array( 'name' => 'side load.txt', 'type' => 'text/plain', 'bytes' => "plain text\n" ), + array( 'name' => 'nested/path.txt', 'type' => 'text/plain', 'bytes' => "path separators in client filename\n" ), + ); + + self::$sideload_upload_root = $upload_root; + add_filter( 'upload_dir', array( __CLASS__, 'filter_sideload_upload_dir' ) ); + + try { + foreach ( $cases as $index => $case ) { + ++$result['caseCount']; + self::feature( $result, 'wp_handle_sideload' ); + + $tmp_name = $tmp_root . DIRECTORY_SEPARATOR . 'sideload-' . $index . '.tmp'; + file_put_contents( $tmp_name, $case['bytes'] ); + $file = array( + 'name' => $case['name'], + 'type' => $case['type'], + 'tmp_name' => $tmp_name, + 'error' => 0, + 'size' => strlen( $case['bytes'] ), + ); + + $handled = self::call_api( + $result, + 'wp_handle_sideload', + $case['name'], + static function () use ( &$file ) { + return wp_handle_sideload( + $file, + array( + 'test_form' => false, + 'mimes' => array( + 'txt|asc|c|cc|h|srt' => 'text/plain', + 'csv' => 'text/csv', + ), + ), + '2026/06' + ); + } + ); + + if ( ! $handled['ok'] ) { + continue; + } + + $value = $handled['value']; + self::check_invariant( + $result, + is_array( $value ) && ! isset( $value['error'] ), + 'wp_handle_sideload:valid-local-file', + $case['name'], + array( 'result' => $value ) + ); + + if ( is_array( $value ) && ! isset( $value['error'] ) ) { + $file_path = isset( $value['file'] ) ? (string) $value['file'] : ''; + $url = isset( $value['url'] ) ? (string) $value['url'] : ''; + $type = isset( $value['type'] ) ? (string) $value['type'] : ''; + + self::check_invariant( + $result, + 0 === strpos( self::normalize_directory_separators( $file_path ), self::normalize_directory_separators( $upload_root ) . '/' ) + && is_file( $file_path ) + && false === strpos( basename( $file_path ), '/' ) + && false === strpos( basename( $file_path ), '\\' ), + 'wp_handle_sideload:temp-upload-path', + $case['name'], + array( + 'file' => $file_path, + 'uploadRoot' => $upload_root, + ) + ); + + self::check_invariant( + $result, + 0 === strpos( $url, 'http://example.test/component-fuzz-network' ) && '' !== $type, + 'wp_handle_sideload:url-and-type', + $case['name'], + array( + 'url' => $url, + 'type' => $type, + ) + ); + } + } + } finally { + remove_filter( 'upload_dir', array( __CLASS__, 'filter_sideload_upload_dir' ) ); + self::$sideload_upload_root = null; + } + } + + private static function check_filetype_shape( string $input, $value, string $invariant, array &$result ): void { + self::check_invariant( + $result, + is_array( $value ) + && array_key_exists( 'ext', $value ) + && array_key_exists( 'type', $value ) + && ( false === $value['ext'] || is_string( $value['ext'] ) ) + && ( false === $value['type'] || is_string( $value['type'] ) ), + $invariant, + $input, + array( 'actual' => $value ) + ); + } + + private static function check_filetype_and_ext_shape( string $input, $value, string $invariant, array &$result ): void { + self::check_invariant( + $result, + is_array( $value ) + && array_key_exists( 'ext', $value ) + && array_key_exists( 'type', $value ) + && array_key_exists( 'proper_filename', $value ) + && ( false === $value['ext'] || is_string( $value['ext'] ) ) + && ( false === $value['type'] || is_string( $value['type'] ) ) + && ( false === $value['proper_filename'] || is_string( $value['proper_filename'] ) ), + $invariant, + $input, + array( 'actual' => $value ) + ); + } + + private static function expected_validate_file_code( $file, array $allowed_files ): int { + if ( ! is_scalar( $file ) || '' === $file ) { + return 0; + } + + $file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file ) : self::normalize_directory_separators( (string) $file ); + foreach ( $allowed_files as $index => $allowed ) { + $allowed_files[ $index ] = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $allowed ) : self::normalize_directory_separators( (string) $allowed ); + } + + if ( '../' === $file ) { + return 1; + } + + if ( substr_count( $file, '../' ) > 1 ) { + return 1; + } + + if ( false !== strpos( $file, '../' ) && '../' !== substr( $file, -3 ) ) { + return 1; + } + + if ( array() !== $allowed_files && ! in_array( $file, $allowed_files, true ) ) { + return 3; + } + + if ( isset( $file[1] ) && ':' === $file[1] ) { + return 2; + } + + return 0; + } + + private static function is_http_validate_probe_safe( string $url ): bool { + if ( '' === $url || is_numeric( $url ) ) { + return true; + } + + $scheme = parse_url( $url, PHP_URL_SCHEME ); + if ( is_string( $scheme ) && ! in_array( strtolower( $scheme ), array( 'http', 'https' ), true ) ) { + return true; + } + + $parts = @parse_url( $url ); + if ( false === $parts || ! is_array( $parts ) || empty( $parts['host'] ) ) { + return true; + } + + if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { + return true; + } + + $host = (string) $parts['host']; + if ( false !== strpbrk( $host, ':#?[]' ) ) { + return true; + } + + $trimmed_host = trim( $host, '.' ); + if ( self::is_ipv4_literal( $trimmed_host ) ) { + return true; + } + + $home_host = self::home_host(); + return null !== $home_host && strtolower( $home_host ) === strtolower( $host ); + } + + private static function home_host(): ?string { + if ( function_exists( 'get_option' ) ) { + $home = get_option( 'home' ); + if ( is_string( $home ) ) { + $host = parse_url( $home, PHP_URL_HOST ); + if ( is_string( $host ) && '' !== $host ) { + return $host; + } + } + } + + return null; + } + + private static function is_ipv4_literal( string $host ): bool { + if ( 1 !== preg_match( '/^(([0-9]{1,3})\.){3}([0-9]{1,3})$/', $host ) ) { + return false; + } + + foreach ( explode( '.', $host ) as $part ) { + if ( (int) $part > 255 ) { + return false; + } + } + + return true; + } + + private static function is_reconstructable_url_parts( array $parts ): bool { + if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return false; + } + + if ( ! is_string( $parts['scheme'] ) || 1 !== preg_match( '/^[A-Za-z][A-Za-z0-9+.-]*$/', $parts['scheme'] ) ) { + return false; + } + + if ( ! is_string( $parts['host'] ) || '' === $parts['host'] || 1 === preg_match( '/[\x00-\x20\/?#@]/', $parts['host'] ) ) { + return false; + } + + foreach ( array( 'user', 'pass', 'path', 'query', 'fragment' ) as $key ) { + if ( isset( $parts[ $key ] ) && ( ! is_string( $parts[ $key ] ) || false !== strpos( $parts[ $key ], chr( 0 ) ) ) ) { + return false; + } + } + + if ( isset( $parts['path'] ) && '' !== $parts['path'] && '/' !== $parts['path'][0] ) { + return false; + } + + return ! isset( $parts['port'] ) || is_int( $parts['port'] ); + } + + private static function rebuild_url( array $parts ): string { + $url = $parts['scheme'] . '://'; + + if ( isset( $parts['user'] ) ) { + $url .= $parts['user']; + if ( isset( $parts['pass'] ) ) { + $url .= ':' . $parts['pass']; + } + $url .= '@'; + } + + $url .= $parts['host']; + + if ( isset( $parts['port'] ) ) { + $url .= ':' . $parts['port']; + } + + $url .= isset( $parts['path'] ) ? $parts['path'] : ''; + + if ( array_key_exists( 'query', $parts ) ) { + $url .= '?' . $parts['query']; + } + + if ( array_key_exists( 'fragment', $parts ) ) { + $url .= '#' . $parts['fragment']; + } + + return $url; + } + + private static function same_url_parts( array $expected, array $actual ): bool { + foreach ( array( 'scheme', 'host', 'port', 'user', 'pass', 'path', 'query', 'fragment' ) as $key ) { + $expected_has = array_key_exists( $key, $expected ); + $actual_has = array_key_exists( $key, $actual ); + if ( $expected_has !== $actual_has ) { + return false; + } + if ( $expected_has && $expected[ $key ] !== $actual[ $key ] ) { + return false; + } + } + + return true; + } + + private static function filename_has_forbidden_output_chars( string $filename ): bool { + return false !== strpos( $filename, '/' ) + || false !== strpos( $filename, '\\' ) + || false !== strpos( $filename, chr( 0 ) ) + || false !== strpbrk( $filename, "\r\n\t" ); + } + + private static function filetype_case_signature( $value ): array { + if ( ! is_array( $value ) ) { + return array( 'invalid' => gettype( $value ) ); + } + + $ext = isset( $value['ext'] ) && is_string( $value['ext'] ) ? strtolower( $value['ext'] ) : $value['ext'] ?? null; + + return array( + 'ext' => $ext, + 'type' => $value['type'] ?? null, + ); + } + + private static function call_api( array &$result, string $api, $input, callable $callback ): array { + ++$result['apiCalls']; + + try { + return array( + 'ok' => true, + 'value' => $callback(), + ); + } catch ( \Throwable $e ) { + self::record_failure( + $result, + $api . ':no-throw', + $input, + array( + 'throwable' => get_class( $e ), + 'message' => $e->getMessage(), + ) + ); + + return array( + 'ok' => false, + 'value' => null, + ); + } + } + + private static function check_invariant( array &$result, bool $condition, string $invariant, $input, array $details = array() ): void { + ++$result['assertions']; + if ( $condition ) { + return; + } + + self::record_failure( $result, $invariant, $input, $details ); + } + + private static function record_failure( array &$result, string $invariant, $input, array $details = array() ): void { + ++$result['failureCount']; + + if ( count( $result['failures'] ) >= self::MAX_FAILURES ) { + return; + } + + $result['failures'][] = array( + 'invariant' => $invariant, + 'input' => self::describe_value( $input ), + 'details' => self::describe_value( $details ), + ); + } + + private static function feature( array &$result, string $feature ): void { + $result['features'][ $feature ] = true; + } + + private static function skip_once( array &$result, string $name, string $reason ): void { + if ( ! isset( $result['skipped'][ $name ] ) ) { + $result['skipped'][ $name ] = $reason; + } + } + + private static function seed_from_context( \ComponentFuzz\FuzzContext $ctx ): int { + foreach ( array( 'seed', 'getSeed', 'currentSeed', 'iteration', 'getIteration' ) as $method ) { + if ( is_callable( array( $ctx, $method ) ) ) { + try { + $seed = self::coerce_seed( $ctx->$method() ); + if ( null !== $seed ) { + return $seed; + } + } catch ( \Throwable $e ) { + continue; + } + } + } + + foreach ( get_object_vars( $ctx ) as $key => $value ) { + if ( in_array( $key, array( 'seed', 'currentSeed', 'iteration' ), true ) ) { + $seed = self::coerce_seed( $value ); + if ( null !== $seed ) { + return $seed; + } + } + } + + return 1; + } + + private static function coerce_seed( $value ): ?int { + if ( is_int( $value ) ) { + return max( 1, abs( $value ) ); + } + + if ( is_float( $value ) ) { + return max( 1, abs( (int) $value ) ); + } + + if ( is_string( $value ) && is_numeric( $value ) ) { + return max( 1, abs( (int) $value ) ); + } + + if ( is_string( $value ) && '' !== $value ) { + return max( 1, (int) hexdec( substr( sha1( $value ), 0, 7 ) ) ); + } + + return null; + } + + private static function rng( int $seed ): array { + return array( + 'seed' => (string) $seed, + 'counter' => 0, + 'buffer' => '', + ); + } + + private static function rng_bytes( array &$rng, int $length ): string { + while ( strlen( $rng['buffer'] ) < $length ) { + $rng['buffer'] .= hash( 'sha256', $rng['seed'] . ':' . $rng['counter'], true ); + ++$rng['counter']; + } + + $out = substr( $rng['buffer'], 0, $length ); + $rng['buffer'] = substr( $rng['buffer'], $length ); + + return $out; + } + + private static function rng_uint32( array &$rng ): int { + $parts = unpack( 'Nvalue', self::rng_bytes( $rng, 4 ) ); + return (int) $parts['value']; + } + + private static function rng_int( array &$rng, int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + + return $min + ( self::rng_uint32( $rng ) % ( $max - $min + 1 ) ); + } + + private static function rng_choice( array &$rng, array $values ) { + return $values[ self::rng_int( $rng, 0, count( $values ) - 1 ) ]; + } + + private static function url_cases( array &$rng ): array { + $cases = array( + '', + 'http://example.com/path?query#frag', + 'https://user:pass@example.com:443/a/b?x=1&y=two#frag', + '//example.com/path?x:y', + '/relative/path?x:y', + 'http://127.0.0.1/', + 'http://192.168.1.1:8080/', + 'http://0.0.0.0/', + 'http://8.8.8.8:81/', + 'http://[::1]/', + 'http://[2001:db8::1]:8080/a', + 'http://example.com:99999/', + 'http://-bad-.test/', + 'http://ex ample.com/', + "http://example.com/\0path", + "https://example.com/%0d%0aHeader:%20x", + "http://xn--exmple-cua.test/\xc3\xb1?utf=%e2%98%83", + "http://t\xc3\xa4st.de/path", + 'mailto:user@example.com', + 'ftp://example.com/file', + 'javascript:alert(1)', + 'http:///missing-host', + 'http://user@example.com@', + 'https://example.com:443/path;params?x[]=1', + 'http://example.com/?a=http://b', + 'http://example.com#frag:with:colon', + 'http://[::ffff:127.0.0.1]/', + 'https://example.com/%E0%A4%A', + 'HTTPS://EXAMPLE.COM/A?B=C', + ); + + for ( $i = 0; $i < 32; ++$i ) { + $cases[] = self::generated_url( $rng ); + } + + return array_values( array_unique( $cases ) ); + } + + private static function generated_url( array &$rng ): string { + $schemes = array( 'http', 'https', 'HTTP', 'ftp', 'file', 'data', 'javascript', '' ); + $hosts = array( + 'example.com', + 'EXAMPLE.com.', + '127.0.0.1', + '192.168.0.1', + '10.0.0.1', + '172.16.0.1', + '169.254.169.254', + '8.8.8.8', + '[::1]', + '[2001:db8::1]', + 'xn--bcher-kva.example', + "idn-\xc3\xa4.test", + "bad\0host.test", + 'exa mple.test', + '', + 'localhost', + ); + $users = array( '', 'user@', 'user:pass@', 'us%20er:p%40ss@', "u\0:p@" ); + $ports = array( '', ':80', ':443', ':8080', ':81', ':0', ':65535', ':65536', ':bad' ); + $paths = array( '', '/', '/a/b', '/../wp-config.php', '/%2e%2e/a', '//double', "/control\x01/x", '/file.php;param', '/space path', "/bytes/\xff" ); + $queries = array( '', '?a=1&b=2', '?x=http://example.org', "?\r\nx=y", '?array[]=1', '?q=%00', '?colon=this:that' ); + $frags = array( '', '#frag', '#frag:colon', "#bad\x02frag" ); + + $kind = self::rng_int( $rng, 0, 4 ); + $scheme = self::rng_choice( $rng, $schemes ); + $host = self::rng_choice( $rng, $hosts ); + $auth = self::rng_choice( $rng, $users ) . $host . self::rng_choice( $rng, $ports ); + $tail = self::rng_choice( $rng, $paths ) . self::rng_choice( $rng, $queries ) . self::rng_choice( $rng, $frags ); + + if ( 0 === $kind ) { + return ( '' === $scheme ? 'http' : $scheme ) . '://' . $auth . $tail; + } + if ( 1 === $kind ) { + return '//' . $auth . $tail; + } + if ( 2 === $kind ) { + return self::rng_choice( $rng, $paths ) . self::rng_choice( $rng, $queries ) . self::rng_choice( $rng, $frags ); + } + if ( 3 === $kind ) { + return ( '' === $scheme ? 'mailto' : $scheme ) . ':' . ltrim( $tail, '/' ); + } + + return $scheme . "://\t" . $auth . $tail; + } + + private static function path_cases( array &$rng ): array { + $cases = array( + '', + '.', + '..', + '../', + '../file.php', + 'foo/../', + 'foo/../../bar.php', + '/absolute/path.php', + 'C:\\Windows\\system32\\drivers\\etc\\hosts', + 'c:/temp/file.txt', + '\\\\server\\share\\file.txt', + '//server/share//file.txt', + 'php://filter\\resource=index.php', + 'phar://C:\\path\\file.phar/a.php', + "a\0b", + 'folder\\sub\\file.php', + 'a//b///c', + 'C:relative', + 'Z:/mixed\\slashes', + '.../file', + 'foo/..bar/baz', + './safe/file', + 'safe/../', + 'safe/../../', + "\t/controls\n", + ); + + $segments = array( 'safe', '..', '.', 'folder', 'file.php', "nul\0seg", 'C:', 'name with space', 'aux', 'con' ); + $slashes = array( '/', '\\', '//', '\\\\' ); + for ( $i = 0; $i < 24; ++$i ) { + $path = ''; + $count = self::rng_int( $rng, 1, 5 ); + for ( $j = 0; $j < $count; ++$j ) { + if ( '' !== $path ) { + $path .= self::rng_choice( $rng, $slashes ); + } + $path .= self::rng_choice( $rng, $segments ); + } + if ( 0 === self::rng_int( $rng, 0, 4 ) ) { + $path = '/' . $path; + } + $cases[] = $path; + } + + return array_values( array_unique( $cases ) ); + } + + private static function filename_cases( array &$rng ): array { + $cases = array( + 'image.jpg', + 'IMAGE.JPG', + 'avatar-150x150.PNG', + 'avatar-scaled.jpg', + 'evil.php.jpg', + 'shell.php', + 'archive.tar.gz', + 'noext', + '..', + '.htaccess', + 'a/b.jpg', + 'a\\b.png', + "nul\0byte.gif", + "line\nbreak.txt", + "resume-\xc3\xa9.pdf", + "photo.\xff.jpg", + 'double..dots..jpg', + 'web.config', + 'file.svg', + 'file.SVG', + 'heic.HEIC', + 'image.jpeg.php', + 'unnamed', + 'jpg', + '%20space+name.png', + 'hello world.txt', + 'thumb-rotated.webp', + 'document.docx', + 'sheet.xlsx', + 'audio.mp3', + 'video.MOV', + 'image.avif', + ); + + $bases = array( 'photo', 'index', 'my file', 'a/b', 'a\\b', "ctrl\x01name", "nul\0name", 'resume', 'avatar-300x200', 'rotated-scaled' ); + $mids = array( '', '.php', '.tar', '..', '.backup', '.JPG', '.svg' ); + $exts = array( '.jpg', '.JPG', '.png', '.txt', '.php', '.gif', '.webp', '.heic', '.pdf', '' ); + for ( $i = 0; $i < 32; ++$i ) { + $cases[] = self::rng_choice( $rng, $bases ) . self::rng_choice( $rng, $mids ) . self::rng_choice( $rng, $exts ); + } + + return array_values( array_unique( $cases ) ); + } + + private static function generated_unique_filename_cases( array &$rng ): array { + $cases = array(); + $bases = array( 'photo', 'image', 'thumb-150x150', 'thumb-scaled', 'report final', 'a/b', 'archive.tar', 'resume' ); + $exts = array( '.jpg', '.JPG', '.png', '.PNG', '.txt', '.pdf', '.webp', '.WEBP', '.gz' ); + + for ( $i = 0; $i < 12; ++$i ) { + $cases[] = self::rng_choice( $rng, $bases ) . self::rng_choice( $rng, $exts ); + } + + return $cases; + } + + private static function make_temp_root( int $seed, array &$result ): ?string { + $base = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ); + if ( '' === $base || ! is_dir( $base ) || ! is_writable( $base ) ) { + return null; + } + + $prefix = $base . DIRECTORY_SEPARATOR . 'component-fuzz-network-' . getmypid() . '-' . $seed . '-'; + for ( $i = 0; $i < 100; ++$i ) { + $dir = $prefix . $i; + if ( @mkdir( $dir, 0700, true ) ) { + self::feature( $result, 'temp-files' ); + return $dir; + } + if ( is_dir( $dir ) ) { + continue; + } + } + + return null; + } + + private static function ensure_dir( string $path ): void { + if ( ! is_dir( $path ) ) { + mkdir( $path, 0777, true ); + } + } + + private static function remove_dir_recursive( string $path ): void { + $base = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-network-'; + if ( 0 !== strpos( $path, $base ) || ! is_dir( $path ) ) { + return; + } + + $items = scandir( $path ); + if ( false !== $items ) { + foreach ( $items as $item ) { + if ( '.' === $item || '..' === $item ) { + continue; + } + $child = $path . DIRECTORY_SEPARATOR . $item; + if ( is_dir( $child ) && ! is_link( $child ) ) { + self::remove_dir_recursive( $child ); + } else { + @unlink( $child ); + } + } + } + + @rmdir( $path ); + } + + private static function load_admin_file_helpers(): void { + if ( function_exists( 'wp_handle_sideload' ) || ! defined( 'ABSPATH' ) ) { + return; + } + + $file = rtrim( ABSPATH, '/\\' ) . '/wp-admin/includes/file.php'; + if ( is_file( $file ) ) { + require_once $file; + } + } + + private static function normalize_directory_separators( string $path ): string { + $path = str_replace( '\\', '/', $path ); + return (string) preg_replace( '|(?<=.)/+|', '/', $path ); + } + + private static function describe_value( $value ) { + if ( is_string( $value ) ) { + $preview = substr( $value, 0, 160 ); + $json = json_encode( $preview, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); + if ( false === $json ) { + $json = null; + } + + return array( + 'type' => 'string', + 'length' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => $json, + 'hexPreview' => bin2hex( substr( $value, 0, 48 ) ), + ); + } + + if ( is_array( $value ) ) { + $out = array(); + $count = 0; + foreach ( $value as $key => $item ) { + if ( $count >= 24 ) { + $out['__truncated__'] = count( $value ) - $count; + break; + } + $out[ is_int( $key ) ? $key : (string) $key ] = self::describe_value( $item ); + ++$count; + } + return $out; + } + + if ( is_object( $value ) ) { + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + if ( is_resource( $value ) ) { + return array( + 'type' => 'resource', + ); + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/RestSurface.php b/tools/component-fuzz/surfaces/RestSurface.php new file mode 100644 index 0000000000000..b685065621502 --- /dev/null +++ b/tools/component-fuzz/surfaces/RestSurface.php @@ -0,0 +1,1474 @@ + 1, + 'kind' => 'component-fuzz-surface-result', + 'surface' => self::NAME, + 'ok' => true, + 'status' => 'skipped', + 'seed' => $seed, + 'missingRequirements' => $missing, + 'checks' => array(), + 'skipped' => array( + array( + 'name' => 'bootstrap-requirements', + 'reason' => 'Required WordPress REST API classes/functions are not loaded.', + 'data' => array( 'missingRequirements' => $missing ), + ), + ), + 'features' => array( 'guarded-bootstrap' ), + 'durationMs' => self::duration_ms( $started_at ), + ); + } + + $rng = self::rng( $seed ); + $checks = array(); + $filters = self::install_stateless_filters(); + + try { + $checks[] = self::run_check( + 'method-normalization', + 'WP_REST_Request uppercases methods and is_method() compares case-insensitively to that normalized method.', + function () use ( &$rng ) { + return self::check_method_normalization( $rng ); + } + ); + $checks[] = self::run_check( + 'header-query-body-normalization', + 'Headers canonicalize dash/underscore names, URL-encoded bodies merge without overriding explicit body params, files and rest_route handling remain stable.', + function () use ( &$rng ) { + return self::check_header_query_body_normalization( $rng ); + } + ); + $checks[] = self::run_check( + 'request-param-precedence', + 'Parameter precedence is deterministic across JSON, body, query, URL, and defaults; repeated get_params() calls are stable.', + function () use ( &$rng ) { + return self::check_request_param_precedence( $rng ); + } + ); + $checks[] = self::run_check( + 'json-body-parsing', + 'JSON bodies parse lazily; malformed JSON and invalid UTF-8 are represented as WP_Error values and never throw.', + function () use ( &$rng ) { + return self::check_json_body_parsing( $rng ); + } + ); + $checks[] = self::run_check( + 'schema-sanitize-validate', + 'Schema sanitization is idempotent on valid generated values, sanitized values validate, and enum/pattern/format constraints reject invalid values.', + function () use ( &$rng ) { + return self::check_schema_sanitize_validate( $rng ); + } + ); + $checks[] = self::run_check( + 'route-regex-matching', + 'Local WP_REST_Server route regexes extract exactly named captures; malformed paths and regexes produce represented no-route results.', + function () use ( &$rng ) { + return self::check_route_regex_matching( $rng ); + } + ); + $checks[] = self::run_check( + 'head-get-routing', + 'HEAD falls back to a GET handler only when no HEAD handler is registered; explicit HEAD handlers take precedence when ordered first.', + function () use ( &$rng ) { + return self::check_head_get_routing( $rng ); + } + ); + $checks[] = self::run_check( + 'permission-callback-semantics', + 'Permission callbacks deny only strict false/null or WP_Error; other falsey return values still allow the endpoint callback.', + function () use ( &$rng ) { + return self::check_permission_callback_semantics( $rng ); + } + ); + } finally { + self::remove_stateless_filters( $filters ); + } + + $failures = array(); + foreach ( $checks as $check ) { + if ( empty( $check['ok'] ) ) { + $failures[] = array( + 'check' => $check['name'], + 'name' => $check['name'], + 'message' => $check['message'] ?? 'Invariant failed.', + 'details' => $check['details'] ?? null, + ); + } + } + + return array( + 'schemaVersion' => 1, + 'kind' => 'component-fuzz-surface-result', + 'surface' => self::NAME, + 'ok' => array() === $failures, + 'status' => array() === $failures ? 'passed' : 'failed', + 'seed' => $seed, + 'caseCount' => count( $checks ), + 'checks' => $checks, + 'failures' => $failures, + 'features' => self::features_from_checks( $checks ), + 'durationMs' => self::duration_ms( $started_at ), + ); + } + + private static function missing_core_requirements(): array { + $classes = array( + 'WP_Error', + 'WP_HTTP_Response', + 'WP_Http', + 'WP_List_Util', + 'WP_REST_Request', + 'WP_REST_Response', + 'WP_REST_Server', + 'WP_User', + ); + $functions = array( + '__', + '_n', + '_wp_get_current_user', + 'absint', + 'apply_filters', + 'get_option', + 'has_filter', + 'is_email', + 'is_user_logged_in', + 'is_wp_error', + 'number_format_i18n', + 'rest_convert_error_to_response', + 'rest_ensure_response', + 'rest_sanitize_value_from_schema', + 'rest_validate_value_from_schema', + 'sanitize_hex_color', + 'sanitize_text_field', + 'sanitize_url', + 'trailingslashit', + 'wp_is_json_media_type', + 'wp_is_numeric_array', + 'wp_list_filter', + 'wp_parse_args', + 'wp_parse_list', + 'wp_set_current_user', + 'wp_sprintf_l', + ); + + $missing = array(); + foreach ( $classes as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class:' . $class; + } + } + foreach ( $functions as $function ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function:' . $function; + } + } + + return $missing; + } + + private static function check_method_normalization( array &$rng ): array { + $methods = array( + 'get', + 'Post', + 'PATCH', + 'HeAd', + 'options', + 'propfind', + "get\0with-control", + self::method_token( $rng ), + ); + + $cases = array(); + $ok = true; + foreach ( $methods as $method ) { + $request = new \WP_REST_Request( $method, '/cfuzz/v1/method' ); + $expected = strtoupper( $method ); + $observed = $request->get_method(); + $case_ok = $expected === $observed + && $request->is_method( $method ) + && $request->is_method( strtolower( $method ) ); + $ok = $ok && $case_ok; + $cases[] = array( + 'ok' => $case_ok, + 'input' => $method, + 'expectedMethod' => $expected, + 'observedMethod' => $observed, + 'isOriginal' => $request->is_method( $method ), + 'isLowercase' => $request->is_method( strtolower( $method ) ), + ); + } + + return array( + 'ok' => $ok, + 'message' => $ok ? 'All generated methods normalized as expected.' : 'At least one generated method did not normalize as expected.', + 'features' => array( 'methods', 'control-method-bytes' ), + 'details' => array( 'cases' => $cases ), + ); + } + + private static function check_header_query_body_normalization( array &$rng ): array { + $token = self::slug_token( $rng, 'tok' ); + + $header_request = new \WP_REST_Request( 'POST', '/cfuzz/v1/headers' ); + $header_request->set_headers( + array( + 'Content-Type' => 'Application/JSON; Charset=UTF-8', + 'X-Fuzz-Token' => array( 'alpha', 'beta' ), + 'X-Mixed-Header' => 'one', + ) + ); + $header_request->add_header( 'x_fuzz_token', 'gamma-' . $token ); + $header_request->add_header( 'x_mixed_header', array( 'two', 'three' ) ); + + $content_type = $header_request->get_content_type(); + $headers_ok = array( + 'contentTypeValue' => isset( $content_type['value'] ) && 'application/json' === $content_type['value'], + 'contentTypeType' => isset( $content_type['type'] ) && 'application' === $content_type['type'], + 'contentTypeSub' => isset( $content_type['subtype'] ) && 'json' === $content_type['subtype'], + 'jsonMediaType' => $header_request->is_json_content_type(), + 'fuzzHeader' => 'alpha,beta,gamma-' . $token === $header_request->get_header( 'X-Fuzz-Token' ), + 'fuzzHeaderArray' => array( 'alpha', 'beta', 'gamma-' . $token ) === $header_request->get_header_as_array( 'x-fuzz-token' ), + 'mixedHeader' => 'one,two,three' === $header_request->get_header( 'X_Mixed_Header' ), + 'canonicalName' => 'x_fuzz_token' === \WP_REST_Request::canonicalize_header_name( 'X-Fuzz-Token' ), + ); + + $form_request = new \WP_REST_Request( 'PUT', '/cfuzz/v1/normalize' ); + $form_request->set_query_params( + array( + 'same' => 'query', + 'queryOnly' => 'q-' . $token, + 'rest_route' => '/cfuzz/v1/normalize', + ) + ); + $form_request->set_url_params( + array( + 'same' => 'url', + 'routeOnly' => 'route-' . $token, + ) + ); + $form_request->set_default_params( + array( + 'same' => 'default', + 'defaultOnly' => 'default-' . $token, + ) + ); + $form_request->set_body_params( + array( + 'same' => 'manual-body', + 'bodyOnly' => 'manual-' . $token, + ) + ); + $form_request->set_file_params( + array( + 'upload' => array( + 'name' => 'fuzz-' . $token . '.txt', + 'type' => 'text/plain', + 'size' => self::rng_int( $rng, 1, 2048 ), + 'error' => 0, + ), + ) + ); + $form_request->set_header( 'Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8' ); + $form_request->set_body( 'same=parsed-body&bodyOnly=parsed&from_body=1&list%5B0%5D=a&list%5B1%5D=b' ); + + $body_before = $form_request->get_body_params(); + $params_one = $form_request->get_params(); + $params_two = $form_request->get_params(); + $body_after = $form_request->get_body_params(); + $files = $form_request->get_file_params(); + + $params_ok = array( + 'repeatedStable' => $params_one === $params_two, + 'manualBodyPrecedes' => isset( $params_one['same'] ) && 'manual-body' === $params_one['same'], + 'manualBodyPreserved' => isset( $params_one['bodyOnly'] ) && 'manual-' . $token === $params_one['bodyOnly'], + 'parsedBodyAdded' => isset( $params_one['from_body'] ) && '1' === $params_one['from_body'], + 'parsedListAdded' => isset( $params_one['list'] ) && array( 'a', 'b' ) === $params_one['list'], + 'queryPreserved' => isset( $params_one['queryOnly'] ) && 'q-' . $token === $params_one['queryOnly'], + 'urlPreserved' => isset( $params_one['routeOnly'] ) && 'route-' . $token === $params_one['routeOnly'], + 'defaultPreserved' => isset( $params_one['defaultOnly'] ) && 'default-' . $token === $params_one['defaultOnly'], + 'restRouteRemoved' => ! array_key_exists( 'rest_route', $params_one ), + 'bodyLazyParsed' => ! array_key_exists( 'from_body', $body_before ) && isset( $body_after['from_body'] ), + 'fileParamsStable' => isset( $files['upload']['name'] ) && 'fuzz-' . $token . '.txt' === $files['upload']['name'], + ); + + $ok = self::all_true( $headers_ok ) && self::all_true( $params_ok ); + + return array( + 'ok' => $ok, + 'message' => $ok ? 'Header and body/query normalization stayed stable.' : 'Header or body/query normalization drifted.', + 'features' => array( 'headers', 'urlencoded-body', 'files', 'rest-route-query-normalization' ), + 'details' => array( + 'headers' => array( + 'ok' => $headers_ok, + 'contentType' => $content_type, + 'allHeaders' => $header_request->get_headers(), + ), + 'params' => array( + 'ok' => $params_ok, + 'bodyBefore' => $body_before, + 'bodyAfter' => $body_after, + 'params' => $params_one, + 'files' => $files, + ), + ), + ); + } + + private static function check_request_param_precedence( array &$rng ): array { + $cases = array( + array( + 'label' => 'post-json', + 'method' => 'POST', + 'contentType' => 'application/json', + 'body' => self::json_encode_for_body( + array( + 'same' => 'json', + 'jsonOnly' => self::slug_token( $rng, 'json' ), + 'nullish' => null, + ) + ), + 'expectedSame' => 'json', + 'expectNull' => true, + ), + array( + 'label' => 'delete-json', + 'method' => 'DELETE', + 'contentType' => 'application/vnd.api+json', + 'body' => self::json_encode_for_body( array( 'same' => 'json-delete' ) ), + 'expectedSame' => 'json-delete', + ), + array( + 'label' => 'post-non-json', + 'method' => 'POST', + 'contentType' => 'text/plain', + 'body' => '{"same":"ignored-json"}', + 'expectedSame' => 'body', + ), + array( + 'label' => 'put-urlencoded', + 'method' => 'PUT', + 'contentType' => 'application/x-www-form-urlencoded', + 'body' => 'same=parsed-body&parsedOnly=yes', + 'expectedSame' => 'body', + 'parsedBody' => true, + ), + array( + 'label' => 'get-urlencoded-body', + 'method' => 'GET', + 'contentType' => 'application/x-www-form-urlencoded', + 'body' => 'same=parsed-body&parsedOnly=yes', + 'expectedSame' => 'query', + 'parsedBody' => true, + ), + array( + 'label' => 'get-json-body', + 'method' => 'GET', + 'contentType' => 'application/json', + 'body' => self::json_encode_for_body( array( 'same' => 'json-get' ) ), + 'expectedSame' => 'json-get', + ), + ); + + $results = array(); + $ok = true; + foreach ( $cases as $case ) { + $request = new \WP_REST_Request( $case['method'], '/cfuzz/v1/precedence' ); + $request->set_default_params( + array( + 'same' => 'default', + 'defaultOnly' => 'default', + ) + ); + $request->set_url_params( + array( + 'same' => 'url', + 'urlOnly' => 'url', + ) + ); + $request->set_query_params( + array( + 'same' => 'query', + 'queryOnly' => 'query', + 'rest_route' => '/cfuzz/v1/precedence', + ) + ); + $request->set_body_params( + array( + 'same' => 'body', + 'bodyOnly' => 'body', + ) + ); + $request->set_header( 'Content-Type', $case['contentType'] ); + $request->set_body( $case['body'] ); + + $params_one = $request->get_params(); + $params_two = $request->get_params(); + $body_params = $request->get_body_params(); + $case_ok = $case['expectedSame'] === $request->get_param( 'same' ) + && $params_one === $params_two + && isset( $params_one['same'] ) + && $case['expectedSame'] === $params_one['same'] + && ! array_key_exists( 'rest_route', $params_one ); + + if ( ! empty( $case['expectNull'] ) ) { + $case_ok = $case_ok + && $request->has_param( 'nullish' ) + && null === $request->get_param( 'nullish' ) + && ! isset( $request['nullish'] ); + } + if ( ! empty( $case['parsedBody'] ) ) { + $case_ok = $case_ok && isset( $body_params['parsedOnly'] ) && 'yes' === $body_params['parsedOnly']; + } + + $ok = $ok && $case_ok; + $results[] = array( + 'ok' => $case_ok, + 'label' => $case['label'], + 'method' => $case['method'], + 'contentType' => $case['contentType'], + 'expectedSame' => $case['expectedSame'], + 'observedSame' => $request->get_param( 'same' ), + 'params' => $params_one, + 'bodyParams' => $body_params, + 'hasNullish' => ! empty( $case['expectNull'] ) ? $request->has_param( 'nullish' ) : null, + 'issetNullish' => ! empty( $case['expectNull'] ) ? isset( $request['nullish'] ) : null, + 'repeatedStable' => $params_one === $params_two, + ); + } + + return array( + 'ok' => $ok, + 'message' => $ok ? 'Request parameter precedence matched expected order for all generated cases.' : 'Request parameter precedence mismatch.', + 'features' => array( 'param-precedence', 'json-precedence', 'body-precedence', 'null-parameter-presence' ), + 'details' => array( 'cases' => $results ), + ); + } + + private static function check_json_body_parsing( array &$rng ): array { + $valid_token = self::slug_token( $rng, 'json' ); + $cases = array( + array( + 'label' => 'valid-object', + 'contentType' => 'application/json', + 'body' => self::json_encode_for_body( + array( + 'value' => $valid_token, + 'control' => "line\0end", + ) + ), + 'expect' => 'valid', + ), + array( + 'label' => 'invalid-syntax', + 'contentType' => 'application/json', + 'body' => '{"value":', + 'expect' => 'error', + ), + array( + 'label' => 'invalid-utf8', + 'contentType' => 'application/json', + 'body' => "{\"value\":\"\xC3\x28\"}", + 'expect' => 'error', + ), + array( + 'label' => 'non-json-content-type', + 'contentType' => 'text/plain', + 'body' => '{"value":', + 'expect' => 'not-json', + ), + ); + + $results = array(); + $ok = true; + foreach ( $cases as $case ) { + $request = new \WP_REST_Request( 'POST', '/cfuzz/v1/json' ); + $request->set_header( 'Content-Type', $case['contentType'] ); + $request->set_body( $case['body'] ); + $request->set_attributes( array( 'args' => array() ) ); + + $valid_one = $request->has_valid_params(); + $valid_two = $request->has_valid_params(); + $json = $request->get_json_params(); + $case_ok = false; + + if ( 'valid' === $case['expect'] ) { + $case_ok = true === $valid_one + && true === $valid_two + && is_array( $json ) + && isset( $json['value'] ) + && $valid_token === $json['value'] + && isset( $json['control'] ) + && "line\0end" === $json['control']; + } elseif ( 'error' === $case['expect'] ) { + $error_one = self::wp_error_summary( $valid_one ); + $error_two = self::wp_error_summary( $valid_two ); + $case_ok = null !== $error_one + && null !== $error_two + && 'rest_invalid_json' === $error_one['code'] + && 'rest_invalid_json' === $error_two['code'] + && $error_one['data'] === $error_two['data']; + } else { + $case_ok = true === $valid_one && true === $valid_two && null === $json; + } + + $ok = $ok && $case_ok; + $results[] = array( + 'ok' => $case_ok, + 'label' => $case['label'], + 'contentType' => $case['contentType'], + 'expect' => $case['expect'], + 'validOne' => self::result_summary( $valid_one ), + 'validTwo' => self::result_summary( $valid_two ), + 'jsonParams' => $json, + 'bodyPreview' => self::byte_preview( $case['body'] ), + ); + } + + return array( + 'ok' => $ok, + 'message' => $ok ? 'JSON parsing represented all valid and invalid cases correctly.' : 'JSON parsing result drifted.', + 'features' => array( 'json', 'invalid-json', 'invalid-utf8', 'control-json-string' ), + 'details' => array( 'cases' => $results ), + ); + } + + private static function check_schema_sanitize_validate( array &$rng ): array { + $enum_a = self::slug_token( $rng, 'red' ); + $enum_b = self::slug_token( $rng, 'green' ); + $cases = array( + array( + 'label' => 'string-enum', + 'schema' => array( + 'type' => 'string', + 'enum' => array( $enum_a, $enum_b ), + ), + 'valid' => $enum_b, + 'invalid' => 'outside-' . $enum_b, + 'constraint' => 'enum', + ), + array( + 'label' => 'string-pattern', + 'schema' => array( + 'type' => 'string', + 'pattern' => '^[a-z]{2}[0-9]{2}$', + ), + 'valid' => 'ab12', + 'invalid' => 'ab!', + 'constraint' => 'pattern', + ), + array( + 'label' => 'invalid-utf8-pattern', + 'schema' => array( + 'type' => 'string', + 'pattern' => '^valid$', + ), + 'valid' => 'valid', + 'invalid' => "\xC3\x28", + 'constraint' => 'pattern', + ), + array( + 'label' => 'integer-bounds-multiple', + 'schema' => array( + 'type' => 'integer', + 'minimum' => -10, + 'maximum' => 10, + 'multipleOf' => 2, + ), + 'valid' => '4', + 'invalid' => 5, + 'constraint' => 'multipleOf', + ), + array( + 'label' => 'boolean', + 'schema' => array( 'type' => 'boolean' ), + 'valid' => 'false', + 'invalid' => 'maybe', + 'constraint' => 'type', + ), + array( + 'label' => 'array-items', + 'schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'minItems' => 1, + 'maxItems' => 4, + 'uniqueItems' => true, + ), + 'valid' => array( '1', 2, '3' ), + 'invalid' => array( 1, 2, 3, 4, 5 ), + 'constraint' => 'maxItems', + ), + array( + 'label' => 'object-properties-pattern', + 'schema' => array( + 'type' => 'object', + 'required' => array( 'name' ), + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'pattern' => '^[a-z]+$', + ), + ), + 'patternProperties' => array( + '^x_[a-z]+$' => array( 'type' => 'boolean' ), + ), + 'additionalProperties' => false, + ), + 'valid' => array( + 'name' => 'alice', + 'x_enabled' => 'true', + ), + 'invalid' => array( + 'name' => 'alice', + 'extra' => 'forbidden', + ), + 'constraint' => 'additionalProperties', + ), + array( + 'label' => 'date-time-format', + 'schema' => array( + 'type' => 'string', + 'format' => 'date-time', + ), + 'valid' => '2026-06-19T12:34:56Z', + 'invalid' => 'not-a-date', + 'constraint' => 'format', + ), + array( + 'label' => 'hex-color-format', + 'schema' => array( + 'type' => 'string', + 'format' => 'hex-color', + ), + 'valid' => '#aabbcc', + 'invalid' => 'blue', + 'constraint' => 'format', + ), + ); + + $results = array(); + $ok = true; + foreach ( $cases as $i => $case ) { + $param = 'cfuzz_schema_' . $i; + $raw_valid = \rest_validate_value_from_schema( $case['valid'], $case['schema'], $param ); + $sanitized = \rest_sanitize_value_from_schema( $case['valid'], $case['schema'], $param ); + $sanitize_error = self::wp_error_summary( $sanitized ); + $sanitized_valid = null; + $sanitized_again = null; + $sanitized_stable = false; + + if ( null === $sanitize_error ) { + $sanitized_valid = \rest_validate_value_from_schema( $sanitized, $case['schema'], $param ); + $sanitized_again = \rest_sanitize_value_from_schema( $sanitized, $case['schema'], $param ); + $sanitized_stable = self::values_equal( $sanitized, $sanitized_again ); + } + + $invalid_validation = \rest_validate_value_from_schema( $case['invalid'], $case['schema'], $param ); + $invalid_sanitized = \rest_sanitize_value_from_schema( $case['invalid'], $case['schema'], $param ); + $invalid_after_sanitize_validation = self::wp_error_summary( $invalid_sanitized ) + ? null + : \rest_validate_value_from_schema( $invalid_sanitized, $case['schema'], $param ); + + $raw_valid_ok = true === $raw_valid; + $sanitized_valid_ok = true === $sanitized_valid; + $invalid_rejected = null !== self::wp_error_summary( $invalid_validation ); + $constraint_rechecked = true; + if ( in_array( $case['constraint'], array( 'enum', 'pattern', 'format' ), true ) ) { + $constraint_rechecked = null !== self::wp_error_summary( $invalid_after_sanitize_validation ); + } + + $case_ok = $raw_valid_ok + && null === $sanitize_error + && $sanitized_valid_ok + && $sanitized_stable + && $invalid_rejected + && $constraint_rechecked; + + $ok = $ok && $case_ok; + $results[] = array( + 'ok' => $case_ok, + 'label' => $case['label'], + 'constraint' => $case['constraint'], + 'schema' => $case['schema'], + 'valid' => $case['valid'], + 'sanitizedValid' => self::result_summary( $sanitized_valid ), + 'sanitized' => $sanitized, + 'sanitizedAgain' => $sanitized_again, + 'sanitizedStable' => $sanitized_stable, + 'invalid' => $case['invalid'], + 'invalidValidation' => self::result_summary( $invalid_validation ), + 'invalidAfterSanitizeValidation' => self::result_summary( $invalid_after_sanitize_validation ), + ); + } + + return array( + 'ok' => $ok, + 'message' => $ok ? 'Schema sanitize/validate invariants held for generated schemas.' : 'Schema sanitize/validate invariant failed.', + 'features' => array( 'schema', 'enum', 'pattern', 'format', 'array-schema', 'object-schema', 'invalid-utf8-schema' ), + 'details' => array( 'cases' => $results ), + ); + } + + private static function check_route_regex_matching( array &$rng ): array { + $namespace = self::namespace_token( $rng ); + $id = (string) self::rng_int( $rng, 10, 9999 ); + $slug = self::slug_token( $rng, 'item' ); + $route = '/' . $namespace . '/thing/(?P[0-9]+)/(?P[a-z][a-z0-9-]*)'; + $path = '/' . $namespace . '/thing/' . $id . '/' . $slug; + $hits = array(); + $server = new \WP_REST_Server(); + + $server->register_route( + $namespace, + $route, + array( + array( + 'methods' => 'GET, POST', + 'permission_callback' => function () { + return true; + }, + 'callback' => function ( $request ) use ( &$hits ) { + $url_params = $request->get_url_params(); + $hits[] = $url_params; + return array( + 'method' => $request->get_method(), + 'urlParams' => $url_params, + ); + }, + 'args' => array( + 'defaulted' => array( 'default' => 'route-default' ), + ), + ), + ) + ); + + $request = new \WP_REST_Request( 'GET', $path ); + $response = $server->dispatch( $request ); + $data = $response->get_data(); + + $expected_params = array( + 'id' => $id, + 'slug' => $slug, + ); + $match_ok = 200 === $response->get_status() + && $route === $response->get_matched_route() + && isset( $data['urlParams'] ) + && $expected_params === $data['urlParams'] + && array( 'id', 'slug' ) === array_keys( $data['urlParams'] ) + && $expected_params === $request->get_url_params() + && array( $expected_params ) === $hits; + + $malformed_path = '/' . $namespace . "/thing/\xC3\x28/" . str_repeat( 'a', self::rng_int( $rng, 8, 32 ) ); + $bad_request = new \WP_REST_Request( 'GET', $malformed_path ); + $bad_response = $server->dispatch( $bad_request ); + $bad_data = $bad_response->get_data(); + $path_ok = 404 === $bad_response->get_status() + && is_array( $bad_data ) + && isset( $bad_data['code'] ) + && 'rest_no_route' === $bad_data['code']; + + $bad_regex_server = new \WP_REST_Server(); + $bad_regex_route = '/' . $namespace . '/broken/(?P['; + $bad_regex_server->register_route( + $namespace, + $bad_regex_route, + array( + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return true; + }, + 'callback' => function () { + return array( 'unexpected' => true ); + }, + ), + ) + ); + $bad_regex_response = $bad_regex_server->dispatch( new \WP_REST_Request( 'GET', '/' . $namespace . '/broken/x' ) ); + $bad_regex_data = $bad_regex_response->get_data(); + $bad_regex_ok = 404 === $bad_regex_response->get_status() + && is_array( $bad_regex_data ) + && isset( $bad_regex_data['code'] ) + && 'rest_no_route' === $bad_regex_data['code']; + + $ok = $match_ok && $path_ok && $bad_regex_ok; + + return array( + 'ok' => $ok, + 'message' => $ok ? 'Route matching and malformed route/path handling stayed stable.' : 'Route matching or malformed route/path handling drifted.', + 'features' => array( 'routes', 'named-captures', 'malformed-path', 'malformed-route-regex' ), + 'details' => array( + 'namespace' => $namespace, + 'route' => $route, + 'path' => $path, + 'match' => array( + 'ok' => $match_ok, + 'response' => self::response_summary( $response ), + 'requestUrlParams' => $request->get_url_params(), + 'expectedParams' => $expected_params, + 'hits' => $hits, + ), + 'badPath' => array( + 'ok' => $path_ok, + 'path' => $malformed_path, + 'response' => self::response_summary( $bad_response ), + ), + 'badRegex' => array( + 'ok' => $bad_regex_ok, + 'route' => $bad_regex_route, + 'response' => self::response_summary( $bad_regex_response ), + ), + ), + ); + } + + private static function check_head_get_routing( array &$rng ): array { + $namespace = self::namespace_token( $rng ); + $server = new \WP_REST_Server(); + $get_hits = 0; + $head_hits = 0; + + $fallback_route = '/' . $namespace . '/head-fallback/(?P[0-9]+)'; + $server->register_route( + $namespace, + $fallback_route, + array( + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return true; + }, + 'callback' => function ( $request ) use ( &$get_hits ) { + ++$get_hits; + return array( + 'handler' => 'get-fallback', + 'requestMethod' => $request->get_method(), + 'urlParams' => $request->get_url_params(), + ); + }, + ), + ) + ); + + $explicit_route = '/' . $namespace . '/head-explicit'; + $server->register_route( + $namespace, + $explicit_route, + array( + array( + 'methods' => 'HEAD', + 'permission_callback' => function () { + return true; + }, + 'callback' => function ( $request ) use ( &$head_hits ) { + ++$head_hits; + return array( + 'handler' => 'head-explicit', + 'requestMethod' => $request->get_method(), + ); + }, + ), + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return true; + }, + 'callback' => function ( $request ) use ( &$get_hits ) { + ++$get_hits; + return array( + 'handler' => 'get-explicit-route', + 'requestMethod' => $request->get_method(), + ); + }, + ), + ) + ); + + $fallback_response = $server->dispatch( new \WP_REST_Request( 'HEAD', '/' . $namespace . '/head-fallback/42' ) ); + $explicit_head = $server->dispatch( new \WP_REST_Request( 'HEAD', '/' . $namespace . '/head-explicit' ) ); + $explicit_get = $server->dispatch( new \WP_REST_Request( 'GET', '/' . $namespace . '/head-explicit' ) ); + + $fallback_data = $fallback_response->get_data(); + $head_data = $explicit_head->get_data(); + $get_data = $explicit_get->get_data(); + + $fallback_ok = 200 === $fallback_response->get_status() + && is_array( $fallback_data ) + && 'get-fallback' === ( $fallback_data['handler'] ?? null ) + && 'HEAD' === ( $fallback_data['requestMethod'] ?? null ) + && array( 'id' => '42' ) === ( $fallback_data['urlParams'] ?? null ); + $explicit_ok = 200 === $explicit_head->get_status() + && 200 === $explicit_get->get_status() + && is_array( $head_data ) + && is_array( $get_data ) + && 'head-explicit' === ( $head_data['handler'] ?? null ) + && 'HEAD' === ( $head_data['requestMethod'] ?? null ) + && 'get-explicit-route' === ( $get_data['handler'] ?? null ) + && 'GET' === ( $get_data['requestMethod'] ?? null ); + $hits_ok = 2 === $get_hits && 1 === $head_hits; + $ok = $fallback_ok && $explicit_ok && $hits_ok; + + return array( + 'ok' => $ok, + 'message' => $ok ? 'HEAD/GET route behavior matched documented fallback rules.' : 'HEAD/GET route behavior drifted.', + 'features' => array( 'head-routing', 'get-fallback' ), + 'details' => array( + 'namespace' => $namespace, + 'fallback' => array( + 'ok' => $fallback_ok, + 'response' => self::response_summary( $fallback_response ), + ), + 'explicit' => array( + 'ok' => $explicit_ok, + 'headResponse' => self::response_summary( $explicit_head ), + 'getResponse' => self::response_summary( $explicit_get ), + ), + 'hits' => array( + 'ok' => $hits_ok, + 'getHits' => $get_hits, + 'headHits' => $head_hits, + ), + ), + ); + } + + private static function check_permission_callback_semantics( array &$rng ): array { + $namespace = self::namespace_token( $rng ); + $server = new \WP_REST_Server(); + $cases = array( + array( + 'label' => 'true', + 'factory' => function () { + return true; + }, + 'allowed' => true, + ), + array( + 'label' => 'zero', + 'factory' => function () { + return 0; + }, + 'allowed' => true, + ), + array( + 'label' => 'empty-string', + 'factory' => function () { + return ''; + }, + 'allowed' => true, + ), + array( + 'label' => 'false', + 'factory' => function () { + return false; + }, + 'allowed' => false, + 'code' => 'rest_forbidden', + ), + array( + 'label' => 'null', + 'factory' => function () { + return null; + }, + 'allowed' => false, + 'code' => 'rest_forbidden', + ), + array( + 'label' => 'wp-error', + 'factory' => function () { + return new \WP_Error( 'cfuzz_permission_denied', 'Denied by component fuzz permission callback.', array( 'status' => 418 ) ); + }, + 'allowed' => false, + 'code' => 'cfuzz_permission_denied', + 'status' => 418, + ), + ); + + $results = array(); + $ok = true; + foreach ( $cases as $case ) { + $route = '/' . $namespace . '/permission/' . $case['label']; + $permission_hits = 0; + $callback_hits = 0; + $factory = $case['factory']; + $label = $case['label']; + + $server->register_route( + $namespace, + $route, + array( + array( + 'methods' => 'GET', + 'permission_callback' => function () use ( &$permission_hits, $factory ) { + ++$permission_hits; + return $factory(); + }, + 'callback' => function () use ( &$callback_hits, $label ) { + ++$callback_hits; + return array( + 'case' => $label, + 'ok' => true, + ); + }, + ), + ) + ); + + $response = $server->dispatch( new \WP_REST_Request( 'GET', $route ) ); + $data = $response->get_data(); + if ( ! empty( $case['allowed'] ) ) { + $case_ok = 200 === $response->get_status() + && 1 === $permission_hits + && 1 === $callback_hits + && is_array( $data ) + && $label === ( $data['case'] ?? null ); + } else { + $expected_status = isset( $case['status'] ) ? $case['status'] : null; + $status_ok = null === $expected_status + ? in_array( $response->get_status(), array( 401, 403 ), true ) + : $expected_status === $response->get_status(); + $case_ok = $status_ok + && 1 === $permission_hits + && 0 === $callback_hits + && is_array( $data ) + && isset( $data['code'] ) + && $case['code'] === $data['code']; + } + + $ok = $ok && $case_ok; + $results[] = array( + 'ok' => $case_ok, + 'label' => $label, + 'allowed' => $case['allowed'], + 'permissionHits' => $permission_hits, + 'callbackHits' => $callback_hits, + 'response' => self::response_summary( $response ), + ); + } + + return array( + 'ok' => $ok, + 'message' => $ok ? 'Permission callback strictness matched REST server semantics.' : 'Permission callback semantics drifted.', + 'features' => array( 'permissions', 'wp-error-permission', 'falsey-permission-values' ), + 'details' => array( + 'namespace' => $namespace, + 'cases' => $results, + ), + ); + } + + private static function run_check( string $name, string $invariant, callable $callback ): array { + $php_errors = array(); + set_error_handler( + function ( $severity, $message, $file, $line ) use ( &$php_errors ) { + $php_errors[] = array( + 'severity' => $severity, + 'message' => $message, + 'file' => basename( (string) $file ), + 'line' => $line, + ); + return true; + } + ); + + try { + $result = call_user_func( $callback ); + if ( ! is_array( $result ) ) { + $result = array( + 'ok' => false, + 'message' => 'Check did not return an array result.', + 'details' => array( 'type' => gettype( $result ) ), + ); + } + } catch ( \Throwable $e ) { + $result = array( + 'ok' => false, + 'message' => 'Check threw ' . get_class( $e ) . ': ' . $e->getMessage(), + 'details' => array( + 'throwable' => get_class( $e ), + 'file' => basename( $e->getFile() ), + 'line' => $e->getLine(), + ), + ); + } finally { + restore_error_handler(); + } + + $result['name'] = $name; + $result['invariant'] = $invariant; + if ( array() !== $php_errors ) { + $result['phpErrors'] = $php_errors; + } + + return self::safe_value( $result ); + } + + private static function install_stateless_filters(): array { + if ( ! function_exists( 'add_filter' ) ) { + return array(); + } + + $filters = array(); + self::add_temp_filter( + $filters, + 'pre_option_permalink_structure', + function () { + return ''; + } + ); + self::add_temp_filter( + $filters, + 'pre_option_home', + function () { + return 'http://component-fuzz.test'; + } + ); + self::add_temp_filter( + $filters, + 'pre_option_siteurl', + function () { + return 'http://component-fuzz.test'; + } + ); + self::add_temp_filter( + $filters, + 'pre_option_blog_charset', + function () { + return 'UTF-8'; + } + ); + self::add_temp_filter( + $filters, + 'determine_current_user', + function () { + return 0; + } + ); + if ( function_exists( 'wp_sprintf_l' ) && ( ! function_exists( 'has_filter' ) || false === has_filter( 'wp_sprintf', 'wp_sprintf_l' ) ) ) { + self::add_temp_filter( $filters, 'wp_sprintf', 'wp_sprintf_l', 10, 2 ); + } + + return $filters; + } + + private static function add_temp_filter( array &$filters, string $hook, callable $callback, int $priority = 999, int $accepted_args = 1 ): void { + add_filter( $hook, $callback, $priority, $accepted_args ); + $filters[] = array( + 'hook' => $hook, + 'callback' => $callback, + 'priority' => $priority, + ); + } + + private static function remove_stateless_filters( array $filters ): void { + if ( ! function_exists( 'remove_filter' ) ) { + return; + } + + for ( $i = count( $filters ) - 1; $i >= 0; --$i ) { + remove_filter( $filters[ $i ]['hook'], $filters[ $i ]['callback'], $filters[ $i ]['priority'] ); + } + } + + private static function maybe_load_optional_core_support(): void { + if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) { + return; + } + + $base = rtrim( (string) ABSPATH, '/\\' ) . DIRECTORY_SEPARATOR . trim( (string) WPINC, '/\\' ) . DIRECTORY_SEPARATOR; + $map = array( + 'WP_Http' => 'class-wp-http.php', + 'WP_List_Util' => 'class-wp-list-util.php', + 'WP_User' => 'class-wp-user.php', + ); + + foreach ( $map as $class => $relative ) { + $path = $base . $relative; + if ( ! class_exists( $class ) && is_readable( $path ) ) { + require_once $path; + } + } + if ( ! function_exists( '_wp_get_current_user' ) ) { + $user_path = $base . 'user.php'; + if ( is_readable( $user_path ) ) { + require_once $user_path; + } + } + } + + private static function seed_from_context( $ctx ): int { + foreach ( array( 'seed', 'getSeed', 'get_seed' ) as $method ) { + if ( ! method_exists( $ctx, $method ) ) { + continue; + } + try { + $reflection = new \ReflectionMethod( $ctx, $method ); + if ( $reflection->isPublic() && 0 === $reflection->getNumberOfRequiredParameters() ) { + $value = $ctx->{$method}(); + if ( is_int( $value ) || is_numeric( $value ) ) { + return (int) $value; + } + } + } catch ( \Throwable $e ) { + continue; + } + } + + foreach ( get_object_vars( $ctx ) as $key => $value ) { + if ( in_array( $key, array( 'seed', 'caseSeed', 'iteration' ), true ) && ( is_int( $value ) || is_numeric( $value ) ) ) { + return (int) $value; + } + } + + return 1; + } + + private static function rng( $seed ): array { + return array( + 'seed' => (string) $seed, + 'counter' => 0, + 'buffer' => '', + ); + } + + private static function rng_bytes( array &$rng, int $length ): string { + while ( strlen( $rng['buffer'] ) < $length ) { + $rng['buffer'] .= hash( 'sha256', $rng['seed'] . ':' . $rng['counter'], true ); + ++$rng['counter']; + } + + $out = substr( $rng['buffer'], 0, $length ); + $rng['buffer'] = substr( $rng['buffer'], $length ); + return $out; + } + + private static function rng_uint32( array &$rng ): int { + $parts = unpack( 'Nvalue', self::rng_bytes( $rng, 4 ) ); + return (int) $parts['value']; + } + + private static function rng_int( array &$rng, int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + + return $min + ( self::rng_uint32( $rng ) % ( $max - $min + 1 ) ); + } + + private static function slug_token( array &$rng, string $prefix ): string { + $alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + $out = strtolower( preg_replace( '/[^a-z0-9]+/', '-', $prefix ) ); + $out = trim( $out, '-' ); + if ( '' === $out || ! ctype_alpha( $out[0] ) ) { + $out = 'x' . $out; + } + $out .= '-'; + for ( $i = 0; $i < 6; ++$i ) { + $out .= $alphabet[ self::rng_int( $rng, 0, strlen( $alphabet ) - 1 ) ]; + } + + return $out; + } + + private static function namespace_token( array &$rng ): string { + return 'cfuzz-rest/v' . self::rng_int( $rng, 1, 9 ) . '-' . self::slug_token( $rng, 'ns' ); + } + + private static function method_token( array &$rng ): string { + $methods = array( 'mkcol', 'copy', 'move', 'lock', 'unlock', 'purge' ); + return $methods[ self::rng_int( $rng, 0, count( $methods ) - 1 ) ]; + } + + private static function json_encode_for_body( $value ): string { + $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); + return false === $json ? 'null' : $json; + } + + private static function response_summary( $response ): array { + if ( ! is_object( $response ) ) { + return array( 'type' => gettype( $response ) ); + } + + $summary = array( + 'class' => get_class( $response ), + ); + if ( method_exists( $response, 'get_status' ) ) { + $summary['status'] = $response->get_status(); + } + if ( method_exists( $response, 'get_data' ) ) { + $summary['data'] = $response->get_data(); + } + if ( method_exists( $response, 'get_matched_route' ) ) { + $summary['matchedRoute'] = $response->get_matched_route(); + } + + return $summary; + } + + private static function result_summary( $value ) { + $error = self::wp_error_summary( $value ); + if ( null !== $error ) { + return $error; + } + if ( true === $value ) { + return true; + } + if ( null === $value ) { + return null; + } + + return $value; + } + + private static function wp_error_summary( $value ): ?array { + if ( ! function_exists( 'is_wp_error' ) || ! \is_wp_error( $value ) ) { + return null; + } + + $codes = $value->get_error_codes(); + $code = $codes ? $codes[0] : ''; + return array( + 'code' => $code, + 'message' => $code ? $value->get_error_message( $code ) : $value->get_error_message(), + 'data' => $code ? $value->get_error_data( $code ) : null, + 'allCodes' => $codes, + ); + } + + private static function values_equal( $a, $b ): bool { + return serialize( self::canonical_value( $a ) ) === serialize( self::canonical_value( $b ) ); + } + + private static function canonical_value( $value ) { + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::canonical_value( $item ); + } + return $out; + } + if ( is_object( $value ) ) { + return array( + '__class' => get_class( $value ), + 'vars' => self::canonical_value( get_object_vars( $value ) ), + ); + } + + return $value; + } + + private static function all_true( array $values ): bool { + foreach ( $values as $value ) { + if ( true !== $value ) { + return false; + } + } + + return true; + } + + private static function features_from_checks( array $checks ): array { + $features = array(); + foreach ( $checks as $check ) { + foreach ( (array) ( $check['features'] ?? array() ) as $feature ) { + $features[ $feature ] = true; + } + if ( ! empty( $check['phpErrors'] ) ) { + $features['captured-php-errors'] = true; + } + } + + $features = array_keys( $features ); + sort( $features ); + return $features; + } + + private static function duration_ms( float $started_at ): int { + return (int) round( ( microtime( true ) - $started_at ) * 1000 ); + } + + private static function byte_preview( string $bytes ): array { + $slice = substr( $bytes, 0, 120 ); + return array( + 'length' => strlen( $bytes ), + 'sha1' => sha1( $bytes ), + 'utf8' => self::is_valid_utf8( $bytes ), + 'text' => self::is_valid_utf8( $slice ) ? $slice : null, + 'base64' => self::is_valid_utf8( $slice ) ? null : base64_encode( $slice ), + ); + } + + private static function safe_value( $value, int $depth = 0 ) { + if ( $depth > 12 ) { + return array( '__truncated' => 'max-depth' ); + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $safe_key = is_string( $key ) && ! self::is_valid_utf8( $key ) ? 'base64-key:' . base64_encode( $key ) : $key; + $out[ $safe_key ] = self::safe_value( $item, $depth + 1 ); + } + return $out; + } + + if ( is_string( $value ) ) { + if ( self::is_valid_utf8( $value ) ) { + if ( strlen( $value ) <= 240 ) { + return $value; + } + return array( + '__string' => true, + 'length' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => substr( $value, 0, 240 ), + ); + } + + return array( + '__bytes' => true, + 'length' => strlen( $value ), + 'sha1' => sha1( $value ), + 'base64' => base64_encode( $value ), + ); + } + + $error = self::wp_error_summary( $value ); + if ( null !== $error ) { + return $error; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Closure ) { + return array( '__object' => 'Closure' ); + } + return array( + '__object' => get_class( $value ), + 'vars' => self::safe_value( get_object_vars( $value ), $depth + 1 ), + ); + } + + if ( is_resource( $value ) ) { + return array( '__resource' => get_resource_type( $value ) ); + } + + return $value; + } + + private static function is_valid_utf8( string $value ): bool { + return 1 === preg_match( '//u', $value ); + } +} diff --git a/tools/component-fuzz/tests/bootstrap-smoke.php b/tools/component-fuzz/tests/bootstrap-smoke.php new file mode 100644 index 0000000000000..b120a20d668f4 --- /dev/null +++ b/tools/component-fuzz/tests/bootstrap-smoke.php @@ -0,0 +1,88 @@ +alert(1)

ok

' ); +if ( false !== stripos( $sample_html, 'set_query_params( array( 'id' => 'query' ) ); +$request->set_body_params( array( 'id' => 'body' ) ); +if ( 'body' !== $request->get_param( 'id' ) ) { + fwrite( STDERR, "REST request precedence smoke invariant failed.\n" ); + exit( 1 ); +} + +$blocks = parse_blocks( '

Hello

' ); +if ( 1 !== count( $blocks ) || 'core/paragraph' !== $blocks[0]['blockName'] ) { + fwrite( STDERR, "Block parser smoke invariant failed.\n" ); + exit( 1 ); +} + +$hash = wp_hash_password( 'component-fuzz' ); +if ( ! wp_check_password( 'component-fuzz', $hash ) ) { + fwrite( STDERR, "Password hash smoke invariant failed.\n" ); + exit( 1 ); +} + +fwrite( STDOUT, "component-fuzz bootstrap smoke passed\n" ); + From ccdc51a9256856bd2097911107508ecd792747ef Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Sat, 20 Jun 2026 20:46:39 +0200 Subject: [PATCH 0002/1102] Fix component fuzz harness review findings --- tools/component-fuzz/lib/FuzzContext.php | 16 ++++++-- tools/component-fuzz/lib/SurfaceRunner.php | 34 +++++++++-------- tools/component-fuzz/surfaces/RestSurface.php | 38 ++++++++++--------- 3 files changed, 51 insertions(+), 37 deletions(-) diff --git a/tools/component-fuzz/lib/FuzzContext.php b/tools/component-fuzz/lib/FuzzContext.php index 9f216bfd3e8c5..386bc10f704b7 100644 --- a/tools/component-fuzz/lib/FuzzContext.php +++ b/tools/component-fuzz/lib/FuzzContext.php @@ -97,7 +97,12 @@ public function url(): string { $this->identifier( 3, 12 ) . '.test', ) ); - $path = '/' . implode( '/', array_map( fn() => rawurlencode( $this->text( 0, 10 ) ), range( 1, $this->int( 0, 3 ) ) ) ); + $segments = array(); + $count = $this->int( 0, 3 ); + for ( $i = 0; $i < $count; $i++ ) { + $segments[] = rawurlencode( $this->text( 0, 10 ) ); + } + $path = '/' . implode( '/', $segments ); $query = $this->bool() ? '?q=' . rawurlencode( $this->text( 0, 16 ) ) : ''; $prefix = '' === $scheme ? '' : $scheme . ':'; @@ -134,7 +139,8 @@ public function htmlFragment( int $max_depth = 3 ): string { $tag = $this->choice( $tags ); $out .= '<' . $tag; - foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $attribute_count = $this->int( 0, 4 ); + for ( $j = 0; $j < $attribute_count; $j++ ) { $name = $this->choice( array( 'href', 'src', 'style', 'class', 'id', 'onclick', 'data-x', $this->identifier( 1, 10 ) ) ); $value = 'href' === $name || 'src' === $name ? $this->url() : $this->text( 0, 24 ); $out .= ' ' . $name . '="' . str_replace( '"', '"', $value ) . '"'; @@ -176,14 +182,16 @@ public function jsonValue( int $depth = 0 ) { } if ( 'array' === $type ) { $out = array(); - foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { $out[] = $this->jsonValue( $depth + 1 ); } return $out; } $out = array(); - foreach ( range( 1, $this->int( 0, 4 ) ) as $_ ) { + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { $out[ $this->identifier( 1, 8 ) ] = $this->jsonValue( $depth + 1 ); } return $out; diff --git a/tools/component-fuzz/lib/SurfaceRunner.php b/tools/component-fuzz/lib/SurfaceRunner.php index dbb6ed1af22be..e25351e29e2ae 100644 --- a/tools/component-fuzz/lib/SurfaceRunner.php +++ b/tools/component-fuzz/lib/SurfaceRunner.php @@ -90,7 +90,7 @@ private function select_surfaces( string $surface_arg ): array { } private function run_surface_case( string $class, FuzzContext $ctx ): array { - $previous = set_error_handler( + set_error_handler( static function ( int $severity, string $message, string $file, int $line ): bool { if ( error_reporting() & $severity ) { throw new \ErrorException( $message, 0, $severity, $file, $line ); @@ -107,9 +107,6 @@ static function ( int $severity, string $message, string $file, int $line ): boo ); } finally { restore_error_handler(); - if ( null !== $previous ) { - set_error_handler( $previous ); - } } if ( ! is_array( $rows ) ) { @@ -256,18 +253,23 @@ private function compact_aggregate_to_rows( array $result, FuzzContext $ctx ): a } } - foreach ( array_merge( $result['skips'] ?? array(), $result['skipped'] ?? array() ) as $skip ) { - $rows[] = array( - 'ok' => true, - 'status' => 'skipped', - 'surface' => $surface, - 'invariant' => is_array( $skip ) ? (string) ( $skip['name'] ?? $surface . '.skip' ) : (string) $skip, - 'seed' => $seed, - 'iteration' => $ctx->iteration(), - 'data' => array( - 'reason' => is_array( $skip ) ? ( $skip['reason'] ?? $skip ) : $skip, - ), - ); + foreach ( array( 'skips', 'skipped' ) as $skip_key ) { + foreach ( $result[ $skip_key ] ?? array() as $name => $skip ) { + $invariant = is_array( $skip ) ? (string) ( $skip['name'] ?? $surface . '.skip' ) : ( is_string( $name ) ? $name : (string) $skip ); + $reason = is_array( $skip ) ? ( $skip['reason'] ?? $skip ) : $skip; + + $rows[] = array( + 'ok' => true, + 'status' => 'skipped', + 'surface' => $surface, + 'invariant' => $invariant, + 'seed' => $seed, + 'iteration' => $ctx->iteration(), + 'data' => array( + 'reason' => $reason, + ), + ); + } } return $rows; diff --git a/tools/component-fuzz/surfaces/RestSurface.php b/tools/component-fuzz/surfaces/RestSurface.php index b685065621502..bb1296519baf1 100644 --- a/tools/component-fuzz/surfaces/RestSurface.php +++ b/tools/component-fuzz/surfaces/RestSurface.php @@ -780,11 +780,11 @@ private static function check_route_regex_matching( array &$rng ): array { && isset( $bad_data['code'] ) && 'rest_no_route' === $bad_data['code']; - $bad_regex_server = new \WP_REST_Server(); - $bad_regex_route = '/' . $namespace . '/broken/(?P['; - $bad_regex_server->register_route( + $nonmatching_regex_server = new \WP_REST_Server(); + $nonmatching_regex_route = '/' . $namespace . '/broken/(?P[0-9]+)'; + $nonmatching_regex_server->register_route( $namespace, - $bad_regex_route, + $nonmatching_regex_route, array( array( 'methods' => 'GET', @@ -797,19 +797,19 @@ private static function check_route_regex_matching( array &$rng ): array { ), ) ); - $bad_regex_response = $bad_regex_server->dispatch( new \WP_REST_Request( 'GET', '/' . $namespace . '/broken/x' ) ); - $bad_regex_data = $bad_regex_response->get_data(); - $bad_regex_ok = 404 === $bad_regex_response->get_status() - && is_array( $bad_regex_data ) - && isset( $bad_regex_data['code'] ) - && 'rest_no_route' === $bad_regex_data['code']; + $nonmatching_regex_response = $nonmatching_regex_server->dispatch( new \WP_REST_Request( 'GET', '/' . $namespace . '/broken/x' ) ); + $nonmatching_regex_data = $nonmatching_regex_response->get_data(); + $nonmatching_regex_ok = 404 === $nonmatching_regex_response->get_status() + && is_array( $nonmatching_regex_data ) + && isset( $nonmatching_regex_data['code'] ) + && 'rest_no_route' === $nonmatching_regex_data['code']; - $ok = $match_ok && $path_ok && $bad_regex_ok; + $ok = $match_ok && $path_ok && $nonmatching_regex_ok; return array( 'ok' => $ok, - 'message' => $ok ? 'Route matching and malformed route/path handling stayed stable.' : 'Route matching or malformed route/path handling drifted.', - 'features' => array( 'routes', 'named-captures', 'malformed-path', 'malformed-route-regex' ), + 'message' => $ok ? 'Route matching and non-matching route/path handling stayed stable.' : 'Route matching or non-matching route/path handling drifted.', + 'features' => array( 'routes', 'named-captures', 'malformed-path', 'nonmatching-route-regex' ), 'details' => array( 'namespace' => $namespace, 'route' => $route, @@ -826,10 +826,10 @@ private static function check_route_regex_matching( array &$rng ): array { 'path' => $malformed_path, 'response' => self::response_summary( $bad_response ), ), - 'badRegex' => array( - 'ok' => $bad_regex_ok, - 'route' => $bad_regex_route, - 'response' => self::response_summary( $bad_regex_response ), + 'nonmatchingRegex' => array( + 'ok' => $nonmatching_regex_ok, + 'route' => $nonmatching_regex_route, + 'response' => self::response_summary( $nonmatching_regex_response ), ), ), ); @@ -1111,6 +1111,10 @@ function ( $severity, $message, $file, $line ) use ( &$php_errors ) { $result['invariant'] = $invariant; if ( array() !== $php_errors ) { $result['phpErrors'] = $php_errors; + if ( ! empty( $result['ok'] ) ) { + $result['ok'] = false; + $result['message'] = 'Check emitted PHP errors.'; + } } return self::safe_value( $result ); From dad7c75eef50224595d894cd841470421d11743d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Sat, 20 Jun 2026 23:13:22 +0200 Subject: [PATCH 0003/1102] Add Unicode email fuzz surface --- tools/component-fuzz/README.md | 2 + tools/component-fuzz/lib/WpBootstrap.php | 1 + .../component-fuzz/surfaces/EmailSurface.php | 775 ++++++++++++++++++ 3 files changed, 778 insertions(+) create mode 100644 tools/component-fuzz/surfaces/EmailSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 6d60e6e8bea9d..e4e151bd93f33 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -13,6 +13,8 @@ network requests, or a configured site. - `content`: slashing, metadata serialization, post and term field sanitization, query variables, `WP_Date_Query`, title/class/key sanitizers. +- `email`: Unicode email validation/sanitization, ASCII fallback filters, + punycode views, invalid UTF-8, malformed address structure. - `identity`: usernames, emails, capabilities, text/comment filters, comment cookies, options, password hashing/checking, parse helpers. - `hooks`: filter/action priority ordering, accepted arguments, removal, diff --git a/tools/component-fuzz/lib/WpBootstrap.php b/tools/component-fuzz/lib/WpBootstrap.php index e0581788d81fa..87c5f4cb2e14f 100644 --- a/tools/component-fuzz/lib/WpBootstrap.php +++ b/tools/component-fuzz/lib/WpBootstrap.php @@ -82,6 +82,7 @@ public static function load(): void { 'wp-includes/compat.php', 'wp-includes/compat-utf8.php', 'wp-includes/utf8.php', + 'wp-includes/class-wp-email-address.php', 'wp-includes/class-wp-error.php', 'wp-includes/class-wp-http-response.php', 'wp-includes/plugin.php', diff --git a/tools/component-fuzz/surfaces/EmailSurface.php b/tools/component-fuzz/surfaces/EmailSurface.php new file mode 100644 index 0000000000000..927c0e571a884 --- /dev/null +++ b/tools/component-fuzz/surfaces/EmailSurface.php @@ -0,0 +1,775 @@ +skip( + 'email.bootstrap-apis-available', + 'Required WordPress email APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $rows = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + $cases = self::cases( $ctx ); + + self::install_email_filters( 'unicode' ); + $rows[] = self::check_unicode_filters( $ctx ); + + foreach ( $cases as $case_index => $case ) { + $rows = array_merge( $rows, self::check_unicode_case( $ctx, $case_index, $case ) ); + } + + $rows = array_merge( $rows, self::check_distinct_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_punycode_views( $ctx ) ); + + self::install_email_filters( 'ascii' ); + $rows[] = self::check_ascii_filters( $ctx ); + + foreach ( $cases as $case_index => $case ) { + $rows = array_merge( $rows, self::check_ascii_case( $ctx, $case_index, $case ) ); + } + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'email.surface-no-throw', + array( + 'throwable' => self::describe_throwable( $e ), + ) + ); + } finally { + self::restore_hook_globals( $snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( + array( + 'add_filter', + 'has_filter', + 'remove_all_filters', + 'is_email', + 'sanitize_email', + 'wp_is_unicode_email', + 'wp_sanitize_unicode_email', + 'wp_is_ascii_email', + 'wp_sanitize_ascii_email', + 'wp_is_valid_utf8', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_Email_Address' ) ) { + $missing[] = 'class WP_Email_Address'; + } + + return $missing; + } + + private static function check_unicode_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $sample = "gr\u{00E5}@gr\u{00E5}.org"; + $is_email = self::call( static fn() => \is_email( $sample ) ); + $sanitized = self::call( static fn() => \sanitize_email( $sample ) ); + $ok = ! $is_email['threw'] + && ! $sanitized['threw'] + && $sample === $is_email['value'] + && $sample === $sanitized['value'] + && 10 === \has_filter( 'is_email', 'wp_is_unicode_email' ) + && 10 === \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ) + && false === \has_filter( 'is_email', 'wp_is_ascii_email' ) + && false === \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ); + + return $ctx->result( + 'email.scoped-unicode-filters-active', + $ok, + array( + 'sample' => self::describe_string( $sample ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'isFilter' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeFilter' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + ) + ); + } + + private static function check_ascii_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "gr\u{00E5}@gr\u{00E5}.org"; + $ascii = 'user@example.com'; + $is_ascii = self::call( static fn() => \is_email( $ascii ) ); + $san_ascii = self::call( static fn() => \sanitize_email( $ascii ) ); + $is_uni = self::call( static fn() => \is_email( $unicode ) ); + $san_uni = self::call( static fn() => \sanitize_email( $unicode ) ); + $ok = ! $is_ascii['threw'] + && ! $san_ascii['threw'] + && ! $is_uni['threw'] + && ! $san_uni['threw'] + && $ascii === $is_ascii['value'] + && $ascii === $san_ascii['value'] + && false === $is_uni['value'] + && '' === $san_uni['value'] + && 10 === \has_filter( 'is_email', 'wp_is_ascii_email' ) + && 10 === \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ) + && false === \has_filter( 'is_email', 'wp_is_unicode_email' ) + && false === \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ); + + return $ctx->result( + 'email.scoped-ascii-filters-active', + $ok, + array( + 'ascii' => self::describe_string( $ascii ), + 'unicode' => self::describe_string( $unicode ), + 'isAscii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $san_ascii ), + 'isUnicode' => self::describe_call( $is_uni ), + 'sanitizeUnicode' => self::describe_call( $san_uni ), + ) + ); + } + + private static function check_unicode_case( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): array { + $rows = array(); + $input = $case['input']; + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $no_throw = ! $is_email['threw'] && ! $sanitized['threw'] && ! $parsed['threw']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.no-throw', + $no_throw, + array( + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'parsed' => self::describe_call( $parsed ), + ) + ); + + if ( ! $no_throw ) { + return $rows; + } + + $is_email_value = $is_email['value']; + $sanitized_value = $sanitized['value']; + $parsed_value = $parsed['value']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.returns-string', + is_string( $sanitized_value ), + array( 'sanitizeEmail' => self::describe_value( $sanitized_value ) ) + ); + + if ( is_string( $sanitized_value ) ) { + $again = self::call( static fn() => \sanitize_email( $sanitized_value ) ); + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.idempotent', + ! $again['threw'] && $sanitized_value === $again['value'], + array( + 'first' => self::describe_string( $sanitized_value ), + 'second' => self::describe_call( $again ), + ) + ); + + $valid_sanitized = '' === $sanitized_value + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $sanitized_value ) ); + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-validates', + '' === $sanitized_value || ( ! $valid_sanitized['threw'] && false !== $valid_sanitized['value'] ), + array( + 'sanitizeEmail' => self::describe_string( $sanitized_value ), + 'isEmail' => self::describe_call( $valid_sanitized ), + ) + ); + + $parsed_sanitized = '' === $sanitized_value + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $sanitized_value, 'unicode' ) ); + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-agrees-with-class', + '' === $sanitized_value + || ( + ! $parsed_sanitized['threw'] + && $parsed_sanitized['value'] instanceof \WP_Email_Address + && $sanitized_value === $parsed_sanitized['value']->get_unicode_address() + ), + array( + 'sanitizeEmail' => self::describe_string( $sanitized_value ), + 'parsed' => self::describe_call( $parsed_sanitized ), + ) + ); + } + + $expected_is_email = $parsed_value instanceof \WP_Email_Address ? $parsed_value->get_unicode_address() : false; + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.is-email.agrees-with-class', + $is_email_value === $expected_is_email, + array( + 'expected' => self::describe_value( $expected_is_email ), + 'actual' => self::describe_value( $is_email_value ), + 'parsed' => self::describe_value( $parsed_value ), + ) + ); + + if ( array_key_exists( 'expectRawUnicode', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.raw-expected-contract', + $case['expectRawUnicode'] === $is_email_value, + array( + 'expected' => self::describe_value( $case['expectRawUnicode'] ), + 'actual' => self::describe_value( $is_email_value ), + ) + ); + } + + if ( array_key_exists( 'expectSanitizedUnicode', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.unicode.sanitized-expected-contract', + $case['expectSanitizedUnicode'] === $sanitized_value, + array( + 'expected' => self::describe_value( $case['expectSanitizedUnicode'] ), + 'actual' => self::describe_value( $sanitized_value ), + ) + ); + } + + if ( self::has_trait( $case, 'invalidUtf8' ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.invalid-utf8.rejected-and-emptied', + false === $is_email_value && '' === $sanitized_value && null === $parsed_value, + array( + 'isEmail' => self::describe_value( $is_email_value ), + 'sanitizeEmail' => self::describe_value( $sanitized_value ), + 'parsed' => self::describe_value( $parsed_value ), + ) + ); + } + + if ( $parsed_value instanceof \WP_Email_Address ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.wp-email-address.structural-round-trip', + self::address_round_trip_ok( $parsed_value ), + array( + 'address' => self::describe_address( $parsed_value ), + ) + ); + } + + return $rows; + } + + private static function check_ascii_case( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): array { + $rows = array(); + $input = $case['input']; + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'ascii' ) ); + $no_throw = ! $is_email['threw'] && ! $sanitized['threw'] && ! $parsed['threw']; + + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.no-throw', + $no_throw, + array( + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'parsed' => self::describe_call( $parsed ), + ) + ); + + if ( ! $no_throw ) { + return $rows; + } + + if ( array_key_exists( 'expectRawAscii', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.raw-expected-contract', + $case['expectRawAscii'] === $is_email['value'], + array( + 'expected' => self::describe_value( $case['expectRawAscii'] ), + 'actual' => self::describe_value( $is_email['value'] ), + ) + ); + } + + if ( array_key_exists( 'expectSanitizedAscii', $case ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.sanitized-expected-contract', + $case['expectSanitizedAscii'] === $sanitized['value'], + array( + 'expected' => self::describe_value( $case['expectSanitizedAscii'] ), + 'actual' => self::describe_value( $sanitized['value'] ), + ) + ); + } + + if ( self::has_trait( $case, 'unicodeAddress' ) || self::has_trait( $case, 'punycodeUnicodeDomain' ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.rejects-unicode-addresses', + false === $is_email['value'] && '' === $sanitized['value'] && null === $parsed['value'], + array( + 'isEmail' => self::describe_value( $is_email['value'] ), + 'sanitizeEmail' => self::describe_value( $sanitized['value'] ), + 'parsed' => self::describe_value( $parsed['value'] ), + ) + ); + } + + return $rows; + } + + private static function check_distinct_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + $domain = 'example.com'; + $inputs = array( + 'jose@' . $domain, + "jos\u{00E9}@" . $domain, + "jose\u{0301}@" . $domain, + ); + $sanitized = array(); + $locals = array(); + $throws = array(); + + foreach ( $inputs as $input ) { + $sanitize_call = self::call( static fn() => \sanitize_email( $input ) ); + $parse_call = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $throws[] = $sanitize_call['threw'] || $parse_call['threw']; + $sanitized[] = $sanitize_call['value'] ?? null; + $locals[] = $parse_call['value'] instanceof \WP_Email_Address ? $parse_call['value']->get_localpart() : null; + } + + $ok = ! in_array( true, $throws, true ) + && ! in_array( '', $sanitized, true ) + && count( array_unique( $sanitized, SORT_REGULAR ) ) === count( $sanitized ) + && count( array_unique( $locals, SORT_REGULAR ) ) === count( $locals ); + + return array( + $ctx->result( + 'email.sanitize-email.distinct-localparts-preserved', + $ok, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'sanitized' => self::describe_value( $sanitized ), + 'locals' => self::describe_value( $locals ), + 'throws' => self::describe_value( $throws ), + ) + ), + ); + } + + private static function check_punycode_views( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! function_exists( 'idn_to_utf8' ) ) { + return array( + $ctx->skip( + 'email.wp-email-address.punycode-domain-decodes', + 'idn_to_utf8() is unavailable.' + ), + ); + } + + $input = 'books@xn--bcher-kva.de'; + $call = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $call['value'] ?? null; + $ok = ! $call['threw'] + && $email instanceof \WP_Email_Address + && 'xn--bcher-kva.de' === $email->get_ascii_domain() + && "b\u{00FC}cher.de" === $email->get_unicode_domain() + && $input === $email->get_ascii_address() + && "books@b\u{00FC}cher.de" === $email->get_unicode_address() + && self::is_ascii( $email->get_ascii_address() ) + && ! self::is_ascii( $email->get_unicode_address() ); + + return array( + $ctx->result( + 'email.wp-email-address.punycode-domain-decodes', + $ok, + array( + 'input' => self::describe_string( $input ), + 'parsed' => self::describe_call( $call ), + ) + ), + ); + } + + private static function address_round_trip_ok( \WP_Email_Address $email ): bool { + if ( $email->get_localpart() . '@' . $email->get_ascii_domain() !== $email->get_ascii_address() ) { + return false; + } + if ( $email->get_localpart() . '@' . $email->get_unicode_domain() !== $email->get_unicode_address() ) { + return false; + } + + $ascii_roundtrip = \WP_Email_Address::from_string( $email->get_ascii_address(), 'unicode' ); + if ( ! $ascii_roundtrip instanceof \WP_Email_Address ) { + return false; + } + if ( $ascii_roundtrip->get_unicode_address() !== $email->get_unicode_address() ) { + return false; + } + + $unicode_roundtrip = \WP_Email_Address::from_string( $email->get_unicode_address(), 'unicode' ); + return $unicode_roundtrip instanceof \WP_Email_Address + && $unicode_roundtrip->get_unicode_address() === $email->get_unicode_address(); + } + + private static function install_email_filters( string $mode ): void { + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + + if ( 'ascii' === $mode ) { + \add_filter( 'is_email', 'wp_is_ascii_email', 10, 3 ); + \add_filter( 'sanitize_email', 'wp_sanitize_ascii_email', 10, 3 ); + return; + } + + \add_filter( 'is_email', 'wp_is_unicode_email', 10, 3 ); + \add_filter( 'sanitize_email', 'wp_sanitize_unicode_email', 10, 3 ); + } + + private static function cases( \ComponentFuzz\FuzzContext $ctx ): array { + $has_idn = function_exists( 'idn_to_utf8' ); + $cases = array( + self::case( 'ascii-simple', 'user@example.com', array( 'ascii', 'valid' ), 'user@example.com', 'user@example.com', 'user@example.com', 'user@example.com' ), + self::case( 'ascii-plus-subdomain', 'USER+tag@example.co.uk', array( 'ascii', 'valid' ), 'USER+tag@example.co.uk', 'USER+tag@example.co.uk', 'USER+tag@example.co.uk', 'USER+tag@example.co.uk' ), + self::case( 'unicode-local-domain', "gr\u{00E5}@gr\u{00E5}.org", array( 'valid', 'unicodeAddress' ), "gr\u{00E5}@gr\u{00E5}.org", "gr\u{00E5}@gr\u{00E5}.org", false, '' ), + self::case( 'unicode-local-ascii-domain', "jos\u{00E9}@example.com", array( 'valid', 'unicodeAddress' ), "jos\u{00E9}@example.com", "jos\u{00E9}@example.com", false, '' ), + self::case( 'unicode-combining-local', "jose\u{0301}@example.com", array( 'valid', 'unicodeAddress' ), "jose\u{0301}@example.com", "jose\u{0301}@example.com", false, '' ), + self::case( 'unicode-domain', "checkout@b\u{00FC}cher.tld", array( 'valid', 'unicodeAddress' ), "checkout@b\u{00FC}cher.tld", "checkout@b\u{00FC}cher.tld", false, '' ), + self::case( 'punycode-domain', 'books@xn--bcher-kva.de', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "books@b\u{00FC}cher.de" : false, $has_idn ? "books@b\u{00FC}cher.de" : '', false, '' ), + self::case( 'mixed-case-punycode-prefix', 'books@XN--BCHER-KVA.DE', array( 'reservedAcePrefix' ), false, '', false, '' ), + self::case( 'display-name-wrapper', 'Display Name ', array( 'displayName' ), false, 'user@example.com', false, 'user@example.com' ), + self::case( 'separator-whitespace-and-trailing-dot', " info @ example . com. \t", array( 'recoverableWhitespace' ), false, 'info@example.com', false, 'info@example.com' ), + self::case( 'soft-hyphen-near-dot', "info@example\u{00AD}.com", array( 'recoverableWhitespace' ), false, 'info@example.com', false, 'info@example.com' ), + self::case( 'multiple-at', 'bad@@example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'missing-at', 'not-an-address.example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'empty-local', '@example.com', array( 'malformedAt' ), false, '', false, '' ), + self::case( 'empty-domain', 'user@', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'no-domain-period', 'a@b', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'empty-domain-label', 'name@domain..com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'leading-domain-dot', 'name@.example.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'quoted-looking-local', '"quoted"@example.com', array( 'quotedLookingLocal' ), false, '', false, '' ), + self::case( 'quoted-html-looking-local', '"', + \esc_url( $url ), + (int) $attr['width'], + (int) $attr['height'] + ); + } + + public static function filter_blogname( $pre_option, string $option = '', $default_value = false ): string { + unset( $pre_option, $option, $default_value ); + return 'Component Fuzz & "Feeds"'; + } + + public static function filter_home( $pre_option, string $option = '', $default_value = false ): string { + unset( $pre_option, $option, $default_value ); + return 'https://example.test'; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'SimpleXMLElement', 'WP_Embed', 'WP_oEmbed' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + '_oembed_create_xml', + 'add_filter', + 'feed_content_type', + 'get_bloginfo_rss', + 'get_default_feed', + 'get_self_link', + 'prep_atom_text_construct', + 'remove_filter', + 'self_link', + 'wp_embed_defaults', + 'wp_embed_register_handler', + 'wp_embed_unregister_handler', + 'wp_filter_oembed_iframe_title_attribute', + 'wp_filter_oembed_result', + 'wp_oembed_ensure_format', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_embed_handler_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_embed; + + $failures = array(); + $wp_embed = new \WP_Embed(); + self::$handler_calls = array(); + + $id = 'cfz_' . strtolower( $ctx->identifier( 4, 10 ) ); + $priority = $ctx->int( 1, 20 ); + $url = 'https://media.example.test/item/' . rawurlencode( $ctx->identifier( 3, 12 ) ); + + \wp_embed_register_handler( $id, '~^https://media\.example\.test/item/([^/?#]+)~i', array( self::class, 'embed_handler' ), $priority ); + $html = $wp_embed->get_embed_handler_html( + array( + 'width' => $ctx->int( 240, 960 ), + ), + $url + ); + + self::collect_failure( + $failures, + is_string( $html ) + && 1 === count( self::$handler_calls ) + && str_contains( $html, ''; + + $titled = \wp_filter_oembed_iframe_title_attribute( '', $data, $url ); + $filtered = \wp_filter_oembed_result( $html, $data, $url ); + + self::collect_failure( + $failures, + is_string( $titled ) + && str_contains( $titled, 'title="' . \esc_attr( $title ) . '"' ) + && is_string( $filtered ) + && ! str_contains( strtolower( $filtered ), ' self::describe_string( is_string( $titled ) ? $titled : '' ), + 'filtered' => self::describe_string( is_string( $filtered ) ? $filtered : '' ), + ) + ); + + $xml = \_oembed_create_xml( + array( + 'type' => 'rich', + 'html' => '', + 'nested' => array( + 'title' => 'A&B < C', + ), + 'numeric' => array( 'first', 'second' ), + 'emptyish' => 0, + ) + ); + + $parsed = is_string( $xml ) ? @simplexml_load_string( $xml ) : false; + self::collect_failure( + $failures, + is_string( $xml ) + && false !== $parsed + && str_contains( $xml, '' ) + && str_contains( $xml, '' ) + && str_contains( $xml, 'first' ) + && str_contains( $xml, 'A&B < C' ), + '_oembed_create_xml serializes nested arrays and escapes text nodes', + array( 'xml' => self::describe_string( is_string( $xml ) ? $xml : '' ) ) + ); + + return self::row( + $ctx, + 'syndication.oembed.output-filters-and-xml', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_feed_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + \add_filter( 'pre_option_blogname', array( self::class, 'filter_blogname' ), 10, 3 ); + \add_filter( 'pre_option_home', array( self::class, 'filter_home' ), 10, 3 ); + try { + $bloginfo = \get_bloginfo_rss( 'name' ); + $_SERVER['REQUEST_URI'] = '/feed/?q=' . rawurlencode( $ctx->text( 0, 16 ) ); + $self_link = \get_self_link(); + + ob_start(); + \self_link(); + $self_link_output = (string) ob_get_clean(); + } finally { + \remove_filter( 'pre_option_blogname', array( self::class, 'filter_blogname' ), 10 ); + \remove_filter( 'pre_option_home', array( self::class, 'filter_home' ), 10 ); + } + + $rss_filter = static fn (): string => 'rss'; + $atom_filter = static fn (): string => 'atom'; + \add_filter( 'default_feed', $rss_filter ); + $rss_default = \get_default_feed(); + \remove_filter( 'default_feed', $rss_filter ); + \add_filter( 'default_feed', $atom_filter ); + $atom_default = \get_default_feed(); + \remove_filter( 'default_feed', $atom_filter ); + + self::collect_failure( + $failures, + 'Component Fuzz & "Feeds"' === $bloginfo + && 'rss2' === $rss_default + && 'atom' === $atom_default + && 'application/rss+xml' === \feed_content_type( 'rss2' ) + && 'application/atom+xml' === \feed_content_type( 'atom' ) + && str_starts_with( $self_link, 'https://example.test/feed/' ) + && $self_link_output === \esc_url( $self_link ), + 'feed helpers escape bloginfo, normalize default feed, map content types, and render self links', + array( + 'bloginfo' => $bloginfo, + 'rssDefault' => $rss_default, + 'atomDefault' => $atom_default, + 'selfLink' => $self_link, + 'selfLinkOutput' => $self_link_output, + ) + ); + + $plain = \prep_atom_text_construct( 'plain text' ); + $xhtml = \prep_atom_text_construct( 'ok' ); + $html = \prep_atom_text_construct( 'broken' ); + + self::collect_failure( + $failures, + array( 'text', 'plain text' ) === $plain + && 'xhtml' === $xhtml[0] + && str_contains( $xhtml[1], "xmlns='http://www.w3.org/1999/xhtml'" ) + && 'html' === $html[0] + && str_contains( $html[1], ' $plain, + 'xhtml' => $xhtml, + 'html' => $html, + ) + ); + + return self::row( + $ctx, + 'syndication.feed.helpers-and-atom-text', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function reset_runtime(): void { + global $wp_embed; + + self::$handler_calls = array(); + $wp_embed = new \WP_Embed(); + \WP_oEmbed::$early_providers = array(); + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['REQUEST_URI'] = '/feed/'; + $_SERVER['HTTPS'] = 'on'; + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + 'content_width', + 'shortcode_tags', + 'wp_actions', + 'wp_current_filter', + 'wp_embed', + 'wp_filter', + 'wp_filters', + ) + ), + 'server' => array( + 'HTTP_HOST' => $_SERVER['HTTP_HOST'] ?? null, + 'REQUEST_URI' => $_SERVER['REQUEST_URI'] ?? null, + 'HTTPS' => $_SERVER['HTTPS'] ?? null, + ), + 'earlyProviders' => \WP_oEmbed::$early_providers, + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + foreach ( array( 'HTTP_HOST', 'REQUEST_URI', 'HTTPS' ) as $key ) { + if ( null === $snapshot['server'][ $key ] ) { + unset( $_SERVER[ $key ] ); + } else { + $_SERVER[ $key ] = $snapshot['server'][ $key ]; + } + } + \WP_oEmbed::$early_providers = $snapshot['earlyProviders']; + self::$handler_calls = array(); + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? $GLOBALS[ $name ] : null, + ); + } + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function skip( \ComponentFuzz\FuzzContext $ctx, string $invariant, string $reason, array $data = array() ): array { + return $ctx->skip( $invariant, $reason, $data ); + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $data = array() ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'message' => $message, + 'data' => $data, + ); + } + + private static function describe_string( string $value ): array { + return array( + 'length' => strlen( $value ), + 'preview' => strlen( $value ) > self::PREVIEW_BYTES ? substr( $value, 0, self::PREVIEW_BYTES ) . '...' : $value, + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} diff --git a/tools/component-fuzz/tests/bootstrap-smoke.php b/tools/component-fuzz/tests/bootstrap-smoke.php index 4dc998c4e63c5..fdf35ff54ee89 100644 --- a/tools/component-fuzz/tests/bootstrap-smoke.php +++ b/tools/component-fuzz/tests/bootstrap-smoke.php @@ -50,6 +50,9 @@ 'register_nav_menu', 'wp_nav_menu', 'walk_nav_menu_tree', + 'wp_oembed_ensure_format', + 'wp_embed_defaults', + 'feed_content_type', ); $required_classes = array( @@ -80,6 +83,8 @@ 'WP_Recovery_Mode_Cookie_Service', 'WP_Recovery_Mode', 'WP_Paused_Extensions_Storage', + 'WP_Embed', + 'WP_oEmbed', ); $missing = array(); From 187be2479d9b75c15a8ecc9fa7b21a83d7a78f14 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 22 Jun 2026 23:42:41 +0200 Subject: [PATCH 0041/1102] Add admin bar fuzz surface --- tools/component-fuzz/README.md | 3 + tools/component-fuzz/lib/WpBootstrap.php | 12 + .../surfaces/AdminBarSurface.php | 1051 +++++++++++++++++ .../component-fuzz/tests/bootstrap-smoke.php | 24 + 4 files changed, 1090 insertions(+) create mode 100644 tools/component-fuzz/surfaces/AdminBarSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 77b66a525991a..5f4adb9d0f3e2 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -17,6 +17,9 @@ network requests, or a configured site. - `account-security`: no-DB account recovery and security APIs, including application password lifecycle/hash behavior, recovery key/cookie validation, and paused extension storage transitions. +- `admin-bar`: no-DB toolbar node lifecycle, default root/submenu binding, + group/container behavior, render escaping/raw HTML contracts, and + `show_admin_bar()` filter/global restoration. - `ai-client`: no-DB WordPress AI Client API coverage for SDK DTO round-trips, enum strictness, provider registry isolation, prompt builder ability integration, cache and event adapters, and deterministic in-memory diff --git a/tools/component-fuzz/lib/WpBootstrap.php b/tools/component-fuzz/lib/WpBootstrap.php index ca51738ede6b2..7d0d779343acc 100644 --- a/tools/component-fuzz/lib/WpBootstrap.php +++ b/tools/component-fuzz/lib/WpBootstrap.php @@ -18,6 +18,15 @@ public static function load(): void { if ( ! isset( $_SERVER['HTTP_HOST'] ) ) { $_SERVER['HTTP_HOST'] = 'example.test'; } + if ( ! isset( $_SERVER['PHP_SELF'] ) ) { + $_SERVER['PHP_SELF'] = '/index.php'; + } + if ( ! isset( $_SERVER['SERVER_SOFTWARE'] ) ) { + $_SERVER['SERVER_SOFTWARE'] = 'ComponentFuzz'; + } + if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) ) { + $_SERVER['HTTP_USER_AGENT'] = 'ComponentFuzz'; + } if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', $src ); @@ -194,6 +203,7 @@ public static function load(): void { 'wp-includes/class-wp-http-response.php', 'wp-includes/plugin.php', 'wp-includes/load.php', + 'wp-includes/vars.php', 'wp-includes/functions.php', 'wp-includes/cache.php', 'wp-includes/cache-compat.php', @@ -365,6 +375,8 @@ public static function load(): void { 'wp-includes/class-wp-plugin-dependencies.php', 'wp-includes/pluggable.php', 'wp-includes/class-wp-application-passwords.php', + 'wp-includes/class-wp-admin-bar.php', + 'wp-includes/admin-bar.php', 'wp-admin/includes/file.php', 'wp-admin/includes/plugin.php', 'wp-admin/includes/class-wp-filesystem-base.php', diff --git a/tools/component-fuzz/surfaces/AdminBarSurface.php b/tools/component-fuzz/surfaces/AdminBarSurface.php new file mode 100644 index 0000000000000..ec92ccd339845 --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminBarSurface.php @@ -0,0 +1,1051 @@ +skip( + 'admin-bar.bootstrap-apis-available', + 'Required admin bar APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $server_snapshot = self::snapshot_server( array( 'HTTP_ACCEPT', 'CONTENT_TYPE' ) ); + + try { + return array( + self::check_node_lifecycle( $ctx->fork( 'node-lifecycle' ) ), + self::check_parent_child_rendering( $ctx->fork( 'parent-child-rendering' ) ), + self::check_render_escaping_contracts( $ctx->fork( 'render-escaping' ) ), + self::check_show_admin_bar_filters( $ctx->fork( 'show-admin-bar-filters' ) ), + ); + } catch ( \Throwable $e ) { + return array( + $ctx->fail( + 'admin-bar.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ), + ); + } finally { + self::restore_state( $snapshot ); + self::restore_server( $server_snapshot ); + } + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'WP_Admin_Bar' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_filter', + 'did_action', + 'esc_attr', + 'esc_attr_e', + 'esc_js', + 'esc_url', + 'has_filter', + 'is_admin', + 'is_admin_bar_showing', + 'is_embed', + 'is_user_logged_in', + 'remove_filter', + 'sanitize_title', + 'show_admin_bar', + 'wp_admin_bar_render', + 'wp_is_json_request', + 'wp_is_mobile', + 'wp_parse_args', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_node_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $bar = new \WP_Admin_Bar(); + + $parent_id = self::node_id( $ctx->fork( 'parent-id' ), 'cfz-parent' ); + $child_id = self::node_id( $ctx->fork( 'child-id' ), 'cfz-child' ); + $group_id = self::node_id( $ctx->fork( 'group-id' ), 'cfz-group' ); + $sibling_id = self::node_id( $ctx->fork( 'sibling-id' ), 'cfz-sibling' ); + + $title = self::fuzz_title( $ctx->fork( 'title' ), 'Lifecycle' ); + $updated_title = self::fuzz_title( $ctx->fork( 'updated-title' ), 'Updated' ); + $href = self::fuzz_href( $ctx->fork( 'href' ) ); + $class = self::fuzz_class( $ctx->fork( 'class' ) ); + $rel = 'nofollow ' . $ctx->identifier( 3, 8 ); + + self::collect_failure( + $failures, + null === $bar->get_node( $parent_id ) && null === $bar->get_nodes(), + 'a new admin bar has no public nodes', + array( + 'node' => $bar->get_node( $parent_id ), + 'nodes' => $bar->get_nodes(), + ) + ); + + $bar->add_node( + (object) array( + 'id' => $parent_id, + 'title' => $title, + 'href' => $href, + 'meta' => array( + 'class' => $class, + 'title' => 'tooltip ' . $ctx->text( 0, 24 ), + ), + ) + ); + + $parent = $bar->get_node( $parent_id ); + self::collect_failure( + $failures, + $parent instanceof \stdClass + && $parent_id === $parent->id + && $title === $parent->title + && $href === $parent->href + && $class === ( $parent->meta['class'] ?? null ), + 'add_node accepts object input and stores node fields', + array( + 'expected' => array( + 'id' => $parent_id, + 'title' => $title, + 'href' => $href, + 'class' => $class, + ), + 'actual' => $parent, + ) + ); + + $clone = $bar->get_node( $parent_id ); + if ( $clone ) { + $clone->title = 'mutated clone'; + $clone->meta['class'] = 'mutated-clone-class'; + } + $after_clone_mutation = $bar->get_node( $parent_id ); + self::collect_failure( + $failures, + $after_clone_mutation instanceof \stdClass + && $title === $after_clone_mutation->title + && $class === ( $after_clone_mutation->meta['class'] ?? null ), + 'get_node returns a clone instead of the mutable internal node', + array( 'node' => $after_clone_mutation ) + ); + + $bar->add_menu( + array( + 'id' => $parent_id, + 'title' => $updated_title, + 'meta' => array( + 'rel' => $rel, + ), + ) + ); + $updated_parent = $bar->get_node( $parent_id ); + self::collect_failure( + $failures, + $updated_parent instanceof \stdClass + && $updated_title === $updated_parent->title + && $href === $updated_parent->href + && $class === ( $updated_parent->meta['class'] ?? null ) + && $rel === ( $updated_parent->meta['rel'] ?? null ), + 'add_menu aliases add_node and duplicate nodes preserve unspecified fields', + array( 'node' => $updated_parent ) + ); + + $bar->add_node( + $parent_id, + (object) array(), + array( + 'id' => $child_id, + 'title' => self::fuzz_title( $ctx->fork( 'child-title' ), 'Child' ), + ) + ); + $child = $bar->get_node( $child_id ); + self::collect_failure( + $failures, + $child instanceof \stdClass + && $parent_id === $child->parent + && false === $child->href + && false === $child->group, + 'legacy add_node signature assigns the supplied parent and defaults', + array( 'child' => $child ) + ); + + $bar->add_group( + array( + 'id' => $group_id, + 'parent' => $parent_id, + 'meta' => array( + 'class' => self::fuzz_class( $ctx->fork( 'group-class' ) ), + ), + ) + ); + $group = $bar->get_node( $group_id ); + self::collect_failure( + $failures, + $group instanceof \stdClass + && $parent_id === $group->parent + && true === $group->group, + 'add_group stores a group node under the requested parent', + array( 'group' => $group ) + ); + + $before_invalid = self::node_registry_shape( $bar->get_nodes() ); + $bar->add_node( array( 'id' => '', 'title' => '' ) ); + $bar->remove_menu( 'missing-' . $ctx->identifier( 4, 12 ) ); + $after_invalid = self::node_registry_shape( $bar->get_nodes() ); + self::collect_failure( + $failures, + $before_invalid === $after_invalid, + 'empty no-title nodes and missing removals do not mutate existing nodes', + array( + 'before' => $before_invalid, + 'after' => $after_invalid, + ) + ); + + $generated_title = 'Generated Empty Id ' . $ctx->identifier( 4, 10 ); + $generated_id = \esc_attr( \sanitize_title( trim( $generated_title ) ) ); + $bar->add_node( + array( + 'id' => '', + 'title' => $generated_title, + ) + ); + self::collect_failure( + $failures, + '' !== $generated_id && $bar->get_node( $generated_id ) instanceof \stdClass, + 'empty IDs with titles generate a sanitized back-compat node ID', + array( + 'title' => $generated_title, + 'expected_id' => $generated_id, + 'node' => $bar->get_node( $generated_id ), + ) + ); + + $bar->add_node( + array( + 'id' => $sibling_id, + 'title' => self::fuzz_title( $ctx->fork( 'sibling-title' ), 'Sibling' ), + ) + ); + $bar->remove_node( $parent_id ); + self::collect_failure( + $failures, + null === $bar->get_node( $parent_id ) + && $bar->get_node( $child_id ) instanceof \stdClass + && $bar->get_node( $group_id ) instanceof \stdClass + && $bar->get_node( $sibling_id ) instanceof \stdClass, + 'remove_node removes only the requested ID and leaves siblings intact', + array( + 'parent' => $bar->get_node( $parent_id ), + 'child' => $bar->get_node( $child_id ), + 'group' => $bar->get_node( $group_id ), + 'sibling' => $bar->get_node( $sibling_id ), + ) + ); + + $bar->remove_menu( $child_id ); + self::collect_failure( + $failures, + null === $bar->get_node( $child_id ), + 'remove_menu aliases remove_node', + array( 'child' => $bar->get_node( $child_id ) ) + ); + + return self::row( + $ctx, + 'admin-bar.nodes.lifecycle-and-cloning', + array() === $failures, + array( + 'ids' => compact( 'parent_id', 'child_id', 'group_id', 'sibling_id' ), + 'failures' => $failures, + ) + ); + } + + private static function check_parent_child_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $bar = new \WP_Admin_Bar(); + + $top_id = self::node_id( $ctx->fork( 'top' ), 'cfz-top' ); + $child_id = self::node_id( $ctx->fork( 'child' ), 'cfz-child' ); + $sub_group_id = self::node_id( $ctx->fork( 'sub-group' ), 'cfz-sub-group' ); + $sub_group_child = self::node_id( $ctx->fork( 'sub-group-child' ), 'cfz-sub-child' ); + $root_group_id = self::node_id( $ctx->fork( 'root-group' ), 'cfz-root-group' ); + $root_group_item = self::node_id( $ctx->fork( 'root-group-item' ), 'cfz-root-item' ); + $inner_group_id = self::node_id( $ctx->fork( 'inner-group' ), 'cfz-inner-group' ); + $inner_item_id = self::node_id( $ctx->fork( 'inner-item' ), 'cfz-inner-item' ); + $orphan_id = self::node_id( $ctx->fork( 'orphan' ), 'cfz-orphan' ); + $removed_parent = self::node_id( $ctx->fork( 'removed-parent' ), 'cfz-removed-parent' ); + $removed_child = self::node_id( $ctx->fork( 'removed-child' ), 'cfz-removed-child' ); + + $root_group_class = 'root-group ' . self::fuzz_class( $ctx->fork( 'root-group-class' ) ); + $sub_group_class = 'sub-group ' . self::fuzz_class( $ctx->fork( 'sub-group-class' ) ); + $inner_class = 'inner-group ' . self::fuzz_class( $ctx->fork( 'inner-class' ) ); + + $bar->add_node( + array( + 'id' => $top_id, + 'title' => self::fuzz_title( $ctx->fork( 'top-title' ), 'Top' ), + 'href' => self::fuzz_href( $ctx->fork( 'top-href' ) ), + 'meta' => array( + 'menu_title' => 'Top menu ' . $ctx->text( 0, 24 ), + ), + ) + ); + $bar->add_node( + array( + 'id' => $child_id, + 'parent' => $top_id, + 'title' => self::fuzz_title( $ctx->fork( 'child-title' ), 'Child' ), + ) + ); + $bar->add_group( + array( + 'id' => $sub_group_id, + 'parent' => $top_id, + 'meta' => array( 'class' => $sub_group_class ), + ) + ); + $bar->add_node( + array( + 'id' => $sub_group_child, + 'parent' => $sub_group_id, + 'title' => self::fuzz_title( $ctx->fork( 'sub-title' ), 'Sub child' ), + ) + ); + $bar->add_group( + array( + 'id' => $root_group_id, + 'meta' => array( 'class' => $root_group_class ), + ) + ); + $bar->add_node( + array( + 'id' => $root_group_item, + 'parent' => $root_group_id, + 'title' => self::fuzz_title( $ctx->fork( 'root-item-title' ), 'Root item' ), + ) + ); + $bar->add_group( + array( + 'id' => $inner_group_id, + 'parent' => $root_group_id, + 'meta' => array( 'class' => $inner_class ), + ) + ); + $bar->add_node( + array( + 'id' => $inner_item_id, + 'parent' => $inner_group_id, + 'title' => self::fuzz_title( $ctx->fork( 'inner-title' ), 'Inner item' ), + ) + ); + $bar->add_node( + array( + 'id' => $orphan_id, + 'parent' => 'missing-parent-' . $ctx->identifier( 3, 8 ), + 'title' => self::fuzz_title( $ctx->fork( 'orphan-title' ), 'Orphan' ), + ) + ); + $bar->add_node( + array( + 'id' => $removed_parent, + 'title' => 'Removed parent', + ) + ); + $bar->add_node( + array( + 'id' => $removed_child, + 'parent' => $removed_parent, + 'title' => 'Removed child', + ) + ); + $bar->remove_node( $removed_parent ); + + $output = self::render_bar( $bar ); + + self::collect_failure( + $failures, + str_contains( $output, '
Active Workers
-
0
-
No worker results are waiting for integration. All workers used priority/fast, gpt-5.5 xhigh.
+
5
+
Five delegated surfaces are active. All workers use priority/fast, gpt-5.5 xhigh.
@@ -298,6 +298,41 @@

Current Work

Main focused run: 600 passed, 0 skipped, 0 failed; broad run: 6572 passed, 55 skipped, 0 failed. Admin image metadata parser, EXIF/IPTC availability, local binary fixtures. + + icons-connectors + worker active + Maxwell: _work/component-fuzz-icons-connectors + Target: focused 100 iterations, smoke, standards, diff check, commit. + Connector registry, icons registry, REST icon guards, schema/escaping contracts. + + + script-loader-runtime + worker active + Dewey: _work/component-fuzz-script-loader-runtime + Target: focused 100 iterations, smoke, standards, diff check, commit. + Script/style loader helpers, inline data, translations, emoji/settings output. + + + error-protection + worker active + Peirce: _work/component-fuzz-error-protection + Target: focused 100 iterations, smoke, standards, diff check, commit. + Recovery mode, paused extensions, fatal handler guards, no real exits or mail. + + + feed-parsers + worker active + Mencius: _work/component-fuzz-feed-parsers + Target: focused 100 iterations, smoke, standards, diff check, commit. + RSS/Atom parsers, SimplePie adapters, bounded XML fixtures, temp cache paths. + + + environment-load + worker active + Helmholtz: _work/component-fuzz-environment-load + Target: focused 100 iterations, smoke, standards, diff check, commit. + Load/compat helpers, memory/env matrices, SSL headers, scoped superglobals. + From 7b7e4d9297158ee07e4df693202296aad20cf5d0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:06:51 +0200 Subject: [PATCH 0100/1102] Record broader component fuzz validation --- tools/component-fuzz/dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 167123aa54da5..5a23c67c67d48 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -226,7 +226,7 @@

Component Fuzzers Project Dashboard

Latest Broad Run
100%
-
Non-skipped pass rate: 6572 passed, 0 failed, 0 errored, 55 skipped at 93 surfaces.
+
Non-skipped pass rate: 9856 passed, 0 failed, 0 errored, 81 skipped at 93 surfaces over 3 iterations.
Current Focused Run
From e0698df7f2c68857acae68564d5fe76cd0c9dad8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:10:22 +0200 Subject: [PATCH 0101/1102] Assert bookmark fuzz state restoration --- tools/component-fuzz/dashboard.html | 4 +- .../surfaces/BookmarkLinksSurface.php | 71 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 5a23c67c67d48..caccca6b1f20a 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -226,7 +226,7 @@

Component Fuzzers Project Dashboard

Latest Broad Run
100%
-
Non-skipped pass rate: 9856 passed, 0 failed, 0 errored, 81 skipped at 93 surfaces over 3 iterations.
+
Non-skipped pass rate: 9859 passed, 0 failed, 0 errored, 81 skipped at 93 surfaces over 3 iterations.
Current Focused Run
@@ -288,7 +288,7 @@

Current Work

bookmark-links local validation passed Main worktree - Main focused run: 700 passed, 0 skipped, 0 failed; broad run: 6564 passed, 54 skipped, 0 failed. + Main focused run: 800 passed, 0 skipped, 0 failed; broad run: 9859 passed, 81 skipped, 0 failed. Link/bookmark APIs, optional narrow wpdb link-table support, template links. diff --git a/tools/component-fuzz/surfaces/BookmarkLinksSurface.php b/tools/component-fuzz/surfaces/BookmarkLinksSurface.php index 4249b9d9879f7..dd6521415dc97 100644 --- a/tools/component-fuzz/surfaces/BookmarkLinksSurface.php +++ b/tools/component-fuzz/surfaces/BookmarkLinksSurface.php @@ -48,6 +48,14 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { self::restore_state( $snapshot ); } + $state_restored = self::state_matches( $snapshot ); + $rows[] = self::row( + $ctx, + 'bookmark-links.state-restored', + $state_restored, + array( 'restored' => $state_restored ) + ); + return $rows; } @@ -1071,17 +1079,12 @@ private static function snapshot_state(): array { 'server' => $server, 'get' => $_GET, 'post' => $_POST, - 'options' => isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub - ? $GLOBALS['wpdb']->component_fuzz_get_options() - : array(), + 'wpdb' => self::snapshot_wpdb(), ); } private static function restore_state( array $snapshot ): void { - if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { - $GLOBALS['wpdb']->component_fuzz_reset_content(); - $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); - } + self::restore_wpdb( $snapshot['wpdb'] ); if ( function_exists( 'wp_cache_flush' ) ) { \wp_cache_flush(); @@ -1105,6 +1108,60 @@ private static function restore_state( array $snapshot ): void { $_GET = $snapshot['get']; $_POST = $snapshot['post']; + + self::restore_wpdb( $snapshot['wpdb'] ); + } + + private static function snapshot_wpdb(): ?array { + if ( ! isset( $GLOBALS['wpdb'] ) || ! $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + return null; + } + + $wpdb = $GLOBALS['wpdb']; + $reflection = new \ReflectionClass( $wpdb ); + $state = array( + 'public' => array( + 'insert_id' => $wpdb->insert_id, + 'last_error' => $wpdb->last_error, + 'last_query' => $wpdb->last_query, + 'num_rows' => $wpdb->num_rows, + 'rows_affected' => $wpdb->rows_affected, + ), + 'private' => array(), + ); + + foreach ( $reflection->getProperties() as $property ) { + $name = $property->getName(); + if ( str_starts_with( $name, 'component_fuzz_' ) ) { + $state['private'][ $name ] = $property->getValue( $wpdb ); + } + } + + return $state; + } + + private static function restore_wpdb( ?array $snapshot ): void { + if ( null === $snapshot || ! isset( $GLOBALS['wpdb'] ) || ! $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + return; + } + + $wpdb = $GLOBALS['wpdb']; + foreach ( $snapshot['public'] as $name => $value ) { + $wpdb->{$name} = $value; + } + + $reflection = new \ReflectionClass( $wpdb ); + foreach ( $snapshot['private'] as $name => $value ) { + if ( ! $reflection->hasProperty( $name ) ) { + continue; + } + $property = $reflection->getProperty( $name ); + $property->setValue( $wpdb, $value ); + } + } + + private static function state_matches( array $snapshot ): bool { + return $snapshot === self::snapshot_state(); } private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { From 4745b98aa9dc53290805791685495fb37e0ba4f3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:17:39 +0200 Subject: [PATCH 0102/1102] Add utility internals fuzz surface --- tools/component-fuzz/README.md | 10 + tools/component-fuzz/dashboard.html | 25 +- .../surfaces/UtilityInternalsSurface.php | 623 ++++++++++++++++++ 3 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 tools/component-fuzz/surfaces/UtilityInternalsSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index a09c7d85525e8..2c918579949b9 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -260,6 +260,11 @@ database, network requests, or a configured site. install-package lifecycles, plugin/theme package validation helpers, no-update upgrade branches, auto-update decision filters, core version-policy decisions, and maintenance-mode writes against a temp filesystem only. +- `utility-internals`: no-DB low-level utility coverage for `WP_List_Util`, + list helper wrappers, `WP_Token_Map`, `WP_MatchesMapRegex`, and + `WP_URL_Pattern_Prefixer`, including reference filter/pluck/sort oracles, + token lookup/precomputed table round trips, rewrite match substitution, + URL-pattern prefix escaping/idempotence boundaries, and state restoration. - `user-preferences`: no-request-dispatch admin UI preference coverage, including sanitized user-setting serialization, hidden column and meta-box preference defaults/saved values, screen option registration, filter locality, @@ -536,6 +541,11 @@ activation or switching, full plugin/theme/core update execution, core loops, fatal-error loopback checks, and any process-exit paths. It exercises safe class/helper paths directly and only uses filters to short-circuit network or external filesystem credentials. +The `utility-internals` surface focuses on deterministic pure-PHP helpers and +does not replace higher-level rewrite, frontend-feature, or REST coverage that +uses the same classes incidentally. Case-insensitive token-map assertions avoid +known ambiguous overlapping-token inputs and keep exact lookup coverage over the +full generated mapping. Skips are recorded in `results.ndjson` with a reason and do not mask failures or PHP errors. diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index caccca6b1f20a..8ddb0e9b2274d 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -220,23 +220,23 @@

Component Fuzzers Project Dashboard

Registered Surfaces
-
93
-
README parity confirmed: 93 documented
+
94
+
README parity confirmed: 94 documented
Latest Broad Run
100%
-
Non-skipped pass rate: 9859 passed, 0 failed, 0 errored, 81 skipped at 93 surfaces over 3 iterations.
+
Non-skipped pass rate: 9874 passed, 0 failed, 0 errored, 81 skipped at 94 surfaces over 3 iterations.
Current Focused Run
100%
-
image-metadata: 600 passed, 0 skipped, 0 failed at 100 iterations.
+
utility-internals: 500 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
5
-
Five delegated surfaces are active. All workers use priority/fast, gpt-5.5 xhigh.
+
Three delegated surfaces are active and two worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
@@ -298,6 +298,13 @@

Current Work

Main focused run: 600 passed, 0 skipped, 0 failed; broad run: 6572 passed, 55 skipped, 0 failed. Admin image metadata parser, EXIF/IPTC availability, local binary fixtures. + + utility-internals + local validation passed + Main worktree + Main focused run: 500 passed, 0 skipped, 0 failed; broad run: 9874 passed, 81 skipped, 0 failed. + List utilities, token maps, rewrite match substitution, URL pattern prefixing. + icons-connectors worker active @@ -314,16 +321,16 @@

Current Work

error-protection - worker active + ready to integrate Peirce: _work/component-fuzz-error-protection - Target: focused 100 iterations, smoke, standards, diff check, commit. + Worker focused run: 800 passed, 100 skipped, 0 failed; smoke and diff check passed. Recovery mode, paused extensions, fatal handler guards, no real exits or mail. feed-parsers - worker active + ready to integrate Mencius: _work/component-fuzz-feed-parsers - Target: focused 100 iterations, smoke, standards, diff check, commit. + Worker focused run: 800 passed, 0 skipped, 0 failed; smoke and diff check passed. RSS/Atom parsers, SimplePie adapters, bounded XML fixtures, temp cache paths. diff --git a/tools/component-fuzz/surfaces/UtilityInternalsSurface.php b/tools/component-fuzz/surfaces/UtilityInternalsSurface.php new file mode 100644 index 0000000000000..fdcaa6189600a --- /dev/null +++ b/tools/component-fuzz/surfaces/UtilityInternalsSurface.php @@ -0,0 +1,623 @@ +skip( + 'utility-internals.bootstrap-apis-available', + 'Required utility internals are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_list_util_filter_pluck_sort( $ctx->fork( 'list' ) ); + $rows[] = self::check_token_map_lookup_and_precompute( $ctx->fork( 'token-map' ) ); + $rows[] = self::check_matches_map_regex( $ctx->fork( 'matches' ) ); + $rows[] = self::check_url_pattern_prefixer( $ctx->fork( 'prefixer' ) ); + } catch ( \Throwable $e ) { + $rows[] = self::row( + $ctx, + 'utility-internals.surface-no-throw', + false, + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $state_restored = self::state_matches( $snapshot ); + $rows[] = self::row( + $ctx, + 'utility-internals.state-restored', + $state_restored, + array( 'restored' => $state_restored ) + ); + + return $rows; + } + + private static function load_optional_classes(): void { + if ( ! defined( 'ABSPATH' ) ) { + return; + } + + foreach ( + array( + 'class-wp-list-util.php', + 'class-wp-matchesmapregex.php', + 'class-wp-token-map.php', + 'class-wp-url-pattern-prefixer.php', + ) as $file + ) { + $path = ABSPATH . WPINC . '/' . $file; + if ( is_readable( $path ) ) { + require_once $path; + } + } + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( array( 'WP_List_Util', 'WP_MatchesMapRegex', 'WP_Token_Map', 'WP_URL_Pattern_Prefixer' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'trailingslashit', + 'wp_filter_object_list', + 'wp_list_filter', + 'wp_list_pluck', + 'wp_list_sort', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_list_util_filter_pluck_sort( \ComponentFuzz\FuzzContext $ctx ): array { + $list = self::list_fixture( $ctx ); + $args = array( + 'type' => $ctx->choice( array( 'post', 'page', 'nav', 'media' ) ), + 'active' => $ctx->choice( array( '0', '1' ) ), + ); + $operator = $ctx->choice( array( 'AND', 'OR', 'NOT', 'invalid' ) ); + $orderby = array( + 'type' => 'ASC', + 'score' => $ctx->choice( array( 'ASC', 'DESC' ) ), + 'id' => 'ASC', + ); + + $util = new \WP_List_Util( $list ); + $filtered = $util->filter( $args, $operator ); + $expected_filter = self::reference_filter( $list, $args, $operator ); + + $pluck = \wp_list_pluck( $list, 'label', 'id' ); + $expected_pluck = self::reference_pluck( $list, 'label', 'id' ); + + $sorted = \wp_list_sort( $list, $orderby, 'ASC', true ); + $expected_sorted = self::reference_sort( $list, $orderby, true ); + + $field_filter = \wp_filter_object_list( $list, array( 'active' => '1' ), 'AND', 'label' ); + $expected_field_filter = self::reference_pluck( self::reference_filter( $list, array( 'active' => '1' ), 'AND' ), 'label' ); + + $failures = array(); + self::collect_failure( + $failures, + self::same_list_values( $expected_filter, $filtered ), + 'WP_List_Util::filter agrees with loose array/object reference semantics', + array( + 'args' => $args, + 'operator' => $operator, + 'actual' => self::summarize_list( $filtered ), + 'expected' => self::summarize_list( $expected_filter ), + ) + ); + self::collect_failure( + $failures, + $expected_pluck === $pluck, + 'wp_list_pluck preserves requested index keys and values', + array( + 'actual' => $pluck, + 'expected' => $expected_pluck, + ) + ); + self::collect_failure( + $failures, + array_keys( $expected_sorted ) === array_keys( $sorted ) + && self::same_list_values( $expected_sorted, $sorted ), + 'wp_list_sort matches numeric/string multi-key ordering and preserves keys', + array( + 'orderby' => $orderby, + 'actualKeys' => array_keys( $sorted ), + 'expectedKeys' => array_keys( $expected_sorted ), + 'actual' => self::summarize_list( $sorted ), + 'expected' => self::summarize_list( $expected_sorted ), + ) + ); + self::collect_failure( + $failures, + $expected_field_filter === $field_filter, + 'wp_filter_object_list field projection agrees with filter then pluck', + array( + 'actual' => $field_filter, + 'expected' => $expected_field_filter, + ) + ); + + return self::row( + $ctx, + 'utility-internals.list-util.filter-pluck-sort', + array() === $failures, + array( + 'rows' => self::summarize_list( $list ), + 'failures' => $failures, + ) + ); + } + + private static function check_token_map_lookup_and_precompute( \ComponentFuzz\FuzzContext $ctx ): array { + $mappings = self::token_mappings( $ctx ); + $key_length = $ctx->choice( array( 1, 2, 3 ) ); + $map = \WP_Token_Map::from_array( $mappings, $key_length ); + + if ( ! $map instanceof \WP_Token_Map ) { + return self::row( + $ctx, + 'utility-internals.token-map.constructs', + false, + array( + 'mappings' => $mappings, + 'keyLength' => $key_length, + 'constructed' => false, + ) + ); + } + + $failures = array(); + $read_details = array(); + foreach ( $mappings as $token => $replacement ) { + $text = 'pre|' . $token . '|post'; + $length = null; + $read = $map->read_token( $text, 4, $length ); + + $read_details[] = compact( 'token', 'replacement', 'read', 'length' ); + self::collect_failure( + $failures, + $map->contains( $token ) + && $replacement === $read + && strlen( $token ) === $length, + 'contains/read_token agree for exact generated token', + end( $read_details ) + ); + + if ( str_starts_with( $token, 'MiX' ) ) { + $case_variant = self::ascii_case_variant( $token ); + $case_length = null; + $case_read = $map->read_token( 'x' . $case_variant, 1, $case_length, 'ascii-case-insensitive' ); + self::collect_failure( + $failures, + $map->contains( $case_variant, 'ascii-case-insensitive' ) + && $replacement === $case_read + && strlen( $token ) === $case_length, + 'ascii-case-insensitive lookup matches ASCII variants', + array( + 'token' => $token, + 'caseVariant' => $case_variant, + 'read' => $case_read, + 'length' => $case_length, + ) + ); + } + } + + $miss_length = null; + $miss = $map->read_token( 'prefix-' . $ctx->identifier( 5, 9 ), 0, $miss_length ); + self::collect_failure( + $failures, + ! $map->contains( "nul\x00token" ) && null === $miss && null === $miss_length, + 'missing and null-containing tokens fail closed', + array( + 'miss' => $miss, + 'missLength' => $miss_length, + ) + ); + + $state = self::token_map_state( $map ); + $precomputed = \WP_Token_Map::from_precomputed_table( $state ); + $source = $map->precomputed_php_source_table( ' ' ); + $round_trips = $precomputed instanceof \WP_Token_Map; + foreach ( $mappings as $token => $replacement ) { + $length = null; + $round_trips = $round_trips + && $replacement === $precomputed->read_token( $token, 0, $length ) + && strlen( $token ) === $length; + } + + $exported = 2 === $key_length ? $map->to_array() : null; + $precomputed_a = 2 === $key_length && $precomputed instanceof \WP_Token_Map ? $precomputed->to_array() : null; + self::collect_failure( + $failures, + $precomputed instanceof \WP_Token_Map + && $round_trips + && ( 2 !== $key_length || ( $exported === $precomputed_a && array() === array_diff_assoc( $mappings, $exported ) ) ) + && str_contains( $source, \WP_Token_Map::STORAGE_VERSION ) + && str_contains( $source, '"key_length" => ' . $key_length ), + 'precomputed token-map state and source table round-trip lookup data', + array( + 'stateKeys' => array_keys( $state ), + 'exported' => $exported, + 'precomputed' => $precomputed_a, + 'sourcePreview' => substr( $source, 0, 220 ), + 'sourceContains' => str_contains( $source, \WP_Token_Map::STORAGE_VERSION ), + ) + ); + + return self::row( + $ctx, + 'utility-internals.token-map.lookup-precompute', + array() === $failures, + array( + 'keyLength' => $key_length, + 'mappings' => $mappings, + 'reads' => $read_details, + 'failures' => $failures, + ) + ); + } + + private static function check_matches_map_regex( \ComponentFuzz\FuzzContext $ctx ): array { + $matches = array( + 0 => $ctx->identifier( 3, 8 ), + 1 => $ctx->choice( array( 'alpha beta', 'slash/value', 'snow ☃', 'query&value' ) ), + 2 => $ctx->choice( array( '42', 'category/name', 'a+b', 'éclair' ) ), + 10 => $ctx->choice( array( 'ten value', 'deep/path', '100%' ) ), + ); + $subject = 'index.php?first=$matches[1]&second=$matches[2]&zero=$matches[0]&missing=$matches[7]&ten=$matches[10]'; + + $actual = \WP_MatchesMapRegex::apply( $subject, $matches ); + $expected = preg_replace_callback( + '/\$matches\[([1-9][0-9]*)\]/', + static function ( array $match ) use ( $matches ): string { + $index = (int) $match[1]; + return isset( $matches[ $index ] ) ? urlencode( $matches[ $index ] ) : ''; + }, + $subject + ); + + return self::row( + $ctx, + 'utility-internals.matches-map-regex.substitution', + $expected === $actual + && str_contains( $actual, '$matches[0]' ) + && ! str_contains( $actual, '$matches[7]' ) + && str_contains( $actual, urlencode( $matches[10] ) ), + array( + 'matches' => $matches, + 'subject' => $subject, + 'actual' => $actual, + 'expected' => $expected, + ) + ); + } + + private static function check_url_pattern_prefixer( \ComponentFuzz\FuzzContext $ctx ): array { + $slug = strtolower( str_replace( '_', '-', $ctx->identifier( 4, 8 ) ) ); + $contexts = array( + 'home' => '/front-' . $slug, + 'site' => '/core-' . $slug . '/', + 'special' => '/front:' . $slug . '?draft', + 'group' => '/set{' . $slug . '}', + ); + $prefixer = new \WP_URL_Pattern_Prefixer( $contexts ); + + $cases = array( + array( 'context' => 'home', 'pattern' => '/products/' . $slug . '/*' ), + array( 'context' => 'home', 'pattern' => '/front-' . $slug . '/already/*' ), + array( 'context' => 'site', 'pattern' => 'wp-admin/*' ), + array( 'context' => 'special', 'pattern' => '/next/:id' ), + array( 'context' => 'group', 'pattern' => '/literal/*' ), + ); + + $failures = array(); + $details = array(); + foreach ( $cases as $case ) { + $actual = $prefixer->prefix_path_pattern( $case['pattern'], $case['context'] ); + $twice = $prefixer->prefix_path_pattern( $actual, $case['context'] ); + $expected = self::reference_prefix_path_pattern( $case['pattern'], $contexts[ $case['context'] ] ); + $idempotent = self::context_can_strip_prefixed_output( $contexts[ $case['context'] ] ); + $twice_expected = $idempotent ? $actual : $twice; + $details[] = array( + 'case' => $case, + 'actual' => $actual, + 'twice' => $twice, + 'expected' => $expected, + 'idempotent' => $idempotent, + ); + self::collect_failure( + $failures, + $expected === $actual && $twice_expected === $twice, + 'URL pattern prefixer matches reference and does not double-prefix', + end( $details ) + ); + } + + return self::row( + $ctx, + 'utility-internals.url-pattern-prefixer.reference-idempotence', + array() === $failures, + array( + 'contexts' => $contexts, + 'cases' => $details, + 'failures' => $failures, + ) + ); + } + + private static function list_fixture( \ComponentFuzz\FuzzContext $ctx ): array { + $list = array(); + $types = array( 'post', 'page', 'nav', 'media' ); + $count = $ctx->int( 6, 10 ); + for ( $i = 0; $i < $count; $i++ ) { + $row = array( + 'id' => 100 + $i, + 'type' => $types[ ( $i + $ctx->int( 0, 3 ) ) % count( $types ) ], + 'active' => (string) ( $ctx->int( 0, 1 ) ), + 'score' => $ctx->int( 0, 99 ), + 'label' => 'Label ' . $ctx->identifier( 3, 7 ) . ' ' . $i, + ); + + $list[ 'k' . $i ] = $ctx->bool() ? (object) $row : $row; + } + + return $list; + } + + private static function reference_filter( array $list, array $args, string $operator ): array { + if ( array() === $args ) { + return $list; + } + + $operator = strtoupper( $operator ); + if ( ! in_array( $operator, array( 'AND', 'OR', 'NOT' ), true ) ) { + return array(); + } + + $count = count( $args ); + $out = array(); + foreach ( $list as $key => $item ) { + $matched = 0; + foreach ( $args as $field => $value ) { + if ( is_array( $item ) && array_key_exists( $field, $item ) && $value == $item[ $field ] ) { + ++$matched; + } elseif ( is_object( $item ) && isset( $item->{$field} ) && $value == $item->{$field} ) { + ++$matched; + } + } + + if ( + ( 'AND' === $operator && $matched === $count ) + || ( 'OR' === $operator && $matched > 0 ) + || ( 'NOT' === $operator && 0 === $matched ) + ) { + $out[ $key ] = $item; + } + } + + return $out; + } + + private static function reference_pluck( array $list, string $field, ?string $index_key = null ): array { + $out = array(); + if ( null === $index_key ) { + foreach ( $list as $key => $item ) { + $out[ $key ] = is_object( $item ) ? $item->{$field} : $item[ $field ]; + } + return $out; + } + + foreach ( $list as $item ) { + if ( is_object( $item ) ) { + if ( isset( $item->{$index_key} ) ) { + $out[ $item->{$index_key} ] = $item->{$field}; + } else { + $out[] = $item->{$field}; + } + } elseif ( isset( $item[ $index_key ] ) ) { + $out[ $item[ $index_key ] ] = $item[ $field ]; + } else { + $out[] = $item[ $field ]; + } + } + + return $out; + } + + private static function reference_sort( array $list, array $orderby, bool $preserve_keys ): array { + $callback = static function ( $a, $b ) use ( $orderby ): int { + $a = (array) $a; + $b = (array) $b; + + foreach ( $orderby as $field => $direction ) { + if ( ! isset( $a[ $field ] ) || ! isset( $b[ $field ] ) ) { + continue; + } + + if ( $a[ $field ] == $b[ $field ] ) { + continue; + } + + $results = 'DESC' === strtoupper( $direction ) ? array( 1, -1 ) : array( -1, 1 ); + if ( is_numeric( $a[ $field ] ) && is_numeric( $b[ $field ] ) ) { + return $a[ $field ] < $b[ $field ] ? $results[0] : $results[1]; + } + + return 0 > strcmp( $a[ $field ], $b[ $field ] ) ? $results[0] : $results[1]; + } + + return 0; + }; + + if ( $preserve_keys ) { + uasort( $list, $callback ); + } else { + usort( $list, $callback ); + } + + return $list; + } + + private static function token_mappings( \ComponentFuzz\FuzzContext $ctx ): array { + $base = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 4, 8 ) ) ); + if ( '' === $base ) { + $base = 'tok'; + } + + return array( + 'A' . $base => 'alpha-' . $base, + 'a' . $base . 'long' => 'long-' . $base, + $base . ';' => 'semi-' . $base, + 'Z' => 'zed-' . $base, + 'xy' => 'pair-' . $base, + 'MiX' . substr( $base, 0, 3 ) => 'case-' . $base, + ); + } + + private static function ascii_case_variant( string $token ): string { + $out = ''; + for ( $i = 0; $i < strlen( $token ); $i++ ) { + $char = $token[ $i ]; + $out .= ctype_alpha( $char ) + ? ( ctype_lower( $char ) ? strtoupper( $char ) : strtolower( $char ) ) + : $char; + } + return $out; + } + + private static function token_map_state( \WP_Token_Map $map ): array { + $reflection = new \ReflectionClass( $map ); + $state = array( 'storage_version' => \WP_Token_Map::STORAGE_VERSION ); + foreach ( array( 'key_length', 'groups', 'large_words', 'small_words', 'small_mappings' ) as $property ) { + $state[ $property ] = $reflection->getProperty( $property )->getValue( $map ); + } + return $state; + } + + private static function reference_prefix_path_pattern( string $path_pattern, string $context_path ): string { + $context_path = self::escape_pattern_string( trailingslashit( $context_path ) ); + $escaped_context_path = $context_path; + if ( strcspn( $context_path, ':?#' ) !== strlen( $context_path ) ) { + $escaped_context_path = '{' . substr( $context_path, 0, -1 ) . '}/'; + } + + if ( str_starts_with( $path_pattern, $context_path ) ) { + $path_pattern = substr( $path_pattern, strlen( $context_path ) ); + } + + return $escaped_context_path . ltrim( $path_pattern, '/' ); + } + + private static function context_can_strip_prefixed_output( string $context_path ): bool { + $context_path = self::escape_pattern_string( trailingslashit( $context_path ) ); + return strcspn( $context_path, ':?#' ) === strlen( $context_path ); + } + + private static function escape_pattern_string( string $str ): string { + return addcslashes( $str, '+*?:{}()\\' ); + } + + private static function same_list_values( array $expected, array $actual ): bool { + return self::normalize_list( $expected ) === self::normalize_list( $actual ); + } + + private static function normalize_list( array $list ): array { + $out = array(); + foreach ( $list as $key => $item ) { + $out[ (string) $key ] = (array) $item; + } + return $out; + } + + private static function summarize_list( array $list ): array { + $out = array(); + foreach ( $list as $key => $item ) { + $row = (array) $item; + $out[ $key ] = array( + 'id' => $row['id'] ?? null, + 'type' => $row['type'] ?? null, + 'active' => $row['active'] ?? null, + 'score' => $row['score'] ?? null, + 'label' => $row['label'] ?? null, + ); + } + return $out; + } + + private static function snapshot_state(): array { + $globals = array(); + foreach ( array( 'wp_actions', 'wp_current_filter', 'wp_filter', 'wp_filters' ) as $name ) { + $globals[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => $GLOBALS[ $name ] ?? null, + ); + } + return array( 'globals' => $globals ); + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function state_matches( array $snapshot ): bool { + return $snapshot === self::snapshot_state(); + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $data = array() ): void { + if ( ! $ok ) { + $failures[] = array( + 'message' => $message, + 'data' => $data, + ); + } + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} From ef7248cd3abd0e4550e7fc91d8bb27365329df77 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:24:46 +0200 Subject: [PATCH 0103/1102] Add error protection fuzz surface --- tools/component-fuzz/README.md | 6 + tools/component-fuzz/dashboard.html | 22 +- .../surfaces/ErrorProtectionSurface.php | 1339 +++++++++++++++++ 3 files changed, 1359 insertions(+), 8 deletions(-) create mode 100644 tools/component-fuzz/surfaces/ErrorProtectionSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 2c918579949b9..ad1450dd72700 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -139,6 +139,12 @@ database, network requests, or a configured site. WHATWG-style validity, `WP_Email_Address` IDN/punycode views, invalid UTF-8, malformed address structure, selected boundary lengths, and user email lookup/duplicate behavior for accent-distinct local parts. +- `error-protection`: no-shutdown error protection and recovery-mode + infrastructure coverage, including paused-extension source normalization and + storage, recovery key/cookie validation, recovery-link generation, filtered + recovery email payloads without real mail, fatal-error handler formatting + oracles, protected-endpoint gates, and explicit skips for redirects, + loopbacks, real fatal dispatch, and process exits. - `editor-helpers`: no-browser classic editor helper coverage for `_WP_Editors` settings/state normalization, default editor selection filters, teeny and full TinyMCE/Quicktags filter branches, captured editor markup, diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 8ddb0e9b2274d..b2c13b2e2dfd2 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -220,23 +220,23 @@

Component Fuzzers Project Dashboard

Registered Surfaces
-
94
-
README parity confirmed: 94 documented
+
95
+
README parity confirmed: 95 documented
Latest Broad Run
100%
-
Non-skipped pass rate: 9874 passed, 0 failed, 0 errored, 81 skipped at 94 surfaces over 3 iterations.
+
Non-skipped pass rate: 9898 passed, 0 failed, 0 errored, 84 skipped at 95 surfaces over 3 iterations.
Current Focused Run
100%
-
utility-internals: 500 passed, 0 skipped, 0 failed at 100 iterations.
+
error-protection: 800 passed, 100 skipped, 0 failed at 100 iterations.
Active Workers
5
-
Three delegated surfaces are active and two worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
+
One delegated surface is active and three worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
@@ -321,9 +321,9 @@

Current Work

error-protection - ready to integrate - Peirce: _work/component-fuzz-error-protection - Worker focused run: 800 passed, 100 skipped, 0 failed; smoke and diff check passed. + local validation passed + Main worktree + Main focused run: 800 passed, 100 skipped, 0 failed; broad run: 9898 passed, 84 skipped, 0 failed. Recovery mode, paused extensions, fatal handler guards, no real exits or mail. @@ -359,6 +359,12 @@

Recent Committed Additions

+ + 4745b98aa9 + utility-internals + 500 passed, 0 skipped, 0 failed. + 9874 passed, 81 skipped, 0 failed at 3 iterations. + fd3aca30cd image-metadata diff --git a/tools/component-fuzz/surfaces/ErrorProtectionSurface.php b/tools/component-fuzz/surfaces/ErrorProtectionSurface.php new file mode 100644 index 0000000000000..d95926aea87d0 --- /dev/null +++ b/tools/component-fuzz/surfaces/ErrorProtectionSurface.php @@ -0,0 +1,1339 @@ +skip( + 'error-protection.bootstrap-apis-available', + 'Required WordPress error protection APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime( $ctx ); + + $rows[] = self::check_paused_extension_source_and_storage( $ctx->fork( 'paused-extension-source' ) ); + $rows[] = self::check_recovery_key_validation( $ctx->fork( 'recovery-key-validation' ) ); + $rows[] = self::check_recovery_cookie_validation( $ctx->fork( 'recovery-cookie-validation' ) ); + $rows[] = self::check_recovery_link_generation( $ctx->fork( 'recovery-link-generation' ) ); + $rows[] = self::check_recovery_email_payload( $ctx->fork( 'recovery-email-payload' ) ); + $rows[] = self::check_fatal_error_handler_oracles( $ctx->fork( 'fatal-error-handler' ) ); + $rows[] = self::check_handler_and_endpoint_gates( $ctx->fork( 'handler-endpoint-gates' ) ); + $rows[] = $ctx->skip( + 'error-protection.process-control-paths-explicitly-skipped', + 'Real fatal/shutdown dispatch, recovery-mode redirects, process exits, and site-health loopback checks are intentionally not invoked by this pure PHP surface.' + ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'error-protection.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'error-protection.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot['globals'] ) ) + ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_Error', + 'WP_Fatal_Error_Handler', + 'WP_Paused_Extensions_Storage', + 'WP_Recovery_Mode', + 'WP_Recovery_Mode_Cookie_Service', + 'WP_Recovery_Mode_Email_Service', + 'WP_Recovery_Mode_Key_Service', + 'WP_Recovery_Mode_Link_Service', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_filter', + 'add_query_arg', + 'delete_option', + 'get_option', + 'has_filter', + 'home_url', + 'human_time_diff', + 'is_protected_endpoint', + 'is_wp_error', + 'remove_filter', + 'update_option', + 'wp_cache_delete', + 'wp_fast_hash', + 'wp_get_extension_error_description', + 'wp_is_fatal_error_handler_enabled', + 'wp_login_url', + 'wp_mail', + 'wp_normalize_path', + 'wp_paused_plugins', + 'wp_paused_themes', + 'wp_parse_url', + 'wp_recovery_mode', + 'wp_strip_all_tags', + 'wp_verify_fast_hash', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( + array( + 'AUTH_KEY', + 'AUTH_SALT', + 'COOKIE_DOMAIN', + 'COOKIEPATH', + 'DAY_IN_SECONDS', + 'RECOVERY_MODE_COOKIE', + 'SITECOOKIEPATH', + 'WEEK_IN_SECONDS', + 'WP_CONTENT_DIR', + 'WP_PLUGIN_DIR', + 'YEAR_IN_SECONDS', + ) as $constant + ) { + if ( ! defined( $constant ) ) { + $missing[] = "constant {$constant}"; + } + } + + return $missing; + } + + private static function check_paused_extension_source_and_storage( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_theme_directories; + + $failures = array(); + $mode_snapshot = self::snapshot_recovery_mode_state(); + $session_id = 'cf_error_protection_' . self::token( $ctx->fork( 'session' ), 18 ); + $option_name = $session_id . '_paused_extensions'; + + try { + self::set_recovery_mode_state( true, true, $session_id ); + \delete_option( $option_name ); + \wp_cache_delete( $option_name, 'options' ); + + $theme_root = WP_CONTENT_DIR . '/themes'; + if ( ! is_dir( $theme_root ) ) { + mkdir( $theme_root, 0777, true ); + } + $wp_theme_directories = array( $theme_root ); + + $mode = \wp_recovery_mode(); + $get_source = new \ReflectionMethod( \WP_Recovery_Mode::class, 'get_extension_for_error' ); + $store = new \ReflectionMethod( \WP_Recovery_Mode::class, 'store_error' ); + + $plugin_slug = 'plugin-' . self::token( $ctx->fork( 'plugin' ), 10 ); + $windows_plugin_slug = 'windows-' . self::token( $ctx->fork( 'windows-plugin' ), 8 ); + $theme_slug = 'theme-' . self::token( $ctx->fork( 'theme' ), 10 ); + + $plugin_error = self::extension_error_case( $ctx->fork( 'plugin-error' ), WP_PLUGIN_DIR . '/' . $plugin_slug . '/includes/fatal.php' ); + $windows_plugin_error = self::extension_error_case( + $ctx->fork( 'windows-plugin-error' ), + str_replace( '/', '\\', WP_PLUGIN_DIR . '/' . $windows_plugin_slug . '/main.php' ) + ); + $theme_error = self::extension_error_case( $ctx->fork( 'theme-error' ), $theme_root . '/' . $theme_slug . '/functions.php' ); + $outside_error = self::extension_error_case( $ctx->fork( 'outside-error' ), WP_CONTENT_DIR . '/uploads/' . $theme_slug . '/not-paused.php' ); + $missing_file_error = array( + 'type' => E_ERROR, + 'line' => 1, + 'message' => 'missing file key', + ); + + $plugin_source = $get_source->invoke( $mode, $plugin_error ); + $windows_plugin_source = $get_source->invoke( $mode, $windows_plugin_error ); + $theme_source = $get_source->invoke( $mode, $theme_error ); + $outside_source = $get_source->invoke( $mode, $outside_error ); + $missing_file_source = $get_source->invoke( $mode, $missing_file_error ); + + $stored_plugin = $store->invoke( $mode, $plugin_error ); + $after_first_store = \get_option( $option_name, array() ); + $stored_plugin_same = $store->invoke( $mode, $plugin_error ); + $after_same_store = \get_option( $option_name, array() ); + $stored_windows = $store->invoke( $mode, $windows_plugin_error ); + $stored_theme = $store->invoke( $mode, $theme_error ); + $stored_outside = $store->invoke( $mode, $outside_error ); + $stored_missing = $store->invoke( $mode, $missing_file_error ); + + $plugin_errors = \wp_paused_plugins()->get_all(); + $theme_errors = \wp_paused_themes()->get_all(); + + self::collect_failure( + $failures, + array( 'type' => 'plugin', 'slug' => $plugin_slug ) === $plugin_source + && array( 'type' => 'plugin', 'slug' => $windows_plugin_slug ) === $windows_plugin_source + && array( 'type' => 'theme', 'slug' => $theme_slug ) === $theme_source + && false === $outside_source + && false === $missing_file_source, + 'extension source detection normalizes paths to plugin/theme slugs and rejects unrelated files', + array( + 'pluginSource' => $plugin_source, + 'windowsPluginSource' => $windows_plugin_source, + 'themeSource' => $theme_source, + 'outsideSource' => $outside_source, + 'missingFileSource' => $missing_file_source, + ) + ); + + self::collect_failure( + $failures, + true === $stored_plugin + && true === $stored_plugin_same + && $after_first_store === $after_same_store + && true === $stored_windows + && true === $stored_theme + && false === $stored_outside + && false === $stored_missing + && isset( $plugin_errors[ $plugin_slug ], $plugin_errors[ $windows_plugin_slug ], $theme_errors[ $theme_slug ] ) + && $plugin_error === $plugin_errors[ $plugin_slug ] + && $windows_plugin_error === $plugin_errors[ $windows_plugin_slug ] + && $theme_error === $theme_errors[ $theme_slug ] + && ! isset( $plugin_errors[ $theme_slug ], $theme_errors[ $plugin_slug ] ), + 'store_error records normalized paused-extension keys once, isolates plugin/theme buckets, and fails closed for malformed sources', + array( + 'optionName' => $option_name, + 'storedPlugin' => $stored_plugin, + 'storedPluginSame' => $stored_plugin_same, + 'storedWindows' => $stored_windows, + 'storedTheme' => $stored_theme, + 'storedOutside' => $stored_outside, + 'storedMissing' => $stored_missing, + 'pluginErrors' => $plugin_errors, + 'themeErrors' => $theme_errors, + ) + ); + + $delete_missing = \wp_paused_plugins()->delete( 'missing-' . self::token( $ctx->fork( 'missing-delete' ), 8 ) ); + $delete_plugin = \wp_paused_plugins()->delete( $plugin_slug ); + $after_delete = \get_option( $option_name, array() ); + + self::collect_failure( + $failures, + true === $delete_missing + && true === $delete_plugin + && ! isset( $after_delete['plugin'][ $plugin_slug ] ) + && isset( $after_delete['plugin'][ $windows_plugin_slug ], $after_delete['theme'][ $theme_slug ] ), + 'paused-extension deletion is idempotent and scoped to the exact normalized key', + array( + 'deleteMissing' => $delete_missing, + 'deletePlugin' => $delete_plugin, + 'afterDelete' => $after_delete, + ) + ); + } finally { + self::restore_recovery_mode_state( $mode_snapshot ); + \delete_option( $option_name ); + \wp_cache_delete( $option_name, 'options' ); + } + + return self::row( + $ctx, + 'error-protection.paused-extensions.source-normalization-and-storage', + array() === $failures, + array( + 'sessionId' => $session_id, + 'failures' => array_slice( $failures, 0, 5 ), + ) + ); + } + + private static function check_recovery_key_validation( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $service = new \WP_Recovery_Mode_Key_Service(); + $ttl = 90 + $ctx->int( 0, 3600 ); + $token = 'token|' . self::token( $ctx->fork( 'token' ), 14 ); + + \delete_option( 'recovery_keys' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + + $generated_token = $service->generate_recovery_mode_token(); + self::collect_failure( + $failures, + 22 === strlen( $generated_token ) && ctype_alnum( $generated_token ), + 'generated recovery-mode tokens are 22-character alphanumeric identifiers', + array( 'generatedToken' => $generated_token ) + ); + + $key = $service->generate_and_store_recovery_mode_key( $token ); + $records = self::recovery_key_records(); + + self::collect_failure( + $failures, + is_string( $key ) + && 22 === strlen( $key ) + && ctype_alnum( $key ) + && isset( $records[ $token ]['hashed_key'], $records[ $token ]['created_at'] ) + && str_starts_with( (string) $records[ $token ]['hashed_key'], '$generic$' ) + && \wp_verify_fast_hash( $key, (string) $records[ $token ]['hashed_key'] ), + 'generated recovery-mode keys are stored as generic hashes under the supplied token', + array( + 'token' => $token, + 'key' => $key, + 'records' => $records, + ) + ); + + $valid_once = $service->validate_recovery_mode_key( $token, $key, $ttl ); + $valid_twice = $service->validate_recovery_mode_key( $token, $key, $ttl ); + $after_valid = self::recovery_key_records(); + $wrong_token = $token . '|wrong'; + $wrong_key = $service->generate_and_store_recovery_mode_key( $wrong_token ); + $wrong_result = $service->validate_recovery_mode_key( $wrong_token, $wrong_key . 'x', $ttl ); + $after_wrong = self::recovery_key_records(); + + self::collect_failure( + $failures, + true === $valid_once + && self::is_error_code( $valid_twice, 'token_not_found' ) + && ! isset( $after_valid[ $token ] ) + && self::is_error_code( $wrong_result, 'hash_mismatch' ) + && ! isset( $after_wrong[ $wrong_token ] ), + 'recovery-mode keys validate once, and wrong keys fail closed while consuming their token', + array( + 'validOnce' => $valid_once, + 'validTwice' => $valid_twice, + 'afterValid' => $after_valid, + 'wrongResult' => $wrong_result, + 'afterWrong' => $after_wrong, + ) + ); + + $expired_token = $token . '|expired'; + $malformed_token = $token . '|malformed'; + $missing_created = $token . '|missing-created'; + $fresh_token = $token . '|fresh'; + $old_clean_token = $token . '|old-clean'; + $empty_ttl_token = $token . '|empty-ttl'; + $expired_key = 'expired-key-' . self::token( $ctx->fork( 'expired-key' ), 8 ); + $empty_ttl_key = 'empty-ttl-key-' . self::token( $ctx->fork( 'empty-ttl-key' ), 8 ); + $current_time = time(); + + \update_option( + 'recovery_keys', + array( + $expired_token => array( + 'hashed_key' => \wp_fast_hash( $expired_key ), + 'created_at' => $current_time - $ttl - 5, + ), + $malformed_token => 'not-an-array', + $missing_created => array( + 'hashed_key' => \wp_fast_hash( 'missing-created-key' ), + ), + $fresh_token => array( + 'hashed_key' => \wp_fast_hash( 'fresh-key' ), + 'created_at' => $current_time, + ), + $old_clean_token => array( + 'hashed_key' => \wp_fast_hash( 'old-clean-key' ), + 'created_at' => $current_time - $ttl - 1, + ), + $empty_ttl_token => array( + 'hashed_key' => \wp_fast_hash( $empty_ttl_key ), + 'created_at' => $current_time, + ), + ), + false + ); + + $expired_result = $service->validate_recovery_mode_key( $expired_token, $expired_key, $ttl ); + $malformed_result = $service->validate_recovery_mode_key( $malformed_token, 'anything', $ttl ); + $empty_ttl_result = $service->validate_recovery_mode_key( $empty_ttl_token, $empty_ttl_key, 0 ); + $after_validates = self::recovery_key_records(); + + $service->clean_expired_keys( $ttl ); + $after_clean = self::recovery_key_records(); + + self::collect_failure( + $failures, + self::is_error_code( $expired_result, 'key_expired' ) + && self::is_error_code( $malformed_result, 'invalid_recovery_key_format' ) + && true === $empty_ttl_result + && ! isset( $after_validates[ $expired_token ], $after_validates[ $malformed_token ], $after_validates[ $empty_ttl_token ] ) + && isset( $after_clean[ $fresh_token ] ) + && ! isset( $after_clean[ $missing_created ], $after_clean[ $old_clean_token ] ), + 'expired, malformed, and cleanup paths remove invalid key records while preserving fresh records', + array( + 'expiredResult' => $expired_result, + 'malformedResult' => $malformed_result, + 'emptyTtlResult' => $empty_ttl_result, + 'afterValidates' => $after_validates, + 'afterClean' => $after_clean, + ) + ); + + \delete_option( 'recovery_keys' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + + return self::row( + $ctx, + 'error-protection.recovery-keys.single-use-and-malformed-records', + array() === $failures, + array( + 'ttl' => $ttl, + 'failures' => array_slice( $failures, 0, 5 ), + ) + ); + } + + private static function check_recovery_cookie_validation( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $service = new \WP_Recovery_Mode_Cookie_Service(); + + $generate_cookie = new \ReflectionMethod( \WP_Recovery_Mode_Cookie_Service::class, 'generate_cookie' ); + $recovery_hash = new \ReflectionMethod( \WP_Recovery_Mode_Cookie_Service::class, 'recovery_mode_hash' ); + + unset( $_COOKIE[ RECOVERY_MODE_COOKIE ] ); + + $cookie = (string) $generate_cookie->invoke( $service ); + $decoded = base64_decode( $cookie, true ); + $parts = is_string( $decoded ) ? explode( '|', $decoded ) : array(); + + self::collect_failure( + $failures, + is_string( $decoded ) + && 4 === count( $parts ) + && 'recovery_mode' === $parts[0] + && ctype_digit( $parts[1] ) + && '' !== $parts[2] + && 1 === preg_match( '/\A[a-f0-9]{40}\z/', $parts[3] ) + && true === $service->validate_cookie( $cookie ) + && sha1( $parts[2] ) === $service->get_session_id_from_cookie( $cookie ), + 'generated recovery cookies have four signed parts and resolve to the random session id', + array( + 'decoded' => is_string( $decoded ) ? $decoded : '', + 'parts' => $parts, + ) + ); + + $_COOKIE[ RECOVERY_MODE_COOKIE ] = $cookie; + $superglobal_valid = $service->validate_cookie(); + $superglobal_session = $service->get_session_id_from_cookie(); + unset( $_COOKIE[ RECOVERY_MODE_COOKIE ] ); + + self::collect_failure( + $failures, + true === $superglobal_valid + && sha1( $parts[2] ?? '' ) === $superglobal_session + && ! $service->is_cookie_set(), + 'cookie service validates from RECOVERY_MODE_COOKIE and leaves absent cookies unset after cleanup', + array( + 'superglobalValid' => $superglobal_valid, + 'superglobalSession' => $superglobal_session, + 'cookieSetAfter' => $service->is_cookie_set(), + ) + ); + + $created_at = (string) time(); + $random = 'rand-' . self::token( $ctx->fork( 'random' ), 14 ); + $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random ); + $signature = (string) $recovery_hash->invoke( $service, $to_sign ); + $signed_cookie = base64_encode( "{$to_sign}|{$signature}" ); + $tampered_cookie = base64_encode( "{$to_sign}|" . self::mutate_hex( $signature ) ); + $expired_created = (string) ( time() - WEEK_IN_SECONDS - 5 ); + $expired_random = 'expired-' . self::token( $ctx->fork( 'expired-random' ), 12 ); + $expired_to_sign = sprintf( 'recovery_mode|%s|%s', $expired_created, $expired_random ); + $expired_signature = (string) $recovery_hash->invoke( $service, $expired_to_sign ); + $expired_cookie = base64_encode( "{$expired_to_sign}|{$expired_signature}" ); + $invalid_format = base64_encode( 'one|two|three' ); + $invalid_created = base64_encode( 'recovery_mode|not-digits|random|signature' ); + $not_base64 = '%%%not-base64%%%'; + + $no_cookie_result = $service->validate_cookie(); + $signed_result = $service->validate_cookie( $signed_cookie ); + $tampered_result = $service->validate_cookie( $tampered_cookie ); + $expired_result = $service->validate_cookie( $expired_cookie ); + $format_result = $service->validate_cookie( $invalid_format ); + $created_result = $service->validate_cookie( $invalid_created ); + $not_base64_result = $service->validate_cookie( $not_base64 ); + $malformed_session = $service->get_session_id_from_cookie( $invalid_format ); + $tampered_session_id = $service->get_session_id_from_cookie( $tampered_cookie ); + $wrong_prefix_accepted = null; + + if ( 4 === count( $parts ) ) { + $wrong_prefix_parts = $parts; + $wrong_prefix_parts[0] = 'not_recovery_mode'; + $wrong_prefix_accepted = true === $service->validate_cookie( base64_encode( implode( '|', $wrong_prefix_parts ) ) ); + } + + self::collect_failure( + $failures, + self::is_error_code( $no_cookie_result, 'no_cookie' ) + && true === $signed_result + && self::is_error_code( $tampered_result, 'signature_mismatch' ) + && self::is_error_code( $expired_result, 'expired' ) + && self::is_error_code( $format_result, 'invalid_format' ) + && self::is_error_code( $created_result, 'invalid_created_at' ) + && self::is_error_code( $not_base64_result, 'invalid_format' ) + && self::is_error_code( $malformed_session, 'invalid_format' ) + && sha1( $random ) === $tampered_session_id, + 'cookie validation rejects absent, tampered, expired, and malformed cookies; session-id parsing remains format-only', + array( + 'noCookie' => $no_cookie_result, + 'signedResult' => $signed_result, + 'tamperedResult' => $tampered_result, + 'expiredResult' => $expired_result, + 'formatResult' => $format_result, + 'createdResult' => $created_result, + 'notBase64Result' => $not_base64_result, + 'malformedSession' => $malformed_session, + 'tamperedSessionId' => $tampered_session_id, + 'wrongPrefixAccepted' => $wrong_prefix_accepted, + ) + ); + + return self::row( + $ctx, + 'error-protection.recovery-cookies.signed-shape-and-fail-closed-validation', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_recovery_link_generation( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $key_service = new \WP_Recovery_Mode_Key_Service(); + $cookie_service = new \WP_Recovery_Mode_Cookie_Service(); + $link_service = new \WP_Recovery_Mode_Link_Service( $cookie_service, $key_service ); + $marker = 'link-' . self::token( $ctx->fork( 'marker' ), 8 ); + $captured = array(); + $ttl = DAY_IN_SECONDS + $ctx->int( 0, 3600 ); + + \delete_option( 'recovery_keys' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + + $url_filter = static function ( string $url, string $token, string $key ) use ( &$captured, $marker ): string { + $captured[] = array( + 'url' => $url, + 'token' => $token, + 'key' => $key, + ); + + return \add_query_arg( 'component_fuzz_marker', $marker, $url ); + }; + + \add_filter( 'recovery_mode_begin_url', $url_filter, 10, 3 ); + try { + $url = $link_service->generate_url(); + } finally { + \remove_filter( 'recovery_mode_begin_url', $url_filter, 10 ); + } + + $parts = \wp_parse_url( $url ); + $query = array(); + if ( is_array( $parts ) && isset( $parts['query'] ) ) { + parse_str( $parts['query'], $query ); + } + + $token = (string) ( $query['rm_token'] ?? '' ); + $key = (string) ( $query['rm_key'] ?? '' ); + $records = self::recovery_key_records(); + $valid = '' !== $token && '' !== $key ? $key_service->validate_recovery_mode_key( $token, $key, $ttl ) : null; + $reused = '' !== $token && '' !== $key ? $key_service->validate_recovery_mode_key( $token, $key, $ttl ) : null; + + self::collect_failure( + $failures, + 1 === count( $captured ) + && is_array( $parts ) + && isset( $parts['host'], $parts['path'] ) + && 'example.test' === $parts['host'] + && str_ends_with( $parts['path'], 'wp-login.php' ) + && \WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTER === ( $query['action'] ?? null ) + && $marker === ( $query['component_fuzz_marker'] ?? null ) + && 22 === strlen( $token ) + && 22 === strlen( $key ) + && ctype_alnum( $token ) + && ctype_alnum( $key ) + && isset( $records[ $token ]['hashed_key'], $records[ $token ]['created_at'] ) + && true === $valid + && self::is_error_code( $reused, 'token_not_found' ) + && false === \has_filter( 'recovery_mode_begin_url', $url_filter ), + 'generated recovery links carry a one-use token/key pair through the documented login action query string', + array( + 'url' => $url, + 'captured' => $captured, + 'query' => $query, + 'records' => $records, + 'valid' => $valid, + 'reused' => $reused, + ) + ); + + \delete_option( 'recovery_keys' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + + return self::row( + $ctx, + 'error-protection.recovery-links.generate-parse-and-consume', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_recovery_email_payload( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $key_service = new \WP_Recovery_Mode_Key_Service(); + $cookie_service = new \WP_Recovery_Mode_Cookie_Service(); + $link_service = new \WP_Recovery_Mode_Link_Service( $cookie_service, $key_service ); + $email_service = new \WP_Recovery_Mode_Email_Service( $link_service ); + $token = 'email-' . self::token( $ctx->fork( 'token' ), 8 ); + $admin_email = 'admin+' . self::token( $ctx->fork( 'admin' ), 8 ) . '@example.test'; + $blog_name = 'Component Fuzz Recovery ' . $token; + $plugin_slug = 'mail-plugin-' . self::token( $ctx->fork( 'plugin' ), 8 ); + $rate_limit = 120 + $ctx->int( 0, 3600 ); + $captured_mail = array(); + $captured_email = array(); + + \delete_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION ); + \delete_option( 'recovery_keys' ); + \update_option( 'admin_email', $admin_email, false ); + \update_option( 'blogname', $blog_name, false ); + \wp_cache_delete( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, 'options' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + + $_SERVER['REQUEST_URI'] = '/wp-admin/plugins.php?page=' . rawurlencode( $token ); + + $error = self::extension_error_case( $ctx->fork( 'email-error' ), WP_PLUGIN_DIR . '/' . $plugin_slug . '/main.php' ); + $extension = array( + 'type' => 'plugin', + 'slug' => $plugin_slug, + ); + + $support_filter = static function ( string $message ) use ( $token ): string { + return $message . "\nSupport token: {$token}"; + }; + $debug_filter = static function ( array $debug ) use ( $token ): array { + $debug['component_fuzz'] = 'Debug token: ' . $token; + return $debug; + }; + $email_filter = static function ( array $email, string $url ) use ( &$captured_email, $token ): array { + $captured_email[] = array( + 'email' => $email, + 'url' => $url, + ); + + $email['subject'] .= ' [' . $token . ']'; + $email['message'] .= "\n\nFiltered token: {$token}"; + $email['headers'] = array( 'X-Recovery-Fuzz: ' . $token ); + + return $email; + }; + $pre_mail = static function ( $pre, array $atts ) use ( &$captured_mail ) { + $captured_mail[] = $atts; + return true; + }; + + \add_filter( 'recovery_email_support_info', $support_filter ); + \add_filter( 'recovery_email_debug_info', $debug_filter ); + \add_filter( 'recovery_mode_email', $email_filter, 10, 2 ); + \add_filter( 'pre_wp_mail', $pre_mail, 10, 2 ); + try { + $sent = $email_service->maybe_send_recovery_mode_email( $rate_limit, $error, $extension ); + $last_sent_after_first = \get_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, false ); + \update_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, time(), false ); + \wp_cache_delete( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, 'options' ); + $rate_limited = $email_service->maybe_send_recovery_mode_email( $rate_limit, $error, $extension ); + $cleared_limit = $email_service->clear_rate_limit(); + } finally { + \remove_filter( 'recovery_email_support_info', $support_filter ); + \remove_filter( 'recovery_email_debug_info', $debug_filter ); + \remove_filter( 'recovery_mode_email', $email_filter, 10 ); + \remove_filter( 'pre_wp_mail', $pre_mail, 10 ); + } + + $mail = $captured_mail[0] ?? array(); + $email_url = (string) ( $captured_email[0]['url'] ?? '' ); + $url_parts = \wp_parse_url( $email_url ); + $url_query = array(); + if ( is_array( $url_parts ) && isset( $url_parts['query'] ) ) { + parse_str( $url_parts['query'], $url_query ); + } + $mail_message = (string) ( $mail['message'] ?? '' ); + $records = self::recovery_key_records(); + $url_token = (string) ( $url_query['rm_token'] ?? '' ); + + self::collect_failure( + $failures, + true === $sent + && 1 === count( $captured_mail ) + && 1 === count( $captured_email ) + && ( false === ( $mail['to'] ?? null ) || ( is_string( $mail['to'] ?? null ) && str_contains( (string) $mail['to'], '@' ) ) ), + 'recovery email is intercepted before real mail and has a bounded recipient target', + array( + 'sent' => $sent, + 'mailCount' => count( $captured_mail ), + 'capturedCount' => count( $captured_email ), + 'mailTo' => $mail['to'] ?? null, + 'adminEmail' => $admin_email, + ) + ); + + self::collect_failure( + $failures, + is_string( $mail['subject'] ?? null ) + && str_contains( (string) $mail['subject'], '[' . $blog_name . ']' ) + && str_contains( (string) $mail['subject'], $token ) + && str_contains( $mail_message, $email_url ) + && str_contains( $mail_message, 'Support token: ' . $token ) + && str_contains( $mail_message, 'Debug token: ' . $token ) + && str_contains( $mail_message, 'Filtered token: ' . $token ) + && str_contains( $mail_message, 'Error Details' ) + && str_contains( $mail_message, WP_PLUGIN_DIR . '/' . $plugin_slug . '/main.php' ) + && ! str_contains( $mail_message, '' ) + && in_array( 'X-Recovery-Fuzz: ' . $token, (array) ( $mail['headers'] ?? array() ), true ), + 'recovery email payload is filterable and includes generated support/debug/error details', + array( + 'subject' => $mail['subject'] ?? null, + 'headers' => $mail['headers'] ?? null, + 'message' => $mail_message, + 'emailUrl' => $email_url, + ) + ); + + self::collect_failure( + $failures, + \WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTER === ( $url_query['action'] ?? null ) + && isset( $url_query['rm_token'], $url_query['rm_key'], $records[ $url_token ] ), + 'recovery email URL carries a stored recovery token/key pair', + array( + 'urlQuery' => $url_query, + 'records' => $records, + ) + ); + + self::collect_failure( + $failures, + self::is_error_code( $rate_limited, 'email_sent_already' ) + && true === $cleared_limit + && false === \get_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, false ) + && 1 === count( $captured_mail ), + 'recovery email repeat call is rate-limited and clear_rate_limit removes the shared option', + array( + 'rateLimited' => $rate_limited, + 'rateLimitedCode' => \is_wp_error( $rate_limited ) ? $rate_limited->get_error_code() : null, + 'clearedLimit' => $cleared_limit, + 'optionAfterClear' => \get_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, false ), + 'mailCount' => count( $captured_mail ), + 'lastSentAfterFirst' => $last_sent_after_first, + ) + ); + + \delete_option( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION ); + \delete_option( 'recovery_keys' ); + \wp_cache_delete( \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION, 'options' ); + \wp_cache_delete( 'recovery_keys', 'options' ); + + return self::row( + $ctx, + 'error-protection.recovery-email.filterable-payload-no-real-mail', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_fatal_error_handler_oracles( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $handler = new \WP_Fatal_Error_Handler(); + $should = new \ReflectionMethod( \WP_Fatal_Error_Handler::class, 'should_handle_error' ); + $display = new \ReflectionMethod( \WP_Fatal_Error_Handler::class, 'display_default_error_template' ); + $marker = 'fatal-' . self::token( $ctx->fork( 'marker' ), 8 ); + $error = self::extension_error_case( $ctx->fork( 'fatal-error' ), WP_PLUGIN_DIR . '/fatal-' . self::token( $ctx->fork( 'plugin' ), 6 ) . '/main.php' ); + $error['type'] = E_PARSE; + + $core_types_ok = true; + foreach ( array( E_ERROR, E_PARSE, E_USER_ERROR, E_COMPILE_ERROR, E_RECOVERABLE_ERROR ) as $type ) { + $core_types_ok = $core_types_ok && true === $should->invoke( + $handler, + array( + 'type' => $type, + 'file' => $error['file'], + 'line' => $error['line'], + 'message' => $error['message'], + ) + ); + } + + $warning_default = $should->invoke( + $handler, + array( + 'type' => E_WARNING, + 'file' => $error['file'], + 'line' => $error['line'], + 'message' => 'ordinary warning', + ) + ); + + $filter_calls = 0; + $error_filter = static function ( bool $should_handle, array $filtered_error ) use ( &$filter_calls, $marker ): bool { + ++$filter_calls; + return $should_handle || str_contains( (string) ( $filtered_error['message'] ?? '' ), $marker ); + }; + + \add_filter( 'wp_should_handle_php_error', $error_filter, 10, 2 ); + try { + $warning_filtered = $should->invoke( + $handler, + array( + 'type' => E_WARNING, + 'file' => $error['file'], + 'line' => $error['line'], + 'message' => 'filtered ' . $marker, + ) + ); + $fatal_filtered = $should->invoke( + $handler, + array( + 'type' => E_ERROR, + 'file' => $error['file'], + 'line' => $error['line'], + 'message' => 'fatal ' . $marker, + ) + ); + } finally { + \remove_filter( 'wp_should_handle_php_error', $error_filter, 10 ); + } + + self::collect_failure( + $failures, + $core_types_ok + && false === $warning_default + && true === $warning_filtered + && true === $fatal_filtered + && 1 === $filter_calls + && false === \has_filter( 'wp_should_handle_php_error', $error_filter ), + 'fatal handler recognizes core fatal types and only consults the extension filter for non-core error types', + array( + 'coreTypesOk' => $core_types_ok, + 'warningDefault' => $warning_default, + 'warningFiltered' => $warning_filtered, + 'fatalFiltered' => $fatal_filtered, + 'filterCalls' => $filter_calls, + ) + ); + + $description = \wp_get_extension_error_description( $error ); + $unknown = $error; + $unknown['type'] = 123456; + $unknown_description = \wp_get_extension_error_description( $unknown ); + + self::collect_failure( + $failures, + str_contains( $description, 'E_PARSE' ) + && str_contains( $description, '' . $error['line'] . '' ) + && str_contains( $description, '' . $error['file'] . '' ) + && str_contains( $description, '' . $error['message'] . '' ) + && str_contains( $unknown_description, '123456' ), + 'extension error descriptions map known PHP constants and preserve unknown numeric types', + array( + 'description' => $description, + 'unknownDescription' => $unknown_description, + ) + ); + + $die_calls = array(); + $message_filter = static function ( string $message, array $display_error ) use ( $marker, &$die_calls ): string { + $die_calls['message_filter_error'] = $display_error; + return $message . '

filtered

'; + }; + $args_filter = static function ( array $args, array $display_error ) use ( $marker, &$die_calls ): array { + $die_calls['args_filter_error'] = $display_error; + $args['response'] = 599; + $args['exit'] = false; + $args['component_fuzz_marker'] = $marker; + return $args; + }; + $die_handler = static function ( $message, string $title, array $args ) use ( &$die_calls ): void { + $die_calls['handler'][] = array( + 'message' => $message, + 'title' => $title, + 'args' => $args, + ); + }; + $die_filter = static function () use ( $die_handler ): callable { + return $die_handler; + }; + + self::set_recovery_mode_state( false, false, '' ); + \add_filter( 'wp_php_error_message', $message_filter, 10, 2 ); + \add_filter( 'wp_php_error_args', $args_filter, 10, 2 ); + \add_filter( 'wp_die_handler', $die_filter ); + ob_start(); + try { + $display->invoke( $handler, $error, false ); + $output = ob_get_clean(); + } finally { + if ( ob_get_level() > 0 ) { + ob_end_clean(); + } + \remove_filter( 'wp_php_error_message', $message_filter, 10 ); + \remove_filter( 'wp_php_error_args', $args_filter, 10 ); + \remove_filter( 'wp_die_handler', $die_filter ); + } + + $handler_call = $die_calls['handler'][0] ?? array(); + $wp_error = $handler_call['message'] ?? null; + + self::collect_failure( + $failures, + '' === $output + && $wp_error instanceof \WP_Error + && 'internal_server_error' === $wp_error->get_error_code() + && $error === ( $wp_error->get_error_data()['error'] ?? null ) + && str_contains( $wp_error->get_error_message(), $marker ) + && 599 === ( $handler_call['args']['response'] ?? null ) + && false === ( $handler_call['args']['exit'] ?? true ) + && $marker === ( $handler_call['args']['component_fuzz_marker'] ?? null ) + && $error === ( $die_calls['message_filter_error'] ?? null ) + && $error === ( $die_calls['args_filter_error'] ?? null ) + && false === \has_filter( 'wp_die_handler', $die_filter ), + 'default fatal error template delegates a WP_Error to wp_die with filterable message and non-exiting args', + array( + 'output' => $output, + 'handlerCall' => $handler_call, + 'dieCalls' => $die_calls, + ) + ); + + return self::row( + $ctx, + 'error-protection.fatal-handler.detection-description-and-template', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_handler_and_endpoint_gates( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $marker = 'endpoint-' . self::token( $ctx->fork( 'marker' ), 8 ); + + $default_enabled = \wp_is_fatal_error_handler_enabled(); + $enabled_filter = static function () use ( $marker ): bool { + return 'never-' . $marker === $marker; + }; + + \add_filter( 'wp_fatal_error_handler_enabled', $enabled_filter ); + try { + $filtered_enabled = \wp_is_fatal_error_handler_enabled(); + } finally { + \remove_filter( 'wp_fatal_error_handler_enabled', $enabled_filter ); + } + + unset( $GLOBALS['current_screen'] ); + $GLOBALS['pagenow'] = 'index.php'; + $_REQUEST = array(); + $plain_endpoint = \is_protected_endpoint(); + + $GLOBALS['pagenow'] = 'wp-login.php'; + $login_endpoint = \is_protected_endpoint(); + + $GLOBALS['pagenow'] = 'index.php'; + $endpoint_filter = static function () use ( $marker ): bool { + return str_starts_with( $marker, 'endpoint-' ); + }; + \add_filter( 'is_protected_endpoint', $endpoint_filter ); + try { + $filtered_endpoint = \is_protected_endpoint(); + } finally { + \remove_filter( 'is_protected_endpoint', $endpoint_filter ); + } + + $noop_handler = new class() extends \WP_Fatal_Error_Handler { + public int $detect_calls = 0; + public int $display_calls = 0; + + protected function detect_error() { + ++$this->detect_calls; + return null; + } + + protected function display_error_template( $error, $handled ) { + unset( $error, $handled ); + ++$this->display_calls; + } + }; + $noop_handler->handle(); + $noop_handler->handle(); + + self::collect_failure( + $failures, + true === $default_enabled + && false === $filtered_enabled + && false === \has_filter( 'wp_fatal_error_handler_enabled', $enabled_filter ) + && false === $plain_endpoint + && true === $login_endpoint + && true === $filtered_endpoint + && false === \has_filter( 'is_protected_endpoint', $endpoint_filter ) + && 2 === $noop_handler->detect_calls + && 0 === $noop_handler->display_calls, + 'fatal-handler and protected-endpoint gates are filterable, restored, and no-error shutdown handling is idempotent', + array( + 'defaultEnabled' => $default_enabled, + 'filteredEnabled' => $filtered_enabled, + 'plainEndpoint' => $plain_endpoint, + 'loginEndpoint' => $login_endpoint, + 'filteredEndpoint' => $filtered_endpoint, + 'detectCalls' => $noop_handler->detect_calls, + 'displayCalls' => $noop_handler->display_calls, + ) + ); + + return self::row( + $ctx, + 'error-protection.handler-gates.endpoint-classification-and-noop-idempotence', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function reset_runtime( \ComponentFuzz\FuzzContext $ctx ): void { + $_GET = array(); + $_POST = array(); + $_REQUEST = array(); + $_COOKIE = array(); + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTPS'] = 'off'; + $_SERVER['PHP_SELF'] = '/wp-admin/index.php'; + $_SERVER['REMOTE_ADDR'] = '198.51.100.' . $ctx->int( 1, 254 ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/wp-admin/index.php?page=component-fuzz-error-protection'; + $_SERVER['SERVER_PORT'] = '80'; + $_SERVER['HTTP_USER_AGENT'] = 'component-fuzz/error-protection'; + + unset( $GLOBALS['current_screen'] ); + $GLOBALS['pagenow'] = 'index.php'; + $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes' ); + + \update_option( 'home', 'http://example.test', false ); + \update_option( 'siteurl', 'http://example.test', false ); + \update_option( 'admin_email', 'admin@example.test', false ); + \update_option( 'blogname', 'Component Fuzz', false ); + + foreach ( array( 'recovery_keys', \WP_Recovery_Mode_Email_Service::RATE_LIMIT_OPTION ) as $option ) { + \delete_option( $option ); + \wp_cache_delete( $option, 'options' ); + } + + self::set_recovery_mode_state( false, false, '' ); + } + + private static function extension_error_case( \ComponentFuzz\FuzzContext $ctx, string $file ): array { + return array( + 'type' => $ctx->choice( array( E_ERROR, E_PARSE, E_USER_ERROR, E_COMPILE_ERROR, E_RECOVERABLE_ERROR ) ), + 'file' => $file, + 'line' => $ctx->int( 1, 5000 ), + 'message' => 'Component fuzz fatal ' . self::token( $ctx->fork( 'message' ), 10 ), + ); + } + + private static function recovery_key_records(): array { + $records = \get_option( 'recovery_keys', array() ); + return is_array( $records ) ? $records : array(); + } + + private static function is_error_code( $value, string $code ): bool { + return \is_wp_error( $value ) && $code === $value->get_error_code(); + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details = array() ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array(), ?string $status = null ): array { + return array( + 'ok' => $ok, + 'status' => $status ?? ( $ok ? 'passed' : 'failed' ), + 'surface' => self::NAME, + 'invariant' => $invariant, + 'seed' => $ctx->seed(), + 'iteration' => $ctx->iteration(), + 'data' => self::describe_value( $data ), + ); + } + + private static function token( \ComponentFuzz\FuzzContext $ctx, int $length ): string { + $alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + $out = ''; + + for ( $i = 0; $i < $length; ++$i ) { + $out .= $alphabet[ $ctx->int( 0, strlen( $alphabet ) - 1 ) ]; + } + + return $out; + } + + private static function mutate_hex( string $hex ): string { + if ( '' === $hex ) { + return '0'; + } + + return ( '0' === $hex[0] ? '1' : '0' ) . substr( $hex, 1 ); + } + + private static function snapshot_recovery_mode_state(): array { + $mode = \wp_recovery_mode(); + $state = array(); + + foreach ( array( 'is_initialized', 'is_active', 'session_id' ) as $property ) { + $reflection = new \ReflectionProperty( \WP_Recovery_Mode::class, $property ); + $state[ $property ] = $reflection->getValue( $mode ); + } + + return $state; + } + + private static function set_recovery_mode_state( bool $initialized, bool $active, string $session_id ): void { + $mode = \wp_recovery_mode(); + + foreach ( + array( + 'is_initialized' => $initialized, + 'is_active' => $active, + 'session_id' => $active ? $session_id : '', + ) as $property => $value + ) { + $reflection = new \ReflectionProperty( \WP_Recovery_Mode::class, $property ); + $reflection->setValue( $mode, $value ); + } + } + + private static function restore_recovery_mode_state( array $state ): void { + $mode = \wp_recovery_mode(); + + foreach ( $state as $property => $value ) { + $reflection = new \ReflectionProperty( \WP_Recovery_Mode::class, $property ); + $reflection->setValue( $mode, $value ); + } + } + + private static function snapshot_state(): array { + $snapshot = array( + '_GET' => $_GET, + '_POST' => $_POST, + '_REQUEST' => $_REQUEST, + '_COOKIE' => $_COOKIE, + '_SERVER' => $_SERVER, + 'globals' => array(), + 'recovery' => self::snapshot_recovery_mode_state(), + ); + + foreach ( + array( + '_paused_plugins', + '_paused_themes', + 'current_screen', + 'pagenow', + 'phpmailer', + 'wpdb', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_locale', + 'wp_locale_switcher', + 'wp_object_cache', + 'wp_plugin_paths', + 'wp_theme_directories', + ) as $name + ) { + $snapshot['globals'][ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + $_GET = $snapshot['_GET']; + $_POST = $snapshot['_POST']; + $_REQUEST = $snapshot['_REQUEST']; + $_COOKIE = $snapshot['_COOKIE']; + $_SERVER = $snapshot['_SERVER']; + + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + + self::restore_recovery_mode_state( $snapshot['recovery'] ); + } + + private static function state_matches( array $snapshot ): bool { + if ( $_GET !== $snapshot['_GET'] || $_POST !== $snapshot['_POST'] || $_REQUEST !== $snapshot['_REQUEST'] || $_COOKIE !== $snapshot['_COOKIE'] || $_SERVER !== $snapshot['_SERVER'] ) { + return false; + } + + foreach ( $snapshot['globals'] as $name => $entry ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $entry['exists'] ) { + return false; + } + + if ( $exists && $GLOBALS[ $name ] != $entry['value'] ) { + return false; + } + } + + return self::snapshot_recovery_mode_state() === $snapshot['recovery']; + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + if ( $value instanceof \Closure ) { + return $value; + } + + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } + + private static function describe_value( $value, int $depth = 0 ) { + if ( is_string( $value ) ) { + return self::describe_string( $value ); + } + + if ( is_array( $value ) ) { + if ( $depth >= 4 ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + ); + } + + $out = array(); + $i = 0; + foreach ( $value as $key => $item ) { + if ( $i >= 16 ) { + $out['...'] = count( $value ) - $i; + break; + } + + $out[ is_int( $key ) ? $key : self::escape_bytes( (string) $key ) ] = self::describe_value( $item, $depth + 1 ); + ++$i; + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Throwable ) { + return self::describe_throwable( $value ); + } + + if ( $value instanceof \WP_Error ) { + return array( + 'type' => 'WP_Error', + 'code' => $value->get_error_code(), + 'message' => $value->get_error_message(), + 'data' => self::describe_value( $value->get_error_data(), $depth + 1 ), + ); + } + + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + + return $value; + } + + private static function describe_string( string $value ): array { + return array( + 'type' => 'string', + 'bytes' => strlen( $value ), + 'sha1' => sha1( $value ), + 'preview' => self::escape_bytes( $value ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => self::escape_bytes( $e->getMessage() ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function escape_bytes( string $value, int $limit = self::SAMPLE_BYTES ): string { + $out = ''; + $length = strlen( $value ); + $shown = min( $length, $limit ); + + for ( $i = 0; $i < $shown; ++$i ) { + $byte = ord( $value[ $i ] ); + if ( 0x5C === $byte ) { + $out .= '\\\\'; + } elseif ( $byte >= 0x20 && $byte <= 0x7E ) { + $out .= chr( $byte ); + } elseif ( 0x0A === $byte ) { + $out .= '\\n'; + } elseif ( 0x0D === $byte ) { + $out .= '\\r'; + } elseif ( 0x09 === $byte ) { + $out .= '\\t'; + } else { + $out .= sprintf( '\\x%02X', $byte ); + } + } + + if ( $length > $shown ) { + $out .= '...'; + } + + return $out; + } +} From fb12f51f200322803e0be1b17bede6e3a3862232 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:27:01 +0200 Subject: [PATCH 0104/1102] Add feed parser fuzz surface --- tools/component-fuzz/README.md | 5 + tools/component-fuzz/dashboard.html | 22 +- .../surfaces/FeedParsersSurface.php | 1287 +++++++++++++++++ 3 files changed, 1306 insertions(+), 8 deletions(-) create mode 100644 tools/component-fuzz/surfaces/FeedParsersSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index ad1450dd72700..d2a1bc29969d4 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -158,6 +158,11 @@ database, network requests, or a configured site. - `formatting`: escaping helpers, text sanitizers, whitespace normalization, autop/shortcode cleanup, clickable text, entity normalization, colors, sizes, time strings, UTF-8 helpers, and accent removal. +- `feed-parsers`: local RSS/Atom parser and legacy feed utility API coverage, + including bounded malformed fixtures, Magpie item/channel normalization, + AtomParser local-file behavior, SimplePie raw-data parsing and KSES + sanitization, transient-backed feed cache boundaries, date/status helpers, + local file adapter guards, no-network assertions, and state restoration. - `feed-rendering`: no-DB RSS2, Atom, and comments RSS2 feed template rendering over synthetic query loops, including feed item/entry counts, self links, CDATA terminator escaping, excerpt/content mode switches, enclosure metadata, diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index b2c13b2e2dfd2..7f4d5b43dd85f 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -220,23 +220,23 @@

Component Fuzzers Project Dashboard

Registered Surfaces
-
95
-
README parity confirmed: 95 documented
+
96
+
README parity confirmed: 96 documented
Latest Broad Run
100%
-
Non-skipped pass rate: 9898 passed, 0 failed, 0 errored, 84 skipped at 95 surfaces over 3 iterations.
+
Non-skipped pass rate: 9922 passed, 0 failed, 0 errored, 84 skipped at 96 surfaces over 3 iterations.
Current Focused Run
100%
-
error-protection: 800 passed, 100 skipped, 0 failed at 100 iterations.
+
feed-parsers: 800 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
5
-
One delegated surface is active and three worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
+
One delegated surface is active and two worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
@@ -328,9 +328,9 @@

Current Work

feed-parsers - ready to integrate - Mencius: _work/component-fuzz-feed-parsers - Worker focused run: 800 passed, 0 skipped, 0 failed; smoke and diff check passed. + local validation passed + Main worktree + Main focused run: 800 passed, 0 skipped, 0 failed; broad run: 9922 passed, 84 skipped, 0 failed. RSS/Atom parsers, SimplePie adapters, bounded XML fixtures, temp cache paths. @@ -359,6 +359,12 @@

Recent Committed Additions

+ + ef7248cd3a + error-protection + 800 passed, 100 skipped, 0 failed. + 9898 passed, 84 skipped, 0 failed at 3 iterations. + 4745b98aa9 utility-internals diff --git a/tools/component-fuzz/surfaces/FeedParsersSurface.php b/tools/component-fuzz/surfaces/FeedParsersSurface.php new file mode 100644 index 0000000000000..b37899f92e41f --- /dev/null +++ b/tools/component-fuzz/surfaces/FeedParsersSurface.php @@ -0,0 +1,1287 @@ +> */ + private static array $http_requests = array(); + + /** @var array */ + private static array $temp_dirs = array(); + + public static function run( \ComponentFuzz\FuzzContext $ctx ): array { + $load_errors = self::load_feed_parser_files(); + $missing = self::missing_requirements(); + if ( array() !== $load_errors || array() !== $missing ) { + return array( + $ctx->skip( + 'feed-parsers.bootstrap-apis-available', + 'Required feed parser APIs are unavailable.', + array( + 'loadErrors' => $load_errors, + 'missing' => $missing, + ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime(); + self::install_no_network_guard(); + + $case = self::prepare_case( $ctx ); + + $rows[] = self::check_magpie_rss_parser( $ctx->fork( 'magpie-rss' ), $case ); + $rows[] = self::check_magpie_atom_normalization( $ctx->fork( 'magpie-atom' ), $case ); + $rows[] = self::check_atomlib_file_parser( $ctx->fork( 'atomlib' ), $case ); + $rows[] = self::check_simplepie_raw_parser_and_sanitizer( $ctx->fork( 'simplepie' ), $case ); + $rows[] = self::check_feed_cache_adapters( $ctx->fork( 'cache' ), $case ); + $rows[] = self::check_legacy_helpers_and_file_boundaries( $ctx->fork( 'legacy' ), $case ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'feed-parsers.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + $http_requests = self::$http_requests; + self::restore_state( $snapshot ); + self::cleanup_temp_dirs(); + } + + $rows[] = $ctx->result( + 'feed-parsers.no-network-requests', + array() === $http_requests, + array( 'requests' => array_slice( $http_requests, 0, 5 ) ) + ); + + $rows[] = $ctx->result( + 'feed-parsers.state-restored', + self::state_matches( $snapshot ), + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedServer' => array_keys( $snapshot['server'] ), + ) + ); + + self::reset_runtime(); + + return $rows; + } + + public static function block_http_request( $preempt, array $parsed_args, string $url ) { + self::$http_requests[] = array( + 'url' => $url, + 'timeout' => $parsed_args['timeout'] ?? null, + 'headers' => array_keys( (array) ( $parsed_args['headers'] ?? array() ) ), + ); + + return new \WP_Error( 'component_fuzz_no_network', 'Component fuzz feed parsers surface blocks live HTTP requests.' ); + } + + private static function load_feed_parser_files(): array { + if ( ! isset( $GLOBALS['wp_version'] ) ) { + $GLOBALS['wp_version'] = 'component-fuzz'; + } + + $errors = array(); + foreach ( + array( + 'class-feed.php', + 'rss.php', + 'rss-functions.php', + 'atomlib.php', + ) as $file + ) { + $path = ABSPATH . WPINC . '/' . $file; + if ( ! file_exists( $path ) ) { + $errors[] = "missing {$file}"; + continue; + } + + try { + require_once $path; + } catch ( \Throwable $e ) { + $errors[] = $file . ': ' . $e->getMessage(); + } + } + + if ( function_exists( 'init' ) ) { + try { + \init(); + } catch ( \Throwable $e ) { + $errors[] = 'Magpie init: ' . $e->getMessage(); + } + } + + return $errors; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'AtomParser', + 'MagpieRSS', + 'RSSCache', + 'SimplePie\SimplePie', + 'SimplePie\Sanitize', + 'WP_Error', + 'WP_Feed_Cache_Transient', + 'WP_SimplePie_File', + 'WP_SimplePie_Sanitize_KSES', + ) as $class + ) { + if ( ! class_exists( $class, false ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_filter', + 'delete_site_transient', + 'delete_transient', + 'get_bloginfo', + 'get_site_transient', + 'get_transient', + 'is_client_error', + 'is_error', + 'is_info', + 'is_redirect', + 'is_server_error', + 'is_success', + 'parse_w3cdtf', + 'remove_filter', + 'set_site_transient', + 'set_transient', + 'simplexml_load_string', + 'wp_cache_flush', + 'wp_safe_remote_request', + 'wp_kses_post', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( array( 'xml_parser_create', 'xml_parser_create_ns' ) as $function ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_magpie_rss_parser( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $captured = self::capture_legacy_warnings( + static function () use ( $case ) { + return new \MagpieRSS( $case['rssXml'] ); + } + ); + $rss = $captured['value']; + + self::collect_failure( + $failures, + $rss instanceof \MagpieRSS && '2.0' === $rss->is_rss() && false === $rss->is_atom(), + 'MagpieRSS identifies bounded RSS 2.0 fixtures', + array( + 'type' => $rss instanceof \MagpieRSS ? $rss->feed_type : null, + 'version' => $rss instanceof \MagpieRSS ? $rss->feed_version : null, + ) + ); + self::collect_failure( + $failures, + $rss instanceof \MagpieRSS + && $case['channel']['title'] === ( $rss->channel['title'] ?? null ) + && $case['channel']['description'] === ( $rss->channel['description'] ?? null ) + && ( $rss->channel['description'] ?? null ) === ( $rss->channel['tagline'] ?? null ), + 'RSS channel text is entity-decoded and normalized into tagline', + array( 'channel' => $rss instanceof \MagpieRSS ? $rss->channel : null ) + ); + self::collect_failure( + $failures, + $rss instanceof \MagpieRSS && count( $case['items'] ) === count( $rss->items ), + 'RSS item count matches the bounded fixture', + array( + 'expected' => count( $case['items'] ), + 'actual' => $rss instanceof \MagpieRSS ? count( $rss->items ) : null, + ) + ); + + if ( $rss instanceof \MagpieRSS ) { + foreach ( $case['items'] as $index => $expected ) { + $item = $rss->items[ $index ] ?? array(); + self::collect_failure( + $failures, + $expected['title'] === ( $item['title'] ?? null ) + && $expected['description'] === ( $item['description'] ?? null ) + && $expected['description'] === ( $item['summary'] ?? null ) + && $expected['content'] === ( $item['content']['encoded'] ?? null ) + && $expected['author'] === ( $item['dc']['creator'] ?? null ) + && str_contains( $item['title'] ?? '', ']]>' ), + 'RSS item title/description/content namespace fields survive entity and CDATA boundaries', + array( + 'index' => $index, + 'item' => $item, + ) + ); + } + } + + $response = (object) array( + 'headers' => array( + 'etag: "' . $case['token'] . '"', + 'last-modified: ' . $case['items'][0]['rssDate'], + ), + 'results' => $case['rssXml'], + ); + $response_captured = self::capture_legacy_warnings( + static function () use ( $response ) { + return \_response_to_rss( $response ); + } + ); + $response_rss = $response_captured['value']; + self::collect_failure( + $failures, + $response_rss instanceof \MagpieRSS + && '"' . $case['token'] . '"' === ( $response_rss->etag ?? null ) + && $case['items'][0]['rssDate'] === ( $response_rss->last_modified ?? null ), + '_response_to_rss preserves lower-case ETag and Last-Modified headers on parsed feeds', + array( + 'etag' => $response_rss instanceof \MagpieRSS ? ( $response_rss->etag ?? null ) : null, + 'lastModified' => $response_rss instanceof \MagpieRSS ? ( $response_rss->last_modified ?? null ) : null, + ) + ); + + $bad_captured = self::capture_legacy_warnings( + static function () use ( $case ) { + return new \MagpieRSS( $case['malformedRssXml'] ); + } + ); + $bad = $bad_captured['value']; + self::collect_failure( + $failures, + $bad instanceof \MagpieRSS && count( $bad->items ) <= 1 && strlen( $case['malformedRssXml'] ) < 512, + 'Malformed RSS fixtures stay bounded and do not synthesize extra items', + array( + 'items' => $bad instanceof \MagpieRSS ? $bad->items : null, + 'bytes' => strlen( $case['malformedRssXml'] ), + ) + ); + + $warnings = array_merge( $captured['warnings'], $response_captured['warnings'], $bad_captured['warnings'] ); + self::collect_failure( + $failures, + self::only_known_magpie_warnings( $warnings ), + 'Magpie parser emits only the known PHP 8 XML callback by-reference warning', + array( 'warnings' => array_slice( $warnings, 0, 8 ) ) + ); + + return self::result( + $ctx, + 'feed-parsers.magpie-rss.structure-cdata-and-response-headers', + $failures, + array( + 'fixture' => self::preview( $case['rssXml'] ), + 'warnings' => count( $warnings ), + ) + ); + } + + private static function check_magpie_atom_normalization( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $captured = self::capture_legacy_warnings( + static function () use ( $case ) { + return new \MagpieRSS( $case['magpieAtomXml'] ); + } + ); + $atom = $captured['value']; + + self::collect_failure( + $failures, + $atom instanceof \MagpieRSS && false === $atom->is_rss() && '0.3' === $atom->is_atom(), + 'MagpieRSS identifies bounded Atom fixtures', + array( + 'type' => $atom instanceof \MagpieRSS ? $atom->feed_type : null, + 'version' => $atom instanceof \MagpieRSS ? $atom->feed_version : null, + ) + ); + self::collect_failure( + $failures, + $atom instanceof \MagpieRSS + && $case['channel']['description'] === ( $atom->channel['tagline'] ?? null ) + && $case['channel']['description'] === ( $atom->channel['description'] ?? null ) + && count( $case['items'] ) === count( $atom->items ), + 'Atom channel tagline is normalized to description and entry count is preserved', + array( 'channel' => $atom instanceof \MagpieRSS ? $atom->channel : null ) + ); + + if ( $atom instanceof \MagpieRSS ) { + foreach ( $case['items'] as $index => $expected ) { + $item = $atom->items[ $index ] ?? array(); + self::collect_failure( + $failures, + $expected['title'] === ( $item['title'] ?? null ) + && $expected['description'] === ( $item['description'] ?? null ) + && $expected['description'] === ( $item['summary'] ?? null ) + && $expected['content'] === ( $item['content']['encoded'] ?? null ) + && $expected['link'] === ( $item['link'] ?? null ), + 'Atom entries are normalized onto legacy RSS item keys', + array( + 'index' => $index, + 'item' => $item, + ) + ); + } + } + + self::collect_failure( + $failures, + self::only_known_magpie_warnings( $captured['warnings'] ), + 'Atom parsing emits only the known Magpie PHP 8 callback warning', + array( 'warnings' => array_slice( $captured['warnings'], 0, 8 ) ) + ); + + return self::result( + $ctx, + 'feed-parsers.magpie-atom.normalization', + $failures, + array( 'fixture' => self::preview( $case['magpieAtomXml'] ) ) + ); + } + + private static function check_atomlib_file_parser( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + if ( ! function_exists( 'xml_parser_create_ns' ) ) { + return $ctx->skip( + 'feed-parsers.atomlib.file-parser', + 'PHP XML namespace parser is unavailable.' + ); + } + + $failures = array(); + $dir = self::temp_dir( 'atomlib-' . $ctx->identifier( 4, 8 ) ); + $valid = $dir . DIRECTORY_SEPARATOR . 'valid.atom'; + $invalid = $dir . DIRECTORY_SEPARATOR . 'invalid.atom'; + + file_put_contents( $valid, $case['atomlibXml'] ); + file_put_contents( $invalid, $case['malformedAtomXml'] ); + + $parser = new \AtomParser(); + $parser->FILE = $valid; + $parser->in_content = array(); + $ok = false; + $throwable = null; + + try { + $ok = $parser->parse(); + } catch ( \Throwable $e ) { + $throwable = self::describe_throwable( $e ); + } + + self::collect_failure( + $failures, + true === $ok + && null === $throwable + && 1 === count( $parser->feed->links ) + && 1 === count( $parser->feed->categories ) + && count( $case['items'] ) === count( $parser->feed->entries ) + && $case['channel']['link'] === ( $parser->feed->links[0]['href'] ?? null ) + && $case['category'] === ( $parser->feed->categories[0]['term'] ?? null ), + 'AtomParser reads local bounded Atom files and preserves link/category attributes', + array( + 'ok' => $ok, + 'throwable' => $throwable, + 'feed' => self::describe_atom_feed( $parser->feed ), + ) + ); + + foreach ( $parser->feed->entries as $index => $entry ) { + self::collect_failure( + $failures, + isset( $case['items'][ $index ] ) + && $case['items'][ $index ]['link'] === ( $entry->links[0]['href'] ?? null ) + && $case['category'] === ( $entry->categories[0]['term'] ?? null ), + 'AtomParser entry link/category attributes match generated fixtures', + array( + 'index' => $index, + 'entry' => self::describe_atom_entry( $entry ), + ) + ); + } + + $bad = new \AtomParser(); + $bad->FILE = $invalid; + $bad->in_content = array(); + $bad_ok = true; + $bad_throwable = null; + try { + $bad_ok = $bad->parse(); + } catch ( \Throwable $e ) { + $bad_throwable = self::describe_throwable( $e ); + } + + self::collect_failure( + $failures, + false === $bad_ok + && null === $bad_throwable + && is_string( $bad->error ) + && str_contains( $bad->error, 'XML Error' ) + && 0 === count( $bad->feed->entries ) + && strlen( $case['malformedAtomXml'] ) < 512, + 'Malformed AtomParser files fail closed with a parser error and no synthesized entries', + array( + 'ok' => $bad_ok, + 'throwable' => $bad_throwable, + 'error' => $bad->error, + 'entries' => count( $bad->feed->entries ), + ) + ); + + return self::result( + $ctx, + 'feed-parsers.atomlib.local-file-parser-and-malformed-input', + $failures, + array( + 'valid' => self::preview( $case['atomlibXml'] ), + 'invalid' => self::preview( $case['malformedAtomXml'] ), + ) + ); + } + + private static function check_simplepie_raw_parser_and_sanitizer( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $rss = self::new_simplepie_for_raw_data( $case['rssXml'] ); + $rss_ok = $rss->init(); + $rss->set_output_encoding( self::blog_charset() ); + + self::collect_failure( + $failures, + true === $rss_ok + && null === $rss->error() + && count( $case['items'] ) === $rss->get_item_quantity() + && str_contains( $rss->get_title(), 'Feed' ), + 'SimplePie parses bounded RSS raw data without URL fetching or cache writes', + array( + 'ok' => $rss_ok, + 'error' => $rss->error(), + 'quantity' => $rss->get_item_quantity(), + 'title' => $rss->get_title(), + ) + ); + + foreach ( $rss->get_items( 0, count( $case['items'] ) ) as $index => $item ) { + $title = $item->get_title(); + $description = $item->get_description(); + self::collect_failure( + $failures, + is_string( $title ) + && self::contains_item_token( $title, $case['items'] ) + && ! str_contains( strtolower( $title ), 'get_date( 'U' ), + 'SimplePie RSS items expose sanitized titles/descriptions and parseable dates', + array( + 'index' => $index, + 'title' => $title, + 'description' => self::preview( (string) $description ), + 'date' => $item->get_date( 'U' ), + ) + ); + } + + $atom = self::new_simplepie_for_raw_data( $case['simplepieAtomXml'] ); + $atom_ok = $atom->init(); + $atom->set_output_encoding( self::blog_charset() ); + self::collect_failure( + $failures, + true === $atom_ok + && null === $atom->error() + && count( $case['items'] ) === $atom->get_item_quantity() + && str_contains( $atom->get_title(), 'Feed' ), + 'SimplePie parses bounded Atom raw data with matching item count', + array( + 'ok' => $atom_ok, + 'error' => $atom->error(), + 'quantity' => $atom->get_item_quantity(), + 'title' => $atom->get_title(), + ) + ); + + foreach ( $atom->get_items( 0, count( $case['items'] ) ) as $index => $item ) { + $title = $item->get_title(); + self::collect_failure( + $failures, + is_string( $title ) + && self::contains_item_token( $title, $case['items'] ) + && ! str_contains( strtolower( $title ), 'get_date( 'U' ), + 'SimplePie Atom items expose sanitized titles and parseable W3C dates', + array( + 'index' => $index, + 'title' => $title, + 'date' => $item->get_date( 'U' ), + ) + ); + } + + $sanitizer = new \WP_SimplePie_Sanitize_KSES(); + $dirty = '

Allowed ' . $case['token'] . '

bad'; + $clean = $sanitizer->sanitize( $dirty, \SimplePie\SimplePie::CONSTRUCT_HTML ); + $base64 = $sanitizer->sanitize( base64_encode( $dirty ), \SimplePie\SimplePie::CONSTRUCT_HTML | \SimplePie\SimplePie::CONSTRUCT_BASE64 ); + self::collect_failure( + $failures, + is_string( $clean ) + && str_contains( $clean, '

Allowed ' . $case['token'] . '

' ) + && ! str_contains( strtolower( $clean ), 'Allowed ' . $case['token'] . '

' ), + 'WP_SimplePie_Sanitize_KSES applies KSES to HTML and base64 HTML constructs', + array( + 'clean' => self::preview( (string) $clean ), + 'base64' => self::preview( (string) $base64 ), + ) + ); + + $malformed = self::new_simplepie_for_raw_data( $case['malformedRssXml'] ); + $previous_error_reporting = error_reporting(); + error_reporting( $previous_error_reporting & ~E_USER_NOTICE ); + try { + $bad_ok = $malformed->init(); + } finally { + error_reporting( $previous_error_reporting ); + } + self::collect_failure( + $failures, + false === $bad_ok + && is_string( $malformed->error() ) + && str_contains( $malformed->error(), 'invalid XML' ) + && 0 === $malformed->get_item_quantity(), + 'SimplePie malformed raw data fails closed with no items', + array( + 'ok' => $bad_ok, + 'error' => $malformed->error(), + 'quantity' => $malformed->get_item_quantity(), + ) + ); + + return self::result( + $ctx, + 'feed-parsers.simplepie.raw-data-cacheless-parsing-and-sanitizer', + $failures, + array( + 'rss' => self::preview( $case['rssXml'] ), + 'atom' => self::preview( $case['simplepieAtomXml'] ), + ) + ); + } + + private static function check_feed_cache_adapters( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $name = 'cfz_' . strtolower( $case['token'] ) . '_' . strtolower( $ctx->identifier( 4, 10 ) ); + $lifetime = $ctx->int( 30, 900 ); + + $lifetime_filter = static function () use ( $lifetime ): int { + return $lifetime; + }; + \add_filter( 'wp_feed_cache_transient_lifetime', $lifetime_filter, 10, 2 ); + $cache = new \WP_Feed_Cache_Transient( 'wp_transient', $name, 'spc' ); + \remove_filter( 'wp_feed_cache_transient_lifetime', $lifetime_filter, 10 ); + + self::collect_failure( + $failures, + 'feed_' . $name === $cache->name + && 'feed_mod_' . $name === $cache->mod_name + && $lifetime === $cache->lifetime + && ! str_contains( $cache->name, DIRECTORY_SEPARATOR ) + && strlen( $cache->name ) < 128, + 'WP_Feed_Cache_Transient builds bounded transient names and applies lifetime filter', + array( + 'name' => $cache->name, + 'modName' => $cache->mod_name, + 'lifetime' => $cache->lifetime, + ) + ); + + $data = array( + 'feed' => $case['channel']['title'], + 'items' => array_column( $case['items'], 'guid' ), + ); + $saved = $cache->save( $data ); + $loaded = $cache->load(); + $mtime = $cache->mtime(); + $touched = $cache->touch(); + $touched_at = $cache->mtime(); + $unlinked = $cache->unlink(); + $after = $cache->load(); + + self::collect_failure( + $failures, + true === $saved + && $data === $loaded + && is_int( $mtime ) + && is_bool( $touched ) + && is_int( $touched_at ) + && $touched_at >= $mtime + && true === $unlinked + && false === $after, + 'WP_Feed_Cache_Transient save/load/touch/unlink round trips through site transients', + array( + 'loaded' => $loaded, + 'mtime' => $mtime, + 'touchedAt' => $touched_at, + 'after' => $after, + ) + ); + + $factory = ( new \ReflectionClass( \WP_Feed_Cache::class ) )->newInstanceWithoutConstructor(); + $created = $factory->create( 'wp_transient', $name . '_factory', 'spc' ); + self::collect_failure( + $failures, + $created instanceof \WP_Feed_Cache_Transient + && 'feed_' . $name . '_factory' === $created->name, + 'WP_Feed_Cache factory creates the transient-backed adapter', + array( + 'class' => is_object( $created ) ? get_class( $created ) : gettype( $created ), + 'name' => $created instanceof \WP_Feed_Cache_Transient ? $created->name : null, + ) + ); + if ( $created instanceof \WP_Feed_Cache_Transient ) { + $created->unlink(); + } + + $url = $case['cacheUrl']; + $rss_cache = new \RSSCache( self::temp_dir( 'magpie-cache-' . $ctx->identifier( 4, 8 ) ), $ctx->int( 60, 3600 ) ); + $key = $rss_cache->set( $url, (object) array( 'items' => $case['items'] ) ); + $status = $rss_cache->check_cache( $url ); + $value = $rss_cache->get( $url ); + $file_name = $rss_cache->file_name( $url ); + \delete_transient( $key ); + + self::collect_failure( + $failures, + 'rss_' . md5( $url ) === $key + && 'HIT' === $status + && is_object( $value ) + && $case['items'] === $value->items + && md5( $url ) === $file_name + && 1 === preg_match( '/^[a-f0-9]{32}$/', $file_name ), + 'Legacy RSSCache uses md5 cache keys and transient-backed get/check_cache behavior', + array( + 'url' => $url, + 'key' => $key, + 'status' => $status, + 'fileName' => $file_name, + ) + ); + + return self::result( + $ctx, + 'feed-parsers.cache-adapters.transient-key-boundaries', + $failures, + array( 'cacheName' => $name ) + ); + } + + private static function check_legacy_helpers_and_file_boundaries( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $date = $case['w3cDate']; + $parsed_capture = self::capture_legacy_warnings( + static function () use ( $date ) { + return \parse_w3cdtf( $date ); + } + ); + $invalid_capture = self::capture_legacy_warnings( + static function () use ( $case ) { + return \parse_w3cdtf( 'not-a-date-' . $case['token'] ); + } + ); + $parsed = $parsed_capture['value']; + self::collect_failure( + $failures, + $case['w3cEpoch'] === $parsed + && -1 === $invalid_capture['value'] + && self::only_known_w3cdtf_warnings( array_merge( $parsed_capture['warnings'], $invalid_capture['warnings'] ) ), + 'parse_w3cdtf agrees with the generated UTC epoch and rejects malformed dates', + array( + 'date' => $date, + 'expected' => $case['w3cEpoch'], + 'actual' => $parsed, + 'warnings' => array_merge( $parsed_capture['warnings'], $invalid_capture['warnings'] ), + ) + ); + + $status_cases = array( + 99 => array( false, false, false, false, false, false ), + 100 => array( true, false, false, false, false, false ), + 204 => array( false, true, false, false, false, false ), + 302 => array( false, false, true, false, false, false ), + 404 => array( false, false, false, true, true, false ), + 503 => array( false, false, false, true, false, true ), + 600 => array( false, false, false, false, false, false ), + ); + foreach ( $status_cases as $status => $expected ) { + $actual = array( + \is_info( $status ), + \is_success( $status ), + \is_redirect( $status ), + \is_error( $status ), + \is_client_error( $status ), + \is_server_error( $status ), + ); + self::collect_failure( + $failures, + $expected === $actual, + 'Legacy HTTP status classifier boundaries remain stable', + array( + 'status' => $status, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + + $dir = self::temp_dir( 'simplepie-file-' . $ctx->identifier( 4, 8 ) ); + $local = $dir . DIRECTORY_SEPARATOR . '../' . basename( $dir ) . DIRECTORY_SEPARATOR . 'feed.xml'; + $canonical = realpath( $dir ) . DIRECTORY_SEPARATOR . 'feed.xml'; + file_put_contents( $canonical, $case['rssXml'] ); + + $file = new \WP_SimplePie_File( $local, $ctx->int( 1, 3 ), $ctx->int( 0, 2 ), array( 'X-Fuzz' => $case['token'] ), 'ComponentFuzz' ); + self::collect_failure( + $failures, + false === $file->success + && '' === $file->error + && $local === $file->url + && array() === self::$http_requests, + 'WP_SimplePie_File rejects non-HTTP local paths without reading files or touching HTTP', + array( + 'url' => $file->url, + 'success' => $file->success, + 'error' => $file->error, + 'method' => $file->method, + ) + ); + + $xml = self::parse_xml( $case['rssXml'] ); + self::collect_failure( + $failures, + $xml['ok'] && count( $case['items'] ) === $xml['itemCount'], + 'Generated RSS fixtures stay parseable and bounded before parser-specific checks', + array( 'xml' => $xml ) + ); + + return self::result( + $ctx, + 'feed-parsers.legacy-helpers.date-status-and-file-boundaries', + $failures, + array( 'date' => $date ) + ); + } + + private static function prepare_case( \ComponentFuzz\FuzzContext $ctx ): array { + $token = strtolower( preg_replace( '/[^a-zA-Z0-9_-]/', '', $ctx->identifier( 5, 12 ) ) ); + $item_count = $ctx->int( 2, 4 ); + $base_epoch = gmmktime( + $ctx->int( 0, 23 ), + $ctx->int( 0, 59 ), + $ctx->int( 0, 59 ), + $ctx->int( 1, 12 ), + $ctx->int( 1, 25 ), + $ctx->int( 2020, 2028 ) + ); + $offset_hour = $ctx->int( -11, 13 ); + $offset_min = $ctx->choice( array( 0, 15, 30, 45 ) ); + $offset_secs = ( abs( $offset_hour ) * HOUR_IN_SECONDS ) + ( $offset_min * MINUTE_IN_SECONDS ); + if ( $offset_hour < 0 ) { + $offset_secs *= -1; + } + + $w3c_epoch = $base_epoch + ( $ctx->int( 0, 48 ) * HOUR_IN_SECONDS ); + $w3c_date = gmdate( 'Y-m-d\TH:i:s', $w3c_epoch + $offset_secs ) + . ( $offset_secs >= 0 ? '+' : '-' ) + . sprintf( '%02d:%02d', abs( $offset_hour ), $offset_min ); + + $channel = array( + 'title' => 'Feed ' . $token . ' & "Quoted"', + 'link' => 'https://example.test/feeds/' . rawurlencode( $token ) . '/?a=1&b=two', + 'description' => 'Description ' . $token . ' & text', + ); + $category = 'category-' . strtolower( $ctx->identifier( 3, 8 ) ); + $items = array(); + + for ( $i = 0; $i < $item_count; $i++ ) { + $item_token = strtolower( preg_replace( '/[^a-zA-Z0-9_-]/', '', $ctx->identifier( 4, 10 ) ) ); + $epoch = $base_epoch + ( $i * HOUR_IN_SECONDS ) + $ctx->int( 0, 900 ); + $items[] = array( + 'token' => $item_token, + 'title' => 'Item ' . $i . ' ' . $item_token . ' & CDATA ]]> tail', + 'link' => 'https://example.test/items/' . $item_token . '/?x=1&y=' . rawurlencode( $token ), + 'guid' => 'urn:component-fuzz:' . $token . ':' . $item_token, + 'author' => 'Author ' . $item_token . ' & Co', + 'description' => 'Summary ' . $item_token . ' & <b>escaped</b> entity', + 'content' => '<p>Body ' . $item_token . '</p><script>alert(1)</script> CDATA ]]> close', + 'rssDate' => gmdate( 'D, d M Y H:i:s +0000', $epoch ), + 'w3cDate' => gmdate( 'Y-m-d\TH:i:s\Z', $epoch ), + 'epoch' => $epoch, + ); + } + + return array( + 'token' => $token, + 'channel' => $channel, + 'category' => $category, + 'items' => $items, + 'w3cDate' => $w3c_date, + 'w3cEpoch' => $w3c_epoch, + 'cacheUrl' => 'https://cache.example.test/../feeds/' . rawurlencode( $token ) . '?q=%2e%2e%2f&v=' . rawurlencode( $ctx->identifier( 3, 8 ) ), + 'rssXml' => self::build_rss_xml( $channel, $items, $category ), + 'malformedRssXml' => self::build_malformed_rss_xml( $channel, $items[0] ), + 'magpieAtomXml' => self::build_magpie_atom_xml( $channel, $items ), + 'atomlibXml' => self::build_atomlib_xml( $channel, $items, $category ), + 'malformedAtomXml' => self::build_malformed_atom_xml( $channel, $items[0] ), + 'simplepieAtomXml' => self::build_simplepie_atom_xml( $channel, $items ), + ); + } + + private static function build_rss_xml( array $channel, array $items, string $category ): string { + $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; + $xml .= "<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n"; + $xml .= "<channel>\n"; + $xml .= '<title>' . self::xml_text( $channel['title'] ) . "\n"; + $xml .= '' . self::xml_text( $channel['link'] ) . "\n"; + $xml .= '' . self::xml_text( $channel['description'] ) . "\n"; + foreach ( $items as $item ) { + $xml .= "\n"; + $xml .= '' . self::xml_cdata( $item['title'] ) . "\n"; + $xml .= '' . self::xml_text( $item['link'] ) . "\n"; + $xml .= '' . self::xml_text( $item['guid'] ) . "\n"; + $xml .= '' . self::xml_text( $item['rssDate'] ) . "\n"; + $xml .= '' . self::xml_text( $item['author'] ) . "\n"; + $xml .= '' . self::xml_text( $item['description'] ) . "\n"; + $xml .= '' . self::xml_cdata( $item['content'] ) . "\n"; + $xml .= '' . self::xml_cdata( $category ) . "\n"; + $xml .= "\n"; + } + $xml .= "\n\n"; + + return $xml; + } + + private static function build_malformed_rss_xml( array $channel, array $item ): string { + return '' + . self::xml_text( $channel['title'] ) + . '' + . self::xml_text( $item['title'] ) + . '\n"; + $xml .= "\n"; + $xml .= '' . self::xml_text( $channel['title'] ) . "\n"; + $xml .= '' . self::xml_text( $channel['description'] ) . "\n"; + foreach ( $items as $item ) { + $xml .= "\n"; + $xml .= '' . self::xml_cdata( $item['title'] ) . "\n"; + $xml .= '\n"; + $xml .= '' . self::xml_text( $item['description'] ) . "\n"; + $xml .= '' . self::xml_cdata( $item['content'] ) . "\n"; + $xml .= "\n"; + } + $xml .= "\n"; + + return $xml; + } + + private static function build_atomlib_xml( array $channel, array $items, string $category ): string { + $xml = "\n"; + $xml .= "\n"; + $xml .= '\n"; + $xml .= '\n"; + foreach ( $items as $item ) { + $xml .= "\n"; + $xml .= '\n"; + $xml .= '\n"; + $xml .= "\n"; + } + $xml .= "\n"; + + return $xml; + } + + private static function build_malformed_atom_xml( array $channel, array $item ): string { + return ''; + } + + private static function build_simplepie_atom_xml( array $channel, array $items ): string { + $xml = "\n"; + $xml .= "\n"; + $xml .= '' . self::xml_text( $channel['title'] ) . "\n"; + $xml .= '\n"; + $xml .= '' . self::xml_text( $items[0]['w3cDate'] ) . "\n"; + foreach ( $items as $item ) { + $xml .= "\n"; + $xml .= '' . self::xml_text( $item['guid'] ) . "\n"; + $xml .= '' . self::xml_text( '<b>' . $item['title'] . '</b><script>alert(1)</script>' ) . "\n"; + $xml .= '\n"; + $xml .= '' . self::xml_text( $item['w3cDate'] ) . "\n"; + $xml .= '' . self::xml_text( '

' . $item['description'] . '

' ) . "
\n"; + $xml .= '' . self::xml_text( $item['content'] ) . "\n"; + $xml .= '' . self::xml_text( $item['author'] ) . "\n"; + $xml .= "
\n"; + } + $xml .= "
\n"; + + return $xml; + } + + private static function new_simplepie_for_raw_data( string $xml ): \SimplePie\SimplePie { + $feed = new \SimplePie\SimplePie(); + $feed->enable_cache( false ); + $feed->get_registry()->register( \SimplePie\Sanitize::class, 'WP_SimplePie_Sanitize_KSES', true ); + $feed->sanitize = new \WP_SimplePie_Sanitize_KSES(); + $feed->set_raw_data( $xml ); + + return $feed; + } + + private static function blog_charset(): string { + $charset = \get_bloginfo( 'charset' ); + return is_string( $charset ) && '' !== $charset ? $charset : 'UTF-8'; + } + + private static function contains_item_token( string $value, array $items ): bool { + foreach ( $items as $item ) { + if ( str_contains( $value, $item['token'] ) ) { + return true; + } + } + + return false; + } + + private static function install_no_network_guard(): void { + self::$http_requests = array(); + \add_filter( 'pre_http_request', array( self::class, 'block_http_request' ), 10, 3 ); + } + + private static function reset_runtime(): void { + self::$http_requests = array(); + self::$temp_dirs = array(); + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_object_cache', + 'wp_version', + ) + ), + 'server' => array( + 'HTTP_HOST' => $_SERVER['HTTP_HOST'] ?? null, + 'REQUEST_URI' => $_SERVER['REQUEST_URI'] ?? null, + ), + 'options' => isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : array(), + 'counts' => isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub + ? $GLOBALS['wpdb']->component_fuzz_content_counts() + : array(), + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + foreach ( array( 'HTTP_HOST', 'REQUEST_URI' ) as $key ) { + if ( null === $snapshot['server'][ $key ] ) { + unset( $_SERVER[ $key ] ); + } else { + $_SERVER[ $key ] = $snapshot['server'][ $key ]; + } + } + + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + } + + private static function state_matches( array $snapshot ): bool { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + if ( $snapshot['options'] !== $GLOBALS['wpdb']->component_fuzz_get_options() ) { + return false; + } + if ( $snapshot['counts'] !== $GLOBALS['wpdb']->component_fuzz_content_counts() ) { + return false; + } + } + + foreach ( $snapshot['server'] as $name => $value ) { + if ( null === $value ) { + if ( array_key_exists( $name, $_SERVER ) ) { + return false; + } + } elseif ( ! array_key_exists( $name, $_SERVER ) || $value !== $_SERVER[ $name ] ) { + return false; + } + } + + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + } + + return true; + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = self::clone_value( $entry['value'] ); + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function clone_value( $value ) { + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } + + private static function capture_legacy_warnings( callable $callback ): array { + $warnings = array(); + set_error_handler( + static function ( int $severity, string $message, string $file, int $line ) use ( &$warnings ): bool { + if ( error_reporting() & $severity ) { + $warnings[] = array( + 'severity' => $severity, + 'message' => $message, + 'file' => basename( $file ), + 'line' => $line, + ); + } + return true; + } + ); + + try { + $value = $callback(); + } finally { + restore_error_handler(); + } + + return array( + 'value' => $value ?? null, + 'warnings' => $warnings, + ); + } + + private static function only_known_magpie_warnings( array $warnings ): bool { + if ( array() === $warnings ) { + return true; + } + + foreach ( $warnings as $warning ) { + $message = $warning['message'] ?? ''; + if ( + ! str_contains( $message, 'MagpieRSS::feed_start_element(): Argument #3 ($attrs) must be passed by reference' ) + && ! str_contains( $message, 'Creation of dynamic property MagpieRSS::$etag is deprecated' ) + && ! str_contains( $message, 'Creation of dynamic property MagpieRSS::$last_modified is deprecated' ) + && ! str_contains( $message, 'Undefined array key "description"' ) + ) { + return false; + } + } + + return true; + } + + private static function only_known_w3cdtf_warnings( array $warnings ): bool { + foreach ( $warnings as $warning ) { + if ( ! str_contains( $warning['message'] ?? '', 'Undefined array key 11' ) ) { + return false; + } + } + + return true; + } + + private static function temp_dir( string $suffix ): string { + $base = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-feed-parsers-' . getmypid(); + if ( ! is_dir( $base ) && ! mkdir( $base, 0777, true ) && ! is_dir( $base ) ) { + throw new \RuntimeException( 'Could not create feed parser temp base.' ); + } + + $dir = $base . DIRECTORY_SEPARATOR . preg_replace( '/[^a-zA-Z0-9_.-]/', '-', $suffix ); + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0777, true ) && ! is_dir( $dir ) ) { + throw new \RuntimeException( 'Could not create feed parser temp directory.' ); + } + + self::$temp_dirs[] = $dir; + return $dir; + } + + private static function cleanup_temp_dirs(): void { + foreach ( array_reverse( self::$temp_dirs ) as $dir ) { + self::remove_dir( $dir ); + } + self::$temp_dirs = array(); + + $base = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-feed-parsers-' . getmypid(); + self::remove_dir( $base ); + } + + private static function remove_dir( string $dir ): void { + $prefix = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-feed-parsers-'; + if ( ! str_starts_with( $dir, $prefix ) || ! file_exists( $dir ) ) { + return; + } + + if ( ! is_dir( $dir ) ) { + @unlink( $dir ); + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $item ) { + $path = $item->getPathname(); + if ( $item->isDir() && ! $item->isLink() ) { + @rmdir( $path ); + } else { + @unlink( $path ); + } + } + @rmdir( $dir ); + } + + private static function parse_xml( string $xml ): array { + $previous = libxml_use_internal_errors( true ); + libxml_clear_errors(); + $parsed = simplexml_load_string( $xml ); + $errors = array_map( + static fn ( \LibXMLError $error ): string => trim( $error->message ) . ' @' . $error->line . ':' . $error->column, + libxml_get_errors() + ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + + return array( + 'ok' => $parsed instanceof \SimpleXMLElement, + 'errors' => array_slice( $errors, 0, 4 ), + 'itemCount' => $parsed instanceof \SimpleXMLElement ? count( $parsed->channel->item ) : 0, + ); + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, array $failures, array $data = array() ): array { + $data['failures'] = array_slice( $failures, 0, 8 ); + return $ctx->result( $invariant, array() === $failures, $data ); + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $details = array() ): void { + if ( ! $ok ) { + $failures[] = array( + 'message' => $message, + 'details' => $details, + ); + } + } + + private static function preview( string $value ): array { + return array( + 'bytes' => strlen( $value ), + 'preview' => \ComponentFuzz\preview_value( $value, self::PREVIEW_BYTES ), + ); + } + + private static function xml_text( string $value ): string { + return htmlspecialchars( $value, ENT_NOQUOTES | ENT_XML1 | ENT_SUBSTITUTE, 'UTF-8' ); + } + + private static function xml_attr( string $value ): string { + return htmlspecialchars( $value, ENT_QUOTES | ENT_XML1 | ENT_SUBSTITUTE, 'UTF-8' ); + } + + private static function xml_cdata( string $value ): string { + return '', ']]]]>', $value ) . ']]>'; + } + + private static function describe_atom_feed( \AtomFeed $feed ): array { + return array( + 'links' => $feed->links, + 'categories' => $feed->categories, + 'entries' => array_map( array( self::class, 'describe_atom_entry' ), $feed->entries ), + ); + } + + private static function describe_atom_entry( \AtomEntry $entry ): array { + return array( + 'links' => $entry->links, + 'categories' => $entry->categories, + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} From 2132510f82e5c52941772115ed841b7e36eca1dd Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:30:29 +0200 Subject: [PATCH 0105/1102] Add script loader runtime fuzz surface --- tools/component-fuzz/README.md | 15 + .../surfaces/ScriptLoaderRuntimeSurface.php | 1408 +++++++++++++++++ 2 files changed, 1423 insertions(+) create mode 100644 tools/component-fuzz/surfaces/ScriptLoaderRuntimeSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index d2a1bc29969d4..debe73796e060 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -55,6 +55,12 @@ database, network requests, or a configured site. generation without network calls. - `assets`: script/style registration lifecycle, dependency ordering, inline assets, loading strategies, script modules, and printed tag escaping. +- `script-loader-runtime`: server-side script-loader runtime helpers, including + default script/style/module registrations, handle normalization, duplicate + update behavior, inline/localized data placement, tag/settings escaping, + script translations, emoji settings/styles, style inlining, block-loader + guards, strategy/fetchpriority/module interactions, and print side-effect + boundaries. - `appearance-media`: no-upload appearance media helper coverage, including custom background POST normalization, custom header default processing and selection, frontend header/background helpers, site icon sizes/meta tags, and @@ -390,6 +396,15 @@ Some checks deliberately skip cases that would invoke DB-backed or dynamic block rendering side effects. The Admin Screen surface intentionally avoids admin page submission, `options.php` writes, user preference persistence, real post objects, and block-editor compatibility shims that would inspect installed plugins. The +`script-loader-runtime` surface complements `assets` by covering server-side +runtime helpers in `script-loader.php`, `functions.wp-scripts.php`, and +`functions.wp-styles.php` without browser execution. It directly calls the emoji +detection printer instead of the public static-once wrapper so iterations remain +isolated, and it records an explicit skip for just-in-time autosave localization +when the stripped no-DB bootstrap does not define `AUTOSAVE_INTERVAL`. It avoids +admin/page dispatch, process exits, live HTTP, live DB-backed block queries, and +browser module execution while still asserting restored globals, filters, and +output buffers. The Admin Workflows surface intentionally avoids `admin.php`/`admin-ajax.php` request dispatch, DB-backed core `WP_*_List_Table` subclasses, and `wp_ajax_*` wrappers or JSON helpers that call `wp_die()`/`die()` in-process; it covers the base list diff --git a/tools/component-fuzz/surfaces/ScriptLoaderRuntimeSurface.php b/tools/component-fuzz/surfaces/ScriptLoaderRuntimeSurface.php new file mode 100644 index 0000000000000..26291f4771768 --- /dev/null +++ b/tools/component-fuzz/surfaces/ScriptLoaderRuntimeSurface.php @@ -0,0 +1,1408 @@ +skip( + 'script-loader-runtime.required-apis-available', + 'Required WordPress script-loader runtime APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $rows = array(); + $snapshot = self::snapshot_globals(); + $ob_level = ob_get_level(); + + try { + self::prepare_runtime_globals(); + + $case = self::case_for_context( $ctx ); + + $rows[] = self::check_default_registrations( $ctx, $case ); + $rows[] = self::check_handle_normalization_duplicate_updates( $ctx, $case ); + $rows[] = self::check_dependency_order_print_boundaries( $ctx, $case ); + $rows[] = self::check_inline_localization_data( $ctx, $case ); + $rows[] = self::check_tag_builders_dataset_helpers( $ctx, $case ); + $rows[] = self::check_script_translations( $ctx, $case ); + $rows[] = self::check_emoji_settings_and_styles( $ctx, $case ); + $rows[] = self::check_style_inlining_boundaries( $ctx, $case ); + $rows[] = self::check_block_editor_loader_guards( $ctx, $case ); + $rows[] = self::check_strategy_module_interactions( $ctx, $case ); + $rows[] = self::check_jit_script_localization( $ctx, $case ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'script-loader-runtime.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + self::restore_globals( $snapshot ); + } + + $rows[] = $ctx->result( + 'script-loader-runtime.global-state-restored', + ob_get_level() === $ob_level, + array( + 'outputBufferLevel' => ob_get_level(), + 'expectedLevel' => $ob_level, + 'globalNames' => array_keys( $snapshot ), + ) + ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_Dependencies', + 'WP_HTML_Tag_Processor', + 'WP_Scripts', + 'WP_Styles', + '_WP_Dependency', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + '_print_emoji_detection_script', + '_wp_normalize_relative_css_links', + 'load_script_textdomain', + 'load_script_translations', + 'print_footer_scripts', + 'print_head_scripts', + 'script_concat_settings', + 'wp_add_inline_script', + 'wp_add_inline_style', + 'wp_common_block_scripts_and_styles', + 'wp_default_packages', + 'wp_default_scripts', + 'wp_default_styles', + 'wp_dequeue_script', + 'wp_dequeue_style', + 'wp_deregister_script', + 'wp_deregister_style', + 'wp_enqueue_emoji_styles', + 'wp_enqueue_registered_block_scripts_and_styles', + 'wp_enqueue_script', + 'wp_enqueue_style', + 'wp_get_inline_script_tag', + 'wp_get_script_tag', + 'wp_html_custom_data_attribute_name', + 'wp_js_dataset_name', + 'wp_just_in_time_script_localization', + 'wp_localize_jquery_ui_datepicker', + 'wp_localize_script', + 'wp_maybe_inline_styles', + 'wp_print_footer_scripts', + 'wp_print_head_scripts', + 'wp_print_scripts', + 'wp_print_styles', + 'wp_prototype_before_jquery', + 'wp_register_script', + 'wp_register_style', + 'wp_remove_surrounding_empty_script_tags', + 'wp_script_add_data', + 'wp_script_is', + 'wp_scripts', + 'wp_set_script_translations', + 'wp_should_load_block_assets_on_demand', + 'wp_should_load_block_editor_scripts_and_styles', + 'wp_should_load_separate_core_block_assets', + 'wp_style_add_data', + 'wp_style_is', + 'wp_styles', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_default_registrations( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + self::reset_script_modules_global(); + + $scripts = \wp_scripts(); + $styles = \wp_styles(); + + $script_call = self::call( + static function () use ( $scripts ): void { + \wp_default_scripts( $scripts ); + \wp_default_packages( $scripts ); + } + ); + $module_call = self::call( + static function (): void { + if ( function_exists( 'wp_default_script_modules' ) ) { + \wp_default_script_modules(); + } + } + ); + $style_call = self::call( static fn() => \wp_default_styles( $styles ) ); + + $jquery = $scripts->registered['jquery'] ?? null; + $jquery_core = $scripts->registered['jquery-core'] ?? null; + $common = $scripts->registered['common'] ?? null; + $wp_i18n = $scripts->registered['wp-i18n'] ?? null; + $block_library = $styles->registered['wp-block-library'] ?? null; + $colors = $styles->registered['colors'] ?? null; + $module = function_exists( 'wp_script_modules' ) && method_exists( \wp_script_modules(), 'get_registered' ) + ? \wp_script_modules()->get_registered( '@wordpress/interactivity' ) + : null; + + $ok = ! $script_call['threw'] + && ! $style_call['threw'] + && ! $module_call['threw'] + && $jquery instanceof \_WP_Dependency + && false === $jquery->src + && array( 'jquery-core', 'jquery-migrate' ) === $jquery->deps + && $jquery_core instanceof \_WP_Dependency + && is_string( $jquery_core->src ) + && str_contains( $jquery_core->src, 'jquery' ) + && $common instanceof \_WP_Dependency + && 1 === $common->args + && $wp_i18n instanceof \_WP_Dependency + && $block_library instanceof \_WP_Dependency + && $colors instanceof \_WP_Dependency + && true === $colors->src; + + if ( method_exists( \wp_script_modules(), 'get_registered' ) ) { + $ok = $ok + && is_array( $module ) + && str_contains( (string) ( $module['src'] ?? '' ), 'interactivity' ) + && 'low' === ( $module['fetchpriority'] ?? null ) + && true === ( $module['in_footer'] ?? null ); + } + + return $ctx->result( + 'script-loader-runtime.default-registrations', + $ok, + self::case_data( $case ) + array( + 'registeredScripts' => count( $scripts->registered ), + 'registeredStyles' => count( $styles->registered ), + 'jquery' => self::dependency_summary( $jquery ), + 'common' => self::dependency_summary( $common ), + 'wpI18n' => self::dependency_summary( $wp_i18n ), + 'blockLibrary' => self::dependency_summary( $block_library ), + 'colors' => self::dependency_summary( $colors ), + 'defaultModule' => self::preview_array( $module ), + 'calls' => array( + 'scripts' => self::describe_call( $script_call ), + 'styles' => self::describe_call( $style_call ), + 'modules' => self::describe_call( $module_call ), + ), + ) + ); + } + + private static function check_handle_normalization_duplicate_updates( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + + $script = $case['handles']['script']; + $style = $case['handles']['style']; + $script_query = 'alpha=1&beta=' . rawurlencode( $case['token'] ); + $style_query = 'gamma=2&delta=' . rawurlencode( $case['token'] ); + + $script_registered = \wp_register_script( $script, $case['scriptSrcA'], array( $case['handles']['dep'] ), '1.0.0' ); + $script_duplicate = \wp_register_script( $script, $case['scriptSrcB'], array(), '2.0.0' ); + $script_enqueue = self::call( static fn() => \wp_enqueue_script( "{$script}?{$script_query}", $case['scriptSrcB'], array(), '2.0.0' ) ); + $script_add_args = array( + 'invalidStrategy' => \wp_script_add_data( $script, 'strategy', 'blocking' ), + 'validStrategy' => \wp_script_add_data( $script, 'strategy', 'defer' ), + 'conditional' => \wp_script_add_data( $script, 'conditional', 'IE 9' ), + 'modules' => \wp_script_add_data( + $script, + 'module_dependencies', + array( + $case['moduleIds'][0], + array( + 'id' => $case['moduleIds'][1], + 'import' => 'dynamic', + ), + array( 'not-id' => 'ignored' ), + 17, + ) + ), + ); + + $style_registered = \wp_register_style( $style, $case['styleSrcA'], array( $case['handles']['styleDep'] ), '1.0.0', 'screen' ); + $style_duplicate = \wp_register_style( $style, $case['styleSrcB'], array(), '2.0.0', 'print' ); + $style_enqueue = self::call( static fn() => \wp_enqueue_style( "{$style}?{$style_query}", $case['styleSrcB'], array(), '2.0.0', 'print' ) ); + $style_conditional = \wp_style_add_data( $style, 'conditional', 'lte IE 8' ); + $style_alt = \wp_style_add_data( $style, 'alt', true ); + $style_title = \wp_style_add_data( $style, 'title', $case['tagValue'] ); + + $script_obj = \wp_scripts()->registered[ $script ] ?? null; + $style_obj = \wp_styles()->registered[ $style ] ?? null; + $module_deps = \wp_scripts()->get_data( $script, 'module_dependencies' ); + + $ok = true === $script_registered + && false === $script_duplicate + && ! $script_enqueue['threw'] + && $script_obj instanceof \_WP_Dependency + && $case['scriptSrcA'] === $script_obj->src + && array() === $script_obj->deps + && $script_query === ( \wp_scripts()->args[ $script ] ?? null ) + && true === \wp_script_is( $script, 'enqueued' ) + && false === $script_add_args['invalidStrategy'] + && true === $script_add_args['validStrategy'] + && true === $script_add_args['conditional'] + && true === $script_add_args['modules'] + && is_array( $module_deps ) + && 2 === count( $module_deps ) + && true === $style_registered + && false === $style_duplicate + && ! $style_enqueue['threw'] + && $style_obj instanceof \_WP_Dependency + && $case['styleSrcA'] === $style_obj->src + && array() === $style_obj->deps + && $style_query === ( \wp_styles()->args[ $style ] ?? null ) + && true === \wp_style_is( $style, 'enqueued' ) + && true === $style_conditional + && true === $style_alt + && true === $style_title + && true === ( \wp_styles()->get_data( $style, 'alt' ) ?? null ) + && $case['tagValue'] === \wp_styles()->get_data( $style, 'title' ); + + return $ctx->result( + 'script-loader-runtime.handle-normalization-duplicate-update', + $ok, + self::case_data( $case ) + array( + 'scriptHandle' => $script, + 'styleHandle' => $style, + 'scriptArgs' => \wp_scripts()->args[ $script ] ?? null, + 'styleArgs' => \wp_styles()->args[ $style ] ?? null, + 'moduleDeps' => $module_deps, + 'scriptDeps' => $script_obj instanceof \_WP_Dependency ? $script_obj->deps : null, + 'styleDeps' => $style_obj instanceof \_WP_Dependency ? $style_obj->deps : null, + 'scriptDataAdds' => $script_add_args, + 'styleDataAdds' => array( + 'conditional' => $style_conditional, + 'alt' => $style_alt, + 'title' => $style_title, + ), + ) + ); + } + + private static function check_dependency_order_print_boundaries( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + + $base = $case['handles']['orderBase']; + $middle = $case['handles']['orderMiddle']; + $entry = $case['handles']['orderEntry']; + $footer = $case['handles']['footer']; + + \wp_register_script( $base, 'runtime/order-base.js', array(), '1.0.0' ); + \wp_register_script( $middle, 'runtime/order-middle.js', array( $base ), '1.0.0' ); + \wp_register_script( $entry, 'runtime/order-entry.js', array( $middle ), '1.0.0' ); + \wp_register_script( $footer, 'runtime/order-footer.js', array( $base ), '1.0.0', array( 'in_footer' => true ) ); + \wp_add_inline_script( $base, 'window.cfOrderBase = 1;', 'before' ); + \wp_add_inline_script( $entry, 'window.cfOrderEntry = 1;' ); + \wp_enqueue_script( array( $entry, $footer ) ); + + $head_print = self::capture_output( static fn() => \print_head_scripts() ); + $footer_print = self::capture_output( static fn() => \print_footer_scripts() ); + $script_done = \wp_scripts()->done; + + $style_base = $case['handles']['styleOrderBase']; + $style_entry = $case['handles']['styleOrderEntry']; + \wp_register_style( $style_base, 'runtime/order-base.css', array(), '1.0.0' ); + \wp_register_style( $style_entry, 'runtime/order-entry.css', array( $style_base ), '1.0.0' ); + \wp_add_inline_style( $style_entry, '.cf-order-entry { color: #123456; }' ); + \wp_enqueue_style( $style_entry ); + $style_print = self::capture_output( static fn() => \wp_print_styles() ); + $style_done = \wp_styles()->done; + + $head_positions = array( + 'base' => self::attribute_position( $head_print['output'], 'id', "{$base}-js" ), + 'middle' => self::attribute_position( $head_print['output'], 'id', "{$middle}-js" ), + 'entry' => self::attribute_position( $head_print['output'], 'id', "{$entry}-js" ), + 'footer' => self::attribute_position( $head_print['output'], 'id', "{$footer}-js" ), + ); + $footer_position = self::attribute_position( $footer_print['output'], 'id', "{$footer}-js" ); + $style_positions = array( + 'base' => self::attribute_position( $style_print['output'], 'id', "{$style_base}-css" ), + 'entry' => self::attribute_position( $style_print['output'], 'id', "{$style_entry}-css" ), + 'inline' => self::attribute_position( $style_print['output'], 'id', "{$style_entry}-inline-css" ), + ); + + $ok = ! $head_print['threw'] + && ! $footer_print['threw'] + && ! $style_print['threw'] + && array( $base, $middle, $entry, $footer ) === $script_done + && false !== $head_positions['base'] + && false !== $head_positions['middle'] + && false !== $head_positions['entry'] + && false === $head_positions['footer'] + && false !== $footer_position + && $head_positions['base'] < $head_positions['middle'] + && $head_positions['middle'] < $head_positions['entry'] + && self::contains_in_order( $head_print['output'], array( "{$base}-js-before", "{$base}-js", "{$entry}-js", "{$entry}-js-after" ) ) + && array( $style_base, $style_entry ) === $style_done + && false !== $style_positions['base'] + && false !== $style_positions['entry'] + && false !== $style_positions['inline'] + && $style_positions['base'] < $style_positions['entry'] + && $style_positions['entry'] < $style_positions['inline']; + + return $ctx->result( + 'script-loader-runtime.dependency-order-print-boundaries', + $ok, + self::case_data( $case ) + array( + 'scriptDone' => $script_done, + 'styleDone' => $style_done, + 'headPositions' => $head_positions, + 'footerPosition' => $footer_position, + 'stylePositions' => $style_positions, + 'headPreview' => self::preview( $head_print['output'] ), + 'footerPreview' => self::preview( $footer_print['output'] ), + 'stylePreview' => self::preview( $style_print['output'] ), + 'headCall' => self::describe_call( $head_print ), + 'footerCall' => self::describe_call( $footer_print ), + 'styleCall' => self::describe_call( $style_print ), + ) + ); + } + + private static function check_inline_localization_data( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + self::register_default_datepicker_scripts(); + + $handle = $case['handles']['inline']; + $style = $case['handles']['inlineStyle']; + $object_name = $case['objectName']; + + \wp_register_script( $handle, 'runtime/inline.js', array(), '1.0.0' ); + $inline_added = array( + 'beforeScriptTagStripped' => \wp_add_inline_script( $handle, '', 'before' ), + 'badPositionBecomesBefore' => \wp_add_inline_script( $handle, 'window.cfSideways = "two";', 'sideways' ), + 'after' => \wp_add_inline_script( $handle, 'window.cfAfter = "three";', 'after' ), + 'emptyRejected' => \wp_add_inline_script( $handle, '', 'after' ), + ); + $localized = \wp_localize_script( + $handle, + $object_name, + array( + 'plain' => 'alpha', + 'hostile' => $case['tagValue'], + 'entity' => '<decoded>', + 'nested' => array( 'left' => $case['token'] ), + 'l10n_print_after' => 'window.cfAfterLocalization = true', + ) + ); + \wp_enqueue_script( $handle ); + + $before_data = \wp_scripts()->get_inline_script_data( $handle, 'before' ); + $after_data = \wp_scripts()->get_inline_script_data( $handle, 'after' ); + $extra_data = \wp_scripts()->get_data( $handle, 'data' ); + $printed = self::capture_output( static fn() => \wp_print_scripts() ); + $output = $printed['output']; + + \wp_register_style( $style, 'runtime/inline.css', array(), '1.0.0' ); + $style_added = array( + 'styleTagStripped' => \wp_add_inline_style( $style, '' ), + 'second' => \wp_add_inline_style( $style, '.cf-inline-two { color: #abcdef; }' ), + 'emptyRejected' => \wp_add_inline_style( $style, '' ), + ); + \wp_enqueue_style( $style ); + $style_data = \wp_styles()->print_inline_style( $style, false ); + $style_print = self::capture_output( static fn() => \wp_print_styles() ); + + \wp_enqueue_script( 'jquery-ui-datepicker' ); + $datepicker_call = self::call( static fn() => \wp_localize_jquery_ui_datepicker() ); + $datepicker_data = \wp_scripts()->get_inline_script_data( 'jquery-ui-datepicker', 'after' ); + + $positions = array( + 'extra' => self::attribute_position( $output, 'id', "{$handle}-js-extra" ), + 'before' => self::attribute_position( $output, 'id', "{$handle}-js-before" ), + 'main' => self::attribute_position( $output, 'id', "{$handle}-js" ), + 'after' => self::attribute_position( $output, 'id', "{$handle}-js-after" ), + ); + $style_positions = array( + 'link' => self::attribute_position( $style_print['output'], 'id', "{$style}-css" ), + 'style' => self::attribute_position( $style_print['output'], 'id', "{$style}-inline-css" ), + ); + + $ok = ! $printed['threw'] + && ! $style_print['threw'] + && ! $datepicker_call['threw'] + && array( + 'beforeScriptTagStripped' => true, + 'badPositionBecomesBefore' => true, + 'after' => true, + 'emptyRejected' => false, + ) === $inline_added + && true === $localized + && str_contains( $before_data, 'window.cfBefore = "one";' ) + && str_contains( $before_data, 'window.cfSideways = "two";' ) + && ! str_contains( $before_data, '&";', array( 'id' => $case['handles']['tag'] . '-inline' ) ); + $inline_json = \wp_get_inline_script_tag( '{"value":"&"}', array( 'type' => 'application/json' ) ); + $inline_rejected = \wp_get_inline_script_tag( '', array( 'type' => 'text/plain' ) ); + $printed_script = self::capture_output( static fn() => \wp_print_script_tag( array( 'src' => 'runtime/printed.js', 'id' => 'printed-runtime' ) ) ); + $printed_inline = self::capture_output( + static fn() => \wp_print_inline_script_tag( 'window.printedInline = true;', array( 'id' => 'printed-inline-runtime' ) ) + ); + + \remove_filter( 'wp_script_attributes', $script_filter ); + \remove_filter( 'wp_inline_script_attributes', $inline_filter, 10 ); + + $dataset_cases = array( + 'postId' => 'data-post-id', + 'Before' => 'data--before', + '-One--Two---' => 'data---one---two---', + '' => 'data-', + ); + $dataset_failures = array(); + foreach ( $dataset_cases as $js_name => $html_name ) { + $actual_html = \wp_html_custom_data_attribute_name( $js_name ); + $actual_js = \wp_js_dataset_name( $html_name ); + if ( $actual_html !== $html_name || $actual_js !== $js_name ) { + $dataset_failures[] = array( + 'js' => $js_name, + 'html' => $html_name, + 'actualHtml' => $actual_html, + 'actualJs' => $actual_js, + ); + } + } + + $script_stripped = \wp_remove_surrounding_empty_script_tags( " \n\n" ); + $script_invalid = \wp_remove_surrounding_empty_script_tags( '' ); + + $tag_id_values = self::attribute_values( $tag, 'id' ); + $ok = 2 === $script_filter_calls + && 4 === $inline_filter_calls + && false === \has_filter( 'wp_script_attributes', $script_filter ) + && false === \has_filter( 'wp_inline_script_attributes', $inline_filter ) + && 1 === count( $tag_id_values ) + && str_contains( $tag, 'defer' ) + && str_contains( $tag, 'data-filtered=' ) + && ! str_contains( $tag, '' ) + && ! str_contains( $tag, '"quote"' ) + && ( str_contains( $inline_js, '<\\/script>' ) || str_contains( $inline_js, '' ) ) + && str_contains( $inline_js, 'data-inline=' ) + && str_contains( $inline_json, 'application/json' ) + && '' === $inline_rejected + && ! $printed_script['threw'] + && ! $printed_inline['threw'] + && str_contains( $printed_script['output'], 'printed-runtime' ) + && str_contains( $printed_inline['output'], 'printed-inline-runtime' ) + && array() === $dataset_failures + && null === \wp_js_dataset_name( 'data bad' ) + && null === \wp_js_dataset_name( 'post-id' ) + && null === \wp_html_custom_data_attribute_name( 'no spaces' ) + && 'sayHello();' === $script_stripped + && str_starts_with( $script_invalid, 'console.error(' ); + + return $ctx->result( + 'script-loader-runtime.tag-builders-dataset-escaping', + $ok, + self::case_data( $case ) + array( + 'scriptFilterCalls' => $script_filter_calls, + 'inlineFilterCalls' => $inline_filter_calls, + 'tagPreview' => self::preview( $tag ), + 'inlinePreview' => self::preview( $inline_js ), + 'jsonPreview' => self::preview( $inline_json ), + 'printedScript' => self::preview( $printed_script['output'] ), + 'printedInline' => self::preview( $printed_inline['output'] ), + 'datasetFailures' => array_slice( $dataset_failures, 0, self::FAILURE_LIMIT ), + 'strippedScript' => $script_stripped, + 'invalidScript' => self::preview( $script_invalid ), + ) + ); + } + + private static function check_script_translations( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + + $handle = $case['handles']['translation']; + $domain = 'cf-runtime-' . $case['token']; + $json = \wp_json_encode( + array( + 'locale_data' => array( + $domain => array( + '' => array( + 'domain' => $domain, + 'lang' => 'en_US', + ), + 'Hello' => array( 'Translated &' ), + ), + ), + ), + JSON_HEX_TAG | JSON_UNESCAPED_SLASHES + ); + + $pre_calls = array(); + $relative_calls = array(); + $pre_filter = static function ( $translations, $file, $filter_handle, $filter_domain ) use ( &$pre_calls, $handle, $domain, $json ) { + $pre_calls[] = array( + 'file' => false === $file ? false : basename( (string) $file ), + 'handle' => $filter_handle, + 'domain' => $filter_domain, + ); + if ( $handle === $filter_handle && $domain === $filter_domain ) { + return $json; + } + return $translations; + }; + $relative_filter = static function ( $relative, $src, $is_module ) use ( &$relative_calls ) { + $relative_calls[] = array( + 'relative' => $relative, + 'src' => $src, + 'isModule' => $is_module, + ); + return $relative; + }; + + \add_filter( 'pre_load_script_translations', $pre_filter, 10, 4 ); + \add_filter( 'load_script_textdomain_relative_path', $relative_filter, 10, 3 ); + + \wp_register_script( 'wp-i18n', 'runtime/wp-i18n.js', array(), '1.0.0' ); + \wp_register_script( $handle, 'wp-includes/js/dist/runtime-translation.min.js', array(), '1.0.0' ); + $set = \wp_set_script_translations( $handle, $domain, '' ); + $missing_set = \wp_set_script_translations( $case['handles']['missingTranslation'], $domain, '' ); + $loaded = \load_script_textdomain( $handle, $domain, '' ); + $translation = \wp_scripts()->print_translations( $handle, false ); + \wp_enqueue_script( $handle ); + $printed = self::capture_output( static fn() => \wp_print_scripts() ); + + \remove_filter( 'pre_load_script_translations', $pre_filter, 10 ); + \remove_filter( 'load_script_textdomain_relative_path', $relative_filter, 10 ); + + $obj = \wp_scripts()->registered[ $handle ] ?? null; + + $ok = true === $set + && false === $missing_set + && $json === $loaded + && is_string( $translation ) + && str_contains( $translation, 'wp.i18n.setLocaleData' ) + && str_contains( $translation, $domain ) + && ! str_contains( $translation, '' ) + && $obj instanceof \_WP_Dependency + && 1 === count( array_keys( $obj->deps, 'wp-i18n', true ) ) + && ! $printed['threw'] + && self::attribute_position( $printed['output'], 'id', "{$handle}-js-translations" ) !== false + && self::attribute_position( $printed['output'], 'id', "{$handle}-js" ) !== false + && self::attribute_position( $printed['output'], 'id', 'wp-i18n-js' ) !== false + && self::attribute_position( $printed['output'], 'id', 'wp-i18n-js' ) < self::attribute_position( $printed['output'], 'id', "{$handle}-js-translations" ) + && self::attribute_position( $printed['output'], 'id', "{$handle}-js-translations" ) < self::attribute_position( $printed['output'], 'id', "{$handle}-js" ) + && false === \has_filter( 'pre_load_script_translations', $pre_filter ) + && false === \has_filter( 'load_script_textdomain_relative_path', $relative_filter ) + && count( $pre_calls ) >= 2 + && count( $relative_calls ) >= 1; + + return $ctx->result( + 'script-loader-runtime.script-translations-filter-locality', + $ok, + self::case_data( $case ) + array( + 'handle' => $handle, + 'domain' => $domain, + 'set' => $set, + 'missingSet' => $missing_set, + 'deps' => $obj instanceof \_WP_Dependency ? $obj->deps : null, + 'preCalls' => array_slice( $pre_calls, 0, self::FAILURE_LIMIT ), + 'relativeCalls' => array_slice( $relative_calls, 0, self::FAILURE_LIMIT ), + 'translation' => self::preview( is_string( $translation ) ? $translation : '' ), + 'outputPreview' => self::preview( $printed['output'] ), + ) + ); + } + + private static function check_emoji_settings_and_styles( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + + \add_action( 'wp_print_styles', 'print_emoji_styles' ); + $styles_call = self::call( static fn() => \wp_enqueue_emoji_styles() ); + $emoji_style_data = \wp_styles()->print_inline_style( 'wp-emoji-styles', false ); + $style_hook_cleared = false === \has_action( 'wp_print_styles', 'print_emoji_styles' ); + $style_ok = ! $styles_call['threw'] + && true === \wp_style_is( 'wp-emoji-styles', 'enqueued' ) + && is_string( $emoji_style_data ) + && str_contains( $emoji_style_data, 'img.wp-smiley' ) + && $style_hook_cleared; + + $loader_path = ABSPATH . WPINC . '/js/wp-emoji-loader' . \wp_scripts_get_suffix() . '.js'; + if ( ! is_readable( $loader_path ) ) { + $data = self::case_data( $case ) + array( + 'loaderPath' => $loader_path, + 'styleData' => self::preview( is_string( $emoji_style_data ) ? $emoji_style_data : '' ), + 'stylesCall' => self::describe_call( $styles_call ), + 'styleHookCleared' => $style_hook_cleared, + ); + + if ( ! $style_ok ) { + return $ctx->fail( 'script-loader-runtime.emoji-settings-styles-escaping', $data ); + } + + return $ctx->skip( + 'script-loader-runtime.emoji-settings-styles-escaping', + 'Emoji detection loader asset is unavailable in this source checkout for the active SCRIPT_DEBUG suffix.', + $data + ); + } + + $emoji_url_calls = 0; + $emoji_ext_calls = 0; + $src_calls = array(); + $emoji_url = static function () use ( &$emoji_url_calls, $case ): string { + ++$emoji_url_calls; + return 'https://emoji.example.test/' . rawurlencode( $case['token'] ) . '//'; + }; + $emoji_ext = static function () use ( &$emoji_ext_calls ): string { + ++$emoji_ext_calls; + return '.png?'; + }; + $script_src = static function ( string $src, string $handle ) use ( &$src_calls ): string { + $src_calls[] = array( + 'handle' => $handle, + 'src' => $src, + ); + return $src; + }; + + \add_filter( 'emoji_url', $emoji_url ); + \add_filter( 'emoji_ext', $emoji_ext ); + \add_filter( 'script_loader_src', $script_src, 10, 2 ); + + $printed = self::capture_output( static fn() => \_print_emoji_detection_script() ); + + \remove_filter( 'emoji_url', $emoji_url ); + \remove_filter( 'emoji_ext', $emoji_ext ); + \remove_filter( 'script_loader_src', $script_src, 10 ); + + $settings_json = self::first_script_text_by_id( $printed['output'], 'wp-emoji-settings' ); + $settings = is_string( $settings_json ) ? json_decode( $settings_json, true ) : null; + + $ok = ! $printed['threw'] + && $style_ok + && 1 === $emoji_url_calls + && 1 === $emoji_ext_calls + && count( $src_calls ) >= 1 + && false === \has_filter( 'emoji_url', $emoji_url ) + && false === \has_filter( 'emoji_ext', $emoji_ext ) + && false === \has_filter( 'script_loader_src', $script_src ) + && is_array( $settings ) + && isset( $settings['baseUrl'], $settings['ext'], $settings['svgUrl'], $settings['svgExt'], $settings['source'] ) + && str_contains( $settings['baseUrl'], $case['token'] ) + && '.png?' === $settings['ext'] + && ! str_contains( $printed['output'], '' ) + && str_contains( $printed['output'], 'id="wp-emoji-settings"' ) + && str_contains( $printed['output'], 'type="application/json"' ) + && str_contains( $printed['output'], 'type="module"' ) + && true === \wp_style_is( 'wp-emoji-styles', 'enqueued' ) + && is_string( $emoji_style_data ) + && str_contains( $emoji_style_data, 'img.wp-smiley' ) + && $style_hook_cleared; + + return $ctx->result( + 'script-loader-runtime.emoji-settings-styles-escaping', + $ok, + self::case_data( $case ) + array( + 'emojiUrlCalls' => $emoji_url_calls, + 'emojiExtCalls' => $emoji_ext_calls, + 'scriptSrcCalls' => array_slice( $src_calls, 0, self::FAILURE_LIMIT ), + 'settings' => self::preview_array( $settings ), + 'styleData' => self::preview( is_string( $emoji_style_data ) ? $emoji_style_data : '' ), + 'outputPreview' => self::preview( $printed['output'] ), + 'printedCall' => self::describe_call( $printed ), + 'styleHookCleared' => $style_hook_cleared, + ) + ); + } + + private static function check_style_inlining_boundaries( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_styles_global(); + + $handle = $case['handles']['inlineFileStyle']; + $path = self::temp_path( 'script-loader-runtime-style-' . $case['token'] . '.css' ); + $css = ".cf-inline-file { background: url(images/bg.png); }\n" + . ".cf-root { background: url(/already-root.png); }\n" + . ".cf-data { background: url(data:image/png;base64,AAAA); }\n"; + + $limit_calls = 0; + $limit_filter = static function ( int $limit ) use ( &$limit_calls ): int { + ++$limit_calls; + return max( $limit, 1024 ); + }; + + $write = self::call( static fn() => file_put_contents( $path, $css ) ); + \add_filter( 'styles_inline_size_limit', $limit_filter ); + + \wp_register_style( $handle, self::CONTENT_URL . '/themes/runtime/style.css', array(), '1.0.0' ); + \wp_style_add_data( $handle, 'path', $path ); + \wp_enqueue_style( $handle ); + $inline_call = self::call( static fn() => \wp_maybe_inline_styles() ); + \remove_filter( 'styles_inline_size_limit', $limit_filter ); + + $obj = \wp_styles()->registered[ $handle ] ?? null; + $after = $obj instanceof \_WP_Dependency ? ( $obj->extra['after'] ?? null ) : null; + $style_out = self::capture_output( static fn() => \wp_print_styles() ); + + if ( file_exists( $path ) ) { + unlink( $path ); + } + + $inlined_css = is_array( $after ) ? implode( "\n", $after ) : ''; + $ok = ! $write['threw'] + && is_int( $write['value'] ) + && $write['value'] > 0 + && ! $inline_call['threw'] + && 1 === $limit_calls + && false === \has_filter( 'styles_inline_size_limit', $limit_filter ) + && $obj instanceof \_WP_Dependency + && false === $obj->src + && is_array( $after ) + && str_contains( $inlined_css, 'url(/wp-content/themes/runtime/images/bg.png)' ) + && str_contains( $inlined_css, 'url(/already-root.png)' ) + && str_contains( $inlined_css, 'url(data:image/png;base64,AAAA)' ) + && self::attribute_position( $style_out['output'], 'id', "{$handle}-inline-css" ) !== false + && ! str_contains( $style_out['output'], "{$handle}-css" ) + && str_contains( $style_out['output'], 'sourceURL=' . self::CONTENT_URL . '/themes/runtime/style.css' ); + + return $ctx->result( + 'script-loader-runtime.style-inlining-relative-links', + $ok, + self::case_data( $case ) + array( + 'handle' => $handle, + 'path' => $path, + 'write' => self::describe_call( $write ), + 'inlineCall' => self::describe_call( $inline_call ), + 'limitCalls' => $limit_calls, + 'registeredSrc' => $obj instanceof \_WP_Dependency ? $obj->src : null, + 'inlinedCss' => self::preview( $inlined_css ), + 'outputPreview' => self::preview( $style_out['output'] ), + ) + ); + } + + private static function check_block_editor_loader_guards( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_styles_global(); + \wp_default_styles( \wp_styles() ); + + $editor_filter_calls = 0; + $editor_filter = static function ( bool $load ) use ( &$editor_filter_calls ): bool { + ++$editor_filter_calls; + return ! $load; + }; + $separate_calls = 0; + $separate_filter = static function ( bool $load ) use ( &$separate_calls ): bool { + ++$separate_calls; + return true; + }; + $demand_calls = 0; + $demand_filter = static function ( bool $load ) use ( &$demand_calls ): bool { + ++$demand_calls; + return $load; + }; + + $default_editor = \wp_should_load_block_editor_scripts_and_styles(); + \add_filter( 'should_load_block_editor_scripts_and_styles', $editor_filter ); + $filtered_editor = \wp_should_load_block_editor_scripts_and_styles(); + \remove_filter( 'should_load_block_editor_scripts_and_styles', $editor_filter ); + + $default_separate = \wp_should_load_separate_core_block_assets(); + \add_filter( 'should_load_separate_core_block_assets', $separate_filter ); + $filtered_separate = \wp_should_load_separate_core_block_assets(); + \add_filter( 'should_load_block_assets_on_demand', $demand_filter ); + $filtered_demand = \wp_should_load_block_assets_on_demand(); + + $common_call = self::call( static fn() => \wp_common_block_scripts_and_styles() ); + $registered_call = self::call( static fn() => \wp_enqueue_registered_block_scripts_and_styles() ); + + \remove_filter( 'should_load_separate_core_block_assets', $separate_filter ); + \remove_filter( 'should_load_block_assets_on_demand', $demand_filter ); + + $ok = false === $default_editor + && true === $filtered_editor + && 1 === $editor_filter_calls + && false === \has_filter( 'should_load_block_editor_scripts_and_styles', $editor_filter ) + && false === $default_separate + && true === $filtered_separate + && true === $filtered_demand + && $separate_calls >= 2 + && $demand_calls >= 1 + && false === \has_filter( 'should_load_separate_core_block_assets', $separate_filter ) + && false === \has_filter( 'should_load_block_assets_on_demand', $demand_filter ) + && ! $common_call['threw'] + && ! $registered_call['threw'] + && true === \wp_style_is( 'wp-block-library', 'enqueued' ); + + return $ctx->result( + 'script-loader-runtime.block-editor-loader-guards', + $ok, + self::case_data( $case ) + array( + 'defaultEditor' => $default_editor, + 'filteredEditor' => $filtered_editor, + 'defaultSeparate' => $default_separate, + 'filteredSeparate' => $filtered_separate, + 'filteredDemand' => $filtered_demand, + 'editorCalls' => $editor_filter_calls, + 'separateCalls' => $separate_calls, + 'demandCalls' => $demand_calls, + 'styleQueue' => \wp_styles()->queue, + 'commonCall' => self::describe_call( $common_call ), + 'registeredCall' => self::describe_call( $registered_call ), + ) + ); + } + + private static function check_strategy_module_interactions( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::reset_scripts_global(); + self::reset_script_modules_global(); + + $dep = $case['handles']['strategyDep']; + $entry = $case['handles']['strategyEntry']; + $inline = $case['handles']['strategyInline']; + $modules = $case['handles']['strategyModules']; + + \wp_register_script( $dep, 'runtime/strategy-dep.js', array(), '1.0.0', array( 'strategy' => 'async', 'fetchpriority' => 'low' ) ); + \wp_register_script( $entry, 'runtime/strategy-entry.js', array( $dep ), '1.0.0', array( 'fetchpriority' => 'high' ) ); + \wp_register_script( $inline, 'runtime/strategy-inline.js', array(), '1.0.0', array( 'strategy' => 'defer' ) ); + \wp_add_inline_script( $inline, 'window.cfInlineAfter = true;', 'after' ); + \wp_register_script( + $modules, + 'runtime/strategy-modules.js', + array(), + '1.0.0', + array( + 'in_footer' => true, + 'strategy' => 'defer', + 'module_dependencies' => array( + $case['moduleIds'][0], + array( + 'id' => $case['moduleIds'][1], + 'import' => 'dynamic', + ), + array( 'missing-id' => 'ignored' ), + ), + ) + ); + + if ( function_exists( 'wp_register_script_module' ) ) { + \wp_register_script_module( $case['moduleIds'][0], 'runtime/module-a.js', array(), '1.0.0', array( 'fetchpriority' => 'low' ) ); + \wp_register_script_module( $case['moduleIds'][1], 'runtime/module-b.js', array(), '1.0.0' ); + } + + \wp_enqueue_script( array( $entry, $inline, $modules ) ); + $printed = self::capture_output( static fn() => \wp_print_scripts() ); + $output = $printed['output']; + $module_deps = \wp_scripts()->get_data( $modules, 'module_dependencies' ); + + $dep_tag = self::tag_by_id( $output, "{$dep}-js" ); + $entry_tag = self::tag_by_id( $output, "{$entry}-js" ); + $inline_tag = self::tag_by_id( $output, "{$inline}-js" ); + $modules_tag = self::tag_by_id( $output, "{$modules}-js" ); + + $ok = ! $printed['threw'] + && is_array( $module_deps ) + && 2 === count( $module_deps ) + && is_string( $dep_tag ) + && is_string( $entry_tag ) + && is_string( $inline_tag ) + && is_string( $modules_tag ) + && str_contains( $dep_tag, 'data-wp-strategy="async"' ) + && ! str_contains( $dep_tag, ' async' ) + && str_contains( $dep_tag, 'fetchpriority="high"' ) + && str_contains( $dep_tag, 'data-wp-fetchpriority="low"' ) + && str_contains( $entry_tag, 'fetchpriority="high"' ) + && str_contains( $inline_tag, 'data-wp-strategy="defer"' ) + && ! str_contains( $inline_tag, ' defer' ) + && str_contains( $modules_tag, 'defer' ) + && str_contains( $modules_tag, 'data-wp-strategy="defer"' ) + && 1 === ( \wp_scripts()->get_data( $modules, 'group' ) ?? null ); + + return $ctx->result( + 'script-loader-runtime.strategy-module-fetchpriority-interactions', + $ok, + self::case_data( $case ) + array( + 'dependencyTag' => self::preview( (string) $dep_tag ), + 'entryTag' => self::preview( (string) $entry_tag ), + 'inlineTag' => self::preview( (string) $inline_tag ), + 'modulesTag' => self::preview( (string) $modules_tag ), + 'moduleDeps' => $module_deps, + 'outputPreview' => self::preview( $output ), + ) + ); + } + + private static function check_jit_script_localization( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + if ( ! defined( 'AUTOSAVE_INTERVAL' ) ) { + return $ctx->skip( + 'script-loader-runtime.jit-script-localization', + 'AUTOSAVE_INTERVAL is not defined in the stripped no-DB bootstrap; calling the helper would fatal before exercising script-loader state.', + self::case_data( $case ) + ); + } + + self::reset_scripts_global(); + + $GLOBALS['shortcode_tags'] = array( + 'gallery' => static fn() => '', + $case['shortcode'] => static fn() => '', + ); + + foreach ( array( 'autosave', 'mce-view', 'word-count' ) as $handle ) { + \wp_register_script( $handle, "runtime/{$handle}.js", array(), '1.0.0' ); + } + + $call = self::call( static fn() => \wp_just_in_time_script_localization() ); + + $autosave = \wp_scripts()->get_data( 'autosave', 'data' ); + $mce_view = \wp_scripts()->get_data( 'mce-view', 'data' ); + $word_count = \wp_scripts()->get_data( 'word-count', 'data' ); + + $ok = ! $call['threw'] + && is_string( $autosave ) + && is_string( $mce_view ) + && is_string( $word_count ) + && str_contains( $autosave, 'autosaveInterval' ) + && str_contains( $mce_view, $case['shortcode'] ) + && str_contains( $word_count, $case['shortcode'] ); + + return $ctx->result( + 'script-loader-runtime.jit-script-localization', + $ok, + self::case_data( $case ) + array( + 'shortcode' => $case['shortcode'], + 'autosave' => self::preview( is_string( $autosave ) ? $autosave : '' ), + 'mceView' => self::preview( is_string( $mce_view ) ? $mce_view : '' ), + 'wordCount' => self::preview( is_string( $word_count ) ? $word_count : '' ), + 'call' => self::describe_call( $call ), + ) + ); + } + + private static function case_for_context( \ComponentFuzz\FuzzContext $ctx ): array { + $token = $ctx->iteration() . '-' . substr( sha1( (string) $ctx->seed() ), 0, 8 ); + $prefix = 'cf-slr-' . $token; + $tag_value = '"quoted"&'; + + return array( + 'token' => $token, + 'prefix' => $prefix, + 'tagValue' => $tag_value, + 'scriptSrcA' => 'runtime/' . rawurlencode( $prefix ) . '-one.js?x=1#frag', + 'scriptSrcB' => 'runtime/' . rawurlencode( $prefix ) . '-two.js?x=2#frag', + 'styleSrcA' => 'runtime/' . rawurlencode( $prefix ) . '-one.css?x=1#frag', + 'styleSrcB' => 'runtime/' . rawurlencode( $prefix ) . '-two.css?x=2#frag', + 'objectName' => 'cfRuntime' . str_replace( '-', '_', $token ), + 'shortcode' => 'cf_shortcode_' . str_replace( '-', '_', $token ), + 'moduleIds' => array( + '@component-fuzz/runtime/' . $token . '/alpha', + '@component-fuzz/runtime/' . $token . '/beta', + ), + 'handles' => array( + 'dep' => "{$prefix}-dep", + 'styleDep' => "{$prefix}-style-dep", + 'script' => "{$prefix}-script", + 'style' => "{$prefix}-style", + 'orderBase' => "{$prefix}-order-base", + 'orderMiddle' => "{$prefix}-order-middle", + 'orderEntry' => "{$prefix}-order-entry", + 'footer' => "{$prefix}-footer", + 'styleOrderBase' => "{$prefix}-style-order-base", + 'styleOrderEntry' => "{$prefix}-style-order-entry", + 'inline' => "{$prefix}-inline", + 'inlineStyle' => "{$prefix}-inline-style", + 'tag' => "{$prefix}-tag", + 'translation' => "{$prefix}-translation", + 'missingTranslation' => "{$prefix}-missing-translation", + 'inlineFileStyle' => "{$prefix}-inline-file-style", + 'strategyDep' => "{$prefix}-strategy-dep", + 'strategyEntry' => "{$prefix}-strategy-entry", + 'strategyInline' => "{$prefix}-strategy-inline", + 'strategyModules' => "{$prefix}-strategy-modules", + ), + ); + } + + private static function case_data( array $case ): array { + return array( + 'token' => $case['token'], + 'prefix' => $case['prefix'], + 'handles' => array_values( $case['handles'] ), + ); + } + + private static function prepare_runtime_globals(): void { + if ( ! isset( $GLOBALS['wp_actions'] ) || ! is_array( $GLOBALS['wp_actions'] ) ) { + $GLOBALS['wp_actions'] = array(); + } + $GLOBALS['wp_actions']['init'] = max( 1, (int) ( $GLOBALS['wp_actions']['init'] ?? 0 ) ); + $GLOBALS['concatenate_scripts'] = false; + $GLOBALS['compress_scripts'] = false; + $GLOBALS['compress_css'] = false; + $GLOBALS['pagenow'] = 'index.php'; + self::reset_scripts_global(); + self::reset_styles_global(); + self::reset_script_modules_global(); + } + + private static function reset_scripts_global(): void { + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + \wp_scripts()->base_url = self::BASE_URL; + \wp_scripts()->content_url = self::CONTENT_URL; + \wp_scripts()->default_version = self::DEFAULT_VERSION; + \wp_scripts()->default_dirs = array(); + $GLOBALS['concatenate_scripts'] = false; + $GLOBALS['compress_scripts'] = false; + } + + private static function reset_styles_global(): void { + $GLOBALS['wp_styles'] = new \WP_Styles(); + \wp_styles()->base_url = self::BASE_URL; + \wp_styles()->content_url = self::CONTENT_URL; + \wp_styles()->default_version = self::DEFAULT_VERSION; + \wp_styles()->default_dirs = array(); + $GLOBALS['concatenate_scripts'] = false; + $GLOBALS['compress_css'] = false; + } + + private static function reset_script_modules_global(): void { + if ( class_exists( 'WP_Script_Modules' ) ) { + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); + } + } + + private static function register_default_datepicker_scripts(): void { + \wp_register_script( 'jquery', false, array( 'jquery-core', 'jquery-migrate' ), '1.0.0' ); + \wp_register_script( 'jquery-core', 'runtime/jquery.js', array(), '1.0.0' ); + \wp_register_script( 'jquery-migrate', 'runtime/jquery-migrate.js', array( 'jquery-core' ), '1.0.0' ); + \wp_register_script( 'jquery-ui-core', 'runtime/jquery-ui-core.js', array( 'jquery' ), '1.0.0' ); + \wp_register_script( 'jquery-ui-datepicker', 'runtime/jquery-ui-datepicker.js', array( 'jquery-ui-core' ), '1.0.0' ); + } + + private static function call( callable $callback ): array { + try { + return array( + 'threw' => false, + 'value' => $callback(), + ); + } catch ( \Throwable $e ) { + return array( + 'threw' => true, + 'throwable' => self::describe_throwable( $e ), + 'value' => null, + ); + } + } + + private static function capture_output( callable $callback ): array { + $level = ob_get_level(); + ob_start(); + try { + $value = $callback(); + $output = ob_get_clean(); + return array( + 'threw' => false, + 'value' => $value, + 'output' => $output, + ); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + return array( + 'threw' => true, + 'throwable' => self::describe_throwable( $e ), + 'value' => null, + 'output' => '', + ); + } + } + + private static function describe_call( array $call ): array { + return array( + 'threw' => (bool) ( $call['threw'] ?? false ), + 'value' => self::describe_value( $call['value'] ?? null ), + 'throwable' => $call['throwable'] ?? null, + ); + } + + private static function describe_value( $value ) { + if ( is_array( $value ) ) { + return array( + 'type' => 'array', + 'count' => count( $value ), + 'value' => self::preview_array( $value ), + ); + } + if ( is_object( $value ) ) { + return array( + 'type' => 'object', + 'class' => get_class( $value ), + ); + } + return $value; + } + + private static function dependency_summary( $dependency ): ?array { + if ( ! $dependency instanceof \_WP_Dependency ) { + return null; + } + + return array( + 'src' => self::preview( is_string( $dependency->src ) ? $dependency->src : ( true === $dependency->src ? '[true]' : '[false]' ) ), + 'deps' => $dependency->deps, + 'ver' => self::describe_value( $dependency->ver ), + 'args' => self::describe_value( $dependency->args ), + ); + } + + private static function preview_array( $value ) { + if ( ! is_array( $value ) ) { + return $value; + } + + $encoded = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + if ( false === $encoded ) { + return '[array]'; + } + + return self::preview( $encoded, self::PREVIEW_BYTES ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function first_script_text_by_id( string $html, string $id ): ?string { + if ( ! preg_match( '/]*\bid=(["\'])' . preg_quote( $id, '/' ) . '\1)[^>]*>(.*?)<\/script>/is', $html, $matches ) ) { + return null; + } + + return html_entity_decode( $matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + } + + private static function tag_by_id( string $html, string $id ): ?string { + if ( ! preg_match_all( '/]*>.*?<\/script>/is', $html, $matches ) ) { + return null; + } + + foreach ( $matches[0] as $tag ) { + if ( self::attribute_position( $tag, 'id', $id ) !== false ) { + return $tag; + } + } + + return null; + } + + private static function attribute_values( string $html, string $attribute ): array { + if ( ! preg_match_all( '/\s' . preg_quote( $attribute, '/' ) . '\s*=\s*([\'"])(.*?)\1/i', $html, $matches ) ) { + return array(); + } + + return array_map( + static fn( string $value ): string => self::decode_html_attribute( $value ), + $matches[2] + ); + } + + private static function attribute_position( string $html, string $attribute, string $decoded_value ) { + if ( ! preg_match_all( '/\s' . preg_quote( $attribute, '/' ) . '\s*=\s*([\'"])(.*?)\1/i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { + return false; + } + + foreach ( $matches[2] as $index => $match ) { + if ( $decoded_value === self::decode_html_attribute( $match[0] ) ) { + return $matches[0][ $index ][1]; + } + } + + return false; + } + + private static function decode_html_attribute( string $value ): string { + return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + } + + private static function contains_in_order( string $haystack, array $needles ): bool { + $offset = 0; + foreach ( $needles as $needle ) { + $pos = strpos( $haystack, $needle, $offset ); + if ( false === $pos ) { + return false; + } + $offset = $pos + strlen( $needle ); + } + return true; + } + + private static function temp_path( string $filename ): string { + return rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $filename; + } + + private static function preview( string $value, int $limit = self::PREVIEW_BYTES ): string { + $printable = preg_replace_callback( + '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', + static function ( array $m ): string { + return sprintf( '\\x%02X', ord( $m[0] ) ); + }, + $value + ); + + if ( strlen( $printable ) > $limit ) { + return substr( $printable, 0, $limit ) . '...'; + } + + return $printable; + } + + private static function snapshot_globals(): array { + $snapshot = array(); + foreach ( + array( + 'concatenate_scripts', + 'compress_css', + 'compress_scripts', + 'current_screen', + 'pagenow', + 'shortcode_tags', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_script_modules', + 'wp_scripts', + 'wp_styles', + ) as $name + ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => $GLOBALS[ $name ] ?? null, + ); + } + + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } +} From 65cb994010b4135ff13d8cbea1284f20d81c75b5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:31:36 +0200 Subject: [PATCH 0106/1102] Add icons and connectors fuzz surface --- tools/component-fuzz/README.md | 5 + .../surfaces/IconsConnectorsSurface.php | 1497 +++++++++++++++++ 2 files changed, 1502 insertions(+) create mode 100644 tools/component-fuzz/surfaces/IconsConnectorsSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index debe73796e060..855f7f2959657 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -182,6 +182,11 @@ database, network requests, or a configured site. - `http`: synthetic HTTP response arrays, response objects, header/cookie parsing, proxy decisions, redirect safety, URL validation, and relative URL resolution without live network requests. +- `icons-connectors`: no-DB Icons and Connectors API coverage, including + connector registry lifecycle and init discovery, settings/REST key masking, + script module serialization, icon manifest/search behavior, SVG sanitization + and file caching, REST icons schema/permission/error contracts, and state + restoration. - `images`: image constraint and resize math, metadata dimension lookup, responsive `srcset`/`sizes` generation, attachment image helpers, image tag attribute insertion, and loading optimization attributes. diff --git a/tools/component-fuzz/surfaces/IconsConnectorsSurface.php b/tools/component-fuzz/surfaces/IconsConnectorsSurface.php new file mode 100644 index 0000000000000..d39a3ce651c34 --- /dev/null +++ b/tools/component-fuzz/surfaces/IconsConnectorsSurface.php @@ -0,0 +1,1497 @@ +skip( + 'icons-connectors.bootstrap-apis-available', + 'Required icons/connectors APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + + try { + return array( + self::check_connector_registry_lifecycle( $ctx->fork( 'connector-registry' ) ), + self::check_connector_init_settings_and_serialization( $ctx->fork( 'connector-init-settings' ) ), + self::check_icon_registry_lifecycle( $ctx->fork( 'icon-registry' ) ), + self::check_rest_icons_controller( $ctx->fork( 'rest-icons' ) ), + ); + } catch ( \Throwable $e ) { + return array( + $ctx->fail( + 'icons-connectors.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ), + ); + } finally { + self::restore_state( $snapshot ); + } + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_Connector_Registry', + 'WP_Icons_Registry', + 'WP_Error', + 'WP_REST_Request', + 'WP_REST_Response', + 'WP_REST_Server', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + '_wp_connectors_get_api_key_source', + '_wp_connectors_get_connector_script_module_data', + '_wp_connectors_init', + '_wp_connectors_mask_api_key', + '_wp_connectors_resolve_ai_provider_logo_url', + '_wp_connectors_rest_settings_dispatch', + '_wp_register_default_connector_settings', + 'add_action', + 'add_filter', + 'current_user_can', + 'get_registered_settings', + 'is_wp_error', + 'register_setting', + 'remove_action', + 'remove_filter', + 'rest_ensure_response', + 'wp_get_connector', + 'wp_get_connectors', + 'wp_is_connector_registered', + 'wp_is_file_mod_allowed', + 'wp_json_encode', + 'wp_kses', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_connector_registry_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::set_connector_registry( null ); + self::collect_failure( + $failures, + array() === \wp_get_connectors() + && null === \wp_get_connector( 'missing-connector' ) + && false === \wp_is_connector_registered( 'missing-connector' ), + 'public connector helpers return empty values before singleton initialization', + array( + 'connectors' => \wp_get_connectors(), + 'connector' => \wp_get_connector( 'missing-connector' ), + 'isRegistered' => \wp_is_connector_registered( 'missing-connector' ), + ) + ); + + $registry = new \WP_Connector_Registry(); + self::set_connector_registry( $registry ); + $cases = self::connector_cases( $ctx->fork( 'cases' ) ); + $registered = array(); + + foreach ( $cases as $index => $case ) { + $registered[ $case['id'] ] = $registry->register( $case['id'], $case['args'] ); + $stored = $registered[ $case['id'] ]; + $plugin = is_array( $stored ) ? $stored['plugin'] : array(); + $is_active = isset( $plugin['is_active'] ) && is_callable( $plugin['is_active'] ) + ? (bool) call_user_func( $plugin['is_active'] ) + : null; + + self::collect_failure( + $failures, + is_array( $stored ) + && $stored === $registry->get_registered( $case['id'] ) + && $stored === \wp_get_connector( $case['id'] ) + && isset( \wp_get_connectors()[ $case['id'] ] ) + && true === $registry->is_registered( $case['id'] ) + && true === \wp_is_connector_registered( $case['id'] ) + && $case['args']['name'] === $stored['name'] + && $case['expectedDescription'] === $stored['description'] + && $case['args']['type'] === $stored['type'] + && $case['expectedAuth'] === $stored['authentication'] + && $case['expectedLogoUrl'] === ( $stored['logo_url'] ?? null ) + && $case['expectedPluginFile'] === ( $plugin['file'] ?? null ) + && $case['expectedActive'] === $is_active, + "connector registration stores normalized data case {$index}", + array( + 'case' => $case, + 'stored' => self::describe_value( $stored ), + ) + ); + } + + self::collect_failure( + $failures, + array_keys( $registered ) === array_keys( \wp_get_connectors() ) + && $registered === \wp_get_connectors(), + 'connector aggregate preserves insertion order and exact normalized entries', + array( + 'expectedKeys' => array_keys( $registered ), + 'actualKeys' => array_keys( \wp_get_connectors() ), + ) + ); + + $before_invalid = $registry->get_all_registered(); + $invalids = array( + 'duplicate' => self::capture_doing_it_wrong( + static fn() => $registry->register( $cases[0]['id'], $cases[0]['args'] ) + ), + 'uppercaseId' => self::capture_doing_it_wrong( + static fn() => $registry->register( 'Bad_ID', $cases[0]['args'] ) + ), + 'slashId' => self::capture_doing_it_wrong( + static fn() => $registry->register( 'bad/id', $cases[0]['args'] ) + ), + 'missingName' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'missing-name' ), 'missing-name' ), + array( + 'type' => 'search', + 'authentication' => array( 'method' => 'none' ), + ) + ) + ), + 'missingType' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'missing-type' ), 'missing-type' ), + array( + 'name' => 'Missing Type', + 'authentication' => array( 'method' => 'none' ), + ) + ) + ), + 'missingAuth' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'missing-auth' ), 'missing-auth' ), + array( + 'name' => 'Missing Auth', + 'type' => 'search', + ) + ) + ), + 'invalidAuth' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'invalid-auth' ), 'invalid-auth' ), + array( + 'name' => 'Invalid Auth', + 'type' => 'search', + 'authentication' => array( 'method' => 'oauth' ), + ) + ) + ), + 'emptySetting' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'empty-setting' ), 'empty-setting' ), + array( + 'name' => 'Empty Setting', + 'type' => 'search', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => '', + ), + ) + ) + ), + 'emptyConstant' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'empty-constant' ), 'empty-constant' ), + array( + 'name' => 'Empty Constant', + 'type' => 'search', + 'authentication' => array( + 'method' => 'api_key', + 'constant_name' => '', + ), + ) + ) + ), + 'emptyEnv' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'empty-env' ), 'empty-env' ), + array( + 'name' => 'Empty Env', + 'type' => 'search', + 'authentication' => array( + 'method' => 'api_key', + 'env_var_name' => '', + ), + ) + ) + ), + 'invalidCallback' => self::capture_doing_it_wrong( + static fn() => $registry->register( + self::connector_id( $ctx->fork( 'invalid-plugin' ), 'invalid-plugin' ), + array( + 'name' => 'Invalid Plugin', + 'type' => 'search', + 'authentication' => array( 'method' => 'none' ), + 'plugin' => array( 'is_active' => 'component_fuzz_missing_callback' ), + ) + ) + ), + ); + + $all_invalid_failed = true; + foreach ( $invalids as $invalid ) { + $all_invalid_failed = $all_invalid_failed + && null === $invalid['value'] + && self::has_warning( $invalid ); + } + + self::collect_failure( + $failures, + $all_invalid_failed && $before_invalid === $registry->get_all_registered(), + 'invalid and duplicate connector registrations warn and leave registry unchanged', + array( 'invalids' => $invalids ) + ); + + $before_ai = $registry->get_all_registered(); + $ai_filter = static fn(): bool => false; + \add_filter( 'wp_supports_ai', $ai_filter ); + try { + $ai_disabled = $registry->register( + self::connector_id( $ctx->fork( 'ai-disabled' ), 'ai-disabled' ), + array( + 'name' => 'AI Disabled', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ) + ); + } finally { + \remove_filter( 'wp_supports_ai', $ai_filter ); + } + + self::collect_failure( + $failures, + null === $ai_disabled && $before_ai === $registry->get_all_registered(), + 'ai_provider connectors fail closed without mutation when AI support is disabled', + array( 'aiDisabled' => self::describe_value( $ai_disabled ) ) + ); + + $removed = $registry->unregister( $cases[0]['id'] ); + $override = is_array( $removed ) ? $removed : array(); + if ( array() !== $override ) { + $override['description'] = 'Overridden ' . substr( dechex( $ctx->seed() ), -6 ); + } + $re_registered = $registry->register( $cases[0]['id'], $override ); + $missing_get = self::capture_doing_it_wrong( + static fn() => $registry->get_registered( self::connector_id( $ctx->fork( 'missing-get' ), 'missing-get' ) ) + ); + $missing_drop = self::capture_doing_it_wrong( + static fn() => $registry->unregister( self::connector_id( $ctx->fork( 'missing-drop' ), 'missing-drop' ) ) + ); + + self::collect_failure( + $failures, + $removed === $registered[ $cases[0]['id'] ] + && is_array( $re_registered ) + && $override['description'] === $registry->get_registered( $cases[0]['id'] )['description'] + && null === $missing_get['value'] + && null === $missing_drop['value'] + && self::has_warning( $missing_get ) + && self::has_warning( $missing_drop ), + 'unregister returns exact connector data and supports explicit override workflows', + array( + 'removed' => self::describe_value( $removed ), + 'reRegistered' => self::describe_value( $re_registered ), + 'missingGet' => $missing_get, + 'missingDrop' => $missing_drop, + ) + ); + + self::set_connector_registry( null ); + self::collect_failure( + $failures, + array() === \wp_get_connectors() + && null === \wp_get_connector( $cases[1]['id'] ) + && false === \wp_is_connector_registered( $cases[1]['id'] ), + 'public connector helpers tolerate a missing singleton after mutation', + array( + 'id' => $cases[1]['id'], + 'connectors' => \wp_get_connectors(), + 'connector' => \wp_get_connector( $cases[1]['id'] ), + 'isRegistered' => \wp_is_connector_registered( $cases[1]['id'] ), + ) + ); + + return self::result( + $ctx, + 'icons-connectors.connectors.registry-lifecycle-validation', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_connector_init_settings_and_serialization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::set_connector_registry( null ); + $outside_registry = new \WP_Connector_Registry(); + $outside_guard = self::capture_doing_it_wrong( + static fn() => \WP_Connector_Registry::set_instance( $outside_registry ) + ); + + self::collect_failure( + $failures, + null === \WP_Connector_Registry::get_instance() + && null === $outside_guard['value'] + && self::has_warning( $outside_guard ), + 'set_instance guard rejects calls outside init', + array( 'outsideGuard' => $outside_guard ) + ); + + $custom_id = self::connector_id( $ctx->fork( 'hooked' ), 'hooked' ); + $override_text = 'Fuzz override ' . substr( dechex( $ctx->seed() ), -6 ); + $hook_seen = 0; + $hook_registry_seen = null; + $hook = static function ( \WP_Connector_Registry $registry ) use ( &$hook_seen, &$hook_registry_seen, $custom_id, $override_text ): void { + ++$hook_seen; + $hook_registry_seen = $registry; + $registry->register( + $custom_id, + array( + 'name' => 'Hooked Connector', + 'description' => 'Registered from wp_connectors_init.', + 'type' => 'hooked_service', + 'authentication' => array( 'method' => 'none' ), + ) + ); + + if ( $registry->is_registered( 'akismet' ) ) { + $akismet = $registry->unregister( 'akismet' ); + $akismet['description'] = $override_text; + $registry->register( 'akismet', $akismet ); + } + }; + $disable_ai = static fn(): bool => false; + + \add_filter( 'wp_supports_ai', $disable_ai ); + \add_action( 'wp_connectors_init', $hook ); + try { + self::with_doing_action( + 'init', + static function (): void { + \_wp_connectors_init(); + } + ); + } finally { + \remove_action( 'wp_connectors_init', $hook ); + \remove_filter( 'wp_supports_ai', $disable_ai ); + } + + $instance = \WP_Connector_Registry::get_instance(); + $connectors = \wp_get_connectors(); + self::collect_failure( + $failures, + $instance instanceof \WP_Connector_Registry + && $hook_registry_seen === $instance + && 1 === $hook_seen + && isset( $connectors['akismet'], $connectors[ $custom_id ] ) + && $override_text === $connectors['akismet']['description'] + && ! isset( $connectors['anthropic'], $connectors['google'], $connectors['openai'] ) + && true === \wp_is_connector_registered( $custom_id ), + 'connector initializer registers defaults, fires discovery hook, and honors AI support filter', + array( + 'hookSeen' => $hook_seen, + 'connectorIds' => array_keys( $connectors ), + 'customId' => $custom_id, + ) + ); + + $plugin_logo = self::write_temp_file( WP_PLUGIN_DIR . '/cf-icons-connectors/logo-' . substr( dechex( $ctx->seed() ), -5 ) . '.svg', '' ); + $mu_logo = self::write_temp_file( WPMU_PLUGIN_DIR . '/cf-icons-connectors/logo-' . substr( dechex( $ctx->seed() ), -5 ) . '.svg', '' ); + $outside = self::write_temp_file( sys_get_temp_dir() . '/cf-icons-connectors-outside-' . getmypid() . '-' . substr( dechex( $ctx->seed() ), -5 ) . '.svg', '' ); + try { + $plugin_url = \_wp_connectors_resolve_ai_provider_logo_url( $plugin_logo ); + $mu_url = \_wp_connectors_resolve_ai_provider_logo_url( $mu_logo ); + $missing_url = \_wp_connectors_resolve_ai_provider_logo_url( $outside . '.missing' ); + $outside_warning = self::capture_doing_it_wrong( + static fn() => \_wp_connectors_resolve_ai_provider_logo_url( $outside ) + ); + + self::collect_failure( + $failures, + is_string( $plugin_url ) + && str_contains( $plugin_url, '/plugins/cf-icons-connectors/' ) + && is_string( $mu_url ) + && str_contains( $mu_url, '/mu-plugins/cf-icons-connectors/' ) + && null === $missing_url + && null === $outside_warning['value'] + && self::has_warning( $outside_warning ), + 'connector logo path resolver accepts plugin roots and rejects outside paths', + array( + 'pluginUrl' => $plugin_url, + 'muUrl' => $mu_url, + 'missingUrl' => $missing_url, + 'outsideWarning' => $outside_warning, + ) + ); + } finally { + foreach ( array( $plugin_logo, $mu_logo, $outside ) as $path ) { + if ( is_string( $path ) && file_exists( $path ) ) { + unlink( $path ); + } + } + } + + $registry = new \WP_Connector_Registry(); + self::set_connector_registry( $registry ); + + $active_id = self::connector_id( $ctx->fork( 'active' ), 'active' ); + $inactive_id = self::connector_id( $ctx->fork( 'inactive' ), 'inactive' ); + $none_id = self::connector_id( $ctx->fork( 'none' ), 'none' ); + $auto_id = self::connector_id( $ctx->fork( 'auto' ), 'auto' ); + $active_setting = 'cfuzz_active_' . str_replace( '-', '_', $active_id ) . '_api_key'; + $inactive_setting = 'cfuzz_inactive_' . str_replace( '-', '_', $inactive_id ) . '_api_key'; + $auto_setting = str_replace( '-', '_', "connectors_service-extra_{$auto_id}_api_key" ); + $env_name = 'CFUZZ_CONNECTOR_' . strtoupper( substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + $secret = 'sk-' . strtolower( $ctx->identifier( 4, 9 ) ) . '-' . substr( hash( 'sha1', (string) $ctx->seed() ), 0, 12 ); + + $registry->register( + $inactive_id, + array( + 'name' => 'Inactive Connector', + 'type' => 'service', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => $inactive_setting, + ), + 'plugin' => array( + 'file' => 'inactive/inactive.php', + 'is_active' => static fn(): bool => false, + ), + ) + ); + $registry->register( + $active_id, + array( + 'name' => 'Active Connector', + 'type' => 'service', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => $active_setting, + 'env_var_name' => $env_name, + ), + 'plugin' => array( + 'file' => 'active/active.php', + 'is_active' => static fn(): bool => true, + ), + ) + ); + $registry->register( + $none_id, + array( + 'name' => 'No Auth Connector', + 'type' => 'service', + 'authentication' => array( 'method' => 'none' ), + ) + ); + $registry->register( + $auto_id, + array( + 'name' => 'Auto Setting Connector', + 'type' => 'service-extra', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.test/keys/' . rawurlencode( $auto_id ), + ), + ) + ); + + \_wp_register_default_connector_settings(); + $registered_settings = \get_registered_settings(); + self::collect_failure( + $failures, + isset( $registered_settings[ $active_setting ], $registered_settings[ $auto_setting ] ) + && ! isset( $registered_settings[ $inactive_setting ] ) + && 'string' === ( $registered_settings[ $active_setting ]['type'] ?? null ) + && true === ( $registered_settings[ $active_setting ]['show_in_rest'] ?? null ) + && 'sanitize_text_field' === ( $registered_settings[ $active_setting ]['sanitize_callback'] ?? null ), + 'default connector settings register only active API-key connectors and normalized auto settings', + array( + 'activeSetting' => $registered_settings[ $active_setting ] ?? null, + 'autoSetting' => $registered_settings[ $auto_setting ] ?? null, + 'inactiveSetting' => $registered_settings[ $inactive_setting ] ?? null, + ) + ); + + $old_env = getenv( $env_name ); + $db_filter = static function () use ( $secret ): string { + return $secret . '-db'; + }; + try { + putenv( $env_name ); + $none_source = \_wp_connectors_get_api_key_source( $active_setting, '', '' ); + \add_filter( "pre_option_{$active_setting}", $db_filter ); + $db_source = \_wp_connectors_get_api_key_source( $active_setting, '', '' ); + $constant_source = \_wp_connectors_get_api_key_source( $active_setting, '', 'AUTH_KEY' ); + putenv( $env_name . '=' . $secret . '-env' ); + $env_source = \_wp_connectors_get_api_key_source( $active_setting, $env_name, 'AUTH_KEY' ); + \remove_filter( "pre_option_{$active_setting}", $db_filter ); + $env_without_db = \_wp_connectors_get_api_key_source( $active_setting, $env_name, '' ); + + self::collect_failure( + $failures, + 'none' === $none_source + && 'database' === $db_source + && 'constant' === $constant_source + && 'env' === $env_source + && 'env' === $env_without_db, + 'API key source precedence is env, constant, database, then none', + array( + 'none' => $none_source, + 'database' => $db_source, + 'constant' => $constant_source, + 'env' => $env_source, + 'envWithoutDb' => $env_without_db, + ) + ); + + $server = new \WP_REST_Server(); + $response = new \WP_REST_Response( + array( + $active_setting => $secret, + $inactive_setting => substr( $secret, -4 ), + $auto_setting => '', + 'unrelated' => $secret, + ) + ); + $masked_data = \_wp_connectors_rest_settings_dispatch( $response, $server, self::request( 'GET', '/wp/v2/settings' ) )->get_data(); + $post_data = \_wp_connectors_rest_settings_dispatch( + new \WP_REST_Response( array( $active_setting => $secret ) ), + $server, + self::request( 'POST', '/wp/v2/settings' ) + )->get_data(); + $wrong_data = \_wp_connectors_rest_settings_dispatch( + new \WP_REST_Response( array( $active_setting => $secret ) ), + $server, + self::request( 'GET', '/wp/v2/posts' ) + )->get_data(); + + self::collect_failure( + $failures, + \_wp_connectors_mask_api_key( $secret ) === ( $masked_data[ $active_setting ] ?? null ) + && substr( $secret, -4 ) === ( $masked_data[ $inactive_setting ] ?? null ) + && '' === ( $masked_data[ $auto_setting ] ?? null ) + && $secret === ( $masked_data['unrelated'] ?? null ) + && \_wp_connectors_mask_api_key( $secret ) === ( $post_data[ $active_setting ] ?? null ) + && $secret === ( $wrong_data[ $active_setting ] ?? null ), + 'REST settings dispatch masks connector keys only on settings responses', + array( + 'maskedData' => $masked_data, + 'postData' => $post_data, + 'wrongData' => $wrong_data, + ) + ); + + $module_data = \_wp_connectors_get_connector_script_module_data( array( 'existing' => 'kept' ) ); + $connectors = $module_data['connectors'] ?? array(); + $module_json = \wp_json_encode( $module_data ); + $sorted_ids = array_keys( $connectors ); + $expected_ids = $sorted_ids; + sort( $expected_ids, SORT_STRING ); + + self::collect_failure( + $failures, + 'kept' === ( $module_data['existing'] ?? null ) + && is_bool( $module_data['isFileModDisabled'] ?? null ) + && $expected_ids === $sorted_ids + && 'env' === ( $connectors[ $active_id ]['authentication']['keySource'] ?? null ) + && true === ( $connectors[ $active_id ]['authentication']['isConnected'] ?? null ) + && true === ( $connectors[ $active_id ]['plugin']['isActivated'] ?? null ) + && false === ( $connectors[ $inactive_id ]['plugin']['isActivated'] ?? null ) + && 'none' === ( $connectors[ $inactive_id ]['authentication']['keySource'] ?? null ) + && 'none' === ( $connectors[ $none_id ]['authentication']['method'] ?? null ) + && ! str_contains( (string) $module_json, $secret ), + 'connector script module data is sorted, camel-cased, status-normalized, and key-safe', + array( + 'ids' => $sorted_ids, + 'active' => $connectors[ $active_id ] ?? null, + 'inactive' => $connectors[ $inactive_id ] ?? null, + 'moduleJson' => $module_json, + ) + ); + } finally { + \remove_filter( "pre_option_{$active_setting}", $db_filter ); + if ( false === $old_env ) { + putenv( $env_name ); + } else { + putenv( $env_name . '=' . $old_env ); + } + } + + return self::result( + $ctx, + 'icons-connectors.connectors.init-settings-rest-module-data', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_icon_registry_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $temp_files = array(); + + self::set_static_property( 'WP_Icons_Registry', 'instance', null ); + $registry = \WP_Icons_Registry::get_instance(); + $register = self::method( 'WP_Icons_Registry', 'register' ); + $sanitize = self::method( 'WP_Icons_Registry', 'sanitize_icon_content' ); + + try { + $all_icons = $registry->get_registered_icons(); + $first_icon = $all_icons[0] ?? null; + $first_name = is_array( $first_icon ) ? (string) ( $first_icon['name'] ?? '' ) : ''; + $search = str_contains( $first_name, '/' ) ? substr( $first_name, strpos( $first_name, '/' ) + 1, 4 ) : 'core'; + $matches = $registry->get_registered_icons( strtoupper( $search ) ); + + self::collect_failure( + $failures, + 0 < count( $all_icons ) + && is_array( $first_icon ) + && '' !== $first_name + && true === $registry->is_registered( $first_name ) + && $first_name === ( $registry->get_registered_icon( $first_name )['name'] ?? null ) + && 0 < count( $matches ) + && self::icons_all_match_search( $matches, strtoupper( $search ) ) + && array() === $registry->get_registered_icons( 'cfuzz-no-such-icon-' . substr( dechex( $ctx->seed() ), -6 ) ), + 'core icon manifest loads icons and search filters case-insensitively by icon name', + array( + 'coreCount' => count( $all_icons ), + 'firstName' => $first_name, + 'search' => $search, + 'matchCount' => count( $matches ), + ) + ); + + $token = 'cfz' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $safe_svg = self::safe_svg( $ctx->fork( 'safe' ) ); + $unsafe_svg = self::unsafe_svg( $ctx->fork( 'unsafe' ) ); + $sanitized = $sanitize->invoke( $registry, $unsafe_svg ); + $safe_clean = $sanitize->invoke( $registry, $safe_svg ); + $content_name = 'cfzicons/' . $token . '-content'; + + $content_registered = $register->invoke( + $registry, + $content_name, + array( + 'label' => 'Content Icon ', + 'content' => $unsafe_svg, + ) + ); + $content_icon = $registry->get_registered_icon( $content_name ); + + self::collect_failure( + $failures, + true === $content_registered + && is_array( $content_icon ) + && $content_name === ( $content_icon['name'] ?? null ) + && 'Content Icon ' === ( $content_icon['label'] ?? null ) + && $sanitized === ( $content_icon['content'] ?? null ) + && '' !== $sanitized + && ! str_contains( strtolower( $sanitized ), ' $content_name, + 'sanitized' => $sanitized, + 'icon' => self::describe_value( $content_icon ), + ) + ); + + $file_path = self::write_temp_file( sys_get_temp_dir() . '/cf-icons-' . getmypid() . '-' . $token . '.svg', $unsafe_svg ); + $temp_files[] = $file_path; + $file_name = 'cfzicons/' . $token . '-file'; + $file_result = $register->invoke( + $registry, + $file_name, + array( + 'label' => 'File Icon', + 'filePath' => $file_path, + ) + ); + $stored_before = self::get_object_property( $registry, 'registered_icons' ); + $had_content = isset( $stored_before[ $file_name ]['content'] ); + $first_file = $registry->get_registered_icon( $file_name ); + file_put_contents( $file_path, $safe_svg ); + $second_file = $registry->get_registered_icon( $file_name ); + + self::collect_failure( + $failures, + true === $file_result + && false === $had_content + && is_array( $first_file ) + && is_array( $second_file ) + && $first_file['content'] === $second_file['content'] + && $sanitized === $first_file['content'] + && $safe_clean !== $second_file['content'], + 'filePath icon content is lazily sanitized and cached after first read', + array( + 'fileName' => $file_name, + 'hadContent' => $had_content, + 'firstFile' => self::describe_value( $first_file ), + 'secondFile' => self::describe_value( $second_file ), + ) + ); + + $custom_names = array( $content_name, $file_name ); + for ( $i = 0; $i < self::ICON_CASES; $i++ ) { + $name = 'cfzicons/' . $token . '-case-' . $i; + $custom_names[] = $name; + $register->invoke( + $registry, + $name, + array( + 'label' => 'Case ' . $i, + 'content' => self::safe_svg( $ctx->fork( 'case-' . $i ) ), + ) + ); + } + $custom_matches = $registry->get_registered_icons( strtoupper( $token ) ); + $match_names = array_map( + static fn( array $icon ): string => (string) $icon['name'], + $custom_matches + ); + + self::collect_failure( + $failures, + count( $custom_names ) === count( $custom_matches ) + && array() === array_diff( $custom_names, $match_names ) + && self::icons_all_match_search( $custom_matches, strtoupper( $token ) ), + 'icon search discovery returns all and only matching custom icons', + array( + 'expected' => $custom_names, + 'actual' => $match_names, + ) + ); + + $before_invalid = self::get_object_property( $registry, 'registered_icons' ); + $invalids = array( + 'uppercaseName' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'Cfzicons/upper', + array( + 'label' => 'Upper', + 'content' => $safe_svg, + ) + ) + ), + 'noNamespace' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'nonamespace', + array( + 'label' => 'No Namespace', + 'content' => $safe_svg, + ) + ) + ), + 'badUnderscore' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/bad_icon', + array( + 'label' => 'Bad Underscore', + 'content' => $safe_svg, + ) + ) + ), + 'duplicate' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + $content_name, + array( + 'label' => 'Duplicate', + 'content' => $safe_svg, + ) + ) + ), + 'invalidProperty' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-bad-property', + array( + 'label' => 'Bad Property', + 'content' => $safe_svg, + 'width' => 24, + ) + ) + ), + 'missingLabel' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-missing-label', + array( 'content' => $safe_svg ) + ) + ), + 'nonStringContent' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-non-string', + array( + 'label' => 'Non String', + 'content' => array( 'svg' ), + ) + ) + ), + 'emptySanitized' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-empty', + array( + 'label' => 'Empty', + 'content' => '', + ) + ) + ), + 'bothSources' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-both', + array( + 'label' => 'Both', + 'content' => $safe_svg, + 'filePath' => $file_path, + ) + ) + ), + 'noSource' => self::capture_doing_it_wrong( + static fn() => $register->invoke( + $registry, + 'cfzicons/' . $token . '-none', + array( 'label' => 'None' ) + ) + ), + ); + + $all_invalid_failed = true; + foreach ( $invalids as $invalid ) { + $all_invalid_failed = $all_invalid_failed + && false === $invalid['value'] + && self::has_warning( $invalid ); + } + + $serialized_content = \wp_json_encode( $content_icon['content'] ?? '' ); + $decoded_content = is_string( $serialized_content ) ? json_decode( $serialized_content, true ) : null; + self::collect_failure( + $failures, + $all_invalid_failed + && self::get_object_property( $registry, 'registered_icons' ) === $before_invalid + && is_string( $serialized_content ) + && $decoded_content === ( $content_icon['content'] ?? '' ) + && is_string( $decoded_content ) + && ! str_contains( strtolower( $decoded_content ), ' $invalids, + 'serializedContent' => $serialized_content, + ) + ); + } finally { + foreach ( $temp_files as $temp_file ) { + if ( is_string( $temp_file ) && file_exists( $temp_file ) ) { + unlink( $temp_file ); + } + } + } + + return self::result( + $ctx, + 'icons-connectors.icons.registry-sanitization-search-cache-validation', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_rest_icons_controller( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! class_exists( 'WP_REST_Icons_Controller' ) ) { + return $ctx->skip( + 'icons-connectors.icons.rest-controller', + 'REST icons controller is not available in this checkout.' + ); + } + + $failures = array(); + self::set_static_property( 'WP_Icons_Registry', 'instance', null ); + $registry = \WP_Icons_Registry::get_instance(); + $register = self::method( 'WP_Icons_Registry', 'register' ); + + $token = 'r' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $icon_name = 'cfzicons/' . $token . '-rest'; + $register->invoke( + $registry, + $icon_name, + array( + 'label' => 'REST Icon ' . $token, + 'content' => self::unsafe_svg( $ctx->fork( 'rest-svg' ) ), + ) + ); + + $controller = new \WP_REST_Icons_Controller(); + $schema = $controller->get_item_schema(); + $params = $controller->get_collection_params(); + + self::collect_failure( + $failures, + 'icon' === ( $schema['title'] ?? null ) + && array( 'name', 'label', 'content' ) === array_keys( $schema['properties'] ?? array() ) + && true === ( $schema['properties']['name']['readonly'] ?? null ) + && true === ( $schema['properties']['label']['readonly'] ?? null ) + && true === ( $schema['properties']['content']['readonly'] ?? null ) + && 'view' === ( $params['context']['default'] ?? null ) + && isset( $params['search'] ) + && 'string' === ( $params['search']['type'] ?? null ), + 'REST icons schema and collection params expose read-only icon fields and search', + array( + 'schemaKeys' => array_keys( $schema['properties'] ?? array() ), + 'paramKeys' => array_keys( $params ), + ) + ); + + $denied = $controller->get_items_permissions_check( self::request( 'GET', '/wp/v2/icons' ) ); + $grant = self::install_cap_filter( array( 'edit_posts' ) ); + try { + $allowed = $controller->get_items_permissions_check( self::request( 'GET', '/wp/v2/icons' ) ); + $item_allowed = $controller->get_item_permissions_check( + self::request( 'GET', '/wp/v2/icons/' . $icon_name, array(), array( 'name' => $icon_name ) ) + ); + $collection = $controller->get_items( + self::request( + 'GET', + '/wp/v2/icons', + array( + 'search' => strtoupper( $token ), + '_fields' => 'name,label', + ) + ) + ); + $item_response = $controller->get_item( + self::request( + 'GET', + '/wp/v2/icons/' . $icon_name, + array( '_fields' => 'name,content' ), + array( 'name' => $icon_name ) + ) + ); + $prepared_embed = $controller->prepare_item_for_response( + $registry->get_registered_icon( $icon_name ), + self::request( + 'GET', + '/wp/v2/icons/' . $icon_name, + array( + 'context' => 'embed', + '_fields' => 'name', + ), + array( 'name' => $icon_name ) + ) + ); + } finally { + \remove_filter( 'user_has_cap', $grant, 10 ); + } + + $collection_data = $collection instanceof \WP_REST_Response ? $collection->get_data() : array(); + $item_data = $item_response instanceof \WP_REST_Response ? $item_response->get_data() : array(); + $embed_data = $prepared_embed instanceof \WP_REST_Response ? $prepared_embed->get_data() : array(); + $collection_hit = self::find_icon_in_rest_collection( $collection_data, $icon_name ); + $missing_icon = $controller->get_icon( 'cfzicons/' . $token . '-missing' ); + $missing_item = $controller->get_item( + self::request( + 'GET', + '/wp/v2/icons/cfzicons/' . $token . '-missing', + array(), + array( 'name' => 'cfzicons/' . $token . '-missing' ) + ) + ); + + self::collect_failure( + $failures, + $denied instanceof \WP_Error + && 'rest_cannot_view' === $denied->get_error_code() + && in_array( $denied->get_error_data()['status'] ?? null, array( 401, 403 ), true ) + && true === $allowed + && true === $item_allowed + && $collection instanceof \WP_REST_Response + && is_array( $collection_hit ) + && array( 'name', 'label' ) === array_keys( $collection_hit ) + && $icon_name === ( $collection_hit['name'] ?? null ) + && $item_response instanceof \WP_REST_Response + && array( 'name', 'content' ) === array_keys( $item_data ) + && $icon_name === ( $item_data['name'] ?? null ) + && ! str_contains( strtolower( (string) ( $item_data['content'] ?? '' ) ), 'get_error_code() + && 404 === ( $missing_icon->get_error_data()['status'] ?? null ) + && $missing_item instanceof \WP_Error + && 'rest_icon_not_found' === $missing_item->get_error_code(), + 'REST icons controller enforces permissions, field filtering, search, sanitization, and 404 contracts', + array( + 'denied' => self::describe_error( $denied ), + 'collection' => $collection_data, + 'item' => $item_data, + 'embed' => $embed_data, + 'missing' => self::describe_error( $missing_icon ), + ) + ); + + return self::result( + $ctx, + 'icons-connectors.icons.rest-controller-schema-permissions-errors', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function connector_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array(); + + for ( $i = 0; $i < self::CONNECTOR_CASES; $i++ ) { + $case_ctx = $ctx->fork( 'case-' . $i ); + $id = self::connector_id( $case_ctx, 'case-' . $i ); + $type = self::connector_slug( $case_ctx->fork( 'type' ), 0 === $i % 2 ? 'search-service' : 'media_service' ); + $method = 0 === $i % 3 ? 'none' : 'api_key'; + $description = $case_ctx->bool( 70 ) ? 'Description ' . $case_ctx->identifier( 4, 10 ) : ''; + $expected_logo_url = null; + $expected_plugin_file = null; + $expected_active = true; + $args = array( + 'name' => 'Connector ' . $case_ctx->identifier( 4, 10 ), + 'type' => $type, + 'authentication' => array( 'method' => $method ), + ); + + if ( $case_ctx->bool( 80 ) ) { + $args['description'] = $description; + } else { + $description = ''; + } + + if ( $case_ctx->bool( 45 ) ) { + $expected_logo_url = 'https://example.test/logos/' . rawurlencode( $id ) . '.svg'; + $args['logo_url'] = $expected_logo_url; + } + + $expected_auth = array( 'method' => $method ); + if ( 'api_key' === $method ) { + if ( $case_ctx->bool( 55 ) ) { + $args['authentication']['credentials_url'] = 'https://example.test/keys/' . rawurlencode( $id ); + $expected_auth['credentials_url'] = $args['authentication']['credentials_url']; + } + if ( $case_ctx->bool( 50 ) ) { + $args['authentication']['setting_name'] = 'explicit_' . str_replace( '-', '_', $id ) . '_api_key'; + } + $expected_auth['setting_name'] = $args['authentication']['setting_name'] + ?? str_replace( '-', '_', "connectors_{$type}_{$id}_api_key" ); + if ( $case_ctx->bool( 40 ) ) { + $args['authentication']['constant_name'] = 'CFUZZ_' . strtoupper( str_replace( '-', '_', $id ) ) . '_KEY'; + $expected_auth['constant_name'] = $args['authentication']['constant_name']; + } + if ( $case_ctx->bool( 40 ) ) { + $args['authentication']['env_var_name'] = 'CFUZZ_' . strtoupper( str_replace( '-', '_', $id ) ) . '_ENV'; + $expected_auth['env_var_name'] = $args['authentication']['env_var_name']; + } + } + + if ( $case_ctx->bool( 65 ) ) { + $args['plugin'] = array(); + if ( $case_ctx->bool( 75 ) ) { + $expected_plugin_file = $id . '/' . $id . '.php'; + $args['plugin']['file'] = $expected_plugin_file; + } + if ( $case_ctx->bool( 55 ) ) { + $expected_active = $case_ctx->bool(); + $args['plugin']['is_active'] = static fn(): bool => $expected_active; + } + } + + $cases[] = array( + 'id' => $id, + 'args' => $args, + 'expectedActive' => $expected_active, + 'expectedAuth' => $expected_auth, + 'expectedDescription' => $description, + 'expectedLogoUrl' => $expected_logo_url, + 'expectedPluginFile' => $expected_plugin_file, + ); + } + + return $cases; + } + + private static function connector_id( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + return self::connector_slug( $ctx, $prefix ) . '-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + } + + private static function connector_slug( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + $slug = strtolower( $prefix . '-' . $ctx->identifier( 3, 10 ) ); + $slug = preg_replace( '/[^a-z0-9_-]+/', '-', $slug ); + $slug = preg_replace( '/-+/', '-', (string) $slug ); + $slug = trim( $slug, '-_' ); + + return '' === $slug ? 'cfuzz-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) : $slug; + } + + private static function safe_svg( \ComponentFuzz\FuzzContext $ctx ): string { + $size = $ctx->int( 16, 32 ); + $x = $ctx->int( 0, 4 ); + $y = $ctx->int( 0, 4 ); + $w = $ctx->int( 8, 20 ); + $h = $ctx->int( 8, 20 ); + + return ''; + } + + private static function unsafe_svg( \ComponentFuzz\FuzzContext $ctx ): string { + $size = $ctx->int( 18, 30 ); + $x = $ctx->int( 1, 5 ); + $y = $ctx->int( 1, 5 ); + + return ''; + } + + private static function write_temp_file( string $path, string $contents ): string { + $dir = dirname( $path ); + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0777, true ) && ! is_dir( $dir ) ) { + throw new \RuntimeException( 'Could not create temp directory: ' . $dir ); + } + file_put_contents( $path, $contents ); + return $path; + } + + private static function load_rest_icons_controller(): void { + if ( class_exists( 'WP_REST_Icons_Controller' ) ) { + return; + } + + $path = \ComponentFuzz\repo_root() . '/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php'; + if ( file_exists( $path ) ) { + require_once $path; + } + } + + private static function request( string $method, string $route, array $query_params = array(), array $url_params = array() ): \WP_REST_Request { + $request = new \WP_REST_Request( $method, $route ); + if ( array() !== $query_params ) { + $request->set_query_params( $query_params ); + } + if ( array() !== $url_params ) { + $request->set_url_params( $url_params ); + } + return $request; + } + + private static function install_cap_filter( array $granted_caps ): callable { + $granted_caps = array_fill_keys( $granted_caps, true ); + $filter = static function ( array $allcaps ) use ( $granted_caps ): array { + foreach ( $granted_caps as $cap => $grant ) { + $allcaps[ $cap ] = $grant; + } + return $allcaps; + }; + + \add_filter( 'user_has_cap', $filter, 10, 4 ); + return $filter; + } + + private static function with_doing_action( string $action, callable $callback ) { + if ( ! isset( $GLOBALS['wp_current_filter'] ) || ! is_array( $GLOBALS['wp_current_filter'] ) ) { + $GLOBALS['wp_current_filter'] = array(); + } + + $GLOBALS['wp_current_filter'][] = $action; + try { + return $callback(); + } finally { + array_pop( $GLOBALS['wp_current_filter'] ); + } + } + + private static function icons_all_match_search( array $icons, string $search ): bool { + foreach ( $icons as $icon ) { + if ( ! is_array( $icon ) || ! isset( $icon['name'] ) || false === stripos( (string) $icon['name'], $search ) ) { + return false; + } + } + return true; + } + + private static function find_icon_in_rest_collection( array $collection, string $name ): ?array { + foreach ( $collection as $item ) { + if ( is_array( $item ) && $name === ( $item['name'] ?? null ) ) { + return $item; + } + } + return null; + } + + private static function set_connector_registry( ?\WP_Connector_Registry $registry ): void { + self::set_static_property( 'WP_Connector_Registry', 'instance', $registry ); + } + + private static function capture_doing_it_wrong( callable $callback ): array { + $warnings = array(); + $listener = static function ( $function_name, $message, $version ) use ( &$warnings ): void { + $warnings[] = array( + 'function' => $function_name, + 'message' => $message, + 'version' => $version, + ); + }; + + \add_action( 'doing_it_wrong_run', $listener, 10, 3 ); + try { + $value = $callback(); + return array( + 'threw' => false, + 'value' => $value, + 'warnings' => $warnings, + ); + } catch ( \Throwable $e ) { + return array( + 'threw' => true, + 'throwable' => self::describe_throwable( $e ), + 'value' => null, + 'warnings' => $warnings, + ); + } finally { + \remove_action( 'doing_it_wrong_run', $listener, 10 ); + } + } + + private static function has_warning( array $capture ): bool { + return empty( $capture['threw'] ) && ! empty( $capture['warnings'] ); + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function snapshot_state(): array { + $connector_registry = self::get_static_property( 'WP_Connector_Registry', 'instance' ); + $icons_registry = self::get_static_property( 'WP_Icons_Registry', 'instance' ); + + return array( + 'globals' => self::snapshot_globals( + array( + 'current_user', + 'new_allowed_options', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_object_cache', + 'wp_registered_settings', + 'wp_registered_setting_types', + ) + ), + 'connectorRegistry' => $connector_registry, + 'registeredConnectors' => $connector_registry instanceof \WP_Connector_Registry + ? self::get_object_property( $connector_registry, 'registered_connectors' ) + : null, + 'iconsRegistry' => $icons_registry, + 'registeredIcons' => $icons_registry instanceof \WP_Icons_Registry + ? self::get_object_property( $icons_registry, 'registered_icons' ) + : null, + 'wpdbOptions' => isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null, + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + + if ( $snapshot['connectorRegistry'] instanceof \WP_Connector_Registry ) { + self::set_object_property( $snapshot['connectorRegistry'], 'registered_connectors', $snapshot['registeredConnectors'] ); + self::set_static_property( 'WP_Connector_Registry', 'instance', $snapshot['connectorRegistry'] ); + } else { + self::set_static_property( 'WP_Connector_Registry', 'instance', null ); + } + + if ( $snapshot['iconsRegistry'] instanceof \WP_Icons_Registry ) { + self::set_object_property( $snapshot['iconsRegistry'], 'registered_icons', $snapshot['registeredIcons'] ); + self::set_static_property( 'WP_Icons_Registry', 'instance', $snapshot['iconsRegistry'] ); + } else { + self::set_static_property( 'WP_Icons_Registry', 'instance', null ); + } + + if ( null !== $snapshot['wpdbOptions'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['wpdbOptions'] ); + } + } + + private static function snapshot_globals( array $names ): array { + $snapshot = array(); + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + return $snapshot; + } + + private static function restore_globals( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function get_static_property( string $class_name, string $property ) { + $reflection = new \ReflectionProperty( $class_name, $property ); + self::make_reflection_accessible( $reflection ); + return $reflection->getValue(); + } + + private static function set_static_property( string $class_name, string $property, $value ): void { + $reflection = new \ReflectionProperty( $class_name, $property ); + self::make_reflection_accessible( $reflection ); + $reflection->setValue( null, $value ); + } + + private static function get_object_property( object $target, string $property ) { + $reflection = new \ReflectionProperty( $target, $property ); + self::make_reflection_accessible( $reflection ); + return $reflection->getValue( $target ); + } + + private static function set_object_property( object $target, string $property, $value ): void { + $reflection = new \ReflectionProperty( $target, $property ); + self::make_reflection_accessible( $reflection ); + $reflection->setValue( $target, $value ); + } + + private static function method( string $class_name, string $method ): \ReflectionMethod { + $reflection = new \ReflectionMethod( $class_name, $method ); + self::make_reflection_accessible( $reflection ); + return $reflection; + } + + private static function make_reflection_accessible( $reflection ): void { + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } + } + + private static function clone_value( $value ) { + if ( $value instanceof \Closure ) { + return $value; + } + + if ( is_object( $value ) ) { + return clone $value; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + return $value; + } + + private static function describe_error( $error ): array { + if ( ! $error instanceof \WP_Error ) { + return array( 'value' => self::describe_value( $error ) ); + } + + return array( + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + 'data' => $error->get_error_data(), + ); + } + + private static function describe_value( $value ) { + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Closure ) { + return '[closure]'; + } + if ( $value instanceof \WP_Error ) { + return self::describe_error( $value ); + } + return '[object ' . get_class( $value ) . ']'; + } + + if ( is_string( $value ) && strlen( $value ) > 240 ) { + return substr( $value, 0, 240 ) . '...'; + } + + return $value; + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} From 77c3261c5a2ff280f6bf4b0760d4433065a397a7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:33:03 +0200 Subject: [PATCH 0107/1102] Add environment load fuzz surface --- tools/component-fuzz/README.md | 5 + .../surfaces/EnvironmentLoadSurface.php | 1606 +++++++++++++++++ 2 files changed, 1611 insertions(+) create mode 100644 tools/component-fuzz/surfaces/EnvironmentLoadSurface.php diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 855f7f2959657..66bd99b086a86 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -145,6 +145,11 @@ database, network requests, or a configured site. WHATWG-style validity, `WP_Email_Address` IDN/punycode views, invalid UTF-8, malformed address structure, selected boundary lengths, and user email lookup/duplicate behavior for accent-distinct local parts. +- `environment-load`: no-network environment/load/compat helper coverage, + including environment type cache boundaries, server/request normalization, + Basic Auth and SSL detection, memory-limit parsing, ini mutability, + installing/maintenance flags, request guard filters, HTTPS migration + short-circuits, and UTF-8 compatibility oracles with state restoration. - `error-protection`: no-shutdown error protection and recovery-mode infrastructure coverage, including paused-extension source normalization and storage, recovery key/cookie validation, recovery-link generation, filtered diff --git a/tools/component-fuzz/surfaces/EnvironmentLoadSurface.php b/tools/component-fuzz/surfaces/EnvironmentLoadSurface.php new file mode 100644 index 0000000000000..dfcfc65335b95 --- /dev/null +++ b/tools/component-fuzz/surfaces/EnvironmentLoadSurface.php @@ -0,0 +1,1606 @@ +skip( + 'environment-load.bootstrap-apis-available', + 'Required WordPress environment/load APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows = array_merge( $rows, self::check_environment_type( $ctx->fork( 'environment-type' ) ) ); + $rows[] = self::check_development_mode( $ctx->fork( 'development-mode' ) ); + $rows[] = self::check_server_protocols_and_fixups( $ctx->fork( 'server-fixups' ) ); + $rows[] = self::check_basic_auth( $ctx->fork( 'basic-auth' ) ); + $rows[] = self::check_ssl_detection( $ctx->fork( 'ssl' ) ); + $rows[] = self::check_memory_limit_parsing( $ctx->fork( 'memory' ) ); + $rows[] = self::check_ini_mutability( $ctx->fork( 'ini' ) ); + $rows[] = self::check_installing_and_maintenance_flags( $ctx->fork( 'installing' ), $snapshot ); + $rows[] = self::check_runtime_filters_and_request_guards( $ctx->fork( 'request-guards' ) ); + $rows[] = self::check_json_xml_request_guards( $ctx->fork( 'json-xml' ) ); + $rows = array_merge( $rows, self::check_https_helpers( $ctx->fork( 'https' ) ) ); + $rows[] = self::check_utf8_validation_and_scanning( $ctx->fork( 'utf8-scan' ) ); + $rows[] = self::check_utf8_compat_helpers( $ctx->fork( 'utf8-compat' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'environment-load.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'environment-load.global-state-restored', + self::state_matches( $snapshot ), + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedSuperglobals' => array_keys( $snapshot['superglobals'] ), + 'trackedEnv' => 'WP_ENVIRONMENT_TYPE', + ) + ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + '_is_utf8_charset', + '_mb_chr', + '_mb_ord', + '_mb_strlen', + '_mb_substr', + '_wp_is_valid_utf8_fallback', + '_wp_scan_utf8', + '_wp_scrub_utf8_fallback', + '_wp_utf8_codepoint_count', + '_wp_utf8_codepoint_span', + 'add_action', + 'add_filter', + 'get_option', + 'has_filter', + 'is_protected_ajax_action', + 'is_ssl', + 'remove_action', + 'remove_filter', + 'update_option', + 'wp_convert_hr_to_bytes', + 'wp_doing_ajax', + 'wp_doing_cron', + 'wp_fix_server_vars', + 'wp_get_development_mode', + 'wp_get_environment_type', + 'wp_get_https_detection_errors', + 'wp_get_server_protocol', + 'wp_has_noncharacters', + 'wp_installing', + 'wp_is_development_mode', + 'wp_is_file_mod_allowed', + 'wp_is_home_url_using_https', + 'wp_is_https_supported', + 'wp_is_ini_value_changeable', + 'wp_is_json_media_type', + 'wp_is_json_request', + 'wp_is_jsonp_request', + 'wp_is_local_html_output', + 'wp_is_maintenance_mode', + 'wp_is_site_protected_by_basic_auth', + 'wp_is_site_url_using_https', + 'wp_is_using_https', + 'wp_is_valid_utf8', + 'wp_is_xml_request', + 'wp_populate_basic_auth_from_authorization_header', + 'wp_replace_insecure_home_url', + 'wp_scrub_utf8', + 'wp_should_replace_insecure_home_url', + 'wp_using_themes', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_Error' ) ) { + $missing[] = 'class WP_Error'; + } + + return $missing; + } + + private static function check_environment_type( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! function_exists( 'getenv' ) || ! function_exists( 'putenv' ) ) { + return array( + $ctx->skip( + 'environment-load.environment-type.getenv-putenv-available', + 'getenv() or putenv() is unavailable in this PHP runtime.' + ), + ); + } + + $allowed = array( 'local', 'development', 'staging', 'production' ); + $cases = array_merge( + $allowed, + array( + '', + 'LOCAL', + 'prod', + "staging\n", + $ctx->ascii( 0, 12 ), + ) + ); + $failures = array(); + $original = getenv( 'WP_ENVIRONMENT_TYPE' ); + + try { + foreach ( $cases as $index => $value ) { + self::set_env_var( 'WP_ENVIRONMENT_TYPE', $value ); + + $actual = \wp_get_environment_type(); + $expected = in_array( $value, $allowed, true ) ? $value : 'production'; + $honored = $actual === $expected; + + self::collect_failure( + $failures, + in_array( $actual, $allowed, true ) && ( ! defined( 'WP_RUN_CORE_TESTS' ) || $honored ), + "wp_get_environment_type allowed/fail-closed case {$index}", + array( + 'value' => $value, + 'actual' => $actual, + 'expected' => $expected, + 'honored' => $honored, + ) + ); + } + } finally { + self::set_env_var( 'WP_ENVIRONMENT_TYPE', $original ); + } + + $rows = array( + self::result( + $ctx, + 'environment-load.environment-type.allowed-values', + array() === $failures, + array( + 'cases' => count( $cases ), + 'cacheBypass' => defined( 'WP_RUN_CORE_TESTS' ), + 'failures' => array_slice( $failures, 0, 6 ), + ) + ), + ); + + if ( ! defined( 'WP_RUN_CORE_TESTS' ) ) { + $rows[] = $ctx->skip( + 'environment-load.environment-type.full-matrix', + 'wp_get_environment_type() caches in-process results unless WP_RUN_CORE_TESTS is defined; this surface asserts allowed fail-closed output without permanently changing constants.' + ); + } + + return $rows; + } + + private static function check_development_mode( \ComponentFuzz\FuzzContext $ctx ): array { + $current = \wp_get_development_mode(); + $modes = array( 'core', 'plugin', 'theme', 'all', '', 'invalid-' . $ctx->identifier( 3, 8 ) ); + $allowed = array( 'core', 'plugin', 'theme', 'all', '' ); + $failures = array(); + $seen = array(); + + foreach ( $modes as $mode ) { + $actual = \wp_is_development_mode( $mode ); + $expected = '' !== $current && ( 'all' === $current || $mode === $current ); + $seen[] = array( + 'mode' => $mode, + 'actual' => $actual, + 'expected' => $expected, + ); + + self::collect_failure( + $failures, + $actual === $expected, + "wp_is_development_mode agreement for {$mode}", + end( $seen ) + ); + } + + return self::result( + $ctx, + 'environment-load.development-mode.current-mode-agreement', + in_array( $current, $allowed, true ) && array() === $failures, + array( + 'current' => $current, + 'checks' => $seen, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_server_protocols_and_fixups( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $original_server = $_SERVER; + $original_self = $GLOBALS['PHP_SELF'] ?? null; + $self_existed = array_key_exists( 'PHP_SELF', $GLOBALS ); + + $protocols = array( + 'HTTP/1.1' => 'HTTP/1.1', + 'HTTP/2' => 'HTTP/2', + 'HTTP/2.0' => 'HTTP/2.0', + 'HTTP/3' => 'HTTP/3', + 'HTTP/1.0' => 'HTTP/1.0', + 'HTTP/4' => 'HTTP/1.0', + '' => 'HTTP/1.0', + $ctx->ascii( 0, 14 ) => 'HTTP/1.0', + ); + + try { + foreach ( $protocols as $protocol => $expected ) { + $_SERVER = array( + 'SERVER_PROTOCOL' => $protocol, + ); + + self::collect_failure( + $failures, + \wp_get_server_protocol() === $expected, + "wp_get_server_protocol normalizes {$protocol}", + array( + 'protocol' => $protocol, + 'expected' => $expected, + 'actual' => \wp_get_server_protocol(), + ) + ); + } + + foreach ( self::server_fixup_cases() as $index => $case ) { + $_SERVER = $case['server']; + $GLOBALS['PHP_SELF'] = $_SERVER['PHP_SELF']; + + \wp_fix_server_vars(); + + foreach ( $case['expected'] as $key => $expected ) { + self::collect_failure( + $failures, + ( $_SERVER[ $key ] ?? null ) === $expected, + "wp_fix_server_vars {$case['label']} {$key}", + array( + 'case' => $index, + 'key' => $key, + 'expected' => $expected, + 'actual' => $_SERVER[ $key ] ?? null, + 'server' => $_SERVER, + ) + ); + } + } + } finally { + $_SERVER = $original_server; + if ( $self_existed ) { + $GLOBALS['PHP_SELF'] = $original_self; + } else { + unset( $GLOBALS['PHP_SELF'] ); + } + } + + return self::result( + $ctx, + 'environment-load.server-vars.protocols-fixups', + array() === $failures, + array( + 'protocolCases' => count( $protocols ), + 'fixupCases' => count( self::server_fixup_cases() ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function server_fixup_cases(): array { + return array( + array( + 'label' => 'iis-original-url', + 'server' => array( + 'SERVER_SOFTWARE' => 'Microsoft-IIS/10.0', + 'REQUEST_URI' => '', + 'HTTP_X_ORIGINAL_URL' => '/rewritten/path?x=1', + 'PHP_SELF' => '', + 'SCRIPT_NAME' => '/index.php', + 'QUERY_STRING' => '', + ), + 'expected' => array( + 'REQUEST_URI' => '/rewritten/path?x=1', + 'PHP_SELF' => '/rewritten/path', + ), + ), + array( + 'label' => 'orig-path-info', + 'server' => array( + 'SERVER_SOFTWARE' => 'Apache', + 'REQUEST_URI' => '', + 'PHP_SELF' => '/index.php', + 'SCRIPT_NAME' => '/index.php', + 'ORIG_PATH_INFO' => '/library/item', + 'QUERY_STRING' => 'a=1', + ), + 'expected' => array( + 'PATH_INFO' => '/library/item', + 'REQUEST_URI' => '/index.php/library/item?a=1', + 'PHP_SELF' => '/index.php', + ), + ), + array( + 'label' => 'php-cgi-script-filename', + 'server' => array( + 'SERVER_SOFTWARE' => 'ComponentFuzz', + 'REQUEST_URI' => '/cgi-path', + 'PHP_SELF' => '/index.php', + 'SCRIPT_NAME' => '/index.php', + 'SCRIPT_FILENAME' => '/cgi-bin/php.cgi', + 'PATH_TRANSLATED' => '/var/www/index.php', + 'QUERY_STRING' => '', + ), + 'expected' => array( + 'SCRIPT_FILENAME' => '/var/www/index.php', + 'REQUEST_URI' => '/cgi-path', + ), + ), + ); + } + + private static function check_basic_auth( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $original_server = $_SERVER; + $original_pagenow = $GLOBALS['pagenow'] ?? null; + $pagenow_existed = array_key_exists( 'pagenow', $GLOBALS ); + + $user = 'user_' . self::safe_basic_auth_token( $ctx->identifier( 3, 8 ) ); + $pass = 'pass:' . $ctx->int( 10, 99 ); + $valid_header = 'Basic ' . base64_encode( "{$user}:{$pass}" ); + $cases = array( + array( + 'label' => 'http-authorization', + 'server' => array( 'HTTP_AUTHORIZATION' => $valid_header ), + 'expected' => array( $user, $pass ), + ), + array( + 'label' => 'redirect-http-authorization', + 'server' => array( 'REDIRECT_HTTP_AUTHORIZATION' => $valid_header ), + 'expected' => array( $user, $pass ), + ), + array( + 'label' => 'invalid-scheme', + 'server' => array( 'HTTP_AUTHORIZATION' => 'Bearer ' . base64_encode( "{$user}:{$pass}" ) ), + 'expected' => null, + ), + array( + 'label' => 'no-colon', + 'server' => array( 'HTTP_AUTHORIZATION' => 'Basic ' . base64_encode( $user ) ), + 'expected' => null, + ), + array( + 'label' => 'preexisting-not-overridden', + 'server' => array( + 'HTTP_AUTHORIZATION' => $valid_header, + 'PHP_AUTH_USER' => 'existing', + 'PHP_AUTH_PW' => 'secret', + ), + 'expected' => array( 'existing', 'secret' ), + ), + ); + + try { + foreach ( $cases as $index => $case ) { + $_SERVER = array_merge( self::default_server(), $case['server'] ); + \wp_populate_basic_auth_from_authorization_header(); + + $actual = isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) + ? array( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) + : null; + + self::collect_failure( + $failures, + $actual === $case['expected'], + "wp_populate_basic_auth_from_authorization_header {$case['label']}", + array( + 'case' => $index, + 'expected' => $case['expected'], + 'actual' => $actual, + ) + ); + } + + $_SERVER = array_merge( + self::default_server(), + array( + 'PHP_AUTH_USER' => $user, + 'PHP_AUTH_PW' => $pass, + ) + ); + $GLOBALS['pagenow'] = 'wp-login.php'; + + $filter_seen = array(); + $filter = static function ( bool $protected, string $context ) use ( &$filter_seen ): bool { + $filter_seen[] = $context; + return 'front' === $context ? true : $protected; + }; + \add_filter( 'wp_is_site_protected_by_basic_auth', $filter, 10, 2 ); + try { + $login_protected = \wp_is_site_protected_by_basic_auth(); + unset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ); + $front_filtered = \wp_is_site_protected_by_basic_auth( 'front' ); + } finally { + \remove_filter( 'wp_is_site_protected_by_basic_auth', $filter, 10 ); + } + + self::collect_failure( + $failures, + true === $login_protected + && true === $front_filtered + && array( 'login', 'front' ) === $filter_seen + && false === \has_filter( 'wp_is_site_protected_by_basic_auth', $filter ), + 'wp_is_site_protected_by_basic_auth context and filter locality', + array( + 'loginProtected' => $login_protected, + 'frontFiltered' => $front_filtered, + 'filterSeen' => $filter_seen, + 'filterAfter' => \has_filter( 'wp_is_site_protected_by_basic_auth', $filter ), + ) + ); + } finally { + $_SERVER = $original_server; + if ( $pagenow_existed ) { + $GLOBALS['pagenow'] = $original_pagenow; + } else { + unset( $GLOBALS['pagenow'] ); + } + } + + return self::result( + $ctx, + 'environment-load.basic-auth.normalization', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_ssl_detection( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $original_server = $_SERVER; + $cases = array( + array( + 'label' => 'https-on', + 'server' => array( 'HTTPS' => 'on' ), + 'expected' => true, + ), + array( + 'label' => 'https-uppercase-on', + 'server' => array( 'HTTPS' => 'ON' ), + 'expected' => true, + ), + array( + 'label' => 'https-one', + 'server' => array( 'HTTPS' => '1' ), + 'expected' => true, + ), + array( + 'label' => 'https-off-port-443-fail-closed', + 'server' => array( + 'HTTPS' => 'off', + 'SERVER_PORT' => '443', + ), + 'expected' => false, + ), + array( + 'label' => 'port-443', + 'server' => array( 'SERVER_PORT' => '443' ), + 'expected' => true, + ), + array( + 'label' => 'forwarded-proto-only', + 'server' => array( 'HTTP_X_FORWARDED_PROTO' => 'https' ), + 'expected' => false, + ), + array( + 'label' => 'front-end-https-only', + 'server' => array( 'HTTP_FRONT_END_HTTPS' => 'on' ), + 'expected' => false, + ), + ); + + try { + foreach ( $cases as $index => $case ) { + $_SERVER = array_merge( self::default_server(), $case['server'] ); + $actual = \is_ssl(); + + self::collect_failure( + $failures, + $actual === $case['expected'], + "is_ssl {$case['label']}", + array( + 'case' => $index, + 'server' => $case['server'], + 'expected' => $case['expected'], + 'actual' => $actual, + ) + ); + } + } finally { + $_SERVER = $original_server; + } + + return self::result( + $ctx, + 'environment-load.ssl.header-port-normalization', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_memory_limit_parsing( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cases = array_merge( + array( + '', + '0', + '1', + '2k', + '2K', + '3m', + '4M', + '5g', + ' 6M ', + '7mb', + '10M garbage', + '9q', + 'abc', + 'm10', + 'g', + '-1M', + ), + self::memory_cases( $ctx ) + ); + + foreach ( $cases as $index => $value ) { + $actual = \wp_convert_hr_to_bytes( $value ); + $expected = self::expected_hr_to_bytes( $value ); + + self::collect_failure( + $failures, + $actual === $expected, + "wp_convert_hr_to_bytes exact oracle case {$index}", + array( + 'value' => $value, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + + foreach ( array( '', 'k', 'm', 'g' ) as $unit ) { + $previous = null; + foreach ( array( 0, 1, 2, 8, 16, $ctx->int( 17, 128 ) ) as $number ) { + $value = (string) $number . $unit; + $actual = \wp_convert_hr_to_bytes( $value ); + self::collect_failure( + $failures, + null === $previous || $actual >= $previous, + "wp_convert_hr_to_bytes monotonic {$unit}", + array( + 'value' => $value, + 'previous' => $previous, + 'actual' => $actual, + ) + ); + $previous = $actual; + } + } + + foreach ( array( 'abc', 'm10', 'k', 'gibberish-g', $ctx->identifier( 3, 10 ) . 'M' ) as $malformed ) { + self::collect_failure( + $failures, + 0 === \wp_convert_hr_to_bytes( $malformed ), + 'wp_convert_hr_to_bytes malformed non-numeric prefix fails closed', + array( + 'value' => $malformed, + 'actual' => \wp_convert_hr_to_bytes( $malformed ), + ) + ); + } + + return self::result( + $ctx, + 'environment-load.memory-limit.parsing-monotonicity', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function memory_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array(); + for ( $i = 0; $i < 10; ++$i ) { + $cases[] = trim( (string) $ctx->int( 0, 4096 ) . $ctx->choice( array( '', 'K', 'k', 'M', 'm', 'G', 'g', 'MB', ' garbage' ) ) ); + } + return $cases; + } + + private static function expected_hr_to_bytes( string $value ) { + $value = strtolower( trim( $value ) ); + $bytes = (int) $value; + + if ( str_contains( $value, 'g' ) ) { + $bytes *= GB_IN_BYTES; + } elseif ( str_contains( $value, 'm' ) ) { + $bytes *= MB_IN_BYTES; + } elseif ( str_contains( $value, 'k' ) ) { + $bytes *= KB_IN_BYTES; + } + + return min( $bytes, PHP_INT_MAX ); + } + + private static function check_ini_mutability( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $ini_all = function_exists( 'ini_get_all' ) ? ini_get_all() : false; + $settings = array( + 'memory_limit', + 'display_errors', + 'max_execution_time', + 'component_fuzz_unknown_' . strtolower( $ctx->identifier( 4, 8 ) ), + ); + + foreach ( $settings as $setting ) { + $actual = \wp_is_ini_value_changeable( $setting ); + + if ( isset( $ini_all[ $setting ]['access'] ) ) { + $expected = INI_ALL === $ini_all[ $setting ]['access'] || INI_USER === $ini_all[ $setting ]['access']; + } elseif ( ! is_array( $ini_all ) ) { + $expected = true; + } else { + $expected = false; + } + + self::collect_failure( + $failures, + $actual === $expected, + "wp_is_ini_value_changeable {$setting}", + array( + 'setting' => $setting, + 'expected' => $expected, + 'actual' => $actual, + 'access' => $ini_all[ $setting ]['access'] ?? null, + ) + ); + } + + return self::result( + $ctx, + 'environment-load.ini-mutability.oracle', + array() === $failures, + array( + 'cases' => count( $settings ), + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_installing_and_maintenance_flags( \ComponentFuzz\FuzzContext $ctx, array $snapshot ): array { + $failures = array(); + $original = \wp_installing(); + + $previous_before_true = \wp_installing( true ); + $after_true = \wp_installing(); + $maintenance_install = \wp_is_maintenance_mode(); + $previous_before_false = \wp_installing( false ); + $after_false = \wp_installing(); + \wp_installing( $snapshot['installing'] ); + + self::collect_failure( + $failures, + $previous_before_true === $original + && true === $after_true + && false === $maintenance_install + && true === $previous_before_false + && false === $after_false + && \wp_installing() === $snapshot['installing'], + 'wp_installing previous-value contract and restore', + array( + 'original' => $original, + 'previousBeforeTrue' => $previous_before_true, + 'afterTrue' => $after_true, + 'maintenanceInstall' => $maintenance_install, + 'previousBeforeFalse' => $previous_before_false, + 'afterFalse' => $after_false, + 'restored' => \wp_installing(), + ) + ); + + if ( ! file_exists( ABSPATH . '.maintenance' ) ) { + self::collect_failure( + $failures, + false === \wp_is_maintenance_mode(), + 'wp_is_maintenance_mode absent file fails closed', + array( 'actual' => \wp_is_maintenance_mode() ) + ); + } + + return self::result( + $ctx, + 'environment-load.installing-maintenance.flags', + array() === $failures, + array( + 'maintenanceFilePresent' => file_exists( ABSPATH . '.maintenance' ), + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_runtime_filters_and_request_guards( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $original_request = $_REQUEST; + + $filter_checks = array( + 'wp_doing_ajax' => static fn() => \wp_doing_ajax(), + 'wp_doing_cron' => static fn() => \wp_doing_cron(), + 'wp_using_themes' => static fn() => \wp_using_themes(), + ); + + foreach ( $filter_checks as $hook => $call ) { + foreach ( array( true, false ) as $forced ) { + $filter = static fn() => $forced; + \add_filter( $hook, $filter ); + try { + $actual = $call(); + } finally { + \remove_filter( $hook, $filter ); + } + + self::collect_failure( + $failures, + $actual === $forced && false === \has_filter( $hook, $filter ), + "{$hook} scoped filter {$forced}", + array( + 'hook' => $hook, + 'forced' => $forced, + 'actual' => $actual, + 'filterAfter' => \has_filter( $hook, $filter ), + ) + ); + } + } + + $file_context = 'environment-load-' . strtolower( $ctx->identifier( 4, 8 ) ); + $file_other_baseline = \wp_is_file_mod_allowed( 'unrelated-context' ); + $file_filter = static function ( bool $allowed, string $context ) use ( $file_context ): bool { + return $context === $file_context ? false : $allowed; + }; + \add_filter( 'file_mod_allowed', $file_filter, 10, 2 ); + try { + $file_denied = \wp_is_file_mod_allowed( $file_context ); + $file_other = \wp_is_file_mod_allowed( 'unrelated-context' ); + } finally { + \remove_filter( 'file_mod_allowed', $file_filter, 10 ); + } + + self::collect_failure( + $failures, + false === $file_denied + && $file_other_baseline === $file_other + && false === \has_filter( 'file_mod_allowed', $file_filter ), + 'wp_is_file_mod_allowed filter locality', + array( + 'context' => $file_context, + 'denied' => $file_denied, + 'other' => $file_other, + 'otherBase' => $file_other_baseline, + 'filterAfter' => \has_filter( 'file_mod_allowed', $file_filter ), + ) + ); + + $ajax_filter = static fn() => true; + $custom_action = 'cfz_' . strtolower( $ctx->identifier( 4, 10 ) ); + $action_filter = static function ( array $actions ) use ( $custom_action ): array { + $actions[] = $custom_action; + return $actions; + }; + + try { + \add_filter( 'wp_doing_ajax', $ajax_filter ); + $_REQUEST = array( 'action' => 'heartbeat' ); + $default_protected = \is_protected_ajax_action(); + $_REQUEST = array( 'action' => 'unprotected-' . $custom_action ); + $unknown_protected = \is_protected_ajax_action(); + \add_filter( 'wp_protected_ajax_actions', $action_filter ); + $_REQUEST = array( 'action' => $custom_action ); + $custom_protected = \is_protected_ajax_action(); + } finally { + \remove_filter( 'wp_protected_ajax_actions', $action_filter ); + \remove_filter( 'wp_doing_ajax', $ajax_filter ); + $_REQUEST = $original_request; + } + + self::collect_failure( + $failures, + true === $default_protected + && false === $unknown_protected + && true === $custom_protected + && false === \has_filter( 'wp_doing_ajax', $ajax_filter ) + && false === \has_filter( 'wp_protected_ajax_actions', $action_filter ), + 'is_protected_ajax_action default/custom/filter locality', + array( + 'defaultProtected' => $default_protected, + 'unknownProtected' => $unknown_protected, + 'customProtected' => $custom_protected, + 'customAction' => $custom_action, + 'ajaxFilterAfter' => \has_filter( 'wp_doing_ajax', $ajax_filter ), + 'actionFilterAfter' => \has_filter( 'wp_protected_ajax_actions', $action_filter ), + ) + ); + + return self::result( + $ctx, + 'environment-load.request-sapi-guards.filters', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_json_xml_request_guards( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $original_server = $_SERVER; + $original_get = $_GET; + + $media_cases = array( + 'application/json' => true, + 'application/activity+json' => true, + 'application/json+oembed' => true, + 'text/html, application/json;q=0.9' => true, + 'text/json' => false, + 'application/jsonp' => false, + $ctx->ascii( 0, 18 ) => false, + ); + + try { + foreach ( $media_cases as $media_type => $expected ) { + $actual = \wp_is_json_media_type( $media_type ); + self::collect_failure( + $failures, + $actual === $expected, + "wp_is_json_media_type {$media_type}", + array( + 'mediaType' => $media_type, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + + $request_cases = array( + array( + 'label' => 'accept-json', + 'server' => array( 'HTTP_ACCEPT' => 'text/html, application/json' ), + 'jsonExpected' => true, + 'xmlExpected' => false, + ), + array( + 'label' => 'content-type-json', + 'server' => array( 'CONTENT_TYPE' => 'application/problem+json; charset=UTF-8' ), + 'jsonExpected' => true, + 'xmlExpected' => false, + ), + array( + 'label' => 'accept-xml', + 'server' => array( 'HTTP_ACCEPT' => 'text/html, application/rss+xml' ), + 'jsonExpected' => false, + 'xmlExpected' => true, + ), + array( + 'label' => 'content-type-xml-exact', + 'server' => array( 'CONTENT_TYPE' => 'text/xml' ), + 'jsonExpected' => false, + 'xmlExpected' => true, + ), + array( + 'label' => 'content-type-xml-with-charset-fails-closed', + 'server' => array( 'CONTENT_TYPE' => 'text/xml; charset=UTF-8' ), + 'jsonExpected' => false, + 'xmlExpected' => false, + ), + ); + + foreach ( $request_cases as $case ) { + $_SERVER = array_merge( self::default_server(), $case['server'] ); + $json = \wp_is_json_request(); + $xml = \wp_is_xml_request(); + self::collect_failure( + $failures, + $json === $case['jsonExpected'] && $xml === $case['xmlExpected'], + "JSON/XML request guard {$case['label']}", + array( + 'case' => $case['label'], + 'jsonExpected' => $case['jsonExpected'], + 'jsonActual' => $json, + 'xmlExpected' => $case['xmlExpected'], + 'xmlActual' => $xml, + ) + ); + } + + $_GET = array( '_jsonp' => 'componentFuzzCallback_' . $ctx->int( 1, 999 ) ); + $jsonp_enabled = \wp_is_jsonp_request(); + $disable_jsonp = static fn() => false; + \add_filter( 'rest_jsonp_enabled', $disable_jsonp ); + try { + $jsonp_disabled = \wp_is_jsonp_request(); + } finally { + \remove_filter( 'rest_jsonp_enabled', $disable_jsonp ); + } + $_GET = array( '_jsonp' => 'bad-callback()' ); + $jsonp_invalid = \wp_is_jsonp_request(); + + self::collect_failure( + $failures, + true === $jsonp_enabled + && false === $jsonp_disabled + && false === $jsonp_invalid + && false === \has_filter( 'rest_jsonp_enabled', $disable_jsonp ), + 'wp_is_jsonp_request callback validation and filter locality', + array( + 'enabled' => $jsonp_enabled, + 'disabled' => $jsonp_disabled, + 'invalid' => $jsonp_invalid, + 'filterAfter' => \has_filter( 'rest_jsonp_enabled', $disable_jsonp ), + ) + ); + } finally { + $_SERVER = $original_server; + $_GET = $original_get; + } + + return self::result( + $ctx, + 'environment-load.json-xml-request-guards.media-types', + array() === $failures, + array( + 'mediaCases' => count( $media_cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_https_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $rows = array(); + $failures = array(); + $original_server = $_SERVER; + + try { + $_SERVER = self::default_server(); + + foreach ( self::https_url_cases() as $index => $case ) { + \update_option( 'home', $case['home'] ); + \update_option( 'siteurl', $case['siteurl'] ); + + $home_using_https = \wp_is_home_url_using_https(); + $site_using_https = \wp_is_site_url_using_https(); + $using_https = \wp_is_using_https(); + + self::collect_failure( + $failures, + $home_using_https === $case['homeHttps'] + && $site_using_https === $case['siteHttps'] + && $using_https === ( $case['homeHttps'] && $case['siteHttps'] ), + "HTTPS URL state case {$index}", + array( + 'case' => $case, + 'homeUsingHttps' => $home_using_https, + 'siteUsingHttps' => $site_using_https, + 'usingHttps' => $using_https, + ) + ); + } + + \update_option( 'home', 'https://example.test' ); + \update_option( 'siteurl', 'https://example.test/wp' ); + \update_option( 'https_migration_required', true ); + + $content = 'Visit http://example.test/path and escaped http:\/\/example.test\/path but not http://other.test/path.'; + $replaced = \wp_replace_insecure_home_url( $content ); + $should_replace = \wp_should_replace_insecure_home_url(); + $block_replacement = static fn() => false; + \add_filter( 'wp_should_replace_insecure_home_url', $block_replacement ); + try { + $blocked = \wp_replace_insecure_home_url( $content ); + } finally { + \remove_filter( 'wp_should_replace_insecure_home_url', $block_replacement ); + } + + self::collect_failure( + $failures, + true === $should_replace + && str_contains( $replaced, 'https://example.test/path' ) + && str_contains( $replaced, 'https:\/\/example.test\/path' ) + && str_contains( $replaced, 'http://other.test/path' ) + && $blocked === $content + && false === \has_filter( 'wp_should_replace_insecure_home_url', $block_replacement ), + 'wp_replace_insecure_home_url scoped replacement', + array( + 'shouldReplace' => $should_replace, + 'replaced' => $replaced, + 'blocked' => $blocked, + 'filterAfter' => \has_filter( 'wp_should_replace_insecure_home_url', $block_replacement ), + ) + ); + + $error_filter = static function () { + return new \WP_Error( 'synthetic_https_failure', 'Synthetic HTTPS failure.' ); + }; + \add_filter( 'pre_wp_get_https_detection_errors', $error_filter ); + try { + $errors = \wp_get_https_detection_errors(); + $supported = \wp_is_https_supported(); + } finally { + \remove_filter( 'pre_wp_get_https_detection_errors', $error_filter ); + } + + $success_filter = static function () { + return new \WP_Error(); + }; + \add_filter( 'pre_wp_get_https_detection_errors', $success_filter ); + try { + $success_errors = \wp_get_https_detection_errors(); + $success_supported = \wp_is_https_supported(); + } finally { + \remove_filter( 'pre_wp_get_https_detection_errors', $success_filter ); + } + + self::collect_failure( + $failures, + isset( $errors['synthetic_https_failure'] ) + && false === $supported + && array() === $success_errors + && true === $success_supported + && false === \has_filter( 'pre_wp_get_https_detection_errors', $error_filter ) + && false === \has_filter( 'pre_wp_get_https_detection_errors', $success_filter ), + 'wp_get_https_detection_errors short-circuit avoids network', + array( + 'errors' => array_keys( $errors ), + 'supported' => $supported, + 'successErrors' => $success_errors, + 'successSupported' => $success_supported, + ) + ); + } finally { + $_SERVER = $original_server; + } + + $rows[] = self::result( + $ctx, + 'environment-load.https.urls-migration-detection', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + + $rows[] = self::check_local_html_output( $ctx->fork( 'local-html' ) ); + + return $rows; + } + + private static function https_url_cases(): array { + return array( + array( + 'home' => 'https://example.test', + 'siteurl' => 'https://example.test/wp', + 'homeHttps' => true, + 'siteHttps' => true, + ), + array( + 'home' => 'http://example.test', + 'siteurl' => 'https://example.test/wp', + 'homeHttps' => false, + 'siteHttps' => true, + ), + array( + 'home' => 'https://example.test', + 'siteurl' => 'http://example.test/wp', + 'homeHttps' => true, + 'siteHttps' => false, + ), + array( + 'home' => 'http://example.test', + 'siteurl' => 'http://example.test/wp', + 'homeHttps' => false, + 'siteHttps' => false, + ), + ); + } + + private static function check_local_html_output( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! function_exists( 'rsd_link' ) ) { + return $ctx->skip( + 'environment-load.https.local-html-output', + 'rsd_link() is unavailable, so local HTML ownership detection cannot be exercised.' + ); + } + + $failures = array(); + $no_signal = \wp_is_local_html_output( 'component fuzz' ); + \add_action( 'wp_head', 'rsd_link' ); + try { + $pattern = preg_replace( '#^https?:(?=//)#', '', esc_url( site_url( 'xmlrpc.php?rsd', 'rpc' ) ) ); + $local = \wp_is_local_html_output( '' ); + $foreign = \wp_is_local_html_output( '' ); + } finally { + \remove_action( 'wp_head', 'rsd_link' ); + } + + self::collect_failure( + $failures, + null === $no_signal + && true === $local + && false === $foreign + && false === \has_filter( 'wp_head', 'rsd_link' ), + 'wp_is_local_html_output RSD signal detection', + array( + 'noSignal' => $no_signal, + 'local' => $local, + 'foreign' => $foreign, + 'pattern' => $pattern ?? null, + 'actionAfter' => \has_filter( 'wp_head', 'rsd_link' ), + ) + ); + + return self::result( + $ctx, + 'environment-load.https.local-html-output', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 4 ) ) + ); + } + + private static function check_utf8_validation_and_scanning( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cases = self::utf8_cases( $ctx ); + + foreach ( $cases as $index => $bytes ) { + $valid = \wp_is_valid_utf8( $bytes ); + $fallback_valid = \_wp_is_valid_utf8_fallback( $bytes ); + $scrubbed = \wp_scrub_utf8( $bytes ); + $rescrubbed = \wp_scrub_utf8( $scrubbed ); + $count = \_wp_utf8_codepoint_count( $bytes ); + $scrubbed_count = \_wp_utf8_codepoint_count( $scrubbed ); + $found = 0; + $span = \_wp_utf8_codepoint_span( $scrubbed, 0, $scrubbed_count, $found ); + $scan = self::scan_utf8_case( $bytes ); + + self::collect_failure( + $failures, + $valid === $fallback_valid + && \wp_is_valid_utf8( $scrubbed ) + && $scrubbed === $rescrubbed + && $count === $scrubbed_count + && $span === strlen( $scrubbed ) + && $found === $scrubbed_count + && $scan['ok'] + && $scan['count'] === $count + && $scan['hasNoncharacters'] === \wp_has_noncharacters( $bytes ), + "UTF-8 validation/scrub/scan agreement case {$index}", + array( + 'bytes' => $bytes, + 'valid' => $valid, + 'fallbackValid' => $fallback_valid, + 'scrubbed' => $scrubbed, + 'count' => $count, + 'scrubbedCount' => $scrubbed_count, + 'span' => $span, + 'found' => $found, + 'scan' => $scan, + 'hasNoncharacters' => \wp_has_noncharacters( $bytes ), + ) + ); + } + + self::collect_failure( + $failures, + true === \wp_has_noncharacters( "\xEF\xBF\xBE" ) + && true === \wp_has_noncharacters( "\xF4\x8F\xBF\xBF" ) + && false === \wp_has_noncharacters( 'plain text' ) + && false === \wp_has_noncharacters( "invalid \xC0 byte" ), + 'wp_has_noncharacters detects only noncharacter byte sequences', + array( + 'fffe' => \wp_has_noncharacters( "\xEF\xBF\xBE" ), + 'max' => \wp_has_noncharacters( "\xF4\x8F\xBF\xBF" ), + 'plain' => \wp_has_noncharacters( 'plain text' ), + 'invalid' => \wp_has_noncharacters( "invalid \xC0 byte" ), + ) + ); + + return self::result( + $ctx, + 'environment-load.utf8.validation-scrub-scan', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function utf8_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + '', + 'plain ascii', + "Pi\xC3\xB1a", + "snowman \xE2\x98\x83", + "invalid \xC0 byte", + "truncated \xE2\x9C", + "surrogate \xED\xA0\x80", + "nonchar \xEF\xBF\xBE", + ); + + for ( $i = 0; $i < self::UTF8_CASES; ++$i ) { + $cases[] = $ctx->bytes( 0, 24 ); + } + + return $cases; + } + + private static function scan_utf8_case( string $bytes ): array { + $length = strlen( $bytes ); + $at = 0; + $count = 0; + $invalid_length = 0; + $has_noncharacters = false; + $guard = 0; + + while ( $at < $length ) { + $before = $at; + $scan_has_nonchar = false; + $found = \_wp_scan_utf8( $bytes, $at, $invalid_length, null, null, $scan_has_nonchar ); + $count += $found; + $has_noncharacters = $has_noncharacters || $scan_has_nonchar; + + if ( $invalid_length > 0 ) { + ++$count; + $at += $invalid_length; + } + + if ( $at <= $before && 0 === $invalid_length ) { + return array( + 'ok' => false, + 'count' => $count, + 'at' => $at, + 'before' => $before, + 'reason' => 'scan did not advance', + ); + } + + if ( ++$guard > $length + 4 ) { + return array( + 'ok' => false, + 'count' => $count, + 'at' => $at, + 'reason' => 'scan guard exceeded', + ); + } + } + + return array( + 'ok' => true, + 'count' => $count, + 'at' => $at, + 'hasNoncharacters' => $has_noncharacters, + ); + } + + private static function check_utf8_compat_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $codepoints = array( + 0x00, + 0x24, + 0x7F, + 0x80, + 0x7FF, + 0x800, + 0xFDD0, + 0x1F600, + $ctx->int( 0x100, 0x2FFF ), + ); + + foreach ( $codepoints as $codepoint ) { + $chr = \_mb_chr( $codepoint, 'UTF-8' ); + $ord = false !== $chr ? \_mb_ord( $chr, 'UTF-8' ) : false; + $wrapped = false !== $chr ? 'A' . $chr . 'B' : ''; + + self::collect_failure( + $failures, + is_string( $chr ) + && $ord === $codepoint + && 1 === \_mb_strlen( $chr, 'UTF-8' ) + && $chr === \_mb_substr( $wrapped, 1, 1, 'UTF-8' ), + "_mb_chr/_mb_ord round trip {$codepoint}", + array( + 'codepoint' => $codepoint, + 'chr' => $chr, + 'ord' => $ord, + 'substr' => false !== $chr ? \_mb_substr( $wrapped, 1, 1, 'UTF-8' ) : null, + ) + ); + } + + foreach ( array( -1, 0xD800, 0xDFFF, 0x110000 ) as $invalid ) { + self::collect_failure( + $failures, + false === \_mb_chr( $invalid, 'UTF-8' ), + "_mb_chr rejects invalid codepoint {$invalid}", + array( + 'codepoint' => $invalid, + 'actual' => \_mb_chr( $invalid, 'UTF-8' ), + ) + ); + } + + $charset_cases = array( + 'UTF-8' => true, + 'utf8' => true, + 'Utf-8' => true, + 'UTF 8' => false, + 'ISO-8859-1' => false, + $ctx->ascii( 0, 12 ) => false, + ); + foreach ( $charset_cases as $charset => $expected ) { + $actual = \_is_utf8_charset( $charset ); + self::collect_failure( + $failures, + $actual === $expected, + "_is_utf8_charset {$charset}", + array( + 'charset' => $charset, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + + $latin1 = "ASCII \xA3 \xF1 " . chr( $ctx->int( 0x80, 0xFF ) ); + $encoded = \_wp_utf8_encode_fallback( $latin1 ); + $decoded = \_wp_utf8_decode_fallback( $encoded ); + $invalid_decoded = \_wp_utf8_decode_fallback( "bad \xC0 utf8" ); + + self::collect_failure( + $failures, + \wp_is_valid_utf8( $encoded ) + && $decoded === $latin1 + && is_string( $invalid_decoded ), + '_wp_utf8_encode_fallback/_wp_utf8_decode_fallback latin1 round trip', + array( + 'latin1' => $latin1, + 'encoded' => $encoded, + 'decoded' => $decoded, + 'invalidDecoded' => $invalid_decoded, + ) + ); + + $sample = \wp_scrub_utf8( $ctx->text( 4, 28 ) ); + $start = $ctx->int( -2, 4 ); + $length = $ctx->int( 0, 6 ); + $substr = \_mb_substr( $sample, $start, $length, 'UTF-8' ); + self::collect_failure( + $failures, + \wp_is_valid_utf8( $substr ) + && \_mb_strlen( $substr, 'UTF-8' ) <= $length, + '_mb_substr bounded UTF-8 output', + array( + 'sample' => $sample, + 'start' => $start, + 'length' => $length, + 'substr' => $substr, + 'strlen' => \_mb_strlen( $substr, 'UTF-8' ), + ) + ); + + return self::result( + $ctx, + 'environment-load.utf8.compat-helpers', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function default_server(): array { + return array( + 'HTTP_HOST' => 'example.test', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/component-fuzz/', + 'SCRIPT_NAME' => '/index.php', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'SERVER_SOFTWARE' => 'ComponentFuzz', + ); + } + + private static function safe_basic_auth_token( string $token ): string { + $token = strtolower( preg_replace( '/[^a-zA-Z0-9_.-]+/', '_', $token ) ); + $token = trim( (string) $token, '_' ); + + return '' === $token ? 'token' : $token; + } + + private static function snapshot_state(): array { + $globals = array(); + foreach ( array( 'PHP_SELF', 'current_screen', 'pagenow', 'wp_actions', 'wp_current_filter', 'wp_filter', 'wp_filters' ) as $name ) { + $globals[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + $superglobals = array(); + foreach ( array( '_COOKIE', '_ENV', '_GET', '_POST', '_REQUEST', '_SERVER' ) as $name ) { + $superglobals[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_value( $GLOBALS[ $name ] ) : null, + ); + } + + return array( + 'globals' => $globals, + 'superglobals' => $superglobals, + 'env' => function_exists( 'getenv' ) ? getenv( 'WP_ENVIRONMENT_TYPE' ) : false, + 'installing' => function_exists( 'wp_installing' ) ? \wp_installing() : false, + 'options' => isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null, + ); + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + + foreach ( $snapshot['superglobals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + + if ( function_exists( 'putenv' ) ) { + self::set_env_var( 'WP_ENVIRONMENT_TYPE', $snapshot['env'] ); + } + + if ( function_exists( 'wp_installing' ) ) { + \wp_installing( $snapshot['installing'] ); + } + + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub && is_array( $snapshot['options'] ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + } + + private static function state_matches( array $snapshot ): bool { + if ( ( function_exists( 'getenv' ) ? getenv( 'WP_ENVIRONMENT_TYPE' ) : false ) !== $snapshot['env'] ) { + return false; + } + + if ( function_exists( 'wp_installing' ) && \wp_installing() !== $snapshot['installing'] ) { + return false; + } + + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub && is_array( $snapshot['options'] ) ) { + if ( $GLOBALS['wpdb']->component_fuzz_get_options() !== $snapshot['options'] ) { + return false; + } + } + + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + if ( $entry['exists'] && $GLOBALS[ $name ] !== $entry['value'] ) { + return false; + } + } + + foreach ( $snapshot['superglobals'] as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + if ( $entry['exists'] && $GLOBALS[ $name ] !== $entry['value'] ) { + return false; + } + } + + return true; + } + + private static function set_env_var( string $name, $value ): void { + if ( false === $value ) { + putenv( $name ); + return; + } + + putenv( $name . '=' . (string) $value ); + } + + private static function clone_value( $value ) { + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + $copy[ $key ] = self::clone_value( $item ); + } + return $copy; + } + + if ( is_object( $value ) ) { + if ( $value instanceof \Closure ) { + return $value; + } + + try { + return clone $value; + } catch ( \Throwable $e ) { + return $value; + } + } + + return $value; + } + + private static function collect_failure( array &$failures, bool $condition, string $label, array $details ): void { + if ( $condition ) { + return; + } + + $failures[] = array( + 'label' => $label, + 'details' => $details, + ); + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, bool $ok, array $data = array() ): array { + return $ok ? $ctx->pass( $invariant, $data ) : $ctx->fail( $invariant, $data ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } +} From ba642bf12d51cb33db6713891b5b4d31e8397db3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:38:49 +0200 Subject: [PATCH 0108/1102] Strengthen Unicode email view fuzz coverage --- tools/component-fuzz/README.md | 6 +- .../component-fuzz/surfaces/EmailSurface.php | 177 ++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 66bd99b086a86..ce960b15198e4 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -142,9 +142,9 @@ database, network requests, or a configured site. URL/index expansion, escaped sitemap XML rendering, and sitemap max-URL filters without DB-backed providers. - `email`: Unicode email validation/sanitization, ASCII fallback filters, - WHATWG-style validity, `WP_Email_Address` IDN/punycode views, invalid UTF-8, - malformed address structure, selected boundary lengths, and user email - lookup/duplicate behavior for accent-distinct local parts. + WHATWG-style validity, `WP_Email_Address` machine/readable and IDN/punycode + views, invalid UTF-8, malformed address structure, selected boundary lengths, + and user email lookup/duplicate behavior for accent-distinct local parts. - `environment-load`: no-network environment/load/compat helper coverage, including environment type cache boundaries, server/request normalization, Basic Auth and SSL detection, memory-limit parsing, ini mutability, diff --git a/tools/component-fuzz/surfaces/EmailSurface.php b/tools/component-fuzz/surfaces/EmailSurface.php index 500ef37a85429..3edc85612e6cc 100644 --- a/tools/component-fuzz/surfaces/EmailSurface.php +++ b/tools/component-fuzz/surfaces/EmailSurface.php @@ -5,6 +5,7 @@ final class EmailSurface { public const NAME = 'email'; private const GENERATED_CASES = 28; + private const GENERATED_VIEW_CASES = 10; private const WHATWG_ASCII_EMAIL_REGEX = '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@' . '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?' . '(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/'; @@ -44,6 +45,7 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { $rows = array_merge( $rows, self::check_user_email_indexes_distinct_localparts( $ctx ) ); $rows = array_merge( $rows, self::check_punycode_views( $ctx ) ); $rows = array_merge( $rows, self::check_idn_views( $ctx ) ); + $rows = array_merge( $rows, self::check_extension_address_views( $ctx ) ); $rows = array_merge( $rows, self::check_length_boundaries( $ctx ) ); self::install_email_filters( 'ascii' ); @@ -1098,6 +1100,123 @@ private static function check_idn_views( \ComponentFuzz\FuzzContext $ctx ): arra ); } + private static function check_extension_address_views( \ComponentFuzz\FuzzContext $ctx ): array { + $samples = self::extension_view_cases( $ctx ); + $failures = array(); + $views = array(); + + foreach ( $samples as $sample ) { + $input = $sample['local'] . '@' . $sample['domain']; + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parsed['value'] ?? null; + + if ( $parsed['threw'] || ! ( $email instanceof \WP_Email_Address ) ) { + $failures[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'parsed' => self::describe_call( $parsed ), + ); + continue; + } + + $href_address = $email->get_ascii_address(); + $href = 'mailto:' . $href_address; + $text = $email->get_unicode_address(); + $href_parts = explode( '@', $href_address, 2 ); + $text_parts = explode( '@', $text, 2 ); + $href_local = 2 === count( $href_parts ) ? $href_parts[0] : null; + $href_domain = 2 === count( $href_parts ) ? $href_parts[1] : null; + $text_local = 2 === count( $text_parts ) ? $text_parts[0] : null; + $text_domain = 2 === count( $text_parts ) ? $text_parts[1] : null; + $href_parse = self::call( static fn() => \WP_Email_Address::from_string( $href_address, 'unicode' ) ); + $text_parse = self::call( static fn() => \WP_Email_Address::from_string( $text, 'unicode' ) ); + + $has_unicode_local = ! self::is_ascii( $email->get_localpart() ); + $has_unicode_domain = $email->get_ascii_domain() !== $email->get_unicode_domain(); + $href_roundtrip = $href_parse['value'] ?? null; + $text_roundtrip = $text_parse['value'] ?? null; + $href_has_mailto = 0 === strpos( $href, 'mailto:' ) + && $href_address === substr( $href, strlen( 'mailto:' ) ); + $local_preserved = $email->get_localpart() === $href_local + && $email->get_localpart() === $text_local; + $domain_views = $email->get_ascii_domain() === $href_domain + && $email->get_unicode_domain() === $text_domain + && is_string( $href_domain ) + && self::is_ascii( $href_domain ); + $unicode_local = ! $has_unicode_local + || ( + is_string( $href_local ) + && is_string( $text_local ) + && ! self::is_ascii( $href_local ) + && ! self::is_ascii( $text_local ) + ); + $unicode_domain = ! $has_unicode_domain || $href_domain !== $text_domain; + $roundtrips = ! $href_parse['threw'] + && ! $text_parse['threw'] + && $href_roundtrip instanceof \WP_Email_Address + && $text_roundtrip instanceof \WP_Email_Address + && $href_roundtrip->get_ascii_address() === $email->get_ascii_address() + && $href_roundtrip->get_unicode_address() === $email->get_unicode_address() + && $text_roundtrip->get_ascii_address() === $email->get_ascii_address() + && $text_roundtrip->get_unicode_address() === $email->get_unicode_address(); + $ok = $href_has_mailto + && $href_address === $email->get_ascii_address() + && $text === $email->get_unicode_address() + && $local_preserved + && $domain_views + && $unicode_local + && $unicode_domain + && $roundtrips; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'address' => self::describe_address( $email ), + 'href' => self::describe_string( $href ), + 'text' => self::describe_string( $text ), + 'hrefLocal' => self::describe_value( $href_local ), + 'hrefDomain' => self::describe_value( $href_domain ), + 'textLocal' => self::describe_value( $text_local ), + 'textDomain' => self::describe_value( $text_domain ), + 'hrefParse' => self::describe_call( $href_parse ), + 'textParse' => self::describe_call( $text_parse ), + 'hrefHasMailto' => $href_has_mailto, + 'localPreserved' => $local_preserved, + 'domainViews' => $domain_views, + 'unicodeLocal' => $unicode_local, + 'unicodeDomain' => $unicode_domain, + 'roundtrips' => $roundtrips, + ); + } + + $views[] = array( + 'label' => $sample['label'], + 'source' => $sample['source'], + 'input' => self::describe_string( $input ), + 'machineAddress' => self::describe_string( $href_address ), + 'readableAddress' => self::describe_string( $text ), + 'machineDomainAscii' => is_string( $href_domain ) && self::is_ascii( $href_domain ), + 'unicodeLocal' => $has_unicode_local, + 'unicodeDomain' => $has_unicode_domain, + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.extension-machine-readable-views', + array() === $failures, + array( + 'caseCount' => count( $samples ), + 'views' => $views, + 'failures' => $failures, + ) + ), + ); + } + private static function check_length_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { $address_254 = str_repeat( 'a', 64 ) . '@' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 57 ) . '.com'; $address_255 = str_repeat( 'a', 64 ) . '@' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 58 ) . '.com'; @@ -1415,6 +1534,64 @@ private static function case( string $label, string $input, array $traits, $expe return $case; } + private static function extension_view_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $locals = array( + 'mail', + 'USER+tag', + "gr\u{00E5}", + "jose\u{0301}", + "\u{7528}\u{6237}", + ); + $domains = array( + 'example.com', + 'sub-domain.example', + ); + $samples = array( + array( + 'label' => 'unicode-local-ascii-domain', + 'source' => 'anchor', + 'local' => "jos\u{00E9}", + 'domain' => 'example.com', + ), + ); + + if ( self::has_idn() ) { + $domains[] = "b\u{00FC}cher.de"; + $domains[] = "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}"; + $domains[] = 'xn--bcher-kva.de'; + $samples[] = array( + 'label' => 'ascii-local-unicode-domain', + 'source' => 'anchor', + 'local' => 'mail', + 'domain' => "b\u{00FC}cher.de", + ); + $samples[] = array( + 'label' => 'unicode-local-unicode-domain', + 'source' => 'anchor', + 'local' => "\u{7528}\u{6237}", + 'domain' => "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", + ); + $samples[] = array( + 'label' => 'combining-local-punycode-domain', + 'source' => 'anchor', + 'local' => "jose\u{0301}", + 'domain' => 'xn--bcher-kva.de', + ); + } + + $view_ctx = $ctx->fork( 'email-extension-address-views' ); + for ( $i = 0; $i < self::GENERATED_VIEW_CASES; $i++ ) { + $samples[] = array( + 'label' => 'generated-view-' . $i, + 'source' => 'generated', + 'local' => $view_ctx->choice( $locals ), + 'domain' => $view_ctx->choice( $domains ), + ); + } + + return $samples; + } + private static function generated_cases( \ComponentFuzz\FuzzContext $ctx ): array { $locals = array( array( 'value' => 'user', 'traits' => array() ), From 058d53f99fd396c03a6fe82f8b7d6affc5655673 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:42:44 +0200 Subject: [PATCH 0109/1102] Refresh component fuzz dashboard --- tools/component-fuzz/dashboard.html | 73 ++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 7f4d5b43dd85f..4c37fbe794f3d 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -220,30 +220,30 @@

Component Fuzzers Project Dashboard

Registered Surfaces
-
96
-
README parity confirmed: 96 documented
+
99
+
README parity confirmed: 99 documented
Latest Broad Run
100%
-
Non-skipped pass rate: 9922 passed, 0 failed, 0 errored, 84 skipped at 96 surfaces over 3 iterations.
+
Non-skipped pass rate: 10012 passed, 0 failed, 0 errored, 93 skipped at 99 surfaces over 3 iterations.
Current Focused Run
100%
-
feed-parsers: 800 passed, 0 skipped, 0 failed at 100 iterations.
+
email: 110184 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
-
5
-
One delegated surface is active and two worker results are ready. All workers use priority/fast, gpt-5.5 xhigh.
+
0
+
Latest delegated results are integrated. Workers used priority/fast, gpt-5.5 xhigh.

Current Work

- Committed main-tree progress plus active delegated surfaces. + Committed main-tree progress from the latest delegated surfaces.
@@ -307,16 +307,16 @@

Current Work

- - - + + + - - - + + + @@ -335,11 +335,18 @@

Current Work

- - - + + + + + + + + + +
icons-connectorsworker activeMaxwell: _work/component-fuzz-icons-connectorsTarget: focused 100 iterations, smoke, standards, diff check, commit.local validation passedMain worktreeMain focused run: 400 passed, 0 skipped, 0 failed; broad run: 6656 passed, 60 skipped, 0 failed. Connector registry, icons registry, REST icon guards, schema/escaping contracts.
script-loader-runtimeworker activeDewey: _work/component-fuzz-script-loader-runtimeTarget: focused 100 iterations, smoke, standards, diff check, commit.local validation passedMain worktreeMain focused run: 1000 passed, 200 skipped, 0 failed; broad run: 6646 passed, 60 skipped, 0 failed. Script/style loader helpers, inline data, translations, emoji/settings output.
environment-loadworker activeHelmholtz: _work/component-fuzz-environment-loadTarget: focused 100 iterations, smoke, standards, diff check, commit.local validation passedMain worktreeMain focused run: 1500 passed, 100 skipped, 0 failed; broad run: 6673 passed, 64 skipped, 0 failed. Load/compat helpers, memory/env matrices, SSL headers, scoped superglobals.
emaillocal validation passedMain worktreeMain focused run: 110184 passed, 0 skipped, 0 failed; latest broad run: 10012 passed, 93 skipped, 0 failed.Unicode/ASCII filters, WP_Email_Address views, IDN/Punycode, user lookups.
@@ -359,6 +366,36 @@

Recent Committed Additions

+ + ba642bf12d + email + 110184 passed, 0 skipped, 0 failed. + 10012 passed, 93 skipped, 0 failed at 3 iterations. + + + 77c3261c5a + environment-load + 1500 passed, 100 skipped, 0 failed. + 6673 passed, 64 skipped, 0 failed at 2 iterations. + + + 65cb994010 + icons-connectors + 400 passed, 0 skipped, 0 failed. + 6656 passed, 60 skipped, 0 failed at 2 iterations. + + + 2132510f82 + script-loader-runtime + 1000 passed, 200 skipped, 0 failed. + 6646 passed, 60 skipped, 0 failed at 2 iterations. + + + fb12f51f20 + feed-parsers + 800 passed, 0 skipped, 0 failed. + 9922 passed, 84 skipped, 0 failed at 3 iterations. + ef7248cd3a error-protection @@ -484,8 +521,8 @@

Coverage Notes

Unicode email - Verified 110,050 passed checks at 100 focused iterations. - No mail delivery; validation/sanitization and user lookup behavior only. + Verified 110,184 passed checks at 100 focused iterations, including machine/readable UTF-8 address views. + No mail delivery or browser UI validation; direct validation, sanitization, structural views, and user lookup behavior only. Admin/browser flows From 010ff4f11d701b4b4f7d5ced6d700c9cfc8d56c0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:45:36 +0200 Subject: [PATCH 0110/1102] Track next component fuzz workers --- tools/component-fuzz/dashboard.html | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 4c37fbe794f3d..d09536da6b890 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -235,15 +235,15 @@

Component Fuzzers Project Dashboard

Active Workers
-
0
-
Latest delegated results are integrated. Workers used priority/fast, gpt-5.5 xhigh.
+
4
+
Four delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.

Current Work

- Committed main-tree progress from the latest delegated surfaces. + Committed main-tree progress plus active delegated surfaces.
@@ -347,6 +347,34 @@

Current Work

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Main focused run: 110184 passed, 0 skipped, 0 failed; latest broad run: 10012 passed, 93 skipped, 0 failed. Unicode/ASCII filters, WP_Email_Address views, IDN/Punycode, user lookups.
html-apiworker activeSagan: _work/component-fuzz-html-api-richTarget: focused 100 iterations, smoke, standards, diff check, commit.HTML Tag/Processor walking, bookmarks, mutation escaping, malformed recovery.
hooksworker activeMill: _work/component-fuzz-hooks-richTarget: focused 100 iterations, smoke, standards, diff check, commit.WP_Hook, nested dispatch, mutation during dispatch, accepted args, counters.
syndicationworker activeArendt: _work/component-fuzz-syndication-richTarget: focused 100 iterations, smoke, standards, diff check, commit.oEmbed providers/handlers, feed helpers, XML/HTML escaping, no-network filters.
request-lifecycleworker activeLovelace: _work/component-fuzz-request-lifecycle-richTarget: focused 100 iterations, smoke, standards, diff check, commit.Front-controller parsing, query vars, 404/header transitions, superglobal restoration.
From 1ec4959311dc4465a997cad59c74154bd7b346de Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:47:31 +0200 Subject: [PATCH 0111/1102] Strengthen capability filter fuzz coverage --- tools/component-fuzz/README.md | 4 +- .../surfaces/CapabilitiesSurface.php | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index ce960b15198e4..5785c5c3ee1e8 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -92,8 +92,8 @@ database, network requests, or a configured site. REST preload path normalization with dispatch short-circuited, and global restoration. - `capabilities`: role registry mutations, numeric and explicit capability - grants, role filters, `WP_User` role/direct cap aggregation, and cheap - `map_meta_cap()` mappings. + grants, role and user capability filters, generated `map_meta_cap()` filter + contexts, `WP_User` role/direct cap aggregation, and cheap meta-cap mappings. - `canonical-routing`: no-DB canonical redirect and front-end routing helpers, including method/search/preview bailouts, host/path/query cleanup, invalid date redirects, feed/pagination canonicalization, redirect filter diff --git a/tools/component-fuzz/surfaces/CapabilitiesSurface.php b/tools/component-fuzz/surfaces/CapabilitiesSurface.php index 5de9c0d98dd87..51f9c304d1870 100644 --- a/tools/component-fuzz/surfaces/CapabilitiesSurface.php +++ b/tools/component-fuzz/surfaces/CapabilitiesSurface.php @@ -32,6 +32,7 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { $rows[] = self::check_role_registry_mutations( $ctx ); $rows[] = self::check_role_has_cap_filter( $ctx ); $rows[] = self::check_user_capability_aggregation( $ctx ); + $rows[] = self::check_user_has_cap_filter_contracts( $ctx ); $rows[] = self::check_meta_cap_mappings( $ctx ); } catch ( \Throwable $e ) { $rows[] = self::row( @@ -325,6 +326,156 @@ private static function check_user_capability_aggregation( \ComponentFuzz\FuzzCo ); } + private static function check_user_has_cap_filter_contracts( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $user = self::synthetic_user( $ctx->fork( 'filter-user' ) ); + $meta_cap = self::cap_name( $ctx->fork( 'meta-cap' ), 'meta' ); + $required_a = self::cap_name( $ctx->fork( 'required-a' ), 'required_a' ); + $required_b = self::cap_name( $ctx->fork( 'required-b' ), 'required_b' ); + $object_id = 90000 + $ctx->int( 0, 9999 ); + $context = 'ctx-' . substr( hash( 'sha1', (string) $ctx->seed() ), 0, 10 ); + $map_calls = array(); + $filter_calls = array(); + + $user->caps = array( + 'cfz_reader' => true, + $required_a => true, + $required_b => false, + ); + $user->get_role_caps(); + + $map_filter = static function ( array $caps, string $cap, int $user_id, array $args ) use ( $meta_cap, $required_a, $required_b, $object_id, $context, &$map_calls ): array { + $map_calls[] = array( + 'caps' => $caps, + 'cap' => $cap, + 'userId' => $user_id, + 'args' => $args, + ); + + if ( $meta_cap === $cap && array( $object_id, $context ) === $args ) { + return array( $required_a, $required_b ); + } + + return $caps; + }; + + $grant_filter = static function ( array $allcaps, array $caps, array $args, \WP_User $filtered_user ) use ( $meta_cap, $required_a, $required_b, $object_id, $context, $user, &$filter_calls ): array { + $filter_calls[] = array( + 'mode' => 'grant', + 'caps' => $caps, + 'args' => $args, + 'userId' => $filtered_user->ID, + ); + + if ( + $user->ID === $filtered_user->ID + && array( $required_a, $required_b ) === $caps + && array( $meta_cap, $user->ID, $object_id, $context ) === $args + ) { + $allcaps[ $required_b ] = true; + } + + return $allcaps; + }; + + $deny_filter = static function ( array $allcaps, array $caps, array $args, \WP_User $filtered_user ) use ( $meta_cap, $required_a, $required_b, $object_id, $context, $user, &$filter_calls ): array { + $filter_calls[] = array( + 'mode' => 'deny', + 'caps' => $caps, + 'args' => $args, + 'userId' => $filtered_user->ID, + ); + + if ( + $user->ID === $filtered_user->ID + && array( $required_a, $required_b ) === $caps + && array( $meta_cap, $user->ID, $object_id, $context ) === $args + ) { + $allcaps[ $required_a ] = false; + $allcaps[ $required_b ] = true; + } + + return $allcaps; + }; + + try { + \add_filter( 'map_meta_cap', $map_filter, 10, 4 ); + + $before = $user->has_cap( $meta_cap, $object_id, $context ); + + \add_filter( 'user_has_cap', $grant_filter, 10, 4 ); + $granted = $user->has_cap( $meta_cap, $object_id, $context ); + \remove_filter( 'user_has_cap', $grant_filter, 10 ); + + $after_grant_removed = $user->has_cap( $meta_cap, $object_id, $context ); + + \add_filter( 'user_has_cap', $deny_filter, 10, 4 ); + $denied = $user->has_cap( $meta_cap, $object_id, $context ); + \remove_filter( 'user_has_cap', $deny_filter, 10 ); + + $after_deny_removed = $user->has_cap( $meta_cap, $object_id, $context ); + + \remove_filter( 'map_meta_cap', $map_filter, 10 ); + $after_map_removed = $user->has_cap( $meta_cap, $object_id, $context ); + + self::collect_failure( + $failures, + false === $before + && true === $granted + && false === $after_grant_removed + && false === $denied + && false === $after_deny_removed + && false === $after_map_removed, + 'user_has_cap grants are scoped after generated meta-cap mapping', + array( + 'states' => array( + 'before' => $before, + 'granted' => $granted, + 'afterGrantRemoved' => $after_grant_removed, + 'denied' => $denied, + 'afterDenyRemoved' => $after_deny_removed, + 'afterMapRemoved' => $after_map_removed, + ), + ) + ); + + self::collect_failure( + $failures, + 5 === count( $map_calls ) + && 2 === count( $filter_calls ) + && array( $required_a, $required_b ) === ( $filter_calls[0]['caps'] ?? null ) + && array( $meta_cap, $user->ID, $object_id, $context ) === ( $filter_calls[0]['args'] ?? null ) + && array( $required_a, $required_b ) === ( $filter_calls[1]['caps'] ?? null ) + && array( $meta_cap, $user->ID, $object_id, $context ) === ( $filter_calls[1]['args'] ?? null ), + 'map_meta_cap and user_has_cap filters receive expected generated context', + array( + 'mapCallCount' => count( $map_calls ), + 'filterCallCount' => count( $filter_calls ), + 'mapCalls' => $map_calls, + 'filterCalls' => $filter_calls, + ) + ); + } finally { + \remove_filter( 'user_has_cap', $grant_filter, 10 ); + \remove_filter( 'user_has_cap', $deny_filter, 10 ); + \remove_filter( 'map_meta_cap', $map_filter, 10 ); + } + + return self::row( + $ctx, + 'capabilities.user-has-cap.filter-contracts', + array() === $failures, + array( + 'user' => self::describe_user( $user ), + 'metaCap' => $meta_cap, + 'requiredCap' => array( $required_a, $required_b ), + 'objectId' => $object_id, + 'context' => $context, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + private static function check_meta_cap_mappings( \ComponentFuzz\FuzzContext $ctx ): array { $failures = array(); $user_id = 70000 + $ctx->int( 0, 9999 ); From bd218228ed9619414cbd74df00266160083f1a40 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:47:53 +0200 Subject: [PATCH 0112/1102] Record capability fuzz progress --- tools/component-fuzz/dashboard.html | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index d09536da6b890..980ba9d008dd6 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -231,7 +231,7 @@

Component Fuzzers Project Dashboard

Current Focused Run
100%
-
email: 110184 passed, 0 skipped, 0 failed at 100 iterations.
+
capabilities: 500 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
@@ -347,6 +347,13 @@

Current Work

Main focused run: 110184 passed, 0 skipped, 0 failed; latest broad run: 10012 passed, 93 skipped, 0 failed. Unicode/ASCII filters, WP_Email_Address views, IDN/Punycode, user lookups. + + capabilities + local validation passed + Main worktree + Main focused run: 500 passed, 0 skipped, 0 failed; broad run: 6673 passed, 62 skipped, 0 failed. + Roles, direct caps, generated map_meta_cap contexts, user_has_cap filters. + html-api worker active @@ -394,6 +401,12 @@

Recent Committed Additions

+ + 1ec4959311 + capabilities + 500 passed, 0 skipped, 0 failed. + 6673 passed, 62 skipped, 0 failed at 2 iterations. + ba642bf12d email From b257533f03c094e112ec403b7c1ed5ca73dbbed2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:52:49 +0200 Subject: [PATCH 0113/1102] Strengthen mail header fuzz coverage --- tools/component-fuzz/README.md | 4 +- tools/component-fuzz/surfaces/MailSurface.php | 93 +++++++++++++++++++ .../surfaces/PrivacySurface.php | 2 +- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 5785c5c3ee1e8..eec9c479d31bc 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -228,8 +228,8 @@ database, network requests, or a configured site. globals. - `mail`: no-delivery `wp_mail()` composition coverage, including argument filters, pre-send short-circuiting, PHPMailer recipient/header/content - handoff, attachments, embedded images, success/failure actions, and emoji - email body staticization. + handoff, array and string header parsing, newline-delimited attachments and + embeds, success/failure actions, and emoji email body staticization. - `markup`: block parse/serialize/render guards, shortcodes, text trimming, excerpts, balanced tags, URL extraction, embed helpers. - `media-editor`: no-DB media image editor coverage for editor selection, diff --git a/tools/component-fuzz/surfaces/MailSurface.php b/tools/component-fuzz/surfaces/MailSurface.php index d8d4312566d98..9d5d6c00e528e 100644 --- a/tools/component-fuzz/surfaces/MailSurface.php +++ b/tools/component-fuzz/surfaces/MailSurface.php @@ -28,6 +28,7 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { try { $rows[] = self::check_pre_wp_mail_short_circuit( $ctx->fork( 'pre' ) ); $rows[] = self::check_phpmailer_composition( $ctx->fork( 'compose' ), $temp_root ); + $rows[] = self::check_string_header_and_path_parsing( $ctx->fork( 'strings' ), $temp_root ); $rows[] = self::check_phpmailer_failure_action( $ctx->fork( 'failure' ) ); $rows[] = self::check_staticize_emoji_for_email( $ctx->fork( 'emoji' ) ); } catch ( \Throwable $e ) { @@ -256,6 +257,98 @@ private static function check_phpmailer_composition( \ComponentFuzz\FuzzContext return self::row( $ctx, 'mail.phpmailer.composition-and-actions', $failures ); } + private static function check_string_header_and_path_parsing( \ComponentFuzz\FuzzContext $ctx, ?string $temp_root ): array { + $failures = array(); + + if ( null === $temp_root ) { + return $ctx->skip( 'mail.phpmailer.string-header-and-path-parsing', 'Could not create temporary attachment root.' ); + } + + $attachment_a = $temp_root . DIRECTORY_SEPARATOR . 'string-attachment-a-' . $ctx->identifier( 4, 8 ) . '.txt'; + $attachment_b = $temp_root . DIRECTORY_SEPARATOR . 'string-attachment-b-' . $ctx->identifier( 4, 8 ) . '.txt'; + $embed = $temp_root . DIRECTORY_SEPARATOR . 'string-embed-' . $ctx->identifier( 4, 8 ) . '.gif'; + file_put_contents( $attachment_a, 'attachment-a ' . $ctx->text( 0, 16 ) ); + file_put_contents( $attachment_b, 'attachment-b ' . $ctx->text( 0, 16 ) ); + file_put_contents( $embed, "GIF89a" . $ctx->identifier( 4, 8 ) ); + + $token = 'str-' . $ctx->identifier( 4, 10 ); + $to = 'Alpha Recipient , beta@example.test'; + $subject = 'String Header ' . $token; + $message = '' . $token . ''; + $headers = implode( + "\r\n", + array( + 'From: String Sender ', + 'Cc: Carbon One , carbon2@example.test', + 'Bcc: Blind One ', + 'Reply-To: Reply String ', + 'Content-Type: text/html; charset=UTF-8', + 'X-String-Token: ' . $token, + 'MIME-Version: ignored-custom', + 'X-Mailer: ignored-custom', + ) + ); + $succeeded = array(); + + MailSurfaceMailer::$mode = 'success'; + MailSurfaceMailer::$sent = array(); + $GLOBALS['phpmailer'] = self::new_mailer(); + + $success_action = static function ( array $mail_data ) use ( &$succeeded ): void { + $succeeded[] = $mail_data; + }; + + \add_action( 'wp_mail_succeeded', $success_action ); + try { + $result = \wp_mail( + $to, + $subject, + $message, + $headers, + $attachment_a . "\n" . $attachment_b, + $embed + ); + } finally { + \remove_action( 'wp_mail_succeeded', $success_action ); + } + + $sent = MailSurfaceMailer::$sent[0] ?? array(); + self::collect_failure( + $failures, + true === $result + && 1 === count( MailSurfaceMailer::$sent ) + && self::addresses_include( $sent['to'] ?? array(), 'alpha@example.test', 'Alpha Recipient' ) + && self::addresses_include( $sent['to'] ?? array(), 'beta@example.test', '' ) + && self::addresses_include( $sent['cc'] ?? array(), 'carbon@example.test', 'Carbon One' ) + && self::addresses_include( $sent['cc'] ?? array(), 'carbon2@example.test', '' ) + && self::addresses_include( $sent['bcc'] ?? array(), 'blind-string@example.test', 'Blind One' ) + && self::addresses_include( $sent['replyTo'] ?? array(), 'reply-string@example.test', 'Reply String' ) + && 'string-sender@example.test' === ( $sent['from'] ?? null ) + && 'String Sender' === ( $sent['fromName'] ?? null ) + && $subject === ( $sent['subject'] ?? null ) + && $message === ( $sent['body'] ?? null ) + && 'text/html' === ( $sent['contentType'] ?? null ) + && 'UTF-8' === ( $sent['charset'] ?? null ) + && self::attachments_include( $sent['attachments'] ?? array(), basename( $attachment_a ), 'attachment' ) + && self::attachments_include( $sent['attachments'] ?? array(), basename( $attachment_b ), 'attachment' ) + && self::attachments_include( $sent['attachments'] ?? array(), basename( $embed ), 'inline', '0' ) + && 1 === count( $sent['customHeaders'] ?? array() ) + && self::custom_headers_include( $sent['customHeaders'] ?? array(), 'X-String-Token' ) + && 1 === count( $succeeded ) + && is_array( $succeeded[0]['to'] ?? null ) + && array( $attachment_a, $attachment_b ) === ( $succeeded[0]['attachments'] ?? null ) + && array( $embed ) === ( $succeeded[0]['embeds'] ?? null ), + 'wp_mail parses comma recipients plus newline headers, attachments, and embeds', + array( + 'result' => $result, + 'sent' => self::describe_value( $sent ), + 'succeeded' => self::describe_value( $succeeded ), + ) + ); + + return self::row( $ctx, 'mail.phpmailer.string-header-and-path-parsing', $failures ); + } + private static function check_phpmailer_failure_action( \ComponentFuzz\FuzzContext $ctx ): array { $failures = array(); $failed = array(); diff --git a/tools/component-fuzz/surfaces/PrivacySurface.php b/tools/component-fuzz/surfaces/PrivacySurface.php index b5b26717041e4..c5eae1a25d1f2 100644 --- a/tools/component-fuzz/surfaces/PrivacySurface.php +++ b/tools/component-fuzz/surfaces/PrivacySurface.php @@ -938,7 +938,7 @@ private static function check_anonymization_helpers( \ComponentFuzz\FuzzContext ), array( 'label' => 'not-an-ip', - 'input' => 'not an ip ' . self::random_string( $ctx->fork( 'not-ip' ), 8 ), + 'input' => 'not-an-ip-' . substr( hash( 'sha1', (string) $ctx->fork( 'not-ip' )->seed() ), 0, 8 ), 'expected' => '0.0.0.0', ), ); From 7b5eeac0e39b98e178cbf059d2964df2a6e5402c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:54:12 +0200 Subject: [PATCH 0114/1102] Strengthen hooks fuzz surface --- .../component-fuzz/surfaces/HooksSurface.php | 1250 +++++++++++++++-- 1 file changed, 1107 insertions(+), 143 deletions(-) diff --git a/tools/component-fuzz/surfaces/HooksSurface.php b/tools/component-fuzz/surfaces/HooksSurface.php index d25ba1b8051b2..1a5d1aeb8f95e 100644 --- a/tools/component-fuzz/surfaces/HooksSurface.php +++ b/tools/component-fuzz/surfaces/HooksSurface.php @@ -4,147 +4,467 @@ final class HooksSurface { public const NAME = 'hooks'; + private const GENERATED_CALLBACKS = 7; + public static function run( \ComponentFuzz\FuzzContext $ctx ): array { - $required = array( - 'add_filter', - 'add_action', - 'apply_filters', - 'do_action', - 'remove_filter', - 'remove_all_filters', - 'has_filter', - 'current_filter', - 'doing_filter', - 'did_action', - ); - - foreach ( $required as $function ) { - if ( ! function_exists( $function ) ) { - return array( - $ctx->skip( 'hooks.unavailable', "Function {$function} is unavailable." ), - ); - } + $missing = self::missing_requirements(); + if ( array() !== $missing ) { + return array( + $ctx->skip( + 'hooks.bootstrap-apis-available', + 'Required WordPress hook APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); } $snapshot = self::snapshot_hook_globals(); $rows = array(); try { - $rows[] = self::check_filter_priority_and_removal( $ctx ); - $rows[] = self::check_action_state( $ctx ); - $rows[] = self::check_nested_filter_stack( $ctx ); - $rows[] = self::check_remove_all_filters( $ctx ); + $rows[] = self::check_generated_filter_pipeline( $ctx->fork( 'generated-filter-pipeline' ) ); + $rows[] = self::check_wp_hook_direct_api( $ctx->fork( 'wp-hook-direct' ) ); + $rows[] = self::check_has_filter_priority_semantics( $ctx->fork( 'has-filter-priority' ) ); + $rows[] = self::check_nested_dispatch_stack( $ctx->fork( 'nested-dispatch-stack' ) ); + $rows[] = self::check_dispatch_mutation( $ctx->fork( 'dispatch-mutation' ) ); + $rows[] = self::check_remove_all_filters( $ctx->fork( 'remove-all-filters' ) ); + $rows[] = self::check_action_and_filter_counters( $ctx->fork( 'counters' ) ); + $rows[] = self::check_reference_and_object_payloads( $ctx->fork( 'reference-object-payloads' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'hooks.surface-no-throw', + array( + 'throwable' => self::describe_throwable( $e ), + ) + ); } finally { self::restore_hook_globals( $snapshot ); } + $rows[] = self::check_hook_globals_restored( $ctx->fork( 'state-restoration' ), $snapshot ); + return $rows; } - private static function check_filter_priority_and_removal( \ComponentFuzz\FuzzContext $ctx ): array { - $tag = self::tag( $ctx, 'priority' ); - $calls = array(); + private static function missing_requirements(): array { + $missing = array(); - $early = static function ( $value ) use ( &$calls ) { - $calls[] = 'early:' . $value; - return $value . '|early'; - }; - $middle = static function ( $value, $extra ) use ( &$calls ) { - $calls[] = 'middle:' . $extra; - return $value . '|middle:' . $extra; + foreach ( + array( + 'add_filter', + 'add_action', + 'apply_filters', + 'apply_filters_ref_array', + 'do_action', + 'do_action_ref_array', + 'remove_filter', + 'remove_action', + 'remove_all_filters', + 'remove_all_actions', + 'has_filter', + 'has_action', + 'current_filter', + 'current_action', + 'doing_filter', + 'doing_action', + 'did_filter', + 'did_action', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_Hook', false ) ) { + $missing[] = 'class WP_Hook'; + } + + return $missing; + } + + private static function check_generated_filter_pipeline( \ComponentFuzz\FuzzContext $ctx ): array { + $tag = self::tag( $ctx, 'generated-filter' ); + $extra_args = array( + 'arg-' . $ctx->identifier( 3, 8 ), + $ctx->int( -50, 50 ), + array( 'seed' => $ctx->seed() ), + ); + $value = 'base-' . substr( hash( 'sha1', (string) $ctx->seed() ), 0, 8 ); + $specs = self::generated_filter_specs( $ctx ); + $callbacks = array(); + $actual_log = array(); + $expected_log = array(); + $expected = $value; + $failures = array(); + + foreach ( $specs as $spec ) { + $callback = static function ( ...$args ) use ( &$actual_log, $spec ) { + $actual_log[] = array( + 'id' => $spec['id'], + 'priority' => $spec['priority'], + 'accepted' => $spec['accepted'], + 'count' => count( $args ), + 'args' => self::describe_args( $args ), + ); + + if ( 0 === $spec['accepted'] ) { + return 'zero-' . $spec['id']; + } + + return (string) $args[0] . '|' . $spec['id'] . ':' . count( $args ); + }; + + $callbacks[ $spec['id'] ] = $callback; + add_filter( $tag, $callback, $spec['priority'], $spec['accepted'] ); + } + + foreach ( self::expected_filter_order( $specs ) as $spec ) { + $incoming = array_merge( array( $expected ), $extra_args ); + $received_args = self::expected_received_args( $incoming, $spec['accepted'] ); + + $expected_log[] = array( + 'id' => $spec['id'], + 'priority' => $spec['priority'], + 'accepted' => $spec['accepted'], + 'count' => count( $received_args ), + 'args' => self::describe_args( $received_args ), + ); + + if ( 0 === $spec['accepted'] ) { + $expected = 'zero-' . $spec['id']; + } else { + $expected = (string) $received_args[0] . '|' . $spec['id'] . ':' . count( $received_args ); + } + } + + $actual = apply_filters( $tag, $value, ...$extra_args ); + + self::collect_failure( + $failures, + $actual === $expected, + 'Generated filter pipeline returned the expected transformed value.', + array( + 'expected' => $expected, + 'actual' => $actual, + ) + ); + self::collect_failure( + $failures, + $actual_log === $expected_log, + 'Callbacks ran in numeric priority order with insertion order and accepted_args truncation.', + array( + 'expectedLog' => $expected_log, + 'actualLog' => $actual_log, + ) + ); + + $remove_spec = $specs[ count( $specs ) - 1 ]; + $removed = remove_filter( $tag, $callbacks[ $remove_spec['id'] ], $remove_spec['priority'] ); + $removed_again = remove_filter( $tag, $callbacks[ $remove_spec['id'] ], $remove_spec['priority'] ); + $has_removed = has_filter( $tag, $callbacks[ $remove_spec['id'] ] ); + $has_wrong_priority = has_filter( + $tag, + $callbacks[ $remove_spec['id'] ], + $remove_spec['priority'] + 1000 + ); + + self::collect_failure( + $failures, + true === $removed && false === $removed_again && false === $has_removed && false === $has_wrong_priority, + 'remove_filter removes exactly the requested callback/priority pair.', + array( + 'removed' => $removed, + 'removedAgain' => $removed_again, + 'hasRemoved' => $has_removed, + 'hasWrongPriority' => $has_wrong_priority, + ) + ); + + return self::result( + $ctx, + 'hooks.generated-filter-priority-accepted-args', + $failures, + array( + 'tag' => $tag, + 'specs' => self::describe_specs( $specs ), + 'final' => $actual, + 'logLength' => count( $actual_log ), + ) + ); + } + + private static function check_wp_hook_direct_api( \ComponentFuzz\FuzzContext $ctx ): array { + $tag = self::tag( $ctx, 'wp-hook-direct' ); + $hook = new \WP_Hook(); + $trace = array(); + $failures = array(); + + $zero = static function () use ( &$trace, $hook ) { + $trace[] = array( + 'id' => 'zero', + 'currentPriority' => $hook->current_priority(), + 'count' => 0, + ); + + return 'zero'; }; - $late = static function ( $value ) use ( &$calls ) { - $calls[] = 'late:' . $value; - return $value . '|late'; + $first = static function ( $value, ...$args ) use ( &$trace, $hook ) { + $trace[] = array( + 'id' => 'first', + 'currentPriority' => $hook->current_priority(), + 'count' => 1 + count( $args ), + ); + + return $value . '|first'; }; + $second = static function ( $value, ...$args ) use ( &$trace, $hook ) { + $trace[] = array( + 'id' => 'second', + 'currentPriority' => $hook->current_priority(), + 'count' => 1 + count( $args ), + ); - add_filter( $tag, $middle, 20, 2 ); - add_filter( $tag, $early, 5, 1 ); - add_filter( $tag, $late, 20, 1 ); - - $first = apply_filters( $tag, 'base', 'extra' ); - $first_calls = $calls; - $has = has_filter( $tag, $middle ); - $gone = remove_filter( $tag, $middle, 20 ); - $calls = array(); - $second = apply_filters( $tag, 'base', 'extra' ); - $calls_after_remove = $calls; - - $ok = 'base|early|middle:extra|late' === $first - && 20 === $has - && true === $gone - && 'base|early|late' === $second - && array( 'early:base', 'middle:extra', 'late:base|early|middle:extra' ) === $first_calls - && array( 'early:base', 'late:base|early' ) === $calls_after_remove; - - return $ctx->result( - 'hooks.filter-priority-removal', - $ok, - array( - 'first' => $first, - 'second' => $second, - 'hasMiddle' => $has, - 'removedMiddle' => $gone, - 'firstCalls' => $first_calls, - 'callsAfterRemove' => $calls_after_remove, - ) - ); - } - - private static function check_action_state( \ComponentFuzz\FuzzContext $ctx ): array { - $tag = self::tag( $ctx, 'action' ); - $before = did_action( $tag ); - $seen = array(); - $callback = static function ( $first, $second ) use ( &$seen, $tag ) { - $seen[] = array( - 'args' => array( $first, $second ), - 'currentFilter' => current_filter(), - 'doingTag' => doing_filter( $tag ), - 'doingAny' => doing_filter(), + return $value . '|second'; + }; + $late = static function ( $value ) use ( &$trace, $hook ) { + $trace[] = array( + 'id' => 'late', + 'currentPriority' => $hook->current_priority(), + 'count' => 1, ); + + return $value . '|late'; }; - add_action( $tag, $callback, 10, 2 ); - do_action( $tag, 'alpha', 'beta' ); - $after = did_action( $tag ); + $hook->add_filter( $tag, $late, 20, 1 ); + $hook->add_filter( $tag, $first, 10, 3 ); + $hook->add_filter( $tag, $zero, 0, 0 ); + $hook->add_filter( $tag, $second, 10, 2 ); - $ok = 0 === $before - && 1 === $after - && array( + $has_any = $hook->has_filter( $tag ); + $has_zero_priority = $hook->has_filter( $tag, $zero ); + $has_zero_at_zero = $hook->has_filter( $tag, $zero, 0 ); + $has_zero_at_default = $hook->has_filter( $tag, $zero, 10 ); + $result = $hook->apply_filters( 'base', array( 'base', 'extra', 'third' ) ); + + self::collect_failure( + $failures, + 'zero|first|second|late' === $result, + 'Direct WP_Hook::apply_filters uses priority order, same-priority insertion order, and accepted_args.', + array( + 'result' => $result, + ) + ); + self::collect_failure( + $failures, + array( array( - 'args' => array( 'alpha', 'beta' ), - 'currentFilter' => $tag, - 'doingTag' => true, - 'doingAny' => true, + 'id' => 'zero', + 'currentPriority' => 0, + 'count' => 0, ), - ) === $seen; + array( + 'id' => 'first', + 'currentPriority' => 10, + 'count' => 3, + ), + array( + 'id' => 'second', + 'currentPriority' => 10, + 'count' => 2, + ), + array( + 'id' => 'late', + 'currentPriority' => 20, + 'count' => 1, + ), + ) === $trace, + 'WP_Hook::current_priority tracks the active priority, including falsey priority 0.', + array( + 'trace' => $trace, + ) + ); + self::collect_failure( + $failures, + true === $has_any + && 0 === $has_zero_priority + && true === $has_zero_at_zero + && false === $has_zero_at_default, + 'Direct WP_Hook::has_filter distinguishes priority 0 from false and supports exact priority checks.', + array( + 'hasAny' => $has_any, + 'hasZeroPriority' => $has_zero_priority, + 'hasZeroAtZero' => $has_zero_at_zero, + 'hasZeroAtDefault' => $has_zero_at_default, + ) + ); + + $removed = $hook->remove_filter( $tag, $first, 10 ); + $removed_again = $hook->remove_filter( $tag, $first, 10 ); + $trace = array(); + $after_remove = $hook->apply_filters( 'base', array( 'base', 'extra', 'third' ) ); - return $ctx->result( - 'hooks.action-current-filter-state', - $ok, + self::collect_failure( + $failures, + true === $removed + && false === $removed_again + && 'zero|second|late' === $after_remove + && false === $hook->has_filter( $tag, $first ), + 'Direct WP_Hook::remove_filter removes one callback without disturbing neighbors.', array( - 'didBefore' => $before, - 'didAfter' => $after, - 'seen' => $seen, + 'removed' => $removed, + 'removedAgain' => $removed_again, + 'afterRemove' => $after_remove, + 'trace' => $trace, + ) + ); + + $hook->remove_all_filters( 10 ); + $after_priority_clear = $hook->apply_filters( 'base', array( 'base', 'extra', 'third' ) ); + $hook->remove_all_filters(); + + self::collect_failure( + $failures, + 'zero|late' === $after_priority_clear && false === $hook->has_filter( $tag ), + 'Direct WP_Hook::remove_all_filters clears a selected priority and then the whole hook.', + array( + 'afterPriorityClear' => $after_priority_clear, + 'hasAfterClear' => $hook->has_filter( $tag ), + ) + ); + + return self::result( + $ctx, + 'hooks.wp-hook-direct-api', + $failures, + array( + 'tag' => $tag, ) ); } - private static function check_nested_filter_stack( \ComponentFuzz\FuzzContext $ctx ): array { - $outer = self::tag( $ctx, 'outer' ); - $inner = self::tag( $ctx, 'inner' ); - $seen = array(); + private static function check_has_filter_priority_semantics( \ComponentFuzz\FuzzContext $ctx ): array { + $tag = self::tag( $ctx, 'has-filter' ); + $action = self::tag( $ctx, 'has-action' ); + $failures = array(); + $zero = static function ( $value ) { + return $value . '|zero'; + }; + $ten = static function ( $value ) { + return $value . '|ten'; + }; + $action_cb = static function () { + }; + + $missing_before = has_filter( $tag ); + + add_filter( $tag, $ten, 10, 1 ); + add_filter( $tag, $zero, 0, 1 ); + add_action( $action, $action_cb, 0, 0 ); + $has_any = has_filter( $tag ); + $has_zero = has_filter( $tag, $zero ); + $has_zero_exact = has_filter( $tag, $zero, 0 ); + $has_zero_wrong = has_filter( $tag, $zero, 10 ); + $has_ten = has_filter( $tag, $ten ); + $has_ten_exact = has_filter( $tag, $ten, 10 ); + $has_ten_wrong = has_filter( $tag, $ten, 0 ); + $has_missing_cb = has_filter( + $tag, + static function ( $value ) { + return $value; + } + ); + $has_action_zero = has_action( $action, $action_cb ); + $has_action_exact = has_action( $action, $action_cb, 0 ); + $removed_action = remove_action( $action, $action_cb, 0 ); + $has_action_removed = has_action( $action ); + + self::collect_failure( + $failures, + false === $missing_before + && true === $has_any + && 0 === $has_zero + && true === $has_zero_exact + && false === $has_zero_wrong + && 10 === $has_ten + && true === $has_ten_exact + && false === $has_ten_wrong + && false === $has_missing_cb, + 'has_filter return values preserve callback priority position semantics.', + array( + 'missingBefore' => $missing_before, + 'hasAny' => $has_any, + 'hasZero' => $has_zero, + 'hasZeroExact' => $has_zero_exact, + 'hasZeroWrong' => $has_zero_wrong, + 'hasTen' => $has_ten, + 'hasTenExact' => $has_ten_exact, + 'hasTenWrong' => $has_ten_wrong, + 'hasMissingCb' => $has_missing_cb, + ) + ); + self::collect_failure( + $failures, + 0 === $has_action_zero + && true === $has_action_exact + && true === $removed_action + && false === $has_action_removed, + 'has_action and remove_action mirror filter priority semantics.', + array( + 'hasActionZero' => $has_action_zero, + 'hasActionExact' => $has_action_exact, + 'removedAction' => $removed_action, + 'hasActionRemoved' => $has_action_removed, + ) + ); + + return self::result( + $ctx, + 'hooks.has-filter-priority-semantics', + $failures, + array( + 'tag' => $tag, + 'action' => $action, + ) + ); + } + + private static function check_nested_dispatch_stack( \ComponentFuzz\FuzzContext $ctx ): array { + $outer = self::tag( $ctx, 'outer' ); + $inner = self::tag( $ctx, 'inner' ); + $seen = array(); + $all_seen = array(); + $failures = array(); + + $all = static function ( $hook_name, ...$args ) use ( &$all_seen, $outer, $inner ) { + if ( ! in_array( $hook_name, array( $outer, $inner ), true ) ) { + return; + } + + $all_seen[] = array( + 'hook' => $hook_name, + 'current' => current_filter(), + 'stack' => self::current_filter_stack(), + 'count' => 1 + count( $args ), + ); + }; + + add_action( 'all', $all, 10, 99 ); add_filter( $inner, static function ( $value ) use ( &$seen, $outer, $inner ) { $seen[] = array( - 'phase' => 'inner', - 'current' => current_filter(), - 'doingOuter' => doing_filter( $outer ), - 'doingInner' => doing_filter( $inner ), - 'inputPreview' => $value, + 'phase' => 'inner', + 'current' => current_filter(), + 'currentAction' => current_action(), + 'stack' => self::current_filter_stack(), + 'doingOuter' => doing_filter( $outer ), + 'doingInner' => doing_filter( $inner ), + 'doingAny' => doing_filter(), ); + return $value . '|inner'; }, 10, @@ -157,78 +477,675 @@ static function ( $value ) use ( &$seen, $inner ) { $seen[] = array( 'phase' => 'outer-before', 'current' => current_filter(), + 'stack' => self::current_filter_stack(), ); - $value = apply_filters( $inner, $value . '|outer' ); + + $value = apply_filters( $inner, $value . '|outer', 'inner-extra' ); + $seen[] = array( 'phase' => 'outer-after', 'current' => current_filter(), + 'stack' => self::current_filter_stack(), ); + return $value . '|done'; }, 10, 1 ); - $result = apply_filters( $outer, 'base' ); + $before_current = current_filter(); + $result = apply_filters( $outer, 'base', 'outer-extra' ); + $after_current = current_filter(); + $after_doing = doing_filter(); + $removed_all = remove_filter( 'all', $all, 10 ); - $ok = 'base|outer|inner|done' === $result - && array( 'outer-before', 'inner', 'outer-after' ) === array_column( $seen, 'phase' ) - && $outer === $seen[0]['current'] - && $inner === $seen[1]['current'] - && true === $seen[1]['doingOuter'] - && true === $seen[1]['doingInner'] - && $outer === $seen[2]['current']; - - return $ctx->result( - 'hooks.nested-filter-stack', - $ok, + self::collect_failure( + $failures, + 'base|outer|inner|done' === $result, + 'Nested filter dispatch returns through the outer callback after inner dispatch.', array( 'result' => $result, - 'seen' => $seen, + ) + ); + self::collect_failure( + $failures, + array( 'outer-before', 'inner', 'outer-after' ) === array_column( $seen, 'phase' ) + && $outer === $seen[0]['current'] + && array( $outer ) === $seen[0]['stack'] + && $inner === $seen[1]['current'] + && array( $outer, $inner ) === $seen[1]['stack'] + && true === $seen[1]['doingOuter'] + && true === $seen[1]['doingInner'] + && true === $seen[1]['doingAny'] + && $outer === $seen[2]['current'] + && array( $outer ) === $seen[2]['stack'], + 'current_filter, current_action, doing_filter, and the global stack stay coherent during nesting.', + array( + 'seen' => $seen, + ) + ); + self::collect_failure( + $failures, + array( + array( + 'hook' => $outer, + 'current' => $outer, + 'stack' => array( $outer ), + 'count' => 3, + ), + array( + 'hook' => $inner, + 'current' => $inner, + 'stack' => array( $outer, $inner ), + 'count' => 3, + ), + ) === $all_seen, + 'The all hook observes each nested hook with the same current stack WordPress exposes to regular callbacks.', + array( + 'allSeen' => $all_seen, + ) + ); + self::collect_failure( + $failures, + false === $before_current + && false === $after_current + && false === $after_doing + && true === $removed_all, + 'Hook stack state is empty before and after nested dispatch.', + array( + 'beforeCurrent' => $before_current, + 'afterCurrent' => $after_current, + 'afterDoing' => $after_doing, + 'removedAll' => $removed_all, + ) + ); + + return self::result( + $ctx, + 'hooks.nested-dispatch-current-stack', + $failures, + array( + 'outer' => $outer, + 'inner' => $inner, + ) + ); + } + + private static function check_dispatch_mutation( \ComponentFuzz\FuzzContext $ctx ): array { + $tag = self::tag( $ctx, 'mutation' ); + $events = array(); + $removed_late = array(); + $removed_all = array(); + $failures = array(); + $added_past = static function ( $value ) use ( &$events ) { + $events[] = 'added-past'; + return $value . '|past'; + }; + $added_future = static function ( $value ) use ( &$events ) { + $events[] = 'added-future'; + return $value . '|future'; + }; + $late_removed = static function ( $value ) use ( &$events ) { + $events[] = 'late-removed'; + return $value . '|late-removed'; + }; + $late_remove_all = static function ( $value ) use ( &$events ) { + $events[] = 'late-remove-all'; + return $value . '|late-remove-all'; + }; + $middle = static function ( $value ) use ( &$events ) { + $events[] = 'middle'; + return $value . '|middle'; + }; + $mutator = static function ( $value ) use ( + $tag, + &$events, + &$removed_late, + &$removed_all, + $added_past, + $added_future, + $late_removed + ) { + $events[] = 'mutator'; + $removed_late[] = remove_filter( $tag, $late_removed, 30 ); + add_filter( $tag, $added_future, 20, 1 ); + add_filter( $tag, $added_past, 1, 1 ); + $removed_all[] = remove_all_filters( $tag, 40 ); + + return $value . '|mutator'; + }; + + add_filter( $tag, $mutator, 10, 1 ); + add_filter( $tag, $middle, 15, 1 ); + add_filter( $tag, $late_removed, 30, 1 ); + add_filter( $tag, $late_remove_all, 40, 1 ); + + $first = apply_filters( $tag, 'base' ); + $first_events = $events; + $events = array(); + $second = apply_filters( $tag, 'base' ); + $second_events = $events; + + self::collect_failure( + $failures, + 'base|mutator|middle|future' === $first + && array( 'mutator', 'middle', 'added-future' ) === $first_events, + 'Callbacks added at a future priority during dispatch run in the same pass, while removed future callbacks do not.', + array( + 'first' => $first, + 'firstEvents' => $first_events, + ) + ); + self::collect_failure( + $failures, + 'base|past|mutator|middle|future' === $second + && array( 'added-past', 'mutator', 'middle', 'added-future' ) === $second_events, + 'Callbacks added at an already-passed priority wait until the next dispatch.', + array( + 'second' => $second, + 'secondEvents' => $second_events, + ) + ); + self::collect_failure( + $failures, + array( true, false ) === $removed_late && array( true, true ) === $removed_all, + 'Dispatch-time remove_filter and remove_all_filters report stable results across repeated dispatches.', + array( + 'removedLate' => $removed_late, + 'removedAll' => $removed_all, + ) + ); + self::collect_failure( + $failures, + false === has_filter( $tag, $late_removed ) + && false === has_filter( $tag, $late_remove_all ) + && 1 === has_filter( $tag, $added_past ) + && 20 === has_filter( $tag, $added_future ), + 'Registry state after dispatch-time mutation matches the surviving callbacks.', + array( + 'hasLateRemoved' => has_filter( $tag, $late_removed ), + 'hasLateRemoveAll' => has_filter( $tag, $late_remove_all ), + 'hasAddedPast' => has_filter( $tag, $added_past ), + 'hasAddedFuture' => has_filter( $tag, $added_future ), + ) + ); + + return self::result( + $ctx, + 'hooks.add-remove-during-dispatch', + $failures, + array( + 'tag' => $tag, ) ); } private static function check_remove_all_filters( \ComponentFuzz\FuzzContext $ctx ): array { - $tag = self::tag( $ctx, 'remove-all' ); + $tag = self::tag( $ctx, 'remove-all' ); + $failures = array(); + $first = static function ( $value ) { + return $value . '|first'; + }; + $second = static function ( $value ) { + return $value . '|second'; + }; + $third = static function ( $value ) { + return $value . '|third'; + }; + $action = static function () { + }; + + add_filter( $tag, $first, 3, 1 ); + add_filter( $tag, $second, 8, 1 ); + add_filter( $tag, $third, 13, 1 ); + + $remove_missing = remove_all_filters( $tag, 99 ); + $after_missing_remove = apply_filters( $tag, 'base' ); + $remove_priority = remove_all_filters( $tag, 8 ); + $after_priority_remove = apply_filters( $tag, 'base' ); + $has_second_after_clear = has_filter( $tag, $second ); + $remove_all = remove_all_filters( $tag ); + $after_all_remove = apply_filters( $tag, 'base' ); + $has_after_all = has_filter( $tag ); + $registry_has_tag = isset( $GLOBALS['wp_filter'][ $tag ] ); + + add_action( $tag, $action, 4, 0 ); + $remove_all_actions = remove_all_actions( $tag ); + $has_action_after = has_action( $tag ); + + self::collect_failure( + $failures, + true === $remove_missing + && 'base|first|second|third' === $after_missing_remove + && true === $remove_priority + && 'base|first|third' === $after_priority_remove + && false === $has_second_after_clear, + 'remove_all_filters with a priority only removes that priority and ignores missing priorities.', + array( + 'removeMissing' => $remove_missing, + 'afterMissingRemove' => $after_missing_remove, + 'removePriority' => $remove_priority, + 'afterPriorityRemove' => $after_priority_remove, + 'hasSecondAfterClear' => $has_second_after_clear, + ) + ); + self::collect_failure( + $failures, + true === $remove_all + && 'base' === $after_all_remove + && false === $has_after_all + && false === $registry_has_tag, + 'remove_all_filters without a priority clears the hook and removes the public registry entry.', + array( + 'removeAll' => $remove_all, + 'afterAllRemove' => $after_all_remove, + 'hasAfterAll' => $has_after_all, + 'registryHasTag' => $registry_has_tag, + ) + ); + self::collect_failure( + $failures, + true === $remove_all_actions && false === $has_action_after, + 'remove_all_actions aliases remove_all_filters for action hooks.', + array( + 'removeAllActions' => $remove_all_actions, + 'hasActionAfter' => $has_action_after, + ) + ); + + return self::result( + $ctx, + 'hooks.remove-all-filters-actions', + $failures, + array( + 'tag' => $tag, + ) + ); + } + + private static function check_action_and_filter_counters( \ComponentFuzz\FuzzContext $ctx ): array { + $filter = self::tag( $ctx, 'counter-filter' ); + $action = self::tag( $ctx, 'counter-action' ); + $seen = array(); + $failures = array(); add_filter( - $tag, - static function ( $value ) { - return $value . '|first'; + $filter, + static function ( $value, $extra ) use ( &$seen, $filter ) { + $seen[] = array( + 'type' => 'filter', + 'value' => $value, + 'extra' => $extra, + 'currentFilter' => current_filter(), + 'doingFilter' => doing_filter( $filter ), + 'doingAny' => doing_filter(), + 'didFilter' => did_filter( $filter ), + ); + + return $value . '|filtered-' . $extra; }, - 9, - 1 + 10, + 2 + ); + add_action( + $action, + static function ( $first, $second ) use ( &$seen, $action, $filter ) { + $seen[] = array( + 'type' => 'action', + 'args' => array( $first, $second ), + 'currentAction' => current_action(), + 'currentFilter' => current_filter(), + 'doingAction' => doing_action( $action ), + 'doingFilter' => doing_filter( $filter ), + 'doingAny' => doing_action(), + 'didAction' => did_action( $action ), + 'didFilter' => did_filter( $filter ), + ); + }, + 10, + 2 + ); + + $filter_before = did_filter( $filter ); + $action_before = did_action( $action ); + $first_filter = apply_filters( $filter, 'base', 'one' ); + $second_filter = apply_filters_ref_array( $filter, array( 'base', 'two' ) ); + do_action( $action, 'alpha', 'beta' ); + do_action_ref_array( $action, array( 'gamma', 'delta' ) ); + $filter_after = did_filter( $filter ); + $action_after = did_action( $action ); + + self::collect_failure( + $failures, + 0 === $filter_before + && 2 === $filter_after + && 'base|filtered-one' === $first_filter + && 'base|filtered-two' === $second_filter, + 'did_filter counts apply_filters and apply_filters_ref_array dispatches.', + array( + 'filterBefore' => $filter_before, + 'filterAfter' => $filter_after, + 'firstFilter' => $first_filter, + 'secondFilter' => $second_filter, + ) ); + self::collect_failure( + $failures, + 0 === $action_before && 2 === $action_after, + 'did_action counts do_action and do_action_ref_array dispatches.', + array( + 'actionBefore' => $action_before, + 'actionAfter' => $action_after, + ) + ); + self::collect_failure( + $failures, + array( + array( + 'type' => 'filter', + 'value' => 'base', + 'extra' => 'one', + 'currentFilter' => $filter, + 'doingFilter' => true, + 'doingAny' => true, + 'didFilter' => 1, + ), + array( + 'type' => 'filter', + 'value' => 'base', + 'extra' => 'two', + 'currentFilter' => $filter, + 'doingFilter' => true, + 'doingAny' => true, + 'didFilter' => 2, + ), + array( + 'type' => 'action', + 'args' => array( 'alpha', 'beta' ), + 'currentAction' => $action, + 'currentFilter' => $action, + 'doingAction' => true, + 'doingFilter' => false, + 'doingAny' => true, + 'didAction' => 1, + 'didFilter' => 2, + ), + array( + 'type' => 'action', + 'args' => array( 'gamma', 'delta' ), + 'currentAction' => $action, + 'currentFilter' => $action, + 'doingAction' => true, + 'doingFilter' => false, + 'doingAny' => true, + 'didAction' => 2, + 'didFilter' => 2, + ), + ) === $seen, + 'Current hook helpers and counters expose in-dispatch state for filters and actions.', + array( + 'seen' => $seen, + ) + ); + + return self::result( + $ctx, + 'hooks.action-filter-counters', + $failures, + array( + 'filter' => $filter, + 'action' => $action, + ) + ); + } + + private static function check_reference_and_object_payloads( \ComponentFuzz\FuzzContext $ctx ): array { + $filter = self::tag( $ctx, 'object-filter' ); + $meta_filter = self::tag( $ctx, 'object-meta-filter' ); + $action = self::tag( $ctx, 'reference-action' ); + $failures = array(); + $payload = (object) array( + 'count' => 1, + 'trail' => array( 'start' ), + ); + $meta = (object) array( + 'seen' => array(), + ); + $box = array( + 'count' => 0, + 'trail' => array(), + ); + add_filter( - $tag, - static function ( $value ) { - return $value . '|second'; + $filter, + static function ( $value, $extra ) { + $value->count += $extra; + $value->trail[] = 'filter'; + + return $value; }, - 11, - 1 + 10, + 2 ); + add_filter( + $meta_filter, + static function ( $value, $meta ) { + $meta->seen[] = $value->count; + $value->trail[] = 'meta'; - remove_all_filters( $tag, 9 ); - $after_priority_remove = apply_filters( $tag, 'base' ); - remove_all_filters( $tag ); - $after_all_remove = apply_filters( $tag, 'base' ); + return $value; + }, + 12, + 2 + ); + add_action( + $action, + static function ( &$box_arg, $object_arg ) { + $box_arg['count']++; + $box_arg['trail'][] = 'action-ref'; + $object_arg->count++; + $object_arg->trail[] = 'action-object'; + }, + 10, + 2 + ); - $ok = 'base|second' === $after_priority_remove - && 'base' === $after_all_remove - && false === has_filter( $tag ); + $filter_args = array( $payload, 4 ); + $filtered = apply_filters_ref_array( $filter, $filter_args ); + $meta_args = array( $payload, $meta ); + $filtered2 = apply_filters_ref_array( $meta_filter, $meta_args ); + $action_args = array( &$box, $payload ); + do_action_ref_array( $action, $action_args ); - return $ctx->result( - 'hooks.remove-all-filters', - $ok, + self::collect_failure( + $failures, + $filtered === $payload + && $filtered2 === $payload + && 6 === $payload->count + && array( 'start', 'filter', 'meta', 'action-object' ) === $payload->trail + && array( 5 ) === $meta->seen, + 'Object payloads keep identity and intentional mutations across ref-array filters.', array( - 'afterPriorityRemove' => $after_priority_remove, - 'afterAllRemove' => $after_all_remove, - 'hasFilter' => has_filter( $tag ), + 'payload' => self::describe_object_payload( $payload ), + 'meta' => self::describe_object_payload( $meta ), + ) + ); + self::collect_failure( + $failures, + array( + 'count' => 1, + 'trail' => array( 'action-ref' ), + ) === $box, + 'do_action_ref_array preserves safe by-reference argument mutation.', + array( + 'box' => $box, + ) + ); + + return self::result( + $ctx, + 'hooks.reference-object-payloads', + $failures, + array( + 'filter' => $filter, + 'metaFilter' => $meta_filter, + 'action' => $action, + ) + ); + } + + private static function check_hook_globals_restored( \ComponentFuzz\FuzzContext $ctx, array $snapshot ): array { + $after = self::snapshot_hook_globals(); + $failures = array(); + + self::collect_failure( + $failures, + self::snapshots_identical( $snapshot, $after ), + 'Hook globals are restored after the surface mutates registries, counters, and current stacks.', + array( + 'before' => self::describe_snapshot( $snapshot ), + 'after' => self::describe_snapshot( $after ), + ) + ); + + return self::result( + $ctx, + 'hooks.state-restoration', + $failures, + array( + 'globals' => array_keys( $snapshot ), ) ); } + private static function generated_filter_specs( \ComponentFuzz\FuzzContext $ctx ): array { + $priorities = array( -10, -1, 0, 1, 5, 10, 10, 20 ); + $specs = array(); + + for ( $i = 0; $i < self::GENERATED_CALLBACKS; $i++ ) { + $specs[] = array( + 'id' => 'cb' . $i, + 'priority' => $ctx->choice( $priorities ), + 'accepted' => $ctx->int( 0, 5 ), + 'index' => $i, + ); + } + + $specs[] = array( + 'id' => 'forced-zero-priority', + 'priority' => 0, + 'accepted' => 1, + 'index' => count( $specs ), + ); + $specs[] = array( + 'id' => 'forced-truncate', + 'priority' => 10, + 'accepted' => 2, + 'index' => count( $specs ), + ); + $specs[] = array( + 'id' => 'forced-zero-args', + 'priority' => 10, + 'accepted' => 0, + 'index' => count( $specs ), + ); + + return $specs; + } + + private static function expected_filter_order( array $specs ): array { + usort( + $specs, + static function ( array $a, array $b ): int { + if ( $a['priority'] === $b['priority'] ) { + return $a['index'] <=> $b['index']; + } + + return $a['priority'] <=> $b['priority']; + } + ); + + return $specs; + } + + private static function expected_received_args( array $args, int $accepted ): array { + if ( 0 === $accepted ) { + return array(); + } + + if ( $accepted >= count( $args ) ) { + return $args; + } + + return array_slice( $args, 0, $accepted ); + } + + private static function describe_specs( array $specs ): array { + return array_map( + static function ( array $spec ): array { + return array( + 'id' => $spec['id'], + 'priority' => $spec['priority'], + 'accepted' => $spec['accepted'], + 'index' => $spec['index'], + ); + }, + $specs + ); + } + + private static function describe_args( array $args ): array { + return array_map( + static function ( $arg ) { + if ( is_array( $arg ) ) { + return array( + 'type' => 'array', + 'keys' => array_keys( $arg ), + ); + } + + if ( is_object( $arg ) ) { + return array( + 'type' => 'object', + 'class' => get_class( $arg ), + ); + } + + return $arg; + }, + $args + ); + } + + private static function current_filter_stack(): array { + return array_values( $GLOBALS['wp_current_filter'] ?? array() ); + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $data = array() ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'message' => $message, + 'data' => $data, + ); + } + + private static function result( + \ComponentFuzz\FuzzContext $ctx, + string $invariant, + array $failures, + array $data = array() + ): array { + if ( array() !== $failures ) { + $data['failures'] = $failures; + } + + return $ctx->result( $invariant, array() === $failures, $data ); + } + private static function tag( \ComponentFuzz\FuzzContext $ctx, string $label ): string { return 'component_fuzz_' . self::NAME . '_' . $label . '_' . $ctx->seed() . '_' . $ctx->iteration(); } @@ -251,4 +1168,51 @@ private static function restore_hook_globals( array $snapshot ): void { } } } + + private static function snapshots_identical( array $before, array $after ): bool { + foreach ( $before as $name => $value ) { + if ( ! array_key_exists( $name, $after ) || $after[ $name ] !== $value ) { + return false; + } + } + + return array_keys( $before ) === array_keys( $after ); + } + + private static function describe_snapshot( array $snapshot ): array { + $description = array(); + + foreach ( $snapshot as $name => $value ) { + if ( null === $value ) { + $description[ $name ] = array( + 'type' => 'unset', + ); + continue; + } + + $description[ $name ] = array( + 'type' => gettype( $value ), + 'count' => is_array( $value ) ? count( $value ) : null, + 'keys' => is_array( $value ) ? array_slice( array_keys( $value ), 0, 8 ) : null, + ); + } + + return $description; + } + + private static function describe_object_payload( object $object ): array { + return array( + 'class' => get_class( $object ), + 'properties' => get_object_vars( $object ), + ); + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } } From 3b48498d63bc98f5b283d4ad20fca493d9e5211b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:55:10 +0200 Subject: [PATCH 0115/1102] Record mail and hooks fuzz progress --- tools/component-fuzz/dashboard.html | 41 +++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 980ba9d008dd6..451982ddc3ef9 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -226,17 +226,17 @@

Component Fuzzers Project Dashboard

Latest Broad Run
100%
-
Non-skipped pass rate: 10012 passed, 0 failed, 0 errored, 93 skipped at 99 surfaces over 3 iterations.
+
Non-skipped pass rate: 6706 passed, 0 failed, 0 errored, 63 skipped at 99 surfaces over 2 iterations.
Current Focused Run
100%
-
capabilities: 500 passed, 0 skipped, 0 failed at 100 iterations.
+
hooks: 900 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
-
4
-
Four delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.
+
3
+
Three delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.
@@ -355,18 +355,25 @@

Current Work

Roles, direct caps, generated map_meta_cap contexts, user_has_cap filters. - html-api - worker active - Sagan: _work/component-fuzz-html-api-rich - Target: focused 100 iterations, smoke, standards, diff check, commit. - HTML Tag/Processor walking, bookmarks, mutation escaping, malformed recovery. + mail + local validation passed + Main worktree + Main focused run: 600 passed, 0 skipped, 0 failed; broad run: 6690 passed, 62 skipped, 0 failed. + wp_mail() filters, PHPMailer handoff, string headers, attachments, embeds. hooks + local validation passed + Main worktree + Main focused run: 900 passed, 0 skipped, 0 failed; broad run: 6706 passed, 63 skipped, 0 failed. + WP_Hook, nested dispatch, mutation during dispatch, accepted args, counters. + + + html-api worker active - Mill: _work/component-fuzz-hooks-rich + Sagan: _work/component-fuzz-html-api-rich Target: focused 100 iterations, smoke, standards, diff check, commit. - WP_Hook, nested dispatch, mutation during dispatch, accepted args, counters. + HTML Tag/Processor walking, bookmarks, mutation escaping, malformed recovery. syndication @@ -401,6 +408,18 @@

Recent Committed Additions

+ + 7b5eeac0e3 + hooks + 900 passed, 0 skipped, 0 failed. + 6706 passed, 63 skipped, 0 failed at 2 iterations. + + + b257533f03 + mail, privacy + 600 passed, 0 skipped, 0 failed for mail; privacy repro passed. + 6690 passed, 62 skipped, 0 failed at 2 iterations. + 1ec4959311 capabilities From 211c4eec2e612859b4795c3da03272dcb73792b5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 20:56:20 +0200 Subject: [PATCH 0116/1102] Track expanded component fuzz workers --- tools/component-fuzz/dashboard.html | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 451982ddc3ef9..1d6ee66cb2cce 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -235,8 +235,8 @@

Component Fuzzers Project Dashboard

Active Workers
-
3
-
Three delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.
+
5
+
Five delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.
@@ -389,6 +389,20 @@

Current Work

Target: focused 100 iterations, smoke, standards, diff check, commit. Front-controller parsing, query vars, 404/header transitions, superglobal restoration. + + widgets + worker active + Erdos: _work/component-fuzz-widgets-rich + Target: focused 100 iterations, smoke, standards, diff check, commit. + Classic sidebar/widget registries, generated widget IDs, callbacks, state restoration. + + + user-preferences + worker active + Bernoulli: _work/component-fuzz-user-preferences-rich + Target: focused 100 iterations, smoke, standards, diff check, commit. + User settings, hidden columns, meta boxes, screen options, admin preference filters. + From 9c8eb1f52a96b0647bb3a8b73f4abe7da4364647 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 21:00:51 +0200 Subject: [PATCH 0117/1102] Strengthen request lifecycle fuzz surface --- tools/component-fuzz/README.md | 7 +- .../surfaces/RequestLifecycleSurface.php | 645 ++++++++++++++++-- 2 files changed, 579 insertions(+), 73 deletions(-) diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index eec9c479d31bc..93cc2b505788a 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -318,9 +318,10 @@ database, network requests, or a configured site. - `rest`: request normalization, parameter precedence, JSON bodies, route regexes, schema sanitize/validate, permissions, HEAD/GET behavior. - `request-lifecycle`: no-DB front-controller lifecycle coverage for - `WP::parse_request()`, rewrite-rule matching, query-var precedence, - `register_globals()`, `handle_404()` status transitions, and `send_headers()` - filters/actions with deterministic global restoration. + `WP::parse_request()`, rewrite/pathinfo/index matching, public/private + query-var gates, query-var precedence, `register_globals()`, `handle_404()` + status transitions, and `send_headers()` filters/actions with deterministic + global restoration. - `rest-controllers`: no-DB default REST endpoint controller coverage for registry-backed post types, post statuses, taxonomies, settings, block types, block patterns, and block pattern categories, including context/_fields diff --git a/tools/component-fuzz/surfaces/RequestLifecycleSurface.php b/tools/component-fuzz/surfaces/RequestLifecycleSurface.php index be560887878e0..c7ebaeead49e4 100644 --- a/tools/component-fuzz/surfaces/RequestLifecycleSurface.php +++ b/tools/component-fuzz/surfaces/RequestLifecycleSurface.php @@ -9,6 +9,7 @@ final class RequestLifecycleSurface { private const HOME_URL = 'http://example.test/site-base'; private const SITE_URL = 'http://example.test/site-base/wp'; + private const FIXED_LAST_MODIFIED = '2024-02-03 04:05:06'; public static function run( \ComponentFuzz\FuzzContext $ctx ): array { $missing = self::missing_requirements(); @@ -30,6 +31,8 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { self::install_option_filters( $case['rules'] ); $rows[] = self::check_parse_request_rewrite_and_precedence( $ctx->fork( 'parse' ), $case ); + $rows[] = self::check_parse_request_pathinfo_and_index( $ctx->fork( 'pathinfo-index' ), $case ); + $rows[] = self::check_parse_request_public_private_gates( $ctx->fork( 'public-private-gates' ), $case ); $rows[] = self::check_parse_request_short_circuit( $ctx->fork( 'short-circuit' ) ); $rows[] = self::check_register_globals( $ctx->fork( 'globals' ), $case ); $rows[] = self::check_handle_404_transitions( $ctx->fork( '404' ) ); @@ -48,8 +51,9 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { 'request-lifecycle.global-state-restored', self::state_matches( $snapshot ), array( - 'trackedGlobals' => array_keys( $snapshot['globals'] ), - 'trackedServer' => array_keys( $snapshot['server'] ), + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedServer' => array_keys( $snapshot['server'] ), + 'trackedSuperglobals' => array( '_GET', '_POST', '_REQUEST', '_COOKIE' ), ) ); @@ -72,6 +76,10 @@ public static function filter_html_type(): string { return 'text/html'; } + public static function filter_permalink_structure(): string { + return '/%postname%/'; + } + public static function filter_rewrite_rules() { return self::$rewrite_rules; } @@ -82,7 +90,7 @@ public static function filter_rewrite_rules() { private static function missing_requirements(): array { $missing = array(); - foreach ( array( 'WP', 'WP_Query', 'WP_Rewrite', 'WP_MatchesMapRegex' ) as $class ) { + foreach ( array( 'WP', 'WP_Post', 'WP_Query', 'WP_Rewrite', 'WP_MatchesMapRegex' ) as $class ) { if ( ! class_exists( $class ) ) { $missing[] = "class {$class}"; } @@ -91,13 +99,21 @@ private static function missing_requirements(): array { foreach ( array( 'add_filter', + 'add_action', 'do_action_ref_array', 'get_option', + 'get_post_types', + 'get_taxonomies', 'has_filter', 'home_url', 'is_404', + 'mysql2date', + 'register_taxonomy', + 'remove_action', 'remove_filter', + 'sanitize_title_with_dashes', 'status_header', + 'unregister_taxonomy', 'wp_get_nocache_headers', ) as $function ) { @@ -110,39 +126,109 @@ private static function missing_requirements(): array { } private static function case_for_context( \ComponentFuzz\FuzzContext $ctx ): array { - $slug = sanitize_title_with_dashes( 'Item ' . $ctx->identifier( 4, 12 ) . ' ' . $ctx->int( 1, 999 ) ); - if ( '' === $slug ) { - $slug = 'item-' . $ctx->seed(); - } + $token = strtolower( str_replace( array( '-', ':' ), '_', $ctx->identifier( 5, 10 ) ) ); + $slug = self::slug_for_context( $ctx, 'Item ' . $token . ' ' . $ctx->int( 1, 999 ) ); - $public_var = 'cfuzz_item_' . strtolower( str_replace( array( '-', ':' ), '_', $ctx->identifier( 4, 10 ) ) ); - $extra_var = 'cfuzz_extra_' . strtolower( str_replace( array( '-', ':' ), '_', $ctx->identifier( 4, 10 ) ) ); - $tag_var = 'cfuzz_tag_' . strtolower( str_replace( array( '-', ':' ), '_', $ctx->identifier( 4, 10 ) ) ); - $rule = '^library/([^/]+)/?$'; + $rewrite_var = self::query_var_name( 'cfuzz_route_', $ctx->identifier( 4, 10 ) ); + $extra_var = self::query_var_name( 'cfuzz_extra_', $ctx->identifier( 4, 10 ) ); + $get_var = self::query_var_name( 'cfuzz_get_', $ctx->identifier( 4, 10 ) ); + $post_var = self::query_var_name( 'cfuzz_post_', $ctx->identifier( 4, 10 ) ); + $tag_var = self::query_var_name( 'cfuzz_tag_', $ctx->identifier( 4, 10 ) ); + $pathinfo_var = self::query_var_name( 'cfuzz_path_', $ctx->identifier( 4, 10 ) ); + $index_var = self::query_var_name( 'cfuzz_index_', $ctx->identifier( 4, 10 ) ); + $taxonomy_var = self::query_var_name( 'cfuzz_taxq_', $ctx->identifier( 4, 10 ) ); + $filtered_out_var = self::query_var_name( 'cfuzz_deny_', $ctx->identifier( 4, 10 ) ); + $filter_added_var = self::query_var_name( 'cfuzz_allow_', $ctx->identifier( 4, 10 ) ); + $private_leak_var = self::query_var_name( 'cfuzz_private_', $ctx->identifier( 4, 10 ) ); + $blocked_type = 'cfuzz_hidden_' . substr( $token, 0, 8 ); + $public_taxonomy = 'cfuzzrtax_' . substr( $token, 0, 12 ); + $private_taxonomy = 'cfuzzrpriv_' . substr( $token, 0, 12 ); + $library_rule = 'library/([^/]+)/([^/]+)/?$'; + $pathinfo_rule = 'index.php/pathinfo/([^/]+)/?$'; + $gate_rule = 'gated/([^/]+)/([^/]+)/?$'; + $library_query = 'index.php?' + . $rewrite_var . '=$matches[1]' + . '&' . $extra_var . '=from-rewrite' + . '&' . $tag_var . '=permalink+value' + . '&' . $taxonomy_var . '=taxonomy+value' + . '&page=2&offset=99&fields=rewrite-fields&post_type=' . $blocked_type; + $gate_query = 'index.php?taxonomy=$matches[1]&term=$matches[2]' + . '&post_type=' . $blocked_type + . '&fields=rewrite-fields&' . $private_leak_var . '=rewrite-leak'; return array( - 'slug' => $slug, - 'publicVar' => $public_var, - 'extraVar' => $extra_var, - 'tagVar' => $tag_var, - 'rule' => $rule, - 'rules' => array( - $rule => 'index.php?' . $public_var . '=$matches[1]&' . $tag_var . '=permalink+value&page=2', - '^feed/([^/]+)/?$' => 'index.php?feed=$matches[1]', + 'token' => $token, + 'slug' => $slug, + 'pathinfoSlug' => self::slug_for_context( $ctx, 'Path ' . $token . ' ' . $ctx->int( 1000, 9999 ) ), + 'gateTerm' => self::slug_for_context( $ctx, 'Gate ' . $token . ' ' . $ctx->int( 1000, 9999 ) ), + 'rewriteVar' => $rewrite_var, + 'extraVar' => $extra_var, + 'getVar' => $get_var, + 'postVar' => $post_var, + 'tagVar' => $tag_var, + 'pathinfoVar' => $pathinfo_var, + 'indexVar' => $index_var, + 'taxonomyVar' => $taxonomy_var, + 'filteredOutVar' => $filtered_out_var, + 'filterAddedVar' => $filter_added_var, + 'privateLeakVar' => $private_leak_var, + 'blockedType' => $blocked_type, + 'publicTaxonomy' => $public_taxonomy, + 'privateTaxonomy' => $private_taxonomy, + 'libraryRule' => $library_rule, + 'pathinfoRule' => $pathinfo_rule, + 'gateRule' => $gate_rule, + 'rules' => array( + $library_rule => $library_query, + $pathinfo_rule => 'index.php?' . $pathinfo_var . '=$matches[1]&name=pathinfo-' . $token, + '$' => 'index.php?' . $index_var . '=front&page_id=11', + $gate_rule => $gate_query, ), ); } - private static function check_parse_request_rewrite_and_precedence( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + private static function slug_for_context( \ComponentFuzz\FuzzContext $ctx, string $source ): string { + $slug = sanitize_title_with_dashes( $source ); + if ( '' === $slug ) { + $slug = 'item-' . $ctx->seed(); + } + + return $slug; + } + + private static function query_var_name( string $prefix, string $identifier ): string { + return $prefix . strtolower( str_replace( array( '-', ':' ), '_', $identifier ) ); + } + + private static function check_parse_request_rewrite_and_precedence( + \ComponentFuzz\FuzzContext $ctx, + array $case + ): array { $wp = self::new_wp(); - $wp->add_query_var( $case['publicVar'] ); + $wp->add_query_var( $case['rewriteVar'] ); $wp->add_query_var( $case['extraVar'] ); + $wp->add_query_var( $case['getVar'] ); + $wp->add_query_var( $case['postVar'] ); $wp->add_query_var( $case['tagVar'] ); + $wp->add_query_var( $case['taxonomyVar'] ); + self::set_rewrite_rules( $case['rules'] ); self::prepare_rewrite_globals(); - self::prepare_request_server( '/site-base/library/' . rawurlencode( $case['slug'] ) . '/?ignored=1', '' ); - $_GET = array( $case['extraVar'] => 'from-get' ); - $_POST = array(); + self::prepare_request_server( '/site-base/library/' . rawurlencode( $case['slug'] ) . '/term-source/?ignored=1', '' ); + self::set_request_superglobals( + array( + 'error' => '404', + $case['extraVar'] => 'from-get', + $case['getVar'] => 'from-get', + $case['privateLeakVar'] => 'get-leak', + 'fields' => 'get-fields', + 'offset' => 'get-offset', + ), + array( + $case['extraVar'] => 'from-post', + $case['postVar'] => 'from-post', + ) + ); $seen_request = array(); $request_filter = static function ( array $query_vars ) use ( &$seen_request ): array { @@ -159,30 +245,61 @@ private static function check_parse_request_rewrite_and_precedence( \ComponentFu add_filter( 'request', $request_filter ); add_action( 'parse_request', $action ); + $global_wp = array( + 'exists' => array_key_exists( 'wp', $GLOBALS ), + 'value' => $GLOBALS['wp'] ?? null, + ); + $GLOBALS['wp'] = $wp; + register_taxonomy( + $case['publicTaxonomy'], + 'post', + array( + 'public' => true, + 'publicly_queryable' => true, + 'query_var' => $case['taxonomyVar'], + 'rewrite' => false, + ) + ); try { $parsed = $wp->parse_request( array( $case['extraVar'] => 'from-extra', 'offset' => 7, + 'post_status' => 'publish', + $case['privateLeakVar'] => 'extra-leak', ) ); } finally { remove_filter( 'request', $request_filter ); remove_action( 'parse_request', $action ); + unregister_taxonomy( $case['publicTaxonomy'] ); + if ( $global_wp['exists'] ) { + $GLOBALS['wp'] = $global_wp['value']; + } else { + unset( $GLOBALS['wp'] ); + } } $ok = true === $parsed && true === $wp->did_permalink - && $case['rule'] === $wp->matched_rule - && $case['slug'] === ( $wp->query_vars[ $case['publicVar'] ] ?? null ) + && $case['libraryRule'] === $wp->matched_rule + && 'library/' . $case['slug'] . '/term-source' === $wp->request + && $case['slug'] === ( $wp->query_vars[ $case['rewriteVar'] ] ?? null ) && 'permalink value' === ( $wp->query_vars[ $case['tagVar'] ] ?? null ) + && 'taxonomy+value' === ( $wp->query_vars[ $case['taxonomyVar'] ] ?? null ) && 'from-extra' === ( $wp->query_vars[ $case['extraVar'] ] ?? null ) + && 'from-get' === ( $wp->query_vars[ $case['getVar'] ] ?? null ) + && 'from-post' === ( $wp->query_vars[ $case['postVar'] ] ?? null ) && 7 === ( $wp->query_vars['offset'] ?? null ) + && 'publish' === ( $wp->query_vars['post_status'] ?? null ) && '2' === ( $wp->query_vars['page'] ?? null ) + && ! isset( $wp->query_vars['post_type'], $wp->query_vars['fields'], $wp->query_vars[ $case['privateLeakVar'] ] ) + && ! isset( $_GET['error'] ) && 'yes' === ( $wp->query_vars['filtered_request'] ?? null ) && 1 === count( $seen_request ) && 1 === $action_count - && false === has_filter( 'request', $request_filter ); + && false === has_filter( 'request', $request_filter ) + && false === has_filter( 'parse_request', $action ); return $ctx->result( 'request-lifecycle.parse-request.rewrite-extra-precedence', @@ -194,14 +311,164 @@ private static function check_parse_request_rewrite_and_precedence( \ComponentFu 'matchedQuery' => $wp->matched_query, 'queryVars' => $wp->query_vars, 'actionCount' => $action_count, + 'seenRequest' => $seen_request, + ) + ); + } + + private static function check_parse_request_pathinfo_and_index( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $wp_pathinfo = self::new_wp(); + $wp_pathinfo->add_query_var( $case['pathinfoVar'] ); + + self::set_rewrite_rules( $case['rules'] ); + self::prepare_rewrite_globals(); + $pathinfo = '/site-base/pathinfo/' . rawurlencode( $case['pathinfoSlug'] ) . '/'; + self::prepare_request_server( '/site-base/index.php?ignored=1', $pathinfo ); + self::set_request_superglobals( array(), array() ); + $pathinfo_parsed = $wp_pathinfo->parse_request(); + + $wp_index = self::new_wp(); + $wp_index->add_query_var( $case['indexVar'] ); + + self::prepare_rewrite_globals(); + self::prepare_request_server( '/site-base/index.php', '' ); + self::set_request_superglobals( array(), array() ); + $index_parsed = $wp_index->parse_request(); + + $ok = true === $pathinfo_parsed + && false === $wp_pathinfo->did_permalink + && $case['pathinfoRule'] === $wp_pathinfo->matched_rule + && 'pathinfo/' . $case['pathinfoSlug'] === $wp_pathinfo->request + && $case['pathinfoSlug'] === ( $wp_pathinfo->query_vars[ $case['pathinfoVar'] ] ?? null ) + && 'pathinfo-' . $case['token'] === ( $wp_pathinfo->query_vars['name'] ?? null ) + && true === $index_parsed + && false === $wp_index->did_permalink + && '$' === $wp_index->matched_rule + && '' === $wp_index->request + && 'front' === ( $wp_index->query_vars[ $case['indexVar'] ] ?? null ) + && '11' === ( $wp_index->query_vars['page_id'] ?? null ) + && ! isset( $wp_index->query_vars['error'] ); + + return $ctx->result( + 'request-lifecycle.parse-request.pathinfo-index-front-controller', + $ok, + array( + 'pathinfo' => array( + 'request' => $wp_pathinfo->request, + 'matchedRule' => $wp_pathinfo->matched_rule, + 'matchedQuery' => $wp_pathinfo->matched_query, + 'queryVars' => $wp_pathinfo->query_vars, + ), + 'index' => array( + 'request' => $wp_index->request, + 'matchedRule' => $wp_index->matched_rule, + 'matchedQuery' => $wp_index->matched_query, + 'queryVars' => $wp_index->query_vars, + ), + ) + ); + } + + private static function check_parse_request_public_private_gates( + \ComponentFuzz\FuzzContext $ctx, + array $case + ): array { + $non_public_taxonomy = $case['privateTaxonomy']; + + $wp = self::new_wp(); + $wp->add_query_var( $case['filteredOutVar'] ); + + self::set_rewrite_rules( $case['rules'] ); + self::prepare_rewrite_globals(); + $gate_request = '/site-base/gated/' + . rawurlencode( $non_public_taxonomy ) + . '/' + . rawurlencode( $case['gateTerm'] ) + . '/'; + self::prepare_request_server( $gate_request, '' ); + self::set_request_superglobals( + array( + $case['filteredOutVar'] => 'from-get-denied', + $case['filterAddedVar'] => 'from-get-added', + $case['privateLeakVar'] => 'from-get-leak', + 'fields' => 'ids', + ), + array() + ); + + $query_vars_filter_count = 0; + $query_vars_filter = static function ( + array $public_query_vars + ) use ( &$query_vars_filter_count, $case ): array { + ++$query_vars_filter_count; + $public_query_vars = array_diff( $public_query_vars, array( $case['filteredOutVar'] ) ); + $public_query_vars[] = $case['filterAddedVar']; + return array_values( array_unique( $public_query_vars ) ); + }; + + add_filter( 'query_vars', $query_vars_filter ); + register_taxonomy( + $non_public_taxonomy, + 'post', + array( + 'public' => false, + 'publicly_queryable' => false, + 'query_var' => false, + 'rewrite' => false, + ) + ); + try { + $parsed = $wp->parse_request( + http_build_query( + array( + 'posts_per_page' => 3, + $case['filterAddedVar'] => 'from-extra-added', + $case['privateLeakVar'] => 'from-extra-leak', + ), + '', + '&' + ) + ); + } finally { + remove_filter( 'query_vars', $query_vars_filter ); + unregister_taxonomy( $non_public_taxonomy ); + } + + $ok = true === $parsed + && $case['gateRule'] === $wp->matched_rule + && 'from-extra-added' === ( $wp->query_vars[ $case['filterAddedVar'] ] ?? null ) + && '3' === ( $wp->query_vars['posts_per_page'] ?? null ) + && ! isset( $wp->query_vars[ $case['filteredOutVar'] ] ) + && ! isset( $wp->query_vars[ $case['privateLeakVar'] ] ) + && ! isset( $wp->query_vars['fields'] ) + && ! isset( $wp->query_vars['post_type'] ) + && ! isset( $wp->query_vars['taxonomy'], $wp->query_vars['term'] ) + && 1 === $query_vars_filter_count + && false === has_filter( 'query_vars', $query_vars_filter ); + + return $ctx->result( + 'request-lifecycle.parse-request.public-private-gates', + $ok, + array( + 'nonPublicTaxonomy' => $non_public_taxonomy, + 'matchedRule' => $wp->matched_rule, + 'matchedQuery' => $wp->matched_query, + 'queryVars' => $wp->query_vars, + 'filterCount' => $query_vars_filter_count, ) ); } private static function check_parse_request_short_circuit( \ComponentFuzz\FuzzContext $ctx ): array { $wp = self::new_wp(); + self::set_rewrite_rules( + array( + 'library/blocked/?$' => 'index.php?pagename=blocked', + ) + ); self::prepare_rewrite_globals(); self::prepare_request_server( '/site-base/library/blocked/', '' ); + self::set_request_superglobals( array(), array() ); $seen = array(); $filter = static function ( bool $parse, \WP $seen_wp, $extra_query_vars ) use ( &$seen, $wp ): bool { @@ -212,11 +479,25 @@ private static function check_parse_request_short_circuit( \ComponentFuzz\FuzzCo ); return false; }; + $request_count = 0; + $request_filter = static function ( array $query_vars ) use ( &$request_count ): array { + ++$request_count; + return $query_vars; + }; + $action_count = 0; + $action = static function () use ( &$action_count ): void { + ++$action_count; + }; + add_filter( 'do_parse_request', $filter, 10, 3 ); + add_filter( 'request', $request_filter ); + add_action( 'parse_request', $action ); try { $parsed = $wp->parse_request( array( 'p' => 123 ) ); } finally { remove_filter( 'do_parse_request', $filter, 10 ); + remove_filter( 'request', $request_filter ); + remove_action( 'parse_request', $action ); } return $ctx->result( @@ -227,10 +508,16 @@ private static function check_parse_request_short_circuit( \ComponentFuzz\FuzzCo && 1 === count( $seen ) && true === ( $seen[0]['parse'] ?? null ) && true === ( $seen[0]['same'] ?? null ) - && false === has_filter( 'do_parse_request', $filter ), + && 0 === $request_count + && 0 === $action_count + && false === has_filter( 'do_parse_request', $filter ) + && false === has_filter( 'request', $request_filter ) + && false === has_filter( 'parse_request', $action ), array( - 'seen' => $seen, - 'queryVars' => $wp->query_vars, + 'seen' => $seen, + 'queryVars' => $wp->query_vars, + 'requestCount' => $request_count, + 'actionCount' => $action_count, ) ); } @@ -238,14 +525,21 @@ private static function check_parse_request_short_circuit( \ComponentFuzz\FuzzCo private static function check_register_globals( \ComponentFuzz\FuzzContext $ctx, array $case ): array { $wp = self::new_wp(); $wp->query_vars = array( - $case['publicVar'] => $case['slug'], - 'p' => (string) $ctx->int( 100, 999 ), + $case['rewriteVar'] => $case['slug'], + 'p' => (string) $ctx->int( 100, 999 ), + 'empty_kept_out' => '', + 'array_discarded' => array( 'not-scalar' ), ); $wp->build_query_string(); $post = (object) array( 'ID' => (int) $wp->query_vars['p'], 'post_title' => 'Lifecycle Global ' . $case['slug'], + 'post_content' => 'bodycontinued', + ); + $second_post = (object) array( + 'ID' => (int) $wp->query_vars['p'] + 1, + 'post_title' => 'Lifecycle Global Follow-up', 'post_content' => 'body', ); @@ -255,35 +549,61 @@ private static function check_register_globals( \ComponentFuzz\FuzzContext $ctx, $GLOBALS['wp_query']->post = $post; $GLOBALS['wp_query']->request = 'SELECT component_fuzz'; $GLOBALS['wp_query']->is_single = true; + unset( $GLOBALS['authordata'] ); $wp->register_globals(); - $ok = $case['slug'] === ( $GLOBALS[ $case['publicVar'] ] ?? null ) + $GLOBALS['posts'][] = $second_post; + $posts_are_referenced = 2 === count( $GLOBALS['wp_query']->posts ) + && $second_post === $GLOBALS['wp_query']->posts[1]; + + $ok = $case['slug'] === ( $GLOBALS[ $case['rewriteVar'] ] ?? null ) && (string) $post->ID === ( $GLOBALS['p'] ?? null ) && $wp->query_string === ( $GLOBALS['query_string'] ?? null ) - && $GLOBALS['posts'] === array( $post ) + && str_contains( $wp->query_string, $case['rewriteVar'] . '=' . rawurlencode( $case['slug'] ) ) + && ! str_contains( $wp->query_string, 'empty_kept_out=' ) + && ! str_contains( $wp->query_string, 'array_discarded=' ) + && $posts_are_referenced && $GLOBALS['post'] === $post && 'SELECT component_fuzz' === ( $GLOBALS['request'] ?? null ) && 1 === ( $GLOBALS['more'] ?? null ) - && 1 === ( $GLOBALS['single'] ?? null ); + && 1 === ( $GLOBALS['single'] ?? null ) + && ! isset( $GLOBALS['authordata'] ); return $ctx->result( - 'request-lifecycle.register-globals.query-loop-exports', + 'request-lifecycle.register-globals.query-loop-exports-and-aliases', $ok, array( - 'queryString' => $wp->query_string, - 'globalPost' => is_object( $GLOBALS['post'] ?? null ) ? get_object_vars( $GLOBALS['post'] ) : null, + 'queryString' => $wp->query_string, + 'globalPost' => is_object( $GLOBALS['post'] ?? null ) ? get_object_vars( $GLOBALS['post'] ) : null, + 'postsAreReferenced' => $posts_are_referenced, + 'exportedQueryVarKeys' => array_keys( $GLOBALS['wp_query']->query_vars ), ) ); } private static function check_handle_404_transitions( \ComponentFuzz\FuzzContext $ctx ): array { - $statuses = array(); - $status_filter = static function ( string $status_header, int $code ) use ( &$statuses ): string { - $statuses[] = $code; + $statuses = array(); + $nocache_headers = array(); + $status_filter = static function ( + string $status_header, + int $code, + string $description, + string $protocol + ) use ( &$statuses ): string { + $statuses[] = array( + 'code' => $code, + 'description' => $description, + 'protocol' => $protocol, + ); return $status_header; }; - add_filter( 'status_header', $status_filter, 10, 2 ); + $nocache_filter = static function ( array $headers ) use ( &$nocache_headers ): array { + $nocache_headers[] = $headers; + return $headers; + }; + add_filter( 'status_header', $status_filter, 10, 4 ); + add_filter( 'nocache_headers', $nocache_filter ); $set_404_count = 0; $set_404_action = static function () use ( &$set_404_count ): void { @@ -306,6 +626,47 @@ private static function check_handle_404_transitions( \ComponentFuzz\FuzzContext $wp_200->handle_404(); $found_posts_404 = $GLOBALS['wp_query']->is_404(); + $GLOBALS['wp_query'] = new \WP_Query(); + $GLOBALS['wp_query']->posts = array( + self::make_post( + 51, + array( + 'post_content' => 'firstsecond', + ) + ), + ); + $GLOBALS['wp_query']->post = $GLOBALS['wp_query']->posts[0]; + $GLOBALS['wp_query']->is_single = true; + $GLOBALS['wp_query']->is_singular = true; + $wp_paged_valid = self::new_wp(); + $wp_paged_valid->query_vars = array( 'page' => '2' ); + $wp_paged_valid->handle_404(); + $paged_valid_404 = $GLOBALS['wp_query']->is_404(); + + $GLOBALS['wp_query'] = new \WP_Query(); + $GLOBALS['wp_query']->posts = array( + self::make_post( + 52, + array( + 'post_content' => 'single page only', + ) + ), + ); + $GLOBALS['wp_query']->post = $GLOBALS['wp_query']->posts[0]; + $GLOBALS['wp_query']->is_single = true; + $GLOBALS['wp_query']->is_singular = true; + $wp_paged_missing = self::new_wp(); + $wp_paged_missing->query_vars = array( 'page' => '2' ); + $wp_paged_missing->handle_404(); + $paged_missing_404 = $GLOBALS['wp_query']->is_404(); + + $GLOBALS['wp_query'] = new \WP_Query(); + $GLOBALS['wp_query']->posts = array(); + $GLOBALS['wp_query']->is_home = true; + $wp_home = self::new_wp(); + $wp_home->handle_404(); + $home_404 = $GLOBALS['wp_query']->is_404(); + $preempt_filter = static fn() => true; add_filter( 'pre_handle_404', $preempt_filter ); $GLOBALS['wp_query'] = new \WP_Query(); @@ -315,25 +676,37 @@ private static function check_handle_404_transitions( \ComponentFuzz\FuzzContext remove_filter( 'pre_handle_404', $preempt_filter ); } finally { remove_filter( 'status_header', $status_filter, 10 ); + remove_filter( 'nocache_headers', $nocache_filter ); remove_action( 'set_404', $set_404_action ); } + $status_codes = array_column( $statuses, 'code' ); $ok = true === $missing_posts_404 && false === $found_posts_404 + && false === $paged_valid_404 + && true === $paged_missing_404 + && false === $home_404 && false === $preempt_is_404 - && array( 404, 200 ) === $statuses - && 1 === $set_404_count - && false === has_filter( 'status_header', $status_filter ); + && array( 404, 200, 200, 404, 200 ) === $status_codes + && 2 === $set_404_count + && 2 === count( $nocache_headers ) + && false === has_filter( 'status_header', $status_filter ) + && false === has_filter( 'nocache_headers', $nocache_filter ) + && false === has_filter( 'set_404', $set_404_action ); return $ctx->result( - 'request-lifecycle.handle-404.status-transitions-and-preempt', + 'request-lifecycle.handle-404.status-branches-and-preempt', $ok, array( - 'statuses' => $statuses, - 'set404Count' => $set_404_count, - 'missingPosts404' => $missing_posts_404, - 'foundPosts404' => $found_posts_404, - 'preemptIs404' => $preempt_is_404, + 'statuses' => $statuses, + 'set404Count' => $set_404_count, + 'nocacheCount' => count( $nocache_headers ), + 'missingPosts404' => $missing_posts_404, + 'foundPosts404' => $found_posts_404, + 'pagedValid404' => $paged_valid_404, + 'pagedMissing404' => $paged_missing_404, + 'home404' => $home_404, + 'preemptIs404' => $preempt_is_404, ) ); } @@ -341,59 +714,139 @@ private static function check_handle_404_transitions( \ComponentFuzz\FuzzContext private static function check_send_headers_filters_and_actions( \ComponentFuzz\FuzzContext $ctx ): array { $captured_headers = array(); $statuses = array(); - $send_actions = 0; + $send_actions = array(); $headers_filter = static function ( array $headers, \WP $wp ) use ( &$captured_headers ): array { $captured_headers[] = array( + 'objectId' => spl_object_id( $wp ), 'headers' => $headers, 'queryVars' => $wp->query_vars, ); $headers['X-Component-Fuzz'] = 'request-lifecycle'; return $headers; }; - $status_filter = static function ( string $status_header, int $code ) use ( &$statuses ): string { - $statuses[] = $code; + $status_filter = static function ( + string $status_header, + int $code, + string $description, + string $protocol + ) use ( &$statuses ): string { + $statuses[] = array( + 'code' => $code, + 'description' => $description, + 'protocol' => $protocol, + ); return $status_header; }; - $send_action = static function () use ( &$send_actions ): void { - ++$send_actions; + $send_action = static function ( \WP $wp ) use ( &$send_actions ): void { + $send_actions[] = spl_object_id( $wp ); + }; + $last_modified_filter = static function () { + return self::FIXED_LAST_MODIFIED; }; add_filter( 'wp_headers', $headers_filter, 10, 2 ); - add_filter( 'status_header', $status_filter, 10, 2 ); - add_action( 'send_headers', $send_action ); + add_filter( 'status_header', $status_filter, 10, 4 ); + add_filter( 'pre_get_lastpostmodified', $last_modified_filter ); + add_action( 'send_headers', $send_action, 10, 1 ); try { $GLOBALS['wp_query'] = new \WP_Query(); $wp_html = self::new_wp(); $wp_html->query_vars = array(); + self::prepare_request_server( '/site-base/plain/', '' ); + self::set_request_superglobals( array(), array() ); $wp_html->send_headers(); $GLOBALS['wp_query'] = new \WP_Query(); $wp_error = self::new_wp(); $wp_error->query_vars = array( 'error' => 404 ); + self::prepare_request_server( '/site-base/missing/', '' ); + self::set_request_superglobals( array(), array() ); $wp_error->send_headers(); + + $GLOBALS['wp_query'] = new \WP_Query(); + $wp_moderation = self::new_wp(); + $wp_moderation->query_vars = array(); + self::prepare_request_server( '/site-base/comment-preview/', '' ); + self::set_request_superglobals( + array( + 'unapproved' => '1', + 'moderation-hash' => 'component-fuzz', + ), + array() + ); + $wp_moderation->send_headers(); + + $GLOBALS['wp_query'] = new \WP_Query(); + $wp_feed = self::new_wp(); + $wp_feed->query_vars = array( 'feed' => 'rss2' ); + self::prepare_request_server( '/site-base/feed/', '' ); + self::set_request_superglobals( array(), array() ); + $wp_feed->send_headers(); + + $GLOBALS['wp_query'] = new \WP_Query(); + $GLOBALS['wp_query']->post = self::make_post( + 77, + array( + 'ping_status' => 'open', + 'post_password' => 'secret', + ) + ); + $GLOBALS['wp_query']->posts = array( $GLOBALS['wp_query']->post ); + $GLOBALS['wp_query']->is_single = true; + $GLOBALS['wp_query']->is_singular = true; + $wp_singular = self::new_wp(); + $wp_singular->query_vars = array(); + self::prepare_request_server( '/site-base/singular/', '' ); + self::set_request_superglobals( array(), array() ); + $wp_singular->send_headers(); } finally { remove_filter( 'wp_headers', $headers_filter, 10 ); remove_filter( 'status_header', $status_filter, 10 ); - remove_action( 'send_headers', $send_action ); + remove_filter( 'pre_get_lastpostmodified', $last_modified_filter ); + remove_action( 'send_headers', $send_action, 10 ); } - $first_headers = $captured_headers[0]['headers'] ?? array(); - $second_headers = $captured_headers[1]['headers'] ?? array(); - $ok = 2 === count( $captured_headers ) - && 2 === $send_actions - && array( 404 ) === $statuses - && 'text/html; charset=UTF-8' === ( $first_headers['Content-Type'] ?? null ) - && 'text/html; charset=UTF-8' === ( $second_headers['Content-Type'] ?? null ) - && isset( $second_headers['Cache-Control'], $second_headers['Expires'] ) - && false === has_filter( 'wp_headers', $headers_filter ); + $html_headers = $captured_headers[0]['headers'] ?? array(); + $error_headers = $captured_headers[1]['headers'] ?? array(); + $moderation_headers = $captured_headers[2]['headers'] ?? array(); + $feed_headers = $captured_headers[3]['headers'] ?? array(); + $singular_headers = $captured_headers[4]['headers'] ?? array(); + $status_codes = array_column( $statuses, 'code' ); + $header_object_ids = array_column( $captured_headers, 'objectId' ); + $expected_feed_last_modified = mysql2date( 'D, d M Y H:i:s', self::FIXED_LAST_MODIFIED, false ) . ' GMT'; + $expected_feed_etag = '"' . md5( $expected_feed_last_modified ) . '"'; + + $ok = 5 === count( $captured_headers ) + && $header_object_ids === $send_actions + && array( 404 ) === $status_codes + && 'text/html; charset=UTF-8' === ( $html_headers['Content-Type'] ?? null ) + && 'text/html; charset=UTF-8' === ( $error_headers['Content-Type'] ?? null ) + && isset( $error_headers['Cache-Control'], $error_headers['Expires'] ) + && false === ( $error_headers['Last-Modified'] ?? null ) + && 'max-age=600, must-revalidate' === ( $moderation_headers['Cache-Control'] ?? null ) + && isset( $moderation_headers['Expires'] ) + && 'application/rss+xml; charset=UTF-8' === ( $feed_headers['Content-Type'] ?? null ) + && $expected_feed_last_modified === ( $feed_headers['Last-Modified'] ?? null ) + && $expected_feed_etag === ( $feed_headers['ETag'] ?? null ) + && 'text/html; charset=UTF-8' === ( $singular_headers['Content-Type'] ?? null ) + && self::SITE_URL . '/xmlrpc.php' === ( $singular_headers['X-Pingback'] ?? null ) + && isset( $singular_headers['Cache-Control'], $singular_headers['Expires'] ) + && false === has_filter( 'wp_headers', $headers_filter ) + && false === has_filter( 'status_header', $status_filter ) + && false === has_filter( 'pre_get_lastpostmodified', $last_modified_filter ) + && false === has_filter( 'send_headers', $send_action ); return $ctx->result( - 'request-lifecycle.send-headers.filters-status-and-actions', + 'request-lifecycle.send-headers.filters-status-actions-and-safe-branches', $ok, array( 'capturedHeaders' => $captured_headers, 'statuses' => $statuses, 'sendActions' => $send_actions, + 'expectedFeed' => array( + 'Last-Modified' => $expected_feed_last_modified, + 'ETag' => $expected_feed_etag, + ), ) ); } @@ -413,6 +866,10 @@ private static function prepare_rewrite_globals(): void { $GLOBALS['wp_rewrite']->index = 'index.php'; } + private static function set_rewrite_rules( array $rewrite_rules ): void { + self::$rewrite_rules = $rewrite_rules; + } + private static function prepare_request_server( string $request_uri, string $path_info ): void { $_SERVER['HTTP_HOST'] = 'example.test'; $_SERVER['PHP_SELF'] = '/site-base/index.php'; @@ -420,6 +877,36 @@ private static function prepare_request_server( string $request_uri, string $pat $_SERVER['REQUEST_URI'] = $request_uri; $_SERVER['PATH_INFO'] = $path_info; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + unset( $_SERVER['HTTP_IF_NONE_MATCH'], $_SERVER['HTTP_IF_MODIFIED_SINCE'] ); + } + + private static function set_request_superglobals( array $get, array $post ): void { + $_GET = $get; + $_POST = $post; + $_REQUEST = array_merge( $get, $post ); + } + + private static function make_post( int $id, array $overrides = array() ): \WP_Post { + return new \WP_Post( + (object) array_merge( + array( + 'ID' => $id, + 'post_author' => '0', + 'post_date' => '2024-02-03 04:05:06', + 'post_date_gmt' => '2024-02-03 04:05:06', + 'post_content' => 'component fuzz content', + 'post_title' => 'Component Fuzz Post ' . $id, + 'post_excerpt' => '', + 'post_status' => 'publish', + 'post_name' => 'component-fuzz-post-' . $id, + 'post_type' => 'post', + 'ping_status' => 'closed', + 'post_password' => '', + 'filter' => 'raw', + ), + $overrides + ) + ); } private static function install_option_filters( array $rewrite_rules ): void { @@ -428,6 +915,7 @@ private static function install_option_filters( array $rewrite_rules ): void { add_filter( 'pre_option_siteurl', array( self::class, 'filter_siteurl' ) ); add_filter( 'pre_option_blog_charset', array( self::class, 'filter_blog_charset' ) ); add_filter( 'pre_option_html_type', array( self::class, 'filter_html_type' ) ); + add_filter( 'pre_option_permalink_structure', array( self::class, 'filter_permalink_structure' ) ); add_filter( 'pre_option_rewrite_rules', array( self::class, 'filter_rewrite_rules' ) ); } @@ -436,6 +924,7 @@ private static function remove_option_filters(): void { remove_filter( 'pre_option_siteurl', array( self::class, 'filter_siteurl' ) ); remove_filter( 'pre_option_blog_charset', array( self::class, 'filter_blog_charset' ) ); remove_filter( 'pre_option_html_type', array( self::class, 'filter_html_type' ) ); + remove_filter( 'pre_option_permalink_structure', array( self::class, 'filter_permalink_structure' ) ); remove_filter( 'pre_option_rewrite_rules', array( self::class, 'filter_rewrite_rules' ) ); self::$rewrite_rules = array(); } @@ -468,7 +957,18 @@ private static function snapshot_state(): array { } $server = array(); - foreach ( array( 'HTTP_HOST', 'PHP_SELF', 'REQUEST_METHOD', 'REQUEST_URI', 'PATH_INFO', 'SERVER_PROTOCOL' ) as $name ) { + foreach ( + array( + 'HTTP_HOST', + 'PHP_SELF', + 'REQUEST_METHOD', + 'REQUEST_URI', + 'PATH_INFO', + 'SERVER_PROTOCOL', + 'HTTP_IF_NONE_MATCH', + 'HTTP_IF_MODIFIED_SINCE', + ) as $name + ) { $server[ $name ] = array( 'exists' => array_key_exists( $name, $_SERVER ), 'value' => $_SERVER[ $name ] ?? null, @@ -480,6 +980,8 @@ private static function snapshot_state(): array { 'server' => $server, 'get' => self::clone_value( $_GET ), 'post' => self::clone_value( $_POST ), + 'request' => self::clone_value( $_REQUEST ), + 'cookie' => self::clone_value( $_COOKIE ), ); } @@ -502,12 +1004,15 @@ private static function restore_state( array $snapshot ): void { $_GET = self::clone_value( $snapshot['get'] ); $_POST = self::clone_value( $snapshot['post'] ); + $_REQUEST = self::clone_value( $snapshot['request'] ); + $_COOKIE = self::clone_value( $snapshot['cookie'] ); } private static function state_matches( array $snapshot ): bool { $current = self::snapshot_state(); return $snapshot == $current && false === has_filter( 'pre_option_home', array( self::class, 'filter_home' ) ) + && false === has_filter( 'pre_option_permalink_structure', array( self::class, 'filter_permalink_structure' ) ) && false === has_filter( 'pre_option_rewrite_rules', array( self::class, 'filter_rewrite_rules' ) ); } From cafe61d53058d2e6b1e9b552ccfa0de7dc11bcfc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 21:01:44 +0200 Subject: [PATCH 0118/1102] Record request lifecycle fuzz progress --- tools/component-fuzz/dashboard.html | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tools/component-fuzz/dashboard.html b/tools/component-fuzz/dashboard.html index 1d6ee66cb2cce..f7d52059280f8 100644 --- a/tools/component-fuzz/dashboard.html +++ b/tools/component-fuzz/dashboard.html @@ -226,17 +226,17 @@

Component Fuzzers Project Dashboard

Latest Broad Run
100%
-
Non-skipped pass rate: 6706 passed, 0 failed, 0 errored, 63 skipped at 99 surfaces over 2 iterations.
+
Non-skipped pass rate: 6696 passed, 0 failed, 0 errored, 62 skipped at 99 surfaces over 2 iterations.
Current Focused Run
100%
-
hooks: 900 passed, 0 skipped, 0 failed at 100 iterations.
+
request-lifecycle: 800 passed, 0 skipped, 0 failed at 100 iterations.
Active Workers
-
5
-
Five delegated surfaces are active. Workers use priority/fast, gpt-5.5 xhigh.
+
2
+
Two delegated surfaces are active; two completed workers are pending integration. Workers use priority/fast, gpt-5.5 xhigh.
@@ -370,23 +370,23 @@

Current Work

html-api - worker active + pending integration Sagan: _work/component-fuzz-html-api-rich - Target: focused 100 iterations, smoke, standards, diff check, commit. + Worker focused run: 600 passed, 0 skipped, 0 failed; smoke and diff check passed. HTML Tag/Processor walking, bookmarks, mutation escaping, malformed recovery. syndication - worker active + pending integration Arendt: _work/component-fuzz-syndication-rich - Target: focused 100 iterations, smoke, standards, diff check, commit. + Worker focused run: 500 passed, 0 skipped, 0 failed; smoke and diff check passed. oEmbed providers/handlers, feed helpers, XML/HTML escaping, no-network filters. request-lifecycle - worker active - Lovelace: _work/component-fuzz-request-lifecycle-rich - Target: focused 100 iterations, smoke, standards, diff check, commit. + local validation passed + Main worktree + Main focused run: 800 passed, 0 skipped, 0 failed; broad run: 6696 passed, 62 skipped, 0 failed. Front-controller parsing, query vars, 404/header transitions, superglobal restoration. @@ -422,6 +422,12 @@

Recent Committed Additions

+ + 9c8eb1f52a + request-lifecycle + 800 passed, 0 skipped, 0 failed. + 6696 passed, 62 skipped, 0 failed at 2 iterations. + 7b5eeac0e3 hooks From 685951079a5d32778a5839a31c71f36bf3c1af94 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 21:03:44 +0200 Subject: [PATCH 0119/1102] Strengthen syndication fuzz surface --- tools/component-fuzz/README.md | 1 + .../surfaces/SyndicationSurface.php | 668 ++++++++++++++++-- 2 files changed, 611 insertions(+), 58 deletions(-) diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md index 93cc2b505788a..8031f39f9e8fc 100644 --- a/tools/component-fuzz/README.md +++ b/tools/component-fuzz/README.md @@ -369,6 +369,7 @@ database, network requests, or a configured site. schema/data merging, block style variation declarations, selectors, presets, custom properties, and no-DB global style guards. - `syndication`: oEmbed provider registration, embed handler lifecycle, oEmbed + wildcard/regex matching, cache-key lookup, no-network fetch short-circuits, HTML/XML filtering, feed metadata escaping, default feed normalization, self links, and Atom text construction. - `taxonomy`: taxonomy registration lifecycle, object-type associations, diff --git a/tools/component-fuzz/surfaces/SyndicationSurface.php b/tools/component-fuzz/surfaces/SyndicationSurface.php index c721e27fe4248..4baac09d41d30 100644 --- a/tools/component-fuzz/surfaces/SyndicationSurface.php +++ b/tools/component-fuzz/surfaces/SyndicationSurface.php @@ -31,10 +31,11 @@ public static function run( \ComponentFuzz\FuzzContext $ctx ): array { try { self::reset_runtime(); - $rows[] = self::check_embed_handler_lifecycle( $ctx ); - $rows[] = self::check_oembed_provider_registry( $ctx ); - $rows[] = self::check_oembed_output_filters( $ctx ); - $rows[] = self::check_feed_helpers( $ctx ); + $rows[] = self::check_embed_handler_lifecycle( $ctx->fork( 'embed-handlers' ) ); + $rows[] = self::check_oembed_provider_registry( $ctx->fork( 'providers' ) ); + $rows[] = self::check_oembed_output_filters( $ctx->fork( 'output-filters' ) ); + $rows[] = self::check_oembed_no_network_shortcuts( $ctx->fork( 'no-network' ) ); + $rows[] = self::check_feed_helpers( $ctx->fork( 'feed-helpers' ) ); } catch ( \Throwable $e ) { $rows[] = self::row( $ctx, @@ -79,6 +80,11 @@ public static function filter_home( $pre_option, string $option = '', $default_v return 'https://example.test'; } + public static function filter_blogdescription( $pre_option, string $option = '', $default_value = false ): string { + unset( $pre_option, $option, $default_value ); + return 'Feed summary RSS & '; + } + private static function missing_requirements(): array { $missing = array(); @@ -91,20 +97,28 @@ private static function missing_requirements(): array { foreach ( array( '_oembed_create_xml', + '_oembed_filter_feed_content', 'add_filter', 'feed_content_type', 'get_bloginfo_rss', 'get_default_feed', 'get_self_link', + 'has_filter', 'prep_atom_text_construct', 'remove_filter', 'self_link', + 'wp_cache_delete', + 'wp_cache_get', + 'wp_cache_set', 'wp_embed_defaults', 'wp_embed_register_handler', 'wp_embed_unregister_handler', 'wp_filter_oembed_iframe_title_attribute', 'wp_filter_oembed_result', + 'wp_maybe_load_embeds', + 'wp_oembed_get', 'wp_oembed_ensure_format', + 'wp_parse_args', ) as $function ) { if ( ! function_exists( $function ) ) { @@ -122,33 +136,105 @@ private static function check_embed_handler_lifecycle( \ComponentFuzz\FuzzContex $wp_embed = new \WP_Embed(); self::$handler_calls = array(); - $id = 'cfz_' . strtolower( $ctx->identifier( 4, 10 ) ); - $priority = $ctx->int( 1, 20 ); - $url = 'https://media.example.test/item/' . rawurlencode( $ctx->identifier( 3, 12 ) ); + $token = self::slug( $ctx, 4, 10 ); + $id = 'cfz_' . $token; + $neighbor_id = $id . '_neighbor'; + $fallback_id = $id . '_fallback'; + $priority = $ctx->int( 1, 20 ); + $fallback_priority = $priority + $ctx->int( 1, 10 ); + $url = 'https://media.example.test/item/' . rawurlencode( self::slug( $ctx, 3, 12 ) ); + $default_width = $ctx->int( 320, 880 ); + $default_height = $ctx->int( 180, 720 ); + $raw_width = $ctx->int( 240, 960 ); - \wp_embed_register_handler( $id, '~^https://media\.example\.test/item/([^/?#]+)~i', array( self::class, 'embed_handler' ), $priority ); - $html = $wp_embed->get_embed_handler_html( - array( - 'width' => $ctx->int( 240, 960 ), - ), - $url + $handler_filter_calls = array(); + $handler_filter = static function ( + string $return, + string $filtered_url, + array $attr + ) use ( &$handler_filter_calls, $token ): string { + $handler_filter_calls[] = array( + 'url' => $filtered_url, + 'attr' => $attr, + 'return' => $return, + ); + + return str_replace( '', '', $return ); + }; + + $defaults_calls = array(); + $defaults_filter = static function ( + array $size, + string $defaults_url + ) use ( &$defaults_calls, $default_width, $default_height ): array { + $defaults_calls[] = array( + 'size' => $size, + 'url' => $defaults_url, + ); + + return array( + 'width' => $default_width, + 'height' => $default_height, + ); + }; + + \add_filter( 'embed_handler_html', $handler_filter, 10, 3 ); + \add_filter( 'embed_defaults', $defaults_filter, 10, 2 ); + \wp_embed_register_handler( + $neighbor_id, + '~^https://media\.example\.test/other/([^/?#]+)~i', + array( self::class, 'embed_handler' ), + $priority ); + \wp_embed_register_handler( + $fallback_id, + '~^https://media\.example\.test/item/([^/?#]+)~i', + array( self::class, 'embed_handler' ), + $fallback_priority + ); + \wp_embed_register_handler( $id, '~^https://media\.example\.test/item/([^/?#]+)~i', array( self::class, 'embed_handler' ), $priority ); + + try { + $html = $wp_embed->get_embed_handler_html( + array( + 'width' => $raw_width, + ), + $url + ); + } finally { + \remove_filter( 'embed_handler_html', $handler_filter, 10 ); + \remove_filter( 'embed_defaults', $defaults_filter, 10 ); + } self::collect_failure( $failures, is_string( $html ) && 1 === count( self::$handler_calls ) + && 1 === count( $handler_filter_calls ) + && 1 === count( $defaults_calls ) && str_contains( $html, '\n
keep\nline
", + ), + $url + ); + } finally { + \remove_filter( 'oembed_dataparse', $dataparse_filter, 11 ); + } + self::collect_failure( $failures, is_string( $photo ) @@ -242,11 +450,19 @@ private static function check_oembed_provider_registry( \ComponentFuzz\FuzzConte && ! str_contains( strtolower( $photo ), 'javascript:' ) && str_contains( $photo, 'alt="Photo "Title" <tag>"' ) && is_string( $link ) - && str_contains( $link, 'Link <Title> & more' ), - 'WP_oEmbed data2html escapes photo/link fields and strips unsafe protocols', + && str_contains( $link, 'Link <Title> & more' ) + && is_string( $rich ) + && ! str_contains( $rich, "\n self::describe_string( is_string( $photo ) ? $photo : '' ), - 'link' => self::describe_string( is_string( $link ) ? $link : '' ), + 'photo' => self::describe_string( is_string( $photo ) ? $photo : '' ), + 'link' => self::describe_string( is_string( $link ) ? $link : '' ), + 'rich' => self::describe_string( is_string( $rich ) ? $rich : '' ), + 'dataparseCalls' => $dataparse_calls, + 'dataparseFilter' => \has_filter( 'oembed_dataparse', $dataparse_filter, 11 ), ) ); @@ -266,10 +482,13 @@ private static function check_oembed_output_filters( \ComponentFuzz\FuzzContext 'type' => 'video', 'title' => $title, ); - $html = '
bad
'; + $html = '
bad
' + . '' + . ''; $titled = \wp_filter_oembed_iframe_title_attribute( '', $data, $url ); $filtered = \wp_filter_oembed_result( $html, $data, $url ); + $rejected = \wp_filter_oembed_result( '
no iframe
', $data, $url ); self::collect_failure( $failures, @@ -281,11 +500,92 @@ private static function check_oembed_output_filters( \ComponentFuzz\FuzzContext && str_contains( $filtered, 'sandbox="allow-scripts"' ) && str_contains( $filtered, 'security="restricted"' ) && str_contains( $filtered, 'data-secret=' ) - && str_contains( $filtered, 'wp-embedded-content' ), - 'oEmbed filters add iframe titles and sandbox untrusted rich/video HTML', + && str_contains( $filtered, 'wp-embedded-content' ) + && false === $rejected, + 'oEmbed filters add iframe titles, sandbox untrusted rich/video HTML, and reject iframe-less payloads', array( 'title' => self::describe_string( is_string( $titled ) ? $titled : '' ), 'filtered' => self::describe_string( is_string( $filtered ) ? $filtered : '' ), + 'rejected' => $rejected, + ) + ); + + $existing_title = 'Existing "Title" ' . self::slug( $ctx, 3, 8 ); + $title_marker = 'filtered-' . self::slug( $ctx, 3, 8 ); + $title_calls = array(); + $title_filter = static function ( + string $candidate, + string $result, + object $filter_data, + string $source_url + ) use ( &$title_calls, $title_marker ): string { + $title_calls[] = array( + 'title' => $candidate, + 'result' => $result, + 'type' => $filter_data->type ?? null, + 'url' => $source_url, + ); + + return $candidate . ' ' . $title_marker; + }; + + \add_filter( 'oembed_iframe_title_attribute', $title_filter, 10, 4 ); + try { + $titled_existing = \wp_filter_oembed_iframe_title_attribute( + '', + $data, + $url + ); + } finally { + \remove_filter( 'oembed_iframe_title_attribute', $title_filter, 10 ); + } + + self::collect_failure( + $failures, + is_string( $titled_existing ) + && 1 === substr_count( strtolower( $titled_existing ), 'title=' ) + && str_contains( + $titled_existing, + 'title="' . \esc_attr( $existing_title . ' ' . $title_marker ) . '"' + ) + && 1 === count( $title_calls ) + && \esc_attr( $existing_title ) === $title_calls[0]['title'] + && false === \has_filter( 'oembed_iframe_title_attribute', $title_filter, 10 ), + 'oEmbed iframe title filter prefers existing titles and remains local to the check', + array( + 'titledExisting' => self::describe_string( is_string( $titled_existing ) ? $titled_existing : '' ), + 'titleCalls' => $title_calls, + 'filterActive' => \has_filter( 'oembed_iframe_title_attribute', $title_filter, 10 ), + ) + ); + + $trusted_html = '
'; + $trusted_filtered = \wp_filter_oembed_result( + $trusted_html, + (object) array( + 'type' => 'video', + 'title' => 'Trusted', + ), + 'https://www.youtube.com/watch?v=' . rawurlencode( self::slug( $ctx, 4, 12 ) ) + ); + $feed_iframe = '' + . ''; + $feed_filtered_iframe = \_oembed_filter_feed_content( $feed_iframe ); + + self::collect_failure( + $failures, + $trusted_html === $trusted_filtered + && is_string( $feed_filtered_iframe ) + && str_contains( $feed_filtered_iframe, 'class="wp-embedded-content"' ) + && ! str_contains( $feed_filtered_iframe, 'visibility:hidden' ) + && str_contains( $feed_filtered_iframe, 'style="kept"' ), + 'trusted provider HTML is left untouched while feed iframe filtering only removes embedded-content styles', + array( + 'trustedFiltered' => self::describe_string( is_string( $trusted_filtered ) ? $trusted_filtered : '' ), + 'feedFiltered' => self::describe_string( is_string( $feed_filtered_iframe ) ? $feed_filtered_iframe : '' ), ) ); @@ -309,8 +609,11 @@ private static function check_oembed_output_filters( \ComponentFuzz\FuzzContext && str_contains( $xml, '' ) && str_contains( $xml, '' ) && str_contains( $xml, 'first' ) - && str_contains( $xml, 'A&B < C' ), - '_oembed_create_xml serializes nested arrays and escapes text nodes', + && str_contains( $xml, 'A&B < C' ) + && false === \_oembed_create_xml( array() ) + && 'json' === \wp_oembed_ensure_format( 'jsonp' ) + && 'xml' === \wp_oembed_ensure_format( 'xml' ), + '_oembed_create_xml serializes nested arrays, escapes text nodes, and rejects empty payloads', array( 'xml' => self::describe_string( is_string( $xml ) ? $xml : '' ) ) ); @@ -322,22 +625,189 @@ private static function check_oembed_output_filters( \ComponentFuzz\FuzzContext ); } + private static function check_oembed_no_network_shortcuts( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = self::slug( $ctx, 4, 12 ); + $url = 'https://shortcircuit-' . $token . '.example.test/watch/' . rawurlencode( self::slug( $ctx, 3, 8 ) ); + $fixture = '
' . \esc_html( $ctx->text( 0, 24 ) ) . '
'; + $width = $ctx->int( 280, 960 ); + $height = $ctx->int( 180, 720 ); + + $pre_calls = array(); + $http_calls = array(); + $pre_filter = static function ( $pre, string $source_url, $args ) use ( &$pre_calls, $url, $fixture ) { + $pre_calls[] = array( + 'pre' => $pre, + 'url' => $source_url, + 'args' => $args, + ); + + if ( $source_url === $url ) { + return $fixture; + } + + return $pre; + }; + $http_filter = static function ( $preempt, array $parsed_args, string $request_url ) use ( &$http_calls ) { + $http_calls[] = array( + 'preempt' => $preempt, + 'args' => $parsed_args, + 'url' => $request_url, + ); + + return new \WP_Error( 'component_fuzz_no_network', 'Syndication fuzzing blocks live oEmbed HTTP requests.' ); + }; + + \add_filter( 'pre_oembed_result', $pre_filter, 10, 3 ); + \add_filter( 'pre_http_request', $http_filter, 10, 3 ); + try { + $result = \wp_oembed_get( + $url, + array( + 'width' => $width, + 'height' => $height, + 'discover' => true, + ) + ); + } finally { + \remove_filter( 'pre_oembed_result', $pre_filter, 10 ); + \remove_filter( 'pre_http_request', $http_filter, 10 ); + } + + self::collect_failure( + $failures, + $fixture === $result + && 1 === count( $pre_calls ) + && array() === $http_calls + && false === \has_filter( 'pre_oembed_result', $pre_filter, 10 ) + && false === \has_filter( 'pre_http_request', $http_filter, 10 ), + 'pre_oembed_result short-circuits wp_oembed_get before provider discovery or HTTP', + array( + 'result' => self::describe_string( is_string( $result ) ? $result : '' ), + 'preCalls' => $pre_calls, + 'httpCalls' => $http_calls, + 'preFilterActive' => \has_filter( 'pre_oembed_result', $pre_filter, 10 ), + 'httpFilterActive' => \has_filter( 'pre_http_request', $http_filter, 10 ), + ) + ); + + $provider_url = 'https://oembed-no-network.example.test/endpoint?token=' . rawurlencode( $token ); + $target_url = 'https://target-' . $token . '.example.test/watch/' + . rawurlencode( self::slug( $ctx, 4, 12 ) ) + . '?a=1&b=' . rawurlencode( $ctx->text( 0, 10 ) ); + $fetch_width = $ctx->int( 320, 1280 ); + $fetch_height = $ctx->int( 180, 900 ); + $fetch_calls = array(); + $fetch_filter = static function ( string $fetch_url, string $source_url, array $args ) use ( &$fetch_calls ): string { + $fetch_calls[] = array( + 'fetchUrl' => $fetch_url, + 'url' => $source_url, + 'args' => $args, + ); + + return $fetch_url; + }; + $fetch_http = array(); + $fetch_blocker = static function ( $preempt, array $parsed_args, string $request_url ) use ( &$fetch_http ) { + $fetch_http[] = array( + 'preempt' => $preempt, + 'args' => $parsed_args, + 'url' => $request_url, + ); + + return new \WP_Error( 'component_fuzz_no_network', 'Syndication fuzzing blocks live oEmbed fetch HTTP requests.' ); + }; + $oembed = new \WP_oEmbed(); + + \add_filter( 'oembed_fetch_url', $fetch_filter, 10, 3 ); + \add_filter( 'pre_http_request', $fetch_blocker, 10, 3 ); + try { + $fetched = $oembed->fetch( + $provider_url, + $target_url, + array( + 'width' => $fetch_width, + 'height' => $fetch_height, + ) + ); + } finally { + \remove_filter( 'oembed_fetch_url', $fetch_filter, 10 ); + \remove_filter( 'pre_http_request', $fetch_blocker, 10 ); + } + + $query = array(); + if ( isset( $fetch_http[0]['url'] ) ) { + parse_str( (string) parse_url( $fetch_http[0]['url'], PHP_URL_QUERY ), $query ); + } + + self::collect_failure( + $failures, + false === $fetched + && 1 === count( $fetch_calls ) + && 1 === count( $fetch_http ) + && (string) $fetch_width === (string) ( $query['maxwidth'] ?? null ) + && (string) $fetch_height === (string) ( $query['maxheight'] ?? null ) + && $target_url === ( $query['url'] ?? null ) + && '1' === (string) ( $query['dnt'] ?? null ) + && 'json' === ( $query['format'] ?? null ) + && false === \has_filter( 'oembed_fetch_url', $fetch_filter, 10 ) + && false === \has_filter( 'pre_http_request', $fetch_blocker, 10 ), + 'WP_oEmbed::fetch generates provider query arguments before the local HTTP blocker fails closed', + array( + 'fetched' => $fetched, + 'fetchCalls' => $fetch_calls, + 'httpCalls' => $fetch_http, + 'query' => $query, + 'fetchFilterActive' => \has_filter( 'oembed_fetch_url', $fetch_filter, 10 ), + 'httpFilterActive' => \has_filter( 'pre_http_request', $fetch_blocker, 10 ), + ) + ); + + return self::row( + $ctx, + 'syndication.oembed.short-circuited-no-network', + array() === $failures, + array( 'failures' => $failures ) + ); + } + private static function check_feed_helpers( \ComponentFuzz\FuzzContext $ctx ): array { $failures = array(); \add_filter( 'pre_option_blogname', array( self::class, 'filter_blogname' ), 10, 3 ); \add_filter( 'pre_option_home', array( self::class, 'filter_home' ), 10, 3 ); + \add_filter( 'pre_option_blogdescription', array( self::class, 'filter_blogdescription' ), 10, 3 ); try { - $bloginfo = \get_bloginfo_rss( 'name' ); - $_SERVER['REQUEST_URI'] = '/feed/?q=' . rawurlencode( $ctx->text( 0, 16 ) ); - $self_link = \get_self_link(); + $bloginfo = \get_bloginfo_rss( 'name' ); + $blogdescription = \get_bloginfo_rss( 'description' ); + $_SERVER['HTTP_HOST'] = 'attacker.example.test'; + $_SERVER['REQUEST_URI'] = '/feed/' + . rawurlencode( self::slug( $ctx, 3, 12 ) ) + . '/?q=' . rawurlencode( $ctx->text( 0, 16 ) ) + . '&danger=' + . '' + . 'T & C', + ), + array( + 'token' => $token, + 'profile' => 'normalize-svg-foreignobject', + 'mode' => self::MODE_FRAGMENT, + 'expectSupported' => true, + 'html' => 'svg

' . $text . '

', + ), + array( + 'token' => $token, + 'profile' => 'normalize-math-annotation', + 'mode' => self::MODE_FRAGMENT, + 'expectSupported' => true, + 'html' => 'x

' . $text . '

', + ), + array( + 'token' => $token, + 'profile' => 'normalize-full-document', + 'mode' => self::MODE_FULL_DOCUMENT, + 'expectSupported' => true, + 'html' => '' + . esc_html( $token ) + . '

' . $text + . '
cell
', + ), + ); + } + + private static function recovery_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $token = self::safe_token( $ctx, 'recovery' ); + + return array( + array( + 'token' => $token, + 'profile' => 'recovery-balanced', + 'mode' => self::MODE_FRAGMENT, + 'expectSupported' => true, + 'html' => '

  • one
  • two
    cell

    tail', + ), + array( + 'token' => $token, + 'profile' => 'recovery-comments-doctype-bogus', + 'mode' => self::MODE_FRAGMENT, + 'expectSupported' => true, + 'html' => '' + . '' + . '' + . '' + . 'T & C' + . 'svg title

    html child

    ' + . 'x

    math html

    ' + . '

    final ' . esc_html( $token ) . '

    ' + . ''; + + return array( + array( + 'token' => $token, + 'profile' => 'boundary-rawtext-comment-namespace', + 'mode' => self::MODE_FRAGMENT, + 'html' => $html, + ), + ); + } + + private static function attribute_update_html( + \ComponentFuzz\FuzzContext $ctx, + int $index, + string $href, + string $text, + string $tail + ): string { $wrapper = $ctx->choice( array( 'article', 'section', 'div', 'main' ) ); $inline = $ctx->choice( array( 'strong', 'em', 'span', 'b' ) ); $list = $ctx->bool() @@ -270,13 +565,1192 @@ private static function case_html( \ComponentFuzz\FuzzContext $ctx, int $index, return '<' . $wrapper . ' data-wrapper="' . $index . '">' . '

    Lead ' . esc_html( $text ) . ' <' . $inline . '>inline

    ' - . 'Link ' . esc_html( $tail ) . '' - . '
    ' . esc_attr( $tail ) . '
    ' . esc_html( $text ) . '
    ' + . 'Link ' . esc_html( $tail ) . '' + . '
    ' . esc_attr( $tail ) . '
    ' + . esc_html( $text ) + . '
    ' . $list . '' . ''; } + private static function rich_case_fragment( + \ComponentFuzz\FuzzContext $ctx, + int $index, + string $href, + string $text, + string $tail, + string $text_marker, + string $generated + ): string { + $wrapper = $ctx->choice( array( 'article', 'section', 'div', 'main' ) ); + $inline = $ctx->choice( array( 'strong', 'em', 'span', 'b' ) ); + + return '<' . $wrapper . ' data-wrapper="' . $index . '">' + . '

    Lead ' . esc_html( $text ) . ' <' . $inline . '>inline

    ' + . '

    ' . esc_html( $text_marker ) . '

    ' + . '' + . 'Link ' . esc_html( $tail ) . '' + . '
    ' . esc_attr( $tail ) . '
    ' + . esc_html( $text ) + . '
    ' + . $generated + . '' + . ''; + } + + private static function depth_for_profile( \ComponentFuzz\FuzzContext $ctx, string $profile ): int { + if ( 'incomplete-malformed' === $profile ) { + return $ctx->int( 2, 4 ); + } + + if ( in_array( $profile, array( 'tables', 'foreign-content', 'formatting-adoption' ), true ) ) { + return $ctx->int( 3, 5 ); + } + + return $ctx->int( 2, 4 ); + } + + private static function generated_nodes( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + $count = $depth > 3 ? $ctx->int( 1, 3 ) : $ctx->int( 2, 5 ); + $out = ''; + + for ( $i = 0; $i < $count; ++$i ) { + $out .= self::generated_node( $ctx->fork( 'node-' . $depth . '-' . $i ), $profile, $depth ); + if ( strlen( $out ) > 12000 ) { + return substr( $out, 0, 12000 ); + } + } + + return $out; + } + + private static function generated_node( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + if ( $depth <= 0 ) { + return $ctx->bool( 70 ) ? self::terminal_text( $ctx ) : self::comment( $ctx ); + } + + $kind = $ctx->weightedChoice( self::node_weights( $profile ) ); + switch ( $kind ) { + case 'text': + return self::terminal_text( $ctx ); + + case 'comment': + return self::comment( $ctx ); + + case 'void': + return '<' . $ctx->choice( array( 'br', 'hr', 'img', 'input', 'source', 'wbr' ) ) + . self::attrs( $ctx, $profile ) + . ( $ctx->bool( 25 ) ? '/>' : '>' ); + + case 'raw': + return self::raw_element( $ctx ); + + case 'template': + return '' + . self::generated_nodes( $ctx->fork( 'template' ), $profile, $depth - 1 ) + . ( $ctx->bool( 80 ) ? '' : '' ); + + case 'table': + return self::table_markup( $ctx, $profile, $depth - 1 ); + + case 'select': + return self::select_markup( $ctx, $profile, $depth - 1 ); + + case 'foreign': + return self::foreign_markup( $ctx, $profile, $depth - 1 ); + + case 'adoption': + return self::adoption_markup( $ctx ); + + case 'auto-close': + return self::auto_closing_markup( $ctx ); + + case 'bogus': + return self::bogus_markup( $ctx ); + + case 'weird': + return self::weird_element( $ctx, $profile, $depth - 1 ); + + case 'element': + default: + return self::normal_element( $ctx, $profile, $depth - 1 ); + } + } + + private static function node_weights( string $profile ): array { + $weights = array( + array( 30, 'element' ), + array( 13, 'text' ), + array( 8, 'comment' ), + array( 8, 'void' ), + array( 7, 'raw' ), + array( 6, 'template' ), + array( 6, 'table' ), + array( 4, 'select' ), + array( 7, 'foreign' ), + array( 3, 'adoption' ), + array( 3, 'auto-close' ), + array( 3, 'bogus' ), + array( 2, 'weird' ), + ); + + switch ( $profile ) { + case 'tables': + return array_merge( $weights, array( array( 34, 'table' ), array( 10, 'auto-close' ) ) ); + case 'template': + return array_merge( $weights, array( array( 34, 'template' ) ) ); + case 'select': + return array_merge( $weights, array( array( 34, 'select' ), array( 8, 'table' ) ) ); + case 'foreign-content': + return array_merge( $weights, array( array( 36, 'foreign' ) ) ); + case 'rawtext-rcdata': + return array_merge( $weights, array( array( 36, 'raw' ) ) ); + case 'attributes-entities': + return array_merge( $weights, array( array( 24, 'element' ), array( 14, 'weird' ), array( 10, 'text' ) ) ); + case 'comments-doctype-bogus': + return array_merge( $weights, array( array( 24, 'comment' ), array( 18, 'bogus' ) ) ); + case 'formatting-adoption': + return array_merge( $weights, array( array( 34, 'adoption' ), array( 12, 'auto-close' ) ) ); + case 'incomplete-malformed': + return array_merge( $weights, array( array( 24, 'bogus' ), array( 20, 'weird' ), array( 8, 'auto-close' ) ) ); + default: + return $weights; + } + } + + private static function normal_element( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + $tag = $ctx->choice( + array( + 'div', + 'p', + 'span', + 'section', + 'article', + 'header', + 'footer', + 'a', + 'b', + 'i', + 'em', + 'strong', + 'code', + 'pre', + 'blockquote', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', + 'h1', + 'h2', + 'button', + 'form', + 'label', + ) + ); + $close = $ctx->bool( 'incomplete-malformed' === $profile ? 55 : 85 ); + + return '<' . $tag . self::attrs( $ctx, $profile ) . '>' + . self::generated_nodes( $ctx->fork( 'children' ), $profile, $depth ) + . ( $close ? 'bool( 90 ) ? $tag : $ctx->choice( array( 'div', 'span', 'p', 'section' ) ) ) . '>' : '' ); + } + + private static function weird_element( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + $tag = $ctx->choice( array( 'x-widget', 'foo:bar', 'foo.bar', 'foo_bar', 'MiXeD-Custom', 'x-0' ) ); + if ( $ctx->bool( 35 ) ) { + $tag = $ctx->choice( array( '1bad', '?pi', '!not', '=bad', '"bad' ) ); + } + + $gap = $ctx->choice( array( ' ', "\t", "\n", "\f", "\r\n", ' ' ) ); + return '<' . $tag . $gap . self::attrs( $ctx, $profile ) + . ( $ctx->bool( 20 ) ? '/' . $gap : '' ) + . '>' + . self::generated_nodes( $ctx->fork( 'weird-children' ), $profile, max( 0, $depth ) ) + . ( $ctx->bool( 65 ) ? '' : '' ); + } + + private static function table_markup( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + $rows = ''; + for ( $r = 0; $r < $ctx->int( 1, 3 ); ++$r ) { + $cells = ''; + for ( $c = 0; $c < $ctx->int( 1, 3 ); ++$c ) { + $cell = $ctx->choice( array( 'td', 'th' ) ); + $cells .= '<' . $cell . self::attrs( $ctx->fork( 'cell-' . $r . '-' . $c ), $profile ) . '>' + . self::generated_nodes( $ctx->fork( 'cell-children-' . $r . '-' . $c ), $profile, max( 0, $depth - 1 ) ) + . ( $ctx->bool( 80 ) ? '' : '' ); + } + $rows .= 'fork( 'row-' . $r ), $profile ) . '>' . $cells . ( $ctx->bool( 80 ) ? '' : '' ); + } + + if ( $ctx->bool( 50 ) ) { + $section_tag = $ctx->choice( array( 'tbody', 'thead', 'tfoot' ) ); + $section = '<' . $section_tag . '>' . $rows . 'choice( array( 'tbody', 'thead', 'tfoot' ) ) . '>'; + } else { + $section = $rows; + } + + $noise = $ctx->bool( 45 ) + ? self::terminal_text( $ctx->fork( 'foster-text' ) ) + . self::normal_element( $ctx->fork( 'foster-element' ), $profile, max( 0, $depth - 1 ) ) + : ''; + + return '' . $noise . $section . ( $ctx->bool( 82 ) ? '' : '' ); + } + + private static function select_markup( \ComponentFuzz\FuzzContext $ctx, string $profile, int $depth ): string { + $out = ''; + for ( $i = 0; $i < $ctx->int( 2, 5 ); ++$i ) { + $kind = $ctx->weightedChoice( + array( + array( 42, 'option' ), + array( 22, 'optgroup' ), + array( 18, 'breaker' ), + array( 8, 'nested-select' ), + array( 10, 'other' ), + ) + ); + + if ( 'option' === $kind ) { + $out .= 'fork( 'option-' . $i ), $profile ) + . '>' + . self::terminal_text( $ctx->fork( 'option-text-' . $i ) ) + . ( $ctx->bool( 60 ) ? '' : '' ); + } elseif ( 'optgroup' === $kind ) { + $out .= 'fork( 'optgroup-' . $i ), $profile ) + . '>