diff --git a/src/wp-admin/includes/class-wp-application-passwords-list-table.php b/src/wp-admin/includes/class-wp-application-passwords-list-table.php index 9a60853016fc5..976c162058983 100644 --- a/src/wp-admin/includes/class-wp-application-passwords-list-table.php +++ b/src/wp-admin/includes/class-wp-application-passwords-list-table.php @@ -97,7 +97,7 @@ public function column_last_ip( $item ) { if ( empty( $item['last_ip'] ) ) { echo '—'; } else { - echo $item['last_ip']; + echo esc_html( $item['last_ip'] ); } } diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php index f6c6dea441d1c..f3496ac751758 100644 --- a/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php @@ -216,7 +216,7 @@ private function create_psr_response( array $wp_response ): ResponseInterface { } } - if ( ! empty( $body ) ) { + if ( '' !== $body ) { $stream = $this->stream_factory->createStream( $body ); $response = $response->withBody( $stream ); } diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index bb04f7767c171..93e5b54c1c165 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -343,8 +343,18 @@ function _get_block_template_file( $template_type, $slug ) { ); foreach ( $themes as $theme_slug => $theme_dir ) { $template_base_paths = get_block_theme_folders( $theme_slug ); - $file_path = $theme_dir . '/' . $template_base_paths[ $template_type ] . '/' . $slug . '.html'; - if ( file_exists( $file_path ) ) { + $template_base_path = $theme_dir . '/' . $template_base_paths[ $template_type ]; + $file_path = $template_base_path . '/' . $slug . '.html'; + $real_base_path = realpath( $template_base_path ); + $real_file_path = realpath( $file_path ); + if ( + false !== $real_base_path && + false !== $real_file_path && + str_starts_with( + wp_normalize_path( $real_file_path ), + trailingslashit( wp_normalize_path( $real_base_path ) ) + ) + ) { $new_template_item = array( 'slug' => $slug, 'path' => $file_path, diff --git a/src/wp-includes/class-wp-block-metadata-registry.php b/src/wp-includes/class-wp-block-metadata-registry.php index b6d0416d5fc5e..bdce6ae17a94c 100644 --- a/src/wp-includes/class-wp-block-metadata-registry.php +++ b/src/wp-includes/class-wp-block-metadata-registry.php @@ -82,7 +82,7 @@ class WP_Block_Metadata_Registry { * @return bool True if the collection was registered successfully, false otherwise. */ public static function register_collection( $path, $manifest ) { - $path = rtrim( wp_normalize_path( $path ), '/' ); + $path = self::normalize_collection_path( $path ); $collection_roots = self::get_default_collection_roots(); @@ -112,7 +112,7 @@ public static function register_collection( $path, $manifest ) { $collection_roots = array_unique( array_map( static function ( $allowed_root ) { - return rtrim( wp_normalize_path( $allowed_root ), '/' ); + return self::normalize_collection_path( $allowed_root ); }, $collection_roots ) @@ -161,7 +161,7 @@ static function ( $allowed_root ) { * @return array|null The block metadata for the block, or null if not found. */ public static function get_metadata( $file_or_folder ) { - $file_or_folder = wp_normalize_path( $file_or_folder ); + $file_or_folder = self::normalize_collection_path( $file_or_folder ); $path = self::find_collection_path( $file_or_folder ); if ( ! $path ) { @@ -196,7 +196,7 @@ public static function get_metadata( $file_or_folder ) { * @return string[] List of block metadata file paths, or an empty array if the given `$path` is invalid. */ public static function get_collection_block_metadata_files( $path ) { - $path = rtrim( wp_normalize_path( $path ), '/' ); + $path = self::normalize_collection_path( $path ); if ( ! isset( self::$collections[ $path ] ) ) { _doing_it_wrong( @@ -223,6 +223,56 @@ static function ( $block_name ) use ( $path ) { ); } + /** + * Normalizes a collection path and collapses dot segments. + * + * @since 7.1.0 + * + * @param string $path File or directory path. + * @return string Normalized path without trailing slashes. + */ + private static function normalize_collection_path( $path ) { + $path = wp_normalize_path( $path ); + + $prefix = ''; + if ( preg_match( '#^([A-Za-z][A-Za-z0-9+.-]*://)(.*)$#', $path, $matches ) ) { + $prefix = $matches[1]; + $path = $matches[2]; + } elseif ( str_starts_with( $path, '//' ) ) { + $prefix = '//'; + $path = substr( $path, 2 ); + } + + $drive = ''; + if ( preg_match( '#^[A-Za-z]:#', $path, $matches ) ) { + $drive = $matches[0]; + $path = substr( $path, 2 ); + } + + $is_absolute = str_starts_with( $path, '/' ); + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + if ( array() !== $parts && '..' !== end( $parts ) ) { + array_pop( $parts ); + continue; + } + if ( ! $is_absolute ) { + $parts[] = $part; + } + continue; + } + $parts[] = $part; + } + + $normalized = ( $is_absolute ? '/' : '' ) . implode( '/', $parts ); + return $prefix . rtrim( $drive . $normalized, '/' ); + } + /** * Finds the collection path for a given file or folder. * @@ -238,13 +288,13 @@ private static function find_collection_path( $file_or_folder ) { // Check the last matched collection first, since block registration usually happens in batches per plugin or theme. $path = rtrim( $file_or_folder, '/' ); - if ( self::$last_matched_collection && str_starts_with( $path, self::$last_matched_collection ) ) { + if ( self::$last_matched_collection && self::is_same_or_child_path( $path, self::$last_matched_collection ) ) { return self::$last_matched_collection; } $collection_paths = array_keys( self::$collections ); foreach ( $collection_paths as $collection_path ) { - if ( str_starts_with( $path, $collection_path ) ) { + if ( self::is_same_or_child_path( $path, $collection_path ) ) { self::$last_matched_collection = $collection_path; return $collection_path; } @@ -252,6 +302,19 @@ private static function find_collection_path( $file_or_folder ) { return null; } + /** + * Checks whether a path is equal to or inside another path. + * + * @since 7.1.0 + * + * @param string $path Normalized path. + * @param string $base_path Normalized base path. + * @return bool True if the path is the base path or a child path, false otherwise. + */ + private static function is_same_or_child_path( $path, $base_path ) { + return $path === $base_path || str_starts_with( $path, $base_path . '/' ); + } + /** * Checks if metadata exists for a given block name in a specific collection. * @@ -317,7 +380,7 @@ private static function is_valid_collection_path( $path, $collection_roots ) { } // If the path is a parent path of any of the roots, it is invalid. - if ( str_starts_with( $allowed_root, $path ) ) { + if ( self::is_same_or_child_path( $allowed_root, $path ) ) { return false; } } diff --git a/src/wp-includes/class-wp-email-address.php b/src/wp-includes/class-wp-email-address.php index fd4f0ef8937ba..e575d0df3bc2d 100644 --- a/src/wp-includes/class-wp-email-address.php +++ b/src/wp-includes/class-wp-email-address.php @@ -17,9 +17,9 @@ * * Example: * - * $email = WP_Email_Address::from_string( 'wordpress@wordpress.org' ); - * 'wordpress' === $email->get_local_part(); - * 'wordpress.org' === $email->get_domain(); + * $email = WP_Email_Address::from_string( 'user@example.org' ); + * 'user' === $email->get_local_part(); + * 'example.org' === $email->get_domain(); * * @see self::from_string() to parse and validate a provided email address. * @see self::get_localpart() for the local part or mailbox of the address. @@ -288,19 +288,31 @@ public static function from_string( string $input, string $character_set = 'unic } } - // The domain must contain at least one dot. - if ( ! str_contains( $ascii_domain, '.' ) ) { - /** This filter is documented in wp-includes/formatting.php */ - if ( ! apply_filters( 'is_email', false, $input, 'domain_no_periods' ) ) { - return null; - } - } - // Validate the domain against the allowed structure. if ( 1 !== preg_match( $domain_pattern, $decoded_domain ) ) { return null; } + if ( $allow_unicode && 1 === preg_match( '/[\x80-\xff]/', $decoded_domain ) ) { + if ( ! function_exists( 'idn_to_ascii' ) ) { + return null; + } + + $encoded_labels = array(); + foreach ( explode( '.', $decoded_domain ) as $label ) { + $encoded_label = 1 === preg_match( '/[\x80-\xff]/', $label ) + ? idn_to_ascii( $label, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ) + : $label; + + if ( false === $encoded_label || strlen( $encoded_label ) > 63 ) { + return null; + } + + $encoded_labels[] = $encoded_label; + } + $ascii_domain = implode( '.', $encoded_labels ); + } + return new self( $localpart, $ascii_domain, $decoded_domain ); } diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index 6753231345f31..5a083caef477e 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -199,6 +199,8 @@ protected function register( $icon_name, $icon_properties ) { ); return false; } + + $icon_properties['content'] = $sanitized_icon_content; } $icon = array_merge( diff --git a/src/wp-includes/class-wp-token-map.php b/src/wp-includes/class-wp-token-map.php index fc223b187f8c5..108f28475241a 100644 --- a/src/wp-includes/class-wp-token-map.php +++ b/src/wp-includes/class-wp-token-map.php @@ -662,7 +662,7 @@ public function to_array(): array { } foreach ( $this->large_words as $index => $group ) { - $prefix = substr( $this->groups, $index * ( $this->key_length + 1 ), 2 ); + $prefix = substr( $this->groups, $index * ( $this->key_length + 1 ), $this->key_length ); $group_length = strlen( $group ); $at = 0; while ( $at < $group_length ) { diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index d921a83de7f1f..e6691d489c9fe 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -254,8 +254,27 @@ public static function get_data_by( $field, $value ) { } } + if ( 'email' === $field ) { + $users = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM $wpdb->users WHERE user_email = %s", + $value + ) + ); + + foreach ( $users as $user ) { + if ( 0 === strcasecmp( $user->user_email, $value ) ) { + update_user_caches( $user ); + return $user; + } + } + + return false; + } + $user = $wpdb->get_row( $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The column name is selected by the hardcoded switch above. "SELECT * FROM $wpdb->users WHERE $db_field = %s LIMIT 1", $value ) diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php index 193a5d0951ddb..0aac64e792c58 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -311,13 +311,7 @@ private function build_font_face_css( array $font_face ) { * Wrap font-family in quotes if it contains spaces * and is not already wrapped in quotes. */ - if ( - str_contains( $font_face['font-family'], ' ' ) && - ! str_contains( $font_face['font-family'], '"' ) && - ! str_contains( $font_face['font-family'], "'" ) - ) { - $font_face['font-family'] = '"' . $font_face['font-family'] . '"'; - } + $font_face['font-family'] = $this->compile_font_family( $font_face['font-family'] ); foreach ( $font_face as $key => $value ) { // Compile the "src" parameter. @@ -338,6 +332,47 @@ private function build_font_face_css( array $font_face ) { return $css; } + /** + * Compiles the `font-family` into valid CSS. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @return string The CSS. + */ + private function compile_font_family( $value ) { + if ( $this->font_family_needs_quotes( $value ) ) { + return '"' . $this->escape_css_string( $value, '"' ) . '"'; + } + + return $value; + } + + /** + * Checks whether a font family value should be quoted. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @return bool Whether the font family value should be quoted. + */ + private function font_family_needs_quotes( $value ) { + if ( + str_contains( $value, ';' ) || + str_contains( $value, '{' ) || + str_contains( $value, '}' ) || + str_contains( $value, '/*' ) || + str_contains( $value, '*/' ) || + preg_match( '/[\x00-\x1F\x7F]/', $value ) + ) { + return true; + } + + return str_contains( $value, ' ' ) && + ! str_contains( $value, '"' ) && + ! str_contains( $value, "'" ); + } + /** * Compiles the `src` into valid CSS. * @@ -350,15 +385,39 @@ private function compile_src( array $value ) { $src = ''; foreach ( $value as $item ) { - $src .= ( 'data' === $item['format'] ) - ? ", url({$item['url']})" - : ", url('{$item['url']}') format('{$item['format']}')"; + $src .= ", url('" . $this->escape_css_string( $item['url'], "'" ) . "')"; + + if ( 'data' !== $item['format'] ) { + $src .= " format('" . $this->escape_css_string( $item['format'], "'" ) . "')"; + } } $src = ltrim( $src, ', ' ); return $src; } + /** + * Escapes a value for use in a quoted CSS string. + * + * @since 6.4.0 + * + * @param string $value Value to process. + * @param string $quote Quote character wrapping the CSS string. + * @return string Escaped CSS string content. + */ + private function escape_css_string( $value, $quote ) { + $value = str_replace( '\\', '\\\\', $value ); + $value = str_replace( $quote, '\\' . $quote, $value ); + + return preg_replace_callback( + '/[\x00-\x1F\x7F]/', + static function ( $matches ) { + return '\\' . strtoupper( dechex( ord( $matches[0] ) ) ) . ' '; + }, + $value + ); + } + /** * Compiles the font variation settings. * diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index acacb131a627b..73920de4d46f7 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2056,6 +2056,9 @@ function sanitize_file_name( $filename ) { * @see https://www.php.net/manual/en/regexp.reference.unicode.php */ $filename = preg_replace( '#\p{Zs}#siu', ' ', $filename ); + if ( null === $filename ) { + $filename = ''; + } } /** @@ -3209,7 +3212,7 @@ function make_clickable( $text ) { $ret = preg_replace_callback( $url_clickable, '_make_url_clickable_cb', $ret ); $ret = preg_replace_callback( '#([\s>])((www|ftp)\.[\w\\x80-\\xff\#$%&~/.\-;:=,?@\[\]+]+)#is', '_make_web_ftp_clickable_cb', $ret ); - $ret = preg_replace_callback( '#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})#i', '_make_email_clickable_cb', $ret ); + $ret = preg_replace_callback( '#([\s>])([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+(?:xn--[0-9a-z-]*[0-9a-z]|[0-9a-z]{2,}))(?![0-9a-z-])#i', '_make_email_clickable_cb', $ret ); $ret = substr( $ret, 1, -1 ); // Remove our whitespace padding. $r .= $ret; @@ -3626,7 +3629,7 @@ function is_email( $email, $deprecated = false ) { * Filters whether an email address is valid. * * This filter is evaluated under several different contexts, such as - * 'local_invalid_chars', 'domain_no_periods', or no specific context. + * 'local_invalid_chars' or no specific context. * Filters registered on this hook perform the actual validation; the * default filter is registered in default-filters.php. * diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php index dd6cc3e9e4c4b..3113105734b8e 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php @@ -75,6 +75,8 @@ public function send(Request $request, ?RequestOptions $options = null): Respons } else { $psr7Response = $this->client->sendRequest($psr7Request); } + } catch (NetworkException $e) { + throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) { diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index f659186b9a70c..226207ac479da 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -448,7 +448,7 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() try { $phpmailer->setFrom( $from_email, $from_name, false ); } catch ( PHPMailer\PHPMailer\Exception $e ) { - $mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments' ); + $mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments', 'embeds' ); $mail_error_data['phpmailer_exception_code'] = $e->getCode(); /** This filter is documented in wp-includes/pluggable.php */ diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index a1d887b45381f..32bf261ed1ab2 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1864,6 +1864,15 @@ function register_post_type( $post_type, $args = array() ) { return new WP_Error( 'post_type_length_invalid', __( 'Post type names must be between 1 and 20 characters in length.' ) ); } + // Re-registration replaces the object; clean side effects from the old object first. + if ( isset( $wp_post_types[ $post_type ] ) && $wp_post_types[ $post_type ] instanceof WP_Post_Type && ! $wp_post_types[ $post_type ]->_builtin ) { + $wp_post_types[ $post_type ]->remove_supports(); + $wp_post_types[ $post_type ]->remove_rewrite_rules(); + $wp_post_types[ $post_type ]->unregister_meta_boxes(); + $wp_post_types[ $post_type ]->remove_hooks(); + $wp_post_types[ $post_type ]->unregister_taxonomies(); + } + $post_type_object = new WP_Post_Type( $post_type, $args ); $post_type_object->add_supports(); $post_type_object->add_rewrite_rules(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 73a888d6eac48..6abe781696969 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -381,7 +381,7 @@ public function get_items( $request ) { $response->header( 'X-WP-TotalPages', (int) $max_pages ); $request_params = $request->get_query_params(); - $base_path = rest_url( sprintf( '%s/%s/%d/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) ); + $base_path = rest_url( sprintf( '%s/%s/%s/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) ); $base = add_query_arg( urlencode_deep( $request_params ), $base_path ); if ( $page > 1 ) { diff --git a/src/wp-includes/shortcodes.php b/src/wp-includes/shortcodes.php index 01fed7244e548..2260ffc230c97 100644 --- a/src/wp-includes/shortcodes.php +++ b/src/wp-includes/shortcodes.php @@ -263,7 +263,7 @@ function do_shortcode( $content, $ignore_html = false ) { $has_filter = has_filter( 'wp_get_attachment_image_context', '_filter_do_shortcode_context' ); $filter_added = false; - if ( ! $has_filter ) { + if ( false === $has_filter ) { $filter_added = add_filter( 'wp_get_attachment_image_context', '_filter_do_shortcode_context' ); } diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index d682762acb7e7..35c2667220491 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -5075,12 +5075,22 @@ function wp_validate_user_request_key( #[\SensitiveParameter] $key ) { - $request_id = absint( $request_id ); - $request = wp_get_user_request( $request_id ); + $request_id = absint( $request_id ); + + if ( ! $request_id ) { + return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); + } + + $request = wp_get_user_request( $request_id ); + + if ( ! $request ) { + return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); + } + $saved_key = $request->confirm_key; $key_request_time = $request->modified_timestamp; - if ( ! $request || ! $saved_key || ! $key_request_time ) { + if ( ! $saved_key || ! $key_request_time ) { return new WP_Error( 'invalid_request', __( 'Invalid personal data request.' ) ); } diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index 409496a1167bd..0be8768ffe875 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -993,6 +993,24 @@ public function test_user_request_key_handling() { $check = wp_validate_user_request_key( $request_id, '' ); $this->assertWPError( $check ); $this->assertSame( 'missing_key', $check->get_error_code() ); + + // A missing request should fail closed, even when get_post() could fall + // back to the global post. + $had_global_post = array_key_exists( 'post', $GLOBALS ); + $global_post = $GLOBALS['post'] ?? null; + $GLOBALS['post'] = get_post( $request_id ); + try { + $check = wp_validate_user_request_key( 0, $key ); + } finally { + if ( $had_global_post ) { + $GLOBALS['post'] = $global_post; + } else { + unset( $GLOBALS['post'] ); + } + } + + $this->assertWPError( $check ); + $this->assertSame( 'invalid_request', $check->get_error_code() ); } /** diff --git a/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php index 4a1ddb3c94c02..8eae5dda0772d 100644 --- a/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php +++ b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php @@ -43,6 +43,90 @@ public function test_get_nonexistent_metadata() { $this->assertNull( $retrieved_metadata ); } + public function test_get_metadata_ignores_sibling_paths_with_matching_prefix() { + $path = WP_PLUGIN_DIR . '/prefix-plugin/blocks'; + $sibling_path = WP_PLUGIN_DIR . '/prefix-plugin/blocks-extra'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + $this->assertSame( $manifest_data['test-block'], WP_Block_Metadata_Registry::get_metadata( $path . '/test-block' ) ); + $this->assertNull( WP_Block_Metadata_Registry::get_metadata( $sibling_path . '/test-block' ) ); + $this->assertFalse( WP_Block_Metadata_Registry::has_metadata( $sibling_path . '/test-block/block.json' ) ); + } + + public function test_register_collection_allows_root_sibling_path_with_matching_prefix() { + $path = WP_CONTENT_DIR . '/plugin'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path, $this->temp_manifest_file ) ); + $this->assertSame( $manifest_data['test-block'], WP_Block_Metadata_Registry::get_metadata( $path . '/test-block' ) ); + } + + public function test_register_collection_rejects_dot_segment_plugin_root_path() { + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( WP_PLUGIN_DIR . '/.', $this->temp_manifest_file ); + $this->assertFalse( $result, 'Plugin root path with a dot segment should not be registered' ); + } + + public function test_register_collection_rejects_dot_segment_plugin_root_parent_path() { + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( WP_PLUGIN_DIR . '/..', $this->temp_manifest_file ); + $this->assertFalse( $result, 'Plugin root parent path with a dot segment should not be registered' ); + } + + public function test_get_collection_block_metadata_files_preserves_unc_path_prefix() { + $path = '//server/share/prefix-plugin/blocks'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path . '/.', $this->temp_manifest_file ) ); + $this->assertSame( + array( $path . '/test-block/block.json' ), + WP_Block_Metadata_Registry::get_collection_block_metadata_files( $path ) + ); + } + + public function test_get_collection_block_metadata_files_preserves_stream_wrapper_prefix() { + $path = 'file://block-metadata-registry/blocks'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'assertTrue( WP_Block_Metadata_Registry::register_collection( $path . '/.', $this->temp_manifest_file ) ); + $this->assertSame( + array( $path . '/test-block/block.json' ), + WP_Block_Metadata_Registry::get_collection_block_metadata_files( $path ) + ); + } + public function test_has_metadata() { $path = WP_PLUGIN_DIR . '/another/test/path'; $manifest_data = array( diff --git a/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php b/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php index a1fea3ac948e2..7dd7a09e5134c 100644 --- a/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php +++ b/tests/phpunit/tests/fonts/font-face/wpPrintFontFaces.php @@ -59,6 +59,27 @@ public function test_should_escape_tags() { @font-face{font-family:"Source Serif Pro";font-style:normal;font-weight:200 900;font-display:fallback;src:url('http://example.com/assets/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');font-stretch:;} +CSS; + + $output = get_echo( 'wp_print_font_faces', array( $fonts ) ); + $this->assertEqualHTML( $expected_output, $output ); + } + + public function test_should_escape_css_delimiters() { + $fonts = array( + 'Fuzz' => array( + array( + 'src' => array( "http://example.com/assets/fonts/a');color:red;/*.woff2" ), + 'font-family' => 'Fuzz";color:red;/*', + ), + ), + ); + + $expected_output = <<<'CSS' + + CSS; $output = get_echo( 'wp_print_font_faces', array( $fonts ) ); diff --git a/tests/phpunit/tests/formatting/isEmail.php b/tests/phpunit/tests/formatting/isEmail.php index db37ca0311380..9e0a7839f577f 100644 --- a/tests/phpunit/tests/formatting/isEmail.php +++ b/tests/phpunit/tests/formatting/isEmail.php @@ -36,6 +36,8 @@ public static function data_valid_email_provider() { 'phil@TLA.example', 'ace@204.32.222.14', 'kevin@many.subdomains.make.a.happy.man.edu', + 'a@b', + 'first.last@example', 'a@b.co', 'a@b.c', 'bill+ted@example.com', @@ -91,17 +93,6 @@ public static function data_invalid_email_provider() { */ '(ab)cd@couc.ou', - /* - * The next address is not globally deliverable, - * so it may work with PHPMailer and break with - * mail sending services. Best not allow users - * to paint themselves into that corner. This also - * avoids security problems like those that were - * used to probe the WordPress server's local - * network. - */ - 'toto@to', - /* * Several addresses are best rejected because * we don't want to allow sending to fe80::, 192.168 diff --git a/tests/phpunit/tests/formatting/makeClickable.php b/tests/phpunit/tests/formatting/makeClickable.php index 5e4c62a1494ef..e7492e5ae3ba0 100644 --- a/tests/phpunit/tests/formatting/makeClickable.php +++ b/tests/phpunit/tests/formatting/makeClickable.php @@ -32,6 +32,7 @@ public function data_valid_mailto() { array( 'Foo.Bar@a.b.c.d.example.com' ), array( '0@example.com' ), array( 'foo@example-example.com' ), + array( 'mail@xn--fsqu00a.xn--4rr70v' ), ); } @@ -57,6 +58,7 @@ public function data_invalid_mailto() { array( '@example.com' ), array( 'foo @example.com' ), array( 'foo@example' ), + array( 'mail@example.xn--' ), ); } diff --git a/tests/phpunit/tests/formatting/sanitizeEmail.php b/tests/phpunit/tests/formatting/sanitizeEmail.php index 75fc0c94f6fe5..b91628f9b76cf 100644 --- a/tests/phpunit/tests/formatting/sanitizeEmail.php +++ b/tests/phpunit/tests/formatting/sanitizeEmail.php @@ -41,9 +41,9 @@ public function test_returns_stripped_email_address( $address, $expected ) { */ public function data_sanitized_email_pairs() { return array( - 'shorter than 6 characters' => array( 'a@b', '' ), + 'shorter than 6 characters' => array( 'a@b', 'a@b' ), 'contains no @' => array( 'ab', '' ), - 'just a TLD' => array( 'abc@com', '' ), + 'just a TLD' => array( 'abc@com', 'abc@com' ), 'plain' => array( 'abc@example.com', 'abc@example.com' ), 'unicode domain' => array( 'abc@grå.org', 'abc@grå.org' ), 'unicode local part' => array( 'grå@example.com', 'grå@example.com' ), diff --git a/tests/phpunit/tests/formatting/sanitizeFileName.php b/tests/phpunit/tests/formatting/sanitizeFileName.php index d0e366f121a5a..5907c6ef31284 100644 --- a/tests/phpunit/tests/formatting/sanitizeFileName.php +++ b/tests/phpunit/tests/formatting/sanitizeFileName.php @@ -95,6 +95,7 @@ public function data_wp_filenames() { return array( array( urldecode( '%B1myfile.png' ), 'myfile.png' ), array( urldecode( '%B1myfile' ), 'myfile' ), + array( hex2bin( '2e2e5c62797465732dfe882d07' ), '' ), array( 'demo bar.png', 'demo-bar.png' ), array( 'demo' . json_decode( '"\u00a0"' ) . 'bar.png', 'demo-bar.png' ), ); diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 23352964db0f5..ce4027e88e01a 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -87,6 +87,32 @@ public function test_register_icon_twice() { $this->assertFalse( $result2 ); } + /** + * Should sanitize inline SVG content when registering an icon. + * + * @ticket 64847 + * + * @covers WP_Icons_Registry::get_registered_icon + */ + public function test_register_sanitizes_inline_content() { + $name = 'test-plugin/unsafe-content'; + $settings = array( + 'label' => 'Icon', + 'content' => '', + ); + + $result = $this->register( $name, $settings ); + $icon = $this->registry->get_registered_icon( $name ); + + $this->assertTrue( $result ); + $this->assertIsArray( $icon ); + $this->assertStringContainsString( 'assertStringContainsString( 'M1 1h2v2z', $icon['content'] ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'onload', $icon['content'] ); + $this->assertStringNotContainsString( 'onclick', $icon['content'] ); + } + /** * Should fail to register icon with invalid names. diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..eba28c38f2cf1 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -119,6 +119,47 @@ public function test_get_users_of_blog() { $this->assertSameSets( $nusers, $found ); } + /** + * Tests that accent-distinct Unicode email addresses are not treated as duplicates. + * + * @ticket 31992 + */ + public function test_unicode_email_addresses_with_distinct_accents_are_not_duplicates() { + global $wpdb; + + if ( 'utf8mb4' !== $wpdb->charset ) { + $this->markTestSkipped( 'The test database does not use utf8mb4.' ); + } + + if ( ! function_exists( 'idn_to_ascii' ) ) { + $this->markTestSkipped( 'idn_to_ascii() is unavailable.' ); + } + + $emails = array( + "josejose@gr\u{00E5}.org", + "jos\u{00E9}jos\u{00E9}@gr\u{00E5}.org", + "jose\u{0301}jose\u{0301}@gr\u{00E5}.org", + ); + + $user_ids = array(); + foreach ( $emails as $index => $email ) { + $user_id = wp_insert_user( + array( + 'user_login' => 'unicode_email_' . $index . '_' . wp_generate_password( 6, false ), + 'user_pass' => 'password', + 'user_email' => $email, + ) + ); + + $this->assertNotWPError( $user_id ); + $user_ids[ $email ] = $user_id; + } + + foreach ( $user_ids as $email => $user_id ) { + $this->assertSame( $user_id, email_exists( $email ) ); + } + } + // Simple get/set tests for user_option functions. public function test_user_option() { $key = rand_str(); diff --git a/tests/phpunit/tests/wp-email-address/wpEmailAddress.php b/tests/phpunit/tests/wp-email-address/wpEmailAddress.php index afcc52354ee5e..7708fe3d11d61 100644 --- a/tests/phpunit/tests/wp-email-address/wpEmailAddress.php +++ b/tests/phpunit/tests/wp-email-address/wpEmailAddress.php @@ -122,23 +122,52 @@ public function test_local_invalid_chars_filter_can_rescue() { } /** - * Tests that an is_email filter returning true rescues a domain_no_periods failure. + * Tests that WHATWG-valid single-label domains are accepted. * * @ticket 31992 * * @covers WP_Email_Address::from_string */ - public function test_domain_no_periods_filter_can_rescue() { - $filter = static function ( $value, $email, $context ) { - return 'domain_no_periods' === $context ? true : $value; - }; - add_filter( 'is_email', $filter, 10, 3 ); - // Single-label domain is used for intranet mail servers. + public function test_single_label_domain_is_valid() { $result = WP_Email_Address::from_string( 'user@mailserver', 'ascii' ); - remove_filter( 'is_email', $filter, 10 ); $this->assertInstanceOf( WP_Email_Address::class, $result ); } + /** + * Tests that Unicode domains expose punycoded ASCII views. + * + * @ticket 31992 + * + * @covers WP_Email_Address::from_string + * @covers WP_Email_Address::get_ascii_domain + * @covers WP_Email_Address::get_ascii_address + */ + public function test_unicode_domain_ascii_view_is_punycoded() { + if ( ! function_exists( 'idn_to_ascii' ) ) { + $this->markTestSkipped( 'idn_to_ascii() is unavailable.' ); + } + + $result = WP_Email_Address::from_string( 'mail@bücher.de', 'unicode' ); + $this->assertInstanceOf( WP_Email_Address::class, $result ); + $this->assertSame( 'xn--bcher-kva.de', $result->get_ascii_domain() ); + $this->assertSame( 'mail@xn--bcher-kva.de', $result->get_ascii_address() ); + } + + /** + * Tests that Unicode domains are rejected when ToASCII is unavailable. + * + * @ticket 31992 + * + * @covers WP_Email_Address::from_string + */ + public function test_unicode_domain_is_rejected_without_toascii_support() { + if ( function_exists( 'idn_to_ascii' ) ) { + $this->markTestSkipped( 'idn_to_ascii() is available.' ); + } + + $this->assertNull( WP_Email_Address::from_string( 'mail@bücher.de', 'unicode' ) ); + } + /** * Tests that rescuing local_invalid_chars does not bypass later checks. * @@ -151,8 +180,8 @@ public function test_local_invalid_chars_rescue_does_not_bypass_domain_check() { return 'local_invalid_chars' === $context ? true : $value; }; add_filter( 'is_email', $filter, 10, 3 ); - // Local part rescued, but domain has no dot — should still be rejected. - $result = WP_Email_Address::from_string( '"quoted"@nodots' ); + // Local part rescued, but domain structure is invalid. + $result = WP_Email_Address::from_string( '"quoted"@bad_domain' ); remove_filter( 'is_email', $filter, 10 ); $this->assertNull( $result ); } @@ -182,9 +211,6 @@ public function data_invalid_addresses() { 'null byte' => array( "user\x00name@example.com" ), 'very invalid UTF8' => array( "\x80\x20ouch@example.com" ), 'overlong encoding of space' => array( "us\xC0\xA0er@example.com" ), - - // Domain without a dot is not a routable internet domain. - 'domain without a dot' => array( 'com@com' ), ); } @@ -235,6 +261,8 @@ public function data_from_string() { 'hyphen in domain label' => array( 'user@my-domain.com' ), 'digits in domain' => array( 'user@123.example.com' ), 'short but valid' => array( 'a@l.is' ), + 'single-label domain' => array( 'a@b' ), + 'long single-label domain' => array( 'first.last@example' ), 'special chars in local part' => array( 'a.!#$%*+/=?^_{|}~-@example.com' ), 'local part is all digits' => array( '1234567890@example.com' ), 'long local part' => array( 'abcdefghijklmnopqrstuvwxyz0123456789@example.com' ), diff --git a/tools/component-fuzz/README.md b/tools/component-fuzz/README.md new file mode 100644 index 0000000000000..853677949d560 --- /dev/null +++ b/tools/component-fuzz/README.md @@ -0,0 +1,1281 @@ +# 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-external-DB subset of WordPress. Surface +modules therefore focus on public APIs that can be exercised without a live +database, network requests, or a configured site. + +## Surfaces + +- `abilities`: Abilities API category and ability registry lifecycles, + action-gated registration, metadata/default preparation, filtered discovery, + schema validation, custom subclass execution, permission checks, execution, + query pipeline ordering, validation filters, callback exception handling, and + unregister/re-register behavior. +- `account-security`: no-DB account recovery and security APIs, including + application password lifecycle/hash/authentication behavior, password-reset + key lifecycle validation, recovery key/cookie validation, and paused + extension storage transitions. +- `admin-ajax`: bounded admin-AJAX response helper coverage, including + captured `wp_die()` handlers, JSON response helpers, `WP_Ajax_Response` + XML boundaries, nonce/capability failures, Heartbeat nonce hook branches, + selected safe AJAX handlers, Find Posts modal/query/JSON branches, + attachment query/save workflows, compression-test capability/body branches, + and superglobal/output-buffer restoration. +- `admin-bar`: no-DB toolbar node lifecycle, default root/submenu binding, + group/container behavior, render escaping/raw HTML contracts, and + back-compat parent alias and tabindex rendering contracts, initialization + hook/theme-support side effects, `show_admin_bar()` filter/global + restoration, default menu hook registration, default callback-produced + toolbar node graphs for WordPress logo, account, appearance, comments, + search, secondary groups, and single-site site-menu, new-content, updates, + sidebar-toggle, and command-palette nodes. +- `admin-dashboard`: no-live-DB admin dashboard API coverage, including + dashboard widget registration/control callbacks, meta-box context and + priority normalization, dashboard container rendering across column counts, + safe recent draft/post/comment output helpers, activity post query + argument/link branches, cached RSS loading/AJAX/cache replay branches, + Browser Happy remote/cache failure and rendering branches, direct + `wp_dashboard_setup()` GET registration across site/network/user dashboard + hooks, Community Events dashboard markup/template contracts, and + filter/global/superglobal/output-buffer restoration. +- `admin-edit-metaboxes`: no-live-DB classic edit-screen meta box callback + coverage, including publish-box status/visibility/action branches, flat and + hierarchical taxonomy boxes with capability gates, excerpt/trackback/custom + field/comment/slug helpers, page attributes, post formats, attachment submit + and ID3 metadata boxes, link target/XFN/advanced/submit boxes, default + `register_and_do_post_meta_boxes()` box registration, hook payloads, context + ordering, and state restoration. +- `admin-screen`: no-DB admin screen, settings, and meta-box APIs, including + `WP_Screen` normalization/current-screen globals, help tabs and screen + options, rendered per-page/layout controls, screen meta/help sidebar and + screen-reader content lifecycles, column header filter locality, settings + registry/default/sanitize callbacks, escaped settings field and nonce output, + settings error/admin notice rendering and filter state, meta-box + ordering/removal/callback args, and accordion section rendering. +- `admin-workflows`: no-DB admin menu, list-table, and referer-helper + workflows, including menu/submenu global registration and removal, hook suffix + and menu URL behavior, parent file normalization, synthetic `WP_List_Table` + pagination/columns/views/bulk actions/row actions/tablenav rendering, direct + bulk-action/month-dropdown helper contracts, safe admin/AJAX nonce checks, and + captured date/time AJAX format wrappers without process exits. +- `admin-list-tables`: no-live-DB concrete admin list-table subclass coverage + for posts, media, comments, terms, users, plugins, plugin install search + results, themes, selected-mode theme install API results, link manager, + post-comments metabox, network themes, application passwords, and guarded + network sites/users, including columns/ + hidden/sortable/default-primary logic, views, actions, bulk actions, exact row + URL/nonce and HTML escaping, base `WP_List_Table` pagination/per-page output, + pagination/counts, synthetic object/bookmark/pre-query/user-meta/plugin/theme + fixtures, capability gates including legacy link-manager option gates, + install/update/activate action rendering, screenshot/icon and description + escaping, localized update-count and theme-root transient cleanup, compact + post-comments table rendering, application-password Last IP escaping and + empty-value fallback, JS row templates, and state/filter restoration. +- `admin-media-chrome`: no-DB admin media chrome helper coverage, including + attachment edit field preparation, media item and compat markup escaping, + image form controls, image editor chrome from cache-seeded metadata, + edit attachment details form output, thumbnail/icon helper filters, direct + caption/send-to-editor helper output, legacy upload tab/header/form shell + hooks, media-view enqueue settings/string contracts, in-process iframe shell + rendering, and safe media button/uploader bypass output. +- `admin-options-submission`: no-DB `wp-admin/options.php` update-flow + coverage, including registered Settings API allowlists and sanitize + callbacks, settings error transients, General Settings date/time/timezone + branches, Reading/Discussion/Media/Writing core option-page sanitization, + conditional Writing Settings allowlist gates, pending admin email + hash/confirmation-mail semantics, legacy `page_options` submissions, + nonce/capability/unknown-page failure paths, redirect capture, and + global/filter/option restoration without process exits. +- `ai-client`: no-DB WordPress AI Client API coverage for SDK DTO + round-trips, enum strictness, provider registry isolation, model-selection + preferences across provider/model collisions, prompt builder ability + integration, cache and event adapters, deterministic in-memory generation, + HTTPlug discovery of the WordPress HTTP adapter, PSR-7 to WordPress HTTP + argument mapping, SDK request option merging, response body edge cases, and + transport error propagation without network calls. +- `assets`: script/style registration lifecycle, dependency ordering, inline + assets, style add-data output metadata, loading strategies, scoped loader-tag + filters, 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, conditional polyfill inline script generation, + inline/localized data placement, concatenated + load-scripts.php/load-styles.php URL construction and exclusion boundaries, + tag/settings escaping, script translations, emoji settings/styles with + temporary generated loader-asset materialization, style + JIT localization with isolated `AUTOSAVE_INTERVAL` coverage, style + inlining, block-loader guards, strategy/fetchpriority/module interactions, + generated classic-script module import-map/modulepreload graphs, 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, custom background head-callback + CSS and custom-logo hide-header-text CSS, custom header video markup/settings + and print-time script localization side effects, synthetic custom-logo + attachment markup/filter contracts, site icon sizes/meta tags, real site-icon + attachment URL/fallback resolution, background remove safe-redirect guards, + and state restoration without admin upload/AJAX dispatch. +- `auth-flow`: no-DB authentication and session flow coverage, including + synthetic user rows, username/email/password authentication filters, + direct `wp_login_form()` rendering/filter/escaping contracts, + sign-on and clear-auth-cookie actions with cookie sending short-circuited, + public `wp_logout()` composition, generated auth-cookie scheme boundaries and + filter payloads, auth cookie validation hooks/default parsing, current-user + and cookie global restoration, stale-cookie rehydration guards, and session + token lifecycle operations. +- `blocks`: block parser/serializer round trips, optimized block detection, + dynamic render filters, render-time block bindings, block type metadata, + variations, block hook insertion, ignored metadata, post-object wrapper and + REST response block hook metadata, style, pattern/category, bindings, and + supports registries, including dynamic block attribute preparation and + wrapper attribute merging. +- `block-supports`: no-DB core block-support lifecycle coverage, including + `WP_Block_Supports` registration and wrapper merging, auto-generated control + markers, direct support callbacks, skip-serialization gates, background, + dimensions, visibility, position, layout, elements, custom CSS, state-style, + helper-matrix, and duotone preset/custom/unset/global-style render behavior, + safe stored CSS/SVG/editor assets, and registry/global/style/duotone static + store restoration. +- `core-block-render`: no-DB direct render-callback coverage for representative + dynamic core blocks, including site title/tagline option handling, search + label/query/placeholder escaping, loginout current-request redirect links, + post title/date/excerpt/read-more context rendering, temporary excerpt filter + cleanup, button/file/image markup transforms, lightbox filter locality, + server-side `core/navigation`/navigation-link/submenu/home-link/page-list + rendering, responsive overlay/interactivity markup, duplicate navigation + label uniqueness, submenu visibility migration, synthetic page-list + active/ancestor classes, frontend list-style archives/categories/latest + posts/latest comments/tag cloud/calendar rendering, archive/calendar cache + key handling, posts/comments/terms query filters, and + global/superglobal/option restoration. +- `block-widgets`: block-backed widget behavior, including `WP_Widget_Block` + rendering, dynamic legacy class mapping matrix, malformed/unknown block + fallbacks, content sanitization on update, form escaping, `the_widget()` + display callback/action flow, registered control rendering, widgets block + editor support toggles, widget ID parsing, unregistered-widget cleanup, and + sidebars widget mapping, `retrieve_widgets()` remapping/customizer + persistence boundaries, lost/inactive block widget recovery, and widget-editor + script/style dependency warning contracts. +- `bookmark-links`: no-live-DB legacy bookmark/link-manager API coverage, + including in-memory link rows and link categories for `get_bookmark()`, + `get_bookmarks()`, `wp_list_bookmarks()` and `_walk_bookmarks()` escaping, + image/update/filter-payload rendering, edit bookmark links, bookmark + sanitizers, selected deprecated wrappers, safe link CRUD, argument filtering, + ordering, limits, visibility, ratings, and bookmark caches. +- `block-templates`: no-DB block template and block theme resolution coverage, + including template registry lifecycle, file-backed templates and parts, + parent/child theme precedence, theme.json metadata, hierarchy resolution, and + malformed filename/path and direct-ID traversal guards with template CPT + queries short-circuited. +- `block-editor-adjuncts`: no-DB block editor adjunct API coverage, including + editor context objects, category and allowed-block filters, legacy widget and + merged editor settings, local theme style helpers, iframe asset collection, + REST preload path normalization with dispatch short-circuited, and global + restoration. +- `capabilities`: no-DB role registry lifecycle and mutation idempotence, + numeric/associative and boundary capability grants, `WP_User` role/direct cap + aggregation and mutators, `WP_User::for_site()` cap-key isolation, + `user_can_for_site()` wrapper contracts, role and user capability filter + locality, generated `map_meta_cap()` filter contexts, cheap meta-cap + mappings, and primitive/meta cap monotonicity. +- `canonical-routing`: no-live-DB canonical redirect and front-end routing helpers, + including method/search/preview bailouts, host/path/query cleanup, invalid + date redirects, DB-stub-backed 404 permalink guessing, old-slug and old-date + redirect helpers, feed/pagination canonicalization, attachment page + permalink/raw-file redirects, redirect filter cancellation and same-host + replacement cascades, canonical URL output helpers for status, paged, + comment-page, plain-permalink, filter, and singular output gates, fragment + stripping, and generated query-argument removal and fragment stripping helper + matrices. +- `classic-walkers`: deterministic Walker base and classic walker coverage, + including `walk()`, `paged_walk()`, direct `display_element()` traversal, + page/category/comment/nav rendering, current/selected classes, admin nav menu + checklist/edit field names, direct admin nav helper contracts, nav menu + quick-search JSON/markup dispatch, post-type and taxonomy meta-box + pagination/search queries, generated has-children oracles, bounded HTML + balance, escaping contracts, and global/filter/superglobal/output-buffer + restoration. +- `content`: slashing, metadata serialization, post and term field sanitization, + whole-post `sanitize_post()` object/array consistency and filter locality, + `get_extended()` more-tag splitting, post-template title/excerpt/password + helper filters and cookie branches, direct `get_the_content()`/`the_content()` + rendering, content pagination and ``/`` handling, + `wp_link_pages()` link/filter contracts, query variables, `WP_Date_Query`, + and title/class/key sanitizers. +- `content-lifecycle`: in-memory wpdb-backed post, post-meta, term, user, and + comment CRUD lifecycles, including insert/update/read/delete round trips, + duplicate and invalid-input errors, sanitizer agreement, monotonic IDs, + metadata cache invalidation, post-to-term relationship field modes, helper + caches, relationship hook arguments, status-transition hook/cache/default + side-effect branches, direct `set_post_type()` row mutation and cache cleanup, + public page/post lookup helpers for ancestry paths, attachment fallback, + hierarchy queries, child normalization, filter payloads, output formats, and + salted query-cache invalidation, post and attachment count/MIME helper contracts, + featured-image helper lifecycle contracts, post-delete cleanup, cache/count + refresh behavior, and per-iteration state restoration. +- `comments`: comment filtering, sanitizer agreement, max-length boundaries, + type partitioning, comment classes, author URL/email links, excerpt/text + helpers, comment cookies, direct `wp_list_comments()` list orchestration, + public comment count/link/popup helpers, comment page navigation and + pagination wrappers, reply/cancel link and comment form rendering branches, + and permalink pagination contracts for `get_page_of_comment()`/ + `get_comment_link()`. +- `community-events`: no-network Community Events API client coverage, + including IP header selection and anonymization, minimal/fail-closed request + bodies, transient key/cache behavior, strict coordinate matching, cache + expiration normalization, search-triggered cache refresh behavior, event + trimming and WordCamp pinning, response normalization, API error contracts, + and admin AJAX `wp_ajax_get_community_events()` JSON envelope and + user-location persistence behavior. +- `comment-workflow`: in-memory comment submission, duplicate/flood approval + decisions, direct `wp_new_comment()` preprocessing and insert hooks, + notification wrapper/direct mail filter paths with intercepted delivery, + moderation short-circuits, update/status transition hooks, trash/untrash and + spam/unspam restoration, force-delete reparenting/meta/count/hook contracts, + and WP_Error failure paths without process exits. +- `cron`: in-memory cron scheduling, recurrence lookup, schedule/unschedule + and next-scheduled filter contracts, unschedule-hook pre-filter return + contracts, duplicate single-event windows, scheduled-event lookup + ordering/exactness, ready-job partitioning, spawn request/lock boundaries, + public `wp_cron()` shutdown deferral/immediate-run wrapper behavior, + unscheduling and rescheduling contracts. +- `default-widgets`: classic default widget subclass coverage, including + constructor/options contracts, saved-instance callback lifecycles, update + sanitization, form escaping, filtered rendering for text/custom + HTML/search/meta/list/nav-menu widgets, cache-backed calendar/archive output, + navigation widget argument filters, and local RSS fixtures without network + requests. +- `customizer`: no-DB Customizer API coverage for manager registry lifecycles, + setting sanitize/validate/post value flows, slashed customized JSON ingestion + and programmatic post-value merge precedence, multidimensional option + previewing, container/control JSON exports, active callbacks, selective + refresh partial registration/rendering, and direct inactive/active theme + preview filter/action lifecycle coverage for child-theme stylesheet/template + switching without changeset persistence. +- `customizer-nav-widgets-requests`: in-memory wpdb-backed Customizer nav menu + and widget request coverage, including loaded component/capability hook + gates, menu available/search AJAX, auto-draft insertion and publish cleanup, + dynamic nav menu/menu-item settings, placeholder menu remaps into locations + and `widget_nav_menu`, preview HMAC/export metadata, signed widget instance + round trips, widget update AJAX, and widget selective-refresh partials. +- `customizer-persistence`: no-live-DB Customizer persistence coverage for + changeset UUID/data normalization, stub-backed `customize_changeset` post + content parsing, changeset lock/heartbeat persistence, transactional + changeset saves, `WP_Customize_Manager::save()` AJAX request gates and JSON + envelopes, Custom CSS setting validate/sanitize/preview/update behavior, + custom CSS post filters, and global/superglobal restoration. +- `date-time`: deterministic no-DB date/time helper coverage, including + `wp_date()`/`DateTimeImmutable` agreement, `date_i18n()` and `mysql2date()` + timestamp oracles, timezone option filters, direct `wp_timezone_choice()` + markup generation for empty, named, BC-only, UTC, manual-offset, hostile, and + locale variants, GMT/local round trips, ISO8601 offset parsing and datetime + conversion across DST boundaries, week windows, `current_time()`, + `current_datetime()`, timezone override offsets, `wp_checkdate()` + validity/filter contracts, date/human diff filter contracts, and safe human + time diffs. +- `discovery`: robots meta directives, scoped public/private robots helper + output, front-controller `do_robots()` robots.txt output and hook order, + front-controller `do_favicon()` favicon redirects through `template-loader.php`, + sitemap enablement and robots.txt injection, provider registration/replacement + filters, query/permalink sitemap URL/index expansion, escaped sitemap XML + rendering, unsupported sitemap field boundaries, stylesheet URL filters, + direct sitemap/index XSL stylesheet output, LTR/RTL stylesheet CSS, sitemap + max-URL filters, and built-in posts/taxonomies/users sitemap providers with + fixture-backed subtype, lastmod, max-page, query-arg, pre-filter, and + public/private gating oracles. +- `email`: Unicode email validation/sanitization, ASCII fallback filters, + WHATWG-style validity, `WP_Email_Address` machine/readable and IDN/punycode + views, generated mixed-script UTF-8 address-model round trips, raw getter + invariants, ASCII-vs-Unicode construction-mode consistency with explicit IDN skips, + optional Unicode API availability skips, disabled-filter fail-closed behavior, + invalid UTF-8, generated malformed address variants and reserved/invalid ACE + domain labels across filter/charset modes, selected boundary lengths, + quoted/escaped local-part rejection, + control-character and Unicode separator sanitization boundaries, local-part + case/width/normalization identity preservation, hook restoration, + normalization-sensitive local parts, generated accent-distinct local-part + alias/index paths, byte-preserving user email search SQL, comment-author email + filtering, UTF-8 comment submission through `wp_handle_comment_submission()` + and direct `wp_new_comment()` sanitization paths, REST user email schema + validation across Unicode/ASCII filter modes, + and no-DB stub-backed user email lookup/duplicate behavior for accent-distinct + local parts/domains, generated Unicode local-part update/collision behavior, + exact Unicode email authentication and machine-view miss behavior, + email-change notification recipient/body preservation, canonical Unicode-domain + save/update collision behavior without MySQL collation/index coverage, + profile email-change confirmation request paths, password-reset Unicode recipient paths, + password-reset notification recipient machine/readable view overrides through + the PHPMailer handoff, current machine-view reset lookup rejection, clickable + mailto rendering boundaries including punycode final-label full links and + malformed final-label no-partial-link cases, and generated + mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter + local parts, IDN/punycode domains, escaped display hrefs, and readable text. +- `environment-load`: no-network environment/load/compat helper coverage, + including environment type cache boundaries, isolated `WP_RUN_CORE_TESTS` + environment-type matrices with constant precedence, server/request + normalization, Basic Auth and SSL detection, memory-limit parsing, ini + mutability, isolated `wp_raise_memory_limit()` context/filter negotiation, + installing/maintenance flags, generated JSON/XML request media matrices, + request guard filters, HTTPS migration short-circuits, and UTF-8 + compatibility oracles with parent/child 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 + recovery email payloads and `handle_error()` protected-endpoint rate limiting + without real mail, fatal-error handler formatting oracles, synthetic + `handle()` dispatch with injected fatal data, recovery-link early-return + boundaries, protected-endpoint gates, and isolated subprocess coverage for + fatal-error handler shutdown registration, recovery begin-link redirects and + dies, and recovery exit redirects, dies, and cleanup without letting process + exits escape the child. +- `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, + editor script enqueue decisions, TinyMCE translation and inline settings + snippets, internal link query/dialog helpers, media-view stylesheet helpers, + and global restoration. +- `fonts`: font-face CSS serialization and validation, theme.json font-face + resolution and default printing, font directory filters, Font Library + collection registration/JSON loading, REST font collection pagination, + filtering, and response boundaries, REST font-family/font-face write + lifecycle coverage for duplicate guards, upload rewriting, force-delete + cleanup, and cascade deletion, REST font-face preparation boundaries, and font + utility sanitization for family lists, face slugs, schemas, and MIME maps. +- `filesystem`: path normalization and joining, file validation classes, + filename sanitization/uniqueness, unique-filename callback and case-collision + filters, temp names, recursive directory creation/listing and stream wrapper + detection, direct filesystem sandboxing, metadata/time/chmod round trips, and + missing-file failure values, ZIP validity and `unzip_file()` ZipArchive/PclZip + parity over safe, traversal, and `__MACOSX` entries, plus `copy_dir()` skip/error + contracts and `move_dir()` overwrite and copy-fallback behavior. +- `formatting`: escaping helpers, text sanitizers, whitespace normalization, + `wptexturize()` rich-text punctuation and protected-region oracles, + `wptexturize_primes()`, texturize split/shortcode regex recomposition, + autop/shortcode cleanup, clickable text, URL sanitization, entity + normalization, deep `map_deep()`/URL-encoding/slash helper shape and round + trip contracts, title/key/class identifier sanitizers, file-name and username + sanitizers, 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 RSS/Atom/RDF item/channel + normalization, AtomParser local-file behavior, SimplePie raw-data parsing and + KSES sanitization, `WP_SimplePie_File` HTTP response/error-state + normalization, `fetch_feed()` empty/single/multi/error orchestration through + preempted HTTP responses, transient-backed feed cache hooks, date/status + helpers, local file adapter guards, no-network assertions, and state + restoration. +- `feed-rendering`: no-DB RSS2, Atom, RDF, RSS 0.92, and comments feed template + rendering over synthetic query loops, including feed item/entry counts, self + links, legacy `do_feed()` dispatch normalization, self-link request URI + host/filter escaping, CDATA terminator escaping, excerpt/content mode + switches, exact `rss_enclosure()` parser fixtures, enclosure metadata, comment + feed escaping, and feed build date selection. +- `frontend-features`: no-DB frontend feature helper coverage for speculative + loading and view transitions, including direct speculation rule validation, + configuration eligibility, mode/eagerness filters, generated URL-pattern + exclusions, disabled lifecycle/load-action isolation, script tag escaping, + theme support behavior, view-transition CSS registration timing, enqueueing, + and global restoration. +- `html-api`: HTML tag and tree processor mutation escaping, deterministic + rich HTML generation, normalization/recovery idempotence with tree + preservation, token walking, breadcrumb stack replay, bookmark/seek replay, + semantic parser mode probes, modifiable text escaping, token serialization, + and namespace/comment/rawtext boundary checks. +- `http`: synthetic HTTP response arrays, request wrapper dispatch, + real `WP_Http::request()` to Requests success-path option mapping and + response conversion through a fake no-network transport, response objects, + header/cookie parsing, proxy decisions, redirect safety, chunk-transfer + decoding, `WP_Http_Encoding` compression/decompression and accept-encoding + policy contracts, direct no-network request normalization and early error + contracts, URL validation, CORS/origin allowlist helpers, HTTP support + capability normalization, allowed HTTP request host mirroring, 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, + AI-provider update validation fail-closed behavior, API-key mask/ + file-modification policy, script module serialization, plugin install/ + activation status metadata, icon manifest/search behavior, SVG sanitization + and file caching, REST icons schema/permission/error contracts, and state + restoration. +- `images`: image constraint and resize math, synthetic intermediate metadata, + metadata dimension lookup, responsive `srcset`/`sizes` generation and filter + boundaries, attachment image helpers and attribute filters, image tag + attribute insertion, content tag image/iframe filtering, auto-sizes helper + gates, loading optimization attributes, and image filetype/extension helpers. +- `image-metadata`: local admin image metadata parser coverage over generated + bounded JPEG/TIFF/PNG byte fixtures, including `wp_read_image_metadata()` + malformed-file behavior, EXIF/IPTC field extraction and sanitization when PHP + extensions are available, locale-aware XMP alt text extraction/fallbacks, + real Core EXIF/IPTC/XMP fixture replay for representative camera, timestamp, + keyword, orientation, UTF-8 caption, and accessibility-alt fields, EXIF + helper normalization, image metadata filters, temp-file cleanup, and state + restoration. +- `identity`: usernames, emails, identity sanitizer filter contracts, + capabilities, generated user contact-method filters and additional-key + propagation, generated `WP_User` identity field/cache/filter behavior, avatar + data/URL/HTML filter pipelines, text/comment filters, comment + cookies/current-commenter payloads, options, password hashing/checking, parse + helpers. +- `import-diff`: importer registry and upload-form helpers, `WP_Importer` + imported post/comment lookup against the in-memory stub, import upload handler + fail-closed and cleanup paths, `wp_text_diff()` rendering/escaping/ + normalization, and `WP_Error` export/merge/remove transfer semantics. +- `install-schema`: no-DB install and upgrade schema coverage, including + `wp_get_db_schema()` table sets, `make_db_current()`/silent wrapper scope + expansion, global-table upgrade gate filters, `dbDelta()` CREATE TABLE + parsing, missing-table creation and replay no-ops, equivalent-schema no-ops, + isolated column/index diffs, SQL table allowlists, and malformed DDL + fail-closed behavior. +- `interactivity`: server-side directive processing for context, bind, class, + style, text, and each directives, explicit namespace/negation/length + evaluation, bind/class/style/text directive syntax and suffix/unique-ID + ordering, context namespace stack merge/sort/restoration, derived state + closure tracking and fail-closed errors, script-module router metadata, + unsupported/unbalanced HTML fallbacks, derived context/element helpers, and + state/config merge serialization. +- `hooks`: filter/action priority ordering, accepted arguments, removal, + nested and reentrant hook stack state, preinitialized hook normalization, + deprecated hook wrapper fast paths and side-effect hooks, ref-array + deprecated dispatch, `current_filter()`, `doing_filter()`, `did_action()`. +- `kses`: KSES policies, wrapper agreement, protocol filtering/helper contracts, + low-level helper contracts, exact `wp_kses_hair()` parser fixtures, + filter-aware safe CSS, attribute/entity/comment handling, deterministic + attribute constraint matrices for required, values, max/min, valueless, and + callback checks, serialized block attribute KSES filtering, PDF object policy + and upload-host/port URL gates, dynamic URI attribute filtering, full-tag + attribute parsing, no-HTML filtering, custom context locality, strict/custom + policy monotonicity, and filter/global restoration. +- `l10n`: translation fallbacks, escaped translation helpers, plural/nooped + selection, textdomain load/unload state, translation path guards, locale and + user-locale switching including generated stack/action payload matrices, + script translation helpers, malformed string boundaries, localized + numbers/dates, and filter/action restoration. +- `translations`: no-DB POMO and translation-file parsing/loading coverage, + including generated `Translation_Entry` lookup and merge behavior, + `NOOP_Translations` identity contracts, MO/PO/PHP translation file round + trips, explicit plural-rule oracles, short-circuited translation API, + available/installed language metadata, dropdown language normalization, + guarded language-pack helpers, transient-backed translation update helpers, + direct `WP_Translation_Controller` locale/domain/file isolation and lazy + malformed-file eviction, textdomain load/unload cycles, helper agreement with + loaded domain entries, malformed-file closed failures, and state restoration across `$l10n`, + `$l10n_unloaded`, registry, controller, current-user, and filter globals. +- `mail`: no-delivery `wp_mail()` composition coverage, including argument + filters, pre-send short-circuiting, PHPMailer recipient/header/content + handoff, UTF-8 local-part recipient/display-name preservation, IDN domain + punycode handoff, RFC2822 display-name MIME header encoding with literal + mailbox preservation and decoded header round trips, array and string header + parsing, newline-delimited attachments and embeds, multipart + boundary/header/body preservation through serialized MIME output, reusable + mailer cleanup/reset behavior, invalid From failure payloads, early failure + cleanup, success/failure actions, and emoji email body staticization. +- `markup`: block parse/serialize/render guards, deterministic `do_blocks()` + fixture rendering for generated block trees with synthetic callback oracles + and block-rendering state restoration, shortcodes, text trimming, excerpts, + balanced tags, URL extraction, link attribute helpers, and embed helpers. +- `media-editor`: no-DB media image editor coverage for editor selection, + GD/Imagick execution or explicit availability accounting, output format filters, abstract editor + filename/quality/EXIF-orientation contracts, resize/save metadata, + intermediate and generated sub-sizes, missing sub-size detection, and + cache/filter-backed attachment metadata helpers with temp-file cleanup. +- `media-image-edit-requests`: admin media image-edit request coverage for + history normalization, preview streaming, save/restore metadata, crop + wrappers, AJAX preview/crop/sub-size boundaries, nonce/capability gates, + file/metadata/id filters, and temp-root attachment fixtures using a + deterministic fake image editor. +- `media-ingest`: no-network media upload and sideload ingest coverage over + generated temp fixtures, including upload directory filters, MIME/filetype + boundaries, sanitized unique filenames, direct handle prefilter/move hooks, + upload override/error semantics, attachment row/post field/postmeta creation + in the in-memory wpdb stub, download short-circuit cleanup, metadata update + failure paths, and cleanup restoration. +- `media-metadata`: local audio/video metadata parser coverage over generated + bounded byte fixtures, including `wp_read_audio_metadata()` and + `wp_read_video_metadata()` malformed-file behavior, ID3 tag helper + sanitization, creation timestamp extraction, audio/video extension and ID3 key + filters, public audio/video shortcode rendering and filter contracts, + mediaelement fallback escaping, HTML5 library switching, YouTube/Vimeo URL + normalization, invalid-source embedded-link fallbacks, public + `gallery_shortcode()`/`wp_playlist_shortcode()` rendering over cache-seeded + attachment selections, gallery style/attribute/link filters, playlist JSON and + script hooks, protected-parent fail-closed behavior, `wp_attachment_is()` + MIME/extension branches, image/document classification, MIME/extension + disagreement, wrapper behavior, attachment metadata get/update/delete filter + contracts, original-image path/URL and image-meta matching normalization + across seeded upload storage styles, and `wp_generate_attachment_metadata()` + audio/video cover-art avoidance. +- `media-remote`: no-live-network remote media helper coverage for + `download_url()`, `media_sideload_image()`, and selected + `media_handle_sideload()` branches, including HTTP short-circuit fixtures, + case-insensitive download headers, Content-Disposition/content-type filename + derivation and sanitization matrix, + signature soft-fail/hard-fail temp-file behavior, URL extension/MIME + boundaries, extension-filtered sideload return types and metadata, + temp-file cleanup, size/type rejection, sideload prefilter cleanup and + override filter contracts, filter locality, and global restoration. +- `metadata`: no-DB Metadata API registration, subtype visibility, defaults, + registration argument edges, legacy callbacks, sanitize/auth/protected-meta + filters, current subtype-aware API accounting for post/term/comment/user + metadata, cache-backed lookup shape, filtered and in-memory CRUD cache + invalidation, by-mid short-circuit filter payloads and fail-closed inputs, + mid-row helpers, cache priming, duplicate-aware lazyloader queue/reset + behavior, and REST meta field schema, custom exposed names, prepared/default + values, validation failures, capability-gated update paths, and reset/delete + semantics. +- `multisite`: no-DB multisite/network API coverage, including synthetic + `WP_Site` and `WP_Network` objects, site data normalization, cache-backed + lookups, legacy blog identity helpers, bootstrap current-site/current-network + resolution, blog-switch stack/cache restoration, filter-backed network option + reads, stub-backed network option CRUD, signup validation for unsafe email + domains, pending `wp_signups` reservations, stale reservation cleanup, + blogname/title/nonce contracts, large-network threshold/filter contracts, + pre-query-short-circuited site/network queries, domain/path lookup helpers, + current/switched URL helpers, and an optional true-multisite subprocess for + DB-backed site lifecycle and `sitemeta` write paths when a readable + `wp-tests-config.php` is available. +- `navigation`: nav menu location registration, theme menu assignment lookup, + menu object and item setup filters, current-item class derivation, current-tree + parent/ancestor propagation, walker output, depth pruning, + short-circuit/fallback behavior, args/items-wrap normalization, container + allowlists, attribute filter escaping, and filtered no-DB `wp_nav_menu()` + rendering. +- `navigation-lifecycle`: in-memory wpdb-backed nav menu persistence and REST + menu controller coverage, including `wp_create_nav_menu()`, + `wp_update_nav_menu_object()`, `wp_delete_nav_menu()`, + `wp_update_nav_menu_item()`, associated object cleanup callbacks, + auto-add page behavior, menu-location remapping, orphan/self-parent item + normalization, sanitized menu item meta, REST menu/menu-item/location + permission gates, invalid location/object errors, forced-delete semantics, + links, `WP_Navigation_Fallback::get_fallback()` classic-menu-to-block + conversion with `core/navigation-link` and `core/navigation-submenu` oracles, + primary-location selection priority, duplicate-free fallback reuse, and state + restoration. +- `network-media`: URL parsing/sanitization/validation, URL scheme + normalization, path normalization, filename sanitization, filetype checks, + unique filenames, generated collision/alternate-extension filename oracles, + callback/filter contracts, `wp_upload_bits()` exact-byte writes, filtered + upload roots, virtual subsize collision file-list short-circuits, + upload-bits and final upload filter payloads, sideload handling, multisite + upload quota, remaining-space, size-limit, network upload MIME allowlists, + direct file-too-large checks, `check_upload_size()` error/state behavior, and + over-quota helpers. +- `options-autoload`: no-DB option CRUD, cache, autoload, and filter coverage + for generated option values, including alloptions membership, notoptions + transitions, raw serialized cache shape, default/pre/update filters, cache + priming stability, registered option-group priming, bulk autoload mutators, + lifecycle action payload ordering, and safe option-name boundary cases. +- `plugin-theme`: plugin headers, plugin path helpers, invalid plugin path + validation, no-DB plugin dependency metadata, dependency slug/name/API-data + fallbacks, active dependency option states, theme headers, parent/child + relationships, active theme file helpers, screenshots, and broken theme errors. +- `plugin-theme-lifecycle`: no-network plugin/theme lifecycle coverage over + generated temp fixtures, including plugin validation and requirement errors, + dependency failure states, activation/deactivation success and output-failure + paths, multi-plugin deactivation scope/action payload ordering, active and + sitewide-active option shapes, plugin/theme deletion validation, theme + enumeration and requirement checks, safe child-theme switching, theme + support/template globals, read-only REST plugin/theme controller paths, and + bounded REST plugin create/install, status update, and delete behavior + against generated temp plugin fixtures and packages. +- `update-install-upgrader`: no-network update/install/upgrader coverage, + including generated core/plugin/theme update transient shapes, aggregate + update counts/titles, `WP_Upgrader_Skin` and `Automatic_Upgrader_Skin` + output behavior, `WP_Upgrader` local/filtered download and temp-directory + install-package lifecycles, temp-backup cleanup/restore rollback contracts, + plugin/theme package validation helpers, no-update upgrade branches, + auto-update decision filters, VCS and PHP compatibility gates, 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, + chained filter/sort/pluck state, parse-list and array-path helper contracts, + token lookup/precomputed table round trips, non-default key-length + `WP_Token_Map::to_array()` export equivalence, rewrite match substitution, + URL-pattern prefix escaping/idempotence boundaries, `_wp_to_kebab_case()` + lodash-compatibility fixtures, hierarchy-loop detection helpers, unique ID + and UUID contracts, boolean validation semantics, diagnostic/error helper + hook and trace contracts, and state restoration. +- `user-preferences`: no-request-dispatch admin UI preference coverage, + including sanitized user-setting/admin-color serialization, hidden column and + meta-box preference defaults/saved values, postbox order/classes, screen + option registration/rendering, Screen Options visibility caching and filters, + composed Screen Options output for columns, meta boxes, layout, pagination, + view modes, and custom settings, layout column rendering and legacy filters, + subprocess-isolated AJAX preference handlers and `set_screen_options()` + redirect/exit paths, filter locality, and state restoration. +- `post-embeds`: in-memory wpdb-backed WordPress-as-oEmbed-provider coverage, + including post type embeddability predicates, public visibility fail-closed + behavior, oEmbed response width clamps, rich iframe and thumbnail conversion, + plain/pretty/path-conflict embed URL selection, iframe/blockquote/script + markup contracts, discovery link output, `wp_maybe_enqueue_oembed_host_js()` + action-gated script enqueue behavior, direct `WP_oEmbed_Controller` item + responses, same-site `pre_oembed_result` short-circuit behavior, and public + custom provider registration/removal, consumer `wp_oembed_get()` and + `WP_Embed::autoembed()` post-meta cache behavior, REST oEmbed proxy provider + fetch/transient-cache behavior with nonce-excluded cache keys, dimension + cache misses, TTL/filter oracles, and no-network HTTP interception. +- `post-types`: post type and post status registry defaults, registration + filter/action/meta-box lifecycle, REST route registration boundaries and + late-route ordering, duplicate post-type replacement cleanup, support feature + registration, capability generation, registry query operators, query/archive + normalization, unregister cleanup, archive/feed link filters, and status + filtering including status viewability and strict filter behavior. +- `privacy`: no-DB user request and privacy helper coverage, including + synthetic `WP_User_Request` objects, request lifecycle helpers, action and + confirmation descriptions, request-key hash/expiration validation including + missing-request and global-post fallback fail-closed behavior, export group + HTML escaping, exporter/eraser registry and processor shape contracts, + final erasure completion status/meta/action behavior, + built-in comments exporter/eraser payload and anonymization behavior, built-in + user exporter profile/community-location/session-token payloads and additional + profile filter contracts, built-in media exporter author/type filtering, + 50-item pagination, URL payloads, and registration callbacks, export + notification recipient/subject/content/header filters through an intercepted + PHPMailer handoff, real personal data export ZIP generation through filtered + temp export roots, `export.json`/`index.html` archive inspection, legacy + export-file meta migration, fail-closed JSON-error paths, directory/expiration + cleanup filters, anonymization helpers, privacy policy suggestion/default text, + suggested-text lifecycle cache transitions, text-change cache and admin + notices without real mail or network delivery. +- `privacy-admin-requests`: in-memory wpdb-backed admin privacy request + coverage, including export and erasure request list-table views, counts, + status filtering, prepared items, row action nonce/data attributes, checkbox + and status markup, bulk complete/delete/resend and direct helper contracts, + resend confirmation key regeneration and intercepted confirmation mail, + admin add-request/retry form handlers, pending-request cleanup, personal data + export/erasure AJAX success flows, default processor state transitions, + capability and request-shape gates, selected exporter/eraser/page callbacks, + malformed callback responses, scoped Unicode email filters, runtime cache + isolation, and state restoration. +- `query`: no-DB query builder and execution APIs, including meta/tax/date + query tree sanitization, SQL fragment generation, relation normalization, + query-var parsing, seeded `WP_Query` execution/found-row result oracles, + classic post-search parser/order/result SQL oracles for terms, exclusions, + columns, stopwords, attachment filename left-join branches, password gates, + literal clause-keyword searches, empty relevance-order boundaries, and + relevance ranking, wpdb stub status-OR branch handling, + cache-key/cache-hit determinism, `WP_User_Query` field/order/search/role/ + capability/has-published-post SQL-shape oracles and hook mutation locality, + user/comment pre-query short-circuits, narrow WP_Query hook-callback + difference accounting, and deterministic global restoration. +- `query-loop`: no-DB `WP_Query` execution and loop-state coverage through + `posts_pre_query` fixtures, including loop wrapper delegation, + setup/reset postdata globals, nested secondary-query reset and conditional + scoping, single/page flag behavior, found/max-page coherence, empty-result + events, offset/no-found-rows field-shape windows for `ids` and `id=>parent`, + `the_posts` result-filter finalization, and deterministic state restoration. +- `registries`: no-DB modern registry coverage for connectors, icons, block + metadata collections, block bindings sources, and speculation rules, + including lifecycle validation, helper oracles, manifest + sanitization/caching, connector override re-registration, block metadata + path-boundary/cache behavior, virtual path prefix preservation, block + bindings source validation, unregister/re-register ordering, callback/filter + payloads, allowlist behavior, rule serialization, and state restoration. +- `rest`: request normalization, parameter precedence, JSON bodies, route regexes, + `register_rest_route()` wrapper merge/override semantics, schema + sanitize/validate, permissions, HEAD/GET behavior, `/batch/v1` dispatch, + allow-batch gates, child request propagation, validation modes, response + links, CURIE compaction, embedding, envelopes, headers, response conversion, + and global REST registration state restoration. +- `request-lifecycle`: no-DB front-controller lifecycle coverage for + `WP::parse_request()`, rewrite/pathinfo/index matching, public/private + query-var gates, query-var precedence and GET/POST mismatch termination, + `WP::main()` sequencing with query short-circuits, `register_globals()`, + `handle_404()` status transitions, and `send_headers()` filters/actions with + feed content-type, last-modified, ETag, stale conditional request, + subprocess-isolated 304/error exit paths, and 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, block pattern categories, block-pattern remote loader + dispatch, local theme pattern file loading, dynamic block renderer dispatch, + menu-location route/permission/link contracts, plugin/theme route/schema + contracts, and REST search handlers, including context/_fields filtering, + registered additional-field get/update/schema callbacks, collection params, + permission gates, namespace-specific REST links, intercepted + core/featured/theme pattern directory requests, snake-case remote pattern + normalization, duplicate suppression, remote-load filter gates, + `WP_Theme::get_block_patterns()` header parsing/cache behavior, lazy + `filePath` content loading, local pattern duplicate preservation, + plugin/theme sanitizer contracts, + route-dispatched defaults/schema validation, custom search handler + result/header/link propagation, invalid subtype rejection, public + search-result schema callback contents, post-format search term/link and + pagination behavior, built-in post/term search handler subtype discovery, + `TYPE_ANY` expansion, include/exclude/search/page/per-page query mapping, + protected-title/no-title preparation branches, REST item/about links, invalid + values, and state restoration. +- `rest-application-passwords`: in-memory user/app-password backed REST + application password controller coverage, including collection/item/ + introspection route and schema contracts, create/update/delete dispatch, + one-time password response and stored hash agreement, response context + filtering for edit/view/embed, created/last-used/last-IP formatting, usage + recording and same-day throttle behavior, `_fields` projection and links, + password non-exposure outside edit context, REST pre/after/prepare hooks, + capability-denied and availability error matrices, current-user introspection, + stale UUID failures, REST application-password auth status globals, REST + index authentication metadata, and global/filter restoration. +- `rest-directory-services`: no-network REST coverage for WordPress.org-backed + directory service controllers, including block-directory, pattern-directory, + and URL-details route/schema contracts, plugin API and HTTP short-circuits, + block-directory `_fields` projection, explicit block title/icon mapping and + installed-plugin link discovery, permission matrices, request validation + errors, transformed response schemas, pattern and URL cache/transient + behavior, HEAD/cache-hit boundaries, pattern proxy query allowlisting and + derived WordPress.org argument overwrite behavior, malformed/unclosed head + metadata extraction, metadata parsing precedence, relative media URL + normalization, and global/filter restoration. +- `rest-media-attachments`: in-memory wpdb-backed REST media attachment write + coverage, including `Content-Disposition` filename parsing, raw upload + validation failures, raw body `create_item()` success through the upload + directory and attachment postmeta pipeline, raw body client-side sideloads + for generated subsizes and original-image metadata, non-image/PDF sideload + rejection, permission gates, client-side media-processing route/argument + contracts, metadata finalization filters, `_fields` response projection, + edit-media fail-closed paths, temp upload cleanup, and global/filter + restoration. +- `rest-widgets-sidebars`: no-live-DB REST widget, widget-type, and sidebar + controller coverage, including route/schema contracts, public + `show_in_rest` read gates, widget type sorting/projection and + `encode_form_data()` instance/hash round trips, isolated + `/widget-types/{id}/render` iframe preview dispatch, text widget + create/update persistence, sidebar reassignment/reorder semantics, legacy + widget `form_data` updates, soft/force delete hooks, HEAD short-circuits, + and widget/global/filter restoration. +- `rest-object-controllers`: in-memory wpdb-backed REST object controller + coverage for posts, terms, comments, users, revisions, and attachments, + including schema/context/_fields filtering, deterministic collection + parameter sanitizer/validation matrices, permission gates, route + registration/dispatch, route index/help-data + projection, REST links, invalid IDs/types, sanitized content/meta fields, + safe create/update/delete error paths, upload-no-data paths, and + deterministic state restoration without live uploads, remote requests, or a + live database. +- `rest-site-editor`: no-live-DB Site Editor REST controller coverage for + global styles, template/template-part response shaping, template create, + update, trash/reset/force-delete mutation lifecycles, template revisions and + autosaves, bounded template and template-part collection GET dispatch, + template item and lookup fallback route dispatch, navigation fallback, direct + block-template ZIP export generation, edit-site export guards, and + subprocess-isolated live edit-site export streaming, + including route normalization, schema/context behavior, permission and error + contracts, `_fields` projection, filter-backed collection query oracles, + custom CSS validation, temp theme fixtures, REST insert hooks, origin + metadata, area/theme taxonomy assignments, streamed ZIP inspection, archive + cleanup, and state restoration. +- `revisions-autosaves`: in-memory wpdb-backed revision and autosave API + coverage, including protected revision field/filter contracts, autosave + create/update/delete and post-lock behavior, autosave and revision predicates, + latest revision count and URL helpers, user-filtered autosave lookup, + revision insert/save/restore/delete helpers, revisioned meta copy and + restore behavior, post type support gates, revision title/list helpers, + revision UI diffs, JS payload preparation, direct revision template output, + preview overlay behavior, bounded preview request dispatch, and + global/filter restoration. +- `rewrite`: rewrite tags, permastruct/rule generation, collision ordering, + endpoint expansion and mask propagation, match substitution, query arg and + build/parse helpers, rewrite-tag removal/query-var retention boundaries, URL + parsing, home/site URL helpers, weird path fragments, cheap no-DB + `url_to_postid()` paths, and pretty-permalink `url_to_postid()` resolution + through generated rewrite rules and short-circuited `WP_Query` oracles. +- `security`: salts and HMACs, password and fast-hash verification, native + bcrypt compatibility and migration signals, nonce generation/verification, + nonce tick/lifetime boundaries, nonce URLs and hidden fields, referer + retrieval precedence/restoration, admin/ajax referer paths including invalid + admin nonce `wp_nonce_ays()` termination branches, synthetic auth cookies and + session token grace/failure edges, password filter locality, redirect + sanitization, redirect validation matrices, sanitize/validate metamorphic + behavior, and safe redirect filters. +- `shortcodes`: no-DB shortcode registry lifecycle, attribute parsing/default + merging and dynamic filters, invalid registration and non-callable callback + guards, callback argument and rendering filter contracts, nested parse + boundaries, callback mutation during rendering, scoped media image context + hooks including priority-zero preexisting filters, escaped shortcode + boundaries, accepted/rejected HTML-attribute shortcode rendering, + placeholder encoding/unescaping for ignore_html, comments, CDATA, and escaped + shortcode forms, tag-name edge/collision behavior, tag discovery, apply + aliasing, stripping preservation and strip filters, presence checks, + malformed inputs, and exact global/hook restoration. +- `site-health`: no-DB Site Health/update/HTTPS helper coverage, including + generated update transients and dismissed core update options, aggregate + update counts/titles, HTTPS option booleans, migration replacement, HTTPS + detection short-circuits, generated `site_status_tests` registry/filter + behavior, selected direct `WP_Site_Health` tests without remote requests, + synthetic loopback and REST availability request outcomes, scheduled event + missed/late/future cron classification, HTTP-blocking constant checks, and + persistent object cache threshold/filter direct-test behavior. +- `site-health-debug`: bounded `WP_Debug_Data` coverage for Site Health Info + formatting, diagnostic size helpers, and an isolated full `debug_data()` scan, + including private field/section suppression, debug vs info labels, + `debug_information` filter locality, scoped `SHOW TABLE STATUS` and + `SHOW VARIABLES` wpdb doubles, malformed database-size row boundary + accounting, generated directory/database/total-size aggregation, MySQL + variable lookup fallbacks, fake no-network WordPress.org + communication, bounded Ghostscript detection, path-size loading placeholders, + and child-process cleanup/restoration checks. +- `state`: object cache groups, direct `wp_cache_switch_to_blog()` local/global + group prefixing, multi-operations, and cache-addition suspension, option, + transient, cache-backed and option-backed site-transient APIs, + update/expiration cleanup, dynamic transient filters, serialization, JSON, and + value helpers. +- `style`: style engine serialization, preset/classname and CSS variable + boundaries, CSS declaration filtering, theme.json schema/data merging and + variable resolution, block style variation declarations, registered block + style `style_data` source-order injection, block-support wrapper + serialization, selector/path helpers, scoped editor style helpers, custom + properties, seeded global stylesheet guards, direct global settings/styles + getter paths, `wp_global_styles` user-data lookup, safe-flag fail-closed + behavior, and global style post-ID creation/cache oracles. +- `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, + automatic feed-link head output gates, `feed_links_extra()` branch output for + singular, post type archive, category, tag, custom taxonomy, author, and + search query states, self links, comment feed-link generation/filtering, and + Atom text construction. +- `taxonomy`: taxonomy registration lifecycle, object-type associations, + registration filter/action locality, registry query consistency, + argument/callback/default-term normalization, query-var and rewrite + side-effect boundaries, REST controller creation, term sanitization and field + filters, slug/name normalization, synthetic `WP_Term` behavior, hierarchy + helper edge cases, `get_terms()` short-circuit contracts, cheap term-link + paths, hierarchical parent-slug expansion, `get_term_parents_list()` + agreement, and term-link filter ordering across legacy taxonomy-specific + hooks. +- `taxonomy-relationships`: in-memory wpdb-backed object/term assignment + coverage, including `wp_set_object_terms()` replace/append behavior, + field-variant agreement, multi-object `all_with_object_id` mapping, scoped + removal, membership/object lookup helpers, invalid-input fail-closed paths, + `get_the_terms()` relationship cache population/invalidation, and generated + multi-object `update_object_term_cache()`/`clean_object_term_cache()` + priming, empty-entry, warm-cache, and re-prime invariants. +- `template-hierarchy`: no-DB classic PHP template hierarchy coverage, + including child/parent lookup priority, query-template filters, direct + archive/page/search/404/embed/author/date/home/front-page/privacy/singular/ + attachment helper filters and path confinement, stylesheet/template root + precedence, single template ordering, generated + category/tag/taxonomy decoded-slug and term-ID ordering, `load_template()` + include semantics, template-part hooks and args, and isolated + `comments_template()` child/parent/custom file loading and comment-query + contracts without leaking `COMMENTS_TEMPLATE` into the parent process. +- `template-links`: no-DB public template and link helpers, including body and + language attributes and filter ordering/locality, `get_post_class()`/ + `post_class()` container tokens, document title stability, resource + hints/preloads, pagination/search/feed/site/admin URLs, archive post-list + navigation and pagination wrappers, `_navigation_markup()` escaping, direct + `get_search_form()` HTML5/XHTML rendering, aria/query escaping, hook/filter + ordering and null fallback behavior, canonical and shortlink head output, + synthetic post preview/edit/delete/shortlink/permalink helpers, date and + author archive URL branches, author display/meta/link/post-count/listing + helpers, multi-author transient/filter behavior, previous/next adjacent post + relation links, adjacent image attachment links, and cached bookmark field/list + rendering. +- `widgets`: classic sidebar registry lifecycle, widget factory instance + registration, direct widget/control callbacks, generated sidebars, widget ID + parsing, sidebar option cache/filter behavior, sidebar assignment + moves/removals, inactive widget placement, render callback wrappers, + `dynamic_sidebar()` action/filter ordering, no-external-DB guards, and + registry restoration. +- `wpdb-sql`: no-connection real `wpdb` SQL formatting coverage, including + placeholder count/type handling, `%i` identifier containment, literal percent + and LIKE escaping, malformed placeholders, and captured insert/update/delete/ + replace builder SQL shape, including null values across string, integer, and + float builder formats. +- `wxr-export`: subprocess-isolated WXR export coverage over deterministic + synthetic posts, terms, authors, comments, and meta, including export + argument filtering, title/content/excerpt export filters, XML/CDATA/UTF-8 + safety, meta skip filters, null post/term/comment meta serialization, + non-exportable and invalid content fallback behavior, filtered-comment + omission, nav-menu term boundaries, attachment URL/file metadata + serialization, author and term ordering, filtered filename and XML + content-type header intent with observable-header assertions when available, + and state restoration. +- `xmlrpc`: no-DB IXR/XML-RPC protocol coverage, including value escaping, + request/message round trips, invalid XML fail-closed behavior, fault XML, + system method dispatch, mixed success/fault multicall ordering, method + registry filters, demo helpers, disabled login behavior, bounded pre-network + pingback fail-closed/read-only lookup branches, legacy post title/category XML + helpers, authenticated read-only `wp.getPost`, `wp.getPosts`, + `wp.getMediaItem`, and `wp.getMediaLibrary` post/media field filtering, + authenticated `wp.newPost`, `wp.editPost`, and `wp.deletePost` post write + lifecycle branches, auth/capability/error paths, media MIME/parent filters, + hook cleanup, and short-circuited `WP_HTTP_IXR_Client` transport/error + handling without option, post-sleep pingback fetch, or live network side + effects. + +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, directly asserts concatenated loader query chunks and separate +strategy/external asset tags under forced concat globals, and covers +just-in-time script localization for autosave, mce-view, and word-count in an +isolated child process so `AUTOSAVE_INTERVAL` does not leak into the parent. 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, direct DB-backed concrete `WP_*_List_Table` subclasses, and direct +`die()` or destructive `wp_ajax_*` wrappers. It covers the base list table API +with synthetic items, records scoped accounting for concrete list-table coverage +owned by `admin-list-tables` and `privacy-admin-requests`, referer helpers where +valid nonces or `stop=false` avoid exits, and deterministic date/time AJAX +wrappers through captured `wp_die()` termination. This is not full admin page +dispatch coverage. The `admin-ajax` surface calls selected `wp_ajax_*` handlers +directly with captured `wp_die()` termination; attachment workflows use +synthetic attachment rows, bounded capability grants, and narrow post MIME LIKE +support in the in-memory `wpdb` stub. The `admin-list-tables` surface complements +that base coverage by loading concrete `WP_*_List_Table` subclasses with +synthetic rows, object-cache fixtures, temporary plugin/theme/network-theme +metadata, exact row-action nonce checks, and `posts_pre_query`, +`comments_pre_query`, `terms_pre_query`, `users_pre_query`, and +`sites_pre_query` short-circuits. `WP_Theme_Install_List_Table::prepare_items()` +raw-requires `theme-install.php`, so the theme install API-argument invariant +runs once per PHP process and later iterations seed concrete table items, +pagination, and view globals directly to replay row/action/escaping coverage +without triggering redeclarations. It intentionally skips full admin dispatch, +install/update tables, destructive plugin/theme operations, real uploads, and +true multisite write paths; privacy request tables and their AJAX handlers live +in the dedicated `privacy-admin-requests` surface, and network site/user rows +remain synthetic when the shared PHP process is not in multisite mode. The +`post-embeds` surface covers direct provider helpers and the oEmbed item +controller without loading the full embed template, dispatching theme rendering, +performing remote discovery, or requiring generated build artifacts; when the +source checkout lacks the built `wp-embed.js` file it suppresses only that +expected file-read warning while still asserting the generated embed markup +shape. The +`appearance-media` surface covers custom background/header/site icon helpers, +frontend head callbacks, printed custom-header script side effects, and +synthetic site-icon attachment metadata without invoking media uploads, image +crops, AJAX actions, or admin page dispatch. The +block templates surface short-circuits template CPT queries through +`posts_pre_query` and asserts that file enumeration and direct template-ID +resolution remain confined to the active theme template directories. The +`block-widgets` surface exercises `WP_Widget_Block`, sidebars widget option +mapping, `retrieve_widgets()` remap/lost-widget recovery, and +`wp_check_widget_editor_deps()` script/style conflict warnings without loading +the browser widgets editor, performing REST persistence, or depending on theme +files. Dependency-warning coverage asserts both widget-editor handles, script +and style conflict classes, dependency-chain enqueued semantics, scoped +`_doing_it_wrong()` capture, version/message payloads, and asset/hook/global +restoration. The +Admin Media Chrome surface intentionally avoids upload dispatch, real +attachments created by browser flows, `wp_media_attach_action()` redirects, +media modal runtime behavior, and browser-side image editor UI; server-side +image-edit AJAX save, preview, crop, restore, and sub-size request branches are +covered by `media-image-edit-requests`. The surface covers direct server-side +helpers with synthetic attachment rows and cache/filter-backed metadata only. The +base `customizer` surface covers direct manager/theme-preview lifecycles but +intentionally avoids changeset save/publish, `setup_theme()`-driven theme +switching, nav-menu persistence, widget persistence, and real post/option +storage beyond the existing no-DB option stub. `customizer-persistence` covers +changesets and custom CSS persistence, while `customizer-nav-widgets-requests` +covers bounded nav-menu/widget request, remap, and selective-refresh persistence +paths against the in-memory `wpdb` stub. The `admin-options-submission` surface +covers the bounded `wp-admin/options.php` submission/update branch without +loading the full admin bootstrap, redirects, or process exits; it locally +installs only the two documented `new_admin_email` dynamic option hooks when exercising the pending +admin email confirmation path. It also mirrors the conditional Writing Settings +allowlist gates for post-by-email, legacy DB-version formatting options, and +public-blog update services without loading the exiting admin controller. The +`options-autoload` surface uses that same bounded in-memory option table and +object cache, and deliberately focuses on core option/autoload/cache/hook +semantics, including registered option-group cache priming, rather than admin +form submission, network options, transients, or arbitrary SQL support. The +`media-editor` surface short-circuits attachment metadata updates and +intentionally avoids media paths that insert attachments, create cover-image +attachments, process audio/video thumbnails, or otherwise require real postmeta +writes. The `media-image-edit-requests` surface complements it with bounded +in-memory attachment rows, temp upload roots, captured AJAX termination, and a +fake `WP_Image_Editor`; it avoids codec-dependent image decoding and +subprocess-only `IMAGE_EDIT_OVERWRITE` branches. The `multisite` surface leaves `MULTISITE` +disabled for the shared PHP process; true multisite `sitemeta` write paths and +site creation/update/deletion run only in an isolated subprocess when a readable +`wp-tests-config.php` points at a real multisite test database, and that row +skips explicitly when no such config is available or the configured database is +not reachable. Broader DB-backed multisite query execution remains +short-circuited through filters, while non-multisite network-option CRUD +remains covered by the existing option stub. The Site +Health surface avoids loopback, WordPress.org, REST availability, update +download, mail, cron, and filesystem-writing checks unless they are fully +short-circuited. The mail +surface intercepts PHPMailer send calls and never attempts real delivery. The +`media-metadata` surface uses malformed local fixtures and cache-seeded +attachments only; it does not download remote media, invoke codecs or external +binaries, insert real attachments, or enable audio/video cover attachment +generation. Its shortcode rendering coverage stays on direct shortcode helper +calls with generated local-looking URLs, cache-seeded gallery/playlist +attachments, `posts_pre_query` short-circuits, and scoped +filter/script/style cleanup, not browser playback. The +`image-metadata` surface complements that audio/video coverage with generated +local image byte fixtures and repo-local Core image fixture replays. It does not +invoke image codecs, live uploads, remote media, or attachment persistence; +EXIF/IPTC extraction rows are recorded as explicit skips when the PHP build +lacks `exif_read_data()` or `iptcparse()`, while malformed/no-metadata image +paths and filter cleanup still run. The +`rest-controllers` surface remains no-live-DB and registry-first, with bounded +temp-file coverage only for local block pattern loader behavior; it also +dispatches the dynamic block renderer controller through a local REST server to +cover route args, attribute validation/sanitization, POST bodies, post context, +and `pre_render_block` filter cleanup, and isolates menu-location controller +routes, schema, anonymous/read-access/capability permissions, `_fields` +projection, assigned menu IDs, REST links, prepare filters, invalid-location +errors, and nav-menu/current-user restoration. DB-backed posts, terms, comments, +users, revisions, and attachments are covered by `rest-object-controllers` +against the in-memory `wpdb` stub. The +`rest-application-passwords` surface complements lower-level account-security +coverage by dispatching the REST controller with synthetic users and scoped +application-password metadata, while directly asserting the REST auth-status and +index-advertisement plumbing that sits outside controller CRUD methods. +`rest-media-attachments` covers the bounded REST attachment upload and +client-side media-processing write paths using raw request bodies, temp upload +roots, attachment postmeta, response projection, metadata finalization, +permission gates, and raw sideload metadata updates for generated subsizes and +original-image files. It intentionally avoids multipart success paths that +depend on PHP's `is_uploaded_file()` state, remote sideload downloads, and admin +image-edit request paths, which are covered by `media-image-edit-requests` when +they can be kept process-local and codec-independent. `rest-widgets-sidebars` +complements the lower-level widget surfaces by exercising REST controller +permissions, schemas, instance encoding, sidebar mutation, legacy form-data +paths, and the `/widget-types/{id}/render` iframe endpoint directly. The render +path runs in an isolated child process so +the global `IFRAME_REQUEST` constant cannot leak into the shared runner. The +object surface records explicit skip rows for template controllers that depend +on block-theme filesystem state and template CPT queries. Broad collection +query translation for posts, attachments, revisions, users, comments, and terms +is covered through REST query filters, query-class pre-query short-circuits, +HEAD pagination headers, no broad SQL execution, and filter restoration. It +documents limits for invalid enum-error formatting +branches that are not warning-safe under the stripped bootstrap. The +registry-backed surface covers plugin/theme controller route, schema, +collection parameter, sanitizer, and permission-gate contracts without +plugin/theme filesystem lifecycle effects. Lifecycle-heavy plugin/theme read and +status paths are covered by `plugin-theme-lifecycle`, along with bounded REST +plugin create/install, status update, and inactive-delete controller methods. +Block pattern coverage now includes +registry-backed response shaping, intercepted remote core, featured, and theme +pattern directory loaders, and local theme `patterns/` PHP file loading through +a scoped temp theme with cache/header/duplicate/lazy-content oracles. +The `rest-site-editor` surface creates a bounded temp theme under the harness +`WP_CONTENT_DIR` and uses the in-memory `wpdb` stub for `wp_global_styles`, +`wp_template`, `wp_template_part`, `revision`, and `wp_navigation` fixtures. It +dispatches only bounded template/template-part item routes and the lookup +fallback route through `WP_REST_Server`; direct template mutation coverage uses +targeted controller calls, scoped capabilities, and fixture-local +`WP_Block_Template` lookups for create/update/delete/reset oracles. Broad +template collection queries and unbounded theme/template CPT query paths remain +skipped. Export coverage uses the direct ZIP generator and a subprocess-isolated +`WP_REST_Edit_Site_Export_Controller::export()` oracle with bounded +`pre_get_block_templates` fixtures, structured stdout metadata, streamed ZIP +inspection, generated ZIP cleanup checks, and parent-process state restoration. +The `block-editor-adjuncts` surface keeps REST preloading on synthetic +`rest_pre_dispatch` responses and keeps theme styles local to temp fixtures; +it does not load editor screens, dispatch DB-backed REST controllers, fetch +remote editor styles, or render browser UI. +The `plugin-theme-lifecycle` surface uses a process-local temp `wp-content` +tree and generated minimal fixtures only; it does not activate repository +plugins or switch to repository themes. Network-wide activation is not forced +when the shared process is not running with `MULTISITE`; in that mode the +surface verifies sitewide option shapes and the non-multisite false branch. +REST plugin/theme controller coverage includes read/status/parameter paths and +bounded REST plugin create/install paths with mocked `plugins_api()` responses, +generated ZIP packages, direct filesystem transport, active-install permission +gates, status updates, and inactive temp-plugin deletion. It avoids live remote +lookups/downloads and destructive operations on repository plugins or themes. +The `content-lifecycle` surface uses a bounded in-memory `wpdb` stub that +recognizes the narrow SQL shapes emitted by core post, term, user, comment, and +metadata lifecycle APIs; it is not a general SQL engine. Post metadata coverage +asserts add/read/update/delete, unique keys, serialized array values, cache +invalidation, metadata hooks, and post-delete cleanup. Post-to-term relationship +coverage asserts category/tag set/append/replace/remove/delete helpers, object +term field modes, relationship caches, hook payloads, and post-delete cleanup. +Post status transition coverage asserts hook order, direct-transition status +storage behavior, count/timeinfo cache invalidation and preservation branches, +empty-GUID publish repair, scheduled future-post hook clearing, and cleanup of +the generated status post type and hooks. +Post and attachment count coverage asserts `wp_count_posts()` status grouping, +readable private-post filtering, count caches, `wp_count_attachments()` MIME and +trash grouping, `get_post_mime_types()` converted group/filter behavior, and +`get_available_post_mime_types()` DB and short-circuit filter paths. +Featured-image coverage asserts `set_post_thumbnail()`/`delete_post_thumbnail()` +postmeta lifecycle behavior, `get_post_thumbnail_id()`/`has_post_thumbnail()` +fail-closed and filter contracts, thumbnail HTML/URL/caption echo helpers, +non-image cleanup, and `update_post_thumbnail_cache()` cache priming. +Page lookup coverage asserts `get_page_by_path()`, `get_pages()`, +`get_page_children()`, and `get_children()` against deterministic page trees, +encoded and full ancestry paths, attachment fallback and page/attachment slug +collisions, custom hierarchical post types, salted hit/miss `post-queries` +caches, include/child/parent/exclude-tree/limit rewrites, filter payloads, +numeric/object/global argument normalization, output shape modes, and cleanup of +posts, filters, globals, and generated post types. +Broader taxonomy relationship behavior is covered by `taxonomy-relationships`, +still limited to the recognized term relationship SQL shapes emitted by the +targeted core APIs. +The `taxonomy` surface remains no-DB; term-query coverage short-circuits through +`terms_pre_query` and asserts query parsing/filter contracts rather than SQL +hydration. +The `bookmark-links` surface extends that stub only for the `wp_links` shapes +emitted by bookmark APIs and safe link CRUD: ID lookups, visibility/search, +include/exclude/category joins through `link_category`, supported order/limit +clauses, and `link_id` projections. It intentionally does not emulate arbitrary +link-manager SQL, admin page dispatch, or live database behavior. +The `auth-flow` surface short-circuits auth cookie sending and covers the direct +`wp_login_form()` and `wp_logout()` lifecycles, but avoids browser login-page +dispatch, redirects, real mail, application-password API requests, and +process-exit paths. +The `canonical-routing` surface calls `redirect_canonical()` with +`do_redirect=false`; 404 permalink guessing uses bounded in-memory post rows, +attachment-page redirects use synthetic parent/attachment rows, and paths that +call `wp_redirect()` and `exit` are intentionally avoided. Old-slug redirects +remain outside the current bounded no-live-DB surface. +The `classic-walkers` surface uses synthetic objects, object-cache fixtures, and +local filters only. It loads the single comment/admin walker class files when +available, and covers the direct nav menu quick-search dispatcher plus +post-type/taxonomy menu item meta-box pagination/search queries with scoped +`posts_pre_query` and `terms_pre_query` fixtures. It still avoids browser admin +page dispatch and any DB-backed menu/page/category/comment queries. +The `import-diff` surface covers importer registry, importer form, imported +post/comment lookup, fail-closed importer upload handling, cleanup, text diff, +and WP_Error transfer/lifecycle helpers without remote importer discovery, real +PHP SAPI upload success, or importer dispatch screens. WXR download generation +is covered separately by `wxr-export`. +The `wxr-export` surface invokes actual `export_wp()` once per isolated PHP +subprocess because core defines `wxr_*` helper functions inside that function. +It uses a surface-local in-memory `wpdb` double and deterministic fixtures only; +it does not use a live database, contact the network, or write download files. +PHP CLI usually does not expose `header()` calls through `headers_list()`, so +filename/content-type checks are asserted when observable and otherwise recorded +as explicit skips while the filename filter invocation remains captured. +The `media-ingest` surface uses local temp files only, routes uploads through a +filtered temp upload root, and passes a custom upload action for +`media_handle_upload()` so CLI fixtures use core's readable-file branch instead +of PHP SAPI uploaded-file state. It exercises direct `wp_handle_*()` hooks and +`download_url()` only through local files or `pre_http_request` short-circuits. +It avoids browser media UI flows, audio/video cover-art generation, and writes +outside the component-fuzz temp root. +The `media-remote` surface complements that local ingest coverage by exercising +remote download and sideload helpers with `pre_http_request` fixtures only. It +records every streamed temp filename observed by the HTTP short-circuit, +including package signature soft-fail/hard-fail paths, removes returned temp +files, routes successful sideloads through a temp upload root, and asserts that +its HTTP, signature, upload, extension, and error-body filters are removed after +each check. It never lets unregistered remote URLs fall through to the live HTTP +transport. +The `comment-workflow` surface uses the bounded content/comment rows in the +in-memory `wpdb` stub and avoids notification mail, browser cookie writes, and +`wp_die()` paths by requesting `WP_Error` returns or using non-exiting helpers. +The `revisions-autosaves` surface uses the bounded post and postmeta rows in +the in-memory `wpdb` stub. It exercises autosave creation/update/delete, +post-lock windows, protected revision field filters, revision title/list +helpers, latest revision count and URL helpers, user-filtered autosave lookup, +post type support gates, and restore action/edit-user side effects without +browser dispatch. The in-memory `wpdb` post query stub supports the bounded +`found_posts` and `post_author` equality/`IN` shapes needed by these helpers, +including author intersections across status-`OR` branches. The surface avoids +browser/admin page dispatch, while covering `_show_post_preview()` and +`wp_print_revision_templates()` through direct bounded helper calls with +synthetic superglobals, captured `wp_die()` responses, and output buffers. +The `feed-rendering` surface renders core feed templates through synthetic +`WP_Query` loops backed by the in-memory `wpdb` stub. It avoids live HTTP +headers, remote enclosures, DB-backed query execution, and arbitrary invalid +bytes so XML structure and escaping remain useful oracles. Direct self-link +helper coverage mutates `REQUEST_URI`/`HTTP_HOST` without dispatching requests. +Direct enclosure helper coverage replays bounded `rss_enclosure()` newline and +filter fixtures against local post meta only. +The `community-events` surface short-circuits `wp_remote_get()` through +`pre_http_request`; it never contacts api.wordpress.org and limits coverage to +request construction, response normalization, cache behavior, the local admin +AJAX wrapper, and helper contracts rather than dashboard browser rendering. +The `editor-helpers` surface exercises classic editor settings and generated +markup without loading browser editors. It avoids live TinyMCE/Quicktags +execution while still covering inline classic-block settings serialization, and +avoids external asset fetching, admin page dispatch, and AJAX media-shortcode +preview paths. Direct internal-link query coverage short-circuits DB lookup with +generated `posts_pre_query` fixtures while asserting query/result filters, +sanitized titles, permalinks, custom-post labels, empty-result behavior, and +link dialog single-print markup. +The `user-preferences` surface exercises `set_screen_options()` and AJAX +preference handlers in a subprocess so redirect, raw `exit`, and `wp_die()` +paths cannot terminate the parent fuzz runner. It still covers the underlying +user-setting, user-option precedence/deletion, screen visibility caching, and +screen preference helpers directly in-process. +The `update-install-upgrader` surface intentionally avoids live package +downloads, real ZIP unpacking into `wp-content/upgrade`, real plugin/theme +activation or switching, full plugin/theme/core update execution, core +`update-core.php` replacement, language-pack updates, automatic updater run +loops, fatal-error loopback checks, and any process-exit paths. It exercises +safe class/helper paths directly, including synthetic plugin/theme automatic +update notification result classification and failure-cache behavior, and only +uses filters to short-circuit network, email delivery, 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; token-map export assertions cover key lengths 1, 2, and +3 to guard prefix reconstruction and NUL padding boundaries. Kebab-case +assertions anchor block/style slug compatibility at the helper boundary, and +hierarchy-loop assertions cover terminating chains, self loops, callback-backed +cycles, start-parent overrides, direct tortoise-hare probes, and callback +argument propagation. Diagnostic/error assertions cover `is_wp_error()` action +payload and counter deltas, `wp_debug_backtrace_summary()` raw/pretty/skip/ +ignore-class behavior over a controlled stack, and `wp_trigger_error()` hook +ordering, suppression filters, `WP_DEBUG` gating, local error-handler capture, +and cloned hook-state restoration. +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/dashboard.html b/tools/component-fuzz/dashboard.html new file mode 100644 index 0000000000000..5028a6ed831f0 --- /dev/null +++ b/tools/component-fuzz/dashboard.html @@ -0,0 +1,3747 @@ + + + + + + Component Fuzzers Project Dashboard + + + +
+
+
+

Component Fuzzers Project Dashboard

+
Scope: broad WordPress component fuzzing with deterministic generators and explicit invariants.
+
+
+ Updated: 2026-07-01
+ Branch: component-fuzzers +
+
+ +
+
+
Registered Surfaces
+
112
+
README parity confirmed: 112 documented
+
+
+
Latest Broad Run
+
100%
+
Non-skipped pass rate: 4814 passed, 0 failed, 0 errored, 1 skipped at 112 surfaces over 1 iteration after adding security admin nonce failure coverage.
+
+
+
Latest Focused Wave
+
100%
+
Latest focused addition: security invalid admin nonce coverage exercises check_admin_referer() failure paths through captured wp_die()/wp_nonce_ays(), including custom nonce-arg precedence, sanitized retry links with removed updated query args, special log-out confirmation URLs, legacy -1 admin-referer false returns without termination, exact check_admin_referer action payloads, and handler/filter/output-buffer cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent smokes passed 375 and 189 checks, bootstrap smoke passed, and broad smoke passed 4814 checks with 1 skip.
+
Previous focused addition: comments direct wp_list_comments() coverage exercises explicit comment-array type routing for comment, pings, pingback, trackback, and all views, echo/getter parity, wp_list_comments_args filter payloads through a recording walker, global $wp_query->comments/comments_by_type fallback, max_num_comment_pages cpage behavior, option-driven depth/pagination/order defaults, page/per-page override requerying through comments_pre_query, $overridden_cpage page computation, and loop/global cleanup; Feynman the 2nd identified the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 11575 checks, adjacent smokes passed 2445, 2565, and 2500 checks, bootstrap smoke passed, and broad smoke passed 4813 checks with 1 skip.
+
Previous focused addition: script-loader-runtime polyfill inline generation coverage exercises wp_get_script_polyfill() over generated feature tests and registered/unregistered handles, relative base URL expansion, content URL preservation, protocol-relative CDN URLs, version query args, script_loader_src filter payloads, filtered-empty skip behavior, skipped missing handles, exact document.write() fragment ordering, and filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts,frontend-features smoke passed 410 checks, bootstrap smoke passed, and broad smoke passed 4803 checks with 1 skip.
+
Previous focused addition: rest-controllers built-in search handler coverage exercises WP_REST_Post_Search_Handler and WP_REST_Term_Search_Handler subtype discovery, TYPE_ANY expansion, generated public versus hidden post types/taxonomies, attachment exclusion, include/exclude/search/page/per-page query mapping, rest_post_search_query/rest_term_search_query payloads, posts_pre_query/terms_pre_query no-SQL result/count short-circuits, protected/private title prefix removal, no-title post support, REST item/about links, permalink filters, and fixture/filter/type/taxonomy cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent rest-controllers,rest,rest-object-controllers,query,query-loop,taxonomy,template-links,content-lifecycle smoke passed 8050 checks, bootstrap smoke passed, and broad smoke passed 4802 checks with 1 skip.
+
Previous focused addition: rest-site-editor revision/autosave collection dispatch coverage exercises registered /wp/v2/templates/{id}/revisions and /wp/v2/templates/{id}/autosaves collection routes through an isolated WP_REST_Server, denied-before-query permissions, canonical double-slash template parent lookup, bounded revision WP_Query shapes, rest_revision_query payloads, template-id-preserving paginated revision links, HEAD empty-body behavior, invalid-page failures, autosave filtering, _fields projection, and filter/server cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent rest-site-editor,rest,rest-object-controllers,block-templates,revisions-autosaves,content-lifecycle,blocks smoke passed 435 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4802 checks with 1 skip.
+
Previous focused addition: blocks built-in block bindings source coverage exercises the core pattern-overrides, post-data, post-meta, and term-data source files, registration metadata, idempotent term-data registration, pattern context lookups, post context versus navigation attribute IDs, date/modified/link branches, REST-registered and protected meta gates, public/private/password post visibility gates, escaped term field/link output, non-public taxonomy read gates, and fixture/filter/registry cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent blocks,registries,block-supports,core-block-render,content-lifecycle,taxonomy,taxonomy-relationships,navigation,navigation-lifecycle smoke passed 450 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4801 checks with 1 skip.
+
Previous focused addition: editor-helpers internal link query/dialog coverage exercises _WP_Editors::wp_link_query() and wp_link_dialog() across generated post/custom-post fixtures, search/pagination query arguments, wp_link_query_args/posts_pre_query/wp_link_query payloads, sanitized hostile titles, post-date and custom-post info labels, permalinks, empty-result false behavior, one-shot dialog markup, nonce/search/result containers, accessibility attributes, cache isolation, generated post-type hook cleanup, and global/static restoration; Plato the 2nd reviewed the slice and the query-cache and future_$post_type cleanup fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4800 checks with 1 skip.
+
Previous focused addition: template-links author-template helper coverage exercises get_the_author()/the_author(), modified-author helpers, author metadata aliases, author links, author post counts, author-post links, wp_list_authors(), and is_multi_author() across echo/getter parity, no-global fail-closed branches, metadata filters, include/exclude/admin/empty/full-name/feed/count/plain-list branches, query-shape assertions, distinct-author semantics, transient/filter behavior, and scoped post/user/cache/filter cleanup; Locke the 2nd reviewed the implementation and the DB-stub reset, query-shape, show-fullname exclusion, distinct-author, and user-meta-path fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4799 checks with 1 skip.
+
Previous focused addition: block-widgets editor dependency warning coverage exercises wp_check_widget_editor_deps() across no-conflict, editor-only, both widget-editor handles, script conflicts, style conflicts, both-conflict, and dependency-chain enqueued scenarios, asserting scoped _doing_it_wrong() capture, warning function/version/message payloads, one warning per conflict class, asset queue/registration stability, and hook/global restoration; Lovelace the 2nd reviewed the implementation and the dependency/handle coverage fixes landed before validation; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4798 checks with 1 skip.
+
Previous focused addition: content-lifecycle page/post lookup helper coverage exercises get_page_by_path(), get_pages(), get_page_children(), and get_children() across deterministic page trees, encoded and full ancestry paths, attachment fallback and page/attachment slug collision preference, custom hierarchical post types, salted hit/miss post-queries caches and invalidation, get_pages() include/child/parent/exclude-tree/limit rewrites and filter payloads, get_children() numeric/object/global argument normalization, output-shape checks, and post/filter/global/post-type cleanup; Godel the 2nd identified the gap and Linnaeus the 2nd reviewed the implementation; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent content-lifecycle,query,query-loop,post-types,template-links,core-block-render,post-embeds,xmlrpc,fonts,default-widgets smoke passed 815 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4797 checks with 1 skip.
+
Previous focused addition: comments count/navigation helper coverage exercises get_comments_number(), get_comments_number_text()/comments_number(), get_comments_link()/comments_link(), comments_popup_link(), get_comments_pagenum_link(), next/previous comments links, paginate_comments_links(), and comments navigation/pagination wrappers across cached post comment counts, zero/one/many labels, respond/comment fragments, closed/password/custom-label popup branches, oldest/newest default-page edges, singular fail-closed behavior, attribute filters, echo/getter parity, class/ARIA wrappers, and scoped query/post/server/cookie/filter cleanup; Dewey the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 11350 checks, adjacent smokes passed 2395 and 2520 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4796 checks with 1 skip.
+
Previous focused addition: admin-bar single-site callback node graph coverage exercises wp_admin_bar_site_menu(), wp_admin_bar_new_content_menu(), wp_admin_bar_updates_menu(), wp_admin_bar_sidebar_toggle(), and wp_admin_bar_command_palette_menu() with synthetic capabilities, link-manager and user metadata gates, update-total filters, admin/front-end screen branches, command-palette script enqueue and shortcut labels, and scoped filter/user/screen/script/server/wpdb cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4795 checks with 1 skip.
+
Previous focused addition: post-embeds host-script enqueue gate coverage exercises wp_maybe_enqueue_oembed_host_js() and the back-compat wp_oembed_add_host_js action marker with generated post-embed blockquote markup, single-quoted class attributes, non-blockquote lookalikes, missing-action fail-closed behavior, exact HTML preservation, script queue assertions for wp-embed, and scoped hook/script cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4794 checks with 1 skip.
+
Previous focused addition: template-links archive navigation wrapper coverage exercises next_posts(), previous_posts(), get_next_posts_link(), get_previous_posts_link(), get_posts_nav_link()/posts_nav_link(), get_the_posts_navigation()/the_posts_navigation(), get_the_posts_pagination()/the_posts_pagination(), and _navigation_markup() across synthetic archive query states, first/middle/last/one-page edges, singular fail-closed checks, scoped attribute/template/pagination filters, echo/getter parity, class/ARIA escaping, and hook/global/server cleanup; Sagan the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent template-links,content,query,query-loop,core-block-render,rewrite smoke passed 3900 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4793 checks with 1 skip.
+
Previous focused addition: http encoding helper coverage exercises WP_Http_Encoding::compress(), decompress(), compatible_gzinflate(), accept_encoding(), should_decode(), content_encoding(), and is_available() across generated text/binary payloads, raw deflate, zlib-wrapped deflate, gzip headers with filename/comment/header-CRC fields, deterministic malformed payload fail-closed cases, accept-encoding policy gates and filter payloads, and zlib availability oracles; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent http,feed-parsers,feed-rendering,syndication smoke passed 90 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4792 checks with 1 skip.
+
Previous focused addition: http origin helper coverage exercises get_allowed_http_origins(), is_allowed_http_origin(), get_http_origin(), non-OPTIONS send_origin_headers(), wp_http_supports(), and allowed_http_request_hosts() with generated exact-origin variants, scoped allowlist/result/origin filters, port-stripping and slash/case rejection oracles, URL-derived SSL capability normalization, host allowlist mirroring, and server/filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent http,feed-parsers,feed-rendering,syndication smoke passed 88 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4791 checks with 1 skip.
+
Previous focused addition: multisite signup validation coverage exercises is_email_address_unsafe(), wpmu_validate_user_signup(), wpmu_validate_blog_signup(), and signup_nonce_check() with scoped default email validation filters, synthetic users, pending and stale wp_signups rows, unsafe/limited email domains, illegal login filters, blogname/title normalization, domain/path reservations, existing-user overrides, valid and invalid nonce branches, and filter/global cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 existing true-multisite skips, adjacent multisite,identity,email smoke passed 1266 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4790 checks with 1 skip.
+
Previous focused addition: formatting deep mapping helper coverage exercises map_deep(), urlencode_deep(), rawurlencode_deep(), urldecode_deep(), stripslashes_deep(), wp_slash(), and wp_unslash() over generated nested arrays and objects, asserting leaf visitation counts, container shape preservation, exact helper-specific transforms, array-only slash round trips, and bounded expansion; focused seeds 1, 224, and 57123 over 25 iterations each passed 425 checks, adjacent formatting,content,security,markup,kses,shortcodes smoke passed 1190 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4789 checks with 1 skip.
+
Previous focused addition: comments direct comment_form() coverage exercises closed-post handling, direct comment_form_title(), get_comment_id_fields()/comment_id_fields() parity, anonymous HTML5 and XHTML form rendering, required-field and email-note behavior, cookies consent, must-log-in and logged-in branches, dynamic field/submit filters, action hooks, synthetic current-user cache seeding, and no-DB sentinel coverage; Avicenna the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 11325 checks, adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 503 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4788 checks with 1 skip.
+
Previous focused addition: editor-helpers direct wp_tinymce_inline_scripts() coverage exercises classic-block filter fan-out, duplicate TinyMCE plugin de-duplication, caption disabling, toolbar/external-plugin merging, raw JSON/array/function serialization, boolean/string serialization, before-script attachment to wp-block-library, and scoped filter cleanup without browser TinyMCE execution; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent editor-helpers,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 67 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4783 checks with 1 skip.
+
Previous focused addition: date-time direct wp_timezone_choice() markup coverage exercises empty placeholder selection, named PHP timezone IDs, deprecated-but-valid BC timezone IDs, UTC grouping, manual UTC offset values and quarter-hour labels, hostile selected-zone strings, locale-invariant option values, balanced optgroups, single selected-option state, scoped pre_load_textdomain short-circuiting, l10n global restoration, and translation-controller cleanup without admin settings-page dispatch; Singer the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent date-time,l10n,translations,admin-options-submission,customizer,admin-screen smoke passed 310 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4780 checks with 1 skip.
+
Previous focused addition: media-metadata gallery and playlist shortcode rendering coverage exercises gallery_shortcode() and wp_playlist_shortcode() over cache-seeded image/audio/video attachments with posts_pre_query short-circuits, ordered ids mapping, image-only gallery selection, invalid tag fallback, HTML5 file-link galleries, caption/orientation/ARIA output, protected-parent fail-closed behavior, playlist type coercion, boolean normalization, ID3 key filters, thumbnail/MIME-icon branches, JSON escaping, one-time script hooks, and scoped filter/global cleanup; Gauss the 2nd reviewed the gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent media-metadata,shortcodes,images,media-editor,media-ingest,media-remote,admin-media-chrome,script-loader-runtime,content-lifecycle,template-links smoke passed 640 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4804 checks with 1 skip.
+
Previous focused addition: media-metadata public audio/video media shortcode rendering coverage exercises wp_audio_shortcode(), wp_video_shortcode(), and wp_mediaelement_fallback() across mediaelement and HTML5 library paths, typed source lists, invalid-source embedded-link fallbacks, override/library/class/output filters, fallback escaping, YouTube/Vimeo URL normalization, boolean/preload normalization, generated encoded payload URLs, and scoped filter/script/style/content-width cleanup without gallery, playlist, or browser playback dispatch; Peirce the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent media-metadata,images,media-editor,media-ingest,media-remote,admin-media-chrome,shortcodes,markup,script-loader-runtime,default-widgets smoke passed 254 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4779 checks with 1 skip.
+
Previous focused addition: template-links direct get_search_form() rendering coverage exercises generated hostile search queries and ARIA labels, HTML5/XHTML format forcing, echo/getter parity, legacy boolean return mode, ordered pre_get_search_form/search_form_args/search_form_format/get_search_form payloads, null-filter fallback, output-buffer stability, and scoped hook/query cleanup without browser template dispatch; Heisenberg the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent template-links,content,query,canonical-routing,rewrite,request-lifecycle,feed-rendering,syndication smoke passed 3715 checks, adjacent template-links,blocks,core-block-render,block-editor-adjuncts,template-hierarchy smoke passed 250 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4778 checks with 1 skip.
+
Previous focused addition: auth-flow direct wp_login_form() rendering coverage exercises generated hostile IDs, labels, username values, redirect URLs, required-field and remember-me toggles, echo/getter parity, default HTTPS redirect derivation from server globals, ordered login_form_defaults/login_form_top/login_form_middle/login_form_bottom filters, and scoped filter/server cleanup without browser login-page dispatch; Lagrange the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 240 checks, adjacent auth-flow,rest-application-passwords,rest,rest-controllers,rest-object-controllers smoke passed 285 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4777 checks with 1 skip.
+
Previous focused addition: cron public wp_cron() wrapper coverage exercises non-alternate shutdown deferral and immediate shutdown action execution, asserting due jobs remain queued when the wrapper only registers _wp_cron, isolated do_action( 'shutdown' ) dispatch runs exactly one spawn path, loopback payloads and doing_cron locks match, hook/action globals restore, and no real network request escapes; Hegel the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 2275 checks, adjacent cron,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 750 checks, adjacent cron,options-autoload,state,environment-load smoke passed 710 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4776 checks with 1 skip.
+
Previous focused addition: auth-flow logout lifecycle coverage exercises public wp_logout() composition over a synthetic current user with two session tokens and a stale logged-in cookie, asserting current-token destruction, sibling-token preservation, clear_auth_cookie/send_auth_cookies no-header behavior, current-user reset before the wp_logout action, action payloads, and failed stale-cookie rehydration through normal determine_current_user filters; Pauli the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 235 checks, adjacent auth-flow,rest-application-passwords,rest,rest-controllers smoke passed 220 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4775 checks with 1 skip.
+
Previous focused addition: customizer theme-preview lifecycle coverage exercises direct inactive and active WP_Customize_Manager::start_previewing_theme()/stop_previewing_theme() flows with a synthetic active theme plus inactive child/parent preview fixture, asserting the seven theme-switching filters at priority 10, global get_stylesheet()/get_template()/current-theme/root option resolution, idempotent second start/stop calls, action payloads, active-theme no-filter behavior, and cleanup; Dirac the 2nd reviewed the slice; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent customizer,customizer-persistence,customizer-nav-widgets-requests smoke passed 115 checks, adjacent plugin-theme-lifecycle,template-hierarchy,block-templates,rest-site-editor smoke passed 240 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4774 checks with 1 skip.
+
Previous focused addition: admin-options-submission core-page coverage exercises Reading, Discussion, Media, and Writing Settings submissions through the bounded options.php update flow, including pagination/front-page absint and checkbox defaults, closed discussion statuses, unique moderation/disallowed keyword lists, media dimension normalization, mailserver text stripping, success transient/redirect/nonce/capability oracles, and conditional Writing Settings allowlist gates for post-by-email, legacy DB-version options, and public-blog update services; Aquinas the 2nd identified the missing Writing gate branch; focused seeds 1, 224, and 57123 over 25 iterations each passed 200 checks, adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4773 checks with 1 skip.
+
Previous focused addition: taxonomy hierarchical term-link coverage exercises cache-backed parent/child WP_Term objects through custom taxonomy, category, and tag permalink generation, hierarchical rewrite parent-slug expansion, get_term_parents_list() linked and unlinked agreement, and ordered pre_term_link, category_link, tag_link, and term_link filter payloads with cleanup; Zeno the 2nd confirmed the no-DB taxonomy gap before implementation; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4771 checks with 1 skip.
+
Previous focused addition: state direct object-cache blog-switch coverage exercises wp_cache_switch_to_blog() without full multisite global switching, synthetic multisite-mode cache prefixes, blog-local custom and default groups, global-group sharing and delete behavior, raw stored-key prefix/unprefixed oracles, string blog-ID casting, and runtime/global restoration; Huygens the 2nd flagged adjacent multisite overlap, so this row is scoped to the direct cache API wrapper rather than switch_to_blog(); focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips, bootstrap smoke passed, and broad seed 224 smoke passed 4770 checks with 1 skip.
+
Previous focused addition: discovery favicon front-controller coverage exercises template-loader.php dispatch for is_favicon(), template_redirect traversal with restored redirect_canonical(), default do_favicon redirect-and-exit behavior, do_faviconico hook order, fallback and filtered get_site_icon_url() payloads, wp_redirect/wp_redirect_status/status_header/x_redirect_by filters, no-output child shutdown capture, and subprocess-unavailable skip behavior; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks, disabled-subprocess smoke passed 14 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4769 checks with 1 skip.
+
Previous focused addition: style global styles user-data/getter coverage exercises wp_get_global_settings(), wp_get_global_styles(), WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles(), get_user_data(), and get_user_global_styles_post_id() for active-theme wp_global_styles lookup, draft/wrong-theme decoys, custom/base origin separation, block-name path rewriting, variable resolution transforms, settings cache keys and cache cleaning, unsafe missing-flag fail-closed behavior, post-ID creation/cache reuse, and resolver/wpdb/global restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 400 checks, adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4768 checks with 1 skip.
+
Previous focused addition: discovery robots.txt front-controller coverage exercises do_robots() for emitted User-agent, Disallow, and Allow lines, site-path admin URL construction, public/private blog_public state, exact do_robotstxt action before robots_txt filtering, filter payload public flags, deterministic filter output extension, duplicate-line guards, and hook cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent discovery,canonical-routing,request-lifecycle,http,rest-site-editor,core-block-render,template-links smoke passed 460 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4767 checks with 1 skip.
+
Previous focused addition: navigation-lifecycle navigation fallback coverage exercises WP_Navigation_Fallback::get_fallback() and WP_Classic_To_Block_Menu_Converter::convert() for disabled creation, primary-location classic-menu priority over primary-slug and newest-menu decoys, inserted wp_navigation title/slug/status, parseable core/navigation-submenu and core/navigation-link content, top-level and nested item ordering, sanitized custom class/rel attributes, post-type target identity, duplicate-free fallback reuse, query-global state, and filter cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent navigation-lifecycle,navigation,customizer-nav-widgets-requests,rest-site-editor,block-templates,core-block-render,template-links,content-lifecycle,taxonomy-relationships smoke passed 490 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4766 checks with 1 skip.
+
Previous focused addition: content-lifecycle set_post_type() coverage exercises filtered target post-type mutation, raw pre_post_type and filtered type_save_pre payloads, stale post/parent/meta/archive cache eviction, page-specific all_page_ids cleanup, untouched sibling rows, missing-ID fail-closed behavior, cache-clean action locality, and hook cleanup; adjacent validation also hardened query to allow only known KSES save-pre hook additions while still failing unexpected hook callback drift; focused content seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, focused query sweeps passed 6440, 6440, and 3220 checks, adjacent content-lifecycle,post-types,query,taxonomy,taxonomy-relationships,comments,comment-workflow,revisions-autosaves,template-links smoke passed 5895 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4765 checks with 1 skip.
+
Previous focused addition: admin-dashboard Community Events markup/template coverage exercises wp_print_community_events_markup() and wp_print_community_events_templates() for the hidden widget shell, admin notice/error branches, AJAX form endpoint, stable IDs/classes/ARIA controls, location input and submit/cancel controls, results container, all four Underscore template IDs, city placeholders, event fields, organizer links, no generated executable markup, and output-buffer restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent admin-dashboard,community-events,admin-ajax,admin-screen,admin-workflows,user-preferences smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4764 checks with 1 skip.
+
Previous focused addition: registries block-bindings source coverage exercises WP_Block_Bindings_Registry, WP_Block_Bindings_Source, public wrapper parity, singleton identity, ordered aggregate registration, source property normalization, exact callback and block_bindings_source_value filter payloads, invalid name/property matrices, duplicate and missing unregister warnings, unregister/re-register tail ordering, and singleton/source restoration; Kuhn the 2nd reviewed the gap and snapshot caveats; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent registries,blocks,block-editor-adjuncts,rest-controllers,style,core-block-render smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4763 checks with 1 skip.
+
Previous focused addition: utility-internals diagnostic/error helper coverage exercises is_wp_error() and the is_wp_error_instance action payload/counter/context contract, wp_debug_backtrace_summary() raw/pretty/skip/ignore-class behavior over a controlled stack, and wp_trigger_error() always-run/filter/run hook ordering, suppression filters, WP_DEBUG gating, local error-handler capture, and sanitized emitted-message boundaries when debug triggering is active; Descartes the 2nd reviewed the gap and hook-state caveats; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent utility-internals,hooks,import-diff,registries,error-protection,site-health-debug smoke passed 305 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4762 checks with 1 skip.
+
Previous focused addition: environment-load memory-limit helper coverage exercises child-process wp_raise_memory_limit() contracts for default admin raises to WP_MAX_MEMORY_LIMIT, high-current no-lowering behavior, image_memory_limit raises above the core max, low cron_memory_limit fallback to the core max, dynamic custom context filters, unlimited-current early return without filter dispatch, scoped hook cleanup, explicit return/post-call memory_limit oracles, child per-case restoration, and unchanged parent memory_limit; Averroes the 2nd reviewed the gap and hardening; focused seeds 1, 224, and 57123 over 25 iterations each passed 450 checks, adjacent environment-load,site-health,site-health-debug,cron,media-editor,update-install-upgrader smoke passed 1520 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4761 checks with 1 skip.
+
Previous focused addition: utility-internals helper coverage exercises _wp_to_kebab_case(), wp_find_hierarchy_loop(), wp_find_hierarchy_loop_tortoise_hare(), wp_unique_id(), wp_unique_prefixed_id(), wp_unique_id_from_values(), wp_generate_uuid4(), wp_is_uuid(), and wp_validate_boolean() for lodash-compatible kebab-case fixtures and generated ASCII cases, terminating/self/cyclic hierarchy maps, start_parent override loops, direct tortoise-hare detection/enumeration, callback-argument propagation, process-wide and prefix-scoped monotonic ID counters, deterministic nested-value hashes, UUID v4/lowercase/variant gates, boolean casting semantics, and state restoration; Aristotle the 2nd reviewed the gap and recommended the kebab-case matrix; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent utility-internals,style,block-supports,fonts,core-block-render,blocks,content-lifecycle,taxonomy,post-types smoke passed 1000 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4760 checks with 1 skip.
+
Previous focused addition: content-lifecycle featured-image helper coverage exercises set_post_thumbnail(), delete_post_thumbnail(), get_post_thumbnail_id(), has_post_thumbnail(), get_the_post_thumbnail(), the_post_thumbnail(), thumbnail URL/caption helpers, and update_post_thumbnail_cache() for meta insert/update/delete paths, missing-object fail-closed behavior, non-image cleanup, image source and attribute filtering, begin/end fetch hooks, echo/getter parity, escaped URL output, attachment caption/display filters, cache priming, and filter/global restoration; Mencius the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 325 checks, adjacent content-lifecycle,template-links,images,admin-edit-metaboxes,post-embeds,revisions-autosaves,media-metadata,media-ingest,admin-media-chrome smoke passed 940 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4757 checks with 1 skip.
+
Previous focused addition: content-lifecycle post/attachment count and MIME helper coverage exercises wp_count_posts(), wp_count_attachments(), get_post_mime_types(), and get_available_post_mime_types() for status grouping, readable private-post filtering, zero-filled post statuses, count cache keys, attachment MIME/trash grouping, exact/wildcard/array MIME filters, converted MIME groups, DB-backed available MIME lists, short-circuit filter behavior, and filter/current-user restoration; Helmholtz the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent content-lifecycle,content,post-types,query,media-ingest,media-metadata,network-media,rest-object-controllers smoke passed 17875 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4756 checks with 1 skip.
+
Previous focused addition: blocks post-object and REST block hook coverage exercises apply_block_hooks_to_content_from_post_object(), update_ignored_hooked_blocks_postmeta(), and insert_hooked_blocks_into_rest_response() for wrapper root first/last-child hooks, suppressed wrapper before/after hooks, persisted ignored metadata, REST content.raw/meta mutation, rendered-content refresh, single-instance metadata suppression, and filter/fixture restoration; Herschel the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 225 checks, adjacent blocks,block-editor-adjuncts,block-supports,block-templates,block-widgets,core-block-render,markup,rest-controllers smoke passed 2225 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4755 checks with 1 skip.
+
Previous focused addition: comment-workflow notification mail-path coverage exercises approved post-author and pending moderator wrapper gates, direct wp_notify_postauthor()/wp_notify_moderator() behavior, filtered recipient/header/subject/body payloads, scoped author capability behavior, pre_wp_mail delivery interception, and filter/option/current-user restoration; Raman the 2nd reviewed the gap and risks; focused seeds 1, 224, and 57123 over 25 iterations each passed 250 checks, adjacent comment-workflow,comments,content-lifecycle,content,mail,email smoke passed 5232 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4754 checks with 1 skip.
+
Previous focused addition: rest-application-passwords response-shaping coverage exercises direct WP_REST_Application_Passwords_Controller::prepare_item_for_response() and dispatched item GET responses for edit/view/embed context filtering, one-time password exposure only in edit context, stored-hash non-exposure, formatted created/last_used timestamps, last_ip preservation, generated _fields projection with and without requested links, forbidden _fields=password view responses, record_application_password_usage() first-use metadata, same-day usage throttling, missing-UUID fail-closed behavior without storage mutation, and self-link canonicalization; Hilbert the 2nd reviewed the gap and suggested the strengthened oracles; focused seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent REST/security smoke passed 144 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4753 checks with 1 skip.
+
Previous focused addition: mail RFC2822 display-name MIME header coverage exercises wp_mail() through the fake PHPMailer handoff with UTF-8 display names and ASCII mailbox addresses for To, From, Cc, Bcc, and Reply-To; the row asserts PHPMailer recipient arrays, literal mailbox preservation in final MIME headers, exactly-one unfolded address headers, encoded-word display names, iconv_mime_decode() round trips back to readable address forms, custom header preservation, unset SMTP Sender, UseSMTPUTF8 staying false, success-action payloads, and hook cleanup; Cicero the 2nd independently identified the same gap; focused seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent mail,email,privacy smoke passed 1280 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4752 checks with 1 skip.
+
Previous focused addition: privacy export ZIP generation coverage exercises wp_privacy_process_personal_data_export_page() with the real wp_privacy_generate_personal_data_export_file() action, filtered temp export roots, exact export.json/index.html ZIP contents, escaped report HTML, export-created action payloads, index protection, temp report cleanup, legacy _export_file_path migration, invalid request/action/email/JSON fail-closed paths through captured AJAX JSON errors, and action/filter restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent privacy/admin/filesystem/mail smoke passed 315 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4751 checks with 1 skip.
+
Previous focused addition: filesystem archive/copy/move coverage exercises wp_zip_file_is_valid(), unzip_file() through both ZipArchive and PclZip routes, safe archive manifests, traversal and __MACOSX skips, unzip filter payload/restoration, standard filesystem chmod initialization, copy_dir() missing-source and nested skip-list contracts, and move_dir() same-source, existing-destination, overwrite, and copy-fallback behavior; focused seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent filesystem consumer smoke passed 265 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4750 checks with 1 skip.
+
Previous focused addition: rest-site-editor collection GET dispatch coverage exercises registered /wp/v2/templates and /wp/v2/template-parts routes through WP_REST_Server, capability-allowed collection callbacks, bounded pre_get_block_templates fixtures, post-type and area query propagation, _fields projection, content block-version serialization, denied-before-lookup guards, HEAD no-query behavior, and filter/server cleanup; focused seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent REST/template smoke passed 310 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4748 checks with 1 skip.
+
Previous focused addition: formatting wptexturize() coverage exercises exact quote, apostrophe, dash, ellipsis, dimension, ampersand, default protected-tag, filtered protected-tag, protected shortcode, unprotected registered-shortcode, HTML comment, punycode double-hyphen, split-regex recomposition, direct prime classification, marker-leak, idempotence, and filter-cleanup oracles; focused seeds 1 and 224 over 25 iterations each passed 400 checks, seed 100 over 50 iterations passed 800 checks, adjacent formatting/shortcodes/markup/content smoke passed 1900 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4748 checks with 1 skip.
+
Previous focused addition: rewrite pretty-permalink url_to_postid() coverage exercises generated rewrite-rule matching, site-base and www URL normalization, query/fragment stripping, custom post-type query-var mapping, non-public query-var filtering, short-circuited WP_Query singular post oracles, missing-post fail-closed behavior, and hook/post-type cleanup; focused seeds 1, 224, and 490 over 25 iterations each passed 475 checks, adjacent rewrite/request/canonical/query smoke passed 2115 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4747 checks with 1 skip.
+
Previous focused addition: discovery sitemap stylesheet output coverage exercises WP_Sitemaps_Stylesheet sitemap and index XSL structure, URL-count expressions, sitemap-only column guards, index-only field boundaries, LTR/RTL CSS alignment, CSS/content filter locality, and wp_locale restoration; focused seeds 1, 224, and 49949 over 25 iterations each passed 325 checks, adjacent discovery/REST/rewrite smoke passed 180 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4746 checks with 1 skip.
+
Previous focused addition: rest-controllers menu-location controller coverage exercises WP_REST_Menu_Locations_Controller route/schema contracts, anonymous denial, read-access filter overrides, edit_theme_options capability access, _fields projection, assigned menu IDs, REST links, prepare filters, invalid-location errors, and nav-menu/current-user cleanup; focused seeds 1, 224, and 40878 over 25 iterations each passed 400 checks, adjacent REST/navigation smoke passed 275 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4745 checks with 1 skip.
+
Previous focused addition: rest-controllers block renderer controller coverage exercises WP_REST_Block_Renderer_Controller route/schema registration, permission and context errors, invalid and unknown attribute rejection, dynamic attribute sanitization, boolean coercion, JSON POST body handling with post context, non-dynamic block rejection, pre_render_block short-circuit payloads, and filter cleanup; focused seeds 1, 224, and 45098 over 25 iterations each passed 375 checks, adjacent REST/block smoke passed 510 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4744 checks with 1 skip.
+
Previous focused addition: admin-screen settings error and admin notice coverage exercises add_settings_error(), get_settings_errors(), settings_errors(), transient replay/deletion, sanitize side effects, legacy updated class mapping, hide-on-update behavior, wp_get_admin_notice() exact markup, notice args/markup filters, wp_admin_notice action dispatch, KSES output sanitization, and hook cleanup; focused seeds 1, 224, and 57791 over 25 iterations each passed 250 checks, adjacent admin smoke passed 170 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4742 checks with 1 skip.
+
Previous focused addition: feed-rendering exact rss_enclosure() fixture coverage exercises Core newline fixture variants, invalid one-line and one-break-line suppression, empty global post and no-meta no-op behavior, multiple enclosure meta rows, first-MIME-token normalization, filter replacement payloads, and filter cleanup; focused seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent feed-rendering,feed-parsers,syndication,http,widgets smoke passed 265 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4741 checks with 1 skip.
+
Previous focused addition: kses exact wp_kses_hair() parser fixture coverage exercises empty and prematurely terminated attributes, quoted/single/unquoted normalization, named/numeric/invalid entity recoding, duplicate first-wins behavior, malformed unclosed quotes, colon/dot and special-character attribute names, slash-separated attributes, spaces around equals, malformed equals patterns, URI protocol filtering, non-URI preservation, and custom allowed protocols; focused seed 1 over 25 iterations passed 27528 checks, seed 224 over 25 iterations passed 27537 checks, adjacent KSES/security/markup/shortcodes/formatting smoke passed 3528 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4740 checks with 1 skip.
+
Previous focused addition: canonical-routing old-slug/date redirect coverage exercises wp_old_slug_redirect() over DB-stub-backed _wp_old_slug/_wp_old_date joins, date-qualified slug matching, direct old-date and slug-plus-date fallback lookups, paged/embed URL suffixes, posts last_changed cache-hit and invalidation behavior, old_slug_redirect_post_id/old_slug_redirect_url filter capture, fail-closed gates for multi-type, hierarchical, non-404, and empty-name requests, and a narrow alias-aware wpdb SQL recognizer; seeds 1 and 224 over 25 iterations each passed 400 checks, adjacent canonical/request/template/content smoke passed 340 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4739 checks with 1 skip.
+
Previous focused addition: request-lifecycle send_headers() exit-path coverage exercises feed conditional GET 304 exits for matching ETag/date validators, error status exits for explicit 403 query vars, status/header filter payloads before termination, absence of send_headers action after exit-required branches, and child-process isolation with structured JSON shutdown reporting; seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent request/security/routing/rewrite/HTTP smoke passed 360 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4738 checks with 1 skip.
+
Previous focused addition: appearance-media background remove redirect coverage exercises Custom_Background::take_action() remove-background nonce validation, same-host wp_safe_redirect() intent capture without headers, unsafe referer fallback routing, background image/thumb clearing, invalid nonce wp_die() fail-closed behavior, check_admin_referer observability, and filter/superglobal cleanup; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent appearance/media/customizer/filesystem smoke passed 350 checks with no skips, and broad seed 224 smoke passed 4736 checks with 1 skip.
+
Previous focused addition: revisions-autosaves preview request dispatch exercises _show_post_preview() missing-query no-ops, valid nonce filter installation, casted preview_id nonce actions, the_preview autosave overlays, invalid nonce wp_die() 403 capture, direct wp_verify_nonce_failed observability without check_admin_referer, and superglobal/filter/user cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent revisions/content/query/security/auth/request-lifecycle smoke passed 3550 checks with no skips, and broad seed 224 smoke passed 4735 checks with 1 skip.
+
Previous focused addition: canonical-routing attachment redirect coverage exercises redirect_canonical() with synthetic parent/attachment rows, enabled attachment-page attachment_id query redirects to the attachment permalink, disabled attachment pages redirecting public-parent attachments to the raw attachment URL, non-public parent fail-closed behavior, query cleanup, GUID fallback URL projection, option/filter cleanup, and DB-stub reset; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent routing/content smoke passed 65 checks with no skips, and broad seed 224 smoke passed 4734 checks with 1 skip.
+
Previous focused addition: post-types status viewability coverage exercises is_post_status_viewable() with explicit public, private, custom, built-in emulation, internal, and protected status cases, generated status-property matrices, exact string/object lookup parity, unsanitized-name and non-string-scalar fail-closed paths, complete unregistered status objects, strict boolean filter return behavior, early-return filter bypass, and filter cleanup; focused seed 224 passed 13 checks, seeds 1, 224, and 49380 over 25 iterations each passed 325 checks, adjacent post/content/query/taxonomy/REST smoke passed 731 checks with no skips, and broad seed 224 smoke passed 4733 checks with 1 skip.
+
Previous focused addition: formatting file-name and username sanitizer coverage exercises exact Core fixtures for multi-extension munging, Unicode space normalization, invalid UTF-8 stripping, unnamed-file fallback, percent/entity/tag removal, strict username ASCII reduction, generated path/control/extension/user cases, idempotence, unsafe-character rejection, and filter cleanup; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent formatting/security/identity/l10n smoke passed 1339 checks with no skips, and broad seed 224 smoke passed 4732 checks with 1 skip.
+
Previous focused addition: block-supports duotone coverage exercises support registration gates, legacy metadata migration, preset/custom/unset/global-style render paths, empty-content CSS generation, unsupported/no-attribute fail-closed paths, safe stored CSS/SVG/editor assets, Core preset slug normalization boundaries, and WP_Duotone static restoration; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent block/editor/style smoke passed 66 checks with no skips, and broad seed 224 smoke passed 4731 checks with 1 skip.
+
Previous focused addition: image-metadata real Core fixture replay covers camera/timestamp/fraction fields, IPTC credit/caption/copyright/title, UTF-8 captions, keywords, orientation, accessibility alt text, metadata shape, final filter payloads, and filter restoration across shuffled JPEG fixtures; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent media parser/chrome smoke passed 87 checks with no skips, standalone rest-media-attachments sanity passed 7 checks, and broad seed 224 smoke passed 4730 checks with 1 skip.
+
Previous focused addition: email make_clickable() coverage links full ACE/punycode final labels without leaving partial xn anchors, keeps malformed final labels unlinked, and converts the old punycode-TLD partial-link skip into a failing assertion; focused seed 224 passed 1248 checks, seed 1 passed 1245 checks, adjacent email,mail,identity smoke passed 1260 checks with no skips, and broad seed 224 smoke passed 4729 checks with 1 skip.
+
Previous focused addition: block-templates direct-ID path containment guards get_block_file_template() and get_block_template() against traversal slugs that point at existing files outside the active theme template directories, converts the guarded skip into a failing assertion, preserves file enumeration confinement, and keeps template CPT queries short-circuited; focused seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks with no skips, adjacent block/theme/REST smoke passed 84 checks with no skips, and broad seed 224 smoke passed 4728 checks with 2 skips.
+
Previous focused addition: admin-list-tables application-password Last IP coverage converts the hostile stored last_ip boundary into an escaping assertion, verifies the row contains esc_html() output, preserves the empty &mdash; fallback, and keeps the synthetic list-table fixture path no-DB; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent admin/security smoke passed 78 checks with no skips, and broad seed 224 smoke passed 4727 checks with 3 skips.
+
Previous focused addition: classic-walkers nav-menu query coverage exercises direct _wp_ajax_menu_quick_search() post-type and taxonomy JSON responses, direct get-item JSON/markup branches, query-argument oracles for bounded quick search, post-type archive pseudo-items, post-type and taxonomy View All pagination, quick-search tab activation, escaped hostile labels/titles/terms, scoped posts_pre_query/terms_pre_query fixtures, and temporary object-type/filter cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent admin/navigation/content/query smoke passed 729 checks with 1 existing skip, and broad seed 224 smoke passed 4726 checks with 4 skips.
+
Previous focused addition: admin-ajax Find Posts coverage exercises find_posts_div() modal output, escaped hostile found_action values, valid/empty/invalid wp_ajax_find_posts() request paths, public post-type query construction without attachments, query short-circuit filtering for hidden/attachment/nonmatching fixtures, escaped table rows for title/type/date/status output, nonce hook observability, and request/user/post-type/hook cleanup; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent admin/content/query smoke passed 711 checks, and broad seed 224 smoke passed 4725 checks with 4 skips.
+
Previous focused addition: rest-directory-services block-directory response-shaping coverage exercises public REST dispatch _fields projection, explicit block title/icon mapping, direct installed-plugin link discovery through seeded plugin cache, plugins_api() request payloads, no-live-HTTP guards, and plugin-cache cleanup; focused seed 224 passed 6 checks, seeds 1 and 224 over 25 iterations each passed 150 checks, adjacent REST/directory/block/plugin smoke passed 97 checks with 1 existing skip, and broad seed 224 smoke passed 4724 checks with 4 skips.
+
Previous focused addition: core-block-render frontend list-style block coverage exercises server-rendered core/archives, core/categories, core/latest-posts, core/latest-comments, core/tag-cloud, and core/calendar callbacks with deterministic post/comment/term fixtures, archive/calendar cache seeding, dropdown script oracles, latest-posts category migration reinstallation across restored filter state, category hierarchy cache handling, comment/content modes, tag font units, hidden-calendar behavior, and posts/comments/terms filter cleanup; focused seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks, adjacent block/navigation smoke passed 131 checks, and broad seed 224 smoke passed 4720 checks with 4 skips.
+
Previous focused addition: metadata REST meta field coverage exercises WP_REST_Meta_Fields registered-field discovery, custom exposed names, schema generation for single/multi/object meta, recursive object additionalProperties defaults, default and prepared values, associative meta parameter validation, denied updates, schema validation failures, capability-gated updates, multi-value replacement, null/empty-array reset semantics, subtype/capability filter cleanup, and in-memory metadata readback; focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent metadata/REST/content/options/auth/capabilities smoke passed 410 checks, and broad seed 224 smoke passed 4719 checks with 4 skips.
+
Previous focused addition: plugin-theme-lifecycle REST plugin create/install coverage exercises WP_REST_Plugins_Controller::create_item() with generated ZIP packages, mocked plugins_api() responses, direct filesystem transport, missing-plugin 404 mapping, generic API 500 mapping, inactive install response and file-state oracles, active install and activation state, broad activation permission denial, plugin-specific activation denial after install, bounded upgrader_pre_download package copies, and temporary suspension/restoration of core updater hooks that would otherwise escape into update-table checks; focused seed 224 passed 14 checks with no skips, seeds 1 and 224 over 25 iterations each passed 350 checks with no skips, adjacent REST/upgrader/filesystem smoke passed 270 checks, and broad seed 224 smoke passed 4718 checks with 4 skips.
+
Previous focused addition: template-links adjacent image link coverage exercises exact previous/next image text anchors, first/last attachment edge behavior, echo wrappers, generated image markup for sibling attachments, escaped alt text, and dynamic previous_image_link/next_image_link filter payload cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent media/template smoke passed 300 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4743 checks with 1 skip.
+
Previous focused addition: error-protection process-control subprocess coverage exercises wp_register_fatal_error_handler(), WP_Recovery_Mode_Link_Service::handle_begin_link(), and WP_Recovery_Mode::handle_exit_recovery_mode() in a child PHP process; the oracles assert readable fatal-error handler drop-in shutdown execution, valid recovery begin-link key consumption and redirect, invalid begin-link WP_Error dies without mutation, inactive exit safe redirects, invalid active exit nonce 403 dies before cleanup, valid active exit cleanup of paused extensions and rate limits, no stray output before JSON reporting, and parent-process isolation; focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent process-control smoke passed 41 checks, and broad seed 224 smoke passed 4717 checks with 4 skips.
+
Previous focused addition: admin-list-tables direct link-manager and post-comments subclass coverage exercises WP_Links_List_Table and WP_Post_Comments_List_Table without live database access; the oracles assert bookmark cache/query argument handling, link-category term fixtures, legacy link_manager_enabled capability gating, link row IDs/edit-delete nonce URLs/current-category output/custom-column dispatch, compact hidden post-comments table rendering, fixed post-comments per-page behavior, post-scoped comment queries, row actions, class/column contracts, escaped views, and filter/cache restoration; focused seed 224 passed 11 checks with 1 documented current-core skip, seeds 1 and 224 over 25 iterations each passed 275 checks with 25 current-core skips, adjacent admin/bookmark/comment smoke passed 520 checks with 1 skip, and broad seed 224 smoke passed 4716 checks with 5 skips.
+
Previous focused addition: core-block-render server-side navigation block coverage exercises explicit-inner-block core/navigation rendering with dynamic navigation links, submenus, home links, and page lists; the oracles assert escaped custom-link labels/descriptions/attributes, submenu visibility migration from openSubmenusOnClick to submenuVisibility, responsive overlay controls, Interactivity API directives and view-module enqueueing, duplicate ariaLabel uniqueness, synthetic page-list active/ancestor classes, self-reference fail-closed behavior, no content mutation, and WP_Navigation_Block_Renderer static restoration; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent block/navigation smoke passed 130 checks, and broad seed 224 smoke passed 4715 checks with 5 skips.
+
Previous focused addition: community-events admin AJAX wrapper coverage exercises wp_ajax_get_community_events() nonce-protected request setup, slashed/unslashed location and timezone inputs, captured JSON success/error envelopes, no ttl leak, trimmed events with WordCamp pinning, user-location persistence on initial IP and manual search, same-IP no-write preservation, API-error no-write behavior, targeted Community Events transient isolation, and full request/global/filter restoration; focused seed 224 passed 10 checks, seeds 1 and 224 over 25 iterations each passed 250 checks, adjacent AJAX/dashboard/HTTP/user-preference smoke passed 54 checks, and broad seed 224 smoke passed 4714 checks with 5 skips.
+
Previous focused addition: utility-internals non-default token-map export coverage fixes WP_Token_Map::to_array() prefix reconstruction for key lengths other than 2, then exercises deterministic key-length 1 and 3 maps with short and long shared-prefix tokens, exact contains()/read_token() agreement, order-insensitive export equivalence, no embedded-NUL exported keys, precomputed-table export equivalence, and precomputed_php_source_table() key-length emission; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent utility smoke passed 53 checks, and broad seed 224 smoke passed 4713 checks with 5 skips.
+
Previous focused addition: feed-parsers RDF and fetch_feed() orchestration coverage exercises Magpie RSS 1.0/RDF root detection, rdf:about item attributes, namespace-aware dc:creator and content:encoded preservation, image/textinput parsing, empty URL handling, one-item array normalization, multi-feed RSS/Atom merge behavior, multi-feed error aggregation, wp_feed_options and cache-lifetime filter payloads, deterministic repeated fetches through the cache-enabled path, preempted local HTTP responses, scoped charset setup, no live network attempts, and full filter/state restoration; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent feed/syndication/widget smoke passed 52 checks, and broad seed 224 smoke passed 4712 checks with 5 skips.
+
Previous focused addition: update-install-upgrader plugin/theme automatic-update notification coverage exercises protected WP_Automatic_Updater::after_plugin_theme_update() and send_plugin_theme_email() through a tiny synthetic subclass, without calling updater run loops or real update paths; the success and fail-closed paths assert success/fail/mixed classification, duplicate failure suppression through auto_plugin_theme_update_emails, success clearing of stale failure versions, mixed success/failure persistence, notification-filter suppression, exact auto_plugin_theme_update_email type ordering, captured email subject/body content for plugin/theme names and management URLs, intercepted wp_mail(), restored option/filter state, and no network attempts; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent plugin/theme/upgrader smoke passed 58 checks with 1 existing skip, and broad seed 224 smoke passed 4710 checks with 5 skips.
+
Previous focused addition: wxr-export edge/resilience coverage exercises export_wp() content fallback and nested WXR helpers through the existing isolated child process; the success paths assert non-exportable post type exclusion, explicit non-exportable content fallback to posts plus reachable attachments, invalid content fallback to the exportable post type set, null post/term/comment meta serialization as empty WXR values, approved-comment emission with spam exclusion, get_comment-filtered null omission without empty wp:comment nodes, nav-menu terms only in full exports, associative-array wpdb::prepare() handling in the WXR double, and parent/child state cleanup; focused seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent wxr-export,import-diff,content smoke passed 36 checks, and broad seed 224 smoke passed 4709 checks with 5 skips.
+
Previous focused addition: xmlrpc authenticated post write-method coverage exercises wp_xmlrpc_server::wp_newPost(), wp_editPost(), and wp_deletePost() against in-memory user/post fixtures; the success and fail-closed paths assert auth failure and capability denial without mutation, caller-supplied ID rejection, unknown-status draft fallback, IXR date conversion, stale edit conflicts, valid edit preservation of omitted fields, post-type change rejection, delete missing/trash behavior, scoped xmlrpc_call/xmlrpc_wp_insert_post_data hooks, no HTTP attempts, option/runtime/global cleanup, and a scoped rewrite stub for post slug generation; focused seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent XML-RPC/content smoke passed 1162 checks, and broad seed 224 smoke passed 4708 checks with 5 skips.
+
Previous focused addition: privacy-admin-requests admin form and default processor coverage exercises _wp_personal_data_handle_actions(), _wp_personal_data_cleanup_requests(), and Core's default export/erasure AJAX processor filters through captured admin request paths; the success paths assert pending export creation with confirmation mail, username-resolved confirmed erasure creation, invalid requester rejection, retry key regeneration, expired-pending cleanup, raw export accumulation without file/mail side effects, erasure completion metadata/action dispatch, scoped filter cleanup, and narrow in-memory WP_Query support for request-title/date bounds; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent privacy/admin/mail smoke passed 1314 checks with 1 skip, and broad seed 224 smoke passed 4707 checks with 5 skips.
+
Previous focused addition: admin-list-tables theme install list-table replay coverage keeps the first in-process WP_Theme_Install_List_Table::prepare_items() API-argument invariant intact, then replays later iterations by seeding concrete table items, pagination, view globals, theme fixtures, allowed tags, and capability/action filters without re-requiring theme-install.php; the replay asserts install/update/latest-installed/newer-installed action states, custom action filter slugs, screenshots, preview metadata, descriptions, views, display output escaping, capability gates, and theme-root/global/server/filter cleanup; focused seed 224 passed 10 checks with 1 documented current-core skip, seeds 1 and 224 over 25 iterations each passed 250 checks with 25 current-core skips, adjacent admin smoke passed 42 checks with 1 skip, and broad seed 224 smoke passed 4705 checks with 5 skips.
+
Previous focused addition: rest-controllers block pattern local theme file-loader coverage exercises WP_Theme::get_block_patterns(), _register_theme_block_patterns(), and WP_REST_Block_Patterns_Controller::get_items() against a scoped temp block theme; the success paths assert active-theme path resolution, patterns/*.php scanning, required-header rejection, comma-list/int/bool header normalization, pattern cache TTL/type behavior, lazy filePath content loading, category migration, duplicate slug preservation, REST field projection, local no-source behavior, and temporary hook/cache/file cleanup; focused seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent block/REST smoke passed 57 checks with 1 skip, and broad seed 224 smoke passed 4705 checks with 5 skips.
+
Previous focused addition: rest-controllers block pattern remote-loader coverage exercises WP_REST_Block_Patterns_Controller::get_items() with intercepted pattern-directory REST responses for core, featured, and theme loaders; the success paths assert internal request parameters, one-shot loader dispatch, source values, snake-case field normalization, category migration, duplicate suppression, theme-json slug loading through a scoped temp file, remote-load filter gates, and temporary hook/file cleanup; focused seed 224 passed 13 checks, seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent block/REST smoke passed 56 checks with 1 skip, and broad seed 224 smoke passed 4704 checks with 5 skips.
+
Previous focused addition: rest-site-editor template mutation lifecycle coverage exercises WP_REST_Templates_Controller create, custom update, theme-template delete rejection, theme-template promotion/reset, non-force trash, already-trashed, and force-delete paths for templates and template parts; the success paths assert 201 Location headers, stored post fields, origin metadata, theme and area taxonomy assignments, REST insert hooks, wp_after_insert_post payloads, trash slug preservation, previous-response data, and temporary hook/filter cleanup; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent Site Editor/template smoke passed 66 checks with 1 skip, and broad seed 224 smoke passed 4703 checks with 5 skips.
+
Previous focused addition: customizer-persistence save AJAX envelope coverage exercises unauthenticated, not-preview, invalid nonce, malformed changeset JSON, bad status, bad date, and successful draft save paths through captured wp_send_json_* responses; the success path asserts stored changeset content, JS-prepared setting validities, customize_save_response filtering, edit-lock ownership, and filter cleanup; focused seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent Customizer/admin smoke passed 44 checks, and broad seed 224 smoke passed 4702 checks with 5 skips.
+
Previous focused addition: plugin-theme-lifecycle REST plugin write/delete coverage exercises capability-denied update gates without mutation, REST status transitions from inactive to active and back, active-plugin delete rejection, inactive temp-plugin deletion through direct filesystem transport, previous-response data, active option cleanup, and plugin cache refresh; focused seed 224 passed 13 checks, seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent plugin/REST/filesystem smoke passed 60 checks with 1 skip, and broad seed 224 smoke passed 4701 checks with 5 skips.
+
Previous focused addition: rest-media-attachments raw sideload coverage exercises client-side media-processing sideloads for generated subsize files and original-image metadata, non-image/PDF rejection, exact upload bytes in isolated temp roots, _fields response projection, Location headers, preserved base attachment files, and conversion/capability filter cleanup; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent media/REST smoke passed 61 checks, and broad seed 224 smoke passed 4700 checks with 5 skips.
+
Previous focused addition: privacy-admin-requests bulk resend coverage exercises mixed valid/invalid privacy request selections, nonce-gated resend bulk action accounting, regenerated confirmation keys, stored hash validation, intercepted confirmation emails, subject/content/header filters, placeholder replacement, and temporary filter cleanup; focused seed 224 passed 5 checks, seeds 1 and 224 over 25 iterations each passed 125 checks, adjacent privacy/admin/mail smoke passed 1312 checks with 1 skip, and broad seed 224 smoke passed 4699 checks with 5 skips.
+
Previous focused addition: admin-options-submission pending admin email coverage exercises changed-address new_admin_email submissions through the documented dynamic option hooks, pending adminhash creation, intercepted confirmation mail, same-current and invalid-email no-mail/no-hash branches, and temporary hook/filter cleanup; focused seed 224 passed 6 checks, seeds 1 and 224 over 25 iterations each passed 150 checks, adjacent admin options/mail/email/security smoke passed 1304 checks with 1 skip, and broad seed 224 smoke passed 4699 checks with 5 skips.
+
Previous focused addition: post-embeds custom provider and autoembed cache coverage landed through 6a78c3ec42, exercising public wp_oembed_add_provider()/wp_oembed_remove_provider(), no-network wp_oembed_get(), provider fetch URL dimensions/DNT/original URL, WP_Embed::shortcode() post-meta cache writes, repeated cache hits, standalone/paragraph autoembed replacement, inline URL preservation, and provider/filter/state restoration; focused seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent post-embeds/REST/HTTP/content/template/media smoke passed 71 checks, and broad seed 224 smoke passed 4696 checks with 5 skips.
+
Previous focused addition: security referer retrieval and native password-hash migration coverage landed through 764df53851, exercising raw/safe/original referer precedence and restoration, current-request self rejection, external redirect rejection, native unprefixed bcrypt verification, wrong-password and mutated-hash rejection, and forced WordPress rehash migration signals; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent security/request/formatting/routing/HTTP smoke passed 66 checks, and broad seed 224 smoke passed 4695 checks with 5 skips.
+
Earlier focused addition: shortcodes HTML-attribute boundary coverage landed through ec3e228603, exercising accepted quoted/unquoted attribute rendering, KSES-rejected onclick and unknown attributes that still execute callbacks before restoring literals, ignore_html delimiter placeholders, escaped shortcode attributes/tag names, comments, CDATA, callback-count accounting, and media image-context hook restoration; focused seed 224 passed 22 checks, seeds 1 and 224 over 25 iterations each passed 550 checks, adjacent markup/blocks/KSES smoke passed 1163 checks, and broad seed 224 smoke passed 4693 checks with 5 skips.
+
Earlier focused addition: network-media upload-bits coverage landed through ece71f4765, exercising wp_upload_bits() exact-byte writes into isolated filtered upload roots, year/month upload paths, virtual pre_wp_unique_filename_file_list subsize collisions, wp_upload_bits/wp_unique_filename/wp_handle_upload filter payloads, filter-error short-circuits, empty-name pre-filter failures, and filter restoration; focused seed 224 passed 1 aggregate row with 299 cases, 1667 API calls, and 2087 assertions, seeds 1 and 224 over 25 iterations each passed 25 aggregate rows, adjacent media/filesystem smoke passed 55 checks, and broad seed 224 smoke passed 4692 checks with 5 skips.
+
Earlier focused addition: rest-directory-services directory-service oracle coverage landed through e581555577, exercising pattern-directory WordPress.org query allowlisting, derived-argument overwrites for client-supplied proxy parameters, and URL-details malformed/unclosed head metadata fallback precedence; focused seed 224 passed 5 checks, seeds 1 and 224 over 25 iterations each passed 125 checks, adjacent REST/HTTP/media smoke passed 56 checks, and broad seed 224 smoke passed 4692 checks with 5 skips.
+
Earlier focused addition: error-protection pure process-boundary-adjacent coverage landed through 61e22a3455, exercising synthetic fatal-handler handle() orchestration and recovery-link begin-link early-return branches without registering shutdown handlers, redirecting, dying, or performing loopbacks; focused seed 224 passed 11 checks with 1 skip, seeds 1 and 224 over 25 iterations each passed 275 checks with 25 skips, adjacent error/Site Health/HTTP/filesystem smoke passed 55 checks with 1 skip, and broad seed 224 smoke passed 4692 checks with 5 skips.
+
Earlier focused addition: script-loader-runtime emoji loader-asset skip retirement landed through e52e60ef77, materializing the generated wp-emoji-loader.js asset from the canonical source during the fuzz case and removing it afterward; focused seed 224 passed 15 checks, seeds 1 and 224 over 25 iterations each passed 375 checks, adjacent script/assets smoke passed 69 checks, and broad seed 224 smoke passed 4690 checks with 5 skips.
+
Latest focused validations: auth-flow direct wp_login_form() rendering coverage passed focused seeds 1, 224, and 57123 at 300 checks each, adjacent auth/security/request smoke at 240 checks, adjacent REST-auth smoke at 285 checks, and broad seed 224 at 4777 checks with 1 skip; cron public wp_cron() wrapper coverage passed focused seeds 1, 224, and 57123 at 2275 checks each, adjacent cron/system smoke at 750 checks, adjacent cron/state smoke at 710 checks, and broad seed 224 at 4776 checks with 1 skip; site-health-debug bounded WP_Debug_Data::debug_data() full-scan coverage passed focused seeds 1 and 224 at 8 checks with 1 skip each, seed 224 over 25 iterations at 200 checks with 25 skips, adjacent site-health-debug,site-health,http,environment-load,cron at 138 checks with 1 skip, and broad seed 224 at 4668 checks with 25 skips; admin-list-tables theme install selected-mode matrix at 801 passed and 299 skipped, admin-list-tables plugin install list-table matrix at 800 passed and 200 skipped, style block-support wrapper serialization matrix at 1400 passed and 100 skipped, reviewer seed 11704 at 350 passed and 25 skipped, admin-media-chrome caption/send-to-editor helper matrix at 1000 passed and 200 skipped, admin-list-tables base pagination/per-page output matrix at 700 passed and 200 skipped, site-health persistent object cache threshold/filter direct-test matrix at 1100 passed, icons-connectors connector plugin install/activation module-data status matrix at 700 passed, block-widgets retrieve/remap/lost-widget matrix at 900 passed, cron spawn request/lock boundary matrix at 8700 passed, script-loader-runtime concat loader URL/exclusion/sourceURL matrix at 1200 passed and 200 skipped, taxonomy-relationships multi-object cache priming and cleaning matrix at 1000 passed, rest-object-controllers collection parameter request-pipeline matrix at 1000 passed and 200 skipped, media-remote Content-Disposition/content-type filename matrix at 700 passed, network-media generated filename collision/alternate-extension oracle at 100 passed, install-schema missing-table creation/replay at 1100 passed, admin-screen screen-meta rendering lifecycle at 900 passed, frontend-features disabled speculation lifecycle/load-action isolation at 800 passed, media-ingest override/error semantics at 1000 passed, and template-links date/author archive URL helpers at 1200 passed; historical component validations are tracked in the tables below.
+
This turn: identity current-user lifecycle helpers and review fixes at 100 passed with 22 cases and 887 checks per iteration plus a 8608 passed, 68 skipped broad run, admin-workflows direct referer-field/original-referer helpers and review fixes at 600 passed and 200 skipped, import-diff importer base helper contracts and review fixes at 900 passed, network-media MIME catalog helpers, upload iframe URL/filter contracts and review fixes, upload policy, custom MIME filetype helpers, and unique filename callback/filter contracts at 100 passed, block-editor-adjuncts selected-post content helper coverage and review fixes at 700 passed, default-widgets review fixes plus follow-up query-runtime, DISTINCT term ID, and nav-menu filter cleanup fixes at 1000 passed with wpdb-sql at 800 passed and shared stub smoke at 2555 passed and 10 skipped, account-security password-reset key lifecycle at 700 passed, identity review at 3 passed with no actionable findings, registries icon file recovery at 600 passed, Unicode email malformed variants, UTF-8 local-part oracles, generated local-part alias/search invariants, and review fixes at 6182 passed and 5 skipped after the 123765-pass follow-up, post-types status-name sanitization at 1100 passed, request-lifecycle status/nocache header contracts at 1100 passed, block-widgets widget ID parsing and cleanup at 800 passed, blocks nested attribute round trips at 700 passed, appearance-media custom-logo helpers at 700 passed and 100 skipped, community-events invalid-IP request minimization at 900 passed, revisions-autosaves retention pruning at 1000 passed and 300 skipped, capabilities site-scoped cap-key contracts at 1000 passed, identity avatar, WP_User field, and username/email existence helpers at 100 passed, comments reply/cancel links at 44800 passed, template-links adjacent relation links at 1100 passed, error-protection recovery-mode handle-error gates at 900 passed and 100 skipped, media-metadata replacement metadata oracles at 800 passed, abilities category-unregister contracts at 800 passed, user-preferences Screen Options visibility caching at 1100 passed and 100 skipped, admin-list-tables network themes row-action coverage at 600 passed and 200 skipped, feed-rendering self-link request URI coverage at 1000 passed, frontend-features view-transition timing at 700 passed, feed-parsers SimplePie file adapter coverage at 900 passed, classic-walkers nav-menu matrix at 1200 passed and 100 skipped, and discovery robots public/private helper matrix at 900 passed.
+
Continuation: style block-support wrapper serialization and review fixes passed seed 1 and seed 224 focused checks at 1400 passed with 100 skips, reviewer seed 11704 passed 350 checks with 25 skips, exact render_block hook restoration repros passed, final review found no actionable findings, and the broad run passed 8661 checks with 66 skips. admin-media-chrome caption/send-to-editor helper matrix and review fixes passed 1000 focused checks with 200 skips, reviewer seed 224 passed 1000 checks with 200 skips and no actionable findings after fixes, and the broad run passed 8659 checks with 66 skips. admin-list-tables base pagination/per-page output matrix and review fixes passed 700 focused checks with 200 skips, reviewer seed 224 passed 700 checks with 200 skips and no actionable findings, and the broad run passed 8657 checks with 66 skips. site-health persistent object cache threshold/filter direct-test matrix and review fixes passed 1100 focused checks, reviewer seed 224 passed 1100 checks with no actionable findings after fixes, and the broad run passed 8655 checks with 66 skips. icons-connectors connector plugin install/activation module-data status matrix passed 700 focused checks, reviewer found no actionable findings, and the broad run passed 8653 checks with 66 skips. block-widgets retrieve/remap/lost-widget matrix passed 900 focused checks, reviewer seed 224 passed 900 checks with no actionable findings, and the broad run passed 8651 checks with 66 skips. cron spawn request/lock matrix and review fixes passed 8700 focused checks, reviewer seed 224 passed 8700 checks with no actionable findings, and the broad run passed 8649 checks with 66 skips. script-loader-runtime concat loader URL/exclusion/sourceURL matrix and review fixes passed 1200 focused checks with 200 skips, reviewer seed 224 passed 1200 checks with 200 skips and no actionable findings, and the broad run passed 8647 checks with 66 skips. taxonomy-relationships object-term cache priming/cleaning matrix passed 1000 focused checks, reviewer seed 224 passed 1000 checks with no actionable findings, and the broad run passed 8645 checks with 66 skips. rest-object-controllers collection parameter matrix and request-pipeline review fixes passed 1000 focused checks with 200 skips, reviewer seed 224 passed 1000 checks with 200 skips and no actionable findings, and the broad run passed 8643 checks with 66 skips. media-remote filename derivation matrix passed 700 focused checks, reviewer seed 224 passed 700 checks with no actionable findings, and the broad run passed 8641 checks with 66 skips. network-media generated filename collision/alternate-extension oracles and review fixes passed 100 focused checks, reviewer seed 224 passed 100 checks, and the broad run passed 8639 checks with 66 skips. install-schema missing-table creation/replay and review fixes passed 1100 focused checks, reviewer seed 224 passed 1100 checks, and the broad run passed 8639 checks with 66 skips. admin-screen screen-meta/help-sidebar/screen-reader lifecycle and review fixes passed 900 focused checks, the priority-20 submit-filter repro passed, and the broad run passed 8637 checks with 66 skips. frontend-features speculation lifecycle/load-action isolation and review fixes passed 800 focused checks, the contamination repro failed as expected, and the broad run passed 8635 checks with 66 skips. media-ingest upload override/error semantics and review fixes passed 1000 focused checks plus 8633 broad checks with 66 skips. template-links archive URL helpers and review fixes passed 1200 focused checks plus 8631 broad checks with 66 skips. post-types duplicate registration cleanup fixed stale first-registration query vars and meta-cap mappings, then passed 1200 focused checks, 8629 broad checks with 66 skips, and the post type PHPUnit suite.
+
Continuation: request-lifecycle feed header variant coverage landed through 61a0a1d628 with review gaps fixed through b1eea51e9a, covering default-feed alias content type mapping, post-only and comments-feed Last-Modified/ETag selection, stale conditional request headers that must not emit 304 in-process, exact default-feed and post-modified filter payloads, preserved timeinfo cache state, and hook restoration; focused seeds passed 12/0, 12/0, and 60/0, and the broad smoke passed 9125 checks with 62 skips.
+
Continuation: rest-site-editor bounded REST dispatch coverage landed through 6002f40de7 with review gaps fixed through 63aadcb347, covering registered template and template-part item routes, single-slash route ID sanitization back to canonical double-slash template IDs, denied access before template lookup with the lookup observer already installed, exact bounded pre_get_block_template lookup logs, exact _fields response-key filtering, /wp/v2/templates/lookup fallback route wiring, empty higher-priority fallback retry behavior, no-fallback empty-object responses, and filter/server restoration; live edit-site export now runs in an isolated child process and asserts structured stdout metadata, streamed ZIP bytes, direct ZIP inspection, export filter logs, temp ZIP unlinking, and parent/child state separation; focused seeds passed 13/1 and 65/5, REST-family smoke passed 88/10, and the prior broad smoke passed 9129 checks with 62 skips.
+
Continuation: admin-options-submission no-exit options update-flow coverage now exercises registered Settings API allowlists/sanitizers, settings error transients, General Settings date/time/timezone normalization, pending admin email hash/confirmation-mail semantics, legacy page_options, nonce/capability/unknown-page failure paths, redirect capture, slashed request handling, and global/filter/option restoration; focused seeds passed 6/0 and 150/0, adjacent admin/options/mail/email/security smoke passed 1304/1, and the broad smoke passed 4699 checks with 5 skips.
+
Continuation: customizer-nav-widgets-requests now exercises forced Customizer nav_menus/widgets component loading, cap-gated AJAX hook registration, menu available/search request envelopes, auto-draft insertion and publish cleanup, dynamic nav-menu/menu-item setting filters, placeholder menu remaps into theme locations and widget_nav_menu, preview HMAC/export placement data, signed widget instance round trips and tamper rejection, widget update AJAX gates, selective-refresh wrapper metadata, partial rendering, and strict option/content/global restoration; focused seeds passed 8/0 and 200/0, adjacent Customizer/navigation/widget/admin smoke passed 560/0, and the broad smoke passed 4623/28.
+
Continuation: media-image-edit-requests admin media image edit request coverage now exercises history operation normalization, save/stream filters, edit-path loading, parent crop property copying, crop wrappers, preview streaming and AJAX, save/restore metadata, image-editor/crop AJAX failure envelopes, successful crop metadata/id filters, and media sub-size AJAX boundaries through temp upload roots and a deterministic fake editor; focused seeds passed 11/0 and 275/0, adjacent media/admin smoke passed 97/4, and the broad smoke passed 4615/28. rest-media-attachments capability grants now run last in user_has_cap so broad smoke remains order-safe after earlier admin capability surfaces.
+
Continuation: site-health-debug now exercises full WP_Debug_Data::debug_data() section assembly in an isolated child process with a generated wpdb double, fake successful WordPress.org response, bounded Ghostscript executable, exact single-site section order, empty plugin/theme buckets, path-size loading placeholders, formatted custom/private field oracles, and child hook/PATH cleanup; focused seeds passed 8/1 and 8/1, seed 224 over 25 iterations passed 200/25, adjacent Site Health/environment/HTTP smoke passed 138/1, and the broad smoke passed 4668/25.
+
Continuation: rest-controllers now exercises WP_REST_Plugins_Controller and WP_REST_Themes_Controller route/schema, collection parameter, sanitizer, and permission-gate contracts without plugin/theme lifecycle filesystem reads; focused seeds passed 12/0 and 12/0, adjacent REST/widget/admin smoke passed 56/2, and the broad smoke passed 4667/26.
+
Continuation: rest-widgets-sidebars now exercises REST widget route/schema registration, public show_in_rest read filtering, widget type sorting/projection and encode_form_data() hashes, isolated /widget-types/{id}/render iframe preview dispatch with fail-closed permission/invalid-id paths, parent-safe IFRAME_REQUEST containment, text-widget preview hooks, text-widget create/update persistence, sidebar reordering, legacy form-data updates, soft/force delete hooks, HEAD short-circuits, and widget update-guard reset isolation; focused seeds passed 9/0 and 225/0, adjacent REST/widget/admin smoke passed 420/10, and the broad smoke passed 4666/28.
+
Continuation: block-supports now exercises core block-support registration, direct callback output, wrapper merge and skip-serialization gates, background/dimensions/visibility/position/layout render filters, elements and custom CSS render-data filters, state-style helpers, auto-generated control markers, duotone preset/custom/unset/global-style render paths, safe stored CSS/SVG/editor assets, and registry/global/style/duotone static restoration; focused seeds passed 7/0 and 175/0, adjacent block/editor/style smoke passed 66/0, and the broad smoke passed 4731/1.
+
Continuation: admin-edit-metaboxes now exercises classic edit-screen publish, taxonomy, content/comment/custom-field, page attributes, post format, attachment, thumbnail, link, XFN, advanced link, and default meta-box registration branches; focused seeds passed 6/0 and 150/0, adjacent admin/content/taxonomy/comment/link smoke passed 562/8, and the broad smoke passed 4637/27.
+
Continuation: rest-media-attachments REST attachment write coverage now exercises Content-Disposition filename parsing, raw upload validation failures, raw body create_item() success through temp upload roots and attachment postmeta, explicit permission gates, client-side media-processing route/argument contracts, metadata finalization filters, _fields response projection, edit-media fail-closed paths, and state restoration; focused seeds passed 6/0 and 150/0, adjacent REST/media/filesystem smoke passed 495/25, and the broad smoke passed 4604 checks with 28 skips.
+
Continuation: post-embeds WordPress-as-provider oEmbed coverage now exercises embeddability predicates, visibility fail-closed behavior, width clamps, rich iframe and thumbnail response conversion, plain/pretty/path-conflict embed URLs, discovery link output, direct WP_oEmbed_Controller item responses, same-site pre_oembed_result short-circuiting, narrow wpdb stub projection support for get_page_by_path(), and state restoration; focused seeds passed 5/0 and 125/0, adjacent embed/syndication/REST/content/template/media smoke passed 445/10, and the broad smoke passed 4598 checks with 28 skips.
+
Continuation: navigation-lifecycle DB-backed nav menu lifecycle coverage now also exercises navigation fallback creation from classic menus through WP_Navigation_Fallback and WP_Classic_To_Block_Menu_Converter, including disabled-create filters, primary-location priority over decoy menus, inserted wp_navigation post shape, parsed navigation-link/submenu block trees, sanitized custom-link attrs, post-type item identity, duplicate-free fallback reuse, query globals, and state restoration; focused seeds 1, 224, and 57123 each passed 225 checks, adjacent navigation/template/content/taxonomy smoke passed 490 checks, bootstrap smoke passed, and the broad smoke passed 4766 checks with 1 skip.
+
Continuation: core-block-render direct render-callback coverage now exercises representative dynamic core blocks for site identity, search/loginout, post context, and button/file/image markup transforms with strict option, cache, filter, global, and superglobal restoration; seed-224 expectation hardening now parses real pagination links and HTML attributes, focused seeds passed 7/0 and the combined 25-iteration admin/core replay passed 300/0, and the broad smoke passed 4585 checks with 28 skips.
+
Continuation: kses helper and block attribute coverage landed through 7f913eb4d5 with review gaps fixed through a2b8c3ff0e, e7f99aa17e, and 50de103325, covering exact low-level helper contracts for array lowercasing, quote-slash stripping, malformed attribute recovery, numeric/XML/HTML entity callbacks, serialized block attribute filtering via filter_block_content(), direct filter_block_kses() agreement, recursive filter_block_kses_value() key/value behavior, core/template-part tagName allowlisting, wp_pre_kses_block_attributes hook-path agreement, callback-order preservation, and run-level pre_kses hook-state restoration; focused seeds passed 1100/0, 1097/0, and 5507/0 after the final review fixes, Copernicus found no actionable findings, and the broad smoke passed 9139 checks with 62 skips.
+
Continuation: content post content pagination coverage landed through 89ec6030b2, covering list and singular get_the_content() more-tag behavior, , generated pages, protected-post password forms, the_content() filter and CDATA escaping, wp_link_pages() numbered/next/single output and final filters, content pagination hooks, post-cache snapshots, and global/superglobal/cookie/filter restoration; focused seeds passed 15/0, 15/0, and 750/0, neighbor smoke passed 3355/0, Nash found no actionable findings after fixes, and the broad smoke passed 9139 checks with 62 skips.
+
Continuation: state option-backed site transient coverage landed through b2b0c9cd6c, covering non-external-cache _site_transient_* option rows, value/timeout update paths, zero-expiration storage, manual timeout expiration cleanup, dynamic site-transient filters, hook cleanup, and external object cache flag restoration; focused seeds passed 19/0, 95/0, and 380/0, adjacent options smoke passed 160/0, Zeno found no actionable findings after fixes, and the broad smoke passed 9141 checks with 62 skips.
+
Continuation: cron pre_unschedule_hook coverage landed through db22a88992, adding short-circuit return contracts for zero, false-with-WP_Error, and WP_Error-without-WP_Error branches, preserving queued events, exact filter payloads, hook removal, and current-filter stack restoration; focused seeds passed 90/0, 450/0, and 1800/0, adjacent cron/options/state smoke passed 244/0, Singer found no actionable findings, and the broad smoke passed 9147 checks with 62 skips.
+
Continuation: mail invalid-From failure coverage landed through f9fb63e7ae, adding no-delivery oracles for early setFrom() failure, exact wp_mail_failed payloads including embeds, no send/phpmailer_init/success action, reusable PHPMailer cleanup before failure, and hook cleanup; focused seeds passed 11/0, 55/0, and 220/0, adjacent mail/email/privacy smoke passed 2561/2, the full wpMail PHPUnit file passed 32 tests and 87 assertions, Sartre found no actionable findings after fixes, and the broad smoke passed 9149 checks with 62 skips.
+
Continuation: hooks deprecated wrapper coverage landed through 9d0ad6c83f, covering no-callback fast paths, deprecated_hook_run payloads, debug-gated deprecated_hook_trigger_error suppression, apply_filters_deprecated()/do_action_deprecated() ref-array dispatch, by-reference mutation, all-hook visibility, counters, cloned hook-global restoration, and hostile preexisting callback isolation; focused seeds passed 12/0, 60/0, 240/0, and 1200/0, adjacent hook/options/plugin smoke passed 68/0, Pasteur found no actionable findings after fixes, and the broad smoke passed 9151 checks with 62 skips.
+
Continuation: admin-list-tables plugin install list-table coverage landed through 74d7c296f3 with review fixes through e95cf978f1, covering generated search API args for term/tag/author modes, install tabs/views, API result ordering and pagination totals, install/update/activate action rendering, compatibility notices, icon and description escaping, fail-closed plugin API short-circuiting, localized update-count script restoration, temp plugin fixture cleanup, and global/server/filter restoration; focused seeds passed 8/2, 200/50, and 800/200, adjacent admin/plugin/upgrader smoke passed 60/4, an exact pre-registered updates handle restoration probe passed, Kierkegaard found no remaining findings, and the broad smoke passed 9155 checks with 62 skips.
+
Continuation: admin-list-tables theme install list-table coverage landed through a71e18bea4 with review fixes through 09fcc5d910, covering one selected request mode per PHP process because core prepare_items() raw-requires theme-install.php, generated search/browse/feature API args, install tabs/views, result ordering and pagination totals, install/update/latest-installed/newer-installed action states, screenshot and description escaping, fail-closed theme API short-circuiting, theme-root option/cache cleanup, temp theme fixture cleanup, and global/server/filter restoration; focused seeds passed 9/2, 81/29, and 801/299, adjacent admin/plugin/upgrader smoke passed 91/8, exact theme-root option restoration probes passed, Kepler found no remaining findings, and the broad smoke passed 9156 checks with 63 skips.
+
Continuation: admin-dashboard Browser Happy coverage landed through 3f5772e8e7 with review fixes through f3527a20ce, covering generated Browse Happy HTTP request payloads, HTTPS endpoint selection, one-week site-transient TTLs, cache reuse, fail-closed WP_Error/non-200/invalid-JSON responses without raw transient option writes, insecure class preservation, regular and Internet Explorer nag rendering, no-network empty-user-agent behavior, cleanup, and Pascal re-review with no findings; focused seeds passed 10/1, 250/25, and 1000/100, adjacent dashboard/site-health/http smoke passed 64/2, and the broad smoke passed 9158 checks with 63 skips.
+
Continuation: email UTF-8 address-model coverage landed through 2a84783187, covering generated mixed-script local parts with combining marks, Unicode and Punycode domain views, WP_Email_Address getter invariants, ASCII/unicode round trips, is_email()/sanitize_email() filter-mode agreement, ASCII-mode rejection, reserved ACE-like and invalid xn-- labels, malformed UTF-8 warning capture, and Gauss review with no findings; focused seeds passed 1244/1, 12502/10, and reviewer 3741/3, adjacent email/mail/identity smoke passed 1256/1, and the broad smoke passed 9160 checks with 63 skips.
+
Continuation: multisite true lifecycle child coverage landed through 352a5bc4de, replacing the broad true-multisite sitemeta skip with an isolated optional real-DB subprocess for wp_insert_site(), wp_update_site(), wp_delete_site(), duplicate site_taken validation, get_site()/get_sites()/domain_exists() cleanup, explicit network-option and site-option alias sitemeta writes/cache semantics, hook event order, generated residue cleanup, and controlled missing-wp-tests-config.php or unreachable-DB skips; focused runs passed 17/1, 425/25, and 245/5, and the broad smoke passed 4663 checks with 28 skips.
+
Continuation: script-loader-runtime JIT localization coverage landed through 12f2cd8d7f with review fixes through 696162da10, replacing the missing-AUTOSAVE_INTERVAL skip with an isolated child-process oracle for autosaveL10n, mceViewL10n, and wordCountL10n, generated shortcode tags and tag-shaped JSON escaping, parent/child state restoration, and row-local subprocess capability skips; focused seeds passed 13/1 and 325/25, disabled-proc_open repro passed 12/2, adjacent assets/script-loader/blocks smoke passed 70/2, Confucius found no remaining findings, and the broad smoke passed 9162 checks with 61 skips.
+
Continuation: environment-load environment type matrix coverage landed through 7580167b9c with review fixes through 2047262604, replacing the full-matrix skip with isolated child-process WP_RUN_CORE_TESTS oracles for env-var values, allowed WP_ENVIRONMENT_TYPE constant overrides, invalid constant fail-closed behavior, parent/child env restoration, row-local subprocess capability skips, and failure on child stderr or stray output; focused seeds passed 17/0 and 425/0, the constant-precedence repro passed 17/0, disabled-proc_open repro passed 16/1, adjacent environment/http/site-health-debug smoke passed 70/4, Hegel found no remaining findings, and the broad smoke passed 9164 checks with 59 skips.
+
Continuation: template-hierarchy isolated comments_template() coverage landed through d1916878e9 with review fixes through 0d7f1e98c9, replacing the guarded comments-template skip with a child-process oracle for parent-safe COMMENTS_TEMPLATE definition, child-over-parent default comments.php, generated custom comments files, exact comments_template filter paths, short-circuited synthetic comment queries, local $comment_args, cloned hook snapshots, child stderr/stray-output failures, and row-local subprocess capability skips; focused seeds passed 10/0 and 250/0, disabled-proc_open repro passed 9/1, adjacent template/content/comments smoke passed 946/0, James found no remaining findings, and the broad smoke passed 9166 checks with 57 skips.
+
Continuation: email Unicode authentication coverage landed through 5d018bedd3, covering stored canonical Unicode account email login via wp_authenticate_email_password(), wrong-password failures, exact alias local-part isolation, machine/Punycode view lookup misses against readable canonical accounts, wp_authenticate_user hook tracking, and stub/cache cleanup; focused seeds passed 1243/1, 2492/2, and 3744/3, adjacent email/mail/identity smoke passed 1255/1, Arendt found no actionable findings, and the broad smoke passed 9153 checks with 62 skips.
+
Continuation: query-loop nested reset/global postdata and exact conditional flag oracles landed through b63ed99053, fa12f15d10, and bbe8f53242; focused runs passed seed 1 and seed 224 at 7700 checks each plus seeds 2, 42, and 123456 at 1925 checks each, final review found no actionable findings, and the broad run passed 8665 checks with 66 skips.
+
Continuation: Unicode email password-reset notification recipient coverage landed through c45c94ca67 and 0cfbc48c93; Sagan implemented the matrix, Descartes found three review gaps, the fixes now exercise wp_mail() through fake PHPMailer, assert current machine-view reset lookup rejection, and label no-DB collation scope, Fermat found no actionable findings, and the broad run passed 8665 checks with 66 skips.
+
Continuation: privacy export-email notification recipient, subject, body-placeholder, header, negative-path, and cleanup coverage landed through 72f3ae35e3 and review fixes through 970437d39f; focused runs passed seed 1 and seed 224 at 170 checks each, reviewer seed 72 passed 1700 checks, lint/syntax/bootstrap checks passed, the broad smoke passed 4324 checks with 33 skips, and Franklin re-review found no actionable findings.
+
Continuation: query WP_Query post-search review fixes landed through 992c2393a5, 729e2a5322, 83039db582, and 8e76f34885, adding real post result oracles, password contradiction coverage, literal clause-keyword search cases, attachment filename null-left-join semantics, independent exact-plus-relevance malformed-order boundary checks, and status-OR stub branch coverage including author-scoped private statuses and mixed equality/IN alternatives; focused seeds 1 and 224 passed 64400 checks each and the broad smoke passed 4519 checks with 33 skips. email UTF-8 boundary coverage landed through 31260a0f66 with quoted/escaped local-part, control-character, local-part identity, and hook-restoration oracles; focused email passed 3742 checks with 3 skips and mail smoke passed 10 checks.
+
Continuation: interactivity directive syntax/order matrix coverage landed through 7790a7504d, covering bind/class/style/text empty suffixes, unique-ID ignores, event-handler bind warnings, boolean data/ARIA conversion, false attribute/style removals, class/style ordering, non-scalar text, and invalid directive names; Nietzsche found two oracle gaps, both fixed before commit, focused seeds 1 and 224 passed 1100 checks each, and the broad smoke passed 13602 checks with 99 skips.
+
Continuation: template-hierarchy direct helper matrix coverage landed through 0afd17bb8d, covering author, date, home, front page, privacy policy, singular, and attachment helper hierarchies, exact dynamic hook type/template payloads, no-DB synthetic author objects, MIME subtype ordering, and generated child/parent path confinement; Hilbert found no actionable findings, focused seeds 1 and 224 passed 900 checks with 100 guarded comments-template skips each, and the broad smoke passed 9073 checks with 66 skips.
+
Continuation: images content tag pipeline coverage landed through 93c6f19416, covering direct auto-sizes, dimensions, srcset/sizes, loading optimization, iframe loading helpers, duplicate image/iframe content filtering, exact generated candidate URLs, filter cleanup, and row-local missing API skips; McClintock found three oracle gaps and a follow-up URL gap, all fixed before commit, focused seeds 1 and 224 passed 1000 checks each, and the broad smoke passed 9075 checks with 66 skips.
+
Continuation: canonical-routing DB-backed 404 permalink guessing coverage landed through cc00dac113, covering loose and strict name guesses, public post type/status gates, comment-feed and single-page variants, guess short-circuit/cancel filters, and redirect_canonical() query cleanup; focused seeds 1 and 224 passed 350 checks each, adjacent canonical/request/template smoke passed 190 checks, and the broad smoke passed 4660 checks with 28 skips.
+
Continuation: syndication feed_links_extra() branch coverage landed through 89e00fbfad, covering singular comments, post type archives, category, tag, custom taxonomy, author, and search query states with exact query-arg, escaping, filter-locality, and cache/global restoration oracles; Tesla found one cleanup gap, fixed before commit, focused seeds 1 and 224 passed 900 checks each, and the broad smoke passed 9079 checks with 66 skips.
+
Continuation: admin-bar default callback node graph coverage landed through 555495f583, covering WordPress logo, readable-user account, appearance/theme-support, comments-count, search-form, and secondary-group callbacks with exact node parent/href/meta/title oracles; Planck found hidden no-DB and cleanup gaps, fixed with reflected synthetic users, scoped option/user-meta short-circuits, and wpdb runtime restoration checks; focused seeds 1 and 224 passed 800 checks each, and the broad smoke passed 9081 checks with 66 skips.
+
Continuation: xmlrpc pingback fail-closed/read-only lookup coverage landed through efc5b84ca5, covering filtered empty sources, off-site targets, unresolved and missing target posts, same-resource rejects, closed pings, duplicate pingbacks, legacy target extraction forms, and pingback.extensions.getPingbacks URL filtering; Linnaeus found three review gaps, fixed with next-ID runtime restoration, narrower comment URL stub matching, and order-independent URL assertions; focused seeds 1 and 224 passed 1000 checks each, and the broad smoke passed 9083 checks with 66 skips.
+
Continuation: revisions-autosaves latest-count URL and user-filtered autosave coverage landed through 4a2cad026c, replacing two stub-limited skips with real oracles for wp_get_latest_revision_id_and_total_count(), wp_get_post_revisions_url(), empty/no-revision posts, revisions-disabled post types, revision-input edit links, and user-specific autosave selection; Archimedes found author-filter SQL broadening gaps, fixed with intersecting equality/IN predicates and status-OR branch handling; focused seeds 1 and 224 passed 600 checks with 50 guarded skips each, and the broad smoke passed 9087 checks with 62 skips.
+
Continuation: privacy final erasure completion coverage landed through df3be8a1aa, replacing the old final-path skip with a DB-stub-backed oracle for last-eraser completion, unchanged response shape, completed request status, completed timestamp meta, request identity preservation, exact erased-action firing, and post-cache cleanup; focused seeds passed 17/0, 17/0, and 85/0, and the broad smoke passed 9123 checks with 62 skips.
+
Continuation: email UTF-8 comment-submission coverage landed through 276dc47a5b, covering front-door wp_handle_comment_submission() validation for valid UTF-8 local parts, optional Unicode domains, and rejected emoji/fullwidth-at/invalid-UTF-8/leading-combining inputs, plus direct wp_new_comment() display-name recovery and invalid-email sanitization-to-empty behavior; Maxwell found four review gaps, fixed with fuller global snapshots, path-specific return oracles, hook payload assertions, and explicit direct invalid semantics; focused seeds passed 6235/5, 6225/5, and 3745/3, and the broad smoke passed 9089 checks with 62 skips.
+
Continuation: Unicode email profile confirmation coverage added direct send_confirmation_on_profile_email() request paths for success, duplicate, invalid, wrong-user, and same-email no-op branches, with exact _new_email meta, hash/link, placeholder replacement, error-data, $_POST, and no-mail/content-filter oracles; review gaps were fixed through d93c8a7e0f with stale-meta preservation and exact escaped self-admin URL checks, focused seeds passed 1242/1, 1245/1, 1255/1, and 3751/3, and the broad smoke passed 9123 checks with 62 skips.
+
Continuation: Unicode email local-part update alias coverage added generated wp_update_user() paths for composed accents, combining marks, Greek/Cyrillic variants, compatibility ligatures, fullwidth Latin, and width-sensitive Katakana, with exact lookup invalidation, duplicate collision, and email-change notification recipient/body oracles; focused seeds passed 1254/1, 1241/1, 1244/1, and 3748/3, and the broad smoke passed 9121 checks with 62 skips.
+
Continuation: multisite legacy blog identity and bootstrap resolution coverage added for get_blog_details(), blog address/name/URL ID helpers, domain_exists() payloads, and ms_load_current_site_and_network() subdirectory, subdomain, and missing-site branches; focused seeds passed 17/1, 17/1, 85/5, and 340/20, and the broad smoke passed 9119 checks with 62 skips.
+
Continuation: xmlrpc authenticated read-only content/media coverage landed through 50b7b3e2a3, covering wp.getPost, wp.getPosts, wp.getMediaItem, and wp.getMediaLibrary auth/capability branches, default and explicit field filtering, IXR dates, future-as-publish response shaping, media metadata/thumbnail projections, same-parent MIME filters, invalid item/type errors, scoped hooks, and seeded content cleanup; review gaps were fixed with cleanup-state capture before snapshot restore, wider post-type global snapshots, same-parent nonmatching media, and OR-style MIME SQL stub handling; focused seeds 1 and 224 passed 550 checks each, and the broad smoke passed 9091 checks with 62 skips.
+
Continuation: kses PDF object and dynamic URI attribute filter coverage landed through e3d43f715a, adding deterministic oracles for required PDF object attributes, upload-host and port matching, HTTP/HTTPS acceptance, query/fragment and case-sensitive extension rejection, duplicate attribute first-wins behavior, non-self invalid object fallback, bad-protocol filtering before the PDF callback, scoped upload_dir cleanup, and custom wp_kses_uri_attributes protocol filtering; focused runs passed 1096/0, 1093/0, 5487/0, and 54818/0, the broad smoke passed 9107 checks with 62 skips, and Bacon review found no actionable findings.
+
Continuation: kses attribute constraint coverage landed through 8fc00de203, adding deterministic oracles for wp_kses_check_attr_val() max/min length and value checks, valueless attributes, allowed-value lists, value callbacks, wp_kses_attr_check() by-reference mutation behavior, required-attribute fallback stripping, entity-decoded style filtering, full-tag wp_kses_attr_parse() round trips and rejects, and safecss_filter_attr_allow_css restoration; focused runs passed 1094/0, 1091/0, 5477/0, and 54718/0, and the broad smoke passed 9103 checks with 62 skips.
+
Continuation: rest batch-v1 dispatch coverage landed through 94c539db4b, covering normal and require-all validation modes, allow-batch gate shapes, child method/query/body/header propagation, parse failures, max-size schema errors, pre/post dispatch locality, response envelopes, and filter/global restoration; focused seeds 1, 224, and 57123 over 25 iterations each passed 275 checks, adjacent REST smoke passed 69 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4782 checks with 1 skip.
+
Continuation: filesystem recursive helper coverage landed through 60dec6d029; focused runs passed seed 1 and seed 224 at 1200 checks each, reviewer seed 60 passed 1200 checks, syntax/PHPCS/diff checks passed, the broad smoke passed 4326 checks with 33 skips, and Gibbs review found no actionable findings.
+
Continuation: comment-workflow hard-delete coverage landed through 5f9d739e21 and review fixes through cc0493297c; focused runs passed seed 1 and seed 224 at 900 checks each, reviewer seed 1 passed 900 checks, syntax/PHPCS/diff checks passed, the broad smoke passed 4328 checks with 33 skips, and Kepler follow-up review found no actionable findings.
+
Continuation: content post-template helper coverage landed through c93166b0ec and review fixes through 50ef617a22; focused runs passed seed 1 and seed 224 at 1400 checks each, syntax/PHPCS/diff checks passed, the broad smoke passed 4329 checks with 33 skips, and Leibniz follow-up review found no actionable findings.
+
Continuation: blocks block bindings render-pipeline coverage landed through f53cfd1bfc and review fixes through d83d02f5e0 and 9e4c4ee83c; focused runs passed seed 1 and seed 224 at 800 checks each, syntax/PHPCS/diff checks passed, the broad smoke passed 4330 checks with 33 skips, and Curie follow-up review found no actionable findings.
+
+
+
Active Workers
+
0
+
No active worker-owned files; reviewed UTF-8 email address-model work is committed.
+
+
+ +
+
+

Current Work

+ Committed main-tree progress and latest validated rich-surface wave. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentStatusOwner / WorktreeValidationDependencies
xmlrpclocal validation passedScoped component worktree component-fuzz-xmlrpc-write-methods; authenticated read-only content/media coverage through 50b7b3e2a3.Main focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks after authenticated post write-method coverage; adjacent xmlrpc,content-lifecycle,content,comments,comment-workflow,post-types,query,metadata smoke passed 1162 checks; latest broad run passed 4708 checks with 5 skips.IXR protocol helpers, wp_xmlrpc_server pingback methods, authenticated read-only wp.getPost/wp.getPosts/wp.getMediaItem/wp.getMediaLibrary methods, authenticated wp.newPost/wp.editPost/wp.deletePost write lifecycles, scoped option/auth/cap/media/post-insert filters, url_to_postid() query and legacy extraction forms, get_post()/pings_open(), in-memory post/comment/user/media fixtures, duplicate pingback lookup by source URL, read-only pingback URL projection, media parent/MIME filtering, HTTP and insert tripwires, object-cache cleanup, hook restoration, and wpdb runtime/next-ID restoration.
restlocal validation passedScoped worktree component-fuzz-rest-batch-v1; batch-v1 dispatch coverage through 94c539db4b; prior route-registration wrapper coverage through 263fd2ce32.Latest focused runs: seed 224 passed 11 checks, seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent rest,rest-controllers,rest-object-controllers,rest-widgets-sidebars,rest-media-attachments,rest-application-passwords,post-embeds smoke passed 69 checks; bootstrap smoke passed; latest broad run passed 4782 checks with 1 skip.REST request normalization, parameter precedence, schema validation, route dispatch, register_rest_route() common-arg merge precedence, route option schema callbacks, override dispatch reachability, permission semantics, /batch/v1 normal/require-all validation, allow-batch gates, child method/query/body/header propagation, response envelopes, response links, CURIE compaction, embedding, headers, response conversion, and REST server global/filter restoration.
rest-application-passwordslocal validation passedScoped component worktree component-fuzz-rest-app-password-response; Hilbert the 2nd response-shaping gap review; builds on status/index plumbing coverage from component-fuzz-rest-app-password-status.Latest focused runs: seed 224 passed 7 checks, seeds 1, 224, and 57123 over 25 iterations each passed 175 checks, adjacent rest-application-passwords,account-security,auth-flow,capabilities,rest,rest-controllers,rest-object-controllers smoke passed 144 checks, bootstrap smoke passed, and latest broad run passed 4753 checks with 1 skip.WP_REST_Application_Passwords_Controller, WP_Application_Passwords, synthetic users, scoped application-password usermeta filters, REST default filters for _fields and index authentication metadata, direct response context filtering for edit/view/embed, one-time password exposure only in edit context, stored-hash non-exposure, formatted created/last-used timestamps, last-IP preservation, requested-link projection, forbidden password projections, usage metadata recording and same-day throttling, missing UUID usage errors without mutation, capability mapping, app-password availability filters, current-user/auth-status globals, REST auth error helpers, and REST server/global restoration.
rest-directory-serviceslocal validation passedBlock-directory field/link follow-up through 87b8fc9686 builds on pattern proxy and URL-details fallback coverage through e581555577.Latest focused runs: seed 224 passed 6 checks, seeds 1 and 224 over 25 iterations each passed 150 checks, adjacent rest-directory-services,rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle smoke passed 97 checks with 1 existing skip, and broad seed 224 smoke passed 4724 checks with 4 skips.WP_REST_Block_Directory_Controller, WP_REST_Pattern_Directory_Controller, WP_REST_URL_Details_Controller, scoped plugins_api and pre_http_request short-circuits, block-directory _fields projection, explicit block title/icon mapping, installed-plugin link discovery through seeded plugin cache, site-transient caches, REST permission/capability filters, pattern-directory proxy query allowlisting, derived WordPress.org argument overwrite behavior, malformed/unclosed head metadata parsing and first-match precedence, URL parser boundaries, and REST server/global restoration.
admin-list-tablesvalidation passedCurrent slice retires the application-password Last IP escaping boundary; earlier direct link-manager and post-comments subclass coverage, theme install replay, plugin install, privacy accounting, and base pagination follow-ups remain covered.Latest focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent admin-list-tables,admin-workflows,privacy-admin-requests,admin-ajax,admin-screen,admin-options-submission,user-preferences,account-security,rest-application-passwords smoke passed 78 checks with no skips, and latest broad run passed 4727 checks with 3 skips. Earlier link-manager/post-comments runs passed 275 checks per seed with the now-retired current-core skip.Concrete list tables for posts, media, comments, link manager, post-comments metabox, terms, users, plugins, plugin install search results, themes, selected-mode theme install API results plus repeat-safe seeded render replay, network themes, application passwords, guarded network sites/users, and base WP_List_Table pagination/per-page behavior, including synthetic fixtures, bookmark cache/query arguments, link-category term fixtures, column/header state isolation, exact row-action URL/nonce checks, escaped pagination URLs, legacy link-manager option/capability gates, compact post-comments table rendering, application-password Last IP escaping and empty-value fallback, install/update/activate action rendering, theme install latest/newer installed states, compatibility notices, screenshot/icon and description escaping, fail-closed plugin/theme API short-circuiting, localized update-count and theme-root transient cleanup, row/template rendering, and dedicated privacy request list-table coverage accounting through privacy-admin-requests.
admin-dashboardvalidation passedScoped component worktree component-fuzz-dashboard-community-events; builds on cached RSS helper coverage through fb3b2503f2, Browser Happy coverage through 3f5772e8e7, and direct setup registration coverage through 68427088d5.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 300 checks with no skips; adjacent admin-dashboard,community-events,admin-ajax,admin-screen,admin-workflows,user-preferences smoke passed 315 checks; bootstrap smoke passed; broad seed 224 smoke passed 4764 checks with 1 skip. Earlier direct setup coverage passed 275 checks per focused seed.Dashboard widget registration/control callbacks, meta-box contexts, dashboard column rendering, direct wp_dashboard_setup() GET registration for site/network/user dashboard action and widget-filter branches, recent draft/comment helpers, activity recent-post query/link branches with editor/non-editor output, cached RSS loading/AJAX/cache replay branches, Browser Happy wp_check_browser_version() request/cache/TTL/failure contracts, dashboard_browser_nag_class() class preservation, regular and IE wp_dashboard_browser_nag() output escaping, Community Events wp_print_community_events_markup() and template output contracts, no-network HTTP tripwires, transient and filter cleanup, and state restoration.
admin-edit-metaboxeslocal validation passedScoped worktree component-fuzz-admin-edit-metaboxes; implementation landed through cbc425b410.Main focused runs: seed 1 passed 6 checks, seed 224 over 25 iterations passed 150 checks, adjacent admin-edit-metaboxes,admin-screen,admin-workflows,admin-list-tables,admin-media-chrome,user-preferences,taxonomy,taxonomy-relationships,content-lifecycle,comments,comment-workflow,bookmark-links,revisions-autosaves smoke passed 562 checks with 8 skips; latest broad run: 4637 passed, 27 skipped, 0 failed.Classic edit-screen meta box callbacks for publish status/visibility/action branches, taxonomy tag/category capability gates, excerpt, trackback, custom field, discussion, comment table header/body, slug, page attributes, post formats, attachment submit/ID3/thumbnail boxes, link submit/target/XFN/advanced fields, default register_and_do_post_meta_boxes() IDs, callbacks, contexts, hook payloads, and state restoration.
account-securityworker validation passedBeauvoir the 4th password-reset key follow-up in 811beb772eMain focused run: 700 passed, 0 skipped, 0 failed after password-reset key coverage; smoke: 35 passed, 0 skipped, 0 failed; previous latest broad run: 7953 passed, 62 skipped, 0 failed.Application password lifecycle, chunk/hash, authentication API gates, success/failure hooks, email fallback, Basic Auth validation, password-reset key lifecycle and fail-closed paths, recovery key/cookie services, paused-extension storage, and global/filter restoration.
ai-clientreview fixes passedAvicenna the 4th follow-up findings fixed through fc2df79179; Laplace the 4th review findings fixed through d66f101681; Mencius the 4th transport follow-up through 5b7b110e98; prior Nietzsche the 3rd exploration plus Bohr the 3rd and Einstein the 3rd reviews found no actionable issues through dccbad638cMain focused run: 900 passed, 0 skipped, 0 failed after transport review follow-up; smoke: 45 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed; latest broad run: 8627 passed, 66 skipped, 0 failed.SDK DTOs and enums, provider registry isolation, provider/model collision selection, tuple and model-instance preferences, provider locks, prompt builder events/prevention, ability resolver integration, cache/event adapters, deterministic in-memory generation, HTTPlug discovery of the WordPress HTTP adapter with discovery state restoration, PSR-7 to wp_safe_remote_request() mapping, exact empty/scalar response bodies, WP_Error to NetworkException paths through direct and SDK transport with wrapped request context, SDK transport option merge precedence including request-only fields, and response DTO mapping.
emailreviewed validation passedSagan Unicode reset recipient implementation through c45c94ca67; Descartes review findings fixed through 0cfbc48c93; Fermat re-review found no actionable findings; UTF-8 boundary follow-up through 31260a0f66, Cicero review found no actionable findings; comment-submission follow-up through 276dc47a5b, Maxwell review findings fixed before commit; generated local-part update follow-up through 30d42d342f; profile confirmation request follow-up through 2a2ccd0aee with review gaps fixed through d93c8a7e0f; Unicode authentication follow-up through 5d018bedd3; UTF-8 address-model follow-up through 2a84783187, Schrodinger the 2nd implementation and Gauss the 2nd review.Latest focused runs: seed 224 passed 1248 checks, seed 1 passed 1245 checks, adjacent email,mail,identity smoke passed 1260 checks with no skips, and latest broad smoke passed 4729 checks with 1 skip after punycode-TLD make_clickable() coverage and partial-link boundary retirement. Earlier runs include seed 20260627 over 10 iterations passed 12502 checks with 10 skips, reviewer seed 424242 over 3 iterations passed 3741 checks with 3 skips, 62043 passed/50 skipped, post-review 12427/10 and 12404/10, email PHPUnit 86 tests and 179 assertions with 1 skip, and mail smoke 10 checks.Unicode email validation/sanitization, generated UTF-8 local/domain filter-view matrix, generated mixed-script UTF-8 address-model invariants, WP_Email_Address raw getter and ASCII/unicode round-trip oracles, reserved ACE-like and invalid xn-- domain rejection, malformed UTF-8 warning capture, UTF-8 local-part oracle agreement, generated malformed UTF-8/local/domain variants across parser/filter/charset modes, quoted/escaped local-part rejection, control-character and Unicode separator sanitization boundaries, local-part case/width/normalization identity preservation, tracked hook restoration, REST user email schema validation across Unicode/ASCII filter modes, optional Unicode API skips, disabled-filter fail-closed behavior, WP_Email_Address machine/readable views and ASCII-vs-Unicode construction modes, explicit IDN construction-mode skips, IDN/punycode, canonical Unicode-domain user save/update collision behavior with no-DB collation scope labeled, generated Unicode local-part alias index/search/update behavior, exact Unicode email authentication with wp_authenticate_email_password(), wrong-password and alias-isolation branches, machine/Punycode view lookup misses against canonical readable accounts, profile email-change confirmation request success/duplicate/invalid/wrong-user/same-email branches, update-time old-email lookup invalidation, duplicate collision rejection, email-change notification recipient/body preservation, confusable UTF-8 local-part lookup/comment/password-reset/search boundaries, real comment submission via wp_handle_comment_submission() and direct wp_new_comment() recovery/sanitization paths, password-reset notification recipient machine/readable view overrides through fake PHPMailer handoff, current machine-view reset lookup rejection, accent-folded DB-candidate lookup guards, byte-preserving local-part search result oracles, user lookup/password reset paths, make_clickable() mailto rendering boundaries with punycode final-label full-link and malformed final-label no-partial-link cases, and generated mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter local parts, IDN/punycode domains, escaped display hrefs, raw URL preservation, and readable link text.
customizerreviewed validation passedScoped component worktree component-fuzz-customizer-theme-preview; Dirac the 2nd reviewed the direct manager theme-preview slice.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 175 checks; adjacent customizer,customizer-persistence,customizer-nav-widgets-requests smoke passed 115 checks; adjacent plugin-theme-lifecycle,template-hierarchy,block-templates,rest-site-editor smoke passed 240 checks; bootstrap smoke passed; latest broad run: 4774 passed, 1 skipped, 0 failed.Customizer manager registries, setting callbacks, slashed customized JSON ingestion, cached post-value parsing, programmatic merge precedence, post-value hooks, unknown validation, multidimensional option previewing, JSON exports, active callbacks, selective refresh partials, direct inactive/active theme-preview filter and action lifecycle coverage, child-theme stylesheet/template switching, and state restoration.
customizer-nav-widgets-requestsvalidation passedLanded through f5f6f5ff23; scoped worktree removed after merge.Main focused runs: seed 1 passed 8 checks, seed 224 over 25 iterations passed 200 checks, adjacent customizer-nav-widgets-requests,customizer,customizer-persistence,navigation-lifecycle,navigation,widgets,default-widgets,block-widgets,admin-ajax,content-lifecycle,taxonomy-relationships,options-autoload smoke passed 560 checks; latest broad run: 4623 passed, 28 skipped, 0 failed.Customizer nav_menus/widgets component loading, capability-gated AJAX hooks, menu available/search requests, auto-draft creation and publish cleanup, dynamic nav menu/menu-item settings, placeholder menu remapping into theme locations and widget_nav_menu, preview HMAC/export metadata, signed widget instance round trips, widget update AJAX gates, selective-refresh wrapper metadata, widget partial rendering, in-memory wpdb content/options, nonces/caps, and global/static restoration.
block-templatesreviewed validation passedCurrent slice retires the direct-ID traversal guard skip; earlier reviewer Mencius the 2nd found no actionable issues.Latest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks with no skips, adjacent block-templates,blocks,block-supports,block-editor-adjuncts,rest-site-editor,rest-controllers,style,template-hierarchy smoke passed 84 checks with no skips, and latest broad run passed 4728 checks with 2 skips. Earlier registry/path runs passed 800 focused checks with historical skips.Template registry lifecycle, public get_block_templates() integration for plugin-only templates, post-type filtering, registered/theme-file collision enrichment without duplicates, file-backed parent/child precedence, template parts, loader globals, malformed filename guards, direct and public template-ID path containment, template CPT short-circuiting, and state restoration.
block-editor-adjunctsreview fixes passedRaman the 4th selected-post content helper follow-up; Erdos the 4th review findings fixed through 55bf6625ccMain focused run: 700 passed, 0 skipped, 0 failed after review fixes for theme-cache restoration, strict template slug queries, and no-selected-post coverage inside the temp block-theme fixture; syntax/PHPCS/diff checks passed; latest broad run before this follow-up: 8615 passed, 66 skipped, 0 failed.Editor contexts, category/allowed-block filters, default/legacy/classic/merged settings, local theme styles, iframe asset collection, REST preload normalization, first-match block tree helpers, selected-post wp_get_post_content_block_attributes() template lookup, missing-target guards, cache/filter/global restoration, and temp block-theme fixtures.
comment-workflowvalidation passedMain worktree; Popper the 2nd review findings fixed in 67234118ad, hard-delete coverage through 5f9d739e21, review fixes through cc0493297c; current notification mail-path follow-up reviewed by Raman the 2nd.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent comment-workflow,comments,content-lifecycle,content,mail,email smoke passed 5232 checks; bootstrap smoke passed; latest broad smoke: 4754 passed, 1 skipped, 0 failed.In-memory comment submission, direct wp_new_comment() preprocessing, post-filter user-ID normalization against a synthetic user, parent normalization, approval filters, insert/comment hooks, notification wrapper and direct mail paths with intercepted wp_mail(), recipient/header/subject/body filters, count behavior, moderation/status helpers, trash/spam restoration, force-delete reparenting/meta/count/hook contracts including commentmeta and type-specific transition hooks, non-counted delete count boundaries, failure paths, and state restoration.
admin-workflowsvalidation passedMain worktree; scoped concrete list-table accounting through b59b5030c4; Anscombe the 4th review findings fixed through 2beecb7e9e; AJAX format wrapper coverage through 10cb72a48aLatest focused runs: seed 224 passed 8 checks with no skips, seeds 1 and 224 over 25 iterations each passed 200 checks with no skips, adjacent admin-workflows,admin-list-tables,privacy-admin-requests,admin-ajax,admin-screen,admin-options-submission,user-preferences smoke passed 291 checks with 9 skips, and scoped admin-workflows,admin-list-tables,privacy-admin-requests smoke passed 111 checks with 9 skips; latest broad run: 4681 passed, 13 skipped, 0 failed.Admin menu globals and hook suffixes, parent file normalization, synthetic list-table columns/views/bulk actions/row actions/tablenav rendering, scoped accounting for concrete list-table coverage owned by admin-list-tables and privacy-admin-requests, direct current-action and month-dropdown helper contracts, nonce/referer helpers, direct referer/original-referer fields, request-over-header precedence, off-host rejection, fallback behavior, nonce fields, selected attributes, submit buttons, captured date/time AJAX format wrappers, and state restoration.
default-widgetsreview fixes passedMain worktree; Sartre the 4th review findings and follow-up findings fixed through 2a62dae1e6Main focused run: 1000 passed, 0 skipped, 0 failed after follow-up fixes; wpdb-sql focused run: 800 passed, 0 skipped, 0 failed; direct probes confirmed query-runtime restoration and duplicate DISTINCT t.term_id collapse; shared stub smoke: 2555 passed, 10 skipped, 0 failed; latest broad run: 8619 passed, 66 skipped, 0 failed.Default widget constructors/options, saved-instance display/form/update callback lifecycles, title/content sanitization, exact sibling preservation, form escaping, filtered rendering, real stub-backed nav-menu item lookup/object filtering/walker output, filter cleanup assertions, cache-backed calendar/archive output, local RSS fixtures, no-live-DB guard, and state/object-cache/query-runtime restoration.
editor-helpersreviewed validation passedScoped worktree component-fuzz-editor-link-query; internal link query/dialog coverage landed in 3c10935ca3 after previous TinyMCE inline-script and enqueue dependency coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks; bootstrap smoke passed; broad seed 224 passed 4800 checks with 1 skip. Earlier focused run: 900 passed, 0 skipped, 0 failed after enqueue review fixes.Classic editor settings/state normalization, default editor selection filters, full and teeny TinyMCE/Quicktags filters, script enqueue decisions with media/link dependency oracles, captured markup, TinyMCE translations, TinyMCE inline settings, direct _WP_Editors::wp_link_query() query/result filters, generated post/custom-post link results, link dialog single-print markup, media-view styles, and global/static/cache restoration.
frontend-featuresreview fixes passedMain worktree; Dewey implementation and Kierkegaard review finding fixed through ec0fed6a9dMain focused run: 800 passed, 0 skipped, 0 failed after lifecycle review fixes; contamination probe confirmed unrelated list URLs fail the invariant; reviewer verification: 160 passed, 0 skipped, 0 failed; latest broad run: 8635 passed, 66 skipped, 0 failed.Speculation rules configuration, direct WP_Speculation_Rules validation diagnostics and serialization oracles, printed rule tags, disabled lifecycle/load-action isolation, exact generated list-rule URL leakage guards, URL pattern prefixing, view-transition registration timing/enqueue helpers, and state restoration.
admin-screenreview fixes passedMain worktree; Hegel implementation and Nash review findings fixed through 8e087d55e6; settings error/admin notice follow-up in this branch.Latest focused runs: seeds 1, 224, and 57791 over 25 iterations each passed 250 checks with no skips after settings error/admin notice coverage; adjacent admin-screen,admin-options-submission,admin-dashboard,privacy-admin-requests smoke passed 170 checks; latest broad seed 224 run passed 4742 checks with 1 skip.WP_Screen normalization/current globals, help tabs, rendered per-page/layout controls, combined screen-meta panel rendering, help sidebar and callback payload escaping, screen-reader content lifecycle, submit-filter priority cleanup, screen options and settings filters, column header filter locality, settings registry/rendering, Settings API error arrays/transients/sanitize side effects, generic admin notice markup/filter/action/KSES output behavior, meta boxes, accordion rendering, and state restoration.
admin-media-chromeskip retirement passedHarvey caption/send-to-editor follow-up through 123d7bae88; Dalton review fixes through 5c68c49525; admin media boundary skip retirement through 12d7260f6cLatest focused runs: seed 1 and seed 224 each 300 passed, 0 skipped, 0 failed after legacy upload shell and media enqueue/iframe boundary accounting; adjacent admin/content smoke passed 569 checks with 1 existing skip; latest broad run: 4687 passed, 8 skipped, 0 failed.Attachment edit field preparation, media item and compat markup escaping, image form controls, image editor chrome, edit attachment details form output with sanitized alt/caption/description fields, edit-only compat required/hidden fields, thumbnail/icon helpers, direct image caption/send-to-editor helpers including default caption filter registration, rel/no-rel editor URLs, media send delegation, legacy upload tab/header/form shell hooks, media-view enqueue settings/string contracts, in-process iframe shell rendering, media buttons, and state restoration.
wpdb-sqlreview fixes passedMain worktree; Hubble the 2nd review findings fixed in e1530693f1; shared stub follow-up through 2a62dae1e6Main focused run: 800 passed, 0 skipped, 0 failed after shared stub follow-up; latest broad run: 7938 passed, 63 skipped, 0 failed.Real no-connection wpdb, prepare placeholder/type behavior, identifier containment, LIKE escaping, malformed placeholder fail-closed paths, insert/update/delete/replace builders, null format handling, and global restoration.
markupreview fixes passedcomponent-fuzz-markup-static-blocks through 4de585019d; earlier Aquinas the 2nd review findings fixed in 6b590ee976Latest focused runs: 575 passed, 0 skipped, 0 failed at seeds 1 and 224; adjacent block/markup smoke passed 11652 checks with 0 skips; latest broad run passed 4670 checks with 24 skips.Block parser/serializer guards, deterministic do_blocks() fixture rendering for generated block trees through synthetic registered block callbacks, callback payload and wrapper-count oracles, render-state restoration, local shortcode registry behavior, strip/balance/excerpt helpers, URL extraction, link target/nofollow attribute oracles, and embed helpers.
media-metadatalocal validation passedScoped component worktree component-fuzz-media-gallery-playlist; Gauss the 2nd reviewed gallery/playlist shortcode gaps; earlier public audio/video shortcode coverage through 6195e533f8; earlier metadata replacement coverage through e61d09f16a.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent media-metadata,shortcodes,images,media-editor,media-ingest,media-remote,admin-media-chrome,script-loader-runtime,content-lifecycle,template-links smoke passed 640 checks; bootstrap smoke passed; broad seed 224 smoke passed 4804 checks with 1 skip. Earlier worker focused run: 800 passed, 0 skipped, 0 failed.Local parser fixtures, ID3 helpers, timestamp extraction, audio/video extension and key filters, public wp_audio_shortcode()/wp_video_shortcode()/wp_mediaelement_fallback() rendering, public gallery_shortcode()/wp_playlist_shortcode() rendering over cache-seeded attachments, gallery style/attribute/link filters, playlist JSON/script/ID3 branches, protected-parent fail-closed behavior, mediaelement and HTML5 library paths, typed source lists, invalid-source embedded-link fallbacks, shortcode override/library/class/output filters, fallback escaping, YouTube/Vimeo URL normalization, boolean/preload normalization, attachment MIME/extension classification, image/document wrappers, MIME/extension disagreement, attachment metadata get/update/delete filters, generated image/audio/video metadata replacement semantics, filtered/unfiltered reads, stale-key removal, serializable round trips, relative/absolute/legacy path-to-URL normalization, original-image path/URL and image-meta matching normalization across upload storage styles, metadata generation branches, and state restoration.
abilitieslocal validation passedMain worktree through 7bc1abc6c2Main focused run: 800 passed, 0 skipped, 0 failed; smoke: 40 passed, 0 skipped, 0 failed; latest broad run: 8007 passed, 67 skipped, 0 failed.Abilities API categories and abilities, action-gated registration, category unregister behavior that preserves existing abilities while blocking new registrations until re-registration, discovery filters, schema validation, custom subclass execution, query pipeline ordering, unregister/re-register identity, execution filters, callback exceptions, and registry restoration.
registrieslocal validation passedScoped component worktree component-fuzz-block-bindings-registry; Kuhn the 2nd reviewed block-bindings registry gaps and snapshot caveats; builds on the icon file recovery follow-up through b88d33922c.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 175 checks; adjacent registries,blocks,block-editor-adjuncts,rest-controllers,style,core-block-render smoke passed 315 checks; bootstrap smoke passed; broad seed 224 smoke passed 4763 checks with 1 skip. Earlier focused run: 600 passed, 0 skipped, 0 failed; PHPUnit: 24 tests, 47 assertions.Connector registry lifecycle, helper oracles, unregister/override replacement, icon manifest sanitization/caching, file-backed icon failure/recovery behavior, block metadata collection path boundaries/cache behavior, virtual path prefix preservation, block bindings singleton/wrapper/source lifecycle, source property validation, invalid name/property matrices, callback and source-value filter payloads, unregister/re-register ordering, speculation rule allowlists, and state restoration.
contentreview fixes passedMain worktree; reviewers Schrodinger and Socrates findings fixed through eb8f0e9d57; post-template helper follow-up through c93166b0ec and 50ef617a22; content pagination follow-up through 89ec6030b2, reviewed by Nash.Main focused runs: seed 1 and seed 224 each 15 passed, 0 skipped, 0 failed after content pagination coverage; seed 20260627 over 50 iterations passed 750 checks; adjacent content,query,template-links smoke passed 3355 checks; syntax/PHPCS/diff checks passed; latest broad run: 9139 passed, 62 skipped, 0 failed; Nash follow-up review found no actionable findings.Post and term field sanitizers, whole-post sanitize_post() array/object consistency, ordered filter-locality hooks, slashing and metadata serialization, get_extended() more-tag parsing, front-end get_the_title()/get_the_excerpt()/has_excerpt()/post_password_required() protected/private/password-cookie branches and filters including wrong/correct phpass-cookie oracles and hook isolation/restoration, direct get_the_content()/the_content() rendering, generated // pagination, protected content forms, wp_link_pages() output/filter contracts, date/query helpers, and class/key/title sanitizers.
content-lifecyclevalidation passedScoped component worktree component-fuzz-page-lookup; page lookup follow-up through 9f14ccc0c7; builds on set-post-type, status-transition, relationship, count/MIME, and featured-image follow-ups.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent content-lifecycle,query,query-loop,post-types,template-links,core-block-render,post-embeds,xmlrpc,fonts,default-widgets smoke passed 815 checks; bootstrap smoke passed; broad seed 224 smoke passed 4797 checks with 1 skip. Earlier set-post-type focused runs passed 350 checks per seed.In-memory post, post-meta, post-to-term relationship, term, user, and comment CRUD lifecycles, duplicate/invalid inputs, sanitizer agreement, metadata cache invalidation, direct set_post_type() filtered row mutation, stale cache eviction, page-specific cache cleanup, sibling/missing-ID boundaries, public page/post lookup helper contracts for get_page_by_path(), get_pages(), get_page_children(), and get_children(), ancestry paths, attachment fallback and collision preference, custom hierarchical post types, salted query-cache invalidation, include/child/parent/exclude-tree/limit rewrites, filter payloads, output-shape modes, status-transition hook order, direct-transition status storage, count/timeinfo cache branches, empty-GUID publish repair, future-post cron cleanup, relationship helper/cache/hook oracles, wp_count_posts() readable/cache/status grouping, wp_count_attachments() MIME/trash grouping, MIME group and available-MIME filter paths, set_post_thumbnail()/delete_post_thumbnail() metadata lifecycle, thumbnail HTML/URL/caption helpers, non-image cleanup, cache priming, post-delete cleanup, count refresh, and state restoration.
auth-flowreviewed validation passedScoped component worktree component-fuzz-auth-login-form; builds on public logout lifecycle coverage reviewed by Pauli the 2nd; direct login-form rendering coverage through 5a6cad2fcd reviewed by Lagrange the 2nd.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 240 checks; adjacent auth-flow,rest-application-passwords,rest,rest-controllers,rest-object-controllers smoke passed 285 checks; bootstrap smoke passed; latest broad run: 4777 passed, 1 skipped, 0 failed.Synthetic user rows, authentication filters/password paths, direct wp_login_form() rendering/filter/escaping contracts, echo/getter parity, generated required/remember field options, HTTPS default redirect derivation, sign-on and clear-auth-cookie short-circuits, public wp_logout() lifecycle composition, current-session destruction with sibling preservation, stale-cookie rehydration guards, generated auth-cookie scheme boundaries and filter payloads, auth-cookie validation hooks, session lifecycle, and current-user/cookie restoration.
wxr-exportfocused validation passedcomponent-fuzz-wxr-export-edges worktree; Pauli gap scan followed previous Raman review and header observability fixesLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent wxr-export,import-diff,content smoke passed 36 checks, and latest broad run passed 4709 checks with 5 skips.Subprocess-isolated export_wp(), selection arguments, non-exportable and invalid content fallback, null post/term/comment meta serialization, filtered-comment omission, nav-menu term boundaries, title/content/excerpt export filters, XML/CDATA/UTF-8 safety, meta skip filters, attachment URL/filemeta serialization, author/term ordering, filtered filename and XML content-type header intent with observable-header assertions when available, and state restoration.
identityreview fixes passedMain worktree current-user lifecycle follow-up through 7686237548; Locke the 4th review fixes through df2626ed8e; prior Darwin the 4th username/email existence helper follow-upMain focused run: 100 passed, 0 skipped, 0 failed after current-user lifecycle review fixes with 22 cases and 887 checks per iteration; smoke: 1 aggregate row passed; latest broad run: 8608 passed, 68 skipped, 0 failed.Username/email sanitization, identity sanitizer filters, capability keys, generated contact-method filters, avatar helper hash/default/URL contracts, WP_User display name, URL, nicename, edit-context escaping and field-filter locality, current-user lifecycle helpers, legacy setup globals including user_level, set_current_user and determine_current_user hook contracts, current-user/global/hook counter restoration, generated user-cache cleanup, username/email existence helper cache/filter contracts, additional user keys, URLs, text/comment helpers, comment cookies/current-commenter payloads, filter-count restoration, options, passwords, and parse helpers.
fontsexpanded validation passedMain worktree; Einstein implementation in ab3113389c; Euler review fixes through 5251e4ff47; Volta follow-up cache restoration in cdf3ecf90a; REST write lifecycle follow-up in 2cb3c60aa9Latest focused runs: 275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations; adjacent smoke: 43 passed, 2 skipped, 0 failed; latest broad smoke: 4662 passed, 28 skipped, 0 failed.Font-face CSS serialization, invalid face rejection, theme.json font-face resolver conversion and default wp_print_font_faces() output, font directory filters, Font Library collection lifecycle/JSON loading, REST font collection pagination, HEAD, _fields, _links, invalid collection skipping, filter cleanup, REST font-family/font-face create lifecycle, duplicate guards, upload src rewriting, relative font-file meta, parent-mismatch and non-force delete errors, force-delete file cleanup, family cascade deletion, REST font-face preparation boundaries, utility normalization, schema sanitization, and MIME maps.
assetsreviewed validation passedMain worktree; Anscombe the 3rd implementation and Mencius the 3rd review found no actionable issues through c2c69c4132Main focused run: 1400 passed, 0 skipped, 0 failed; latest broad run: 8021 passed, 67 skipped, 0 failed.Script/style dependency ordering, inline assets, style add-data output metadata, strategy/fetchpriority/module data, source/version escaping, scoped loader-tag filters, and script modules.
media-editorskip retirement passedMain worktree; Russell the 3rd implementation and Boyle the 3rd review findings fixed through f5aad1d00c; real-editor availability accounting through 8fd73afc6cLatest focused runs: seeds 1 and 224 each 225 passed, 0 skipped, 0 failed over 25 iterations; adjacent media smoke passed 101 checks with 0 skips; latest broad run: 4689 passed, 6 skipped, 0 failed.Editor selection, GD execution and Imagick/GD availability accounting, output format filters, abstract editor filename/quality/EXIF-orientation contracts, resize/save metadata, intermediate/generated sub-sizes, missing sub-size detection, cache/filter-backed attachment metadata.
media-image-edit-requestsvalidation passedScoped worktree component-fuzz-media-image-edit-requests; implementation landed through cfb49670d1.Main focused runs: seed 1 passed 11 checks, seed 224 over 25 iterations passed 275 checks, adjacent media-image-edit-requests,rest-media-attachments,admin-ajax,admin-media-chrome,media-editor,media-ingest,media-remote,media-metadata,image-metadata,network-media,appearance-media,filesystem smoke passed 97 checks with 4 skips; latest broad run: 4615 passed, 28 skipped, 0 failed.Server-side image-edit request coverage for normalized edit histories, preview streaming and AJAX, save/restore metadata, save/stream filters, crop wrappers, parent-property copying, crop AJAX metadata/id filters, media sub-size AJAX boundaries, nonce/capability gates, captured wp_die()/JSON envelopes, temp upload roots, fake editor operations, and global/filter/superglobal restoration.
filesystemvalidation passedScoped worktree component-fuzz-filesystem-archives; builds on unique-filename callback/case follow-up through b2a7077ab8 and recursive helper coverage through 60dec6d029.Latest focused runs: seed 224 passed 14 checks, seeds 1, 224, and 57123 over 25 iterations each passed 350 checks, adjacent filesystem,plugin-theme-lifecycle,update-install-upgrader,media-ingest,network-media smoke passed 265 checks, bootstrap smoke passed, and latest broad run passed 4750 checks with 1 skip.Path normalization/joining, validation classes, filename sanitization/uniqueness, callback and lowercase-extension collision metadata, temp names, wp_mkdir_p() recursive creation, list_files() hidden/exclusion/depth behavior, wp_is_stream()/wp_is_writable() contracts, direct filesystem sandboxing, metadata/time/chmod round trips, missing-file failure values, wp_zip_file_is_valid() valid/invalid archive parity, unzip_file() ZipArchive/PclZip extraction parity and skip guards, copy_dir() missing-source/skip-list behavior, and move_dir() overwrite and fallback-copy behavior.
classic-walkersworker validation passedCurrent slice adds nav-menu quick-search and meta-box query coverage; earlier nav-menu matrix through 11f544d777 and direct admin nav helper coverage through bc2dec631c.Latest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent classic-walkers,admin-ajax,admin-list-tables,navigation,content,query,taxonomy,post-types smoke passed 729 checks with 1 existing skip, and latest broad run passed 4726 checks with 4 skips. Earlier direct admin nav helper runs passed 325 checks per seed; earlier nav-menu matrix passed 1200 checks with 100 historical skips.Classic walker traversal, generated hierarchies, depth and parent/current state, generated nav-menu item matrix locality, direct _wp_nav_menu_meta_box_object(), wp_nav_menu_disabled_check(), custom-link meta-box, nav-menu column helper contracts, direct _wp_ajax_menu_quick_search() JSON/markup branches, post-type archive pseudo-items, post-type/taxonomy meta-box pagination and search query oracles, has-children oracles, output escaping/filter boundaries, argument normalization, and state restoration.
admin-ajaxlocal validation passedFind Posts modal and AJAX search coverage through 6070f5e381; earlier worker Ohm and reviewer Plato findings fixed through 259d6bd90e.Latest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent admin-ajax,admin-media-chrome,admin-workflows,admin-screen,content,post-types,query smoke passed 711 checks, and latest broad run passed 4725 checks with 4 skips.Captured wp_die() handlers, JSON/XML response helpers, nonce/capability failures, safe AJAX handlers, Find Posts modal output and wp_ajax_find_posts() JSON query branches, attachment query/save workflows, heartbeat nonce/hook/server-time branches, compression-test capability/body branches, superglobal and output-buffer restoration.
feed-renderingexpanded validation passedPasteur the 3rd / main worktree through df556c4fff; legacy feed template/dispatch follow-up in 5136734b9b; exact rss_enclosure() fixture follow-up in this branch.Latest focused runs: seeds 1 and 224 over 25 iterations each passed 300 checks with no skips after rss_enclosure() fixture coverage; adjacent feed-rendering,feed-parsers,syndication,http,widgets smoke passed 265 checks; latest broad seed 224 run passed 4741 checks with 1 skip.RSS2, Atom, RDF, RSS 0.92, and comments feed templates, namespace/header/item hook payloads, context and loop-ID oracles, legacy do_feed() normalization/default-feed/comment-feed dispatch, self-link request URI host/filter escaping, escaping, CDATA boundaries, content/excerpt switches, exact rss_enclosure() newline/filter fixture behavior, enclosure/build-date behavior.
httplocal validation passedScoped worktree component-fuzz-http-encoding through 86ea9ecac9; origin helper coverage through a25a6c03f6; prior Requests success-path coverage through 1cc604ef19, review fixes through afc95daacc, and cleanup hardening through 1aecc04af2.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 325 checks after WP_Http_Encoding coverage; adjacent http,feed-parsers,feed-rendering,syndication smoke passed 90 checks; bootstrap smoke passed; latest broad run passed 4792 checks with 1 skip.Remote request wrappers, pre_http_request short-circuiting, direct WP_Http::request() normalization and early-error contracts, real Requests success-path option mapping through a no-network fake transport, method/data-format/redirect/SSL/max-bytes/stream/cookie/header oracles, WP_HTTP_Requests_Response conversion, http_response mutation, debug hook cardinality, stream destination guards, chunk-transfer decoding, WP_Http_Encoding compression/decompression and accept-encoding contracts, safe URL flags, response helpers, header/cookie/proxy/redirect contracts, CORS/origin exact-match allowlists, HTTP origin filters, support capability normalization, and allowed request-host mirroring.
securityfocused passedScoped worktree component-fuzz-security-nonce-ays; invalid admin nonce follow-up in this slice; Lagrange the 3rd implementation and Hume the 3rd review findings fixed through 47b9b32fb5; referer/native-password follow-up through 764df53851.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent security,request-lifecycle,formatting,canonical-routing,http smoke passed 375 checks; adjacent security,auth-flow,account-security,identity,admin-ajax,admin-options-submission,admin-workflows smoke passed 189 checks; bootstrap smoke passed; latest broad run: 4814 passed, 1 skipped, 0 failed.Salts/HMACs, password and fast-hash verification filter locality, native bcrypt compatibility and migration rehash signals, nonce tick/lifetime boundaries, nonce URLs and hidden fields, raw/safe/original referer retrieval, admin/AJAX referers, invalid admin nonce wp_nonce_ays() failure paths, sanitized retry links, logout confirmation URLs, legacy -1 admin-referer bypass, auth cookie grace/session edges, and redirect sanitization/validation expected outcomes.
statefocused validation passedScoped worktree component-fuzz-state-cache-blog-switch; Huygens the 2nd reviewed overlap with multisite switch coverage, so this slice targets the direct object-cache wrapper.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips, bootstrap smoke passed, and broad seed 224 smoke passed 4770 checks with 1 skip. Earlier main focused runs passed 19 checks per iteration.Object cache groups and multi-operations, direct wp_cache_switch_to_blog() local/global group prefixing without full blog-context switching, default-group blog locality, raw stored-key prefix oracles, cache-addition suspension, options, transient filters, cache-backed and option-backed site transients, timeout update and manual-expiration cleanup paths, dynamic site-transient filters, external object cache flag restoration, serialization/JSON value helpers.
capabilitieslocal validation passedChandrasekhar the 2nd / main worktree through 3a78724c43Main focused run: 1000 passed, 0 skipped, 0 failed; smoke: 50 passed, 0 skipped, 0 failed; latest broad run: 7630 passed, 63 skipped, 0 failed.Role registry lifecycle, associative/boundary caps, user mutators, WP_User::for_site() cap-key isolation, user_can_for_site() wrapper contracts, filter locality, meta-cap monotonicity.
imagesintegrated validation passedZeno the 2nd / main worktree; rounding-drift oracle fix in 5f0eb0a4cc; content tag pipeline coverage through 93c6f19416, reviewed by McClintockMain focused runs: seed 1 and seed 224 each 1000 passed, 0 skipped, 0 failed after content tag pipeline review fixes; syntax/PHPCS/diff checks passed; latest broad run: 9075 passed, 66 skipped, 0 failed.Image helper math, synthetic metadata, responsive/filter boundaries, attachment attrs, content image/iframe tag filtering, direct auto-sizes/dimensions/srcset/sizes/loading helper gates, filetype helpers, and bounded wp_constrain_dimensions() rounding/idempotence behavior.
discoveryvalidation passedScoped worktree component-fuzz-discovery-favicon-front-controller builds on robots front-controller, sitemap stylesheet, and built-in sitemap provider coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 375 checks, adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks, disabled-subprocess smoke passed 14 checks with 1 skip, bootstrap smoke passed, and broad seed 224 smoke passed 4769 checks with 1 skip. Prior robots.txt front-controller runs passed 350 checks per seed.Robots meta, scoped public/private robots helper output, front-controller do_robots() robots.txt output, front-controller do_favicon() redirect-and-exit behavior through template-loader.php, favicon site-icon fallback/filter payloads, site-path admin directives, do_robotstxt/robots_txt hook order and cleanup, sitemap enablement, robots.txt injection, provider filters, query/permalink URL modes, escaped renderer XML, unsupported sitemap field boundaries, stylesheet URL filters, direct sitemap/index XSL output, LTR/RTL stylesheet CSS and filter locality, max-URL filters, and built-in posts/taxonomies/users sitemap provider subtype, lastmod, max-page, query-arg, pre-filter, and public/private gating oracles.
import-difflocal validation passedScoped component worktree component-fuzz-import-upload-lifecycle; import upload handler fail-closed and cleanup coverage validated before merge.Latest focused runs: seed 224 passed 10 checks, seeds 1 and 224 over 25 iterations each passed 250 checks, adjacent import-diff,media-ingest,media-remote,filesystem,cron,mail,email smoke passed 1387 checks with 1 skip, and broad seed 224 smoke passed 4698 checks with 5 skips.Importer registry/form helpers, import upload missing-file and prefiltered-error paths, upload override and filter cleanup, import attachment cleanup, text diff rendering, post/comment imported lookup through in-memory postmeta/comment fixtures, importer chunking, duplicate permalink behavior, base importer byte-length sorting, whitespace compaction, timeout/quota true and false branches, stop-the-insanity cleanup and WPDB restoration, WP_Error transfer and lifecycle ordering, and stub meta-query limit projection.
navigationintegrated validation passedBernoulli the 3rd / main worktree through 9aa5167057Main focused run: 1000 passed, 0 skipped, 0 failed; latest broad run: 8497 passed, 66 skipped, 0 failed.Nav menu registry, current-tree parent/ancestor propagation, fallback/short-circuit pipeline, args/items-wrap normalization, walker filters, container and attribute escaping.
navigation-lifecyclevalidation passedMain worktree on component-fuzzers; old component-fuzz worktrees and branches cleaned after validation.Main focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent navigation-lifecycle,navigation,customizer-nav-widgets-requests,rest-site-editor,block-templates,core-block-render,template-links,content-lifecycle,taxonomy-relationships smoke passed 490 checks; bootstrap smoke passed; latest broad run: 4766 passed, 1 skipped, 0 failed.DB-backed nav menu create/update/delete, duplicate-name failures, hook payloads, location assignment cleanup and remapping, custom/post/term menu item meta sanitization, ordering, orphan/self-parent normalization, associated object cleanup callbacks, auto-add page behavior, REST menu/menu-item/location read gates, validation errors, locations and auto_add mutation, forced-delete semantics, links, bootstrap controller includes, navigation fallback creation from classic menus, primary-location priority, parsed navigation-link/submenu block conversion, duplicate-free fallback reuse, query globals, and state restoration.
post-embedsvalidation passedScoped worktrees component-fuzz-post-embeds, component-fuzz-post-embeds-oembed-cache, component-fuzz-post-embeds-consumer, and component-fuzz-post-embed-host-js; provider implementation landed through b958538155; REST proxy cache follow-up through 0c0debbb5e; consumer provider/cache follow-up through 6a78c3ec42; host-script gate follow-up through 57fe546d48.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks; bootstrap smoke passed; latest broad run: 4794 passed, 1 skipped, 0 failed.WordPress-as-provider oEmbed helper coverage for embeddability filters, public visibility gates, response width clamps, rich iframe/thumbnail conversion, plain/pretty/path-conflict embed URL selection, iframe/blockquote/script markup, discovery links, wp_maybe_enqueue_oembed_host_js() host-script enqueue gates, direct WP_oEmbed_Controller item responses, same-site pre_oembed_result short-circuiting, public custom provider add/remove behavior, no-network wp_oembed_get() provider fetch URLs, WP_Embed::shortcode() post-meta cache writes/hits, standalone and paragraph autoembed() replacement, inline URL preservation, REST oEmbed proxy provider fetch and transient-cache behavior, nonce-excluded cache keys, dimension-sensitive cache misses, oembed_fetch_url/oembed_remote_get_args/embed_oembed_html/oembed_ttl/oembed_result/rest_oembed_ttl filter oracles, no-network HTTP interception, no content-row mutation, and narrow wpdb stub support for core path-conflict lookup queries.
rest-media-attachmentsvalidation passedScoped worktrees component-fuzz-rest-media-attachments and component-fuzz-rest-media-sideload.Latest focused runs: seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent rest-media-attachments,media-ingest,network-media,filesystem,media-metadata,admin-media-chrome,media-image-edit-requests smoke passed 61 checks, and broad seed 224 smoke passed 4700 checks with 5 skips.REST media attachment write coverage for raw upload parser/error contracts, raw body create_item() success, raw sideload subsize and original-image metadata updates, non-image/PDF rejection, attachment postmeta and response headers, explicit permission gates, client-side media-processing route/argument contracts, metadata finalization filters, _fields projection, edit-media fail-closed paths, and temp file/filter/global restoration.
rest-widgets-sidebarsvalidation passedScoped worktree component-fuzz-rest-widgets-sidebars; base implementation landed through bb8c89fa2a; render endpoint follow-up through bd01a25986.Main focused runs: seed 1 passed 9 checks, seed 224 over 25 iterations passed 225 checks, adjacent rest-widgets-sidebars,widgets,default-widgets,block-widgets,customizer-nav-widgets-requests,rest,rest-controllers,block-editor-adjuncts,admin-ajax smoke passed 420 checks with 10 skips; latest broad run: 4666 passed, 28 skipped, 0 failed.REST widget, widget type, and sidebar controller route/schema contracts, public show_in_rest read gates, widget type sorting/projection and encoded instance hashes, isolated /widget-types/{id}/render iframe preview dispatch, fail-closed permission and invalid-id render paths, parent-safe IFRAME_REQUEST containment, text-widget preview hooks and mutable-state restoration, text widget create/update persistence and sidebar reassignment, sidebar reorder/inactive semantics, legacy widget form-data updates, soft/force delete hooks, HEAD short-circuits, update-guard reset isolation, and state restoration.
mailreviewed validation passedScoped worktree component-fuzz-mail-address-encoding; Cicero the 2nd gap review; prior invalid-From follow-up through f9fb63e7ae.Latest focused runs: seed 224 passed 12 checks, seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent mail,email,privacy smoke passed 1280 checks, bootstrap smoke passed, and latest broad run passed 4752 checks with 1 skip; related PHPUnit: 32 tests, 87 assertions.wp_mail(), PHPMailer state reuse, UTF-8 local-part and IDN-domain recipient/display-name handoff, RFC2822 display-name MIME header encoding, literal mailbox preservation, decoded header round trips, unset SMTP Sender, UseSMTPUTF8 boundaries, recipient/header parsing, attachments, embeds, multipart boundary matrix, serialized MIME body/header preservation, invalid From failure payloads, cleanup/reset behavior, and success/failure hooks.
block-widgetsreviewed validation passedScoped component worktree component-fuzz-widget-editor-deps; editor dependency warning follow-up through 309a336a97; earlier widget ID cleanup through 32acfc1459, ID fixture fix through 4b90ae7339, retrieve follow-up through 12dcc507e5.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks; bootstrap smoke passed; broad seed 224 smoke passed 4798 checks with 1 skip. Earlier retrieve/remap focused run passed 900 checks.WP_Widget_Block, legacy class matrix, malformed/unknown block fallbacks, update/form escaping, the_widget() display/action/content ordering, rendered block output, registered control rendering, widget ID parsing, unregistered-widget cleanup, sidebar widget mapping, retrieve_widgets() remapping, customize no-persist behavior, orphaned/inactive carryover, lost multi-widget recovery, wp_check_widget_editor_deps() script/style conflict warnings, both widget-editor handles, dependency-chain enqueued semantics, scoped _doing_it_wrong() capture, asset queue/registration stability, and state restoration.
l10nlocal validation passedKant the 2nd / main worktree; locale-stack follow-up through 2f006c120fMain focused run: 1400 passed, 0 skipped, 0 failed; latest broad run: 8525 passed, 66 skipped, 0 failed.Translation globals, MO files, generated locale-switch stack/action payload matrices, locale switching, script translations, nooped plurals, filters/actions.
privacyvalidation passedScoped worktree component-fuzz-privacy-export-zip; builds on export-email notification follow-up through 72f3ae35e3, review fixes through 970437d39f, comments callback follow-up through 4fd5feddc9, final erasure completion follow-up through df3be8a1aa, built-in user/media exporter follow-up through de213220ba, and privacy policy lifecycle follow-up through 8cf920b5c3.Latest focused runs: seed 224 passed 20 checks, seeds 1, 224, and 57123 over 25 iterations each passed 500 checks, adjacent privacy,privacy-admin-requests,admin-ajax,filesystem,mail smoke passed 315 checks, bootstrap smoke passed, and latest broad run passed 4751 checks with 1 skip; related PHPUnit: 143 tests, 358 assertions.User requests, request keys, missing-request and global-post fallback fail-closed behavior, exporter/eraser registries and processors, real personal data export ZIP generation with filtered temp roots, exact export.json/index.html archive inspection, report HTML escaping and KSES value handling, export-created action payloads, temp report cleanup, legacy export-file meta migration, invalid request/action/email/JSON fail-closed paths, final erasure completion status/meta/action behavior, built-in comments exporter/eraser payloads and anonymization, built-in user profile/community-location/session-token exporter payloads, additional profile filter and escaped-report contracts, built-in media exporter registration, author/type filtering, URL payloads, and 50-item pagination, export email recipient/subject/content/header filters through fake PHPMailer, invalid-request and mail-error paths, default Unicode email filter setup, temp export paths, privacy policy registration/default content, suggested-text lifecycle cache transitions, text-change cache and admin notices, and state restoration.
privacy-admin-requestslocal validation passedScoped component worktree component-fuzz-privacy-admin-actions; bulk resend follow-up on component-fuzz-privacy-resend; original feature coverage through 0cc587f3a1Main focused runs: seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks after admin form action/default processor coverage, adjacent privacy-admin-requests,privacy,mail,email,user-preferences,admin-workflows,admin-ajax smoke passed 1314 checks with 1 skip, and latest broad run passed 4707 checks with 5 skips.Privacy export and erasure request list-table view/count/status filtering, row action nonce/data attributes, checkbox/status/bulk-action markup, direct request completion/delete helpers, mixed bulk resend success/failure accounting, regenerated confirmation keys and stored hash validation, intercepted confirmation email subject/content/header filter contracts, admin add-request and retry form handlers, pending request cleanup, default export/erasure AJAX processor state transitions, AJAX export and erasure success and fail-closed branches, capability gates, selected exporter/eraser/page callbacks, malformed callback response contracts, scoped Unicode email filters, runtime cache isolation, and state restoration.
interactivityreview fixes passedMain worktree; Copernicus the 3rd exploration and Rawls the 3rd review findings fixed through 4747a53d87Main focused run: 1000 passed, 0 skipped, 0 failed; bootstrap smoke passed; latest broad run: 7982 passed, 66 skipped, 0 failed.Interactivity API directives, context namespace stack merge/sort/restoration, derived closure tracking and fail-closed errors, state/config stores, script module hooks, router region support.
appearance-medialocal validation passedScoped worktree component-fuzz-appearance-background-remove; custom-header print side-effect follow-up through 627a726d0e builds on site-icon attachment URL follow-up through a942ff51c7, head-callback CSS follow-up through 643ba2fd4d, Dirac the 4th fixes through 39a5bb77d1, and no-upload admin guard follow-up through 91a7645818.Latest focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks after background remove safe-redirect coverage; adjacent appearance-media,admin-media-chrome,media-ingest,media-metadata,images,filesystem,customizer smoke passed 350 checks with no skips; latest broad run: 4736 passed, 1 skipped, 0 failed.Theme mods, theme support globals, custom background/header helpers, custom background frontend head-callback CSS normalization and escaping, custom-logo hide-header-text CSS class sanitization, custom header video URL/settings/markup active gates, exact the_custom_header_markup() output/enqueue/localization side effects with isolated WP_Scripts state, synthetic custom-logo attachment markup and filter payload oracles with DB-stub cleanup, request-scheme-aware header oracles, site icon filters, real site-icon option-to-attachment metadata URL and stale-ID fallback oracles, bounded Custom_Image_Header::step(), no-upload header/background admin action guards, remove-background same-host and fallback safe redirects, invalid remove nonce fail-closed behavior, deprecated media field/tab passthroughs, content-count state restoration, and post type/status bootstrap.
customizer-persistencevalidation passedScoped worktree component-fuzz-customizer-save builds on earlier persistence work through 8f757f9079.Latest focused runs: seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent customizer-persistence,customizer-nav-widgets-requests,customizer,admin-ajax,user-preferences smoke passed 44 checks, and broad seed 224 smoke passed 4702 checks with 5 skips.Customizer classes, changeset UUID/data parsing, changeset lock/heartbeat persistence, transactional changeset saves, captured WP_Customize_Manager::save() AJAX envelopes, nonce/status/date/JSON failure paths, successful draft save response filters, edit locks, Custom CSS settings, options/theme mods, and global/superglobal restoration.
rest-site-editorvalidation passedScoped worktree component-fuzz-rest-site-editor-revisions-autosaves builds on mutation coverage, bounded dispatch coverage through 6002f40de7, review fixes through 63aadcb347, template collection dispatch guards through 0b57a4238a, and collection GET dispatch coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent rest-site-editor,rest,rest-object-controllers,block-templates,revisions-autosaves,content-lifecycle,blocks smoke passed 435 checks; bootstrap smoke passed; broad seed 224 smoke passed 4802 checks with 1 skip.REST server/request, global styles/templates/navigation controllers, bounded template and template-part collection GET dispatch, post-type and area query propagation, _fields collection projection, bounded pre_get_block_templates fixtures, template and template-part item dispatch, template and template-part mutation lifecycles, create/update/delete/reset controller paths, REST insert hooks, origin metadata, theme and area taxonomy assignments, collection route-index contracts, denied collection/lookup dispatch before template lookup, HEAD collection short-circuits, revision/autosave collection route metadata and collection dispatch, bounded revision WP_Query shapes, revision query filters, template-id-preserving revision pagination headers/links, autosave filtering, invalid-page failures, lookup fallback retry/no-fallback dispatch, direct ZIP export generator, subprocess-isolated live export streaming, scoped template filters, streamed ZIP inspection, archive cleanup, temp fixtures, and theme-state restoration.
media-remotereviewed validation passedMain worktree; Ptolemy implementation reviewed clean by Planck through 570897ffdfMain focused run: 700 passed, 0 skipped, 0 failed after download_url() filename derivation matrix; reviewer seed 224 run: 700 passed, 0 skipped, 0 failed; latest broad run: 8641 passed, 66 skipped, 0 failed.HTTP short-circuiting, case-insensitive download headers, Content-Disposition/content-type filename derivation and sanitization matrix, URL basename fallback, signature soft/hard failures, temp files, filetype checks, media sideload return types, sideload prefilter cleanup and override filename contracts.
bookmark-linksreview follow-up passedMain worktree through d0c40e9079Main focused run: 900 passed, 0 skipped, 0 failed; latest broad run: 8497 passed, 66 skipped, 0 failed.Link/bookmark APIs, optional narrow wpdb link-table support, template links, and direct _walk_bookmarks() image/update/filter rendering contracts.
template-linksreviewed validation passedScoped worktree component-fuzz-template-authors; author-template helper coverage landed in 02a49529f7 after template archive navigation, direct search-form, adjacent image attachment, post container, archive, adjacent-post, and canonical coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 425 checks; adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks; bootstrap smoke passed; broad seed 224 passed 4799 checks with 1 skip. Earlier archive-navigation focused runs passed 400 checks per seed, search-form focused runs passed 375 checks per seed, and adjacent-image focused runs passed 350 checks per seed.Body/language attributes, body/language filter ordering and locality, get_post_class()/post_class() base/custom/filter/taxonomy/sticky/password/thumbnail container tokens, document titles, resource hints/preloads, pagination/search/feed/site/admin URLs, archive post-list navigation wrappers, get_the_posts_pagination() argument filtering and echo parity, _navigation_markup() class/ARIA escaping, direct get_search_form() HTML5/XHTML rendering, ARIA/query escaping, echo/getter parity, legacy boolean return mode, ordered search-form hook/filter payloads, null-filter fallback, output-buffer and query-state restoration, canonical and shortlink head output, synthetic post helpers, date and author archive URL helper filter payloads, author display/modified-author/meta/link/post-count/listing/multi-author helper contracts, dirty author nicename display escaping/locality, previous/next adjacent post relation links, adjacent image attachment links, and bookmark rendering.
image-metadatalocal validation passedMain worktree through 582c472ce7; real Core fixture replay follow-up in the current component slice.Latest focused runs: seed 224 passed 8 checks, seeds 1 and 224 over 25 iterations each passed 200 checks, adjacent media parser/chrome smoke passed 87 checks with no skips, standalone rest-media-attachments sanity passed 7 checks, and latest broad run passed 4730 checks with 1 skip. Earlier focused run: 700 passed, 0 skipped, 0 failed.Admin image metadata parser, EXIF/IPTC availability, locale-aware XMP alt selection/fallbacks, generated local binary fixtures, real Core EXIF/IPTC/XMP fixture replay for representative camera, timestamp, keyword, orientation, UTF-8 caption, and accessibility-alt metadata, parser filter payloads, and state restoration.
utility-internalslocal validation passedScoped component worktree component-fuzz-diagnostics-error-helpers; Descartes the 2nd reviewed direct diagnostic/error helper gaps and hook-state caveats; builds on earlier component-fuzz-utility-deep and component-fuzz-token-map-export work.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent utility-internals,hooks,import-diff,registries,error-protection,site-health-debug smoke passed 305 checks; bootstrap smoke passed; broad seed 224 smoke passed 4762 checks with 1 skip.List utilities, chained filter/sort/pluck state, parse-list and array-path helpers, token-map lookup/precompute/export behavior, non-default key-length prefix reconstruction, exported-key NUL boundaries, rewrite match substitution, URL pattern prefixing, lodash-compatible kebab-case helper fixtures, hierarchy-loop detection and direct tortoise-hare probes, unique ID and UUID contracts, boolean validation semantics, direct is_wp_error() action payload/counter/context checks, wp_debug_backtrace_summary() raw/pretty/skip/ignore-class stack contracts, wp_trigger_error() hook/filter/debug-gating oracles, cloned hook snapshots, and state restoration.
icons-connectorsreviewed validation passedMain worktree through fb5b15bc76; Rawls implementation reviewed clean by PauliMain focused run: 700 passed, 0 skipped, 0 failed; reviewer found no actionable findings; latest broad run: 8653 passed, 66 skipped, 0 failed.Connector registry, REST settings AI-key validation fail-closed behavior, key masking and file-modification policy, connector plugin install/activation module-data status, icons registry, REST icon guards, schema/escaping contracts.
script-loader-runtimevalidation passedScoped worktree component-fuzz-script-polyfill; builds on emoji style split, concat follow-up, JIT localization, and emoji loader asset accounting coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts,frontend-features smoke passed 410 checks; bootstrap smoke passed; latest broad run: 4803 passed, 1 skipped, 0 failed.Script/style loader helpers, conditional polyfill inline script generation for relative/content/CDN URLs, version query args, script_loader_src filter payloads and skip behavior, inline data, translations, emoji style enqueue/hook removal and inline CSS escaping, emoji detection settings/module output with generated loader-asset materialization/cleanup, isolated JIT localization for autosave, mce-view, and word-count with generated shortcode tags, JSON escaping, child/parent state restoration and row-local subprocess capability skips, strategy/fetchpriority/module interactions, generated classic-script module import-map/modulepreload graphs, concatenated load-scripts/load-styles URL chunk parsing, excluded async/defer/external tags, inline sourceURL placement, output ordering, and state restoration.
error-protectionlocal validation passedCicero the 3rd / main worktree through 4bc14e0ecc; pure boundary follow-up through 61e22a3455; process-control subprocess follow-up.Latest focused runs: seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks with no skips, adjacent error-protection,user-preferences,site-health-debug,admin-workflows smoke passed 41 checks, and broad seed 224 smoke passed 4717 checks with 4 skips.Recovery mode, paused extensions, fatal handler guards, synthetic fatal-handler handle() orchestration, recovery-link begin-link early returns and child-process redirect/die paths, recovery-mode exit redirect/die/cleanup paths, wp_register_fatal_error_handler() drop-in shutdown execution, recovery-mode handle_error() gate behavior, fatal-error email rate limits, and no real mail or parent-process exits.
feed-parsersfocused validation passedScoped component worktree component-fuzz-feed-fetch; Archimedes read-only gap scan identified RDF parser coverage, with additional bounded fetch_feed() orchestration coverage added locally.Latest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent feed-parsers,feed-rendering,syndication,http,widgets smoke passed 52 checks, and broad seed 224 smoke passed 4712 checks with 5 skips.RSS/Atom/RDF parsers, Magpie RSS 1.0 rdf:about and namespace normalization, image/textinput parsing, AtomParser local-file behavior, SimplePie raw-data/KSES parsing, WP_SimplePie_File HTTP response/header/error-state normalization, fetch_feed() empty/single/multi/error orchestration, transient cache hook payloads, bounded XML fixtures, temp cache paths, preempted HTTP responses, no-network assertions, and state restoration.
environment-loadreview fixes passedScoped component worktree component-fuzz-env-memory-limit; Averroes the 2nd reviewed isolated wp_raise_memory_limit() coverage; builds on environment type matrix follow-up through 7580167b9c and review fix through 2047262604.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 450 checks; adjacent environment-load,site-health,site-health-debug,cron,media-editor,update-install-upgrader smoke passed 1520 checks; bootstrap smoke passed; broad seed 224 smoke passed 4761 checks with 1 skip.Load/compat helpers, isolated WP_RUN_CORE_TESTS environment-type matrices, env-var allowed/fail-closed behavior, WP_ENVIRONMENT_TYPE constant precedence including invalid constant fail-closed behavior, parent/child environment restoration, row-local subprocess capability skips, memory parsing and ini mutability, isolated wp_raise_memory_limit() context/filter negotiation, high-current and unlimited-current no-op branches, dynamic memory-limit hooks, SSL headers, generated JSON/XML request media matrices, and scoped superglobals.
update-install-upgraderfocused validation passedcomponent-fuzz-upgrader-notifications worktree; Halley gap scan followed earlier McClintock/Helmholtz updater workLatest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent update-install-upgrader,plugin-theme,plugin-theme-lifecycle,filesystem,admin-list-tables smoke passed 58 checks with 1 existing skip, and latest broad run passed 4710 checks with 5 skips.No-network updater flows, update transient aggregation, upgrader skins, temp install-package lifecycle, temp-backup cleanup/restore default paths, package validation, auto-update filters, VCS/PHP gates, core version policy, plugin/theme auto-update notification classification and failure-cache behavior, intercepted email delivery, maintenance-mode temp writes.
plugin-themereview fixes passedMain worktree; Popper the 3rd implementation and Godel the 3rd review findings fixed through 260ccee45dMain focused run: 900 passed, 0 skipped, 0 failed; latest broad run: 8035 passed, 67 skipped, 0 failed.Plugin headers/path helpers, invalid path validation, dependency slug/name/API-data fallbacks with fail-closed live-API guards, local active dependency option states, theme headers, parent/child relationships, active theme file helpers, screenshots, and broken theme errors.
plugin-theme-lifecyclevalidation passedScoped worktree component-fuzz-rest-plugin-install builds on earlier lifecycle work through 848decfbb5 and REST write/delete coverage.Latest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks, adjacent plugin-theme-lifecycle,rest-controllers,update-install-upgrader,filesystem smoke passed 270 checks, and broad seed 224 smoke passed 4718 checks with 4 skips.Plugin/theme lifecycle helpers, generated headers and paths, activation/deactivation hook locality, multi-plugin deactivation scope/action payload ordering, basename/path normalization, theme roots, stylesheet/template boundaries, read-only REST plugin/theme controller paths, REST plugin create/install paths with generated packages and mocked repository API responses, REST plugin status update transitions, active-delete rejection, inactive temp-plugin deletion, updater-hook isolation, and filesystem/cache cleanup.
install-schemareview fixes passedMain worktree; Sartre implementation and Poincare review findings fixed through 224b36546dMain focused run: 1100 passed, 0 skipped, 0 failed after creation/replay review fixes; reviewer seed 224 run: 1100 passed, 0 skipped, 0 failed; latest broad run: 8639 passed, 66 skipped, 0 failed.No-DB install/schema wrappers, wp_get_db_schema() scope expansion, global-table upgrade gate filters, make_db_current() noisy/silent wrappers, dbDelta() parser/diff/index/allowlist contracts, missing-table creation messages, same-double replay no-ops, and executed/introspection table allowlists.
translationslocal validation passedFeynman the 2nd / main worktree; controller follow-up through 89fd5f092eMain focused run: 800 passed, 0 skipped, 0 failed; latest broad run: 8523 passed, 66 skipped, 0 failed.Translation globals, MO/PO/PHP files, direct WP_Translation_Controller locale/domain/file isolation, lazy malformed-file eviction, locale switching, script translations, nooped plurals, install/API short-circuit paths, and filter/action restoration.
community-eventslocal validation passedScoped component worktree component-fuzz-community-events-ajax; admin AJAX wrapper coverage validated before merge.Latest focused runs: seed 224 passed 10 checks, seeds 1 and 224 over 25 iterations each passed 250 checks, adjacent community-events,admin-ajax,admin-dashboard,http,user-preferences smoke passed 54 checks, and latest broad run passed 4714 checks with 5 skips.IP anonymization, fail-closed request minimization, transient/cache behavior, strict coordinate matching, cache expiration normalization, search-triggered cache refresh, event trimming, response and error contracts, and wp_ajax_get_community_events() JSON envelopes/user-location persistence.
taxonomylocal validation passedScoped component worktree component-fuzz-taxonomy-hierarchical-term-links; builds on the no-DB taxonomy surface.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 325 checks; adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks; bootstrap smoke passed; latest broad run passed 4771 checks with 1 skip. Earlier main focused run passed 1200 checks.Taxonomy registry lifecycle, registration filters/actions, object shape, REST controller creation, query/rewrite side effects, term-query short-circuit parsing, term field filters, hierarchy helper edges, cache-backed term objects, link helpers, hierarchical parent-slug expansion, get_term_parents_list() agreement, and legacy term-link filter ordering.
admin-barvalidation passedScoped worktree component-fuzz-admin-bar-nodes; single-site callback node graph follow-up through 9211f6d1e5; earlier default callback node graph follow-up through 555495f583.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks; bootstrap smoke passed; latest broad run: 4795 passed, 1 skipped, 0 failed.Toolbar node lifecycle, root/submenu binding, raw/escaped render contracts, back-compat parent aliases, deprecation hook firing, numeric/hostile tabindex rendering, initialization hooks/theme support, show_admin_bar() filters, default menu hook priorities/action firing, default callback-produced WordPress logo, account, appearance, comments, search, and secondary-group node graphs, plus single-site site menu, new-content, updates, sidebar-toggle, and command-palette callback nodes.
post-typeslocal validation passedScoped worktree component-fuzz-post-status-viewability; builds on duplicate registration cleanup through 09c2d3b4f8.Latest focused runs: seed 224 passed 13 checks, seeds 1, 224, and 49380 over 25 iterations each passed 325 checks; adjacent post-types,content,content-lifecycle,query,taxonomy,taxonomy-relationships,rest-controllers,metadata smoke passed 731 checks with no skips; latest broad run passed 4733 checks with 1 skip. Targeted PHPUnit was unavailable because wp-tests-config.php is missing.Post type/status registries, sanitized post-status key storage and overwrite behavior, generated is_post_status_viewable() public/private/custom/built-in/internal/protected object-property matrices, string/object lookup parity, unsanitized-name and invalid-input fail-closed paths, strict boolean is_post_status_viewable filter contracts and cleanup, duplicate post-type replacement cleanup for query vars, rewrite rules, supports, taxonomies, hooks, and meta caps, registration filters/actions and meta-box lifecycle, isolated REST route registration, support-gated revisions/autosaves, late route ordering, capabilities, query/rewrite state, unregister cleanup, archive/feed link helpers and filters.
stylelocal validation passedMendel the 3rd source-order follow-up through cc5a912e25; Erdos block-support wrapper follow-up through 11704a8287; Newton review fixes through efff07281e; global stylesheet resolver follow-up through bc27826265Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks after global styles user-data/getter coverage; adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4768 checks with 1 skip. Prior global stylesheet runs passed seed 1 and seed 224 at 375 checks, and block-support wrapper runs passed seed 1 and seed 224 at 1400 passed with 100 skips.Style engine preset class/CSS-var boundaries, block selector helpers, theme.json variable resolution, registered block style style_data source-order injection, block-support register/apply callbacks, direct and aggregate WP_Block_Supports wrapper attributes, final get_block_wrapper_attributes() escaping/merge/dedupe behavior, custom-only hostile style values, exact render_block hook restoration, block editor theme style filters, direct seeded wp_get_global_stylesheet() variables/styles/presets/custom-css output, cache reuse/preservation, direct wp_get_global_settings()/wp_get_global_styles() getter paths, wp_global_styles active-theme user-data lookup, safe-flag fail-closed behavior, global style post-ID creation/cache reuse, zero unintended wpdb-stub leakage, resolver/object-cache restoration, stylesheet serialization, and cleanup.
site-health-debugskip retirement passedMain worktree; Chandrasekhar the 3rd findings fixed through e11ee9a6a7; full debug-data scan follow-up through 9f1d3f3662; database malformed-row boundary accounting through 791c426de8Latest focused runs: seeds 1 and 224 each 225 passed, 0 skipped, 0 failed over 25 iterations; adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 139 checks with 0 skips; latest broad run: 4688 passed, 7 skipped, 0 failed. Prior size-helper matrix passed 700 checks with 200 skips.WP_Debug_Data formatting, debug_information filter locality, isolated full debug_data() section assembly, fake no-network WordPress.org response, bounded Ghostscript detection, path-size loading placeholders, database size including malformed SHOW TABLE STATUS row strict/omission boundary accounting, generated directory/database/total size aggregation, timeout-control skip behavior, scoped SHOW TABLE STATUS/SHOW VARIABLES wpdb doubles, MySQL variable fallbacks, and child-process cleanup/restoration.
site-healthreview fixes passedSchrodinger implementation through 38d7aaaabe; Raman review fixes through 6b25026e9b; loopback/REST/cron follow-up through a9fdacdc73Main focused runs: loopback/REST/cron seed 1 and seed 224 each passed 300 checks; adjacent site-health,site-health-debug,http,cron smoke passed 600 checks with 10 skips; latest broad run: 4664 passed, 28 skipped, 0 failed.Site Health update and HTTPS helpers plus generated site_status_tests filter modes, direct/async test metadata preservation, synthetic loopback/REST request outcomes and request-shape oracles, scheduled event missed/late/future cron classification, HTTP-blocking constant status checks, persistent object cache thresholds/filters/table-row oracles, direct callback mutation through site_status_test_result, environment/basic-auth gates, no-network guarantees, and singleton/output-buffer restoration.
block-supportslocal validation passedScoped worktree component-fuzz-block-supports-duotone; builds on base block-support coverage through e2f832432a.Latest focused runs: seed 224 passed 7 checks, seeds 1 and 224 over 25 iterations each passed 175 checks, adjacent block-supports,blocks,style,core-block-render,block-editor-adjuncts,block-widgets,interactivity smoke passed 66 checks with no skips; latest broad run: 4731 passed, 1 skipped, 0 failed.WP_Block_Supports, block support registration callbacks, wrapper attribute merging, skip-serialization gates, auto-generated control markers, background/dimensions/visibility/position/layout render filters, elements and custom CSS render-data filters, state-style selector/layout helpers, duotone support registration gates, legacy metadata migration, preset/custom/unset/global-style render paths, empty-content CSS generation, unsupported/no-attribute fail-closed paths, safe stored CSS/SVG/editor assets, WP_Block_Type_Registry, theme-json caches, scripts/styles globals, style-engine stores, and WP_Duotone static restoration.
blockslocal validation passedScoped worktree component-fuzz-builtin-block-bindings; builds on nested attribute, render-time block bindings, and block hook follow-ups through 51db0730d0, f53cfd1bfc, d83d02f5e0, 9e4c4ee83c, and the post-object hooks slice.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent blocks,registries,block-supports,core-block-render,content-lifecycle,taxonomy,taxonomy-relationships,navigation,navigation-lifecycle smoke passed 450 checks; bootstrap smoke passed; latest broad smoke passed 4801 checks with 1 skip.Nested parse/serialize round trips, generated nested attribute round trips, has_block()/has_blocks(), dynamic render callbacks, render filters, registry/support behavior, render-time block bindings supported-attribute filters, source and value-filter payloads, context propagation, computed attribute merge before dynamic render callbacks, supported HTML replacement, unchanged unsupported attributes, missing/malformed binding skips, built-in block binding source loading/registration for core/pattern-overrides, core/post-data, core/post-meta, and core/term-data, pattern override lookups, post and navigation entity source paths, REST meta visibility/protection gates, term field escaping and non-public taxonomy gates, block hook insertion plus ignored-metadata oracles, post-object wrapper root metadata, update_ignored_hooked_blocks_postmeta(), and REST response raw/meta/rendered block hook mutation.
admin-options-submissionlocal validation passedScoped component worktree component-fuzz-admin-options-core-pages; builds on pending admin email coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks; bootstrap smoke passed; latest broad run passed 4773 checks with 1 skip. Earlier pending-email runs passed 150 checks per focused seed.Settings API options submission allowlists and sanitize callbacks, settings error transient persistence, General Settings date/time/timezone normalization, Reading/Discussion/Media/Writing core option-page sanitization, conditional Writing Settings allowlist gates for post-by-email, legacy DB-version options, and public-blog update services, pending new_admin_email adminhash/confirmation-mail behavior and same-current/invalid no-mail branches, legacy options.php page_options submissions, nonce/capability/unknown-page failure paths, redirect capture, slashed request semantics, and global/filter/option restoration without including the exiting admin controller.
core-block-rendervalidation passedScoped component worktree component-fuzz-core-block-lists; frontend list-style dynamic block render coverage validated before merge.Latest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks, adjacent core-block-render,blocks,block-supports,navigation,navigation-lifecycle,classic-walkers,query-loop smoke passed 131 checks, and latest broad run passed 4720 checks with 4 skips.Representative dynamic core block render callbacks for site title/tagline, search, loginout, post title/date/excerpt/read-more, button, file, image, server-side navigation blocks, and frontend list-style archives/categories/latest-posts/latest-comments/tag-cloud/calendar blocks; option/cache setup, current-request links, archive/calendar cache keys, posts/comments/terms pre-query filters, KSES and attribute escaping, parsed pagination-link oracles, navigation overlay/interactivity/page-list oracles, actual HTML attribute safety scans, temporary filter cleanup, and global/superglobal/option/static restoration.
date-timereview follow-up passedScoped component worktree component-fuzz-timezone-choice; Singer the 2nd reviewed the direct timezone-choice markup gap; latest coverage through ead9a7ca12; earlier DST and wp_checkdate() follow-up through 4778dea14a.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent date-time,l10n,translations,admin-options-submission,customizer,admin-screen smoke passed 310 checks; bootstrap smoke passed; broad seed 224 smoke passed 4780 checks with 1 skip. Earlier main focused run: 1400 passed, 0 skipped, 0 failed.wp_date()/date_i18n()/mysql2date(), timezone options and override offsets, direct wp_timezone_choice() markup for empty, named, deprecated BC-only, UTC, manual-offset, hostile, and locale-variant selections, l10n textdomain short-circuit/restoration, named-timezone DST/ISO8601 conversion modes, wp_checkdate() filter contracts, GMT/local round trips, date and human-diff filter contracts.
emailworker validation passedDarwin the 3rd / Bacon the 3rd / main worktree through c53162cd27Unicode generated-matrix focused run: 123379 passed, 100 skipped, 0 failed; latest broad run: 8537 passed, 66 skipped, 0 failed.Unicode/ASCII filters, generated UTF-8 local/domain filter-view matrices, optional Unicode API availability skips, disabled-filter fail-closed behavior, REST user email schema validation, WP_Email_Address views, IDN/Punycode, accent-distinct local/domain user lookups, canonical Unicode-domain save/update collisions, password-reset recipients, malformed UTF-8 and boundary oracles.
xmlrpclocal validation passedScoped component worktree component-fuzz-xmlrpc-write-methods; prior read-only content/media coverage through 50b7b3e2a3.Main focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks after authenticated write-method coverage; adjacent XML-RPC/content smoke passed 1162 checks; latest broad run passed 4708 checks with 5 skips.IXR value/request/message/server contracts, mixed success/fault multicall ordering, legacy post XML helpers, pingback fail-closed/read-only lookup coverage, authenticated wp.getPost/wp.getPosts/wp.getMediaItem/wp.getMediaLibrary field/auth/cap/media filtering, authenticated wp.newPost/wp.editPost/wp.deletePost auth/capability/mutation/trash branches, short-circuited WP_HTTP_IXR_Client transport, fault/error mapping, and scoped cleanup.
commentsvalidation passedScoped worktree component-fuzz-comments-list-rendering; direct wp_list_comments() follow-up in this slice; count/navigation helper follow-up through 07a873186b; prior comment-form follow-up through 9bfd3cd9bf.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 11575 checks; adjacent comments,template-links,core-block-render smoke passed 2445 checks; adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 2565 checks; query-oriented adjacent comments,classic-walkers,template-hierarchy,content,comment-workflow,query,query-loop,template-links smoke passed 2500 checks; bootstrap smoke passed; latest broad seed 224 run passed 4813 checks with 1 skip.Filtering, max lengths, partitioning, cookies, classes, author links and fallback author-name contract, excerpts, direct wp_list_comments() rendering orchestration, explicit type routing, echo/getter parity, list-argument filter payloads, global comment-query fallback, option-driven pagination/depth/order defaults, requery and overridden-cpage paths, public comment count/text/link/popup helpers, comment page number links, next/previous comments links, comment pagination and navigation wrappers, singular fail-closed behavior, reply/cancel link rendering gates, full comment_form() rendering branches, field/action/submit filters, comment ID fields, page-of-comment counting, permalink anchors, exact cpage matching, threaded parent resolution, and post/parent query constraints.
ksesvalidation passedScoped worktree component-fuzz-kses-hair-fixtures; builds on helper/block attribute fixes through 50de103325, PDF/URI coverage through e3d43f715a, and attribute constraint coverage through 8fc00de203.Latest focused runs: seed 1 over 25 iterations passed 27528 checks and seed 224 over 25 iterations passed 27537 checks after exact wp_kses_hair() parser fixture coverage; adjacent kses,security,markup,shortcodes,formatting smoke passed 3528 checks; bootstrap smoke passed; latest broad run: 4740 passed, 1 skipped, 0 failed.KSES policies, protocol filtering/helper contracts, exact low-level helper contract matrix, exact wp_kses_hair() parser fixtures for entity recoding, duplicate attributes, malformed quote/equal recovery, special attribute names, slash-separated attributes, URI protocol filtering, and custom protocols, filter-aware safe CSS, deterministic attribute constraint matrices, serialized block attribute filtering and hook-path agreement, built-in PDF object URL/type policy, dynamic URI attribute filtering, by-reference attribute mutation behavior, required attribute fallback stripping, entity-decoded style filtering, full-tag attribute parsing, malformed HTML, no-HTML filtering, scoped filter cleanup, callback-order preservation, run-level pre_kses hook-state restoration, and global restoration.
formattinglocal validation passedScoped worktree component-fuzz-formatting-wptexturize; builds on file/user sanitizer and identifier sanitizer follow-ups through 30c7fcc27e.Latest focused runs: seeds 1 and 224 over 25 iterations each passed 400 checks, seed 100 over 50 iterations passed 800 checks, adjacent formatting,shortcodes,markup,content smoke passed 1900 checks, bootstrap smoke passed, and latest broad run passed 4748 checks with 1 skip.Escaping, text/URL sanitizers, whitespace, wptexturize() exact quote/apostrophe/dash/ellipsis/dimension/ampersand oracles, protected tag and shortcode preservation, registered shortcode texturization, HTML comment and punycode double-hyphen guards, split/shortcode regex recomposition, direct prime classification, marker-leak/idempotence/filter-cleanup checks, clickable links, title/key/class identifier sanitizers, file-name sanitizer exact fixtures for multi-extension munging, Unicode-space normalization, invalid UTF-8, unnamed-file fallback, path/control/extension generated cases, username sanitizer exact fixtures for tag/entity/percent stripping and strict ASCII mode, sanitizer filter contracts and cleanup, entities, colors, sizes, time strings, UTF-8 helpers, and accents.
network-mediareview fixes passedMain worktree; Socrates implementation and Helmholtz review findings fixed through ac5734c0c9; upload-bits follow-up through ece71f4765.Latest focused runs: seed 224 passed 1 aggregate row with 299 cases, 1667 API calls, and 2087 assertions, seeds 1 and 224 over 25 iterations each passed 25 aggregate rows, adjacent network-media,media-ingest,media-remote,media-metadata,image-metadata,images,filesystem smoke passed 55 checks, and broad seed 224 smoke passed 4692 checks with 5 skips.URL parsing/sanitization/validation, set_url_scheme() aliases and filter payloads, SSL/admin globals, path normalization, filenames, filetypes, wp_get_ext_types()/wp_ext2type()/wp_get_mime_types()/wp_match_mime_types() catalog/filter contracts, upload iframe URL query/filter/global contracts, custom wp_check_filetype_and_ext() MIME allowlist oracles, wp_unique_filename() callback/filter contracts, generated collision/alternate-extension filename oracles under filtered upload roots, wp_upload_bits() exact-byte writes, virtual subsize file-list collisions, upload-bits and final upload filter payloads, filter-error and empty-name failures, dimension-like/-scaled/-rotated basename guards, reverse output-format source basename reservations, core-empty basename behavior, sideload helpers, upload quota oracles, network upload MIME allowlists, direct file-too-large checks, and check_upload_size() error/state behavior.
revisions-autosaveslocal validation passedScoped worktree component-fuzz-revisions-preview-dispatch; latest revision count/user-filtered autosave coverage through 4a2cad026c; direct revision template output through a3c1660089.Latest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks after preview request dispatch coverage; adjacent revisions-autosaves,content,query,security,auth-flow,request-lifecycle smoke passed 3550 checks with no skips; latest broad run: 4735 passed, 1 skipped, 0 failed. Earlier latest-count/autosave lookup matrix passed seed 1 and seed 224 at 600 checks with 50 historical guarded skips.Protected revision field filters, autosave create/update/delete, autosave-preserving retention pruning, user-filtered autosave lookup, latest revision count and URL helpers, direct wp_print_revision_templates() structure/lock-branch output, bounded _show_post_preview() request dispatch, nonce fail-closed wp_die() capture, the_preview autosave overlays, empty/no-revision and revisions-disabled gates, revision-input edit links, post locks, restore/title/list helpers, post type support, capability/error paths, and bounded WP_Query found_posts/post_author stub semantics.
options-autoloadlocal validation passedScoped worktree component-fuzz-option-group-prime; registered option-group cache priming coverage through d10d0fa5bf; prior review fixes through 6e806f722a.Latest focused runs: seed 224 passed 14 checks, seeds 1, 224, and 57123 over 25 iterations each passed 350 checks; adjacent options-autoload,state,multisite,admin-options-submission,admin-workflows,security smoke passed 82 checks with 1 skip; bootstrap smoke passed; latest broad run passed 4781 checks with 1 skip. Earlier main focused run passed 1300 checks.Option CRUD, alloptions/notoptions caches, direct and registered option-group cache priming, duplicate/missing/preprimed/alloptions group-member cache boundaries, bulk autoload mutators, lifecycle hooks, wpdb option SQL shapes, filtered default/pre/option cache boundaries, query-count cache-hit guards, and direct alloptions mutation-state checks.
mailreviewed validation passedScoped worktree component-fuzz-mail-address-encoding; Cicero the 2nd review; prior invalid-From follow-up through f9fb63e7ae.Latest focused runs: seed 224 passed 12 checks, seeds 1, 224, and 57123 over 25 iterations each passed 300 checks, adjacent mail,email,privacy smoke passed 1280 checks, bootstrap smoke passed, and latest broad run passed 4752 checks with 1 skip; earlier invalid-From adjacent smoke passed 2561 with 2 skips; related PHPUnit: 32 tests, 87 assertions.wp_mail() filters, PHPMailer handoff, string/array headers, RFC2822 display-name MIME header encoding, literal mailbox preservation, decoded header round trips, unset SMTP Sender, UseSMTPUTF8 boundaries, attachments, embeds, reusable state reset, Unicode handoff, multipart boundary matrix, invalid From wp_mail_failed payloads including embeds, pre-send cleanup, and no-delivery failure hooks.
hooksreviewed validation passedMain worktree through 9d0ad6c83f; Turing the 3rd implementation, Beauvoir the 3rd review fixes, Sagan the 2nd implementation, Euler the 2nd review findings, and Pasteur the 2nd re-reviewMain focused runs: seed 1 passed 12 checks, seed 224 over 5 iterations passed 60 checks, seed 20260627 over 20 iterations passed 240 checks, seed 999 over 100 iterations passed 1200 checks, and adjacent hooks,options-autoload,plugin-theme smoke passed 68 checks; latest broad run: 9151 passed, 62 skipped, 0 failed.WP_Hook, nested and same-tag reentrant dispatch, mutation during dispatch, preinitialized hook normalization, accepted args/ref arrays, deprecated filter/action wrappers, deprecated_hook_run and trigger-error side-effect hooks, all-hook visibility, current-priority stack internals, counters, and cleanup.
shortcodesfollow-up review passedMain worktree; image-context follow-up reviewed by Mendel through e4f39081f1; HTML-attribute boundary follow-up through ec3e228603.Latest focused runs: seed 224 passed 22 checks, seeds 1 and 224 over 25 iterations each passed 550 checks, adjacent markup,blocks,block-supports,core-block-render,shortcodes,kses smoke passed 1163 checks, and broad seed 224 smoke passed 4693 checks with 5 skips.Registry, invalid registration and non-callable callback guards, exact attribute-filter locality, nested parser behavior, callback mutation, scoped wp_get_attachment_image_context hooks including priority-zero preexisting filters, escaped shortcode boundaries, accepted quoted/unquoted HTML-attribute rendering, KSES-rejected attribute placeholder restoration with callback accounting, ignore_html placeholders, comments, CDATA, strip preservation, tag discovery, aliasing, malformed cases, and prefix/punctuated tag collisions.
cronreview fixes passedScoped worktree component-fuzz-cron-wrapper; builds on spawn/request review fixes through 12bde1192d and pre-unschedule-hook coverage through db22a88992; public wrapper coverage through de1da7b556 reviewed by Hegel the 2nd.Latest focused runs: seed 224 passed 91 checks, seeds 1, 224, and 57123 over 25 iterations each passed 2275 checks, adjacent cron,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 750 checks, adjacent cron,options-autoload,state,environment-load smoke passed 710 checks, bootstrap smoke passed, and latest broad run passed 4776 checks with 1 skip.In-memory cron store, schedule and next-scheduled filters, pre_unschedule_hook zero/false/WP_Error short-circuit contracts, duplicate windows, scheduled-event lookup order/exactness, ready-job partitioning, exact spawn request payloads, doing_cron lock behavior, no-network loopback short-circuiting, _wp_cron() ready delegation, public wp_cron() shutdown deferral and isolated immediate shutdown-run wrapper behavior, recurrence/unschedule/reschedule, and state restoration.
html-apireview follow-up passedMain worktree; Huygens the 3rd semantic oracle follow-up through e41dcde903Main focused run: 700 passed, 0 skipped, 0 failed; latest broad run: 8465 passed, 66 skipped, 0 failed.HTML Tag/Processor walking, semantic parser mode probes, breadcrumb stack replay, tree-preservation normalization, token serialization, namespace/comment/rawtext boundaries, bookmarks, mutation escaping, and malformed recovery.
syndicationlocal validation passedMain worktree; feed_links_extra() branch output through 89e00fbfadMain focused run: 900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() coverage and Tesla cleanup fix; syntax/PHPCS/diff checks passed. Latest clean broad run: 9079 passed, 66 skipped, 0 failed.oEmbed providers/handlers, REST proxy permission and cache-key behavior, feed helpers, automatic feed-link head output gates, feed_links_extra() singular/post-type/taxonomy/author/search branches, comment feed-link generation/filtering, XML/HTML escaping, no-network filters.
request-lifecyclelocal validation passedScoped worktree component-fuzz-request-send-headers-exit; builds on Curie the 3rd and the feed header variant follow-up.Latest focused runs: seeds 1 and 224 over 25 iterations each passed 325 checks, adjacent request-lifecycle,security,canonical-routing,rewrite,http smoke passed 360 checks, bootstrap smoke passed, and broad seed 224 passed 4738 checks with 1 skip. Earlier feed header variant run passed 60 checks over 5 iterations.Front-controller parsing, GET/POST mismatch termination across wp_die() handlers, WP::main() sequencing/no-DB sentinel, query vars, 404/header transitions, generated status_header()/wp_get_nocache_headers() contracts, feed content-type, last-modified, ETag, stale conditional request, default-feed branch contracts, subprocess-isolated feed 304 and explicit error-status exit paths, and superglobal restoration.
template-hierarchyreview fixes passedRaman the 2nd / main worktree through 1a26db9f1d; direct helper matrix through 0afd17bb8d; comments-template follow-up through d1916878e9, review fix through 0d7f1e98c9.Main focused runs: isolated comments_template() seed 1 passed 10 checks with 0 skips, seed 224 passed 250 checks with 0 skips, disabled-proc_open guard passed 9 checks with 1 skip, and adjacent template-hierarchy,content,comments smoke passed 946 checks with 0 skips; syntax/PHPCS/diff checks and James re-review passed; latest broad run: 9166 passed, 57 skipped, 0 failed.Child/parent template lookup, direct archive/page/search/404/embed/author/date/home/front-page/privacy/singular/attachment helpers, generated category/tag/taxonomy decoded-slug and term-ID ordering, exact dynamic hierarchy/template hook payloads, no-DB author/attachment queried objects, root/path precedence, filter locality, load/no-load behavior, isolated comments_template() default/custom file loading, comment-query and $comment_args contracts, cloned child hook snapshots, and parent-safe COMMENTS_TEMPLATE coverage.
widgetsreview fixes passedParfit the 3rd / main worktree; Mill the 4th review findings fixed through 5076b6523fMain focused run: 1000 passed, 0 skipped, 0 failed after the_widget() helper review fixes; smoke: 10 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed; latest broad run: 8604 passed, 68 skipped, 0 failed.Classic sidebar/widget registries, generated widget IDs, sidebars option cache/filter behavior, wp_render_widget(), the_widget() display-filter/action/cancellation behavior, dynamic_sidebar() action/filter ordering, callbacks, and state restoration.
user-preferenceslocal validation passedMain worktree through a7993f1bb1; request-handler subprocess coverage through 5568f588fcLatest focused runs: seed 1 and seed 224 each passed 300 checks with no skips; adjacent user-preferences,admin-workflows,admin-ajax,admin-screen,admin-options-submission smoke passed 210 checks with 10 skips; latest broad run: 4671 passed, 23 skipped, 0 failed.User settings, user-option prefix precedence/delete/filter behavior, hidden columns, meta boxes, postbox classes, AJAX preference handlers, screen option registration/rendering, set_screen_options() redirect/exit behavior, Screen Options visibility caching and filters, composed Screen Options output for columns, meta boxes, layout, pagination, view modes and custom settings, screen layout radios, admin preference filters, and state restoration.
rest-controllersvalidation passedScoped worktree component-fuzz-rest-search-builtins builds on menu-location, block renderer, block pattern remote/local loader, and plugin/theme controller contract coverage.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent rest-controllers,rest,rest-object-controllers,query,query-loop,taxonomy,template-links,content-lifecycle smoke passed 8050 checks; bootstrap smoke passed; broad smoke passed 4802 checks with 1 skip. Targeted PHPUnit was unavailable because wp-tests-config.php is missing.REST controller registries, schemas, context filtering, additional fields, permissions, namespace-specific post type/taxonomy item links, menu-location route/schema/read-access/capability/_fields/link/filter cleanup contracts, block type registry fields/links, dynamic block renderer route/schema/permission/attribute/POST/post-context/filter contracts, block pattern registry/category responses, intercepted core/featured/theme remote pattern loaders, local theme patterns/*.php scanning and header parsing, pattern file cache behavior, lazy filePath content loading, one-shot pattern loader dispatch, snake-case remote field normalization, category migration, duplicate suppression, remote-load gates, plugin/theme route/schema/collection-parameter/sanitizer/permission contracts, route helper visibility, REST search controller constructor/params/sanitizers, route-dispatched defaults, invalid subtype/schema validation, public search-result schema contents, custom handler result/header/link propagation, post-format search term/link pagination behavior, built-in post/term search handler subtype discovery, TYPE_ANY query expansion, include/exclude/search/page/per-page query mapping, protected-title/no-title preparation branches, REST item/about link generation, no-SQL query short-circuiting, and state restoration.
queryoracle hardening passedScoped component worktree component-fuzz-set-post-type; callback-shape oracle hardening builds on WP_Query post-search matrix and status-OR branch fixes.Focused query sweeps: seeds 1 and 224 over 10 iterations each passed 6440 checks; regression seed 761574832 over 5 iterations passed 3220 checks after allowing only known KSES save-pre hook additions; adjacent content/query smoke passed 5895 checks; broad seed 224 smoke passed 4765 checks with 1 skip.Query arg normalization, meta/tax/date/search interactions, pagination, sticky posts, SQL-shape safety, WP_Query execution order/found rows/cache-key/cache-hit behavior, classic WP_Query post-search terms/exclusions/columns/stopwords/relevance/password/attachment filename SQL and result behavior, password contradiction coverage, literal ORDER BY/GROUP BY/LIMIT search terms, attachment filename null-left-join semantics, independent exact-plus-relevance malformed-order boundary detection, direct status-OR stub coverage including author-scoped private branches and mixed equality/IN alternatives, WP_User_Query field/order/search/role/capability/has-published-post SQL-shape behavior, hook mutation locality with narrow callback-difference accounting, typed wpdb prepare placeholders, user/comment pre-query short-circuits, and state restoration.
query-loopreview fixes passedMain worktree; Parfit nested reset implementation and Galileo review fixes landed through bbe8f53242Main focused runs: seed 1 and seed 224 each 7700 passed, 0 skipped, 0 failed; seeds 2, 42, and 123456 each 1925 passed, 0 skipped, 0 failed; latest broad run: 8665 passed, 66 skipped, 0 failed.WP_Query, loop state, complete conditional flag vectors, nested primary/secondary loop reset behavior, global postdata restoration, offset/no-found-rows field windows, the_posts final result filtering, count oracles, cache/filter restoration.
media-ingestreview fixes passedMain worktree; Boyle implementation and Copernicus review findings fixed through 40040cb842Main focused run: 1000 passed, 0 skipped, 0 failed; latest broad run: 8633 passed, 66 skipped, 0 failed.Upload/sideload helpers, MIME checks, sanitized unique filenames, real handle override/error semantics, custom error-handler payloads, action-specific overrides, test_form/test_size/test_type gates, callback/filter destination shaping, postData/desc field preservation, and local temp cleanup.
multisitelocal validation passedMain worktree from Ramanujan the 2nd; large-network follow-up through f476712903; legacy blog identity and bootstrap resolution follow-up through 179ae88db5; true multisite lifecycle child follow-up through 352a5bc4de; signup validation follow-up through 07ddf2ba58.Latest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 config/DB-gated skips; adjacent multisite,identity,email smoke passed 1266 checks with 1 skip; bootstrap smoke passed; latest broad run passed 4790 checks with 1 skip.Network/site helpers, legacy blog identity helpers, bootstrap current-site/current-network resolution, switch/restore stack, cache groups, signup validation for unsafe email domains, user/blog validation errors, pending/stale wp_signups reservations, nonce checks, large-network thresholds and filters, pre-query site/network queries, domain/path lookups, URL/path normalization, optional real-DB site lifecycle and sitemeta write child checks, and global/cache restoration.
taxonomy-relationshipsreviewed validation passedMain worktree from James the 2nd; cache priming follow-up by Carver through aeb52fbf45Main focused run: 1000 passed, 0 skipped, 0 failed after object-term cache priming/cleaning matrix; reviewer seed 224: 1000 passed, 0 skipped, 0 failed; latest broad run: 8645 passed, 66 skipped, 0 failed.Object-term assignment, append/replace, multi-object object_id maps, counts, duplicate/invalid inputs, get_the_terms() cache population/invalidation, generated multi-object update_object_term_cache()/clean_object_term_cache() prime/clean/re-prime invariants, empty cache entries, warm-cache return behavior, clean action locality, and state restoration.
post-typeslocal validation passedMain worktree from Poincare the 2nd; query-operator follow-up through a514c0c42eMain focused run: 1000 passed, 0 skipped, 0 failed; latest broad run: 8511 passed, 66 skipped, 0 failed.Post type/status registries, labels, supports, capabilities, REST args, registry query operators.
rewrite, canonical-routingintegrated validation passedMain worktree plus scoped worktree component-fuzz-rewrite-url-to-postid; latest canonical URL helper output through 53453d79ff, latest rewrite update through 7ac42d758b.Latest rewrite focused runs: seed 224 passed 19 checks, seeds 1, 224, and 490 over 25 iterations each passed 475 checks, adjacent rewrite,request-lifecycle,canonical-routing,query,post-types smoke passed 2115 checks, bootstrap smoke passed, and broad seed 224 smoke passed 4747 checks with 1 skip. Earlier canonical old-slug/date focused runs passed 400 checks per seed, attachment-page redirect focused runs passed 375 checks per seed, canonical URL helper focused runs passed 1300 checks per seed, and rewrite main focused run passed 1800 checks.Rewrite tags, tag-removal query-var boundaries, permastructs, endpoints, rule precedence, query substitution, match maps, build/parse helpers, pretty-permalink url_to_postid() rewrite matching, site-base/www URL normalization, custom post-type query-var mapping, non-public query-var filtering, short-circuited WP_Query singular oracles, canonical path/query normalization, DB-stub-backed 404 guessing, old-slug/date helper redirects, paged/embed redirect suffixes, helper cache-hit and invalidation contracts, enabled attachment-page permalink redirects, disabled attachment-page raw-file redirects, non-public parent fail-closed behavior, redirect filter cancellation, safe same-host replacement cascades, canonical URL status/pagination/comment/filter/output gates, and generated query-argument removal/fragment stripping helper matrices.
rest-object-controllersreview fixes passedMain worktree from Averroes the 2nd; collection-param follow-up by Feynman through ad64492f8d, review fix through 5daf69b00f, template-controller follow-up through 152c90ded2, collection-query short-circuit follow-up through d9f3bf8a9fLatest focused runs: seed 224 passed 12 checks, seeds 1 and 224 over 25 iterations each passed 300 checks, adjacent REST-family smoke passed 365 checks, and latest broad run passed 4683 checks with 12 skips after retiring the broad collection-query skip.Object controller schemas, context filtering, deterministic collection parameter sanitizer/validation matrices, actual WP_REST_Request::has_valid_params()/sanitize_params() pipeline oracles, short-circuited collection query translation for posts, attachments, revisions, users, comments, and terms, permissions, route registration/dispatch, route index/help-data projection, bounded WP_REST_Templates_Controller routes/schemas/sanitization/capability gates and synthetic template responses without filesystem or template CPT queries, in-memory row effects, and state restoration.
metadatareview fixes passedMain worktree; Archimedes the 3rd worktree implementation, Lorentz the 3rd and Volta the 3rd reviews clean through 72182b7826, subtype current-API accounting through a8de6a7e5eLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks, adjacent metadata/content smoke passed 2600 checks, and latest broad run passed 4684 checks with 11 skips after retiring the absent subtype-helper availability skip.Metadata registry, subtype visibility, current subtype-aware API accounting for posts/terms/comments/users, defaults, sanitize/auth/protected filters, cache lookups, by-mid short-circuit filter payloads, invalid input fail-closed behavior, and duplicate-aware lazyloader behavior.
+
+ +
+
+

Recent Committed Additions

+ Latest main-branch commits from this pass. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommitSurfacesFocused ResultBroad Result
current branchrest-site-editorrest-site-editor seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent rest-site-editor,rest,rest-object-controllers,block-templates,revisions-autosaves,content-lifecycle,blocks smoke passed 435 checks; bootstrap smoke passed after adding revision/autosave collection dispatch coverage for denied-before-query permissions, bounded revision queries, template-id-preserving pagination links, HEAD empty-body behavior, invalid-page failures, autosave filtering, _fields projection, and filter/server cleanup.4802 passed, 1 skipped, 0 failed at 1 iteration after REST Site Editor revision/autosave collection dispatch coverage.
3c10935ca3editor-helperseditor-helpers seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent editor-helpers,admin-ajax,query,content-lifecycle,template-links,script-loader-runtime,assets,block-editor-adjuncts,admin-media-chrome,media-editor smoke passed 3775 checks; bootstrap smoke passed after adding direct _WP_Editors::wp_link_query() and wp_link_dialog() coverage for generated link results, query/result filters, sanitized labels, empty-result false behavior, dialog single-print markup, and cleanup.4800 passed, 1 skipped, 0 failed at 1 iteration after editor-helper internal link query/dialog coverage.
02a49529f7template-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 425 checks; adjacent template-links,identity,query,query-loop,content-lifecycle,feed-rendering,discovery smoke passed 3905 checks; bootstrap smoke passed after adding author display, modified-author, metadata alias/filter, author link, author post count/link, wp_list_authors(), and is_multi_author() coverage with scoped cleanup.4799 passed, 1 skipped, 0 failed at 1 iteration after template-links author-template helper coverage.
309a336a97block-widgetsblock-widgets seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent block-widgets,widgets,default-widgets,rest-widgets-sidebars,customizer-nav-widgets-requests,block-editor-adjuncts,assets smoke passed 68 checks; bootstrap smoke passed after adding wp_check_widget_editor_deps() coverage for script/style conflicts, both widget editor handles, dependency-chain enqueued semantics, warning payloads, asset stability, and hook/global cleanup.4798 passed, 1 skipped, 0 failed at 1 iteration after block-widgets editor dependency warning coverage.
9f14ccc0c7content-lifecyclecontent-lifecycle seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent content-lifecycle,query,query-loop,post-types,template-links,core-block-render,post-embeds,xmlrpc,fonts,default-widgets smoke passed 815 checks; bootstrap smoke passed after adding page lookup helper coverage for ancestry paths, attachment fallback and collision preference, custom hierarchical post types, salted cache invalidation, get_pages() rewrites and filters, get_children() normalization, output-shape checks, and cleanup.4797 passed, 1 skipped, 0 failed at 1 iteration after content-lifecycle page lookup helper coverage.
07a873186bcommentscomments seeds 1, 224, and 57123 over 25 iterations each passed 11350 checks; adjacent comments,template-links,core-block-render smoke passed 2395 checks; adjacent comments,comment-workflow,content,identity,template-hierarchy,classic-walkers smoke passed 2520 checks; bootstrap smoke passed after adding count/text/link/popup, comments-page link, next/previous link, pagination, navigation wrapper, singular fail-closed, attribute-filter, echo/getter parity, and cleanup coverage.4796 passed, 1 skipped, 0 failed at 1 iteration after comments count/navigation helper coverage.
9211f6d1e5admin-baradmin-bar seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent admin-bar,post-types,assets,script-loader-runtime,update-install-upgrader,site-health smoke passed 77 checks; bootstrap smoke passed after adding single-site site-menu, new-content, updates, sidebar-toggle, command-palette, script-queue, shortcut-label, and cleanup coverage.4795 passed, 1 skipped, 0 failed at 1 iteration after single-site admin-bar callback node coverage.
57fe546d48post-embedspost-embeds seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent post-embeds,rest,http,content,template-links,syndication,media-remote,script-loader-runtime smoke passed 188 checks; bootstrap smoke passed after adding wp_maybe_enqueue_oembed_host_js() action-gate, script-queue, markup-detection, HTML-preservation, and cleanup coverage.4794 passed, 1 skipped, 0 failed at 1 iteration after host-script enqueue gate coverage.
fb60b38f3ctemplate-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent template-links,content,query,query-loop,core-block-render,rewrite smoke passed 3900 checks; bootstrap smoke passed after adding archive navigation wrapper, pagination wrapper, echo/getter parity, edge-state, filter cleanup, and _navigation_markup() escaping coverage.4793 passed, 1 skipped, 0 failed at 1 iteration after archive navigation wrapper coverage.
ead9a7ca12date-timedate-time seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent date-time,l10n,translations,admin-options-submission,customizer,admin-screen smoke passed 310 checks; bootstrap smoke passed after adding direct wp_timezone_choice() markup, selected-state, offset-label, hostile-input, locale-invariance, and l10n cleanup coverage.4780 passed, 1 skipped, 0 failed at 1 iteration after direct wp_timezone_choice() markup coverage.
component-fuzz-media-gallery-playlistmedia-metadatamedia-metadata seeds 1, 224, and 57123 over 25 iterations each passed 250 checks; adjacent media-metadata,shortcodes,images,media-editor,media-ingest,media-remote,admin-media-chrome,script-loader-runtime,content-lifecycle,template-links smoke passed 640 checks; bootstrap smoke passed after adding public gallery/playlist shortcode rendering, attachment-query short-circuits, protected-parent fail-closed checks, playlist JSON/script hooks, ID3 filters, and cleanup coverage.4804 passed, 1 skipped, 0 failed at 1 iteration after public gallery/playlist media shortcode rendering coverage.
903654af08template-linkstemplate-links seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent template-links,content,query,canonical-routing,rewrite,request-lifecycle,feed-rendering,syndication smoke passed 3715 checks; adjacent template-links,blocks,core-block-render,block-editor-adjuncts,template-hierarchy smoke passed 250 checks; bootstrap smoke passed after adding direct get_search_form() rendering, filter, escaping, echo/getter parity, legacy boolean, null-fallback, and query-state cleanup coverage.4778 passed, 1 skipped, 0 failed at 1 iteration after direct get_search_form() rendering coverage.
5a6cad2fcdauth-flowauth-flow seeds 1, 224, and 57123 over 25 iterations each passed 300 checks; adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 240 checks; adjacent auth-flow,rest-application-passwords,rest,rest-controllers,rest-object-controllers smoke passed 285 checks; bootstrap smoke passed after adding direct wp_login_form() rendering, filter, escaping, echo/getter parity, default redirect, required-field, and remember-me coverage.4777 passed, 1 skipped, 0 failed at 1 iteration after direct wp_login_form() rendering coverage.
de1da7b556croncron seed 224 passed 91 checks, seeds 1, 224, and 57123 over 25 iterations each passed 2275 checks; adjacent cron,request-lifecycle,http,site-health,site-health-debug,update-install-upgrader smoke passed 750 checks; adjacent cron,options-autoload,state,environment-load smoke passed 710 checks; bootstrap smoke passed after adding public wp_cron() shutdown deferral and isolated immediate-run wrapper coverage.4776 passed, 1 skipped, 0 failed at 1 iteration after public wp_cron() wrapper coverage.
69ffaf4b90auth-flowauth-flow seeds 1, 224, and 57123 over 25 iterations each passed 275 checks; adjacent auth-flow,account-security,security,identity,request-lifecycle smoke passed 235 checks; adjacent auth-flow,rest-application-passwords,rest,rest-controllers smoke passed 220 checks; bootstrap smoke passed after adding public wp_logout() lifecycle coverage.4775 passed, 1 skipped, 0 failed at 1 iteration after auth-flow logout lifecycle coverage.
6afe48a008customizercustomizer seeds 1, 224, and 57123 over 25 iterations each passed 175 checks; adjacent customizer,customizer-persistence,customizer-nav-widgets-requests smoke passed 115 checks; adjacent plugin-theme-lifecycle,template-hierarchy,block-templates,rest-site-editor smoke passed 240 checks; bootstrap smoke passed after adding direct theme-preview filter/action lifecycle coverage.4774 passed, 1 skipped, 0 failed at 1 iteration after Customizer theme-preview lifecycle coverage.
6070f5e381admin-ajaxadmin-ajax focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent admin-ajax,admin-media-chrome,admin-workflows,admin-screen,content,post-types,query smoke passed 711 checks after adding Find Posts modal output, valid/empty/invalid AJAX search branches, exact public post-type query oracles, escaped table rows, nonce observability, and scoped state cleanup.4725 passed, 4 skipped, 0 failed at 1 iteration after Find Posts modal and AJAX search coverage.
87b8fc9686rest-directory-servicesrest-directory-services focused seed 224 passed 6 checks with no skips, seeds 1 and 224 over 25 iterations each passed 150 checks, and adjacent rest-directory-services,rest,rest-controllers,rest-object-controllers,rest-media-attachments,http,blocks,block-templates,block-editor-adjuncts,plugin-theme-lifecycle smoke passed 97 checks with 1 existing skip after adding block-directory _fields projection, explicit title/icon mapping, installed-plugin link discovery, and no-HTTP oracles.4724 passed, 4 skipped, 0 failed at 1 iteration after REST directory block-directory field/link coverage.
627a726d0eappearance-mediaappearance-media focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 95 checks after adding exact the_custom_header_markup() output, enqueue, localization, and script-state restoration coverage.4723 passed, 4 skipped, 0 failed at 1 iteration after custom-header print side-effect coverage.
a942ff51c7appearance-mediaappearance-media focused seed 224 passed 10 checks with no skips, seeds 1 and 224 over 25 iterations each passed 250 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 94 checks after adding real site-icon option-to-attachment metadata URL and stale-ID fallback coverage.4722 passed, 4 skipped, 0 failed at 1 iteration after site-icon attachment URL coverage.
643ba2fd4dappearance-mediaappearance-media focused seed 224 passed 9 checks with no skips, seeds 1 and 224 over 25 iterations each passed 225 checks, and adjacent appearance-media,customizer,customizer-persistence,frontend-features,admin-media-chrome,media-editor,media-metadata,image-metadata,images,network-media,script-loader-runtime smoke passed 93 checks after adding custom background head-callback CSS and custom-logo hide-header-text CSS coverage.4721 passed, 4 skipped, 0 failed at 1 iteration after appearance media head-callback CSS coverage.
98c23465dewxr-exportwxr-export focused seed 224 passed 10 checks with no skips, seeds 1 and 224 over 25 iterations each passed 250 checks, and adjacent wxr-export,import-diff,content,content-lifecycle,metadata,media-remote smoke passed 315 checks after replacing the CLI header observability skip with filtered filename/content-type intent accounting.4685 passed, 10 skipped, 0 failed at 1 iteration after WXR export header skip retirement.
a8de6a7e5emetadatametadata focused seed 224 passed 11 checks with no skips, seeds 1 and 224 over 25 iterations each passed 275 checks, and adjacent metadata,content,content-lifecycle,post-types,taxonomy,taxonomy-relationships,comments,identity smoke passed 2600 checks after replacing the absent subtype-helper availability skip with current API accounting.4684 passed, 11 skipped, 0 failed at 1 iteration after metadata subtype-helper skip retirement.
d9f3bf8a9frest-object-controllersrest-object-controllers focused seed 224 passed 12 checks with no skips, seeds 1 and 224 over 25 iterations each passed 300 checks, and adjacent REST-family smoke passed 365 checks after replacing the broad collection-query skip with REST/query-class short-circuit coverage for posts, attachments, revisions, users, comments, and terms.4683 passed, 12 skipped, 0 failed at 1 iteration after REST object collection query skip retirement.
e52e60ef77script-loader-runtimescript-loader-runtime focused seed 224 passed 15 checks with no skips, seeds 1 and 224 over 25 iterations each passed 375 checks with no skips, adjacent script-loader-runtime,assets,blocks,block-supports,style,fonts smoke passed 69 checks with no skips, and cleanup verified the generated wp-emoji-loader.js asset is removed after each run.4690 passed, 5 skipped, 0 failed at 1 iteration after script-loader emoji loader asset accounting.
d4a516e4c5script-loader-runtimescript-loader-runtime focused seed 224 passed 14 checks with 1 loader-asset skip, seeds 1 and 224 over 25 iterations each passed 350 checks with 25 loader-asset skips, and adjacent script-loader-runtime,assets,style,block-editor-adjuncts,blocks smoke passed 290 checks with 5 skips after splitting emoji style enqueue/hook coverage from asset-gated emoji detection settings output.4682 passed, 13 skipped, 0 failed at 1 iteration after script-loader emoji style coverage split.
b59b5030c4admin-workflowsadmin-workflows focused seed 224 passed 8 checks with no skips, seeds 1 and 224 over 25 iterations each passed 200 checks with no skips, adjacent admin smoke passed 291 checks with 9 skips, and scoped admin-workflows,admin-list-tables,privacy-admin-requests smoke passed 111 checks with 9 skips after replacing the stale concrete list-table skip with scoped accounting for dedicated surface coverage and explicit not-claimed boundaries.4681 passed, 13 skipped, 0 failed at 1 iteration after admin workflow list-table coverage accounting.
6a28aff00badmin-list-tablesadmin-list-tables focused seed 224 passed 10 checks with 1 current-core skip, seeds 1 and 224 over 25 iterations each passed 226 checks with 49 existing skips, and adjacent admin-list-tables,privacy-admin-requests,admin-workflows,admin-ajax,admin-screen smoke passed 201 checks with 14 skips after retiring the stale privacy request table skip and accounting for the dedicated privacy-admin-requests coverage.4680 passed, 14 skipped, 0 failed at 1 iteration after admin list-table privacy coverage accounting.
10cb72a48aadmin-workflowsadmin-workflows focused seeds 1 and 224 each passed 175 checks with 25 existing core-list-table skips, and adjacent admin-workflows,admin-ajax,admin-screen,admin-options-submission,user-preferences,admin-list-tables smoke passed 256 checks with 19 skips after adding captured wp_ajax_date_format() and wp_ajax_time_format() wrapper coverage.4672 passed, 22 skipped, 0 failed at 1 iteration after admin workflow AJAX date/time format wrapper coverage.
5568f588fcuser-preferencesuser-preferences focused seeds 1 and 224 each passed 300 checks with no skips, and adjacent user-preferences,admin-workflows,admin-ajax,admin-screen,admin-options-submission smoke passed 210 checks with 10 skips after adding subprocess-isolated AJAX preference handler and set_screen_options() redirect/exit coverage.4671 passed, 23 skipped, 0 failed at 1 iteration after user preference request handler coverage.
4de585019dmarkupmarkup focused seeds 1 and 224 each passed 575 checks with no skips, and adjacent markup,blocks,block-supports,core-block-render,shortcodes,kses smoke passed 11652 checks with no skips after adding deterministic do_blocks() fixture rendering and state restoration.4670 passed, 24 skipped, 0 failed at 1 iteration after deterministic markup do_blocks() fixture coverage.
791c426de8site-health-debugsite-health-debug focused seeds 1 and 224 each passed 225 checks with no skips over 25 iterations, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 139 checks with no skips after replacing the malformed SHOW TABLE STATUS row skip with strict/omission boundary accounting.4688 passed, 7 skipped, 0 failed at 1 iteration after Site Health database malformed-row boundary accounting.
9f1d3f3662site-health-debugsite-health-debug focused seeds 1 and 224 each passed 8 checks with 1 skip, seed 224 over 25 iterations passed 200 checks with 25 skips, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 138 checks with 1 skip after adding bounded full WP_Debug_Data::debug_data() coverage.4668 passed, 25 skipped, 0 failed at 1 iteration after bounded Site Health debug-data full-scan coverage.
bc09ade067rest-controllersrest-controllers focused seeds 1 and 224 each passed 12 checks, and adjacent rest-controllers,rest-widgets-sidebars,widgets,admin-ajax,admin-screen,admin-workflows smoke passed 56 checks with 2 skips after adding REST plugin/theme controller contract coverage.4667 passed, 26 skipped, 0 failed at 1 iteration after REST plugin/theme controller contract coverage.
bd01a25986rest-widgets-sidebarsrest-widgets-sidebars passed 9 checks at seed 1, 225 checks at seed 1 over 25 iterations, 225 checks at seed 224 over 25 iterations, and adjacent REST/widget/admin smoke passed 420 checks with 10 skips after adding isolated REST widget-type render endpoint coverage.4666 passed, 28 skipped, 0 failed at 1 iteration after isolated REST widget-type render endpoint coverage.
0c0debbb5epost-embedspost-embeds passed 6 checks at seed 1, 150 checks at seed 1 over 25 iterations, 150 checks at seed 224 over 25 iterations, and adjacent post-embeds,rest,http,content,template-links,syndication,media-remote smoke passed 350 checks after adding REST oEmbed proxy provider-fetch and transient-cache coverage.4665 passed, 28 skipped, 0 failed at 1 iteration after REST oEmbed proxy provider-fetch and transient-cache coverage.
a9fdacdc73site-healthsite-health passed 12 checks at seed 224, 300 checks at seed 1 over 25 iterations, 300 checks at seed 224 over 25 iterations, and adjacent site-health,site-health-debug,http,cron smoke passed 600 checks with 10 skips after adding loopback, REST availability, HTTP-blocking, and scheduled-event direct-test coverage.4664 passed, 28 skipped, 0 failed at 1 iteration after Site Health loopback, REST availability, HTTP-blocking, and scheduled-event direct-test coverage.
8cf920b5c3privacyprivacy passed 19 checks at seed 1, 475 checks at seed 1 over 25 iterations, and 475 checks at seed 224 over 25 iterations after adding privacy policy suggested-text lifecycle, policy-page cache update, text-change cache, and admin notice coverage.4661 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after privacy policy lifecycle coverage.
cc00dac113canonical-routingcanonical-routing passed 14 checks at seed 1, 350 checks at seed 1 over 25 iterations, 350 checks at seed 224 over 25 iterations, and adjacent canonical-routing,request-lifecycle,template-links smoke passed 190 checks after adding DB-backed 404 permalink guessing coverage.4660 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after canonical DB-backed redirect coverage.
0cc587f3a1privacy-admin-requestsprivacy-admin-requests passed 5 checks at seed 1, 125 checks at seed 1 over 25 iterations, 125 checks at seed 224 over 25 iterations, and adjacent privacy,admin-ajax,admin-list-tables,privacy-admin-requests smoke passed 206 checks with 14 skips after adding admin privacy request list-table and AJAX handler coverage.4659 passed, 28 skipped, 0 failed at 1 iteration across 112 surfaces after privacy admin request coverage.
current branchappearance-mediaappearance-media passed 12 checks at seed 224 and 300 checks at seeds 1 and 224 over 25 iterations; adjacent appearance/media/customizer/filesystem smoke passed 350 checks with no skips after adding remove-background safe redirect coverage for same-host referers, unsafe referer fallback, cleared background image/thumb theme mods, invalid nonce wp_die() fail-closed behavior, and redirect/filter/superglobal cleanup.4736 passed, 1 skipped, 0 failed at 1 iteration after adding appearance background remove redirect coverage.
cbc425b410admin-edit-metaboxesadmin-edit-metaboxes passed 6 checks at seed 1 and 150 checks at seed 224 over 25 iterations; adjacent admin/content/taxonomy/comment/link smoke passed 562 checks with 8 skips after adding classic edit-screen callback and default registration coverage.4637 passed, 27 skipped, 0 failed at 1 iteration after admin edit meta box coverage and bootstrap admin include additions.
bb8c89fa2arest-widgets-sidebarsrest-widgets-sidebars passed 8 checks at seed 1 and 200 checks at seed 224 over 25 iterations; adjacent REST/widget/admin smoke passed 415 checks with 10 skips after adding REST widget, widget-type, sidebar mutation, legacy form-data, delete hook, HEAD, and update-guard isolation coverage.4631 passed, 28 skipped, 0 failed at 1 iteration after REST widget/sidebar controller coverage and bootstrap endpoint includes.
f5f6f5ff23customizer-nav-widgets-requestscustomizer-nav-widgets-requests passed 8 checks at seed 1 and 200 checks at seed 224 over 25 iterations; adjacent Customizer/navigation/widget/admin smoke passed 560 checks after adding nav-menu/widget request, placeholder remap, and selective-refresh coverage.4623 passed, 28 skipped, 0 failed at 1 iteration after Customizer nav/widget request coverage.
cfb49670d1media-image-edit-requestsmedia-image-edit-requests passed 11 checks at seed 1 and 275 checks at seed 224 over 25 iterations; adjacent media/admin smoke passed 97 checks with 4 skips after adding image-edit request save/preview/crop/sub-size coverage and REST media capability grant hardening.4615 passed, 28 skipped, 0 failed at 1 iteration after media image-edit request coverage.
bb12296c68rest-media-attachmentsrest-media-attachments passed 6 checks at seed 1 and 150 checks at seed 224 over 25 iterations; adjacent REST/media/filesystem smoke passed 495 checks with 25 skips after adding REST attachment raw upload, metadata finalization, permission, response projection, and fail-closed edit-media coverage.4604 passed, 28 skipped, 0 failed at 1 iteration after REST media attachment write coverage.
b958538155post-embedspost-embeds passed 5 checks at seed 1 and 125 checks at seed 224 over 25 iterations; adjacent embed/syndication/REST/content/template/media smoke passed 445 checks with 10 skips after adding WordPress-as-provider oEmbed helper and controller coverage.4598 passed, 28 skipped, 0 failed at 1 iteration after post embed provider coverage and narrow wpdb path-conflict query projection support.
925224f77eadmin-options-submissionadmin-options-submission seeds 1, 224, and 57123 over 25 iterations each passed 200 checks; adjacent admin-options-submission,admin-workflows,admin-screen,security,options-autoload smoke passed 270 checks; bootstrap smoke passed after adding Reading/Discussion/Media/Writing core option-page sanitization plus conditional Writing Settings allowlist gate coverage.4773 passed, 1 skipped, 0 failed at 1 iteration after admin options core-page submission coverage.
f49c489f0ftaxonomytaxonomy seeds 1, 224, and 57123 over 25 iterations each passed 325 checks; adjacent taxonomy,taxonomy-relationships,template-links,classic-walkers,rest-controllers smoke passed 335 checks; bootstrap smoke passed after adding hierarchical get_term_link() parent-slug expansion, get_term_parents_list() agreement, and legacy term-link filter ordering coverage.4771 passed, 1 skipped, 0 failed at 1 iteration after hierarchical taxonomy term-link coverage.
23df3bbf25statestate seeds 1, 224, and 57123 over 25 iterations each passed 500 checks; adjacent state,multisite,options-autoload smoke passed 250 checks with 5 existing skips; bootstrap smoke passed after adding direct wp_cache_switch_to_blog() local/global group prefix coverage.4770 passed, 1 skipped, 0 failed at 1 iteration after direct object-cache blog-switch coverage.
474524f187discoverydiscovery seeds 1, 224, and 57123 over 25 iterations each passed 375 checks; adjacent discovery,canonical-routing,request-lifecycle,http smoke passed 110 checks; disabled-subprocess smoke passed 14 checks with 1 skip; bootstrap smoke passed after adding favicon front-controller redirect-and-exit coverage.4769 passed, 1 skipped, 0 failed at 1 iteration after favicon front-controller redirect coverage.
c915462831discoverydiscovery seeds 1, 224, and 57123 over 25 iterations each passed 350 checks; adjacent discovery,canonical-routing,request-lifecycle,http,rest-site-editor,core-block-render,template-links smoke passed 460 checks after adding robots.txt front-controller output and hook-order coverage.4767 passed, 1 skipped, 0 failed at 1 iteration after robots.txt front-controller output coverage.
cd5fe64d54stylestyle seeds 1, 224, and 57123 over 25 iterations each passed 400 checks; adjacent style,fonts,block-supports,rest-site-editor smoke passed 245 checks after adding global styles user-data/getter coverage.4768 passed, 1 skipped, 0 failed at 1 iteration after global styles user-data getter coverage.
8953de8d7fnavigation-lifecyclenavigation-lifecycle seeds 1, 224, and 57123 over 25 iterations each passed 225 checks; adjacent navigation/template/content/taxonomy smoke passed 490 checks after adding navigation fallback classic-menu conversion coverage.4766 passed, 1 skipped, 0 failed at 1 iteration after navigation fallback classic-menu conversion coverage.
7f9abcb47dadmin-options-submission, core-block-renderadmin-options-submission passed 5 checks at seed 1 and 125 checks at seed 224 over 25 iterations; adjacent admin/options/security smoke passed 230 checks with 10 skips; combined admin/core replay passed 300 checks after core block render expectation hardening.4585 passed, 28 skipped, 0 failed at 1 iteration after admin options submission coverage and core block render seed-224 hardening.
75ef2dfad2core-block-render5 passed with 0 skips at seed 1, 25 passed with 0 skips at seed 224 over 5 iterations, and adjacent blocks,core-block-render,query-loop smoke passed 180 checks with 0 skips after direct dynamic core block render-callback coverage for site identity, search/loginout, post context, and button/file/image markup transforms.4583 passed, 28 skipped, 0 failed at 1 iteration after core block render callback fuzzing.
d1916878e9 / 0d7f1e98c9template-hierarchy10 passed with 0 skips at seed 1, 250 passed with 0 skips at seed 224 over 25 iterations, disabled-proc_open guard passed 9 checks with 1 skip, and adjacent template-hierarchy,content,comments smoke passed 946 checks with 0 skips after isolated comments_template() coverage for child-over-parent default files, generated custom comments files, comments_template filter paths, synthetic comment query args including orderby and no_found_rows, local $comment_args, cloned child hook snapshots, parent-safe COMMENTS_TEMPLATE, and child stderr/stray-output failures; James re-review found no remaining findings.9166 passed, 57 skipped, 0 failed at 2 iterations after comments template hierarchy fuzzing.
7580167b9c / 2047262604environment-load17 passed with 0 skips at seed 1, 425 passed with 0 skips at seed 224 over 25 iterations, WP_RUN_CORE_TESTS plus WP_ENVIRONMENT_TYPE=staging repro passed 17 checks with 0 skips, disabled-proc_open guard passed 16 checks with 1 skip, and adjacent environment-load,http,site-health-debug smoke passed 70 checks with 4 known skips after isolated environment-type matrix coverage for env-var values, allowed constant overrides, invalid constant fail-closed behavior, parent/child restoration, row-local subprocess capability skips, and child stderr/stray-output failures; Hegel re-review found no remaining findings.9164 passed, 59 skipped, 0 failed at 2 iterations after environment type matrix fuzzing.
12f2cd8d7f / 696162da10script-loader-runtime13 passed with 1 skip at seed 1, 325 passed with 25 skips at seed 224 over 25 iterations, disabled-proc_open guard repro passed 12 checks with 2 skips, and adjacent assets,script-loader-runtime,blocks smoke passed 70 checks with 2 skips after JIT localization coverage for autosaveL10n, mceViewL10n, wordCountL10n, generated shortcode tags, tag-shaped JSON escaping, child/parent state restoration, and row-local subprocess capability skips; Confucius re-review found no remaining findings.9162 passed, 61 skipped, 0 failed at 2 iterations after script-loader JIT localization fuzzing.
2a84783187email1244 passed with 1 skip at seed 1, 12502 passed with 10 skips at seed 20260627 over 10 iterations, reviewer seed 424242 passed 3741 checks with 3 skips, and adjacent email,mail,identity smoke passed 1256 checks with 1 skip after UTF-8 address-model coverage for mixed-script combining local parts, Unicode/Punycode domains, WP_Email_Address getter round trips, filter-mode agreement, ASCII-mode rejection, reserved ACE-like labels, invalid xn-- labels, and malformed UTF-8 warning capture; Gauss review found no findings.9160 passed, 63 skipped, 0 failed at 2 iterations after UTF-8 email address-model fuzzing.
3f5772e8e7 / f3527a20ceadmin-dashboard10 passed with 1 skip at seed 1, 250 passed with 25 skips at seed 224 over 25 iterations, 1000 passed with 100 skips at seed 999 over 100 iterations, and adjacent admin-dashboard,site-health,http smoke passed 64 checks with 2 skips after Browser Happy coverage for generated request payloads, HTTPS endpoint selection, one-week site-transient TTLs, cache reuse, fail-closed remote errors without raw transient writes, insecure class preservation, regular and IE nag rendering, empty-user-agent no-network behavior, and review fixes; Pascal re-review found no findings.9158 passed, 63 skipped, 0 failed at 2 iterations after dashboard Browser Happy fuzzing.
a71e18bea4 / 09fcc5d910admin-list-tables9 passed with 2 skips at seed 1, 81 passed with 29 skips at seed 224 over 10 iterations, 801 passed with 299 skips at seed 999 over 100 iterations, and adjacent admin-list-tables,plugin-theme,update-install-upgrader smoke passed 91 checks with 8 skips after selected-mode theme install list-table coverage for generated API args, tab/view plumbing, result ordering, pagination totals, install/update/latest-installed/newer-installed states, screenshot and description escaping, fail-closed theme API behavior, theme-root option/cache cleanup, and review fixes; Kepler re-review found no remaining findings.9156 passed, 63 skipped, 0 failed at 2 iterations after theme install list-table fuzzing.
74d7c296f3 / e95cf978f1admin-list-tables8 passed with 2 skips at seed 1, 200 passed with 50 skips at seed 224 over 25 iterations, 800 passed with 200 skips at seed 999 over 100 iterations, and adjacent admin-list-tables,plugin-theme,update-install-upgrader smoke passed 60 checks with 4 skips after plugin install list-table coverage for generated search API args, tab/view plumbing, result ordering, pagination totals, install/update/activate actions, compatibility notices, icon and description escaping, fail-closed plugin API behavior, localized update-count cleanup, and review fixes; Kierkegaard re-review found no remaining findings.9155 passed, 62 skipped, 0 failed at 2 iterations after plugin install list-table fuzzing.
5d018bedd3email1243 passed with 1 skip at seed 1, 2492 passed with 2 skips at seed 224 over 2 iterations, 3744 passed with 3 skips at seed 20260627 over 3 iterations, and adjacent email,mail,identity smoke passed 1255 checks with 1 skip after Unicode authentication coverage for exact canonical readable address login, wrong-password failures, alias local-part isolation, machine/Punycode view lookup misses, tracked auth hooks, and stub/cache cleanup; Arendt review found no actionable findings.9153 passed, 62 skipped, 0 failed at 2 iterations after Unicode email authentication fuzzing.
9d0ad6c83fhooks12 passed at seed 1, 60 passed at seed 224 over 5 iterations, 240 passed at seed 20260627 over 20 iterations, 1200 passed at seed 999 over 100 iterations, and adjacent hooks,options-autoload,plugin-theme smoke passed 68 checks after deprecated hook wrapper coverage for no-callback fast paths, exact deprecation payloads, trigger-error suppression, ref-array dispatch, by-reference mutation, all-hook visibility, counters, cloned hook-global restoration, and hostile preexisting callback isolation; Pasteur re-review found no actionable findings.9151 passed, 62 skipped, 0 failed at 2 iterations after deprecated hook wrapper fuzzing.
e3d43f715akses1096 passed, 0 skipped, 0 failed at seed 1; 1093 passed, 0 skipped, 0 failed at seed 224; 5487 passed, 0 skipped, 0 failed at seed 54261 over 5 iterations; 54818 passed, 0 skipped, 0 failed at seed 65270 over 50 iterations after PDF object policy, upload host/port, bad-protocol prefilter, duplicate attribute, non-self invalid fallback, and dynamic URI attribute filter coverage. Syntax/PHPCS/diff checks passed; Bacon review found no actionable findings.9107 passed, 62 skipped, 0 failed at 2 iterations after KSES PDF object and URI attribute filter coverage.
8fc00de203kses1094 passed, 0 skipped, 0 failed at seed 1; 1091 passed, 0 skipped, 0 failed at seed 224; 5477 passed, 0 skipped, 0 failed at seed 54261 over 5 iterations; 54718 passed, 0 skipped, 0 failed at seed 65270 over 50 iterations after deterministic attribute constraint, mutation, required-attribute, full-tag parse, style entity-decoding, and unsafe CSS allow-filter coverage. Syntax/PHPCS/diff checks passed; Harvey review found no actionable findings.9103 passed, 62 skipped, 0 failed at 2 iterations after KSES attribute constraint coverage.
50b7b3e2a3xmlrpc550 passed, 0 skipped, 0 failed at seeds 1 and 224 after authenticated read-only wp.getPost, wp.getPosts, wp.getMediaItem, and wp.getMediaLibrary coverage for auth/capability errors, field filtering, media projection, parent/MIME filters, scoped hooks, seeded cleanup, and review fixes. Syntax/PHPCS/diff checks passed.9091 passed, 62 skipped, 0 failed at 2 iterations after XML-RPC authenticated read-only content/media coverage.
276dc47a5bemail6235 passed, 5 skipped, 0 failed at seed 1; 6225 passed, 5 skipped, 0 failed at seed 224; 3745 passed, 3 skipped, 0 failed at seed 20260626 after UTF-8 comment-submission coverage for front-door validation, direct comment insertion sanitization, hook payloads, and state restoration. Syntax/PHPCS/diff checks passed; Maxwell re-review found no blockers.9089 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 email comment-submission coverage.
4a2cad026crevisions-autosaves600 passed, 50 guarded browser-template skips, 0 failed at seeds 1 and 224 after latest revision count/URL helper and user-filtered autosave lookup coverage; direct post_author equality/IN/status-OR SQL probes passed, Archimedes re-review found no blocker, and syntax/PHPCS/diff checks passed.9087 passed, 62 skipped, 0 failed at 2 iterations after replacing the latest-count URL and user-filtered autosave stub-limited skips.
555495f583admin-bar800 passed, 0 skipped, 0 failed at seeds 1 and 224 after default callback node graph coverage for WordPress logo, account, appearance, comments, search, and secondary groups, with exact node parent/href/meta/title oracles plus Planck review fixes for no-DB short-circuits and wpdb runtime restoration. Syntax/PHPCS/diff checks passed.9081 passed, 66 skipped, 0 failed at 2 iterations after admin-bar default callback node graph coverage.
89e00fbfadsyndication900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() branch coverage for singular comments, post type archives, category, tag, custom taxonomy, author, and search query states, with exact query args, escaping, filter-locality, cache restoration, and Tesla review cleanup. Syntax/PHPCS/diff checks passed.9079 passed, 66 skipped, 0 failed at 2 iterations after feed_links_extra() branch coverage.
53453d79ffcanonical-routing1300 passed, 0 skipped, 0 failed at seeds 1 and 224 after canonical URL output helper coverage for status gates, paged/comment URLs, plain-permalink fallback, get_canonical_url filters, rel_canonical() output, cache restoration, and Turing review fixes. Syntax/PHPCS/diff checks passed.9077 passed, 66 skipped, 0 failed at 2 iterations after canonical URL helper output coverage.
93c6f19416images1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after content tag pipeline coverage for auto-sizes, dimensions, srcset/sizes, loading optimization, iframe loading, duplicate media replacement, and review fixes for exact generated candidate URLs plus core loading execution. Syntax/PHPCS/diff checks passed.9075 passed, 66 skipped, 0 failed at 2 iterations after image content tag pipeline coverage.
0afd17bb8dtemplate-hierarchy900 passed, 100 guarded comments-template skips, 0 failed at seeds 1 and 224 after direct helper hierarchy coverage for author/date/home/frontpage/privacypolicy/singular/attachment, exact dynamic hook payloads, no-DB synthetic WP_User, attachment MIME subtype ordering, and child/parent path confinement. Syntax/PHPCS/diff checks passed; Hilbert review found no actionable findings.9073 passed, 66 skipped, 0 failed at 2 iterations after template hierarchy direct helper coverage.
7790a7504dinteractivity1100 passed, 0 skipped, 0 failed at seeds 1 and 224 after directive syntax/order coverage for bind/class/style/text suffixes, unique-ID ignores, event-handler bind warnings, boolean data/ARIA conversion, false removals, class/style ordering, non-scalar text, invalid directive names, and review fixes for ignored bind unique IDs plus exact class tokens. Syntax/PHPCS/diff checks passed.13602 passed, 99 skipped, 0 failed at 3 iterations after interactivity directive syntax/order coverage.
31260a0f66email3742 passed, 3 skipped, 0 failed at seed 20260626 over 3 iterations after UTF-8 email boundary coverage for quoted/escaped local parts, control characters and Unicode separators, local-part identity preservation, and hook restoration; reviewer seed 31260 passed 1241 with 1 skip and no actionable findings; mail smoke passed 10 checks; email PHPUnit passed 86 tests and 179 assertions with 1 skip; syntax/PHPCS/diff checks passed.4519 passed, 33 skipped, 0 failed at 1 iteration after query hardening and UTF-8 email boundaries.
276dc47a5bemailLatest focused runs: seed 1 passed 6235 checks with 5 skips, seed 224 passed 6225 checks with 5 skips, and seed 20260626 passed 3745 checks with 3 skips after UTF-8 comment-submission coverage for wp_handle_comment_submission() valid/rejected paths and direct wp_new_comment() recovery/sanitization semantics.9089 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 email comment-submission coverage.
30d42d342femail1254 passed, 1 skipped at seed 260626; 1241 passed, 1 skipped at seed 1; 1244 passed, 1 skipped at seed 224; 3748 passed, 3 skipped at seed 20260626 over 3 iterations after generated Unicode local-part update aliases, lookup invalidation, duplicate collision rejection, and email-change notification preservation.9121 passed, 62 skipped, 0 failed at 2 iterations after Unicode email local-part update fuzzing.
2a2ccd0aee / d93c8a7e0femail1242 passed, 1 skipped at seed 1; 1245 passed, 1 skipped at seed 224; 1255 passed, 1 skipped at seed 260626; 3751 passed, 3 skipped at seed 20260626 over 3 iterations after Unicode profile confirmation request coverage for success, duplicate, invalid, wrong-user, and same-email no-op paths, with review fixes for stale _new_email preservation and exact escaped confirmation URLs.9123 passed, 62 skipped, 0 failed at 2 iterations after Unicode email profile confirmation fuzzing.
992c2393a5 / 729e2a5322 / 83039db582 / 8e76f34885query, wpdb query stub64400 passed, 0 skipped, 0 failed at seeds 1 and 224 after WP_Query post-search result oracle hardening, contradictory password clauses, literal clause-keyword searches, attachment filename null-left-join semantics, independent empty relevance-order boundary detection, and status-OR stub coverage for simple equality, author-scoped private, and mixed equality/IN branches; customizer-persistence regression smoke passed 7 checks; syntax/PHPCS/diff checks passed.4519 passed, 33 skipped, 0 failed at 1 iteration after query hardening and UTF-8 email boundaries.
244b2278a8query58800 passed, 0 skipped, 0 failed at seeds 1 and 224 after classic WP_Query post-search parser/order coverage for quoted phrases, stopwords, short-term fallback, 10+ term sentence fallback, CR/LF normalization, exact matches, exclusion-prefix disabling, supported search-column allowlisting, post_search_columns, wp_search_stopwords, logged-in password gates, attachment filename SQL branches, and relevance CASE ranking. Syntax/PHPCS/diff checks passed.4459 passed, 33 skipped, 0 failed at 1 iteration after WP_Query post-search fuzzing.
f53cfd1bfc / d83d02f5e0 / 9e4c4ee83cblocks800 passed, 0 skipped, 0 failed at seeds 1 and 224 after render-time block bindings coverage for supported-attribute global/dynamic filters, source callback and block_bindings_source_value payloads, render_block_context and source uses_context, computed attribute merge before dynamic callbacks, rich-text and attribute HTML replacement, unchanged unsupported attributes, and exact missing/malformed binding key absence. Syntax/PHPCS/diff checks passed, and Curie follow-up review found no actionable findings.4330 passed, 33 skipped, 0 failed at 1 iteration after block bindings render-pipeline review fixes.
c93166b0ec / 50ef617a22 / 89ec6030b2content15 passed, 0 skipped, 0 failed at seeds 1 and 224 plus 750 passed over 50 iterations after post content pagination coverage for get_the_content(), the_content(), generated // branches, protected content forms, wp_link_pages() output/filter contracts, and global/cache/cookie/filter restoration; adjacent content/query/template-links smoke passed 3355 checks. Earlier post-template helper runs passed 1400 checks at seeds 1 and 224. Syntax/PHPCS/diff checks passed, and Nash follow-up review found no actionable findings.9139 passed, 62 skipped, 0 failed at 2 iterations after content pagination and KSES review-fix coverage.
5f9d739e21 / cc0493297ccomment-workflow900 passed, 0 skipped, 0 failed at seeds 1 and 224 after hard-delete coverage for wp_delete_comment() child reparenting, comment meta deletion and hooks, approved-count refresh, missing-ID failure, delete/status transition hook payloads, type-specific transition hooks, and pending/spam/trash/note count boundaries; syntax/PHPCS/diff checks passed.4328 passed, 33 skipped, 0 failed at 1 iteration after comment hard-delete workflow review fixes; Kepler follow-up review found no actionable findings.
60dec6d029filesystem1200 passed, 0 skipped, 0 failed at seeds 1 and 224 after recursive helper coverage for wp_mkdir_p(), list_files() hidden/exclusion/depth behavior, wp_is_writable(), and wp_is_stream(); reviewer seed 60 also passed 1200 checks, and syntax/PHPCS/diff checks passed.4326 passed, 33 skipped, 0 failed at 1 iteration after filesystem recursive helper coverage; Gibbs review found no actionable findings.
07ddf2ba58multisiteSeeds 1, 224, and 57123 over 25 iterations each passed 450 checks with 25 existing true-multisite skips after signup validation coverage for unsafe email domains, user/blog validation errors, pending and stale wp_signups rows, and nonce branches.4790 passed, 1 skipped, 0 failed at seed 224 after multisite signup validation coverage; adjacent multisite,identity,email smoke passed 1266 checks with 1 skip and bootstrap smoke passed.
5883479b5aformattingSeeds 1, 224, and 57123 over 25 iterations each passed 425 checks after deep helper coverage for map_deep(), URL encode/decode helpers, stripslashes_deep(), and slash/unslash round trips over generated nested arrays and objects.4789 passed, 1 skipped, 0 failed at seed 224 after deep formatting helper coverage; adjacent formatting/content/security/markup/KSES/shortcodes smoke passed 1190 checks and bootstrap smoke passed.
9bfd3cd9bfcommentsSeeds 1, 224, and 57123 over 25 iterations each passed 11325 checks after direct comment_form() coverage for closed-post handling, title/hidden-ID helpers, anonymous HTML5/XHTML forms, cookies consent, must-log-in/logged-in branches, field/action/submit filters, no-DB sentinel execution, and cleanup.4788 passed, 1 skipped, 0 failed at seed 224 after comment form coverage; adjacent comments/content/template smoke passed 503 checks and bootstrap smoke passed.
78b8b78390editor-helpersSeeds 1, 224, and 57123 over 25 iterations each passed 250 checks after direct wp_tinymce_inline_scripts() coverage for classic-block filter fan-out, duplicate plugin de-duplication, caption/toolbars/external-plugin merging, raw JSON/array/function settings serialization, before-script attachment, and filter cleanup.4783 passed, 1 skipped, 0 failed at seed 224 after TinyMCE inline script coverage; adjacent editor/assets/media smoke passed 67 checks and bootstrap smoke passed.
94c539db4brestSeeds 1, 224, and 57123 over 25 iterations each passed 275 checks after /batch/v1 coverage for allowed/disallowed route gates, generated child methods, request propagation, normal partial success, require-all validation blocking, parse-path errors, max-size schema enforcement, and pre/post dispatch filter locality.4782 passed, 1 skipped, 0 failed at seed 224 after REST batch-v1 coverage; adjacent REST-family smoke passed 69 checks and bootstrap smoke passed.
1cc604ef19 / afc95daacc / 1aecc04af2http2200 passed, 0 skipped, 0 failed at seeds 1 and 224 after Requests success-path coverage, review fixes for same-site hermeticity, actual transport header assertions, max-bytes and stream-success behavior, and finally-backed stream cleanup; forced WP_HTTP_BLOCK_EXTERNAL smoke passed 11 checks; final cleanup re-review found no actionable findings.4354 passed, 33 skipped, 0 failed at 1 iteration after HTTP Requests success-path review fixes.
d0bbc3c4fb / e2f4e658d1query, wpdb stub, import-diff48600 passed, 0 skipped, 0 failed at seeds 1 and 224 after WP_User_Query field/order/search/role/capability/has-published-post SQL-shape coverage plus review fixes for hook cleanup and typed placeholders; reviewer follow-up found no actionable findings; related PHPUnit: 52 tests, 156 assertions.4353 passed, 33 skipped, 0 failed at 1 iteration after user-query semantic coverage and typed wpdb prepare fix.
de213220baprivacy18 passed, 0 skipped at seed 1; 450 passed, 0 skipped at seed 224 over 25 iterations; adjacent privacy,comments,media-metadata,media-ingest,identity,admin-ajax,state smoke passed 2570 checks after built-in user/media exporter registration, profile/community-location/session-token payloads, additional profile filter contracts, media author/type filtering, URL payloads, and 50-item pagination coverage.4654 passed, 28 skipped, 0 failed at 1 iteration after privacy built-in user/media exporter fuzzing.
df3be8a1aaprivacy17 passed, 0 skipped at seed 1; 17 passed, 0 skipped at seed 224; 85 passed, 0 skipped at seed 20260626 over 5 iterations after final personal-data erasure completion coverage for last-eraser done responses, request completion status/meta, request identity preservation, exact erased-action firing, and post-cache cleanup.9123 passed, 62 skipped, 0 failed at 2 iterations after privacy final erasure completion fuzzing.
72f3ae35e3 / 970437d39fprivacy170 passed, 0 skipped, 0 failed at seeds 1 and 224 after export-email recipient override, subject filter, content placeholder replacement with exact expiration value, header value, fake PHPMailer send capture, invalid request/mail-error negative paths, wp_mail_succeeded/wp_mail_failed payloads, Unicode email validator setup, and global/static cleanup assertions; reviewer seed 72 passed 1700 checks, and Franklin re-review found no actionable findings.4324 passed, 33 skipped, 0 failed at 1 iteration after privacy export-email review fixes.
c45c94ca67 / 0cfbc48c93email62043 passed, 50 skipped, 0 failed at seed 1 for the Unicode password-reset notification recipient machine/readable view matrix before review fixes; post-review checks passed 12427 with 10 skips at seed 1 and 12404 with 10 skips at seed 224 after fake PHPMailer handoff, machine-view reset rejection, and no-DB collation scope fixes. Fermat re-review found no actionable findings.8665 passed, 66 skipped, 0 failed at 2 iterations after Unicode email reset recipient review fixes.
b63ed99053 / fa12f15d10 / bbe8f53242query-loop7700 passed, 0 skipped, 0 failed at seeds 1 and 224 after nested primary/secondary loop reset coverage, exact primary/query/global conditional flag vectors, manual postdata/global summaries, wp_reset_postdata() source-object behavior, wp_reset_query() restoration, and review fixes; seeds 2, 42, and 123456 also passed 1925 checks each.8665 passed, 66 skipped, 0 failed at 2 iterations after query-loop nested reset review fixes; final review found no actionable findings.
98c23465dewxr-export250 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the CLI header observability skip with filtered filename/content-type intent accounting; adjacent export/import/content smoke passed 315 checks.4685 passed, 10 skipped, 0 failed at 1 iteration after WXR export header skip retirement.
a8de6a7e5emetadata275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the absent subtype-helper availability skip with current API accounting; adjacent metadata/content smoke passed 2600 checks.4684 passed, 11 skipped, 0 failed at 1 iteration after metadata subtype-helper skip retirement.
d9f3bf8a9frest-object-controllers300 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing the broad collection-query skip with short-circuited query translation coverage; adjacent REST-family smoke passed 365 checks.4683 passed, 12 skipped, 0 failed at 1 iteration after REST object collection query skip retirement.
d4a516e4c5script-loader-runtime350 passed, 25 skipped, 0 failed at seeds 1 and 224 over 25 iterations after splitting always-available emoji style enqueue/hook coverage from asset-gated emoji detection settings/module output; adjacent assets/style smoke passed 290 checks with 5 skips.4682 passed, 13 skipped, 0 failed at 1 iteration after script-loader emoji style coverage split.
b59b5030c4admin-workflows200 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing stale core-list-table skip accounting with scoped ownership by admin-workflows, admin-list-tables, and privacy-admin-requests; adjacent admin smoke passed 291 checks with 9 skips.4681 passed, 13 skipped, 0 failed at 1 iteration after admin workflow list-table coverage accounting.
6a28aff00badmin-list-tables226 passed, 49 skipped, 0 failed at seeds 1 and 224 over 25 iterations after replacing stale privacy request table skip accounting with a passed coverage-accounting invariant tied to the dedicated privacy-admin-requests surface; adjacent admin/privacy smoke passed 201 checks with 14 skips.4680 passed, 14 skipped, 0 failed at 1 iteration after admin list-table privacy coverage accounting.
current branchprivacy500 passed, 0 skipped, 0 failed at seeds 1, 224, and 57123 over 25 iterations after adding real personal data export ZIP generation, exact archive inspection, HTML escaping/KSES report oracles, export-created action payloads, temp report cleanup, legacy export-file meta migration, and captured JSON-error negative paths; adjacent privacy/admin/filesystem/mail smoke passed 315 checks.4751 passed, 1 skipped, 0 failed at 1 iteration after privacy export ZIP generation coverage.
0b57a4238arest-site-editor350 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the broad template dispatch guard with bounded template/template-part collection route metadata, denied collection and lookup dispatch before template lookup, HEAD collection dispatch short-circuits, revision/autosave collection route-index contracts, and filter/server restoration.4679 passed, 15 skipped, 0 failed at 1 iteration after REST Site Editor template dispatch guard coverage.
91a7645818appearance-media200 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the unsafe upload/AJAX guard with bounded no-upload Custom_Image_Header::step(), header action cap/nonce/sanitization/default/remove/reset branches, background empty-upload and reset branches, deprecated field/tab passthrough callbacks, and current-user/superglobal restoration.4678 passed, 16 skipped, 0 failed at 1 iteration after appearance media no-upload admin guard coverage.
152c90ded2rest-object-controllers275 passed, 25 skipped, 0 failed at seeds 1 and 224 after replacing the template-controller guard with bounded WP_REST_Templates_Controller route registration, route-index schema, ID sanitization, collection params, permission gates, projected synthetic template responses, HEAD behavior, and global/server restoration.4677 passed, 17 skipped, 0 failed at 1 iteration after bounded REST template-controller coverage.
bc27826265style375 passed, 0 skipped, 0 failed at seeds 1 and 224 after replacing the global stylesheet guard with seeded WP_Theme_JSON_Resolver coverage for wp_get_global_stylesheet() variables, styles, presets, custom CSS, empty-call cache reuse, typed-call cache preservation, zero wpdb-stub query delta, unsafe-byte rejection, and exact resolver/object-cache restoration.4676 passed, 18 skipped, 0 failed at 1 iteration after seeded global stylesheet resolver/cache coverage.
11704a8287 / 212d31f2a2 / efff07281estyle1400 passed, 100 skipped, 0 failed after block-support register/apply wrapper coverage, aggregate WP_Block_Supports::apply_block_supports() output, final get_block_wrapper_attributes() escaping/merge/dedupe oracles, skip-serialization checks, custom-only hostile style-value coverage, exact render_block hook restoration, and review fixes; reviewer seed 11704 also passed 350 checks with 25 skips and no actionable findings after fixes.8661 passed, 66 skipped, 0 failed at 2 iterations after style block-support wrapper review fixes.
12d7260f6cadmin-media-chrome300 passed, 0 skipped, 0 failed for seed 1 and seed 224 focused runs after replacing upload-dispatch and browser-modal skips with legacy upload shell, media enqueue data, and iframe shell boundary accounting; adjacent admin/content smoke passed 569 checks with 1 existing skip.4687 passed, 8 skipped, 0 failed at 1 iteration after admin media boundary accounting.
123d7bae88 / 758d2f7b4f / eb4ee82995 / 5c68c49525admin-media-chrome1000 passed, 200 skipped, 0 failed after direct get_image_send_to_editor(), image_add_caption(), and image_media_send_to_editor() coverage for escaped attributes, default caption filter registration, caption cleanup/early returns, attachment and non-attachment rel behavior, media delegation, non-image pass-through, and review fixes; reviewer seed 224 also passed 1000 checks with 200 skips and no actionable findings after fixes.8659 passed, 66 skipped, 0 failed at 2 iterations after admin media caption/send-to-editor review fixes.
523349bed5 / d436abf722admin-list-tables700 passed, 200 skipped, 0 failed after base WP_List_Table per-page fallback/filter coverage, pagination arg derivation and clamping, top/bottom/infinite/empty pagination output, escaped URL/query preservation, and review fixes for attribute-order-tolerant anchor extraction, stronger href escaping/query oracles, and localized item-count assertions; reviewer seed 224 also passed 700 checks with 200 skips and no actionable findings.8657 passed, 66 skipped, 0 failed at 2 iterations after admin list table base pagination review fixes.
4b90ae7339 / 12dcc507e5block-widgets900 passed, 0 skipped, 0 failed after stabilizing the generated widget-ID fixture and adding retrieve_widgets() remap/customizer persistence/lost-widget coverage; reviewer seed 224 also passed 900 checks with no actionable findings.8651 passed, 66 skipped, 0 failed at 2 iterations after block-widget retrieval review.
ab6dfb94a6 / 12bde1192dcron8700 passed, 0 skipped, 0 failed after generated spawn request/lock boundary coverage plus review fixes for exact lock/request-key matching, realistic missing-transient behavior, _wp_cron() ready delegation, exact loopback URL/query payloads, SSL filter input, and object-cache restoration; reviewer seed 224 also passed 8700 checks with no actionable findings.8649 passed, 66 skipped, 0 failed at 2 iterations after cron spawn/request review fixes.
db22a88992cron90 passed at seed 1, 450 passed at seed 224 over 5 iterations, 1800 passed at seed 20260627 over 20 iterations, and adjacent cron,options-autoload,state smoke passed 244 after pre_unschedule_hook short-circuit coverage for zero, false-with-WP_Error, and WP_Error-without-WP_Error branches, event preservation, exact filter payloads, hook cleanup, and current-filter stack restoration; Singer review found no actionable findings.9147 passed, 62 skipped, 0 failed at 2 iterations after cron pre_unschedule_hook fuzzing.
f9fb63e7aemail11 passed at seed 1, 55 passed at seed 224 over 5 iterations, 220 passed at seed 20260627 over 20 iterations, adjacent mail,email,privacy smoke passed 2561 with 2 skips, and wpMail PHPUnit passed 32 tests and 87 assertions after invalid From setFrom() failure coverage for exact wp_mail_failed payloads including embeds, no-send/no-init branches, reusable PHPMailer cleanup, and hook removal; Sartre re-review found no actionable findings.9149 passed, 62 skipped, 0 failed at 2 iterations after mail invalid-From failure fuzzing.
6f044e8832 / d23da38599script-loader-runtime1200 passed, 200 skipped, 0 failed after generated concatenated load-scripts.php/load-styles.php runtime boundary coverage plus review fixes for exact external/delayed asset URL assertions and real WordPress load-query ordering; reviewer seed 224 also passed 1200 checks with 200 skips and no actionable findings.8647 passed, 66 skipped, 0 failed at 2 iterations after script-loader concat review fixes.
aeb52fbf45taxonomy-relationships1000 passed, 0 skipped, 0 failed after generated multi-object update_object_term_cache()/clean_object_term_cache() prime, clean, empty-entry, warm-cache, action-locality, and exact re-prime invariants; reviewer seed 224 also passed 1000 checks with no actionable findings.8645 passed, 66 skipped, 0 failed at 2 iterations after taxonomy relationship cache priming review.
ad64492f8d / 5daf69b00frest-object-controllers1000 passed, 200 skipped, 0 failed after deterministic collection parameter sanitizer/validation matrices for posts, terms, comments, users, revisions, and attachments plus review fixes adding real request-pipeline has_valid_params()/sanitize_params() oracles; reviewer seed 224 also passed 1000 checks with 200 skips.8643 passed, 66 skipped, 0 failed at 2 iterations after REST object collection-parameter request-pipeline review fixes.
fc2df79179ai-client900 passed, 0 skipped, 0 failed after follow-up fixes wrapping WP HTTP adapter SDK NetworkException failures with request context through HttpTransporter, asserting previous exception/request DTO details, and restoring HTTPlug discovery strategies plus cache by reflection; smoke: 45 passed; syntax/PHPCS/diff checks passed.8627 passed, 66 skipped, 0 failed at 2 iterations after AI transport review follow-up.
d66f101681ai-client, emailai-client: 900 passed, 0 skipped, 0 failed after review fixes for HTTPlug discovery, SDK transport WP_Error propagation, merged request option capture, and exact empty/scalar response bodies; smoke: 45 passed. email: 6205 passed, 5 skipped, 0 failed after generated mailto/rendering-context round trips for UTF-8 local parts, WHATWG delimiter local parts, IDN/punycode domains, display/raw URL escaping, and readable text; syntax/PHPCS/diff checks passed.8627 passed, 66 skipped, 0 failed at 2 iterations after AI transport review fixes and email mailto context fuzzing.
5b7b110e98ai-client900 passed, 0 skipped, 0 failed after WordPress HTTP client discovery, PSR-7 request to wp_safe_remote_request() argument mapping, response mapping, WP_Error to NetworkException, SDK HttpTransporter option precedence, and response DTO coverage; smoke: 9 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8610 passed, 68 skipped, 0 failed at 2 iterations after AI client HTTP transport fuzzing.
df2626ed8eidentity100 passed, 0 skipped, 0 failed after review fixes adding generated user-cache cleanup assertions, current-user global restoration assertions, hook counter and temporary-hook restoration assertions, user_level setup checks, and exception-safe cleanup/restore; smoke: 1 aggregate row passed with 22 cases and 887 checks.8608 passed, 68 skipped, 0 failed at 2 iterations after identity current-user lifecycle review fixes.
7686237548identity100 passed, 0 skipped, 0 failed after cache-seeded wp_set_current_user()/wp_get_current_user() lifecycle coverage, logged-in/logged-out helper contracts, legacy setup globals, set_current_user action dispatch, legacy object upgrades, determine_current_user discovery, and cloned hook snapshot restoration; smoke: 1 aggregate row passed with 22 cases and 884 checks.8608 passed, 68 skipped, 0 failed at 2 iterations after identity current-user lifecycle coverage.
15ac9a877eemail, wpdb-stub123865 passed, 100 skipped, 0 failed after review fixes replacing the circular users_pre_query search probe with real stub-backed WP_User_Query results and isolating password-reset/mail hooks; smoke: 1233 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after the confusable UTF-8 email local-part review fixes.
685f308f24email123865 passed, 100 skipped, 0 failed after confusable UTF-8 local-part coverage for ASCII-vs-Unicode parser/filter behavior, user insert/duplicate/lookup boundaries, hostile lookup candidates, comment filtering, byte-preserving user search, and exact password-reset mail routing; smoke: 1233 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after the confusable UTF-8 email local-part follow-up.
0a88e12213syndication800 passed, 0 skipped, 0 failed after direct WP_oEmbed_Controller proxy cache coverage for fail-closed permissions, nonce-free cache keys, transient cleanup, and local controller loading; smoke: 8 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after syndication, network-media, and confusable UTF-8 email local-part follow-ups.
957b767d46network-media100 passed, 0 skipped, 0 failed after MIME catalog coverage for extension type buckets, default MIME extensions, wildcard MIME matching, sanitize filters, and custom ext2type/mime_types/upload_mimes filter restoration; smoke: 1 aggregate row passed; syntax/PHPCS/diff checks passed.8608 passed, 68 skipped, 0 failed at 2 iterations after network-media, syndication, and confusable UTF-8 email local-part follow-ups.
5076b6523fwidgets1000 passed, 0 skipped, 0 failed after review fixes for explicit display-filter/action/widget-callback ordering and safe output-buffer cleanup; smoke: 10 passed, 0 skipped, 0 failed; syntax/PHPCS/diff checks passed.8604 passed, 68 skipped, 0 failed at 2 iterations after the_widget() review fixes.
b3f95cec40widgets1000 passed, 0 skipped, 0 failed after the_widget() object-registration, display-filter mutation/cancellation, action payload, wrapper substitution, and escaping coverage; smoke: 10 passed, 0 skipped, 0 failed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
fb3b2503f2admin-dashboard900 passed, 100 skipped, 0 failed after review fixes for transient cleanup and a scoped no-network HTTP tripwire; smoke: 9 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
dc3d102ad5admin-dashboard900 passed, 100 skipped, 0 failed after cached RSS helper branch coverage; smoke: 9 passed, 1 skipped, 0 failed; syntax/PHPCS/diff checks passed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
2a62dae1e6default-widgets, shared wp-stubs1000 passed, 0 skipped, 0 failed for default-widgets; 800 passed, 0 skipped, 0 failed for wpdb-sql; direct probes confirmed $wpdb runtime restoration and duplicate DISTINCT t.term_id collapse; shared stub smoke: 2555 passed, 10 skipped, 0 failed.8619 passed, 66 skipped, 0 failed at 2 iterations after the_widget() coverage.
b8ebf13ea3default-widgets, shared wp-stubs1000 passed, 0 skipped, 0 failed after fixing cache restoration, no-live-DB guarding, and real stub-backed nav-menu item lookup/object filtering/walker output; direct cache-sentinel and non-stub $wpdb probes passed; shared stub smoke: 2555 passed, 10 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the default-widgets review fixes and SQL/term selector stub corrections.
55bf6625ccblock-editor-adjuncts700 passed, 0 skipped, 0 failed after review fixes for theme-cache restoration, strict slug__in template queries, and no-selected-post coverage inside the temp block-theme fixture; syntax/PHPCS/diff checks passed.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before this selected-post follow-up.
2498b1f923block-editor-adjuncts700 passed, 0 skipped, 0 failed after generated selected-post core/post-content template coverage, first-match attrs, missing-target guard, cache/filter/global restoration, and temp block-theme fixtures.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before this selected-post follow-up.
e6a146a9eaemail6182 passed, 5 skipped, 0 failed after review fixes for accent-folded DB-candidate lookup guards and byte-preserving local-part user-search result oracles; syntax/PHPCS/diff checks passed.Pending next broad rerun; latest broad remains 8615 passed, 66 skipped, 0 failed before these review fixes.
0544644089email123765 passed, 100 skipped, 0 failed after generated Unicode local-part alias/index cleanup and byte-preserving user-email search coverage; smoke: 6182 passed, 5 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
cc76f6455enetwork-media100 passed, 0 skipped, 0 failed after review fixes for upload iframe dynamic filter timing and media filter coverage; smoke: 5 passed, 0 skipped, 0 failed.8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
39a5bb77d1appearance-media700 passed, 100 skipped, 0 failed after review fixes for fetchpriority static-state leakage, synthetic attachment/postmeta cleanup, multisite-safe logo blog IDs, and content-count restoration; reviewer seed 4: 35 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
7ca5e5d845identity100 passed, 0 skipped, 0 failed after cache-backed username_exists()/email_exists() lookups, raw-query filter payloads, override behavior, filter restoration, and generated user-cache cleanup; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
90dd0e0afdappearance-media700 passed, 100 skipped, 0 failed after synthetic custom-logo attachment, image source, image-attribute filter, final output filter, echo parity, and filter-cleanup coverage; smoke: 35 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
51db0730d0blocks700 passed, 0 skipped, 0 failed after generated nested block attribute parser/serializer round-trip coverage; smoke: 35 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
48cc953226default-widgets1000 passed, 0 skipped, 0 failed after WP_Nav_Menu_Widget selected-menu rendering, HTML5/XHTML argument filters, wp_nav_menu() short-circuit payloads, and empty-menu early return coverage; smoke: 50 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
48d8e996d2email123565 passed, 100 skipped, 0 failed after generated UTF-8 local-part oracle agreement across mixed scripts, combining marks, disallowed punctuation, symbols/emoji boundaries, invalid UTF-8, ASCII fallback behavior, and IDN/punycode domain views; smoke: 6172 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
b2101aeceecommunity-events900 passed, 0 skipped, 0 failed after invalid or absent client IP request-body minimization coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
32acfc1459block-widgets800 passed, 0 skipped, 0 failed after wp_parse_widget_id() numeric-instance parsing and _wp_remove_unregistered_widgets() cleanup coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
6f73d20a46network-media100 passed, 0 skipped, 0 failed after wp_unique_filename() callback argument, final filter payload, returned filename, and filter-restoration coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
11f544d777classic-walkers1200 passed, 100 skipped, 0 failed after generated Walker_Nav_Menu item matrix locality, preorder/depth filter order, current/parent/ancestor state, aria-current, escaped attributes, omitted non-scalar attributes, and submenu depth-local attributes; smoke: 60 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
d3924d2e1adiscovery1200 passed, 0 skipped, 0 failed on both seed 1 and seed 224 after built-in posts/taxonomies/users sitemap provider coverage, review fixes for public custom subtypes, GMT lastmod oracles, private-CPT user exclusion, custom taxonomy paging, and page/home sitemap entries; reviewer seeds passed; smoke: 4524 passed, 33 skipped, 0 failed.Latest broad rerun: 4524 passed, 33 skipped, 0 failed at 1 iteration after discovery sitemap provider coverage.
4aa0ec3c2bdiscovery900 passed, 0 skipped, 0 failed after robots public/private helper and wp_robots() output matrix coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
c901056b01feed-parsers900 passed, 0 skipped, 0 failed after WP_SimplePie_File HTTP response, repeated-header normalization, request-argument propagation, and error-state coverage; smoke: 45 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
5cde283a7badmin-list-tables600 passed, 200 skipped, 0 failed after generated WP_MS_Themes_List_Table row-action, nonce, auto-update, custom-column, view partition, and capability-gate coverage; smoke: 30 passed, 10 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
d088a7e89bidentity100 passed, 0 skipped, 0 failed after generated WP_User field filter, display-name, URL, nicename, edit-context escaping, and filter-restoration coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
9c6f37455aadmin-workflows500 passed, 200 skipped, 0 failed after nonce field, selected attribute, submit button, and request-state restoration coverage; smoke: 25 passed, 10 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
961cd4024fnetwork-media100 passed, 0 skipped, 0 failed after custom wp_check_filetype_and_ext() MIME allowlist coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
7bc1abc6c2abilities800 passed, 0 skipped, 0 failed after category unregister preservation and re-registration coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
e61d09f16amedia-metadata800 passed, 0 skipped, 0 failed after generated attachment metadata replacement, filtered/unfiltered read, stale-key cleanup, path/URL, and MIME/ext helper coverage; smoke: 40 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
b88d33922cregistries600 passed, 0 skipped, 0 failed after file-backed icon retrieval failure/recovery coverage; smoke: 30 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
0486038a3aemail123465 passed, 100 skipped, 0 failed after generated malformed Unicode/ASCII/Punycode email variant coverage; smoke: 6167 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
ebce407494template-links1200 passed, 0 skipped, 0 failed after archive URL helper coverage and review fixes for date filter payloads, sanitized author URL contracts, and dirty author nicename escaping/locality; smoke: 60 passed, 0 skipped, 0 failed.Latest broad rerun: 8631 passed, 66 skipped, 0 failed at 2 iterations after template-links archive helper review fixes.
09c2d3b4f8post-types1200 passed, 0 skipped, 0 failed after duplicate-registration replacement cleanup and review fixes; smoke: 60 passed, 0 skipped, 0 failed; PHPUnit: 44 tests, 133 assertions.Latest broad rerun: 8629 passed, 66 skipped, 0 failed at 2 iterations after post-type duplicate-registration cleanup.
3cce0a5c4dpost-types1100 passed, 0 skipped, 0 failed after sanitized post-status name storage and duplicate-key overwrite coverage; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
61a0a1d628 / b1eea51e9arequest-lifecycle12 passed, 0 skipped at seed 1; 12 passed, 0 skipped at seed 224; 60 passed, 0 skipped at seed 20260626 over 5 iterations after feed header variants for default-feed alias mapping, post-only and comments-feed modified timestamp selection, stale conditional headers without 304 exit, exact filter payloads, and preserved timeinfo cache cleanup.9125 passed, 62 skipped, 0 failed at 2 iterations after request-lifecycle feed header variant fuzzing.
3193c7f30drequest-lifecycle1100 passed, 0 skipped, 0 failed after generated status-header protocol/default-description and nocache-filter contracts; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
70b7b720a4network-media100 passed, 0 skipped, 0 failed after network upload MIME allowlist and direct file-too-large policy coverage; smoke: 5 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
df556c4fff / 5136734b9bfeed-rendering275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after legacy RDF/RSS 0.92 template and do_feed() dispatch coverage; adjacent smoke: 200 passed, 0 skipped, 0 failed.4663 passed, 28 skipped, 0 failed at 1 iteration after legacy feed template and dispatch coverage.
017cbda0abfrontend-features700 passed, 0 skipped, 0 failed after view-transition pre-init registration and post-init inline CSS timing coverage; smoke: 35 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
c8410135cbrevisions-autosaves1000 passed, 300 skipped, 0 failed after retention-pruning-preserves-autosaves coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
3a78724c43capabilities1000 passed, 0 skipped, 0 failed after site-scoped WP_User::for_site() cap-key isolation and user_can_for_site() wrapper coverage; smoke: 50 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
55a25d1dc5identity100 passed, 0 skipped, 0 failed after avatar helper hash/default/URL contract coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
39bce30e74comments44800 passed, 0 skipped, 0 failed after comment reply/cancel link matrix coverage and author-link fallback oracle fix; smoke: 1344 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
e8c7cfecf0template-links1100 passed, 0 skipped, 0 failed after previous/next adjacent post relation link coverage; smoke: 55 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
4bc14e0eccerror-protection900 passed, 100 skipped, 0 failed after recovery-mode handle_error() gates and fatal-error email rate-limit coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
6a78c3ec42post-embedsSeed 224 passed 7 checks; seeds 1 and 224 over 25 iterations each passed 175 checks after custom provider add/remove and WP_Embed::autoembed() post-meta cache coverage; adjacent post-embeds/REST/HTTP/content/template/media smoke passed 71 checks with 0 skips.Latest broad rerun: 4696 passed, 5 skipped, 0 failed at 1 iteration.
764df53851securitySeed 224 passed 15 checks; seeds 1 and 224 over 25 iterations each passed 375 checks after referer retrieval precedence/restoration and native bcrypt compatibility/migration coverage; adjacent security/request/formatting/routing/HTTP smoke passed 66 checks with 0 skips.Latest broad rerun: 4695 passed, 5 skipped, 0 failed at 1 iteration.
ec3e228603shortcodesSeed 224 passed 22 checks; seeds 1 and 224 over 25 iterations each passed 550 checks after HTML-attribute shortcode KSES/placeholder boundary coverage; adjacent markup/blocks/KSES smoke passed 1163 checks with 0 skips.Latest broad rerun: 4693 passed, 5 skipped, 0 failed at 1 iteration.
ece71f4765network-mediaSeed 224 passed 1 aggregate row with 299 cases, 1667 API calls, and 2087 assertions; seeds 1 and 224 over 25 iterations each passed 25 aggregate rows after wp_upload_bits() exact-byte writes, virtual file-list subsize collision, upload-bits, unique-filename, and final upload-context filter payload coverage; adjacent media/filesystem smoke passed 55 checks with 0 skips.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
e581555577rest-directory-servicesSeed 224 passed 5 checks; seeds 1 and 224 over 25 iterations each passed 125 checks after pattern-directory proxy allowlist/derived-argument overwrite coverage and URL-details malformed/unclosed head metadata fallback coverage; adjacent REST/HTTP/media smoke passed 56 checks with 0 skips.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
61e22a3455error-protectionSeed 224 passed 11 checks with 1 process-boundary skip; seeds 1 and 224 over 25 iterations each passed 275 checks with 25 skips after synthetic fatal-handler dispatch and recovery-link early-return coverage; adjacent error/Site Health/HTTP/filesystem smoke passed 55 checks with 1 skip.Latest broad rerun: 4692 passed, 5 skipped, 0 failed at 1 iteration.
c923c30323admin-media-chrome900 passed, 200 skipped, 0 failed after edit attachment details form output and compat-field coverage; local smoke: 180 passed, 40 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
ba524750d9canonical-routing1200 passed, 0 skipped, 0 failed after generated query-argument removal and fragment stripping helper matrices; local smoke: 60 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
a7993f1bb1user-preferences1100 passed, 100 skipped, 0 failed after Screen Options visibility-cache and composed output coverage; local smoke: 55 passed, 5 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
0c0fa09bedsite-health1000 passed, 0 skipped, 0 failed after generated site_status_tests registry/filter/direct-callback coverage.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
90c50c8538media-metadata700 passed, 0 skipped, 0 failed after original-image metadata normalization coverage; local smoke: 140 passed, 0 skipped, 0 failed.Latest broad rerun: 8615 passed, 66 skipped, 0 failed at 2 iterations after the latest email follow-up.
d28094b227 / ee89789187 / 8e087d55e6admin-screen900 passed, 0 skipped, 0 failed after screen-meta/help-sidebar/screen-reader lifecycle coverage and review fixes for submit-filter preservation, post-render global assertions, escaped payload checks, and priority-aware cleanup; priority-20 repro and reviewer smoke passed.8637 passed, 66 skipped, 0 failed at 2 iterations after admin-screen screen-meta review fixes.
64bd67ecde / 224b36546dinstall-schema1100 passed, 0 skipped, 0 failed after missing-table creation/replay coverage and review fixes for same-double replay plus create/replay introspection allowlists; reviewer seed 224 run: 1100 passed, 0 skipped, 0 failed.8639 passed, 66 skipped, 0 failed at 2 iterations after install-schema creation/replay review fixes.
570897ffdfmedia-remote700 passed, 0 skipped, 0 failed after download_url() Content-Disposition/content-type filename derivation matrix coverage; reviewer seed 224 run: 700 passed, 0 skipped, 0 failed with no actionable findings.8641 passed, 66 skipped, 0 failed at 2 iterations after media-remote filename derivation review.
2f91761be5 / ac5734c0c9network-media100 passed, 0 skipped, 0 failed after generated wp_unique_filename() collision/alternate-extension coverage and review fixes for reverse output-format reservations plus core-empty basename behavior; reviewer seed 224 run: 100 passed, 0 skipped, 0 failed.8639 passed, 66 skipped, 0 failed at 2 iterations after network-media filename collision review fixes.
7b87697022admin-screen800 passed, 0 skipped, 0 failed after generated add_screen_option() and WP_Screen::render_screen_options() per-page/layout rendering, wrapping, nonce, submit-filter, and cleanup coverage.8545 passed, 66 skipped, 0 failed at 2 iterations after admin-screen option rendering and script-loader module runtime graph fuzzing.
a760942f1fscript-loader-runtime1100 passed, 200 skipped, 0 failed after generated classic-script module import-map/modulepreload graph coverage.8545 passed, 66 skipped, 0 failed at 2 iterations after script-loader module runtime graph and admin-screen option rendering fuzzing.
f65b0c3fceadmin-dashboard800 passed, 100 skipped, 0 failed after activity recent-post query argument, editable/permalink link branch, relative-date, escaping, and restoration coverage.8541 passed, 66 skipped, 0 failed at 2 iterations after dashboard recent-post fuzzing and REST namespace route coverage.
bc09ade067rest-controllersSeeds 1 and 224 each passed 12 checks, 0 skipped, 0 failed after adding REST plugin/theme controller route/schema, collection parameter, sanitizer, and permission-gate contract coverage.4667 passed, 26 skipped, 0 failed at 1 iteration after REST plugin/theme controller contract coverage.
e52ab28cberest-controllersSeeds 1 and 224 each 1100 passed, 200 skipped, 0 failed after REST search route-dispatch invalid subtype rejection, public schema callback assertions, fixed custom-handler oracle, and post-format pagination review fixes.4357 passed, 33 skipped, 0 failed at 1 iteration after REST search review hardening.
1a26db9f1dtemplate-hierarchy800 passed, 100 skipped, 0 failed after generated category/tag/taxonomy decoded-slug, raw-slug, term-ID hierarchy, selected path, and filter-payload coverage.8537 passed, 66 skipped, 0 failed at 2 iterations after term template hierarchy and Unicode email matrix fuzzing.
c53162cd27email123379 passed, 100 skipped, 0 failed after generated Unicode email filter/view matrix coverage.8537 passed, 66 skipped, 0 failed at 2 iterations after Unicode email matrix and term template hierarchy fuzzing.
4fd5feddc9privacy1500 passed, 0 skipped, 0 failed after built-in comments exporter/eraser registration, payload, anonymization, retention-message, and filter-locality coverage.8535 passed, 66 skipped, 0 failed at 2 iterations after privacy comment callback fuzzing.
848decfbb5plugin-theme-lifecycle1200 passed, 0 skipped, 0 failed after multi-plugin deactivation scope, silent-mode state mutation, and action payload ordering coverage.8531 passed, 66 skipped, 0 failed at 2 iterations after plugin lifecycle and feed hook payload fuzzing.
7eab40d97dfeed-rendering900 passed, 0 skipped, 0 failed after RSS2/Atom post/comment feed hook payload context, escaping, loop-ID, and hook cleanup coverage.8531 passed, 66 skipped, 0 failed at 2 iterations after feed hook payload and plugin lifecycle fuzzing.
cc5a912e25style1300 passed, 100 skipped, 0 failed after registered block style style_data source-order injection, CSS serialization, selector, sanitization, and registry/metadata restoration coverage.8527 passed, 66 skipped, 0 failed at 2 iterations after style variation source-order fuzzing.
2f006c120fl10n1400 passed, 0 skipped, 0 failed after generated locale-switch stack, action payload ordering, user ID, controller locale, and restore-sequence coverage.8525 passed, 66 skipped, 0 failed at 2 iterations after l10n locale-stack fuzzing.
89fd5f092etranslations800 passed, 0 skipped, 0 failed after direct WP_Translation_Controller locale/domain/file isolation, lazy malformed-file eviction, lookup, header, plural, and scoped unload coverage.8523 passed, 66 skipped, 0 failed at 2 iterations after translation controller fuzzing.
e5224d3912environment-load1600 passed, 100 skipped, 0 failed after generated JSON/XML request media matrix coverage.8523 passed, 66 skipped, 0 failed at 2 iterations after environment request media fuzzing.
7ac42d758brewrite1800 passed, 0 skipped, 0 failed after rewrite-tag removal and query-var retention coverage.8511 passed, 66 skipped, 0 failed at 2 iterations after rewrite tag removal fuzzing.
a514c0c42epost-types1000 passed, 0 skipped, 0 failed after post type registry query operator coverage.8511 passed, 66 skipped, 0 failed at 2 iterations after post type query fuzzing.
4efe65c213taxonomy-relationships900 passed, 0 skipped, 0 failed after multi-object all_with_object_id mapping coverage.8509 passed, 66 skipped, 0 failed at 2 iterations after taxonomy relationship object mapping fuzzing.
f476712903multisite1500 passed, 100 skipped, 0 failed after large-network threshold and filter-payload coverage.8509 passed, 66 skipped, 0 failed at 2 iterations after multisite large-network fuzzing.
179ae88db5multisite17 passed, 1 skipped at seeds 1 and 224; 85 passed, 5 skipped at seed 20260626 over 5 iterations; 340 passed, 20 skipped at seed 65270 over 20 iterations after legacy blog identity and bootstrap current-site/current-network coverage.9119 passed, 62 skipped, 0 failed at 2 iterations after multisite legacy identity and bootstrap resolution fuzzing.
352a5bc4demultisite17 passed, 1 skipped at seed 224; 425 passed, 25 skipped at seed 1 over 25 iterations; adjacent multisite/options-autoload/state passed 245 checks with 5 skips after true multisite lifecycle child coverage.4663 passed, 28 skipped, 0 failed at 1 iteration after true multisite lifecycle child fuzzing.
9f24b46262 / ec0fed6a9dfrontend-features800 passed, 0 skipped, 0 failed after disabled speculation lifecycle/load-action isolation and review fix; contamination probe confirmed unrelated prefetch/prerender list URLs fail the invariant; reviewer verification: 160 passed, 0 skipped, 0 failed.8635 passed, 66 skipped, 0 failed at 2 iterations after frontend-features lifecycle review fixes.
40040cb842media-ingest1000 passed, 0 skipped, 0 failed after upload override/error semantics and review fixes for returned error-handler payloads, type-rejection upload-dir bypass, and unique-callback sanitized-name payloads; smoke: 50 passed, 0 skipped, 0 failed.8633 passed, 66 skipped, 0 failed at 2 iterations after media-ingest override/error review fixes.
1c15ebeda4media-ingest900 passed, 0 skipped, 0 failed after attachment post field, desc, post date, and ignored-ID coverage.8507 passed, 66 skipped, 0 failed at 2 iterations after media ingest post-field fuzzing.
a0d21cf48fcron8600 passed, 0 skipped, 0 failed after scheduled-event lookup ordering and exactness coverage.8507 passed, 66 skipped, 0 failed at 2 iterations after cron scheduled-event lookup fuzzing.
d3645d08fainstall-schema1000 passed, 0 skipped, 0 failed after global-table upgrade gate filter coverage.8505 passed, 66 skipped, 0 failed at 2 iterations after install/schema global-table fuzzing.
582c472ce7image-metadata700 passed, 0 skipped, 0 failed after locale-aware XMP alt extraction and fallback coverage.8503 passed, 66 skipped, 0 failed at 2 iterations after image metadata XMP fuzzing.
38d7aaaabe / 6b25026e9bsite-health1100 passed, 0 skipped, 0 failed after persistent object cache threshold/filter direct-test coverage and review fixes for natural below-threshold and table-list oracles.8655 passed, 66 skipped, 0 failed at 2 iterations after Site Health persistent object cache fuzzing.
fb5b15bc76icons-connectors700 passed, 0 skipped, 0 failed after connector plugin install/activation module-data status coverage; reviewer found no actionable findings.8653 passed, 66 skipped, 0 failed at 2 iterations after connector plugin-status fuzzing.
be1aa94ff4icons-connectors600 passed, 0 skipped, 0 failed after REST settings AI-provider API-key validation fail-closed coverage.8501 passed, 66 skipped, 0 failed at 2 iterations after connector REST key validation fuzzing.
b44d2442adutility-internals700 passed, 0 skipped, 0 failed after parse-list, ID/slug normalization, array slicing, recursive sorting, nested get/set, and numeric-array helper coverage.8499 passed, 66 skipped, 0 failed at 2 iterations after utility helper fuzzing.
9aa5167057navigation1000 passed, 0 skipped, 0 failed after current custom-link class propagation through parent and ancestor menu trees.8497 passed, 66 skipped, 0 failed at 2 iterations after navigation current-tree fuzzing.
d0c40e9079bookmark-links900 passed, 0 skipped, 0 failed after direct _walk_bookmarks() image URL, updated marker, rating, and display-filter payload coverage.8495 passed, 66 skipped, 0 failed at 2 iterations after bookmark link walker fuzzing.
38d3d79708widgets900 passed, 0 skipped, 0 failed after dynamic_sidebar() active/empty sidebar hook-order, callback-param mutation, rendered order, and no-output empty-state coverage.8493 passed, 66 skipped, 0 failed at 2 iterations after widgets dynamic sidebar fuzzing.
d7bf4c89c3state1800 passed, 0 skipped, 0 failed after cache-addition suspension coverage for wp_cache_add(), wp_cache_add_multiple(), and wp_cache_set().8491 passed, 66 skipped, 0 failed at 2 iterations after state cache suspension fuzzing.
259d6bd90eadmin-ajax, wpdb query stub1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after direct wp_ajax_query_attachments() MIME/search/order/paging oracles, filename-search hook restoration, query capability denial, wp_ajax_save_attachment() title/caption/description/alt mutation oracles, invalid nonce and capability denial, synthetic fixture cleanup, and narrow post MIME LIKE stub support.4521 passed, 33 skipped, 0 failed at 1 iteration after admin-ajax attachment workflow review fixes.
7d314bc82fadmin-ajax900 passed, 0 skipped, 0 failed after heartbeat valid/invalid nonce branches, hook order, JSON termination, unslashing, and restoration coverage.8489 passed, 66 skipped, 0 failed at 2 iterations after admin-ajax heartbeat fuzzing.
30c7fcc27eformatting1400 passed, 0 skipped, 0 failed after title/key/class identifier sanitizer shape, fallback, idempotence, and filter-locality coverage.8489 passed, 66 skipped, 0 failed at 2 iterations after admin-ajax heartbeat fuzzing.
70acf5e7c4media-remote600 passed, 0 skipped, 0 failed after case-insensitive headers, signature soft/hard failures, sideload return types, extension-filtered metadata, and prefilter cleanup coverage.8487 passed, 66 skipped, 0 failed at 2 iterations after formatting identifier sanitizer fuzzing.
4778dea14adate-time1400 passed, 0 skipped, 0 failed after named-timezone DST/ISO8601 mode and wp_checkdate() filter-contract coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
c891e636db / 4a21f6a5easyndication700 passed, 0 skipped, 0 failed after automatic feed-link theme-support, posts/comments gate, title/href escaping, and filter locality coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
b2a7077ab8filesystem1100 passed, 0 skipped, 0 failed after unique-filename callback metadata and uppercase/lowercase image-extension collision coverage.8485 passed, 66 skipped, 0 failed at 2 iterations after date-time, syndication, and media-remote follow-ups.
31897e4b5etaxonomy1200 passed, 0 skipped, 0 failed after registration filter/action locality, REST controller reuse, object-type association hooks, and get_terms() short-circuit contracts.8477 passed, 66 skipped, 0 failed at 2 iterations after filesystem unique filename fuzzing.
bde139dd85community-events800 passed, 0 skipped, 0 failed after manual search cache-bypass and cache-refresh coverage.8475 passed, 66 skipped, 0 failed at 2 iterations after taxonomy and Community Events follow-ups.
eb38b87a55 / e4f39081f1shortcodes, src/wp-includes/shortcodes.php2100 passed, 0 skipped, 0 failed at seeds 1 and 224 after scoped image-context hook coverage plus review fixes for hook-runtime restoration and priority-zero preexisting filters; reviewer seed 12345: 525 passed, 0 skipped, 0 failed.4331 passed, 33 skipped, 0 failed at 1 iteration after shortcode image-context coverage.
7f3c1c433aadmin-bar700 passed, 0 skipped, 0 failed after back-compat parent alias, deprecation hook, and tabindex rendering coverage.8475 passed, 66 skipped, 0 failed at 2 iterations after taxonomy and Community Events follow-ups.
5092a4032ediscovery800 passed, 0 skipped, 0 failed after sitemap renderer unsupported URL/index field boundary coverage.8469 passed, 66 skipped, 0 failed at 2 iterations after admin-bar render fuzzing.
e41dcde903html-api700 passed, 0 skipped, 0 failed after semantic parser mode probes, breadcrumb stack replay, tree-preservation normalization, token serialization, and textarea/foreign-content boundary coverage.8467 passed, 66 skipped, 0 failed at 2 iterations after discovery sitemap field fuzzing.
2a965fb770content-lifecycleSeeds 1 and 224 each 11 passed, 0 skipped, 0 failed; seed 20260626 at 5 iterations: 55 passed, 0 skipped, 0 failed; seed 65270 at 20 iterations: 220 passed, 0 skipped, 0 failed after status-transition hook order, direct status-storage, cache branch, GUID repair, cron cleanup, and review isolation fixes.9115 passed, 62 skipped, 0 failed at 2 iterations after content-lifecycle status-transition review fixes.
3b335ffb7bcontent-lifecycle700 passed, 0 skipped, 0 failed at seeds 1 and 224 after post-to-term relationship set/append/replace/remove/delete helpers, object-term field modes, relationship cache invalidation, hook payloads/counts, and post-delete cleanup coverage.4520 passed, 33 skipped, 0 failed at 1 iteration after content-lifecycle term relationship review fixes.
9d3f744ee5content-lifecycle600 passed, 0 skipped, 0 failed after post-meta add/read/update/delete, unique key, serialized value, cache invalidation, metadata hook, and post-delete cleanup coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
a971095532rest-object-controllers900 passed, 200 skipped, 0 failed after route registration, method/schema, dispatch URL-param, HEAD, and no-route error coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
9f1f64d929kses108820 passed, 0 skipped, 0 failed after protocol helper fixed-point, scheme-normalization, and filter-aware safe CSS oracle coverage.8465 passed, 66 skipped, 0 failed at 2 iterations after HTML API semantic oracles.
31e3df4d4bquery, wpdb query stub46400 passed, 0 skipped, 0 failed after review fixes for WP_Query ordering, found-row oracles, SQL empty-condition detection, and cache-hit isolation.8208 passed, 66 skipped, 0 failed at 2 iterations after query execution coverage and oracle hardening.
47b9b32fb5security1300 passed, 0 skipped, 0 failed after review fixes for redirect expected outcomes and auth-cookie grace-window stability.8208 passed, 66 skipped, 0 failed at 2 iterations after security boundary coverage and oracle hardening.
b37285c933shortcodes2000 passed, 0 skipped, 0 failed after review fixes for escaped shortcode source boundaries and prefix/punctuated tag collision coverage.8208 passed, 66 skipped, 0 failed at 2 iterations after shortcode parser coverage and oracle hardening.
d10d0fa5bfoptions-autoload, wpdb option stubSeeds 1, 224, and 57123 over 25 iterations each passed 350 checks after registered option-group cache priming coverage for duplicate members, missing members, preprimed entries, alloptions members, non-target groups, and warm-cache query stability.4781 passed, 1 skipped, 0 failed at seed 224 after options group-priming coverage; adjacent options-autoload,state,multisite,admin-options-submission,admin-workflows,security passed 82 checks with 1 skip and bootstrap smoke passed.
af998e5de5 / 6e806f722ahooks1100 passed, 0 skipped, 0 failed after same-tag reentrant mutation and preinitialized-hook normalization coverage plus review fixes for priority-stack and extra-argument assertions.8054 passed, 66 skipped, 0 failed at 2 iterations after hooks and options review fixes.
da90357e60 / db09e88c0aupdate-install-upgrader1300 passed, 0 skipped, 0 failed after temp-backup install cleanup/restore coverage and review fixes for default no-arg backup lists plus normalized sandbox containment.8037 passed, 67 skipped, 0 failed at 2 iterations after updater temp-backup coverage and oracle hardening.
e06ee18528 / 260ccee45dplugin-theme900 passed, 0 skipped, 0 failed after plugin dependency public-contract coverage and review fixes for unmasked active option states plus fail-closed plugin API/HTTP guards.8035 passed, 67 skipped, 0 failed at 2 iterations after plugin dependency public-contract coverage and oracle hardening.
46b69bb45d / 5bbf3f6c27http1000 passed, 0 skipped, 0 failed after chunk-transfer decoding, direct no-network request normalization and early-error coverage, plus review fixes for stream hermeticity, temp-parent reservation, hook/debug cardinality, and normalized stream headers.8033 passed, 67 skipped, 0 failed at 2 iterations after HTTP request-contract coverage and oracle hardening.
0892834d10 / 96c73556dfemail123168 passed, 100 skipped, 0 failed after canonical Unicode-domain account save/update alias coverage and review fixes for lookup boundaries, no-delivery isolation, and rejected-update non-mutation.8029 passed, 67 skipped, 0 failed at 2 iterations after the domain-alias invariant and oracle hardening.
448dc0b981 / 0674925660email61493 passed, 50 skipped, 0 failed after ASCII-vs-Unicode WP_Email_Address construction-mode coverage and explicit IDN skip review fix; related PHPUnit passed 86 tests and 179 assertions with 1 skip.8004 passed, 66 skipped, 0 failed at 2 iterations after base and IDN construction-mode invariants were added.
263c82ef81 / e3e1c64930 / 3ad9e6487bpost-types9000 passed, 0 skipped, 0 failed after registration lifecycle and REST route coverage plus review fixes; related PHPUnit passed 94 tests and 406 assertions.8000 passed, 66 skipped, 0 failed at 2 iterations after isolated post-type REST route registration, no-diagnostic route context, cron leak check, and explicit REST meta dependency coverage.
688a716cc8 / dccbad638cai-client8000 passed, 0 skipped, 0 failed after model-selection collision coverage and review follow-up; prompt-builder PHPUnit passed 179 tests and 456 assertions.7996 passed, 66 skipped, 0 failed at 2 iterations after shared-model collision, provider tuple, provider lock, model-instance preference, fallback, and reversed-registration checks.
8d7efd528c / 4747a53d87interactivity1000 passed, 0 skipped, 0 failed after review fixes; bootstrap smoke passed.7982 passed, 66 skipped, 0 failed at 2 iterations after context namespace stack merge/sort/restoration and cross-namespace derived-state fail-closed coverage.
63bdea67ec / 2bb73fae62request-lifecycle1000 passed, 0 skipped, 0 failed after review fixes; JSON-header smoke: 50 passed, 0 skipped, 0 failed; reviewer Ajax/XML handler reruns passed.7975 passed, 67 skipped, 0 failed at 2 iterations after GET/POST mismatch termination capture, WP::main() hook sequencing, consistent query request globals, and no-DB sentinel hardening.
9f1d3f3662site-health-debugSeeds 1 and 224 each passed 8 checks with 1 skip, seed 224 over 25 iterations passed 200 checks with 25 skips, and adjacent site-health-debug,site-health,http,environment-load,cron smoke passed 138 checks with 1 skip after adding isolated full WP_Debug_Data::debug_data() section assembly coverage.4668 passed, 25 skipped, 0 failed at 1 iteration after bounded Site Health debug-data full-scan coverage.
7d8adb7f93 / e11ee9a6a7site-health-debug700 passed, 200 skipped, 0 failed after review fixes; timeout-control smoke: 35 passed, 10 skipped, 0 failed; disabled-ini_set smoke: 30 passed, 15 skipped, 0 failed.7984 passed, 67 skipped, 0 failed at 2 iterations after bounded WP_Debug_Data::get_sizes() directory/database/total aggregation, timeout hardening, and final review.
5f0eb0a4ccimages900 passed, 0 skipped, 0 failed; exact exposed seed: 18 passed, 0 skipped, 0 failed.7984 passed, 67 skipped, 0 failed at 2 iterations after allowing documented wp_constrain_dimensions() one-pixel rounding drift while preserving bounds and idempotence checks.
ca8db4d687 / 8ec4cfb4aaappearance-media600 passed, 100 skipped, 0 failed after review fixes; HTTPS smoke run: 30 passed, 5 skipped, 0 failed.7971 passed, 66 skipped, 0 failed at 2 iterations after custom-header video URL/settings/markup coverage, request-scheme oracle hardening, and video-only active-gate checks.
23cf0ded2c / e6d2b6b02dblock-widgets700 passed, 0 skipped, 0 failed after review fixes.7981 passed, 66 skipped, 0 failed at 2 iterations after the_widget() dispatch callback/action/content ordering, rendered block output, registered control escaping, and sidebars state isolation.
90161ef07f / 0700329bbaimport-diff800 passed, 0 skipped, 0 failed after review fixes.7959 passed, 67 skipped, 0 failed at 2 iterations after imported post permalink lookup, postmeta count/projection support, meta query limit handling, chunk-boundary coverage, and duplicate permalink oracle hardening.
6a573889ae / f7dc6d84e8admin-list-tables500 passed, 200 skipped, 0 failed after review fixes.7962 passed, 66 skipped, 0 failed at 2 iterations after application-password list table user-meta fixtures, row/template rendering, global restoration, column-cache isolation, and last-IP boundary recording.
c7afdcbeca / 416f45a6b7rest900 passed, 0 skipped, 0 failed after review fixes.7966 passed, 64 skipped, 0 failed at 2 iterations after REST response links, CURIE compaction, embed-all rejection, envelope filtering/cleanup, automatic target hints, and response conversion coverage.
6e314890cd / f171eac2dbblocks600 passed, 0 skipped, 0 failed after review fixes.7974 passed, 65 skipped, 0 failed at 2 iterations after block hook insertion, filter-added hooks, single-instance suppression, exact registry grouping, and ignored-metadata coverage.
c425fca547 / f4c5664ddeemail12290 passed, 10 skipped, 0 failed after review fixes.7970 passed, 66 skipped, 0 failed at 2 iterations after make_clickable() mailto rendering, raw UTF-8 non-linkification, parse round trips, and deterministic historical punycode-TLD partial-link boundary coverage; latest follow-up retires that boundary with a Core fix and failing assertion.
cc5f151138 / 343f3e03c1account-security600 passed, 0 skipped, 0 failed after review fixes.7953 passed, 62 skipped, 0 failed at 2 iterations after application-password authentication API gates, hook payloads, email fallback, Basic Auth validation, usage recording, and global restoration coverage.
fa973409d2 / de5c310150identity100 passed, 0 skipped, 0 failed after review fixes.7947 passed, 62 skipped, 0 failed at 2 iterations after current-commenter cookie payload, filter override, and filter-count restoration coverage.
11f0fa2dc9 / 23619d1ec7network-media100 passed, 0 skipped, 0 failed after review fixes.7949 passed, 64 skipped, 0 failed at 2 iterations after URL scheme normalization, alias, filter-payload, and SSL/admin state-restoration coverage.
97ccdbedea / 92f1ea75f6query-loop7600 passed, 0 skipped, 0 failed after review fixes.7958 passed, 62 skipped, 0 failed at 2 iterations after the_posts final result filtering, post-count recalculation, loop-state, and hook cleanup coverage.
fdf4e75843customizer600 passed, 0 skipped, 0 failed locally; reviewer smoke rerun 60 passed, 0 failed.7960 passed, 62 skipped, 0 failed at 2 iterations after slashed customized JSON ingestion, post-value merge precedence, hook ordering, and unknown validation coverage.
9918a62fd6block-templates800 passed, 100 skipped, 0 failed locally; reviewer rerun 1600 passed, 200 skipped, 0 failed.7964 passed, 63 skipped, 0 failed at 2 iterations after public registry list, post-type filtering, and collision-enrichment coverage.
b8cf3766f9 / 67234118adcomment-workflow700 passed, 0 skipped, 0 failed after review fixes.7951 passed, 62 skipped, 0 failed at 2 iterations after direct wp_new_comment() preprocessing, hook, parent, and user-normalization coverage.
0f64862222 / a4d44a1d28admin-workflows400 passed, 200 skipped, 0 failed after review fixes.7953 passed, 63 skipped, 0 failed at 2 iterations after direct list-table current-action and month-dropdown helper coverage.
34593c0811 / c20c4b262fdefault-widgets900 passed, 0 skipped, 0 failed after review fixes.7946 passed, 63 skipped, 0 failed at 2 iterations after saved-instance callback lifecycle coverage.
76eae4e1cf / 043856be50editor-helpers900 passed, 0 skipped, 0 failed after review fixes.7958 passed, 63 skipped, 0 failed at 2 iterations after enqueue handle/dependency coverage.
374949bd7d / c8c67895fffrontend-features700 passed, 0 skipped, 0 failed after review fixes.7953 passed, 63 skipped, 0 failed at 2 iterations after direct speculation rule validation and diagnostic oracle hardening.
6b9565d9da / a98060aa86admin-screen700 passed, 0 skipped, 0 failed after review fixes.7938 passed, 64 skipped, 0 failed at 2 iterations after help tab and screen option coverage.
8b3f051c05 / e1530693f1wpdb-sql800 passed, 0 skipped, 0 failed after review fixes.7938 passed, 63 skipped, 0 failed at 2 iterations after null builder format coverage.
b691234d07 / 6b590ee976markup2173 passed, 27 skipped, 0 failed after review fixes.7953 passed, 62 skipped, 0 failed at 2 iterations after link target/nofollow attribute helper coverage.
55387b1acfmedia-metadata500 passed, 0 skipped, 0 failed at seeds 112233 and 987654.7942 passed, 63 skipped, 0 failed at 2 iterations after attachment classification and MIME/extension disagreement coverage.
ab3113389c / 5251e4ff47 / cdf3ecf90a / 2cb3c60aa9fonts275 passed, 0 skipped, 0 failed at seeds 1 and 224 over 25 iterations after REST font write lifecycle coverage.4662 passed, 28 skipped, 0 failed at 1 iteration after REST font-family/font-face write lifecycle coverage.
f8a6444fd2identity100 passed, 0 skipped, 0 failed.7939 passed, 62 skipped, 0 failed at 2 iterations after sanitizer filter-contract coverage.
c93166b0ec / 50ef617a22 / 89ec6030b2content15 passed, 0 skipped, 0 failed at seeds 1 and 224 plus 750 passed over 50 iterations after content pagination coverage; earlier post-template helper runs passed 1400 checks at seeds 1 and 224.9139 passed, 62 skipped, 0 failed at 2 iterations after content pagination and KSES review-fix coverage.
b2b0c9cd6cstate19 passed at seed 1, 95 passed at seed 224 over 5 iterations, 380 passed at seed 20260627 over 20 iterations, and adjacent state,options-autoload smoke passed 160 after option-backed site transient coverage for option rows, timeout updates, zero-expiration storage, manual expiration cleanup, dynamic filters, hook cleanup, and external object cache restoration.9141 passed, 62 skipped, 0 failed at 2 iterations after option-backed state site-transient fuzzing.
42a893e3d5wxr-export900 passed, 100 skipped, 0 failed.8015 passed, 67 skipped, 0 failed at 2 iterations after attachment URL and _wp_attached_file serialization coverage.
7966c93f67auth-flow1000 passed, 0 skipped, 0 failed.8013 passed, 67 skipped, 0 failed at 2 iterations after generated auth-cookie scheme-boundary and filter-payload coverage.
eb8f0e9d57content1300 passed, 0 skipped, 0 failed.8011 passed, 67 skipped, 0 failed at 2 iterations after whole-post sanitize_post() array/object, ordered filter-locality, and review-fix coverage.
906aca07e2registries600 passed, 0 skipped, 0 failed; related PHPUnit: 24 tests, 47 assertions.8005 passed, 67 skipped, 0 failed at 2 iterations after block metadata collection path-boundary, dot-segment, cache, and virtual-prefix coverage.
a4e710265babilities700 passed, 0 skipped, 0 failed.8007 passed, 67 skipped, 0 failed at 2 iterations after custom subclass query pipeline, unregister/re-register identity, and default-input execution coverage.
187eb56d1bsecurity900 passed, 0 skipped, 0 failed.7927 passed, 63 skipped, 0 failed at 2 iterations after redirect validation matrix coverage.
e51aa0ed46mail2000 passed, 0 skipped, 0 failed; related PHPUnit: 32 tests, 87 assertions.8001 passed, 67 skipped, 0 failed at 2 iterations after multipart boundary matrix coverage across string/array headers, LF/CRLF bodies, uppercase boundary parameters, quoted/unquoted boundaries, and serialized MIME body/header preservation.
6719f743e3user-preferences800 passed, 100 skipped, 0 failed.7928 passed, 62 skipped, 0 failed at 2 iterations after screen layout rendering coverage.
7914c3dd70discovery700 passed, 0 skipped, 0 failed.7936 passed, 62 skipped, 0 failed at 2 iterations after sitemap stylesheet filter coverage.
5d0cbfc3c5navigation900 passed, 0 skipped, 0 failed.7924 passed, 63 skipped, 0 failed at 2 iterations after args filter and items-wrap coverage.
6388f29533template-links900 passed, 0 skipped, 0 failed.7931 passed, 63 skipped, 0 failed at 2 iterations after canonical and shortlink head-output coverage.
39d8b05f6esyndication600 passed, 0 skipped, 0 failed.7922 passed, 64 skipped, 0 failed at 2 iterations after comment feed-link generation and filter coverage.
cf11d874fdadmin-bar600 passed, 0 skipped, 0 failed.7913 passed, 63 skipped, 0 failed at 2 iterations after initialization hook/theme-support restoration coverage.
8004401a7dicons-connectors500 passed, 0 skipped, 0 failed.7912 passed, 63 skipped, 0 failed at 2 iterations after API-key masking and file-modification policy coverage.
01ae929ca5media-remote500 passed, 0 skipped, 0 failed.7921 passed, 63 skipped, 0 failed at 2 iterations after sideload prefilter and override contract coverage.
5cad5cda2autility-internals600 passed, 0 skipped, 0 failed.7913 passed, 62 skipped, 0 failed at 2 iterations after chained list-state coverage and the classic-walkers oracle fix.
a8b3eed2efclassic-walkers1100 passed, 100 skipped, 0 failed.Fixed broad-run generated has-children oracle finding for seed 696015146.
f3859d19eequery-loop7500 passed, 0 skipped, 0 failed.7910 passed, 62 skipped, 0 failed at 2 iterations after offset/no-found-rows field-shape coverage.
787b89ace6mail800 passed, 0 skipped, 0 failed.7907 passed, 62 skipped, 0 failed at 2 iterations after UTF-8 local-part PHPMailer handoff coverage.
bba81e013bcanonical-routing1100 passed, 0 skipped, 0 failed.7913 passed, 62 skipped, 0 failed at 2 iterations after same-host redirect filter replacement cascade coverage.
2beac968c2xmlrpc900 passed, 0 skipped, 0 failed.7897 passed, 64 skipped, 0 failed at 2 iterations after mixed success/fault multicall ordering coverage.
c2c69c4132assets1400 passed, 0 skipped, 0 failed.8021 passed, 67 skipped, 0 failed at 2 iterations after style add-data output metadata coverage.
8fd73afc6cmedia-editor225 passed, 0 skipped, 0 failed for seeds 1 and 224 over 25 iterations after replacing unavailable real-editor skips with explicit GD/Imagick extension and mime-support accounting; adjacent media smoke passed 101 checks with no skips.4689 passed, 6 skipped, 0 failed at 1 iteration after media editor availability accounting.
fee60b3b4a / f5aad1d00cmedia-editor800 passed, 100 skipped, 0 failed after abstract editor filename/quality/EXIF-orientation coverage and review fixes.8023 passed, 67 skipped, 0 failed at 2 iterations after abstract editor contract coverage.
e6864cfb09filesystem1000 passed, 0 skipped, 0 failed.7898 passed, 62 skipped, 0 failed at 2 iterations after direct filesystem metadata/time/chmod coverage and media-editor integration.
2370d0b086classic-walkers1100 passed, 100 skipped, 0 failed.7896 passed, 63 skipped, 0 failed at 2 iterations after classic walker traversal and output oracle hardening.
62e6ac9ad4admin-ajax800 passed, 0 skipped, 0 failed.7896 passed, 63 skipped, 0 failed at 2 iterations after compression-test capability/body branch coverage and classic-walkers integration.
8474330e39update-install-upgrader1200 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after VCS guard and theme auto-update filter/PHP gate coverage.
38b101b44eplugin-theme-lifecycle1100 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after plugin/theme lifecycle oracle hardening.
1e84bac01binstall-schema900 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after make_db_current() wrapper scope coverage.
5c09c881b1translations700 passed, 0 skipped, 0 failed.7887 passed, 62 skipped, 0 failed at 2 iterations after translation install/API short-circuit coverage.
7e02656bf4community-events700 passed, 0 skipped, 0 failed.7872 passed, 62 skipped, 0 failed at 2 iterations after strict coordinate matching and cache expiration normalization coverage.
c4c5b7456btaxonomy1000 passed, 0 skipped, 0 failed.7885 passed, 62 skipped, 0 failed at 2 iterations after registration side-effect, term field filter, and hierarchy helper coverage.
d5de86ecd5admin-bar500 passed, 0 skipped, 0 failed.7876 passed, 63 skipped, 0 failed at 2 iterations in a clean worktree after default menu hook registration coverage.
5b99565f57post-types700 passed, 0 skipped, 0 failed.7853 passed, 64 skipped, 0 failed at 2 iterations after archive/feed link helper, query branch, and filter-locality coverage.
1329011c91style1200 passed, 100 skipped, 0 failed.7871 passed, 62 skipped, 0 failed at 2 iterations after preset classname/CSS-var, block selector, theme.json variable, and editor theme-style filter coverage.
7fbad6f455site-health-debug600 passed, 200 skipped, 0 failed.7871 passed, 62 skipped, 0 failed at 2 iterations after MySQL variable lookup and scoped SHOW VARIABLES wpdb-double coverage.
48bfee1d39blocks500 passed, 0 skipped, 0 failed.7854 passed, 63 skipped, 0 failed at 2 iterations after nested parse/serialize, block detection, and dynamic render callback/filter coverage.
cc1ac49ff3date-time1200 passed, 0 skipped, 0 failed.7854 passed, 63 skipped, 0 failed at 2 iterations after current datetime, timezone override, ISO8601 conversion, and date/human filter coverage.
93c6f19416imagesLatest focused runs: 1000 passed, 0 skipped, 0 failed at seeds 1 and 224 after content tag pipeline matrix and review fixes; earlier image runs passed 900 checks plus the rounding repro.9075 passed, 66 skipped, 0 failed at 2 iterations after image content tag pipeline coverage.
53453d79ffcanonical-routingLatest focused runs: 350 passed, 0 skipped, 0 failed at seeds 1 and 224 after DB-backed 404 permalink guessing coverage; earlier canonical-routing runs passed 1300 checks after canonical URL output helpers and 1200 checks after helper-matrix smoke.4660 passed, 28 skipped, 0 failed at 1 iteration after canonical DB-backed 404 redirect coverage.
89e00fbfadsyndicationLatest focused runs: 900 passed, 0 skipped, 0 failed at seeds 1 and 224 after feed_links_extra() branch matrix and review cleanup; earlier syndication runs passed oEmbed cache and feed-link helper coverage.9079 passed, 66 skipped, 0 failed at 2 iterations after feed_links_extra() branch coverage.
555495f583admin-barLatest focused runs: seeds 1, 224, and 57123 over 25 iterations each passed 225 checks after single-site callback node graph coverage; earlier admin-bar runs passed 800 checks after default callback node graph coverage and 700 checks after render alias/tabindex coverage.4795 passed, 1 skipped, 0 failed at 1 iteration after single-site admin-bar callback node coverage; earlier broad run passed 9081 checks with 66 skips at 2 iterations after default callback node graph coverage.
component-fuzz-upgrader-notificationsupdate-install-upgraderLatest focused runs: seed 224 passed 14 checks, seeds 1 and 224 over 25 iterations each passed 350 checks after synthetic WP_Automatic_Updater plugin/theme notification classification, duplicate failure suppression, failure-version persistence, stale success clearing, mixed result handling, email body/filter payload, intercepted mail, and option/filter restoration coverage; adjacent plugin/theme/upgrader smoke passed 58 checks with 1 existing skip.4710 passed, 5 skipped, 0 failed at 1 iteration after updater notification coverage.
component-fuzz-wxr-export-edgeswxr-exportLatest focused runs: seed 224 passed 11 checks, seeds 1 and 224 over 25 iterations each passed 275 checks after non-exportable/invalid content fallback, null meta, filtered-comment, nav-menu term, and associative-array prepare coverage; adjacent wxr-export,import-diff,content smoke passed 36 checks.4709 passed, 5 skipped, 0 failed at 1 iteration after WXR edge/resilience coverage.
component-fuzz-xmlrpc-write-methodsxmlrpcLatest focused runs: 300 passed, 0 skipped, 0 failed at seeds 1 and 224 after authenticated wp.newPost, wp.editPost, and wp.deletePost write-method coverage; earlier XML-RPC runs passed 550 checks for authenticated read-only content/media, 1000 checks for pingback fail-closed/read-only lookup coverage, and 800 checks for legacy post XML helpers and short-circuited HTTP IXR client coverage.4708 passed, 5 skipped, 0 failed at 1 iteration after XML-RPC authenticated post write-method coverage.
4a2cad026crevisions-autosavesLatest focused runs: 600 passed, 50 guarded browser-template skips, 0 failed at seeds 1 and 224 after latest revision count/URL helper and user-filtered autosave lookup coverage; earlier revisions-autosaves runs passed retention pruning, protected field filters, post-locks, restore/title/list, and support-gate coverage.9087 passed, 62 skipped, 0 failed at 2 iterations after replacing the latest-count URL and user-filtered autosave stub-limited skips.
0b1c9674a6 / 0afd17bb8dtemplate-hierarchyLatest focused runs: 900 passed, 100 guarded comments-template skips, 0 failed at seeds 1 and 224 after direct helper matrix; earlier run passed 700 checks with 100 skips.9073 passed, 66 skipped, 0 failed at 2 iterations after template hierarchy direct helper coverage.
63f21f64d0shortcodes1400 passed, 0 skipped, 0 failed.7853 passed, 62 skipped, 0 failed at 2 iterations after invalid registration and non-callable callback guard coverage.
bd7fc967f2email24570 passed, 0 skipped, 0 failed at 20 iterations for the Unicode follow-up.7850 passed, 62 skipped, 0 failed at 2 iterations after Unicode-domain user lookups and password-reset recipient coverage.
e330757e2exmlrpc800 passed, 0 skipped, 0 failed.7849 passed, 62 skipped, 0 failed at 2 iterations after legacy post XML helper and short-circuited HTTP IXR client coverage.
4841e86db6revisions-autosaves900 passed, 300 skipped, 0 failed.7842 passed, 64 skipped, 0 failed at 2 iterations after protected field filters, autosave/post-lock, restore/title/list, and support-gate coverage.
e77aa7c74anetwork-media100 aggregate iterations passed; sampled result had 1752 assertions and 0 failures.7838 passed, 62 skipped, 0 failed at 2 iterations after multisite upload quota and size-limit helper coverage.
537a1945d8query7940 passed, 0 skipped, 0 failed.7840 passed, 62 skipped, 0 failed at 2 iterations after no-DB WP_Query execution SQL-shape and bounded global-state coverage.
fa59e81908rest-controllers900 passed, 200 skipped, 0 failed.7840 passed, 62 skipped, 0 failed at 2 iterations after REST additional-field get/update/schema coverage.
ad4062a0afwidgets800 passed, 0 skipped, 0 failed.7690 passed, 63 skipped, 0 failed at 2 iterations after sidebar option cache/filter and widget ID parsing coverage.
22386accbdrewrite1800 passed, 0 skipped, 0 failed.7692 passed, 62 skipped, 0 failed at 2 iterations after rewrite collision, endpoint-mask, match-map, query parse, and weird-path coverage.
cb2f19fcefcron8500 passed, 0 skipped, 0 failed.7679 passed, 64 skipped, 0 failed at 2 iterations after wp_next_scheduled filter coverage.
9b99f0ea11shortcodes1300 passed, 0 skipped, 0 failed.7678 passed, 63 skipped, 0 failed at 2 iterations after strip_shortcodes_tagnames filter coverage.
d3b4aa01ffemail183696 passed, 0 skipped, 0 failed.7667 passed, 62 skipped, 0 failed at 2 iterations after malformed UTF-8, Unicode local-part byte boundary, and display-name recovery integration.
9d9434d4b5security800 passed, 0 skipped, 0 failed.7642 passed, 62 skipped, 0 failed at 2 iterations after password and fast-hash verification coverage.
d759a80a3ehttp800 passed, 0 skipped, 0 failed.7643 passed, 62 skipped, 0 failed at 2 iterations after remote wrapper short-circuit dispatch coverage.
89f78b4db1feed-rendering800 passed, 0 skipped, 0 failed.7639 passed, 62 skipped, 0 failed at 2 iterations after Atom comments, feed links, content-mode, and URL escaping integration.
8e7a6a32ecstate1700 passed, 0 skipped, 0 failed.7640 passed, 62 skipped, 0 failed at 2 iterations after cache-backed site-transient branch and filter integration.
dd69f1c879capabilities900 passed, 0 skipped, 0 failed.7630 passed, 63 skipped, 0 failed at 2 iterations after role lifecycle, user mutation, filter-locality, and meta-cap monotonicity integration.
ec81e176e5filesystem, formattingFilesystem focused: 900 passed, 0 skipped, 0 failed; sanitizeFileName PHPUnit: 21 tests, 29 assertions.7622 passed, 63 skipped, 0 failed at 2 iterations after fixing stream/newline path normalization oracle and malformed UTF-8 filename handling.
46237be00dimport-diff1050 passed, 0 skipped, 0 failed before the imported-post follow-up.7621 passed, 62 skipped, 0 failed at 2 iterations after WP_Error lifecycle ordering and default-code integration.
57ddcc6ba7discovery600 passed, 0 skipped, 0 failed.7609 passed, 64 skipped, 0 failed at 2 iterations after sitemap enablement, robots.txt, provider filter, and URL-mode integration.
8ace846176images900 passed, 0 skipped, 0 failed.7620 passed, 62 skipped, 0 failed at 2 iterations after synthetic metadata, responsive filters, and filetype helper integration.
d744e80b6bblock-widgets600 passed, 0 skipped, 0 failed before the dispatch-control follow-up.7618 passed, 62 skipped, 0 failed at 2 iterations after legacy class matrix integration.
0a907d9fbcmail700 passed, 0 skipped, 0 failed.7607 passed, 62 skipped, 0 failed at 2 iterations after reusable PHPMailer cleanup/reset integration.
f267c4f0b2navigation800 passed, 0 skipped, 0 failed.7600 passed, 63 skipped, 0 failed at 2 iterations after fallback, allowlist, and filter-pipeline integration.
5e6efd5c91l10n1300 passed, 0 skipped, 0 failed.7597 passed, 62 skipped, 0 failed at 2 iterations after locale, path, script translation, and nooped-plural integration.
93b5eda0b5cron8400 passed, 0 skipped, 0 failed.7593 passed, 62 skipped, 0 failed at 2 iterations after duplicate-window and filter short-circuit integration.
9335eed6e6 / 7790a7504dinteractivityLatest focused runs: 1100 passed, 0 skipped, 0 failed at seeds 1 and 224 after directive syntax/order coverage and review fixes; earlier directive edge integration passed 800 checks.13602 passed, 99 skipped, 0 failed at 3 iterations after interactivity directive syntax/order coverage.
7c46819228privacy700 passed, 0 skipped, 0 failed.8013 passed, 66 skipped, 0 failed at 2 iterations after request-key missing-request and global-post fallback fail-closed coverage plus review fixes.
current branch / component-fuzz-core-block-listscore-block-renderLatest focused runs: seed 224 passed 9 checks, seeds 1 and 224 over 25 iterations each passed 225 checks after frontend list-style block render coverage for archives, categories, latest posts, latest comments, tag cloud, and calendar.Latest broad run: 4720 passed, 4 skipped, 0 failed at 1 iteration after frontend list-style core block coverage; earlier metadata REST meta field broad run passed 4719 with 4 skips.
3b5c004dd9shortcodes1200 passed, 0 skipped, 0 failed.7563 passed, 62 skipped, 0 failed at 2 iterations after shortcode filter/discovery integration.
f46c90f1efrest-object-controllers800 passed, 200 skipped, 0 failed.7555 passed, 63 skipped, 0 failed at 2 iterations after REST object controller integration.
4e3f7a3de1options-autoload, wpdb option stub1200 passed, 0 skipped, 0 failed.7555 passed, 63 skipped, 0 failed at 2 iterations after options/autoload integration.
8c834c4ed4rewrite, canonical-routing2200 passed, 0 skipped, 0 failed.7551 passed, 62 skipped, 0 failed at 2 iterations after the rewrite/routing integration.
4aa97eacbdpost-types600 passed, 0 skipped, 0 failed.7551 passed, 62 skipped, 0 failed at 2 iterations after the post-types integration.
f5f6ca038d / 7f913eb4d5 / a2b8c3ff0e / e7f99aa17e / 50de103325ksesSeed 1 passed 1100 checks, seed 224 passed 1097 checks, and seed 54261 over 5 iterations passed 5507 checks after helper contract, serialized block attribute coverage, and hook-state review fixes.9139 passed, 62 skipped, 0 failed at 2 iterations after KSES hook-state review fixes and content pagination coverage.
34aff69e0bnetwork-media100 passed, 0 skipped, 0 failed; repro seed 616161: 2 passed.Fixed protocol-relative URL shape finding from the formatting broad run.
287c6d72acformatting1300 passed, 0 skipped, 0 failed.Initial broad rerun found network-media URL-shape finding, fixed by 34aff69e0b.
41342333f0 / e05923c75c / 1f63d15d7acomments89000 passed, 0 skipped, 0 failed; comments+workflow integration: 11300 passed, 0 skipped, 0 failed.7992 passed, 66 skipped, 0 failed at 2 iterations after comment permalink pagination coverage and review fixes for exact cpage matching plus post/parent count-query constraints.
6fe5c096eaemail120981 passed, 0 skipped, 0 failed.7112 passed, 62 skipped, 0 failed at 2 iterations after the Unicode email integration.
b1439dba84taxonomy-relationships900 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
a6adbb00dcquery-loop7400 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
d5c3d20572multisite1400 passed, 100 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
fce93ba0d1rest-controllers800 passed, 200 skipped, 0 failed; repro seed 470788260: 8 passed, 2 skipped.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
afe952e27cmedia-ingest900 passed, 0 skipped, 0 failed.6894 passed, 62 skipped, 0 failed at 2 iterations after the rich-surface wave.
4cce3d5090cron8000 passed, 0 skipped, 0 failed.Previous broad rerun found rest-controllers seed 470788260; fixed by fce93ba0d1.
95a2e1aa81user-preferences700 passed, 100 skipped, 0 failed.6720 passed, 62 skipped, 0 failed at 2 iterations.
ff28a0beeawpdb-stubcontent-lifecycle repro: 6 passed, 0 failed.Resolved empty quoted CSV value warning in final broad run.
424837f7e9widgets700 passed, 0 skipped, 0 failed.6720 passed, 63 skipped, 0 failed at 2 iterations before stub rerun.
fcee053dd5html-api600 passed, 0 skipped, 0 failed.6708 passed, 63 skipped, 0 failed at 2 iterations.
685951079asyndication500 passed, 0 skipped, 0 failed.6715 passed, 62 skipped, 0 failed at 2 iterations.
9c8eb1f52arequest-lifecycle800 passed, 0 skipped, 0 failed.6696 passed, 62 skipped, 0 failed at 2 iterations.
7b5eeac0e3hooks900 passed, 0 skipped, 0 failed.6706 passed, 63 skipped, 0 failed at 2 iterations.
b257533f03mail, privacy600 passed, 0 skipped, 0 failed for mail; privacy repro passed.6690 passed, 62 skipped, 0 failed at 2 iterations.
1ec4959311capabilities500 passed, 0 skipped, 0 failed.6673 passed, 62 skipped, 0 failed at 2 iterations.
ba642bf12demail110184 passed, 0 skipped, 0 failed.10012 passed, 93 skipped, 0 failed at 3 iterations.
77c3261c5aenvironment-load1500 passed, 100 skipped, 0 failed.6673 passed, 64 skipped, 0 failed at 2 iterations.
65cb994010icons-connectors400 passed, 0 skipped, 0 failed.6656 passed, 60 skipped, 0 failed at 2 iterations.
2132510f82script-loader-runtime1000 passed, 200 skipped, 0 failed.6646 passed, 60 skipped, 0 failed at 2 iterations.
fb12f51f20feed-parsers800 passed, 0 skipped, 0 failed.9922 passed, 84 skipped, 0 failed at 3 iterations.
ef7248cd3aerror-protection800 passed, 100 skipped, 0 failed.9898 passed, 84 skipped, 0 failed at 3 iterations.
4745b98aa9utility-internals500 passed, 0 skipped, 0 failed.9874 passed, 81 skipped, 0 failed at 3 iterations.
fd3aca30cdimage-metadata600 passed, 0 skipped, 0 failed.6572 passed, 55 skipped, 0 failed at 2 iterations.
aca1415156bookmark-links700 passed, 0 skipped, 0 failed.6564 passed, 54 skipped, 0 failed at 2 iterations.
e2974861e4 / 208ba72ab2 / da8fc3eb89 / 6002f40de7 / 63aadcb347rest-site-editor13 passed, 1 skipped, 0 failed after subprocess-isolated live edit-site export coverage; seed 224 passed 65 checks with 5 skips over 5 iterations.Previous broad run: 9129 passed, 62 skipped, 0 failed at 2 iterations; REST-family smoke passed 88 checks with 10 skips.
b64a03f1ef / 8f757f9079customizer-persistence700 passed, 0 skipped, 0 failed after changeset lock/heartbeat coverage and review fixes.8027 passed, 67 skipped, 0 failed at 2 iterations after Customizer lock persistence coverage.
bae57b7db8media-remote400 passed, 0 skipped, 0 failed.6519 passed, 51 skipped, 0 failed at 2 iterations.
3205dbbf0dappearance-media, dashboard500 passed, 100 skipped, 0 failed before the custom-header video follow-up.6527 passed, 50 skipped, 0 failed at 2 iterations.
021058dbc4classic-walkers, wxr-export1150 passed, 150 skipped, 0 failed across focused runs.6504 passed, 49 skipped, 0 failed at 2 iterations.
98fc9b211etaxonomy-relationships600 passed, 0 failed.6473 passed, 45 skipped, 0 failed at 2 iterations.
29400ff5c3admin-dashboard, site-health-debug1200 passed, 300 skipped, 0 failed across focused runs.6466 passed, 45 skipped, 0 failed at 2 iterations.
6d88c378d2query-loop500 passed, 0 failed.6435 passed, 39 skipped, 0 failed at 2 iterations.
+
+ +
+
+

Component Dependencies

+ High-level dependencies used for planning and integration order. +
+
+
+ Bootstrap Core + WpBootstrap, hooks, options, cache, roles, post types, taxonomies, content parsers, connector/icon registries. +
+
+ In-memory DB Stub + Content lifecycle, taxonomy relationships, bookmarks, WXR, REST object controllers. +
+
+ REST Stack + REST request/response/server, capabilities, schema validation, controller registries, widget/sidebar controllers, Abilities API registries. +
+
+ Media Stack + Filesystem temp roots, HTTP short-circuits, filetype validation, metadata parsers, fake image editors, image-edit AJAX, nonce/capability gates. +
+
+ Theme/Editor Stack + Theme supports, template fixtures, global styles, Customizer settings, nav menu/widget request components, selective refresh, style engine. +
+
+ Identity/Security Stack + Users, roles/caps, nonces, auth cookies, email validation, KSES, privacy exports. +
+
+
+ +
+
+

Coverage Notes

+ Known intentional boundaries remain explicit skips, not hidden passes. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaCurrent CoverageIntentional Boundaries
Application passwordsValidated lifecycle, chunking, generic/legacy hash checks, API-request gating, site/user availability errors, matched-password constraint hooks, success/failure hook payloads, email-identifier fallback, Basic Auth validation, usage IP/time recording, concrete list-table row/template rendering, escaped hostile Last IP output, empty Last IP fallback, REST application-password collection/item/introspection route contracts, CRUD dispatch, one-time password response and stored hash agreement, _fields projection, REST hook payloads, exact permission/availability errors, stale UUID introspection failures, and role/REST/list-table global restoration.No live HTTP Basic Auth request dispatch or browser profile-screen authorization flow; direct public API calls and synthetic REST dispatch cover the authentication, persistence, and controller contracts without network or process-exit paths.
Unicode emailVerified latest focused make_clickable() punycode-TLD coverage at 1248 checks, adjacent email,mail,identity smoke at 1260 checks, and broad smoke at 4729 checks with 1 skip, plus earlier UTF-8 address-model replays including REST schema email validation across Unicode/ASCII filter modes, Unicode-domain user lookups, canonical account save/update collision behavior, confusable UTF-8 local-part lookup/comment/password-reset/search boundaries, password-reset recipient preservation, UTF-8 local-part PHPMailer handoff, machine/readable address views and round trips, mixed-script combining local parts, reserved ACE-like and invalid xn-- domain rejection, malformed UTF-8 warning capture, and make_clickable() mailto rendering boundaries.No real PHPMailer delivery, browser UI validation, or full REST request dispatch; direct validation, sanitization, REST schema helpers, structural views, user lookup behavior, pre-send password-reset routing, intercepted PHPMailer composition, and direct mailto rendering only. Direct punycode alias lookup miss is recorded as an explicit current-core boundary; uppercase ACE-prefix rejection is covered as a strict current-model assumption.
Admin/browser flowsCovered through direct helpers, list tables, dashboard, user preferences, AJAX helpers.Full browser page dispatch, redirects, and process exits are skipped.
Remote/network pathsHTTP request/response, transfer encoding, origin/CORS, capability, and redirect helpers, community events, media remote helpers, REST block-directory, REST pattern-directory, and REST URL-details controllers use short-circuited local responses with cache/error oracles.Live network access remains out of scope.
DB-backed behaviorTargeted in-memory SQL shapes are covered where practical.The wpdb stub is intentionally not a general SQL engine.
+
+
+ + diff --git a/tools/component-fuzz/lib/FuzzContext.php b/tools/component-fuzz/lib/FuzzContext.php new file mode 100644 index 0000000000000..386bc10f704b7 --- /dev/null +++ b/tools/component-fuzz/lib/FuzzContext.php @@ -0,0 +1,231 @@ +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', + ) + ); + $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 . ':'; + + 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; + $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 ) . '"'; + } + $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(); + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { + $out[] = $this->jsonValue( $depth + 1 ); + } + return $out; + } + + $out = array(); + $count = $this->int( 0, 4 ); + for ( $i = 0; $i < $count; $i++ ) { + $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..299bff9b1045c --- /dev/null +++ b/tools/component-fuzz/lib/Support.php @@ -0,0 +1,131 @@ + $limit ) { + return substr( $printable, 0, $limit ) . '...'; + } + + return $printable; + } + + if ( is_array( $value ) ) { + $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + 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..e25351e29e2ae --- /dev/null +++ b/tools/component-fuzz/lib/SurfaceRunner.php @@ -0,0 +1,311 @@ + */ + 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 { + 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 ( ! 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( '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; + } + + 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..73d9a17b657cb --- /dev/null +++ b/tools/component-fuzz/lib/WpBootstrap.php @@ -0,0 +1,715 @@ +component_fuzz_reset_options( + array_merge( + array( + 'home' => 'http://example.test', + 'siteurl' => 'http://example.test', + ), + $GLOBALS['wpdb']->component_fuzz_get_options() + ) + ); + } + + if ( function_exists( 'wp_cache_init' ) && ! isset( $GLOBALS['wp_object_cache'] ) ) { + wp_cache_init(); + } + + if ( ! isset( $GLOBALS['blog_id'] ) ) { + $GLOBALS['blog_id'] = 1; + } + if ( ! isset( $GLOBALS['table_prefix'] ) ) { + $GLOBALS['table_prefix'] = 'wp_'; + } + if ( ! isset( $GLOBALS['wp_plugin_paths'] ) || ! is_array( $GLOBALS['wp_plugin_paths'] ) ) { + $GLOBALS['wp_plugin_paths'] = array(); + } + if ( ! isset( $GLOBALS['_wp_switched_stack'] ) || ! is_array( $GLOBALS['_wp_switched_stack'] ) ) { + $GLOBALS['_wp_switched_stack'] = array(); + } + if ( ! isset( $GLOBALS['switched'] ) ) { + $GLOBALS['switched'] = false; + } + + if ( class_exists( 'WP_Site' ) && ! isset( $GLOBALS['current_blog'] ) ) { + $default_site = (object) array( + 'blog_id' => '1', + 'domain' => 'example.test', + 'path' => '/', + 'site_id' => '1', + 'registered' => '2026-06-22 00:00:00', + 'last_updated' => '2026-06-22 00:00:00', + 'public' => '1', + 'archived' => '0', + 'mature' => '0', + 'spam' => '0', + 'deleted' => '0', + 'lang_id' => '0', + ); + + $GLOBALS['current_blog'] = new \WP_Site( $default_site ); + + if ( function_exists( 'wp_cache_set' ) ) { + wp_cache_set( 1, $default_site, 'sites' ); + } + } + + if ( class_exists( 'WP_Network' ) && ! isset( $GLOBALS['current_site'] ) ) { + $default_network = (object) array( + 'id' => '1', + 'domain' => 'example.test', + 'path' => '/', + 'blog_id' => '1', + 'cookie_domain' => 'example.test', + 'site_name' => 'Component Fuzz Network', + ); + + $GLOBALS['current_site'] = new \WP_Network( $default_network ); + + if ( function_exists( 'wp_cache_set' ) ) { + wp_cache_set( 1, $default_network, 'networks' ); + } + } + + if ( class_exists( 'WP_Textdomain_Registry' ) && ! isset( $GLOBALS['wp_textdomain_registry'] ) ) { + $GLOBALS['wp_textdomain_registry'] = new \WP_Textdomain_Registry(); + $GLOBALS['wp_textdomain_registry']->init(); + } + + if ( class_exists( 'WP_Locale' ) && ! isset( $GLOBALS['wp_locale'] ) ) { + $GLOBALS['wp_locale'] = new \WP_Locale(); + } + + if ( class_exists( 'WP_Widget_Factory' ) && ! isset( $GLOBALS['wp_widget_factory'] ) ) { + $GLOBALS['wp_widget_factory'] = new \WP_Widget_Factory(); + } + + if ( class_exists( 'WP_AI_Client_Discovery_Strategy' ) ) { + \WP_AI_Client_Discovery_Strategy::init(); + } + + if ( class_exists( 'WordPress\AiClient\AiClient' ) ) { + if ( class_exists( 'WP_AI_Client_Cache' ) ) { + \WordPress\AiClient\AiClient::setCache( new \WP_AI_Client_Cache() ); + } + if ( class_exists( 'WP_AI_Client_Event_Dispatcher' ) ) { + \WordPress\AiClient\AiClient::setEventDispatcher( new \WP_AI_Client_Event_Dispatcher() ); + } + } + + self::$loaded = true; + } + + private static function reset_temp_content_dir( string $dir ): void { + if ( file_exists( $dir ) ) { + self::remove_temp_content_dir( $dir ); + } + + foreach ( array( $dir, $dir . '/plugins', $dir . '/mu-plugins', $dir . '/themes', $dir . '/languages' ) as $path ) { + if ( ! is_dir( $path ) && ! mkdir( $path, 0777, true ) && ! is_dir( $path ) ) { + throw new \RuntimeException( 'Could not create component fuzz content directory: ' . $path ); + } + } + } + + public static function remove_temp_content_dir( string $dir ): void { + $temp_prefix = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-wp-content-'; + if ( ! str_starts_with( $dir, $temp_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 ); + } +} diff --git a/tools/component-fuzz/lib/autoload.php b/tools/component-fuzz/lib/autoload.php new file mode 100644 index 0000000000000..53e86bf58e2f3 --- /dev/null +++ b/tools/component-fuzz/lib/autoload.php @@ -0,0 +1,11 @@ +component_fuzz_reset_options( $options ); + $this->component_fuzz_reset_content(); + } + + public function component_fuzz_reset_options( array $options = array() ) { + $this->component_fuzz_options = array(); + + foreach ( $options as $option => $entry ) { + if ( is_array( $entry ) && array_key_exists( 'option_value', $entry ) ) { + $value = $entry['option_value']; + $autoload = $entry['autoload'] ?? 'auto'; + } else { + $value = function_exists( 'maybe_serialize' ) ? maybe_serialize( $entry ) : $entry; + $autoload = 'auto'; + } + + $this->component_fuzz_options[ (string) $option ] = array( + 'option_value' => $value, + 'autoload' => (string) $autoload, + ); + } + } + + public function component_fuzz_get_options() { + return $this->component_fuzz_options; + } + + public function component_fuzz_get_queries() { + return $this->component_fuzz_queries; + } + + public function component_fuzz_get_runtime_state() { + return array( + 'last_query' => $this->last_query, + 'last_error' => $this->last_error, + 'rows_affected' => $this->rows_affected, + 'insert_id' => $this->insert_id, + 'num_rows' => $this->num_rows, + 'num_queries' => $this->num_queries, + 'queries' => $this->component_fuzz_queries, + 'last_found_rows' => $this->component_fuzz_last_found_rows, + 'next_ids' => $this->component_fuzz_next_ids, + ); + } + + public function component_fuzz_restore_runtime_state( array $state ) { + $this->last_query = (string) ( $state['last_query'] ?? '' ); + $this->last_error = (string) ( $state['last_error'] ?? '' ); + $this->rows_affected = (int) ( $state['rows_affected'] ?? 0 ); + $this->insert_id = (int) ( $state['insert_id'] ?? 0 ); + $this->num_rows = (int) ( $state['num_rows'] ?? 0 ); + $this->num_queries = (int) ( $state['num_queries'] ?? 0 ); + $this->component_fuzz_queries = is_array( $state['queries'] ?? null ) ? array_values( $state['queries'] ) : array(); + $this->component_fuzz_last_found_rows = (int) ( $state['last_found_rows'] ?? 0 ); + $this->component_fuzz_next_ids = is_array( $state['next_ids'] ?? null ) ? array_map( 'intval', $state['next_ids'] ) : $this->component_fuzz_next_ids; + } + + public function component_fuzz_reset_content() { + $this->component_fuzz_posts = array(); + $this->component_fuzz_terms = array(); + $this->component_fuzz_term_taxonomy_rows = array(); + $this->component_fuzz_term_relationship_rows = array(); + $this->component_fuzz_users = array(); + $this->component_fuzz_comments = array(); + $this->component_fuzz_links = array(); + $this->component_fuzz_signups = array(); + $this->component_fuzz_meta = array( + 'post' => array(), + 'term' => array(), + 'comment' => array(), + 'user' => array(), + ); + $this->component_fuzz_next_ids = array( + 'posts' => 1, + 'terms' => 1, + 'term_taxonomy' => 1, + 'users' => 1, + 'comments' => 1, + 'links' => 1, + 'signups' => 1, + 'post_meta' => 1, + 'term_meta' => 1, + 'comment_meta' => 1, + 'user_meta' => 1, + ); + $this->insert_id = 0; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->component_fuzz_last_found_rows = 0; + } + + public function component_fuzz_content_counts() { + return array( + 'posts' => count( $this->component_fuzz_posts ), + 'terms' => count( $this->component_fuzz_terms ), + 'term_taxonomy' => count( $this->component_fuzz_term_taxonomy_rows ), + 'term_relationships' => count( $this->component_fuzz_term_relationship_rows ), + 'users' => count( $this->component_fuzz_users ), + 'comments' => count( $this->component_fuzz_comments ), + 'links' => count( $this->component_fuzz_links ), + 'signups' => count( $this->component_fuzz_signups ), + 'post_meta' => count( $this->component_fuzz_meta['post'] ), + 'term_meta' => count( $this->component_fuzz_meta['term'] ), + 'comment_meta' => count( $this->component_fuzz_meta['comment'] ), + 'user_meta' => count( $this->component_fuzz_meta['user'] ), + ); + } + + public function _escape( $data ) { + if ( is_array( $data ) ) { + return array_map( array( $this, '_escape' ), $data ); + } + + return addslashes( (string) $data ); + } + + public function prepare( $query, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $index = 0; + return preg_replace_callback( + '/%[sdFfi]/', + function ( $match ) use ( $args, &$index ) { + if ( ! array_key_exists( $index, $args ) ) { + return $match[0]; + } + + $placeholder = $match[0]; + $arg = $args[ $index++ ]; + + if ( '%d' === $placeholder ) { + return (string) (int) $arg; + } + + if ( '%f' === $placeholder || '%F' === $placeholder ) { + return (string) (float) $arg; + } + + if ( '%i' === $placeholder ) { + return preg_replace( '/[^A-Za-z0-9_$\.]/', '', (string) $arg ); + } + + return "'" . $this->_escape( $arg ) . "'"; + }, + $query + ); + } + + public function esc_like( $text ) { + return addcslashes( (string) $text, '_%\\' ); + } + + public function placeholder_escape() { + static $placeholder; + + if ( ! $placeholder ) { + $placeholder = '{component-fuzz-placeholder-escape}'; + } + + if ( function_exists( 'has_filter' ) && function_exists( 'add_filter' ) && false === has_filter( 'query', array( $this, 'remove_placeholder_escape' ) ) ) { + add_filter( 'query', array( $this, 'remove_placeholder_escape' ), 0 ); + } + + return $placeholder; + } + + public function add_placeholder_escape( $query ) { + return str_replace( '%', $this->placeholder_escape(), (string) $query ); + } + + public function remove_placeholder_escape( $query ) { + return str_replace( $this->placeholder_escape(), '%', (string) $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 ) { + unset( $x, $y ); + + $this->component_fuzz_record_query( $query ); + + if ( preg_match( '/\bSELECT\s+COUNT\s*\(\s*\*\s*\)/i', $this->last_query ) ) { + return $this->component_fuzz_count_for_query( $this->last_query ); + } + + if ( preg_match( '/^\s*SELECT\s+FOUND_ROWS\s*\(\s*\)/i', $this->last_query ) ) { + return $this->component_fuzz_last_found_rows; + } + + if ( preg_match( '/\bSELECT\s+MAX\(term_group\)\s+FROM\s+`?wp_terms`?/i', $this->last_query ) ) { + $groups = array_column( $this->component_fuzz_terms, 'term_group' ); + return $groups ? max( array_map( 'intval', $groups ) ) : 0; + } + + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + if ( array() === $rows ) { + return null; + } + + $row = reset( $rows ); + return reset( $row ); + } + + public function get_row( $query = null, $output = OBJECT, $y = 0 ) { + unset( $y ); + + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + + if ( array() === $rows ) { + return null; + } + + return $this->component_fuzz_format_row( reset( $rows ), $output ); + } + + public function get_results( $query = null, $output = OBJECT ) { + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + + return $this->component_fuzz_format_results( $rows, $output ); + } + + public function get_col( $query = null, $x = 0 ) { + $this->component_fuzz_record_query( $query ); + $rows = $this->component_fuzz_select_rows( $this->last_query ); + $this->num_rows = count( $rows ); + $values = array(); + + foreach ( $rows as $row ) { + $row_values = array_values( $row ); + if ( array_key_exists( $x, $row_values ) ) { + $values[] = $row_values[ $x ]; + } + } + + return $values; + } + + public function query( $query ) { + $this->component_fuzz_record_query( $query ); + $this->rows_affected = 0; + + if ( preg_match( '/\bINSERT\s+INTO\s+`?wp_options`?\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_insert_option( $this->last_query ); + } + + if ( preg_match( '/\bUPDATE\s+`?wp_options`?\s+SET\s+`?autoload`?\s*=/i', $this->last_query ) ) { + return $this->component_fuzz_query_update_option_autoload( $this->last_query ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_(post|term|comment)meta`?\s+WHERE\s+`?meta_id`?\s+IN\s*\(([^)]*)\)/i', $this->last_query, $matches ) ) { + return $this->component_fuzz_delete_meta_ids( $matches[1], $this->component_fuzz_csv_int_values( $matches[2] ) ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_usermeta`?\s+WHERE\s+`?umeta_id`?\s+IN\s*\(([^)]*)\)/i', $this->last_query, $matches ) ) { + return $this->component_fuzz_delete_meta_ids( 'user', $this->component_fuzz_csv_int_values( $matches[1] ) ); + } + + if ( preg_match( '/\bDELETE\s+FROM\s+`?wp_term_relationships`?\s+WHERE\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_delete_term_relationships( $this->last_query ); + } + + if ( preg_match( '/\bINSERT\s+INTO\s+`?wp_term_relationships`?\b/i', $this->last_query ) ) { + return $this->component_fuzz_query_insert_term_relationships( $this->last_query ); + } + + if ( preg_match( '/\bUPDATE\s+`?wp_comments`?\s+SET\s+comment_approved\s*=/i', $this->last_query ) ) { + return $this->component_fuzz_query_update_comment_statuses( $this->last_query ); + } + + return 0; + } + + public function insert( $table, $data, $format = null ) { + unset( $format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $data = is_array( $data ) ? $data : array(); + + if ( 'options' === $table_key ) { + if ( ! isset( $data['option_name'] ) ) { + return false; + } + + $option = (string) $data['option_name']; + if ( isset( $this->component_fuzz_options[ $option ] ) ) { + return false; + } + + $this->component_fuzz_options[ $option ] = array( + 'option_value' => (string) ( $data['option_value'] ?? '' ), + 'autoload' => (string) ( $data['autoload'] ?? 'auto' ), + ); + $this->insert_id = $this->component_fuzz_next_id( 'options' ); + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'ID', 'posts' ); + $row = array_merge( $this->component_fuzz_post_defaults(), $data, array( 'ID' => $id ) ); + $this->component_fuzz_posts[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'terms' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'term_id', 'terms' ); + $row = array_merge( $this->component_fuzz_term_defaults(), $data, array( 'term_id' => $id ) ); + $this->component_fuzz_terms[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'term_taxonomy' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'term_taxonomy_id', 'term_taxonomy' ); + $row = array_merge( $this->component_fuzz_term_taxonomy_defaults(), $data, array( 'term_taxonomy_id' => $id ) ); + $this->component_fuzz_term_taxonomy_rows[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'term_relationships' === $table_key ) { + if ( ! isset( $data['object_id'], $data['term_taxonomy_id'] ) ) { + return false; + } + + $key = (int) $data['object_id'] . ':' . (int) $data['term_taxonomy_id']; + if ( isset( $this->component_fuzz_term_relationship_rows[ $key ] ) ) { + return false; + } + + $this->component_fuzz_term_relationship_rows[ $key ] = array( + 'object_id' => (int) $data['object_id'], + 'term_taxonomy_id' => (int) $data['term_taxonomy_id'], + 'term_order' => (int) ( $data['term_order'] ?? 0 ), + ); + $this->rows_affected = 1; + return 1; + } + + if ( 'users' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'ID', 'users' ); + $row = array_merge( $this->component_fuzz_user_defaults(), $data, array( 'ID' => $id ) ); + $this->component_fuzz_users[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'comments' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'comment_ID', 'comments' ); + $row = array_merge( $this->component_fuzz_comment_defaults(), $data, array( 'comment_ID' => $id ) ); + $row['comment_approved'] = (string) $row['comment_approved']; + $this->component_fuzz_comments[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'links' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'link_id', 'links' ); + $row = array_merge( $this->component_fuzz_link_defaults(), $data, array( 'link_id' => $id ) ); + $this->component_fuzz_links[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + if ( 'signups' === $table_key ) { + $id = $this->component_fuzz_row_id( $data, 'signup_id', 'signups' ); + $row = array_merge( $this->component_fuzz_signup_defaults(), $data, array( 'signup_id' => $id ) ); + $this->component_fuzz_signups[ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + $id_column = 'user' === $meta_type ? 'umeta_id' : 'meta_id'; + $object_key = $this->component_fuzz_meta_object_column( $meta_type ); + $id = $this->component_fuzz_row_id( $data, $id_column, $meta_type . '_meta' ); + $row = array_merge( + array( + $id_column => $id, + $object_key => 0, + 'meta_key' => '', + 'meta_value' => '', + ), + $data, + array( $id_column => $id ) + ); + + $this->component_fuzz_meta[ $meta_type ][ $id ] = $row; + return $this->component_fuzz_finish_insert( $id ); + } + + return false; + } + + public function update( $table, $data, $where, $format = null, $where_format = null ) { + unset( $format, $where_format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $data = is_array( $data ) ? $data : array(); + $where = is_array( $where ) ? $where : array(); + + if ( 'options' === $table_key ) { + $option = isset( $where['option_name'] ) ? (string) $where['option_name'] : null; + + if ( null === $option || ! isset( $this->component_fuzz_options[ $option ] ) ) { + return 0; + } + + foreach ( array( 'option_value', 'autoload' ) as $column ) { + if ( array_key_exists( $column, $data ) ) { + $this->component_fuzz_options[ $option ][ $column ] = (string) $data[ $column ]; + } + } + + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_posts, $data, $where ); + } + + if ( 'terms' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_terms, $data, $where ); + } + + if ( 'term_taxonomy' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_term_taxonomy_rows, $data, $where ); + } + + if ( 'users' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_users, $data, $where ); + } + + if ( 'comments' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_comments, $data, $where ); + } + + if ( 'links' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_links, $data, $where ); + } + + if ( 'signups' === $table_key ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_signups, $data, $where ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return $this->component_fuzz_update_rows( $this->component_fuzz_meta[ $meta_type ], $data, $where ); + } + + return false; + } + + public function delete( $table, $where, $where_format = null ) { + unset( $where_format ); + + $this->rows_affected = 0; + $table_key = $this->component_fuzz_table_key( $table ); + $where = is_array( $where ) ? $where : array(); + + if ( 'options' === $table_key ) { + $option = isset( $where['option_name'] ) ? (string) $where['option_name'] : null; + + if ( null === $option || ! isset( $this->component_fuzz_options[ $option ] ) ) { + return 0; + } + + unset( $this->component_fuzz_options[ $option ] ); + $this->rows_affected = 1; + return 1; + } + + if ( 'posts' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_posts, $where ); + } + + if ( 'terms' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_terms, $where ); + } + + if ( 'term_taxonomy' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_term_taxonomy_rows, $where ); + } + + if ( 'users' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_users, $where ); + } + + if ( 'comments' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_comments, $where ); + } + + if ( 'links' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_links, $where ); + } + + if ( 'signups' === $table_key ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_signups, $where ); + } + + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return $this->component_fuzz_delete_rows( $this->component_fuzz_meta[ $meta_type ], $where ); + } + + return false; + } + + public function get_blog_prefix( $blog_id = null ) { + return 'wp_'; + } + + public function get_col_charset( $table, $column ) { + unset( $table, $column ); + return 'utf8mb4'; + } + + public function get_col_length( $table, $column ) { + $lengths = array( + 'comment_author' => 245, + 'comment_author_email' => 100, + 'comment_author_url' => 200, + 'comment_content' => 65525, + 'user_login' => 60, + 'user_nicename' => 50, + 'user_email' => 100, + 'user_url' => 100, + 'post_title' => 65535, + 'post_name' => 200, + ); + + unset( $table ); + return $lengths[ $column ] ?? 255; + } + + public function strip_invalid_text_for_column( $table, $column, $value ) { + unset( $table, $column ); + return (string) $value; + } + + private function component_fuzz_query_insert_option( $query ) { + $values = $this->component_fuzz_quoted_values( $query ); + if ( count( $values ) < 3 ) { + return false; + } + + $option = (string) $values[0]; + $exists = isset( $this->component_fuzz_options[ $option ] ); + $autoload = (string) $values[2]; + + $this->component_fuzz_options[ $option ] = array( + 'option_value' => $values[1], + 'autoload' => $autoload, + ); + $this->rows_affected = $exists ? 2 : 1; + $this->insert_id++; + + return $this->rows_affected; + } + + private function component_fuzz_query_update_option_autoload( $query ) { + $autoload = $this->component_fuzz_compare_value( $query, 'autoload' ); + $names = $this->component_fuzz_in_values( $query, 'option_name' ); + + if ( null === $autoload || array() === $names ) { + return 0; + } + + foreach ( $names as $option ) { + $option = (string) $option; + if ( ! isset( $this->component_fuzz_options[ $option ] ) ) { + continue; + } + + if ( (string) $autoload === $this->component_fuzz_options[ $option ]['autoload'] ) { + continue; + } + + $this->component_fuzz_options[ $option ]['autoload'] = (string) $autoload; + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_record_query( $query ) { + $this->last_query = (string) $query; + $this->component_fuzz_queries[] = $this->last_query; + ++$this->num_queries; + + return $this->last_query; + } + + private function component_fuzz_finish_insert( $id ) { + $this->insert_id = (int) $id; + $this->rows_affected = 1; + return 1; + } + + private function component_fuzz_row_id( array $data, $column, $bucket ) { + if ( isset( $data[ $column ] ) && (int) $data[ $column ] > 0 ) { + $id = (int) $data[ $column ]; + $this->component_fuzz_bump_next_id( $bucket, $id ); + return $id; + } + + return $this->component_fuzz_next_id( $bucket ); + } + + private function component_fuzz_next_id( $bucket ) { + if ( ! isset( $this->component_fuzz_next_ids[ $bucket ] ) ) { + $this->component_fuzz_next_ids[ $bucket ] = 1; + } + + return $this->component_fuzz_next_ids[ $bucket ]++; + } + + private function component_fuzz_bump_next_id( $bucket, $id ) { + if ( ! isset( $this->component_fuzz_next_ids[ $bucket ] ) || $this->component_fuzz_next_ids[ $bucket ] <= $id ) { + $this->component_fuzz_next_ids[ $bucket ] = $id + 1; + } + } + + private function component_fuzz_update_rows( array &$rows, array $data, array $where ) { + foreach ( $rows as &$row ) { + if ( ! $this->component_fuzz_row_matches_where( $row, $where ) ) { + continue; + } + + foreach ( $data as $column => $value ) { + $row[ $column ] = $value; + } + ++$this->rows_affected; + } + unset( $row ); + + return $this->rows_affected; + } + + private function component_fuzz_delete_rows( array &$rows, array $where ) { + foreach ( $rows as $id => $row ) { + if ( $this->component_fuzz_row_matches_where( $row, $where ) ) { + unset( $rows[ $id ] ); + ++$this->rows_affected; + } + } + + return $this->rows_affected; + } + + private function component_fuzz_row_matches_where( array $row, array $where ) { + foreach ( $where as $column => $value ) { + if ( ! array_key_exists( $column, $row ) || (string) $row[ $column ] !== (string) $value ) { + return false; + } + } + + return true; + } + + private function component_fuzz_select_rows( $query ) { + $first_table = $this->component_fuzz_query_first_from_table( $query ); + $old_slug_rows = $this->component_fuzz_select_old_slug_redirect_rows( $query ); + if ( null !== $old_slug_rows ) { + return $old_slug_rows; + } + + if ( '' === $first_table && preg_match( '/\bAS\s+filtered_posts\b/i', (string) $query ) && preg_match( '/\bFROM\s+`?wp_posts`?\b/i', (string) $query ) ) { + return $this->component_fuzz_select_posts( $query ); + } + + if ( 'wp_options' === $first_table ) { + return $this->component_fuzz_select_options( $query ); + } + + if ( 'wp_posts' === $first_table ) { + return $this->component_fuzz_select_posts( $query ); + } + + if ( 'wp_users' === $first_table ) { + return $this->component_fuzz_select_users( $query ); + } + + if ( 'wp_comments' === $first_table ) { + return $this->component_fuzz_select_comments( $query ); + } + + if ( 'wp_links' === $first_table ) { + return $this->component_fuzz_select_links( $query ); + } + + if ( 'wp_signups' === $first_table ) { + return $this->component_fuzz_select_signups( $query ); + } + + if ( 'wp_term_relationships' === $first_table ) { + return $this->component_fuzz_select_term_relationships( $query ); + } + + if ( 'wp_terms' === $first_table || 'wp_term_taxonomy' === $first_table ) { + return $this->component_fuzz_select_terms( $query ); + } + + $table_key = $this->component_fuzz_table_key_from_query( $query ); + if ( null !== $this->component_fuzz_meta_type_for_table_key( $table_key ) ) { + return $this->component_fuzz_select_meta( $query, $this->component_fuzz_meta_type_for_table_key( $table_key ) ); + } + + return array(); + } + + private function component_fuzz_query_first_from_table( $query ) { + if ( ! preg_match( '/^\s*SELECT\b.*?\bFROM\s+`?([A-Za-z0-9_]+)`?\b/is', (string) $query, $matches ) ) { + return ''; + } + + return strtolower( $matches[1] ); + } + + private function component_fuzz_select_options( $query ) { + $rows = array(); + + if ( preg_match_all( '/\bautoload\s*!=\s*(\'{1,2}(?:\\\\.|[^\'\\\\])*\'{1,2}|"[^"]*"|-?\d+)\s+AND\s+`?option_name`?\s+IN\s*\(([^)]*)\)/i', (string) $query, $matches, PREG_SET_ORDER ) ) { + $seen = array(); + foreach ( $matches as $match ) { + $excluded_autoload = (string) $this->component_fuzz_unquote_sql_value( $match[1] ); + $names = array_fill_keys( $this->component_fuzz_csv_values( $match[2] ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $names[ $option ] ) && $excluded_autoload !== (string) $entry['autoload'] && ! isset( $seen[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + $seen[ $option ] = true; + } + } + } + } elseif ( preg_match( '/\boption_name\s+IN\s*\(/i', $query ) ) { + $names = array_fill_keys( $this->component_fuzz_in_values( $query, 'option_name' ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $names[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + } + } + } elseif ( preg_match( '/\bautoload\s+IN\s*\(/i', $query ) ) { + $autoload_values = array_fill_keys( $this->component_fuzz_in_values( $query, 'autoload' ), true ); + foreach ( $this->component_fuzz_options as $option => $entry ) { + if ( isset( $autoload_values[ $entry['autoload'] ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $entry ); + } + } + } else { + $option = $this->component_fuzz_compare_value( $query, 'option_name' ); + if ( null !== $option && isset( $this->component_fuzz_options[ $option ] ) ) { + $rows[] = $this->component_fuzz_option_row( $option, $this->component_fuzz_options[ $option ] ); + } elseif ( null === $option ) { + foreach ( $this->component_fuzz_options as $option_name => $entry ) { + $rows[] = $this->component_fuzz_option_row( $option_name, $entry ); + } + } + } + + if ( preg_match( '/SELECT\s+autoload\b/i', $query ) ) { + return array_map( + static function ( $row ) { + return array( 'autoload' => $row['autoload'] ); + }, + $rows + ); + } + + if ( preg_match( '/SELECT\s+option_value\b/i', $query ) ) { + return array_map( + static function ( $row ) { + return array( 'option_value' => $row['option_value'] ); + }, + $rows + ); + } + + return $rows; + } + + private function component_fuzz_option_row( $option, array $entry ) { + return array( + 'option_name' => $option, + 'option_value' => $entry['option_value'], + 'autoload' => $entry['autoload'], + ); + } + + private function component_fuzz_select_posts( $query, $project = true ) { + $rows = array_values( $this->component_fuzz_posts ); + + if ( $project ) { + $readable_count_rows = $this->component_fuzz_select_readable_post_count_rows( $query, $rows ); + if ( null !== $readable_count_rows ) { + return $readable_count_rows; + } + } + + $id = $this->component_fuzz_compare_value( $query, 'ID' ); + if ( null !== $id ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $id ) { + return (int) $row['ID'] === (int) $id; + } + ); + } + + $ids = $this->component_fuzz_in_values( $query, 'ID' ); + if ( array() !== $ids ) { + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_map ) { + return isset( $id_map[ (int) $row['ID'] ] ); + } + ); + } + + $id_not = $this->component_fuzz_not_compare_value( $query, 'ID' ); + if ( null !== $id_not ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_not ) { + return (int) $row['ID'] !== (int) $id_not; + } + ); + } + + $status_or_rows = $this->component_fuzz_filter_posts_by_status_or_branches( $query, array_values( $rows ) ); + $status_or_handled = null !== $status_or_rows; + if ( $status_or_handled ) { + $rows = $status_or_rows; + } + + $author_filter_sql = $status_or_handled ? $this->component_fuzz_top_level_author_filter_sql( $query ) : $query; + $rows = $this->component_fuzz_filter_posts_by_author_constraints( $author_filter_sql, $rows ); + + foreach ( array( 'post_name', 'post_title', 'post_type', 'post_parent', 'post_status', 'post_password' ) as $column ) { + if ( $status_or_handled && 'post_status' === $column ) { + continue; + } + + if ( 'post_password' === $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + $conjunctive = true; + } elseif ( 'post_status' === $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + $conjunctive = false; + } else { + $values = array_filter( + array( $this->component_fuzz_compare_value( $query, $column ) ), + static function ( $value ) { + return null !== $value; + } + ); + $conjunctive = true; + } + if ( array() === $values ) { + continue; + } + if ( ! $conjunctive ) { + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + continue; + } + foreach ( $values as $value ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + } + + foreach ( array( 'post_status', 'post_mime_type' ) as $column ) { + $value = $this->component_fuzz_not_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] !== (string) $value; + } + ); + } + + foreach ( array( 'post_name', 'post_parent', 'post_status' ) as $column ) { + if ( $status_or_handled && 'post_status' === $column ) { + continue; + } + + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + if ( array() !== $post_types ) { + $type_map = array_fill_keys( $post_types, true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $type_map ) { + return isset( $type_map[ (string) $row['post_type'] ] ); + } + ); + } + + $rows = $this->component_fuzz_filter_posts_by_mime_constraints( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_filter_posts_by_search_like( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_filter_posts_by_datetime_bounds( $query, array_values( $rows ) ); + + $rows = $this->component_fuzz_sort_post_rows( $query, array_values( $rows ) ); + + if ( preg_match( '/\bSQL_CALC_FOUND_ROWS\b/i', $query ) ) { + $this->component_fuzz_last_found_rows = count( $rows ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( ! $project ) { + return array_values( $rows ); + } + + if ( preg_match( '/SELECT\s+COUNT\s*\(\s*\*\s*\)/i', $query ) ) { + return array( array( 'COUNT(*)' => count( $rows ) ) ); + } + + if ( preg_match( '/SELECT\s+post_status\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/i', $query ) && preg_match( '/\bGROUP\s+BY\s+post_status\b/i', $query ) ) { + return $this->component_fuzz_group_count_rows( $rows, 'post_status' ); + } + + if ( preg_match( '/SELECT\s+post_mime_type\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/i', $query ) && preg_match( '/\bGROUP\s+BY\s+post_mime_type\b/i', $query ) ) { + return $this->component_fuzz_group_count_rows( $rows, 'post_mime_type' ); + } + + if ( preg_match( '/SELECT\s+DISTINCT\s+post_mime_type\b/i', $query ) ) { + $rows = $this->component_fuzz_distinct_rows( array_values( $rows ), array( 'post_mime_type' ) ); + return $this->component_fuzz_project_rows( $rows, array( 'post_mime_type' ) ); + } + + if ( preg_match( '/SELECT\s+DISTINCT\s+(?:`?[a-z_]+`?\.)?`?post_author`?\b/i', $query ) ) { + $rows = $this->component_fuzz_distinct_rows( array_values( $rows ), array( 'post_author' ) ); + return $this->component_fuzz_project_rows( $rows, array( 'post_author' ) ); + } + + if ( preg_match( '/SELECT\s+post_name\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'post_name' ) ); + } + + if ( preg_match( '/SELECT\s+post_author\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'post_author' ) ); + } + + if ( preg_match( '/SELECT\s+ID\s*,\s*post_name\s*,\s*post_parent\s*,\s*post_type\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID', 'post_name', 'post_parent', 'post_type' ) ); + } + + if ( preg_match( '/SELECT\s+ID\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID' ) ); + } + + return $rows; + } + + private function component_fuzz_select_readable_post_count_rows( $query, array $rows ) { + if ( + ! preg_match( '/SELECT\s+post_status\s*,\s*COUNT\s*\(\s*\*\s*\)\s+AS\s+num_posts\b/is', $query ) + || ! preg_match( '/\bUNION\s+ALL\b/is', $query ) + || ! preg_match( '/\bAS\s+filtered_posts\b/is', $query ) + || ! preg_match( '/\bGROUP\s+BY\s+post_status\b/is', $query ) + ) { + return null; + } + + $post_types = $this->component_fuzz_compare_values( $query, 'post_type' ); + $author_id = $this->component_fuzz_compare_value( $query, 'post_author' ); + if ( array() === $post_types || null === $author_id ) { + return null; + } + + $post_type = (string) reset( $post_types ); + $filtered = array_filter( + $rows, + static function ( $row ) use ( $post_type, $author_id ) { + if ( (string) $row['post_type'] !== $post_type ) { + return false; + } + + if ( 'private' !== (string) $row['post_status'] ) { + return true; + } + + return (int) $row['post_author'] === (int) $author_id; + } + ); + + return $this->component_fuzz_group_count_rows( $filtered, 'post_status' ); + } + + private function component_fuzz_group_count_rows( array $rows, $column ) { + $counts = array(); + + foreach ( $rows as $row ) { + $value = (string) ( $row[ $column ] ?? '' ); + if ( ! isset( $counts[ $value ] ) ) { + $counts[ $value ] = 0; + } + ++$counts[ $value ]; + } + + $count_rows = array(); + foreach ( $counts as $value => $count ) { + $count_rows[] = array( + $column => $value, + 'num_posts' => $count, + ); + } + + return $count_rows; + } + + private function component_fuzz_select_old_slug_redirect_rows( $query ) { + if ( + ! preg_match( '/\bwp_postmeta\b/i', (string) $query ) + || ! preg_match( '/\bwp_posts\b/i', (string) $query ) + || ! preg_match( '/\b_wp_old_(?:slug|date)\b/', (string) $query ) + ) { + return null; + } + + $meta_keys = $this->component_fuzz_compare_values( $query, 'meta_key' ); + $has_slug = in_array( '_wp_old_slug', $meta_keys, true ); + $has_date = in_array( '_wp_old_date', $meta_keys, true ); + + if ( $has_slug && ! $has_date ) { + return $this->component_fuzz_select_old_slug_redirect_by_slug( $query ); + } + + if ( $has_date && ! $has_slug ) { + return $this->component_fuzz_select_old_slug_redirect_by_date( $query ); + } + + if ( $has_slug && $has_date ) { + return $this->component_fuzz_select_old_slug_redirect_by_slug_and_date( $query ); + } + + return null; + } + + private function component_fuzz_select_old_slug_redirect_by_slug( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $old_slug = $this->component_fuzz_compare_value( $query, 'meta_value' ); + if ( null === $post_type || null === $old_slug ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_meta['post'] as $meta_row ) { + if ( '_wp_old_slug' !== (string) $meta_row['meta_key'] || (string) $old_slug !== (string) $meta_row['meta_value'] ) { + continue; + } + + $post_id = (int) $meta_row['post_id']; + $post = $this->component_fuzz_posts[ $post_id ] ?? null; + if ( ! is_array( $post ) || (string) $post_type !== (string) $post['post_type'] ) { + continue; + } + + if ( ! $this->component_fuzz_date_parts_match( (string) $post['post_date'], $query, 'post_date' ) ) { + continue; + } + + $rows[] = array( 'post_id' => $post_id ); + } + + return $rows; + } + + private function component_fuzz_select_old_slug_redirect_by_date( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $post_name = $this->component_fuzz_compare_value( $query, 'post_name' ); + if ( null === $post_type || null === $post_name ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_meta['post'] as $meta_row ) { + if ( '_wp_old_date' !== (string) $meta_row['meta_key'] ) { + continue; + } + + $post_id = (int) $meta_row['post_id']; + $post = $this->component_fuzz_posts[ $post_id ] ?? null; + if ( + ! is_array( $post ) + || (string) $post_type !== (string) $post['post_type'] + || (string) $post_name !== (string) $post['post_name'] + ) { + continue; + } + + if ( ! $this->component_fuzz_date_parts_match( (string) $meta_row['meta_value'], $query, 'meta_value' ) ) { + continue; + } + + $rows[] = array( 'post_id' => $post_id ); + } + + return $rows; + } + + private function component_fuzz_select_old_slug_redirect_by_slug_and_date( $query ) { + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $old_slug = $this->component_fuzz_compare_value( $query, 'meta_value' ); + if ( null === $post_type || null === $old_slug ) { + return array(); + } + + $rows = array(); + foreach ( $this->component_fuzz_posts as $post_id => $post ) { + if ( (string) $post_type !== (string) $post['post_type'] ) { + continue; + } + + if ( ! in_array( (string) $old_slug, $this->component_fuzz_post_meta_values( $post_id, '_wp_old_slug' ), true ) ) { + continue; + } + + foreach ( $this->component_fuzz_post_meta_values( $post_id, '_wp_old_date' ) as $old_date ) { + if ( $this->component_fuzz_date_parts_match( (string) $old_date, $query, 'meta_value' ) ) { + $rows[] = array( 'ID' => (int) $post_id ); + continue 2; + } + } + } + + return $rows; + } + + private function component_fuzz_date_parts_match( $datetime, $query, $column ) { + foreach ( + array( + 'YEAR' => array( 0, 4 ), + 'MONTH' => array( 5, 2 ), + 'DAYOFMONTH' => array( 8, 2 ), + ) as $function => $slice + ) { + $expected = $this->component_fuzz_sql_date_part_value( $query, $function, $column ); + if ( null === $expected ) { + continue; + } + + if ( (int) $expected !== (int) substr( (string) $datetime, $slice[0], $slice[1] ) ) { + return false; + } + } + + return true; + } + + private function component_fuzz_sql_date_part_value( $query, $function, $column ) { + if ( + ! preg_match( + '/\b' . preg_quote( $function, '/' ) . '\s*\(\s*(?:`?[a-z_][a-z0-9_]*`?\.)?`?' . preg_quote( $column, '/' ) . '`?\s*\)\s*=\s*(\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*"|-?\d+)/i', + (string) $query, + $matches + ) + ) { + return null; + } + + return $this->component_fuzz_unquote_sql_value( $matches[1] ); + } + + private function component_fuzz_filter_posts_by_datetime_bounds( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + return $rows; + } + + foreach ( array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ) as $column ) { + if ( ! preg_match_all( '/(?=|<|>)\s*(\'(?:\\\\.|[^\'\\\\])*\'|"[^"]*")/i', $where, $matches, PREG_SET_ORDER ) ) { + continue; + } + + foreach ( $matches as $match ) { + $operator = $match[1]; + $bound = $this->component_fuzz_unquote_sql_value( $match[2] ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $operator, $bound ) { + $value = (string) ( $row[ $column ] ?? '' ); + $comparison = strcmp( $value, $bound ); + + switch ( $operator ) { + case '<': + return $comparison < 0; + case '<=': + return $comparison <= 0; + case '>': + return $comparison > 0; + case '>=': + return $comparison >= 0; + } + + return true; + } + ); + } + } + + return array_values( $rows ); + } + + private function component_fuzz_filter_posts_by_status_or_branches( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where || ! preg_match( '/\bOR\b/i', $where ) || ! preg_match( '/post_status/i', $where ) ) { + return null; + } + + $status_branches = array(); + foreach ( $this->component_fuzz_split_sql_or_terms( $where ) as $branch ) { + if ( ! preg_match( '/post_status/i', $branch ) ) { + continue; + } + + $statuses = array_values( + array_unique( + array_merge( + $this->component_fuzz_compare_values( $branch, 'post_status' ), + $this->component_fuzz_in_values( $branch, 'post_status' ) + ) + ) + ); + + if ( array() === $statuses ) { + continue; + } + + $status_branches[] = array( + 'author_equals' => $this->component_fuzz_compare_values( $branch, 'post_author' ), + 'author_in' => $this->component_fuzz_in_values( $branch, 'post_author' ), + 'statuses' => $statuses, + ); + } + + if ( array() === $status_branches ) { + return null; + } + + return array_filter( + $rows, + static function ( $row ) use ( $status_branches ) { + foreach ( $status_branches as $branch ) { + if ( ! in_array( (string) $row['post_status'], array_map( 'strval', $branch['statuses'] ), true ) ) { + continue; + } + + foreach ( $branch['author_equals'] as $author ) { + if ( (int) $row['post_author'] !== (int) $author ) { + continue 2; + } + } + + if ( array() !== $branch['author_in'] ) { + $author_map = array_fill_keys( array_map( 'intval', $branch['author_in'] ), true ); + if ( ! isset( $author_map[ (int) $row['post_author'] ] ) ) { + continue; + } + } + + return true; + } + + return false; + } + ); + } + + private function component_fuzz_filter_posts_by_author_constraints( $query, array $rows ) { + foreach ( $this->component_fuzz_compare_values( $query, 'post_author' ) as $author ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $author ) { + return (int) $row['post_author'] === (int) $author; + } + ); + } + + $authors = $this->component_fuzz_in_values( $query, 'post_author' ); + if ( array() === $authors ) { + return $rows; + } + + $author_map = array_fill_keys( array_map( 'intval', $authors ), true ); + return array_filter( + $rows, + static function ( $row ) use ( $author_map ) { + return isset( $author_map[ (int) $row['post_author'] ] ); + } + ); + } + + private function component_fuzz_top_level_author_filter_sql( $query ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + $where = (string) $query; + } + + $terms = array(); + foreach ( $this->component_fuzz_split_sql_top_level_terms( $where, 'AND' ) as $term ) { + if ( ! preg_match( '/post_author/i', $term ) ) { + continue; + } + + if ( preg_match( '/\bOR\b/i', $term ) ) { + continue; + } + + $terms[] = $term; + } + + return implode( ' AND ', $terms ); + } + + private function component_fuzz_filter_posts_by_mime_constraints( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where || ! preg_match( '/post_mime_type/i', $where ) ) { + return $rows; + } + + $exact_values = $this->component_fuzz_compare_values( $where, 'post_mime_type' ); + $like_values = array(); + if ( preg_match_all( '/(?component_fuzz_sql_like_match( $mime_type, (string) $pattern ) ) { + return true; + } + } + + return false; + } + ); + } + + private function component_fuzz_filter_posts_by_search_like( $query, array $rows ) { + $where = $this->component_fuzz_where_clause( $query ); + if ( '' === $where ) { + return $rows; + } + + $patterns = array(); + if ( preg_match_all( '/(?component_fuzz_unquote_sql_value( $match[3] ); + if ( ! isset( $patterns[ $key ] ) ) { + $patterns[ $key ] = array( + 'operator' => strtoupper( preg_replace( '/\s+/', ' ', $match[2] ) ), + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[3] ), + 'columns' => array(), + ); + } + $patterns[ $key ]['columns'][ $match[1] ] = true; + } + } + + if ( preg_match_all( '/(?component_fuzz_unquote_sql_value( $match[2] ); + if ( ! isset( $patterns[ $key ] ) ) { + $patterns[ $key ] = array( + 'operator' => strtoupper( preg_replace( '/\s+/', ' ', $match[1] ) ), + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[2] ), + 'columns' => array(), + ); + } + $patterns[ $key ]['columns']['sq1.meta_value'] = true; + } + } + + if ( array() === $patterns ) { + return $rows; + } + + return array_filter( + $rows, + function ( $row ) use ( $patterns ) { + foreach ( $patterns as $pattern ) { + $matched = false; + $missing_meta = false; + foreach ( array_keys( $pattern['columns'] ) as $column ) { + $values = 'sq1.meta_value' === $column + ? $this->component_fuzz_post_meta_values( (int) $row['ID'], '_wp_attached_file' ) + : array( (string) ( $row[ $column ] ?? '' ) ); + + if ( 'sq1.meta_value' === $column && array() === $values ) { + $missing_meta = true; + } + + foreach ( $values as $value ) { + if ( $this->component_fuzz_sql_like_match( (string) $value, (string) $pattern['pattern'] ) ) { + $matched = true; + break 2; + } + } + } + + if ( 'LIKE' === $pattern['operator'] && ! $matched ) { + return false; + } + + if ( 'NOT LIKE' === $pattern['operator'] && ( $matched || $missing_meta ) ) { + return false; + } + } + + return true; + } + ); + } + + private function component_fuzz_sort_post_rows( $query, array $rows ) { + usort( + $rows, + function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+FIELD\s*\(\s*(?:`?wp_posts`?\.)?`?ID`?\s*,\s*([^)]+)\)/i', $query, $matches ) ) { + $ordered_ids = array_values( array_unique( array_map( 'intval', $this->component_fuzz_csv_values( $matches[1] ) ) ) ); + $positions = array_flip( $ordered_ids ); + $a_position = $positions[ (int) $a['ID'] ] ?? PHP_INT_MAX; + $b_position = $positions[ (int) $b['ID'] ] ?? PHP_INT_MAX; + + if ( $a_position !== $b_position ) { + return $a_position <=> $b_position; + } + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_posts`?\.)?`?post_date`?\s+DESC/i', $query ) ) { + $comparison = strcmp( (string) $b['post_date'], (string) $a['post_date'] ); + if ( 0 !== $comparison ) { + return $comparison; + } + return (int) $b['ID'] <=> (int) $a['ID']; + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_posts`?\.)?`?ID`?\s+DESC/i', $query ) ) { + return (int) $b['ID'] <=> (int) $a['ID']; + } + + return (int) $a['ID'] <=> (int) $b['ID']; + } + ); + + return $rows; + } + + private function component_fuzz_select_users( $query ) { + $rows = array_values( $this->component_fuzz_users ); + $rows = $this->component_fuzz_filter_users_by_published_posts_subquery( $query, $rows ); + + foreach ( array( 'ID', 'user_login', 'user_nicename', 'user_email' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + if ( 'user_email' === $column ) { + return 0 === strcasecmp( (string) $row[ $column ], (string) $value ); + } + return (string) $row[ $column ] === (string) $value; + } + ); + } + + $not_login = $this->component_fuzz_not_compare_value( $query, 'user_login' ); + if ( null !== $not_login ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_login ) { + return (string) $row['user_login'] !== (string) $not_login; + } + ); + } + + $rows = $this->component_fuzz_filter_users_by_search_like( $query, $rows ); + + $rows = $this->component_fuzz_sort_user_rows( $query, array_values( $rows ) ); + + if ( preg_match( '/\bSQL_CALC_FOUND_ROWS\b/i', $query ) ) { + $this->component_fuzz_last_found_rows = count( $rows ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+(?:SQL_CALC_FOUND_ROWS\s+)?(?:`?wp_users`?\.)?`?ID`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'ID' ) ); + } + + return $rows; + } + + private function component_fuzz_select_signups( $query ) { + $rows = array_values( $this->component_fuzz_signups ); + + foreach ( array( 'signup_id', 'domain', 'path', 'user_login', 'user_email', 'activation_key' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) ( $row[ $column ] ?? '' ) === (string) $value; + } + ); + } + + $rows = $this->component_fuzz_apply_limit( $query, array_values( $rows ) ); + + if ( preg_match( '/SELECT\s+activation_key\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'activation_key' ) ); + } + + if ( preg_match( '/SELECT\s+user_login\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'user_login' ) ); + } + + if ( preg_match( '/SELECT\s+signup_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'signup_id' ) ); + } + + return $rows; + } + + private function component_fuzz_filter_users_by_published_posts_subquery( $query, array $rows ) { + if ( ! preg_match( '/\b(?:`?wp_users`?\.)?`?ID`?\s+IN\s*\(\s*SELECT\s+DISTINCT\s+(?:`?wp_posts`?\.)?`?post_author`?\s+FROM\s+`?wp_posts`?/is', (string) $query ) ) { + return $rows; + } + + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + $type_map = array_fill_keys( array_map( 'strval', $post_types ), true ); + $status = $this->component_fuzz_compare_value( $query, 'post_status' ); + $status = null === $status ? 'publish' : (string) $status; + $authors = array(); + + foreach ( $this->component_fuzz_posts as $post ) { + if ( (string) $post['post_status'] !== $status ) { + continue; + } + if ( array() !== $post_types && ! isset( $type_map[ (string) $post['post_type'] ] ) ) { + continue; + } + + $authors[ (int) $post['post_author'] ] = true; + } + + return array_filter( + $rows, + static function ( $row ) use ( $authors ) { + return isset( $authors[ (int) $row['ID'] ] ); + } + ); + } + + private function component_fuzz_sort_user_rows( $query, array $rows ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + $column = 'ID'; + $direction = 'ASC'; + + if ( preg_match( '/ORDER\s+BY\s+(?:`?wp_users`?\.)?`?(ID|user_login|user_nicename|user_email|display_name)`?(?:\s+(ASC|DESC))?/i', (string) $query, $matches ) ) { + $column = $matches[1]; + $direction = strtoupper( $matches[2] ?? 'ASC' ); + } + + if ( 'ID' === $column ) { + $comparison = (int) $a['ID'] <=> (int) $b['ID']; + } else { + $comparison = strcasecmp( (string) ( $a[ $column ] ?? '' ), (string) ( $b[ $column ] ?? '' ) ); + if ( 0 === $comparison ) { + $comparison = (int) $a['ID'] <=> (int) $b['ID']; + } + } + + return 'DESC' === $direction ? -$comparison : $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_filter_users_by_search_like( $query, array $rows ) { + if ( ! preg_match_all( '/(? $match[1], + 'pattern' => $this->component_fuzz_unquote_sql_value( $match[2] ), + ); + } + + return array_filter( + $rows, + function ( $row ) use ( $patterns ) { + foreach ( $patterns as $pattern ) { + $column = $pattern['column']; + if ( array_key_exists( $column, $row ) && $this->component_fuzz_sql_like_match( (string) $row[ $column ], (string) $pattern['pattern'] ) ) { + return true; + } + } + + return false; + } + ); + } + + private function component_fuzz_select_comments( $query ) { + $rows = array_values( $this->component_fuzz_comments ); + + foreach ( array( 'comment_ID', 'comment_post_ID', 'comment_parent', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_approved', 'user_id' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + $ids = $this->component_fuzz_in_values( $query, 'comment_ID' ); + if ( array() !== $ids ) { + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_map ) { + return isset( $id_map[ (int) $row['comment_ID'] ] ); + } + ); + } + + if ( preg_match( '/comment_approved\s*!=\s*([^\s)]+)/i', $query, $matches ) ) { + $not_approved = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_approved ) { + return (string) $row['comment_approved'] !== (string) $not_approved; + } + ); + } + + if ( preg_match( '/comment_type\s*!=\s*([^\s)]+)/i', $query, $matches ) ) { + $not_type = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $not_type ) { + return (string) $row['comment_type'] !== (string) $not_type; + } + ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+comment_ID\s+DESC/i', $query ) ) { + return (int) $b['comment_ID'] <=> (int) $a['comment_ID']; + } + return (int) $a['comment_ID'] <=> (int) $b['comment_ID']; + } + ); + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+comment_ID\s*,\s*comment_approved\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID', 'comment_approved' ) ); + } + + if ( preg_match( '/SELECT\s+comment_ID\s*,\s*comment_agent\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID', 'comment_agent' ) ); + } + + if ( preg_match( '/SELECT\s+comment_author_url\s*,\s*comment_content\s*,\s*comment_author_IP\s*,\s*comment_type\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_author_url', 'comment_content', 'comment_author_IP', 'comment_type' ) ); + } + + if ( preg_match( '/SELECT\s+comment_ID\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_ID' ) ); + } + + if ( preg_match( '/SELECT\s+comment_date_gmt\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'comment_date_gmt' ) ); + } + + return $rows; + } + + private function component_fuzz_select_links( $query ) { + $rows = array_values( $this->component_fuzz_links ); + $rows = $this->component_fuzz_maybe_join_links_to_link_categories( $query, $rows ); + + if ( preg_match( '/\bCHAR_LENGTH\s*\(\s*link_name\s*\)\s+AS\s+length\b/i', $query ) ) { + foreach ( $rows as &$row ) { + $row['length'] = function_exists( 'mb_strlen' ) ? mb_strlen( (string) $row['link_name'], 'UTF-8' ) : strlen( (string) $row['link_name'] ); + } + unset( $row ); + } + + if ( preg_match( '/\brecently_updated\b/i', $query ) || preg_match( '/\blink_updated_f\b/i', $query ) ) { + foreach ( $rows as &$row ) { + $updated_timestamp = strtotime( (string) $row['link_updated'] ); + $row['link_updated_f'] = false === $updated_timestamp ? '0' : (string) $updated_timestamp; + $row['recently_updated'] = false !== $updated_timestamp && $updated_timestamp + 120 * MINUTE_IN_SECONDS >= time() ? '1' : '0'; + } + unset( $row ); + } + + foreach ( array( 'link_id', 'link_owner', 'link_visible' ) as $column ) { + $values = $this->component_fuzz_all_compare_values( $query, $column, '=' ); + if ( array() === $values ) { + continue; + } + + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $excluded_ids = $this->component_fuzz_all_compare_values( $query, 'link_id', '<>' ); + if ( array() === $excluded_ids ) { + $excluded_ids = $this->component_fuzz_all_compare_values( $query, 'link_id', '!=' ); + } + if ( array() !== $excluded_ids ) { + $excluded_map = array_fill_keys( array_map( 'intval', $excluded_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $excluded_map ) { + return ! isset( $excluded_map[ (int) $row['link_id'] ] ); + } + ); + } + + $term_ids = $this->component_fuzz_all_compare_values( $query, 'term_id', '=' ); + if ( array() !== $term_ids ) { + $term_map = array_fill_keys( array_map( 'intval', $term_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $term_map ) { + return isset( $row['term_id'] ) && isset( $term_map[ (int) $row['term_id'] ] ); + } + ); + } + + $taxonomy = $this->component_fuzz_compare_value( $query, 'taxonomy' ); + if ( null !== $taxonomy ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy ) { + return isset( $row['taxonomy'] ) && (string) $row['taxonomy'] === (string) $taxonomy; + } + ); + } + + $rows = $this->component_fuzz_filter_links_by_search_like( $query, $rows ); + $rows = $this->component_fuzz_sort_link_rows( $query, array_values( $rows ) ); + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+link_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'link_id' ) ); + } + + return $rows; + } + + private function component_fuzz_maybe_join_links_to_link_categories( $query, array $link_rows ) { + if ( ! preg_match( '/\bwp_term_relationships\b/i', $query ) && ! preg_match( '/\bwp_term_taxonomy\b/i', $query ) ) { + return $link_rows; + } + + $joined = array(); + foreach ( $link_rows as $link_row ) { + foreach ( $this->component_fuzz_term_relationship_rows as $relationship ) { + if ( (int) $relationship['object_id'] !== (int) $link_row['link_id'] ) { + continue; + } + + $tt_id = (int) $relationship['term_taxonomy_id']; + if ( ! isset( $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ) ) { + continue; + } + + $joined[] = array_merge( $link_row, $relationship, $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ); + } + } + + return $joined; + } + + private function component_fuzz_filter_links_by_search_like( $query, array $rows ) { + if ( ! preg_match( '/\blink_url\s+LIKE\s+(\'(?:\\\\.|[^\'\\\\])*\')/i', $query, $matches ) ) { + return $rows; + } + + $needle = $this->component_fuzz_unquote_sql_value( $matches[1] ); + $needle = str_replace( array( '\\%', '\\_' ), array( '%', '_' ), trim( $needle, '%' ) ); + + return array_filter( + $rows, + static function ( $row ) use ( $needle ) { + foreach ( array( 'link_url', 'link_name', 'link_description' ) as $column ) { + if ( false !== stripos( (string) $row[ $column ], $needle ) ) { + return true; + } + } + return false; + } + ); + } + + private function component_fuzz_sql_like_match( $value, $pattern ) { + $regex = ''; + $length = strlen( (string) $pattern ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char && $i + 1 < $length ) { + $regex .= preg_quote( $pattern[ ++$i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + return 1 === preg_match( '/\A' . $regex . '\z/is', (string) $value ); + } + + private function component_fuzz_sort_link_rows( $query, array $rows ) { + if ( ! preg_match( '/\bORDER\s+BY\s+(.+?)(?:\s+LIMIT\s+\d+|\z)/is', $query, $matches ) ) { + return array_values( $rows ); + } + + $order_expression = trim( $matches[1] ); + if ( preg_match( '/\brand\s*\(\s*\)/i', $order_expression ) ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + return strcmp( + md5( $query . ':' . (string) $a['link_id'] ), + md5( $query . ':' . (string) $b['link_id'] ) + ); + } + ); + return $rows; + } + + $order = 'ASC'; + if ( preg_match( '/\s+(ASC|DESC)\s*$/i', $order_expression, $order_match ) ) { + $order = strtoupper( $order_match[1] ); + $order_expression = trim( substr( $order_expression, 0, -strlen( $order_match[0] ) ) ); + } + + $columns = array_filter( + array_map( + array( $this, 'component_fuzz_normalize_link_orderby_column' ), + explode( ',', $order_expression ) + ) + ); + + if ( array() === $columns ) { + $columns = array( 'link_name' ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $columns, $order ) { + foreach ( $columns as $column ) { + if ( in_array( $column, array( 'link_id', 'link_owner', 'link_rating', 'length' ), true ) ) { + $comparison = (int) ( $a[ $column ] ?? 0 ) <=> (int) ( $b[ $column ] ?? 0 ); + } else { + $comparison = strcasecmp( (string) ( $a[ $column ] ?? '' ), (string) ( $b[ $column ] ?? '' ) ); + } + + if ( 0 !== $comparison ) { + return 'DESC' === $order ? -$comparison : $comparison; + } + } + + $comparison = (int) $a['link_id'] <=> (int) $b['link_id']; + return 'DESC' === $order ? -$comparison : $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_normalize_link_orderby_column( $column ) { + $column = trim( (string) $column, "` \t\n\r\0\x0B" ); + $column = preg_replace( '/^`?wp_links`?\./i', '', $column ); + $column = trim( (string) $column, "` \t\n\r\0\x0B" ); + + if ( 'length' === $column ) { + return 'length'; + } + + $allowed = array( + 'link_id', + 'link_name', + 'link_url', + 'link_visible', + 'link_rating', + 'link_owner', + 'link_updated', + 'link_notes', + 'link_description', + ); + + return in_array( $column, $allowed, true ) ? $column : ''; + } + + private function component_fuzz_select_term_relationships( $query ) { + $rows = array_values( $this->component_fuzz_term_relationship_rows ); + + if ( + preg_match( '/\bwp_term_taxonomy\b/i', $query ) + || preg_match( '/\btt\./i', $query ) + || preg_match( '/\bterm_id\b/i', $query ) + || preg_match( '/\btaxonomy\b/i', $query ) + ) { + $joined = array(); + foreach ( $rows as $row ) { + $tt_id = (int) $row['term_taxonomy_id']; + if ( ! isset( $this->component_fuzz_term_taxonomy_rows[ $tt_id ] ) ) { + continue; + } + $joined[] = array_merge( $this->component_fuzz_term_taxonomy_rows[ $tt_id ], $row ); + } + $rows = $joined; + } + + $rows = $this->component_fuzz_filter_relationships_by_posts( $query, $rows ); + + foreach ( array( 'object_id', 'term_taxonomy_id', 'term_id', 'taxonomy' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return array_key_exists( $column, $row ) && (string) $row[ $column ] === (string) $value; + } + ); + } + + foreach ( array( 'object_id', 'term_taxonomy_id', 'term_id', 'taxonomy' ) as $column ) { + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return array_key_exists( $column, $row ) && isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + $comparison = (int) $a['object_id'] <=> (int) $b['object_id']; + if ( 0 === $comparison ) { + $comparison = (int) $a['term_taxonomy_id'] <=> (int) $b['term_taxonomy_id']; + } + + if ( preg_match( '/ORDER\s+BY\s+(?:`?tr`?\.)?`?object_id`?\s+DESC/i', $query ) ) { + return -$comparison; + } + + return $comparison; + } + ); + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+(?:`?[a-z_]+`?\.)?`?object_id`?\s*,\s*(?:`?[a-z_]+`?\.)?`?term_taxonomy_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'object_id', 'term_taxonomy_id' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?(?:`?[a-z_]+`?\.)?`?object_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'object_id' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?(?:`?[a-z_]+`?\.)?`?term_taxonomy_id`?\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_taxonomy_id' ) ); + } + + return $rows; + } + + private function component_fuzz_filter_relationships_by_posts( $query, array $rows ) { + if ( ! preg_match( '/\bwp_posts\b/i', $query ) ) { + return $rows; + } + + $post_statuses = $this->component_fuzz_in_values( $query, 'post_status' ); + $post_types = $this->component_fuzz_in_values( $query, 'post_type' ); + $post_status = $this->component_fuzz_compare_value( $query, 'post_status' ); + $post_type = $this->component_fuzz_compare_value( $query, 'post_type' ); + $status_map = array_fill_keys( array_map( 'strval', $post_statuses ), true ); + $type_map = array_fill_keys( array_map( 'strval', $post_types ), true ); + + return array_values( + array_filter( + $rows, + function ( $row ) use ( $post_status, $post_type, $post_statuses, $post_types, $status_map, $type_map ) { + $object_id = (int) $row['object_id']; + if ( ! isset( $this->component_fuzz_posts[ $object_id ] ) ) { + return false; + } + + $post = $this->component_fuzz_posts[ $object_id ]; + if ( null !== $post_status && (string) $post['post_status'] !== (string) $post_status ) { + return false; + } + if ( null !== $post_type && (string) $post['post_type'] !== (string) $post_type ) { + return false; + } + if ( array() !== $post_statuses && ! isset( $status_map[ (string) $post['post_status'] ] ) ) { + return false; + } + if ( array() !== $post_types && ! isset( $type_map[ (string) $post['post_type'] ] ) ) { + return false; + } + + return true; + } + ) + ); + } + + private function component_fuzz_select_terms( $query ) { + $rows = $this->component_fuzz_joined_term_rows(); + + $taxonomy = $this->component_fuzz_compare_value( $query, 'taxonomy' ); + if ( null !== $taxonomy ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy ) { + return (string) $row['taxonomy'] === (string) $taxonomy; + } + ); + } + + $taxonomies = $this->component_fuzz_in_values( $query, 'taxonomy' ); + if ( array() !== $taxonomies ) { + $taxonomy_map = array_fill_keys( $taxonomies, true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $taxonomy_map ) { + return isset( $taxonomy_map[ (string) $row['taxonomy'] ] ); + } + ); + } + + foreach ( array( 'term_id', 'term_taxonomy_id', 'name', 'slug', 'parent' ) as $column ) { + $value = $this->component_fuzz_compare_value( $query, $column ); + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + foreach ( array( 'term_id', 'term_taxonomy_id', 'name', 'slug' ) as $column ) { + $values = $this->component_fuzz_in_values( $query, $column ); + if ( array() === $values ) { + continue; + } + $value_map = array_fill_keys( array_map( 'strval', $values ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value_map ) { + return isset( $value_map[ (string) $row[ $column ] ] ); + } + ); + } + + $term_id_less_than = $this->component_fuzz_less_than_value( $query, 'term_id' ); + if ( null !== $term_id_less_than ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $term_id_less_than ) { + return (int) $row['term_id'] < (int) $term_id_less_than; + } + ); + } + + $tt_not = $this->component_fuzz_not_compare_value( $query, 'term_taxonomy_id' ); + if ( null !== $tt_not ) { + $rows = array_filter( + $rows, + static function ( $row ) use ( $tt_not ) { + return (int) $row['term_taxonomy_id'] !== (int) $tt_not; + } + ); + } + + if ( preg_match( '/tt\.count\s*>\s*0/i', $query ) ) { + $rows = array_filter( + $rows, + static function ( $row ) { + return (int) $row['count'] > 0; + } + ); + } + + $object_ids = $this->component_fuzz_in_values( $query, 'object_id' ); + if ( array() !== $object_ids ) { + $object_map = array_fill_keys( array_map( 'intval', $object_ids ), true ); + $tt_map = array(); + foreach ( $this->component_fuzz_term_relationship_rows as $relationship ) { + if ( isset( $object_map[ (int) $relationship['object_id'] ] ) ) { + $tt_map[ (int) $relationship['term_taxonomy_id'] ][] = (int) $relationship['object_id']; + } + } + + $expanded_rows = array(); + foreach ( $rows as $row ) { + $tt_id = (int) $row['term_taxonomy_id']; + if ( ! isset( $tt_map[ $tt_id ] ) ) { + continue; + } + + foreach ( array_unique( $tt_map[ $tt_id ] ) as $object_id ) { + $row['object_id'] = $object_id; + $expanded_rows[] = $row; + } + } + $rows = $expanded_rows; + } + + $rows = $this->component_fuzz_sort_term_rows( $query, array_values( $rows ) ); + if ( preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\b/i', $query ) + && ! preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\s*,\s*tr\.object_id\b/i', $query ) + ) { + $rows = $this->component_fuzz_distinct_rows( $rows, array( 'term_id' ) ); + } + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+tt\.term_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id' ) ); + } + + if ( preg_match( '/SELECT\s+t\.term_id\s*,\s*t\.slug\s*,\s*tt\.term_taxonomy_id\s*,\s*tt\.taxonomy/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id', 'slug', 'term_taxonomy_id', 'taxonomy' ) ); + } + + if ( preg_match( '/SELECT\s+(?:DISTINCT\s+)?t\.term_id\s*,\s*tr\.object_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( + $rows, + array( + 'term_id', + 'name', + 'slug', + 'term_group', + 'term_taxonomy_id', + 'taxonomy', + 'description', + 'parent', + 'count', + 'object_id', + ) + ); + } + + if ( preg_match( '/SELECT\s+t\.term_id\b/i', $query ) || preg_match( '/SELECT\s+DISTINCT\s+t\.term_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_id' ) ); + } + + if ( preg_match( '/SELECT\s+tt\.term_taxonomy_id\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( 'term_taxonomy_id' ) ); + } + + return $rows; + } + + private function component_fuzz_select_meta( $query, $meta_type ) { + $rows = array_values( $this->component_fuzz_meta[ $meta_type ] ); + $id_column = 'user' === $meta_type ? 'umeta_id' : 'meta_id'; + $object_key = $this->component_fuzz_meta_object_column( $meta_type ); + $meta_key = $this->component_fuzz_compare_value( $query, 'meta_key' ); + $object_id = $this->component_fuzz_compare_value( $query, $object_key ); + $meta_id = $this->component_fuzz_compare_value( $query, $id_column ); + $meta_value = $this->component_fuzz_compare_value( $query, 'meta_value' ); + $object_ids = $this->component_fuzz_in_values( $query, $object_key ); + $meta_ids = $this->component_fuzz_in_values( $query, $id_column ); + $filter_spec = array( + 'meta_key' => $meta_key, + $object_key => $object_id, + $id_column => $meta_id, + 'meta_value' => $meta_value, + ); + + foreach ( $filter_spec as $column => $value ) { + if ( null === $value ) { + continue; + } + $rows = array_filter( + $rows, + static function ( $row ) use ( $column, $value ) { + return (string) $row[ $column ] === (string) $value; + } + ); + } + + if ( array() !== $object_ids ) { + $object_map = array_fill_keys( array_map( 'intval', $object_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $object_key, $object_map ) { + return isset( $object_map[ (int) $row[ $object_key ] ] ); + } + ); + } + + if ( array() !== $meta_ids ) { + $meta_map = array_fill_keys( array_map( 'intval', $meta_ids ), true ); + $rows = array_filter( + $rows, + static function ( $row ) use ( $id_column, $meta_map ) { + return isset( $meta_map[ (int) $row[ $id_column ] ] ); + } + ); + } + + usort( + $rows, + static function ( $a, $b ) use ( $id_column ) { + return (int) $a[ $id_column ] <=> (int) $b[ $id_column ]; + } + ); + + if ( preg_match( '/SELECT\s+COUNT\(\s*' . preg_quote( $object_key, '/' ) . '\s*\)\s+AS\s+cnt\b/i', $query ) ) { + return array( array( 'cnt' => count( $rows ) ) ); + } + + $rows = $this->component_fuzz_apply_limit( $query, $rows ); + + if ( preg_match( '/SELECT\s+' . preg_quote( $id_column, '/' ) . '\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $id_column ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\s*,\s*meta_key\s*,\s*meta_value\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key, 'meta_key', 'meta_value' ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\s*,\s*meta_value\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key, 'meta_value' ) ); + } + + if ( preg_match( '/SELECT\s+' . preg_quote( $object_key, '/' ) . '\b/i', $query ) ) { + return $this->component_fuzz_project_rows( $rows, array( $object_key ) ); + } + + return $rows; + } + + private function component_fuzz_count_for_query( $query ) { + if ( preg_match( '/\bFROM\s+`?wp_comments`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_comments( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_links`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_links( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_signups`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_signups( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_term_relationships`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_term_relationships( $query ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_posts`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_posts( $this->component_fuzz_without_limit_clause( $query ), false ) ); + } + + if ( preg_match( '/\bFROM\s+`?wp_terms`?\b/i', $query ) || preg_match( '/\bFROM\s+`?wp_term_taxonomy`?\b/i', $query ) ) { + return count( $this->component_fuzz_select_terms( $this->component_fuzz_without_limit_clause( $query ) ) ); + } + + $table_key = $this->component_fuzz_table_key_from_query( $query ); + $meta_type = $this->component_fuzz_meta_type_for_table_key( $table_key ); + if ( null !== $meta_type ) { + return count( $this->component_fuzz_select_meta( $query, $meta_type ) ); + } + + return 0; + } + + private function component_fuzz_joined_term_rows() { + $rows = array(); + + foreach ( $this->component_fuzz_term_taxonomy_rows as $tt_row ) { + $term_id = (int) $tt_row['term_id']; + if ( ! isset( $this->component_fuzz_terms[ $term_id ] ) ) { + continue; + } + $rows[] = array_merge( $this->component_fuzz_terms[ $term_id ], $tt_row ); + } + + return $rows; + } + + private function component_fuzz_sort_term_rows( $query, array $rows ) { + usort( + $rows, + static function ( $a, $b ) use ( $query ) { + if ( preg_match( '/ORDER\s+BY\s+t\.name\b/i', $query ) ) { + $comparison = strcasecmp( (string) $a['name'], (string) $b['name'] ); + } elseif ( preg_match( '/ORDER\s+BY\s+t\.slug\b/i', $query ) ) { + $comparison = strcmp( (string) $a['slug'], (string) $b['slug'] ); + } elseif ( preg_match( '/ORDER\s+BY\s+tt\.term_taxonomy_id\b/i', $query ) ) { + $comparison = (int) $a['term_taxonomy_id'] <=> (int) $b['term_taxonomy_id']; + } else { + $comparison = (int) $a['term_id'] <=> (int) $b['term_id']; + } + + if ( preg_match( '/\bDESC\b/i', $query ) ) { + return -$comparison; + } + + return $comparison; + } + ); + + return $rows; + } + + private function component_fuzz_project_rows( array $rows, array $columns ) { + $projected = array(); + + foreach ( $rows as $row ) { + $out = array(); + foreach ( $columns as $column ) { + $out[ $column ] = $row[ $column ] ?? null; + } + $projected[] = $out; + } + + return $projected; + } + + private function component_fuzz_distinct_rows( array $rows, array $columns ) { + $seen = array(); + $distinct = array(); + + foreach ( $rows as $row ) { + $key_values = array(); + foreach ( $columns as $column ) { + $key_values[] = $row[ $column ] ?? null; + } + $key = serialize( $key_values ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $distinct[] = $row; + } + + return $distinct; + } + + private function component_fuzz_apply_limit( $query, array $rows ) { + if ( preg_match( '/\bLIMIT\s+[\'"]?(\d+)[\'"]?\s*,\s*[\'"]?(\d+)[\'"]?/i', $query, $matches ) ) { + return array_slice( array_values( $rows ), (int) $matches[1], (int) $matches[2] ); + } + + if ( preg_match( '/\bLIMIT\s+[\'"]?(\d+)[\'"]?/i', $query, $matches ) ) { + return array_slice( array_values( $rows ), 0, (int) $matches[1] ); + } + + return array_values( $rows ); + } + + private function component_fuzz_without_limit_clause( $query ) { + return preg_replace( '/\s+LIMIT\s+[\'"]?\d+[\'"]?(?:\s*,\s*[\'"]?\d+[\'"]?)?\s*$/i', '', (string) $query ); + } + + private function component_fuzz_table_key( $table ) { + $table = trim( (string) $table, "` \t\n\r\0\x0B" ); + + $map = array( + $this->options => 'options', + $this->posts => 'posts', + $this->terms => 'terms', + $this->term_taxonomy => 'term_taxonomy', + $this->term_relationships => 'term_relationships', + $this->users => 'users', + $this->comments => 'comments', + $this->links => 'links', + $this->signups => 'signups', + $this->postmeta => 'post_meta', + $this->termmeta => 'term_meta', + $this->commentmeta => 'comment_meta', + $this->usermeta => 'user_meta', + ); + + if ( isset( $map[ $table ] ) ) { + return $map[ $table ]; + } + + if ( str_starts_with( $table, $this->prefix ) ) { + return str_replace( 'wp_', '', $table ); + } + + return $table; + } + + private function component_fuzz_table_key_from_query( $query ) { + if ( preg_match( '/\bFROM\s+`?(wp_[a-z_]+)`?/i', $query, $matches ) ) { + return $this->component_fuzz_table_key( $matches[1] ); + } + + return ''; + } + + private function component_fuzz_where_clause( $query ) { + $sql = (string) $query; + if ( ! preg_match( '/\bWHERE\b/i', $sql, $matches, PREG_OFFSET_CAPTURE ) ) { + return ''; + } + + $start = $matches[0][1] + strlen( $matches[0][0] ); + $end = $this->component_fuzz_sql_clause_boundary( $sql, $start, array( 'GROUP BY', 'ORDER BY', 'LIMIT' ) ); + + return substr( $sql, $start, $end - $start ); + } + + private function component_fuzz_sql_clause_boundary( $sql, $start, array $keywords ) { + $length = strlen( (string) $sql ); + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = (int) $start; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + continue; + } + + foreach ( $keywords as $keyword ) { + if ( null !== $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, $keyword ) ) { + return $i; + } + } + } + + return $length; + } + + private function component_fuzz_split_sql_or_terms( $sql ) { + $terms = array(); + $current = ''; + $length = strlen( (string) $sql ); + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + $current .= $char; + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + $current .= $char; + continue; + } + + $end = $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, 'OR' ); + if ( null !== $end ) { + $terms[] = $current; + $current = ''; + $i = $end - 1; + continue; + } + + $current .= $char; + } + + $terms[] = $current; + return $terms; + } + + private function component_fuzz_split_sql_top_level_terms( $sql, $keyword ) { + $terms = array(); + $current = ''; + $length = strlen( (string) $sql ); + $depth = 0; + $in_string = false; + $quote = ''; + $escaped = false; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + if ( $in_string ) { + $current .= $char; + if ( '\\' === $char && ! $escaped ) { + $escaped = true; + continue; + } + + if ( $quote === $char && ! $escaped ) { + $in_string = false; + } + + $escaped = false; + continue; + } + + if ( "'" === $char || '"' === $char ) { + $in_string = true; + $quote = $char; + $escaped = false; + $current .= $char; + continue; + } + + if ( '(' === $char ) { + ++$depth; + $current .= $char; + continue; + } + + if ( ')' === $char ) { + $depth = max( 0, $depth - 1 ); + $current .= $char; + continue; + } + + $end = 0 === $depth ? $this->component_fuzz_sql_keyword_match_end_at( $sql, $i, $keyword ) : null; + if ( null !== $end ) { + $terms[] = $current; + $current = ''; + $i = $end - 1; + continue; + } + + $current .= $char; + } + + $terms[] = $current; + return $terms; + } + + private function component_fuzz_sql_keyword_match_end_at( $sql, $offset, $keyword ) { + if ( $offset > 0 && preg_match( '/[A-Za-z0-9_]/', $sql[ $offset - 1 ] ) ) { + return null; + } + + $parts = preg_split( '/\s+/', trim( (string) $keyword ) ); + $pattern = '/\A' . implode( '\s+', array_map( 'preg_quote', $parts ) ) . '\b/i'; + if ( ! preg_match( $pattern, substr( (string) $sql, (int) $offset ), $matches ) ) { + return null; + } + + return (int) $offset + strlen( $matches[0] ); + } + + private function component_fuzz_meta_type_for_table_key( $table_key ) { + $map = array( + 'post_meta' => 'post', + 'term_meta' => 'term', + 'comment_meta' => 'comment', + 'user_meta' => 'user', + 'postmeta' => 'post', + 'termmeta' => 'term', + 'commentmeta' => 'comment', + 'usermeta' => 'user', + ); + + return $map[ $table_key ] ?? null; + } + + private function component_fuzz_meta_object_column( $meta_type ) { + return $meta_type . '_id'; + } + + private function component_fuzz_post_meta_values( $post_id, $meta_key ) { + $values = array(); + foreach ( $this->component_fuzz_meta['post'] as $row ) { + if ( (int) $row['post_id'] === (int) $post_id && (string) $row['meta_key'] === (string) $meta_key ) { + $values[] = (string) $row['meta_value']; + } + } + + return $values; + } + + private function component_fuzz_compare_value( $query, $column ) { + $values = $this->component_fuzz_compare_values( $query, $column ); + return $values[0] ?? null; + } + + private function component_fuzz_compare_values( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( ! preg_match_all( '/(?component_fuzz_unquote_sql_value( $matches[1] ); + } + + return null; + } + + private function component_fuzz_less_than_value( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( preg_match( '/(?component_fuzz_unquote_sql_value( $matches[1] ); + } + + return null; + } + + private function component_fuzz_in_values( $query, $column ) { + $column = preg_quote( $column, '/' ); + if ( ! preg_match( '/(?component_fuzz_csv_values( $matches[1] ); + } + + private function component_fuzz_all_compare_values( $query, $column, $operator ) { + $column = preg_quote( $column, '/' ); + $operator = preg_quote( $operator, '/' ); + + if ( ! preg_match_all( '/(?component_fuzz_csv_values( $csv ) ); + } + + private function component_fuzz_unquote_sql_value( $value ) { + $value = trim( (string) $value ); + if ( strlen( $value ) >= 4 && "''" === substr( $value, 0, 2 ) && "''" === substr( $value, -2 ) ) { + return stripslashes( substr( $value, 2, -2 ) ); + } + if ( strlen( $value ) >= 2 && "'" === $value[0] && "'" === $value[ strlen( $value ) - 1 ] ) { + return stripslashes( substr( $value, 1, -1 ) ); + } + if ( strlen( $value ) >= 2 && '"' === $value[0] && '"' === $value[ strlen( $value ) - 1 ] ) { + return substr( $value, 1, -1 ); + } + + return $value; + } + + private function component_fuzz_quoted_values( $query ) { + if ( ! preg_match_all( '/\'((?:\\\\.|[^\'\\\\])*)\'/s', (string) $query, $matches ) ) { + return array(); + } + + return array_map( array( $this, 'component_fuzz_unescape_addslashes_sql_string' ), $matches[1] ); + } + + private function component_fuzz_unescape_addslashes_sql_string( $value ) { + $value = (string) $value; + $out = ''; + $length = strlen( $value ); + + for ( $i = 0; $i < $length; $i++ ) { + if ( '\\' !== $value[ $i ] || $i + 1 >= $length ) { + $out .= $value[ $i ]; + continue; + } + + $next = $value[ ++$i ]; + if ( '0' === $next ) { + $out .= "\0"; + } elseif ( in_array( $next, array( '\\', "'", '"' ), true ) ) { + $out .= $next; + } else { + $out .= '\\' . $next; + } + } + + return $out; + } + + private function component_fuzz_delete_meta_ids( $meta_type, array $ids ) { + if ( ! isset( $this->component_fuzz_meta[ $meta_type ] ) ) { + return 0; + } + + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + foreach ( $this->component_fuzz_meta[ $meta_type ] as $id => $row ) { + if ( isset( $id_map[ (int) $id ] ) ) { + unset( $this->component_fuzz_meta[ $meta_type ][ $id ] ); + ++$this->rows_affected; + } + } + + return $this->rows_affected; + } + + private function component_fuzz_query_delete_term_relationships( $query ) { + $object_id = $this->component_fuzz_compare_value( $query, 'object_id' ); + $tt_ids = $this->component_fuzz_in_values( $query, 'term_taxonomy_id' ); + $tt_map = array_fill_keys( array_map( 'intval', $tt_ids ), true ); + + foreach ( $this->component_fuzz_term_relationship_rows as $id => $relationship ) { + if ( null !== $object_id && (int) $relationship['object_id'] !== (int) $object_id ) { + continue; + } + if ( array() !== $tt_ids && ! isset( $tt_map[ (int) $relationship['term_taxonomy_id'] ] ) ) { + continue; + } + + unset( $this->component_fuzz_term_relationship_rows[ $id ] ); + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_query_insert_term_relationships( $query ) { + if ( ! preg_match( '/\bVALUES\s+(.+?)(?:\s+ON\s+DUPLICATE|\z)/is', $query, $matches ) ) { + return false; + } + + if ( ! preg_match_all( '/\(\s*\'?(\d+)\'?\s*,\s*\'?(\d+)\'?\s*,\s*\'?(\d+)\'?\s*\)/', $matches[1], $rows, PREG_SET_ORDER ) ) { + return false; + } + + foreach ( $rows as $row ) { + $key = (int) $row[1] . ':' . (int) $row[2]; + $this->component_fuzz_term_relationship_rows[ $key ] = array( + 'object_id' => (int) $row[1], + 'term_taxonomy_id' => (int) $row[2], + 'term_order' => (int) $row[3], + ); + ++$this->rows_affected; + } + + return $this->rows_affected; + } + + private function component_fuzz_query_update_comment_statuses( $query ) { + $status = $this->component_fuzz_compare_value( $query, 'comment_approved' ); + $ids = $this->component_fuzz_in_values( $query, 'comment_ID' ); + $id_map = array_fill_keys( array_map( 'intval', $ids ), true ); + + foreach ( $this->component_fuzz_comments as &$comment ) { + if ( array() !== $ids && ! isset( $id_map[ (int) $comment['comment_ID'] ] ) ) { + continue; + } + + $comment['comment_approved'] = $status; + ++$this->rows_affected; + } + unset( $comment ); + + return $this->rows_affected; + } + + private function component_fuzz_post_defaults() { + return array( + 'ID' => 0, + 'post_author' => 0, + 'post_date' => '0000-00-00 00:00:00', + 'post_date_gmt' => '0000-00-00 00:00:00', + 'post_content' => '', + 'post_title' => '', + 'post_excerpt' => '', + 'post_status' => 'draft', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => '', + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '0000-00-00 00:00:00', + 'post_modified_gmt' => '0000-00-00 00:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => '', + 'menu_order' => 0, + 'post_type' => 'post', + 'post_mime_type' => '', + 'comment_count' => '0', + ); + } + + private function component_fuzz_term_defaults() { + return array( + 'term_id' => 0, + 'name' => '', + 'slug' => '', + 'term_group' => 0, + ); + } + + private function component_fuzz_term_taxonomy_defaults() { + return array( + 'term_taxonomy_id' => 0, + 'term_id' => 0, + 'taxonomy' => '', + 'description' => '', + 'parent' => 0, + 'count' => 0, + ); + } + + private function component_fuzz_user_defaults() { + return array( + 'ID' => 0, + 'user_login' => '', + 'user_pass' => '', + 'user_nicename' => '', + 'user_email' => '', + 'user_url' => '', + 'user_registered' => '0000-00-00 00:00:00', + 'user_activation_key' => '', + 'user_status' => 0, + 'display_name' => '', + 'spam' => '0', + 'deleted' => '0', + ); + } + + private function component_fuzz_signup_defaults() { + return array( + 'signup_id' => 0, + 'domain' => '', + 'path' => '', + 'title' => '', + 'user_login' => '', + 'user_email' => '', + 'registered' => '0000-00-00 00:00:00', + 'activated' => '0000-00-00 00:00:00', + 'active' => 0, + 'activation_key' => '', + 'meta' => '', + ); + } + + private function component_fuzz_comment_defaults() { + return array( + 'comment_ID' => 0, + 'comment_post_ID' => 0, + 'comment_author' => '', + 'comment_author_email' => '', + 'comment_author_url' => '', + 'comment_author_IP' => '', + 'comment_date' => '0000-00-00 00:00:00', + 'comment_date_gmt' => '0000-00-00 00:00:00', + 'comment_content' => '', + 'comment_karma' => 0, + 'comment_approved' => '1', + 'comment_agent' => '', + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'user_id' => 0, + ); + } + + private function component_fuzz_link_defaults() { + return array( + 'link_id' => 0, + 'link_url' => '', + 'link_name' => '', + 'link_image' => '', + 'link_target' => '', + 'link_description' => '', + 'link_visible' => 'Y', + 'link_owner' => 0, + 'link_rating' => 0, + 'link_updated' => '0000-00-00 00:00:00', + 'link_rel' => '', + 'link_notes' => '', + 'link_rss' => '', + ); + } + + private function component_fuzz_format_row( array $row, $output ) { + if ( ARRAY_A === $output ) { + return $row; + } + + if ( ARRAY_N === $output ) { + return array_values( $row ); + } + + return (object) $row; + } + + private function component_fuzz_format_results( array $rows, $output ) { + if ( ARRAY_A === $output || ARRAY_N === $output ) { + return array_map( + function ( $row ) use ( $output ) { + return $this->component_fuzz_format_row( $row, $output ); + }, + $rows + ); + } + + $objects = array_map( + function ( $row ) { + return (object) $row; + }, + $rows + ); + + if ( OBJECT_K === $output ) { + $keyed = array(); + foreach ( $objects as $object ) { + $values = get_object_vars( $object ); + $key = reset( $values ); + if ( ! isset( $keyed[ $key ] ) ) { + $keyed[ $key ] = $object; + } + } + return $keyed; + } + + return $objects; + } + } +} + +$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..ef67d06c41033 --- /dev/null +++ b/tools/component-fuzz/notes/identity.md @@ -0,0 +1,18 @@ +# 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()` +- current-user lifecycle: cache-seeded `wp_set_current_user()`, `wp_get_current_user()`, `get_current_user_id()`, `is_user_logged_in()`, legacy setup globals, `set_current_user` action dispatch, legacy object upgrades, and `determine_current_user` discovery +- 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, while current-user lifecycle coverage also restores current-user/setup globals and hook counters locally. The outer run restores `wp_filter` after temporary filters are removed, with cloned hook snapshots so existing `WP_Hook` instances are not mutated through shallow snapshot references. 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 `'; + }; + + \add_filter( 'image_add_caption_text', $caption_text_filter, 10, 2 ); + \add_filter( 'image_add_caption_shortcode', $caption_shortcode_filter, 10, 2 ); + try { + $captioned_html = \image_add_caption( $caption_html, $attachment->ID, $caption_text, $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'image_add_caption_text', $caption_text_filter, 10 ); + \remove_filter( 'image_add_caption_shortcode', $caption_shortcode_filter, 10 ); + } + + self::collect_failure( + $failures, + is_string( $captioned_html ) + && str_starts_with( $captioned_html, '[caption id="attachment_' . $attachment->ID . '" align="alignright" width="456"]' ) + && str_contains( $captioned_html, 'class="size-medium wp-image-' . $attachment->ID . ' component-fuzz"' ) + && ! str_contains( $captioned_html, 'class="alignright' ) + && str_contains( $captioned_html, 'tag
Second line
Third abbr
Filtered tag' ) + && str_ends_with( $captioned_html, '' ) + && self::html_has_no_raw_script( $captioned_html ), + 'image_add_caption() extracts width, strips image align class, and normalizes caption tag and line breaks', + array( + 'html' => self::describe_string( $captioned_html ), + 'events' => $caption_events, + ) + ); + + self::collect_failure( + $failures, + array( 'image_add_caption_text', 'image_add_caption_shortcode' ) === array_column( $caption_events, 'filter' ) + && (int) $attachment->ID === (int) ( $caption_events[0]['id'] ?? 0 ) + && isset( $caption_events[1]['html'] ) + && ! str_contains( (string) $caption_events[1]['html'], 'class="alignright' ) + && false === \has_filter( 'image_add_caption_text', $caption_text_filter ) + && false === \has_filter( 'image_add_caption_shortcode', $caption_shortcode_filter ), + 'image_add_caption_text and image_add_caption_shortcode filters fire in order and remain local', + array( 'events' => $caption_events ) + ); + + $early_shortcode_events = array(); + $early_shortcode_filter = static function ( string $shortcode, string $html ) use ( &$early_shortcode_events ): string { + $early_shortcode_events[] = array( + 'shortcode' => $shortcode, + 'html' => $html, + ); + + return $shortcode; + }; + + $disabled_events = array(); + $disable_captions_filter = static function ( $disabled ) use ( &$disabled_events ): bool { + $disabled_events[] = $disabled; + return true; + }; + + \add_filter( 'disable_captions', $disable_captions_filter ); + \add_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10, 2 ); + try { + $disabled_caption_html = \image_add_caption( $caption_html, $attachment->ID, 'Disabled caption', $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'disable_captions', $disable_captions_filter ); + \remove_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10 ); + } + + $empty_text_events = array(); + $empty_disable_events = array(); + $empty_text_filter = static function ( string $filtered_caption, int $id ) use ( &$empty_text_events ): string { + $empty_text_events[] = array( + 'id' => $id, + 'caption' => $filtered_caption, + ); + + return ''; + }; + $empty_disable_filter = static function ( $disabled ) use ( &$empty_disable_events ): bool { + $empty_disable_events[] = $disabled; + return false; + }; + + \add_filter( 'image_add_caption_text', $empty_text_filter, 10, 2 ); + \add_filter( 'disable_captions', $empty_disable_filter ); + \add_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10, 2 ); + try { + $empty_caption_html = \image_add_caption( $caption_html, $attachment->ID, 'Will be emptied', $title, 'right', $url, 'medium', $alt ); + } finally { + \remove_filter( 'image_add_caption_text', $empty_text_filter, 10 ); + \remove_filter( 'disable_captions', $empty_disable_filter ); + \remove_filter( 'image_add_caption_shortcode', $early_shortcode_filter, 10 ); + } + + self::collect_failure( + $failures, + $caption_html === $disabled_caption_html + && $caption_html === $empty_caption_html + && array( '' ) === $disabled_events + && 1 === count( $empty_text_events ) + && array() === $empty_disable_events + && array() === $early_shortcode_events + && false === \has_filter( 'disable_captions', $disable_captions_filter ) + && false === \has_filter( 'image_add_caption_text', $empty_text_filter ) + && false === \has_filter( 'disable_captions', $empty_disable_filter ) + && false === \has_filter( 'image_add_caption_shortcode', $early_shortcode_filter ), + 'image_add_caption() returns original HTML for disabled or empty captions before shortcode filters', + array( + 'disabledEvents' => $disabled_events, + 'emptyTextEvents' => $empty_text_events, + 'emptyDisableEvents' => $empty_disable_events, + 'earlyShortcodeEvents' => $early_shortcode_events, + ) + ); + + $media_events = array(); + $media_send_filter = static function ( string $html, int $id, string $captured_caption, string $captured_title, string $captured_align, string $captured_url, $captured_size, string $captured_alt, string $captured_rel ) use ( &$media_events ): string { + $media_events[] = array( + 'html' => $html, + 'id' => $id, + 'caption' => $captured_caption, + 'title' => $captured_title, + 'align' => $captured_align, + 'url' => $captured_url, + 'size' => $captured_size, + 'alt' => $captured_alt, + 'rel' => $captured_rel, + ); + + return $html; + }; + + $media_permalink_url = \get_attachment_link( $attachment->ID ); + $media_query_url = \add_query_arg( 'attachment_id', (string) $attachment->ID, 'http://example.test/component-fuzz/media-send' ); + $media_plain_url = 'http://example.test/component-fuzz/media-send-plain?raw=' . rawurlencode( '' ); + $media_alt = 'Media alt "' . $ctx->identifier( 3, 8 ); + $media_payload = array( + 'url' => $media_permalink_url, + 'align' => 'left', + 'image-size' => 'medium', + 'image_alt' => $media_alt, + 'post_excerpt' => $attachment->post_excerpt, + 'post_title' => $attachment->post_title, + ); + $media_query_payload = array_merge( + $media_payload, + array( + 'url' => $media_query_url, + 'align' => 'center', + 'image-size' => 'thumbnail', + ) + ); + $media_plain_payload = array_merge( + $media_payload, + array( + 'url' => $media_plain_url, + 'align' => 'none', + 'image-size' => 'medium', + ) + ); + $unchanged_document_html = 'Document HTML'; + $media_caption_payload = array_merge( + $media_payload, + array( + 'align' => 'right', + 'image-size' => 'medium', + 'image_alt' => 'Media caption alt "' . $ctx->identifier( 3, 8 ), + 'post_excerpt' => "Media integrated caption\nSecond line", + 'post_title' => 'Media integrated title', + 'url' => $media_permalink_url, + ) + ); + $media_caption_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_caption_payload ); + + \add_filter( 'image_send_to_editor', $media_send_filter, 10, 9 ); + \add_filter( 'disable_captions', $disable_captions_filter ); + try { + $media_permalink_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_payload ); + $media_query_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_query_payload ); + $media_plain_html = \image_media_send_to_editor( 'input', $attachment->ID, $media_plain_payload ); + $document_html = \image_media_send_to_editor( $unchanged_document_html, $document->ID, array( 'url' => \wp_get_attachment_url( $document->ID ) ) ); + } finally { + \remove_filter( 'image_send_to_editor', $media_send_filter, 10 ); + \remove_filter( 'disable_captions', $disable_captions_filter ); + } + + self::collect_failure( + $failures, + is_string( $media_permalink_html ) + && is_string( $media_query_html ) + && is_string( $media_plain_html ) + && str_contains( $media_permalink_html, 'href="' . \esc_url( $media_permalink_url ) . '"' ) + && str_contains( $media_query_html, 'href="' . \esc_url( $media_query_url ) . '"' ) + && str_contains( $media_plain_html, 'href="' . \esc_url( $media_plain_url ) . '"' ) + && str_contains( $media_permalink_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && str_contains( $media_query_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && ! str_contains( $media_plain_html, ' rel=' ) + && str_contains( $media_permalink_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && str_contains( $media_query_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && str_contains( $media_plain_html, 'alt="' . \esc_attr( $media_alt ) . '"' ) + && self::html_has_no_raw_script( $media_permalink_html . $media_query_html . $media_plain_html ) + && $unchanged_document_html === $document_html, + 'image_media_send_to_editor() delegates image attachments and leaves non-image HTML unchanged', + array( + 'permalinkHtml' => self::describe_string( $media_permalink_html ), + 'queryHtml' => self::describe_string( $media_query_html ), + 'plainHtml' => self::describe_string( $media_plain_html ), + 'documentHtml' => self::describe_string( $document_html ), + ) + ); + + self::collect_failure( + $failures, + 20 === \has_filter( 'image_send_to_editor', 'image_add_caption' ) + && is_string( $media_caption_html ) + && str_starts_with( $media_caption_html, '[caption id="attachment_' . $attachment->ID . '" align="alignright" width="300"]' ) + && str_contains( $media_caption_html, 'href="' . \esc_url( $media_permalink_url ) . '"' ) + && str_contains( $media_caption_html, 'rel="attachment wp-att-' . $attachment->ID . '"' ) + && str_contains( $media_caption_html, 'alt="' . \esc_attr( $media_caption_payload['image_alt'] ) . '"' ) + && str_contains( $media_caption_html, 'Media integrated caption
Second line[/caption]' ) + && self::html_has_no_raw_script( $media_caption_html ), + 'image_media_send_to_editor() observes the default image_send_to_editor caption wrapping path', + array( 'html' => self::describe_string( $media_caption_html ) ) + ); + + self::collect_failure( + $failures, + 3 === count( $media_events ) + && (int) $attachment->ID === (int) ( $media_events[0]['id'] ?? 0 ) + && (int) $attachment->ID === (int) ( $media_events[1]['id'] ?? 0 ) + && (int) $attachment->ID === (int) ( $media_events[2]['id'] ?? 0 ) + && $attachment->post_excerpt === ( $media_events[0]['caption'] ?? null ) + && $attachment->post_title === ( $media_events[0]['title'] ?? null ) + && 'left' === ( $media_events[0]['align'] ?? null ) + && 'medium' === ( $media_events[0]['size'] ?? null ) + && $media_permalink_url === ( $media_events[0]['url'] ?? null ) + && $media_alt === ( $media_events[0]['alt'] ?? null ) + && 'center' === ( $media_events[1]['align'] ?? null ) + && 'thumbnail' === ( $media_events[1]['size'] ?? null ) + && $media_query_url === ( $media_events[1]['url'] ?? null ) + && 'none' === ( $media_events[2]['align'] ?? null ) + && 'medium' === ( $media_events[2]['size'] ?? null ) + && $media_plain_url === ( $media_events[2]['url'] ?? null ) + && ' rel="attachment wp-att-' . $attachment->ID . '"' === ( $media_events[0]['rel'] ?? null ) + && ' rel="attachment wp-att-' . $attachment->ID . '"' === ( $media_events[1]['rel'] ?? null ) + && '' === ( $media_events[2]['rel'] ?? null ) + && false === \has_filter( 'image_send_to_editor', $media_send_filter ) + && false === \has_filter( 'disable_captions', $disable_captions_filter ), + 'image_media_send_to_editor() forwards attachment fields into get_image_send_to_editor() payloads with scoped filters', + array( 'events' => $media_events ) + ); + + self::restore_state( $filter_snapshot ); + + return self::row( + $ctx, + 'admin-media-chrome.image-caption-editor-output', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_legacy_upload_shell_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $snapshot = self::snapshot_state(); + $token = $ctx->identifier( 3, 8 ); + $post_id = self::seed_parent_post( $ctx->fork( 'post' ) ); + $unsafe_type = 'image-' . $token . ''; + $unsafe_tab = 'type-' . $token . ''; + $unsafe_post_id = (string) $post_id . ''; + $action_counts = array(); + $tab_events = array(); + $form_url_events = array(); + $post_events = array(); + $plupload_events = array(); + $direct_tabs = array(); + $header_html = ''; + $chromeless_html = ''; + $type_form_html = ''; + + $tabs_filter = static function ( array $tabs ) use ( &$tab_events, $token ): array { + $tab_events[] = array_keys( $tabs ); + $tabs['component_fuzz'] = 'Component Fuzz ' . $token; + return $tabs; + }; + $form_url_filter = static function ( string $url, string $type ) use ( &$form_url_events, $token ): string { + $form_url_events[] = array( + 'url' => $url, + 'type' => $type, + ); + return \add_query_arg( 'cfz_upload_marker', $token, $url ); + }; + $post_params_filter = static function ( array $params ) use ( &$post_events, $token ): array { + $post_events[] = $params; + $params['component_fuzz_param'] = $token; + return $params; + }; + $plupload_filter = static function ( array $init ) use ( &$plupload_events, $token ): array { + $plupload_events[] = $init; + $init['component_fuzz_init'] = $token; + return $init; + }; + $tracked_actions = array( + 'pre-upload-ui', + 'pre-plupload-upload-ui', + 'post-plupload-upload-ui', + 'pre-html-upload-ui', + 'post-html-upload-ui', + 'post-upload-ui', + ); + $action_callbacks = array(); + foreach ( $tracked_actions as $hook ) { + $action_counts[ $hook ] = 0; + $action_callbacks[ $hook ] = static function () use ( &$action_counts, $hook ): void { + ++$action_counts[ $hook ]; + }; + } + + \add_filter( 'media_upload_tabs', $tabs_filter ); + \add_filter( 'media_upload_form_url', $form_url_filter, 10, 2 ); + \add_filter( 'upload_post_params', $post_params_filter ); + \add_filter( 'plupload_init', $plupload_filter ); + foreach ( $action_callbacks as $hook => $callback ) { + \add_action( $hook, $callback ); + } + + try { + $GLOBALS['type'] = $unsafe_type; + $GLOBALS['tab'] = $unsafe_tab; + $_SERVER['HTTP_USER_AGENT'] = 'ComponentFuzz Desktop'; + $_GET = array( + 'tab' => 'component_fuzz', + ); + $_POST = array(); + $_REQUEST = array( + 'post_id' => $unsafe_post_id, + ); + + $direct_tabs = \media_upload_tabs(); + $header_html = self::capture_output( + static function (): void { + \media_upload_header(); + } + ); + + $_GET['chromeless'] = '1'; + $chromeless_html = self::capture_output( + static function (): void { + \media_upload_header(); + } + ); + + unset( $_GET['chromeless'] ); + $type_form_html = self::capture_output( + static function (): void { + \media_upload_type_form( 'image', null, null ); + } + ); + } finally { + \remove_filter( 'media_upload_tabs', $tabs_filter ); + \remove_filter( 'media_upload_form_url', $form_url_filter, 10 ); + \remove_filter( 'upload_post_params', $post_params_filter ); + \remove_filter( 'plupload_init', $plupload_filter ); + foreach ( $action_callbacks as $hook => $callback ) { + \remove_action( $hook, $callback ); + } + self::restore_state( $snapshot ); + } + + self::collect_failure( + $failures, + isset( $direct_tabs['type'], $direct_tabs['type_url'], $direct_tabs['gallery'], $direct_tabs['library'], $direct_tabs['component_fuzz'] ) + && 'Component Fuzz ' . $token === $direct_tabs['component_fuzz'], + 'media_upload_tabs() exposes default legacy tabs and scoped filter additions', + array( 'directTabs' => $direct_tabs ) + ); + + self::collect_failure( + $failures, + str_contains( $header_html, '' ) + && str_contains( $header_html, '
' ) + && str_contains( $header_html, "id='tab-component_fuzz'" ) + && str_contains( $header_html, "class='current'" ) + && str_contains( $chromeless_html, '' ) + && ! str_contains( $chromeless_html, '
' ) + && ! str_contains( $header_html . $chromeless_html, $unsafe_post_id ), + 'media_upload_header() casts request post IDs and honors chromeless legacy tab rendering', + array( + 'header' => self::describe_string( $header_html ), + 'chromeless' => self::describe_string( $chromeless_html ), + ) + ); + + self::collect_failure( + $failures, + str_contains( $type_form_html, 'enctype="multipart/form-data"' ) + && str_contains( $type_form_html, 'id="image-form"' ) + && str_contains( $type_form_html, 'name="_wpnonce"' ) + && str_contains( $type_form_html, 'id="post_id" value="' . $post_id . '"' ) + && str_contains( $type_form_html, 'cfz_upload_marker=' . $token ) + && str_contains( $type_form_html, 'wpUploaderInit = ' ) + && str_contains( $type_form_html, 'component_fuzz_param' ) + && str_contains( $type_form_html, 'component_fuzz_init' ) + && str_contains( $type_form_html, '\\u003C/script\\u003E' ) + && ! str_contains( $type_form_html, $unsafe_type ) + && ! str_contains( $type_form_html, $unsafe_tab ) + && ! str_contains( $type_form_html, $unsafe_post_id ), + 'media_upload_type_form() renders the non-dispatch upload shell with nonce, filtered action URL, and JSON-escaped uploader settings', + array( 'html' => self::describe_string( $type_form_html ) ) + ); + + self::collect_failure( + $failures, + 1 === count( $form_url_events ) + && 'image' === ( $form_url_events[0]['type'] ?? null ) + && 1 === count( $post_events ) + && $post_id === (int) ( $post_events[0]['post_id'] ?? 0 ) + && $unsafe_type === ( $post_events[0]['type'] ?? null ) + && $unsafe_tab === ( $post_events[0]['tab'] ?? null ) + && 1 === count( $plupload_events ) + && $token === ( $plupload_events[0]['multipart_params']['component_fuzz_param'] ?? null ) + && array() === array_filter( + $action_counts, + static function ( int $count ): bool { + return 1 !== $count; + } + ), + 'legacy upload shell fires documented form filters and upload UI hooks exactly once without dispatching uploads', + array( + 'formUrlEvents' => $form_url_events, + 'postEvents' => $post_events, + 'pluploadEvents' => $plupload_events, + 'actionCounts' => $action_counts, + 'tabEvents' => $tab_events, + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'media_upload_tabs', $tabs_filter ) + && false === \has_filter( 'media_upload_form_url', $form_url_filter ) + && false === \has_filter( 'upload_post_params', $post_params_filter ) + && false === \has_filter( 'plupload_init', $plupload_filter ), + 'legacy upload shell filters are scoped to the invariant', + array() + ); + + return self::row( + $ctx, + 'admin-media-chrome.legacy-upload-shell-server-output', + array() === $failures, + array( + 'failures' => $failures, + 'notClaimed' => array( + 'media_upload_form_handler() send/insert-gallery dispatch branches', + 'media_upload_type_form() WP_Error branch that exits', + 'wp_media_attach_action() redirect/exit branch', + 'media_handle_upload() and media_handle_sideload() real ingest paths', + ), + ) + ); + } + + private static function check_media_enqueue_and_iframe_shell( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $snapshot = self::snapshot_state(); + $token = $ctx->identifier( 3, 8 ); + $post_id = self::seed_parent_post( $ctx->fork( 'post' ) ); + $iframe_payload = 'iframe ' . $token; + $settings_events = array(); + $strings_events = array(); + $filter_events = array(); + $enqueue_actions = 0; + $localized = ''; + $script_status = array(); + $style_status = array(); + $template_hooks = array(); + $did_enqueue = 0; + $iframe_html = ''; + + $tabs_filter = static function ( array $tabs ) use ( &$filter_events, $token ): array { + $filter_events['tabs'][] = array_keys( $tabs ); + $tabs['component_fuzz_modal'] = 'Component Fuzz Modal ' . $token; + return $tabs; + }; + $audio_filter = static function ( $show ) use ( &$filter_events ): bool { + $filter_events['audio'][] = $show; + return false; + }; + $video_filter = static function ( $show ) use ( &$filter_events ): bool { + $filter_events['video'][] = $show; + return true; + }; + $months_filter = static function ( $months ) use ( &$filter_events ): array { + $filter_events['months'][] = $months; + return array( + (object) array( + 'month' => 6, + 'year' => 2026, + ), + ); + }; + $infinite_filter = static function ( bool $infinite ) use ( &$filter_events ): bool { + $filter_events['infinite'][] = $infinite; + return true; + }; + $captions_filter = static function ( $disabled ) use ( &$filter_events ): bool { + $filter_events['captions'][] = $disabled; + return true; + }; + $settings_filter = static function ( array $settings, $post ) use ( &$settings_events, $token ): array { + $settings['componentFuzzSetting'] = $token; + $settings_events[] = array( + 'postId' => $post instanceof \WP_Post ? $post->ID : null, + 'settings' => $settings, + ); + return $settings; + }; + $strings_filter = static function ( array $strings, $post ) use ( &$strings_events, $token ): array { + $strings['componentFuzzString'] = $token; + $strings_events[] = array( + 'postId' => $post instanceof \WP_Post ? $post->ID : null, + 'strings' => $strings, + ); + return $strings; + }; + $enqueue_action = static function () use ( &$enqueue_actions ): void { + ++$enqueue_actions; + }; + + \add_filter( 'media_upload_tabs', $tabs_filter ); + \add_filter( 'media_library_show_audio_playlist', $audio_filter ); + \add_filter( 'media_library_show_video_playlist', $video_filter ); + \add_filter( 'media_library_months_with_files', $months_filter ); + \add_filter( 'media_library_infinite_scrolling', $infinite_filter ); + \add_filter( 'disable_captions', $captions_filter ); + \add_filter( 'media_view_settings', $settings_filter, 10, 2 ); + \add_filter( 'media_view_strings', $strings_filter, 10, 2 ); + \add_action( 'wp_enqueue_media', $enqueue_action ); + + try { + $GLOBALS['content_width'] = 733; + $GLOBALS['body_id'] = 'component-fuzz-iframe'; + + \wp_enqueue_media( array( 'post' => $post_id ) ); + $localized = (string) \wp_scripts()->get_data( 'media-views', 'data' ); + $script_status = array( + 'media-editor' => \wp_script_is( 'media-editor', 'enqueued' ), + 'media-audiovideo' => \wp_script_is( 'media-audiovideo', 'enqueued' ), + ); + $style_status = array( + 'media-views' => \wp_style_is( 'media-views', 'enqueued' ), + 'imgareaselect' => \wp_style_is( 'imgareaselect', 'enqueued' ), + ); + $template_hooks = array( + 'admin_footer' => \has_action( 'admin_footer', 'wp_print_media_templates' ), + 'wp_footer' => \has_action( 'wp_footer', 'wp_print_media_templates' ), + 'customize_controls_print_footer_scripts' => \has_action( 'customize_controls_print_footer_scripts', 'wp_print_media_templates' ), + ); + $did_enqueue = \did_action( 'wp_enqueue_media' ); + + $iframe_html = self::capture_output( + static function () use ( $iframe_payload ): void { + \wp_iframe( 'esc_html_e', $iframe_payload ); + } + ); + } finally { + \remove_filter( 'media_upload_tabs', $tabs_filter ); + \remove_filter( 'media_library_show_audio_playlist', $audio_filter ); + \remove_filter( 'media_library_show_video_playlist', $video_filter ); + \remove_filter( 'media_library_months_with_files', $months_filter ); + \remove_filter( 'media_library_infinite_scrolling', $infinite_filter ); + \remove_filter( 'disable_captions', $captions_filter ); + \remove_filter( 'media_view_settings', $settings_filter, 10 ); + \remove_filter( 'media_view_strings', $strings_filter, 10 ); + \remove_action( 'wp_enqueue_media', $enqueue_action ); + self::restore_state( $snapshot ); + } + + $settings = $settings_events[0]['settings'] ?? array(); + $strings = $strings_events[0]['strings'] ?? array(); + $month = $settings['months'][0] ?? null; + + self::collect_failure( + $failures, + 1 === count( $settings_events ) + && $post_id === (int) ( $settings_events[0]['postId'] ?? 0 ) + && isset( $settings['tabs']['component_fuzz_modal'] ) + && 'Component Fuzz Modal ' . $token === $settings['tabs']['component_fuzz_modal'] + && false === ( $settings['captions'] ?? null ) + && 0 === (int) ( $settings['attachmentCounts']['audio'] ?? -1 ) + && 1 === (int) ( $settings['attachmentCounts']['video'] ?? -1 ) + && 1 === (int) ( $settings['infiniteScrolling'] ?? 0 ) + && 733 === (int) ( $settings['contentWidth'] ?? 0 ) + && $month instanceof \stdClass + && 6 === (int) $month->month + && 2026 === (int) $month->year + && isset( $month->text ), + 'wp_enqueue_media() builds filterable media-view settings without querying media months or playlist counts', + array( + 'settingsEvents' => $settings_events, + 'filterEvents' => $filter_events, + ) + ); + + self::collect_failure( + $failures, + 1 === count( $strings_events ) + && $post_id === (int) ( $strings_events[0]['postId'] ?? 0 ) + && isset( $strings['componentFuzzString'] ) + && $token === $strings['componentFuzzString'], + 'wp_enqueue_media() filters media strings before attaching the filtered settings payload for localization', + array( 'stringsEvents' => $strings_events ) + ); + + self::collect_failure( + $failures, + '' === $localized + || ( + str_contains( $localized, '_wpMediaViewsL10n' ) + && str_contains( $localized, 'componentFuzzSetting' ) + && str_contains( $localized, 'componentFuzzString' ) + && str_contains( $localized, $token ) + ), + 'wp_enqueue_media() localizes filtered media strings when the media-views handle is registered', + array( + 'localized' => self::describe_string( $localized ), + 'scripts' => $script_status, + 'styles' => $style_status, + ) + ); + + self::collect_failure( + $failures, + 1 === $did_enqueue + && 1 === $enqueue_actions + && ! in_array( false, $template_hooks, true ), + 'wp_enqueue_media() completes its action and registers media templates once', + array( + 'templateHooks' => $template_hooks, + 'didAction' => $did_enqueue, + 'actionCount' => $enqueue_actions, + ) + ); + + self::collect_failure( + $failures, + str_contains( $iframe_html, 'pagenow = \'media-upload-popup\'' ) + && str_contains( $iframe_html, 'alert(1) ' . $ctx->identifier( 3, 8 ); + $events = array(); + $months_filter = static function ( $months ) use ( &$events ): array { + $events[] = $months; + return array( + (object) array( + 'month' => 6, + 'year' => 2026, + ), + ); + }; + + \add_filter( 'media_library_months_with_files', $months_filter ); + try { + $button = self::capture_output( + static function () use ( $editor_id ): void { + \media_buttons( $editor_id ); + } + ); + } finally { + \remove_filter( 'media_library_months_with_files', $months_filter ); + } + + $html_bypass = self::capture_output( + static function (): void { + \media_upload_html_bypass(); + } + ); + $flash_bypass = self::capture_output( + static function (): void { + \media_upload_flash_bypass(); + } + ); + + self::collect_failure( + $failures, + str_contains( $button, 'class="button insert-media add_media"' ) + && str_contains( $button, 'aria-controls="wp-media-modal"' ) + && str_contains( $button, 'data-editor="' . \esc_attr( $editor_id ) . '"' ) + && ! str_contains( $button, $editor_id ) + && self::html_has_no_raw_script( $button ) + && false === \has_filter( 'media_library_months_with_files', $months_filter ), + 'media_buttons() escapes the editor ID while rendering the Add Media button', + array( + 'button' => self::describe_string( $button ), + 'events' => $events, + ) + ); + + self::collect_failure( + $failures, + str_contains( $html_bypass, 'upload-html-bypass' ) + && str_contains( $html_bypass, 'type="button" class="button-link"' ) + && str_contains( $flash_bypass, 'upload-flash-bypass' ) + && str_contains( $flash_bypass, 'type="button" class="button-link"' ) + && self::html_has_no_raw_script( $html_bypass . $flash_bypass ), + 'legacy uploader bypass helpers emit fixed button markup without upload dispatch', + array( + 'htmlBypass' => self::describe_string( $html_bypass ), + 'flashBypass' => self::describe_string( $flash_bypass ), + ) + ); + + return self::row( + $ctx, + 'admin-media-chrome.media-buttons-bypass-output', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function seed_attachment( \ComponentFuzz\FuzzContext $ctx, string $mime, array $args = array() ): \WP_Post { + $extension = (string) ( $args['extension'] ?? self::extension_for_mime( $mime ) ); + $parent_id = isset( $args['parent_id'] ) ? (int) $args['parent_id'] : self::seed_parent_post( $ctx->fork( 'parent' ) ); + $title = (string) ( $args['post_title'] ?? 'Component fuzz attachment ' . $ctx->identifier( 3, 8 ) ); + $excerpt = (string) ( $args['post_excerpt'] ?? 'Component fuzz caption ' . $ctx->text( 0, 18 ) ); + $content = (string) ( $args['post_content'] ?? 'Component fuzz description ' . $ctx->text( 0, 18 ) ); + + $post_id = \wp_insert_post( + array( + 'post_author' => 0, + 'post_content' => $content, + 'post_excerpt' => $excerpt, + 'post_mime_type' => $mime, + 'post_name' => 'component-fuzz-attachment-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ), + 'post_parent' => $parent_id, + 'post_status' => 'inherit', + 'post_title' => $title, + 'post_type' => 'attachment', + 'post_date' => '2026-06-01 12:00:00', + 'post_date_gmt' => '2026-06-01 12:00:00', + 'menu_order' => $ctx->int( 0, 12 ), + 'guid' => 'http://example.test/component-fuzz/' . rawurlencode( $title ) . '.' . $extension, + ), + true + ); + + if ( ! is_int( $post_id ) || $post_id <= 0 ) { + throw new \RuntimeException( 'Could not seed attachment post.' ); + } + + $unsafe_suffix = ! empty( $args['unsafe_file'] ) ? '-unsafe-' : ''; + $file_base = 'component-fuzz-' . $post_id . $unsafe_suffix . '.' . $extension; + $relative_file = '2026/06/' . $file_base; + $width = (int) ( $args['width'] ?? $ctx->int( 640, 1600 ) ); + $height = (int) ( $args['height'] ?? $ctx->int( 480, 1200 ) ); + $metadata = array( + 'width' => max( 0, $width ), + 'height' => max( 0, $height ), + 'file' => $relative_file, + 'filesize' => $ctx->int( 1024, 1048576 ), + 'sizes' => array(), + 'image_meta' => array( + 'caption' => '', + 'credit' => '', + 'created_timestamp' => 0, + 'copyright' => '', + 'title' => '', + ), + ); + + if ( str_starts_with( $mime, 'image/' ) ) { + $metadata['sizes'] = array( + 'thumbnail' => array( + 'file' => 'component-fuzz-' . $post_id . '-150x150.' . $extension, + 'width' => 150, + 'height' => 150, + 'mime-type' => $mime, + 'filesize' => 4096, + ), + 'medium' => array( + 'file' => 'component-fuzz-' . $post_id . '-300x225.' . $extension, + 'width' => 300, + 'height' => 225, + 'mime-type' => $mime, + 'filesize' => 8192, + ), + ); + } + + \update_post_meta( $post_id, '_wp_attached_file', $relative_file ); + \update_post_meta( $post_id, '_wp_attachment_metadata', $metadata ); + \update_post_meta( + $post_id, + '_wp_attachment_image_alt', + (string) ( $args['alt'] ?? 'Alt text "' . $ctx->identifier( 3, 8 ) ) + ); + + $post = \get_post( $post_id ); + if ( ! $post instanceof \WP_Post ) { + throw new \RuntimeException( 'Seeded attachment could not be read.' ); + } + + return $post; + } + + private static function seed_parent_post( \ComponentFuzz\FuzzContext $ctx ): int { + $post_id = \wp_insert_post( + array( + 'post_content' => 'Component fuzz parent content', + 'post_date' => '2026-06-01 11:00:00', + 'post_date_gmt' => '2026-06-01 11:00:00', + 'post_name' => 'component-fuzz-parent-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ), + 'post_status' => 'publish', + 'post_title' => 'Component fuzz parent ' . $ctx->identifier( 3, 8 ), + 'post_type' => 'post', + ), + true + ); + + if ( ! is_int( $post_id ) || $post_id <= 0 ) { + throw new \RuntimeException( 'Could not seed parent post.' ); + } + + return $post_id; + } + + private static function extension_for_mime( string $mime ): string { + $map = array( + 'application/pdf' => 'pdf', + 'image/gif' => 'gif', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'text/plain' => 'txt', + ); + + return $map[ $mime ] ?? 'bin'; + } + + private static function prepare_runtime(): void { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + $GLOBALS['wpdb']->component_fuzz_reset_options( + array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blogname' => 'Component Fuzz', + 'default_category' => 0, + 'default_comment_status' => 'closed', + 'default_ping_status' => 'closed', + 'gmt_offset' => 0, + 'home' => 'http://example.test', + 'image_default_align' => 'none', + 'image_default_link_type' => 'file', + 'image_default_size' => 'medium', + 'large_size_h' => 1024, + 'large_size_w' => 1024, + 'medium_size_h' => 300, + 'medium_size_w' => 300, + 'permalink_structure' => '', + 'siteurl' => 'http://example.test', + 'thumbnail_crop' => 1, + 'thumbnail_size_h' => 150, + 'thumbnail_size_w' => 150, + 'timezone_string' => '', + 'upload_path' => '', + 'upload_url_path' => '', + 'uploads_use_yearmonth_folders' => 1, + ) + ); + } + + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + + $GLOBALS['wp_post_types'] = array(); + $GLOBALS['wp_post_statuses'] = array(); + $GLOBALS['wp_taxonomies'] = array(); + $GLOBALS['post'] = null; + $GLOBALS['post_ID'] = 0; + $GLOBALS['pagenow'] = 'upload.php'; + $GLOBALS['wp_rewrite'] = new \WP_Rewrite(); + + \create_initial_post_types(); + \create_initial_taxonomies(); + \wp_set_current_user( 0 ); + } + + private static function load_default_filters(): void { + $wpdb = $GLOBALS['wpdb'] ?? null; + if ( ! is_object( $wpdb ) ) { + $wpdb = new \stdClass(); + $wpdb->charset = 'utf8mb4'; + $GLOBALS['wpdb'] = $wpdb; + } + + require \ComponentFuzz\repo_root() . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'wp-includes' . DIRECTORY_SEPARATOR . 'default-filters.php'; + } + + private static function cleanup_runtime(): void { + if ( isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + if ( function_exists( 'wp_cache_flush' ) ) { + \wp_cache_flush(); + } + } + + private static function capture_output( callable $callback ): string { + ob_start(); + try { + $callback(); + } catch ( \Throwable $e ) { + ob_end_clean(); + throw $e; + } + + return (string) ob_get_clean(); + } + + 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 html_has_no_raw_script( string $html ): bool { + return ! str_contains( strtolower( $html ), ' $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 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 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 ); + } + + 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 escape_bytes( string $value, int $limit = self::PREVIEW_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; + } + + private static function snapshot_state(): array { + $snapshot = array(); + foreach ( + array( + '_GET', + '_POST', + '_REQUEST', + '_SERVER', + 'body_id', + 'content_width', + 'pagenow', + 'post', + 'post_ID', + 'redir_tab', + 'tab', + 'type', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_statuses', + 'wp_post_types', + 'wp_rewrite', + 'wp_scripts', + 'wp_styles', + 'wp_taxonomies', + ) 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 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_state( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function load_editor_class(): void { + if ( ! class_exists( '_WP_Editors', false ) && defined( 'ABSPATH' ) && defined( 'WPINC' ) ) { + require_once ABSPATH . WPINC . '/class-wp-editor.php'; + } + } + + private static function snapshot_editor_statics(): array { + $statics = array(); + foreach ( self::editor_static_property_names() as $property ) { + $statics[ $property ] = self::clone_value( self::get_editor_static_property( $property ) ); + } + + return $statics; + } + + private static function restore_editor_statics( array $snapshot ): void { + foreach ( $snapshot as $property => $value ) { + self::set_editor_static_property( (string) $property, $value ); + } + } + + private static function editor_static_property_names(): array { + return array( + 'mce_locale', + 'mce_settings', + 'qt_settings', + 'plugins', + 'qt_buttons', + 'ext_plugins', + 'baseurl', + 'first_init', + 'this_tinymce', + 'this_quicktags', + 'has_tinymce', + 'has_quicktags', + 'has_medialib', + 'editor_buttons_css', + 'drag_drop_upload', + 'translation', + 'tinymce_scripts_printed', + 'link_dialog_printed', + ); + } + + private static function get_editor_static_property( string $property ) { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + return $reflection->getValue(); + } + + private static function set_editor_static_property( string $property, $value ): void { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + $reflection->setValue( null, $value ); + } + + 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; + } +} diff --git a/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php b/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php new file mode 100644 index 0000000000000..bdd0504e76137 --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminOptionsSubmissionSurface.php @@ -0,0 +1,2002 @@ +skip( + 'admin-options-submission.bootstrap-apis-available', + 'Required admin settings submission APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_registered_settings_submission( $ctx->fork( 'registered' ) ); + $rows[] = self::check_general_options_submission_branches( $ctx->fork( 'general' ) ); + $rows[] = self::check_core_options_page_sanitization( $ctx->fork( 'core-pages' ) ); + $rows[] = self::check_writing_options_allowlist_gates( $ctx->fork( 'writing-gates' ) ); + $rows[] = self::check_new_admin_email_pending_change( $ctx->fork( 'new-admin-email' ) ); + $rows[] = self::check_legacy_options_page_submission( $ctx->fork( 'legacy' ) ); + $rows[] = self::check_failure_paths( $ctx->fork( 'failures' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-options-submission.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'admin-options-submission.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot['globals'] ) ) + ); + + return $rows; + } + + private static function load_new_admin_email_support(): void { + $misc_file = defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/misc.php' : ''; + if ( ! function_exists( 'update_option_new_admin_email' ) && $misc_file && file_exists( $misc_file ) ) { + require_once $misc_file; + } + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'add_filter', + 'add_query_arg', + 'add_action', + 'add_option', + 'add_settings_error', + 'admin_url', + 'apply_filters', + 'apply_filters_deprecated', + 'check_admin_referer', + 'current_user_can', + 'delete_transient', + 'esc_url', + 'esc_html', + 'get_current_user_id', + 'get_settings_errors', + 'get_site_option', + 'get_transient', + 'get_user_locale', + 'get_option', + 'has_action', + 'has_filter', + 'home_url', + 'is_email', + 'is_multisite', + 'is_utf8_charset', + 'is_wp_error', + 'load_default_textdomain', + 'option_update_filter', + 'restore_previous_locale', + 'remove_action', + 'register_setting', + 'remove_filter', + 'sanitize_key', + 'sanitize_email', + 'sanitize_text_field', + 'sanitize_textarea_field', + 'sanitize_option', + 'set_transient', + 'unregister_setting', + 'update_option', + 'update_option_new_admin_email', + 'wp_cache_flush', + 'wp_create_nonce', + 'wp_die', + 'wp_get_current_user', + 'wp_get_referer', + 'wp_insert_user', + 'wp_mail', + 'wp_rand', + 'wp_redirect', + 'wp_set_current_user', + 'wp_slash', + 'wp_specialchars_decode', + 'wp_unslash', + 'self_admin_url', + 'switch_to_user_locale', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'Component_Fuzz_WPDB_Stub', false ) ) { + $missing[] = 'class Component_Fuzz_WPDB_Stub'; + } + if ( ! class_exists( 'WP_User', false ) ) { + $missing[] = 'class WP_User'; + } + + return $missing; + } + + private static function check_registered_settings_submission( \ComponentFuzz\FuzzContext $ctx ): array { + self::reset_runtime(); + + $failures = array(); + $group = self::id( $ctx->fork( 'group' ), 'cfz_settings_group', 40 ); + $text_option = self::id( $ctx->fork( 'text' ), 'cfz_settings_text', 40 ); + $array_option = self::id( $ctx->fork( 'array' ), 'cfz_settings_array', 40 ); + $missing_option = self::id( $ctx->fork( 'missing' ), 'cfz_settings_missing', 40 ); + $error_option = self::id( $ctx->fork( 'error' ), 'cfz_settings_error', 40 ); + $intruder = self::id( $ctx->fork( 'intruder' ), 'cfz_settings_intruder', 40 ); + $raw_text = " Raw " . $ctx->identifier( 4, 8 ) . " \\\"quoted\\\" "; + $raw_error = 'Needs review '; + $array_raw = array( + ' First Value ', + '' . $ctx->identifier( 3, 7 ) . '', + 'third\\value', + ); + $sanitize_calls = array(); + + \add_option( $text_option, 'previous text' ); + \add_option( $array_option, array( 'previous-array' ) ); + \add_option( $missing_option, 'previous missing' ); + \add_option( $error_option, 'previous error' ); + + $text_sanitize = static function ( $value ) use ( &$sanitize_calls ): string { + $sanitize_calls['text'][] = $value; + return 'text:' . \sanitize_text_field( (string) $value ); + }; + $array_sanitize = static function ( $value ) use ( &$sanitize_calls ): array { + $sanitize_calls['array'][] = $value; + return array_map( + static fn( $item ): string => \sanitize_key( \sanitize_text_field( (string) $item ) ), + (array) $value + ); + }; + $missing_sanitize = static function ( $value ) use ( &$sanitize_calls ): string { + $sanitize_calls['missing'][] = $value; + return null === $value ? 'missing-was-null' : 'missing:' . \sanitize_text_field( (string) $value ); + }; + $error_sanitize = static function ( $value ) use ( &$sanitize_calls, $error_option ): string { + $sanitize_calls['error'][] = $value; + \add_settings_error( $error_option, 'component_fuzz_rejected', 'Generated setting requires review.', 'error' ); + return 'error:' . \sanitize_text_field( (string) $value ); + }; + + \register_setting( $group, $text_option, array( 'sanitize_callback' => $text_sanitize ) ); + \register_setting( $group, $array_option, array( 'sanitize_callback' => $array_sanitize ) ); + \register_setting( $group, $missing_option, array( 'sanitize_callback' => $missing_sanitize ) ); + \register_setting( $group, $error_option, array( 'sanitize_callback' => $error_sanitize ) ); + + $cap_events = array(); + $cap_filter = self::install_cap_filter( + array( 'manage_options', 'manage_component_fuzz_settings' ), + $cap_events + ); + $capability_filter = static function ( string $capability ) use ( &$cap_events ): string { + $cap_events[] = array( + 'type' => 'option-page-capability', + 'capability' => $capability, + ); + return 'manage_component_fuzz_settings'; + }; + + \add_filter( "option_page_capability_{$group}", $capability_filter ); + \add_filter( 'allowed_options', 'option_update_filter' ); + + try { + $result = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $group, + '_wpnonce' => \wp_create_nonce( $group . '-options' ), + $text_option => $raw_text, + $array_option => $array_raw, + $error_option => $raw_error, + $intruder => 'posted but not allowlisted', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=' . rawurlencode( $group ) . '&tab=main', + ) + ); + } finally { + \remove_filter( 'allowed_options', 'option_update_filter' ); + \remove_filter( "option_page_capability_{$group}", $capability_filter ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + \unregister_setting( $group, $text_option ); + \unregister_setting( $group, $array_option ); + \unregister_setting( $group, $missing_option ); + \unregister_setting( $group, $error_option ); + } + + $errors = $result['settingsErrors'] ?? array(); + $transient = $result['settingsTransient'] ?? array(); + $expected_text = 'text:' . \sanitize_text_field( (string) trim( $raw_text ) ); + $expected_array = array_map( + static fn( $item ): string => \sanitize_key( \sanitize_text_field( (string) $item ) ), + $array_raw + ); + $expected_error = 'error:' . \sanitize_text_field( (string) trim( $raw_error ) ); + + self::collect_failure( + $failures, + 'completed' === ( $result['status'] ?? null ) + && false === ( $result['redirectResult'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && 302 === ( $result['redirect']['status'] ?? null ) + && array( trim( $raw_text ) ) === ( $sanitize_calls['text'] ?? null ) + && array( $array_raw ) === ( $sanitize_calls['array'] ?? null ) + && array( null ) === ( $sanitize_calls['missing'] ?? null ) + && array( trim( $raw_error ) ) === ( $sanitize_calls['error'] ?? null ) + && $expected_text === \get_option( $text_option ) + && $expected_array === \get_option( $array_option ) + && 'missing-was-null' === \get_option( $missing_option ) + && $expected_error === \get_option( $error_option ) + && '__missing__' === \get_option( $intruder, '__missing__' ) + && in_array( $text_option, $result['allowedOptions'][ $group ] ?? array(), true ) + && in_array( $array_option, $result['updatedOptions'] ?? array(), true ) + && self::has_settings_error( $errors, $error_option, 'component_fuzz_rejected', 'error' ) + && $errors === $transient + && ! self::has_settings_error( $errors, 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $result, $group . '-options', 1 ) + && self::capability_event_seen( $cap_events, 'manage_component_fuzz_settings' ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ) + && false === \has_filter( "option_page_capability_{$group}", $capability_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'registered Settings API submission updates only allowlisted options, runs sanitize callbacks, persists errors, and redirects back', + array( + 'group' => $group, + 'result' => self::summarize_dispatch_result( $result ), + 'sanitizeCalls' => self::describe_value( $sanitize_calls ), + 'capEvents' => array_slice( $cap_events, 0, 8 ), + 'stored' => array( + 'text' => \get_option( $text_option ), + 'array' => \get_option( $array_option ), + 'missing' => \get_option( $missing_option ), + 'error' => \get_option( $error_option ), + 'intruder' => \get_option( $intruder, '__missing__' ), + ), + 'errors' => $errors, + ) + ); + + return $ctx->result( + 'admin-options-submission.registered-settings-update', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 3 ) ) + ); + } + + private static function check_general_options_submission_branches( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $email_filter = static function ( $is_email, string $email ) { + return 'admin@example.com' === $email ? $email : $is_email; + }; + $sanitize_email_filter = static function ( string $sanitized, string $email ): string { + return 'admin@example.com' === $email ? $email : $sanitized; + }; + \add_filter( 'is_email', $email_filter, 10, 2 ); + \add_filter( 'sanitize_email', $sanitize_email_filter, 10, 2 ); + + try { + self::reset_runtime( + array( + 'timezone_string' => 'Europe/Madrid', + 'gmt_offset' => '1', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + ) + ); + + $invalid_tz = self::dispatch_options_update( + array( + 'post' => array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'date_format' => '\c\u\s\t\o\m', + 'date_format_custom' => 'Y/m/d ' . $ctx->identifier( 3, 6 ) . '', + 'time_format' => '\c\u\s\t\o\m', + 'time_format_custom' => 'H:i:s ' . $ctx->identifier( 3, 6 ) . '', + 'timezone_string' => 'Mars/Olympus Mons', + 'gmt_offset' => '1', + 'blogname' => 'General Branch', + 'blogdescription' => 'Description Branch', + ) + ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + + $invalid_values = array( + 'date_format' => \get_option( 'date_format' ), + 'time_format' => \get_option( 'time_format' ), + 'timezone_string' => \get_option( 'timezone_string' ), + 'gmt_offset' => \get_option( 'gmt_offset' ), + 'blogname' => \get_option( 'blogname' ), + 'blogdescription' => \get_option( 'blogdescription' ), + ); + + self::reset_runtime( + array( + 'timezone_string' => 'Europe/Madrid', + 'gmt_offset' => '1', + ) + ); + $utc_offset = self::dispatch_options_update( + array( + 'post' => array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'timezone_string' => 'UTC+5.5', + 'gmt_offset' => '1', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + 'blogname' => 'UTC Branch', + 'blogdescription' => 'UTC Description', + ) + ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $utc_values = array( + 'timezone_string' => \get_option( 'timezone_string' ), + 'gmt_offset' => \get_option( 'gmt_offset' ), + ); + } finally { + \remove_filter( 'sanitize_email', $sanitize_email_filter, 10 ); + \remove_filter( 'is_email', $email_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + 'completed' === ( $invalid_tz['status'] ?? null ) + && self::redirect_has_settings_updated( $invalid_tz['redirect']['location'] ?? '' ) + && str_contains( $invalid_values['date_format'], 'Y/m/d' ) + && ! str_contains( $invalid_values['date_format'], '<' ) + && str_contains( $invalid_values['time_format'], 'H:i:s' ) + && ! str_contains( $invalid_values['time_format'], '<' ) + && 'Europe/Madrid' === $invalid_values['timezone_string'] + && '1' === (string) $invalid_values['gmt_offset'] + && 'General <b>Branch</b>' === $invalid_values['blogname'] + && 'Description <i>Branch</i>' === $invalid_values['blogdescription'] + && self::has_settings_error( $invalid_tz['settingsErrors'] ?? array(), 'general', 'settings_updated', 'error' ) + && ! self::has_settings_error( $invalid_tz['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $invalid_tz, 'general-options', 1 ), + 'general options submission applies custom date/time formats, rejects invalid timezones, preserves current timezone, and stores error transient', + array( + 'result' => self::summarize_dispatch_result( $invalid_tz ), + 'values' => $invalid_values, + ) + ); + + self::collect_failure( + $failures, + 'completed' === ( $utc_offset['status'] ?? null ) + && '' === $utc_values['timezone_string'] + && '5.5' === (string) $utc_values['gmt_offset'] + && self::has_settings_error( $utc_offset['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && ! self::has_settings_error( $utc_offset['settingsErrors'] ?? array(), 'general', 'settings_updated', 'error' ) + && self::nonce_event_seen( $utc_offset, 'general-options', 1 ) + && false === \has_filter( 'sanitize_email', $sanitize_email_filter ) + && false === \has_filter( 'is_email', $email_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'general options submission maps UTC offsets to gmt_offset and records the default success settings error', + array( + 'result' => self::summarize_dispatch_result( $utc_offset ), + 'values' => $utc_values, + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.general-date-timezone-branches', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 4 ) ) + ); + } + + private static function check_core_options_page_sanitization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $suffix = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 5, 12 ) ) ); + $suffix = '' === $suffix ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + + $reading = self::dispatch_core_page_update( + 'reading', + array( + 'posts_per_page' => '-7 posts', + 'posts_per_rss' => '0', + 'rss_use_excerpt' => ' 1 ', + 'show_on_front' => 'page', + 'page_on_front' => '-42front', + 'page_for_posts' => '77posts', + ), + array( + 'posts_per_page' => 10, + 'posts_per_rss' => 10, + 'blog_public' => '0', + ) + ); + + self::collect_failure( + $failures, + self::core_page_completed( $reading, 'reading' ) + && array( 'posts_per_page', 'posts_per_rss', 'rss_use_excerpt', 'show_on_front', 'page_on_front', 'page_for_posts', 'blog_public' ) === ( $reading['result']['updatedOptions'] ?? null ) + && 7 === \get_option( 'posts_per_page' ) + && 1 === \get_option( 'posts_per_rss' ) + && '1' === \get_option( 'rss_use_excerpt' ) + && 'page' === \get_option( 'show_on_front' ) + && 42 === \get_option( 'page_on_front' ) + && 77 === \get_option( 'page_for_posts' ) + && 1 === \get_option( 'blog_public' ) + && self::pre_update_event_seen( $reading['preUpdateEvents'], 'blog_public', '0', 1 ) + && self::pre_update_event_seen( $reading['preUpdateEvents'], 'posts_per_rss', 10, 1 ), + 'reading options page sanitizes numeric pagination/front-page values and applies the missing blog_public checkbox default', + array( + 'result' => self::summarize_dispatch_result( $reading['result'] ), + 'stored' => self::stored_options( + array( 'posts_per_page', 'posts_per_rss', 'rss_use_excerpt', 'show_on_front', 'page_on_front', 'page_for_posts', 'blog_public' ) + ), + 'preUpdateEvents' => $reading['preUpdateEvents'], + 'capEvents' => array_slice( $reading['capEvents'], 0, 8 ), + ) + ); + + $discussion = self::dispatch_core_page_update( + 'discussion', + array( + 'default_pingback_flag' => '1', + 'default_ping_status' => '0', + 'default_comment_status' => '', + 'comments_notify' => '1', + 'moderation_notify' => '0', + 'comment_moderation' => '1', + 'require_name_email' => '1', + 'comment_previously_approved' => '0', + 'comment_max_links' => '-11 links', + 'moderation_keys' => "hold-{$suffix}\n hold-{$suffix} \nreview-{$suffix}\n\n", + 'disallowed_keys' => "spam-{$suffix}\n\nspam-{$suffix}\ntrash-{$suffix} ", + 'show_avatars' => '1', + 'avatar_rating' => 'pg', + 'avatar_default' => 'mystery', + 'close_comments_for_old_posts' => '1', + 'close_comments_days_old' => '-30 days', + 'thread_comments' => '1', + 'thread_comments_depth' => '-4', + 'page_comments' => '1', + 'comments_per_page' => '-50', + 'default_comments_page' => 'newest', + 'comment_order' => 'desc', + 'comment_registration' => '1', + 'show_comments_cookies_opt_in' => '1', + 'wp_notes_notify' => '0', + ) + ); + + $expected_moderation = "hold-{$suffix}\nreview-{$suffix}"; + $expected_disallowed = "spam-{$suffix}\ntrash-{$suffix}"; + self::collect_failure( + $failures, + self::core_page_completed( $discussion, 'discussion' ) + && ( $discussion['result']['allowedOptions']['discussion'] ?? array() ) === ( $discussion['result']['updatedOptions'] ?? null ) + && 'closed' === \get_option( 'default_ping_status' ) + && 'closed' === \get_option( 'default_comment_status' ) + && 11 === \get_option( 'comment_max_links' ) + && $expected_moderation === \get_option( 'moderation_keys' ) + && $expected_disallowed === \get_option( 'disallowed_keys' ) + && 30 === \get_option( 'close_comments_days_old' ) + && 4 === \get_option( 'thread_comments_depth' ) + && 50 === \get_option( 'comments_per_page' ) + && self::pre_update_event_seen( $discussion['preUpdateEvents'], 'default_ping_status', false, 'closed' ) + && self::pre_update_event_seen( $discussion['preUpdateEvents'], 'moderation_keys', false, $expected_moderation ), + 'discussion options page normalizes closed statuses, absolute integer fields, and unique keyword lists', + array( + 'result' => self::summarize_dispatch_result( $discussion['result'] ), + 'stored' => self::stored_options( + array( 'default_ping_status', 'default_comment_status', 'comment_max_links', 'moderation_keys', 'disallowed_keys', 'close_comments_days_old', 'thread_comments_depth', 'comments_per_page' ) + ), + 'preUpdateEvents' => $discussion['preUpdateEvents'], + 'capEvents' => array_slice( $discussion['capEvents'], 0, 8 ), + ) + ); + + $media = self::dispatch_core_page_update( + 'media', + array( + 'thumbnail_size_w' => '-150px', + 'thumbnail_size_h' => '90px', + 'thumbnail_crop' => '1', + 'medium_size_w' => '-640', + 'medium_size_h' => '0', + 'large_size_w' => '-2048', + 'large_size_h' => '1024', + 'image_default_size' => 'large', + 'image_default_align' => 'left', + 'image_default_link_type' => 'file', + 'uploads_use_yearmonth_folders' => '1', + ) + ); + + self::collect_failure( + $failures, + self::core_page_completed( $media, 'media' ) + && ( $media['result']['allowedOptions']['media'] ?? array() ) === ( $media['result']['updatedOptions'] ?? null ) + && 150 === \get_option( 'thumbnail_size_w' ) + && 90 === \get_option( 'thumbnail_size_h' ) + && '1' === \get_option( 'thumbnail_crop' ) + && 640 === \get_option( 'medium_size_w' ) + && 0 === \get_option( 'medium_size_h' ) + && 2048 === \get_option( 'large_size_w' ) + && 1024 === \get_option( 'large_size_h' ) + && 'large' === \get_option( 'image_default_size' ) + && 'left' === \get_option( 'image_default_align' ) + && 'file' === \get_option( 'image_default_link_type' ) + && self::pre_update_event_seen( $media['preUpdateEvents'], 'thumbnail_size_w', false, 150 ) + && self::pre_update_event_seen( $media['preUpdateEvents'], 'large_size_w', false, 2048 ), + 'media options page applies absint dimensions while preserving enumerated media defaults', + array( + 'result' => self::summarize_dispatch_result( $media['result'] ), + 'stored' => self::stored_options( + array( 'thumbnail_size_w', 'thumbnail_size_h', 'thumbnail_crop', 'medium_size_w', 'medium_size_h', 'large_size_w', 'large_size_h', 'image_default_size', 'image_default_align', 'image_default_link_type', 'uploads_use_yearmonth_folders' ) + ), + 'preUpdateEvents' => $media['preUpdateEvents'], + 'capEvents' => array_slice( $media['capEvents'], 0, 8 ), + ) + ); + + $writing = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '-12', + 'default_email_category' => '34cats', + 'default_link_category' => '-56links', + 'default_post_format' => 'aside', + 'mailserver_url' => " mail.{$suffix}.example.test ", + 'mailserver_port' => '-110', + 'mailserver_login' => " login{$suffix} ", + 'mailserver_pass' => " pass{$suffix} ", + ), + array(), + static function (): callable { + $initial_db_filter = static function () { + return 32453; + }; + \add_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + + return static function () use ( $initial_db_filter ): void { + \remove_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + }; + } + ); + + self::collect_failure( + $failures, + self::core_page_completed( $writing, 'writing' ) + && ( $writing['result']['allowedOptions']['writing'] ?? array() ) === ( $writing['result']['updatedOptions'] ?? null ) + && 12 === \get_option( 'default_category' ) + && 34 === \get_option( 'default_email_category' ) + && 56 === \get_option( 'default_link_category' ) + && 'aside' === \get_option( 'default_post_format' ) + && "mail.{$suffix}.example.test" === \get_option( 'mailserver_url' ) + && 110 === \get_option( 'mailserver_port' ) + && "login{$suffix}" === \get_option( 'mailserver_login' ) + && "pass{$suffix}" === \get_option( 'mailserver_pass' ) + && self::pre_update_event_seen( $writing['preUpdateEvents'], 'mailserver_url', false, "mail.{$suffix}.example.test" ) + && self::pre_update_event_seen( $writing['preUpdateEvents'], 'mailserver_port', false, 110 ), + 'writing options page sanitizes category IDs, mail server port, and stripped mail server text fields', + array( + 'result' => self::summarize_dispatch_result( $writing['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass' ) + ), + 'preUpdateEvents' => $writing['preUpdateEvents'], + 'capEvents' => array_slice( $writing['capEvents'], 0, 8 ), + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'pre_update_option' ) + && false === \has_filter( 'user_has_cap' ), + 'core page submission filters are removed after the matrix', + array( + 'preUpdate' => \has_filter( 'pre_update_option' ), + 'cap' => \has_filter( 'user_has_cap' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.core-page-sanitization-matrix', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_writing_options_allowlist_gates( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $suffix = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 5, 12 ) ) ); + $suffix = '' === $suffix ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + + $enabled_ping_sites = " https://updates.example/{$suffix}\n\nhttp://example.test/{$suffix}?a=1 "; + $enabled = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '-12', + 'default_email_category' => '34cats', + 'default_link_category' => '-56links', + 'default_post_format' => 'quote', + 'mailserver_url' => " mail.{$suffix}.example.test ", + 'mailserver_port' => '-110', + 'mailserver_login' => " login{$suffix} ", + 'mailserver_pass' => " pass{$suffix} ", + 'use_smilies' => '1', + 'use_balanceTags' => '0', + 'ping_sites' => $enabled_ping_sites, + 'not_a_writing_option' => 'do-not-store', + ), + array( + 'blog_public' => '1', + ), + self::writing_gate_filter_installer( true, 32452 ) + ); + $enabled_expected = array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + 'mailserver_url', + 'mailserver_port', + 'mailserver_login', + 'mailserver_pass', + 'use_smilies', + 'use_balanceTags', + 'ping_sites', + ); + $expected_ping_sites = "https://updates.example/{$suffix}\nhttp://example.test/{$suffix}?a=1"; + + self::collect_failure( + $failures, + self::core_page_completed( $enabled, 'writing' ) + && $enabled_expected === ( $enabled['result']['allowedOptions']['writing'] ?? null ) + && $enabled_expected === ( $enabled['result']['updatedOptions'] ?? null ) + && 12 === \get_option( 'default_category' ) + && 34 === \get_option( 'default_email_category' ) + && 56 === \get_option( 'default_link_category' ) + && 'quote' === \get_option( 'default_post_format' ) + && "mail.{$suffix}.example.test" === \get_option( 'mailserver_url' ) + && 110 === \get_option( 'mailserver_port' ) + && "login{$suffix}" === \get_option( 'mailserver_login' ) + && "pass{$suffix}" === \get_option( 'mailserver_pass' ) + && '1' === \get_option( 'use_smilies' ) + && '0' === \get_option( 'use_balanceTags' ) + && $expected_ping_sites === \get_option( 'ping_sites' ) + && '__missing__' === \get_option( 'not_a_writing_option', '__missing__' ) + && self::pre_update_event_seen( $enabled['preUpdateEvents'], 'ping_sites', false, $expected_ping_sites ) + && self::pre_update_event_seen( $enabled['preUpdateEvents'], 'mailserver_port', false, 110 ), + 'enabled legacy/public writing submission includes post-by-email, legacy formatting, and update-service allowlist branches', + array( + 'result' => self::summarize_dispatch_result( $enabled['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass', 'use_smilies', 'use_balanceTags', 'ping_sites', 'not_a_writing_option' ) + ), + 'preUpdateEvents' => $enabled['preUpdateEvents'], + 'capEvents' => array_slice( $enabled['capEvents'], 0, 8 ), + ) + ); + + $disabled = self::dispatch_core_page_update( + 'writing', + array( + 'default_category' => '9', + 'default_email_category' => '8', + 'default_link_category' => '7', + 'default_post_format' => 'status', + 'mailserver_url' => 'posted.example.test', + 'mailserver_port' => '995', + 'mailserver_login' => 'posted-login', + 'mailserver_pass' => 'posted-pass', + 'use_smilies' => '1', + 'use_balanceTags' => '1', + 'ping_sites' => 'https://posted.example.test/', + ), + array( + 'blog_public' => '0', + 'mailserver_url' => 'keep.mail.example.test', + 'mailserver_port' => 143, + 'mailserver_login' => 'keep-login', + 'mailserver_pass' => 'keep-pass', + 'use_smilies' => '0', + 'use_balanceTags' => '0', + 'ping_sites' => 'https://old.example.test/', + ), + self::writing_gate_filter_installer( false, 32453 ) + ); + $disabled_expected = array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + ); + + self::collect_failure( + $failures, + self::core_page_completed( $disabled, 'writing' ) + && $disabled_expected === ( $disabled['result']['allowedOptions']['writing'] ?? null ) + && $disabled_expected === ( $disabled['result']['updatedOptions'] ?? null ) + && 9 === \get_option( 'default_category' ) + && 8 === \get_option( 'default_email_category' ) + && 7 === \get_option( 'default_link_category' ) + && 'status' === \get_option( 'default_post_format' ) + && 'keep.mail.example.test' === \get_option( 'mailserver_url' ) + && 143 === \get_option( 'mailserver_port' ) + && 'keep-login' === \get_option( 'mailserver_login' ) + && 'keep-pass' === \get_option( 'mailserver_pass' ) + && '0' === \get_option( 'use_smilies' ) + && '0' === \get_option( 'use_balanceTags' ) + && 'https://old.example.test/' === \get_option( 'ping_sites' ) + && ! self::pre_update_option_seen( $disabled['preUpdateEvents'], 'mailserver_url' ) + && ! self::pre_update_option_seen( $disabled['preUpdateEvents'], 'ping_sites' ), + 'disabled modern/private writing submission ignores posted gated fields and updates only base writing options', + array( + 'result' => self::summarize_dispatch_result( $disabled['result'] ), + 'stored' => self::stored_options( + array( 'default_category', 'default_email_category', 'default_link_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'mailserver_pass', 'use_smilies', 'use_balanceTags', 'ping_sites' ) + ), + 'preUpdateEvents' => $disabled['preUpdateEvents'], + 'capEvents' => array_slice( $disabled['capEvents'], 0, 8 ), + ) + ); + + self::collect_failure( + $failures, + false === \has_filter( 'enable_post_by_email_configuration' ) + && false === \has_filter( 'pre_site_option_initial_db_version' ) + && false === \has_filter( 'pre_update_option' ) + && false === \has_filter( 'user_has_cap' ), + 'writing allowlist gate filters are removed after enabled and disabled dispatches', + array( + 'postByEmail' => \has_filter( 'enable_post_by_email_configuration' ), + 'dbVersion' => \has_filter( 'pre_site_option_initial_db_version' ), + 'preUpdate' => \has_filter( 'pre_update_option' ), + 'cap' => \has_filter( 'user_has_cap' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.writing-allowlist-gates', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 4 ) ) + ); + } + + private static function check_new_admin_email_pending_change( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = strtolower( preg_replace( '/[^a-z0-9]+/', '', $ctx->identifier( 6, 12 ) ) ); + $token = '' === $token ? 'cfz' . substr( md5( (string) $ctx->seed() ), 0, 8 ) : $token; + $current_email = 'current-admin-' . $token . '@example.com'; + $pending_email = 'pending-admin-' . $token . '@example.com'; + $invalid_email = 'not-an-email-' . $token; + $site_title = 'Admin Email ' . $token; + $site_url = 'https://example.com/component-fuzz-' . $token; + $user_login = 'cfz_admin_email_' . $token; + $user_email = $user_login . '@example.com'; + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $accepted_emails = array_fill_keys( array( $current_email, $pending_email, $user_email ), true ); + $email_filter = static function ( $is_email, string $email ) use ( $accepted_emails ) { + return isset( $accepted_emails[ $email ] ) ? $email : $is_email; + }; + $sanitize_email_filter = static function ( string $sanitized, string $email ) use ( $accepted_emails ): string { + return isset( $accepted_emails[ $email ] ) ? $email : $sanitized; + }; + $pre_mail_filter = static function ( $pre, array $atts ) use ( &$mail_events ) { + unset( $pre ); + $mail_events[] = $atts; + return true; + }; + $content_filter = static function ( string $content, array $new_admin_email ) use ( &$content_events ): string { + $content_events[] = $new_admin_email; + return $content; + }; + $subject_filter = static function ( string $subject ) use ( &$subject_events ): string { + $subject_events[] = $subject; + return '[cfz] ' . $subject; + }; + $added_add_hook = false; + $added_update_hook = false; + + \add_filter( 'is_email', $email_filter, 10, 2 ); + \add_filter( 'sanitize_email', $sanitize_email_filter, 10, 2 ); + \add_filter( 'pre_wp_mail', $pre_mail_filter, 10, 2 ); + \add_filter( 'new_admin_email_content', $content_filter, 10, 2 ); + \add_filter( 'new_admin_email_subject', $subject_filter, 10 ); + + if ( false === \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ) ) { + \add_action( 'add_option_new_admin_email', 'update_option_new_admin_email', 10, 2 ); + $added_add_hook = true; + } + if ( false === \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ) ) { + \add_action( 'update_option_new_admin_email', 'update_option_new_admin_email', 10, 2 ); + $added_update_hook = true; + } + + try { + $user = self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $valid = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $pending_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $valid_adminhash = \get_option( 'adminhash', '__missing__' ); + $valid_mail = $mail_events[0] ?? null; + $valid_message = is_array( $valid_mail ) ? (string) ( $valid_mail['message'] ?? '' ) : ''; + $valid_subject = is_array( $valid_mail ) ? (string) ( $valid_mail['subject'] ?? '' ) : ''; + + self::collect_failure( + $failures, + 'completed' === ( $valid['status'] ?? null ) + && self::redirect_has_settings_updated( $valid['redirect']['location'] ?? '' ) + && self::nonce_event_seen( $valid, 'general-options', 1 ) + && self::has_settings_error( $valid['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && $current_email === \get_option( 'admin_email' ) + && $pending_email === \get_option( 'new_admin_email' ) + && is_array( $valid_adminhash ) + && $pending_email === ( $valid_adminhash['newemail'] ?? null ) + && is_string( $valid_adminhash['hash'] ?? null ) + && 1 === preg_match( '/^[a-f0-9]{32}$/', (string) ( $valid_adminhash['hash'] ?? '' ) ) + && in_array( 'new_admin_email', $valid['updatedOptions'] ?? array(), true ) + && 1 === count( $mail_events ) + && is_array( $valid_mail ) + && $pending_email === ( $valid_mail['to'] ?? null ) + && str_starts_with( $valid_subject, '[cfz] ' ) + && str_contains( $subject_events[0] ?? '', $site_title ) + && isset( $content_events[0]['hash'], $content_events[0]['newemail'] ) + && ( $valid_adminhash['hash'] ?? null ) === $content_events[0]['hash'] + && $pending_email === $content_events[0]['newemail'] + && str_contains( $valid_message, $pending_email ) + && str_contains( $valid_message, (string) $user->user_login ) + && str_contains( $valid_message, 'options.php?adminhash=' . (string) ( $valid_adminhash['hash'] ?? '' ) ) + && str_contains( $valid_message, (string) ( $valid_adminhash['hash'] ?? '' ) ) + && ! str_contains( $valid_message, '###' ) + && self::capability_event_seen( $cap_events, 'manage_options' ), + 'changed General Settings new_admin_email stores pending adminhash and sends one confirmation email without changing admin_email', + array( + 'result' => self::summarize_dispatch_result( $valid ), + 'adminhash' => $valid_adminhash, + 'mailEvents' => array_slice( $mail_events, 0, 3 ), + 'contentEvents' => $content_events, + 'subjectEvents' => $subject_events, + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $same_current = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $current_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $same_adminhash = \get_option( 'adminhash', '__missing__' ); + + self::collect_failure( + $failures, + 'completed' === ( $same_current['status'] ?? null ) + && self::nonce_event_seen( $same_current, 'general-options', 1 ) + && self::has_settings_error( $same_current['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && $current_email === \get_option( 'admin_email' ) + && $current_email === \get_option( 'new_admin_email' ) + && '__missing__' === $same_adminhash + && array() === $mail_events + && array() === $content_events + && array() === $subject_events, + 'same-current new_admin_email persists the submitted option but does not create adminhash or send confirmation mail', + array( + 'result' => self::summarize_dispatch_result( $same_current ), + 'adminhash' => $same_adminhash, + 'mailEvents' => $mail_events, + ) + ); + + $mail_events = array(); + $content_events = array(); + $subject_events = array(); + self::prepare_admin_email_runtime( $current_email, $site_title, $site_url, $user_login ); + $invalid = self::dispatch_options_update( + array( + 'post' => self::general_admin_email_post( $invalid_email, $site_title, $site_url ), + 'referer' => 'http://example.test/wp-admin/options-general.php', + ) + ); + $invalid_adminhash = \get_option( 'adminhash', '__missing__' ); + + self::collect_failure( + $failures, + 'completed' === ( $invalid['status'] ?? null ) + && self::nonce_event_seen( $invalid, 'general-options', 1 ) + && self::has_settings_error( $invalid['settingsErrors'] ?? array(), 'new_admin_email', 'invalid_new_admin_email', 'error' ) + && $current_email === \get_option( 'admin_email' ) + && '__missing__' === $invalid_adminhash + && array() === $mail_events + && array() === $content_events + && array() === $subject_events, + 'invalid new_admin_email records the sanitization error but does not create adminhash or send confirmation mail', + array( + 'result' => self::summarize_dispatch_result( $invalid ), + 'adminhash' => $invalid_adminhash, + 'mailEvents' => $mail_events, + ) + ); + } finally { + if ( $added_update_hook ) { + \remove_action( 'update_option_new_admin_email', 'update_option_new_admin_email', 10 ); + } + if ( $added_add_hook ) { + \remove_action( 'add_option_new_admin_email', 'update_option_new_admin_email', 10 ); + } + \remove_filter( 'new_admin_email_subject', $subject_filter, 10 ); + \remove_filter( 'new_admin_email_content', $content_filter, 10 ); + \remove_filter( 'pre_wp_mail', $pre_mail_filter, 10 ); + \remove_filter( 'sanitize_email', $sanitize_email_filter, 10 ); + \remove_filter( 'is_email', $email_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + false === \has_filter( 'pre_wp_mail', $pre_mail_filter ) + && false === \has_filter( 'new_admin_email_content', $content_filter ) + && false === \has_filter( 'new_admin_email_subject', $subject_filter ) + && false === \has_filter( 'sanitize_email', $sanitize_email_filter ) + && false === \has_filter( 'is_email', $email_filter ) + && false === \has_filter( 'user_has_cap', $cap_filter ) + && ( ! $added_add_hook || false === \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ) ) + && ( ! $added_update_hook || false === \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ) ), + 'new_admin_email pending-change filters and locally installed dynamic option hooks are removed after dispatches', + array( + 'preMail' => \has_filter( 'pre_wp_mail', $pre_mail_filter ), + 'content' => \has_filter( 'new_admin_email_content', $content_filter ), + 'subject' => \has_filter( 'new_admin_email_subject', $subject_filter ), + 'sanitize' => \has_filter( 'sanitize_email', $sanitize_email_filter ), + 'isEmail' => \has_filter( 'is_email', $email_filter ), + 'capability' => \has_filter( 'user_has_cap', $cap_filter ), + 'addHook' => \has_action( 'add_option_new_admin_email', 'update_option_new_admin_email' ), + 'updateHook' => \has_action( 'update_option_new_admin_email', 'update_option_new_admin_email' ), + ) + ); + + return $ctx->result( + 'admin-options-submission.new-admin-email-pending-change', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_legacy_options_page_submission( \ComponentFuzz\FuzzContext $ctx ): array { + self::reset_runtime(); + + $failures = array(); + $first = self::id( $ctx->fork( 'first' ), 'cfz_legacy_first', 36 ); + $second = self::id( $ctx->fork( 'second' ), 'cfz_legacy_second', 36 ); + $missing = self::id( $ctx->fork( 'missing' ), 'cfz_legacy_missing', 36 ); + $intruder = self::id( $ctx->fork( 'intruder' ), 'cfz_legacy_intruder', 36 ); + $cap_events = array(); + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + + try { + $result = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + '_wpnonce' => \wp_create_nonce( 'update-options' ), + 'page_options' => implode( ',', array( $first, ' ' . $second . ' ', $missing ) ), + $first => " Alpha " . $ctx->identifier( 3, 8 ) . " ", + $second => array( ' one ', 'two\\three' ), + $intruder => 'not-listed', + ), + 'referer' => 'http://example.test/wp-admin/options.php', + ) + ); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + self::collect_failure( + $failures, + 'completed' === ( $result['status'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && str_starts_with( \get_option( $first ), 'Alpha ' ) + && array( ' one ', 'two\\three' ) === \get_option( $second ) + && '' === \get_option( $missing, '__not-null__' ) + && '__missing__' === \get_option( $intruder, '__missing__' ) + && array( $first, $second, $missing ) === ( $result['updatedOptions'] ?? null ) + && self::has_settings_error( $result['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && self::nonce_event_seen( $result, 'update-options', 1 ) + && self::capability_event_seen( $cap_events, 'manage_options' ) + && false === \has_filter( 'user_has_cap', $cap_filter ), + 'legacy options page submission updates only page_options, trims scalar values, preserves array values after wp_unslash, and stores success transient', + array( + 'result' => self::summarize_dispatch_result( $result ), + 'stored' => array( + 'first' => \get_option( $first ), + 'second' => \get_option( $second ), + 'missing' => \get_option( $missing, '__not-null__' ), + 'intruder'=> \get_option( $intruder, '__missing__' ), + ), + 'capEvents' => array_slice( $cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.legacy-options-page', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 3 ) ) + ); + } + + private static function check_failure_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::reset_runtime(); + $unknown_group = self::id( $ctx->fork( 'unknown' ), 'cfz_unknown_group', 36 ); + $unknown_option = self::id( $ctx->fork( 'unknown-option' ), 'cfz_unknown_option', 36 ); + $unknown_cap_events = array(); + $unknown_cap_filter = self::install_cap_filter( array( 'manage_options' ), $unknown_cap_events ); + try { + $unknown = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $unknown_group, + '_wpnonce' => \wp_create_nonce( $unknown_group . '-options' ), + $unknown_option => 'should not persist', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=unknown', + ) + ); + } finally { + \remove_filter( 'user_has_cap', $unknown_cap_filter, 10 ); + } + $unknown_stored = \get_option( $unknown_option, '__missing__' ); + + self::reset_runtime(); + $denied_group = self::id( $ctx->fork( 'denied' ), 'cfz_denied_group', 36 ); + $denied_option = self::id( $ctx->fork( 'denied-option' ), 'cfz_denied_option', 36 ); + $denied_cap_filter = static function ( string $capability ): string { + unset( $capability ); + return 'manage_component_fuzz_denied'; + }; + \register_setting( $denied_group, $denied_option, array( 'sanitize_callback' => 'sanitize_text_field' ) ); + \add_filter( 'allowed_options', 'option_update_filter' ); + \add_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ); + try { + $denied = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $denied_group, + '_wpnonce' => \wp_create_nonce( $denied_group . '-options' ), + $denied_option => 'denied', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=denied', + ) + ); + } finally { + \remove_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ); + \remove_filter( 'allowed_options', 'option_update_filter' ); + \unregister_setting( $denied_group, $denied_option ); + } + $denied_stored = \get_option( $denied_option, '__missing__' ); + + self::reset_runtime(); + $nonce_group = self::id( $ctx->fork( 'nonce' ), 'cfz_nonce_group', 36 ); + $nonce_option = self::id( $ctx->fork( 'nonce-option' ), 'cfz_nonce_option', 36 ); + $nonce_cap_events = array(); + $nonce_cap_filter = self::install_cap_filter( array( 'manage_options' ), $nonce_cap_events ); + \register_setting( $nonce_group, $nonce_option, array( 'sanitize_callback' => 'sanitize_text_field' ) ); + \add_filter( 'allowed_options', 'option_update_filter' ); + try { + $bad_nonce = self::dispatch_options_update( + array( + 'post' => array( + 'action' => 'update', + 'option_page' => $nonce_group, + '_wpnonce' => 'not-a-valid-nonce-' . $ctx->identifier( 3, 8 ), + $nonce_option => 'bad nonce', + ), + 'referer' => 'http://example.test/wp-admin/options-general.php?page=nonce', + ) + ); + } finally { + \remove_filter( 'allowed_options', 'option_update_filter' ); + \remove_filter( 'user_has_cap', $nonce_cap_filter, 10 ); + \unregister_setting( $nonce_group, $nonce_option ); + } + $nonce_stored = \get_option( $nonce_option, '__missing__' ); + + self::collect_failure( + $failures, + 'died' === ( $unknown['status'] ?? null ) + && str_contains( (string) ( $unknown['die']['message'] ?? '' ), 'options page is not in the allowed options list' ) + && self::nonce_event_seen( $unknown, $unknown_group . '-options', 1 ) + && '__missing__' === $unknown_stored + && null === ( $unknown['redirect'] ?? null ) + && false === \has_filter( 'user_has_cap', $unknown_cap_filter ), + 'unknown option_page dies after nonce validation and before mutating submitted options', + array( + 'result' => self::summarize_dispatch_result( $unknown ), + 'stored' => $unknown_stored, + ) + ); + + self::collect_failure( + $failures, + 'died' === ( $denied['status'] ?? null ) + && str_contains( (string) ( $denied['die']['message'] ?? '' ), 'higher level of permission' ) + && array() === ( $denied['nonceEvents'] ?? array() ) + && '__missing__' === $denied_stored + && null === ( $denied['redirect'] ?? null ) + && false === \has_filter( "option_page_capability_{$denied_group}", $denied_cap_filter ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ), + 'denied option_page capability dies before nonce validation and option mutation', + array( + 'result' => self::summarize_dispatch_result( $denied ), + 'stored' => $denied_stored, + ) + ); + + self::collect_failure( + $failures, + 'died' === ( $bad_nonce['status'] ?? null ) + && self::nonce_event_seen( $bad_nonce, $nonce_group . '-options', false ) + && '__missing__' === $nonce_stored + && null === ( $bad_nonce['redirect'] ?? null ) + && false === \has_filter( 'user_has_cap', $nonce_cap_filter ) + && false === \has_filter( 'allowed_options', 'option_update_filter' ), + 'invalid nonce dies before allowed-options lookup and option mutation', + array( + 'result' => self::summarize_dispatch_result( $bad_nonce ), + 'stored' => $nonce_stored, + 'capEvents' => array_slice( $nonce_cap_events, 0, 8 ), + ) + ); + + return $ctx->result( + 'admin-options-submission.failure-paths', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function dispatch_core_page_update( string $page, array $post_values, array $initial_options = array(), ?callable $install_extra_filters = null ): array { + self::reset_runtime( $initial_options ); + + $pre_update_events = array(); + $cap_events = array(); + $cleanup_extra = null; + $cap_filter = self::install_cap_filter( array( 'manage_options' ), $cap_events ); + $pre_update_filter = static function ( $value, string $option, $old_value ) use ( &$pre_update_events ) { + $pre_update_events[] = array( + 'option' => $option, + 'old' => $old_value, + 'value' => $value, + ); + return $value; + }; + + \add_filter( 'pre_update_option', $pre_update_filter, 10, 3 ); + try { + if ( null !== $install_extra_filters ) { + $cleanup_extra = $install_extra_filters(); + } + $result = self::dispatch_options_update( + array( + 'post' => array_merge( + array( + 'action' => 'update', + 'option_page' => $page, + '_wpnonce' => \wp_create_nonce( $page . '-options' ), + ), + $post_values + ), + 'referer' => 'http://example.test/wp-admin/options-' . $page . '.php', + ) + ); + } finally { + if ( is_callable( $cleanup_extra ) ) { + $cleanup_extra(); + } + \remove_filter( 'pre_update_option', $pre_update_filter, 10 ); + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + + return array( + 'result' => $result, + 'preUpdateEvents' => $pre_update_events, + 'capEvents' => $cap_events, + ); + } + + private static function core_page_completed( array $dispatch, string $page ): bool { + $result = $dispatch['result'] ?? array(); + + return is_array( $result ) + && 'completed' === ( $result['status'] ?? null ) + && false === ( $result['redirectResult'] ?? null ) + && self::redirect_has_settings_updated( $result['redirect']['location'] ?? '' ) + && 302 === ( $result['redirect']['status'] ?? null ) + && self::nonce_event_seen( $result, $page . '-options', 1 ) + && self::has_settings_error( $result['settingsErrors'] ?? array(), 'general', 'settings_updated', 'success' ) + && ( $result['settingsErrors'] ?? array() ) === ( $result['settingsTransient'] ?? null ) + && self::capability_event_seen( $dispatch['capEvents'] ?? array(), 'manage_options' ); + } + + private static function pre_update_event_seen( array $events, string $option, $old_value, $new_value ): bool { + foreach ( $events as $event ) { + if ( + is_array( $event ) + && $option === ( $event['option'] ?? null ) + && $old_value === ( $event['old'] ?? null ) + && $new_value === ( $event['value'] ?? null ) + ) { + return true; + } + } + + return false; + } + + private static function pre_update_option_seen( array $events, string $option ): bool { + foreach ( $events as $event ) { + if ( is_array( $event ) && $option === ( $event['option'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function writing_gate_filter_installer( bool $post_by_email_enabled, int $initial_db_version ): callable { + return static function () use ( $post_by_email_enabled, $initial_db_version ): callable { + $post_by_email_filter = static function () use ( $post_by_email_enabled ): bool { + return $post_by_email_enabled; + }; + $initial_db_filter = static function () use ( $initial_db_version ): int { + return $initial_db_version; + }; + + \add_filter( 'enable_post_by_email_configuration', $post_by_email_filter ); + \add_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + + return static function () use ( $post_by_email_filter, $initial_db_filter ): void { + \remove_filter( 'enable_post_by_email_configuration', $post_by_email_filter ); + \remove_filter( 'pre_site_option_initial_db_version', $initial_db_filter ); + }; + }; + } + + private static function stored_options( array $options ): array { + $stored = array(); + foreach ( $options as $option ) { + $stored[ $option ] = \get_option( $option, '__missing__' ); + } + + return $stored; + } + + private static function dispatch_options_update( array $request ): array { + $post = is_array( $request['post'] ?? null ) ? $request['post'] : array(); + $get = is_array( $request['get'] ?? null ) ? $request['get'] : array(); + $referer = (string) ( $request['referer'] ?? 'http://example.test/wp-admin/options-general.php' ); + $nonce_events = array(); + $redirect = null; + $updated = array(); + $slashed_get = \wp_slash( $get ); + $slashed_post = \wp_slash( $post ); + + $_GET = $slashed_get; + $_POST = $slashed_post; + $_REQUEST = array_merge( $slashed_get, $slashed_post ); + $_SERVER = array_merge( + $_SERVER, + array( + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/wp-admin/options.php', + 'PHP_SELF' => '/wp-admin/options.php', + 'SCRIPT_NAME' => '/wp-admin/options.php', + 'HTTP_HOST' => 'example.test', + 'HTTP_REFERER' => $referer, + ) + ); + + $nonce_action = static function ( string $action, $result ) use ( &$nonce_events ): void { + $nonce_events[] = array( + 'action' => $action, + 'result' => $result, + ); + }; + $redirect_filter = static function ( $location, int $status ) use ( &$redirect ) { + $redirect = array( + 'location' => (string) $location, + 'status' => $status, + ); + return false; + }; + $die_filter = static function (): callable { + return static function ( $message = '', $title = '', $args = array() ): void { + throw new AdminOptionsSubmissionSurface_DieCaptured( $message, $title, $args ); + }; + }; + + \add_action( 'check_admin_referer', $nonce_action, 10, 2 ); + \add_filter( 'wp_redirect', $redirect_filter, 10, 2 ); + \add_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + + $result = array( + 'status' => 'completed', + 'redirect' => null, + 'redirectResult' => null, + 'nonceEvents' => array(), + 'allowedOptions' => array(), + 'updatedOptions' => array(), + 'settingsErrors' => array(), + 'settingsTransient' => false, + 'die' => null, + ); + + try { + $action = ! empty( $_REQUEST['action'] ) ? \sanitize_text_field( $_REQUEST['action'] ) : ''; + $option_page = ! empty( $_REQUEST['option_page'] ) ? \sanitize_text_field( $_REQUEST['option_page'] ) : ''; + if ( empty( $option_page ) ) { + $option_page = 'options'; + } + + $capability = \apply_filters( "option_page_capability_{$option_page}", 'manage_options' ); + if ( ! \current_user_can( $capability ) ) { + \wp_die( + '

' . __( 'You need a higher level of permission.' ) . '

' . + '

' . __( 'Sorry, you are not allowed to manage options for this site.' ) . '

', + 403 + ); + } + + $allowed_options = self::allowed_options_for_submission(); + $result['allowedOptions'] = $allowed_options; + + if ( 'update' === $action ) { + if ( 'options' === $option_page && ! isset( $_POST['option_page'] ) ) { + $unregistered = true; + \check_admin_referer( 'update-options' ); + } else { + $unregistered = false; + \check_admin_referer( $option_page . '-options' ); + } + + if ( ! isset( $allowed_options[ $option_page ] ) ) { + \wp_die( + sprintf( + __( 'Error: The %s options page is not in the allowed options list.' ), + '' . esc_html( $option_page ) . '' + ) + ); + } + + if ( 'options' === $option_page ) { + $options = isset( $_POST['page_options'] ) ? explode( ',', \wp_unslash( $_POST['page_options'] ) ) : null; + } else { + $options = $allowed_options[ $option_page ]; + } + + if ( 'general' === $option_page ) { + self::normalize_general_post_values(); + } + + if ( $options ) { + $user_language_old = \get_user_locale(); + + foreach ( $options as $option ) { + if ( $unregistered ) { + _deprecated_argument( + 'options.php', + '2.7.0', + sprintf( + __( 'The %1$s setting is unregistered. See %2$s.' ), + '' . esc_html( (string) $option ) . '', + 'https://developer.wordpress.org/plugins/settings/settings-api/' + ) + ); + } + + $option = trim( (string) $option ); + $value = null; + if ( isset( $_POST[ $option ] ) ) { + $value = $_POST[ $option ]; + if ( ! is_array( $value ) ) { + $value = trim( (string) $value ); + } + $value = \wp_unslash( $value ); + } + + \update_option( $option, $value ); + $updated[] = $option; + } + + unset( $GLOBALS['locale'] ); + $user_language_new = \get_user_locale(); + if ( $user_language_old !== $user_language_new ) { + \load_default_textdomain( $user_language_new ); + } + } else { + \add_settings_error( 'general', 'settings_updated', __( 'Settings save failed.' ), 'error' ); + } + + if ( ! count( \get_settings_errors() ) ) { + \add_settings_error( 'general', 'settings_updated', __( 'Settings saved.' ), 'success' ); + } + + \set_transient( 'settings_errors', \get_settings_errors(), 30 ); + $goback = \add_query_arg( 'settings-updated', 'true', \wp_get_referer() ); + $result['redirectResult'] = \wp_redirect( $goback ); + } + } catch ( AdminOptionsSubmissionSurface_DieCaptured $e ) { + $result['status'] = 'died'; + $result['die'] = $e->payload; + } finally { + $result['redirect'] = $redirect; + $result['nonceEvents'] = $nonce_events; + $result['updatedOptions'] = $updated; + $result['settingsErrors'] = \get_settings_errors(); + $result['settingsTransient'] = \get_transient( 'settings_errors' ); + + \remove_action( 'check_admin_referer', $nonce_action, 10 ); + \remove_filter( 'wp_redirect', $redirect_filter, 10 ); + \remove_filter( 'wp_die_handler', $die_filter, PHP_INT_MAX ); + } + + return $result; + } + + private static function allowed_options_for_submission(): array { + $allowed_options = array( + 'general' => array( + 'blogname', + 'blogdescription', + 'site_icon', + 'gmt_offset', + 'date_format', + 'time_format', + 'start_of_week', + 'timezone_string', + 'WPLANG', + 'new_admin_email', + ), + 'discussion' => array( + 'default_pingback_flag', + 'default_ping_status', + 'default_comment_status', + 'comments_notify', + 'moderation_notify', + 'comment_moderation', + 'require_name_email', + 'comment_previously_approved', + 'comment_max_links', + 'moderation_keys', + 'disallowed_keys', + 'show_avatars', + 'avatar_rating', + 'avatar_default', + 'close_comments_for_old_posts', + 'close_comments_days_old', + 'thread_comments', + 'thread_comments_depth', + 'page_comments', + 'comments_per_page', + 'default_comments_page', + 'comment_order', + 'comment_registration', + 'show_comments_cookies_opt_in', + 'wp_notes_notify', + ), + 'media' => array( + 'thumbnail_size_w', + 'thumbnail_size_h', + 'thumbnail_crop', + 'medium_size_w', + 'medium_size_h', + 'large_size_w', + 'large_size_h', + 'image_default_size', + 'image_default_align', + 'image_default_link_type', + ), + 'reading' => array( + 'posts_per_page', + 'posts_per_rss', + 'rss_use_excerpt', + 'show_on_front', + 'page_on_front', + 'page_for_posts', + 'blog_public', + ), + 'writing' => array( + 'default_category', + 'default_email_category', + 'default_link_category', + 'default_post_format', + ), + 'misc' => array(), + 'options' => array(), + 'privacy' => array(), + ); + + if ( \apply_filters( 'enable_post_by_email_configuration', true ) ) { + $allowed_options['writing'][] = 'mailserver_url'; + $allowed_options['writing'][] = 'mailserver_port'; + $allowed_options['writing'][] = 'mailserver_login'; + $allowed_options['writing'][] = 'mailserver_pass'; + } + + if ( ! \is_utf8_charset() ) { + $allowed_options['reading'][] = 'blog_charset'; + } + + if ( \get_site_option( 'initial_db_version' ) < 32453 ) { + $allowed_options['writing'][] = 'use_smilies'; + $allowed_options['writing'][] = 'use_balanceTags'; + } + + if ( ! \is_multisite() ) { + if ( ! defined( 'WP_SITEURL' ) ) { + $allowed_options['general'][] = 'siteurl'; + } + if ( ! defined( 'WP_HOME' ) ) { + $allowed_options['general'][] = 'home'; + } + + $allowed_options['general'][] = 'users_can_register'; + $allowed_options['general'][] = 'default_role'; + + if ( '1' === (string) \get_option( 'blog_public' ) ) { + $allowed_options['writing'][] = 'ping_sites'; + } + + $allowed_options['media'][] = 'uploads_use_yearmonth_folders'; + + if ( \get_option( 'upload_url_path' ) + || \get_option( 'upload_path' ) && 'wp-content/uploads' !== \get_option( 'upload_path' ) + ) { + $allowed_options['media'][] = 'upload_path'; + $allowed_options['media'][] = 'upload_url_path'; + } + } + + $allowed_options = \apply_filters_deprecated( + 'whitelist_options', + array( $allowed_options ), + '5.5.0', + 'allowed_options', + __( 'Please consider writing more inclusive code.' ) + ); + + return \apply_filters( 'allowed_options', $allowed_options ); + } + + private static function general_post_defaults(): array { + return array( + 'blogname' => 'Component Fuzz', + 'blogdescription' => 'Component Fuzz Settings', + 'site_icon' => '0', + 'gmt_offset' => '0', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + 'start_of_week' => '1', + 'timezone_string' => '', + 'WPLANG' => '', + 'new_admin_email' => 'admin@example.com', + 'siteurl' => 'http://example.test', + 'home' => 'http://example.test', + 'users_can_register' => '0', + 'default_role' => 'subscriber', + ); + } + + private static function general_admin_email_post( string $new_admin_email, string $site_title, string $site_url ): array { + return array_merge( + self::general_post_defaults(), + array( + 'action' => 'update', + 'option_page' => 'general', + '_wpnonce' => \wp_create_nonce( 'general-options' ), + 'blogname' => $site_title, + 'blogdescription' => 'Admin email pending change', + 'gmt_offset' => '1', + 'new_admin_email' => $new_admin_email, + 'siteurl' => $site_url, + 'home' => $site_url, + 'timezone_string' => 'Europe/Madrid', + ) + ); + } + + private static function normalize_general_post_values(): void { + if ( ! empty( $_POST['date_format'] ) && isset( $_POST['date_format_custom'] ) + && '\c\u\s\t\o\m' === \wp_unslash( $_POST['date_format'] ) + ) { + $_POST['date_format'] = $_POST['date_format_custom']; + } + + if ( ! empty( $_POST['time_format'] ) && isset( $_POST['time_format_custom'] ) + && '\c\u\s\t\o\m' === \wp_unslash( $_POST['time_format'] ) + ) { + $_POST['time_format'] = $_POST['time_format_custom']; + } + + if ( ! empty( $_POST['timezone_string'] ) && preg_match( '/^UTC[+-]/', (string) $_POST['timezone_string'] ) ) { + $_POST['gmt_offset'] = $_POST['timezone_string']; + $_POST['gmt_offset'] = preg_replace( '/UTC\+?/', '', (string) $_POST['gmt_offset'] ); + $_POST['timezone_string'] = ''; + } elseif ( isset( $_POST['timezone_string'] ) && ! in_array( $_POST['timezone_string'], timezone_identifiers_list( \DateTimeZone::ALL_WITH_BC ), true ) ) { + $current_timezone_string = \get_option( 'timezone_string' ); + + if ( ! empty( $current_timezone_string ) ) { + $_POST['timezone_string'] = $current_timezone_string; + } else { + $_POST['gmt_offset'] = \get_option( 'gmt_offset' ); + $_POST['timezone_string'] = ''; + } + + \add_settings_error( + 'general', + 'settings_updated', + __( 'The timezone you have entered is not valid. Please select a valid timezone.' ), + 'error' + ); + } + } + + private static function install_cap_filter( array $granted_caps, array &$events ): callable { + $grant_map = array_fill_keys( $granted_caps, true ); + $filter = static function ( array $allcaps, array $caps, array $args = array(), $user = null ) use ( $grant_map, &$events ): array { + $events[] = array( + 'type' => 'user-has-cap', + 'caps' => $caps, + 'args' => $args, + 'userId' => is_object( $user ) ? (int) ( $user->ID ?? 0 ) : null, + ); + foreach ( $grant_map as $cap => $grant ) { + $allcaps[ $cap ] = $grant; + } + return $allcaps; + }; + + \add_filter( 'user_has_cap', $filter, 10, 4 ); + return $filter; + } + + private static function reset_runtime( array $options = array() ): void { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + $defaults = array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blogdescription' => 'Component Fuzz Settings', + 'blogname' => 'Component Fuzz', + 'blog_public' => '0', + 'date_format' => 'Y-m-d', + 'default_role' => 'subscriber', + 'gmt_offset' => '0', + 'home' => 'http://example.test', + 'html_type' => 'text/html', + 'siteurl' => 'http://example.test', + 'start_of_week' => '1', + 'time_format' => 'H:i', + 'timezone_string' => '', + 'users_can_register' => '0', + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( array_merge( $defaults, $options ) ); + } + + \wp_cache_flush(); + + $GLOBALS['new_allowed_options'] = array(); + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + $GLOBALS['current_user'] = new \WP_User( 0 ); + $GLOBALS['wp_registered_settings'] = array(); + $GLOBALS['wp_settings_errors'] = array(); + unset( $GLOBALS['locale'] ); + + $_GET = array(); + $_POST = array(); + $_REQUEST = array(); + $_SERVER = array_merge( + $_SERVER, + array( + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/wp-admin/options.php', + 'PHP_SELF' => '/wp-admin/options.php', + 'SCRIPT_NAME' => '/wp-admin/options.php', + 'HTTP_HOST' => 'example.test', + 'HTTP_REFERER' => 'http://example.test/wp-admin/options-general.php', + ) + ); + } + + private static function prepare_admin_email_runtime( string $admin_email, string $site_title, string $site_url, string $user_login ): \WP_User { + self::reset_runtime( + array( + 'admin_email' => $admin_email, + 'blogname' => $site_title, + 'home' => $site_url, + 'siteurl' => $site_url, + ) + ); + + $user_id = \wp_insert_user( + array( + 'user_login' => $user_login, + 'user_pass' => 'component-fuzz-admin-email-pass', + 'user_email' => $user_login . '@example.com', + 'user_nicename' => $user_login, + 'display_name' => 'Component Fuzz Admin Email', + 'locale' => '', + ) + ); + if ( \is_wp_error( $user_id ) ) { + throw new \RuntimeException( 'Unable to seed admin email current user: ' . $user_id->get_error_message() ); + } + + \wp_set_current_user( (int) $user_id ); + return \wp_get_current_user(); + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + '_SERVER', + 'current_user', + 'locale', + 'new_allowed_options', + 'new_whitelist_options', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_registered_settings', + 'wp_settings_errors', + ) + ), + 'options' => self::option_store_snapshot(), + ); + } + + private static function restore_state( array $snapshot ): void { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + + if ( array_key_exists( 'new_allowed_options', $GLOBALS ) ) { + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + } + + \wp_cache_flush(); + } + + private static function state_matches( array $snapshot ): bool { + if ( self::option_store_snapshot() !== $snapshot['options'] ) { + 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 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 option_store_snapshot(): array { + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + return $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return array(); + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix, int $max_len ): string { + $suffix = strtolower( preg_replace( '/[^a-zA-Z0-9_]+/', '_', $ctx->identifier( 4, 14 ) ) ); + $id = $prefix . '_' . $suffix; + + return substr( $id, 0, $max_len ); + } + + private static function redirect_has_settings_updated( string $location ): bool { + return str_contains( $location, 'settings-updated=true' ); + } + + private static function has_settings_error( array $errors, string $setting, string $code, string $type ): bool { + foreach ( $errors as $error ) { + if ( + is_array( $error ) + && $setting === ( $error['setting'] ?? null ) + && $code === ( $error['code'] ?? null ) + && $type === ( $error['type'] ?? null ) + ) { + return true; + } + } + + return false; + } + + private static function nonce_event_seen( array $result, string $action, $expected_result ): bool { + foreach ( $result['nonceEvents'] ?? array() as $event ) { + if ( is_array( $event ) && $action === ( $event['action'] ?? null ) && $expected_result === ( $event['result'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function capability_event_seen( array $events, string $capability ): bool { + foreach ( $events as $event ) { + if ( ! is_array( $event ) ) { + continue; + } + if ( in_array( $capability, $event['caps'] ?? array(), true ) ) { + return true; + } + if ( $capability === ( $event['capability'] ?? null ) ) { + return true; + } + } + + return false; + } + + private static function summarize_dispatch_result( array $result ): array { + if ( isset( $result['allowedOptions'] ) && is_array( $result['allowedOptions'] ) ) { + $result['allowedOptions'] = array_map( 'array_values', $result['allowedOptions'] ); + } + + return $result; + } + + private static function collect_failure( array &$failures, bool $ok, string $message, array $data ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'message' => $message, + 'data' => $data, + ); + } + + private static function describe_value( $value ) { + if ( is_string( $value ) && strlen( $value ) > 180 ) { + return substr( $value, 0, 180 ) . '...'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( array_slice( $value, 0, 8, true ) as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + if ( count( $value ) > 8 ) { + $out['__truncated__'] = count( $value ) - 8; + } + return $out; + } + + 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(), + ); + } + + 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 ) ) { + return clone $value; + } + + return $value; + } +} + +final class AdminOptionsSubmissionSurface_DieCaptured extends \RuntimeException { + public array $payload; + + public function __construct( $message, $title, $args ) { + $this->payload = array( + 'message' => is_scalar( $message ) ? (string) $message : get_debug_type( $message ), + 'title' => is_scalar( $title ) ? (string) $title : get_debug_type( $title ), + 'args' => $args, + ); + + parent::__construct( $this->payload['message'] ); + } +} diff --git a/tools/component-fuzz/surfaces/AdminScreenSurface.php b/tools/component-fuzz/surfaces/AdminScreenSurface.php new file mode 100644 index 0000000000000..d18fef594941a --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminScreenSurface.php @@ -0,0 +1,1887 @@ +skip( + 'admin-screen.bootstrap-apis-available', + 'Required admin screen APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_screen_normalization( $ctx->fork( 'screen-normalization' ) ); + $rows[] = self::check_help_tabs_and_screen_options( $ctx->fork( 'help-screen-options' ) ); + $rows[] = self::check_screen_options_rendering( $ctx->fork( 'screen-options-rendering' ) ); + $rows[] = self::check_screen_meta_rendering_lifecycle( $ctx->fork( 'screen-meta-rendering-lifecycle' ) ); + $rows[] = self::check_column_headers( $ctx->fork( 'column-headers' ) ); + $rows[] = self::check_settings_registry( $ctx->fork( 'settings-registry' ) ); + $rows[] = self::check_settings_rendering( $ctx->fork( 'settings-rendering' ) ); + $rows[] = self::check_settings_errors_and_admin_notices( $ctx->fork( 'settings-errors-notices' ) ); + $rows[] = self::check_meta_boxes( $ctx->fork( 'meta-boxes' ) ); + $rows[] = self::check_accordion_sections( $ctx->fork( 'accordion-sections' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-screen.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'WP_Screen' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_meta_box', + 'add_screen_option', + 'add_settings_error', + 'add_settings_field', + 'add_settings_section', + 'convert_to_screen', + 'delete_transient', + 'do_action', + 'do_accordion_sections', + 'do_meta_boxes', + 'do_settings_fields', + 'do_settings_sections', + 'esc_attr', + 'esc_html', + 'get_column_headers', + 'get_current_screen', + 'get_option', + 'get_registered_settings', + 'get_settings_errors', + 'get_transient', + 'has_filter', + 'post_type_exists', + 'register_post_type', + 'register_setting', + 'register_taxonomy', + 'remove_action', + 'remove_filter', + 'remove_meta_box', + 'sanitize_key', + 'sanitize_html_class', + 'sanitize_option', + 'sanitize_text_field', + 'set_current_screen', + 'set_transient', + 'settings_errors', + 'settings_fields', + 'taxonomy_exists', + 'unregister_setting', + 'update_option', + 'wp_admin_notice', + 'wp_get_admin_notice', + 'wp_kses_post', + 'wp_parse_args', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_screen_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $post_type = self::post_type_key( $ctx->fork( 'post-type' ) ); + $taxonomy = self::taxonomy_key( $ctx->fork( 'taxonomy' ) ); + $tool_page = self::id( $ctx->fork( 'tool-page' ), 'cfz_tool_page', 36 ); + + \register_post_type( + $post_type, + array( + 'label' => 'Component Fuzz ' . self::fuzz_label( $ctx->fork( 'post-label' ) ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => false, + 'supports' => array( 'title' ), + ) + ); + \register_taxonomy( + $taxonomy, + $post_type, + array( + 'label' => 'Component Fuzz Tax ' . self::fuzz_label( $ctx->fork( 'tax-label' ) ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => false, + ) + ); + + $cases = array( + array( + 'hook' => $post_type, + 'id' => $post_type, + 'base' => 'post', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $post_type, + 'id' => 'edit-' . $post_type, + 'base' => 'edit', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $taxonomy, + 'id' => 'edit-' . $taxonomy, + 'base' => 'edit-tags', + 'post_type' => 'post', + 'taxonomy' => $taxonomy, + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'post-new.php', + 'id' => 'post', + 'base' => 'post', + 'post_type' => 'post', + 'taxonomy' => '', + 'action' => 'add', + 'in_admin' => 'site', + ), + array( + 'hook' => 'index.php', + 'id' => 'dashboard', + 'base' => 'dashboard', + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'front', + 'id' => 'front', + 'base' => 'front', + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => false, + ), + array( + 'hook' => $tool_page . '.php', + 'id' => \sanitize_key( $tool_page ), + 'base' => \sanitize_key( $tool_page ), + 'post_type' => '', + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'site', + ), + array( + 'hook' => 'edit-' . $post_type . '-network', + 'id' => 'edit-' . $post_type . '-network', + 'base' => 'edit-network', + 'post_type' => $post_type, + 'taxonomy' => '', + 'action' => '', + 'in_admin' => 'network', + ), + ); + + foreach ( $cases as $case ) { + $screen = \WP_Screen::get( $case['hook'] ); + $converted = \convert_to_screen( $case['hook'] ); + + self::collect_failure( + $failures, + $screen instanceof \WP_Screen + && $converted === $screen + && $case['id'] === $screen->id + && $case['base'] === $screen->base + && $case['post_type'] === $screen->post_type + && $case['taxonomy'] === $screen->taxonomy + && $case['action'] === $screen->action + && ( false === $case['in_admin'] ? false === $screen->in_admin() : $screen->in_admin( $case['in_admin'] ) ), + 'WP_Screen::get() and convert_to_screen() normalize hook names consistently', + array( + 'case' => $case, + 'screen' => self::describe_screen( $screen ), + ) + ); + } + + $screen_snapshot = self::snapshot_globals( array( 'current_screen', 'typenow', 'taxnow' ) ); + $current_calls = array(); + $listener = static function ( \WP_Screen $screen ) use ( &$current_calls ): void { + $current_calls[] = array( + 'id' => $screen->id, + 'post_type' => $screen->post_type, + 'taxonomy' => $screen->taxonomy, + ); + }; + + \add_action( 'current_screen', $listener, 10, 1 ); + $edit_screen = \WP_Screen::get( 'edit-' . $post_type ); + \set_current_screen( $edit_screen ); + $current = \get_current_screen(); + $typenow_after_set = $GLOBALS['typenow'] ?? null; + $taxnow_after_set = $GLOBALS['taxnow'] ?? null; + \remove_action( 'current_screen', $listener, 10 ); + self::restore_globals( $screen_snapshot ); + + self::collect_failure( + $failures, + $current === $edit_screen + && $edit_screen->post_type === $typenow_after_set + && $edit_screen->taxonomy === $taxnow_after_set + && array( $edit_screen->id ) === array_column( $current_calls, 'id' ) + && self::globals_match( $screen_snapshot, array( 'current_screen', 'typenow', 'taxnow' ) ), + 'set_current_screen() updates globals, fires current_screen once, and can be restored', + array( + 'current' => self::describe_screen( $current ), + 'calls' => $current_calls, + 'globals' => array( + 'typenow' => $typenow_after_set, + 'taxnow' => $taxnow_after_set, + ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.screen.normalization-and-current', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 5 ) ) + ); + } + + private static function check_help_tabs_and_screen_options( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_help_screen', 40 ) ); + $tab_high = self::id( $ctx->fork( 'high-tab' ), 'cfz_help_high', 32 ); + $tab_mid = self::id( $ctx->fork( 'mid-tab' ), 'cfz_help_mid', 32 ); + $tab_low = self::id( $ctx->fork( 'low-tab' ), 'cfz_help_low', 32 ); + + $screen->add_help_tab( + array( + 'id' => $tab_low, + 'title' => 'Low ' . self::fuzz_label( $ctx->fork( 'low-title' ) ), + 'content' => '

Low ' . \esc_html( self::fuzz_label( $ctx->fork( 'low-content' ) ) ) . '

', + 'priority' => 30, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_high, + 'title' => 'High ' . self::fuzz_label( $ctx->fork( 'high-title' ) ), + 'content' => '

High ' . \esc_html( self::fuzz_label( $ctx->fork( 'high-content' ) ) ) . '

', + 'priority' => 5, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_mid, + 'title' => 'Original ' . self::fuzz_label( $ctx->fork( 'mid-title-old' ) ), + 'content' => '

Original

', + 'priority' => 20, + ) + ); + $screen->add_help_tab( + array( + 'id' => $tab_mid, + 'title' => 'Override ' . self::fuzz_label( $ctx->fork( 'mid-title-new' ) ), + 'content' => '

Override ' . \esc_html( self::fuzz_label( $ctx->fork( 'mid-content' ) ) ) . '

', + 'priority' => 15, + ) + ); + $screen->add_help_tab( + array( + 'id' => self::id( $ctx->fork( 'invalid-tab' ), 'cfz_help_invalid', 32 ), + 'content' => '

Missing title

', + 'priority' => 1, + ) + ); + + $tabs_before_remove = $screen->get_help_tabs(); + $mid_tab = $screen->get_help_tab( $tab_mid ); + $missing_tab = $screen->get_help_tab( 'cfz_missing_tab' ); + $screen->remove_help_tab( $tab_high ); + $tabs_after_remove = $screen->get_help_tabs(); + $screen->remove_help_tabs(); + $tabs_after_clear = $screen->get_help_tabs(); + + self::collect_failure( + $failures, + array( $tab_high, $tab_mid, $tab_low ) === array_keys( $tabs_before_remove ) + && is_array( $mid_tab ) + && 15 === ( $mid_tab['priority'] ?? null ) + && str_starts_with( (string) ( $mid_tab['title'] ?? '' ), 'Override ' ) + && null === $missing_tab + && array( $tab_mid, $tab_low ) === array_keys( $tabs_after_remove ) + && array() === $tabs_after_clear, + 'WP_Screen help tabs sort by priority, override duplicate IDs, and remove cleanly', + array( + 'screen' => self::describe_screen( $screen ), + 'tabsBeforeRemove' => $tabs_before_remove, + 'midTab' => $mid_tab, + 'tabsAfterRemove' => $tabs_after_remove, + 'tabsAfterClear' => $tabs_after_clear, + ) + ); + + $per_page = array( + 'label' => 'Per page ' . self::fuzz_label( $ctx->fork( 'per-page-label' ) ), + 'default' => $ctx->int( 5, 99 ), + 'option' => self::id( $ctx->fork( 'per-page-option' ), 'cfz_per_page', 32 ), + ); + $layout = array( + 'max' => $ctx->int( 2, 6 ), + 'default' => $ctx->int( 1, 2 ), + ); + + $screen->add_option( 'per_page', $per_page ); + $screen->add_option( 'layout_columns', $layout ); + $options_before_remove = $screen->get_options(); + $per_page_default = $screen->get_option( 'per_page', 'default' ); + $missing_option = $screen->get_option( 'cfz_missing_option' ); + $screen->remove_option( 'layout_columns' ); + $options_after_remove = $screen->get_options(); + + $options_settings_calls = array(); + $options_show_calls = array(); + $options_settings_filter = static function ( string $settings, \WP_Screen $seen_screen ) use ( &$options_settings_calls, $screen ): string { + $options_settings_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'incoming' => $settings, + ); + + return $settings; + }; + $options_show_filter = static function ( bool $show_screen, \WP_Screen $seen_screen ) use ( &$options_show_calls, $screen ): bool { + $options_show_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'incoming' => $show_screen, + ); + + return $show_screen; + }; + + \add_filter( 'screen_settings', $options_settings_filter, 10, 2 ); + \add_filter( 'screen_options_show_screen', $options_show_filter, 10, 2 ); + try { + $show_first = $screen->show_screen_options(); + $show_second = $screen->show_screen_options(); + } finally { + \remove_filter( 'screen_options_show_screen', $options_show_filter, 10 ); + \remove_filter( 'screen_settings', $options_settings_filter, 10 ); + self::reset_screen_options_cache( $screen ); + } + + $screen->remove_options(); + $options_after_clear = $screen->get_options(); + + $settings_screen = \convert_to_screen( self::id( $ctx->fork( 'settings-screen' ), 'cfz_settings_screen', 40 ) ); + $settings_calls = array(); + $settings_show_calls = array(); + $settings_filter = static function ( string $settings, \WP_Screen $seen_screen ) use ( &$settings_calls, $settings_screen ): string { + $settings_calls[] = array( + 'sameScreen' => $seen_screen === $settings_screen, + 'incoming' => $settings, + ); + + return $settings . '

settings

'; + }; + $settings_show_filter = static function ( bool $show_screen, \WP_Screen $seen_screen ) use ( &$settings_show_calls, $settings_screen ): bool { + $settings_show_calls[] = array( + 'sameScreen' => $seen_screen === $settings_screen, + 'incoming' => $show_screen, + ); + + return $show_screen; + }; + + \add_filter( 'screen_settings', $settings_filter, 10, 2 ); + \add_filter( 'screen_options_show_screen', $settings_show_filter, 10, 2 ); + try { + $settings_show_first = $settings_screen->show_screen_options(); + $settings_show_second = $settings_screen->show_screen_options(); + } finally { + \remove_filter( 'screen_options_show_screen', $settings_show_filter, 10 ); + \remove_filter( 'screen_settings', $settings_filter, 10 ); + self::reset_screen_options_cache( $settings_screen ); + } + + self::collect_failure( + $failures, + isset( $options_before_remove['per_page'], $options_before_remove['layout_columns'] ) + && $per_page_default === $per_page['default'] + && null === $missing_option + && isset( $options_after_remove['per_page'] ) + && ! isset( $options_after_remove['layout_columns'] ) + && array() === $options_after_clear + && true === $show_first + && true === $show_second + && 1 === count( $options_settings_calls ) + && 1 === count( $options_show_calls ) + && true === ( $options_settings_calls[0]['sameScreen'] ?? null ) + && '' === ( $options_settings_calls[0]['incoming'] ?? null ) + && true === ( $options_show_calls[0]['sameScreen'] ?? null ) + && true === ( $options_show_calls[0]['incoming'] ?? null ) + && array() === $settings_screen->get_options() + && true === $settings_show_first + && true === $settings_show_second + && 1 === count( $settings_calls ) + && 1 === count( $settings_show_calls ) + && true === ( $settings_calls[0]['sameScreen'] ?? null ) + && '' === ( $settings_calls[0]['incoming'] ?? null ) + && true === ( $settings_show_calls[0]['sameScreen'] ?? null ) + && true === ( $settings_show_calls[0]['incoming'] ?? null ) + && false === \has_filter( 'screen_settings', $options_settings_filter ) + && false === \has_filter( 'screen_options_show_screen', $options_show_filter ) + && false === \has_filter( 'screen_settings', $settings_filter ) + && false === \has_filter( 'screen_options_show_screen', $settings_show_filter ), + 'WP_Screen options store values, remove cleanly, show options once, and restore filters', + array( + 'perPage' => $per_page, + 'layout' => $layout, + 'optionsBeforeRemove' => $options_before_remove, + 'optionsAfterRemove' => $options_after_remove, + 'optionsAfterClear' => $options_after_clear, + 'showFirst' => $show_first, + 'showSecond' => $show_second, + 'optionsSettingsCalls'=> $options_settings_calls, + 'optionsShowCalls' => $options_show_calls, + 'settingsScreen' => self::describe_screen( $settings_screen ), + 'settingsShowFirst' => $settings_show_first, + 'settingsShowSecond' => $settings_show_second, + 'settingsCalls' => $settings_calls, + 'settingsShowCalls' => $settings_show_calls, + 'optionsSettingsHasFilter' => \has_filter( 'screen_settings', $options_settings_filter ), + 'optionsShowHasFilter' => \has_filter( 'screen_options_show_screen', $options_show_filter ), + 'settingsHasFilter' => \has_filter( 'screen_settings', $settings_filter ), + 'settingsShowHasFilter' => \has_filter( 'screen_options_show_screen', $settings_show_filter ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.help-tabs-and-screen-options', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_screen_options_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + $current_snapshot = self::snapshot_globals( array( 'current_screen' ) ); + unset( $GLOBALS['current_screen'] ); + \add_screen_option( + 'per_page', + array( + 'label' => 'No screen', + 'default' => 17, + 'option' => 'cfz_no_screen_per_page', + ) + ); + $no_screen_result = \get_current_screen(); + self::restore_globals( $current_snapshot ); + + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_render_screen', 40 ) ); + $option = self::id( $ctx->fork( 'option' ), 'cfz_render_per_page', 32 ); + $default_per_page = $ctx->int( 7, 55 ); + $filtered_per_page = $default_per_page + $ctx->int( 3, 25 ); + $columns = $ctx->int( 2, 5 ); + $label = 'Items per page: ' . \esc_html( self::fuzz_label( $ctx->fork( 'label' ) ) ); + $per_page_calls = array(); + $submit_calls = array(); + + \set_current_screen( $screen ); + \add_screen_option( + 'per_page', + array( + 'label' => $label, + 'default' => $default_per_page, + 'option' => $option, + ) + ); + \add_screen_option( + 'layout_columns', + array( + 'max' => $columns, + 'default' => 1, + ) + ); + + $per_page_filter = static function ( int $per_page ) use ( &$per_page_calls, $filtered_per_page, $option ): int { + $per_page_calls[] = array( + 'option' => $option, + 'incoming' => $per_page, + ); + + return $filtered_per_page; + }; + $submit_filter = static function ( bool $show, \WP_Screen $seen_screen ) use ( &$submit_calls, $screen ): bool { + $submit_calls[] = array( + 'incoming' => $show, + 'sameScreen' => $seen_screen === $screen, + ); + + return $show; + }; + + \add_filter( $option, $per_page_filter, 10, 1 ); + \add_filter( 'screen_options_show_submit', $submit_filter, 20, 2 ); + $ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_options( array( 'wrap' => true ) ); + $wrapped_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + \remove_filter( 'screen_options_show_submit', '__return_true' ); + \remove_filter( 'screen_options_show_submit', $submit_filter, 20 ); + \remove_filter( $option, $per_page_filter, 10 ); + } + + \add_filter( $option, $per_page_filter, 10, 1 ); + \add_filter( 'screen_options_show_submit', $submit_filter, 20, 2 ); + $ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_options( array( 'wrap' => false ) ); + $unwrapped_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + \remove_filter( 'screen_options_show_submit', '__return_true' ); + \remove_filter( 'screen_options_show_submit', $submit_filter, 20 ); + \remove_filter( $option, $per_page_filter, 10 ); + } + + self::collect_failure( + $failures, + null === $no_screen_result + && $screen === \get_current_screen() + && $screen->get_option( 'per_page', 'option' ) === $option + && $screen->get_option( 'per_page', 'default' ) === $default_per_page + && $screen->get_option( 'layout_columns', 'max' ) === $columns, + 'add_screen_option() no-ops without a current screen and attaches options to the current WP_Screen', + array( + 'noScreenResult' => $no_screen_result, + 'screen' => self::describe_screen( $screen ), + 'options' => $screen->get_options(), + ) + ); + + self::collect_failure( + $failures, + str_contains( $wrapped_html, 'id="screen-options-wrap"' ) + && str_contains( $wrapped_html, "form id='adv-settings' method='post'" ) + && str_contains( $wrapped_html, 'name="screenoptionnonce"' ) + && str_contains( $wrapped_html, 'class="screen-options"' ) + && str_contains( $wrapped_html, 'name="wp_screen_options[value]"' ) + && str_contains( $wrapped_html, 'id="' . \esc_attr( $option ) . '"' ) + && str_contains( $wrapped_html, 'value="' . \esc_attr( (string) $filtered_per_page ) . '"' ) + && str_contains( $wrapped_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $option ) . '"' ) + && substr_count( $wrapped_html, "name='screen_columns'" ) === $columns + && str_contains( $wrapped_html, 'id="screen-options-apply"' ) + && self::html_has_no_unsafe_raw_markup( $wrapped_html ), + 'WP_Screen::render_screen_options() emits wrapped form, nonce, generated per-page option, column radios, and submit button safely', + array( 'html' => self::describe_string( $wrapped_html ) ) + ); + + self::collect_failure( + $failures, + ! str_contains( $unwrapped_html, 'id="screen-options-wrap"' ) + && str_contains( $unwrapped_html, "form id='adv-settings' method='post'" ) + && str_contains( $unwrapped_html, 'name="screenoptionnonce"' ) + && str_contains( $unwrapped_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $option ) . '"' ) + && self::html_has_no_unsafe_raw_markup( $unwrapped_html ), + 'WP_Screen::render_screen_options() honors wrap=false while preserving form controls', + array( 'html' => self::describe_string( $unwrapped_html ) ) + ); + + self::collect_failure( + $failures, + array( + array( 'option' => $option, 'incoming' => $default_per_page ), + array( 'option' => $option, 'incoming' => $default_per_page ), + ) === $per_page_calls + && 2 === count( $submit_calls ) + && self::all_call_values( $submit_calls, 'incoming', true ) + && self::all_call_values( $submit_calls, 'sameScreen', true ) + && false === \has_filter( $option, $per_page_filter ) + && false === \has_filter( 'screen_options_show_submit', $submit_filter ), + 'per-page and submit filters receive generated screen context and are removed after rendering', + array( + 'perPageCalls' => $per_page_calls, + 'submitCalls' => $submit_calls, + 'perPageHook' => \has_filter( $option, $per_page_filter ), + 'submitHook' => \has_filter( 'screen_options_show_submit', $submit_filter ), + ) + ); + + self::restore_globals( $current_snapshot ); + + return self::row( + $ctx, + 'admin-screen.screen-options.rendering-controls', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_screen_meta_rendering_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_screen_meta', 40 ) ); + + $reader_heading = \esc_html( 'Views ' . self::fuzz_label( $ctx->fork( 'reader-heading' ) ) ); + $reader_list = \esc_html( 'List " onclick="bad ' . self::fuzz_label( $ctx->fork( 'reader-list' ) ) ); + $reader_custom = \esc_html( 'Custom reader ' . self::fuzz_label( $ctx->fork( 'reader-custom' ) ) ); + $reader_content = array( + 'heading_views' => $reader_heading, + 'heading_list' => $reader_list, + 'cfz_custom' => $reader_custom, + ); + + $screen->set_screen_reader_content( $reader_content ); + $reader_after_set = $screen->get_screen_reader_content(); + $reader_heading_text = $screen->get_screen_reader_text( 'heading_views' ); + $reader_missing_text = $screen->get_screen_reader_text( 'cfz_missing_reader' ); + $reader_ob_level = ob_get_level(); + $reader_html = ''; + $missing_reader_html = ''; + $removed_reader_html = ''; + try { + ob_start(); + $screen->render_screen_reader_content( 'heading_views', 'h3' ); + $reader_html = (string) ob_get_clean(); + + ob_start(); + $screen->render_screen_reader_content( 'cfz_missing_reader', 'h3' ); + $missing_reader_html = (string) ob_get_clean(); + + $screen->remove_screen_reader_content(); + $reader_after_remove = $screen->get_screen_reader_content(); + $reader_after_remove_text = $screen->get_screen_reader_text( 'heading_views' ); + + ob_start(); + $screen->render_screen_reader_content( 'heading_views', 'h3' ); + $removed_reader_html = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $reader_ob_level ) { + ob_end_clean(); + } + } + + self::collect_failure( + $failures, + $reader_heading === ( $reader_after_set['heading_views'] ?? null ) + && $reader_list === ( $reader_after_set['heading_list'] ?? null ) + && $reader_custom === ( $reader_after_set['cfz_custom'] ?? null ) + && isset( $reader_after_set['heading_pagination'] ) + && $reader_heading === $reader_heading_text + && null === $reader_missing_text + && str_contains( $reader_html, "

" ) + && str_contains( $reader_html, $reader_heading ) + && '' === $missing_reader_html + && array() === $reader_after_remove + && null === $reader_after_remove_text + && '' === $removed_reader_html + && self::html_has_no_unsafe_raw_markup( $reader_html ), + 'Screen reader content stores generated labels, renders requested keys, no-ops missing keys, and removes cleanly', + array( + 'screen' => self::describe_screen( $screen ), + 'readerAfterSet' => $reader_after_set, + 'readerHtml' => self::describe_string( $reader_html ), + 'missingReaderHtml' => self::describe_string( $missing_reader_html ), + 'readerAfterRemove' => $reader_after_remove, + 'readerAfterRemoveText' => $reader_after_remove_text, + 'removedReaderHtml' => self::describe_string( $removed_reader_html ), + ) + ); + + $primary_tab = self::id( $ctx->fork( 'primary-tab' ), 'cfz_help_meta_primary', 32 ); + $secondary_tab = self::id( $ctx->fork( 'secondary-tab' ), 'cfz_help_meta_secondary', 32 ); + $primary_title = 'Primary ' . self::fuzz_label( $ctx->fork( 'primary-title' ) ); + $secondary_title = 'Secondary " onclick="bad ' . self::fuzz_label( $ctx->fork( 'secondary-title' ) ); + $primary_payload = 'Primary body ' . self::fuzz_label( $ctx->fork( 'primary-content' ) ); + $secondary_payload = 'Secondary body " onclick="bad ' . self::fuzz_label( $ctx->fork( 'secondary-content' ) ); + $callback_payload = 'Callback body ' . self::fuzz_label( $ctx->fork( 'callback-content' ) ); + $sidebar_payload = 'Sidebar body ' . self::fuzz_label( $ctx->fork( 'sidebar-content' ) ); + $escaped_payloads = array( + 'primary' => \esc_html( $primary_payload ), + 'secondary' => \esc_html( $secondary_payload ), + 'callback' => \esc_html( $callback_payload ), + 'sidebar' => \esc_html( $sidebar_payload ), + ); + $primary_content = '

' . $escaped_payloads['primary'] . '

'; + $secondary_content = '

' . $escaped_payloads['secondary'] . '

'; + $sidebar_html = ''; + $callback_calls = array(); + $callback = static function ( \WP_Screen $seen_screen, array $tab ) use ( &$callback_calls, $screen, $escaped_payloads ): void { + $callback_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'id' => $tab['id'] ?? null, + 'title' => $tab['title'] ?? null, + ); + echo ''; + echo $escaped_payloads['callback']; + echo ''; + }; + + $screen->add_help_tab( + array( + 'id' => $secondary_tab, + 'title' => $secondary_title, + 'content' => $secondary_content, + 'priority' => 30, + ) + ); + $screen->add_help_tab( + array( + 'id' => $primary_tab, + 'title' => $primary_title, + 'content' => $primary_content, + 'callback' => $callback, + 'priority' => 5, + ) + ); + $screen->set_help_sidebar( $sidebar_html ); + + $per_page_option = self::id( $ctx->fork( 'per-page-option' ), 'cfz_meta_per_page', 32 ); + $default_per_page = $ctx->int( 5, 40 ); + $filtered_per_page = $default_per_page + $ctx->int( 3, 25 ); + $layout_columns_max = $ctx->int( 2, 5 ); + $layout_default = $ctx->int( 1, $layout_columns_max ); + $layout_calls = array(); + $per_page_calls = array(); + $submit_filter_before = \has_filter( 'screen_options_show_submit', '__return_true' ); + $globals_snapshot = self::snapshot_globals( array( 'current_screen', 'screen_layout_columns', 'taxnow', 'typenow' ) ); + $meta_html = ''; + $columns_during = null; + $globals_after_render = array(); + + $layout_filter = static function ( array $columns, string $screen_id, \WP_Screen $seen_screen ) use ( &$layout_calls, $screen ): array { + $layout_calls[] = array( + 'sameScreen' => $seen_screen === $screen, + 'screenId' => $screen_id, + 'incoming' => $columns, + ); + + return $columns; + }; + $per_page_filter = static function ( int $per_page ) use ( &$per_page_calls, $filtered_per_page, $per_page_option ): int { + $per_page_calls[] = array( + 'option' => $per_page_option, + 'incoming' => $per_page, + ); + + return $filtered_per_page; + }; + + \set_current_screen( $screen ); + \add_screen_option( + 'per_page', + array( + 'label' => 'Meta per page ' . \esc_html( self::fuzz_label( $ctx->fork( 'per-page-label' ) ) ), + 'default' => $default_per_page, + 'option' => $per_page_option, + ) + ); + \add_screen_option( + 'layout_columns', + array( + 'max' => $layout_columns_max, + 'default' => $layout_default, + ) + ); + + \add_filter( 'screen_layout_columns', $layout_filter, 10, 3 ); + \add_filter( $per_page_option, $per_page_filter, 10, 1 ); + $meta_ob_level = ob_get_level(); + try { + ob_start(); + $screen->render_screen_meta(); + $meta_html = (string) ob_get_clean(); + $columns_during = $GLOBALS['screen_layout_columns'] ?? null; + $globals_after_render = array( + 'currentScreenSame' => ( $GLOBALS['current_screen'] ?? null ) === $screen, + 'screenLayoutColumns' => $GLOBALS['screen_layout_columns'] ?? null, + 'taxnow' => $GLOBALS['taxnow'] ?? null, + 'typenow' => $GLOBALS['typenow'] ?? null, + ); + } finally { + while ( ob_get_level() > $meta_ob_level ) { + ob_end_clean(); + } + if ( 10 !== $submit_filter_before ) { + \remove_filter( 'screen_options_show_submit', '__return_true', 10 ); + } + \remove_filter( $per_page_option, $per_page_filter, 10 ); + \remove_filter( 'screen_layout_columns', $layout_filter, 10 ); + self::restore_globals( $globals_snapshot ); + } + $globals_restored_after_cleanup = self::globals_match( $globals_snapshot, array( 'current_screen', 'screen_layout_columns', 'taxnow', 'typenow' ) ); + + self::collect_failure( + $failures, + $sidebar_html === $screen->get_help_sidebar() + && str_contains( $meta_html, 'id="screen-meta" class="metabox-prefs"' ) + && str_contains( $meta_html, 'id="contextual-help-wrap" class="hidden"' ) + && ! str_contains( $meta_html, 'no-sidebar' ) + && str_contains( $meta_html, 'class="contextual-help-tabs"' ) + && str_contains( $meta_html, 'id="tab-link-' . \esc_attr( $primary_tab ) . '" class="active"' ) + && str_contains( $meta_html, 'id="tab-panel-' . \esc_attr( $primary_tab ) . '" class="help-tab-content active"' ) + && str_contains( $meta_html, \esc_html( $primary_title ) ) + && str_contains( $meta_html, \esc_html( $secondary_title ) ) + && str_contains( $meta_html, 'class="contextual-help-sidebar"' ) + && str_contains( $meta_html, 'cfz-help-sidebar' ) + && str_contains( $meta_html, 'cfz-help-content' ) + && str_contains( $meta_html, 'cfz-help-callback' ) + && str_contains( $meta_html, $escaped_payloads['primary'] ) + && str_contains( $meta_html, $escaped_payloads['secondary'] ) + && str_contains( $meta_html, $escaped_payloads['callback'] ) + && str_contains( $meta_html, $escaped_payloads['sidebar'] ) + && 1 === count( $callback_calls ) + && true === ( $callback_calls[0]['sameScreen'] ?? null ) + && $primary_tab === ( $callback_calls[0]['id'] ?? null ) + && str_contains( $meta_html, 'id="screen-options-wrap" class="hidden"' ) + && str_contains( $meta_html, "form id='adv-settings' method='post'" ) + && str_contains( $meta_html, 'name="screenoptionnonce"' ) + && str_contains( $meta_html, 'class="screen-options"' ) + && str_contains( $meta_html, 'id="' . \esc_attr( $per_page_option ) . '"' ) + && str_contains( $meta_html, 'value="' . \esc_attr( (string) $filtered_per_page ) . '"' ) + && str_contains( $meta_html, 'name="wp_screen_options[option]" value="' . \esc_attr( $per_page_option ) . '"' ) + && substr_count( $meta_html, "name='screen_columns'" ) === $layout_columns_max + && str_contains( $meta_html, "value='" . \esc_attr( (string) $layout_default ) . "'" ) + && str_contains( $meta_html, 'id="screen-options-link-wrap"' ) + && str_contains( $meta_html, 'id="show-settings-link"' ) + && str_contains( $meta_html, 'aria-controls="screen-options-wrap"' ) + && str_contains( $meta_html, 'id="contextual-help-link-wrap"' ) + && str_contains( $meta_html, 'id="contextual-help-link"' ) + && str_contains( $meta_html, 'aria-controls="contextual-help-wrap"' ) + && self::html_has_no_unsafe_raw_markup( $meta_html ), + 'WP_Screen::render_screen_meta() combines help tabs, sidebar, callbacks, screen options, and toggle links safely', + array( + 'screen' => self::describe_screen( $screen ), + 'helpTabs' => array_keys( $screen->get_help_tabs() ), + 'callbackCalls' => $callback_calls, + 'options' => $screen->get_options(), + 'columnsDuring' => $columns_during, + 'escapedPayloads' => $escaped_payloads, + 'metaHtml' => self::describe_string( $meta_html ), + ) + ); + + self::collect_failure( + $failures, + $layout_default === $columns_during + && true === ( $globals_after_render['currentScreenSame'] ?? null ) + && $layout_default === ( $globals_after_render['screenLayoutColumns'] ?? null ) + && '' === ( $globals_after_render['taxnow'] ?? null ) + && '' === ( $globals_after_render['typenow'] ?? null ) + && array( + array( + 'sameScreen' => true, + 'screenId' => $screen->id, + 'incoming' => array(), + ), + ) === $layout_calls + && array( + array( + 'option' => $per_page_option, + 'incoming' => $default_per_page, + ), + ) === $per_page_calls + && false === \has_filter( 'screen_layout_columns', $layout_filter ) + && false === \has_filter( $per_page_option, $per_page_filter ) + && $submit_filter_before === \has_filter( 'screen_options_show_submit', '__return_true' ) + && $globals_restored_after_cleanup, + 'render_screen_meta() applies scoped option/layout filters, sets the legacy layout global, and restores filters/globals', + array( + 'layoutDefault' => $layout_default, + 'columnsDuring' => $columns_during, + 'globalsAfterRender' => $globals_after_render, + 'layoutCalls' => $layout_calls, + 'perPageCalls' => $per_page_calls, + 'layoutHasFilter' => \has_filter( 'screen_layout_columns', $layout_filter ), + 'perPageHasFilter' => \has_filter( $per_page_option, $per_page_filter ), + 'submitFilterBefore' => $submit_filter_before, + 'submitFilterAfter' => \has_filter( 'screen_options_show_submit', '__return_true' ), + 'globalsRestored' => $globals_restored_after_cleanup, + ) + ); + + return self::row( + $ctx, + 'admin-screen.screen-meta.rendering-lifecycle', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_column_headers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen_a = \convert_to_screen( self::id( $ctx->fork( 'screen-a' ), 'cfz_columns_a', 40 ) ); + $screen_b = \convert_to_screen( self::id( $ctx->fork( 'screen-b' ), 'cfz_columns_b', 40 ) ); + $column = self::id( $ctx->fork( 'column' ), 'cfz_column', 30 ); + $label = 'Column ' . self::fuzz_label( $ctx->fork( 'label' ) ); + $calls = 0; + $filter = static function ( array $columns ) use ( &$calls, $column, $label ): array { + ++$calls; + $columns[ $column ] = $label; + return $columns; + }; + + \add_filter( "manage_{$screen_a->id}_columns", $filter ); + $first = \get_column_headers( $screen_a ); + $second = \get_column_headers( $screen_a ); + \remove_filter( "manage_{$screen_a->id}_columns", $filter ); + $other = \get_column_headers( $screen_b ); + + self::collect_failure( + $failures, + 1 === $calls + && $first === $second + && isset( $first[ $column ] ) + && $label === $first[ $column ] + && array() === $other + && false === \has_filter( "manage_{$screen_a->id}_columns", $filter ), + 'get_column_headers() applies the dynamic filter once per screen id and caches locally', + array( + 'screenA' => $screen_a->id, + 'screenB' => $screen_b->id, + 'column' => $column, + 'calls' => $calls, + 'first' => $first, + 'second' => $second, + 'other' => $other, + ) + ); + + return self::row( + $ctx, + 'admin-screen.columns.filter-locality', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_registry( \ComponentFuzz\FuzzContext $ctx ): array { + global $new_allowed_options; + + $failures = array(); + $group = self::id( $ctx->fork( 'group' ), 'cfz_group', 36 ); + $name = self::id( $ctx->fork( 'name' ), 'cfz_option', 40 ); + $raw = " Raw \xE2\x98\x83 " . $ctx->text( 0, 28 ); + $calls = array(); + $sanitize = static function ( $value ) use ( &$calls ): string { + $calls[] = $value; + return 'sanitized:' . \sanitize_key( \sanitize_text_field( (string) $value ) ); + }; + $args = array( + 'type' => 'string', + 'label' => 'Label ' . self::fuzz_label( $ctx->fork( 'label' ) ), + 'description' => 'Description ' . self::fuzz_label( $ctx->fork( 'description' ) ), + 'sanitize_callback' => $sanitize, + 'default' => 'default-' . $ctx->int( 100, 999 ), + 'show_in_rest' => false, + ); + + \register_setting( $group, $name, $args ); + + $registered = \get_registered_settings(); + $allowed_before = $new_allowed_options[ $group ] ?? array(); + $sanitize_filter_before = \has_filter( "sanitize_option_{$name}", $sanitize ); + $default_filter_before = \has_filter( "default_option_{$name}", 'filter_default_option' ); + $sanitized = \sanitize_option( $name, $raw ); + $default = \get_option( $name ); + $registered_before_drop = $registered[ $name ] ?? null; + + \unregister_setting( $group, $name ); + + $registered_after = \get_registered_settings(); + $allowed_after = $new_allowed_options[ $group ] ?? array(); + $sanitize_filter_after = \has_filter( "sanitize_option_{$name}", $sanitize ); + $default_filter_after = \has_filter( "default_option_{$name}", 'filter_default_option' ); + + self::collect_failure( + $failures, + is_array( $registered_before_drop ) + && in_array( $name, $allowed_before, true ) + && 10 === $sanitize_filter_before + && 10 === $default_filter_before + && $args['label'] === $registered_before_drop['label'] + && $args['description'] === $registered_before_drop['description'] + && $args['default'] === $default + && array( $raw ) === $calls + && 'sanitized:' . \sanitize_key( \sanitize_text_field( (string) $raw ) ) === $sanitized + && ! isset( $registered_after[ $name ] ) + && ! in_array( $name, $allowed_after, true ) + && false === $sanitize_filter_after + && false === $default_filter_after, + 'register_setting() wires registry, allowed options, defaults, sanitize callbacks, and unregister cleanup', + array( + 'group' => $group, + 'name' => $name, + 'registeredBeforeDrop' => $registered_before_drop, + 'allowedBefore' => $allowed_before, + 'sanitizeFilterBefore' => $sanitize_filter_before, + 'defaultFilterBefore' => $default_filter_before, + 'sanitized' => $sanitized, + 'default' => $default, + 'calls' => $calls, + 'allowedAfter' => $allowed_after, + 'sanitizeFilterAfter' => $sanitize_filter_after, + 'defaultFilterAfter' => $default_filter_after, + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings.registry-sanitize-unregister', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $page = self::id( $ctx->fork( 'page' ), 'cfz_settings_page', 40 ); + $section = self::id( $ctx->fork( 'section' ), 'cfz_section', 32 ); + $field = self::id( $ctx->fork( 'field' ), 'cfz_field', 32 ); + $label_for = $field . '" onclick="bad' . $ctx->int( 10, 99 ); + $class = 'cfz-field-class " onclick="bad ' . $ctx->identifier( 3, 8 ); + $group = self::id( $ctx->fork( 'fields-group' ), 'cfz_fields', 28 ) . '"'; + $section_calls = array(); + $field_calls = array(); + + $section_callback = static function ( array $section_args ) use ( &$section_calls ): void { + $section_calls[] = $section_args; + echo '

'; + echo \esc_html( (string) ( $section_args['section_class'] ?? '' ) ); + echo '

'; + }; + $field_callback = static function ( array $field_args ) use ( &$field_calls ): void { + $field_calls[] = $field_args; + echo ''; + }; + + \add_settings_section( + $section, + 'Section ' . \esc_html( self::fuzz_label( $ctx->fork( 'section-title' ) ) ), + $section_callback, + $page, + array( + 'before_section' => '

lead

', + 'after_section' => '

tail

', + 'section_class' => 'cfz-section " onclick="bad ' . $ctx->identifier( 3, 8 ), + ) + ); + \add_settings_field( + $field, + 'Field ' . \esc_html( self::fuzz_label( $ctx->fork( 'field-title' ) ) ), + $field_callback, + $page, + $section, + array( + 'label_for' => $label_for, + 'class' => $class, + 'payload' => self::fuzz_label( $ctx->fork( 'payload' ) ), + ) + ); + + ob_start(); + \do_settings_sections( $page ); + $sections_html = (string) ob_get_clean(); + + ob_start(); + \do_settings_fields( $page, $section ); + $fields_html = (string) ob_get_clean(); + + ob_start(); + \settings_fields( $group ); + $settings_fields_html = (string) ob_get_clean(); + + self::collect_failure( + $failures, + 1 === count( $section_calls ) + && 2 === count( $field_calls ) + && $section === ( $section_calls[0]['id'] ?? null ) + && $label_for === ( $field_calls[0]['label_for'] ?? null ) + && str_contains( $sections_html, 'cfz-section-marker' ) + && str_contains( $sections_html, 'class="form-table"' ) + && str_contains( $sections_html, 'cfz-field-input' ) + && str_contains( $fields_html, 'cfz-field-input' ) + && str_contains( $settings_fields_html, "name='option_page'" ) + && str_contains( $settings_fields_html, 'name="action" value="update"' ) + && str_contains( $settings_fields_html, 'name="_wpnonce"' ) + && str_contains( $settings_fields_html, "value='" . \esc_attr( $group ) . "'" ) + && self::html_has_no_unsafe_raw_markup( $sections_html ) + && self::html_has_no_unsafe_raw_markup( $fields_html ) + && self::html_has_no_unsafe_raw_markup( $settings_fields_html ), + 'Settings section, field, and nonce renderers call callbacks and escape hostile args', + array( + 'page' => $page, + 'section' => $section, + 'field' => $field, + 'sectionCalls' => $section_calls, + 'fieldCalls' => $field_calls, + 'sectionsHtml' => self::describe_string( $sections_html ), + 'fieldsHtml' => self::describe_string( $fields_html ), + 'settingsFieldsHtml' => self::describe_string( $settings_fields_html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings.rendering-and-escaping', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_settings_errors_and_admin_notices( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_settings_errors; + + $failures = array(); + $setting = self::id( $ctx->fork( 'setting' ), 'cfz_notice_setting', 40 ); + $other = self::id( $ctx->fork( 'other-setting' ), 'cfz_other_notice_setting', 40 ); + $code = 'code-"quoted-' . $ctx->identifier( 3, 8 ); + $message = 'Capital P dangit! ' . \esc_html( self::fuzz_label( $ctx->fork( 'message' ) ) ) . ''; + $updated = array( + 'setting' => $setting, + 'code' => $code, + 'message' => $message, + 'type' => 'updated', + ); + $custom = array( + 'setting' => $setting, + 'code' => 'custom-class', + 'message' => 'Custom class notice', + 'type' => 'error my-own-css-class hello world', + ); + $other_error = array( + 'setting' => $other, + 'code' => 'other-code', + 'message' => 'Other setting notice', + 'type' => 'error', + ); + + $wp_settings_errors = array(); + unset( $_GET['settings-updated'] ); + \delete_transient( 'settings_errors' ); + + \add_settings_error( $updated['setting'], $updated['code'], $updated['message'], $updated['type'] ); + \add_settings_error( $custom['setting'], $custom['code'], $custom['message'], $custom['type'] ); + \add_settings_error( $other_error['setting'], $other_error['code'], $other_error['message'], $other_error['type'] ); + + $setting_errors = \get_settings_errors( $setting ); + $all_errors = \get_settings_errors(); + + ob_start(); + \settings_errors( $setting ); + $settings_html = (string) ob_get_clean(); + + $_GET['settings-updated'] = '1'; + ob_start(); + \settings_errors( $setting, false, true ); + $hidden_html = (string) ob_get_clean(); + + $transient_error = array( + 'setting' => $setting, + 'code' => 'transient-code', + 'message' => 'Transient replay notice', + 'type' => 'info', + ); + $wp_settings_errors = array(); + \set_transient( 'settings_errors', array( $transient_error ) ); + $transient_errors = \get_settings_errors( $setting ); + $transient_after = \get_transient( 'settings_errors' ); + + unset( $_GET['settings-updated'] ); + $sanitize_setting = self::id( $ctx->fork( 'sanitize-setting' ), 'cfz_notice_sanitize', 40 ); + $sanitize_calls = array(); + $sanitize_error = array( + 'setting' => $sanitize_setting, + 'code' => 'sanitize-code', + 'message' => 'Sanitize side-effect notice', + 'type' => 'warning', + ); + \register_setting( + 'cfz_notice_group', + $sanitize_setting, + array( + 'sanitize_callback' => static function ( $value ) use ( &$sanitize_calls, $sanitize_error ): string { + $sanitize_calls[] = $value; + \add_settings_error( $sanitize_error['setting'], $sanitize_error['code'], $sanitize_error['message'], $sanitize_error['type'] ); + return (string) $value; + }, + ) + ); + \update_option( $sanitize_setting, 'stored notice value' ); + $sanitize_calls_before_replay = count( $sanitize_calls ); + $wp_settings_errors = array(); + $sanitize_errors = \get_settings_errors( $sanitize_setting, true ); + \unregister_setting( 'cfz_notice_group', $sanitize_setting ); + + self::collect_failure( + $failures, + array( $updated, $custom ) === $setting_errors + && array( $updated, $custom, $other_error ) === $all_errors + && str_contains( $settings_html, "id='setting-error-" . \esc_attr( $code ) . "'" ) + && str_contains( $settings_html, "class='notice notice-success settings-error is-dismissible'" ) + && str_contains( $settings_html, "class='notice error my-own-css-class hello world settings-error is-dismissible'" ) + && str_contains( $settings_html, '' . $message . '' ) + && ! str_contains( $settings_html, 'notice-notice-' ) + && ! str_contains( $settings_html, $other_error['message'] ) + && '' === $hidden_html + && array( $transient_error ) === $transient_errors + && false === $transient_after + && array( $sanitize_error ) === $sanitize_errors + && count( $sanitize_calls ) > $sanitize_calls_before_replay + && in_array( 'stored notice value', $sanitize_calls, true ), + 'settings errors preserve source arrays, filter by setting, map legacy classes, hide on update, replay/delete transients, and run sanitize side effects', + array( + 'settingErrors' => $setting_errors, + 'allErrors' => $all_errors, + 'settingsHtml' => self::describe_string( $settings_html ), + 'hiddenHtml' => self::describe_string( $hidden_html ), + 'transientErrors' => $transient_errors, + 'transientAfter' => $transient_after, + 'sanitizeErrors' => $sanitize_errors, + 'sanitizeCalls' => $sanitize_calls, + 'sanitizeBefore' => $sanitize_calls_before_replay, + ) + ); + + unset( $_GET['settings-updated'] ); + $wp_settings_errors = array(); + + $notice_message = 'Generated notice ' . \esc_html( self::fuzz_label( $ctx->fork( 'notice-message' ) ) ) . ''; + $notice_id = 'cfz-notice-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $notice_args = array( + 'type' => 'warning', + 'dismissible' => true, + 'id' => $notice_id, + 'additional_classes' => array( 'inline', 'notice-alt' ), + 'attributes' => array( + 'role' => 'alert', + 'data-cfz' => '', + 'data-skip' => '', + 'hidden' => true, + ), + 'paragraph_wrap' => false, + ); + $notice_markup = \wp_get_admin_notice( $notice_message, $notice_args ); + + $arg_events = array(); + $markup_events = array(); + $args_filter = static function ( array $args, string $message ) use ( &$arg_events ): array { + $arg_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + ); + $args['type'] = 'success'; + $args['additional_classes'][] = 'filtered-class'; + return $args; + }; + $markup_filter = static function ( string $markup, string $message, array $args ) use ( &$markup_events ): string { + $markup_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + 'markup' => $markup, + ); + return $markup . 'filtered'; + }; + + \add_filter( 'wp_admin_notice_args', $args_filter, 10, 2 ); + \add_filter( 'wp_admin_notice_markup', $markup_filter, 10, 3 ); + try { + $filtered_markup = \wp_get_admin_notice( 'Filtered notice.', array( 'type' => 'error' ) ); + } finally { + \remove_filter( 'wp_admin_notice_args', $args_filter, 10 ); + \remove_filter( 'wp_admin_notice_markup', $markup_filter, 10 ); + } + + $action_events = array(); + $notice_action = static function ( string $message, array $args ) use ( &$action_events ): void { + $action_events[] = array( + 'message' => $message, + 'type' => $args['type'] ?? null, + ); + }; + \add_action( 'wp_admin_notice', $notice_action, 10, 2 ); + try { + ob_start(); + \wp_admin_notice( + 'Output bold', + array( + 'type' => 'info', + 'dismissible' => true, + 'attributes' => array( 'role' => 'status' ), + ) + ); + $admin_notice_output = (string) ob_get_clean(); + } finally { + \remove_action( 'wp_admin_notice', $notice_action, 10 ); + } + + self::collect_failure( + $failures, + '' === $notice_markup + && str_contains( $filtered_markup, 'notice notice-success filtered-class' ) + && str_contains( $filtered_markup, 'cfz-notice-filtered' ) + && 1 === count( $arg_events ) + && 1 === count( $markup_events ) + && false === \has_filter( 'wp_admin_notice_args', $args_filter ) + && false === \has_filter( 'wp_admin_notice_markup', $markup_filter ) + && 1 === count( $action_events ) + && 'info' === ( $action_events[0]['type'] ?? null ) + && str_contains( $admin_notice_output, 'notice notice-info is-dismissible' ) + && str_contains( $admin_notice_output, 'role="status"' ) + && str_contains( $admin_notice_output, 'bold' ) + && ! str_contains( $admin_notice_output, ' self::describe_string( $notice_markup ), + 'filteredMarkup' => self::describe_string( $filtered_markup ), + 'argEvents' => $arg_events, + 'markupEvents' => $markup_events, + 'actionEvents' => $action_events, + 'adminNoticeOutput' => self::describe_string( $admin_notice_output ), + 'argsFilter' => \has_filter( 'wp_admin_notice_args', $args_filter ), + 'markupFilter' => \has_filter( 'wp_admin_notice_markup', $markup_filter ), + 'noticeAction' => \has_filter( 'wp_admin_notice', $notice_action ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.settings-errors-and-admin-notices.rendering-filters-state', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_meta_boxes( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_meta_boxes; + + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_meta_screen', 40 ) ); + $context = $ctx->choice( array( 'normal', 'side', 'advanced' ) ); + $alt_context = 'side' === $context ? 'normal' : 'side'; + $high_id = self::id( $ctx->fork( 'high' ), 'cfz_box_high', 32 ); + $moved_id = self::id( $ctx->fork( 'moved' ), 'cfz_box_moved', 32 ); + $default_id = self::id( $ctx->fork( 'default' ), 'cfz_box_default', 32 ); + $removed_id = self::id( $ctx->fork( 'removed' ), 'cfz_box_removed', 32 ); + $data_object = (object) array( + 'ID' => $ctx->int( 1000, 9999 ), + 'post_type' => 'component_fuzz', + ); + $calls = array(); + $callback = static function ( $object, array $box ) use ( &$calls, $data_object ): void { + $calls[] = array( + 'id' => $box['id'], + 'sameObject' => $object === $data_object, + 'payload' => $box['args']['payload'] ?? null, + 'argKeys' => array_keys( (array) ( $box['args'] ?? array() ) ), + ); + echo ''; + echo \esc_html( (string) ( $box['args']['payload'] ?? '' ) ); + echo ''; + }; + + \add_meta_box( + $high_id, + 'High ' . \esc_html( self::fuzz_label( $ctx->fork( 'high-title' ) ) ), + $callback, + $screen, + $context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'high-payload' ) ) ) + ); + \add_meta_box( + $moved_id, + 'Moved ' . \esc_html( self::fuzz_label( $ctx->fork( 'moved-title' ) ) ), + $callback, + $screen, + $alt_context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'moved-payload-initial' ) ) ) + ); + \add_meta_box( + $moved_id, + 'Moved Updated ' . \esc_html( self::fuzz_label( $ctx->fork( 'moved-title-updated' ) ) ), + $callback, + $screen, + $context, + '', + array( 'payload' => self::fuzz_label( $ctx->fork( 'moved-payload' ) ) ) + ); + \add_meta_box( + $default_id, + 'Default ' . \esc_html( self::fuzz_label( $ctx->fork( 'default-title' ) ) ), + $callback, + $screen, + $context, + 'default', + array( 'payload' => self::fuzz_label( $ctx->fork( 'default-payload' ) ) ) + ); + \add_meta_box( + $removed_id, + 'Removed ' . \esc_html( self::fuzz_label( $ctx->fork( 'removed-title' ) ) ), + $callback, + $screen, + $context, + 'low', + array( 'payload' => self::fuzz_label( $ctx->fork( 'removed-payload' ) ) ) + ); + \remove_meta_box( $removed_id, array( $screen ), $context ); + + ob_start(); + $count = \do_meta_boxes( $screen, $context, $data_object ); + $html = (string) ob_get_clean(); + + $call_ids = array_column( $calls, 'id' ); + $expected_order = array( $high_id, $moved_id, $default_id ); + $page = $screen->id; + + self::collect_failure( + $failures, + 3 === $count + && $expected_order === $call_ids + && ! isset( $wp_meta_boxes[ $page ][ $alt_context ]['high'][ $moved_id ] ) + && isset( $wp_meta_boxes[ $page ][ $context ]['high'][ $moved_id ] ) + && false === ( $wp_meta_boxes[ $page ][ $context ]['low'][ $removed_id ] ?? null ) + && self::all_call_values( $calls, 'sameObject', true ) + && str_contains( $html, 'id="' . $context . '-sortables"' ) + && str_contains( $html, 'cfz-meta-callback' ) + && ! str_contains( $html, $removed_id ) + && self::strings_in_order( $html, $expected_order ) + && self::html_has_no_unsafe_raw_markup( $html ), + 'Meta boxes render in priority order, move duplicate IDs, pass args, and honor removals', + array( + 'screen' => self::describe_screen( $screen ), + 'context' => $context, + 'altContext' => $alt_context, + 'expectedOrder' => $expected_order, + 'callIds' => $call_ids, + 'count' => $count, + 'calls' => $calls, + 'html' => self::describe_string( $html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.meta-boxes.order-removal-callbacks', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_accordion_sections( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $screen = \convert_to_screen( self::id( $ctx->fork( 'screen' ), 'cfz_accordion_screen', 40 ) ); + $context = 'side'; + $box_id = self::id( $ctx->fork( 'box' ), 'cfz_accordion_box', 32 ); + $removed_id = self::id( $ctx->fork( 'removed' ), 'cfz_accordion_removed', 32 ); + $data_object = (object) array( 'kind' => 'accordion-data' ); + $calls = array(); + $title = 'Accordion ' . self::fuzz_label( $ctx->fork( 'title' ) ); + $callback = static function ( $object, array $box ) use ( &$calls, $data_object ): void { + $calls[] = array( + 'id' => $box['id'], + 'sameObject' => $object === $data_object, + 'payload' => $box['args']['payload'] ?? null, + ); + echo ''; + echo \esc_html( (string) ( $box['args']['payload'] ?? '' ) ); + echo ''; + }; + + \add_meta_box( + $box_id, + $title, + $callback, + $screen, + $context, + 'high', + array( 'payload' => self::fuzz_label( $ctx->fork( 'payload' ) ) ) + ); + \add_meta_box( + $removed_id, + 'Removed Accordion ' . self::fuzz_label( $ctx->fork( 'removed-title' ) ), + $callback, + $screen, + $context, + 'low', + array( 'payload' => self::fuzz_label( $ctx->fork( 'removed-payload' ) ) ) + ); + \remove_meta_box( $removed_id, $screen, $context ); + + ob_start(); + $count = \do_accordion_sections( $screen, $context, $data_object ); + $html = (string) ob_get_clean(); + + self::collect_failure( + $failures, + 1 === $count + && array( $box_id ) === array_column( $calls, 'id' ) + && self::all_call_values( $calls, 'sameObject', true ) + && str_contains( $html, 'accordion-container' ) + && str_contains( $html, 'cfz-accordion-callback' ) + && str_contains( $html, \esc_html( $title ) ) + && ! str_contains( $html, $removed_id ) + && self::html_has_no_unsafe_raw_markup( $html ), + 'do_accordion_sections() renders escaped accordion titles and skips removed boxes', + array( + 'screen' => self::describe_screen( $screen ), + 'boxId' => $box_id, + 'count' => $count, + 'calls' => $calls, + 'html' => self::describe_string( $html ), + ) + ); + + return self::row( + $ctx, + 'admin-screen.meta-boxes.accordion-rendering', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function post_type_key( \ComponentFuzz\FuzzContext $ctx ): string { + return 'cfzpt' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + } + + private static function taxonomy_key( \ComponentFuzz\FuzzContext $ctx ): string { + return 'cfztax' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix, int $max = 32 ): string { + return strtolower( substr( $prefix . '_' . hash( 'crc32b', (string) $ctx->seed() ), 0, $max ) ); + } + + private static function fuzz_label( \ComponentFuzz\FuzzContext $ctx ): string { + return '☃ ' . $ctx->text( 0, 36 ) . ' HTML'; + } + + private static function html_has_no_unsafe_raw_markup( string $html ): bool { + $lower = strtolower( $html ); + + return ! str_contains( $lower, ' $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 describe_screen( $screen ): array { + if ( ! $screen instanceof \WP_Screen ) { + return array( + 'type' => is_object( $screen ) ? get_class( $screen ) : gettype( $screen ), + ); + } + + return array( + 'id' => $screen->id, + 'base' => $screen->base, + 'post_type' => $screen->post_type, + 'taxonomy' => $screen->taxonomy, + 'action' => $screen->action, + 'in_admin' => $screen->in_admin(), + 'is_block_editor' => $screen->is_block_editor(), + ); + } + + 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 ); + } + + 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::PREVIEW_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; + } + + private static function snapshot_state(): array { + $snapshot = array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'current_screen', + 'hook_suffix', + 'new_allowed_options', + 'new_whitelist_options', + 'pagenow', + 'taxnow', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_meta_boxes', + 'wp_post_types', + 'wp_registered_settings', + 'wp_scripts', + 'wp_settings_errors', + 'wp_settings_fields', + 'wp_settings_sections', + 'wp_taxonomies', + ) + ), + 'options' => null, + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $snapshot['options'] = $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + + if ( array_key_exists( 'new_allowed_options', $GLOBALS ) ) { + $GLOBALS['new_whitelist_options'] = &$GLOBALS['new_allowed_options']; + } + } + + 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 globals_match( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] != $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + 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 ) ) { + return clone $value; + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php b/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php new file mode 100644 index 0000000000000..35ffea4ab2f6f --- /dev/null +++ b/tools/component-fuzz/surfaces/AdminWorkflowsSurface.php @@ -0,0 +1,2055 @@ +skip( + 'admin-workflows.bootstrap-apis-available', + 'Required admin workflow APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'REQUEST_URI', 'PHP_SELF' ) ); + $rows = array(); + + try { + $rows[] = self::check_menu_globals( $ctx->fork( 'menu-globals' ) ); + $rows[] = self::check_synthetic_list_table( $ctx->fork( 'list-table' ) ); + $rows[] = self::check_list_table_action_and_month_helpers( $ctx->fork( 'list-table-helpers' ) ); + $rows[] = self::check_referer_helpers( $ctx->fork( 'referer-helpers' ) ); + $rows[] = self::check_referer_field_helpers( $ctx->fork( 'referer-field-helpers' ) ); + $rows[] = self::check_admin_form_controls( $ctx->fork( 'form-controls' ) ); + $rows[] = self::check_core_list_table_coverage_accounting( $ctx->fork( 'core-list-table-accounting' ) ); + $rows[] = self::check_exiting_ajax_wrappers( $ctx->fork( 'ajax-wrappers' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'admin-workflows.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + self::restore_server( $server_snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'WP_List_Table', 'WP_Screen' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_management_page', + 'add_menu_page', + 'add_query_arg', + 'add_submenu_page', + 'admin_url', + 'check_admin_referer', + 'check_ajax_referer', + 'convert_to_screen', + 'current_user_can', + 'esc_attr', + 'esc_html', + 'esc_url', + 'get_admin_page_parent', + 'get_admin_page_title', + 'get_column_headers', + 'get_hidden_columns', + 'home_url', + 'get_plugin_page_hook', + 'get_plugin_page_hookname', + 'has_action', + 'has_filter', + 'menu_page_url', + 'plugin_basename', + 'remove_action', + 'remove_filter', + 'remove_menu_page', + 'remove_query_arg', + 'remove_submenu_page', + 'sanitize_key', + 'sanitize_option', + 'sanitize_title', + 'selected', + 'set_url_scheme', + 'submit_button', + 'date_i18n', + 'wp_create_nonce', + 'wp_get_original_referer', + 'wp_get_raw_referer', + 'wp_get_referer', + 'wp_nonce_field', + 'wp_nonce_url', + 'wp_original_referer_field', + 'wp_referer_field', + 'wp_strip_all_tags', + 'wp_ajax_date_format', + 'wp_ajax_time_format', + 'wp_unslash', + 'wp_validate_redirect', + 'wp_verify_nonce', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function maybe_load_optional_ajax_support(): void { + if ( defined( 'ABSPATH' ) && ! function_exists( 'wp_ajax_date_format' ) ) { + $ajax_actions = ABSPATH . 'wp-admin/includes/ajax-actions.php'; + if ( file_exists( $ajax_actions ) ) { + require_once $ajax_actions; + } + } + } + + private static function check_menu_globals( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( self::menu_global_names() ); + $capability = self::capability( $ctx->fork( 'capability' ), 'manage' ); + $denied_cap = self::capability( $ctx->fork( 'denied-capability' ), 'denied' ); + $top_slug = self::menu_slug( $ctx->fork( 'top-slug' ), 'top' ) . '.php'; + $second_slug = self::menu_slug( $ctx->fork( 'second-slug' ), 'second' ) . '.php'; + $sub_slug = self::menu_slug( $ctx->fork( 'sub-slug' ), 'sub' ); + $tools_slug = self::menu_slug( $ctx->fork( 'tools-slug' ), 'tools' ); + $denied_slug = self::menu_slug( $ctx->fork( 'denied-slug' ), 'denied' ); + $alias_slug = self::menu_slug( $ctx->fork( 'alias-slug' ), 'alias' ) . '.php'; + $top_title = 'Top Page ' . self::hostile_label( $ctx->fork( 'top-title' ) ); + $top_menu_title = 'Top Menu ' . self::hostile_label( $ctx->fork( 'top-menu-title' ) ); + $sub_title = 'Sub Page ' . self::hostile_label( $ctx->fork( 'sub-title' ) ); + $sub_menu_title = 'Sub Menu ' . self::hostile_label( $ctx->fork( 'sub-menu-title' ) ); + $callback_calls = 0; + $callback = static function () use ( &$callback_calls ): void { + ++$callback_calls; + echo 'callback'; + }; + $result = array(); + $restored = false; + + self::reset_menu_globals(); + + try { + $result = self::with_capabilities( + array( $capability ), + static function () use ( + $alias_slug, + $callback, + $capability, + $denied_cap, + $denied_slug, + $second_slug, + $sub_menu_title, + $sub_slug, + $sub_title, + $tools_slug, + $top_menu_title, + $top_slug, + $top_title + ): array { + global $admin_page_hooks, $menu, $submenu, $_parent_pages, $_registered_pages, + $_wp_real_parent_file, $_wp_submenu_nopriv; + + $top_hook = \add_menu_page( + $top_title, + $top_menu_title, + $capability, + $top_slug, + $callback, + 'dashicons-admin-generic', + 65 + ); + $second_hook = \add_menu_page( + 'Second Page', + 'Second Menu', + $capability, + $second_slug, + $callback, + 'none', + 65 + ); + + $_wp_real_parent_file[ $alias_slug ] = $top_slug; + + $sub_hook = \add_submenu_page( + $alias_slug, + $sub_title, + $sub_menu_title, + $capability, + $sub_slug, + $callback, + 1 + ); + $denied_hook = \add_submenu_page( + $top_slug, + 'Denied Page', + 'Denied Menu', + $denied_cap, + $denied_slug, + $callback, + 2 + ); + $tools_hook = \add_management_page( + 'Tools Page', + 'Tools Menu', + $capability, + $tools_slug, + $callback, + 3 + ); + + $sub_url = \menu_page_url( $sub_slug, false ); + ob_start(); + $tools_url = \menu_page_url( $tools_slug, true ); + $tools_url_display = (string) ob_get_clean(); + + $GLOBALS['plugin_page'] = $sub_slug; + $GLOBALS['pagenow'] = 'admin.php'; + $GLOBALS['parent_file'] = ''; + $GLOBALS['typenow'] = ''; + $GLOBALS['title'] = ''; + + $resolved_parent = \get_admin_page_parent(); + $resolved_title = \get_admin_page_title(); + $hook_lookup = \get_plugin_page_hook( $sub_slug, $top_slug ); + + $top_entry = self::find_menu_entry( $top_slug ); + $second_entry = self::find_menu_entry( $second_slug ); + $top_position = self::find_menu_position( $top_slug ); + $second_position = self::find_menu_position( $second_slug ); + $sub_entries = $submenu[ $top_slug ] ?? array(); + $sub_entry = self::find_submenu_entry( $top_slug, $sub_slug ); + $auto_parent = $sub_entries[0] ?? null; + $action_priorites = array( + 'top' => \has_action( $top_hook, $callback ), + 'second' => \has_action( $second_hook, $callback ), + 'sub' => \has_action( $sub_hook, $callback ), + 'tools' => \has_action( $tools_hook, $callback ), + ); + + \remove_action( $top_hook, $callback, 10 ); + \remove_action( $second_hook, $callback, 10 ); + \remove_action( $sub_hook, $callback, 10 ); + \remove_action( $tools_hook, $callback, 10 ); + + $action_cleanup = array( + 'top' => \has_action( $top_hook, $callback ), + 'second' => \has_action( $second_hook, $callback ), + 'sub' => \has_action( $sub_hook, $callback ), + 'tools' => \has_action( $tools_hook, $callback ), + ); + + $removed_sub = \remove_submenu_page( $top_slug, $sub_slug ); + $missing_sub = \remove_submenu_page( $top_slug, $sub_slug ); + $removed_top = \remove_menu_page( $top_slug ); + $missing_top = \remove_menu_page( $top_slug ); + $tools_alt_hook = \get_plugin_page_hookname( $tools_slug, 'edit.php' ); + $expected_values = array( + 'denied_hook' => \get_plugin_page_hookname( $denied_slug, $top_slug ), + 'top_hook' => \get_plugin_page_hookname( $top_slug, '' ), + 'second_hook' => \get_plugin_page_hookname( $second_slug, '' ), + 'sub_hook' => \get_plugin_page_hookname( $sub_slug, $top_slug ), + 'tools_hook' => \get_plugin_page_hookname( $tools_slug, 'tools.php' ), + ); + + return compact( + 'action_cleanup', + 'action_priorites', + 'admin_page_hooks', + 'auto_parent', + 'denied_hook', + 'denied_slug', + 'expected_values', + 'hook_lookup', + 'menu', + 'missing_sub', + 'missing_top', + 'removed_sub', + 'removed_top', + 'resolved_parent', + 'resolved_title', + 'second_entry', + 'second_hook', + 'second_position', + 'sub_entry', + 'sub_hook', + 'sub_url', + 'submenu', + 'tools_alt_hook', + 'tools_hook', + 'tools_slug', + 'tools_url', + 'tools_url_display', + 'top_entry', + 'top_hook', + 'top_position', + '_parent_pages', + '_registered_pages', + '_wp_submenu_nopriv' + ); + } + ); + } finally { + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, self::menu_global_names() ); + } + + self::collect_failure( + $failures, + isset( $result['top_hook'], $result['sub_hook'], $result['second_hook'], $result['tools_hook'] ) + && $result['top_hook'] === ( $result['expected_values']['top_hook'] ?? null ) + && $result['second_hook'] === ( $result['expected_values']['second_hook'] ?? null ) + && $result['sub_hook'] === ( $result['expected_values']['sub_hook'] ?? null ) + && $result['tools_hook'] === ( $result['expected_values']['tools_hook'] ?? null ) + && $result['top_hook'] !== $result['second_hook'] + && $result['sub_hook'] === $result['hook_lookup'], + 'menu helpers generate deterministic distinct hook suffixes', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['top_entry'] ?? null ) + && is_array( $result['second_entry'] ?? null ) + && $top_menu_title === ( $result['top_entry'][0] ?? null ) + && $capability === ( $result['top_entry'][1] ?? null ) + && $top_slug === ( $result['top_entry'][2] ?? null ) + && $top_title === ( $result['top_entry'][3] ?? null ) + && str_contains( (string) ( $result['top_entry'][4] ?? '' ), (string) $result['top_hook'] ) + && false !== $result['top_position'] + && false !== $result['second_position'] + && $result['top_position'] !== $result['second_position'] + && (float) $result['second_position'] > (float) $result['top_position'], + 'top-level menu globals preserve title/capability fields and resolve position collisions monotonically', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['sub_entry'] ?? null ) + && is_array( $result['auto_parent'] ?? null ) + && $top_slug === ( $result['auto_parent'][2] ?? null ) + && $sub_menu_title === ( $result['sub_entry'][0] ?? null ) + && $capability === ( $result['sub_entry'][1] ?? null ) + && $sub_slug === ( $result['sub_entry'][2] ?? null ) + && $sub_title === ( $result['sub_entry'][3] ?? null ) + && $top_slug === ( $result['_parent_pages'][ $sub_slug ] ?? null ) + && false === ( $result['_parent_pages'][ $top_slug ] ?? null ) + && true === ( $result['_registered_pages'][ $result['sub_hook'] ] ?? null ), + 'submenu globals normalize real parents, add parent back-links, and register page hooks', + $result + ); + + self::collect_failure( + $failures, + false === ( $result['denied_hook'] ?? null ) + && true === ( $result['_wp_submenu_nopriv'][ $top_slug ][ $denied_slug ] ?? null ) + && ! isset( $result['_registered_pages'][ $result['expected_values']['denied_hook'] ?? '' ] ), + 'submenu capability failures mark no-priv globals without registering pages', + $result + ); + + self::collect_failure( + $failures, + $top_slug === ( $result['resolved_parent'] ?? null ) + && $sub_title === ( $result['resolved_title'] ?? null ) + && is_string( $result['sub_url'] ?? null ) + && str_contains( $result['sub_url'], 'admin.php?page=' . $sub_slug ) + && is_string( $result['tools_url'] ?? null ) + && str_contains( $result['tools_url'], 'tools.php?page=' . $tools_slug ) + && $result['tools_url'] === ( $result['tools_url_display'] ?? null ) + && true === ( $result['_registered_pages'][ $result['tools_alt_hook'] ] ?? null ) + && self::html_has_no_unsafe_raw_markup( $result['sub_url'] . $result['tools_url'] ), + 'menu page URLs, current parent, and current title resolve through normalized parent files', + $result + ); + + self::collect_failure( + $failures, + is_array( $result['removed_sub'] ?? null ) + && $sub_slug === ( $result['removed_sub'][2] ?? null ) + && false === ( $result['missing_sub'] ?? null ) + && is_array( $result['removed_top'] ?? null ) + && $top_slug === ( $result['removed_top'][2] ?? null ) + && false === ( $result['missing_top'] ?? null ) + && self::all_values( $result['action_priorites'] ?? array(), 10 ) + && self::all_values( $result['action_cleanup'] ?? array(), false ) + && 0 === $callback_calls, + 'remove_menu_page(), remove_submenu_page(), and callback action cleanup are deterministic', + $result + ); + + self::collect_failure( + $failures, + $restored, + 'admin menu globals are restored after the generated case', + array( 'restored' => $restored ) + ); + + return self::row( + $ctx, + 'admin-workflows.menu.globals-hooks-urls-removal', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'slugs' => compact( 'top_slug', 'second_slug', 'sub_slug', 'tools_slug', 'denied_slug', 'alias_slug' ), + ) + ); + } + + private static function check_synthetic_list_table( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_COOKIE', + '_GET', + '_POST', + '_REQUEST', + 'current_screen', + 'hook_suffix', + 'pagenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + ) + ); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'REQUEST_URI', 'PHP_SELF' ) ); + $screen_id = self::screen_id( $ctx->fork( 'screen' ) ); + $screen = \convert_to_screen( $screen_id ); + $hidden_column = 'status'; + $primary_column = 'title'; + $hostile_label = self::hostile_label( $ctx->fork( 'labels' ) ); + $columns = array( + 'cb' => '' . \esc_html( 'Select ' . $hostile_label ) . '', + 'title' => \esc_html( 'Title ' . $hostile_label ), + 'status' => \esc_html( 'Status ' . $hostile_label ), + 'notes' => \esc_html( 'Notes ' . $hostile_label ), + ); + $sortable = array( + 'title' => array( 'cfz_title', false, 'Title abbr "' . $ctx->identifier( 3, 8 ), 'Title order text', 'asc' ), + 'status' => array( 'cfz_status', 'desc' ), + ); + $bulk_actions = array( + 'trash' => \esc_html( 'Trash ' . $hostile_label ), + 'Change State ' . $ctx->identifier( 3, 8 ) => array( + 'feature' => \esc_html( 'Feature ' . $hostile_label ), + 'archive' => \esc_html( 'Archive ' . $hostile_label ), + ), + ); + $item_id = 'cfz-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ); + $item_title = 'Item ' . self::hostile_label( $ctx->fork( 'item-title' ) ); + $views = array( + 'all' => self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=all', 'All ' . $hostile_label, true ), + 'featured' => self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=featured', 'Featured ' . $hostile_label, false ), + ); + $config = array( + 'bulk_actions' => $bulk_actions, + 'columns' => $columns, + 'extra_label' => 'Filter ' . $hostile_label, + 'items' => array( + array( + 'delete_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=delete&item=' . rawurlencode( $item_id ) ), + 'edit_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=edit&item=' . rawurlencode( $item_id ) ), + 'id' => $item_id, + 'notes' => 'Note ' . self::hostile_label( $ctx->fork( 'notes' ) ), + 'status' => 'Draft ' . self::hostile_label( $ctx->fork( 'status' ) ), + 'title' => $item_title, + 'url' => 'https://example.test/admin-workflows/?q=' . rawurlencode( $hostile_label ), + ), + array( + 'delete_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=delete&item=secondary' ), + 'edit_url' => \admin_url( 'admin.php?page=' . $screen_id . '&action=edit&item=secondary' ), + 'id' => $item_id . '-secondary', + 'notes' => 'Secondary', + 'status' => 'Published', + 'title' => 'Secondary ' . $ctx->identifier( 3, 8 ), + 'url' => 'https://example.test/admin-workflows/secondary/', + ), + ), + 'pagination' => array( + 'per_page' => 2, + 'total_items' => 5, + ), + 'plural' => 'cfz_items', + 'primary' => $primary_column, + 'screen' => $screen, + 'singular' => 'cfz_item', + 'sortable' => $sortable, + 'views' => $views, + ); + $table = null; + $hidden_filter = static function ( array $hidden, \WP_Screen $filter_screen ) use ( $hidden_column, $screen ): array { + if ( $filter_screen->id === $screen->id ) { + return array( $hidden_column ); + } + + return $hidden; + }; + $sortable_filter = static function ( array $sortable_columns ) use ( $screen ): array { + if ( $screen instanceof \WP_Screen ) { + $sortable_columns['notes'] = array( 'cfz_notes', false, 'Notes', 'Notes order text', false ); + } + + return $sortable_columns; + }; + $primary_filter = static function ( string $default, string $context ) use ( $primary_column, $screen ): string { + if ( $context === $screen->id ) { + return $primary_column; + } + + return $default; + }; + $views_filter = static function ( array $views_arg ) use ( $screen_id ): array { + $views_arg['mine'] = self::view_link( '/wp-admin/admin.php?page=' . $screen_id . '&view=mine', 'Mine ', false ); + return $views_arg; + }; + $bulk_filter = static function ( array $actions ): array { + $actions['export'] = \esc_html( 'Export ' ); + return $actions; + }; + $result = array(); + $filters_removed = false; + $restored = false; + + $_GET = array( + 'order' => 'asc', + 'orderby' => 'cfz_title', + 'page' => $screen_id, + 'paged' => 2, + ); + $_POST = array(); + $_REQUEST = $_GET; + + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['PHP_SELF'] = '/wp-admin/admin.php'; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=' . rawurlencode( $screen_id ) + . '&orderby=cfz_title&order=asc' + . '&bad=%22%3E%3Cscript%3Ealert(1)%3C/script%3E' + . '&paged=2'; + + \add_filter( 'hidden_columns', $hidden_filter, 10, 3 ); + \add_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter, 10, 1 ); + \add_filter( 'list_table_primary_column', $primary_filter, 10, 2 ); + \add_filter( "views_{$screen->id}", $views_filter, 10, 1 ); + \add_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10, 1 ); + + try { + $table = self::new_synthetic_list_table( $config ); + $table->prepare_items(); + + $column_info = $table->get_column_info(); + $column_count = $table->get_column_count(); + $page_number = $table->get_pagenum(); + $total_pages = $table->get_pagination_arg( 'total_pages' ); + $total_items = $table->get_pagination_arg( 'total_items' ); + $per_page = $table->get_pagination_arg( 'per_page' ); + $current_action = $table->current_action(); + + ob_start(); + $table->views(); + $views_html = (string) ob_get_clean(); + + ob_start(); + $table->display_tablenav( 'top' ); + $tablenav_html = (string) ob_get_clean(); + + ob_start(); + $table->display(); + $display_html = (string) ob_get_clean(); + + $result = compact( + 'column_count', + 'column_info', + 'current_action', + 'display_html', + 'page_number', + 'per_page', + 'tablenav_html', + 'total_items', + 'total_pages', + 'views_html' + ); + } finally { + if ( $table instanceof \WP_List_Table ) { + \remove_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ), 0 ); + } + \remove_filter( 'hidden_columns', $hidden_filter, 10 ); + \remove_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter, 10 ); + \remove_filter( 'list_table_primary_column', $primary_filter, 10 ); + \remove_filter( "views_{$screen->id}", $views_filter, 10 ); + \remove_filter( "bulk_actions-{$screen->id}", $bulk_filter, 10 ); + + $filters_removed = false === \has_filter( 'hidden_columns', $hidden_filter ) + && false === \has_filter( "manage_{$screen->id}_sortable_columns", $sortable_filter ) + && false === \has_filter( 'list_table_primary_column', $primary_filter ) + && false === \has_filter( "views_{$screen->id}", $views_filter ) + && false === \has_filter( "bulk_actions-{$screen->id}", $bulk_filter ) + && ( ! $table instanceof \WP_List_Table || false === \has_filter( "manage_{$screen->id}_columns", array( $table, 'get_columns' ) ) ); + + self::restore_server( $server_snapshot ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( + $local_snapshot, + array( '_COOKIE', '_GET', '_POST', '_REQUEST', 'current_screen', 'hook_suffix', 'pagenow' ) + ); + } + + $column_info = $result['column_info'] ?? array(); + $columns_out = $column_info[0] ?? array(); + $hidden_out = $column_info[1] ?? array(); + $sortable_out = $column_info[2] ?? array(); + $primary_out = $column_info[3] ?? null; + $display_html = (string) ( $result['display_html'] ?? '' ); + $tablenav_html = (string) ( $result['tablenav_html'] ?? '' ); + $views_html = (string) ( $result['views_html'] ?? '' ); + + self::collect_failure( + $failures, + $columns === $columns_out + && array( $hidden_column ) === $hidden_out + && $primary_column === $primary_out + && isset( $sortable_out['title'], $sortable_out['status'], $sortable_out['notes'] ) + && 'cfz_title' === ( $sortable_out['title'][0] ?? null ) + && false === ( $sortable_out['title'][1] ?? null ) + && str_starts_with( (string) ( $sortable_out['title'][2] ?? '' ), 'Title abbr "' ) + && 'Title order text' === ( $sortable_out['title'][3] ?? null ) + && 'asc' === ( $sortable_out['title'][4] ?? null ) + && 'cfz_status' === ( $sortable_out['status'][0] ?? null ) + && 'desc' === ( $sortable_out['status'][1] ?? null ) + && 'cfz_notes' === ( $sortable_out['notes'][0] ?? null ), + 'list table column info is populated', + array( + 'columnInfo' => $column_info, + 'expected' => array( $columns, array( $hidden_column ), $sortable, $primary_column ), + ) + ); + + self::collect_failure( + $failures, + 3 === ( $result['total_pages'] ?? null ) + && 5 === ( $result['total_items'] ?? null ) + && 2 === ( $result['per_page'] ?? null ) + && 2 === ( $result['page_number'] ?? null ) + && 3 === ( $result['column_count'] ?? null ) + && false === ( $result['current_action'] ?? null ), + 'list table pagination args, current page, column counts, and current action are deterministic', + $result + ); + + self::collect_failure( + $failures, + str_contains( $display_html, 'wp-list-table' ) + && str_contains( $display_html, 'column-status hidden' ) + && str_contains( $display_html, 'aria-sort="ascending"' ) + && str_contains( $display_html, 'row-actions visible' ) + && str_contains( $display_html, 'name="cfz_item[]"' ) + && str_contains( $display_html, 'data-wp-lists=\'list:cfz_item\'' ) + && str_contains( $display_html, 'bulk-action-selector-top' ) + && str_contains( $display_html, 'bulk-action-selector-bottom' ) + && str_contains( $display_html, 'name="action2"' ) + && str_contains( $display_html, 'name="_wpnonce"' ), + 'list table display renders rows, hidden/sortable headers, row actions, bulk controls, and nonces', + array( + 'display' => self::describe_string( $display_html ), + 'tablenav' => self::describe_string( $tablenav_html ), + ) + ); + + self::collect_failure( + $failures, + str_contains( $views_html, "class='subsubsub'" ) + && str_contains( $views_html, 'aria-current="page"' ) + && str_contains( $views_html, 'view=mine' ) + && str_contains( $tablenav_html, 'class="cfz-extra-filter"' ) + && str_contains( $tablenav_html, 'name="_wpnonce"' ) + && self::html_has_no_unsafe_raw_markup( $views_html . $tablenav_html . $display_html ), + 'views and display_tablenav output are escaped for generated labels and URLs', + array( + 'views' => self::describe_string( $views_html ), + 'tablenav' => self::describe_string( $tablenav_html ), + 'display' => self::describe_string( $display_html ), + ) + ); + + self::collect_failure( + $failures, + $filters_removed && $restored, + 'list table filters, request globals, screen globals, and server metadata are restored', + array( + 'filtersRemoved' => $filters_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.list-table.synthetic-rendering', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'screen' => $screen->id, + ) + ); + } + + private static function check_list_table_action_and_month_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + '_wp_post_type_features', + 'current_screen', + 'post_type_meta_caps', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_types', + ) + ); + $screen_id = self::screen_id( $ctx->fork( 'screen' ) ); + $screen = \convert_to_screen( $screen_id ); + $hostile_label = self::hostile_label( $ctx->fork( 'bulk-label' ) ); + $month_seen = array(); + $disable_seen = array(); + $table = self::new_helper_list_table( + array( + 'bulk_actions' => array( + 'trash' => \esc_html( 'Trash ' . $hostile_label ), + 'Change ' . $hostile_label => array( + 'feature' => \esc_html( 'Feature ' . $hostile_label ), + 'archive' => \esc_html( 'Archive ' . $hostile_label ), + ), + ), + 'screen' => $screen, + ) + ); + $pre_months = static function ( $months, string $post_type ) use ( &$month_seen ): array { + $month_seen[] = array( + 'filter' => 'pre', + 'postType' => $post_type, + 'input' => $months, + ); + + return array( + (object) array( + 'year' => 2026, + 'month' => 6, + ), + (object) array( + 'year' => 2025, + 'month' => 12, + ), + (object) array( + 'year' => 0, + 'month' => 0, + ), + ); + }; + $month_results = static function ( array $months, string $post_type ) use ( &$month_seen ): array { + $month_seen[] = array( + 'filter' => 'results', + 'postType' => $post_type, + 'count' => count( $months ), + ); + + return $months; + }; + $disable_months = static function ( bool $disabled, string $post_type ) use ( &$disable_seen ): bool { + $disable_seen[] = array( + 'disabled' => $disabled, + 'postType' => $post_type, + ); + + return 'page' === $post_type; + }; + $result = array(); + $filters_removed = false; + $restored = false; + + if ( function_exists( 'create_initial_post_types' ) ) { + \create_initial_post_types(); + } + + \add_filter( 'pre_months_dropdown_query', $pre_months, 10, 2 ); + \add_filter( 'months_dropdown_results', $month_results, 10, 2 ); + \add_filter( 'disable_months_dropdown', $disable_months, 10, 2 ); + + try { + $_REQUEST = array( + 'action' => 'trash', + 'action2' => 'archive', + 'filter_action' => 'Filter', + ); + $filter_action = $table->current_action(); + + $_REQUEST = array( + 'action' => 'trash', + 'action2' => 'archive', + ); + $top_action = $table->current_action(); + + $_REQUEST = array( + 'action' => '-1', + 'action2' => 'archive', + ); + $bottom_ignored = $table->current_action(); + + $bulk_top_html = $table->expose_bulk_actions( 'top' ); + $bulk_bottom_html = $table->expose_bulk_actions( 'bottom' ); + + $_GET = array( 'm' => '202606' ); + $_POST = array(); + $_REQUEST = $_GET; + $months_html = $table->expose_months_dropdown( 'post' ); + + $_GET = array(); + $_REQUEST = array(); + $disabled_months_html = $table->expose_months_dropdown( 'page' ); + + $result = compact( + 'bulk_bottom_html', + 'bulk_top_html', + 'bottom_ignored', + 'disabled_months_html', + 'disable_seen', + 'filter_action', + 'month_seen', + 'months_html', + 'top_action' + ); + } finally { + \remove_filter( 'disable_months_dropdown', $disable_months, 10 ); + \remove_filter( 'months_dropdown_results', $month_results, 10 ); + \remove_filter( 'pre_months_dropdown_query', $pre_months, 10 ); + + $filters_removed = false === \has_filter( 'disable_months_dropdown', $disable_months ) + && false === \has_filter( 'months_dropdown_results', $month_results ) + && false === \has_filter( 'pre_months_dropdown_query', $pre_months ); + + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( + $local_snapshot, + array( '_GET', '_POST', '_REQUEST', '_wp_post_type_features', 'current_screen', 'post_type_meta_caps', 'wp_post_types' ) + ); + } + + self::collect_failure( + $failures, + false === ( $result['filter_action'] ?? null ) + && 'trash' === ( $result['top_action'] ?? null ) + && false === ( $result['bottom_ignored'] ?? null ), + 'current_action honors filter_action suppression and top bulk action precedence', + $result + ); + + self::collect_failure( + $failures, + str_contains( (string) ( $result['bulk_top_html'] ?? '' ), 'name="action"' ) + && str_contains( (string) ( $result['bulk_top_html'] ?? '' ), 'bulk-action-selector-top' ) + && str_contains( (string) ( $result['bulk_top_html'] ?? '' ), ' self::describe_string( (string) ( $result['bulk_top_html'] ?? '' ) ), + 'bottom' => self::describe_string( (string) ( $result['bulk_bottom_html'] ?? '' ) ), + ) + ); + + self::collect_failure( + $failures, + str_contains( (string) ( $result['months_html'] ?? '' ), 'id="filter-by-date"' ) + && str_contains( (string) ( $result['months_html'] ?? '' ), "value='202606'" ) + && str_contains( (string) ( $result['months_html'] ?? '' ), "selected='selected'" ) + && str_contains( (string) ( $result['months_html'] ?? '' ), 'June 2026' ) + && str_contains( (string) ( $result['months_html'] ?? '' ), 'December 2025' ) + && ! str_contains( (string) ( $result['months_html'] ?? '' ), "value='000000'" ) + && '' === (string) ( $result['disabled_months_html'] ?? '' ) + && array( 'post' ) === array_column( array_filter( $result['month_seen'] ?? array(), static fn( array $entry ): bool => 'pre' === $entry['filter'] ), 'postType' ) + && array( 'post' ) === array_column( array_filter( $result['month_seen'] ?? array(), static fn( array $entry ): bool => 'results' === $entry['filter'] ), 'postType' ) + && array( 'post', 'page' ) === array_column( $result['disable_seen'] ?? array(), 'postType' ), + 'months_dropdown uses filter-provided months, selected request state, zero-year skipping, and disable short-circuit', + array( + 'months' => self::describe_string( (string) ( $result['months_html'] ?? '' ) ), + 'disabled' => self::describe_string( (string) ( $result['disabled_months_html'] ?? '' ) ), + 'monthSeen' => $result['month_seen'] ?? array(), + 'disableSeen' => $result['disable_seen'] ?? array(), + ) + ); + + self::collect_failure( + $failures, + $filters_removed && $restored, + 'list table helper filters and request globals are restored', + array( + 'filtersRemoved' => $filters_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.list-table.actions-and-month-filters', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_referer_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + ) + ); + $action = 'cfz-admin-' . substr( hash( 'sha256', (string) $ctx->seed() ), 0, 12 ); + $ajax_action = $action . '-ajax'; + $query_arg = 'cfz_nonce_' . \sanitize_key( $ctx->identifier( 3, 8 ) ); + $admin_nonce = \wp_create_nonce( $action ); + $ajax_nonce = \wp_create_nonce( $ajax_action ); + $admin_calls = array(); + $ajax_calls = array(); + $admin_listener = static function ( $seen_action, $result ) use ( &$admin_calls ): void { + $admin_calls[] = array( + 'action' => $seen_action, + 'result' => $result, + ); + }; + $ajax_listener = static function ( $seen_action, $result ) use ( &$ajax_calls ): void { + $ajax_calls[] = array( + 'action' => $seen_action, + 'result' => $result, + ); + }; + $result = array(); + $actions_removed = false; + $restored = false; + + \add_action( 'check_admin_referer', $admin_listener, 10, 2 ); + \add_action( 'check_ajax_referer', $ajax_listener, 10, 2 ); + + try { + $_GET = array( $query_arg => $admin_nonce ); + $_POST = array(); + $_REQUEST = $_GET; + + $admin_result = \check_admin_referer( $action, $query_arg ); + + $_GET = array(); + $_POST = array( 'nonce' => $ajax_nonce ); + $_REQUEST = $_POST; + + $ajax_result = \check_ajax_referer( $ajax_action, 'nonce', false ); + + $_GET = array(); + $_POST = array( 'nonce' => 'not-a-valid-nonce' ); + $_REQUEST = $_POST; + + $invalid_ajax_result = \check_ajax_referer( $ajax_action, 'nonce', false ); + + $nonce_target_url = \admin_url( + 'admin-post.php?action=' . rawurlencode( $action ) + . '&label=' . rawurlencode( self::hostile_label( $ctx->fork( 'url-label' ) ) ) + ); + $nonce_url = \wp_nonce_url( $nonce_target_url, $action, $query_arg ); + + $result = compact( + 'admin_calls', + 'admin_result', + 'ajax_calls', + 'ajax_result', + 'invalid_ajax_result', + 'nonce_url', + 'query_arg' + ); + } finally { + \remove_action( 'check_admin_referer', $admin_listener, 10 ); + \remove_action( 'check_ajax_referer', $ajax_listener, 10 ); + $actions_removed = false === \has_action( 'check_admin_referer', $admin_listener ) + && false === \has_action( 'check_ajax_referer', $ajax_listener ); + self::restore_globals( $local_snapshot ); + $restored = self::globals_match( $local_snapshot, array( '_GET', '_POST', '_REQUEST' ) ); + } + + self::collect_failure( + $failures, + in_array( $result['admin_result'] ?? null, array( 1, 2 ), true ) + && in_array( $result['ajax_result'] ?? null, array( 1, 2 ), true ) + && false === ( $result['invalid_ajax_result'] ?? null ) + && array( $action ) === array_column( $result['admin_calls'] ?? array(), 'action' ) + && array( $ajax_action, $ajax_action ) === array_column( $result['ajax_calls'] ?? array(), 'action' ), + 'admin and ajax referer helpers verify valid nonces and return false without exiting when stop is disabled', + $result + ); + + self::collect_failure( + $failures, + is_string( $result['nonce_url'] ?? null ) + && str_contains( $result['nonce_url'], 'admin-post.php' ) + && str_contains( $result['nonce_url'], $query_arg . '=' ) + && self::html_has_no_unsafe_raw_markup( $result['nonce_url'] ), + 'admin-post nonce URLs include escaped custom nonce names and generated query labels', + $result + ); + + self::collect_failure( + $failures, + $actions_removed && $restored, + 'referer helper actions and request globals are restored', + array( + 'actionsRemoved' => $actions_removed, + 'restored' => $restored, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.referers.nonce-admin-post-ajax', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_referer_field_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( array( '_GET', '_POST', '_REQUEST' ) ); + $server_snapshot = self::snapshot_server( array( 'HTTP_HOST', 'HTTP_REFERER', 'REQUEST_URI' ) ); + $page = \sanitize_key( 'cfz_referer_' . $ctx->identifier( 3, 8 ) ); + $token = \sanitize_key( $ctx->fork( 'token' )->identifier( 3, 8 ) ); + $current_path = '/wp-admin/admin.php?page=' . rawurlencode( $page ) + . '&_wp_http_referer=/wp-admin/old.php?drop="e="bad"'; + + $result['nonce_with_referer'] = \wp_nonce_field( $action, $nonce_name, true, false ); + $result['nonce_without_referer'] = \wp_nonce_field( $action, $nonce_name, false, false ); + + ob_start(); + \wp_nonce_field( $action, $nonce_name, true, true ); + $result['nonce_echo'] = (string) ob_get_clean(); + + $result['selected_match'] = \selected( $action, $action, false ); + $result['selected_miss'] = \selected( $action, $action . '-miss', false ); + + ob_start(); + \selected( $action, $action, true ); + $result['selected_echo'] = (string) ob_get_clean(); + + ob_start(); + \submit_button( + $label, + 'primary large', + $submit_name, + false, + array( + 'id' => $button_id, + 'data-cfz' => $label, + ) + ); + $result['submit_button'] = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + self::restore_server( $server_snapshot ); + } + + self::collect_failure( + $failures, + is_string( $result['nonce_with_referer'] ?? null ) + && str_contains( $result['nonce_with_referer'], 'name="' . $nonce_name . '"' ) + && str_contains( $result['nonce_with_referer'], 'name="_wp_http_referer"' ) + && is_string( $result['nonce_without_referer'] ?? null ) + && str_contains( $result['nonce_without_referer'], 'name="' . $nonce_name . '"' ) + && ! str_contains( $result['nonce_without_referer'], '_wp_http_referer' ) + && $result['nonce_echo'] === $result['nonce_with_referer'] + && self::html_has_no_unsafe_raw_markup( $result['nonce_with_referer'] ), + 'nonce fields include custom names, optional referer fields, and escaped referer state', + array( + 'withReferer' => $result['nonce_with_referer'] ?? null, + 'withoutReferer' => $result['nonce_without_referer'] ?? null, + 'echo' => $result['nonce_echo'] ?? null, + ) + ); + + self::collect_failure( + $failures, + " selected='selected'" === ( $result['selected_match'] ?? null ) + && '' === ( $result['selected_miss'] ?? null ) + && ( $result['selected_echo'] ?? null ) === ( $result['selected_match'] ?? null ), + 'selected helper returns and echoes only exact-match selection attributes', + array( + 'match' => $result['selected_match'] ?? null, + 'miss' => $result['selected_miss'] ?? null, + 'echo' => $result['selected_echo'] ?? null, + ) + ); + + self::collect_failure( + $failures, + is_string( $result['submit_button'] ?? null ) + && str_contains( $result['submit_button'], 'type="submit"' ) + && str_contains( $result['submit_button'], 'name="' . $submit_name . '"' ) + && str_contains( $result['submit_button'], 'id="' . $button_id . '"' ) + && str_contains( $result['submit_button'], 'class="button button-primary button-large"' ) + && str_contains( $result['submit_button'], 'data-cfz=' ) + && self::html_has_no_unsafe_raw_markup( $result['submit_button'] ), + 'submit_button renders escaped generated labels, ids, names, classes, and custom attributes', + array( 'submitButton' => $result['submit_button'] ?? null ) + ); + + self::collect_failure( + $failures, + self::server_matches( $server_snapshot, array( 'REQUEST_URI' ) ), + 'admin form control helper request URI state is restored', + array( 'requestUri' => $_SERVER['REQUEST_URI'] ?? null ) + ); + + return self::row( + $ctx, + 'admin-workflows.form-controls.nonce-selected-submit', + array() === $failures, + array( + 'action' => $action, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_core_list_table_coverage_accounting( \ComponentFuzz\FuzzContext $ctx ): array { + $admin_list_surface = AdminListTablesSurface::NAME; + $privacy_surface = PrivacyAdminRequestsSurface::NAME; + + return self::row( + $ctx, + 'admin-workflows.list-table.scoped-coverage-accounted', + class_exists( AdminListTablesSurface::class ) && class_exists( PrivacyAdminRequestsSurface::class ), + array( + 'retired_skip' => 'admin-workflows.list-table.core-subclasses-skipped', + 'admin_workflows_direct_coverage' => array( + 'synthetic WP_List_Table rendering', + 'columns, hidden columns, sortable columns, and primary column filters', + 'views and tablenav output', + 'bulk controls and row actions', + 'current action and month dropdown helpers', + 'referer and form-control helpers', + 'request/filter/global restoration', + ), + 'dedicated_surface_coverage' => array( + $admin_list_surface => array( + 'WP_Posts_List_Table', + 'WP_Media_List_Table', + 'WP_Terms_List_Table', + 'WP_Users_List_Table', + 'WP_Comments_List_Table', + 'WP_Plugins_List_Table', + 'WP_Themes_List_Table', + 'WP_Plugin_Install_List_Table', + 'WP_Theme_Install_List_Table', + 'WP_Application_Passwords_List_Table', + 'WP_MS_Themes_List_Table', + 'WP_MS_Sites_List_Table', + 'WP_MS_Users_List_Table', + ), + $privacy_surface => array( + 'WP_Privacy_Data_Export_Requests_List_Table', + 'WP_Privacy_Data_Removal_Requests_List_Table', + ), + ), + 'not_claimed' => array( + 'full admin.php/admin-ajax.php request dispatch', + 'destructive plugin/theme lifecycle operations', + 'real uploads', + 'true multisite write paths', + 'direct WP_Links_List_Table subclass-specific coverage', + 'direct WP_Post_Comments_List_Table subclass-specific coverage', + ), + ) + ); + } + + private static function check_exiting_ajax_wrappers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $local_snapshot = self::snapshot_globals( array( '_GET', '_POST', '_REQUEST', '_COOKIE' ) ); + $server_snapshot = self::snapshot_server( array( 'REQUEST_METHOD', 'REQUEST_URI', 'PHP_SELF' ) ); + $token = \sanitize_key( $ctx->identifier( 3, 8 ) ); + $date_format = 'Y-m-d \D\a\t\e "' . $token . '" '; + $time_format = 'H:i \T\i\m\e "' . $token . '" '; + $cases = array( + 'date' => array( + 'option' => 'date_format', + 'format' => $date_format, + 'function' => 'wp_ajax_date_format', + ), + 'time' => array( + 'option' => 'time_format', + 'format' => $time_format, + 'function' => 'wp_ajax_time_format', + ), + ); + $results = array(); + $restored = false; + + try { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php'; + $_SERVER['PHP_SELF'] = '/wp-admin/admin-ajax.php'; + + foreach ( $cases as $name => $case ) { + $_GET = array(); + $_COOKIE = array(); + $_POST = array( 'date' => addslashes( $case['format'] ) ); + $_REQUEST = $_POST; + + $capture = self::capture_ajax_call( + static function () use ( $case ): void { + $case['function'](); + } + ); + + $results[ $name ] = array( + 'capture' => $capture, + 'expected' => \date_i18n( \sanitize_option( $case['option'], \wp_unslash( $_POST['date'] ) ) ), + 'sanitized_format' => \sanitize_option( $case['option'], \wp_unslash( $_POST['date'] ) ), + 'raw_format' => $case['format'], + ); + } + } finally { + self::restore_globals( $local_snapshot ); + self::restore_server( $server_snapshot ); + $restored = self::globals_match( $local_snapshot, array( '_GET', '_POST', '_REQUEST', '_COOKIE' ) ) + && self::server_matches( $server_snapshot, array( 'REQUEST_METHOD', 'REQUEST_URI', 'PHP_SELF' ) ); + } + + foreach ( $results as $name => $result ) { + $body = self::ajax_body( $result['capture'] ?? array() ); + self::collect_failure( + $failures, + ! empty( $result['capture']['captured'] ) + && null === ( $result['capture']['threw'] ?? null ) + && ! empty( $result['capture']['bufferBalanced'] ) + && ! empty( $result['capture']['filtersRestored'] ) + && $body === ( $result['expected'] ?? null ) + && ! str_contains( strtolower( $body ), ' $result ) + ); + } + + self::collect_failure( + $failures, + $restored + && self::all_ajax_captures_restored( array_column( $results, 'capture' ) ), + 'date/time AJAX wrapper coverage restores request globals, server globals, filters, and output buffers', + array( + 'restored' => $restored, + 'results' => $results, + ) + ); + + return self::row( + $ctx, + 'admin-workflows.ajax.date-time-format-wrappers', + array() === $failures, + array( + 'token' => $token, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function new_synthetic_list_table( array $config ): \WP_List_Table { + return new class( $config ) extends \WP_List_Table { + private array $config; + + public function __construct( array $config ) { + $this->config = $config; + parent::__construct( + array( + 'ajax' => false, + 'plural' => $config['plural'], + 'screen' => $config['screen'], + 'singular' => $config['singular'], + ) + ); + } + + public function get_columns(): array { + return $this->config['columns']; + } + + public function prepare_items(): void { + $this->items = $this->config['items']; + $this->set_pagination_args( $this->config['pagination'] ); + } + + protected function get_sortable_columns(): array { + return $this->config['sortable']; + } + + protected function get_bulk_actions(): array { + return $this->config['bulk_actions']; + } + + protected function get_views(): array { + return $this->config['views']; + } + + protected function column_cb( $item ): string { + return ''; + } + + public function column_title( $item ): string { + return '' . \esc_html( $item['title'] ) . ''; + } + + public function column_status( $item ): string { + return '' . \esc_html( $item['status'] ) . ''; + } + + protected function column_default( $item, $column_name ): string { + return \esc_html( (string) ( $item[ $column_name ] ?? '' ) ); + } + + protected function handle_row_actions( $item, $column_name, $primary ): string { + if ( $column_name !== $primary ) { + return ''; + } + + $actions = array( + 'edit' => '' . \esc_html__( 'Edit' ) . '', + 'delete' => '' . \esc_html__( 'Delete' ) . '', + ); + + return $this->row_actions( $actions, true ); + } + + protected function extra_tablenav( $which ): void { + echo '
'; + echo ''; + echo ''; + echo '
'; + } + }; + } + + private static function new_helper_list_table( array $config ): \WP_List_Table { + return new class( $config ) extends \WP_List_Table { + private array $config; + + public function __construct( array $config ) { + $this->config = $config; + parent::__construct( + array( + 'ajax' => false, + 'plural' => 'cfz_helper_items', + 'screen' => $config['screen'], + 'singular' => 'cfz_helper_item', + ) + ); + } + + protected function get_bulk_actions(): array { + return $this->config['bulk_actions']; + } + + public function expose_bulk_actions( string $which ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->bulk_actions( $which ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + + public function expose_months_dropdown( string $post_type ): string { + $level = ob_get_level(); + ob_start(); + try { + $this->months_dropdown( $post_type ); + return (string) ob_get_clean(); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + throw $e; + } + } + }; + } + + private static function with_capabilities( array $capabilities, callable $callback ) { + $cap_filter = static function ( array $allcaps ) use ( $capabilities ): array { + foreach ( $capabilities as $capability ) { + $allcaps[ $capability ] = true; + } + + return $allcaps; + }; + + \add_filter( 'user_has_cap', $cap_filter, 10, 4 ); + try { + return $callback(); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + } + + private static function reset_menu_globals(): void { + $GLOBALS['menu'] = array(); + $GLOBALS['submenu'] = array(); + $GLOBALS['admin_page_hooks'] = array(); + $GLOBALS['_registered_pages'] = array(); + $GLOBALS['_parent_pages'] = array(); + $GLOBALS['_wp_real_parent_file'] = array(); + $GLOBALS['_wp_menu_nopriv'] = array(); + $GLOBALS['_wp_submenu_nopriv'] = array(); + $GLOBALS['parent_file'] = ''; + $GLOBALS['plugin_page'] = null; + $GLOBALS['pagenow'] = 'admin.php'; + $GLOBALS['typenow'] = ''; + $GLOBALS['title'] = ''; + } + + private static function menu_global_names(): array { + return array( + '_GET', + '_POST', + '_REQUEST', + 'admin_page_hooks', + 'current_user', + 'hook_suffix', + 'menu', + 'pagenow', + 'parent_file', + 'plugin_page', + 'submenu', + 'title', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + '_parent_pages', + '_registered_pages', + '_wp_menu_nopriv', + '_wp_real_parent_file', + '_wp_submenu_nopriv', + ); + } + + private static function find_menu_entry( string $slug ): ?array { + foreach ( (array) ( $GLOBALS['menu'] ?? array() ) as $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $entry; + } + } + + return null; + } + + private static function find_menu_position( string $slug ) { + foreach ( (array) ( $GLOBALS['menu'] ?? array() ) as $position => $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $position; + } + } + + return false; + } + + private static function find_submenu_entry( string $parent_slug, string $slug ): ?array { + foreach ( (array) ( $GLOBALS['submenu'][ $parent_slug ] ?? array() ) as $entry ) { + if ( isset( $entry[2] ) && $slug === $entry[2] ) { + return $entry; + } + } + + return null; + } + + private static function view_link( string $url, string $label, bool $current ): string { + return sprintf( + '%s', + \esc_url( $url ), + $current ? ' class="current" aria-current="page"' : '', + \esc_html( $label ) + ); + } + + private static function capability( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + return \sanitize_key( 'cfz_' . $prefix . '_' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function menu_slug( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + return \sanitize_key( 'cfz-' . $prefix . '-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function screen_id( \ComponentFuzz\FuzzContext $ctx ): string { + return \sanitize_key( 'cfz-list-' . substr( hash( 'crc32b', (string) $ctx->seed() ), 0, 8 ) ); + } + + private static function hostile_label( \ComponentFuzz\FuzzContext $ctx ): string { + return 'label "' . $ctx->identifier( 3, 8 ) . '" onclick="bad" & value'; + } + + private static function html_has_no_unsafe_raw_markup( string $html ): bool { + $lower = strtolower( $html ); + + return ! str_contains( $lower, ' $label, + 'details' => self::describe_value( $details ), + ); + } + + private static function capture_ajax_call( callable $callback ): array { + $die_calls = array(); + $doing_ajax = static fn (): bool => true; + $handler_filter = static function () use ( &$die_calls ): callable { + return static function ( $message = '', $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'message' => $message, + 'title' => $title, + 'args' => is_array( $args ) ? $args : array( 'raw' => $args ), + ); + throw new AdminWorkflowsSurface_DieCaptured( 'Captured ajax wp_die.' ); + }; + }; + + $level = ob_get_level(); + $output = ''; + $captured = false; + $threw = null; + + if ( ! headers_sent() ) { + header_remove(); + } + + \add_filter( 'wp_doing_ajax', $doing_ajax, 1 ); + \add_filter( 'wp_die_ajax_handler', $handler_filter, 1 ); + ob_start(); + try { + $callback(); + } catch ( AdminWorkflowsSurface_DieCaptured $e ) { + $captured = true; + } catch ( \Throwable $e ) { + $threw = self::describe_throwable( $e ); + } finally { + $output = (string) ob_get_clean(); + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + \remove_filter( 'wp_die_ajax_handler', $handler_filter, 1 ); + \remove_filter( 'wp_doing_ajax', $doing_ajax, 1 ); + if ( ! headers_sent() ) { + header_remove(); + } + } + + return array( + 'captured' => $captured, + 'threw' => $threw, + 'output' => $output, + 'dieCalls' => $die_calls, + 'filtersRestored' => false === \has_filter( 'wp_die_ajax_handler', $handler_filter ) + && false === \has_filter( 'wp_doing_ajax', $doing_ajax ), + 'bufferBalanced' => ob_get_level() === $level, + ); + } + + private static function ajax_body( array $capture ): string { + $output = (string) ( $capture['output'] ?? '' ); + if ( '' !== $output ) { + return $output; + } + + $message = $capture['dieCalls'][0]['message'] ?? ''; + return is_scalar( $message ) ? (string) $message : gettype( $message ); + } + + private static function all_ajax_captures_restored( array $captures ): bool { + foreach ( $captures as $capture ) { + if ( + empty( $capture['captured'] ) + || ! empty( $capture['threw'] ) + || empty( $capture['bufferBalanced'] ) + || empty( $capture['filtersRestored'] ) + ) { + return false; + } + } + + return true; + } + + 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 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 ); + } + + 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::PREVIEW_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; + } + + private static function snapshot_state(): array { + $snapshot = array( + 'globals' => self::snapshot_globals( + array( + '_COOKIE', + '_GET', + '_POST', + '_REQUEST', + 'admin_page_hooks', + 'current_screen', + 'current_user', + 'hook_suffix', + 'menu', + 'pagenow', + 'parent_file', + 'plugin_page', + 'submenu', + 'title', + 'typenow', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + '_parent_pages', + '_registered_pages', + '_wp_menu_nopriv', + '_wp_real_parent_file', + '_wp_submenu_nopriv', + ) + ), + 'options' => null, + ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $snapshot['options'] = $GLOBALS['wpdb']->component_fuzz_get_options(); + } + + return $snapshot; + } + + private static function restore_state( array $snapshot ): void { + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::restore_globals( $snapshot['globals'] ); + } + + 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 globals_match( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $GLOBALS ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $GLOBALS[ $name ] != $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + private static function snapshot_server( array $names ): array { + $snapshot = array(); + + foreach ( $names as $name ) { + $snapshot[ $name ] = array( + 'exists' => array_key_exists( $name, $_SERVER ), + 'value' => $_SERVER[ $name ] ?? null, + ); + } + + return $snapshot; + } + + private static function restore_server( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $_SERVER[ $name ] = $entry['value']; + } else { + unset( $_SERVER[ $name ] ); + } + } + } + + private static function server_matches( array $snapshot, array $names ): bool { + foreach ( $names as $name ) { + $exists = array_key_exists( $name, $_SERVER ); + if ( $exists !== $snapshot[ $name ]['exists'] ) { + return false; + } + if ( $exists && $_SERVER[ $name ] !== $snapshot[ $name ]['value'] ) { + return false; + } + } + + return true; + } + + 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 ) ) { + return clone $value; + } + + return $value; + } +} + +final class AdminWorkflowsSurface_DieCaptured extends \RuntimeException {} diff --git a/tools/component-fuzz/surfaces/AiClientSurface.php b/tools/component-fuzz/surfaces/AiClientSurface.php new file mode 100644 index 0000000000000..9947f7321a167 --- /dev/null +++ b/tools/component-fuzz/surfaces/AiClientSurface.php @@ -0,0 +1,2878 @@ +skip( + 'ai-client.bootstrap-apis-available', + 'Required WordPress AI Client APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + AiClientSurface_FakeProvider::reset(); + + $rows[] = self::check_dto_round_trips( $ctx ); + $rows[] = self::check_invalid_value_rejection( $ctx ); + $rows[] = self::check_enum_strictness( $ctx ); + $rows[] = self::check_provider_registry_isolation( $ctx ); + $rows[] = self::check_model_selection_preferences_and_provider_collisions( $ctx ); + $rows[] = self::check_prompt_builder_and_events( $ctx ); + $rows[] = self::check_ability_resolver_integration( $ctx ); + $rows[] = self::check_cache_and_dispatcher_adapters( $ctx ); + $rows[] = self::check_http_transport_bridge( $ctx ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'ai-client.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + AiClientSurface_FakeProvider::reset(); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WordPress\AiClient\AiClient', + 'WordPress\AiClient\Messages\DTO\Message', + 'WordPress\AiClient\Messages\DTO\MessagePart', + 'WordPress\AiClient\Files\DTO\File', + 'WordPress\AiClient\Tools\DTO\FunctionCall', + 'WordPress\AiClient\Tools\DTO\FunctionDeclaration', + 'WordPress\AiClient\Tools\DTO\FunctionResponse', + 'WordPress\AiClient\Providers\ProviderRegistry', + 'WordPress\AiClient\Providers\Http\HttpTransporter', + 'WordPress\AiClient\Providers\Http\DTO\Request', + 'WordPress\AiClient\Providers\Http\DTO\RequestOptions', + 'WordPress\AiClient\Providers\Http\DTO\Response', + 'WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum', + 'WordPress\AiClient\Providers\Http\Exception\NetworkException', + 'WordPress\AiClient\Providers\Models\DTO\ModelConfig', + 'WordPress\AiClient\Results\DTO\GenerativeAiResult', + 'WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery', + 'WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery', + 'WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory', + 'WP_AI_Client_Ability_Function_Resolver', + 'WP_AI_Client_Cache', + 'WP_AI_Client_Discovery_Strategy', + 'WP_AI_Client_Event_Dispatcher', + 'WP_AI_Client_HTTP_Client', + 'WP_AI_Client_Prompt_Builder', + 'WP_Ability', + 'WP_Abilities_Registry', + 'WP_Ability_Categories_Registry', + 'WP_Error', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface', + 'WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', + ) as $interface + ) { + if ( ! interface_exists( $interface ) ) { + $missing[] = "interface {$interface}"; + } + } + + foreach ( + array( + '__', + 'add_action', + 'add_filter', + 'apply_filters', + 'do_action', + 'is_wp_error', + 'remove_action', + 'remove_filter', + 'wp_ai_client_prompt', + 'wp_cache_get', + 'wp_cache_set', + 'wp_cache_delete', + 'wp_cache_get_multiple', + 'wp_cache_set_multiple', + 'wp_cache_delete_multiple', + 'wp_cache_supports', + 'wp_remote_retrieve_body', + 'wp_remote_retrieve_headers', + 'wp_remote_retrieve_response_code', + 'wp_remote_retrieve_response_message', + 'wp_safe_remote_request', + 'wp_get_ability', + 'wp_register_ability', + 'wp_register_ability_category', + 'wp_supports_ai', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_dto_round_trips( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cases = self::dto_cases( $ctx->fork( 'dto-cases' ) ); + + foreach ( $cases as $case ) { + $class = $case['class']; + + try { + $first = $class::fromArray( $case['input'] ); + $canonical = $first->toArray(); + $second = $class::fromArray( $canonical ); + $round_trip = $second->toArray(); + $json = json_decode( + wp_json_encode( + $first, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + ), + true + ); + + self::collect_failure( + $failures, + self::same_value( $canonical, $round_trip ), + "{$case['label']} canonical fromArray/toArray is idempotent", + array( + 'canonical' => $canonical, + 'roundTrip' => $round_trip, + ) + ); + + self::collect_failure( + $failures, + self::same_value( $canonical, $json ), + "{$case['label']} json serialization matches canonical array", + array( + 'canonical' => $canonical, + 'json' => $json, + ) + ); + + self::collect_failure( + $failures, + true === $class::isArrayShape( $canonical ), + "{$case['label']} reports its canonical array as valid shape", + array( 'canonical' => $canonical ) + ); + + foreach ( $case['canonicalKeys'] as $key ) { + self::collect_failure( + $failures, + array_key_exists( $key, $canonical ), + "{$case['label']} canonical array includes {$key}", + array( 'canonical' => $canonical ) + ); + } + + $schema = $class::getJsonSchema(); + foreach ( $case['schemaRequiredSets'] as $required_set ) { + self::collect_failure( + $failures, + self::schema_declares_required_set( $schema, $required_set ), + "{$case['label']} schema declares required keys", + array( + 'requiredSet' => $required_set, + 'schema' => $schema, + ) + ); + } + } catch ( \Throwable $e ) { + $failures[] = array( + 'label' => "{$case['label']} round trip threw", + 'throwable' => self::describe_throwable( $e ), + 'input' => $case['input'], + ); + } + } + + return self::result( + $ctx, + 'ai-client.dto-round-trips-and-schema-required-keys', + array() === $failures, + array( + 'cases' => count( $cases ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_invalid_value_rejection( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $invalids = array( + 'message.invalid-role' => static function (): void { + \WordPress\AiClient\Messages\DTO\Message::fromArray( + array( + 'role' => 'administrator', + 'parts' => array(), + ) + ); + }, + 'message-part.missing-content' => static function (): void { + \WordPress\AiClient\Messages\DTO\MessagePart::fromArray( + array( + 'channel' => 'content', + 'type' => 'text', + ) + ); + }, + 'user-message.function-call' => static function (): void { + new \WordPress\AiClient\Messages\DTO\UserMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + new \WordPress\AiClient\Tools\DTO\FunctionCall( 'call', 'tool', array() ) + ), + ) + ); + }, + 'model-message.function-response' => static function (): void { + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + new \WordPress\AiClient\Tools\DTO\FunctionResponse( 'call', 'tool', array() ) + ), + ) + ); + }, + 'candidate.user-message' => static function (): void { + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\UserMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( 'not a model reply' ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ); + }, + 'file.base64-without-mime' => static function () use ( $ctx ): void { + new \WordPress\AiClient\Files\DTO\File( base64_encode( $ctx->fork( 'bad-file' )->ascii( 3, 8 ) ) ); + }, + 'function-call.no-id-or-name' => static function (): void { + new \WordPress\AiClient\Tools\DTO\FunctionCall(); + }, + 'function-response.no-id-or-name' => static function (): void { + \WordPress\AiClient\Tools\DTO\FunctionResponse::fromArray( array( 'response' => true ) ); + }, + 'provider.invalid-id' => static function (): void { + new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + 'Bad Provider', + 'Bad', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + }, + 'model-config.bad-aspect-ratio' => static function (): void { + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::fromArray( + array( + 'outputMediaOrientation' => 'square', + 'outputMediaAspectRatio' => '16:9', + ) + ); + }, + 'model-config.non-list-modalities' => static function (): void { + $config = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig(); + $config->setOutputModalities( + array( 'text' => \WordPress\AiClient\Messages\Enums\ModalityEnum::text() ) + ); + }, + 'supported-option.non-list-values' => static function (): void { + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::temperature(), + array( 'low' => 0.1 ) + ); + }, + 'model-metadata.non-list-capabilities' => static function (): void { + new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + 'bad', + 'Bad', + array( 'text' => \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ), + array() + ); + }, + 'result.empty-candidates' => static function (): void { + new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'empty', + array(), + new \WordPress\AiClient\Results\DTO\TokenUsage( 1, 1, 2 ), + AiClientSurface_FakeProvider::metadata(), + AiClientSurface_FakeProvider::modelMetadata() + ); + }, + 'operation.succeeded-without-result' => static function (): void { + \WordPress\AiClient\Operations\DTO\GenerativeAiOperation::fromArray( + array( + 'id' => 'operation', + 'state' => 'succeeded', + ) + ); + }, + 'request-options.negative-timeout' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( array( 'timeout' => -0.1 ) ); + }, + 'request-options.negative-redirects' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( array( 'maxRedirects' => -1 ) ); + }, + 'request.invalid-method' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\Request::fromArray( + array( + 'method' => 'FETCH', + 'uri' => 'https://example.test/api', + 'headers' => array(), + ) + ); + }, + 'response.invalid-status' => static function (): void { + \WordPress\AiClient\Providers\Http\DTO\Response::fromArray( + array( + 'statusCode' => 99, + 'headers' => array(), + ) + ); + }, + 'registry.invalid-provider-class' => static function (): void { + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry->registerProvider( \stdClass::class ); + }, + ); + + foreach ( $invalids as $label => $callback ) { + self::collect_failure( + $failures, + self::throws( $callback ), + "{$label} rejects invalid input", + array( 'label' => $label ) + ); + } + + return self::result( + $ctx, + 'ai-client.invalid-inputs-are-rejected', + array() === $failures, + array( + 'cases' => count( $invalids ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_enum_strictness( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $enum_classes = array( + 'WordPress\AiClient\Files\Enums\FileTypeEnum', + 'WordPress\AiClient\Files\Enums\MediaOrientationEnum', + 'WordPress\AiClient\Messages\Enums\MessagePartChannelEnum', + 'WordPress\AiClient\Messages\Enums\MessagePartTypeEnum', + 'WordPress\AiClient\Messages\Enums\MessageRoleEnum', + 'WordPress\AiClient\Messages\Enums\ModalityEnum', + 'WordPress\AiClient\Operations\Enums\OperationStateEnum', + 'WordPress\AiClient\Providers\Enums\ProviderTypeEnum', + 'WordPress\AiClient\Providers\Enums\ToolTypeEnum', + 'WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum', + 'WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod', + 'WordPress\AiClient\Providers\Models\Enums\CapabilityEnum', + 'WordPress\AiClient\Providers\Models\Enums\OptionEnum', + 'WordPress\AiClient\Results\Enums\FinishReasonEnum', + ); + + foreach ( $enum_classes as $class ) { + $values = $class::getValues(); + $cases = $class::cases(); + + self::collect_failure( + $failures, + array() !== $values && count( $values ) === count( $cases ), + "{$class} values and cases agree", + array( + 'values' => $values, + 'cases' => count( $cases ), + ) + ); + + foreach ( $cases as $case ) { + self::collect_failure( + $failures, + $class::from( $case->value ) === $case + && $class::tryFrom( $case->value ) === $case + && true === $class::isValidValue( $case->value ) + && $case->equals( $case->value ) + && (string) $case === $case->value + && json_decode( wp_json_encode( $case ), true ) === $case->value, + "{$class} case {$case->name} is strict and serializable", + array( + 'name' => $case->name, + 'value' => $case->value, + ) + ); + } + + $invalid = '__invalid_' . dechex( $ctx->seed() ) . '_' . str_replace( '\\', '_', strtolower( $class ) ); + self::collect_failure( + $failures, + null === $class::tryFrom( $invalid ) + && false === $class::isValidValue( $invalid ) + && self::throws( static fn() => $class::from( $invalid ) ), + "{$class} rejects invalid backing values", + array( 'invalid' => $invalid ) + ); + } + + self::collect_failure( + $failures, + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->value + === \WordPress\AiClient\Providers\Models\DTO\ModelConfig::KEY_MAX_TOKENS + && \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->isMaxTokens(), + 'OptionEnum dynamically exposes ModelConfig KEY_* constants', + array( + 'maxTokens' => \WordPress\AiClient\Providers\Models\Enums\OptionEnum::maxTokens()->value, + ) + ); + + return self::result( + $ctx, + 'ai-client.enums-are-strict-singleton-values', + array() === $failures, + array( + 'classes' => count( $enum_classes ), + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_provider_registry_isolation( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + self::set_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry', null ); + AiClientSurface_FakeProvider::set_configured( true ); + + $registry_a = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry_b = new \WordPress\AiClient\Providers\ProviderRegistry(); + + $registry_a->registerProvider( AiClientSurface_FakeProvider::class ); + + $default_registry_1 = \WordPress\AiClient\AiClient::defaultRegistry(); + $default_registry_2 = \WordPress\AiClient\AiClient::defaultRegistry(); + $default_registry_1->registerProvider( AiClientSurface_FakeProvider::class ); + + $requirements = new \WordPress\AiClient\Providers\Models\DTO\ModelRequirements( + array( \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ), + array() + ); + + $model = $registry_a->getProviderModel( self::PROVIDER_ID, self::MODEL_ID, self::model_config( $ctx ) ); + $matches = $registry_a->findModelsMetadataForSupport( $requirements ); + $configured = $registry_a->isProviderConfigured( self::PROVIDER_ID ); + + AiClientSurface_FakeProvider::set_configured( false ); + $unconfigured_matches = $registry_a->findModelsMetadataForSupport( $requirements ); + $unconfigured = $registry_a->isProviderConfigured( self::PROVIDER_ID ); + AiClientSurface_FakeProvider::set_configured( true ); + + self::collect_failure( + $failures, + $registry_a->hasProvider( self::PROVIDER_ID ) + && $registry_a->hasProvider( AiClientSurface_FakeProvider::class ) + && AiClientSurface_FakeProvider::class === $registry_a->getProviderClassName( self::PROVIDER_ID ) + && self::PROVIDER_ID === $registry_a->getProviderId( AiClientSurface_FakeProvider::class ) + && array( self::PROVIDER_ID ) === $registry_a->getRegisteredProviderIds(), + 'registered provider is discoverable by ID and class', + array( 'ids' => $registry_a->getRegisteredProviderIds() ) + ); + + self::collect_failure( + $failures, + ! $registry_b->hasProvider( self::PROVIDER_ID ) + && self::throws( static fn() => $registry_b->getProviderClassName( self::PROVIDER_ID ) ), + 'separate ProviderRegistry instances do not share registered providers', + array( 'registryBIds' => $registry_b->getRegisteredProviderIds() ) + ); + + self::collect_failure( + $failures, + $default_registry_1 === $default_registry_2 + && $default_registry_1 !== $registry_a + && $default_registry_1->hasProvider( self::PROVIDER_ID ) + && ! $registry_b->hasProvider( self::PROVIDER_ID ), + 'AiClient default registry is singleton state but isolated from custom registries', + array( + 'defaultIds' => $default_registry_1->getRegisteredProviderIds(), + 'customIds' => $registry_b->getRegisteredProviderIds(), + ) + ); + + self::collect_failure( + $failures, + $model instanceof AiClientSurface_FakeTextModel + && self::same_value( self::model_config( $ctx )->toArray(), $model->getConfig()->toArray() ), + 'registry returns configured model instances without network dependencies', + array( + 'modelClass' => is_object( $model ) ? get_class( $model ) : gettype( $model ), + 'config' => $model instanceof AiClientSurface_FakeTextModel ? $model->getConfig()->toArray() : null, + ) + ); + + self::collect_failure( + $failures, + $configured + && ! $unconfigured + && 1 === count( $matches ) + && array() === $unconfigured_matches, + 'provider availability gates metadata lookup without mutating registration', + array( + 'matches' => count( $matches ), + 'unconfiguredMatches' => count( $unconfigured_matches ), + 'stillRegisteredAfter' => $registry_a->hasProvider( self::PROVIDER_ID ), + ) + ); + + return self::result( + $ctx, + 'ai-client.provider-registry-registration-lookup-and-isolation', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_model_selection_preferences_and_provider_collisions( + \ComponentFuzz\FuzzContext $ctx + ): array { + $failures = array(); + $case = $ctx->fork( 'model-selection' ); + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + + $registry->registerProvider( AiClientSurface_CollisionProviderA::class ); + $registry->registerProvider( AiClientSurface_CollisionProviderB::class ); + + $reversed_registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $reversed_registry->registerProvider( AiClientSurface_CollisionProviderB::class ); + $reversed_registry->registerProvider( AiClientSurface_CollisionProviderA::class ); + + $prompt = 'Choose model ' . self::safe_text( $case->fork( 'prompt' ), 5, 24 ); + $history = new \WordPress\AiClient\Messages\DTO\UserMessage( + array( + new \WordPress\AiClient\Messages\DTO\MessagePart( + 'History ' . self::safe_text( $case->fork( 'history' ), 4, 18 ) + ), + ) + ); + + $model_only = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $provider_tuple = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( + array( self::COLLISION_PROVIDER_B_ID, self::COLLISION_SHARED_MODEL ) + ) + ->generate_text_result(); + + $model_instance = AiClientSurface_CollisionProviderB::model( + self::COLLISION_SHARED_MODEL, + new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + $model_instance_preference = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( $model_instance ) + ->generate_text_result(); + + $provider_locked_by_id = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_provider( self::COLLISION_PROVIDER_B_ID ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $provider_locked_by_class = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_provider( AiClientSurface_CollisionProviderB::class ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + $fallback = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( + 'missing-' . self::slug_piece( $case->fork( 'missing' ), 'model' ), + array( self::COLLISION_PROVIDER_B_ID, self::COLLISION_B_ONLY_MODEL ) + ) + ->generate_text_result(); + + $discovery_order = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->with_history( $history ) + ->generate_text_result(); + + $reversed_model_only = ( new \WP_AI_Client_Prompt_Builder( $reversed_registry, $prompt ) ) + ->with_history( $history ) + ->using_model_preference( self::COLLISION_SHARED_MODEL ) + ->generate_text_result(); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $model_only, + self::COLLISION_PROVIDER_A_ID, + self::COLLISION_SHARED_MODEL + ), + 'model-only preference for shared model preserves first registered provider', + array( 'selection' => self::describe_ai_result_selection( $model_only ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_tuple, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider/model tuple preference overrides shared model collision order', + array( 'selection' => self::describe_ai_result_selection( $provider_tuple ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $model_instance_preference, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'model-instance preference carries its provider/model pair through collisions', + array( 'selection' => self::describe_ai_result_selection( $model_instance_preference ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_locked_by_id, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider lock by ID narrows shared model lookup to that provider', + array( 'selection' => self::describe_ai_result_selection( $provider_locked_by_id ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $provider_locked_by_class, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'provider lock by class narrows shared model lookup to that provider', + array( 'selection' => self::describe_ai_result_selection( $provider_locked_by_class ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $fallback, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_B_ONLY_MODEL + ), + 'missing model preference falls through to the first matching later preference', + array( 'selection' => self::describe_ai_result_selection( $fallback ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $discovery_order, + self::COLLISION_PROVIDER_A_ID, + self::COLLISION_SHARED_MODEL + ), + 'no model preference falls back to first matching provider/model discovery order', + array( 'selection' => self::describe_ai_result_selection( $discovery_order ) ) + ); + + self::collect_failure( + $failures, + self::result_selects_provider_model( + $reversed_model_only, + self::COLLISION_PROVIDER_B_ID, + self::COLLISION_SHARED_MODEL + ), + 'reversed registration order changes the model-only collision winner', + array( 'selection' => self::describe_ai_result_selection( $reversed_model_only ) ) + ); + + return self::result( + $ctx, + 'ai-client.model-selection-preferences-and-provider-collisions', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_prompt_builder_and_events( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $registry = new \WordPress\AiClient\Providers\ProviderRegistry(); + $registry->registerProvider( AiClientSurface_FakeProvider::class ); + + $events = array( + 'before' => array(), + 'after' => array(), + ); + + $before_listener = static function ( object $event ) use ( &$events ): void { + $events['before'][] = get_class( $event ); + }; + $after_listener = static function ( object $event ) use ( &$events ): void { + $events['after'][] = get_class( $event ); + }; + + \WordPress\AiClient\AiClient::setEventDispatcher( new \WP_AI_Client_Event_Dispatcher() ); + \add_action( 'wp_ai_client_before_generate_result', $before_listener ); + \add_action( 'wp_ai_client_after_generate_result', $after_listener ); + + try { + $prompt = 'Summarize ' . self::safe_text( $ctx->fork( 'prompt' ), 6, 20 ); + $result = ( new \WP_AI_Client_Prompt_Builder( $registry, $prompt ) ) + ->using_provider( AiClientSurface_FakeProvider::class ) + ->using_model_preference( self::MODEL_ID ) + ->using_temperature( self::small_float( $ctx->fork( 'temperature' ), 0, 20 ) ) + ->using_max_tokens( $ctx->fork( 'tokens' )->int( 8, 64 ) ) + ->using_system_instruction( 'System ' . self::safe_text( $ctx->fork( 'system' ), 4, 16 ) ) + ->as_json_response( self::simple_json_schema() ) + ->generate_text_result(); + } finally { + \remove_action( 'wp_ai_client_before_generate_result', $before_listener ); + \remove_action( 'wp_ai_client_after_generate_result', $after_listener ); + } + + self::collect_failure( + $failures, + $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + && 1 === $result->getCandidateCount() + && str_starts_with( $result->toText(), 'component-fuzz:' ), + 'WordPress prompt builder proxies snake_case configuration and generates with fake model', + array( + 'result' => $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + ? $result->toArray() + : self::describe_value( $result ), + ) + ); + + self::collect_failure( + $failures, + array( 'WordPress\AiClient\Events\BeforeGenerateResultEvent' ) === $events['before'] + && array( 'WordPress\AiClient\Events\AfterGenerateResultEvent' ) === $events['after'], + 'WordPress event dispatcher maps SDK events to expected action hooks once', + array( 'events' => $events ) + ); + + $bad_builder = new \WP_AI_Client_Prompt_Builder( $registry, '' ); + $chain_after_error = $bad_builder->with_text( 'ignored after constructor error' ); + $supported_after_error = $bad_builder->is_supported_for_text_generation(); + $error_after_error = $bad_builder->generate_text_result(); + + self::collect_failure( + $failures, + $chain_after_error === $bad_builder + && false === $supported_after_error + && \is_wp_error( $error_after_error ) + && 'prompt_invalid_argument' === $error_after_error->get_error_code(), + 'WP prompt builder preserves error state for fluent and terminal calls', + array( + 'supported' => $supported_after_error, + 'error' => self::describe_error( $error_after_error ), + ) + ); + + $disable_filter = static fn(): bool => false; + \add_filter( 'wp_supports_ai', $disable_filter ); + try { + $prevented = ( new \WP_AI_Client_Prompt_Builder( $registry, 'Prevented prompt' ) ) + ->generate_text_result(); + } finally { + \remove_filter( 'wp_supports_ai', $disable_filter ); + } + + self::collect_failure( + $failures, + \is_wp_error( $prevented ) && 'prompt_prevented' === $prevented->get_error_code(), + 'wp_supports_ai filter prevents generation before model execution', + array( 'prevented' => self::describe_error( $prevented ) ) + ); + + return self::result( + $ctx, + 'ai-client.prompt-builder-events-and-wp-error-state', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_ability_resolver_integration( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = $ctx->fork( 'ability' ); + $category = self::slug_piece( $case, 'ai-cat' ); + $name = 'cfuzz-ai/' . self::slug_piece( $case->fork( 'ability-name' ), 'resolve' ); + $value = 'value-' . self::slug_piece( $case->fork( 'value' ), 'v' ); + + self::ensure_init_fired(); + self::reset_ability_registries(); + + $input_schema = array( + 'type' => 'object', + 'required' => array( 'value' ), + 'properties' => array( + 'value' => array( 'type' => 'string' ), + ), + ); + $output_schema = array( + 'type' => 'object', + 'required' => array( 'echo', 'ability' ), + 'properties' => array( + 'echo' => array( 'type' => 'string' ), + 'ability' => array( 'type' => 'string' ), + ), + ); + + $category_action = static function () use ( $category ): void { + \wp_register_ability_category( + $category, + array( + 'label' => 'AI Client Fuzz', + 'description' => 'AI client fuzzer abilities.', + ) + ); + }; + $ability_action = static function () use ( $name, $category, $input_schema, $output_schema ): void { + \wp_register_ability( + $name, + array( + 'label' => 'Resolve AI function call', + 'description' => 'Echoes deterministic fuzzer input.', + 'category' => $category, + 'input_schema' => $input_schema, + 'output_schema' => $output_schema, + 'execute_callback' => static fn( array $input ): array => array( + 'echo' => $input['value'], + 'ability' => $name, + ), + 'permission_callback' => static fn(): bool => true, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + }; + + \add_action( 'wp_abilities_api_categories_init', $category_action ); + \add_action( 'wp_abilities_api_init', $ability_action ); + try { + \WP_Ability_Categories_Registry::get_instance(); + \WP_Abilities_Registry::get_instance(); + } finally { + \remove_action( 'wp_abilities_api_categories_init', $category_action ); + \remove_action( 'wp_abilities_api_init', $ability_action ); + } + + $ability = \wp_get_ability( $name ); + $function_name = \WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $name ); + $call = new \WordPress\AiClient\Tools\DTO\FunctionCall( + 'call-' . $case->iteration(), + $function_name, + array( 'value' => $value ) + ); + $resolver = new \WP_AI_Client_Ability_Function_Resolver( $ability ); + $response = $resolver->execute_ability( $call ); + $denied = ( new \WP_AI_Client_Ability_Function_Resolver( 'cfuzz-ai/other' ) )->execute_ability( $call ); + $message = new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $call ) ) + ); + $responses = $resolver->execute_abilities( $message ); + + self::collect_failure( + $failures, + $ability instanceof \WP_Ability + && $function_name === 'wpab__' . str_replace( '/', '__', $name ) + && \WP_AI_Client_Ability_Function_Resolver::function_name_to_ability_name( $function_name ) === $name, + 'ability registration and function-name mapping are reversible', + array( + 'ability' => self::describe_ability( $ability ), + 'functionName' => $function_name, + ) + ); + + self::collect_failure( + $failures, + $resolver->is_ability_call( $call ) + && array( + 'echo' => $value, + 'ability' => $name, + ) === $response->getResponse(), + 'allowed resolver executes registered ability and returns exact response payload', + array( 'response' => $response->toArray() ) + ); + + self::collect_failure( + $failures, + 'ability_not_allowed' === ( $denied->getResponse()['code'] ?? null ), + 'resolver rejects ability calls outside the allowed set', + array( 'denied' => $denied->toArray() ) + ); + + $response_parts = $responses->getParts(); + self::collect_failure( + $failures, + $resolver->has_ability_calls( $message ) + && $responses->getRole()->isUser() + && 1 === count( $response_parts ) + && $response_parts[0]->getFunctionResponse() instanceof \WordPress\AiClient\Tools\DTO\FunctionResponse + && $response_parts[0]->getFunctionResponse()->getResponse()['echo'] === $value, + 'resolver converts model function calls to user function response messages', + array( 'responses' => $responses->toArray() ) + ); + + $builder = new \WP_AI_Client_Prompt_Builder( new \WordPress\AiClient\Providers\ProviderRegistry(), 'Use ability' ); + $same = $builder->using_abilities( $name ); + $config = self::get_wrapped_prompt_builder_model_config( $builder ); + $tools = $config instanceof \WordPress\AiClient\Providers\Models\DTO\ModelConfig + ? $config->getFunctionDeclarations() + : null; + $tool = is_array( $tools ) ? ( $tools[0] ?? null ) : null; + + self::collect_failure( + $failures, + $same === $builder + && $tool instanceof \WordPress\AiClient\Tools\DTO\FunctionDeclaration + && $function_name === $tool->getName() + && $input_schema === $tool->getParameters(), + 'WP prompt builder converts registered abilities into function declarations', + array( + 'tool' => $tool instanceof \WordPress\AiClient\Tools\DTO\FunctionDeclaration ? $tool->toArray() : $tool, + ) + ); + + return self::result( + $ctx, + 'ai-client.ability-function-resolver-and-prompt-builder-integration', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_cache_and_dispatcher_adapters( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $cache = new \WP_AI_Client_Cache(); + $key_a = 'ai-client-' . dechex( $ctx->seed() ) . '-a'; + $key_b = 'ai-client-' . dechex( $ctx->seed() ) . '-b'; + $value_a = array( + 'seed' => $ctx->seed(), + 'text' => self::safe_text( $ctx->fork( 'cache-a' ), 4, 20 ), + 'false' => false, + ); + $value_b = false; + + \WordPress\AiClient\AiClient::setCache( $cache ); + + $missing = $cache->get( $key_a, 'fallback' ); + $set = $cache->setMultiple( + array( + $key_a => $value_a, + $key_b => $value_b, + ) + ); + $multi = $cache->getMultiple( array( $key_a, $key_b, 'missing-' . $key_a ), 'fallback' ); + $has_a = $cache->has( $key_a ); + $delete = $cache->deleteMultiple( array( $key_a, $key_b ) ); + $after = $cache->getMultiple( array( $key_a, $key_b ), 'fallback' ); + + self::collect_failure( + $failures, + $cache === \WordPress\AiClient\AiClient::getCache() + && 'fallback' === $missing + && true === $set + && true === $has_a + && self::same_value( $value_a, $multi[ $key_a ] ?? null ) + && false === ( $multi[ $key_b ] ?? null ) + && 'fallback' === ( $multi[ 'missing-' . $key_a ] ?? null ) + && true === $delete + && array( $key_a => 'fallback', $key_b => 'fallback' ) === $after, + 'WP AI cache adapter preserves PSR-16 get/set/delete semantics including stored false', + array( + 'missing' => $missing, + 'multi' => $multi, + 'after' => $after, + ) + ); + + $clear_key = $key_a . '-clear'; + $cache->set( $clear_key, 'clear-value' ); + $clear = $cache->clear(); + $clear_supported = function_exists( 'wp_cache_supports' ) && \wp_cache_supports( 'flush_group' ); + $after_clear = $cache->get( $clear_key, 'fallback' ); + $cache->delete( $clear_key ); + + self::collect_failure( + $failures, + ( $clear_supported && true === $clear && 'fallback' === $after_clear ) + || ( ! $clear_supported && false === $clear && 'clear-value' === $after_clear ), + 'WP AI cache clear mirrors object-cache flush_group support', + array( + 'supported' => $clear_supported, + 'clear' => $clear, + 'afterClear' => $after_clear, + ) + ); + + $dispatcher = new \WP_AI_Client_Event_Dispatcher(); + $seen = array(); + $listener = static function ( object $event ) use ( &$seen ): void { + $seen[] = $event; + }; + $event = new \stdClass(); + + \WordPress\AiClient\AiClient::setEventDispatcher( $dispatcher ); + \add_action( 'wp_ai_client_std_class', $listener ); + try { + $dispatched = $dispatcher->dispatch( $event ); + } finally { + \remove_action( 'wp_ai_client_std_class', $listener ); + } + + self::collect_failure( + $failures, + $dispatcher === \WordPress\AiClient\AiClient::getEventDispatcher() + && $dispatched === $event + && array( $event ) === $seen, + 'WP AI event dispatcher returns the original event and fires the derived hook', + array( + 'seenCount' => count( $seen ), + 'eventClass' => get_class( $event ), + 'returnedSame' => $dispatched === $event, + ) + ); + + return self::result( + $ctx, + 'ai-client.cache-and-event-dispatcher-adapters-are-in-memory', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + private static function check_http_transport_bridge( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = $ctx->fork( 'http-transport' ); + $factory = new \WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory(); + + $candidates = \WP_AI_Client_Discovery_Strategy::getCandidates( + \WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface::class + ); + $candidate_factory = $candidates[0]['class'] ?? null; + $discovered_client = is_callable( $candidate_factory ) ? $candidate_factory() : null; + $client = $discovered_client instanceof \WP_AI_Client_HTTP_Client + ? $discovered_client + : new \WP_AI_Client_HTTP_Client( $factory, $factory ); + + self::collect_failure( + $failures, + $discovered_client instanceof \WP_AI_Client_HTTP_Client, + 'WP AI client discovery strategy creates the WordPress HTTP client adapter', + array( + 'candidateCount' => count( $candidates ), + 'clientClass' => is_object( $discovered_client ) ? get_class( $discovered_client ) : gettype( $discovered_client ), + ) + ); + + $discovery_state = self::snapshot_class_discovery_state(); + + $discovery_url = 'https://' . self::domain( $case->fork( 'discovery-domain' ) ) . '/discovered'; + $discovery_body = (string) wp_json_encode( array( 'ping' => self::safe_text( $case->fork( 'discovery-body' ), 3, 10 ) ) ); + $discovery_reply = (string) wp_json_encode( array( 'ok' => true, 'source' => 'discovery' ) ); + $discovery_seen = array(); + $discovery_response = null; + $discovery_throwable = null; + $discovery_request = new \WordPress\AiClient\Providers\Http\DTO\Request( + \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum::from( 'POST' ), + $discovery_url, + array( 'Content-Type' => array( 'application/json' ) ), + $discovery_body + ); + $discovery_filter = static function ( $_pre, array $args, string $url ) use ( &$discovery_seen, $discovery_reply ) { + $discovery_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 202, + 'Accepted', + array( 'Content-Type' => 'application/json' ), + $discovery_reply + ); + }; + + \add_filter( 'pre_http_request', $discovery_filter, 10, 3 ); + try { + self::restore_class_discovery_state( + array( + 'strategies' => array( \WP_AI_Client_Discovery_Strategy::class ), + 'cache' => array(), + ) + ); + $discovery_transporter = new \WordPress\AiClient\Providers\Http\HttpTransporter( null, $factory, $factory ); + $discovery_response = $discovery_transporter->send( $discovery_request ); + } catch ( \Throwable $e ) { + $discovery_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $discovery_filter, 10 ); + self::restore_class_discovery_state( $discovery_state ); + } + + $discovery_args = $discovery_seen[0]['args'] ?? array(); + $restored_state = self::snapshot_class_discovery_state(); + self::collect_failure( + $failures, + null === $discovery_throwable + && 1 === count( $discovery_seen ) + && $discovery_url === ( $discovery_seen[0]['url'] ?? null ) + && 'POST' === ( $discovery_args['method'] ?? null ) + && $discovery_body === ( $discovery_args['body'] ?? null ) + && $discovery_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 202 === $discovery_response->getStatusCode() + && $discovery_reply === $discovery_response->getBody() + && $discovery_state === $restored_state, + 'HTTPlug discovery finds the WordPress HTTP client for HttpTransporter', + array( + 'throwable' => $discovery_throwable ? self::describe_throwable( $discovery_throwable ) : null, + 'request' => $discovery_seen[0] ?? null, + 'response' => $discovery_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + ? $discovery_response->toArray() + : self::describe_value( $discovery_response ), + 'stateRestored' => $discovery_state === $restored_state, + ) + ); + + $direct_url = 'https://' . self::domain( $case->fork( 'direct-domain' ) ) . '/v1/responses'; + $direct_body = (string) wp_json_encode( + array( + 'prompt' => self::safe_text( $case->fork( 'direct-body' ), 6, 24 ), + 'seed' => $ctx->seed(), + ) + ); + $direct_protocol = $case->bool() ? '1.0' : '1.1'; + $direct_timeout = self::small_float( $case->fork( 'direct-timeout' ), 10, 90 ); + $direct_redirection = $case->fork( 'direct-redirection' )->int( 0, 4 ); + $direct_response = null; + $direct_throwable = null; + $direct_requests = array(); + $direct_header_extra = 'branch-' . self::slug_piece( $case->fork( 'direct-header' ), 'branch' ); + $direct_reply_body = (string) wp_json_encode( + array( + 'id' => 'resp-' . self::slug_piece( $case->fork( 'direct-response' ), 'resp' ), + 'output' => array( 'text' => self::safe_text( $case->fork( 'direct-output' ), 4, 18 ) ), + ) + ); + $direct_request = $factory->createRequest( 'PATCH', $direct_url ) + ->withProtocolVersion( $direct_protocol ) + ->withHeader( 'Content-Type', 'application/json' ) + ->withHeader( 'X-Fuzz', array( 'seed-' . $ctx->seed(), $direct_header_extra ) ) + ->withHeader( 'X-Trace', 'trace-' . self::slug_piece( $case->fork( 'direct-trace' ), 'trace' ) ) + ->withBody( $factory->createStream( $direct_body ) ); + $direct_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => $direct_timeout, + 'maxRedirects' => $direct_redirection, + ) + ); + $direct_filter = static function ( $_pre, array $args, string $url ) use ( &$direct_requests, $direct_reply_body ) { + $direct_requests[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 207, + 'Multi-Status', + array( + 'Content-Type' => 'application/json', + 'X-Reply' => array( 'reply-a', 'reply-b' ), + ), + $direct_reply_body + ); + }; + + \add_filter( 'pre_http_request', $direct_filter, 10, 3 ); + try { + $direct_response = $client->sendRequestWithOptions( $direct_request, $direct_options ); + } catch ( \Throwable $e ) { + $direct_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $direct_filter, 10 ); + } + + $direct_args = $direct_requests[0]['args'] ?? array(); + self::collect_failure( + $failures, + null === $direct_throwable + && 1 === count( $direct_requests ) + && $direct_url === ( $direct_requests[0]['url'] ?? null ) + && 'PATCH' === ( $direct_args['method'] ?? null ) + && $direct_protocol === ( $direct_args['httpversion'] ?? null ) + && true === ( $direct_args['blocking'] ?? null ) + && $direct_body === ( $direct_args['body'] ?? null ) + && $direct_timeout === ( $direct_args['timeout'] ?? null ) + && $direct_redirection === ( $direct_args['redirection'] ?? null ) + && 'application/json' === ( $direct_args['headers']['Content-Type'] ?? null ) + && 'seed-' . $ctx->seed() . ', ' . $direct_header_extra === ( $direct_args['headers']['X-Fuzz'] ?? null ), + 'WP HTTP client maps PSR-7 request fields and transport options to WordPress HTTP args', + array( + 'throwable' => $direct_throwable ? self::describe_throwable( $direct_throwable ) : null, + 'request' => $direct_requests[0] ?? null, + ) + ); + + self::collect_failure( + $failures, + $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + && 207 === $direct_response->getStatusCode() + && 'Multi-Status' === $direct_response->getReasonPhrase() + && 'application/json' === $direct_response->getHeaderLine( 'Content-Type' ) + && 'reply-a, reply-b' === $direct_response->getHeaderLine( 'X-Reply' ) + && $direct_reply_body === (string) $direct_response->getBody(), + 'WP HTTP client maps WordPress HTTP responses back to PSR-7 responses', + array( + 'status' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? $direct_response->getStatusCode() + : null, + 'headers' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? $direct_response->getHeaders() + : null, + 'body' => $direct_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? (string) $direct_response->getBody() + : null, + ) + ); + + $body_edge_cases = array( + array( 'label' => 'empty', 'body' => '' ), + array( 'label' => 'zero', 'body' => '0' ), + array( 'label' => 'scalar-json-false', 'body' => 'false' ), + array( 'label' => 'scalar-json-string', 'body' => '"scalar"' ), + ); + $body_edge_failures = array(); + $body_edge_seen = array(); + + foreach ( $body_edge_cases as $edge_case ) { + $edge_seen = array(); + $edge_response = null; + $edge_throwable = null; + $edge_url = 'https://' . self::domain( $case->fork( 'body-edge-' . $edge_case['label'] ) ) . '/edge'; + $edge_request = $factory->createRequest( 'GET', $edge_url ); + $edge_filter = static function ( $_pre, array $args, string $url ) use ( &$edge_seen, $edge_case ) { + $edge_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 200, + 'OK', + array( 'Content-Type' => 'application/json' ), + $edge_case['body'] + ); + }; + + \add_filter( 'pre_http_request', $edge_filter, 10, 3 ); + try { + $edge_response = $client->sendRequestWithOptions( $edge_request, $direct_options ); + } catch ( \Throwable $e ) { + $edge_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $edge_filter, 10 ); + } + + $edge_actual = $edge_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface + ? (string) $edge_response->getBody() + : null; + $body_edge_seen[] = array( + 'label' => $edge_case['label'], + 'body' => self::describe_value( $edge_case['body'] ), + 'actual' => self::describe_value( $edge_actual ), + 'request' => $edge_seen[0] ?? null, + 'throwable' => $edge_throwable ? self::describe_throwable( $edge_throwable ) : null, + ); + + if ( + null !== $edge_throwable || + 1 !== count( $edge_seen ) || + ! $edge_response instanceof \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface || + $edge_case['body'] !== $edge_actual + ) { + $body_edge_failures[] = end( $body_edge_seen ); + } + } + + self::collect_failure( + $failures, + array() === $body_edge_failures, + 'WP HTTP client preserves exact empty and scalar response bodies', + array( + 'cases' => $body_edge_seen, + 'failures' => $body_edge_failures, + ) + ); + + $error_requests = array(); + $error_throwable = null; + $error_filter = static function ( $_pre, array $args, string $url ) use ( &$error_requests ) { + $error_requests[] = array( + 'url' => $url, + 'args' => $args, + ); + + return new \WP_Error( '429', 'Synthetic AI transport timeout' ); + }; + + \add_filter( 'pre_http_request', $error_filter, 10, 3 ); + try { + $client->sendRequestWithOptions( $direct_request, $direct_options ); + } catch ( \Throwable $e ) { + $error_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $error_filter, 10 ); + } + + self::collect_failure( + $failures, + 1 === count( $error_requests ) + && $error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && 429 === $error_throwable->getCode() + && str_contains( $error_throwable->getMessage(), $direct_url ) + && str_contains( $error_throwable->getMessage(), 'Synthetic AI transport timeout' ), + 'WP HTTP client converts WP_Error failures to SDK NetworkException', + array( + 'throwable' => $error_throwable ? self::describe_throwable( $error_throwable ) : null, + 'requests' => $error_requests, + ) + ); + + $request_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => 1.2, + 'connectTimeout' => 0.7, + 'maxRedirects' => 5, + ) + ); + $parameter_timeout = self::small_float( $case->fork( 'parameter-timeout' ), 20, 80 ); + $parameter_options = \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray( + array( + 'timeout' => $parameter_timeout, + 'maxRedirects' => 0, + ) + ); + $transport_url = 'https://' . self::domain( $case->fork( 'transport-domain' ) ) . '/chat/completions'; + $transport_body = (string) wp_json_encode( + array( + 'model' => self::MODEL_ID, + 'input' => self::safe_text( $case->fork( 'transport-body' ), 5, 18 ), + ) + ); + $transport_request = new \WordPress\AiClient\Providers\Http\DTO\Request( + \WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum::from( 'POST' ), + $transport_url, + array( + 'Content-Type' => array( 'application/json' ), + 'Authorization' => array( 'Bearer local-' . self::slug_piece( $case->fork( 'token' ), 'token' ) ), + 'X-Client' => array( 'ai-client-fuzz', 'transport' ), + ), + $transport_body, + $request_options + ); + $transport_reply = (string) wp_json_encode( + array( + 'choices' => array( + array( + 'message' => array( + 'content' => self::safe_text( $case->fork( 'transport-reply' ), 6, 18 ), + ), + ), + ), + ) + ); + $transporter = new \WordPress\AiClient\Providers\Http\HttpTransporter( $client, $factory, $factory ); + $capture_client = new AiClientSurface_CapturingHttpClient( $factory, $factory, 202, 'Accepted', $transport_reply ); + $capture_transport = new \WordPress\AiClient\Providers\Http\HttpTransporter( $capture_client, $factory, $factory ); + $capture_response = null; + $capture_throwable = null; + try { + $capture_response = $capture_transport->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $capture_throwable = $e; + } + + $captured_options = $capture_client->last_options(); + $captured_request = $capture_client->last_request(); + self::collect_failure( + $failures, + null === $capture_throwable + && $capture_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 202 === $capture_response->getStatusCode() + && 0 === $capture_client->send_request_count() + && 1 === $capture_client->send_request_with_options_count() + && $captured_request instanceof \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface + && 'POST' === $captured_request->getMethod() + && $transport_url === (string) $captured_request->getUri() + && $captured_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions + && $parameter_timeout === $captured_options->getTimeout() + && 0.7 === $captured_options->getConnectTimeout() + && 0 === $captured_options->getMaxRedirects(), + 'HttpTransporter preserves request-only SDK options while parameter options override matching fields', + array( + 'throwable' => $capture_throwable ? self::describe_throwable( $capture_throwable ) : null, + 'request' => $captured_request instanceof \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface + ? array( + 'method' => $captured_request->getMethod(), + 'uri' => (string) $captured_request->getUri(), + ) + : self::describe_value( $captured_request ), + 'options' => $captured_options instanceof \WordPress\AiClient\Providers\Http\DTO\RequestOptions + ? $captured_options->toArray() + : self::describe_value( $captured_options ), + 'counts' => array( + 'sendRequest' => $capture_client->send_request_count(), + 'sendRequestWithOptions' => $capture_client->send_request_with_options_count(), + ), + ) + ); + + $transport_seen = array(); + $transport_response = null; + $transport_throwable = null; + $transport_filter = static function ( $_pre, array $args, string $url ) use ( &$transport_seen, $transport_reply ) { + $transport_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return self::wp_http_response( + 201, + 'Created', + array( + 'Content-Type' => 'application/json', + 'X-SDK' => array( 'mapped', 'response' ), + ), + $transport_reply + ); + }; + + \add_filter( 'pre_http_request', $transport_filter, 10, 3 ); + try { + $transport_response = $transporter->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $transport_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $transport_filter, 10 ); + } + + $transport_args = $transport_seen[0]['args'] ?? array(); + self::collect_failure( + $failures, + null === $transport_throwable + && 1 === count( $transport_seen ) + && $transport_url === ( $transport_seen[0]['url'] ?? null ) + && 'POST' === ( $transport_args['method'] ?? null ) + && '1.1' === ( $transport_args['httpversion'] ?? null ) + && $transport_body === ( $transport_args['body'] ?? null ) + && $parameter_timeout === ( $transport_args['timeout'] ?? null ) + && 0 === ( $transport_args['redirection'] ?? null ) + && ! array_key_exists( 'connect_timeout', $transport_args ) + && ! array_key_exists( 'connectTimeout', $transport_args ) + && 'application/json' === ( $transport_args['headers']['Content-Type'] ?? null ) + && 'ai-client-fuzz, transport' === ( $transport_args['headers']['X-Client'] ?? null ), + 'HttpTransporter maps SDK requests through WP HTTP and lets parameter options override request options', + array( + 'throwable' => $transport_throwable ? self::describe_throwable( $transport_throwable ) : null, + 'request' => $transport_seen[0] ?? null, + ) + ); + + self::collect_failure( + $failures, + $transport_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + && 201 === $transport_response->getStatusCode() + && $transport_reply === $transport_response->getBody() + && array( 'application/json' ) === self::header_values( $transport_response->getHeaders(), 'Content-Type' ) + && array( 'mapped', 'response' ) === self::header_values( $transport_response->getHeaders(), 'X-SDK' ) + && is_array( $transport_response->getData() ) + && isset( $transport_response->getData()['choices'][0]['message']['content'] ), + 'HttpTransporter maps PSR-7 responses back to SDK response DTOs', + array( + 'response' => $transport_response instanceof \WordPress\AiClient\Providers\Http\DTO\Response + ? $transport_response->toArray() + : self::describe_value( $transport_response ), + ) + ); + + $transport_error_seen = array(); + $transport_error_throwable = null; + $transport_error_filter = static function ( $_pre, array $args, string $url ) use ( &$transport_error_seen ) { + $transport_error_seen[] = array( + 'url' => $url, + 'args' => $args, + ); + + return new \WP_Error( '503', 'Synthetic SDK transport outage' ); + }; + + \add_filter( 'pre_http_request', $transport_error_filter, 10, 3 ); + try { + $transporter->send( $transport_request, $parameter_options ); + } catch ( \Throwable $e ) { + $transport_error_throwable = $e; + } finally { + \remove_filter( 'pre_http_request', $transport_error_filter, 10 ); + } + + $transport_error_request = null; + $transport_error_request_throwable = null; + if ( $transport_error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException ) { + try { + $transport_error_request = $transport_error_throwable->getRequest(); + } catch ( \Throwable $e ) { + $transport_error_request_throwable = $e; + } + } + $transport_error_previous = $transport_error_throwable instanceof \Throwable + ? $transport_error_throwable->getPrevious() + : null; + + self::collect_failure( + $failures, + 1 === count( $transport_error_seen ) + && $transport_error_throwable instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && str_contains( $transport_error_throwable->getMessage(), $transport_url ) + && str_contains( $transport_error_throwable->getMessage(), 'Synthetic SDK transport outage' ) + && $transport_error_previous instanceof \WordPress\AiClient\Providers\Http\Exception\NetworkException + && 503 === $transport_error_previous->getCode() + && null === $transport_error_request_throwable + && $transport_error_request instanceof \WordPress\AiClient\Providers\Http\DTO\Request + && $transport_url === $transport_error_request->getUri() + && 'POST' === $transport_error_request->getMethod()->value, + 'HttpTransporter wraps WP HTTP adapter NetworkException failures with request context', + array( + 'throwable' => $transport_error_throwable ? self::describe_throwable( $transport_error_throwable ) : null, + 'previous' => $transport_error_previous ? self::describe_throwable( $transport_error_previous ) : null, + 'requestThrowable' => $transport_error_request_throwable ? self::describe_throwable( $transport_error_request_throwable ) : null, + 'request' => $transport_error_request instanceof \WordPress\AiClient\Providers\Http\DTO\Request + ? $transport_error_request->toArray() + : self::describe_value( $transport_error_request ), + 'requests' => $transport_error_seen, + ) + ); + + return self::result( + $ctx, + 'ai-client.wp-http-transport-mapping-and-errors', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + 'requests' => array( + 'discovery' => count( $discovery_seen ), + 'direct' => count( $direct_requests ), + 'bodyEdges' => count( $body_edge_seen ), + 'error' => count( $error_requests ), + 'optionsCapture' => $capture_client->send_request_with_options_count(), + 'transport' => count( $transport_seen ), + 'transportError' => count( $transport_error_seen ), + ), + ) + ); + } + + private static function dto_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $text_part = self::message_part_text_array( $ctx->fork( 'text-part' ) ); + $file_part = self::message_part_file_array( $ctx->fork( 'file-part' ) ); + $function_call = self::function_call_array( $ctx->fork( 'function-call' ) ); + $function_resp = self::function_response_array( $ctx->fork( 'function-response' ) ); + $model_message = self::model_message_array( $ctx->fork( 'model-message' ) ); + $user_message = self::user_message_array( $ctx->fork( 'user-message' ) ); + $provider = self::provider_metadata_array( $ctx->fork( 'provider' ) ); + $supported = self::supported_option_array(); + $model = self::model_metadata_array( $ctx->fork( 'model' ), array( $supported ) ); + $token_usage = self::token_usage_array( $ctx->fork( 'token-usage' ) ); + $candidate = self::candidate_array( $model_message ); + $result = self::result_array( $ctx->fork( 'result' ), $candidate, $token_usage, $provider, $model ); + $operation_state = $ctx->bool() ? 'succeeded' : 'processing'; + $operation = 'succeeded' === $operation_state + ? array( + 'id' => 'op-' . self::slug_piece( $ctx->fork( 'operation' ), 'op' ), + 'state' => 'succeeded', + 'result' => $result, + ) + : array( + 'id' => 'op-' . self::slug_piece( $ctx->fork( 'operation' ), 'op' ), + 'state' => $operation_state, + ); + + return array( + self::dto_case( + 'file.inline', + \WordPress\AiClient\Files\DTO\File::class, + self::file_array( $ctx->fork( 'file' ) ), + array( 'fileType', 'mimeType', 'base64Data' ), + array( array( 'fileType', 'mimeType', 'base64Data' ) ) + ), + self::dto_case( + 'message-part.text', + \WordPress\AiClient\Messages\DTO\MessagePart::class, + $text_part, + array( 'channel', 'type', 'text' ), + array( array( 'type', 'text' ) ) + ), + self::dto_case( + 'message-part.file', + \WordPress\AiClient\Messages\DTO\MessagePart::class, + $file_part, + array( 'channel', 'type', 'file' ), + array( array( 'type', 'file' ) ) + ), + self::dto_case( + 'message.user', + \WordPress\AiClient\Messages\DTO\Message::class, + $user_message, + array( 'role', 'parts' ), + array( array( 'role', 'parts' ) ) + ), + self::dto_case( + 'message.model', + \WordPress\AiClient\Messages\DTO\Message::class, + $model_message, + array( 'role', 'parts' ), + array( array( 'role', 'parts' ) ) + ), + self::dto_case( + 'function-declaration', + \WordPress\AiClient\Tools\DTO\FunctionDeclaration::class, + self::function_declaration_array( $ctx->fork( 'function-declaration' ) ), + array( 'name', 'description', 'parameters' ), + array( array( 'name', 'description' ) ) + ), + self::dto_case( + 'function-call', + \WordPress\AiClient\Tools\DTO\FunctionCall::class, + $function_call, + array( 'id', 'name', 'args' ), + array( array( 'id' ), array( 'name' ) ) + ), + self::dto_case( + 'function-response', + \WordPress\AiClient\Tools\DTO\FunctionResponse::class, + $function_resp, + array( 'id', 'name', 'response' ), + array( array( 'response', 'id' ), array( 'response', 'name' ) ) + ), + self::dto_case( + 'web-search', + \WordPress\AiClient\Tools\DTO\WebSearch::class, + self::web_search_array( $ctx->fork( 'web-search' ) ), + array( 'allowedDomains', 'disallowedDomains' ), + array() + ), + self::dto_case( + 'model-config', + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + self::model_config_array( $ctx->fork( 'model-config' ) ), + array( 'outputModalities', 'systemInstruction', 'outputMimeType', 'outputSchema' ), + array() + ), + self::dto_case( + 'supported-option', + \WordPress\AiClient\Providers\Models\DTO\SupportedOption::class, + $supported, + array( 'name', 'supportedValues' ), + array( array( 'name' ) ) + ), + self::dto_case( + 'required-option', + \WordPress\AiClient\Providers\Models\DTO\RequiredOption::class, + self::required_option_array(), + array( 'name', 'value' ), + array( array( 'name', 'value' ) ) + ), + self::dto_case( + 'model-metadata', + \WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class, + $model, + array( 'id', 'name', 'supportedCapabilities', 'supportedOptions' ), + array( array( 'id', 'name', 'supportedCapabilities', 'supportedOptions' ) ) + ), + self::dto_case( + 'model-requirements', + \WordPress\AiClient\Providers\Models\DTO\ModelRequirements::class, + self::model_requirements_array(), + array( 'requiredCapabilities', 'requiredOptions' ), + array( array( 'requiredCapabilities', 'requiredOptions' ) ) + ), + self::dto_case( + 'provider-metadata', + \WordPress\AiClient\Providers\DTO\ProviderMetadata::class, + $provider, + array( 'id', 'name', 'description', 'type', 'credentialsUrl', 'authenticationMethod', 'logoPath' ), + array( array( 'id', 'name', 'type' ) ) + ), + self::dto_case( + 'provider-models-metadata', + \WordPress\AiClient\Providers\DTO\ProviderModelsMetadata::class, + array( + 'provider' => $provider, + 'models' => array( $model ), + ), + array( 'provider', 'models' ), + array( array( 'provider', 'models' ) ) + ), + self::dto_case( + 'token-usage', + \WordPress\AiClient\Results\DTO\TokenUsage::class, + $token_usage, + array( 'promptTokens', 'completionTokens', 'totalTokens', 'thoughtTokens' ), + array( array( 'promptTokens', 'completionTokens', 'totalTokens' ) ) + ), + self::dto_case( + 'candidate', + \WordPress\AiClient\Results\DTO\Candidate::class, + $candidate, + array( 'message', 'finishReason' ), + array( array( 'message', 'finishReason' ) ) + ), + self::dto_case( + 'generative-result', + \WordPress\AiClient\Results\DTO\GenerativeAiResult::class, + $result, + array( 'id', 'candidates', 'tokenUsage', 'providerMetadata', 'modelMetadata', 'additionalData' ), + array( array( 'id', 'candidates', 'tokenUsage', 'providerMetadata', 'modelMetadata' ) ) + ), + self::dto_case( + 'operation', + \WordPress\AiClient\Operations\DTO\GenerativeAiOperation::class, + $operation, + array_keys( $operation ), + 'succeeded' === $operation_state + ? array( array( 'id', 'state', 'result' ) ) + : array( array( 'id', 'state' ) ) + ), + self::dto_case( + 'request-options', + \WordPress\AiClient\Providers\Http\DTO\RequestOptions::class, + self::request_options_array( $ctx->fork( 'request-options' ) ), + array( 'timeout', 'connectTimeout', 'maxRedirects' ), + array() + ), + self::dto_case( + 'request', + \WordPress\AiClient\Providers\Http\DTO\Request::class, + self::request_array( $ctx->fork( 'request' ) ), + array( 'method', 'uri', 'headers', 'body', 'options' ), + array( array( 'method', 'uri', 'headers' ) ) + ), + self::dto_case( + 'response', + \WordPress\AiClient\Providers\Http\DTO\Response::class, + self::response_array( $ctx->fork( 'response' ) ), + array( 'statusCode', 'headers', 'body' ), + array( array( 'statusCode', 'headers' ) ) + ), + self::dto_case( + 'api-key-auth', + \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication::class, + array( 'apiKey' => 'key-' . self::slug_piece( $ctx->fork( 'api-key' ), 'key' ) ), + array( 'apiKey' ), + array( array( 'apiKey' ) ) + ), + ); + } + + private static function dto_case( + string $label, + string $class, + array $input, + array $canonical_keys, + array $schema_required_sets + ): array { + return array( + 'label' => $label, + 'class' => $class, + 'input' => $input, + 'canonicalKeys' => $canonical_keys, + 'schemaRequiredSets' => $schema_required_sets, + ); + } + + private static function file_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'fileType' => 'inline', + 'mimeType' => 'text/plain', + 'base64Data' => base64_encode( self::safe_text( $ctx, 4, 24 ) ), + ); + } + + private static function message_part_text_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => $ctx->bool() ? 'content' : 'thought', + 'type' => 'text', + 'text' => self::safe_text( $ctx, 3, 28 ), + 'thoughtSignature' => 'sig-' . self::slug_piece( $ctx->fork( 'sig' ), 'sig' ), + ); + } + + private static function message_part_file_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'file', + 'file' => self::file_array( $ctx->fork( 'part-file' ) ), + ); + } + + private static function message_part_function_call_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'function_call', + 'functionCall' => self::function_call_array( $ctx ), + ); + } + + private static function message_part_function_response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'channel' => 'content', + 'type' => 'function_response', + 'functionResponse' => self::function_response_array( $ctx ), + ); + } + + private static function user_message_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'role' => 'user', + 'parts' => array( + self::message_part_text_array( $ctx->fork( 'text' ) ), + self::message_part_file_array( $ctx->fork( 'file' ) ), + self::message_part_function_response_array( $ctx->fork( 'function-response' ) ), + ), + ); + } + + private static function model_message_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'role' => 'model', + 'parts' => array( + self::message_part_text_array( $ctx->fork( 'text' ) ), + self::message_part_function_call_array( $ctx->fork( 'function-call' ) ), + ), + ); + } + + private static function function_declaration_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'name' => 'tool_' . self::slug_piece( $ctx, 'tool' ), + 'description' => 'Tool ' . self::safe_text( $ctx->fork( 'description' ), 4, 20 ), + 'parameters' => self::simple_json_schema(), + ); + } + + private static function function_call_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'call-' . self::slug_piece( $ctx, 'id' ), + 'name' => 'tool_' . self::slug_piece( $ctx->fork( 'name' ), 'tool' ), + 'args' => array( + 'value' => self::safe_text( $ctx->fork( 'args' ), 2, 16 ), + 'count' => $ctx->int( 0, 10 ), + ), + ); + } + + private static function function_response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'call-' . self::slug_piece( $ctx, 'id' ), + 'name' => 'tool_' . self::slug_piece( $ctx->fork( 'name' ), 'tool' ), + 'response' => array( + 'ok' => true, + 'value' => self::safe_text( $ctx->fork( 'response' ), 2, 16 ), + ), + ); + } + + private static function web_search_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'allowedDomains' => array( self::domain( $ctx->fork( 'allow-a' ) ), self::domain( $ctx->fork( 'allow-b' ) ) ), + 'disallowedDomains' => array( self::domain( $ctx->fork( 'deny' ) ) ), + ); + } + + private static function model_config_array( \ComponentFuzz\FuzzContext $ctx ): array { + $orientation = $ctx->choice( array( 'square', 'landscape', 'portrait' ) ); + $aspect = array( + 'square' => '1:1', + 'landscape' => '16:9', + 'portrait' => '9:16', + )[ $orientation ]; + + return array( + 'outputModalities' => array( 'text' ), + 'systemInstruction' => 'System ' . self::safe_text( $ctx->fork( 'system' ), 4, 18 ), + 'candidateCount' => $ctx->int( 1, 3 ), + 'maxTokens' => $ctx->int( 8, 96 ), + 'temperature' => self::small_float( $ctx->fork( 'temperature' ), 0, 20 ), + 'topP' => self::small_float( $ctx->fork( 'top-p' ), 1, 10 ), + 'topK' => $ctx->int( 1, 64 ), + 'stopSequences' => array( 'STOP-' . self::slug_piece( $ctx->fork( 'stop' ), 'stop' ) ), + 'presencePenalty' => self::small_float( $ctx->fork( 'presence' ), -10, 10 ), + 'frequencyPenalty' => self::small_float( $ctx->fork( 'frequency' ), -10, 10 ), + 'logprobs' => $ctx->bool(), + 'topLogprobs' => $ctx->int( 1, 5 ), + 'functionDeclarations' => array( self::function_declaration_array( $ctx->fork( 'tool' ) ) ), + 'webSearch' => self::web_search_array( $ctx->fork( 'web' ) ), + 'outputFileType' => 'inline', + 'outputMimeType' => 'application/json', + 'outputSchema' => self::simple_json_schema(), + 'outputMediaOrientation' => $orientation, + 'outputMediaAspectRatio' => $aspect, + 'outputSpeechVoice' => 'voice-' . self::slug_piece( $ctx->fork( 'voice' ), 'voice' ), + 'customOptions' => array( + 'trace' => 'trace-' . self::slug_piece( $ctx->fork( 'trace' ), 'trace' ), + ), + ); + } + + private static function supported_option_array(): array { + return array( + 'name' => 'outputModalities', + 'supportedValues' => array( array( 'text' ), array( 'image' ) ), + ); + } + + private static function required_option_array(): array { + return array( + 'name' => 'outputModalities', + 'value' => array( 'text' ), + ); + } + + private static function model_requirements_array(): array { + return array( + 'requiredCapabilities' => array( 'text_generation' ), + 'requiredOptions' => array( self::required_option_array() ), + ); + } + + private static function provider_metadata_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'id' => 'provider-' . self::slug_piece( $ctx, 'provider' ), + 'name' => 'Provider ' . self::safe_text( $ctx->fork( 'name' ), 4, 18 ), + 'description' => 'Description ' . self::safe_text( $ctx->fork( 'description' ), 4, 20 ), + 'type' => 'server', + 'credentialsUrl' => 'https://example.test/credentials', + 'authenticationMethod' => 'api_key', + 'logoPath' => '/tmp/component-fuzz-logo.svg', + ); + } + + private static function model_metadata_array( \ComponentFuzz\FuzzContext $ctx, array $supported_options ): array { + return array( + 'id' => 'model-' . self::slug_piece( $ctx, 'model' ), + 'name' => 'Model ' . self::safe_text( $ctx->fork( 'name' ), 4, 18 ), + 'supportedCapabilities' => array( 'text_generation', 'chat_history' ), + 'supportedOptions' => $supported_options, + ); + } + + private static function token_usage_array( \ComponentFuzz\FuzzContext $ctx ): array { + $prompt = $ctx->int( 1, 128 ); + $completion = $ctx->int( 1, 128 ); + $thought = $ctx->int( 0, $completion ); + + return array( + 'promptTokens' => $prompt, + 'completionTokens' => $completion, + 'totalTokens' => $prompt + $completion, + 'thoughtTokens' => $thought, + ); + } + + private static function candidate_array( array $model_message ): array { + return array( + 'message' => $model_message, + 'finishReason' => 'stop', + ); + } + + private static function result_array( + \ComponentFuzz\FuzzContext $ctx, + array $candidate, + array $token_usage, + array $provider, + array $model + ): array { + return array( + 'id' => 'result-' . self::slug_piece( $ctx, 'result' ), + 'candidates' => array( $candidate ), + 'tokenUsage' => $token_usage, + 'providerMetadata' => $provider, + 'modelMetadata' => $model, + 'additionalData' => array( + 'seed' => $ctx->seed(), + 'note' => self::safe_text( $ctx->fork( 'note' ), 2, 18 ), + ), + ); + } + + private static function request_options_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'timeout' => self::small_float( $ctx->fork( 'timeout' ), 1, 100 ), + 'connectTimeout' => self::small_float( $ctx->fork( 'connect-timeout' ), 1, 50 ), + 'maxRedirects' => $ctx->int( 0, 5 ), + ); + } + + private static function request_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'method' => 'POST', + 'uri' => 'https://example.test/ai/' . self::slug_piece( $ctx, 'request' ), + 'headers' => array( + 'Content-Type' => array( 'application/json' ), + 'X-Fuzz' => array( 'seed-' . $ctx->seed() ), + ), + 'body' => wp_json_encode( + array( + 'value' => self::safe_text( $ctx->fork( 'body' ), 2, 18 ), + ) + ), + 'options' => self::request_options_array( $ctx->fork( 'options' ) ), + ); + } + + private static function response_array( \ComponentFuzz\FuzzContext $ctx ): array { + return array( + 'statusCode' => $ctx->choice( array( 200, 201, 202, 400, 429, 500 ) ), + 'headers' => array( + 'Content-Type' => array( 'application/json' ), + 'X-Fuzz' => array( 'response-' . $ctx->seed() ), + ), + 'body' => wp_json_encode( + array( + 'ok' => true, + 'value' => self::safe_text( $ctx->fork( 'body' ), 2, 18 ), + ) + ), + ); + } + + private static function model_config( \ComponentFuzz\FuzzContext $ctx ): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return \WordPress\AiClient\Providers\Models\DTO\ModelConfig::fromArray( + array( + 'outputModalities' => array( 'text' ), + 'systemInstruction' => 'System ' . self::safe_text( $ctx->fork( 'config' ), 3, 16 ), + 'maxTokens' => $ctx->fork( 'max-tokens' )->int( 8, 64 ), + 'temperature' => self::small_float( $ctx->fork( 'config-temp' ), 0, 20 ), + ) + ); + } + + private static function simple_json_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'value' ), + 'properties' => array( + 'value' => array( 'type' => 'string' ), + ), + ); + } + + private static function schema_declares_required_set( array $schema, array $required_set ): bool { + if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) { + $required = array_map( 'strval', $schema['required'] ); + if ( array() === array_diff( $required_set, $required ) ) { + return true; + } + } + + foreach ( array( 'oneOf', 'anyOf', 'allOf' ) as $key ) { + if ( ! isset( $schema[ $key ] ) || ! is_array( $schema[ $key ] ) ) { + continue; + } + foreach ( $schema[ $key ] as $child ) { + if ( is_array( $child ) && self::schema_declares_required_set( $child, $required_set ) ) { + return true; + } + } + } + + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $child ) { + if ( is_array( $child ) && self::schema_declares_required_set( $child, $required_set ) ) { + return true; + } + } + } + + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + return self::schema_declares_required_set( $schema['items'], $required_set ); + } + + return false; + } + + private static function get_wrapped_prompt_builder_model_config( \WP_AI_Client_Prompt_Builder $builder ) { + $builder_property = new \ReflectionProperty( \WP_AI_Client_Prompt_Builder::class, 'builder' ); + $sdk_builder = $builder_property->getValue( $builder ); + + $config_property = new \ReflectionProperty( $sdk_builder, 'modelConfig' ); + return $config_property->getValue( $sdk_builder ); + } + + private static function ensure_init_fired(): 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 ) ); + } + + private static function reset_ability_registries(): void { + self::set_static_property( 'WP_Abilities_Registry', 'instance', null ); + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', null ); + } + + private static function snapshot_state(): array { + $abilities = self::get_static_property( 'WP_Abilities_Registry', 'instance' ); + $categories = self::get_static_property( 'WP_Ability_Categories_Registry', 'instance' ); + + return array( + 'globals' => self::snapshot_globals( + array( + 'wp_filter', + 'wp_filters', + 'wp_actions', + 'wp_current_filter', + 'wp_object_cache', + ) + ), + 'aiDefaultRegistry' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry' ), + 'aiCache' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'cache' ), + 'aiEventDispatcher' => self::get_static_property( \WordPress\AiClient\AiClient::class, 'eventDispatcher' ), + 'fakeProvider' => AiClientSurface_FakeProvider::snapshot(), + 'abilities' => $abilities, + 'registeredAbilities' => $abilities instanceof \WP_Abilities_Registry + ? self::get_object_property( $abilities, 'registered_abilities' ) + : null, + 'categories' => $categories, + 'registeredCategories' => $categories instanceof \WP_Ability_Categories_Registry + ? self::get_object_property( $categories, 'registered_categories' ) + : null, + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + self::set_static_property( \WordPress\AiClient\AiClient::class, 'defaultRegistry', $snapshot['aiDefaultRegistry'] ); + self::set_static_property( \WordPress\AiClient\AiClient::class, 'cache', $snapshot['aiCache'] ); + self::set_static_property( + \WordPress\AiClient\AiClient::class, + 'eventDispatcher', + $snapshot['aiEventDispatcher'] + ); + AiClientSurface_FakeProvider::restore( $snapshot['fakeProvider'] ); + + if ( $snapshot['abilities'] instanceof \WP_Abilities_Registry ) { + self::set_object_property( $snapshot['abilities'], 'registered_abilities', $snapshot['registeredAbilities'] ); + self::set_static_property( 'WP_Abilities_Registry', 'instance', $snapshot['abilities'] ); + } else { + self::set_static_property( 'WP_Abilities_Registry', 'instance', null ); + } + + if ( $snapshot['categories'] instanceof \WP_Ability_Categories_Registry ) { + self::set_object_property( $snapshot['categories'], 'registered_categories', $snapshot['registeredCategories'] ); + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', $snapshot['categories'] ); + } else { + self::set_static_property( 'WP_Ability_Categories_Registry', 'instance', null ); + } + } + + private static function snapshot_class_discovery_state(): array { + $class = \WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery::class; + + return array( + 'strategies' => self::get_static_property( $class, 'strategies' ), + 'cache' => self::get_static_property( $class, 'cache' ), + ); + } + + private static function restore_class_discovery_state( array $snapshot ): void { + $class = \WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery::class; + + self::set_static_property( $class, 'strategies', $snapshot['strategies'] ?? array() ); + self::set_static_property( $class, 'cache', $snapshot['cache'] ?? 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 get_static_property( string $class, string $property ) { + if ( ! class_exists( $class ) ) { + return null; + } + $reflection = new \ReflectionProperty( $class, $property ); + return $reflection->getValue(); + } + + private static function set_static_property( string $class, string $property, $value ): void { + if ( ! class_exists( $class ) ) { + return; + } + $reflection = new \ReflectionProperty( $class, $property ); + $reflection->setValue( null, $value ); + } + + private static function get_object_property( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + return $reflection->getValue( $object ); + } + + private static function set_object_property( object $object, string $property, $value ): void { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setValue( $object, $value ); + } + + private static function throws( callable $callback ): bool { + try { + $callback(); + } catch ( \Throwable $e ) { + return true; + } + + return false; + } + + private static function same_value( $expected, $actual ): bool { + return $expected === $actual; + } + + 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 describe_ability( $ability ): array { + if ( ! $ability instanceof \WP_Ability ) { + return array( 'value' => self::describe_value( $ability ) ); + } + + return array( + 'name' => $ability->get_name(), + 'description' => $ability->get_description(), + 'category' => $ability->get_category(), + 'inputSchema' => $ability->get_input_schema(), + ); + } + + private static function describe_error( $value ): array { + if ( ! \is_wp_error( $value ) ) { + return array( 'value' => self::describe_value( $value ) ); + } + + return array( + 'code' => $value->get_error_code(), + 'message' => $value->get_error_message(), + 'data' => $value->get_error_data(), + ); + } + + private static function wp_http_response( int $status_code, string $message, array $headers, string $body ): array { + return array( + 'headers' => $headers, + 'body' => $body, + 'response' => array( + 'code' => $status_code, + 'message' => $message, + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + private static function header_values( array $headers, string $name ): ?array { + foreach ( $headers as $header_name => $values ) { + if ( 0 !== strcasecmp( (string) $header_name, $name ) ) { + continue; + } + + if ( is_array( $values ) ) { + return array_map( 'strval', array_values( $values ) ); + } + + return array_map( 'trim', explode( ',', (string) $values ) ); + } + + return null; + } + + private static function result_selects_provider_model( $result, string $provider_id, string $model_id ): bool { + return $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult + && $provider_id === $result->getProviderMetadata()->getId() + && $model_id === $result->getModelMetadata()->getId(); + } + + private static function describe_ai_result_selection( $result ): array { + if ( \is_wp_error( $result ) ) { + return self::describe_error( $result ); + } + + if ( ! $result instanceof \WordPress\AiClient\Results\DTO\GenerativeAiResult ) { + return array( 'value' => self::describe_value( $result ) ); + } + + return array( + 'providerId' => $result->getProviderMetadata()->getId(), + 'modelId' => $result->getModelMetadata()->getId(), + 'text' => $result->toText(), + ); + } + + private static function describe_value( $value ) { + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + if ( is_array( $value ) ) { + return $value; + } + 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(), + ); + } + + private static function safe_text( \ComponentFuzz\FuzzContext $ctx, int $min, int $max ): string { + $text = $ctx->ascii( $min, $max ); + $text = preg_replace( '/[^A-Za-z0-9 _.,:-]/', '_', $text ); + $text = trim( (string) $text ); + return '' === $text ? 'component-fuzz' : $text; + } + + private static function slug_piece( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + $raw = strtolower( $prefix . '-' . $ctx->identifier( 3, 12 ) . '-' . dechex( $ctx->seed() & 0xffff ) ); + $raw = preg_replace( '/[^a-z0-9]+/', '-', $raw ); + $raw = trim( (string) $raw, '-' ); + $raw = preg_replace( '/-+/', '-', $raw ); + return '' === $raw ? $prefix . '-' . dechex( $ctx->seed() & 0xffff ) : substr( $raw, 0, 40 ); + } + + private static function domain( \ComponentFuzz\FuzzContext $ctx ): string { + return self::slug_piece( $ctx, 'domain' ) . '.example.test'; + } + + private static function small_float( \ComponentFuzz\FuzzContext $ctx, int $min_tenths, int $max_tenths ): float { + return $ctx->int( $min_tenths, $max_tenths ) / 10; + } +} + +final class AiClientSurface_CapturingHttpClient implements + \WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface, + \WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface { + + private \WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface $response_factory; + private \WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface $stream_factory; + private ?\WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $last_request = null; + private ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $last_options = null; + private int $send_request_count = 0; + private int $send_request_with_options_count = 0; + private int $status_code; + private string $reason_phrase; + private string $body; + + public function __construct( + \WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface $response_factory, + \WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface $stream_factory, + int $status_code, + string $reason_phrase, + string $body + ) { + $this->response_factory = $response_factory; + $this->stream_factory = $stream_factory; + $this->status_code = $status_code; + $this->reason_phrase = $reason_phrase; + $this->body = $body; + } + + public function sendRequest( + \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $request + ): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $this->last_request = $request; + $this->last_options = null; + ++$this->send_request_count; + + return $this->response(); + } + + public function sendRequestWithOptions( + \WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface $request, + \WordPress\AiClient\Providers\Http\DTO\RequestOptions $options + ): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $this->last_request = $request; + $this->last_options = $options; + ++$this->send_request_with_options_count; + + return $this->response(); + } + + public function last_request(): ?\WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface { + return $this->last_request; + } + + public function last_options(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions { + return $this->last_options; + } + + public function send_request_count(): int { + return $this->send_request_count; + } + + public function send_request_with_options_count(): int { + return $this->send_request_with_options_count; + } + + private function response(): \WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface { + $response = $this->response_factory->createResponse( $this->status_code, $this->reason_phrase ) + ->withHeader( 'Content-Type', 'application/json' ) + ->withHeader( 'X-Capture', 'options' ); + + if ( '' !== $this->body ) { + $response = $response->withBody( $this->stream_factory->createStream( $this->body ) ); + } + + return $response; + } +} + +final class AiClientSurface_FakeProvider implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + private static bool $configured = true; + + public static function reset(): void { + self::$configured = true; + } + + public static function snapshot(): array { + return array( 'configured' => self::$configured ); + } + + public static function restore( array $snapshot ): void { + self::$configured = (bool) ( $snapshot['configured'] ?? true ); + } + + public static function set_configured( bool $configured ): void { + self::$configured = $configured; + } + + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::PROVIDER_ID, + 'Component Fuzz AI', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function modelMetadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + $options = array_map( + static fn( \WordPress\AiClient\Providers\Models\Enums\OptionEnum $option ) => + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( $option ), + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::cases() + ); + + return new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + AiClientSurface::MODEL_ID, + 'Component Fuzz Text', + array( + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration(), + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::chatHistory(), + ), + $options + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( AiClientSurface::MODEL_ID !== $modelId ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown fake model: ' . $modelId ); + } + + return new AiClientSurface_FakeTextModel( $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( self::$configured ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_FakeModelMetadataDirectory(); + } +} + +final class AiClientSurface_FakeAvailability implements \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + private bool $configured; + + public function __construct( bool $configured ) { + $this->configured = $configured; + } + + public function isConfigured(): bool { + return $this->configured; + } +} + +final class AiClientSurface_FakeModelMetadataDirectory implements \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + public function listModelMetadata(): array { + return array( AiClientSurface_FakeProvider::modelMetadata() ); + } + + public function hasModelMetadata( string $modelId ): bool { + return AiClientSurface::MODEL_ID === $modelId; + } + + public function getModelMetadata( string $modelId ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + if ( ! $this->hasModelMetadata( $modelId ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown fake model: ' . $modelId ); + } + + return AiClientSurface_FakeProvider::modelMetadata(); + } +} + +final class AiClientSurface_FakeTextModel implements + \WordPress\AiClient\Providers\Models\Contracts\ModelInterface, + \WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface { + + private \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config; + + public function __construct( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ) { + $this->config = $config; + } + + public function metadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return AiClientSurface_FakeProvider::modelMetadata(); + } + + public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return AiClientSurface_FakeProvider::metadata(); + } + + public function setConfig( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): \WordPress\AiClient\Results\DTO\GenerativeAiResult { + $last_text = ''; + foreach ( $prompt as $message ) { + if ( ! $message instanceof \WordPress\AiClient\Messages\DTO\Message ) { + continue; + } + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + $last_text = $part->getText(); + } + } + } + + $text = 'component-fuzz:' . count( $prompt ) . ':' . $last_text; + + return new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'fake-result-' . substr( sha1( $text ), 0, 12 ), + array( + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ), + ), + new \WordPress\AiClient\Results\DTO\TokenUsage( strlen( $last_text ), strlen( $text ), strlen( $last_text ) + strlen( $text ) ), + $this->providerMetadata(), + $this->metadata(), + array( 'promptCount' => count( $prompt ) ) + ); + } +} + +final class AiClientSurface_CollisionProviderA implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::COLLISION_PROVIDER_A_ID, + 'Component Fuzz Collision A', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( ! in_array( $modelId, array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_A_ONLY_MODEL ), true ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model A: ' . $modelId ); + } + + return new AiClientSurface_CollisionTextModel( + self::metadata(), + $modelId, + $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( true ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_CollisionModelMetadataDirectory( + array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_A_ONLY_MODEL ) + ); + } +} + +final class AiClientSurface_CollisionProviderB implements \WordPress\AiClient\Providers\Contracts\ProviderInterface { + public static function metadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return new \WordPress\AiClient\Providers\DTO\ProviderMetadata( + AiClientSurface::COLLISION_PROVIDER_B_ID, + 'Component Fuzz Collision B', + \WordPress\AiClient\Providers\Enums\ProviderTypeEnum::server() + ); + } + + public static function model( + string $modelId, + ?\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig = null + ): \WordPress\AiClient\Providers\Models\Contracts\ModelInterface { + if ( ! in_array( $modelId, array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_B_ONLY_MODEL ), true ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model B: ' . $modelId ); + } + + return new AiClientSurface_CollisionTextModel( + self::metadata(), + $modelId, + $modelConfig ?? new \WordPress\AiClient\Providers\Models\DTO\ModelConfig() + ); + } + + public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface { + return new AiClientSurface_FakeAvailability( true ); + } + + public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + return new AiClientSurface_CollisionModelMetadataDirectory( + array( AiClientSurface::COLLISION_SHARED_MODEL, AiClientSurface::COLLISION_B_ONLY_MODEL ) + ); + } +} + +final class AiClientSurface_CollisionModelMetadataDirectory implements + \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface { + + /** @var list */ + private array $model_ids; + + /** + * @param list $model_ids Model identifiers exposed by the provider. + */ + public function __construct( array $model_ids ) { + $this->model_ids = array_values( $model_ids ); + } + + public function listModelMetadata(): array { + return array_map( + static fn( string $model_id ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata => + AiClientSurface_CollisionTextModel::metadata_for( $model_id ), + $this->model_ids + ); + } + + public function hasModelMetadata( string $modelId ): bool { + return in_array( $modelId, $this->model_ids, true ); + } + + public function getModelMetadata( string $modelId ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + if ( ! $this->hasModelMetadata( $modelId ) ) { + throw new \WordPress\AiClient\Common\Exception\InvalidArgumentException( 'Unknown collision model: ' . $modelId ); + } + + return AiClientSurface_CollisionTextModel::metadata_for( $modelId ); + } +} + +final class AiClientSurface_CollisionTextModel implements + \WordPress\AiClient\Providers\Models\Contracts\ModelInterface, + \WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface { + + private \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider_metadata; + + private string $model_id; + + private \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config; + + public function __construct( + \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider_metadata, + string $model_id, + \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config + ) { + $this->provider_metadata = $provider_metadata; + $this->model_id = $model_id; + $this->config = $config; + } + + public static function metadata_for( string $model_id ): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + $options = array_map( + static fn( \WordPress\AiClient\Providers\Models\Enums\OptionEnum $option ) => + new \WordPress\AiClient\Providers\Models\DTO\SupportedOption( $option ), + \WordPress\AiClient\Providers\Models\Enums\OptionEnum::cases() + ); + + return new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + $model_id, + 'Component Fuzz ' . $model_id, + array( + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration(), + \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::chatHistory(), + ), + $options + ); + } + + public function metadata(): \WordPress\AiClient\Providers\Models\DTO\ModelMetadata { + return self::metadata_for( $this->model_id ); + } + + public function providerMetadata(): \WordPress\AiClient\Providers\DTO\ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): \WordPress\AiClient\Providers\Models\DTO\ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): \WordPress\AiClient\Results\DTO\GenerativeAiResult { + $last_text = ''; + foreach ( $prompt as $message ) { + if ( ! $message instanceof \WordPress\AiClient\Messages\DTO\Message ) { + continue; + } + foreach ( $message->getParts() as $part ) { + if ( null !== $part->getText() ) { + $last_text = $part->getText(); + } + } + } + + $text = implode( + ':', + array( + 'component-fuzz-collision', + $this->provider_metadata->getId(), + $this->model_id, + $last_text, + ) + ); + + return new \WordPress\AiClient\Results\DTO\GenerativeAiResult( + 'collision-result-' . substr( sha1( $text ), 0, 12 ), + array( + new \WordPress\AiClient\Results\DTO\Candidate( + new \WordPress\AiClient\Messages\DTO\ModelMessage( + array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) + ), + \WordPress\AiClient\Results\Enums\FinishReasonEnum::stop() + ), + ), + new \WordPress\AiClient\Results\DTO\TokenUsage( strlen( $last_text ), strlen( $text ), strlen( $last_text ) + strlen( $text ) ), + $this->providerMetadata(), + $this->metadata(), + array( + 'providerId' => $this->provider_metadata->getId(), + 'modelId' => $this->model_id, + 'promptCount' => count( $prompt ), + ) + ); + } +} diff --git a/tools/component-fuzz/surfaces/AppearanceMediaSurface.php b/tools/component-fuzz/surfaces/AppearanceMediaSurface.php new file mode 100644 index 0000000000000..12e449c256ab5 --- /dev/null +++ b/tools/component-fuzz/surfaces/AppearanceMediaSurface.php @@ -0,0 +1,2211 @@ +skip( + 'appearance-media.bootstrap-apis-available', + 'Required appearance media APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::prepare_runtime( $ctx ); + + $rows[] = self::check_background_post_normalization( $ctx->fork( 'background' ) ); + $rows[] = self::check_header_defaults_and_selection( $ctx->fork( 'headers' ) ); + $rows[] = self::check_header_and_background_frontend_helpers( $ctx->fork( 'frontend' ) ); + $rows[] = self::check_head_callback_css( $ctx->fork( 'head-callback-css' ) ); + $rows[] = self::check_custom_header_markup_and_video( $ctx->fork( 'custom-header-video' ) ); + $rows[] = self::check_custom_header_markup_print_side_effects( $ctx->fork( 'custom-header-print' ) ); + $rows[] = self::check_custom_logo_helpers( $ctx->fork( 'custom-logo' ) ); + $rows[] = self::check_site_icon_helpers( $ctx->fork( 'site-icon' ) ); + $rows[] = self::check_site_icon_attachment_urls( $ctx->fork( 'site-icon-attachment-urls' ) ); + $rows[] = self::check_admin_action_guards( $ctx->fork( 'admin-action-guards' ) ); + $rows[] = self::check_background_remove_redirect( $ctx->fork( 'background-remove-redirect' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'appearance-media.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $state_diff = self::state_diff( $snapshot ); + $rows[] = self::row( + $ctx, + 'appearance-media.state-restored', + array() === $state_diff, + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedOptions' => array_keys( $snapshot['options'] ), + 'contentCounts' => $snapshot['contentCounts'], + 'obLevel' => ob_get_level(), + 'diff' => $state_diff, + ) + ); + + return $rows; + } + + private static function load_support(): void { + $files = array( + 'Custom_Background' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-custom-background.php' : '', + 'Custom_Image_Header' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-custom-image-header.php' : '', + 'WP_Site_Icon' => defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/class-wp-site-icon.php' : '', + ); + + foreach ( $files as $class => $path ) { + if ( ! class_exists( $class, false ) && $path && file_exists( $path ) ) { + require_once $path; + } + } + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( array( 'Custom_Background', 'Custom_Image_Header', 'WP_Site_Icon' ) as $class ) { + if ( ! class_exists( $class, false ) ) { + $missing[] = "class {$class}"; + } + } + if ( ! class_exists( 'WP_Scripts', false ) ) { + $missing[] = 'class WP_Scripts'; + } + if ( ! class_exists( 'Component_Fuzz_WPDB_Stub', false ) ) { + $missing[] = 'class Component_Fuzz_WPDB_Stub'; + } + + foreach ( + array( + 'add_filter', + 'add_theme_support', + 'admin_url', + 'apply_filters', + 'checked', + 'check_admin_referer', + 'create_initial_post_types', + 'current_user_can', + 'current_theme_supports', + 'delete_option', + 'display_header_text', + 'esc_attr', + 'esc_url', + 'get_background_color', + 'get_background_image', + 'get_custom_logo', + 'get_custom_header', + 'get_custom_header_markup', + 'get_header_image', + 'get_header_image_tag', + 'get_header_textcolor', + 'get_header_video_settings', + 'get_header_video_url', + 'get_option', + 'get_post_meta', + 'get_site_icon_url', + 'get_stylesheet', + 'get_template_directory_uri', + 'get_theme_mod', + 'get_theme_support', + 'has_custom_logo', + 'has_custom_header', + 'has_filter', + 'has_header_image', + 'has_header_video', + 'has_site_icon', + 'home_url', + 'is_header_video_active', + 'is_random_header_image', + 'maybe_hash_hex_color', + 'remove_all_filters', + 'remove_filter', + 'remove_theme_support', + 'remove_theme_mod', + 'sanitize_html_class', + 'sanitize_url', + 'set_url_scheme', + 'set_theme_mod', + 'site_icon_url', + 'the_custom_logo', + 'the_custom_header_markup', + 'the_header_video_url', + '_custom_background_cb', + '_custom_logo_header_styles', + 'update_option', + 'update_post_meta', + 'wp_check_filetype', + 'wp_create_nonce', + 'wp_die', + 'wp_get_attachment_image_src', + 'wp_get_attachment_image_url', + 'wp_get_attachment_metadata', + 'wp_get_attachment_url', + 'wp_get_referer', + 'wp_get_mime_types', + 'wp_get_upload_dir', + 'wp_enqueue_script', + 'wp_localize_script', + 'wp_nonce_ays', + 'wp_nonce_tick', + 'wp_register_script', + 'wp_script_is', + 'wp_scripts', + 'wp_update_attachment_metadata', + 'wp_verify_nonce', + 'wp_cache_delete', + 'wp_json_encode', + 'wp_redirect', + 'wp_safe_redirect', + 'wp_sanitize_redirect', + 'wp_site_icon', + 'wp_validate_redirect', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! isset( $GLOBALS['wpdb'] ) || ! $GLOBALS['wpdb'] instanceof \Component_Fuzz_WPDB_Stub ) { + $missing[] = 'global wpdb Component_Fuzz_WPDB_Stub'; + } + + return $missing; + } + + private static function prepare_runtime( \ComponentFuzz\FuzzContext $ctx ): void { + unset( $ctx ); + + if ( ! get_post_type_object( 'post' ) || ! get_post_status_object( 'publish' ) ) { + \create_initial_post_types(); + } + + \update_option( 'template', 'component-fuzz-theme' ); + \update_option( 'stylesheet', 'component-fuzz-theme' ); + \update_option( self::theme_mod_option_name(), array() ); + + \add_theme_support( + 'custom-background', + array( + 'default-color' => 'f0f0f0', + 'default-image' => 'http://example.test/default-background.png', + 'default-preset' => 'fill', + 'default-position-x' => 'left', + 'default-position-y' => 'top', + 'default-size' => 'auto', + 'default-repeat' => 'repeat', + 'default-attachment' => 'scroll', + ) + ); + \add_theme_support( + 'custom-header', + array( + 'default-image' => '%s/images/default-header.jpg', + 'default-text-color' => '123456', + 'header-text' => true, + 'width' => 1200, + 'height' => 300, + 'random-default' => true, + ) + ); + + $GLOBALS['_wp_default_headers'] = self::default_headers_case(); + } + + private static function check_background_post_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $case = self::background_case( $ctx ); + $nonce = \wp_create_nonce( 'custom-background' ); + $subject = new \Custom_Background(); + + $_POST = array( + '_wpnonce' => $nonce, + 'background-preset' => $case['presetInput'], + 'background-position' => $case['positionInput'], + 'background-size' => $case['sizeInput'], + 'background-repeat' => $case['repeatInput'], + 'background-attachment' => $case['attachmentInput'], + 'background-color' => $case['colorInput'], + ); + $_REQUEST = $_POST; + + $subject->take_action(); + + $actual = array( + 'preset' => \get_theme_mod( 'background_preset' ), + 'positionX' => \get_theme_mod( 'background_position_x' ), + 'positionY' => \get_theme_mod( 'background_position_y' ), + 'size' => \get_theme_mod( 'background_size' ), + 'repeat' => \get_theme_mod( 'background_repeat' ), + 'attachment' => \get_theme_mod( 'background_attachment' ), + 'color' => \get_theme_mod( 'background_color' ), + ); + + self::collect_failure( + $failures, + $case['expected'] === $actual, + 'Custom_Background::take_action normalizes bounded display options into theme mods', + array( + 'case' => $case, + 'actual' => $actual, + 'themeMod' => \get_option( self::theme_mod_option_name() ), + ) + ); + + return self::row( + $ctx, + 'appearance-media.background.post-normalization', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_header_defaults_and_selection( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $subject = new \Custom_Image_Header( static function (): void {} ); + $subject->process_default_headers(); + + $template_uri = \get_template_directory_uri(); + $stylesheet_uri = \get_stylesheet_directory_uri(); + $header_alpha = $subject->default_headers['alpha'] ?? null; + $header_beta = $subject->default_headers['beta'] ?? null; + + self::collect_failure( + $failures, + is_array( $header_alpha ) + && is_array( $header_beta ) + && "{$template_uri}/images/alpha.jpg" === $header_alpha['url'] + && "{$stylesheet_uri}/images/alpha-thumb.jpg" === $header_alpha['thumbnail_url'] + && "{$stylesheet_uri}/images/beta.jpg" === $header_beta['url'], + 'process_default_headers expands template and stylesheet placeholders once', + array( + 'alpha' => $header_alpha, + 'beta' => $header_beta, + 'templateUri' => $template_uri, + 'stylesheetUri' => $stylesheet_uri, + ) + ); + + $subject->set_header_image( 'alpha' ); + $selected = \get_theme_mod( 'header_image' ); + $selected_data = \get_theme_mod( 'header_image_data' ); + $output = self::capture( static fn() => $subject->show_header_selector( 'default' ) ); + $subject->remove_header_image(); + $removed = \get_header_image(); + $subject->set_header_image( + array( + 'attachment_id' => 991, + 'url' => 'http://example.test/uploads/generated-header.jpg?unsafe=', + 'width' => 1440, + 'height' => 360, + ) + ); + $array_data = \get_theme_mod( 'header_image_data' ); + $array_url = \get_theme_mod( 'header_image' ); + $subject->set_header_image( 'random-default-image' ); + + self::collect_failure( + $failures, + "{$template_uri}/images/alpha.jpg" === $selected + && is_array( $selected_data ) + && 'Alpha ' === ( $selected_data['alt_text'] ?? null ) + && false === $removed + && $array_data instanceof \stdClass + && 991 === (int) $array_data->attachment_id + && 'http://example.test/uploads/generated-header.jpg?unsafe=tag' === $array_url + && \is_random_header_image( 'default' ) + && str_contains( $output, 'Random:' ) + && str_contains( $output, 'Alpha <unsafe>' ) + && ! str_contains( $output, '' ), + 'set_header_image handles default, remove, array, and random choices with escaped selector output', + array( + 'selected' => $selected, + 'selectedData' => self::describe_value( $selected_data ), + 'removed' => $removed, + 'arrayData' => self::describe_value( $array_data ), + 'arrayUrl' => $array_url, + 'currentImage' => \get_theme_mod( 'header_image' ), + 'random' => \is_random_header_image( 'default' ), + 'selector' => self::preview( $output ), + ) + ); + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->postmeta, array( 'post_id' => 991 ) ); + \wp_cache_delete( 991, 'post_meta' ); + + return self::row( + $ctx, + 'appearance-media.header.defaults-and-selection', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_header_and_background_frontend_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + + \set_theme_mod( 'header_textcolor', 'abcdef' ); + \set_theme_mod( 'header_image', 'http://example.test/header-image.jpg?x=' ); + \set_theme_mod( + 'header_image_data', + (object) array( + 'attachment_id' => 0, + 'url' => 'http://example.test/header-image.jpg?x=', + 'thumbnail_url' => 'http://example.test/header-thumb.jpg', + 'width' => 960, + 'height' => 240, + ) + ); + \set_theme_mod( 'background_image', 'http://example.test/bg.png?x=' ); + \set_theme_mod( 'background_color', '#12zz34' ); + + $header_image = \get_header_image(); + $header_tag = \get_header_image_tag( + array( + 'alt' => 'Header ', + 'loading' => false, + 'decoding' => false, + ) + ); + $bg_image = \get_background_image(); + $bg_color = \get_background_color(); + $text_color = \get_header_textcolor(); + $display_text = \display_header_text(); + $expected_header_image = \set_url_scheme( 'http://example.test/header-image.jpg?x=tag' ); + + self::collect_failure( + $failures, + $expected_header_image === $header_image + && str_contains( $header_tag, 'src="' . \esc_attr( $expected_header_image ) . '"' ) + && str_contains( $header_tag, 'alt="Header <alt>"' ) + && ! str_contains( $header_tag, 'loading=' ) + && ! str_contains( $header_tag, 'decoding=' ) + && 'http://example.test/bg.png?x=' === $bg_image + && '#12zz34' === $bg_color + && 'abcdef' === $text_color + && true === $display_text, + 'frontend header/background helpers sanitize URLs, escape markup attributes, and expose theme mods consistently', + array( + 'headerImage' => self::preview( (string) $header_image ), + 'expectedHeaderImage' => self::preview( $expected_header_image ), + 'headerTag' => self::preview( $header_tag ), + 'bgImage' => self::preview( (string) $bg_image ), + 'bgColor' => $bg_color, + 'textColor' => $text_color, + 'displayText' => $display_text, + ) + ); + + \set_theme_mod( 'header_textcolor', 'blank' ); + self::collect_failure( + $failures, + false === \display_header_text(), + 'blank header text color hides header text', + array( 'displayText' => \display_header_text() ) + ); + + return self::row( + $ctx, + 'appearance-media.frontend.helpers', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ) ) + ); + } + + private static function check_head_callback_css( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = strtolower( preg_replace( '/[^a-z0-9-]+/', '-', $ctx->identifier( 4, 10 ) ) ); + $background_url = 'http://example.test/component-fuzz/bg-' . rawurlencode( $token ) . '.png?unsafe='; + $position_x = $ctx->choice( array( 'left', 'center', 'right', 'bad-x' ) ); + $position_y = $ctx->choice( array( 'top', 'center', 'bottom', 'bad-y' ) ); + $size = $ctx->choice( array( 'auto', 'contain', 'cover', 'stretch' ) ); + $repeat = $ctx->choice( array( 'repeat-x', 'repeat-y', 'repeat', 'no-repeat', 'round' ) ); + $attachment = $ctx->choice( array( 'scroll', 'fixed', 'local' ) ); + $color = $ctx->choice( array( 'abc123', '#abc', '112233' ) ); + $expected_position_x = in_array( $position_x, array( 'left', 'center', 'right' ), true ) ? $position_x : 'left'; + $expected_position_y = in_array( $position_y, array( 'top', 'center', 'bottom' ), true ) ? $position_y : 'top'; + $expected_size = in_array( $size, array( 'auto', 'contain', 'cover' ), true ) ? $size : 'auto'; + $expected_repeat = in_array( $repeat, array( 'repeat-x', 'repeat-y', 'repeat', 'no-repeat' ), true ) ? $repeat : 'repeat'; + $expected_attachment = 'fixed' === $attachment ? 'fixed' : 'scroll'; + $expected_url = \sanitize_url( \set_url_scheme( str_replace( '', 'tag', $background_url ) ) ); + $expected_color = \maybe_hash_hex_color( $color ); + $had_theme_features = array_key_exists( '_wp_theme_features', $GLOBALS ); + $theme_features_before = $GLOBALS['_wp_theme_features'] ?? null; + $logo_css = ''; + $logo_css_visible = ''; + + try { + \set_theme_mod( 'background_image', $background_url ); + \set_theme_mod( 'background_color', $color ); + \set_theme_mod( 'background_position_x', $position_x ); + \set_theme_mod( 'background_position_y', $position_y ); + \set_theme_mod( 'background_size', $size ); + \set_theme_mod( 'background_repeat', $repeat ); + \set_theme_mod( 'background_attachment', $attachment ); + + $background_css = self::capture( static fn() => \_custom_background_cb() ); + + self::collect_failure( + $failures, + str_contains( $background_css, '', + 'seed() . ':' . $ctx->iteration() . ':' . $suffix ), 0, 12 ); + $user_id = \wp_insert_user( + array( + 'display_name' => 'Customizer Lock ' . ucfirst( $suffix ) . ' ' . $token, + 'role' => 'subscriber', + 'user_email' => 'customizer-lock-' . $suffix . '-' . $token . '@example.test', + 'user_login' => 'customizer_lock_' . $suffix . '_' . $token, + 'user_pass' => 'component-fuzz-pass', + ) + ); + + return is_int( $user_id ) ? $user_id : 0; + } + + private static function parse_changeset_lock( $lock ): ?array { + if ( ! is_string( $lock ) ) { + return null; + } + + if ( ! preg_match( '/^([0-9]+):([0-9]+)$/', $lock, $matches ) ) { + return null; + } + + return array( + 'time' => (int) $matches[1], + 'userId' => (int) $matches[2], + ); + } + + private static function changeset_lock_string( int $timestamp, int $user_id ): string { + return max( 1, $timestamp ) . ':' . max( 1, $user_id ); + } + + private static function lock_belongs_to_user( ?array $lock, int $user_id ): bool { + return is_array( $lock ) + && (int) $user_id === (int) ( $lock['userId'] ?? 0 ) + && (int) ( $lock['time'] ?? 0 ) > 0; + } + + private static function capture_ajax( callable $callback ): array { + $start_level = ob_get_level(); + $die_calls = array(); + $status_headers = array(); + $captured = false; + $returned = false; + $throwable = null; + $output = ''; + $cleaned_buffers = 0; + $doing_ajax_filter = static function (): bool { + return true; + }; + $ajax_die_filter = static function ( $handler ) use ( &$die_calls ) { + unset( $handler ); + return static function ( $message = '', string $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'kind' => 'ajax', + 'message' => $message, + 'title' => $title, + 'args' => \wp_parse_args( $args ), + ); + throw new CustomizerPersistence_DieCaptured( 'Captured ajax wp_die.' ); + }; + }; + $default_die_filter = static function ( $handler ) use ( &$die_calls ) { + unset( $handler ); + return static function ( $message = '', string $title = '', $args = array() ) use ( &$die_calls ): void { + $die_calls[] = array( + 'kind' => 'default', + 'message' => $message, + 'title' => $title, + 'args' => \wp_parse_args( $args ), + ); + throw new CustomizerPersistence_DieCaptured( 'Captured default wp_die.' ); + }; + }; + $status_filter = static function ( string $status_header, int $code, string $description, string $protocol ) use ( &$status_headers ): string { + $status_headers[] = compact( 'code', 'description', 'protocol', 'status_header' ); + return $status_header; + }; + $charset_filter = static fn() => 'UTF-8'; + + if ( ! headers_sent() ) { + header_remove(); + } + + \add_filter( 'wp_doing_ajax', $doing_ajax_filter, 9999 ); + \add_filter( 'wp_die_ajax_handler', $ajax_die_filter, 1 ); + \add_filter( 'wp_die_handler', $default_die_filter, 1 ); + \add_filter( 'status_header', $status_filter, 10, 4 ); + \add_filter( 'pre_option_blog_charset', $charset_filter, 10, 3 ); + + ob_start(); + try { + $callback(); + $returned = true; + } catch ( CustomizerPersistence_DieCaptured $e ) { + $captured = true; + } catch ( \Throwable $e ) { + $throwable = $e; + } finally { + while ( ob_get_level() > $start_level ) { + $chunk = ob_get_clean(); + $output = ( false === $chunk ? '' : $chunk ) . $output; + ++$cleaned_buffers; + } + + \remove_filter( 'wp_doing_ajax', $doing_ajax_filter, 9999 ); + \remove_filter( 'wp_die_ajax_handler', $ajax_die_filter, 1 ); + \remove_filter( 'wp_die_handler', $default_die_filter, 1 ); + \remove_filter( 'status_header', $status_filter, 10 ); + \remove_filter( 'pre_option_blog_charset', $charset_filter, 10 ); + + if ( ! headers_sent() ) { + header_remove(); + } + } + + return array( + 'bufferBalanced' => $start_level === ob_get_level() && 1 === $cleaned_buffers, + 'captured' => $captured, + 'dieCalls' => $die_calls, + 'output' => $output, + 'returned' => $returned, + 'statusHeaders' => $status_headers, + 'throwable' => null === $throwable ? null : self::describe_throwable( $throwable ), + ); + } + + private static function set_ajax_post( array $post ): void { + $_POST = $post; + $_REQUEST = $post; + } + + private static function json_body( array $capture ): ?array { + $body = trim( (string) ( $capture['output'] ?? '' ) ); + foreach ( array_reverse( $capture['dieCalls'] ?? array() ) as $call ) { + $message = $call['message'] ?? ''; + if ( is_string( $message ) && '' !== trim( $message ) ) { + $body .= trim( $message ); + break; + } + } + + $decoded = json_decode( $body, true ); + return is_array( $decoded ) ? $decoded : null; + } + + private static function json_success( array $capture ): bool { + $body = self::json_body( $capture ); + return is_array( $body ) && true === ( $body['success'] ?? null ); + } + + private static function json_error_code( array $capture ): ?string { + $body = self::json_body( $capture ); + if ( ! is_array( $body ) || true === ( $body['success'] ?? null ) ) { + return null; + } + if ( is_string( $body['data'] ?? null ) ) { + return $body['data']; + } + return is_array( $body['data'] ?? null ) && isset( $body['data']['code'] ) ? (string) $body['data']['code'] : null; + } + + private static function status_code( array $capture ): ?int { + $headers = $capture['statusHeaders'] ?? array(); + $last = is_array( $headers ) ? end( $headers ) : false; + return is_array( $last ) && isset( $last['code'] ) ? (int) $last['code'] : null; + } + + private static function ajax_die_message( array $capture ): ?string { + $call = $capture['dieCalls'][0] ?? null; + if ( ! is_array( $call ) ) { + return null; + } + return (string) ( $call['message'] ?? '' ); + } + + private static function summarize_capture( array $capture ): array { + return array( + 'captured' => (bool) ( $capture['captured'] ?? false ), + 'returned' => (bool) ( $capture['returned'] ?? false ), + 'bufferBalanced' => (bool) ( $capture['bufferBalanced'] ?? false ), + 'json' => self::json_body( $capture ), + 'dieMessage' => self::ajax_die_message( $capture ), + 'statusHeaders' => $capture['statusHeaders'] ?? array(), + 'throwable' => $capture['throwable'] ?? null, + ); + } + + private static function customizer_ajax_action_names(): array { + return array( + 'wp_ajax_customize_save', + 'wp_ajax_customize_trash', + 'wp_ajax_customize_refresh_nonces', + 'wp_ajax_customize_override_changeset_lock', + ); + } + + private static function action_counts( array $names ): array { + $counts = array(); + foreach ( $names as $name ) { + $counts[ $name ] = (int) ( $GLOBALS['wp_actions'][ $name ] ?? 0 ); + } + return $counts; + } + + private static function all_sanitize_calls_match( array $calls ): bool { + foreach ( $calls as $call ) { + if ( empty( $call['matches'] ) ) { + return false; + } + } + return true; + } + + private static function same_value( $expected, $actual ): bool { + return $expected === $actual; + } + + private static function wp_error_code_is( $value, string $code ): bool { + return $value instanceof \WP_Error && $code === $value->get_error_code(); + } + + 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 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_lock( $lock ): array { + return array( + 'raw' => is_string( $lock ) ? self::preview( $lock ) : self::describe_value( $lock ), + 'parsed' => self::parse_changeset_lock( $lock ), + ); + } + + private static function describe_lock_user( $user ) { + if ( ! $user instanceof \WP_User ) { + return self::describe_value( $user ); + } + + return array( + 'ID' => $user->ID, + 'user_login' => $user->user_login, + 'display_name' => $user->display_name, + ); + } + + private static function describe_post( $post ) { + if ( ! $post instanceof \WP_Post ) { + return self::describe_value( $post ); + } + + return array( + 'ID' => $post->ID, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_name' => $post->post_name, + 'post_title' => $post->post_title, + 'post_content' => self::preview( $post->post_content ), + 'post_content_filtered' => self::preview( $post->post_content_filtered ), + ); + } + + private static function describe_throwable( \Throwable $throwable ): array { + return array( + 'class' => get_class( $throwable ), + 'message' => $throwable->getMessage(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + ); + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return array( + 'wpErrorCodes' => $value->get_error_codes(), + 'wpErrorData' => $value->get_all_error_data(), + ); + } + + if ( $value instanceof \WP_Post ) { + return self::describe_post( $value ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + + if ( is_string( $value ) ) { + return self::preview( $value ); + } + + return $value; + } + + private static function preview( string $value ) { + return \ComponentFuzz\preview_value( $value ); + } +} + +final class CustomizerPersistence_DieCaptured extends \RuntimeException {} diff --git a/tools/component-fuzz/surfaces/CustomizerSurface.php b/tools/component-fuzz/surfaces/CustomizerSurface.php new file mode 100644 index 0000000000000..7fe8f3e3e7832 --- /dev/null +++ b/tools/component-fuzz/surfaces/CustomizerSurface.php @@ -0,0 +1,1729 @@ +skip( + 'customizer.bootstrap-apis-available', + 'Required Customizer APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime(); + + $rows[] = self::check_registry_lifecycle_and_ordering( $ctx->fork( 'registry' ) ); + $rows[] = self::check_setting_callbacks_and_post_values( $ctx->fork( 'settings' ) ); + $rows[] = self::check_manager_post_value_merging( $ctx->fork( 'post-values' ) ); + $rows[] = self::check_multidimensional_values( $ctx->fork( 'multidimensional' ) ); + $rows[] = self::check_json_and_active_callbacks( $ctx->fork( 'json-active' ) ); + $rows[] = self::check_selective_refresh_partials( $ctx->fork( 'partials' ) ); + $rows[] = self::check_theme_preview_lifecycle( $ctx->fork( 'theme-preview' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'customizer.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + return $rows; + } + + public static function grant_runtime_capabilities( array $allcaps ): array { + $allcaps[ self::CAPABILITY ] = true; + $allcaps['customize'] = true; + $allcaps['edit_theme_options'] = true; + return $allcaps; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_Customize_Manager', + 'WP_Customize_Setting', + 'WP_Customize_Control', + 'WP_Customize_Section', + 'WP_Customize_Panel', + 'WP_Customize_Selective_Refresh', + 'WP_Customize_Partial', + 'WP_Error', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'apply_filters', + 'current_user_can', + 'esc_attr', + 'get_option', + 'get_raw_theme_root', + 'get_stylesheet', + 'get_template', + 'has_action', + 'has_filter', + 'is_wp_error', + 'remove_action', + 'remove_filter', + 'update_option', + 'wp_json_encode', + 'wp_slash', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_registry_lifecycle_and_ordering( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + + $setting_id = self::id( $ctx, 'setting' ); + $setting = $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_no_db', + 'capability' => self::CAPABILITY, + 'default' => self::fuzz_value( $ctx->fork( 'default' ) ), + 'transport' => $ctx->choice( array( 'refresh', 'postMessage' ) ), + 'dirty' => true, + ) + ); + + $temp_setting_id = self::id( $ctx->fork( 'temp-setting' ), 'setting' ); + $temp_setting = $manager->add_setting( + $temp_setting_id, + array( + 'type' => 'component_fuzz_no_db', + 'capability' => self::CAPABILITY, + 'default' => 'temporary', + ) + ); + $manager->remove_setting( $temp_setting_id ); + + self::collect_failure( + $failures, + $setting instanceof \WP_Customize_Setting + && $setting === $manager->get_setting( $setting_id ) + && $setting_id === $setting->id + && true === $setting->json()['dirty'] + && null === $manager->get_setting( $temp_setting_id ) + && $temp_setting instanceof \WP_Customize_Setting, + 'add_setting/get_setting/remove_setting keep the registry consistent', + array( + 'settingId' => $setting_id, + 'tempSettingId' => $temp_setting_id, + 'json' => $setting->json(), + ) + ); + + $panel_a = self::id( $ctx->fork( 'panel-a' ), 'panel' ); + $panel_b = self::id( $ctx->fork( 'panel-b' ), 'panel' ); + $panel_c = self::id( $ctx->fork( 'panel-c' ), 'panel' ); + $manager->add_panel( + $panel_a, + array( + 'priority' => 5, + 'title' => 'Panel A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_panel( + $panel_b, + array( + 'priority' => 20, + 'title' => 'Panel B', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_panel( + $panel_c, + array( + 'priority' => 5, + 'title' => 'Panel C', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_panel = self::id( $ctx->fork( 'temp-panel' ), 'panel' ); + $manager->add_panel( $temp_panel, array( 'capability' => self::CAPABILITY ) ); + $manager->remove_panel( $temp_panel ); + + self::collect_failure( + $failures, + $manager->get_panel( $panel_a ) instanceof \WP_Customize_Panel + && $manager->get_panel( $panel_b ) instanceof \WP_Customize_Panel + && $manager->get_panel( $panel_c ) instanceof \WP_Customize_Panel + && null === $manager->get_panel( $temp_panel ), + 'add_panel/get_panel/remove_panel keep the registry consistent', + array( + 'panels' => array( $panel_a, $panel_b, $panel_c ), + 'tempPanel' => $temp_panel, + ) + ); + + $section_main = self::id( $ctx->fork( 'section-main' ), 'section' ); + $section_a = self::id( $ctx->fork( 'section-a' ), 'section' ); + $section_b = self::id( $ctx->fork( 'section-b' ), 'section' ); + $manager->add_section( + $section_main, + array( + 'priority' => 15, + 'title' => 'Main ' . self::unsafe_string( $ctx->fork( 'section-title' ) ), + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_section( + $section_a, + array( + 'priority' => 5, + 'title' => 'Section A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_section( + $section_b, + array( + 'priority' => 5, + 'title' => 'Section B', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_section = self::id( $ctx->fork( 'temp-section' ), 'section' ); + $manager->add_section( $temp_section, array( 'capability' => self::CAPABILITY ) ); + $manager->remove_section( $temp_section ); + + self::collect_failure( + $failures, + $manager->get_section( $section_main ) instanceof \WP_Customize_Section + && $manager->get_section( $section_a ) instanceof \WP_Customize_Section + && $manager->get_section( $section_b ) instanceof \WP_Customize_Section + && null === $manager->get_section( $temp_section ), + 'add_section/get_section/remove_section keep the registry consistent', + array( + 'sections' => array( $section_a, $section_b, $section_main ), + 'tempSection' => $temp_section, + ) + ); + + $control_a = self::id( $ctx->fork( 'control-a' ), 'control' ); + $control_b = self::id( $ctx->fork( 'control-b' ), 'control' ); + $control_c = self::id( $ctx->fork( 'control-c' ), 'control' ); + $manager->add_control( + $control_a, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 30, + 'label' => 'Control A', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_control( + $control_b, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 10, + 'label' => 'Control B', + 'capability' => self::CAPABILITY, + ) + ); + $manager->add_control( + $control_c, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'priority' => 10, + 'label' => 'Control C', + 'capability' => self::CAPABILITY, + ) + ); + + $temp_control = self::id( $ctx->fork( 'temp-control' ), 'control' ); + $manager->add_control( + $temp_control, + array( + 'settings' => $setting_id, + 'section' => $section_main, + 'capability' => self::CAPABILITY, + ) + ); + $manager->remove_control( $temp_control ); + + self::collect_failure( + $failures, + $manager->get_control( $control_a ) instanceof \WP_Customize_Control + && $manager->get_control( $control_b ) instanceof \WP_Customize_Control + && $manager->get_control( $control_c ) instanceof \WP_Customize_Control + && null === $manager->get_control( $temp_control ), + 'add_control/get_control/remove_control keep the registry consistent', + array( + 'controls' => array( $control_a, $control_b, $control_c ), + 'tempControl' => $temp_control, + ) + ); + + self::with_capabilities( + static function () use ( $manager ): void { + $manager->prepare_controls(); + } + ); + + $panels = array_keys( $manager->panels() ); + $sections = array_keys( $manager->sections() ); + $section_control = $manager->get_section( $section_main ); + $control_order = $section_control instanceof \WP_Customize_Section + ? array_map( + static function ( \WP_Customize_Control $control ): string { + return $control->id; + }, + $section_control->controls + ) + : array(); + + self::collect_failure( + $failures, + array( $panel_a, $panel_c, $panel_b ) === array_values( + array_intersect( $panels, array( $panel_a, $panel_b, $panel_c ) ) + ) + && array( $section_a, $section_b, $section_main ) === array_values( + array_intersect( $sections, array( $section_a, $section_b, $section_main ) ) + ) + && array( $control_b, $control_c, $control_a ) === $control_order, + 'prepare_controls sorts panels, sections, and controls by priority with stable instance order', + array( + 'panelOrder' => $panels, + 'sectionOrder' => $sections, + 'controlOrder' => $control_order, + ) + ); + + return self::row( + $ctx, + 'customizer.registry.lifecycle-priority', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_setting_callbacks_and_post_values( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $id = self::id( $ctx, 'option' ); + $raw = self::non_null_value( $ctx->fork( 'raw' ) ); + $expected = array( + 'kind' => gettype( $raw ), + 'value' => self::stringify_value( $raw ), + 'id' => $id, + ); + $calls = array( + 'sanitize' => array(), + 'validate' => array(), + 'js' => array(), + ); + + $sanitize_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$calls, $raw, $expected, $id ) { + $calls['sanitize'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + + if ( $value === $raw ) { + return $expected; + } + + return $value; + }; + $validate_callback = static function ( \WP_Error $validity, $value, \WP_Customize_Setting $setting ) use ( &$calls, $id ) { + $calls['validate'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + return $validity; + }; + $sanitize_js_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$calls, $id ) { + $calls['js'][] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + 'sameSetting' => $id === $setting->id, + ); + return array( + 'js' => true, + 'setting' => $setting->id, + 'value' => $value, + ); + }; + + $setting = $manager->add_setting( + $id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'fallback', + 'transport' => 'postMessage', + 'dirty' => true, + 'sanitize_callback' => $sanitize_callback, + 'validate_callback' => $validate_callback, + 'sanitize_js_callback' => $sanitize_js_callback, + ) + ); + + $invalid_id = self::id( $ctx->fork( 'invalid' ), 'option' ); + $invalid_calls = array( + 'validate' => 0, + 'sanitize' => 0, + ); + $manager->add_setting( + $invalid_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'sanitize_callback' => static function ( $value ) use ( &$invalid_calls ) { + ++$invalid_calls['sanitize']; + return $value; + }, + 'validate_callback' => static function ( \WP_Error $validity ) use ( &$invalid_calls ) { + ++$invalid_calls['validate']; + $validity->add( 'component_fuzz_invalid', 'Component fuzz invalid value.' ); + return $validity; + }, + ) + ); + + $null_id = self::id( $ctx->fork( 'null' ), 'option' ); + $null_calls = array( + 'validate' => 0, + 'sanitize' => 0, + ); + $manager->add_setting( + $null_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'sanitize_callback' => static function ( $value ) use ( &$null_calls ) { + ++$null_calls['sanitize']; + return $value; + }, + 'validate_callback' => static function ( \WP_Error $validity ) use ( &$null_calls ) { + ++$null_calls['validate']; + return $validity; + }, + ) + ); + + $manager->set_post_value( $id, $raw ); + + $post_value = $setting->post_value( 'fallback-post' ); + $previewed = $setting->preview(); + $value_after_preview = $setting->value(); + $js_value = $setting->js_value(); + $unknown_id = self::id( $ctx->fork( 'unknown' ), 'option' ); + $validities = $manager->validate_setting_values( + array( + $id => $raw, + $invalid_id => 'invalid', + $null_id => null, + $unknown_id => 'unknown', + ), + array( 'validate_existence' => true ) + ); + + self::collect_failure( + $failures, + $expected === $post_value + && true === $previewed + && $expected === $value_after_preview + && array( + 'js' => true, + 'setting' => $id, + 'value' => $expected, + ) === $js_value + && true === ( $validities[ $id ] ?? null ), + 'post values are sanitized, previewed, exported to JS, and validated deterministically', + array( + 'raw' => self::describe_value( $raw ), + 'postValue' => $post_value, + 'valueAfterPreview' => $value_after_preview, + 'jsValue' => $js_value, + 'validity' => self::describe_value( $validities[ $id ] ?? null ), + ) + ); + + self::collect_failure( + $failures, + 6 === count( $calls['validate'] ) + && 5 === count( $calls['sanitize'] ) + && 1 === count( $calls['js'] ) + && self::all_call_settings_match( $calls['validate'] ) + && self::all_call_settings_match( $calls['sanitize'] ) + && self::all_call_settings_match( $calls['js'] ), + 'custom sanitize, validate, and JS callbacks are invoked exactly as expected', + array( 'calls' => $calls ) + ); + + self::collect_failure( + $failures, + isset( $validities[ $invalid_id ] ) + && \is_wp_error( $validities[ $invalid_id ] ) + && $validities[ $invalid_id ]->get_error_code() === 'component_fuzz_invalid' + && 1 === $invalid_calls['validate'] + && 0 === $invalid_calls['sanitize'] + && ! array_key_exists( $null_id, $validities ) + && 0 === $null_calls['validate'] + && 0 === $null_calls['sanitize'] + && isset( $validities[ $unknown_id ] ) + && \is_wp_error( $validities[ $unknown_id ] ) + && 'unrecognized' === $validities[ $unknown_id ]->get_error_code(), + 'validate_setting_values marks invalid/unrecognized values and skips null without callbacks', + array( + 'invalidValidity' => self::describe_value( $validities[ $invalid_id ] ?? null ), + 'invalidCalls' => $invalid_calls, + 'nullCalls' => $null_calls, + 'unknownValidity' => self::describe_value( $validities[ $unknown_id ] ?? null ), + ) + ); + + return self::row( + $ctx, + 'customizer.settings.callbacks-post-values', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_manager_post_value_merging( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $posted_id = self::id( $ctx, 'posted' ); + $program_id = self::id( $ctx->fork( 'program' ), 'posted' ); + $unknown_id = self::id( $ctx->fork( 'unknown' ), 'posted' ); + $posted_value = array( + 'source' => 'post', + 'value' => self::non_null_value( $ctx->fork( 'posted-value' ) ), + ); + $program_from_post = array( + 'source' => 'post-before-programmatic', + 'value' => self::non_null_value( $ctx->fork( 'program-posted' ) ), + ); + $program_override = array( + 'source' => 'programmatic', + 'value' => self::non_null_value( $ctx->fork( 'program-override' ) ), + ); + $sanitize_calls = array(); + $post_value_events = array(); + $expected_sanitized = static function ( string $setting_id, $value ): array { + return array( + 'setting' => $setting_id, + 'value' => $value, + 'encoded' => self::stringify_value( $value ), + ); + }; + $sanitize_callback = static function ( $value, \WP_Customize_Setting $setting ) use ( &$sanitize_calls, $expected_sanitized ): array { + $sanitize_calls[] = array( + 'setting' => $setting->id, + 'value' => self::describe_value( $value ), + ); + return $expected_sanitized( $setting->id, $value ); + }; + + $posted_setting = $manager->add_setting( + $posted_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'posted-default', + 'sanitize_callback' => $sanitize_callback, + ) + ); + $program_setting = $manager->add_setting( + $program_id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'program-default', + 'sanitize_callback' => $sanitize_callback, + ) + ); + + $customized = \wp_json_encode( + array( + $posted_id => $posted_value, + $program_id => $program_from_post, + ) + ); + if ( ! is_string( $customized ) ) { + throw new \RuntimeException( 'Could not encode Customizer posted values.' ); + } + $decoded_customized = json_decode( $customized, true ); + if ( ! is_array( $decoded_customized ) ) { + throw new \RuntimeException( 'Could not decode Customizer posted values.' ); + } + $posted_value_from_json = $decoded_customized[ $posted_id ] ?? null; + $program_from_post_from_json = $decoded_customized[ $program_id ] ?? null; + + $_POST['customized'] = \wp_slash( $customized ); + $_REQUEST['customized'] = $_POST['customized']; + self::set_object_property( $manager, '_post_values', null ); + + $from_post = self::with_capabilities( + static function () use ( $manager ): array { + return $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + } + ); + + $dynamic_action = static function ( $value, \WP_Customize_Manager $seen_manager ) use ( &$post_value_events, $manager ): void { + $post_value_events[] = array( + 'hook' => 'dynamic', + 'value' => self::describe_value( $value ), + 'sameManager' => $seen_manager === $manager, + ); + }; + $global_action = static function ( string $setting_id, $value, \WP_Customize_Manager $seen_manager ) use ( &$post_value_events, $manager ): void { + $post_value_events[] = array( + 'hook' => 'global', + 'setting' => $setting_id, + 'value' => self::describe_value( $value ), + 'sameManager' => $seen_manager === $manager, + ); + }; + + \add_action( "customize_post_value_set_{$program_id}", $dynamic_action, 10, 2 ); + \add_action( 'customize_post_value_set', $global_action, 10, 3 ); + try { + $manager->set_post_value( $program_id, $program_override ); + } finally { + \remove_action( 'customize_post_value_set', $global_action, 10 ); + \remove_action( "customize_post_value_set_{$program_id}", $dynamic_action, 10 ); + } + + $merged = self::with_capabilities( + static function () use ( $manager ): array { + return $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + } + ); + $posted_post_value = $manager->post_value( $posted_setting, 'posted-fallback' ); + $program_post_value = $manager->post_value( $program_setting, 'program-fallback' ); + $validities = $manager->validate_setting_values( + array( + $posted_id => $posted_value_from_json, + $program_id => $program_override, + $unknown_id => 'unknown', + ), + array( 'validate_existence' => true ) + ); + + self::collect_failure( + $failures, + array( + $posted_id => $posted_value_from_json, + $program_id => $program_from_post_from_json, + ) === $from_post + && $posted_value_from_json === ( $merged[ $posted_id ] ?? null ) + && $program_override === ( $merged[ $program_id ] ?? null ), + 'unsanitized_post_values parses slashed customized JSON and programmatic values override posted values', + array( + 'fromPost' => $from_post, + 'merged' => $merged, + ) + ); + + self::collect_failure( + $failures, + $expected_sanitized( $posted_id, $posted_value_from_json ) === $posted_post_value + && $expected_sanitized( $program_id, $program_override ) === $program_post_value + && true === ( $validities[ $posted_id ] ?? null ) + && true === ( $validities[ $program_id ] ?? null ) + && isset( $validities[ $unknown_id ] ) + && \is_wp_error( $validities[ $unknown_id ] ) + && 'unrecognized' === $validities[ $unknown_id ]->get_error_code(), + 'post_value sanitizes merged values and validate_setting_values reports unknown settings', + array( + 'postedPostValue' => $posted_post_value, + 'programPostValue' => $program_post_value, + 'validities' => self::describe_value( $validities ), + 'sanitizeCalls' => $sanitize_calls, + ) + ); + + self::collect_failure( + $failures, + array( + array( + 'hook' => 'dynamic', + 'value' => self::describe_value( $program_override ), + 'sameManager' => true, + ), + array( + 'hook' => 'global', + 'setting' => $program_id, + 'value' => self::describe_value( $program_override ), + 'sameManager' => true, + ), + ) === $post_value_events + && false === \has_action( "customize_post_value_set_{$program_id}", $dynamic_action ) + && false === \has_action( 'customize_post_value_set', $global_action ), + 'set_post_value fires scoped and global events and removes temporary hooks', + array( 'events' => $post_value_events ) + ); + + return self::row( + $ctx, + 'customizer.manager.post-value-json-merge-events', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_multidimensional_values( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $root_id = self::id( $ctx, 'root' ); + $key_a = 'alpha_' . $ctx->identifier( 3, 8 ); + $key_b = 'beta_' . $ctx->identifier( 3, 8 ); + $id = $root_id . '[' . $key_a . '][' . $key_b . ']'; + $initial = self::non_null_value( $ctx->fork( 'initial' ) ); + $posted = self::non_null_value( $ctx->fork( 'posted' ) ); + $updated = self::non_null_value( $ctx->fork( 'updated' ) ); + $root = array( + $key_a => array( + $key_b => $initial, + 'spare' => self::unsafe_string( $ctx->fork( 'spare' ) ), + ), + 'flat' => $ctx->int( -50, 50 ), + ); + + \update_option( $root_id, $root, false ); + + $sanitize_calls = array(); + $setting = $manager->add_setting( + $id, + array( + 'type' => 'option', + 'capability' => self::CAPABILITY, + 'default' => 'fallback-dimensional', + 'sanitize_callback' => static function ( $value, \WP_Customize_Setting $setting ) use ( &$sanitize_calls ) { + $sanitize_calls[] = array( + 'value' => self::describe_value( $value ), + 'settingId' => $setting->id, + ); + return $value; + }, + ) + ); + + $id_data = $setting->id_data(); + self::collect_failure( + $failures, + $root_id === $id_data['base'] + && array( $key_a, $key_b ) === $id_data['keys'] + && $initial === $setting->value(), + 'multidimensional setting ID is parsed and initial option leaf is read', + array( + 'id' => $id, + 'idData' => $id_data, + 'initial' => self::describe_value( $initial ), + 'value' => self::describe_value( $setting->value() ), + ) + ); + + $manager->set_post_value( $setting->id, $posted ); + $unsanitized = $manager->unsanitized_post_values( + array( + 'exclude_changeset' => true, + 'exclude_post_data' => false, + ) + ); + $post_value = $setting->post_value( 'fallback-posted' ); + $previewed = $setting->preview(); + $value = $setting->value(); + $preview_root = \get_option( $root_id ); + + self::collect_failure( + $failures, + array_key_exists( $setting->id, $unsanitized ) + && $posted === $unsanitized[ $setting->id ] + && $posted === $post_value + && true === $previewed + && $posted === $value + && is_array( $preview_root ) + && $posted === self::nested_get( $preview_root, array( $key_a, $key_b ), null ) + && self::nested_get( $root, array( $key_a, 'spare' ), null ) === self::nested_get( $preview_root, array( $key_a, 'spare' ), null ), + 'set_post_value and preview round-trip multidimensional values without clobbering siblings', + array( + 'unsanitized' => $unsanitized, + 'postValue' => self::describe_value( $post_value ), + 'value' => self::describe_value( $value ), + 'previewRoot' => self::describe_value( $preview_root ), + ) + ); + + $manager->set_post_value( $setting->id, $updated ); + $value_after_second_post = $setting->value(); + $preview_root_updated = \get_option( $root_id ); + + self::collect_failure( + $failures, + $updated === $value_after_second_post + && is_array( $preview_root_updated ) + && $updated === self::nested_get( $preview_root_updated, array( $key_a, $key_b ), null ) + && count( $sanitize_calls ) >= 3, + 'multidimensional dirty state is cleared when a new post value is set', + array( + 'updated' => self::describe_value( $updated ), + 'valueAfterPost' => self::describe_value( $value_after_second_post ), + 'previewRoot' => self::describe_value( $preview_root_updated ), + 'sanitizeCalls' => $sanitize_calls, + ) + ); + + return self::row( + $ctx, + 'customizer.settings.multidimensional-post-values', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_json_and_active_callbacks( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $setting_id = self::id( $ctx, 'json-setting' ); + $panel_id = self::id( $ctx->fork( 'panel' ), 'json-panel' ); + $section_id = self::id( $ctx->fork( 'section' ), 'json-section' ); + $control_id = self::id( $ctx->fork( 'control' ), 'json-control' ); + $unsafe_label = self::unsafe_string( $ctx->fork( 'label' ) ); + $unsafe_desc = self::unsafe_string( $ctx->fork( 'description' ) ); + $active_calls = array(); + $panel_active = $ctx->bool(); + $section_active = $ctx->bool(); + $control_active = $ctx->bool(); + + $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_json', + 'capability' => self::CAPABILITY, + 'default' => self::unsafe_string( $ctx->fork( 'setting-default' ) ), + ) + ); + + $panel = $manager->add_panel( + $panel_id, + array( + 'title' => 'Panel & ' . $unsafe_label, + 'description' => $unsafe_desc, + 'priority' => 15, + 'capability' => self::CAPABILITY, + 'active_callback' => static function ( \WP_Customize_Panel $panel ) use ( &$active_calls, $panel_active ): bool { + $active_calls[] = array( + 'kind' => 'panel', + 'id' => $panel->id, + ); + return $panel_active; + }, + ) + ); + $section = $manager->add_section( + $section_id, + array( + 'title' => 'Section & ' . $unsafe_label, + 'description' => $unsafe_desc, + 'panel' => $panel_id, + 'priority' => 10, + 'capability' => self::CAPABILITY, + 'description_hidden' => $ctx->bool(), + 'active_callback' => static function ( \WP_Customize_Section $section ) use ( &$active_calls, $section_active ): bool { + $active_calls[] = array( + 'kind' => 'section', + 'id' => $section->id, + ); + return $section_active; + }, + ) + ); + $control = $manager->add_control( + $control_id, + array( + 'settings' => $setting_id, + 'section' => $section_id, + 'type' => 'text', + 'label' => $unsafe_label, + 'description' => $unsafe_desc, + 'priority' => 7, + 'capability' => self::CAPABILITY, + 'input_attrs' => array( + 'data-component-fuzz' => self::unsafe_string( $ctx->fork( 'input-attr' ) ), + ), + 'active_callback' => static function ( \WP_Customize_Control $control ) use ( &$active_calls, $control_active ): bool { + $active_calls[] = array( + 'kind' => 'control', + 'id' => $control->id, + ); + return $control_active; + }, + ) + ); + + $json = self::with_capabilities( + static function () use ( $panel, $section, $control ): array { + return array( + 'panel' => $panel->json(), + 'section' => $section->json(), + 'control' => $control->json(), + 'link' => $control->get_link(), + ); + } + ); + $encoded = \wp_json_encode( $json ); + $decoded = is_string( $encoded ) ? json_decode( $encoded, true ) : null; + + self::collect_failure( + $failures, + $panel_active === $json['panel']['active'] + && $section_active === $json['section']['active'] + && $control_active === $json['control']['active'] + && array( + array( + 'kind' => 'panel', + 'id' => $panel_id, + ), + array( + 'kind' => 'section', + 'id' => $section_id, + ), + array( + 'kind' => 'control', + 'id' => $control_id, + ), + ) === $active_calls, + 'panel, section, and control active callbacks are scoped to their own instances', + array( + 'expected' => array( $panel_active, $section_active, $control_active ), + 'json' => $json, + 'calls' => $active_calls, + ) + ); + + self::collect_failure( + $failures, + $panel_id === $json['panel']['id'] + && 'Panel & ' . $unsafe_label === $json['panel']['title'] + && $section_id === $json['section']['id'] + && $panel_id === $json['section']['panel'] + && 'Section & ' . $unsafe_label === $json['section']['title'] + && $control_id === $control->id + && $unsafe_label === $json['control']['label'] + && $unsafe_desc === $json['control']['description'] + && array( 'default' => $setting_id ) === $json['control']['settings'] + && str_contains( $json['link'], 'data-customize-setting-link="' ) + && str_contains( $json['link'], esc_attr( $setting_id ) ) + && is_string( $encoded ) + && is_array( $decoded ), + 'JSON exports preserve expected fields and encode deterministically', + array( + 'json' => $json, + 'encoded' => $encoded, + 'decoded' => $decoded, + ) + ); + + return self::row( + $ctx, + 'customizer.controls-containers.json-active-callbacks', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_selective_refresh_partials( \ComponentFuzz\FuzzContext $ctx ): array { + $manager = self::manager( $ctx ); + $failures = array(); + $setting_id = self::id( $ctx, 'partial-setting' ); + $partial_id = $setting_id . '[' . $ctx->identifier( 3, 8 ) . ']'; + $unsafe = self::unsafe_string( $ctx->fork( 'partial' ) ); + $calls = array(); + + $manager->add_setting( + $setting_id, + array( + 'type' => 'component_fuzz_partial', + 'capability' => self::CAPABILITY, + 'default' => 'partial-default', + ) + ); + + $partial = $manager->selective_refresh->add_partial( + $partial_id, + array( + 'selector' => '#component-fuzz-' . preg_replace( '/[^A-Za-z0-9_-]/', '-', $partial_id ), + 'settings' => array( $setting_id ), + 'primary_setting' => $setting_id, + 'capability' => self::CAPABILITY, + 'container_inclusive' => $ctx->bool(), + 'fallback_refresh' => $ctx->bool(), + 'render_callback' => static function ( \WP_Customize_Partial $partial, array $context ) use ( &$calls, $unsafe ) { + $calls[] = array( + 'id' => $partial->id, + 'context' => $context, + ); + return '' . $unsafe . ''; + }, + ) + ); + + $context = array( + 'number' => $ctx->int( -100, 100 ), + 'text' => self::unsafe_string( $ctx->fork( 'context' ) ), + ); + $json = $partial->json(); + $rendered = $partial->render( $context ); + $can = self::with_capabilities( + static function () use ( $partial ): bool { + return $partial->check_capabilities(); + } + ); + $all_partials = $manager->selective_refresh->partials(); + + self::collect_failure( + $failures, + $partial instanceof \WP_Customize_Partial + && $partial === $manager->selective_refresh->get_partial( $partial_id ) + && isset( $all_partials[ $partial_id ] ) + && $partial === $all_partials[ $partial_id ], + 'selective refresh partial add/get/aggregate registry is consistent', + array( + 'partialId' => $partial_id, + 'partials' => array_keys( $all_partials ), + ) + ); + + self::collect_failure( + $failures, + array( $setting_id ) === $json['settings'] + && $setting_id === $json['primarySetting'] + && $partial->selector === $json['selector'] + && $partial->container_inclusive === $json['containerInclusive'] + && $partial->fallback_refresh === $json['fallbackRefresh'] + && true === $can, + 'partial JSON and capability checks reflect configured fields', + array( + 'json' => $json, + 'can' => $can, + ) + ); + + self::collect_failure( + $failures, + is_string( $rendered ) + && str_contains( $rendered, esc_attr( $partial_id ) ) + && str_contains( $rendered, $unsafe ) + && array( + array( + 'id' => $partial_id, + 'context' => $context, + ), + ) === $calls, + 'partial render callback receives scoped context and returns deterministic markup', + array( + 'rendered' => $rendered, + 'calls' => $calls, + ) + ); + + $manager->selective_refresh->remove_partial( $partial_id ); + self::collect_failure( + $failures, + null === $manager->selective_refresh->get_partial( $partial_id ), + 'remove_partial removes the registered partial', + array( 'partialId' => $partial_id ) + ); + + return self::row( + $ctx, + 'customizer.selective-refresh.partials', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_theme_preview_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $fixture = self::create_theme_fixture( $ctx ); + $option_snapshot = isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null; + $start_events = array(); + $stop_events = array(); + $preview_manager = null; + $active_manager = null; + + $theme_filters = array( + 'template' => 'get_template', + 'stylesheet' => 'get_stylesheet', + 'pre_option_current_theme' => 'current_theme', + 'pre_option_stylesheet' => 'get_stylesheet', + 'pre_option_template' => 'get_template', + 'pre_option_stylesheet_root' => 'get_stylesheet_root', + 'pre_option_template_root' => 'get_template_root', + ); + + $start_action = static function ( \WP_Customize_Manager $manager ) use ( &$start_events, &$preview_manager, &$active_manager ): void { + $start_events[] = array( + 'samePreviewManager' => null !== $preview_manager && $manager === $preview_manager, + 'sameActiveManager' => null !== $active_manager && $manager === $active_manager, + 'isPreview' => $manager->is_preview(), + 'isThemeActive' => $manager->is_theme_active(), + 'stylesheet' => $manager->get_stylesheet(), + 'template' => $manager->get_template(), + ); + }; + $stop_action = static function ( \WP_Customize_Manager $manager ) use ( &$stop_events, &$preview_manager, &$active_manager ): void { + $stop_events[] = array( + 'samePreviewManager' => null !== $preview_manager && $manager === $preview_manager, + 'sameActiveManager' => null !== $active_manager && $manager === $active_manager, + 'isPreview' => $manager->is_preview(), + 'isThemeActive' => $manager->is_theme_active(), + 'stylesheet' => $manager->get_stylesheet(), + 'template' => $manager->get_template(), + ); + }; + + \add_action( 'start_previewing_theme', $start_action ); + \add_action( 'stop_previewing_theme', $stop_action ); + + try { + self::seed_active_theme_options( $fixture ); + + $preview_manager = self::manager( $ctx, array( 'theme' => $fixture['previewSlug'] ) ); + $before_preview_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + + $preview_manager->start_previewing_theme(); + $after_start_values = self::theme_preview_values(); + $after_start_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + $preview_manager->start_previewing_theme(); + $after_second_start_events = count( $start_events ); + + $preview_manager->stop_previewing_theme(); + $after_stop_values = self::theme_preview_values(); + $after_stop_filters = self::manager_theme_filters( $preview_manager, $theme_filters ); + $preview_manager->stop_previewing_theme(); + $after_second_stop_events = count( $stop_events ); + + self::collect_failure( + $failures, + ! $preview_manager->is_theme_active() + && false === $before_preview_filters['any'] + && true === $after_start_filters['all'] + && false === $after_stop_filters['any'] + && $fixture['previewSlug'] === $after_start_values['stylesheet'] + && $fixture['previewTemplate'] === $after_start_values['template'] + && $fixture['previewName'] === $after_start_values['currentTheme'] + && $fixture['rawRoot'] === $after_start_values['stylesheetRoot'] + && $fixture['rawRoot'] === $after_start_values['templateRoot'] + && $fixture['activeSlug'] === $after_stop_values['stylesheet'] + && $fixture['activeSlug'] === $after_stop_values['template'] + && $fixture['activeName'] === $after_stop_values['currentTheme'] + && $fixture['rawRoot'] === $after_stop_values['stylesheetRoot'] + && $fixture['rawRoot'] === $after_stop_values['templateRoot'] + && array( + array( + 'samePreviewManager' => true, + 'sameActiveManager' => false, + 'isPreview' => true, + 'isThemeActive' => false, + 'stylesheet' => $fixture['previewSlug'], + 'template' => $fixture['previewTemplate'], + ), + ) === $start_events + && array( + array( + 'samePreviewManager' => true, + 'sameActiveManager' => false, + 'isPreview' => false, + 'isThemeActive' => false, + 'stylesheet' => $fixture['previewSlug'], + 'template' => $fixture['previewTemplate'], + ), + ) === $stop_events + && 1 === $after_second_start_events + && 1 === $after_second_stop_events, + 'inactive theme preview installs theme-switching filters, exposes preview values, fires once, and cleans up on stop', + array( + 'fixture' => $fixture, + 'beforeFilters' => $before_preview_filters, + 'afterStartFilters' => $after_start_filters, + 'afterStopFilters' => $after_stop_filters, + 'afterStartValues' => $after_start_values, + 'afterStopValues' => $after_stop_values, + 'startEvents' => $start_events, + 'stopEvents' => $stop_events, + 'afterSecondStartEvents' => $after_second_start_events, + 'afterSecondStopEvents' => $after_second_stop_events, + ) + ); + + $start_events = array(); + $stop_events = array(); + $active_manager = self::manager( $ctx->fork( 'active' ), array( 'theme' => $fixture['activeSlug'] ) ); + $before_active_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + $active_manager->start_previewing_theme(); + $active_start_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + $active_manager->stop_previewing_theme(); + $active_stop_filters = self::manager_theme_filters( $active_manager, $theme_filters ); + + self::collect_failure( + $failures, + $active_manager->is_theme_active() + && false === $before_active_filters['any'] + && false === $active_start_filters['any'] + && false === $active_stop_filters['any'] + && array( + array( + 'samePreviewManager' => false, + 'sameActiveManager' => true, + 'isPreview' => true, + 'isThemeActive' => true, + 'stylesheet' => $fixture['activeSlug'], + 'template' => $fixture['activeSlug'], + ), + ) === $start_events + && array( + array( + 'samePreviewManager' => false, + 'sameActiveManager' => true, + 'isPreview' => false, + 'isThemeActive' => true, + 'stylesheet' => $fixture['activeSlug'], + 'template' => $fixture['activeSlug'], + ), + ) === $stop_events, + 'active theme preview toggles preview state and actions without installing theme-switching filters', + array( + 'beforeFilters' => $before_active_filters, + 'activeStartFilters' => $active_start_filters, + 'activeStopFilters' => $active_stop_filters, + 'startEvents' => $start_events, + 'stopEvents' => $stop_events, + ) + ); + } finally { + \remove_action( 'start_previewing_theme', $start_action ); + \remove_action( 'stop_previewing_theme', $stop_action ); + if ( null !== $option_snapshot && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $option_snapshot ); + } + self::remove_theme_fixture( $fixture ); + } + + self::collect_failure( + $failures, + false === \has_action( 'start_previewing_theme', $start_action ) + && false === \has_action( 'stop_previewing_theme', $stop_action ), + 'theme preview lifecycle actions are removed after the check', + array( + 'startAction' => \has_action( 'start_previewing_theme', $start_action ), + 'stopAction' => \has_action( 'stop_previewing_theme', $stop_action ), + ) + ); + + return self::row( + $ctx, + 'customizer.manager.theme-preview-filter-lifecycle', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function manager( \ComponentFuzz\FuzzContext $ctx, array $args = array() ): \WP_Customize_Manager { + $components_filter = static function (): array { + return array(); + }; + \add_filter( 'customize_loaded_components', $components_filter, 1000 ); + try { + $manager = new \WP_Customize_Manager( + array_merge( + array( + 'changeset_uuid' => self::uuid( $ctx ), + 'settings_previewed' => false, + 'branching' => true, + 'autosaved' => false, + ), + $args + ) + ); + } finally { + \remove_filter( 'customize_loaded_components', $components_filter, 1000 ); + } + + self::set_object_property( $manager, '_changeset_data', array() ); + self::set_object_property( $manager, '_post_values', array() ); + if ( false === \has_filter( 'user_has_cap', array( self::class, 'grant_runtime_capabilities' ) ) ) { + \add_filter( 'user_has_cap', array( self::class, 'grant_runtime_capabilities' ), 10, 4 ); + } + return $manager; + } + + private static function create_theme_fixture( \ComponentFuzz\FuzzContext $ctx ): array { + $theme_root = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/themes' : sys_get_temp_dir() . '/component-fuzz-themes'; + if ( ! is_dir( $theme_root ) ) { + @mkdir( $theme_root, 0777, true ); + } + + $suffix = strtolower( preg_replace( '/[^a-z0-9-]+/', '-', $ctx->identifier( 6, 14 ) ) ); + $suffix = trim( $suffix, '-' ); + $suffix = '' === $suffix ? substr( md5( (string) $ctx->seed() ), 0, 8 ) : $suffix; + $active_slug = 'cfz-active-' . $suffix; + $preview_parent = 'cfz-parent-' . $suffix; + $preview_slug = 'cfz-child-' . $suffix; + $active_name = 'CFZ Active ' . $suffix; + $parent_name = 'CFZ Parent ' . $suffix; + $preview_name = 'CFZ Preview ' . $suffix; + $created_paths = array( + $theme_root . '/' . $active_slug, + $theme_root . '/' . $preview_parent, + $theme_root . '/' . $preview_slug, + ); + + self::write_theme_files( $created_paths[0], $active_name ); + self::write_theme_files( $created_paths[1], $parent_name ); + self::write_theme_files( $created_paths[2], $preview_name, $preview_parent ); + self::refresh_theme_discovery(); + + return array( + 'themeRoot' => $theme_root, + 'rawRoot' => \get_raw_theme_root( $preview_slug, true ), + 'activeSlug' => $active_slug, + 'activeName' => $active_name, + 'previewSlug' => $preview_slug, + 'previewTemplate' => $preview_parent, + 'previewName' => $preview_name, + 'createdPaths' => $created_paths, + ); + } + + private static function write_theme_files( string $path, string $name, ?string $template = null ): void { + if ( ! is_dir( $path ) ) { + @mkdir( $path, 0777, true ); + } + + $headers = "/*\nTheme Name: {$name}\n"; + if ( null !== $template ) { + $headers .= "Template: {$template}\n"; + } + $headers .= "*/\n"; + + file_put_contents( $path . '/style.css', $headers ); + file_put_contents( $path . '/index.php', " $method ) { + $states[ $hook ] = \has_filter( $hook, array( $manager, $method ) ); + } + + $truthy = array_filter( + $states, + static function ( $priority ): bool { + return false !== $priority; + } + ); + + return array( + 'states' => $states, + 'all' => count( $states ) === count( $truthy ) && array( 10 ) === array_values( array_unique( array_values( $truthy ) ) ), + 'any' => array() !== $truthy, + ); + } + + private static function theme_preview_values(): array { + return array( + 'stylesheet' => \get_stylesheet(), + 'template' => \get_template(), + 'currentTheme' => \get_option( 'current_theme' ), + 'stylesheetRoot' => \get_option( 'stylesheet_root' ), + 'templateRoot' => \get_option( 'template_root' ), + ); + } + + private static function with_capabilities( callable $callback ) { + $cap_filter = static function ( array $allcaps ): array { + $allcaps[ self::CAPABILITY ] = true; + $allcaps['customize'] = true; + $allcaps['edit_theme_options'] = true; + $allcaps['unfiltered_html'] = true; + $allcaps['edit_css'] = true; + return $allcaps; + }; + + \add_filter( 'user_has_cap', $cap_filter, 10, 4 ); + try { + return $callback(); + } finally { + \remove_filter( 'user_has_cap', $cap_filter, 10 ); + } + } + + private static function id( \ComponentFuzz\FuzzContext $ctx, string $prefix ): string { + $pieces = array( + $prefix, + (string) $ctx->iteration(), + substr( md5( (string) $ctx->seed() . ':' . $prefix ), 0, 8 ), + $ctx->identifier( 3, 10 ), + ); + + return 'cfz_' . implode( '_', $pieces ); + } + + private static function uuid( \ComponentFuzz\FuzzContext $ctx ): string { + $hex = md5( 'customizer:' . $ctx->seed() . ':' . $ctx->iteration() ); + return sprintf( + '%s-%s-%s-%s-%s', + substr( $hex, 0, 8 ), + substr( $hex, 8, 4 ), + substr( $hex, 12, 4 ), + substr( $hex, 16, 4 ), + substr( $hex, 20, 12 ) + ); + } + + private static function unsafe_string( \ComponentFuzz\FuzzContext $ctx ): string { + return '' + . '' + . '& "\' ' + . $ctx->text( 0, 32 ); + } + + private static function fuzz_value( \ComponentFuzz\FuzzContext $ctx, bool $allow_null = true ) { + $choices = array( 'unsafe-string', 'utf8-text', 'int', 'bool', 'array' ); + if ( $allow_null ) { + $choices[] = 'null'; + } + + switch ( $ctx->choice( $choices ) ) { + case 'unsafe-string': + return self::unsafe_string( $ctx->fork( 'unsafe' ) ); + case 'utf8-text': + return "utf8-\xE2\x98\x83-" . $ctx->text( 1, 48 ); + case 'int': + return $ctx->int( -100000, 100000 ); + case 'bool': + return $ctx->bool(); + case 'array': + return array( + 'html' => self::unsafe_string( $ctx->fork( 'array-html' ) ), + 'number' => $ctx->int( -1000, 1000 ), + 'flag' => $ctx->bool(), + ); + default: + return null; + } + } + + private static function non_null_value( \ComponentFuzz\FuzzContext $ctx ) { + return self::fuzz_value( $ctx, false ); + } + + private static function stringify_value( $value ): string { + if ( is_scalar( $value ) || null === $value ) { + return (string) $value; + } + + $encoded = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); + return false === $encoded ? '[unencodable]' : $encoded; + } + + private static function nested_get( $root, array $keys, $default ) { + $node = $root; + foreach ( $keys as $key ) { + if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { + return $default; + } + $node = $node[ $key ]; + } + return $node; + } + + private static function all_call_settings_match( array $calls ): bool { + foreach ( $calls as $call ) { + if ( empty( $call['sameSetting'] ) ) { + return false; + } + } + return true; + } + + private static function reset_runtime(): void { + unset( $_POST['customized'], $_POST['customize_changeset_data'] ); + unset( $_REQUEST['customized'], $_REQUEST['customize_changeset_data'] ); + unset( $_GET['customize_theme'], $_GET['theme'] ); + unset( $_POST['customize_theme'], $_POST['theme'] ); + unset( $_REQUEST['customize_theme'], $_REQUEST['theme'] ); + + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) ) { + $options = $GLOBALS['wpdb']->component_fuzz_get_options(); + $GLOBALS['wpdb']->component_fuzz_reset_options( + array_merge( + $options, + array( + 'home' => 'http://example.test', + 'siteurl' => 'http://example.test', + 'stylesheet' => 'component-fuzz-theme', + 'template' => 'component-fuzz-theme', + 'current_theme' => 'Component Fuzz Theme', + 'theme_mods_component-fuzz-theme' => array(), + 'customize_stashed_theme_mods' => array(), + 'can_compress_scripts' => '0', + 'page_for_posts' => '0', + 'page_on_front' => '0', + 'show_on_front' => 'posts', + 'fresh_site' => '0', + 'widget_block' => array(), + 'sidebars_widgets' => array(), + 'nav_menu_options' => array(), + 'theme_switched_via_customizer' => false, + 'dismissed_update_core' => array(), + 'auto_update_core_major' => 'unset', + 'auto_update_core_minor' => 'unset', + 'auto_update_core_dev' => 'unset', + 'wp_force_deactivated_plugins' => array(), + 'wp_force_deactivated_plugins_changed' => false, + 'wp_force_deactivated_plugins_baseline' => array(), + ) + ) + ); + } + + if ( method_exists( 'WP_Customize_Setting', 'reset_aggregated_multidimensionals' ) ) { + \WP_Customize_Setting::reset_aggregated_multidimensionals(); + } + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_GET', + '_POST', + '_REQUEST', + 'current_user', + 'wp_actions', + 'wp_current_filter', + 'wp_customize', + 'wp_filter', + 'wp_filters', + 'wp_object_cache', + 'wp_stylesheet_path', + 'wp_template_path', + 'wp_theme_directories', + ) + ), + 'options' => isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_get_options' ) + ? $GLOBALS['wpdb']->component_fuzz_get_options() + : null, + 'aggregatedSettings' => self::get_static_property( 'WP_Customize_Setting', 'aggregated_multidimensionals' ), + 'controlCount' => self::get_static_property( 'WP_Customize_Control', 'instance_count' ), + 'sectionCount' => self::get_static_property( 'WP_Customize_Section', 'instance_count' ), + 'panelCount' => self::get_static_property( 'WP_Customize_Panel', 'instance_count' ), + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + + if ( null !== $snapshot['options'] && isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_options( $snapshot['options'] ); + } + + self::set_static_property( 'WP_Customize_Setting', 'aggregated_multidimensionals', $snapshot['aggregatedSettings'] ); + self::set_static_property( 'WP_Customize_Control', 'instance_count', $snapshot['controlCount'] ); + self::set_static_property( 'WP_Customize_Section', 'instance_count', $snapshot['sectionCount'] ); + self::set_static_property( 'WP_Customize_Panel', 'instance_count', $snapshot['panelCount'] ); + } + + 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 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 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 set_object_property( object $object, string $property, $value ): void { + $reflection = new \ReflectionProperty( $object, $property ); + self::make_reflection_accessible( $reflection ); + $reflection->setValue( $object, $value ); + } + + private static function make_reflection_accessible( \ReflectionProperty $reflection ): void { + if ( PHP_VERSION_ID < 80100 && method_exists( $reflection, 'setAccessible' ) ) { + $reflection->setAccessible( true ); + } + } + + 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 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 $throwable ): array { + return array( + 'class' => get_class( $throwable ), + 'message' => $throwable->getMessage(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + ); + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return array( + 'wpErrorCodes' => $value->get_error_codes(), + 'wpErrorData' => $value->get_all_error_data(), + ); + } + + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + + if ( is_string( $value ) ) { + return \ComponentFuzz\preview_value( $value ); + } + + return $value; + } +} diff --git a/tools/component-fuzz/surfaces/DateTimeSurface.php b/tools/component-fuzz/surfaces/DateTimeSurface.php new file mode 100644 index 0000000000000..b049ba8d0e561 --- /dev/null +++ b/tools/component-fuzz/surfaces/DateTimeSurface.php @@ -0,0 +1,2229 @@ +skip( + 'date-time.required-apis-available', + 'Required WordPress date/time APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_runtime(); + + try { + $timestamps = self::timestamp_cases( $ctx->fork( 'timestamps' ) ); + $timezones = self::timezone_cases( $ctx->fork( 'timezones' ) ); + $formats = self::format_cases( $ctx->fork( 'formats' ) ); + $mysql = self::mysql_cases( $ctx->fork( 'mysql' ), $timestamps, $timezones ); + $offsets = self::offset_cases( $ctx->fork( 'offsets' ) ); + + $rows = array( + self::check_wp_date_oracle( $ctx, $timestamps, $timezones, $formats ), + self::check_date_i18n_oracle( $ctx, $timestamps, $timezones, $formats ), + self::check_mysql2date_oracle( $ctx, $mysql, $timezones ), + self::check_timezone_options_and_current_time( $ctx, $timezones, $offsets ), + self::check_timezone_choice_markup( $ctx, $timezones ), + self::check_gmt_local_round_trips( $ctx, $timestamps, $timezones ), + self::check_iso8601_offsets( $ctx, $offsets ), + self::check_weekstartend_windows( $ctx, $timestamps ), + self::check_human_time_diff( $ctx ), + self::check_safe_format_option_filters( $ctx, $timestamps, $timezones, $formats ), + self::check_current_datetime_timezone_override_and_iso8601( $ctx, $timestamps, $timezones, $offsets ), + self::check_date_and_human_diff_filter_contracts( $ctx, $timestamps, $timezones, $formats ), + self::check_named_timezone_dst_boundaries_and_iso8601_modes( $ctx, $timezones, $offsets ), + self::check_wp_checkdate_contract( $ctx ), + ); + } catch ( \Throwable $e ) { + $rows = array( + self::throwable_row( $ctx, 'date-time.surface-no-throw', $e ), + ); + } finally { + self::restore_runtime( $snapshot ); + } + + $rows[] = self::check_runtime_restored( $ctx, $snapshot ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'wp_date', + 'date_i18n', + 'current_time', + 'current_datetime', + 'mysql2date', + 'get_gmt_from_date', + 'get_date_from_gmt', + 'iso8601_timezone_to_offset', + 'iso8601_to_datetime', + 'wp_timezone_string', + 'wp_timezone', + 'wp_timezone_override_offset', + 'wp_timezone_choice', + 'wp_checkdate', + 'get_weekstartend', + 'human_time_diff', + 'get_option', + 'add_filter', + 'has_filter', + 'remove_filter', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( array( 'DateTimeImmutable', 'DateTimeZone' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + return $missing; + } + + private static function check_wp_date_oracle( \ComponentFuzz\FuzzContext $ctx, array $timestamps, array $timezones, array $formats ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $timestamps as $index => $timestamp ) { + $timezone = new \DateTimeZone( $timezones[ $index % count( $timezones ) ] ); + $format = $formats[ $index % count( $formats ) ]; + $expected = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $timezone )->format( $format ); + $actual = \wp_date( $format, $timestamp, $timezone ); + ++$cases; + + self::sample( + $samples, + array( + 'timestamp' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + + if ( $actual !== $expected ) { + $failures[] = array( + 'api' => 'wp_date', + 'timestamp' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => self::describe_value( $actual ), + ); + } + } + + $invalid = \wp_date( 'Y-m-d H:i:s', 'not-a-timestamp', new \DateTimeZone( 'UTC' ) ); + if ( false !== $invalid ) { + $failures[] = array( + 'api' => 'wp_date', + 'case' => 'invalid timestamp', + 'expected' => false, + 'actual' => self::describe_value( $invalid ), + ); + } + + return self::result( + $ctx, + 'date-time.wp-date-datetimeimmutable-oracle', + $failures, + array( + 'cases' => $cases, + 'timezones' => array_values( array_unique( $timezones ) ), + 'formats' => $formats, + 'preEpoch' => count( array_filter( $timestamps, static fn ( int $timestamp ): bool => $timestamp < 0 ) ), + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_date_i18n_oracle( \ComponentFuzz\FuzzContext $ctx, array $timestamps, array $timezones, array $formats ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $timestamps as $index => $timestamp ) { + $timezone_name = $timezones[ $index % count( $timezones ) ]; + $timezone = new \DateTimeZone( $timezone_name ); + $format = self::date_i18n_format( $formats[ ( $index + 3 ) % count( $formats ) ] ); + + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $timestamp, $timezone, $format, &$failures, &$samples, &$cases ): void { + $local_time = gmdate( 'Y-m-d H:i:s', $timestamp ); + $expected = ( new \DateTimeImmutable( $local_time, $timezone ) )->format( $format ); + $actual = \date_i18n( $format, $timestamp, false ); + $actual_u = \date_i18n( 'U', $timestamp, false ); + ++$cases; + + self::sample( + $samples, + array( + 'timestampWithOffset' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => $actual, + ) + ); + + if ( $actual !== $expected ) { + $failures[] = array( + 'api' => 'date_i18n', + 'timestampWithOffset' => $timestamp, + 'timezone' => $timezone->getName(), + 'format' => $format, + 'expected' => $expected, + 'actual' => self::describe_value( $actual ), + ); + } + + if ( (string) $timestamp !== (string) $actual_u ) { + $failures[] = array( + 'api' => 'date_i18n', + 'case' => 'U preserves explicit timestamp-with-offset', + 'timestampWithOffset' => $timestamp, + 'expected' => (string) $timestamp, + 'actual' => self::describe_value( $actual_u ), + ); + } + } + ); + } + + return self::result( + $ctx, + 'date-time.date-i18n-explicit-timestamp-oracle', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_mysql2date_oracle( \ComponentFuzz\FuzzContext $ctx, array $mysql_cases, array $timezones ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( $mysql_cases as $index => $case ) { + $mysql = $case['mysql']; + $timezone_name = $case['timezone'] ?? $timezones[ $index % count( $timezones ) ]; + $timezone = new \DateTimeZone( $timezone_name ); + $format = 0 === $index % 3 ? 'Y-m-d H:i:s' : 'Y-m-d\TH:i:sP'; + + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $mysql, $timezone, $format, &$failures, &$samples, &$cases ): void { + $expected_u = strtotime( $mysql . ' UTC' ); + $actual_u = \mysql2date( 'U', $mysql, false ); + $expected_formatted = ( new \DateTimeImmutable( $mysql, $timezone ) )->format( $format ); + $actual_formatted = \mysql2date( $format, $mysql, false ); + ++$cases; + + self::sample( + $samples, + array( + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expectedTimestamp' => $expected_u, + 'actualTimestamp' => $actual_u, + 'format' => $format, + ) + ); + + if ( false === $expected_u || (string) $expected_u !== (string) $actual_u ) { + $failures[] = array( + 'api' => 'mysql2date', + 'format' => 'U', + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expected' => self::describe_value( $expected_u ), + 'actual' => self::describe_value( $actual_u ), + ); + } + + if ( $actual_formatted !== $expected_formatted ) { + $failures[] = array( + 'api' => 'mysql2date', + 'format' => $format, + 'mysql' => $mysql, + 'timezone' => $timezone->getName(), + 'expected' => $expected_formatted, + 'actual' => self::describe_value( $actual_formatted ), + ); + } + } + ); + } + + $invalid = \mysql2date( 'U', '', false ); + if ( false !== $invalid ) { + $failures[] = array( + 'api' => 'mysql2date', + 'case' => 'empty date', + 'expected' => false, + 'actual' => self::describe_value( $invalid ), + ); + } + + return self::result( + $ctx, + 'date-time.mysql2date-strtotime-oracle', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_timezone_options_and_current_time( \ComponentFuzz\FuzzContext $ctx, array $timezones, array $offsets ): array { + $failures = array(); + $samples = array(); + $cases = 0; + + foreach ( array_slice( $timezones, 0, min( 8, count( $timezones ) ) ) as $timezone_name ) { + self::with_option_filters( + array( + 'timezone_string' => $timezone_name, + 'gmt_offset' => 0, + ), + static function () use ( $timezone_name, &$failures, &$samples, &$cases ): void { + $string = \wp_timezone_string(); + $zone = \wp_timezone(); + ++$cases; + + self::sample( + $samples, + array( + 'mode' => 'timezone_string', + 'timezoneString' => $timezone_name, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ) + ); + + if ( $string !== $timezone_name || $zone->getName() !== $timezone_name ) { + $failures[] = array( + 'case' => 'timezone_string option wins', + 'timezoneString' => $timezone_name, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ); + } + } + ); + } + + foreach ( array_slice( $offsets, 0, min( 10, count( $offsets ) ) ) as $offset ) { + self::with_option_filters( + array( + 'timezone_string' => '', + 'gmt_offset' => $offset, + ), + static function () use ( $offset, &$failures, &$samples, &$cases ): void { + $expected_string = self::format_gmt_offset( $offset ); + $string = \wp_timezone_string(); + $zone = \wp_timezone(); + + $before = time(); + $gmt = \time(); + $local = \current_time( 'timestamp', false ); + $after = time(); + ++$cases; + + self::sample( + $samples, + array( + 'mode' => 'gmt_offset', + 'offset' => $offset, + 'expectedTimezone' => $expected_string, + 'actualTimezone' => $string, + 'currentDelta' => $local - $gmt, + ) + ); + + if ( $string !== $expected_string || $zone->getName() !== $expected_string ) { + $failures[] = array( + 'case' => 'gmt_offset fallback', + 'offset' => $offset, + 'expected' => $expected_string, + 'wpTimezoneString' => $string, + 'wpTimezoneGetName' => $zone->getName(), + ); + } + + $expected_delta = (int) ( (float) $offset * HOUR_IN_SECONDS ); + if ( $gmt < $before || $gmt > $after || abs( ( $local - $gmt ) - $expected_delta ) > 2 ) { + $failures[] = array( + 'api' => 'current_time', + 'offset' => $offset, + 'expectedDelta' => $expected_delta, + 'actualDelta' => $local - $gmt, + 'gmtWindow' => array( $before, $after ), + 'gmt' => $gmt, + 'local' => $local, + ); + } + } + ); + } + + return self::result( + $ctx, + 'date-time.timezone-options-and-current-time', + $failures, + array( + 'cases' => $cases, + 'samples' => $samples, + 'failures' => array_slice( $failures, 0, self::FAILURE_LIMIT ), + ) + ); + } + + private static function check_timezone_choice_markup( \ComponentFuzz\FuzzContext $ctx, array $timezones ): array { + $failures = array(); + $samples = array(); + $textdomain_events = array(); + $locale = 'cf_' . preg_replace( '/[^A-Za-z0-9_]+/', '_', $ctx->identifier( 5, 12 ) ); + $hostile = 'UTC+5.75"> ' . $ctx->identifier( 3, 8 ); + $html = '

Allowed ' . $ctx->identifier( 3, 8 ) . '

'; + + \wp_set_current_user( 0 ); + + $search = ( new \WP_Widget_Search() )->update( array( 'title' => $title ), array() ); + $meta = ( new \WP_Widget_Meta() )->update( array( 'title' => $title ), array() ); + $cal = ( new \WP_Widget_Calendar() )->update( array( 'title' => $title ), array() ); + self::collect_failure( + $failures, + self::safe_title( $search['title'] ?? '' ) + && self::safe_title( $meta['title'] ?? '' ) + && self::safe_title( $cal['title'] ?? '' ), + 'Single-title widgets sanitize title fields on update', + array( + 'search' => $search, + 'meta' => $meta, + 'calendar' => $cal, + ) + ); + + $pages = ( new \WP_Widget_Pages() )->update( + array( + 'title' => $title, + 'sortby' => 'post_date', + 'exclude' => '12, 19', + ), + array() + ); + self::collect_failure( + $failures, + self::safe_title( $pages['title'] ?? '' ) + && 'menu_order' === ( $pages['sortby'] ?? null ) + && '12, 19' === ( $pages['exclude'] ?? null ), + 'Pages update rejects unsupported sort keys and sanitizes title/exclude', + array( 'pages' => $pages ) + ); + + $categories = ( new \WP_Widget_Categories() )->update( + array( + 'title' => $title, + 'count' => 'yes', + 'hierarchical' => 'on', + 'dropdown' => '1', + ), + array() + ); + $archives = ( new \WP_Widget_Archives() )->update( + array( + 'title' => $title, + 'count' => 1, + 'dropdown' => 'on', + ), + array() + ); + self::collect_failure( + $failures, + 1 === ( $categories['count'] ?? null ) + && 1 === ( $categories['hierarchical'] ?? null ) + && 1 === ( $categories['dropdown'] ?? null ) + && 1 === ( $archives['count'] ?? null ) + && 1 === ( $archives['dropdown'] ?? null ), + 'Categories and Archives updates normalize checkbox fields', + array( + 'categories' => $categories, + 'archives' => $archives, + ) + ); + + $recent_posts = ( new \WP_Widget_Recent_Posts() )->update( + array( + 'title' => $title, + 'number' => '-7', + 'show_date' => '1', + ), + array() + ); + $recent_comments = ( new \WP_Widget_Recent_Comments() )->update( + array( + 'title' => $title, + 'number' => '-7', + ), + array() + ); + self::collect_failure( + $failures, + -7 === ( $recent_posts['number'] ?? null ) + && true === ( $recent_posts['show_date'] ?? null ) + && 7 === ( $recent_comments['number'] ?? null ), + 'Recent Posts preserves integer number while Recent Comments absints it', + array( + 'recentPosts' => $recent_posts, + 'recentComments' => $recent_comments, + ) + ); + + $text = ( new \WP_Widget_Text() )->update( + array( + 'title' => $title, + 'text' => $html, + 'filter' => 'content', + 'visual' => '1', + ), + array() + ); + $html_widget = ( new \WP_Widget_Custom_HTML() )->update( + array( + 'title' => $title, + 'content' => $html, + ), + array() + ); + self::collect_failure( + $failures, + ! str_contains( $text['text'] ?? '', '' ) + && true === ( $text['filter'] ?? null ) + && true === ( $text['visual'] ?? null ) + && ! str_contains( $html_widget['content'] ?? '', '' ), + 'Text and Custom HTML updates apply capability-sensitive KSES and visual flags', + array( + 'text' => $text, + 'customHtml' => $html_widget, + ) + ); + + $rss_url = 'https://example.test/feed/' . $ctx->identifier( 3, 8 ); + $rss = ( new \WP_Widget_RSS() )->update( + array( + 'title' => $title, + 'url' => $rss_url, + 'items' => '99', + 'show_summary' => '1', + 'show_author' => '1', + 'show_date' => '1', + ), + array( 'url' => $rss_url ) + ); + self::collect_failure( + $failures, + self::safe_title( $rss['title'] ?? '' ) + && $rss_url === ( $rss['url'] ?? null ) + && 10 === ( $rss['items'] ?? null ) + && false === ( $rss['error'] ?? null ) + && 1 === ( $rss['show_summary'] ?? null ), + 'RSS update sanitizes title/url and bounds item count without feed check when URL is unchanged', + array( 'rss' => $rss ) + ); + + $tag_cloud = ( new \WP_Widget_Tag_Cloud() )->update( + array( + 'title' => $title, + 'count' => 'on', + 'taxonomy' => 'category\\', + ), + array() + ); + $links = ( new \WP_Widget_Links() )->update( + array( + 'images' => 'on', + 'name' => 'on', + 'description' => 'on', + 'orderby' => 'post_date', + 'category' => '12dogs', + 'limit' => '', + ), + array() + ); + $nav_menu = ( new \WP_Nav_Menu_Widget() )->update( + array( + 'title' => $title, + 'nav_menu' => '23cats', + ), + array() + ); + self::collect_failure( + $failures, + 1 === ( $tag_cloud['count'] ?? null ) + && 'category' === ( $tag_cloud['taxonomy'] ?? null ) + && 'name' === ( $links['orderby'] ?? null ) + && 12 === ( $links['category'] ?? null ) + && -1 === ( $links['limit'] ?? null ) + && 23 === ( $nav_menu['nav_menu'] ?? null ) + && self::safe_title( $nav_menu['title'] ?? '' ), + 'Tag Cloud, Links, and Nav Menu updates normalize taxonomy, booleans, and numeric selections', + array( + 'tagCloud' => $tag_cloud, + 'links' => $links, + 'navMenu' => $nav_menu, + ) + ); + + return self::result( $ctx, 'default-widgets.update.sanitization-and-normalization', $failures ); + } + + private static function check_widget_callback_lifecycle( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $number = $ctx->int( 60, 95 ); + $blocked_number = $number + 100; + $token = $ctx->identifier( 4, 9 ); + $widget = new \WP_Widget_Text(); + $display_seen = array(); + $form_seen = array(); + $update_seen = array(); + $original_post = $_POST; + $saved_instances = array( + $number => array( + 'title' => 'Saved ' . $token, + 'text' => '

Saved body ' . $token . '

', + 'filter' => true, + 'visual' => true, + ), + $blocked_number => array( + 'title' => 'Blocked ' . $token, + 'text' => '

Blocked body ' . $token . '

', + 'filter' => true, + 'visual' => true, + ), + ); + + $display_filter = static function ( $instance, \WP_Widget $current_widget, array $args ) use ( &$display_seen, $token, $blocked_number ) { + $display_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'title' => $instance['title'] ?? null, + 'widgetId' => $args['widget_id'] ?? null, + ); + + if ( $blocked_number === (int) $current_widget->number ) { + return false; + } + + $instance['title'] .= ' filtered'; + $instance['text'] .= ''; + return $instance; + }; + $form_filter = static function ( $instance, \WP_Widget $current_widget ) use ( &$form_seen, $token ) { + $form_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'title' => $instance['title'] ?? null, + ); + + $instance['title'] = ( $instance['title'] ?? '' ) . ' form-filtered'; + $instance['text'] = ( $instance['text'] ?? '' ) . "\nForm " . $token; + return $instance; + }; + $form_action = static function ( \WP_Widget $current_widget, &$return, array $instance ) use ( &$form_seen ): void { + $form_seen[] = array( + 'action' => 'in_widget_form', + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'return' => $return, + 'title' => $instance['title'] ?? null, + ); + }; + $update_filter = static function ( $instance, array $new_instance, array $old_instance, \WP_Widget $current_widget ) use ( &$update_seen ) { + $update_seen[] = array( + 'idBase' => $current_widget->id_base, + 'number' => $current_widget->number, + 'new' => $new_instance, + 'old' => $old_instance, + 'saved' => $instance, + ); + + if ( is_array( $instance ) ) { + $instance['title'] .= ' update-filtered'; + } + + return $instance; + }; + + $widget->save_settings( $saved_instances ); + $widget->_set( $number ); + $display_args = self::widget_args( $widget ); + $widget->_set( $number + 1000 ); + + \add_filter( 'widget_display_callback', $display_filter, 10, 3 ); + \add_filter( 'widget_form_callback', $form_filter, 10, 2 ); + \add_action( 'in_widget_form', $form_action, 10, 3 ); + \add_filter( 'widget_update_callback', $update_filter, 10, 4 ); + try { + $display_output = self::capture_callback_output( + static function () use ( $widget, $display_args, $number ): void { + $widget->display_callback( $display_args, array( 'number' => $number ) ); + } + ); + + $widget->_set( $blocked_number ); + $blocked_args = self::widget_args( $widget ); + $widget->_set( $blocked_number + 1000 ); + $blocked_output = self::capture_callback_output( + static function () use ( $widget, $blocked_args, $blocked_number ): void { + $widget->display_callback( $blocked_args, $blocked_number ); + } + ); + $form_output = self::capture_callback_output( + static function () use ( $widget, $number ): void { + $widget->form_callback( array( 'number' => $number ) ); + } + ); + $template_output = self::capture_callback_output( + static function () use ( $widget ): void { + $widget->form_callback( array( 'number' => -1 ) ); + } + ); + + $_POST = array( + 'widget-text' => array( + $number => array( + 'title' => 'Updated ' . $token . '', + 'text' => '

Updated ' . $token . '

', + 'filter' => 'content', + 'visual' => '1', + ), + ), + ); + $widget->update_callback(); + $settings_after_update = $widget->get_settings(); + } finally { + $_POST = $original_post; + \remove_filter( 'widget_update_callback', $update_filter, 10 ); + \remove_action( 'in_widget_form', $form_action, 10 ); + \remove_filter( 'widget_form_callback', $form_filter, 10 ); + \remove_filter( 'widget_display_callback', $display_filter, 10 ); + } + + $updated = $settings_after_update[ $number ] ?? array(); + + self::collect_failure( + $failures, + str_contains( $display_output, '
' ) + && str_contains( $display_output, 'Saved ' . $token . ' filtered' ) + && str_contains( $display_output, 'data-display-token="' . \esc_attr( $token ) . '"' ) + && '' === $blocked_output + && 2 === count( $display_seen ) + && $number === (int) ( $display_seen[0]['number'] ?? 0 ) + && $blocked_number === (int) ( $display_seen[1]['number'] ?? 0 ), + 'display_callback loads saved instances, applies display filter mutations, and honors false short-circuit', + array( + 'display' => self::preview( $display_output ), + 'blocked' => self::preview( $blocked_output ), + 'seen' => $display_seen, + ) + ); + self::collect_failure( + $failures, + str_contains( $form_output, 'Saved ' . $token . ' form-filtered' ) + && str_contains( $form_output, 'Form ' . $token ) + && str_contains( $template_output, 'widget-text[__i__]' ) + && isset( $form_seen[0], $form_seen[1], $form_seen[2], $form_seen[3] ) + && $number === (int) ( $form_seen[0]['number'] ?? 0 ) + && 'in_widget_form' === ( $form_seen[1]['action'] ?? null ) + && '__i__' === (string) ( $form_seen[2]['number'] ?? '' ) + && 'in_widget_form' === ( $form_seen[3]['action'] ?? null ), + 'form_callback filters saved and template instances and fires in_widget_form action', + array( + 'form' => self::preview( $form_output ), + 'template' => self::preview( $template_output ), + 'seen' => $form_seen, + ) + ); + self::collect_failure( + $failures, + 1 === count( $update_seen ) + && $number === (int) ( $update_seen[0]['number'] ?? 0 ) + && 'Saved ' . $token === ( $update_seen[0]['old']['title'] ?? null ) + && str_contains( $updated['title'] ?? '', 'Updated ' . $token ) + && str_contains( $updated['title'] ?? '', 'update-filtered' ) + && self::safe_title( $updated['title'] ?? '' ) + && str_contains( $updated['text'] ?? '', '

Updated ' . $token . '

' ) + && ! str_contains( $updated['text'] ?? '', ' $updated, + 'sibling' => $settings_after_update[ $blocked_number ] ?? null, + 'settings' => $settings_after_update, + 'seen' => $update_seen, + ) + ); + + return self::result( + $ctx, + 'default-widgets.callbacks.display-form-update-lifecycle', + $failures, + array( 'number' => $number ) + ); + } + + private static function check_form_escaping( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $title = 'Form "Title" '; + $content = '

' . $ctx->identifier( 3, 8 ) . '

'; + + $forms = array( + 'search' => self::capture_form( new \WP_Widget_Search(), 2, array( 'title' => $title ) ), + 'pages' => self::capture_form( new \WP_Widget_Pages(), 3, array( 'title' => $title, 'sortby' => 'ID', 'exclude' => '2, 4' ) ), + 'categories' => self::capture_form( new \WP_Widget_Categories(), 4, array( 'title' => $title, 'dropdown' => 1, 'count' => 1 ) ), + 'archives' => self::capture_form( new \WP_Widget_Archives(), 5, array( 'title' => $title, 'dropdown' => 1, 'count' => 1 ) ), + 'recentPosts' => self::capture_form( new \WP_Widget_Recent_Posts(), 6, array( 'title' => $title, 'number' => 3, 'show_date' => true ) ), + 'recentComments' => self::capture_form( new \WP_Widget_Recent_Comments(), 7, array( 'title' => $title, 'number' => 3 ) ), + 'rss' => self::capture_form( new \WP_Widget_RSS(), 8, array( 'title' => $title, 'url' => 'https://example.test/feed/', 'items' => 3 ) ), + 'tagCloud' => self::capture_form( new \WP_Widget_Tag_Cloud(), 9, array( 'title' => $title, 'taxonomy' => 'post_tag', 'count' => 1 ) ), + 'text' => self::capture_form( new \WP_Widget_Text(), 10, array( 'title' => $title, 'text' => $content, 'filter' => false, 'visual' => false ) ), + 'customHtml' => self::capture_form( new \WP_Widget_Custom_HTML(), 11, array( 'title' => $title, 'content' => $content ) ), + 'navigationMenu' => self::capture_form( new \WP_Nav_Menu_Widget(), 12, array( 'title' => $title, 'nav_menu' => 0 ) ), + 'links' => self::capture_form( new \WP_Widget_Links(), 13, array( 'orderby' => 'rating', 'limit' => 4 ) ), + ); + + foreach ( $forms as $name => $form ) { + self::collect_failure( + $failures, + ! str_contains( $form, '' ) + && ! str_contains( $form, ''; + $unsupported_index = 'unsupported-index-' . self::field_token( $ctx->fork( 'unsupported-index' ) ) . ''; + $lastmod = '2026-06-' . sprintf( '%02d', 1 + $ctx->int( 0, 20 ) ) . 'T12:34:56+00:00'; + $url_xml = $renderer->get_sitemap_xml( + array( + array( + 'loc' => 'https://example.test/render-field-boundary?unsafe="e="', + 'lastmod' => $lastmod, + 'changefreq' => 'daily', + 'priority' => '0.' . $ctx->int( 1, 9 ), + 'component:fuzz' => $unsupported_url, + 'image:image' => array( 'loc' => $unsupported_url ), + ), + ) + ); + $index_xml = $renderer->get_sitemap_index_xml( + array( + array( + 'loc' => 'https://example.test/wp-sitemap-posts-post-1.xml?unsafe="e="', + 'lastmod' => $lastmod, + 'changefreq' => 'daily', + 'componentFuzz' => $unsupported_index, + ), + ) + ); + + $parsed_url = is_string( $url_xml ) ? @simplexml_load_string( $url_xml ) : false; + $parsed_index = is_string( $index_xml ) ? @simplexml_load_string( $index_xml ) : false; + + self::collect_failure( + $failures, + is_string( $url_xml ) + && false !== $parsed_url + && 1 === count( $parsed_url->url ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && 1 === substr_count( $url_xml, '' ) + && ! str_contains( $url_xml, 'component:fuzz' ) + && ! str_contains( $url_xml, 'image:image' ) + && ! str_contains( $url_xml, 'unsupported-url-' ) + && ! str_contains( $url_xml, '' ) + && ! str_contains( $url_xml, ' self::describe_string( is_string( $url_xml ) ? $url_xml : '' ), + 'unsupported' => self::describe_string( $unsupported_url ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index_xml ) + && false !== $parsed_index + && 1 === count( $parsed_index->sitemap ) + && 1 === substr_count( $index_xml, '' ) + && 1 === substr_count( $index_xml, '' ) + && ! str_contains( $index_xml, '' ) + && ! str_contains( $index_xml, 'componentFuzz' ) + && ! str_contains( $index_xml, 'unsupported-index-' ) + && ! str_contains( $index_xml, '' ) + && ! str_contains( $index_xml, ' self::describe_string( is_string( $index_xml ) ? $index_xml : '' ), + 'unsupported' => self::describe_string( $unsupported_index ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.renderer-field-boundaries', + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 6 ) ) + ); + } + + private static function check_sitemap_renderer_stylesheet_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = substr( hash( 'sha1', self::NAME . ':stylesheet:' . $ctx->seed() ), 0, 12 ); + $sitemap_style_base = 'https://example.test/styles/sitemap-' . $token . '.xsl'; + $index_style_base = 'https://example.test/styles/index-' . $token . '.xsl'; + $sitemap_style_url = $sitemap_style_base . '?unsafe="e="&q=' . rawurlencode( $ctx->text( 0, 20 ) ); + $index_style_url = $index_style_base . '?unsafe="e="&q=' . rawurlencode( $ctx->fork( 'index-style' )->text( 0, 20 ) ); + $seen = array(); + + $sitemap_filter = static function ( string $stylesheet_url ) use ( $sitemap_style_url, &$seen ): string { + $seen[] = array( + 'hook' => 'sitemap', + 'url' => $stylesheet_url, + ); + + return $sitemap_style_url; + }; + + $index_filter = static function ( string $stylesheet_url ) use ( $index_style_url, &$seen ): string { + $seen[] = array( + 'hook' => 'index', + 'url' => $stylesheet_url, + ); + + return $index_style_url; + }; + + \remove_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + + try { + \add_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter, 10, 1 ); + + $renderer = new \WP_Sitemaps_Renderer(); + $xml = $renderer->get_sitemap_xml( + array( + array( + 'loc' => 'https://example.test/content/style?unsafe=&ok=1', + 'lastmod' => '2026-06-24T00:00:00+00:00', + ), + ) + ); + $index = $renderer->get_sitemap_index_xml( + array( + array( + 'loc' => 'https://example.test/wp-sitemap-posts-post-1.xml', + 'lastmod' => '2026-06-24T00:00:00+00:00', + ), + ) + ); + } finally { + \remove_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter, 10 ); + \add_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + $parsed_xml = is_string( $xml ) ? @simplexml_load_string( $xml ) : false; + $parsed_index = is_string( $index ) ? @simplexml_load_string( $index ) : false; + + self::collect_failure( + $failures, + array( 'sitemap', 'index' ) === array_column( $seen, 'hook' ) + && is_string( $xml ) + && false !== $parsed_xml + && 1 === substr_count( $xml, '' ) + && ! str_contains( $xml, 'quote="' ), + 'sitemap renderer includes filtered stylesheet processing instruction with escaped URL', + array( + 'seen' => $seen, + 'styleBase' => $sitemap_style_base, + 'xml' => self::describe_string( is_string( $xml ) ? $xml : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index ) + && false !== $parsed_index + && 1 === substr_count( $index, '' ) + && ! str_contains( $index, 'quote="' ) + && false === \has_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter ) + && false !== \has_filter( 'wp_sitemaps_stylesheet_url', '__return_false' ) + && false !== \has_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false' ), + 'sitemap index renderer includes its own filtered stylesheet and restores no-stylesheet filters', + array( + 'styleBase' => $index_style_base, + 'index' => self::describe_string( is_string( $index ) ? $index : '' ), + 'sitemapFilter' => \has_filter( 'wp_sitemaps_stylesheet_url', '__return_false' ), + 'indexFilter' => \has_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false' ), + 'customSitemap' => \has_filter( 'wp_sitemaps_stylesheet_url', $sitemap_filter ), + 'customIndex' => \has_filter( 'wp_sitemaps_stylesheet_index_url', $index_filter ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.renderer-stylesheet-filters', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_stylesheet_output( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = substr( hash( 'sha1', self::NAME . ':stylesheet-output:' . $ctx->seed() ), 0, 12 ); + $css_marker = 'cfz-css-' . $token; + $sitemap_marker = 'cfz-sitemap-xsl-' . $token; + $index_marker = 'cfz-index-xsl-' . $token; + $css_seen = array(); + $content_seen = array(); + $had_wp_locale = array_key_exists( 'wp_locale', $GLOBALS ); + $previous_wp_locale = $GLOBALS['wp_locale'] ?? null; + $stylesheet = new \WP_Sitemaps_Stylesheet(); + + $css_filter = static function ( string $css ) use ( &$css_seen, $css_marker ): string { + $css_seen[] = array( + 'bytes' => strlen( $css ), + 'alignLeft' => str_contains( $css, 'text-align: left' ), + 'alignRight' => str_contains( $css, 'text-align: right' ), + ); + + return $css . "\n /* {$css_marker} */\n #sitemap__table .{$css_marker} { text-align: inherit; }\n"; + }; + + $sitemap_content_filter = static function ( string $xsl ) use ( &$content_seen, $sitemap_marker ): string { + $content_seen[] = array( + 'hook' => 'sitemap', + 'hasUrlset' => str_contains( $xsl, 'sitemap:urlset/sitemap:url' ), + 'hasIndexSet' => str_contains( $xsl, 'sitemap:sitemapindex/sitemap:sitemap' ), + ); + + return str_replace( '', "\n\n", $xsl ); + }; + + $index_content_filter = static function ( string $xsl ) use ( &$content_seen, $index_marker ): string { + $content_seen[] = array( + 'hook' => 'index', + 'hasUrlset' => str_contains( $xsl, 'sitemap:urlset/sitemap:url' ), + 'hasIndexSet' => str_contains( $xsl, 'sitemap:sitemapindex/sitemap:sitemap' ), + ); + + return str_replace( '', "\n\n", $xsl ); + }; + + try { + \add_filter( 'wp_sitemaps_stylesheet_css', $css_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter, 10, 1 ); + + $GLOBALS['wp_locale'] = new \WP_Locale(); + $GLOBALS['wp_locale']->text_direction = 'ltr'; + $ltr_css = $stylesheet->get_stylesheet_css(); + $sitemap_xsl = $stylesheet->get_sitemap_stylesheet(); + $index_xsl = $stylesheet->get_sitemap_index_stylesheet(); + + $GLOBALS['wp_locale']->text_direction = 'rtl'; + $rtl_css = $stylesheet->get_stylesheet_css(); + } finally { + \remove_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter, 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_css', $css_filter, 10 ); + + if ( $had_wp_locale ) { + $GLOBALS['wp_locale'] = $previous_wp_locale; + } else { + unset( $GLOBALS['wp_locale'] ); + } + } + + $parsed_sitemap = is_string( $sitemap_xsl ) ? @simplexml_load_string( $sitemap_xsl ) : false; + $parsed_index = is_string( $index_xsl ) ? @simplexml_load_string( $index_xsl ) : false; + + self::collect_failure( + $failures, + is_string( $ltr_css ) + && is_string( $rtl_css ) + && str_contains( $ltr_css, 'text-align: left' ) + && ! str_contains( $ltr_css, 'text-align: right' ) + && str_contains( $rtl_css, 'text-align: right' ) + && ! str_contains( $rtl_css, 'text-align: left' ) + && str_contains( $ltr_css, $css_marker ) + && str_contains( $rtl_css, $css_marker ) + && 4 === count( $css_seen ) + && array( true, true, true, false ) === array_column( $css_seen, 'alignLeft' ) + && array( false, false, false, true ) === array_column( $css_seen, 'alignRight' ), + 'WP_Sitemaps_Stylesheet CSS honors LTR/RTL text alignment and scoped CSS filters', + array( + 'cssSeen' => $css_seen, + 'ltrCss' => self::describe_string( is_string( $ltr_css ) ? $ltr_css : '' ), + 'rtlCss' => self::describe_string( is_string( $rtl_css ) ? $rtl_css : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $sitemap_xsl ) + && false !== $parsed_sitemap + && str_starts_with( $sitemap_xsl, '' ) + && str_contains( $sitemap_xsl, 'sitemap:urlset/sitemap:url' ) + && str_contains( $sitemap_xsl, 'count( sitemap:urlset/sitemap:url )' ) + && str_contains( $sitemap_xsl, 'name="has-changefreq"' ) + && str_contains( $sitemap_xsl, 'name="has-priority"' ) + && str_contains( $sitemap_xsl, 'class="changefreq"' ) + && str_contains( $sitemap_xsl, 'class="priority"' ) + && str_contains( $sitemap_xsl, $css_marker ) + && str_contains( $sitemap_xsl, $sitemap_marker ) + && ! str_contains( $sitemap_xsl, $index_marker ) + && 1 === substr_count( $sitemap_xsl, ' $content_seen, + 'sitemapXsl' => self::describe_string( is_string( $sitemap_xsl ) ? $sitemap_xsl : '' ), + ) + ); + + self::collect_failure( + $failures, + is_string( $index_xsl ) + && false !== $parsed_index + && str_starts_with( $index_xsl, '' ) + && str_contains( $index_xsl, 'sitemap:sitemapindex/sitemap:sitemap' ) + && str_contains( $index_xsl, 'count( sitemap:sitemapindex/sitemap:sitemap )' ) + && str_contains( $index_xsl, 'name="has-lastmod"' ) + && ! str_contains( $index_xsl, 'name="has-changefreq"' ) + && ! str_contains( $index_xsl, 'name="has-priority"' ) + && ! str_contains( $index_xsl, 'class="changefreq"' ) + && ! str_contains( $index_xsl, 'class="priority"' ) + && str_contains( $index_xsl, $css_marker ) + && str_contains( $index_xsl, $index_marker ) + && ! str_contains( $index_xsl, $sitemap_marker ) + && 1 === substr_count( $index_xsl, ' $content_seen, + 'indexXsl' => self::describe_string( is_string( $index_xsl ) ? $index_xsl : '' ), + ) + ); + + self::collect_failure( + $failures, + array( + array( + 'hook' => 'sitemap', + 'hasUrlset' => true, + 'hasIndexSet' => false, + ), + array( + 'hook' => 'index', + 'hasUrlset' => false, + 'hasIndexSet' => true, + ), + ) === $content_seen + && false === \has_filter( 'wp_sitemaps_stylesheet_css', $css_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter ) + && false === \has_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter ) + && ( $had_wp_locale ? ( $GLOBALS['wp_locale'] ?? null ) === $previous_wp_locale : ! array_key_exists( 'wp_locale', $GLOBALS ) ), + 'WP_Sitemaps_Stylesheet content filters are type-specific and temporary locale/filter globals are restored', + array( + 'contentSeen' => $content_seen, + 'cssFilter' => \has_filter( 'wp_sitemaps_stylesheet_css', $css_filter ), + 'sitemapFilter' => \has_filter( 'wp_sitemaps_stylesheet_content', $sitemap_content_filter ), + 'indexFilter' => \has_filter( 'wp_sitemaps_stylesheet_index_content', $index_content_filter ), + 'wpLocaleRestored' => $had_wp_locale ? ( ( $GLOBALS['wp_locale'] ?? null ) === $previous_wp_locale ) : ! array_key_exists( 'wp_locale', $GLOBALS ), + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.stylesheet-output', + array() === $failures, + array( + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_sitemap_max_url_filter( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $limit = 10 + $ctx->int( 0, 50 ); + $seen = array(); + $filter = static function ( int $max_urls, string $object_type ) use ( $limit, &$seen ): int { + $seen[] = array( + 'max' => $max_urls, + 'type' => $object_type, + ); + + return 'component' === $object_type ? $limit : $max_urls; + }; + + try { + \add_filter( 'wp_sitemaps_max_urls', $filter, 10, 2 ); + $component = \wp_sitemaps_get_max_urls( 'component' ); + $post = \wp_sitemaps_get_max_urls( 'post' ); + } finally { + \remove_filter( 'wp_sitemaps_max_urls', $filter, 10 ); + } + + $after = \wp_sitemaps_get_max_urls( 'component' ); + self::collect_failure( + $failures, + $limit === $component + && 2000 === $post + && 2000 === $after + && 2 === count( $seen ) + && 'component' === $seen[0]['type'] + && 'post' === $seen[1]['type'], + 'wp_sitemaps_get_max_urls filter is scoped by object type and removable', + array( + 'limit' => $limit, + 'component' => $component, + 'post' => $post, + 'after' => $after, + 'seen' => $seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.max-url-filter-locality', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_sitemap_posts_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.posts-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'posts-runtime' ) ); + $provider = new \WP_Sitemaps_Posts(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_type = $case['postTypePrivate']; + $public_type = $case['postTypePublic']; + $published = array(); + + \register_post_type( + $public_type, + array( + 'public' => true, + 'label' => 'Discovery Public', + 'has_archive' => true, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + \register_post_type( + $private_type, + array( + 'public' => false, + 'label' => 'Discovery Private', + 'has_archive' => false, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + for ( $i = 0; $i < ( $limit * 2 ) + 1; ++$i ) { + $day = 1 + $i; + $modified_gmt = sprintf( '2026-06-%02d %02d:00:00', $day, 8 + ( $i % 10 ) ); + $modified = sprintf( '2026-06-%02d %02d:30:00', $day, 11 + ( $i % 10 ) ); + $published[] = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap post ' . $i, + 'post_name' => 'discovery-sitemap-post-' . $case['token'] . '-' . $i, + 'post_date' => $modified, + 'post_date_gmt' => $modified_gmt, + 'post_modified' => $modified, + 'post_modified_gmt' => $modified_gmt, + ) + ), + 'modified_gmt' => $modified_gmt, + ); + } + + $custom_published = array(); + for ( $i = 0; $i < $limit + 1; ++$i ) { + $day = 10 + $i; + $custom_gmt = sprintf( '2026-06-%02d %02d:15:00', $day, 7 + ( $i % 10 ) ); + $custom_modified = sprintf( '2026-06-%02d %02d:45:00', $day, 10 + ( $i % 10 ) ); + $custom_published[] = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => $public_type, + 'post_status' => 'publish', + 'post_title' => 'Discovery custom sitemap post ' . $i, + 'post_name' => 'discovery-custom-sitemap-post-' . $case['token'] . '-' . $i, + 'post_date' => $custom_modified, + 'post_date_gmt' => $custom_gmt, + 'post_modified' => $custom_modified, + 'post_modified_gmt' => $custom_gmt, + ) + ), + 'modified_gmt' => $custom_gmt, + ); + } + + $page_gmt = '2026-06-18 06:00:00'; + $page_modified = '2026-06-18 09:45:00'; + $page_fixture = array( + 'id' => self::insert_sitemap_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap page', + 'post_name' => 'discovery-sitemap-page-' . $case['token'], + 'post_date' => $page_modified, + 'post_date_gmt' => $page_gmt, + 'post_modified' => $page_modified, + 'post_modified_gmt' => $page_gmt, + ) + ), + 'modified_gmt' => $page_gmt, + ); + + $draft_id = self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_title' => 'Discovery draft sitemap post', + 'post_name' => 'discovery-draft-' . $case['token'], + 'post_modified_gmt' => '2026-06-20 10:00:00', + ) + ); + $private_id = self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'private', + 'post_title' => 'Discovery private sitemap post', + 'post_name' => 'discovery-private-' . $case['token'], + 'post_modified_gmt' => '2026-06-21 10:00:00', + ) + ); + $unsupported_private_type_id = self::insert_sitemap_post( + array( + 'post_type' => $private_type, + 'post_status' => 'publish', + 'post_title' => 'Discovery unsupported post type sitemap post', + 'post_name' => 'discovery-unsupported-' . $case['token'], + 'post_modified_gmt' => '2026-06-22 10:00:00', + ) + ); + $custom_draft_id = self::insert_sitemap_post( + array( + 'post_type' => $public_type, + 'post_status' => 'draft', + 'post_title' => 'Discovery custom draft sitemap post', + 'post_name' => 'discovery-custom-draft-' . $case['token'], + 'post_modified_gmt' => '2026-07-20 10:00:00', + ) + ); + + $subtypes = $provider->get_object_subtypes(); + self::collect_failure( + $failures, + isset( $subtypes['post'] ) + && isset( $subtypes['page'] ) + && isset( $subtypes[ $public_type ] ) + && ! isset( $subtypes['attachment'] ) + && ! isset( $subtypes[ $private_type ] ), + 'WP_Sitemaps_Posts exposes viewable public post subtypes and excludes attachments/private types', + array( + 'subtypes' => array_keys( $subtypes ), + 'publicType' => $public_type, + 'privateType' => $private_type, + ) + ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args, string $post_type ) use ( &$query_seen ): array { + $query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $entry_filter, 10, 3 ); + try { + $max_pages = $provider->get_max_num_pages( 'post' ); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page, 'post' ); + } + $unsupported_list = $provider->get_url_list( 1, $private_type ); + $attachment_list = $provider->get_url_list( 1, 'attachment' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + self::collect_failure( + $failures, + 3 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && $limit === count( $page_entries[2] ?? array() ) + && 1 === count( $page_entries[3] ?? array() ) + && count( $published ) === count( $all_entries ) + && self::sitemap_post_entries_match_fixtures( $all_entries, $published ) + && ! self::sitemap_entries_contain_locs_for_ids( $all_entries, array( $draft_id, $private_id, $unsupported_private_type_id ) ) + && array() === $unsupported_list + && array() === $attachment_list + && self::sitemap_post_query_args_local_to_type( $query_seen, 'post', $limit ) + && array_column( $entry_seen, 'id' ) === array_column( $published, 'id' ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Posts lists only published supported subtype entries with lastmod and max-page math', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'unsupportedList' => $unsupported_list, + 'attachmentList' => $attachment_list, + ) + ); + + $custom_query_seen = array(); + $custom_entry_seen = array(); + $custom_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $custom_query_filter = static function ( array $args, string $post_type ) use ( &$custom_query_seen ): array { + $custom_query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $custom_entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$custom_entry_seen ): array { + $custom_entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $custom_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter, 10, 3 ); + try { + $custom_max_pages = $provider->get_max_num_pages( $public_type ); + $custom_page_entries = array(); + for ( $page = 1; $page <= $custom_max_pages; ++$page ) { + $custom_page_entries[ $page ] = $provider->get_url_list( $page, $public_type ); + } + } finally { + \remove_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $custom_max_filter, 10 ); + } + + $custom_entries = array_merge( ...array_values( $custom_page_entries ) ); + self::collect_failure( + $failures, + 2 === $custom_max_pages + && $limit === count( $custom_page_entries[1] ?? array() ) + && 1 === count( $custom_page_entries[2] ?? array() ) + && count( $custom_published ) === count( $custom_entries ) + && self::sitemap_post_entries_match_fixtures( $custom_entries, $custom_published ) + && ! self::sitemap_entries_contain_locs_for_ids( $custom_entries, array( $custom_draft_id ) ) + && self::sitemap_post_query_args_local_to_type( $custom_query_seen, $public_type, $limit ) + && array_column( $custom_entry_seen, 'id' ) === array_column( $custom_published, 'id' ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $custom_entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $custom_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $custom_max_filter ), + 'WP_Sitemaps_Posts includes viewable public custom post type entries with local filters and max-page math', + array( + 'publicType' => $public_type, + 'limit' => $limit, + 'maxPages' => $custom_max_pages, + 'pageCounts' => array_map( 'count', $custom_page_entries ), + 'entrySeen' => $custom_entry_seen, + 'querySeen' => $custom_query_seen, + 'customDraft' => $custom_draft_id, + ) + ); + + $page_query_seen = array(); + $page_entry_seen = array(); + $home_seen = array(); + $page_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'post' === $object_type ? $limit : $max_urls; + }; + $page_query_filter = static function ( array $args, string $post_type ) use ( &$page_query_seen ): array { + $page_query_seen[] = array( + 'postType' => $post_type, + 'status' => $args['post_status'] ?? null, + 'postsPerPage' => $args['posts_per_page'] ?? null, + 'noFoundRows' => $args['no_found_rows'] ?? null, + 'ignoreSticky' => $args['ignore_sticky_posts'] ?? null, + ); + + return $args; + }; + $page_entry_filter = static function ( array $entry, \WP_Post $post, string $post_type ) use ( &$page_entry_seen ): array { + $page_entry_seen[] = array( + 'id' => (int) $post->ID, + 'type' => $post_type, + 'loc' => $entry['loc'] ?? null, + 'lastmod' => $entry['lastmod'] ?? null, + ); + + $entry['component-fuzz-id'] = (string) $post->ID; + return $entry; + }; + $home_filter = static function ( array $entry ) use ( &$home_seen ): array { + $home_seen[] = $entry; + $entry['component-fuzz-home'] = '1'; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $page_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_query_args', $page_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_posts_entry', $page_entry_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter, 10, 1 ); + try { + $page_max_pages = $provider->get_max_num_pages( 'page' ); + $page_list = $provider->get_url_list( 1, 'page' ); + $page_two_list = $provider->get_url_list( 2, 'page' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_entry', $page_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_query_args', $page_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $page_max_filter, 10 ); + } + + $latest_post = $published[ count( $published ) - 1 ]; + $expected_home_lastmod = self::expected_sitemap_lastmod_from_gmt( (string) $latest_post['modified_gmt'] ); + $page_post_entries = array_slice( $page_list, 1 ); + $page_home_entry = $page_list[0] ?? array(); + $captured_home_entry = $home_seen[0] ?? array(); + self::collect_failure( + $failures, + 1 === $page_max_pages + && 2 === count( $page_list ) + && array() === $page_two_list + && \home_url( '/' ) === ( $page_home_entry['loc'] ?? null ) + && '1' === ( $page_home_entry['component-fuzz-home'] ?? null ) + && $expected_home_lastmod === ( $page_home_entry['lastmod'] ?? null ) + && \home_url( '/' ) === ( $captured_home_entry['loc'] ?? null ) + && $expected_home_lastmod === ( $captured_home_entry['lastmod'] ?? null ) + && self::sitemap_post_entries_match_fixtures( $page_post_entries, array( $page_fixture ) ) + && self::sitemap_post_query_args_local_to_type( $page_query_seen, 'page', $limit ) + && array_column( $page_entry_seen, 'id' ) === array( $page_fixture['id'] ) + && false === \has_filter( 'wp_sitemaps_posts_show_on_front_entry', $home_filter ) + && false === \has_filter( 'wp_sitemaps_posts_entry', $page_entry_filter ) + && false === \has_filter( 'wp_sitemaps_posts_query_args', $page_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $page_max_filter ), + 'WP_Sitemaps_Posts page subtype includes the show-on-front home entry, page entries, and min-page math', + array( + 'limit' => $limit, + 'pageMaxPages' => $page_max_pages, + 'pageList' => $page_list, + 'pageTwoList' => $page_two_list, + 'homeSeen' => $home_seen, + 'expectedHomeLastmod' => $expected_home_lastmod, + 'pageEntrySeen' => $page_entry_seen, + 'pageQuerySeen' => $page_query_seen, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( + array( + 'loc' => 'https://example.test/pre-posts-' . $case['token'], + 'lastmod' => '2026-06-25T00:00:00+00:00', + ), + ); + $pre_list_filter = static function ( $url_list, string $post_type, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'type' => $post_type, + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 'post' === $post_type && 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages, string $post_type ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( + 'type' => $post_type, + 'null' => null === $max_num_pages, + ); + + return 'post' === $post_type ? 17 : (int) $max_num_pages; + }; + + \add_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter, 10, 2 ); + try { + $pre_list = $provider->get_url_list( 2, 'post' ); + $pre_unsupported = $provider->get_url_list( 2, $private_type ); + $pre_pages = $provider->get_max_num_pages( 'post' ); + } finally { + \remove_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && array() === $pre_unsupported + && 17 === $pre_pages + && array( array( 'type' => 'post', 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'type' => 'post', 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_posts_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_posts_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Posts pre-list and pre-page filters short-circuit only matching subtype calls', + array( + 'preList' => $pre_list, + 'preUnsupported' => $pre_unsupported, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.posts-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_taxonomies_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.taxonomies-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'taxonomies-runtime' ) ); + $provider = new \WP_Sitemaps_Taxonomies(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_tax = $case['taxonomyPrivate']; + $public_tax = $case['taxonomyPublic']; + $included = array(); + $empty = array(); + + \register_taxonomy( + $public_tax, + array( 'post' ), + array( + 'public' => true, + 'hierarchical' => false, + 'label' => 'Discovery Public Taxonomy', + 'rewrite' => false, + ) + ); + + \register_taxonomy( + $private_tax, + array( 'post' ), + array( + 'public' => false, + 'hierarchical' => true, + 'label' => 'Discovery Private Taxonomy', + 'rewrite' => false, + ) + ); + + for ( $i = 0; $i < $limit + 1; ++$i ) { + $included[] = self::insert_sitemap_term( + 'category', + 'Discovery Sitemap Category ' . $i, + 'discovery-sitemap-category-' . $case['token'] . '-' . $i, + 1 + $i + ); + } + + for ( $i = 0; $i < 2; ++$i ) { + $empty[] = self::insert_sitemap_term( + 'category', + 'Discovery Empty Category ' . $i, + 'discovery-empty-category-' . $case['token'] . '-' . $i, + 0 + ); + } + + $public_included = array(); + for ( $i = 0; $i < $limit + 1; ++$i ) { + $public_included[] = self::insert_sitemap_term( + $public_tax, + 'Discovery Public Taxonomy Term ' . $i, + 'discovery-public-taxonomy-term-' . $case['token'] . '-' . $i, + 2 + $i + ); + } + + $public_empty = self::insert_sitemap_term( + $public_tax, + 'Discovery Empty Public Taxonomy Term', + 'discovery-empty-public-taxonomy-term-' . $case['token'], + 0 + ); + + $private_term = self::insert_sitemap_term( + $private_tax, + 'Discovery Private Term', + 'discovery-private-term-' . $case['token'], + 3 + ); + + $subtypes = $provider->get_object_subtypes(); + self::collect_failure( + $failures, + isset( $subtypes['category'] ) + && isset( $subtypes['post_tag'] ) + && isset( $subtypes[ $public_tax ] ) + && ! isset( $subtypes[ $private_tax ] ), + 'WP_Sitemaps_Taxonomies exposes public taxonomies and excludes private taxonomy subtypes', + array( + 'subtypes' => array_keys( $subtypes ), + 'publicTax' => $public_tax, + 'privateTax' => $private_tax, + ) + ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'term' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args, string $taxonomy ) use ( &$query_seen ): array { + $query_seen[] = array( + 'taxonomy' => $taxonomy, + 'number' => $args['number'] ?? null, + 'hideEmpty' => $args['hide_empty'] ?? null, + 'fields' => $args['fields'] ?? null, + 'offset' => $args['offset'] ?? null, + ); + + return $args; + }; + $entry_filter = static function ( array $entry, int $term_id, string $taxonomy, \WP_Term $term ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => $term_id, + 'taxonomy' => $taxonomy, + 'count' => (int) $term->count, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-term'] = (string) $term_id; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter, 10, 4 ); + try { + $max_pages = $provider->get_max_num_pages( 'category' ); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page, 'category' ); + } + $unsupported_list = $provider->get_url_list( 1, $private_tax ); + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + self::collect_failure( + $failures, + 2 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && 1 === count( $page_entries[2] ?? array() ) + && count( $included ) === count( $all_entries ) + && self::sitemap_term_entries_match_fixtures( $all_entries, $included, 'category' ) + && array_column( $entry_seen, 'id' ) === array_column( $included, 'term_id' ) + && ! array_intersect( array_column( $entry_seen, 'id' ), array_column( $empty, 'term_id' ) ) + && ! in_array( $private_term['term_id'], array_column( $entry_seen, 'id' ), true ) + && array() === $unsupported_list + && self::sitemap_taxonomy_query_args_local_to_taxonomy( $query_seen, 'category', $limit ) + && false === \has_filter( 'wp_sitemaps_taxonomies_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Taxonomies honors public subtype gating, hide-empty terms, locs, and max-page math', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'unsupportedList' => $unsupported_list, + ) + ); + + $public_tax_query_seen = array(); + $public_tax_entry_seen = array(); + $public_tax_max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'term' === $object_type ? $limit : $max_urls; + }; + $public_tax_query_filter = static function ( array $args, string $taxonomy ) use ( &$public_tax_query_seen ): array { + $public_tax_query_seen[] = array( + 'taxonomy' => $taxonomy, + 'number' => $args['number'] ?? null, + 'hideEmpty' => $args['hide_empty'] ?? null, + 'fields' => $args['fields'] ?? null, + 'offset' => $args['offset'] ?? null, + ); + + return $args; + }; + $public_tax_entry_filter = static function ( array $entry, int $term_id, string $taxonomy, \WP_Term $term ) use ( &$public_tax_entry_seen ): array { + $public_tax_entry_seen[] = array( + 'id' => $term_id, + 'taxonomy' => $taxonomy, + 'count' => (int) $term->count, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-term'] = (string) $term_id; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter, 10, 4 ); + try { + $public_tax_max_pages = $provider->get_max_num_pages( $public_tax ); + $public_tax_page_entries = array(); + for ( $page = 1; $page <= $public_tax_max_pages; ++$page ) { + $public_tax_page_entries[ $page ] = $provider->get_url_list( $page, $public_tax ); + } + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter, 10 ); + } + + $public_tax_entries = array_merge( ...array_values( $public_tax_page_entries ) ); + self::collect_failure( + $failures, + 2 === $public_tax_max_pages + && $limit === count( $public_tax_page_entries[1] ?? array() ) + && 1 === count( $public_tax_page_entries[2] ?? array() ) + && count( $public_included ) === count( $public_tax_entries ) + && self::sitemap_term_entries_match_fixtures( $public_tax_entries, $public_included, $public_tax ) + && array_column( $public_tax_entry_seen, 'id' ) === array_column( $public_included, 'term_id' ) + && ! in_array( $public_empty['term_id'], array_column( $public_tax_entry_seen, 'id' ), true ) + && self::sitemap_taxonomy_query_args_local_to_taxonomy( $public_tax_query_seen, $public_tax, $limit ) + && false === \has_filter( 'wp_sitemaps_taxonomies_entry', $public_tax_entry_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_query_args', $public_tax_query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $public_tax_max_filter ), + 'WP_Sitemaps_Taxonomies includes public custom taxonomy terms with hide-empty and max-page math', + array( + 'publicTax' => $public_tax, + 'limit' => $limit, + 'maxPages' => $public_tax_max_pages, + 'pageCounts' => array_map( 'count', $public_tax_page_entries ), + 'entrySeen' => $public_tax_entry_seen, + 'querySeen' => $public_tax_query_seen, + 'publicEmpty' => $public_empty, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( array( 'loc' => 'https://example.test/pre-taxonomies-' . $case['token'] ) ); + $pre_list_filter = static function ( $url_list, string $taxonomy, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'taxonomy' => $taxonomy, + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 'category' === $taxonomy && 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages, string $taxonomy ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( + 'taxonomy' => $taxonomy, + 'null' => null === $max_num_pages, + ); + + return 'category' === $taxonomy ? 19 : (int) $max_num_pages; + }; + + \add_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter, 10, 3 ); + \add_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter, 10, 2 ); + try { + $pre_list = $provider->get_url_list( 2, 'category' ); + $pre_unsupported = $provider->get_url_list( 2, $private_tax ); + $pre_pages = $provider->get_max_num_pages( 'category' ); + } finally { + \remove_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && array() === $pre_unsupported + && 19 === $pre_pages + && array( array( 'taxonomy' => 'category', 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'taxonomy' => 'category', 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_taxonomies_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_taxonomies_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Taxonomies pre-list and pre-page filters short-circuit only matching taxonomy calls', + array( + 'preList' => $pre_list, + 'preUnsupported' => $pre_unsupported, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.taxonomies-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function check_sitemap_users_provider( \ComponentFuzz\FuzzContext $ctx ): array { + $missing = self::sitemap_provider_fixture_missing_requirements(); + if ( array() !== $missing ) { + return self::skip( + $ctx, + 'discovery.sitemaps.users-provider-fixtures', + 'Built-in sitemap provider fixture APIs are unavailable.', + array( 'missing' => $missing ) + ); + } + + $failures = array(); + $case = self::prepare_sitemap_provider_runtime( $ctx->fork( 'users-runtime' ) ); + $provider = new \WP_Sitemaps_Users(); + $limit = 2 + $ctx->int( 0, 1 ); + $private_type = $case['postTypePrivate']; + $public_type = $case['postTypePublic']; + $included_users = array(); + + \register_post_type( + $private_type, + array( + 'public' => false, + 'label' => 'Discovery Private Author Type', + 'has_archive' => false, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + \register_post_type( + $public_type, + array( + 'public' => true, + 'label' => 'Discovery Public Author Type', + 'has_archive' => true, + 'rewrite' => false, + 'supports' => array( 'title', 'editor', 'author' ), + ) + ); + + for ( $i = 0; $i < $limit + 1; ++$i ) { + $user_id = self::insert_sitemap_user( $case, 'public-' . $i ); + $included_users[] = $user_id; + self::insert_sitemap_post( + array( + 'post_type' => 0 === $i % 2 ? 'post' : $public_type, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'post_title' => 'Discovery public author post ' . $i, + 'post_name' => 'discovery-public-author-' . $case['token'] . '-' . $i, + 'post_modified_gmt' => sprintf( '2026-06-%02d 12:00:00', 1 + $i ), + ) + ); + } + + $page_only_user = self::insert_sitemap_user( $case, 'page-only' ); + self::insert_sitemap_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_author' => $page_only_user, + 'post_title' => 'Discovery author page only', + 'post_name' => 'discovery-author-page-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-15 12:00:00', + ) + ); + + $draft_only_user = self::insert_sitemap_user( $case, 'draft-only' ); + self::insert_sitemap_post( + array( + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_author' => $draft_only_user, + 'post_title' => 'Discovery author draft only', + 'post_name' => 'discovery-author-draft-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-16 12:00:00', + ) + ); + + $private_only_user = self::insert_sitemap_user( $case, 'private-only' ); + self::insert_sitemap_post( + array( + 'post_type' => $private_type, + 'post_status' => 'publish', + 'post_author' => $private_only_user, + 'post_title' => 'Discovery author private type only', + 'post_name' => 'discovery-author-private-only-' . $case['token'], + 'post_modified_gmt' => '2026-06-17 12:00:00', + ) + ); + + $empty_user = self::insert_sitemap_user( $case, 'empty' ); + + $query_seen = array(); + $entry_seen = array(); + $max_filter = static function ( int $max_urls, string $object_type ) use ( $limit ): int { + return 'user' === $object_type ? $limit : $max_urls; + }; + $query_filter = static function ( array $args ) use ( &$query_seen ): array { + $query_seen[] = array( + 'number' => $args['number'] ?? null, + 'hasPublishedPosts' => array_values( (array) ( $args['has_published_posts'] ?? array() ) ), + ); + + return $args; + }; + $entry_filter = static function ( array $entry, \WP_User $user ) use ( &$entry_seen ): array { + $entry_seen[] = array( + 'id' => (int) $user->ID, + 'loc' => $entry['loc'] ?? null, + ); + + $entry['component-fuzz-user'] = (string) $user->ID; + return $entry; + }; + + \add_filter( 'wp_sitemaps_max_urls', $max_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_users_query_args', $query_filter, 10, 1 ); + \add_filter( 'wp_sitemaps_users_entry', $entry_filter, 10, 2 ); + try { + $max_pages = $provider->get_max_num_pages(); + $page_entries = array(); + for ( $page = 1; $page <= $max_pages; ++$page ) { + $page_entries[ $page ] = $provider->get_url_list( $page ); + } + } finally { + \remove_filter( 'wp_sitemaps_users_entry', $entry_filter, 10 ); + \remove_filter( 'wp_sitemaps_users_query_args', $query_filter, 10 ); + \remove_filter( 'wp_sitemaps_max_urls', $max_filter, 10 ); + } + + $all_entries = array_merge( ...array_values( $page_entries ) ); + $excluded_ids = array( $page_only_user, $draft_only_user, $private_only_user, $empty_user ); + self::collect_failure( + $failures, + 2 === $max_pages + && $limit === count( $page_entries[1] ?? array() ) + && 1 === count( $page_entries[2] ?? array() ) + && count( $included_users ) === count( $all_entries ) + && self::sitemap_user_entries_match_ids( $all_entries, $included_users ) + && array_column( $entry_seen, 'id' ) === $included_users + && ! array_intersect( array_column( $entry_seen, 'id' ), $excluded_ids ) + && self::sitemap_user_query_args_include_public_post_types( $query_seen, $limit, $public_type, $private_type ) + && false === \has_filter( 'wp_sitemaps_users_entry', $entry_filter ) + && false === \has_filter( 'wp_sitemaps_users_query_args', $query_filter ) + && false === \has_filter( 'wp_sitemaps_max_urls', $max_filter ), + 'WP_Sitemaps_Users lists only authors with published public posts and computes max pages', + array( + 'limit' => $limit, + 'maxPages' => $max_pages, + 'pageCounts' => array_map( 'count', $page_entries ), + 'entrySeen' => $entry_seen, + 'querySeen' => $query_seen, + 'excluded' => $excluded_ids, + 'privateType' => $private_type, + ) + ); + + $pre_seen = array(); + $pre_page_seen = array(); + $pre_sentinel = array( array( 'loc' => 'https://example.test/pre-users-' . $case['token'] ) ); + $pre_list_filter = static function ( $url_list, int $page_num ) use ( &$pre_seen, $pre_sentinel ) { + $pre_seen[] = array( + 'page' => $page_num, + 'null' => null === $url_list, + ); + + return 2 === $page_num ? $pre_sentinel : $url_list; + }; + $pre_page_filter = static function ( $max_num_pages ) use ( &$pre_page_seen ): int { + $pre_page_seen[] = array( 'null' => null === $max_num_pages ); + return 23; + }; + + \add_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter, 10, 2 ); + \add_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter, 10, 1 ); + try { + $pre_list = $provider->get_url_list( 2 ); + $pre_pages = $provider->get_max_num_pages(); + } finally { + \remove_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter, 10 ); + \remove_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter, 10 ); + } + + self::collect_failure( + $failures, + $pre_sentinel === $pre_list + && 23 === $pre_pages + && array( array( 'page' => 2, 'null' => true ) ) === $pre_seen + && array( array( 'null' => true ) ) === $pre_page_seen + && false === \has_filter( 'wp_sitemaps_users_pre_url_list', $pre_list_filter ) + && false === \has_filter( 'wp_sitemaps_users_pre_max_num_pages', $pre_page_filter ), + 'WP_Sitemaps_Users pre-list and pre-page filters short-circuit provider calls', + array( + 'preList' => $pre_list, + 'prePages' => $pre_pages, + 'preSeen' => $pre_seen, + 'prePageSeen' => $pre_page_seen, + ) + ); + + return self::row( + $ctx, + 'discovery.sitemaps.users-provider-fixtures', + array() === $failures, + array( + 'limit' => $limit, + 'failures' => array_slice( $failures, 0, 6 ), + ) + ); + } + + private static function sitemap_provider_fixture_missing_requirements(): array { + $missing = array(); + + self::load_builtin_sitemap_provider_classes(); + + foreach ( array( 'WP_Post', 'WP_Sitemaps_Posts', 'WP_Sitemaps_Taxonomies', 'WP_Sitemaps_Users', 'WP_Term', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'create_initial_post_types', + 'create_initial_taxonomies', + 'get_author_posts_url', + 'get_permalink', + 'get_term_link', + 'home_url', + 'is_wp_error', + 'register_post_type', + 'register_taxonomy', + 'wp_cache_flush', + 'wp_insert_post', + 'wp_insert_term', + 'wp_insert_user', + 'wp_date', + 'wp_timezone', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! isset( $GLOBALS['wpdb'] ) || ! method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) || ! method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_options' ) ) { + $missing[] = 'component fuzz wpdb stub'; + } + + return $missing; + } + + private static function load_builtin_sitemap_provider_classes(): void { + if ( class_exists( 'WP_Sitemaps_Posts' ) && class_exists( 'WP_Sitemaps_Taxonomies' ) && class_exists( 'WP_Sitemaps_Users' ) ) { + return; + } + + if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) { + return; + } + + foreach ( + array( + 'WP_Sitemaps_Posts' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-posts.php', + 'WP_Sitemaps_Taxonomies' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-taxonomies.php', + 'WP_Sitemaps_Users' => ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-users.php', + ) as $class => $path + ) { + if ( ! class_exists( $class ) && is_file( $path ) ) { + require_once $path; + } + } + } + + private static function load_sitemap_stylesheet_class(): void { + if ( class_exists( 'WP_Sitemaps_Stylesheet' ) ) { + return; + } + + if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) { + return; + } + + $path = ABSPATH . WPINC . '/sitemaps/class-wp-sitemaps-stylesheet.php'; + if ( is_file( $path ) ) { + require_once $path; + } + } + + private static function prepare_sitemap_provider_runtime( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb, $wp_rewrite; + + $token = substr( hash( 'crc32b', 'discovery-sitemaps:' . $ctx->seed() ), 0, 7 ); + $wpdb->component_fuzz_reset_content(); + $wpdb->component_fuzz_reset_options( + array( + 'admin_email' => 'admin@example.test', + 'blog_charset' => 'UTF-8', + 'blog_public' => 1, + 'blogname' => 'Component Fuzz', + 'default_category' => 0, + 'default_comment_status' => 'closed', + 'default_ping_status' => 'closed', + 'gmt_offset' => 2, + 'home' => 'https://example.test', + 'permalink_structure' => '', + 'show_on_front' => 'posts', + 'siteurl' => 'https://example.test', + 'timezone_string' => 'Europe/Madrid', + ) + ); + + \wp_cache_flush(); + + $GLOBALS['wp_post_types'] = array(); + $GLOBALS['wp_taxonomies'] = array(); + \create_initial_post_types(); + \create_initial_taxonomies(); + + if ( class_exists( 'WP_Rewrite' ) ) { + $wp_rewrite = new \WP_Rewrite(); + } + + return array( + 'token' => $token, + 'postTypePrivate' => 'cfzdp' . substr( $token, 0, 7 ), + 'postTypePublic' => 'cfzdu' . substr( $token, 0, 7 ), + 'taxonomyPrivate' => 'cfzdtax' . substr( $token, 0, 7 ), + 'taxonomyPublic' => 'cfzdtp' . substr( $token, 0, 7 ), + ); + } + + private static function insert_sitemap_post( array $fields ): int { + $defaults = array( + 'post_author' => 0, + 'post_content' => 'Discovery sitemap fixture content', + 'post_date' => '2026-06-01 00:00:00', + 'post_date_gmt' => '2026-06-01 00:00:00', + 'post_excerpt' => '', + 'post_modified' => $fields['post_modified_gmt'] ?? '2026-06-01 00:00:00', + 'post_modified_gmt' => '2026-06-01 00:00:00', + 'post_name' => '', + 'post_status' => 'publish', + 'post_title' => 'Discovery sitemap fixture', + 'post_type' => 'post', + ); + + $post_id = \wp_insert_post( array_merge( $defaults, $fields ), true, false ); + if ( \is_wp_error( $post_id ) ) { + throw new \RuntimeException( 'Could not insert sitemap post fixture: ' . $post_id->get_error_code() ); + } + + return (int) $post_id; + } + + private static function insert_sitemap_user( array $case, string $suffix ): int { + $safe_suffix = preg_replace( '/[^a-z0-9_]+/', '-', strtolower( $suffix ) ); + $login = 'cfz_' . $case['token'] . '_' . $safe_suffix; + $user_id = \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz', + 'user_email' => $login . '@example.test', + 'user_nicename' => str_replace( '_', '-', $login ), + 'display_name' => 'Discovery User ' . $suffix, + ) + ); + + if ( \is_wp_error( $user_id ) ) { + throw new \RuntimeException( 'Could not insert sitemap user fixture: ' . $user_id->get_error_code() ); + } + + return (int) $user_id; + } + + private static function insert_sitemap_term( string $taxonomy, string $name, string $slug, int $count ): array { + global $wpdb; + + $result = \wp_insert_term( + $name, + $taxonomy, + array( + 'slug' => $slug, + ) + ); + + if ( \is_wp_error( $result ) ) { + throw new \RuntimeException( 'Could not insert sitemap term fixture: ' . $result->get_error_code() ); + } + + $wpdb->update( + $wpdb->term_taxonomy, + array( 'count' => $count ), + array( 'term_taxonomy_id' => (int) $result['term_taxonomy_id'] ) + ); + + return array( + 'term_id' => (int) $result['term_id'], + 'term_taxonomy_id' => (int) $result['term_taxonomy_id'], + 'taxonomy' => $taxonomy, + 'slug' => $slug, + 'count' => $count, + ); + } + + private static function sitemap_post_entries_match_fixtures( array $entries, array $posts ): bool { + if ( count( $entries ) !== count( $posts ) ) { + return false; + } + + foreach ( $posts as $index => $post ) { + $entry = $entries[ $index ] ?? array(); + $expected = self::expected_sitemap_lastmod_from_gmt( (string) $post['modified_gmt'] ); + if ( ( $entry['loc'] ?? null ) !== \get_permalink( $post['id'] ) ) { + return false; + } + if ( ( $entry['lastmod'] ?? null ) !== $expected || ! self::is_w3c_datetime( (string) ( $entry['lastmod'] ?? '' ) ) ) { + return false; + } + if ( (string) $post['id'] !== ( $entry['component-fuzz-id'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function expected_sitemap_lastmod_from_gmt( string $modified_gmt ): string { + $datetime = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $modified_gmt, new \DateTimeZone( 'UTC' ) ); + if ( ! $datetime ) { + return ''; + } + + $timezone = function_exists( 'wp_timezone' ) ? \wp_timezone() : new \DateTimeZone( 'UTC' ); + return $datetime->setTimezone( $timezone )->format( DATE_W3C ); + } + + private static function sitemap_entries_contain_locs_for_ids( array $entries, array $post_ids ): bool { + $locs = array_column( $entries, 'loc' ); + foreach ( $post_ids as $post_id ) { + if ( in_array( \get_permalink( $post_id ), $locs, true ) ) { + return true; + } + } + + return false; + } + + private static function sitemap_post_query_args_local_to_type( array $query_seen, string $post_type, int $limit ): bool { + if ( array() === $query_seen ) { + return false; + } + + foreach ( $query_seen as $seen ) { + if ( $post_type !== ( $seen['postType'] ?? null ) ) { + return false; + } + if ( $limit !== (int) ( $seen['postsPerPage'] ?? 0 ) ) { + return false; + } + if ( array( 'publish' ) !== array_values( (array) ( $seen['status'] ?? array() ) ) ) { + return false; + } + if ( true !== ( $seen['ignoreSticky'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_term_entries_match_fixtures( array $entries, array $terms, string $taxonomy ): bool { + if ( count( $entries ) !== count( $terms ) ) { + return false; + } + + foreach ( $terms as $index => $term ) { + $entry = $entries[ $index ] ?? array(); + $link = \get_term_link( $term['term_id'], $taxonomy ); + if ( \is_wp_error( $link ) || ( $entry['loc'] ?? null ) !== $link ) { + return false; + } + if ( (string) $term['term_id'] !== ( $entry['component-fuzz-term'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_taxonomy_query_args_local_to_taxonomy( array $query_seen, string $taxonomy, int $limit ): bool { + if ( array() === $query_seen ) { + return false; + } + + foreach ( $query_seen as $seen ) { + if ( $taxonomy !== ( $seen['taxonomy'] ?? null ) ) { + return false; + } + if ( $limit !== (int) ( $seen['number'] ?? 0 ) ) { + return false; + } + if ( true !== ( $seen['hideEmpty'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_user_entries_match_ids( array $entries, array $user_ids ): bool { + if ( count( $entries ) !== count( $user_ids ) ) { + return false; + } + + foreach ( $user_ids as $index => $user_id ) { + $entry = $entries[ $index ] ?? array(); + if ( ( $entry['loc'] ?? null ) !== \get_author_posts_url( $user_id ) ) { + return false; + } + if ( (string) $user_id !== ( $entry['component-fuzz-user'] ?? null ) ) { + return false; + } + } + + return true; + } + + private static function sitemap_user_query_args_include_public_post_types( array $query_seen, int $limit, string $public_type, string $private_type ): bool { + if ( array() === $query_seen ) { + return false; + } + + $expected_post_types = array( 'post', $public_type ); + sort( $expected_post_types ); + + foreach ( $query_seen as $seen ) { + $post_types = array_values( (array) ( $seen['hasPublishedPosts'] ?? array() ) ); + sort( $post_types ); + if ( $limit !== (int) ( $seen['number'] ?? 0 ) ) { + return false; + } + if ( $expected_post_types !== $post_types ) { + return false; + } + if ( in_array( 'page', $post_types, true ) || in_array( 'attachment', $post_types, true ) || in_array( $private_type, $post_types, true ) ) { + return false; + } + } + + return true; + } + + private static function is_w3c_datetime( string $value ): bool { + return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/', $value ); + } + + private static function robots_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'directives' => array() ), + array( 'directives' => array( 'noindex' => true, 'nofollow' => false, 'max-image-preview' => 'large' ) ), + array( 'directives' => array( 'index' => true, 'snippet' => 'max-snippet:' . $ctx->int( 1, 99 ) ) ), + array( 'directives' => array( 'unsafe 'a"b true ) ), + ); + + for ( $i = count( $cases ); $i < 8; ++$i ) { + $case = $ctx->fork( 'case-' . $i ); + $directive = 'cfz-' . substr( hash( 'crc32b', (string) $case->seed() ), 0, 8 ); + $value = $case->choice( array( true, false, 'value-' . $case->int( 0, 999 ), "unsafe\"<>&" ) ); + $cases[] = array( + 'directives' => array( + $directive => $value, + 'max-snippet' => (string) $case->int( 1, 500 ), + 'max-image-preview' => 'large', + ), + ); + } + + return $cases; + } + + private static function field_token( \ComponentFuzz\FuzzContext $ctx ): string { + return substr( hash( 'sha1', self::NAME . ':field:' . $ctx->seed() ), 0, 10 ); + } + + private static function sitemap_url_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $urls = array(); + for ( $i = 0; $i < self::URL_CASES; ++$i ) { + $case = $ctx->fork( 'url-' . $i ); + $urls[] = array( + 'loc' => 'https://example.test/content/' . $i . '?q=' . rawurlencode( $case->text( 0, 24 ) ), + 'lastmod' => sprintf( '2026-06-%02dT%02d:00:00+00:00', 1 + $case->int( 0, 20 ), $case->int( 0, 23 ) ), + 'changefreq' => $case->choice( array( 'daily', 'weekly', 'monthly' ) ), + 'priority' => sprintf( '0.%d', $case->int( 1, 9 ) ), + ); + } + + $urls[] = array( + 'loc' => 'https://example.test/unsafe?x="e="', + 'lastmod' => '2026-06-21T00:00:00+00:00', + ); + + return $urls; + } + + private static function sitemap_provider( string $name, string $object_type, array $subtypes, array $urls ): \WP_Sitemaps_Provider { + return new class( $name, $object_type, $subtypes, $urls ) extends \WP_Sitemaps_Provider { + /** @var array> */ + private array $subtypes; + + /** @var array> */ + private array $urls; + + public function __construct( string $name, string $object_type, array $subtypes, array $urls ) { + $this->name = $name; + $this->object_type = $object_type; + $this->subtypes = $subtypes; + $this->urls = $urls; + } + + public function get_url_list( $page_num, $object_subtype = '' ) { + unset( $object_subtype ); + $page_num = max( 1, (int) $page_num ); + return array_slice( $this->urls, ( $page_num - 1 ) * 4, 4 ); + } + + public function get_max_num_pages( $object_subtype = '' ) { + return 'beta' === $object_subtype ? 3 : 1; + } + + public function get_object_subtypes() { + return $this->subtypes; + } + }; + } + + private static function all_entries_are_sitemap_locs( array $entries, string $name ): bool { + foreach ( $entries as $entry ) { + if ( ! isset( $entry['loc'] ) || ! is_string( $entry['loc'] ) || ! str_contains( $entry['loc'], 'sitemap=' . rawurlencode( $name ) ) ) { + return false; + } + } + + return true; + } + + private static function reset_runtime(): void { + global $wp_rewrite; + + self::$provider_data = array(); + if ( class_exists( 'WP_Rewrite' ) ) { + $wp_rewrite = new \WP_Rewrite(); + } else { + $wp_rewrite = new class() { + public function using_permalinks(): bool { + return false; + } + }; + } + + $GLOBALS['wp_sitemaps'] = new \WP_Sitemaps(); + $GLOBALS['wp_sitemaps']->registry = new \WP_Sitemaps_Registry(); + $GLOBALS['wp_sitemaps']->renderer = new \WP_Sitemaps_Renderer(); + $GLOBALS['wp_sitemaps']->index = new \WP_Sitemaps_Index( $GLOBALS['wp_sitemaps']->registry ); + } + + private static function install_url_filters(): void { + \add_filter( 'pre_option_home', array( __CLASS__, 'filter_home_option' ), 10, 3 ); + \add_filter( 'pre_option_siteurl', array( __CLASS__, 'filter_home_option' ), 10, 3 ); + \add_filter( 'pre_option_blog_public', array( __CLASS__, 'filter_blog_public' ), 10, 3 ); + \add_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \add_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + private static function remove_url_filters(): void { + \remove_filter( 'pre_option_home', array( __CLASS__, 'filter_home_option' ), 10 ); + \remove_filter( 'pre_option_siteurl', array( __CLASS__, 'filter_home_option' ), 10 ); + \remove_filter( 'pre_option_blog_public', array( __CLASS__, 'filter_blog_public' ), 10 ); + \remove_filter( 'wp_sitemaps_stylesheet_url', '__return_false', 0 ); + \remove_filter( 'wp_sitemaps_stylesheet_index_url', '__return_false', 0 ); + } + + 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 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 skip( \ComponentFuzz\FuzzContext $ctx, string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return self::row( $ctx, $invariant, true, $data, 'skipped' ); + } + + 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 ); + } + + 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::PREVIEW_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; + } + + private static function snapshot_globals(): array { + $snapshot = array( + 'providerData' => self::$provider_data, + 'globals' => array(), + ); + + foreach ( array( 'wpdb', 'wp_rewrite', 'wp_sitemaps', 'wp_filter', 'wp_filters', 'wp_actions', 'wp_current_filter', 'wp_object_cache', 'wp_post_types', 'wp_taxonomies', 'wp_locale' ) 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_globals( array $snapshot ): void { + self::remove_url_filters(); + self::$provider_data = $snapshot['providerData']; + + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $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; + } +} diff --git a/tools/component-fuzz/surfaces/EditorHelpersSurface.php b/tools/component-fuzz/surfaces/EditorHelpersSurface.php new file mode 100644 index 0000000000000..1ea6b1ffef328 --- /dev/null +++ b/tools/component-fuzz/surfaces/EditorHelpersSurface.php @@ -0,0 +1,1803 @@ +skip( + 'editor-helpers.bootstrap-apis-available', + 'Required WordPress editor helper APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $ob_level = ob_get_level(); + $rows = array(); + + try { + self::prepare_editor_globals(); + $case = self::case_for_context( $ctx ); + + $rows[] = self::check_parse_settings( $ctx->fork( 'parse-settings' ), $case ); + $rows[] = self::check_default_editor_selection( $ctx->fork( 'default-editor' ), $case ); + $rows[] = self::check_full_editor_settings( $ctx->fork( 'full-settings' ), $case ); + $rows[] = self::check_teeny_editor_settings( $ctx->fork( 'teeny-settings' ), $case ); + $rows[] = self::check_enqueue_scripts( $ctx->fork( 'enqueue-scripts' ), $case ); + $rows[] = self::check_editor_markup( $ctx->fork( 'editor-markup' ), $case ); + $rows[] = self::check_mce_translation( $ctx->fork( 'mce-translation' ), $case ); + $rows[] = self::check_tinymce_inline_scripts( $ctx->fork( 'tinymce-inline-scripts' ), $case ); + $rows[] = self::check_link_query_and_dialog( $ctx->fork( 'link-query-dialog' ), $case ); + $rows[] = self::check_media_view_styles( $ctx->fork( 'media-view-styles' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'editor-helpers.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'editor-helpers.global-state-restored', + self::state_restored( $snapshot ), + array( + 'trackedGlobals' => array_keys( $snapshot['globals'] ), + 'trackedStatics' => array_keys( $snapshot['editorStatics'] ), + ) + ); + + return $rows; + } + + private static function missing_requirements(): array { + self::load_editor_class(); + + $missing = array(); + foreach ( array( '_WP_Editors', 'WP_Post', 'WP_Query', 'WP_Rewrite', 'WP_Scripts', 'WP_Styles' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_action', + 'add_filter', + 'add_thickbox', + 'esc_attr', + 'get_permalink', + 'get_post_type_object', + 'get_post_types', + 'has_filter', + 'mysql2date', + 'remove_action', + 'remove_filter', + 'register_post_type', + 'user_can_richedit', + 'unregister_post_type', + 'wp_cache_delete', + 'wp_cache_set', + 'wp_default_editor', + 'wp_editor', + 'wp_enqueue_script', + 'wp_nonce_field', + 'wp_enqueue_style', + 'wp_parse_url', + 'wp_print_scripts', + 'wp_print_styles', + 'wp_script_is', + 'wp_scripts', + 'wp_style_is', + 'wp_styles', + 'wp_tinymce_inline_scripts', + 'wpview_media_sandbox_styles', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function load_editor_class(): void { + if ( ! class_exists( '_WP_Editors', false ) ) { + require_once ABSPATH . WPINC . '/class-wp-editor.php'; + } + + global $tinymce_version; + if ( ! isset( $tinymce_version ) || '' === $tinymce_version ) { + require ABSPATH . WPINC . '/version.php'; + } + } + + private static function check_parse_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array(); + $rich_seen = 0; + $rich_filter = static function () use ( &$rich_seen ): bool { + ++$rich_seen; + return true; + }; + $settings_filter = static function ( array $settings, string $editor_id ) use ( &$seen, $case ): array { + $seen[] = $editor_id; + $settings['textarea_name'] = $case['textareaName']; + $settings['editor_height'] = $case['parseHeight']; + $settings['tabindex'] = $case['tabindex']; + return $settings; + }; + + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'wp_editor_settings', $settings_filter, 10, 2 ); + try { + $parsed = \_WP_Editors::parse_settings( + $case['editorId'], + array( + 'drag_drop_upload' => $case['dragDropUpload'], + 'quicktags' => $case['quicktagsInput'], + 'teeny' => false, + 'tinymce' => $case['tinymceInput'], + ) + ); + $statics = self::editor_statics(); + + self::reset_editor_statics(); + $bracket_id = $case['editorId'] . '[field]'; + \_WP_Editors::parse_settings( + $bracket_id, + array( + 'quicktags' => false, + 'tinymce' => true, + ) + ); + $bracket_statics = self::editor_statics(); + } finally { + \remove_filter( 'wp_editor_settings', $settings_filter, 10 ); + \remove_filter( 'user_can_richedit', $rich_filter ); + } + + $expected_height = empty( $case['parseHeight'] ) + ? $case['parseHeight'] + : max( 50, min( 5000, (int) $case['parseHeight'] ) ); + + self::record_if_false( + $failures, + $seen === array( $case['editorId'], $case['editorId'] . '[field]' ) + && $rich_seen >= 2, + 'wp_editor_settings and user_can_richedit filters are scoped to parse_settings calls', + array( + 'seenEditorIds' => $seen, + 'richSeen' => $rich_seen, + ) + ); + self::record_if_false( + $failures, + $case['textareaName'] === $parsed['textarea_name'] + && $expected_height === $parsed['editor_height'] + && $case['tabindex'] === $parsed['tabindex'], + 'parse_settings applies filtered settings and clamps non-empty editor heights', + array( + 'expectedHeight' => $expected_height, + 'parsed' => self::preview( $parsed ), + ) + ); + self::record_if_false( + $failures, + true === $statics['this_tinymce'] + && true === $statics['has_tinymce'] + && true === $statics['this_quicktags'] + && true === $statics['has_quicktags'], + 'parse_settings records current and aggregate TinyMCE/Quicktags state', + array( 'statics' => self::preview( $statics ) ) + ); + self::record_if_false( + $failures, + false === $bracket_statics['this_tinymce'] + && false === $bracket_statics['this_quicktags'], + 'parse_settings disables TinyMCE for editor IDs containing brackets', + array( + 'bracketId' => $bracket_id, + 'bracketStatics' => self::preview( $bracket_statics ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.parse-settings.normalization-and-state', + $failures, + array( + 'editorId' => $case['editorId'], + 'inputHeight' => $case['parseHeight'], + ) + ); + } + + private static function check_default_editor_selection( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $base_seen = array(); + $rich_filter = static function () use ( $case ): bool { + return $case['richEditing']; + }; + $default_filter = static function ( string $default ) use ( &$base_seen, $case ): string { + $base_seen[] = $default; + return $case['defaultOverride']; + }; + + unset( $GLOBALS['wp_rich_edit'] ); + \add_filter( 'user_can_richedit', $rich_filter ); + try { + $base = \wp_default_editor(); + \add_filter( 'wp_default_editor', $default_filter ); + $filtered = \wp_default_editor(); + } finally { + \remove_filter( 'wp_default_editor', $default_filter ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $expected_base = $case['richEditing'] ? 'tinymce' : 'html'; + if ( \wp_get_current_user() ) { + $user_setting = \get_user_setting( 'editor', 'tinymce' ); + $expected_base = in_array( $user_setting, array( 'tinymce', 'html', 'test' ), true ) ? $user_setting : $expected_base; + } + + self::record_if_false( + $failures, + $expected_base === $base, + 'wp_default_editor follows rich-edit capability and user-setting fallback order', + array( + 'expected' => $expected_base, + 'actual' => $base, + 'richEditing' => $case['richEditing'], + ) + ); + self::record_if_false( + $failures, + $case['defaultOverride'] === $filtered + && $base_seen === array( $expected_base ), + 'wp_default_editor filter receives the computed default and can override it', + array( + 'override' => $case['defaultOverride'], + 'filtered' => $filtered, + 'seen' => $base_seen, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.default-editor.filters-and-rich-edit', + $failures, + array( 'override' => $case['defaultOverride'] ) + ); + } + + private static function check_full_editor_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = self::install_full_editor_filters( $case ); + + try { + $first = self::generate_full_editor_js_output( $case ); + $second = self::generate_full_editor_js_output( $case ); + } finally { + self::remove_full_editor_filters( $case, $seen ); + } + + $statics = $first['statics']; + $mce = $statics['mce_settings'][ $case['editorId'] ] ?? array(); + $qt = $statics['qt_settings'][ $case['editorId'] ] ?? array(); + + self::record_if_false( + $failures, + ! $first['call']['threw'] + && ! $second['call']['threw'] + && $first['output'] === $second['output'] + && str_contains( $first['output'], 'tinyMCEPreInit' ) + && str_contains( $first['output'], $case['button1'] ) + && str_contains( $first['output'], $case['quicktagsButton'] ), + 'editor_js output is deterministic for identical generated settings', + array( + 'firstCall' => self::describe_call( $first['call'] ), + 'secondCall' => self::describe_call( $second['call'] ), + 'firstHash' => sha1( $first['output'] ), + 'secondHash' => sha1( $second['output'] ), + 'preview' => self::preview( $first['output'] ), + ) + ); + self::record_if_false( + $failures, + isset( $qt['id'], $qt['buttons'] ) + && $case['editorId'] === $qt['id'] + && str_contains( $qt['buttons'], $case['quicktagsButton'] ) + && in_array( $case['quicktagsButton'], $statics['qt_buttons'], true ), + 'quicktags_settings filter updates per-editor settings and aggregate button state', + array( + 'qt' => self::preview( $qt ), + 'qtButtons' => self::preview( $statics['qt_buttons'] ), + ) + ); + self::record_if_false( + $failures, + isset( $mce['plugins'], $mce['external_plugins'] ) + && str_contains( $mce['plugins'], $case['plugin'] ) + && ! str_contains( $mce['plugins'], 'spellchecker' ) + && self::external_plugin_registered( $mce['external_plugins'], $case ), + 'tiny_mce_plugins removes spellchecker and preserves filtered external plugin config', + array( + 'plugins' => $mce['plugins'] ?? null, + 'externalPlugins' => $mce['external_plugins'] ?? null, + ) + ); + self::record_if_false( + $failures, + isset( $mce['toolbar1'], $mce['toolbar3'], $mce['toolbar4'] ) + && str_contains( $mce['toolbar1'], $case['button1'] ) + && $case['button4'] === $mce['toolbar3'] + && '' === $mce['toolbar4'], + 'mce button filters update toolbars and toolbar4 rolls up to toolbar3 when toolbar3 is empty', + array( + 'toolbar1' => $mce['toolbar1'] ?? null, + 'toolbar3' => $mce['toolbar3'] ?? null, + 'toolbar4' => $mce['toolbar4'] ?? null, + ) + ); + self::record_if_false( + $failures, + isset( $mce['content_css'], $mce['body_class'], $mce['component_fuzz_marker'] ) + && str_contains( $mce['content_css'], $case['cssUrl'] ) + && ! str_starts_with( $mce['content_css'], ',' ) + && ! str_ends_with( $mce['content_css'], ',' ) + && str_contains( $mce['body_class'], $case['bodyClass'] ) + && $case['marker'] === $mce['component_fuzz_marker'], + 'tiny_mce_before_init and mce_css filters normalize generated TinyMCE settings', + array( + 'contentCss' => $mce['content_css'] ?? null, + 'bodyClass' => $mce['body_class'] ?? null, + 'marker' => $mce['component_fuzz_marker'] ?? null, + ) + ); + self::record_if_false( + $failures, + self::seen_counts_match( + $seen['events'], + array( + 'quicktags' => 2, + 'mce_external_plugins' => 2, + 'tiny_mce_plugins' => 2, + 'mce_buttons' => 2, + 'mce_buttons_2' => 2, + 'mce_buttons_3' => 2, + 'mce_buttons_4' => 2, + 'tiny_mce_before_init' => 2, + 'mce_css' => 2, + ) + ), + 'full editor filters fire exactly once per generated settings pass', + array( 'seen' => self::preview( self::seen_events_to_array( $seen['events'] ) ) ) + ); + + return self::row( + $ctx, + 'editor-helpers.full-settings.filters-scripts-and-state', + $failures, + array( 'editorId' => $case['editorId'] ) + ); + } + + private static function check_teeny_editor_settings( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array( + 'teeny_mce_plugins' => array(), + 'teeny_mce_buttons' => array(), + 'teeny_mce_before_init' => array(), + 'tiny_mce_plugins' => array(), + ); + + $plugins_filter = static function ( array $plugins, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_plugins'][] = $editor_id; + $plugins[] = $case['teenyPlugin']; + return $plugins; + }; + $buttons_filter = static function ( array $buttons, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_buttons'][] = $editor_id; + $buttons[] = $case['teenyButton']; + return $buttons; + }; + $before_filter = static function ( array $init, string $editor_id ) use ( &$seen, $case ): array { + $seen['teeny_mce_before_init'][] = $editor_id; + $init['component_fuzz_teeny'] = $case['marker']; + return $init; + }; + $full_filter = static function ( array $plugins, string $editor_id ) use ( &$seen ): array { + $seen['tiny_mce_plugins'][] = $editor_id; + return $plugins; + }; + $rich_filter = static fn (): bool => true; + + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'teeny_mce_plugins', $plugins_filter, 10, 2 ); + \add_filter( 'teeny_mce_buttons', $buttons_filter, 10, 2 ); + \add_filter( 'teeny_mce_before_init', $before_filter, 10, 2 ); + \add_filter( 'tiny_mce_plugins', $full_filter, 10, 2 ); + try { + $set = \_WP_Editors::parse_settings( + $case['teenyEditorId'], + array( + 'media_buttons' => false, + 'quicktags' => false, + 'teeny' => true, + 'tinymce' => true, + ) + ); + \_WP_Editors::editor_settings( $case['teenyEditorId'], $set ); + $statics = self::editor_statics(); + } finally { + \remove_filter( 'tiny_mce_plugins', $full_filter, 10 ); + \remove_filter( 'teeny_mce_before_init', $before_filter, 10 ); + \remove_filter( 'teeny_mce_buttons', $buttons_filter, 10 ); + \remove_filter( 'teeny_mce_plugins', $plugins_filter, 10 ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $mce = $statics['mce_settings'][ $case['teenyEditorId'] ] ?? array(); + + self::record_if_false( + $failures, + isset( $mce['plugins'], $mce['toolbar1'], $mce['toolbar2'], $mce['component_fuzz_teeny'] ) + && str_contains( $mce['plugins'], $case['teenyPlugin'] ) + && str_contains( $mce['toolbar1'], $case['teenyButton'] ) + && '' === $mce['toolbar2'] + && $case['marker'] === $mce['component_fuzz_teeny'], + 'teeny editor settings use teeny plugin/button/init filters and keep secondary toolbars empty', + array( 'mce' => self::preview( $mce ) ) + ); + self::record_if_false( + $failures, + $seen['teeny_mce_plugins'] === array( $case['teenyEditorId'] ) + && $seen['teeny_mce_buttons'] === array( $case['teenyEditorId'] ) + && $seen['teeny_mce_before_init'] === array( $case['teenyEditorId'] ) + && array() === $seen['tiny_mce_plugins'], + 'teeny settings do not invoke full TinyMCE plugin filter', + array( 'seen' => $seen ) + ); + + return self::row( + $ctx, + 'editor-helpers.teeny-settings.filter-branch', + $failures, + array( 'editorId' => $case['teenyEditorId'] ) + ); + } + + private static function check_enqueue_scripts( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $targeted_seen = array(); + $default_seen = array(); + $expects_wplink = 'wplink' === $case['enqueuePlugin'] || 'link' === $case['enqueueQuicktag']; + $targeted_hook = static function ( array $to_load ) use ( &$targeted_seen ): void { + $targeted_seen[] = $to_load; + }; + $default_hook = static function ( array $to_load ) use ( &$default_seen ): void { + $default_seen[] = $to_load; + }; + + self::reset_scripts_and_styles(); + self::register_editor_enqueue_handles(); + self::reset_editor_statics(); + self::set_editor_static_property( 'has_tinymce', true ); + self::set_editor_static_property( 'has_quicktags', true ); + self::set_editor_static_property( 'has_medialib', $case['enqueueMedia'] ); + self::set_editor_static_property( 'plugins', array( $case['enqueuePlugin'] ) ); + self::set_editor_static_property( 'qt_buttons', array( $case['enqueueQuicktag'] ) ); + + \add_action( 'wp_enqueue_editor', $targeted_hook ); + try { + \_WP_Editors::enqueue_scripts( false ); + $targeted_queue = self::editor_enqueue_state(); + } finally { + \remove_action( 'wp_enqueue_editor', $targeted_hook ); + } + + self::reset_scripts_and_styles(); + self::register_editor_enqueue_handles(); + self::reset_editor_statics(); + + \add_action( 'wp_enqueue_editor', $default_hook ); + try { + \_WP_Editors::enqueue_scripts( true ); + $default_queue = self::editor_enqueue_state(); + } finally { + \remove_action( 'wp_enqueue_editor', $default_hook ); + } + + self::record_if_false( + $failures, + array( + array( + 'tinymce' => true, + 'quicktags' => true, + ), + ) === $targeted_seen + && $targeted_queue['scripts']['editor'] + && $targeted_queue['scripts']['quicktags'] + && $targeted_queue['styles']['buttons'] + && $expects_wplink === $targeted_queue['scripts']['wplink'] + && $expects_wplink === $targeted_queue['scripts']['jquery-ui-autocomplete'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['media-upload'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['wp-embed'] + && $case['enqueueMedia'] === $targeted_queue['scripts']['thickbox'] + && $case['enqueueMedia'] === $targeted_queue['styles']['thickbox'], + 'enqueue_scripts loads targeted editor, link, and optional media handles from editor static state', + array( + 'seen' => $targeted_seen, + 'queue' => $targeted_queue, + 'case' => array( + 'media' => $case['enqueueMedia'], + 'plugin' => $case['enqueuePlugin'], + 'quicktag' => $case['enqueueQuicktag'], + 'wplink' => $expects_wplink, + ), + ) + ); + self::record_if_false( + $failures, + array( + array( + 'tinymce' => true, + 'quicktags' => true, + ), + ) === $default_seen + && $default_queue['scripts']['editor'] + && $default_queue['scripts']['quicktags'] + && $default_queue['styles']['buttons'] + && $default_queue['scripts']['wplink'] + && $default_queue['scripts']['jquery-ui-autocomplete'] + && $default_queue['scripts']['media-upload'] + && ! $default_queue['scripts']['wp-embed'] + && $default_queue['scripts']['thickbox'] + && ! $default_queue['styles']['thickbox'], + 'enqueue_scripts default mode loads editor/link/media-upload handles, including script dependencies, without forcing embeds or Thickbox styles', + array( + 'seen' => $default_seen, + 'queue' => $default_queue, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.enqueue-scripts.handle-selection-and-action-payload', + $failures, + array( + 'media' => $case['enqueueMedia'], + 'targeted' => $targeted_queue, + 'default' => $default_queue, + ) + ); + } + + private static function check_editor_markup( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $the_editor_seen = array(); + $content_seen = array(); + $default_seen = array(); + $rich_filter = static fn (): bool => true; + $default_filter = static function ( string $default ) use ( &$default_seen, $case ): string { + $default_seen[] = $default; + return $case['markupDefaultEditor']; + }; + $the_editor_filter = static function ( string $html ) use ( &$the_editor_seen, $case ): string { + $the_editor_seen[] = str_contains( $html, '%s' ); + return str_replace( + 'class="wp-editor-container"', + 'class="wp-editor-container" data-component-fuzz="' . \esc_attr( $case['marker'] ) . '"', + $html + ); + }; + $content_filter = static function ( string $content, string $default_editor ) use ( &$content_seen ): string { + $content_seen[] = $default_editor; + return $content; + }; + $format_filter_was = \has_filter( 'the_editor_content', 'format_for_editor' ); + + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + self::set_editor_static_property( 'editor_buttons_css', false ); + unset( $GLOBALS['wp_rich_edit'] ); + + \add_filter( 'user_can_richedit', $rich_filter ); + \add_filter( 'wp_default_editor', $default_filter ); + \add_filter( 'the_editor', $the_editor_filter ); + \add_filter( 'the_editor_content', $content_filter, 20, 2 ); + try { + $call = self::capture_output( + static function () use ( $case ): void { + \wp_editor( + $case['dangerousContent'], + $case['markupEditorId'], + array( + 'editor_class' => $case['editorClass'], + 'editor_height' => $case['markupHeight'], + 'media_buttons' => false, + 'quicktags' => true, + 'textarea_name' => $case['textareaName'], + 'textarea_rows' => 4, + 'tinymce' => array( + 'wp_skip_init' => true, + ), + ) + ); + } + ); + $statics = self::editor_statics(); + } finally { + \remove_filter( 'the_editor_content', $content_filter, 20 ); + \remove_filter( 'the_editor', $the_editor_filter ); + \remove_filter( 'wp_default_editor', $default_filter ); + \remove_filter( 'user_can_richedit', $rich_filter ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + $output = $call['output']; + $expected_wrap_class = 'html' === $case['markupDefaultEditor'] ? 'html-active' : 'tmce-active'; + $escaped_name = \esc_attr( $case['textareaName'] ); + $escaped_id = \esc_attr( $case['markupEditorId'] ); + + self::record_if_false( + $failures, + ! $call['threw'] + && str_contains( $output, 'id="' . $escaped_id . '"' ) + && str_contains( $output, 'name="' . $escaped_name . '"' ) + && str_contains( $output, $expected_wrap_class ) + && str_contains( $output, 'data-component-fuzz="' . \esc_attr( $case['marker'] ) . '"' ), + 'wp_editor emits captured markup with escaped editor attributes and expected active tab', + array( + 'call' => self::describe_call( $call ), + 'expectedWrapClass' => $expected_wrap_class, + 'preview' => self::preview( $output ), + ) + ); + self::record_if_false( + $failures, + str_contains( $output, '</textarea' ) + && ! str_contains( $output, ' self::preview( $output ) ) + ); + self::record_if_false( + $failures, + $the_editor_seen === array( true ) + && $content_seen === array( $case['markupDefaultEditor'] ) + && $default_seen === array( 'tinymce' ), + 'the_editor, the_editor_content, and wp_default_editor filters are local and contextual', + array( + 'theEditorSeen' => $the_editor_seen, + 'contentSeen' => $content_seen, + 'defaultSeen' => $default_seen, + ) + ); + self::record_if_false( + $failures, + isset( $statics['mce_settings'][ $case['markupEditorId'] ], $statics['qt_settings'][ $case['markupEditorId'] ] ) + && \has_filter( 'the_editor_content', 'format_for_editor' ) === $format_filter_was, + 'wp_editor records per-editor settings and removes its temporary content filter', + array( + 'hasMce' => isset( $statics['mce_settings'][ $case['markupEditorId'] ] ), + 'hasQuicktags' => isset( $statics['qt_settings'][ $case['markupEditorId'] ] ), + 'formatFilterBefore' => $format_filter_was, + 'formatFilterAfter' => \has_filter( 'the_editor_content', 'format_for_editor' ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.editor-markup.output-buffer-escaping-and-filters', + $failures, + array( 'editorId' => $case['markupEditorId'] ) + ); + } + + private static function check_mce_translation( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $seen = array(); + $filter = static function ( array $translations, string $locale ) use ( &$seen, $case ): array { + $seen[] = $locale; + $translations['Component Fuzz Same'] = 'Component Fuzz Same'; + $translations['Component Fuzz Entity'] = 'Ampersand & Entity'; + $translations['Component Fuzz Marker'] = $case['translationMarker']; + return $translations; + }; + + self::reset_editor_statics(); + \add_filter( 'wp_mce_translation', $filter, 10, 2 ); + try { + $json_one = \_WP_Editors::wp_mce_translation( 'en', true ); + $json_two = \_WP_Editors::wp_mce_translation( 'en', true ); + $script = \_WP_Editors::wp_mce_translation( 'en', false ); + } finally { + \remove_filter( 'wp_mce_translation', $filter, 10 ); + } + + $decoded = json_decode( $json_one, true ); + + self::record_if_false( + $failures, + is_array( $decoded ) + && $json_one === $json_two + && ! isset( $decoded['Component Fuzz Same'] ) + && 'Ampersand & Entity' === ( $decoded['Component Fuzz Entity'] ?? null ) + && $case['translationMarker'] === ( $decoded['Component Fuzz Marker'] ?? null ), + 'wp_mce_translation JSON output is deterministic, filtered, and entity-normalized', + array( + 'seen' => $seen, + 'jsonPreview' => self::preview( $json_one ), + 'jsonError' => json_last_error_msg(), + ) + ); + self::record_if_false( + $failures, + str_contains( $script, "tinymce.addI18n( 'en', " ) + && str_contains( $script, '/langs/en.js' ) + && ! str_contains( $script, ' self::preview( $script ) ) + ); + + return self::row( + $ctx, + 'editor-helpers.mce-translation.json-and-script-snippet', + $failures, + array( 'marker' => $case['translationMarker'] ) + ); + } + + private static function check_tinymce_inline_scripts( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $events = array( + 'editor_settings' => array(), + 'tiny_mce_plugins' => array(), + 'disable_captions' => 0, + 'mce_buttons' => array(), + 'mce_buttons_2' => array(), + 'mce_buttons_3' => array(), + 'mce_buttons_4' => array(), + 'mce_external_plugins' => array(), + 'tiny_mce_before_init' => array(), + ); + $inline_plugin = 'cf_inline_plugin_' . self::safe_key( $case['marker'], 'plugin' ); + $inline_button = 'cf_inline_button_' . self::safe_key( $case['button1'], 'button' ); + $inline_button_two = 'cf_inline_button2_' . self::safe_key( $case['button2'], 'button2' ); + $inline_button_three = 'cf_inline_button3_' . self::safe_key( $case['button4'], 'button3' ); + $inline_button_four = 'cf_inline_button4_' . self::safe_key( $case['teenyButton'], 'button4' ); + $external_plugin = 'cf_inline_external_' . self::safe_key( $case['externalPlugin'], 'external' ); + $external_url = 'https://example.test/classic-block/' . rawurlencode( $case['marker'] ) . '/plugin.js'; + $plain_setting = 'plain-' . self::safe_key( $case['marker'], 'plain' ); + $json_setting = '{"marker":"' . self::safe_key( $case['marker'], 'json' ) . '"}'; + $array_setting = '["' . self::safe_key( $case['marker'], 'array' ) . '"]'; + $function_setting = 'function () { return "' . self::safe_key( $case['marker'], 'fn' ) . '"; }'; + + $settings_filter = static function ( array $settings, string $editor_id ) use ( &$events, $plain_setting, $json_setting, $array_setting, $function_setting ): array { + $events['editor_settings'][] = array( + 'editorId' => $editor_id, + 'input' => $settings, + ); + $settings['tinymce'] = array( + 'component_plain' => $plain_setting, + 'component_json' => $json_setting, + 'component_array' => $array_setting, + 'component_function' => $function_setting, + 'wp_autoresize_on' => true, + ); + return $settings; + }; + $plugins_filter = static function ( array $plugins, string $editor_id ) use ( &$events, $inline_plugin ): array { + $events['tiny_mce_plugins'][] = $editor_id; + $plugins[] = $inline_plugin; + $plugins[] = $inline_plugin; + return $plugins; + }; + $disable_filter = static function () use ( &$events ): bool { + ++$events['disable_captions']; + return true; + }; + $buttons_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button ): array { + $events['mce_buttons'][] = $editor_id; + $buttons[] = $inline_button; + return $buttons; + }; + $buttons_two_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_two ): array { + $events['mce_buttons_2'][] = $editor_id; + $buttons[] = $inline_button_two; + return $buttons; + }; + $buttons_three_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_three ): array { + $events['mce_buttons_3'][] = $editor_id; + $buttons[] = $inline_button_three; + return $buttons; + }; + $buttons_four_filter = static function ( array $buttons, string $editor_id ) use ( &$events, $inline_button_four ): array { + $events['mce_buttons_4'][] = $editor_id; + $buttons[] = $inline_button_four; + return $buttons; + }; + $external_filter = static function ( array $plugins, string $editor_id ) use ( &$events, $external_plugin, $external_url ): array { + $events['mce_external_plugins'][] = $editor_id; + $plugins[ $external_plugin ] = $external_url; + return $plugins; + }; + $before_filter = static function ( array $settings, string $editor_id ) use ( &$events, $case ): array { + $events['tiny_mce_before_init'][] = array( + 'editorId' => $editor_id, + 'settings' => $settings, + ); + $settings['component_before_marker'] = $case['marker']; + return $settings; + }; + + self::reset_scripts_and_styles(); + \wp_scripts()->add( 'wp-block-library', false ); + + \add_filter( 'wp_editor_settings', $settings_filter, 10, 2 ); + \add_filter( 'tiny_mce_plugins', $plugins_filter, 10, 2 ); + \add_filter( 'disable_captions', $disable_filter ); + \add_filter( 'mce_buttons', $buttons_filter, 10, 2 ); + \add_filter( 'mce_buttons_2', $buttons_two_filter, 10, 2 ); + \add_filter( 'mce_buttons_3', $buttons_three_filter, 10, 2 ); + \add_filter( 'mce_buttons_4', $buttons_four_filter, 10, 2 ); + \add_filter( 'mce_external_plugins', $external_filter, 10, 2 ); + \add_filter( 'tiny_mce_before_init', $before_filter, 10, 2 ); + try { + \wp_tinymce_inline_scripts(); + $before_data = \wp_scripts()->get_data( 'wp-block-library', 'before' ); + } finally { + \remove_filter( 'tiny_mce_before_init', $before_filter, 10 ); + \remove_filter( 'mce_external_plugins', $external_filter, 10 ); + \remove_filter( 'mce_buttons_4', $buttons_four_filter, 10 ); + \remove_filter( 'mce_buttons_3', $buttons_three_filter, 10 ); + \remove_filter( 'mce_buttons_2', $buttons_two_filter, 10 ); + \remove_filter( 'mce_buttons', $buttons_filter, 10 ); + \remove_filter( 'disable_captions', $disable_filter ); + \remove_filter( 'tiny_mce_plugins', $plugins_filter, 10 ); + \remove_filter( 'wp_editor_settings', $settings_filter, 10 ); + } + + $inline_scripts = is_array( $before_data ) ? array_values( array_filter( $before_data, 'is_string' ) ) : array(); + $script = implode( "\n", $inline_scripts ); + $captured = $events['tiny_mce_before_init'][0]['settings'] ?? array(); + + self::record_if_false( + $failures, + ( $events['editor_settings'][0]['editorId'] ?? null ) === 'classic-block' + && array( 'tinymce' => true ) === ( $events['editor_settings'][0]['input'] ?? null ) + && $events['tiny_mce_plugins'] === array( 'classic-block' ) + && 1 === $events['disable_captions'] + && $events['mce_buttons'] === array( 'classic-block' ) + && $events['mce_buttons_2'] === array( 'classic-block' ) + && $events['mce_buttons_3'] === array( 'classic-block' ) + && $events['mce_buttons_4'] === array( 'classic-block' ) + && $events['mce_external_plugins'] === array( 'classic-block' ) + && array( 'classic-block' ) === array_column( $events['tiny_mce_before_init'], 'editorId' ), + 'wp_tinymce_inline_scripts applies classic-block editor filters exactly once and in the expected branch', + array( 'events' => self::preview( $events ) ) + ); + self::record_if_false( + $failures, + isset( $captured['plugins'], $captured['toolbar1'], $captured['toolbar2'], $captured['toolbar3'], $captured['toolbar4'], $captured['external_plugins'] ) + && 1 === substr_count( ',' . $captured['plugins'] . ',', ',' . $inline_plugin . ',' ) + && str_contains( $captured['toolbar1'], $inline_button ) + && str_contains( $captured['toolbar2'], $inline_button_two ) + && str_contains( $captured['toolbar3'], $inline_button_three ) + && str_contains( $captured['toolbar4'], $inline_button_four ) + && true === ( $captured['classic_block_editor'] ?? null ) + && true === ( $captured['wpeditimage_disable_captions'] ?? null ) + && true === ( $captured['wp_autoresize_on'] ?? null ) + && $plain_setting === ( $captured['component_plain'] ?? null ) + && $json_setting === ( $captured['component_json'] ?? null ) + && $array_setting === ( $captured['component_array'] ?? null ) + && $function_setting === ( $captured['component_function'] ?? null ) + && $external_url === ( json_decode( $captured['external_plugins'] ?? '[]', true )[ $external_plugin ] ?? null ), + 'TinyMCE inline settings merge generated editor/plugin/button/caption/external-plugin values before serialization', + array( 'captured' => self::preview( $captured ) ) + ); + self::record_if_false( + $failures, + 1 === count( $inline_scripts ) + && str_contains( $script, 'window.wpEditorL10n' ) + && str_contains( $script, 'baseURL: "http://example.test/wp-includes/js/tinymce"' ) + && str_contains( $script, 'component_plain:"' . $plain_setting . '"' ) + && str_contains( $script, 'component_json:' . $json_setting ) + && str_contains( $script, 'component_array:' . $array_setting ) + && str_contains( $script, 'component_function:' . $function_setting ) + && str_contains( $script, 'wp_autoresize_on:true' ) + && str_contains( $script, 'wpeditimage_disable_captions:true' ) + && str_contains( $script, 'component_before_marker:"' . $case['marker'] . '"' ) + && ! str_contains( $script, ' count( $inline_scripts ), + 'preview' => self::preview( $script ), + ) + ); + self::record_if_false( + $failures, + false === \has_filter( 'wp_editor_settings', $settings_filter ) + && false === \has_filter( 'tiny_mce_plugins', $plugins_filter ) + && false === \has_filter( 'disable_captions', $disable_filter ) + && false === \has_filter( 'mce_buttons', $buttons_filter ) + && false === \has_filter( 'mce_buttons_2', $buttons_two_filter ) + && false === \has_filter( 'mce_buttons_3', $buttons_three_filter ) + && false === \has_filter( 'mce_buttons_4', $buttons_four_filter ) + && false === \has_filter( 'mce_external_plugins', $external_filter ) + && false === \has_filter( 'tiny_mce_before_init', $before_filter ), + 'wp_tinymce_inline_scripts coverage removes all temporary classic-block filters', + array( 'marker' => $case['marker'] ) + ); + + return self::row( + $ctx, + 'editor-helpers.tinymce-inline-scripts.classic-block-filter-merge', + $failures, + array( + 'marker' => $case['marker'], + 'scriptHash' => sha1( $script ), + ) + ); + } + + private static function check_link_query_and_dialog( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $failures = array(); + $marker_key = 'cf_link_' . self::safe_key( $case['marker'], 'link' ); + $post_id = 700000 + ( $ctx->seed() % 10000 ); + $cpt_id = $post_id + 1; + $cpt = 'cf_link_' . self::safe_key( strtolower( substr( $case['linkTitleToken'], -5 ) ), 'cpt' ); + $cpt_label = 'Component Link ' . strtoupper( substr( $cpt, -3 ) ); + $post = self::link_query_post( + $post_id, + 'post', + 'editor-link-post-' . self::safe_key( $case['marker'], 'post' ), + ' Link Query ' . $case['linkTitleToken'] . ' & More ', + '2026-06-' . str_pad( (string) $ctx->int( 1, 28 ), 2, '0', STR_PAD_LEFT ) . ' 13:24:00' + ); + $cpt_post = self::link_query_post( + $cpt_id, + $cpt, + 'editor-link-cpt-' . self::safe_key( $case['marker'], 'cpt' ), + 'Custom Link ' . $case['linkTitleToken'] . ' & Other', + '2026-05-15 08:10:00' + ); + $fixtures = array( $post, $cpt_post ); + $events = array( + 'args' => array(), + 'queries' => array(), + 'results' => array(), + ); + $post_type_names = array(); + $expected_post_permalink = ''; + $expected_cpt_permalink = ''; + $local_globals = self::snapshot_globals( array( 'wp_rewrite' ) ); + + $args_filter = static function ( array $query ) use ( &$events, $marker_key ): array { + $events['args'][] = $query; + $query['component_fuzz_link_query_marker'] = $marker_key; + $query['cache_results'] = false; + return $query; + }; + $posts_filter = static function ( $posts, \WP_Query $query ) use ( &$events, $fixtures, $case, $marker_key ) { + if ( ( $query->query_vars['component_fuzz_link_query_marker'] ?? null ) !== $marker_key ) { + return $posts; + } + + $events['queries'][] = array( + 'post_type' => $query->query_vars['post_type'] ?? null, + 's' => $query->query_vars['s'] ?? null, + 'offset' => $query->query_vars['offset'] ?? null, + 'posts_per_page' => $query->query_vars['posts_per_page'] ?? null, + 'post_status' => $query->query_vars['post_status'] ?? null, + 'suppress_filters' => $query->query_vars['suppress_filters'] ?? null, + 'cache_results' => $query->query_vars['cache_results'] ?? null, + 'term' => $query->query_vars['s'] ?? null, + ); + + if ( ( $query->query_vars['s'] ?? '' ) === $case['linkNoMatchSearch'] ) { + $query->found_posts = 0; + $query->max_num_pages = 0; + return array(); + } + + $query->found_posts = count( $fixtures ); + $query->max_num_pages = 1; + return $fixtures; + }; + $results_filter = static function ( array $results, array $query ) use ( &$events ): array { + $events['results'][] = array( + 'query' => $query, + 'results' => $results, + ); + return $results; + }; + + try { + if ( ! isset( $GLOBALS['wp_rewrite'] ) || ! is_object( $GLOBALS['wp_rewrite'] ) ) { + $GLOBALS['wp_rewrite'] = new \WP_Rewrite(); + } + + \register_post_type( + $cpt, + array( + 'public' => true, + 'labels' => array( + 'name' => $cpt_label . 's', + 'singular_name' => $cpt_label, + ), + ) + ); + + foreach ( $fixtures as $fixture ) { + \wp_cache_set( $fixture->ID, $fixture, 'posts' ); + } + $post_type_names = array_keys( \get_post_types( array( 'public' => true ), 'objects' ) ); + $expected_post_permalink = \get_permalink( $post->ID ); + $expected_cpt_permalink = \get_permalink( $cpt_post->ID ); + + \add_filter( 'wp_link_query_args', $args_filter, 10, 1 ); + \add_filter( 'posts_pre_query', $posts_filter, 10, 2 ); + \add_filter( 'wp_link_query', $results_filter, 10, 2 ); + $results = \_WP_Editors::wp_link_query( + array( + 'pagenum' => $case['linkPageNumber'], + 's' => $case['linkSearch'], + ) + ); + $empty_results = \_WP_Editors::wp_link_query( + array( + 'pagenum' => 0, + 's' => $case['linkNoMatchSearch'], + ) + ); + } finally { + \remove_filter( 'wp_link_query', $results_filter, 10 ); + \remove_filter( 'posts_pre_query', $posts_filter, 10 ); + \remove_filter( 'wp_link_query_args', $args_filter, 10 ); + + foreach ( $fixtures as $fixture ) { + \wp_cache_delete( $fixture->ID, 'posts' ); + } + if ( isset( $GLOBALS['wp'] ) && is_object( $GLOBALS['wp'] ) ) { + \unregister_post_type( $cpt ); + } else { + unset( $GLOBALS['wp_post_types'][ $cpt ] ); + \remove_action( 'future_' . $cpt, '_future_post_hook', 5 ); + } + self::restore_globals( $local_globals ); + } + + $expected_post_title = 'Link Query ' . $case['linkTitleToken'] . ' & Morebad'; + $expected_cpt_title = 'Custom Link ' . $case['linkTitleToken'] . ' & Other'; + $expected_offset = 20 * ( $case['linkPageNumber'] - 1 ); + $first_result = is_array( $results ) ? ( $results[0] ?? array() ) : array(); + $second_result = is_array( $results ) ? ( $results[1] ?? array() ) : array(); + + self::record_if_false( + $failures, + is_array( $results ) + && 2 === count( $results ) + && $post->ID === ( $first_result['ID'] ?? null ) + && $expected_post_title === ( $first_result['title'] ?? null ) + && $expected_post_permalink === ( $first_result['permalink'] ?? null ) + && \mysql2date( __( 'Y/m/d' ), $post->post_date ) === ( $first_result['info'] ?? null ) + && $cpt_post->ID === ( $second_result['ID'] ?? null ) + && $expected_cpt_title === ( $second_result['title'] ?? null ) + && $expected_cpt_permalink === ( $second_result['permalink'] ?? null ) + && $cpt_label === ( $second_result['info'] ?? null ) + && false === $empty_results + && ! str_contains( strtolower( wp_json_encode( $results ) ?: '' ), ' $results, + 'emptyResults' => $empty_results, + 'expected' => array( + 'postTitle' => $expected_post_title, + 'cptTitle' => $expected_cpt_title, + 'cptLabel' => $cpt_label, + ), + ) + ); + self::record_if_false( + $failures, + 2 === count( $events['args'] ) + && 2 === count( $events['queries'] ) + && 2 === count( $events['results'] ) + && $case['linkSearch'] === ( $events['args'][0]['s'] ?? null ) + && $case['linkNoMatchSearch'] === ( $events['args'][1]['s'] ?? null ) + && $expected_offset === ( $events['args'][0]['offset'] ?? null ) + && 0 === ( $events['args'][1]['offset'] ?? null ) + && 20 === ( $events['args'][0]['posts_per_page'] ?? null ) + && true === ( $events['args'][0]['suppress_filters'] ?? null ) + && self::same_string_set( $events['args'][0]['post_type'] ?? array(), $post_type_names ) + && $case['linkSearch'] === ( $events['queries'][0]['s'] ?? null ) + && $expected_offset === ( $events['queries'][0]['offset'] ?? null ) + && 20 === ( $events['queries'][0]['posts_per_page'] ?? null ) + && 'publish' === ( $events['queries'][0]['post_status'] ?? null ) + && true === ( $events['queries'][0]['suppress_filters'] ?? null ) + && false === ( $events['queries'][0]['cache_results'] ?? null ) + && $results === ( $events['results'][0]['results'] ?? null ) + && array() === ( $events['results'][1]['results'] ?? null ), + 'wp_link_query exposes deterministic query-argument, WP_Query, and result-filter payloads for generated searches', + array( + 'events' => $events, + 'postTypeNames' => $post_type_names, + 'expectedOffset' => $expected_offset, + ) + ); + self::record_if_false( + $failures, + false === \has_filter( 'wp_link_query_args', $args_filter ) + && false === \has_filter( 'posts_pre_query', $posts_filter ) + && false === \has_filter( 'wp_link_query', $results_filter ) + && false === \has_filter( 'future_' . $cpt, '_future_post_hook' ), + 'wp_link_query coverage removes temporary query/result filters and generated post-type hooks', + array( + 'argsFilter' => \has_filter( 'wp_link_query_args', $args_filter ), + 'postsFilter' => \has_filter( 'posts_pre_query', $posts_filter ), + 'resultsFilter' => \has_filter( 'wp_link_query', $results_filter ), + 'futureHook' => \has_filter( 'future_' . $cpt, '_future_post_hook' ), + ) + ); + + self::reset_editor_statics(); + $first_dialog = self::capture_output( + static function (): void { + \_WP_Editors::wp_link_dialog(); + } + ); + $second_dialog = self::capture_output( + static function (): void { + \_WP_Editors::wp_link_dialog(); + } + ); + $dialog_output = $first_dialog['output'] ?? ''; + self::record_if_false( + $failures, + empty( $first_dialog['threw'] ) + && empty( $second_dialog['threw'] ) + && '' === ( $second_dialog['output'] ?? '' ) + && str_contains( $dialog_output, 'id="wp-link-backdrop"' ) + && str_contains( $dialog_output, 'id="wp-link-wrap"' ) + && str_contains( $dialog_output, 'role="dialog"' ) + && str_contains( $dialog_output, 'aria-modal="true"' ) + && str_contains( $dialog_output, 'id="wp-link-url"' ) + && str_contains( $dialog_output, 'id="wp-link-search"' ) + && str_contains( $dialog_output, 'name="_ajax_linking_nonce"' ) + && str_contains( $dialog_output, 'id="search-results"' ) + && str_contains( $dialog_output, 'id="most-recent-results"' ) + && str_contains( $dialog_output, 'id="wp-link-submit"' ) + && true === self::get_editor_static_property( 'link_dialog_printed' ) + && ! str_contains( strtolower( $dialog_output ), ' self::describe_call( $first_dialog ), + 'second' => self::describe_call( $second_dialog ), + 'output' => self::preview( $dialog_output ), + ) + ); + + return self::row( + $ctx, + 'editor-helpers.link-query-dialog.query-results-and-single-print-markup', + $failures, + array( + 'search' => $case['linkSearch'], + 'pageNumber' => $case['linkPageNumber'], + 'resultCount' => is_array( $results ) ? count( $results ) : 0, + 'dialogHash' => sha1( $dialog_output ), + ) + ); + } + + private static function check_media_view_styles( \ComponentFuzz\FuzzContext $ctx ): array { + $styles = \wpview_media_sandbox_styles(); + $failures = array(); + $hosts = array(); + + foreach ( $styles as $style ) { + $parts = \wp_parse_url( $style ); + if ( isset( $parts['host'] ) ) { + $hosts[] = $parts['host']; + } + } + + self::record_if_false( + $failures, + 2 === count( $styles ) + && str_contains( $styles[0], 'mediaelementplayer-legacy.min.css' ) + && str_contains( $styles[1], 'wp-mediaelement.css' ) + && str_contains( $styles[0], 'ver=' ) + && str_contains( $styles[1], 'ver=' ), + 'wpview_media_sandbox_styles returns deterministic versioned media-view stylesheet URLs', + array( + 'styles' => $styles, + 'hosts' => $hosts, + ) + ); + + return self::row( + $ctx, + 'editor-helpers.media-view.stylesheet-urls', + $failures, + array( 'styles' => $styles ) + ); + } + + private static function generate_full_editor_js_output( array $case ): array { + self::reset_scripts_and_styles(); + self::reset_editor_statics(); + unset( $GLOBALS['wp_rich_edit'] ); + + $set = \_WP_Editors::parse_settings( + $case['editorId'], + array( + '_content_editor_dfw' => $case['dfw'], + 'media_buttons' => false, + 'quicktags' => array( + 'buttons' => 'strong,em,link', + ), + 'tabfocus_elements' => ':prev,:next', + 'teeny' => false, + 'tinymce' => array( + 'body_class' => $case['bodyClass'], + 'wp_skip_init' => true, + ), + 'wpautop' => false, + ) + ); + \_WP_Editors::editor_settings( $case['editorId'], $set ); + $statics = self::editor_statics(); + $call = self::capture_output( + static function (): void { + \_WP_Editors::editor_js(); + } + ); + + return array( + 'call' => $call, + 'output' => $call['output'], + 'statics' => $statics, + ); + } + + private static function install_full_editor_filters( array $case ): array { + $events = (object) array( + 'quicktags' => array(), + 'mce_external_plugins' => array(), + 'tiny_mce_plugins' => array(), + 'mce_buttons' => array(), + 'mce_buttons_2' => array(), + 'mce_buttons_3' => array(), + 'mce_buttons_4' => array(), + 'tiny_mce_before_init' => array(), + 'mce_css' => array(), + ); + $seen = array( 'events' => $events ); + + $seen['user_can_richedit'] = static fn (): bool => true; + $seen['quicktags_filter'] = static function ( array $qt_init, string $editor_id ) use ( $events, $case ): array { + $events->quicktags[] = $editor_id; + $qt_init['buttons'] .= ',' . $case['quicktagsButton']; + $qt_init['componentId'] = $case['marker']; + return $qt_init; + }; + $seen['external_filter'] = static function ( array $plugins, string $editor_id ) use ( $events, $case ): array { + $events->mce_external_plugins[] = $editor_id; + $plugins[ $case['externalPlugin'] ] = $case['externalUrl']; + return $plugins; + }; + $seen['plugins_filter'] = static function ( array $plugins, string $editor_id ) use ( $events, $case ): array { + $events->tiny_mce_plugins[] = $editor_id; + $plugins[] = 'spellchecker'; + $plugins[] = $case['plugin']; + return $plugins; + }; + $seen['buttons_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + $events->mce_buttons[] = $editor_id; + $buttons[] = $case['button1']; + return $buttons; + }; + $seen['buttons_2_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + $events->mce_buttons_2[] = $editor_id; + $buttons[] = $case['button2']; + return $buttons; + }; + $seen['buttons_3_filter'] = static function ( array $buttons, string $editor_id ) use ( $events ): array { + unset( $buttons ); + $events->mce_buttons_3[] = $editor_id; + return array(); + }; + $seen['buttons_4_filter'] = static function ( array $buttons, string $editor_id ) use ( $events, $case ): array { + unset( $buttons ); + $events->mce_buttons_4[] = $editor_id; + return array( $case['button4'] ); + }; + $seen['before_filter'] = static function ( array $init, string $editor_id ) use ( $events, $case ): array { + $events->tiny_mce_before_init[] = $editor_id; + $init['component_fuzz_marker'] = $case['marker']; + return $init; + }; + $seen['css_filter'] = static function ( string $css ) use ( $events, $case ): string { + $events->mce_css[] = $css; + return $css . ',' . $case['cssUrl']; + }; + + \add_filter( 'user_can_richedit', $seen['user_can_richedit'] ); + \add_filter( 'quicktags_settings', $seen['quicktags_filter'], 10, 2 ); + \add_filter( 'mce_external_plugins', $seen['external_filter'], 10, 2 ); + \add_filter( 'tiny_mce_plugins', $seen['plugins_filter'], 10, 2 ); + \add_filter( 'mce_buttons', $seen['buttons_filter'], 10, 2 ); + \add_filter( 'mce_buttons_2', $seen['buttons_2_filter'], 10, 2 ); + \add_filter( 'mce_buttons_3', $seen['buttons_3_filter'], 10, 2 ); + \add_filter( 'mce_buttons_4', $seen['buttons_4_filter'], 10, 2 ); + \add_filter( 'tiny_mce_before_init', $seen['before_filter'], 10, 2 ); + \add_filter( 'mce_css', $seen['css_filter'] ); + + return $seen; + } + + private static function remove_full_editor_filters( array $case, array $seen ): void { + unset( $case ); + \remove_filter( 'mce_css', $seen['css_filter'] ); + \remove_filter( 'tiny_mce_before_init', $seen['before_filter'], 10 ); + \remove_filter( 'mce_buttons_4', $seen['buttons_4_filter'], 10 ); + \remove_filter( 'mce_buttons_3', $seen['buttons_3_filter'], 10 ); + \remove_filter( 'mce_buttons_2', $seen['buttons_2_filter'], 10 ); + \remove_filter( 'mce_buttons', $seen['buttons_filter'], 10 ); + \remove_filter( 'tiny_mce_plugins', $seen['plugins_filter'], 10 ); + \remove_filter( 'mce_external_plugins', $seen['external_filter'], 10 ); + \remove_filter( 'quicktags_settings', $seen['quicktags_filter'], 10 ); + \remove_filter( 'user_can_richedit', $seen['user_can_richedit'] ); + unset( $GLOBALS['wp_rich_edit'] ); + } + + private static function external_plugin_registered( string $json, array $case ): bool { + $decoded = json_decode( $json, true ); + $expected_url = function_exists( 'set_url_scheme' ) ? \set_url_scheme( $case['externalUrl'] ) : $case['externalUrl']; + + return is_array( $decoded ) + && isset( $decoded[ $case['externalPlugin'] ] ) + && $expected_url === $decoded[ $case['externalPlugin'] ]; + } + + private static function seen_counts_match( $seen, array $expected ): bool { + $seen = is_object( $seen ) ? get_object_vars( $seen ) : $seen; + foreach ( $expected as $key => $count ) { + if ( ! isset( $seen[ $key ] ) || count( $seen[ $key ] ) !== $count ) { + return false; + } + } + return true; + } + + private static function seen_events_to_array( object $events ): array { + return get_object_vars( $events ); + } + + private static function link_query_post( int $id, string $post_type, string $post_name, string $post_title, string $post_date ): \WP_Post { + return new \WP_Post( + (object) array( + 'ID' => $id, + 'post_author' => 1, + 'post_date' => $post_date, + 'post_date_gmt' => $post_date, + 'post_content' => '', + 'post_title' => $post_title, + 'post_excerpt' => '', + 'post_status' => 'publish', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => $post_name, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => $post_date, + 'post_modified_gmt' => $post_date, + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => 'http://example.test/?p=' . $id, + 'menu_order' => 0, + 'post_type' => $post_type, + 'post_mime_type' => '', + 'comment_count' => 0, + 'filter' => 'raw', + ) + ); + } + + private static function same_string_set( $actual, array $expected ): bool { + if ( ! is_array( $actual ) ) { + return false; + } + + $actual = array_values( array_map( 'strval', $actual ) ); + $expected = array_values( array_map( 'strval', $expected ) ); + sort( $actual ); + sort( $expected ); + + return $actual === $expected; + } + + private static function case_for_context( \ComponentFuzz\FuzzContext $ctx ): array { + $slug = self::safe_key( strtolower( $ctx->identifier( 4, 10 ) ), 'case' ); + + return array( + 'bodyClass' => 'cf-body-' . $slug, + 'button1' => 'cf_btn_' . $slug, + 'button2' => 'cf_btn2_' . $slug, + 'button4' => 'cf_btn4_' . $slug, + 'cssUrl' => 'https://example.test/editor-' . $slug . '.css?ver=1%2C2', + 'dangerousContent' => "Lead {$slug}\nTail", + 'defaultOverride' => $ctx->choice( array( 'tinymce', 'html', 'test' ) ), + 'dfw' => $ctx->bool(), + 'dragDropUpload' => $ctx->bool(), + 'enqueueMedia' => $ctx->bool(), + 'enqueuePlugin' => $ctx->choice( array( 'wplink', 'lists' ) ), + 'enqueueQuicktag' => $ctx->choice( array( 'link', 'strong' ) ), + 'editorClass' => 'cf-class-' . $slug . ' quoted', + 'editorId' => 'cf_editor_' . $slug, + 'externalPlugin' => 'cf_external_' . $slug, + 'externalUrl' => 'https://example.test/plugins/' . $slug . '/plugin.js', + 'linkNoMatchSearch' => 'missing-' . $slug . ' ', + 'linkPageNumber' => $ctx->int( 2, 5 ), + 'linkSearch' => 'needle ' . $slug . ' & ', + 'linkTitleToken' => 'Token ' . strtoupper( substr( $slug, 0, 5 ) ), + 'markupDefaultEditor' => $ctx->choice( array( 'tinymce', 'html' ) ), + 'markupEditorId' => 'cf_markup_' . $slug, + 'markupHeight' => $ctx->int( 50, 420 ), + 'marker' => 'cf-marker-' . $slug, + 'parseHeight' => $ctx->choice( array( -20, 0, 49, 50, 51, 4999, 5000, 5001, 6400 ) ), + 'plugin' => 'cf_plugin_' . $slug, + 'quicktagsButton' => 'cf_qt_' . $slug, + 'quicktagsInput' => array( 'buttons' => 'strong,em,link' ), + 'richEditing' => $ctx->bool(), + 'tabindex' => (string) $ctx->int( 1, 20 ), + 'teenyButton' => 'cf_teeny_btn_' . $slug, + 'teenyEditorId' => 'cf_teeny_' . $slug, + 'teenyPlugin' => 'cf_teeny_plugin_' . $slug, + 'textareaName' => 'component_fuzz[' . $slug . ']', + 'tinymceInput' => array( 'body_class' => 'parse-body-' . $slug ), + 'translationMarker' => 'Translated marker ' . $slug, + ); + } + + private static function prepare_editor_globals(): void { + self::load_editor_class(); + if ( ! isset( $_SERVER['SERVER_NAME'] ) ) { + $_SERVER['SERVER_NAME'] = 'example.test'; + } + } + + private static function reset_scripts_and_styles(): void { + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + $GLOBALS['wp_styles'] = new \WP_Styles(); + } + + private static function reset_editor_statics(): void { + foreach ( self::editor_static_defaults() as $property => $value ) { + self::set_editor_static_property( $property, $value ); + } + } + + private static function editor_static_defaults(): array { + return array( + 'mce_locale' => null, + 'mce_settings' => array(), + 'qt_settings' => array(), + 'plugins' => array(), + 'qt_buttons' => array(), + 'ext_plugins' => null, + 'baseurl' => null, + 'first_init' => null, + 'this_tinymce' => false, + 'this_quicktags' => false, + 'has_tinymce' => false, + 'has_quicktags' => false, + 'has_medialib' => false, + 'editor_buttons_css' => true, + 'drag_drop_upload' => false, + 'translation' => null, + 'tinymce_scripts_printed' => false, + 'link_dialog_printed' => false, + ); + } + + private static function snapshot_state(): array { + return array( + 'globals' => self::snapshot_globals( + array( + '_updated_user_settings', + 'concatenate_scripts', + 'compress_css', + 'compress_scripts', + 'current_screen', + 'current_user', + 'is_IE', + 'is_chrome', + 'is_edge', + 'is_gecko', + 'is_opera', + 'is_safari', + 'post', + 'tinymce_version', + 'user_ID', + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_post_types', + 'wp_rich_edit', + 'wp_rewrite', + 'wp_scripts', + 'wp_styles', + ) + ), + 'editorStatics' => self::snapshot_editor_statics(), + ); + } + + private static function restore_state( array $snapshot ): void { + self::restore_globals( $snapshot['globals'] ); + foreach ( $snapshot['editorStatics'] as $property => $entry ) { + self::set_editor_static_property( $property, $entry['value'] ); + } + } + + private static function state_restored( array $snapshot ): bool { + 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; + } + } + + foreach ( $snapshot['editorStatics'] as $property => $entry ) { + if ( self::get_editor_static_property_raw( $property ) !== $entry['value'] ) { + 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 ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function snapshot_editor_statics(): array { + $statics = array(); + foreach ( array_keys( self::editor_static_defaults() ) as $property ) { + $statics[ $property ] = array( + 'value' => self::get_editor_static_property( $property ), + ); + } + + return $statics; + } + + private static function editor_statics(): array { + $statics = array(); + foreach ( array_keys( self::editor_static_defaults() ) as $property ) { + $statics[ $property ] = self::get_editor_static_property( $property ); + } + return $statics; + } + + private static function register_editor_enqueue_handles(): void { + $scripts = \wp_scripts(); + $styles = \wp_styles(); + + foreach ( array( 'editor', 'quicktags', 'wplink', 'jquery-ui-autocomplete', 'wp-embed', 'thickbox', 'shortcode' ) as $handle ) { + $scripts->add( $handle, false ); + } + $scripts->add( 'media-upload', false, array( 'thickbox', 'shortcode' ) ); + + foreach ( array( 'buttons', 'thickbox' ) as $handle ) { + $styles->add( $handle, false ); + } + } + + private static function editor_enqueue_state(): array { + $scripts = array( + 'editor', + 'quicktags', + 'wplink', + 'jquery-ui-autocomplete', + 'media-upload', + 'wp-embed', + 'thickbox', + ); + $styles = array( + 'buttons', + 'thickbox', + ); + $state = array( + 'scripts' => array(), + 'styles' => array(), + ); + + foreach ( $scripts as $handle ) { + $state['scripts'][ $handle ] = \wp_script_is( $handle, 'enqueued' ); + } + + foreach ( $styles as $handle ) { + $state['styles'][ $handle ] = \wp_style_is( $handle, 'enqueued' ); + } + + return $state; + } + + private static function get_editor_static_property_raw( string $property ) { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + return $reflection->getValue(); + } + + private static function get_editor_static_property( string $property ) { + return self::clone_value( self::get_editor_static_property_raw( $property ) ); + } + + private static function set_editor_static_property( string $property, $value ): void { + $reflection = new \ReflectionProperty( '_WP_Editors', $property ); + $reflection->setValue( null, $value ); + } + + 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, + 'output' => $output, + 'value' => $value, + ); + } catch ( \Throwable $e ) { + while ( ob_get_level() > $level ) { + ob_end_clean(); + } + return array( + 'threw' => true, + 'output' => '', + 'throwable' => self::describe_throwable( $e ), + ); + } + } + + 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 ( $value instanceof \Closure ) { + return $value; + } + if ( is_object( $value ) ) { + try { + return clone $value; + } catch ( \Throwable $e ) { + return $value; + } + } + + return $value; + } + + private static function row( \ComponentFuzz\FuzzContext $ctx, string $invariant, array $failures, array $data = array() ): array { + return $ctx->result( + $invariant, + array() === $failures, + $data + array( + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function record_if_false( array &$failures, bool $ok, string $check, array $data = array() ): void { + if ( $ok ) { + return; + } + + $failures[] = array( + 'check' => $check, + 'data' => $data, + ); + } + + private static function safe_key( string $value, string $fallback ): string { + $value = preg_replace( '/[^A-Za-z0-9_-]+/', '-', $value ); + $value = trim( (string) $value, '-' ); + + return '' === $value ? $fallback : $value; + } + + private static function preview( $value ) { + if ( is_string( $value ) ) { + return strlen( $value ) > self::PREVIEW_BYTES ? substr( $value, 0, self::PREVIEW_BYTES ) . '...' : $value; + } + if ( is_array( $value ) ) { + $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES ); + return false === $json ? '[array]' : self::preview( $json ); + } + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + + return $value; + } + + private static function describe_call( array $call ): array { + if ( empty( $call['threw'] ) ) { + return array( + 'threw' => false, + 'outputBytes' => strlen( $call['output'] ?? '' ), + ); + } + + return array( + 'threw' => true, + 'throwable' => $call['throwable'] ?? null, + ); + } + + 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/surfaces/EmailSurface.php b/tools/component-fuzz/surfaces/EmailSurface.php new file mode 100644 index 0000000000000..bc4991cf9f0b9 --- /dev/null +++ b/tools/component-fuzz/surfaces/EmailSurface.php @@ -0,0 +1,10028 @@ +skip( + 'email.bootstrap-apis-available', + 'Required WordPress email APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $unicode_missing = self::missing_unicode_requirements(); + if ( array() !== $unicode_missing ) { + return array_merge( + self::check_core_ascii_email_baseline( $ctx ), + array( + $ctx->skip( + 'email.unicode-email-optional-apis-available', + 'Optional WordPress Unicode email APIs are unavailable in this checkout.', + array( 'missing' => implode( ', ', $unicode_missing ) ) + ), + ) + ); + } + + $rows = array(); + $snapshot = self::snapshot_hook_globals(); + $tracked_hook_snapshot = self::snapshot_tracked_hook_state(); + + try { + $cases = self::cases( $ctx ); + + $rows = array_merge( $rows, self::check_utf8mb4_filter_gate( $ctx ) ); + + self::install_email_filters( 'unicode' ); + $rows[] = self::check_unicode_filters( $ctx ); + $rows = array_merge( $rows, self::check_direct_filter_callbacks( $ctx ) ); + $rows = array_merge( $rows, self::check_disabled_filter_fail_closed( $ctx ) ); + $rows = array_merge( $rows, self::check_whatwg_examples( $ctx ) ); + $rows = array_merge( $rows, self::check_whatwg_ascii_oracle( $ctx ) ); + $rows = array_merge( $rows, self::check_sanitizer_recovery( $ctx ) ); + $rows = array_merge( $rows, self::check_malformed_utf8_byte_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_unicode_localpart_byte_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_utf8_localpart_oracle( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_utf8_address_model_invariants( $ctx ) ); + $rows = array_merge( $rows, self::check_construction_mode_consistency( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_unicode_filter_view_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_generated_malformed_variant_matrix( $ctx ) ); + $rows = array_merge( $rows, self::check_quoted_escaped_localpart_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_control_character_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_localpart_identity_preservation( $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_normalization_sensitive_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_comment_author_email_filters( $ctx ) ); + $rows = array_merge( $rows, self::check_comment_submission_unicode_email_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_rest_email_schema_filter_modes( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_distinct_localparts( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_generated_localpart_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_updates_generated_localpart_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_authentication_unicode_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_profile_email_confirmation_unicode_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_search_unicode_terms( $ctx ) ); + $rows = array_merge( $rows, self::check_confusable_localpart_boundaries( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_distinct_domains( $ctx ) ); + $rows = array_merge( $rows, self::check_user_email_indexes_canonical_domain_aliases( $ctx ) ); + $rows = array_merge( $rows, self::check_password_reset_unicode_email_paths( $ctx ) ); + $rows = array_merge( $rows, self::check_password_reset_notification_recipient_views( $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_mailto_rendering_context_round_trips( $ctx ) ); + $rows = array_merge( $rows, self::check_make_clickable_email_rendering( $ctx ) ); + $rows = array_merge( $rows, self::check_length_boundaries( $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 ); + } + + $tracked_hook_actual = self::snapshot_tracked_hook_state(); + $tracked_hook_ok = $tracked_hook_snapshot === $tracked_hook_actual; + $tracked_hook_data = array( 'trackedHooks' => self::TRACKED_HOOKS ); + if ( ! $tracked_hook_ok ) { + $tracked_hook_data['expected'] = $tracked_hook_snapshot; + $tracked_hook_data['actual'] = $tracked_hook_actual; + } + + $rows[] = $ctx->result( + 'email.global-hook-state-restored', + $tracked_hook_ok, + $tracked_hook_data + ); + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( + array( + 'add_filter', + 'has_filter', + 'remove_all_filters', + 'email_exists', + 'get_user_by', + 'is_email', + 'is_wp_error', + 'make_clickable', + 'sanitize_email', + 'wp_cache_flush', + 'wp_insert_user', + 'wp_is_valid_utf8', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + if ( ! class_exists( 'WP_User' ) ) { + $missing[] = 'class WP_User'; + } + + return $missing; + } + + private static function missing_unicode_requirements(): array { + $missing = array(); + foreach ( + array( + 'wp_is_unicode_email', + 'wp_sanitize_unicode_email', + 'wp_is_ascii_email', + 'wp_sanitize_ascii_email', + ) 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_core_ascii_email_baseline( \ComponentFuzz\FuzzContext $ctx ): array { + $valid = 'user+tag@example.com'; + $unicode = "jos\u{00E9}@example.com"; + $invalid_cases = array( + 'missing-at' => 'missing-at.example.com', + 'empty-local' => '@example.com', + 'empty-domain' => 'user@', + 'double-at' => 'bad@@example.com', + ); + $failures = array(); + $observed = array(); + + $valid_is_email = self::call( static fn() => \is_email( $valid ) ); + $valid_sanitized = self::call( static fn() => \sanitize_email( $valid ) ); + + if ( + $valid_is_email['threw'] || + $valid_sanitized['threw'] || + $valid !== $valid_is_email['value'] || + $valid !== $valid_sanitized['value'] + ) { + $failures[] = array( + 'label' => 'ascii-valid', + 'input' => self::describe_string( $valid ), + 'isEmail' => self::describe_call( $valid_is_email ), + 'sanitizeEmail' => self::describe_call( $valid_sanitized ), + ); + } + + $observed[] = array( + 'label' => 'ascii-valid', + 'input' => self::describe_string( $valid ), + 'isEmail' => self::describe_call( $valid_is_email ), + 'sanitizeEmail' => self::describe_call( $valid_sanitized ), + ); + + foreach ( $invalid_cases as $label => $input ) { + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + + if ( + $is_email['threw'] || + $sanitized['threw'] || + false !== $is_email['value'] || + '' !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $label, + 'input' => self::describe_string( $input ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $observed[] = array( + 'label' => $label, + 'input' => self::describe_string( $input ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $unicode_is_email = self::call( static fn() => \is_email( $unicode ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $unicode ) ); + $observed[] = array( + 'label' => 'unicode-observed-without-optional-oracles', + 'input' => self::describe_string( $unicode ), + 'isEmail' => self::describe_call( $unicode_is_email ), + 'sanitizeEmail' => self::describe_call( $unicode_sanitized ), + ); + + return array( + $ctx->result( + 'email.core-ascii-baseline-without-unicode-oracles', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_unicode_filters( \ComponentFuzz\FuzzContext $ctx ): array { + $sample = "gr\u{00E5}@example.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_utf8mb4_filter_gate( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "jos\u{00E9}@example.org"; + $ascii = 'user@example.com'; + $charsets = array( + array( 'charset' => 'utf8mb4', 'unicode' => true ), + array( 'charset' => 'utf8', 'unicode' => false ), + array( 'charset' => 'latin1', 'unicode' => false ), + array( 'charset' => '', 'unicode' => false ), + ); + $failures = array(); + $observed = array(); + + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $charsets as $case ) { + self::restore_hook_globals( $hook_snapshot ); + self::install_email_filters_from_default_filters( $case['charset'] ); + + $is_unicode = self::call( static fn() => \is_email( $unicode ) ); + $sanitize_unicode = self::call( static fn() => \sanitize_email( $unicode ) ); + $is_ascii = self::call( static fn() => \is_email( $ascii ) ); + $sanitize_ascii = self::call( static fn() => \sanitize_email( $ascii ) ); + $unicode_filter = \has_filter( 'is_email', 'wp_is_unicode_email' ); + $unicode_sanitize = \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ); + $ascii_filter = \has_filter( 'is_email', 'wp_is_ascii_email' ); + $ascii_sanitize = \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ); + + $expected_unicode = $case['unicode'] ? $unicode : false; + $expected_sanitize_unicode = $case['unicode'] ? $unicode : ''; + $expected_unicode_filter = $case['unicode'] ? 10 : false; + $expected_ascii_filter = $case['unicode'] ? false : 10; + $ok = ! $is_unicode['threw'] + && ! $sanitize_unicode['threw'] + && ! $is_ascii['threw'] + && ! $sanitize_ascii['threw'] + && $expected_unicode === $is_unicode['value'] + && $expected_sanitize_unicode === $sanitize_unicode['value'] + && $ascii === $is_ascii['value'] + && $ascii === $sanitize_ascii['value'] + && $expected_unicode_filter === $unicode_filter + && $expected_unicode_filter === $unicode_sanitize + && $expected_ascii_filter === $ascii_filter + && $expected_ascii_filter === $ascii_sanitize; + + if ( ! $ok ) { + $failures[] = array( + 'charset' => $case['charset'], + 'expectsUnicode' => $case['unicode'], + 'isUnicode' => self::describe_call( $is_unicode ), + 'sanitizeUnicode' => self::describe_call( $sanitize_unicode ), + 'isAscii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $sanitize_ascii ), + 'unicodeFilter' => $unicode_filter, + 'unicodeSanitize' => $unicode_sanitize, + 'asciiFilter' => $ascii_filter, + 'asciiSanitize' => $ascii_sanitize, + ); + } + + $observed[] = array( + 'charset' => $case['charset'], + 'unicodeEnabled' => $case['unicode'], + 'isEmailFilter' => false !== $unicode_filter ? 'unicode' : 'ascii', + 'sanitizeFilter' => false !== $unicode_sanitize ? 'unicode' : 'ascii', + 'unicodeAccepted' => ! $is_unicode['threw'] && false !== $is_unicode['value'], + 'unicodeSanitized' => ! $sanitize_unicode['threw'] && '' !== $sanitize_unicode['value'], + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.default-filters.utf8mb4-gate', + array() === $failures, + array( + 'unicode' => self::describe_string( $unicode ), + 'ascii' => self::describe_string( $ascii ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + 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_direct_filter_callbacks( \ComponentFuzz\FuzzContext $ctx ): array { + $unicode = "gr\u{00E5}@example.org"; + $ascii = 'user@example.com'; + $punycode = 'books@xn--bcher-kva.de'; + $punycode_unicode = "books@b\u{00FC}cher.de"; + $sentinel = 'sentinel@example.com'; + $is_unicode = self::call( static fn() => \wp_is_unicode_email( false, $unicode, null ) ); + $sanitize_unicode = self::call( static fn() => \wp_sanitize_unicode_email( '', $unicode, null ) ); + $is_ascii = self::call( static fn() => \wp_is_ascii_email( false, $ascii, null ) ); + $sanitize_ascii = self::call( static fn() => \wp_sanitize_ascii_email( '', $ascii, null ) ); + $is_unicode_as_ascii = self::call( static fn() => \wp_is_ascii_email( false, $unicode, null ) ); + $sanitize_unicode_as_ascii = self::call( static fn() => \wp_sanitize_ascii_email( '', $unicode, null ) ); + $is_unicode_context = self::call( static fn() => \wp_is_unicode_email( $sentinel, 'not an address', 'local_invalid_chars' ) ); + $is_ascii_context = self::call( static fn() => \wp_is_ascii_email( $sentinel, 'not an address', 'domain_no_periods' ) ); + $is_punycode_unicode = self::call( static fn() => \wp_is_unicode_email( false, $punycode, null ) ); + $sanitize_punycode_unicode = self::call( static fn() => \wp_sanitize_unicode_email( '', $punycode, null ) ); + + $punycode_ok = ! $is_punycode_unicode['threw'] && ! $sanitize_punycode_unicode['threw'] && ( + ! self::has_idn() + ? false === $is_punycode_unicode['value'] && '' === $sanitize_punycode_unicode['value'] + : $punycode_unicode === $is_punycode_unicode['value'] && $punycode_unicode === $sanitize_punycode_unicode['value'] + ); + + $ok = ! $is_unicode['threw'] + && ! $sanitize_unicode['threw'] + && ! $is_ascii['threw'] + && ! $sanitize_ascii['threw'] + && ! $is_unicode_as_ascii['threw'] + && ! $sanitize_unicode_as_ascii['threw'] + && ! $is_unicode_context['threw'] + && ! $is_ascii_context['threw'] + && ! $is_punycode_unicode['threw'] + && ! $sanitize_punycode_unicode['threw'] + && $unicode === $is_unicode['value'] + && $unicode === $sanitize_unicode['value'] + && $ascii === $is_ascii['value'] + && $ascii === $sanitize_ascii['value'] + && false === $is_unicode_as_ascii['value'] + && '' === $sanitize_unicode_as_ascii['value'] + && $sentinel === $is_unicode_context['value'] + && $sentinel === $is_ascii_context['value'] + && $punycode_ok; + + return array( + $ctx->result( + 'email.filter-callbacks.direct-contracts', + $ok, + array( + 'unicode' => self::describe_call( $is_unicode ), + 'sanitizeUnicode' => self::describe_call( $sanitize_unicode ), + 'ascii' => self::describe_call( $is_ascii ), + 'sanitizeAscii' => self::describe_call( $sanitize_ascii ), + 'unicodeAsAscii' => self::describe_call( $is_unicode_as_ascii ), + 'sanitizeUnicodeAsAscii' => self::describe_call( $sanitize_unicode_as_ascii ), + 'unicodeContext' => self::describe_call( $is_unicode_context ), + 'asciiContext' => self::describe_call( $is_ascii_context ), + 'punycodeUnicode' => self::describe_call( $is_punycode_unicode ), + 'sanitizePunycodeUnicode' => self::describe_call( $sanitize_punycode_unicode ), + ) + ), + ); + } + + private static function check_disabled_filter_fail_closed( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'ascii', 'input' => 'user@example.com' ), + array( 'label' => 'unicode-local', 'input' => "gr\u{00E5}@example.com" ), + array( 'label' => 'unicode-domain', 'input' => "mail@gr\u{00E5}.org" ), + array( 'label' => 'punycode-domain', 'input' => 'mail@xn--bcher-kva.de' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + + foreach ( $cases as $case ) { + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $ok = ! $is_email['threw'] + && ! $sanitized['threw'] + && false === $is_email['value'] + && '' === $sanitized['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.filters.disabled-fail-closed', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_whatwg_examples( \ComponentFuzz\FuzzContext $ctx ): array { + $valid = array( + array( 'label' => 'ascii-atext-local', 'input' => 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'expected' => 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com' ), + array( 'label' => 'single-label-domain', 'input' => 'a@b', 'expected' => 'a@b' ), + array( 'label' => 'consecutive-local-dots', 'input' => 'first..last@example.com', 'expected' => 'first..last@example.com' ), + array( 'label' => 'subdomain-hyphen', 'input' => 'user@sub-domain.example', 'expected' => 'user@sub-domain.example' ), + array( 'label' => 'latin-local', 'input' => "jos\u{00E9}@example.com", 'expected' => "jos\u{00E9}@example.com" ), + array( 'label' => 'combining-local', 'input' => "jose\u{0301}@example.com", 'expected' => "jose\u{0301}@example.com" ), + array( 'label' => 'devanagari-local', 'input' => "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", 'expected' => "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com" ), + array( 'label' => 'arabic-local', 'input' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@example.com", 'expected' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@example.com" ), + array( 'label' => 'greek-local', 'input' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@example.com", 'expected' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@example.com" ), + array( 'label' => 'cyrillic-local', 'input' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", 'expected' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com" ), + array( 'label' => 'hiragana-local', 'input' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", 'expected' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com" ), + ); + if ( self::has_idn() ) { + $valid[] = array( 'label' => 'arabic-address', 'input' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}", 'expected' => "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" ); + $valid[] = array( 'label' => 'cjk-address', 'input' => "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", 'expected' => "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" ); + $valid[] = array( 'label' => 'greek-address', 'input' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", 'expected' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" ); + $valid[] = array( 'label' => 'cyrillic-address', 'input' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", 'expected' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" ); + $valid[] = array( 'label' => 'hiragana-address', 'input' => "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", 'expected' => "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" ); + } + $invalid = array( + array( 'label' => 'quoted-rfc5322-local', 'input' => '"quoted"@example.com' ), + array( 'label' => 'comment-local', 'input' => 'user(comment)@example.com' ), + array( 'label' => 'domain-literal', 'input' => 'user@[127.0.0.1]' ), + array( 'label' => 'domain-underscore', 'input' => 'user@example_corp.com' ), + array( 'label' => 'leading-domain-hyphen', 'input' => 'user@-example.com' ), + array( 'label' => 'trailing-domain-hyphen', 'input' => 'user@example-.com' ), + array( 'label' => 'leading-combining-local', 'input' => "\u{0301}bad@example.com" ), + array( 'label' => 'leading-combining-domain', 'input' => "user@\u{0301}bad.example" ), + array( 'label' => 'fullwidth-at-separator', 'input' => "user\u{FF20}example.com" ), + array( 'label' => 'ideographic-dot-separator', 'input' => "user@example\u{3002}com" ), + array( 'label' => 'zero-width-local', 'input' => "zero\u{200D}width@example.com" ), + array( 'label' => 'line-break-local', 'input' => "line\nbreak@example.com" ), + ); + $failures = array(); + + foreach ( $valid as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected = $case['expected']; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + ! ( $parsed['value'] instanceof \WP_Email_Address ) || + $expected !== $parsed['value']->get_unicode_address() || + $expected !== $is_email['value'] || + $expected !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_string( $expected ), + 'expectedValid' => true, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } + + foreach ( $invalid as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + null !== $parsed['value'] || + false !== $is_email['value'] || + '' !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => false, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + } + + return array( + $ctx->result( + 'email.whatwg-style.examples', + array() === $failures, + array( + 'validCount' => count( $valid ), + 'invalidCount' => count( $invalid ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_whatwg_ascii_oracle( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'single-label-domain', 'input' => 'a@b' ), + array( 'label' => 'single-label-domain-long-local', 'input' => 'first.last@example' ), + array( 'label' => 'consecutive-local-dots', 'input' => 'first..last@example.com' ), + array( 'label' => 'leading-local-dot', 'input' => '.start@example.com' ), + array( 'label' => 'trailing-local-dot', 'input' => 'end.@example.com' ), + array( 'label' => 'subdomain-hyphen', 'input' => 'user@sub-domain.example' ), + array( 'label' => 'quoted-local', 'input' => '"quoted"@example.com' ), + array( 'label' => 'comment-local', 'input' => 'user(comment)@example.com' ), + array( 'label' => 'domain-underscore', 'input' => 'user@example_corp.com' ), + array( 'label' => 'leading-domain-hyphen', 'input' => 'user@-example.com' ), + array( 'label' => 'trailing-domain-hyphen', 'input' => 'user@example-.com' ), + array( 'label' => 'domain-literal', 'input' => 'user@[127.0.0.1]' ), + array( 'label' => 'line-break-local', 'input' => "line\nbreak@example.com" ), + ); + $failures = array(); + + foreach ( $cases as $case ) { + $expected_valid = 1 === preg_match( self::WHATWG_ASCII_EMAIL_REGEX, $case['input'] ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected_is_email = $expected_valid ? $case['input'] : false; + $expected_sanitized = $expected_valid ? $case['input'] : ''; + $actual_valid = $parsed['value'] instanceof \WP_Email_Address; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected_valid !== $actual_valid || + $expected_is_email !== $is_email['value'] || + $expected_sanitized !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'actualValid' => $actual_valid, + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'whatwgAsciiRegex' => self::WHATWG_ASCII_EMAIL_REGEX, + ); + } + } + + return array( + $ctx->result( + 'email.whatwg-ascii.oracle-agreement', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_sanitizer_recovery( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'separator-whitespace', + 'input' => " info @ example . com. \t", + 'expected' => 'info@example.com', + ), + array( + 'label' => 'display-name-ascii', + 'input' => 'Display Name ', + 'expected' => 'user@example.com', + ), + array( + 'label' => 'display-name-unicode', + 'input' => "Display Name ", + 'expected' => "jos\u{00E9}@gr\u{00E5}.org", + ), + array( + 'label' => 'quoted-display-name-unicode', + 'input' => "\"\u{00C5}sa Example\" ", + 'expected' => "gr\u{00E5}@example.com", + ), + array( + 'label' => 'nbsp-around-separators', + 'input' => "user\u{00A0}@\u{00A0}example.com", + 'expected' => 'user@example.com', + ), + array( + 'label' => 'soft-hyphen-before-dot', + 'input' => "info@example\u{00AD}.com", + 'expected' => 'info@example.com', + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'punycode-with-separators', + 'input' => 'books @ xn--bcher-kva . de.', + 'expected' => "books@b\u{00FC}cher.de", + ); + } + + $failures = array(); + foreach ( $cases as $case ) { + $raw_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $again = self::call( static fn() => \sanitize_email( $case['expected'] ) ); + $expected_is = self::call( static fn() => \is_email( $case['expected'] ) ); + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['expected'], 'unicode' ) ); + + if ( + $raw_is_email['threw'] || + $sanitized['threw'] || + $again['threw'] || + $expected_is['threw'] || + $parsed['threw'] || + $case['expected'] !== $sanitized['value'] || + $case['expected'] !== $again['value'] || + $case['expected'] !== $expected_is['value'] || + ! ( $parsed['value'] instanceof \WP_Email_Address ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_string( $case['expected'] ), + 'rawIsEmail' => self::describe_call( $raw_is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'sanitizeAgain' => self::describe_call( $again ), + 'expectedIsEmail' => self::describe_call( $expected_is ), + 'parsedExpected' => self::describe_call( $parsed ), + ); + } + } + + return array( + $ctx->result( + 'email.sanitize-email.recovery-contracts', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'failures' => $failures, + ) + ), + ); + } + + private static function check_malformed_utf8_byte_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + $fragments = array( + array( 'label' => 'lone-continuation', 'bytes' => "\x80" ), + array( 'label' => 'truncated-two-byte', 'bytes' => "\xC3" ), + array( 'label' => 'truncated-three-byte', 'bytes' => "\xE2\x82" ), + array( 'label' => 'truncated-four-byte', 'bytes' => "\xF0\x9F\x98" ), + array( 'label' => 'overlong-slash', 'bytes' => "\xC0\xAF" ), + array( 'label' => 'surrogate-codepoint', 'bytes' => "\xED\xA0\x80" ), + array( 'label' => 'impossible-leading-byte', 'bytes' => "\xFE" ), + ); + $failures = array(); + $observed = array(); + + foreach ( $fragments as $fragment ) { + $inputs = array( + array( + 'label' => $fragment['label'] . '-local', + 'segment' => 'local', + 'input' => 'bad' . $fragment['bytes'] . '@example.com', + ), + array( + 'label' => $fragment['label'] . '-domain-label', + 'segment' => 'domain', + 'input' => 'user@bad' . $fragment['bytes'] . '.example', + ), + array( + 'label' => $fragment['label'] . '-domain-suffix', + 'segment' => 'domain', + 'input' => 'user@example.' . $fragment['bytes'], + ), + ); + + foreach ( $inputs as $case ) { + $is_valid_utf8 = self::call( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ok = ! $is_valid_utf8['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $unicode_parse['threw'] + && ! $ascii_parse['threw'] + && false === $is_valid_utf8['value'] + && false === $is_email['value'] + && '' === $sanitized['value'] + && null === $unicode_parse['value'] + && null === $ascii_parse['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'segment' => $case['segment'], + 'input' => self::describe_string( $case['input'] ), + 'validUtf8' => self::describe_call( $is_valid_utf8 ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'asciiParse' => self::describe_call( $ascii_parse ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'segment' => $case['segment'], + 'bytes' => strlen( $case['input'] ), + 'rejected' => $ok, + ); + } + } + + return array( + $ctx->result( + 'email.invalid-utf8.byte-matrix-rejected', + array() === $failures, + array( + 'caseCount' => count( $observed ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_unicode_localpart_byte_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'latin-two-byte-local-64-bytes', + 'local' => str_repeat( "\u{00E5}", 32 ), + 'localBytes' => 64, + ), + array( + 'label' => 'latin-two-byte-local-65-bytes', + 'local' => str_repeat( "\u{00E5}", 32 ) . 'a', + 'localBytes' => 65, + ), + array( + 'label' => 'combining-local-64-bytes', + 'local' => str_repeat( "e\u{0301}", 21 ) . 'x', + 'localBytes' => 64, + ), + array( + 'label' => 'combining-local-65-bytes', + 'local' => str_repeat( "e\u{0301}", 21 ) . 'xy', + 'localBytes' => 65, + ), + array( + 'label' => 'cjk-local-63-bytes', + 'local' => str_repeat( "\u{7528}", 21 ), + 'localBytes' => 63, + ), + array( + 'label' => 'cjk-local-66-bytes', + 'local' => str_repeat( "\u{7528}", 22 ), + 'localBytes' => 66, + ), + ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $input = $case['local'] . '@example.com'; + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $input ) ); + $sanitized = self::call( static fn() => \sanitize_email( $input ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $input, 'ascii' ) ); + $ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $input, null ) ); + $ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $input, null ) ); + $email = $parsed['value'] ?? null; + $ok = ! $parsed['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $ascii_parse['threw'] + && ! $ascii_is['threw'] + && ! $ascii_sanitize['threw'] + && $email instanceof \WP_Email_Address + && $case['localBytes'] === strlen( $case['local'] ) + && \wp_is_valid_utf8( $case['local'] ) + && $case['local'] === $email->get_localpart() + && 'example.com' === $email->get_ascii_domain() + && 'example.com' === $email->get_unicode_domain() + && $input === $email->get_ascii_address() + && $input === $email->get_unicode_address() + && $input === $is_email['value'] + && $input === $sanitized['value'] + && null === $ascii_parse['value'] + && false === $ascii_is['value'] + && '' === $ascii_sanitize['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'localBytes' => strlen( $case['local'] ), + 'expectedBytes' => $case['localBytes'], + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is ), + 'asciiSanitize' => self::describe_call( $ascii_sanitize ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'localBytes' => strlen( $case['local'] ), + 'addressBytes' => strlen( $input ), + 'unicodeValid' => $email instanceof \WP_Email_Address, + 'asciiRejected' => ! $ascii_parse['threw'] && null === $ascii_parse['value'], + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.unicode-localpart-byte-boundaries', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_utf8_localpart_oracle( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = self::generated_utf8_localpart_oracle_cases( $ctx->fork( 'utf8-localpart-oracle' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + self::install_email_filters( 'unicode' ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $unicode_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_unicode_is = self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ); + $direct_unicode_sanitize = self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ); + $ascii_view_parse = $case['expectedUnicodeValid'] + ? self::call( static fn() => \WP_Email_Address::from_string( $case['expectedAsciiAddress'], 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + $unicode_view_parse = $case['expectedUnicodeValid'] + ? self::call( static fn() => \WP_Email_Address::from_string( $case['expectedUnicodeAddress'], 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $expected_unicode_is_email = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_unicode_sanitized = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : ''; + $expected_ascii_is_email = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_ascii_sanitized = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : ''; + + $unicode_parse_ok = ! $unicode_parse['threw'] + && ( + $case['expectedUnicodeValid'] + ? $unicode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $unicode_email ) + : null === $unicode_email + ); + + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && $expected_unicode_is_email === $unicode_is_email['value'] + && $expected_unicode_sanitized === $unicode_sanitized['value'] + && $expected_unicode_is_email === $direct_unicode_is['value'] + && $expected_unicode_sanitized === $direct_unicode_sanitize['value']; + + $ascii_mode_ok = ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ( + $case['expectedAsciiValid'] + ? $ascii_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_email ) + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value'] + && $expected_ascii_is_email === $direct_ascii_is['value'] + && $expected_ascii_sanitized === $direct_ascii_sanitize['value']; + + $view_roundtrip_ok = ! $case['expectedUnicodeValid'] || ( + ! $ascii_view_parse['threw'] + && ! $unicode_view_parse['threw'] + && $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ) + && $unicode_email instanceof \WP_Email_Address + && self::address_round_trip_ok( $unicode_email ) + ); + + $ok = $unicode_parse_ok + && $unicode_filter_ok + && $ascii_mode_ok + && $view_roundtrip_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'local' => self::describe_string( $case['local'] ), + 'profile' => $case['profile'], + 'domainLabel' => $case['domainLabel'], + 'inputDomainView' => $case['inputDomainView'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'expectedViews' => self::describe_value( $expected_views ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_call( $direct_unicode_sanitize ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_call( $direct_ascii_sanitize ), + 'asciiViewParse' => self::describe_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_call( $unicode_view_parse ), + 'unicodeParseOk' => $unicode_parse_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiModeOk' => $ascii_mode_ok, + 'viewRoundtripOk' => $view_roundtrip_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domainLabel' => $case['domainLabel'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => ! self::is_ascii( $case['local'] ), + 'localValidUtf8' => \wp_is_valid_utf8( $case['local'] ), + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'acceptedUnicode' => $unicode_email instanceof \WP_Email_Address, + 'acceptedAscii' => $ascii_email instanceof \WP_Email_Address, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-utf8-localpart.oracle-agreement', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_utf8_address_model_invariants( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.generated-utf8-address-model.invariants', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $cases = self::generated_utf8_address_model_cases( $ctx->fork( 'utf8-address-model' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $valid_utf8 = self::capture_warnings( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + + self::install_email_filters( 'unicode' ); + $unicode_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $unicode_is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $direct_unicode_is = self::capture_warnings( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ); + $direct_unicode_sanitize = self::capture_warnings( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ); + + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_view_parse = $unicode_email instanceof \WP_Email_Address + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $unicode_email->get_ascii_address(), 'unicode' ) ) + : self::not_called(); + $unicode_view_parse = $unicode_email instanceof \WP_Email_Address + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $unicode_email->get_unicode_address(), 'unicode' ) ) + : self::not_called(); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $ascii_is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $direct_ascii_is = self::capture_warnings( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_ascii_sanitize = self::capture_warnings( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $expected_unicode_is_email = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_unicode_sanitized = $case['expectedUnicodeValid'] ? $case['expectedUnicodeAddress'] : ''; + $expected_ascii_is_email = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : false; + $expected_ascii_sanitized = $case['expectedAsciiValid'] ? $case['expectedUnicodeAddress'] : ''; + $captured_calls = array( + 'validUtf8' => $valid_utf8, + 'unicodeParse' => $unicode_parse, + 'unicodeIsEmail' => $unicode_is_email, + 'unicodeSanitized' => $unicode_sanitized, + 'directUnicodeIs' => $direct_unicode_is, + 'directUnicodeSanitize' => $direct_unicode_sanitize, + 'asciiViewParse' => $ascii_view_parse, + 'unicodeViewParse' => $unicode_view_parse, + 'asciiParse' => $ascii_parse, + 'asciiIsEmail' => $ascii_is_email, + 'asciiSanitized' => $ascii_sanitized, + 'directAsciiIs' => $direct_ascii_is, + 'directAsciiSanitize' => $direct_ascii_sanitize, + ); + + $calls_clean = true; + foreach ( $captured_calls as $call ) { + $calls_clean = $calls_clean + && ! $call['threw'] + && array() === ( $call['warnings'] ?? array() ); + } + + $utf8_ok = ! $valid_utf8['threw'] + && array() === $valid_utf8['warnings'] + && ( $case['malformedUtf8'] ? false === $valid_utf8['value'] : true === $valid_utf8['value'] ); + $unicode_parse_ok = $case['expectedUnicodeValid'] + ? ( + $unicode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $unicode_email ) + && self::address_parts_ok( $unicode_email ) + ) + : null === $unicode_email; + $roundtrip_ok = ! $case['expectedUnicodeValid'] || ( + $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ) + && self::address_raw_views( $ascii_view_email ) === self::address_raw_views( $unicode_view_email ) + ); + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && $expected_unicode_is_email === $unicode_is_email['value'] + && $expected_unicode_sanitized === $unicode_sanitized['value'] + && $expected_unicode_is_email === $direct_unicode_is['value'] + && $expected_unicode_sanitized === $direct_unicode_sanitize['value']; + $ascii_mode_ok = ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ( + $case['expectedAsciiValid'] + ? $ascii_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_email ) + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value'] + && $expected_ascii_is_email === $direct_ascii_is['value'] + && $expected_ascii_sanitized === $direct_ascii_sanitize['value']; + $ok = $calls_clean + && $utf8_ok + && $unicode_parse_ok + && $roundtrip_ok + && $unicode_filter_ok + && $ascii_mode_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'reason' => $case['reason'], + 'input' => self::describe_string( $case['input'] ), + 'inputDomainView' => $case['inputDomainView'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'expectedViews' => self::describe_value( $expected_views ), + 'validUtf8' => self::describe_captured_call( $valid_utf8 ), + 'unicodeParse' => self::describe_captured_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_captured_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_captured_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_captured_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_captured_call( $direct_unicode_sanitize ), + 'asciiViewParse' => self::describe_captured_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_captured_call( $unicode_view_parse ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_captured_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_captured_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_captured_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_captured_call( $direct_ascii_sanitize ), + 'callsClean' => $calls_clean, + 'utf8Ok' => $utf8_ok, + 'unicodeParseOk' => $unicode_parse_ok, + 'roundtripOk' => $roundtrip_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiModeOk' => $ascii_mode_ok, + ); + } + + $warning_count = 0; + foreach ( $captured_calls as $call ) { + $warning_count += count( $call['warnings'] ?? array() ); + } + + $observed[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'reason' => $case['reason'], + 'inputDomainView' => $case['inputDomainView'], + 'hasUnicodeLocal' => $case['hasUnicodeLocal'], + 'hasUnicodeDomain' => $case['hasUnicodeDomain'], + 'malformedUtf8' => $case['malformedUtf8'], + 'expectedUnicodeValid' => $case['expectedUnicodeValid'], + 'expectedAsciiValid' => $case['expectedAsciiValid'], + 'unicodeAccepted' => $unicode_email instanceof \WP_Email_Address, + 'asciiAccepted' => $ascii_email instanceof \WP_Email_Address, + 'warnings' => $warning_count, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-utf8-address-model.invariants', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_construction_mode_consistency( \ComponentFuzz\FuzzContext $ctx ): array { + $base_cases = array( + array( + 'label' => 'ascii-plus-subdomain', + 'input' => 'USER+tag@example.co.uk', + 'localpart' => 'USER+tag', + 'asciiDomain' => 'example.co.uk', + 'unicodeDomain' => 'example.co.uk', + 'asciiModeValid' => true, + ), + array( + 'label' => 'whatwg-single-label-domain', + 'input' => 'a@b', + 'localpart' => 'a', + 'asciiDomain' => 'b', + 'unicodeDomain' => 'b', + 'asciiModeValid' => true, + ), + array( + 'label' => 'whatwg-local-dots', + 'input' => 'first..last@example.com', + 'localpart' => 'first..last', + 'asciiDomain' => 'example.com', + 'unicodeDomain' => 'example.com', + 'asciiModeValid' => true, + ), + array( + 'label' => 'unicode-local-ascii-domain', + 'input' => "gr\u{00E5}@example.com", + 'localpart' => "gr\u{00E5}", + 'asciiDomain' => 'example.com', + 'unicodeDomain' => 'example.com', + 'asciiModeValid' => false, + ), + ); + + $base_result = self::evaluate_construction_mode_cases( $base_cases ); + $rows = array( + $ctx->result( + 'email.wp-email-address.construction-mode-consistency', + array() === $base_result['failures'], + $base_result + ), + ); + + if ( ! self::has_idn() ) { + $rows[] = $ctx->skip( + 'email.wp-email-address.idn-construction-mode-consistency', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ); + + return $rows; + } + + $idn_cases = array( + array( + 'label' => 'punycode-domain', + 'input' => 'books@xn--bcher-kva.de', + 'localpart' => 'books', + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + array( + 'label' => 'unicode-domain', + 'input' => "books@b\u{00FC}cher.de", + 'localpart' => 'books', + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + array( + 'label' => 'unicode-local-punycode-domain', + 'input' => "jose\u{0301}@xn--bcher-kva.de", + 'localpart' => "jose\u{0301}", + 'asciiDomain' => 'xn--bcher-kva.de', + 'unicodeDomain' => "b\u{00FC}cher.de", + 'asciiModeValid' => false, + ), + ); + $idn_result = self::evaluate_construction_mode_cases( $idn_cases ); + + $rows[] = $ctx->result( + 'email.wp-email-address.idn-construction-mode-consistency', + array() === $idn_result['failures'], + $idn_result + ); + + return $rows; + } + + private static function evaluate_construction_mode_cases( array $cases ): array { + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + $unicode_email = $unicode_parse['value'] ?? null; + $ascii_email = $ascii_parse['value'] ?? null; + $expected = array( + 'localpart' => $case['localpart'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['localpart'] . '@' . $case['asciiDomain'], + 'unicodeAddress' => $case['localpart'] . '@' . $case['unicodeDomain'], + ); + $unicode_views = $unicode_email instanceof \WP_Email_Address + ? self::address_raw_views( $unicode_email ) + : null; + $ascii_views = $ascii_email instanceof \WP_Email_Address + ? self::address_raw_views( $ascii_email ) + : null; + + $unicode_ok = ! $unicode_parse['threw'] + && $unicode_email instanceof \WP_Email_Address + && $expected === $unicode_views; + $ascii_ok = $case['asciiModeValid'] + ? ( + ! $ascii_parse['threw'] + && $ascii_email instanceof \WP_Email_Address + && $expected === $ascii_views + && $unicode_views === $ascii_views + ) + : ! $ascii_parse['threw'] && null === $ascii_email; + $ok = $unicode_ok && $ascii_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expected' => self::describe_value( $expected ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'unicodeViews' => self::describe_value( $unicode_views ), + 'asciiViews' => self::describe_value( $ascii_views ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'asciiModeValid' => $case['asciiModeValid'], + 'unicodeViews' => self::describe_value( $unicode_views ), + 'asciiAccepted' => $ascii_email instanceof \WP_Email_Address, + ); + } + + return array( + 'observed' => $observed, + 'failures' => $failures, + ); + } + + private static function check_generated_unicode_filter_view_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.generated-unicode.filter-view-matrix', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $cases = self::generated_unicode_filter_view_cases( $ctx->fork( 'unicode-filter-view-matrix' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $input_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $ascii_view_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['expectedAsciiAddress'], 'unicode' ) ); + $unicode_view_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['expectedUnicodeAddress'], 'unicode' ) ); + $ascii_mode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'ascii' ) ); + + self::install_email_filters( 'unicode' ); + $unicode_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $unicode_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::install_email_filters( 'ascii' ); + $ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $ascii_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'utf8mb4' ); + $default_utf8mb4_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $default_utf8mb4_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $default_utf8mb4_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'latin1' ); + $default_ascii_is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $default_ascii_sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $default_ascii_filter_state = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + + $input_email = $input_parse['value'] ?? null; + $ascii_view_email = $ascii_view_parse['value'] ?? null; + $unicode_view_email = $unicode_view_parse['value'] ?? null; + $ascii_mode_email = $ascii_mode_parse['value'] ?? null; + $expected_views = array( + 'localpart' => $case['local'], + 'asciiDomain' => $case['asciiDomain'], + 'unicodeDomain' => $case['unicodeDomain'], + 'asciiAddress' => $case['expectedAsciiAddress'], + 'unicodeAddress' => $case['expectedUnicodeAddress'], + ); + $ascii_mode_expected_is_email = $case['asciiModeValid'] ? $case['expectedUnicodeAddress'] : false; + $ascii_mode_expected_sanitized = $case['asciiModeValid'] ? $case['expectedUnicodeAddress'] : ''; + + $parse_ok = ! $input_parse['threw'] + && ! $ascii_view_parse['threw'] + && ! $unicode_view_parse['threw'] + && $input_email instanceof \WP_Email_Address + && $ascii_view_email instanceof \WP_Email_Address + && $unicode_view_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $input_email ) + && $expected_views === self::address_raw_views( $ascii_view_email ) + && $expected_views === self::address_raw_views( $unicode_view_email ); + + $ascii_parse_ok = ! $ascii_mode_parse['threw'] + && ( + $case['asciiModeValid'] + ? $ascii_mode_email instanceof \WP_Email_Address + && $expected_views === self::address_raw_views( $ascii_mode_email ) + : null === $ascii_mode_email + ); + + $unicode_filter_ok = ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && $case['expectedUnicodeAddress'] === $unicode_is_email['value'] + && $case['expectedUnicodeAddress'] === $unicode_sanitized['value'] + && 10 === $unicode_filter_state['isUnicode'] + && 10 === $unicode_filter_state['sanitizeUnicode'] + && false === $unicode_filter_state['isAscii'] + && false === $unicode_filter_state['sanitizeAscii']; + + $ascii_filter_ok = ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && $ascii_mode_expected_is_email === $ascii_is_email['value'] + && $ascii_mode_expected_sanitized === $ascii_sanitized['value'] + && 10 === $ascii_filter_state['isAscii'] + && 10 === $ascii_filter_state['sanitizeAscii'] + && false === $ascii_filter_state['isUnicode'] + && false === $ascii_filter_state['sanitizeUnicode']; + + $default_filter_ok = ! $default_utf8mb4_is_email['threw'] + && ! $default_utf8mb4_sanitized['threw'] + && ! $default_ascii_is_email['threw'] + && ! $default_ascii_sanitized['threw'] + && $case['expectedUnicodeAddress'] === $default_utf8mb4_is_email['value'] + && $case['expectedUnicodeAddress'] === $default_utf8mb4_sanitized['value'] + && $ascii_mode_expected_is_email === $default_ascii_is_email['value'] + && $ascii_mode_expected_sanitized === $default_ascii_sanitized['value'] + && 10 === $default_utf8mb4_filter_state['isUnicode'] + && 10 === $default_utf8mb4_filter_state['sanitizeUnicode'] + && false === $default_utf8mb4_filter_state['isAscii'] + && false === $default_utf8mb4_filter_state['sanitizeAscii'] + && 10 === $default_ascii_filter_state['isAscii'] + && 10 === $default_ascii_filter_state['sanitizeAscii'] + && false === $default_ascii_filter_state['isUnicode'] + && false === $default_ascii_filter_state['sanitizeUnicode']; + + $view_shape_ok = $input_email instanceof \WP_Email_Address + && self::is_ascii( $input_email->get_ascii_domain() ) + && self::is_ascii( $case['local'] ) === self::is_ascii( $input_email->get_ascii_address() ) + && $case['hasUnicodeDomain'] === ( $input_email->get_ascii_domain() !== $input_email->get_unicode_domain() ) + && $case['hasUnicodeLocal'] === ! self::is_ascii( $input_email->get_localpart() ); + + $ok = $parse_ok + && $ascii_parse_ok + && $unicode_filter_ok + && $ascii_filter_ok + && $default_filter_ok + && $view_shape_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'inputDomainView' => $case['inputDomainView'], + 'expectedViews' => self::describe_value( $expected_views ), + 'inputParse' => self::describe_call( $input_parse ), + 'asciiViewParse' => self::describe_call( $ascii_view_parse ), + 'unicodeViewParse' => self::describe_call( $unicode_view_parse ), + 'asciiModeParse' => self::describe_call( $ascii_mode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'defaultUtf8mb4Is' => self::describe_call( $default_utf8mb4_is_email ), + 'defaultUtf8mb4San' => self::describe_call( $default_utf8mb4_sanitized ), + 'defaultAsciiIs' => self::describe_call( $default_ascii_is_email ), + 'defaultAsciiSan' => self::describe_call( $default_ascii_sanitized ), + 'unicodeFilterState' => self::describe_value( $unicode_filter_state ), + 'asciiFilterState' => self::describe_value( $ascii_filter_state ), + 'defaultUtf8mb4FilterState' => self::describe_value( $default_utf8mb4_filter_state ), + 'defaultAsciiFilterState' => self::describe_value( $default_ascii_filter_state ), + 'parseOk' => $parse_ok, + 'asciiParseOk' => $ascii_parse_ok, + 'unicodeFilterOk' => $unicode_filter_ok, + 'asciiFilterOk' => $ascii_filter_ok, + 'defaultFilterOk' => $default_filter_ok, + 'viewShapeOk' => $view_shape_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => $case['hasUnicodeLocal'], + 'domainIdn' => $case['hasUnicodeDomain'], + 'asciiModeValid' => $case['asciiModeValid'], + 'unicodeAddress' => self::describe_string( $case['expectedUnicodeAddress'] ), + 'asciiAddress' => self::describe_string( $case['expectedAsciiAddress'] ), + 'asciiAddressAscii' => $input_email instanceof \WP_Email_Address ? self::is_ascii( $input_email->get_ascii_address() ) : null, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-unicode.filter-view-matrix', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_generated_malformed_variant_matrix( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = self::generated_malformed_variant_cases( $ctx->fork( 'malformed-variant-matrix' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $wpdb_snapshot = self::snapshot_wpdb_charset(); + + try { + foreach ( $cases as $case ) { + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'domain' => $case['domainLabel'], + 'error' => $case['conversionError'], + ); + continue; + } + + $base_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $base_email = $base_parse['value'] ?? null; + $base_ok = ! $base_parse['threw'] + && $base_email instanceof \WP_Email_Address + && $case['expectedAsciiAddress'] === $base_email->get_ascii_address() + && $case['expectedUnicodeAddress'] === $base_email->get_unicode_address() + && self::address_round_trip_ok( $base_email ); + + if ( ! $base_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedAscii' => self::describe_string( $case['expectedAsciiAddress'] ), + 'expectedUnicode' => self::describe_string( $case['expectedUnicodeAddress'] ), + 'baseParse' => self::describe_call( $base_parse ), + ); + continue; + } + + foreach ( $case['variants'] as $variant ) { + self::install_email_filters( 'unicode' ); + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $variant['input'], 'unicode' ) ); + $unicode_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $unicode_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $direct_unicode_is = self::call( static fn() => \wp_is_unicode_email( false, $variant['input'], null ) ); + $direct_unicode_sanitize = self::call( static fn() => \wp_sanitize_unicode_email( '', $variant['input'], null ) ); + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::call( static fn() => \WP_Email_Address::from_string( $variant['input'], 'ascii' ) ); + $ascii_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $ascii_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $direct_ascii_is = self::call( static fn() => \wp_is_ascii_email( false, $variant['input'], null ) ); + $direct_ascii_sanitize = self::call( static fn() => \wp_sanitize_ascii_email( '', $variant['input'], null ) ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'utf8mb4' ); + $default_utf8mb4_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $default_utf8mb4_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $default_utf8mb4_filters = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + \remove_all_filters( 'is_email' ); + \remove_all_filters( 'sanitize_email' ); + self::install_email_filters_from_default_filters( 'latin1' ); + $default_ascii_is_email = self::call( static fn() => \is_email( $variant['input'] ) ); + $default_ascii_sanitized = self::call( static fn() => \sanitize_email( $variant['input'] ) ); + $default_ascii_filters = array( + 'isUnicode' => \has_filter( 'is_email', 'wp_is_unicode_email' ), + 'sanitizeUnicode' => \has_filter( 'sanitize_email', 'wp_sanitize_unicode_email' ), + 'isAscii' => \has_filter( 'is_email', 'wp_is_ascii_email' ), + 'sanitizeAscii' => \has_filter( 'sanitize_email', 'wp_sanitize_ascii_email' ), + ); + + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + + $ok = ! $unicode_parse['threw'] + && ! $unicode_is_email['threw'] + && ! $unicode_sanitized['threw'] + && ! $direct_unicode_is['threw'] + && ! $direct_unicode_sanitize['threw'] + && ! $ascii_parse['threw'] + && ! $ascii_is_email['threw'] + && ! $ascii_sanitized['threw'] + && ! $direct_ascii_is['threw'] + && ! $direct_ascii_sanitize['threw'] + && ! $default_utf8mb4_is_email['threw'] + && ! $default_utf8mb4_sanitized['threw'] + && ! $default_ascii_is_email['threw'] + && ! $default_ascii_sanitized['threw'] + && null === $unicode_parse['value'] + && false === $unicode_is_email['value'] + && '' === $unicode_sanitized['value'] + && false === $direct_unicode_is['value'] + && '' === $direct_unicode_sanitize['value'] + && null === $ascii_parse['value'] + && false === $ascii_is_email['value'] + && '' === $ascii_sanitized['value'] + && false === $direct_ascii_is['value'] + && '' === $direct_ascii_sanitize['value'] + && false === $default_utf8mb4_is_email['value'] + && '' === $default_utf8mb4_sanitized['value'] + && false === $default_ascii_is_email['value'] + && '' === $default_ascii_sanitized['value'] + && 10 === $default_utf8mb4_filters['isUnicode'] + && 10 === $default_utf8mb4_filters['sanitizeUnicode'] + && false === $default_utf8mb4_filters['isAscii'] + && false === $default_utf8mb4_filters['sanitizeAscii'] + && false === $default_ascii_filters['isUnicode'] + && false === $default_ascii_filters['sanitizeUnicode'] + && 10 === $default_ascii_filters['isAscii'] + && 10 === $default_ascii_filters['sanitizeAscii']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['label'], + 'base' => self::describe_string( $case['input'] ), + 'input' => self::describe_string( $variant['input'] ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'unicodeIsEmail' => self::describe_call( $unicode_is_email ), + 'unicodeSanitized' => self::describe_call( $unicode_sanitized ), + 'directUnicodeIs' => self::describe_call( $direct_unicode_is ), + 'directUnicodeSanitize' => self::describe_call( $direct_unicode_sanitize ), + 'asciiParse' => self::describe_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_call( $ascii_sanitized ), + 'directAsciiIs' => self::describe_call( $direct_ascii_is ), + 'directAsciiSanitize' => self::describe_call( $direct_ascii_sanitize ), + 'defaultUtf8mb4Is' => self::describe_call( $default_utf8mb4_is_email ), + 'defaultUtf8mb4San' => self::describe_call( $default_utf8mb4_sanitized ), + 'defaultAsciiIs' => self::describe_call( $default_ascii_is_email ), + 'defaultAsciiSan' => self::describe_call( $default_ascii_sanitized ), + 'defaultUtf8mb4Filters' => self::describe_value( $default_utf8mb4_filters ), + 'defaultAsciiFilters' => self::describe_value( $default_ascii_filters ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'variant' => $variant['label'], + 'inputDomainView' => $case['inputDomainView'], + 'localUtf8' => $case['hasUnicodeLocal'], + 'domainIdn' => $case['hasUnicodeDomain'], + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_wpdb_charset( $wpdb_snapshot ); + } + + return array( + $ctx->result( + 'email.generated-malformed-variants.reject-consistently', + array() === $failures, + array( + 'baseCaseCount' => count( $cases ), + 'variantCount' => count( $observed ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_quoted_escaped_localpart_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'quoted-simple', 'input' => '"quoted"@example.com' ), + array( 'label' => 'quoted-space', 'input' => '"first last"@example.com' ), + array( 'label' => 'quoted-escaped-backslash', 'input' => '"quo\\ted"@example.com' ), + array( 'label' => 'quoted-escaped-quote', 'input' => '"a\"b"@example.com' ), + array( 'label' => 'quoted-at-sign', 'input' => '"a@b"@example.com' ), + array( 'label' => 'quoted-control', 'input' => "\"\x01\"@example.com" ), + array( 'label' => 'escaped-at-outside-quotes', 'input' => 'user\\@example.com@example.org' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], $mode ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_is = 'unicode' === $mode + ? self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ) + : self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_sanitize = 'unicode' === $mode + ? self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ) + : self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + $ok = ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $direct_is['threw'] + && ! $direct_sanitize['threw'] + && null === $parse['value'] + && false === $is_email['value'] + && '' === $sanitized['value'] + && false === $direct_is['value'] + && '' === $direct_sanitize['value']; + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'parse' => self::describe_call( $parse ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'directIs' => self::describe_call( $direct_is ), + 'directSanitize' => self::describe_call( $direct_sanitize ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.rfc-quoted-escaped-localparts.rejected', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_control_character_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'label' => 'nul-local', 'input' => "bad\0local@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'tab-local', 'input' => "bad\tlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'lf-local', 'input' => "bad\nlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'cr-local', 'input' => "bad\rlocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'del-local', 'input' => "bad\x7Flocal@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'nul-domain', 'input' => "user@example\0.com", 'expectedSanitized' => '' ), + array( 'label' => 'tab-domain', 'input' => "user@example\t.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'lf-domain', 'input' => "user@example\n.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'cr-domain', 'input' => "user@example\r.com", 'expectedSanitized' => 'user@example.com' ), + array( 'label' => 'del-domain', 'input' => "user@example\x7F.com", 'expectedSanitized' => '' ), + array( 'label' => 'control-before-at', 'input' => "user\x01@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'control-after-at', 'input' => "user@\x01example.com", 'expectedSanitized' => '' ), + array( 'label' => 'line-separator-local', 'input' => "line\u{2028}sep@example.com", 'expectedSanitized' => '' ), + array( 'label' => 'paragraph-separator-domain', 'input' => "user@example\u{2029}.com", 'expectedSanitized' => 'user@example.com' ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $valid_utf8 = self::call( static fn() => \wp_is_valid_utf8( $case['input'] ) ); + $parse = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], $mode ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $direct_is = 'unicode' === $mode + ? self::call( static fn() => \wp_is_unicode_email( false, $case['input'], null ) ) + : self::call( static fn() => \wp_is_ascii_email( false, $case['input'], null ) ); + $direct_sanitize = 'unicode' === $mode + ? self::call( static fn() => \wp_sanitize_unicode_email( '', $case['input'], null ) ) + : self::call( static fn() => \wp_sanitize_ascii_email( '', $case['input'], null ) ); + $sanitized_value = is_string( $sanitized['value'] ?? null ) ? $sanitized['value'] : ''; + $expected_sanitized = $case['expectedSanitized']; + $sanitized_parse = '' === $sanitized_value + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $sanitized_value, $mode ) ); + $sanitized_is = '' === $sanitized_value + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $sanitized_value ) ); + $ok = ! $valid_utf8['threw'] + && ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $direct_is['threw'] + && ! $direct_sanitize['threw'] + && ! $sanitized_parse['threw'] + && ! $sanitized_is['threw'] + && null === $parse['value'] + && false === $is_email['value'] + && false === $direct_is['value'] + && $expected_sanitized === $sanitized_value + && '' === $direct_sanitize['value'] + && 1 !== preg_match( '/[\x00-\x1F\x7F]/', $sanitized_value ) + && \wp_is_valid_utf8( $sanitized_value ) + && ( + '' === $expected_sanitized + ? null === $sanitized_parse['value'] && false === $sanitized_is['value'] + : $sanitized_parse['value'] instanceof \WP_Email_Address && $expected_sanitized === $sanitized_is['value'] + ); + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedSanitized' => self::describe_string( $expected_sanitized ), + 'validUtf8' => self::describe_call( $valid_utf8 ), + 'parse' => self::describe_call( $parse ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + 'directIs' => self::describe_call( $direct_is ), + 'directSanitize' => self::describe_call( $direct_sanitize ), + 'sanitizedParse' => self::describe_call( $sanitized_parse ), + 'sanitizedIs' => self::describe_call( $sanitized_is ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'validUtf8' => ! $valid_utf8['threw'] ? $valid_utf8['value'] : null, + 'sanitized' => self::describe_string( $sanitized_value ), + 'rejected' => $ok, + ); + } + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.control-characters.rejected-in-all-segments', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_localpart_identity_preservation( \ComponentFuzz\FuzzContext $ctx ): array { + $groups = array( + array( + 'label' => 'case-sensitive-ascii-local', + 'locals' => array( 'user', 'User', 'USER' ), + ), + array( + 'label' => 'width-sensitive-latin-local', + 'locals' => array( 'user', "\u{FF55}ser" ), + ), + array( + 'label' => 'width-sensitive-katakana-local', + 'locals' => array( "\u{30A2}\u{30A4}", "\u{FF71}\u{FF72}" ), + ), + array( + 'label' => 'compatibility-ligature-local', + 'locals' => array( 'ffi', "\u{FB03}" ), + ), + array( + 'label' => 'normalization-sensitive-latin-local', + 'locals' => array( "jos\u{00E9}", "jose\u{0301}" ), + ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + self::install_email_filters( 'unicode' ); + + foreach ( $groups as $group ) { + $addresses = self::addresses_for_localparts( $group['locals'], 'example.com' ); + $views = array(); + + foreach ( $addresses as $index => $address ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $is_email = self::capture_warnings( static fn() => \is_email( $address ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $address ) ); + $ascii_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'ascii' ) ); + $email = $parse['value'] ?? null; + $local = $group['locals'][ $index ]; + $ascii_email = $ascii_parse['value'] ?? null; + $ascii_valid = self::is_ascii( $local ); + $expected_raw = $local . '@example.com'; + $ok = ! $parse['threw'] + && ! $is_email['threw'] + && ! $sanitized['threw'] + && ! $ascii_parse['threw'] + && array() === $parse['warnings'] + && array() === $is_email['warnings'] + && array() === $sanitized['warnings'] + && array() === $ascii_parse['warnings'] + && $email instanceof \WP_Email_Address + && $local === $email->get_localpart() + && $expected_raw === $email->get_unicode_address() + && $expected_raw === $email->get_ascii_address() + && $expected_raw === $is_email['value'] + && $expected_raw === $sanitized['value'] + && ( + $ascii_valid + ? $ascii_email instanceof \WP_Email_Address && $expected_raw === $ascii_email->get_unicode_address() + : null === $ascii_email + ); + + if ( ! $ok ) { + $failures[] = array( + 'group' => $group['label'], + 'local' => self::describe_string( $local ), + 'address' => self::describe_string( $address ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + ); + } + + $views[] = array( + 'local' => self::describe_string( $local ), + 'address' => self::describe_string( $address ), + 'asciiValid' => $ascii_valid, + 'unicodeValid' => $email instanceof \WP_Email_Address, + ); + } + + if ( + count( array_unique( $group['locals'], SORT_REGULAR ) ) !== count( $group['locals'] ) || + count( array_unique( $addresses, SORT_REGULAR ) ) !== count( $addresses ) + ) { + $failures[] = array( + 'group' => $group['label'], + 'failure' => 'locals-or-addresses-collapsed-before-validation', + 'locals' => array_map( array( self::class, 'describe_string' ), $group['locals'] ), + 'addresses' => array_map( array( self::class, 'describe_string' ), $addresses ), + ); + } + + $observed[] = array( + 'label' => $group['label'], + 'views' => $views, + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.localpart-identity.case-width-normalization-preserved', + array() === $failures, + array( + 'groupCount' => count( $groups ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + 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 ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.sanitize-email.output-valid-utf8', + \wp_is_valid_utf8( $sanitized_value ), + array( 'sanitizeEmail' => self::describe_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-parts', + self::address_parts_ok( $parsed_value ), + array( + 'address' => self::describe_address( $parsed_value ), + ) + ); + $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 ( is_string( $sanitized['value'] ) ) { + $rows[] = self::case_result( + $ctx, + $case_index, + $case, + 'email.ascii-fallback.sanitize-email.output-valid-utf8', + \wp_is_valid_utf8( $sanitized['value'] ), + array( 'sanitizeEmail' => self::describe_string( $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, + "josejose@gr\u{00E5}.org", + "jos\u{00E9}jos\u{00E9}@gr\u{00E5}.org", + "jose\u{0301}jose\u{0301}@gr\u{00E5}.org", + ); + $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_normalization_sensitive_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + $pairs = array( + array( + 'label' => 'latin-acute', + 'nfc' => "jos\u{00E9}@example.com", + 'nfd' => "jose\u{0301}@example.com", + ), + array( + 'label' => 'latin-ring', + 'nfc' => "\u{00E5}ngstrom@example.com", + 'nfd' => "a\u{030A}ngstrom@example.com", + ), + array( + 'label' => 'greek-tonos', + 'nfc' => "\u{03AC}\u{03BB}\u{03C6}\u{03B1}@example.com", + 'nfd' => "\u{03B1}\u{0301}\u{03BB}\u{03C6}\u{03B1}@example.com", + ), + ); + $failures = array(); + $observed = array(); + + foreach ( $pairs as $pair ) { + $nfc_sanitized = self::call( static fn() => \sanitize_email( $pair['nfc'] ) ); + $nfd_sanitized = self::call( static fn() => \sanitize_email( $pair['nfd'] ) ); + $nfc_parsed = self::call( static fn() => \WP_Email_Address::from_string( $pair['nfc'], 'unicode' ) ); + $nfd_parsed = self::call( static fn() => \WP_Email_Address::from_string( $pair['nfd'], 'unicode' ) ); + $nfc_email = $nfc_parsed['value'] ?? null; + $nfd_email = $nfd_parsed['value'] ?? null; + $ok = ! $nfc_sanitized['threw'] + && ! $nfd_sanitized['threw'] + && ! $nfc_parsed['threw'] + && ! $nfd_parsed['threw'] + && $pair['nfc'] === $nfc_sanitized['value'] + && $pair['nfd'] === $nfd_sanitized['value'] + && $nfc_email instanceof \WP_Email_Address + && $nfd_email instanceof \WP_Email_Address + && $pair['nfc'] === $nfc_email->get_unicode_address() + && $pair['nfd'] === $nfd_email->get_unicode_address() + && $nfc_email->get_localpart() !== $nfd_email->get_localpart() + && $nfc_email->get_unicode_address() !== $nfd_email->get_unicode_address(); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $pair['label'], + 'nfc' => self::describe_string( $pair['nfc'] ), + 'nfd' => self::describe_string( $pair['nfd'] ), + 'nfcSanitized' => self::describe_call( $nfc_sanitized ), + 'nfdSanitized' => self::describe_call( $nfd_sanitized ), + 'nfcParsed' => self::describe_call( $nfc_parsed ), + 'nfdParsed' => self::describe_call( $nfd_parsed ), + ); + } + + $observed[] = array( + 'label' => $pair['label'], + 'nfcLocal' => $nfc_email instanceof \WP_Email_Address ? self::describe_string( $nfc_email->get_localpart() ) : null, + 'nfdLocal' => $nfd_email instanceof \WP_Email_Address ? self::describe_string( $nfd_email->get_localpart() ) : null, + 'localsDistinct' => $nfc_email instanceof \WP_Email_Address && $nfd_email instanceof \WP_Email_Address && $nfc_email->get_localpart() !== $nfd_email->get_localpart(), + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.normalization-sensitive-localparts-preserved', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_comment_author_email_filters( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! function_exists( 'wp_filter_comment' ) ) { + return array( + $ctx->skip( + 'email.comment-author-email.filter-agreement', + 'wp_filter_comment() is unavailable.' + ), + ); + } + + $cases = array( + array( + 'label' => 'cyrillic-local', + 'email' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", + 'expectEmail' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", + ), + array( + 'label' => 'hiragana-local', + 'email' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", + 'expectEmail' => "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", + ), + array( + 'label' => 'display-name-recovery', + 'email' => "Display Name ", + 'expectEmail' => "jos\u{00E9}@example.com", + ), + array( + 'label' => 'emoji-local-rejected', + 'email' => "emoji\u{1F600}@example.com", + 'expectEmail' => '', + ), + array( + 'label' => 'fullwidth-at-rejected', + 'email' => "bad\u{FF20}example.com", + 'expectEmail' => '', + ), + ); + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + \remove_all_filters( 'pre_comment_author_email' ); + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + + foreach ( $cases as $index => $case ) { + $comment = array( + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $case['email'], + 'comment_author_url' => '', + 'comment_content' => 'Unicode email comment case ' . $index, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'component-fuzz', + ); + $filtered = self::call( static fn() => \wp_filter_comment( $comment ) ); + $sanitized = self::call( static fn() => \sanitize_email( trim( $case['email'] ) ) ); + $filtered_email = ! $filtered['threw'] && is_array( $filtered['value'] ) + ? ( $filtered['value']['comment_author_email'] ?? null ) + : null; + $filtered_valid = '' === $filtered_email + ? array( 'threw' => false, 'value' => false ) + : self::call( static fn() => \is_email( $filtered_email ) ); + $parsed = '' === $filtered_email || ! is_string( $filtered_email ) + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $filtered_email, 'unicode' ) ); + $ok = ! $filtered['threw'] + && ! $sanitized['threw'] + && ! $filtered_valid['threw'] + && ! $parsed['threw'] + && is_array( $filtered['value'] ) + && true === ( $filtered['value']['filtered'] ?? null ) + && $case['expectEmail'] === $filtered_email + && $sanitized['value'] === $filtered_email + && ( + '' === $filtered_email + ? false === $filtered_valid['value'] && null === $parsed['value'] + : $filtered_email === $filtered_valid['value'] && $parsed['value'] instanceof \WP_Email_Address + ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['email'] ), + 'expectedEmail' => self::describe_string( $case['expectEmail'] ), + 'filteredEmail' => self::describe_value( $filtered_email ), + 'filtered' => self::describe_call( $filtered ), + 'sanitized' => self::describe_call( $sanitized ), + 'isEmail' => self::describe_call( $filtered_valid ), + 'parsed' => self::describe_call( $parsed ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['email'] ), + 'filteredEmail' => self::describe_value( $filtered_email ), + 'accepted' => '' !== $filtered_email, + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.comment-author-email.filter-agreement', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_comment_submission_unicode_email_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.comment-submission.unicode-address-paths'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'add_action', + 'add_filter', + 'create_initial_post_types', + 'get_comment', + 'is_email', + 'is_wp_error', + 'remove_all_filters', + 'sanitize_email', + 'wp_handle_comment_submission', + 'wp_new_comment', + 'wp_set_current_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + foreach ( array( 'WP_Comment', 'WP_Email_Address', 'WP_Error' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress comment submission APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_comment_submission_cases( $ctx->fork( 'comment-submission' ) ); + $failures = array(); + $observed = array(); + $insert_events = array(); + $post_events = array(); + $submission_hooks = array( + 'preprocess_comment', + 'pre_comment_author_email', + 'pre_comment_author_name', + 'pre_comment_author_url', + 'pre_comment_content', + 'pre_comment_user_agent', + 'pre_comment_user_ip', + 'pre_comment_approved', + 'duplicate_comment_id', + 'check_comment_flood', + 'wp_is_comment_flood', + 'comment_post', + 'wp_insert_comment', + 'comment_text', + 'comment_max_links_url', + 'wp_check_comment_disallowed_list', + 'pre_option_comment_registration', + 'pre_option_require_name_email', + 'pre_option_comment_moderation', + 'pre_option_comment_max_links', + 'pre_option_moderation_keys', + 'pre_option_disallowed_keys', + 'pre_option_comment_previously_approved', + 'pre_option_comments_notify', + 'pre_option_moderation_notify', + 'pre_option_admin_email', + 'pre_option_blogname', + ); + $hook_snapshot = self::snapshot_hook_globals(); + $hook_state_before = self::snapshot_selected_hook_state( $submission_hooks ); + $server_keys = array( 'REMOTE_ADDR', 'HTTP_USER_AGENT', 'REQUEST_URI', 'HTTP_HOST' ); + $global_keys = array( + 'current_user', + 'user_ID', + 'userdata', + 'user_login', + 'user_level', + 'user_email', + 'user_url', + 'user_identity', + 'post', + 'comment', + 'wp_post_types', + 'wp_post_statuses', + '_wp_post_type_features', + 'post_type_meta_caps', + ); + $server_snapshot = self::snapshot_array_keys( $_SERVER, $server_keys ); + $global_snapshot = self::snapshot_named_globals( $global_keys ); + + self::reset_stub_content(); + $content_before = self::stub_content_counts(); + + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_USER_AGENT'] = 'ComponentFuzz EmailSurface'; + $_SERVER['REQUEST_URI'] = '/component-fuzz/email-comment-unicode/'; + $_SERVER['HTTP_HOST'] = 'example.test'; + + \wp_set_current_user( 0 ); + \create_initial_post_types(); + self::install_email_filters( 'unicode' ); + self::install_comment_submission_filters( $insert_events, $post_events ); + + foreach ( $cases as $case_index => $case ) { + $post_id = self::seed_comment_submission_post( $ctx, $case_index, $case ); + if ( $post_id <= 0 ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'post-seed-failed', + ); + continue; + } + + $comments_before = self::stub_comment_count(); + $insert_before = count( $insert_events ); + $post_before = count( $post_events ); + $content = 'Unicode comment submission ' . $ctx->iteration() . '.' . $case_index . ' ' . $case['label']; + $submit = self::capture_warnings( + static function () use ( $case, $post_id, $content ) { + if ( 'handle-submission' === $case['path'] ) { + return \wp_handle_comment_submission( + array( + 'comment_post_ID' => $post_id, + 'author' => 'Component Fuzzer', + 'email' => $case['input'], + 'url' => '', + 'comment' => $content, + 'comment_parent' => 0, + ) + ); + } + + return \wp_new_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => 0, + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $case['input'], + 'comment_author_url' => '', + 'comment_content' => $content, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'ComponentFuzz EmailSurface', + 'comment_date' => '2026-06-26 12:00:00', + 'comment_date_gmt' => '2026-06-26 10:00:00', + 'comment_type' => 'comment', + 'user_id' => 0, + ), + true + ); + } + ); + $comments_after = self::stub_comment_count(); + $case_inserts = array_slice( $insert_events, $insert_before ); + $case_posts = array_slice( $post_events, $post_before ); + + if ( $case['accepted'] || 'wp-new-comment-sanitizes-invalid' === $case['expectedError'] ) { + $comment_id = 0; + $returned = $submit['value'] ?? null; + $return_shape_ok = 'handle-submission' === $case['path'] + ? $returned instanceof \WP_Comment + : is_int( $returned ); + if ( 'handle-submission' === $case['path'] && $returned instanceof \WP_Comment ) { + $comment_id = (int) $returned->comment_ID; + } elseif ( 'wp-new-comment' === $case['path'] && is_int( $returned ) ) { + $comment_id = $returned; + } + + $stored = $comment_id > 0 + ? self::capture_warnings( static fn() => \get_comment( $comment_id ) ) + : self::not_called(); + $stored_comment = $stored['value'] ?? null; + $expected_email = (string) $case['expectedEmail']; + $raw_sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $expected_sanitized = self::capture_warnings( static fn() => \sanitize_email( $expected_email ) ); + $is_email = '' === $expected_email + ? self::not_called() + : self::capture_warnings( static fn() => \is_email( $expected_email ) ); + $parsed = '' === $expected_email + ? self::not_called() + : self::capture_warnings( static fn() => \WP_Email_Address::from_string( $expected_email, 'unicode' ) ); + $email = $parsed['value'] ?? null; + $insert_event = $case_inserts[0] ?? null; + $post_event = $case_posts[0] ?? null; + + $stored_ok = $stored_comment instanceof \WP_Comment + && $expected_email === $stored_comment->comment_author_email + && (string) $post_id === (string) $stored_comment->comment_post_ID + && '1' === (string) $stored_comment->comment_approved; + $oracle_ok = ! $raw_sanitized['threw'] + && array() === $raw_sanitized['warnings'] + && ! $expected_sanitized['threw'] + && array() === $expected_sanitized['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $parsed['threw'] + && array() === $parsed['warnings'] + && $expected_email === $raw_sanitized['value'] + && $expected_email === $expected_sanitized['value'] + && ( + '' === $expected_email + ? null === $is_email['value'] && null === $parsed['value'] + : $expected_email === $is_email['value'] + && $email instanceof \WP_Email_Address + && $expected_email === $email->get_unicode_address() + ); + $events_ok = 1 === count( $case_inserts ) + && 1 === count( $case_posts ) + && is_array( $insert_event ) + && is_array( $post_event ) + && $comment_id === (int) ( $insert_event['id'] ?? 0 ) + && $comment_id === (int) ( $post_event['id'] ?? 0 ) + && $post_id === (int) ( $insert_event['postId'] ?? 0 ) + && $post_id === (int) ( $post_event['postId'] ?? 0 ) + && $expected_email === ( $insert_event['email'] ?? null ) + && $expected_email === ( $post_event['email'] ?? null ) + && '1' === (string) ( $insert_event['approved'] ?? '' ) + && '1' === (string) ( $post_event['approved'] ?? '' ) + && true === ( $post_event['filtered'] ?? null ); + $ok = ! $submit['threw'] + && array() === $submit['warnings'] + && $return_shape_ok + && ! $stored['threw'] + && array() === $stored['warnings'] + && $comment_id > 0 + && $comments_after === $comments_before + 1 + && $stored_ok + && $oracle_ok + && $events_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'input' => self::describe_string( $case['input'] ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'submit' => self::describe_captured_call( $submit ), + 'stored' => self::describe_captured_call( $stored ), + 'rawSanitized' => self::describe_captured_call( $raw_sanitized ), + 'expectedSanitized' => self::describe_captured_call( $expected_sanitized ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'parsed' => self::describe_captured_call( $parsed ), + 'insertEvents' => self::describe_value( $case_inserts ), + 'postEvents' => self::describe_value( $case_posts ), + 'returnShapeOk' => $return_shape_ok, + 'commentsBefore' => $comments_before, + 'commentsAfter' => $comments_after, + 'storedOk' => $stored_ok, + 'oracleOk' => $oracle_ok, + 'eventsOk' => $events_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'accepted' => (bool) $case['accepted'], + 'inserted' => true, + 'expectedEmail' => self::describe_string( $expected_email ), + 'commentId' => $comment_id, + 'traits' => implode( ',', $case['traits'] ), + ); + continue; + } + + $is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['input'] ) ); + $parsed = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $error = $submit['value'] ?? null; + $ok = ! $submit['threw'] + && array() === $submit['warnings'] + && \is_wp_error( $error ) + && $case['expectedError'] === $error->get_error_code() + && $comments_after === $comments_before + && array() === $case_inserts + && array() === $case_posts + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && false === $is_email['value'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && '' === $sanitized['value'] + && ! $parsed['threw'] + && array() === $parsed['warnings'] + && null === $parsed['value']; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'input' => self::describe_string( $case['input'] ), + 'expectedError' => $case['expectedError'], + 'submit' => self::describe_captured_call( $submit ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + 'parsed' => self::describe_captured_call( $parsed ), + 'insertEvents' => self::describe_value( $case_inserts ), + 'postEvents' => self::describe_value( $case_posts ), + 'commentsBefore' => $comments_before, + 'commentsAfter' => $comments_after, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'path' => $case['path'], + 'accepted' => false, + 'error' => \is_wp_error( $error ) ? $error->get_error_code() : self::describe_value( $error ), + 'traits' => implode( ',', $case['traits'] ), + 'commentsHeld' => $comments_after === $comments_before, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::restore_named_globals( $global_snapshot ); + self::restore_array_keys( $_SERVER, $server_snapshot ); + self::reset_stub_content(); + } + + $hook_state_after = self::snapshot_selected_hook_state( $submission_hooks ); + $server_after = self::snapshot_array_keys( $_SERVER, $server_keys ); + $global_after = self::snapshot_named_globals( $global_keys ); + $content_after = self::stub_content_counts(); + $state_ok = $hook_state_before === $hook_state_after + && $server_snapshot === $server_after + && $global_snapshot === $global_after + && $content_before === $content_after; + + if ( ! $state_ok ) { + $failures[] = array( + 'failure' => 'state-not-restored', + 'hooksBefore' => self::describe_value( $hook_state_before ), + 'hooksAfter' => self::describe_value( $hook_state_after ), + 'serverBefore' => self::describe_value( $server_snapshot ), + 'serverAfter' => self::describe_value( $server_after ), + 'globalsBefore' => self::describe_value( $global_snapshot ), + 'globalsAfter' => self::describe_value( $global_after ), + 'contentBefore' => self::describe_value( $content_before ), + 'contentAfter' => self::describe_value( $content_after ), + ); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'idnSupported' => self::has_idn(), + 'observed' => $observed, + 'stateRestored' => $state_ok, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_rest_email_schema_filter_modes( \ComponentFuzz\FuzzContext $ctx ): array { + foreach ( array( 'rest_validate_value_from_schema', 'rest_sanitize_value_from_schema' ) as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.rest-schema.email-format-filter-modes', + 'Required REST schema APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + if ( ! class_exists( 'WP_REST_Users_Controller' ) ) { + return array( + $ctx->skip( + 'email.rest-schema.email-format-filter-modes', + 'WP_REST_Users_Controller is unavailable.' + ), + ); + } + + $controller = new \WP_REST_Users_Controller(); + $item_schema = $controller->get_item_schema(); + $email_schema = $item_schema['properties']['email'] ?? null; + + if ( + ! is_array( $email_schema ) || + 'string' !== ( $email_schema['type'] ?? null ) || + 'email' !== ( $email_schema['format'] ?? null ) + ) { + return array( + $ctx->result( + 'email.rest-schema.email-format-filter-modes', + false, + array( + 'emailSchema' => self::describe_value( $email_schema ), + ) + ), + ); + } + + $cases = array( + array( + 'label' => 'ascii', + 'input' => 'user@example.com', + 'expect' => array( + 'unicode' => true, + 'ascii' => true, + ), + ), + array( + 'label' => 'unicode-local', + 'input' => "jos\u{00E9}@example.com", + 'expect' => array( + 'unicode' => true, + 'ascii' => false, + ), + ), + array( + 'label' => 'invalid-unicode-local', + 'input' => "emoji\u{1F600}@example.com", + 'expect' => array( + 'unicode' => false, + 'ascii' => false, + ), + ), + array( + 'label' => 'fullwidth-at', + 'input' => "bad\u{FF20}example.com", + 'expect' => array( + 'unicode' => false, + 'ascii' => false, + ), + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'unicode-local-and-domain', + 'input' => "gr\u{00E5}@gr\u{00E5}.org", + 'expect' => array( + 'unicode' => true, + 'ascii' => false, + ), + ); + } + + $failures = array(); + $observed = array(); + $snapshot = self::snapshot_hook_globals(); + + try { + foreach ( array( 'unicode', 'ascii' ) as $mode ) { + self::install_email_filters( $mode ); + + foreach ( $cases as $case ) { + $expected_valid = $case['expect'][ $mode ]; + $validate = self::capture_warnings( + static fn() => \rest_validate_value_from_schema( $case['input'], $email_schema, 'email' ) + ); + $sanitize = self::capture_warnings( + static fn() => \rest_sanitize_value_from_schema( $case['input'], $email_schema, 'email' ) + ); + $sanitized = $sanitize['value'] ?? null; + $validate_sanitized = is_string( $sanitized ) + ? self::capture_warnings( + static fn() => \rest_validate_value_from_schema( $sanitized, $email_schema, 'email' ) + ) + : array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + $is_email = self::capture_warnings( static fn() => \is_email( $case['input'] ) ); + + $validation_result = $validate['value'] ?? null; + $sanitized_validation_result = $validate_sanitized['value'] ?? null; + $is_email_result = $is_email['value'] ?? null; + $validation_ok = true === $validation_result; + $sanitized_validation_ok = true === $sanitized_validation_result; + $is_email_ok = false !== $is_email_result; + $validation_error_ok = $expected_valid || ( + \is_wp_error( $validation_result ) && + 'rest_invalid_email' === $validation_result->get_error_code() + ); + $sanitized_validation_error_ok = $expected_valid || ( + \is_wp_error( $sanitized_validation_result ) && + 'rest_invalid_email' === $sanitized_validation_result->get_error_code() + ); + + $ok = ! $validate['threw'] + && ! $sanitize['threw'] + && ! $validate_sanitized['threw'] + && ! $is_email['threw'] + && array() === $validate['warnings'] + && array() === $sanitize['warnings'] + && array() === $validate_sanitized['warnings'] + && array() === $is_email['warnings'] + && $expected_valid === $validation_ok + && $expected_valid === $sanitized_validation_ok + && $expected_valid === $is_email_ok + && $validation_error_ok + && $sanitized_validation_error_ok + && $case['input'] === $sanitized + && ( ! $expected_valid || $case['input'] === $is_email_result ); + + if ( ! $ok ) { + $failures[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'validate' => self::describe_captured_call( $validate ), + 'sanitize' => self::describe_captured_call( $sanitize ), + 'validateSanitized' => self::describe_captured_call( $validate_sanitized ), + 'isEmail' => self::describe_captured_call( $is_email ), + ); + } + + $observed[] = array( + 'mode' => $mode, + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $expected_valid, + 'validation' => self::describe_value( $validation_result ), + 'sanitized' => self::describe_value( $sanitized ), + 'sanitizedValid' => self::describe_value( $sanitized_validation_result ), + 'isEmail' => self::describe_value( $is_email_result ), + ); + } + } + + if ( ! self::has_idn() ) { + $observed[] = array( + 'label' => 'unicode-local-and-domain', + 'status' => 'skipped-idn-unavailable', + ); + } + } finally { + self::restore_hook_globals( $snapshot ); + } + + return array( + $ctx->result( + 'email.rest-schema.email-format-filter-modes', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_user_email_indexes_distinct_localparts( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-localparts-preserved', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $inputs = array( + 'josejose@example.org', + 'joséjosé@example.org', + "jose\u{0301}jose\u{0301}@example.org", + ); + $inserted = array(); + $lookups = array(); + $failures = array(); + + self::reset_stub_content(); + try { + foreach ( $inputs as $index => $input ) { + $user_id = \wp_insert_user( + array( + 'user_login' => 'cfz_email_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $input ), 0, 10 ), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ); + + $inserted[] = $user_id; + + if ( ! is_int( $user_id ) ) { + $failures[] = array( + 'label' => 'insert-failed', + 'input' => self::describe_string( $input ), + 'result' => self::describe_value( $user_id ), + ); + continue; + } + + $exists = \email_exists( $input ); + $by_email = \get_user_by( 'email', $input ); + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists, + 'byEmail' => $by_email instanceof \WP_User ? $by_email->ID : self::describe_value( $by_email ), + ); + + if ( $exists !== $user_id || ! ( $by_email instanceof \WP_User ) || $by_email->ID !== $user_id ) { + $failures[] = array( + 'label' => 'lookup-mismatch', + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists, + 'byEmail' => self::describe_value( $by_email ), + ); + } + } + + $duplicate = \wp_insert_user( + array( + 'user_login' => 'cfz_email_duplicate_' . $ctx->iteration(), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $inputs[1], + 'role' => 'subscriber', + ) + ); + + if ( + count( array_filter( $inserted, 'is_int' ) ) !== count( $inputs ) + || count( array_unique( array_filter( $inserted, 'is_int' ), SORT_REGULAR ) ) !== count( $inputs ) + ) { + $failures[] = array( + 'label' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + if ( ! \is_wp_error( $duplicate ) || 'existing_user_email' !== $duplicate->get_error_code() ) { + $failures[] = array( + 'label' => 'exact-duplicate-not-rejected', + 'duplicate' => self::describe_value( $duplicate ), + ); + } + } finally { + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.distinct-localparts-preserved', + array() === $failures, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'inserted' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_generated_localpart_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.generated-localpart-aliases-clean', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $cases = self::generated_localpart_alias_cases( $ctx->fork( 'user-email-localpart-aliases' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + + self::reset_stub_content(); + try { + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + + $inserted = array(); + $lookups = array(); + $canonical_addresses = array(); + + foreach ( $case['addresses'] as $address_index => $input ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $login = 'cfz_alias_local_' . $ctx->iteration() . '_' . $case_index . '_' . $address_index . '_' . substr( sha1( $input ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $inserted[] = $user_id; + + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists_input = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $input ) ) : $not_called(); + $by_input = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $input ) ) : $not_called(); + $exists_canonical = is_int( $user_id ) && is_string( $canonical ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_canonical = is_int( $user_id ) && is_string( $canonical ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_input_user = $by_input['value'] ?? null; + $by_canonical_user = $by_canonical['value'] ?? null; + + if ( is_string( $canonical ) ) { + $canonical_addresses[] = $canonical; + } + + $ok = ! $parse['threw'] + && ! $insert['threw'] + && ! $stored['threw'] + && ! $exists_input['threw'] + && ! $by_input['threw'] + && ! $exists_canonical['threw'] + && ! $by_canonical['threw'] + && array() === $parse['warnings'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists_input['warnings'] + && array() === $by_input['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $by_canonical['warnings'] + && $email instanceof \WP_Email_Address + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $exists_input['value'] === $user_id + && $by_input_user instanceof \WP_User + && $by_input_user->ID === $user_id + && $exists_canonical['value'] === $user_id + && $by_canonical_user instanceof \WP_User + && $by_canonical_user->ID === $user_id; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'parse' => self::describe_captured_call( $parse ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'existsInput' => self::describe_captured_call( $exists_input ), + 'byInput' => self::describe_captured_call( $by_input ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + ); + } + + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'userId' => $user_id, + 'warnings' => count( $parse['warnings'] ) + count( $insert['warnings'] ) + count( $stored['warnings'] ) + count( $exists_input['warnings'] ) + count( $by_input['warnings'] ) + count( $exists_canonical['warnings'] ) + count( $by_canonical['warnings'] ), + ); + } + + $ids = array_values( array_filter( $inserted, 'is_int' ) ); + if ( count( $ids ) !== count( $case['addresses'] ) || count( array_unique( $ids, SORT_REGULAR ) ) !== count( $case['addresses'] ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + if ( count( $canonical_addresses ) !== count( $case['addresses'] ) || count( array_unique( $canonical_addresses, SORT_REGULAR ) ) !== count( $case['addresses'] ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'canonical-addresses-not-distinct', + 'addresses' => self::describe_value( $canonical_addresses ), + ); + } + + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_alias_local_duplicate_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $case['addresses'][1], + 'role' => 'subscriber', + ) + ) + ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + $hostile_lookup = $case['addresses'][0]; + $hostile_candidate = $case['addresses'][1] ?? null; + $hostile_probe = array( + 'ok' => false, + 'reason' => 'not-run', + ); + if ( is_string( $hostile_candidate ) && $hostile_lookup !== $hostile_candidate ) { + self::reset_stub_content(); + $hostile_seed = self::seed_stub_user_email_row( + $hostile_candidate, + 'cfz_alias_local_hostile_' . $ctx->iteration() . '_' . $case_index + ); + if ( ! $hostile_seed['threw'] && array() === $hostile_seed['warnings'] && is_int( $hostile_seed['value'] ?? null ) ) { + $hostile_probe = self::hostile_user_email_lookup_probe( $hostile_lookup, $hostile_candidate ); + } else { + $hostile_probe = array( + 'ok' => false, + 'reason' => 'candidate-seed-failed', + 'seed' => self::describe_captured_call( $hostile_seed ), + ); + } + self::reset_stub_content(); + } + + if ( true !== ( $hostile_probe['ok'] ?? false ) ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'accent-folded-db-candidate-not-ignored', + 'lookup' => self::describe_string( $hostile_lookup ), + 'candidate' => is_string( $hostile_candidate ) ? self::describe_string( $hostile_candidate ) : null, + 'probe' => self::describe_value( $hostile_probe ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'addressCount' => count( $case['addresses'] ), + 'insertedIds' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'duplicateOk' => ! $duplicate['threw'] && array() === $duplicate['warnings'] && \is_wp_error( $duplicate['value'] ?? null ), + 'hostileLookup' => array( + 'lookup' => self::describe_string( $hostile_lookup ), + 'candidate' => is_string( $hostile_candidate ) ? self::describe_string( $hostile_candidate ) : null, + 'ok' => true === ( $hostile_probe['ok'] ?? false ), + ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.generated-localpart-aliases-clean', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_updates_generated_localpart_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.user-email-updates.generated-localpart-aliases-clean'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'email_exists', + 'get_user_by', + 'is_email', + 'sanitize_email', + 'wp_insert_user', + 'wp_mail', + 'wp_update_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress user update APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_localpart_update_alias_cases( $ctx->fork( 'user-email-localpart-update-aliases' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $mail_calls = array(); + $mail_filter = static function ( $return, array $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + $mail_calls = array(); + $canonical_addresses = array(); + $address_oracles = array(); + + foreach ( $case['addresses'] as $address ) { + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $is_email = self::capture_warnings( static fn() => \is_email( $address ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $address ) ); + $oracle_ok = ! $parse['threw'] + && array() === $parse['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && $email instanceof \WP_Email_Address + && $canonical === $is_email['value'] + && $canonical === $sanitized['value']; + + if ( ! $oracle_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'address-oracle-mismatch', + 'address' => self::describe_string( $address ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + ); + } + + if ( is_string( $canonical ) ) { + $canonical_addresses[] = $canonical; + } + + $address_oracles[] = array( + 'input' => self::describe_string( $address ), + 'canonical' => is_string( $canonical ) ? self::describe_string( $canonical ) : null, + 'ok' => $oracle_ok, + ); + } + + if ( + count( $canonical_addresses ) !== count( $case['addresses'] ) || + count( array_unique( $canonical_addresses, SORT_REGULAR ) ) !== count( $case['addresses'] ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'canonical-addresses-not-distinct', + 'addresses' => self::describe_value( $case['addresses'] ), + 'canonical' => self::describe_value( $canonical_addresses ), + 'oracles' => $address_oracles, + ); + continue; + } + + $primary_email = $canonical_addresses[0]; + $holder_email = 'cfz-update-holder-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $primary_email ), 0, 10 ) . '@example.org'; + $primary_login = 'cfz_update_primary_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $primary_email ), 0, 8 ); + $other_login = 'cfz_update_other_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $holder_email ), 0, 8 ); + + $primary_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $primary_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $primary_email, + 'role' => 'subscriber', + ) + ) + ); + $other_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $holder_email, + 'role' => 'subscriber', + ) + ) + ); + $primary_id = $primary_insert['value'] ?? null; + $other_id = $other_insert['value'] ?? null; + + if ( + $primary_insert['threw'] || + $other_insert['threw'] || + array() !== $primary_insert['warnings'] || + array() !== $other_insert['warnings'] || + ! is_int( $primary_id ) || + ! is_int( $other_id ) + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'seed-users-not-inserted-cleanly', + 'primaryEmail' => self::describe_string( $primary_email ), + 'holderEmail' => self::describe_string( $holder_email ), + 'primaryInsert' => self::describe_captured_call( $primary_insert ), + 'otherInsert' => self::describe_captured_call( $other_insert ), + ); + continue; + } + + $current_email = $holder_email; + $updates = array(); + foreach ( array_slice( $case['addresses'], 1, null, true ) as $address_index => $input_address ) { + $expected_email = $canonical_addresses[ $address_index ]; + $mail_before = count( $mail_calls ); + $update = self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $input_address, + ) + ) + ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ); + $by_new = self::capture_warnings( static fn() => \get_user_by( 'email', $expected_email ) ); + $exists_new = self::capture_warnings( static fn() => \email_exists( $expected_email ) ); + $by_old = self::capture_warnings( static fn() => \get_user_by( 'email', $current_email ) ); + $exists_old = self::capture_warnings( static fn() => \email_exists( $current_email ) ); + $primary_exists = self::capture_warnings( static fn() => \email_exists( $primary_email ) ); + $mail_slice = array_slice( $mail_calls, $mail_before ); + $mail = $mail_slice[0] ?? null; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $stored_user = $stored['value'] ?? null; + $new_user = $by_new['value'] ?? null; + $update_ok = ! $update['threw'] + && array() === $update['warnings'] + && $update['value'] === $other_id + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $other_id + && $expected_email === $stored_user->user_email + && ! $by_new['threw'] + && array() === $by_new['warnings'] + && $new_user instanceof \WP_User + && $new_user->ID === $other_id + && ! $exists_new['threw'] + && array() === $exists_new['warnings'] + && $exists_new['value'] === $other_id + && ! $by_old['threw'] + && array() === $by_old['warnings'] + && false === $by_old['value'] + && ! $exists_old['threw'] + && array() === $exists_old['warnings'] + && false === $exists_old['value'] + && ! $primary_exists['threw'] + && array() === $primary_exists['warnings'] + && $primary_exists['value'] === $primary_id; + $mail_ok = 1 === count( $mail_slice ) + && is_array( $mail ) + && $current_email === ( $mail['to'] ?? null ) + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && str_contains( $message, $current_email ) + && str_contains( $message, $expected_email ); + + if ( ! $update_ok || ! $mail_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'update-alias-not-preserved-cleanly', + 'input' => self::describe_string( $input_address ), + 'previousEmail' => self::describe_string( $current_email ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'update' => self::describe_captured_call( $update ), + 'stored' => self::describe_captured_call( $stored ), + 'byNew' => self::describe_captured_call( $by_new ), + 'existsNew' => self::describe_captured_call( $exists_new ), + 'byOld' => self::describe_captured_call( $by_old ), + 'existsOld' => self::describe_captured_call( $exists_old ), + 'primaryExists' => self::describe_captured_call( $primary_exists ), + 'mailCalls' => self::describe_value( $mail_slice ), + 'updateOk' => $update_ok, + 'mailOk' => $mail_ok, + ); + } + + $updates[] = array( + 'input' => self::describe_string( $input_address ), + 'previousEmail' => self::describe_string( $current_email ), + 'expectedEmail' => self::describe_string( $expected_email ), + 'ok' => $update_ok && $mail_ok, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ); + $current_email = $expected_email; + } + + $mail_before = count( $mail_calls ); + $collision_update = self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $case['addresses'][0], + ) + ) + ); + $stored_after_collision = self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ); + $primary_lookup = self::capture_warnings( static fn() => \email_exists( $primary_email ) ); + $current_lookup = self::capture_warnings( static fn() => \email_exists( $current_email ) ); + $stored_after_user = $stored_after_collision['value'] ?? null; + $collision_ok = ! $collision_update['threw'] + && array() === $collision_update['warnings'] + && \is_wp_error( $collision_update['value'] ?? null ) + && 'existing_user_email' === $collision_update['value']->get_error_code() + && ! $stored_after_collision['threw'] + && array() === $stored_after_collision['warnings'] + && $stored_after_user instanceof \WP_User + && $stored_after_user->ID === $other_id + && $current_email === $stored_after_user->user_email + && ! $primary_lookup['threw'] + && array() === $primary_lookup['warnings'] + && $primary_lookup['value'] === $primary_id + && ! $current_lookup['threw'] + && array() === $current_lookup['warnings'] + && $current_lookup['value'] === $other_id + && $mail_before === count( $mail_calls ); + + if ( ! $collision_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'exact-collision-update-not-rejected-cleanly', + 'primaryEmail' => self::describe_string( $primary_email ), + 'currentEmail' => self::describe_string( $current_email ), + 'collisionUpdate' => self::describe_captured_call( $collision_update ), + 'storedAfterCollision' => self::describe_captured_call( $stored_after_collision ), + 'primaryLookup' => self::describe_captured_call( $primary_lookup ), + 'currentLookup' => self::describe_captured_call( $current_lookup ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_before ) ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'addressCount' => count( $case['addresses'] ), + 'canonical' => self::describe_value( $canonical_addresses ), + 'updates' => $updates, + 'collisionRejected' => $collision_ok, + 'mailCalls' => count( $mail_calls ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_authentication_unicode_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.user-email-authentication.unicode-exact-addresses'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'email_exists', + 'get_user_by', + 'is_email', + 'is_wp_error', + 'sanitize_email', + 'wp_authenticate_email_password', + 'wp_insert_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'WP_Email_Address', 'WP_Error', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress user authentication APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_authentication_cases( $ctx->fork( 'user-email-authentication' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'wp_authenticate_user' ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $alias_parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['aliasInput'], 'unicode' ) ); + $email = $parse['value'] ?? null; + $alias_email = $alias_parse['value'] ?? null; + + if ( + null !== $case['conversionError'] + || $parse['threw'] + || $alias_parse['threw'] + || array() !== $parse['warnings'] + || array() !== $alias_parse['warnings'] + || ! $email instanceof \WP_Email_Address + || ! $alias_email instanceof \WP_Email_Address + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'authentication-address-oracle-unavailable', + 'input' => self::describe_string( $case['input'] ), + 'aliasInput' => self::describe_string( $case['aliasInput'] ), + 'conversionError' => $case['conversionError'], + 'parse' => self::describe_captured_call( $parse ), + 'aliasParse' => self::describe_captured_call( $alias_parse ), + ); + continue; + } + + $canonical = $email->get_unicode_address(); + $machine = $email->get_ascii_address(); + $alias_canonical = $alias_email->get_unicode_address(); + $password = 'cfz-auth-pass-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $canonical ), 0, 12 ); + $alias_password = 'cfz-auth-alias-pass-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $alias_canonical ), 0, 12 ); + $login = 'cfz_auth_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $canonical ), 0, 8 ); + $alias_login = 'cfz_auth_alias_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $alias_canonical ), 0, 8 ); + + if ( $canonical === $alias_canonical ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'authentication-alias-not-distinct', + 'canonical' => self::describe_string( $canonical ), + 'alias' => self::describe_string( $alias_canonical ), + ); + continue; + } + + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => $password, + 'user_email' => $case['input'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + $alias_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $alias_login, + 'user_pass' => $alias_password, + 'user_email' => $case['aliasInput'], + 'role' => 'subscriber', + ) + ) + ); + $alias_id = $alias_insert['value'] ?? null; + + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : self::not_called(); + $by_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : self::not_called(); + $exists_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : self::not_called(); + $alias_stored = is_int( $alias_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $alias_id ) ) : self::not_called(); + $alias_by_email = is_int( $alias_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $alias_canonical ) ) : self::not_called(); + $auth = is_int( $user_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $canonical, $password ) ) : self::not_called(); + $wrong_password = is_int( $user_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $canonical, $alias_password ) ) : self::not_called(); + $alias_auth = is_int( $alias_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $alias_canonical, $alias_password ) ) : self::not_called(); + $alias_with_target_password = is_int( $alias_id ) ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $alias_canonical, $password ) ) : self::not_called(); + $machine_auth = $machine !== $canonical && is_int( $user_id ) + ? self::capture_warnings( static fn() => \wp_authenticate_email_password( null, $machine, $password ) ) + : self::not_called(); + + $stored_user = $stored['value'] ?? null; + $canonical_user = $by_canonical['value'] ?? null; + $alias_stored_user = $alias_stored['value'] ?? null; + $alias_lookup_user = $alias_by_email['value'] ?? null; + $auth_user = $auth['value'] ?? null; + $alias_auth_user = $alias_auth['value'] ?? null; + $wrong_error = $wrong_password['value'] ?? null; + $alias_wrong_error = $alias_with_target_password['value'] ?? null; + $machine_auth_value = $machine_auth['value'] ?? null; + + $insert_lookup_ok = ! $insert['threw'] + && ! $alias_insert['threw'] + && ! $stored['threw'] + && ! $by_canonical['threw'] + && ! $exists_canonical['threw'] + && ! $alias_stored['threw'] + && ! $alias_by_email['threw'] + && array() === $insert['warnings'] + && array() === $alias_insert['warnings'] + && array() === $stored['warnings'] + && array() === $by_canonical['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $alias_stored['warnings'] + && array() === $alias_by_email['warnings'] + && is_int( $user_id ) + && is_int( $alias_id ) + && $user_id !== $alias_id + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $canonical_user instanceof \WP_User + && $canonical_user->ID === $user_id + && $exists_canonical['value'] === $user_id + && $alias_stored_user instanceof \WP_User + && $alias_stored_user->ID === $alias_id + && $alias_canonical === $alias_stored_user->user_email + && $alias_lookup_user instanceof \WP_User + && $alias_lookup_user->ID === $alias_id; + + $auth_ok = ! $auth['threw'] + && ! $wrong_password['threw'] + && ! $alias_auth['threw'] + && ! $alias_with_target_password['threw'] + && array() === $auth['warnings'] + && array() === $wrong_password['warnings'] + && array() === $alias_auth['warnings'] + && array() === $alias_with_target_password['warnings'] + && $auth_user instanceof \WP_User + && $auth_user->ID === $user_id + && $canonical === $auth_user->user_email + && \is_wp_error( $wrong_error ) + && 'incorrect_password' === $wrong_error->get_error_code() + && $alias_auth_user instanceof \WP_User + && $alias_auth_user->ID === $alias_id + && $alias_canonical === $alias_auth_user->user_email + && \is_wp_error( $alias_wrong_error ) + && 'incorrect_password' === $alias_wrong_error->get_error_code(); + + $machine_probe_ok = $machine === $canonical + ? null === $machine_auth['value'] + : ( + ! $machine_auth['threw'] + && array() === $machine_auth['warnings'] + && \is_wp_error( $machine_auth_value ) + && 'invalid_email' === $machine_auth_value->get_error_code() + ); + + if ( ! $insert_lookup_ok || ! $auth_ok || ! $machine_probe_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'input' => self::describe_string( $case['input'] ), + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'alias' => self::describe_string( $alias_canonical ), + 'insert' => self::describe_captured_call( $insert ), + 'aliasInsert' => self::describe_captured_call( $alias_insert ), + 'stored' => self::describe_captured_call( $stored ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'aliasStored' => self::describe_captured_call( $alias_stored ), + 'aliasByEmail' => self::describe_captured_call( $alias_by_email ), + 'auth' => self::describe_captured_call( $auth ), + 'wrongPassword' => self::describe_captured_call( $wrong_password ), + 'aliasAuth' => self::describe_captured_call( $alias_auth ), + 'aliasWithTargetPassword' => self::describe_captured_call( $alias_with_target_password ), + 'machineAuth' => self::describe_captured_call( $machine_auth ), + 'insertLookupOk' => $insert_lookup_ok, + 'authOk' => $auth_ok, + 'machineProbeOk' => $machine_probe_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'source' => $case['source'], + 'inputDomainView' => $case['inputDomainView'], + 'canonical' => self::describe_string( $canonical ), + 'machineDiffers' => $machine !== $canonical, + 'alias' => self::describe_string( $alias_canonical ), + 'userId' => $user_id, + 'aliasId' => $alias_id, + 'exactAuthUserId' => $auth_user instanceof \WP_User ? $auth_user->ID : null, + 'aliasAuthUserId' => $alias_auth_user instanceof \WP_User ? $alias_auth_user->ID : null, + 'machineRejected' => $machine !== $canonical ? \is_wp_error( $machine_auth_value ) : null, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_profile_email_confirmation_unicode_paths( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.profile-confirmation.unicode-email-change-paths'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'delete_user_meta', + 'email_exists', + 'get_user_by', + 'get_user_meta', + 'is_email', + 'sanitize_email', + 'send_confirmation_on_profile_email', + 'update_user_meta', + 'wp_insert_user', + 'wp_mail', + 'wp_set_current_user', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'WP_Email_Address', 'WP_Error', 'WP_User' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required WordPress profile email confirmation APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_profile_confirmation_cases( $ctx->fork( 'profile-email-confirmation' ) ); + $failures = array(); + $observed = array(); + $mail_calls = array(); + $content_events = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $global_snapshot = self::snapshot_named_globals( + array( + 'current_user', + 'errors', + 'user_ID', + 'userdata', + 'user_email', + 'user_identity', + 'user_level', + 'user_login', + 'user_url', + ) + ); + $post_snapshot = $_POST; + $mail_filter = static function ( $return, array $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + $content_filter = static function ( string $content, array $new_user_email ) use ( &$content_events ): string { + $content_events[] = $new_user_email; + return $content . "\nFiltered confirmation for ###EMAIL### via ###ADMIN_URL###"; + }; + $option_values = array( + 'pre_option_blogname' => 'Component Fuzz Profile Site', + 'pre_option_home' => 'http://profile.example.test', + 'pre_option_siteurl' => 'http://profile.example.test/wp', + ); + + self::reset_stub_content(); + try { + self::install_email_filters( 'unicode' ); + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + \remove_all_filters( 'new_user_email_content' ); + \add_filter( 'new_user_email_content', $content_filter, 10, 2 ); + foreach ( $option_values as $hook => $value ) { + \remove_all_filters( $hook ); + \add_filter( + $hook, + static function () use ( $value ) { + return $value; + } + ); + } + + foreach ( $cases as $case_index => $case ) { + $mail_calls = array(); + $content_events = array(); + + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $case['email'], 'unicode' ) ); + $email = $parse['value'] ?? null; + $canonical = $email instanceof \WP_Email_Address ? $email->get_unicode_address() : null; + $is_email = self::capture_warnings( static fn() => \is_email( $case['email'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $case['email'] ) ); + + if ( + $parse['threw'] + || array() !== $parse['warnings'] + || ! $email instanceof \WP_Email_Address + || $is_email['value'] !== $canonical + || $sanitized['value'] !== $canonical + ) { + $failures[] = array( + 'label' => $case['label'], + 'failure' => 'profile-confirmation-address-oracle-mismatch', + 'email' => self::describe_string( $case['email'] ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitizeEmail' => self::describe_captured_call( $sanitized ), + ); + continue; + } + + $success = self::exercise_profile_email_confirmation_success( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $success['ok'] ?? false ) ) { + $failures[] = $success; + } + + $duplicate = self::exercise_profile_email_confirmation_duplicate( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $duplicate['ok'] ?? false ) ) { + $failures[] = $duplicate; + } + + $invalid = self::exercise_profile_email_confirmation_invalid( $ctx, $case_index, $case, $mail_calls, $content_events ); + if ( ! ( $invalid['ok'] ?? false ) ) { + $failures[] = $invalid; + } + + $wrong_user = self::exercise_profile_email_confirmation_wrong_user( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $wrong_user['ok'] ?? false ) ) { + $failures[] = $wrong_user; + } + + $same_email = self::exercise_profile_email_confirmation_same_email( $ctx, $case_index, $case, $canonical, $mail_calls, $content_events ); + if ( ! ( $same_email['ok'] ?? false ) ) { + $failures[] = $same_email; + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'email' => self::describe_string( $canonical ), + 'success' => $success['summary'] ?? null, + 'duplicate' => $duplicate['summary'] ?? null, + 'invalid' => $invalid['summary'] ?? null, + 'wrongUser' => $wrong_user['summary'] ?? null, + 'sameEmail' => $same_email['summary'] ?? null, + ); + } + } finally { + $_POST = $post_snapshot; + self::restore_named_globals( $global_snapshot ); + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function exercise_profile_email_confirmation_success( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $login = 'cfz_profile_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'success', + 'failure' => 'profile-confirmation-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ); + $mail = $mail_calls[0] ?? null; + $event = $content_events[0] ?? null; + $meta_value = $meta['value'] ?? null; + $stored_user = $stored['value'] ?? null; + $hash = is_array( $meta_value ) && is_string( $meta_value['hash'] ?? null ) ? $meta_value['hash'] : ''; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $subject = is_array( $mail ) && is_string( $mail['subject'] ?? null ) ? $mail['subject'] : ''; + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $expected_url = '' !== $hash ? \esc_url( \self_admin_url( 'profile.php?newuseremail=' . $hash ) ) : ''; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && ! $meta['threw'] + && array() === $meta['warnings'] + && is_array( $meta_value ) + && $email === ( $meta_value['newemail'] ?? null ) + && 1 === preg_match( '/^[a-f0-9]{32}$/', $hash ) + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $current_email === $stored_user->user_email + && 1 === count( $content_events ) + && is_array( $event ) + && $hash === ( $event['hash'] ?? null ) + && $email === ( $event['newemail'] ?? null ) + && 1 === count( $mail_calls ) + && is_array( $mail ) + && $email === ( $mail['to'] ?? null ) + && '[Component Fuzz Profile Site] Email Change Request' === $subject + && str_contains( $message, $login ) + && str_contains( $message, $email ) + && '' !== $expected_url + && str_contains( $message, $expected_url ) + && str_contains( $message, 'Filtered confirmation for ' . $email . ' via ' . $expected_url ) + && ! str_contains( $message, '###' ) + && $current_email === ( $_POST['email'] ?? null ) + && array() === $error_codes; + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'success', + 'failure' => $ok ? null : 'profile-confirmation-success-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'currentEmail' => self::describe_string( $current_email ), + 'hash' => $hash, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'stored' => self::describe_captured_call( $stored ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'expectedUrl' => $expected_url, + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function exercise_profile_email_confirmation_duplicate( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-duplicate-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $current_login = 'cfz_profile_dup_current_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $current_email ), 0, 8 ); + $other_login = 'cfz_profile_dup_other_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $current = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $current_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other_login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $current['value'] ?? null; + + if ( $current['threw'] || $duplicate['threw'] || array() !== $current['warnings'] || array() !== $duplicate['warnings'] || ! is_int( $user_id ) || ! is_int( $duplicate['value'] ?? null ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'duplicate', + 'failure' => 'profile-confirmation-duplicate-users-not-inserted', + 'current' => self::describe_captured_call( $current ), + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + $stale_meta = array( 'hash' => 'stale', 'newemail' => 'stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + $meta_before = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $error_data = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_data( 'user_email' ) : null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && ! $meta_before['threw'] + && array() === $meta_before['warnings'] + && $stale_meta === $meta_before['value'] + && ! $meta['threw'] + && array() === $meta['warnings'] + && '' === $meta['value'] + && in_array( 'user_email', $error_codes, true ) + && array( 'form-field' => 'email' ) === $error_data + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'duplicate', + 'failure' => $ok ? null : 'profile-confirmation-duplicate-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'errors' => $error_codes, + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'metaCleared' => '' === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'metaBefore' => self::describe_captured_call( $meta_before ), + 'meta' => self::describe_captured_call( $meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + 'errorData' => self::describe_value( $error_data ), + ), + ); + } + + private static function exercise_profile_email_confirmation_invalid( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-invalid-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $case['invalid'] ), 0, 10 ) . '@example.org'; + $login = 'cfz_profile_invalid_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $current_email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'invalid', + 'failure' => 'profile-confirmation-invalid-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + $stale_meta = array( 'hash' => 'stale', 'newemail' => 'stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = 'not-an-error-object'; + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $case['invalid'], + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $error_data = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_data( 'user_email' ) : null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && $GLOBALS['errors'] instanceof \WP_Error + && ! $meta['threw'] + && array() === $meta['warnings'] + && $stale_meta === $meta['value'] + && in_array( 'user_email', $error_codes, true ) + && array( 'form-field' => 'email' ) === $error_data + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $case['invalid'] === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'invalid', + 'failure' => $ok ? null : 'profile-confirmation-invalid-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'invalid' => self::describe_string( $case['invalid'] ), + 'errors' => $error_codes, + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'metaRetained' => $stale_meta === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + 'errorData' => self::describe_value( $error_data ), + ), + ); + } + + private static function exercise_profile_email_confirmation_wrong_user( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $current_email = 'profile-wrong-current-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $email ), 0, 10 ) . '@example.org'; + $other_email = 'profile-wrong-other-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $current_email ), 0, 10 ) . '@example.org'; + $current = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_profile_wrong_current_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $current_email, + 'role' => 'subscriber', + ) + ) + ); + $other = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_profile_wrong_other_' . $ctx->iteration() . '_' . $case_index, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $other_email, + 'role' => 'subscriber', + ) + ) + ); + $current_id = $current['value'] ?? null; + $other_id = $other['value'] ?? null; + + if ( $current['threw'] || $other['threw'] || array() !== $current['warnings'] || array() !== $other['warnings'] || ! is_int( $current_id ) || ! is_int( $other_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'wrong-user', + 'failure' => 'profile-confirmation-wrong-user-seed-failed', + 'current' => self::describe_captured_call( $current ), + 'other' => self::describe_captured_call( $other ), + ); + } + + $current_stale_meta = array( 'hash' => 'current-stale', 'newemail' => 'current-stale@example.org' ); + $other_stale_meta = array( 'hash' => 'other-stale', 'newemail' => 'other-stale@example.org' ); + \update_user_meta( $current_id, '_new_email', $current_stale_meta ); + \update_user_meta( $other_id, '_new_email', $other_stale_meta ); + \wp_set_current_user( $current_id ); + $GLOBALS['errors'] = new \WP_Error(); + $_POST = array( + 'user_id' => (string) $other_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $current_meta = self::capture_warnings( static fn() => \get_user_meta( $current_id, '_new_email', true ) ); + $other_meta = self::capture_warnings( static fn() => \get_user_meta( $other_id, '_new_email', true ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $ok = ! $call['threw'] + && array() === $call['warnings'] + && false === $call['value'] + && ! $current_meta['threw'] + && ! $other_meta['threw'] + && array() === $current_meta['warnings'] + && array() === $other_meta['warnings'] + && $current_stale_meta === $current_meta['value'] + && $other_stale_meta === $other_meta['value'] + && array() === $error_codes + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'wrong-user', + 'failure' => $ok ? null : 'profile-confirmation-wrong-user-oracle-mismatch', + 'summary' => array( + 'currentId' => $current_id, + 'postedId' => $other_id, + 'email' => self::describe_string( $email ), + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'currentMetaPreserved' => $current_stale_meta === ( $current_meta['value'] ?? null ), + 'otherMetaPreserved' => $other_stale_meta === ( $other_meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'currentMeta' => self::describe_captured_call( $current_meta ), + 'otherMeta' => self::describe_captured_call( $other_meta ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function exercise_profile_email_confirmation_same_email( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case, string $email, array &$mail_calls, array &$content_events ): array { + self::reset_stub_content(); + \wp_set_current_user( 0 ); + $mail_calls = array(); + $content_events = array(); + + $login = 'cfz_profile_same_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $email ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $email, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + return array( + 'ok' => false, + 'label' => $case['label'], + 'branch' => 'same-email', + 'failure' => 'profile-confirmation-same-email-user-insert-failed', + 'insert' => self::describe_captured_call( $insert ), + ); + } + + $stale_meta = array( 'hash' => 'same-stale', 'newemail' => 'same-stale@example.org' ); + \update_user_meta( $user_id, '_new_email', $stale_meta ); + \wp_set_current_user( $user_id ); + $GLOBALS['errors'] = 'same-email-scalar'; + $_POST = array( + 'user_id' => (string) $user_id, + 'email' => $email, + ); + + $call = self::capture_warnings( static fn() => \send_confirmation_on_profile_email() ); + $meta = self::capture_warnings( static fn() => \get_user_meta( $user_id, '_new_email', true ) ); + $stored = self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ); + $error_codes = $GLOBALS['errors'] instanceof \WP_Error ? $GLOBALS['errors']->get_error_codes() : array( 'missing-error-object' ); + $stored_user = $stored['value'] ?? null; + $ok = ! $call['threw'] + && array() === $call['warnings'] + && null === $call['value'] + && $GLOBALS['errors'] instanceof \WP_Error + && array() === $error_codes + && ! $meta['threw'] + && array() === $meta['warnings'] + && $stale_meta === $meta['value'] + && ! $stored['threw'] + && array() === $stored['warnings'] + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $email === $stored_user->user_email + && 0 === count( $mail_calls ) + && 0 === count( $content_events ) + && $email === ( $_POST['email'] ?? null ); + + return array( + 'ok' => $ok, + 'label' => $case['label'], + 'branch' => 'same-email', + 'failure' => $ok ? null : 'profile-confirmation-same-email-oracle-mismatch', + 'summary' => array( + 'userId' => $user_id, + 'email' => self::describe_string( $email ), + 'mailCalls' => count( $mail_calls ), + 'contentEvents' => count( $content_events ), + 'errors' => $error_codes, + 'metaPreserved' => $stale_meta === ( $meta['value'] ?? null ), + ), + 'details' => $ok ? null : array( + 'call' => self::describe_captured_call( $call ), + 'meta' => self::describe_captured_call( $meta ), + 'stored' => self::describe_captured_call( $stored ), + 'mailCalls' => self::describe_value( $mail_calls ), + 'contentEvents' => self::describe_value( $content_events ), + 'postEmail' => self::describe_value( $_POST['email'] ?? null ), + 'errors' => self::describe_value( $error_codes ), + ), + ); + } + + private static function check_user_email_search_unicode_terms( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! class_exists( 'WP_User_Query' ) ) { + return array( + $ctx->skip( + 'email.user-email-search.unicode-terms-byte-preserving', + 'WP_User_Query is unavailable.' + ), + ); + } + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-search.unicode-terms-byte-preserving', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $cases = self::generated_user_search_cases( $ctx->fork( 'user-email-search-unicode-terms' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + + try { + foreach ( $cases as $case ) { + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['unicodeAddress'], 'unicode' ) ); + $folded_parse = self::call( static fn() => \WP_Email_Address::from_string( $case['foldedAddress'], 'unicode' ) ); + $unicode_local = self::email_localpart( $case['unicodeAddress'] ); + $folded_local = self::email_localpart( $case['foldedAddress'] ); + $full_query = self::prepare_user_email_search_query( $case['unicodeAddress'] ); + $wild_query = self::prepare_user_email_search_query( '*' . $unicode_local . '*', array( 'user_email' ) ); + $folded_query = self::prepare_user_email_search_query( '*' . $folded_local . '*', array( 'user_email' ) ); + $default_query = self::prepare_user_email_search_query( '*' . $unicode_local . '*' ); + $full_where = is_array( $full_query['value'] ?? null ) ? (string) ( $full_query['value']['queryWhere'] ?? '' ) : ''; + $wild_where = is_array( $wild_query['value'] ?? null ) ? (string) ( $wild_query['value']['queryWhere'] ?? '' ) : ''; + $folded_where = is_array( $folded_query['value'] ?? null ) ? (string) ( $folded_query['value']['queryWhere'] ?? '' ) : ''; + $default_where = is_array( $default_query['value'] ?? null ) ? (string) ( $default_query['value']['queryWhere'] ?? '' ) : ''; + $full_likes = self::sql_like_literals( $full_where, 'user_email' ); + $wild_likes = self::sql_like_literals( $wild_where, 'user_email' ); + $folded_likes = self::sql_like_literals( $folded_where, 'user_email' ); + $default_likes = self::sql_like_literals( $default_where, 'user_email' ); + $full_other = self::sql_like_literals_for_columns( $full_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $wild_other = self::sql_like_literals_for_columns( $wild_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $folded_other = self::sql_like_literals_for_columns( $folded_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $default_other = self::sql_like_literals_for_columns( $default_where, array( 'user_login', 'user_url', 'user_nicename', 'display_name' ) ); + $default_other_literals = self::flatten_sql_like_literal_groups( $default_other ); + $expected_full = $case['unicodeAddress']; + $expected_wild = '%' . $unicode_local . '%'; + $folded_wild = '%' . $folded_local . '%'; + $result_probe = self::probe_user_email_localpart_search_results( $ctx, $case ); + + $ok = ! $unicode_parse['threw'] + && ! $folded_parse['threw'] + && ! $full_query['threw'] + && ! $wild_query['threw'] + && ! $folded_query['threw'] + && ! $default_query['threw'] + && $unicode_parse['value'] instanceof \WP_Email_Address + && $folded_parse['value'] instanceof \WP_Email_Address + && array() === ( $full_query['warnings'] ?? array() ) + && array() === ( $wild_query['warnings'] ?? array() ) + && array() === ( $folded_query['warnings'] ?? array() ) + && array() === ( $default_query['warnings'] ?? array() ) + && array( $expected_full ) === $full_likes + && array( $expected_wild ) === $wild_likes + && array( $folded_wild ) === $folded_likes + && array( $expected_wild ) === $default_likes + && array() === $full_other + && array() === $wild_other + && array() === $folded_other + && count( $default_other_literals ) >= 4 + && array() === array_diff( $default_other_literals, array( $expected_wild ) ) + && $case['unicodeAddress'] !== $case['foldedAddress'] + && $unicode_local !== $folded_local + && $expected_wild !== $folded_wild + && bin2hex( $expected_wild ) !== bin2hex( $folded_wild ) + && true === ( $result_probe['ok'] ?? false ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'unicode' => self::describe_string( $case['unicodeAddress'] ), + 'folded' => self::describe_string( $case['foldedAddress'] ), + 'unicodeLocal' => self::describe_string( $unicode_local ), + 'foldedLocal' => self::describe_string( $folded_local ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'foldedParse' => self::describe_call( $folded_parse ), + 'fullQuery' => self::describe_captured_call( $full_query ), + 'wildQuery' => self::describe_captured_call( $wild_query ), + 'foldedQuery' => self::describe_captured_call( $folded_query ), + 'defaultQuery' => self::describe_captured_call( $default_query ), + 'fullLikes' => self::describe_value( $full_likes ), + 'wildLikes' => self::describe_value( $wild_likes ), + 'foldedLikes' => self::describe_value( $folded_likes ), + 'defaultLikes' => self::describe_value( $default_likes ), + 'fullOther' => self::describe_value( $full_other ), + 'wildOther' => self::describe_value( $wild_other ), + 'foldedOther' => self::describe_value( $folded_other ), + 'defaultOther' => self::describe_value( $default_other ), + 'defaultOtherLiterals' => self::describe_value( $default_other_literals ), + 'resultProbe' => self::describe_value( $result_probe ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'unicode' => self::describe_string( $case['unicodeAddress'] ), + 'folded' => self::describe_string( $case['foldedAddress'] ), + 'unicodeLocal' => self::describe_string( $unicode_local ), + 'foldedLocal' => self::describe_string( $folded_local ), + 'fullLikes' => self::describe_value( $full_likes ), + 'wildLikes' => self::describe_value( $wild_likes ), + 'foldedLikes' => self::describe_value( $folded_likes ), + 'resultProbe' => array( + 'unicode' => $result_probe['unicode'] ?? null, + 'folded' => $result_probe['folded'] ?? null, + ), + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-search.unicode-terms-byte-preserving', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_confusable_localpart_boundaries( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.confusable-localparts.lookup-boundaries'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( array( 'retrieve_password', 'wp_filter_comment', 'wp_mail' ) as $function ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + if ( ! class_exists( 'WP_User_Query' ) ) { + $missing[] = 'class WP_User_Query'; + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required lookup/search/password/comment APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::generated_confusable_localpart_cases( $ctx->fork( 'confusable-localparts' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + $mail_calls = array(); + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + \remove_all_filters( 'pre_comment_author_email' ); + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + + foreach ( + array( + 'lostpassword_user_data', + 'lostpassword_post', + 'lostpassword_errors', + 'send_retrieve_password_email', + 'retrieve_password_title', + 'retrieve_password_message', + 'retrieve_password_notification_email', + 'wp_mail', + 'pre_wp_mail', + ) as $password_reset_hook + ) { + \remove_all_filters( $password_reset_hook ); + } + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + $mail_calls = array(); + $variants = array( + array( + 'kind' => 'ascii', + 'local' => $case['asciiLocal'], + 'address' => $case['asciiAddress'], + 'asciiModeValid' => true, + ), + array( + 'kind' => 'confusable', + 'local' => $case['confusableLocal'], + 'address' => $case['confusableAddress'], + 'asciiModeValid' => false, + ), + ); + $inserted = array(); + $lookups = array(); + + foreach ( $variants as $variant_index => $variant ) { + self::install_email_filters( 'unicode' ); + + $parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $variant['address'], 'unicode' ) + ); + $is_email = self::capture_warnings( static fn() => \is_email( $variant['address'] ) ); + $sanitized = self::capture_warnings( static fn() => \sanitize_email( $variant['address'] ) ); + $email = $parse['value'] ?? null; + $parse_ok = ! $parse['threw'] + && array() === $parse['warnings'] + && ! $is_email['threw'] + && array() === $is_email['warnings'] + && ! $sanitized['threw'] + && array() === $sanitized['warnings'] + && $email instanceof \WP_Email_Address + && $variant['local'] === $email->get_localpart() + && $case['domain'] === $email->get_ascii_domain() + && $case['domain'] === $email->get_unicode_domain() + && $variant['address'] === $email->get_ascii_address() + && $variant['address'] === $email->get_unicode_address() + && $variant['address'] === $is_email['value'] + && $variant['address'] === $sanitized['value']; + + self::install_email_filters( 'ascii' ); + $ascii_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $variant['address'], 'ascii' ) + ); + $ascii_is_email = self::capture_warnings( static fn() => \is_email( $variant['address'] ) ); + $ascii_sanitized = self::capture_warnings( static fn() => \sanitize_email( $variant['address'] ) ); + $ascii_email = $ascii_parse['value'] ?? null; + $expected_ascii_is_email = $variant['asciiModeValid'] ? $variant['address'] : false; + $expected_ascii_sanitized = $variant['asciiModeValid'] ? $variant['address'] : ''; + $ascii_mode_ok = ! $ascii_parse['threw'] + && array() === $ascii_parse['warnings'] + && ! $ascii_is_email['threw'] + && array() === $ascii_is_email['warnings'] + && ! $ascii_sanitized['threw'] + && array() === $ascii_sanitized['warnings'] + && ( + $variant['asciiModeValid'] + ? $ascii_email instanceof \WP_Email_Address + && $variant['address'] === $ascii_email->get_unicode_address() + : null === $ascii_email + ) + && $expected_ascii_is_email === $ascii_is_email['value'] + && $expected_ascii_sanitized === $ascii_sanitized['value']; + + self::install_email_filters( 'unicode' ); + $login = 'cfz_confusable_' . $ctx->iteration() . '_' . $case_index . '_' . $variant_index . '_' . substr( sha1( $variant['address'] ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $variant['address'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $variant['address'] ) ) : $not_called(); + $by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $variant['address'] ) ) : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_email_user = $by_email['value'] ?? null; + $insert_ok = ! $insert['threw'] + && array() === $insert['warnings'] + && ! $stored['threw'] + && array() === $stored['warnings'] + && ! $exists['threw'] + && array() === $exists['warnings'] + && ! $by_email['threw'] + && array() === $by_email['warnings'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $variant['address'] === $stored_user->user_email + && $exists['value'] === $user_id + && $by_email_user instanceof \WP_User + && $by_email_user->ID === $user_id + && $variant['address'] === $by_email_user->user_email; + + $comment = array( + 'comment_author' => 'Component Fuzzer', + 'comment_author_email' => $variant['address'], + 'comment_author_url' => '', + 'comment_content' => 'Confusable email local-part case ' . $case_index . '.' . $variant_index, + 'comment_author_IP' => '127.0.0.1', + 'comment_agent' => 'component-fuzz', + ); + $filtered = self::capture_warnings( static fn() => \wp_filter_comment( $comment ) ); + $filtered_email = ! $filtered['threw'] && is_array( $filtered['value'] ) + ? ( $filtered['value']['comment_author_email'] ?? null ) + : null; + $filtered_valid = is_string( $filtered_email ) + ? self::capture_warnings( static fn() => \is_email( $filtered_email ) ) + : $not_called(); + $filtered_parse = is_string( $filtered_email ) + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $filtered_email, 'unicode' ) ) + : $not_called(); + $comment_ok = ! $filtered['threw'] + && array() === $filtered['warnings'] + && ! $filtered_valid['threw'] + && array() === $filtered_valid['warnings'] + && ! $filtered_parse['threw'] + && array() === $filtered_parse['warnings'] + && is_array( $filtered['value'] ) + && true === ( $filtered['value']['filtered'] ?? null ) + && $variant['address'] === $filtered_email + && $variant['address'] === $filtered_valid['value'] + && ( $filtered_parse['value'] ?? null ) instanceof \WP_Email_Address; + + if ( ! $parse_ok || ! $ascii_mode_ok || ! $insert_ok || ! $comment_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'address' => self::describe_string( $variant['address'] ), + 'parse' => self::describe_captured_call( $parse ), + 'isEmail' => self::describe_captured_call( $is_email ), + 'sanitized' => self::describe_captured_call( $sanitized ), + 'asciiParse' => self::describe_captured_call( $ascii_parse ), + 'asciiIsEmail' => self::describe_captured_call( $ascii_is_email ), + 'asciiSanitized' => self::describe_captured_call( $ascii_sanitized ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + 'comment' => self::describe_captured_call( $filtered ), + 'commentValid' => self::describe_captured_call( $filtered_valid ), + 'commentParse' => self::describe_captured_call( $filtered_parse ), + 'parseOk' => $parse_ok, + 'asciiModeOk' => $ascii_mode_ok, + 'insertOk' => $insert_ok, + 'commentOk' => $comment_ok, + ); + } + + $inserted[ $variant['kind'] ] = array( + 'id' => $user_id, + 'login' => $login, + 'address' => $variant['address'], + 'local' => $variant['local'], + ); + $lookups[] = array( + 'variant' => $variant['kind'], + 'address' => self::describe_string( $variant['address'] ), + 'userId' => $user_id, + 'asciiModeValid' => $variant['asciiModeValid'], + 'asciiModeResult' => self::describe_value( $ascii_is_email['value'] ?? null ), + 'commentEmail' => self::describe_value( $filtered_email ), + ); + } + + $ids = array(); + foreach ( $inserted as $entry ) { + if ( is_int( $entry['id'] ?? null ) ) { + $ids[] = $entry['id']; + } + } + + $distinct_ok = 2 === count( $ids ) + && 2 === count( array_unique( $ids, SORT_REGULAR ) ) + && $case['asciiLocal'] !== $case['confusableLocal'] + && $case['asciiAddress'] !== $case['confusableAddress'] + && bin2hex( $case['asciiLocal'] ) !== bin2hex( $case['confusableLocal'] ); + + foreach ( $variants as $variant ) { + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_confusable_duplicate_' . $ctx->iteration() . '_' . $case_index . '_' . $variant['kind'], + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $variant['address'], + 'role' => 'subscriber', + ) + ) + ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'failure' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + } + + $hostile_ascii = self::hostile_user_email_lookup_probe( $case['asciiAddress'], $case['confusableAddress'] ); + $hostile_confusable = self::hostile_user_email_lookup_probe( $case['confusableAddress'], $case['asciiAddress'] ); + $hostile_ok = true === ( $hostile_ascii['ok'] ?? false ) + && true === ( $hostile_confusable['ok'] ?? false ); + + $ascii_query = self::prepare_user_email_search_query( '*' . $case['asciiLocal'] . '*', array( 'user_email' ) ); + $confusable_query = self::prepare_user_email_search_query( '*' . $case['confusableLocal'] . '*', array( 'user_email' ) ); + $ascii_where = is_array( $ascii_query['value'] ?? null ) ? (string) ( $ascii_query['value']['queryWhere'] ?? '' ) : ''; + $confusable_where = is_array( $confusable_query['value'] ?? null ) ? (string) ( $confusable_query['value']['queryWhere'] ?? '' ) : ''; + $ascii_likes = self::sql_like_literals( $ascii_where, 'user_email' ); + $confusable_likes = self::sql_like_literals( $confusable_where, 'user_email' ); + $search_records = array(); + foreach ( $inserted as $entry ) { + if ( is_int( $entry['id'] ?? null ) && is_string( $entry['address'] ?? null ) ) { + $search_records[] = array( + 'id' => $entry['id'], + 'email' => $entry['address'], + ); + } + } + $result_probe = self::probe_user_email_localpart_search_results( + $ctx, + array( + 'label' => $case['label'], + 'unicodeAddress' => $case['confusableAddress'], + 'foldedAddress' => $case['asciiAddress'], + ), + $search_records + ); + $search_ok = ! $ascii_query['threw'] + && array() === $ascii_query['warnings'] + && ! $confusable_query['threw'] + && array() === $confusable_query['warnings'] + && array( '%' . $case['asciiLocal'] . '%' ) === $ascii_likes + && array( '%' . $case['confusableLocal'] . '%' ) === $confusable_likes + && true === ( $result_probe['ok'] ?? false ); + + if ( ! $distinct_ok || ! $hostile_ok || ! $search_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'ascii' => self::describe_string( $case['asciiAddress'] ), + 'confusable' => self::describe_string( $case['confusableAddress'] ), + 'inserted' => self::describe_value( $inserted ), + 'hostileAscii' => self::describe_value( $hostile_ascii ), + 'hostileConfusable' => self::describe_value( $hostile_confusable ), + 'asciiQuery' => self::describe_captured_call( $ascii_query ), + 'confusableQuery' => self::describe_captured_call( $confusable_query ), + 'asciiLikes' => self::describe_value( $ascii_likes ), + 'confusableLikes' => self::describe_value( $confusable_likes ), + 'resultProbe' => self::describe_value( $result_probe ), + 'distinctOk' => $distinct_ok, + 'hostileOk' => $hostile_ok, + 'searchOk' => $search_ok, + ); + } + + $password_resets = array(); + foreach ( $variants as $variant ) { + self::install_email_filters( 'unicode' ); + $user_id = $inserted[ $variant['kind'] ]['id'] ?? null; + $login = $inserted[ $variant['kind'] ]['login'] ?? ''; + $mail_count_before = count( $mail_calls ); + $reset = is_int( $user_id ) + ? self::capture_warnings( static fn() => \retrieve_password( $variant['address'] ) ) + : $not_called(); + $mail = $mail_calls[ $mail_count_before ] ?? null; + $reset_ok = is_int( $user_id ) + && ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && count( $mail_calls ) === $mail_count_before + 1 + && is_array( $mail ) + && $variant['address'] === ( $mail['to'] ?? null ) + && is_string( $mail['message'] ?? null ) + && str_contains( $mail['message'], rawurlencode( $login ) ); + + if ( ! $reset_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'variant' => $variant['kind'], + 'failure' => 'password-reset-not-routed-to-exact-address', + 'address' => self::describe_string( $variant['address'] ), + 'userId' => self::describe_value( $user_id ), + 'login' => self::describe_value( $login ), + 'reset' => self::describe_captured_call( $reset ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_count_before ) ), + ); + } + + $password_resets[] = array( + 'variant' => $variant['kind'], + 'userId' => $user_id, + 'ok' => $reset_ok, + 'mailTo' => is_array( $mail ) && is_string( $mail['to'] ?? null ) ? self::describe_string( $mail['to'] ) : null, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'domain' => self::describe_string( $case['domain'] ), + 'asciiLocal' => self::describe_string( $case['asciiLocal'] ), + 'confusableLocal' => self::describe_string( $case['confusableLocal'] ), + 'insertedIds' => self::describe_value( $ids ), + 'lookups' => $lookups, + 'hostileLookups' => array( + 'asciiLookup' => true === ( $hostile_ascii['ok'] ?? false ), + 'confusableLookup' => true === ( $hostile_confusable['ok'] ?? false ), + ), + 'searchLikes' => array( + 'ascii' => self::describe_value( $ascii_likes ), + 'confusable' => self::describe_value( $confusable_likes ), + ), + 'passwordResets' => $password_resets, + ); + } + } finally { + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_distinct_domains( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-domains-preserved', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.distinct-domains-preserved', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $inputs = array( + 'mail@gra.org', + "mail@gr\u{00E5}.org", + 'mail@bucher.de', + "mail@b\u{00FC}cher.de", + "jos\u{00E9}@example.org", + "jos\u{00E9}@gr\u{00E5}.org", + ); + $inserted = array(); + $lookups = array(); + $failures = array(); + + self::reset_stub_content(); + try { + foreach ( $inputs as $index => $input ) { + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_domain_email_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $input ), 0, 10 ), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $input, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $inserted[] = $user_id; + + if ( $insert['threw'] || array() !== $insert['warnings'] || ! is_int( $user_id ) ) { + $failures[] = array( + 'label' => 'insert-failed-or-warned', + 'input' => self::describe_string( $input ), + 'insert' => self::describe_captured_call( $insert ), + ); + continue; + } + + $exists = self::capture_warnings( static fn() => \email_exists( $input ) ); + $by_email = self::capture_warnings( static fn() => \get_user_by( 'email', $input ) ); + $user = $by_email['value'] ?? null; + $lookups[] = array( + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => $exists['value'] ?? null, + 'byEmail' => $user instanceof \WP_User ? $user->ID : self::describe_value( $user ), + 'warnings' => count( $exists['warnings'] ) + count( $by_email['warnings'] ), + ); + + if ( + $exists['threw'] || + $by_email['threw'] || + array() !== $exists['warnings'] || + array() !== $by_email['warnings'] || + $exists['value'] !== $user_id || + ! ( $user instanceof \WP_User ) || + $user->ID !== $user_id || + $user->user_email !== $input + ) { + $failures[] = array( + 'label' => 'lookup-mismatch-or-warning', + 'input' => self::describe_string( $input ), + 'userId' => $user_id, + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + ); + } + } + + $ids = array_values( array_filter( $inserted, 'is_int' ) ); + if ( count( $ids ) !== count( $inputs ) || count( array_unique( $ids, SORT_REGULAR ) ) !== count( $inputs ) ) { + $failures[] = array( + 'label' => 'inserted-ids-not-distinct', + 'inserted' => self::describe_value( $inserted ), + ); + } + + $duplicate = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => 'cfz_domain_email_duplicate_' . $ctx->iteration(), + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $inputs[1], + 'role' => 'subscriber', + ) + ) + ); + $missing = self::capture_warnings( static fn() => \email_exists( "missing@gr\u{00E5}.org" ) ); + + if ( + $duplicate['threw'] || + array() !== $duplicate['warnings'] || + ! \is_wp_error( $duplicate['value'] ?? null ) || + 'existing_user_email' !== $duplicate['value']->get_error_code() + ) { + $failures[] = array( + 'label' => 'exact-duplicate-not-rejected-cleanly', + 'duplicate' => self::describe_captured_call( $duplicate ), + ); + } + + if ( $missing['threw'] || array() !== $missing['warnings'] || false !== $missing['value'] ) { + $failures[] = array( + 'label' => 'missing-unicode-domain-lookup-not-false-cleanly', + 'missing' => self::describe_captured_call( $missing ), + ); + } + } finally { + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.distinct-domains-preserved', + array() === $failures, + array( + 'inputs' => array_map( array( self::class, 'describe_string' ), $inputs ), + 'inserted' => self::describe_value( $inserted ), + 'lookups' => self::describe_value( $lookups ), + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_user_email_indexes_canonical_domain_aliases( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + if ( ! function_exists( 'wp_update_user' ) ) { + return array( + $ctx->skip( + 'email.user-email-indexes.canonical-domain-aliases', + 'wp_update_user() is unavailable.' + ), + ); + } + + $domain_aliases = array( + "gr\u{00E5}.org", + "b\u{00FC}cher.de", + "fa\u{00DF}.de", + "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", + "\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", + "\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", + "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", + ); + $localparts = array( + 'mail', + 'USER+tag', + "gr\u{00E5}", + "jose\u{0301}", + "\u{7528}\u{6237}", + "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}", + "\u{3086}\u{3046}\u{3056}\u{3042}", + ); + $alias_ctx = $ctx->fork( 'user-email-canonical-domain-aliases' ); + $cases = array(); + $failures = array(); + $observed = array(); + $mail_calls = array(); + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + for ( $i = 0; $i < self::GENERATED_DOMAIN_ALIAS_CASES; $i++ ) { + $unicode_domain = $alias_ctx->choice( $domain_aliases ); + $ascii_domain = idn_to_ascii( $unicode_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $decoded_domain = false === $ascii_domain + ? false + : idn_to_utf8( $ascii_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $localpart = $alias_ctx->choice( $localparts ) . $alias_ctx->int( 10, 99 ); + + if ( false === $ascii_domain || false === $decoded_domain ) { + $failures[] = array( + 'label' => 'idn-conversion-failed', + 'domain' => self::describe_string( $unicode_domain ), + ); + continue; + } + + $unicode_input = $localpart . '@' . $decoded_domain; + $machine_input = $localpart . '@' . $ascii_domain; + $unicode_parse = self::call( static fn() => \WP_Email_Address::from_string( $unicode_input, 'unicode' ) ); + $machine_parse = self::call( static fn() => \WP_Email_Address::from_string( $machine_input, 'unicode' ) ); + $unicode_email = $unicode_parse['value'] ?? null; + $machine_email = $machine_parse['value'] ?? null; + + if ( + $unicode_parse['threw'] || + $machine_parse['threw'] || + ! ( $unicode_email instanceof \WP_Email_Address ) || + ! ( $machine_email instanceof \WP_Email_Address ) || + $unicode_email->get_ascii_address() !== $machine_email->get_ascii_address() || + $unicode_email->get_unicode_address() !== $machine_email->get_unicode_address() + ) { + $failures[] = array( + 'label' => 'alias-parse-mismatch', + 'unicodeInput' => self::describe_string( $unicode_input ), + 'machineInput' => self::describe_string( $machine_input ), + 'unicodeParse' => self::describe_call( $unicode_parse ), + 'machineParse' => self::describe_call( $machine_parse ), + ); + continue; + } + + $cases[] = array( + 'label' => 'generated-domain-alias-' . $i, + 'canonical' => $unicode_email->get_unicode_address(), + 'machine' => $unicode_email->get_ascii_address(), + 'domain' => $unicode_email->get_unicode_domain(), + 'ascii' => $unicode_email->get_ascii_domain(), + ); + } + + $hook_snapshot = self::snapshot_hook_globals(); + + self::reset_stub_content(); + try { + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + \remove_all_filters( 'pre_wp_mail' ); + \add_filter( 'pre_wp_mail', $mail_filter, PHP_INT_MAX, 2 ); + + foreach ( $cases as $index => $case ) { + self::reset_stub_content(); + + $canonical = $case['canonical']; + $machine = $case['machine']; + $login = 'cfz_alias_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $canonical ), 0, 10 ); + $other = 'cfz_alias_other_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $machine ), 0, 10 ); + $other_email = 'other-' . substr( sha1( $canonical ), 0, 12 ) . '@example.org'; + $mail_count_before = count( $mail_calls ); + + $canonical_sanitized = self::capture_warnings( static fn() => \sanitize_email( $canonical ) ); + $machine_sanitized = self::capture_warnings( static fn() => \sanitize_email( $machine ) ); + $canonical_valid = self::capture_warnings( static fn() => \is_email( $canonical ) ); + $machine_valid = self::capture_warnings( static fn() => \is_email( $machine ) ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $machine, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $machine_exists = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $machine ) ) : $not_called(); + $machine_by_email = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $machine ) ) : $not_called(); + $duplicate = is_int( $user_id ) + ? self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login . '_duplicate', + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $canonical, + 'role' => 'subscriber', + ) + ) + ) + : $not_called(); + $self_update = is_int( $user_id ) + ? self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $user_id, + 'user_email' => $canonical, + ) + ) + ) + : $not_called(); + $stored_after_update = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $other_insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $other, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $other_email, + 'role' => 'subscriber', + ) + ) + ); + $other_id = $other_insert['value'] ?? null; + $collision_update = is_int( $other_id ) + ? self::capture_warnings( + static fn() => \wp_update_user( + array( + 'ID' => $other_id, + 'user_email' => $machine, + ) + ) + ) + : $not_called(); + $stored_user = $stored['value'] ?? null; + $by_email_user = $by_email['value'] ?? null; + $machine_email_user = $machine_by_email['value'] ?? null; + $updated_user = $stored_after_update['value'] ?? null; + $other_after_collision = is_int( $other_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $other_id ) ) : $not_called(); + $other_after_user = $other_after_collision['value'] ?? null; + + $ok = ! $canonical_sanitized['threw'] + && ! $machine_sanitized['threw'] + && ! $canonical_valid['threw'] + && ! $machine_valid['threw'] + && ! $insert['threw'] + && ! $stored['threw'] + && ! $exists['threw'] + && ! $by_email['threw'] + && ! $machine_exists['threw'] + && ! $machine_by_email['threw'] + && ! $duplicate['threw'] + && ! $self_update['threw'] + && ! $stored_after_update['threw'] + && ! $other_insert['threw'] + && ! $collision_update['threw'] + && ! $other_after_collision['threw'] + && array() === $canonical_sanitized['warnings'] + && array() === $machine_sanitized['warnings'] + && array() === $canonical_valid['warnings'] + && array() === $machine_valid['warnings'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists['warnings'] + && array() === $by_email['warnings'] + && array() === $machine_exists['warnings'] + && array() === $machine_by_email['warnings'] + && array() === $duplicate['warnings'] + && array() === $self_update['warnings'] + && array() === $stored_after_update['warnings'] + && array() === $other_insert['warnings'] + && array() === $collision_update['warnings'] + && array() === $other_after_collision['warnings'] + && $canonical === $canonical_sanitized['value'] + && $canonical === $machine_sanitized['value'] + && $canonical === $canonical_valid['value'] + && $canonical === $machine_valid['value'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $canonical === $stored_user->user_email + && $exists['value'] === $user_id + && $by_email_user instanceof \WP_User + && $by_email_user->ID === $user_id + && $canonical === $by_email_user->user_email + && false === $machine_exists['value'] + && false === $machine_email_user + && \is_wp_error( $duplicate['value'] ?? null ) + && 'existing_user_email' === $duplicate['value']->get_error_code() + && $self_update['value'] === $user_id + && $updated_user instanceof \WP_User + && $canonical === $updated_user->user_email + && is_int( $other_id ) + && \is_wp_error( $collision_update['value'] ?? null ) + && 'existing_user_email' === $collision_update['value']->get_error_code() + && $other_after_user instanceof \WP_User + && $other_after_user->ID === $other_id + && $other_email === $other_after_user->user_email + && count( $mail_calls ) === $mail_count_before; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'canonicalSanitized' => self::describe_captured_call( $canonical_sanitized ), + 'machineSanitized' => self::describe_captured_call( $machine_sanitized ), + 'canonicalValid' => self::describe_captured_call( $canonical_valid ), + 'machineValid' => self::describe_captured_call( $machine_valid ), + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + 'machineExists' => self::describe_captured_call( $machine_exists ), + 'machineByEmail' => self::describe_captured_call( $machine_by_email ), + 'duplicate' => self::describe_captured_call( $duplicate ), + 'selfUpdate' => self::describe_captured_call( $self_update ), + 'storedAfterUpdate' => self::describe_captured_call( $stored_after_update ), + 'otherInsert' => self::describe_captured_call( $other_insert ), + 'collisionUpdate' => self::describe_captured_call( $collision_update ), + 'otherAfterCollision' => self::describe_captured_call( $other_after_collision ), + 'mailCalls' => self::describe_value( array_slice( $mail_calls, $mail_count_before ) ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'domain' => self::describe_string( $case['domain'] ), + 'asciiDomain' => self::describe_string( $case['ascii'] ), + 'userId' => $user_id, + 'otherId' => $other_id, + 'storedCanonical' => $stored_user instanceof \WP_User && $canonical === $stored_user->user_email, + 'machineLookupMiss' => false === ( $machine_exists['value'] ?? null ) && false === $machine_email_user, + 'duplicateRejected' => \is_wp_error( $duplicate['value'] ?? null ), + 'collisionRejected' => \is_wp_error( $collision_update['value'] ?? null ), + 'collisionLeftOtherUnchanged' => $other_after_user instanceof \WP_User && $other_email === $other_after_user->user_email, + 'mailCalls' => count( $mail_calls ) - $mail_count_before, + ); + } + } finally { + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.user-email-indexes.canonical-domain-aliases', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_password_reset_unicode_email_paths( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + 'email.password-reset.unicode-email-paths', + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + $required = array( + 'retrieve_password', + 'wp_mail', + 'add_filter', + 'remove_filter', + ); + foreach ( $required as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.password-reset.unicode-email-paths', + 'Required password reset APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + $cases = array( + array( + 'label' => 'unicode-local', + 'email' => "jos\u{00E9}.reset@example.org", + ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'unicode-domain', + 'email' => "reset@gr\u{00E5}.org", + ); + $cases[] = array( + 'label' => 'unicode-local-and-domain', + 'email' => "jos\u{00E9}.reset@gr\u{00E5}.org", + ); + } + + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + foreach ( $cases as $index => $case ) { + $mail_calls = array(); + $mail_filter = static function ( $return, $atts ) use ( &$mail_calls ) { + $mail_calls[] = $atts; + return true; + }; + + \add_filter( 'pre_wp_mail', $mail_filter, 10, 2 ); + try { + $login = 'cfz_reset_' . $ctx->iteration() . '_' . $index . '_' . substr( sha1( $case['email'] ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $case['email'], + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $reset = is_int( $user_id ) + ? self::capture_warnings( static fn() => \retrieve_password( $case['email'] ) ) + : array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + $mail = $mail_calls[0] ?? null; + + $ok = ! $insert['threw'] + && array() === $insert['warnings'] + && is_int( $user_id ) + && ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && 1 === count( $mail_calls ) + && is_array( $mail ) + && $case['email'] === ( $mail['to'] ?? null ) + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && is_string( $mail['message'] ?? null ) + && str_contains( $mail['message'], rawurlencode( $login ) ); + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'email' => self::describe_string( $case['email'] ), + 'login' => $login, + 'insert' => self::describe_captured_call( $insert ), + 'reset' => self::describe_captured_call( $reset ), + 'mailCalls' => self::describe_value( $mail_calls ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'email' => self::describe_string( $case['email'] ), + 'userId' => $user_id, + 'reset' => self::describe_value( $reset['value'] ?? null ), + 'mailCalls' => count( $mail_calls ), + 'mailTo' => isset( $mail['to'] ) && is_string( $mail['to'] ) ? self::describe_string( $mail['to'] ) : null, + 'warnings' => count( $insert['warnings'] ) + count( $reset['warnings'] ), + ); + } finally { + \remove_filter( 'pre_wp_mail', $mail_filter, 10 ); + } + } + } finally { + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + 'email.password-reset.unicode-email-paths', + array() === $failures, + array( + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_password_reset_notification_recipient_views( \ComponentFuzz\FuzzContext $ctx ): array { + $result_name = 'email.password-reset.notification-recipient-views'; + + if ( ! self::can_reset_stub_content() ) { + return array( + $ctx->skip( + $result_name, + 'The in-memory wpdb content reset hook is unavailable.' + ), + ); + } + + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + $result_name, + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $missing = array(); + foreach ( + array( + 'add_action', + 'add_filter', + 'email_exists', + 'get_user_by', + 'is_email', + 'remove_filter', + 'remove_action', + 'retrieve_password', + 'sanitize_email', + 'wp_insert_user', + 'wp_mail', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = 'function ' . $function; + } + } + + foreach ( array( 'PHPMailer\PHPMailer\PHPMailer', 'WP_PHPMailer' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = 'class ' . $class; + } + } + + if ( array() !== $missing ) { + return array( + $ctx->skip( + $result_name, + 'Required password reset recipient APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $cases = self::password_reset_recipient_alias_cases( $ctx->fork( 'password-reset-recipient-views' ) ); + $failures = array(); + $observed = array(); + $hook_snapshot = self::snapshot_hook_globals(); + $server_snapshot = array_key_exists( 'REMOTE_ADDR', $_SERVER ) + ? array( 'exists' => true, 'value' => $_SERVER['REMOTE_ADDR'] ) + : array( 'exists' => false, 'value' => null ); + $mailer_snapshot = array_key_exists( 'phpmailer', $GLOBALS ) + ? array( 'exists' => true, 'value' => $GLOBALS['phpmailer'] ) + : array( 'exists' => false, 'value' => null ); + $validator_snapshot = \PHPMailer\PHPMailer\PHPMailer::$validator; + $not_called = static function (): array { + return array( + 'threw' => false, + 'value' => null, + 'warnings' => array(), + ); + }; + $mail_succeeded = array(); + $success_action = static function ( array $mail_data ) use ( &$mail_succeeded ): void { + $mail_succeeded[] = $mail_data; + }; + + self::reset_stub_content(); + try { + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + \PHPMailer\PHPMailer\PHPMailer::$validator = static function ( $email ): bool { + return (bool) \is_email( $email ); + }; + + \remove_all_filters( 'pre_user_email' ); + \add_filter( 'pre_user_email', 'trim' ); + \add_filter( 'pre_user_email', 'sanitize_email' ); + if ( function_exists( 'wp_filter_kses' ) ) { + \add_filter( 'pre_user_email', 'wp_filter_kses' ); + } + + foreach ( + array( + 'lostpassword_user_data', + 'lostpassword_post', + 'lostpassword_errors', + 'send_retrieve_password_email', + 'retrieve_password_title', + 'retrieve_password_message', + 'retrieve_password_notification_email', + 'wp_mail', + 'pre_wp_mail', + 'wp_mail_succeeded', + ) as $password_reset_hook + ) { + \remove_all_filters( $password_reset_hook ); + } + \add_action( 'wp_mail_succeeded', $success_action ); + + foreach ( $cases as $case_index => $case ) { + self::reset_stub_content(); + self::install_email_filters( 'unicode' ); + EmailSurfaceMailer::$sent = array(); + $GLOBALS['phpmailer'] = self::new_mailer(); + $mail_succeeded = array(); + + if ( null !== $case['conversionError'] ) { + $failures[] = array( + 'label' => $case['label'], + 'error' => $case['conversionError'], + ); + continue; + } + + $unicode_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $case['unicodeInput'], 'unicode' ) + ); + $machine_parse = self::capture_warnings( + static fn() => \WP_Email_Address::from_string( $case['machineInput'], 'unicode' ) + ); + $unicode_email = $unicode_parse['value'] ?? null; + $machine_email = $machine_parse['value'] ?? null; + $parse_ok = ! $unicode_parse['threw'] + && ! $machine_parse['threw'] + && array() === $unicode_parse['warnings'] + && array() === $machine_parse['warnings'] + && $unicode_email instanceof \WP_Email_Address + && $machine_email instanceof \WP_Email_Address + && $case['local'] === $unicode_email->get_localpart() + && $case['unicodeInput'] === $unicode_email->get_unicode_address() + && $case['machineInput'] === $unicode_email->get_ascii_address() + && $unicode_email->get_unicode_address() === $machine_email->get_unicode_address() + && $unicode_email->get_ascii_address() === $machine_email->get_ascii_address() + && $unicode_email->get_unicode_domain() !== $unicode_email->get_ascii_domain() + && self::is_ascii( $unicode_email->get_ascii_domain() ); + + if ( ! $parse_ok ) { + $failures[] = array( + 'label' => $case['label'], + 'unicodeInput' => self::describe_string( $case['unicodeInput'] ), + 'machineInput' => self::describe_string( $case['machineInput'] ), + 'unicodeParse' => self::describe_captured_call( $unicode_parse ), + 'machineParse' => self::describe_captured_call( $machine_parse ), + ); + continue; + } + + $canonical = $unicode_email->get_unicode_address(); + $machine = $unicode_email->get_ascii_address(); + $notification_calls = array(); + $notification_filter = static function ( $defaults, $key, $user_login, $user_data ) use ( &$notification_calls, $machine ) { + $notification_calls[] = array( + 'defaults' => $defaults, + 'key' => $key, + 'userLogin' => $user_login, + 'userEmail' => $user_data instanceof \WP_User ? $user_data->user_email : null, + ); + + $defaults['to'] = $machine; + return $defaults; + }; + + \add_filter( 'retrieve_password_notification_email', $notification_filter, 10, 4 ); + try { + $login = 'cfz_reset_views_' . $ctx->iteration() . '_' . $case_index . '_' . substr( sha1( $canonical ), 0, 8 ); + $insert = self::capture_warnings( + static fn() => \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-pass', + 'user_email' => $machine, + 'role' => 'subscriber', + ) + ) + ); + $user_id = $insert['value'] ?? null; + $stored = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'id', $user_id ) ) : $not_called(); + $exists_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $canonical ) ) : $not_called(); + $by_canonical = is_int( $user_id ) ? self::capture_warnings( static fn() => \get_user_by( 'email', $canonical ) ) : $not_called(); + $exists_machine = is_int( $user_id ) ? self::capture_warnings( static fn() => \email_exists( $machine ) ) : $not_called(); + $reset = is_int( $user_id ) ? self::capture_warnings( static fn() => \retrieve_password( $canonical ) ) : $not_called(); + $mail = EmailSurfaceMailer::$sent[0] ?? null; + $mail_success = $mail_succeeded[0] ?? null; + $notification = $notification_calls[0] ?? null; + $mail_to = is_array( $mail ) && is_array( $mail['to'] ?? null ) && is_string( $mail['to'][0][0] ?? null ) + ? $mail['to'][0][0] + : null; + $mail_to_parse = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \WP_Email_Address::from_string( $mail_to, 'unicode' ) ) + : $not_called(); + $mail_to_is = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \is_email( $mail_to ) ) + : $not_called(); + $mail_to_sanitize = is_string( $mail_to ) + ? self::capture_warnings( static fn() => \sanitize_email( $mail_to ) ) + : $not_called(); + $mail_count_before = count( EmailSurfaceMailer::$sent ); + $success_count_before = count( $mail_succeeded ); + $notification_count_before = count( $notification_calls ); + $machine_reset = is_int( $user_id ) ? self::capture_warnings( static fn() => \retrieve_password( $machine ) ) : $not_called(); + } finally { + \remove_filter( 'retrieve_password_notification_email', $notification_filter, 10 ); + } + + $stored_user = $stored['value'] ?? null; + $canonical_user = $by_canonical['value'] ?? null; + $mail_to_email = $mail_to_parse['value'] ?? null; + $notification_to = is_array( $notification ) && is_array( $notification['defaults'] ?? null ) + ? ( $notification['defaults']['to'] ?? null ) + : null; + $notification_login = is_array( $notification ) ? ( $notification['userLogin'] ?? null ) : null; + $notification_email = is_array( $notification ) ? ( $notification['userEmail'] ?? null ) : null; + $message = is_array( $mail ) && is_string( $mail['message'] ?? null ) ? $mail['message'] : ''; + $success_to = is_array( $mail_success ) ? (array) ( $mail_success['to'] ?? array() ) : array(); + + $insert_lookup_ok = ! $insert['threw'] + && ! $stored['threw'] + && ! $exists_canonical['threw'] + && ! $by_canonical['threw'] + && ! $exists_machine['threw'] + && array() === $insert['warnings'] + && array() === $stored['warnings'] + && array() === $exists_canonical['warnings'] + && array() === $by_canonical['warnings'] + && array() === $exists_machine['warnings'] + && is_int( $user_id ) + && $stored_user instanceof \WP_User + && $stored_user->ID === $user_id + && $canonical === $stored_user->user_email + && $exists_canonical['value'] === $user_id + && $canonical_user instanceof \WP_User + && $canonical_user->ID === $user_id + && $canonical === $canonical_user->user_email + && false === $exists_machine['value']; + + $notification_ok = is_array( $notification ) + && $canonical === $notification_to + && $login === $notification_login + && $canonical === $notification_email + && is_string( $notification['key'] ?? null ) + && '' !== ( $notification['key'] ?? '' ); + + $mail_to_ok = ! $mail_to_parse['threw'] + && ! $mail_to_is['threw'] + && ! $mail_to_sanitize['threw'] + && array() === $mail_to_parse['warnings'] + && array() === $mail_to_is['warnings'] + && array() === $mail_to_sanitize['warnings'] + && $mail_to_email instanceof \WP_Email_Address + && $machine === $mail_to + && $machine === $mail_to_email->get_ascii_address() + && $canonical === $mail_to_email->get_unicode_address() + && $canonical === $mail_to_is['value'] + && $canonical === $mail_to_sanitize['value'] + && is_array( $mail ) + && 1 === count( $mail['to'] ?? array() ) + && self::addresses_include( $mail['to'] ?? array(), $machine, '' ); + + $reset_ok = ! $reset['threw'] + && array() === $reset['warnings'] + && true === $reset['value'] + && 1 === count( $notification_calls ) + && 1 === count( EmailSurfaceMailer::$sent ) + && 1 === count( $mail_succeeded ) + && is_array( $mail ) + && is_array( $mail_success ) + && array( $machine ) === $success_to + && is_string( $mail['subject'] ?? null ) + && '' !== ( $mail['subject'] ?? '' ) + && str_contains( $message, rawurlencode( $login ) ); + + $machine_reset_ok = ! $machine_reset['threw'] + && array() === $machine_reset['warnings'] + && \is_wp_error( $machine_reset['value'] ?? null ) + && 'invalid_email' === $machine_reset['value']->get_error_code() + && $mail_count_before === count( EmailSurfaceMailer::$sent ) + && $success_count_before === count( $mail_succeeded ) + && $notification_count_before === count( $notification_calls ); + + $ok = $insert_lookup_ok + && $notification_ok + && $mail_to_ok + && $reset_ok + && $machine_reset_ok; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'login' => $login, + 'insert' => self::describe_captured_call( $insert ), + 'stored' => self::describe_captured_call( $stored ), + 'existsCanonical' => self::describe_captured_call( $exists_canonical ), + 'byCanonical' => self::describe_captured_call( $by_canonical ), + 'existsMachine' => self::describe_captured_call( $exists_machine ), + 'reset' => self::describe_captured_call( $reset ), + 'machineReset' => self::describe_captured_call( $machine_reset ), + 'notificationCalls' => self::describe_value( $notification_calls ), + 'sentMail' => self::describe_value( EmailSurfaceMailer::$sent ), + 'mailSucceeded' => self::describe_value( $mail_succeeded ), + 'mailToParse' => self::describe_captured_call( $mail_to_parse ), + 'mailToIsEmail' => self::describe_captured_call( $mail_to_is ), + 'mailToSanitize' => self::describe_captured_call( $mail_to_sanitize ), + 'insertLookupOk' => $insert_lookup_ok, + 'notificationOk' => $notification_ok, + 'mailToOk' => $mail_to_ok, + 'resetOk' => $reset_ok, + 'machineResetOk' => $machine_reset_ok, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'profile' => $case['profile'], + 'canonical' => self::describe_string( $canonical ), + 'machine' => self::describe_string( $machine ), + 'unicodeLocal' => ! self::is_ascii( $unicode_email->get_localpart() ), + 'unicodeDomain' => self::describe_string( $unicode_email->get_unicode_domain() ), + 'asciiDomain' => self::describe_string( $unicode_email->get_ascii_domain() ), + 'storedCanonical' => $stored_user instanceof \WP_User && $canonical === $stored_user->user_email, + 'machineLookupMiss' => false === ( $exists_machine['value'] ?? null ), + 'machineResetRejected' => \is_wp_error( $machine_reset['value'] ?? null ), + 'mailToMachine' => is_array( $mail ) && self::addresses_include( $mail['to'] ?? array(), $machine, '' ), + ); + } + } finally { + \remove_action( 'wp_mail_succeeded', $success_action ); + \PHPMailer\PHPMailer\PHPMailer::$validator = $validator_snapshot; + EmailSurfaceMailer::$sent = array(); + if ( $mailer_snapshot['exists'] ) { + $GLOBALS['phpmailer'] = $mailer_snapshot['value']; + } else { + unset( $GLOBALS['phpmailer'] ); + } + if ( $server_snapshot['exists'] ) { + $_SERVER['REMOTE_ADDR'] = $server_snapshot['value']; + } else { + unset( $_SERVER['REMOTE_ADDR'] ); + } + self::restore_hook_globals( $hook_snapshot ); + self::reset_stub_content(); + } + + return array( + $ctx->result( + $result_name, + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'dbCoverage' => 'stub-backed no-DB lookup only; MySQL user_email collation and index behavior is not exercised here.', + 'observed' => $observed, + 'failures' => self::describe_value( $failures ), + ) + ), + ); + } + + private static function check_punycode_views( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.wp-email-address.punycode-domain-decodes', + 'idn_to_ascii() or 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 check_idn_views( \ComponentFuzz\FuzzContext $ctx ): array { + if ( ! self::has_idn() ) { + return array( + $ctx->skip( + 'email.wp-email-address.idn-view-matrix', + 'idn_to_ascii() or idn_to_utf8() is unavailable.' + ), + ); + } + + $samples = array( + array( 'label' => 'latin-diaeresis', 'domain' => "b\u{00FC}cher.de" ), + array( 'label' => 'latin-ring', 'domain' => "gr\u{00E5}.org" ), + array( 'label' => 'cjk', 'domain' => "\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" ), + array( 'label' => 'greek', 'domain' => "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" ), + array( 'label' => 'cyrillic', 'domain' => "\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" ), + array( 'label' => 'hiragana', 'domain' => "\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" ), + array( 'label' => 'eszett', 'domain' => "fa\u{00DF}.de" ), + ); + $failures = array(); + $views = array(); + + foreach ( $samples as $sample ) { + $encoded_domain = idn_to_ascii( $sample['domain'], IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + $decoded_domain = false === $encoded_domain ? false : idn_to_utf8( $encoded_domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); + if ( false === $encoded_domain || false === $decoded_domain ) { + $failures[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'error' => 'IDN conversion failed.', + ); + continue; + } + + $ascii_input = 'mail@' . $encoded_domain; + $unicode_input = 'mail@' . $sample['domain']; + $encoded_parse = self::call( static fn() => \WP_Email_Address::from_string( $ascii_input, 'unicode' ) ); + $raw_parse = self::call( static fn() => \WP_Email_Address::from_string( $unicode_input, 'unicode' ) ); + $is_encoded = self::call( static fn() => \is_email( $ascii_input ) ); + $sanitize_encoded = self::call( static fn() => \sanitize_email( $ascii_input ) ); + $encoded_email = $encoded_parse['value'] ?? null; + $raw_email = $raw_parse['value'] ?? null; + $raw_roundtrip = $raw_email instanceof \WP_Email_Address + ? self::call( static fn() => \WP_Email_Address::from_string( $raw_email->get_ascii_address(), 'unicode' ) ) + : array( 'threw' => false, 'value' => null ); + + $encoded_ok = ! $encoded_parse['threw'] + && ! $is_encoded['threw'] + && ! $sanitize_encoded['threw'] + && $encoded_email instanceof \WP_Email_Address + && $encoded_domain === $encoded_email->get_ascii_domain() + && $decoded_domain === $encoded_email->get_unicode_domain() + && $ascii_input === $encoded_email->get_ascii_address() + && 'mail@' . $decoded_domain === $encoded_email->get_unicode_address() + && 'mail@' . $decoded_domain === $is_encoded['value'] + && 'mail@' . $decoded_domain === $sanitize_encoded['value'] + && self::is_ascii( $encoded_email->get_ascii_address() ); + + $raw_ok = ! $raw_parse['threw'] + && ! $raw_roundtrip['threw'] + && $raw_email instanceof \WP_Email_Address + && $encoded_domain === $raw_email->get_ascii_domain() + && $sample['domain'] === $raw_email->get_unicode_domain() + && 'mail@' . $encoded_domain === $raw_email->get_ascii_address() + && $unicode_input === $raw_email->get_unicode_address() + && self::is_ascii( $raw_email->get_ascii_address() ) + && $raw_roundtrip['value'] instanceof \WP_Email_Address + && $unicode_input === $raw_roundtrip['value']->get_unicode_address(); + + if ( ! $encoded_ok || ! $raw_ok ) { + $failures[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'encodedDomain' => self::describe_string( $encoded_domain ), + 'decodedDomain' => self::describe_string( $decoded_domain ), + 'encodedParse' => self::describe_call( $encoded_parse ), + 'rawParse' => self::describe_call( $raw_parse ), + 'isEncoded' => self::describe_call( $is_encoded ), + 'sanitizeEncoded' => self::describe_call( $sanitize_encoded ), + 'rawRoundtrip' => self::describe_call( $raw_roundtrip ), + ); + } + + $views[] = array( + 'label' => $sample['label'], + 'domain' => self::describe_string( $sample['domain'] ), + 'encodedDomain' => self::describe_string( $encoded_domain ), + 'decodedDomain' => self::describe_string( $decoded_domain ), + 'rawAsciiDomainMatchesIdn' => $raw_email instanceof \WP_Email_Address && $encoded_domain === $raw_email->get_ascii_domain(), + 'rawAsciiAddressIsAscii' => $raw_email instanceof \WP_Email_Address && self::is_ascii( $raw_email->get_ascii_address() ), + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.idn-view-matrix', + array() === $failures, + array( + 'views' => $views, + 'failures' => $failures, + ) + ), + ); + } + + 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_mailto_rendering_context_round_trips( \ComponentFuzz\FuzzContext $ctx ): array { + foreach ( array( 'esc_html', 'esc_url', 'esc_url_raw', 'sanitize_url' ) as $function ) { + if ( ! function_exists( $function ) ) { + return array( + $ctx->skip( + 'email.mailto.rendering-context-round-trips', + 'Required URL/HTML escaping APIs are unavailable.', + array( 'missing' => 'function ' . $function ) + ), + ); + } + } + + $cases = self::mailto_context_cases( $ctx->fork( 'mailto-rendering-contexts' ) ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $case ) { + $input = $case['local'] . '@' . $case['domain']; + $parse = self::capture_warnings( static fn() => \WP_Email_Address::from_string( $input, 'unicode' ) ); + $email = $parse['value'] ?? null; + + if ( $parse['threw'] || array() !== $parse['warnings'] || ! ( $email instanceof \WP_Email_Address ) ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $input ), + 'parse' => self::describe_captured_call( $parse ), + ); + continue; + } + + $uri_mailbox = self::mailto_uri_mailbox( $email ); + $raw_href = 'mailto:' . $uri_mailbox . '?subject=' . rawurlencode( $case['subject'] ) . '&body=' . rawurlencode( $case['body'] ); + $escaped_href = self::capture_warnings( static fn() => \esc_url( $raw_href ) ); + $raw_escaped_href = self::capture_warnings( static fn() => \esc_url_raw( $raw_href ) ); + $sanitized_href = self::capture_warnings( static fn() => \sanitize_url( $raw_href ) ); + $escaped_text = self::capture_warnings( static fn() => \esc_html( $email->get_unicode_address() ) ); + $anchor_html = ! $escaped_href['threw'] && ! $escaped_text['threw'] && is_string( $escaped_href['value'] ?? null ) && is_string( $escaped_text['value'] ?? null ) + ? '' . $escaped_text['value'] . '' + : ''; + $anchors = '' !== $anchor_html ? self::mailto_anchors( $anchor_html ) : array(); + $anchor = $anchors[0] ?? array(); + $decoded_href = isset( $anchor['href'] ) && is_string( $anchor['href'] ) + ? 'mailto:' . html_entity_decode( $anchor['href'], ENT_QUOTES, 'UTF-8' ) + : ''; + $decoded_text = isset( $anchor['text'] ) && is_string( $anchor['text'] ) + ? html_entity_decode( $anchor['text'], ENT_QUOTES, 'UTF-8' ) + : ''; + $href_parts = '' !== $decoded_href ? parse_url( $decoded_href ) : false; + $decoded_mailbox = is_array( $href_parts ) && isset( $href_parts['path'] ) ? rawurldecode( $href_parts['path'] ) : ''; + $query = array(); + if ( is_array( $href_parts ) && isset( $href_parts['query'] ) ) { + parse_str( $href_parts['query'], $query ); + } + $href_parse = '' === $decoded_mailbox + ? array( 'threw' => false, 'value' => null, 'warnings' => array() ) + : self::capture_warnings( static fn() => \WP_Email_Address::from_string( $decoded_mailbox, 'unicode' ) ); + $href_email = $href_parse['value'] ?? null; + + $display_href_round_trips = ! $escaped_href['threw'] + && is_string( $escaped_href['value'] ?? null ) + && $raw_href === html_entity_decode( $escaped_href['value'], ENT_QUOTES, 'UTF-8' ) + && str_contains( $escaped_href['value'], '&body=' ) + && ! str_contains( $escaped_href['value'], '&body=' ); + $raw_href_round_trips = ! $raw_escaped_href['threw'] + && ! $sanitized_href['threw'] + && array() === $raw_escaped_href['warnings'] + && array() === $sanitized_href['warnings'] + && $raw_href === $raw_escaped_href['value'] + && $raw_href === $sanitized_href['value']; + $anchor_ok = 1 === count( $anchors ) + && $decoded_href === $raw_href + && $decoded_text === $email->get_unicode_address(); + $href_parts_ok = is_array( $href_parts ) + && 'mailto' === ( $href_parts['scheme'] ?? null ) + && $decoded_mailbox === $email->get_ascii_address() + && ( $query['subject'] ?? null ) === $case['subject'] + && ( $query['body'] ?? null ) === $case['body']; + $href_parse_ok = ! $href_parse['threw'] + && array() === ( $href_parse['warnings'] ?? array() ) + && $href_email instanceof \WP_Email_Address + && $href_email->get_ascii_address() === $email->get_ascii_address() + && $href_email->get_unicode_address() === $email->get_unicode_address(); + $uri_uses_context_views = str_contains( $raw_href, '@' . $email->get_ascii_domain() ) + && ( + $email->get_ascii_domain() === $email->get_unicode_domain() + || ! str_contains( $uri_mailbox, $email->get_unicode_domain() ) + ) + && ( + self::is_ascii( $email->get_localpart() ) + || ! str_contains( $uri_mailbox, $email->get_localpart() ) + ); + $ok = array() === $parse['warnings'] + && ! $escaped_href['threw'] + && ! $raw_escaped_href['threw'] + && ! $sanitized_href['threw'] + && ! $escaped_text['threw'] + && array() === $escaped_href['warnings'] + && array() === $escaped_text['warnings'] + && $display_href_round_trips + && $raw_href_round_trips + && $anchor_ok + && $href_parts_ok + && $href_parse_ok + && $uri_uses_context_views; + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'input' => self::describe_string( $input ), + 'address' => self::describe_address( $email ), + 'uriMailbox' => self::describe_string( $uri_mailbox ), + 'rawHref' => self::describe_string( $raw_href ), + 'escapedHref' => self::describe_captured_call( $escaped_href ), + 'rawEscapedHref' => self::describe_captured_call( $raw_escaped_href ), + 'sanitizedHref' => self::describe_captured_call( $sanitized_href ), + 'escapedText' => self::describe_captured_call( $escaped_text ), + 'anchorHtml' => self::describe_string( $anchor_html ), + 'anchors' => self::describe_value( $anchors ), + 'decodedHref' => self::describe_string( $decoded_href ), + 'decodedText' => self::describe_string( $decoded_text ), + 'decodedMailbox' => self::describe_string( $decoded_mailbox ), + 'query' => self::describe_value( $query ), + 'hrefParse' => self::describe_captured_call( $href_parse ), + 'displayRoundTrips' => $display_href_round_trips, + 'rawRoundTrips' => $raw_href_round_trips, + 'anchorOk' => $anchor_ok, + 'hrefPartsOk' => $href_parts_ok, + 'hrefParseOk' => $href_parse_ok, + 'uriViewsOk' => $uri_uses_context_views, + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'source' => $case['source'], + 'readableAddress' => self::describe_string( $email->get_unicode_address() ), + 'uriMailbox' => self::describe_string( $uri_mailbox ), + 'unicodeLocal' => ! self::is_ascii( $email->get_localpart() ), + 'unicodeDomain' => $email->get_ascii_domain() !== $email->get_unicode_domain(), + 'hrefPercentEncoded' => $uri_mailbox !== $email->get_ascii_address(), + ); + } + + return array( + $ctx->result( + 'email.mailto.rendering-context-round-trips', + array() === $failures, + array( + 'caseCount' => count( $cases ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + } + + private static function check_make_clickable_email_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $samples = array( + array( + 'label' => 'ascii-baseline', + 'address' => 'user@example.com', + 'valid' => true, + ), + array( + 'label' => 'plus-tag', + 'address' => 'USER+tag@example.com', + 'valid' => true, + ), + ); + + if ( self::has_idn() ) { + $samples[] = array( + 'label' => 'ascii-local-punycode-domain', + 'address' => 'mail@xn--bcher-kva.de', + 'valid' => true, + ); + $samples[] = array( + 'label' => 'ascii-local-punycode-tld', + 'address' => 'mail@xn--fsqu00a.xn--4rr70v', + 'valid' => true, + ); + } + + foreach ( self::extension_view_cases( $ctx->fork( 'make-clickable' ) ) 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 ) { + $samples[] = array( + 'label' => $sample['label'] . '-machine', + 'address' => $email->get_ascii_address(), + 'valid' => true, + ); + + if ( $email->get_unicode_address() !== $email->get_ascii_address() ) { + $samples[] = array( + 'label' => $sample['label'] . '-readable', + 'address' => $email->get_unicode_address(), + 'valid' => true, + ); + } + } + } + + $samples = array_merge( + $samples, + array( + array( + 'label' => 'unicode-local-valid-address', + 'address' => "jos\u{00E9}@example.com", + 'valid' => true, + ), + array( + 'label' => 'emoji-local-invalid-address', + 'address' => "emoji\u{1F600}@example.com", + 'valid' => false, + ), + array( + 'label' => 'invalid-utf8-local-address', + 'address' => "bad\x80@example.com", + 'valid' => false, + ), + array( + 'label' => 'double-at-address', + 'address' => 'bad@@example.com', + 'valid' => false, + ), + array( + 'label' => 'double-dot-domain-address', + 'address' => 'user@example..com', + 'valid' => false, + ), + array( + 'label' => 'invalid-punycode-tld-no-partial-link', + 'address' => 'mail@example.xn--', + 'valid' => false, + ), + array( + 'label' => 'trailing-hyphen-tld-no-partial-link', + 'address' => 'mail@example.co-', + 'valid' => false, + ), + ) + ); + + $failures = array(); + $observed = array(); + $partial_link_failures = array(); + + foreach ( $samples as $case ) { + $address = $case['address']; + $wrapped = 'Contact ' . $address . ' now'; + $rendered = self::call( static fn() => \make_clickable( $wrapped ) ); + $parse = self::call( static fn() => \WP_Email_Address::from_string( $address, 'unicode' ) ); + $email = $parse['value'] ?? null; + $anchors = ! $rendered['threw'] && is_string( $rendered['value'] ) + ? self::mailto_anchors( $rendered['value'] ) + : array(); + $expect_link = true === $case['valid'] && self::make_clickable_email_pattern_matches( $address ); + $actual_link = 1 === count( $anchors ); + $anchor = $anchors[0] ?? array(); + $href_address = (string) ( $anchor['href'] ?? '' ); + $text_address = (string) ( $anchor['text'] ?? '' ); + $href_parse = '' === $href_address + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $href_address, 'unicode' ) ); + $text_parse = '' === $text_address + ? array( 'threw' => false, 'value' => null ) + : self::call( static fn() => \WP_Email_Address::from_string( $text_address, 'unicode' ) ); + $href_email = $href_parse['value'] ?? null; + $text_email = $text_parse['value'] ?? null; + + if ( + ! $expect_link + && array() !== $anchors + && str_contains( strtolower( $address ), '.xn--' ) + ) { + $partial_link_failures[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'rendered' => self::describe_call( $rendered ), + 'anchors' => self::describe_value( $anchors ), + ); + } + + if ( $expect_link ) { + $ok = ! $rendered['threw'] + && $email instanceof \WP_Email_Address + && $actual_link + && $address === $href_address + && $address === $text_address + && $href_email instanceof \WP_Email_Address + && $text_email instanceof \WP_Email_Address + && $href_email->get_unicode_address() === $email->get_unicode_address() + && $text_email->get_unicode_address() === $email->get_unicode_address() + && str_contains( $rendered['value'], '' . $address . '' ); + } else { + $ok = ! $rendered['threw'] + && array() === $anchors + && $wrapped === $rendered['value']; + } + + if ( ! $ok ) { + $failures[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'valid' => $case['valid'], + 'expectLink' => $expect_link, + 'rendered' => self::describe_call( $rendered ), + 'parse' => self::describe_call( $parse ), + 'anchors' => self::describe_value( $anchors ), + 'hrefParse' => self::describe_call( $href_parse ), + 'textParse' => self::describe_call( $text_parse ), + ); + } + + $observed[] = array( + 'label' => $case['label'], + 'address' => self::describe_string( $address ), + 'valid' => $case['valid'], + 'expectLink' => $expect_link, + 'linked' => $actual_link, + ); + } + + $rows = array( + $ctx->result( + 'email.make-clickable.mailto-rendering-boundaries', + array() === $failures, + array( + 'caseCount' => count( $samples ), + 'observed' => $observed, + 'failures' => $failures, + ) + ), + ); + + $rows[] = $ctx->result( + 'email.make-clickable.punycode-tld-partial-link-boundary', + array() === $partial_link_failures, + array( 'cases' => $partial_link_failures ) + ); + + return $rows; + } + + 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'; + $exact = array( + array( + 'label' => 'ascii-domain-label-63', + 'input' => 'u@' . str_repeat( 'a', 63 ) . '.com', + 'valid' => true, + ), + array( + 'label' => 'ascii-domain-label-64', + 'input' => 'u@' . str_repeat( 'a', 64 ) . '.com', + 'valid' => false, + ), + ); + if ( self::has_idn() ) { + $exact[] = array( + 'label' => 'unicode-domain-label-63-bytes', + 'input' => 'u@' . str_repeat( "\u{00E5}", 31 ) . 'a.com', + 'valid' => true, + ); + $exact[] = array( + 'label' => 'unicode-domain-label-64-bytes', + 'input' => 'u@' . str_repeat( "\u{00E5}", 32 ) . '.com', + 'valid' => false, + ); + } + $observed = array( + array( 'label' => 'local-64-bytes', 'input' => str_repeat( 'a', 64 ) . '@example.com' ), + array( 'label' => 'local-65-bytes', 'input' => str_repeat( 'a', 65 ) . '@example.com' ), + array( 'label' => 'whole-address-254-bytes', 'input' => $address_254 ), + array( 'label' => 'whole-address-255-bytes', 'input' => $address_255 ), + ); + $failures = array(); + $lengths = array(); + + foreach ( $exact as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected_is_email = $case['valid'] ? $case['input'] : false; + $expected_sanitized = $case['valid'] ? $case['input'] : ''; + $expected_parsed = $case['valid']; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected_parsed !== ( $parsed['value'] instanceof \WP_Email_Address ) || + $expected_is_email !== $is_email['value'] || + $expected_sanitized !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValid' => $case['valid'], + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $lengths[] = array( + 'label' => $case['label'], + 'bytes' => strlen( $case['input'] ), + 'valid' => $case['valid'], + ); + } + + foreach ( $observed as $case ) { + $parsed = self::call( static fn() => \WP_Email_Address::from_string( $case['input'], 'unicode' ) ); + $is_email = self::call( static fn() => \is_email( $case['input'] ) ); + $sanitized = self::call( static fn() => \sanitize_email( $case['input'] ) ); + $expected = $parsed['value'] instanceof \WP_Email_Address ? $parsed['value']->get_unicode_address() : false; + + if ( + $parsed['threw'] || + $is_email['threw'] || + $sanitized['threw'] || + $expected !== $is_email['value'] || + ( false === $expected ? '' : $expected ) !== $sanitized['value'] + ) { + $failures[] = array( + 'label' => $case['label'], + 'input' => self::describe_string( $case['input'] ), + 'expectedValue' => self::describe_value( $expected ), + 'parsed' => self::describe_call( $parsed ), + 'isEmail' => self::describe_call( $is_email ), + 'sanitizeEmail' => self::describe_call( $sanitized ), + ); + } + + $lengths[] = array( + 'label' => $case['label'], + 'bytes' => strlen( $case['input'] ), + 'valid' => $parsed['value'] instanceof \WP_Email_Address, + ); + } + + return array( + $ctx->result( + 'email.wp-email-address.length-boundaries', + array() === $failures, + array( + 'lengths' => $lengths, + 'failures' => $failures, + ) + ), + ); + } + + private static function address_parts_ok( \WP_Email_Address $email ): bool { + $parts = array( + $email->get_localpart(), + $email->get_ascii_domain(), + $email->get_unicode_domain(), + $email->get_ascii_address(), + $email->get_unicode_address(), + ); + + foreach ( $parts as $part ) { + if ( ! \wp_is_valid_utf8( $part ) ) { + return false; + } + } + + if ( + '' === $email->get_localpart() || + '' === $email->get_ascii_domain() || + '' === $email->get_unicode_domain() || + str_contains( $email->get_localpart(), '@' ) || + str_contains( $email->get_ascii_domain(), '@' ) || + str_contains( $email->get_unicode_domain(), '@' ) + ) { + return false; + } + + if ( ! self::is_ascii( $email->get_ascii_domain() ) ) { + return false; + } + + foreach ( explode( '.', $email->get_ascii_domain() ) as $label ) { + if ( '' === $label || strlen( $label ) > 63 ) { + return false; + } + } + + foreach ( explode( '.', $email->get_unicode_domain() ) as $label ) { + if ( '' === $label ) { + return false; + } + } + + return str_contains( $email->get_ascii_address(), '@' ) + && str_contains( $email->get_unicode_address(), '@' ); + } + + private static function address_raw_views( \WP_Email_Address $email ): array { + return array( + 'localpart' => $email->get_localpart(), + 'asciiDomain' => $email->get_ascii_domain(), + 'unicodeDomain' => $email->get_unicode_domain(), + 'asciiAddress' => $email->get_ascii_address(), + 'unicodeAddress' => $email->get_unicode_address(), + ); + } + + private static function mailto_uri_mailbox( \WP_Email_Address $email ): string { + return rawurlencode( $email->get_localpart() ) . '@' . $email->get_ascii_domain(); + } + + private static function mailto_anchors( string $html ): array { + $count = preg_match_all( '#([^<]+)#', $html, $matches, PREG_SET_ORDER ); + if ( false === $count || 0 === $count ) { + return array(); + } + + $anchors = array(); + foreach ( $matches as $match ) { + $anchors[] = array( + 'href' => $match[1], + 'text' => $match[2], + ); + } + + return $anchors; + } + + private static function make_clickable_email_pattern_matches( string $address ): bool { + return 1 === preg_match( '/\A[.0-9a-z_+-]+@(?:[0-9a-z-]+\.)+(?:xn--[0-9a-z-]*[0-9a-z]|[0-9a-z]{2,})\z/i', $address ); + } + + 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 hostile_user_email_lookup_probe( string $lookup, string $candidate ): array { + global $wpdb; + + self::delete_user_email_cache( $candidate ); + $candidate_user = \get_user_by( 'email', $candidate ); + if ( ! ( $candidate_user instanceof \WP_User ) || ! is_object( $candidate_user->data ) ) { + return array( + 'ok' => false, + 'reason' => 'candidate-user-unavailable', + ); + } + + $original_wpdb = $wpdb; + $candidate_row = clone $candidate_user->data; + $proxy = new class( $original_wpdb, $candidate_row ) { + /** @var object */ + private $delegate; + + /** @var object */ + private $candidate; + + /** @var int */ + public $hits = 0; + + /** @var string */ + public $last_query = ''; + + public function __construct( $delegate, object $candidate ) { + $this->delegate = $delegate; + $this->candidate = $candidate; + } + + public function get_results( $query = null, $output = OBJECT ) { + $sql = null === $query ? (string) ( $this->delegate->last_query ?? '' ) : (string) $query; + $this->last_query = $sql; + + if ( preg_match( '/WHERE\s+user_email\s*=\s*/i', $sql ) ) { + ++$this->hits; + return array( clone $this->candidate ); + } + + return $this->delegate->get_results( $query, $output ); + } + + public function __call( string $method, array $args ) { + return $this->delegate->$method( ...$args ); + } + + public function __get( string $name ) { + return $this->delegate->$name; + } + + public function __set( string $name, $value ): void { + $this->delegate->$name = $value; + } + + public function __isset( string $name ): bool { + return isset( $this->delegate->$name ); + } + }; + + self::delete_user_email_cache( $lookup ); + $wpdb = $proxy; + $GLOBALS['wpdb'] = $proxy; + try { + $exists = self::capture_warnings( static fn() => \email_exists( $lookup ) ); + self::delete_user_email_cache( $lookup ); + $by_email = self::capture_warnings( static fn() => \get_user_by( 'email', $lookup ) ); + } finally { + $wpdb = $original_wpdb; + $GLOBALS['wpdb'] = $original_wpdb; + self::delete_user_email_cache( $lookup ); + } + + return array( + 'ok' => 0 !== strcasecmp( $lookup, $candidate ) + && ! $exists['threw'] + && ! $by_email['threw'] + && array() === $exists['warnings'] + && array() === $by_email['warnings'] + && false === $exists['value'] + && false === $by_email['value'] + && $proxy->hits >= 2, + 'lookup' => self::describe_string( $lookup ), + 'candidate' => self::describe_string( $candidate ), + 'hits' => $proxy->hits, + 'query' => self::describe_string( $proxy->last_query ), + 'exists' => self::describe_captured_call( $exists ), + 'byEmail' => self::describe_captured_call( $by_email ), + ); + } + + private static function seed_stub_user_email_row( string $email, string $login ): array { + global $wpdb; + + self::delete_user_email_cache( $email ); + + return self::capture_warnings( + static function () use ( $wpdb, $email, $login ) { + $result = $wpdb->insert( + $wpdb->users, + array( + 'user_login' => $login, + 'user_pass' => 'component-fuzz-seeded-password', + 'user_nicename' => $login, + 'user_email' => $email, + 'user_url' => '', + 'user_registered' => '2026-06-25 00:00:00', + 'display_name' => $login, + ) + ); + + if ( false === $result ) { + return false; + } + + return (int) $wpdb->insert_id; + } + ); + } + + private static function probe_user_email_localpart_search_results( \ComponentFuzz\FuzzContext $ctx, array $case, ?array $records = null ): array { + $unicode_local = self::email_localpart( $case['unicodeAddress'] ); + $folded_local = self::email_localpart( $case['foldedAddress'] ); + $seeded_ids = array(); + $seed_call = array( + 'threw' => false, + 'value' => array(), + 'warnings' => array(), + ); + + if ( null === $records ) { + $base_id = 800000 + ( 2 * ( (int) sprintf( '%u', crc32( $case['label'] . '|' . $ctx->iteration() ) ) % 50000 ) ); + $records = array( + array( + 'id' => $base_id, + 'email' => $case['unicodeAddress'], + ), + array( + 'id' => $base_id + 1, + 'email' => $case['foldedAddress'], + ), + ); + + $seed_call = self::capture_warnings( + static function () use ( $records, &$seeded_ids ): array { + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + return array(); + } + + foreach ( $records as $record ) { + $id = (int) ( $record['id'] ?? 0 ); + $email = (string) ( $record['email'] ?? '' ); + if ( $id <= 0 || '' === $email ) { + continue; + } + + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->users, array( 'ID' => $id ) ); + $GLOBALS['wpdb']->insert( + $GLOBALS['wpdb']->users, + array( + 'ID' => $id, + 'user_login' => 'cfz_search_probe_' . $id, + 'user_pass' => 'component-fuzz-pass', + 'user_nicename' => 'cfz-search-probe-' . $id, + 'user_email' => $email, + 'display_name' => 'Component Fuzz Search Probe ' . $id, + ) + ); + self::delete_user_email_cache( $email ); + $seeded_ids[] = $id; + } + + return $seeded_ids; + } + ); + } else { + $records = array_values( array_filter( $records, static fn( $record ) => is_array( $record ) ) ); + } + + $hook_snapshot = self::snapshot_hook_globals(); + try { + \remove_all_filters( 'pre_user_query' ); + \remove_all_filters( 'users_pre_query' ); + $unicode_query = self::run_user_email_localpart_search_query( '*' . $unicode_local . '*' ); + $folded_query = self::run_user_email_localpart_search_query( '*' . $folded_local . '*' ); + } finally { + self::restore_hook_globals( $hook_snapshot ); + foreach ( $seeded_ids as $seeded_id ) { + if ( isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) ) { + $GLOBALS['wpdb']->delete( $GLOBALS['wpdb']->users, array( 'ID' => (int) $seeded_id ) ); + } + } + } + + $expected_unicode = self::expected_byte_preserving_email_search_ids( $records, $unicode_local ); + $expected_folded = self::expected_byte_preserving_email_search_ids( $records, $folded_local ); + $actual_unicode = self::captured_query_result_ids( $unicode_query ); + $actual_folded = self::captured_query_result_ids( $folded_query ); + $used_query_path = is_array( $unicode_query['value'] ?? null ) + && is_array( $folded_query['value'] ?? null ) + && str_contains( (string) ( $unicode_query['value']['queryWhere'] ?? '' ), 'LIKE' ) + && str_contains( (string) ( $folded_query['value']['queryWhere'] ?? '' ), 'LIKE' ); + $seed_ok = ! $seed_call['threw'] && array() === $seed_call['warnings']; + + return array( + 'ok' => 2 === count( $records ) + && $seed_ok + && ! $unicode_query['threw'] + && ! $folded_query['threw'] + && array() === $unicode_query['warnings'] + && array() === $folded_query['warnings'] + && $actual_unicode === $expected_unicode + && $actual_folded === $expected_folded + && $used_query_path, + 'unicode' => array( + 'search' => self::describe_string( $unicode_local ), + 'expected' => $expected_unicode, + 'actual' => $actual_unicode, + ), + 'folded' => array( + 'search' => self::describe_string( $folded_local ), + 'expected' => $expected_folded, + 'actual' => $actual_folded, + ), + 'seed' => self::describe_captured_call( $seed_call ), + 'records' => $records, + 'unicodeQuery' => self::describe_captured_call( $unicode_query ), + 'foldedQuery' => self::describe_captured_call( $folded_query ), + 'usedQueryPath' => $used_query_path, + ); + } + + private static function prepare_user_email_search_query( string $search, array $search_columns = array() ): array { + $query = new \WP_User_Query(); + + return self::capture_warnings( + static function () use ( $query, $search, $search_columns ): array { + $query->prepare_query( + array( + 'blog_id' => 0, + 'cache_results' => false, + 'count_total' => false, + 'fields' => 'ID', + 'number' => 1, + 'orderby' => 'ID', + 'search' => $search, + 'search_columns' => $search_columns, + ) + ); + + return array( + 'queryWhere' => (string) $query->query_where, + 'queryVars' => $query->query_vars, + ); + } + ); + } + + private static function run_user_email_localpart_search_query( string $search ): array { + return self::capture_warnings( + static function () use ( $search ): array { + $query = new \WP_User_Query( + array( + 'blog_id' => 0, + 'cache_results' => false, + 'count_total' => false, + 'fields' => 'ID', + 'orderby' => 'ID', + 'order' => 'ASC', + 'search' => $search, + 'search_columns' => array( 'user_email' ), + ) + ); + + return array( + 'results' => array_map( 'intval', (array) $query->get_results() ), + 'queryWhere' => (string) $query->query_where, + 'queryVars' => $query->query_vars, + ); + } + ); + } + + private static function expected_byte_preserving_email_search_ids( array $records, string $needle ): array { + $ids = array(); + foreach ( $records as $record ) { + if ( str_contains( $record['email'], $needle ) ) { + $ids[] = (int) $record['id']; + } + } + + return $ids; + } + + private static function captured_query_result_ids( array $query ): array { + if ( ! is_array( $query['value'] ?? null ) || ! is_array( $query['value']['results'] ?? null ) ) { + return array(); + } + + return array_map( 'intval', $query['value']['results'] ); + } + + private static function email_localpart( string $address ): string { + $at = strpos( $address, '@' ); + if ( false === $at ) { + return $address; + } + + return substr( $address, 0, $at ); + } + + private static function generated_comment_submission_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( + 'label' => 'handle-unicode-local-latin', + 'path' => 'handle-submission', + 'input' => "gr\u{00E5}@example.com", + 'expectedEmail' => "gr\u{00E5}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'handleSubmission' ), + ), + array( + 'label' => 'handle-unicode-local-combining', + 'path' => 'handle-submission', + 'input' => "jose\u{0301}@example.com", + 'expectedEmail' => "jose\u{0301}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'combiningMark', 'handleSubmission' ), + ), + array( + 'label' => 'new-comment-display-name-recovery', + 'path' => 'wp-new-comment', + 'input' => "\"\u{00C5}sa Example\" ", + 'expectedEmail' => "gr\u{00E5}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'displayName', 'recoverableWhitespace', 'wpNewComment' ), + ), + array( + 'label' => 'new-comment-nbsp-recovery', + 'path' => 'wp-new-comment', + 'input' => "jos\u{00E9}\u{00A0}@\u{00A0}example.com", + 'expectedEmail' => "jos\u{00E9}@example.com", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeLocal', 'recoverableWhitespace', 'wpNewComment' ), + ), + array( + 'label' => 'handle-emoji-local-rejected', + 'path' => 'handle-submission', + 'input' => "emoji\u{1F600}@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'emojiLocal', 'handleSubmission' ), + ), + array( + 'label' => 'handle-fullwidth-at-rejected', + 'path' => 'handle-submission', + 'input' => "bad\u{FF20}example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'fullwidthAt', 'handleSubmission' ), + ), + array( + 'label' => 'handle-invalid-utf8-rejected', + 'path' => 'handle-submission', + 'input' => "bad\xE2\x82@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'invalidUtf8', 'handleSubmission' ), + ), + array( + 'label' => 'handle-leading-combining-local-rejected', + 'path' => 'handle-submission', + 'input' => "\u{0301}bad@example.com", + 'expectedEmail' => null, + 'accepted' => false, + 'expectedError' => 'require_valid_email', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'leadingCombiningMark', 'handleSubmission' ), + ), + array( + 'label' => 'new-comment-emoji-local-sanitized-empty', + 'path' => 'wp-new-comment', + 'input' => "direct\u{1F600}@example.com", + 'expectedEmail' => '', + 'accepted' => false, + 'expectedError' => 'wp-new-comment-sanitizes-invalid', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'emojiLocal', 'wpNewComment', 'sanitizedEmpty' ), + ), + array( + 'label' => 'new-comment-fullwidth-at-sanitized-empty', + 'path' => 'wp-new-comment', + 'input' => "direct\u{FF20}example.com", + 'expectedEmail' => '', + 'accepted' => false, + 'expectedError' => 'wp-new-comment-sanitizes-invalid', + 'source' => 'fixed', + 'traits' => array( 'invalid', 'fullwidthAt', 'wpNewComment', 'sanitizedEmpty' ), + ), + ); + + $valid_locals = array( + array( 'value' => "gr\u{00E5}", 'traits' => array( 'unicodeLocal', 'latin' ) ), + array( 'value' => "jos\u{00E9}", 'traits' => array( 'unicodeLocal', 'latin' ) ), + array( 'value' => "jose\u{0301}", 'traits' => array( 'unicodeLocal', 'combiningMark' ) ), + array( 'value' => "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", 'traits' => array( 'unicodeLocal', 'greek' ) ), + array( 'value' => "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}", 'traits' => array( 'unicodeLocal', 'cyrillic' ) ), + array( 'value' => "\u{3086}\u{3046}\u{3056}\u{3042}", 'traits' => array( 'unicodeLocal', 'hiragana' ) ), + ); + $domains = array( + array( 'value' => 'example.com', 'traits' => array( 'asciiDomain' ) ), + array( 'value' => 'sub-domain.example', 'traits' => array( 'asciiDomain' ) ), + ); + + if ( self::has_idn() ) { + $cases[] = array( + 'label' => 'handle-unicode-domain-latin', + 'path' => 'handle-submission', + 'input' => "mail@gr\u{00E5}.org", + 'expectedEmail' => "mail@gr\u{00E5}.org", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeDomain', 'handleSubmission' ), + ); + $cases[] = array( + 'label' => 'new-comment-unicode-domain-recovery', + 'path' => 'wp-new-comment', + 'input' => "Display Name ", + 'expectedEmail' => "mail@gr\u{00E5}.org", + 'accepted' => true, + 'expectedError' => null, + 'source' => 'fixed', + 'traits' => array( 'valid', 'unicodeDomain', 'displayName', 'recoverableWhitespace', 'wpNewComment' ), + ); + $domains[] = array( 'value' => "gr\u{00E5}.org", 'traits' => array( 'unicodeDomain', 'latin' ) ); + $domains[] = array( 'value' => "b\u{00FC}cher.tld", 'traits' => array( 'unicodeDomain', 'latin' ) ); + $domains[] = array( + 'value' => "\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", + 'traits' => array( 'unicodeDomain', 'greek' ), + ); + } + + $invalid_profiles = array( + array( 'label' => 'emoji-local', 'input' => "emoji\u{1F642}@example.com", 'traits' => array( 'emojiLocal' ) ), + array( 'label' => 'fullwidth-at', 'input' => "generated\u{FF20}example.com", 'traits' => array( 'fullwidthAt' ) ), + array( 'label' => 'invalid-utf8', 'input' => "generated\xC0\xAF@example.com", 'traits' => array( 'invalidUtf8' ) ), + array( 'label' => 'leading-combining-local', 'input' => "\u{0301}generated@example.com", 'traits' => array( 'leadingCombiningMark' ) ), + ); + + for ( $i = 0; $i < self::GENERATED_COMMENT_SUBMISSION_CASES; $i++ ) { + if ( $ctx->bool( 58 ) ) { + $local = $ctx->choice( $valid_locals ); + $domain = $ctx->choice( $domains ); + $expected_email = $local['value'] . '@' . $domain['value']; + $path = $ctx->bool( 35 ) ? 'wp-new-comment' : 'handle-submission'; + $input = $expected_email; + $traits = array_merge( array( 'valid' ), $local['traits'], $domain['traits'] ); + + if ( 'wp-new-comment' === $path ) { + $input = 'Generated ' . $i . ' <' . $local['value'] . ' @ ' . str_replace( '.', ' . ', $domain['value'] ) . '.>'; + $traits[] = 'displayName'; + $traits[] = 'recoverableWhitespace'; + $traits[] = 'wpNewComment'; + } else { + $traits[] = 'handleSubmission'; + } + + $cases[] = array( + 'label' => 'generated-valid-' . $i, + 'path' => $path, + 'input' => $input, + 'expectedEmail' => $expected_email, + 'accepted' => true, + 'expectedError' => null, + 'source' => 'generated', + 'traits' => array_values( array_unique( $traits ) ), + ); + continue; + } + + $invalid = $ctx->choice( $invalid_profiles ); + $path = $ctx->bool( 30 ) ? 'wp-new-comment' : 'handle-submission'; + $cases[] = array( + 'label' => 'generated-invalid-' . $i . '-' . $invalid['label'], + 'path' => $path, + 'input' => $invalid['input'], + 'expectedEmail' => 'wp-new-comment' === $path ? '' : null, + 'accepted' => false, + 'expectedError' => 'wp-new-comment' === $path ? 'wp-new-comment-sanitizes-invalid' : 'require_valid_email', + 'source' => 'generated', + 'traits' => array_values( + array_unique( + array_merge( + array( 'invalid', 'wp-new-comment' === $path ? 'wpNewComment' : 'handleSubmission' ), + 'wp-new-comment' === $path ? array( 'sanitizedEmpty' ) : array(), + $invalid['traits'] + ) + ) + ), + ); + } + + return $cases; + } + + private static function install_comment_submission_filters( array &$insert_events, array &$post_events ): void { + foreach ( + array( + 'preprocess_comment', + 'pre_comment_author_name', + 'pre_comment_author_url', + 'pre_comment_content', + 'pre_comment_user_agent', + 'pre_comment_user_ip', + 'pre_comment_author_email', + 'pre_comment_approved', + 'duplicate_comment_id', + 'check_comment_flood', + 'wp_is_comment_flood', + 'comment_post', + 'wp_insert_comment', + 'comment_text', + 'comment_max_links_url', + 'wp_check_comment_disallowed_list', + ) as $hook + ) { + \remove_all_filters( $hook ); + } + + foreach ( + array( + 'pre_option_comment_registration', + 'pre_option_require_name_email', + 'pre_option_comment_moderation', + 'pre_option_comment_max_links', + 'pre_option_moderation_keys', + 'pre_option_disallowed_keys', + 'pre_option_comment_previously_approved', + 'pre_option_comments_notify', + 'pre_option_moderation_notify', + 'pre_option_admin_email', + 'pre_option_blogname', + ) as $hook + ) { + \remove_all_filters( $hook ); + } + + \add_filter( 'pre_comment_author_email', 'trim' ); + \add_filter( 'pre_comment_author_email', 'sanitize_email' ); + \add_filter( + 'pre_comment_approved', + static function () { + return 1; + }, + 10, + 2 + ); + \add_filter( + 'wp_is_comment_flood', + static function () { + return false; + }, + PHP_INT_MAX, + 5 + ); + + $option_values = array( + 'pre_option_comment_registration' => 0, + 'pre_option_require_name_email' => 1, + 'pre_option_comment_moderation' => 0, + 'pre_option_comment_max_links' => 0, + 'pre_option_moderation_keys' => '', + 'pre_option_disallowed_keys' => '', + 'pre_option_comment_previously_approved' => 0, + 'pre_option_comments_notify' => 0, + 'pre_option_moderation_notify' => 0, + 'pre_option_admin_email' => 'admin@example.test', + 'pre_option_blogname' => 'Component Fuzz', + ); + foreach ( $option_values as $hook => $value ) { + \add_filter( + $hook, + static function () use ( $value ) { + return $value; + } + ); + } + + \add_action( + 'wp_insert_comment', + static function ( $comment_id, $comment ) use ( &$insert_events ): void { + $insert_events[] = array( + 'id' => (int) $comment_id, + 'postId' => $comment instanceof \WP_Comment ? (int) $comment->comment_post_ID : null, + 'email' => $comment instanceof \WP_Comment ? $comment->comment_author_email : null, + 'approved' => $comment instanceof \WP_Comment ? (string) $comment->comment_approved : null, + ); + }, + 10, + 2 + ); + \add_action( + 'comment_post', + static function ( $comment_id, $approved, array $commentdata ) use ( &$post_events ): void { + $post_events[] = array( + 'id' => (int) $comment_id, + 'postId' => isset( $commentdata['comment_post_ID'] ) ? (int) $commentdata['comment_post_ID'] : null, + 'email' => $commentdata['comment_author_email'] ?? null, + 'approved' => (string) $approved, + 'filtered' => true === ( $commentdata['filtered'] ?? null ), + ); + }, + 10, + 3 + ); + } + + private static function seed_comment_submission_post( \ComponentFuzz\FuzzContext $ctx, int $case_index, array $case ): int { + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + return 0; + } + + $slug = 'email-comment-unicode-' . $ctx->iteration() . '-' . $case_index . '-' . substr( sha1( $case['label'] ), 0, 10 ); + $result = $GLOBALS['wpdb']->insert( + $GLOBALS['wpdb']->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-26 12:00:00', + 'post_date_gmt' => '2026-06-26 10:00:00', + 'post_content' => 'Email comment Unicode host post.', + 'post_title' => 'Email Comment Unicode ' . $case_index, + 'post_status' => 'publish', + 'comment_status' => 'open', + 'ping_status' => 'closed', + 'post_name' => $slug, + 'post_modified' => '2026-06-26 12:00:00', + 'post_modified_gmt' => '2026-06-26 10:00:00', + 'post_type' => 'post', + ) + ); + + return false === $result ? 0 : (int) $GLOBALS['wpdb']->insert_id; + } + + private static function delete_user_email_cache( string $email ): void { + if ( function_exists( 'wp_cache_delete' ) ) { + \wp_cache_delete( $email, 'useremail' ); + } + } + + private static function sql_like_literals_for_columns( string $sql, array $columns ): array { + $out = array(); + + foreach ( $columns as $column ) { + $literals = self::sql_like_literals( $sql, $column ); + if ( array() !== $literals ) { + $out[ $column ] = $literals; + } + } + + return $out; + } + + private static function flatten_sql_like_literal_groups( array $groups ): array { + $out = array(); + foreach ( $groups as $literals ) { + foreach ( (array) $literals as $literal ) { + $out[] = $literal; + } + } + + return $out; + } + + private static function sql_like_literals( string $sql, string $column ): array { + $column = preg_quote( $column, '/' ); + if ( ! preg_match_all( '/(?= $length ) { + $out .= $literal[ $i ]; + continue; + } + + $next = $literal[ ++$i ]; + if ( '0' === $next ) { + $out .= "\0"; + } elseif ( in_array( $next, array( '\\', "'", '"' ), true ) ) { + $out .= $next; + } else { + $out .= '\\' . $next; + } + } + + return $out; + } + + private static function can_reset_stub_content(): bool { + return isset( $GLOBALS['wpdb'] ) + && is_object( $GLOBALS['wpdb'] ) + && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ); + } + + private static function reset_stub_content(): void { + if ( self::can_reset_stub_content() ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + + \wp_cache_flush(); + } + + private static function stub_content_counts(): array { + if ( + isset( $GLOBALS['wpdb'] ) + && is_object( $GLOBALS['wpdb'] ) + && method_exists( $GLOBALS['wpdb'], 'component_fuzz_content_counts' ) + ) { + return $GLOBALS['wpdb']->component_fuzz_content_counts(); + } + + return array(); + } + + private static function stub_comment_count(): int { + $counts = self::stub_content_counts(); + return (int) ( $counts['comments'] ?? 0 ); + } + + 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 install_email_filters_from_default_filters( string $charset ): void { + $default_filters = \ComponentFuzz\repo_root() . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'wp-includes' . DIRECTORY_SEPARATOR . 'default-filters.php'; + + if ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { + $GLOBALS['wpdb'] = new \stdClass(); + } + + $GLOBALS['wpdb']->charset = $charset; + $wpdb = $GLOBALS['wpdb']; + require $default_filters; + } + + private static function cases( \ComponentFuzz\FuzzContext $ctx ): array { + $has_idn = self::has_idn(); + $unicode_domain_label_63 = 'u@' . str_repeat( "\u{00E5}", 31 ) . 'a.com'; + $unicode_domain_label_64 = 'u@' . str_repeat( "\u{00E5}", 32 ) . '.com'; + $unicode_domain_63_traits = array( 'unicodeAddress', 'boundaryLength' ); + if ( $has_idn ) { + $unicode_domain_63_traits[] = 'valid'; + } + + $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( 'ascii-whatwg-atext-local', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', array( 'ascii', 'valid', 'whatwgAtext' ), 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com', 'azAZ09.!#$%&\'*+/=?^_`{|}~-@example.com' ), + self::case( 'unicode-local-domain', "gr\u{00E5}@gr\u{00E5}.org", array( 'valid', 'unicodeAddress' ), $has_idn ? "gr\u{00E5}@gr\u{00E5}.org" : false, $has_idn ? "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' ), $has_idn ? "checkout@b\u{00FC}cher.tld" : false, $has_idn ? "checkout@b\u{00FC}cher.tld" : '', false, '' ), + self::case( 'unicode-cjk-address', "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : false, $has_idn ? "\u{7528}\u{6237}@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : '', false, '' ), + self::case( 'unicode-arabic-address', "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" : false, $has_idn ? "\u{0645}\u{0633}\u{062A}\u{062E}\u{062F}\u{0645}@\u{0645}\u{062B}\u{0627}\u{0644}.\u{0625}\u{062E}\u{062A}\u{0628}\u{0627}\u{0631}" : '', false, '' ), + self::case( 'unicode-greek-address', "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : false, $has_idn ? "\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : '', false, '' ), + self::case( 'unicode-devanagari-local', "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", array( 'valid', 'unicodeAddress' ), "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", "\u{0928}\u{092E}\u{0938}\u{094D}\u{0924}\u{0947}@example.com", false, '' ), + self::case( 'unicode-cyrillic-local', "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", array( 'valid', 'unicodeAddress' ), "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@example.com", false, '' ), + self::case( 'unicode-hiragana-local', "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", array( 'valid', 'unicodeAddress' ), "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", "\u{3086}\u{3046}\u{3056}\u{3042}@example.com", false, '' ), + self::case( 'unicode-cyrillic-address', "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" : false, $has_idn ? "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}@\u{043F}\u{0440}\u{0438}\u{043C}\u{0435}\u{0440}.\u{0438}\u{0441}\u{043F}\u{044B}\u{0442}\u{0430}\u{043D}\u{0438}\u{0435}" : '', false, '' ), + self::case( 'unicode-hiragana-address', "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}", array( 'valid', 'unicodeAddress' ), $has_idn ? "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" : false, $has_idn ? "\u{3086}\u{3046}\u{3056}\u{3042}@\u{308C}\u{3044}.\u{307F}\u{3093}\u{306A}" : '', false, '' ), + self::case( 'unicode-eszett-domain', "mail@fa\u{00DF}.de", array( 'valid', 'unicodeAddress' ), $has_idn ? "mail@fa\u{00DF}.de" : false, $has_idn ? "mail@fa\u{00DF}.de" : '', 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( 'punycode-cjk-domain', 'mail@xn--fsqu00a.xn--4rr70v', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "mail@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : false, $has_idn ? "mail@\u{4F8B}\u{5B50}.\u{5E7F}\u{544A}" : '', false, '' ), + self::case( 'punycode-greek-domain', 'mail@xn--hxajbheg2az3al.xn--jxalpdlp', array( 'valid', 'punycodeUnicodeDomain' ), $has_idn ? "mail@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : false, $has_idn ? "mail@\u{03C0}\u{03B1}\u{03C1}\u{03AC}\u{03B4}\u{03B5}\u{03B9}\u{03B3}\u{03BC}\u{03B1}.\u{03B4}\u{03BF}\u{03BA}\u{03B9}\u{03BC}\u{03AE}" : '', 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( 'display-name-unicode-wrapper', "Display Name ", array( 'displayName', 'recoverableWhitespace' ), false, "jos\u{00E9}@gr\u{00E5}.org", false, '' ), + self::case( 'display-name-quoted-local-rejected', 'Display <"quoted"@example.com>', array( 'displayName', 'quotedLookingLocal' ), false, '', false, '' ), + self::case( 'separator-whitespace-and-trailing-dot', " info @ example . com. \t", array( 'recoverableWhitespace' ), false, 'info@example.com', false, 'info@example.com' ), + self::case( 'nbsp-separator-whitespace', "user\u{00A0}@\u{00A0}example.com", array( 'recoverableWhitespace' ), false, 'user@example.com', false, 'user@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( 'fullwidth-at-separator', "bad\u{FF20}example.com", array( 'unicodeAddress', '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( 'ascii', 'valid', 'whatwgSingleLabelDomain' ), 'a@b', 'a@b', 'a@b', 'a@b' ), + 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( 'leading-domain-hyphen', 'name@-example.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'trailing-domain-hyphen', 'name@example-.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'domain-underscore', 'name@example_corp.com', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'domain-literal', 'name@[127.0.0.1]', array( 'malformedDomain' ), false, '', false, '' ), + self::case( 'ideographic-dot-separator', "name@example\u{3002}com", array( 'unicodeAddress', 'malformedDomain' ), false, '', false, '' ), + self::case( 'quoted-looking-local', '"quoted"@example.com', array( 'quotedLookingLocal' ), false, '', false, '' ), + self::case( 'quoted-html-looking-local', '"' . $iframe_open . ''; + $filtered_content = \wp_filter_content_tags( $content, $context ); + $filtered_imgs = self::extract_tag_opens( $filtered_content, 'img' ); + $filtered_iframes = self::extract_tag_opens( $filtered_content, 'iframe' ); + $attachment_imgs = array_values( + array_filter( + $filtered_imgs, + static function ( string $tag ) use ( $medium_src ): bool { + $attrs = self::tag_attributes( $tag, 'img' ); + return $medium_src === ( $attrs['src'] ?? null ); + } + ) + ); + $non_attachment_imgs = array_values( + array_filter( + $filtered_imgs, + static function ( string $tag ) use ( $non_attachment_src ): bool { + $attrs = self::tag_attributes( $tag, 'img' ); + return $non_attachment_src === ( $attrs['src'] ?? null ); + } + ) + ); + $attachment_attrs = isset( $attachment_imgs[0] ) ? self::tag_attributes( $attachment_imgs[0], 'img' ) : array(); + $non_attrs = isset( $non_attachment_imgs[0] ) ? self::tag_attributes( $non_attachment_imgs[0], 'img' ) : array(); + $iframe_attrs = isset( $filtered_iframes[0] ) ? self::tag_attributes( $filtered_iframes[0], 'iframe' ) : array(); + $attachment_srcset_widths = isset( $attachment_attrs['srcset'] ) && is_string( $attachment_attrs['srcset'] ) + ? self::parse_srcset_widths( $attachment_attrs['srcset'] ) + : array(); + $attachment_srcset_urls = isset( $attachment_attrs['srcset'] ) && is_string( $attachment_attrs['srcset'] ) + ? self::parse_srcset_width_urls( $attachment_attrs['srcset'] ) + : array(); + $attachment_sizes = 'auto, ' . sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $medium['width'] ); + $attachment_call_count = count( + array_filter( + $content_img_calls, + static fn( array $call ): bool => $attachment_id === ( $call['attachmentId'] ?? null ) + ) + ); + $non_attachment_call_count = count( + array_filter( + $content_img_calls, + static fn( array $call ): bool => 0 === ( $call['attachmentId'] ?? null ) + ) + ); + $all_filtered_tags_unique = true; + foreach ( array_merge( $filtered_imgs, $filtered_iframes ) as $tag ) { + $all_filtered_tags_unique = $all_filtered_tags_unique + && self::tag_has_unique_attributes( $tag, array( 'srcset', 'sizes', 'loading', 'decoding' ) ); + } + + self::collect_failure( + $failures, + 3 === count( $filtered_imgs ) + && 2 === count( $filtered_iframes ) + && 2 === count( $attachment_imgs ) + && isset( $attachment_imgs[0] ) + && 2 === substr_count( $filtered_content, $attachment_imgs[0] ) + && 1 === $attachment_call_count + && 1 === $non_attachment_call_count + && (string) $medium['width'] === ( $attachment_attrs['width'] ?? null ) + && (string) $medium['height'] === ( $attachment_attrs['height'] ?? null ) + && isset( $attachment_attrs['srcset'], $attachment_attrs['sizes'] ) + && self::expected_srcset_widths_present( $meta, $attachment_srcset_widths, (int) $medium['width'], (int) $medium['height'] ) + && self::expected_srcset_urls_present( $meta, $attachment_srcset_urls, (int) $medium['width'], (int) $medium['height'] ) + && $attachment_sizes === $attachment_attrs['sizes'] + && 'lazy' === ( $attachment_attrs['loading'] ?? null ) + && 'async' === ( $attachment_attrs['decoding'] ?? null ) + && (string) $attachment_id === ( $attachment_attrs['data-content-filtered'] ?? null ) + && 1 === count( $non_attachment_imgs ) + && '0' === ( $non_attrs['data-content-filtered'] ?? null ) + && 'lazy' === ( $non_attrs['loading'] ?? null ) + && 'async' === ( $non_attrs['decoding'] ?? null ) + && ! isset( $non_attrs['srcset'], $non_attrs['sizes'] ) + && '320' === ( $non_attrs['width'] ?? null ) + && '180' === ( $non_attrs['height'] ?? null ) + && 2 === substr_count( $filtered_content, $filtered_iframes[0] ?? '' ) + && 1 === count( $iframe_loading_calls ) + && $iframe_open === ( $iframe_loading_calls[0] ?? null ) + && 'lazy' === ( $iframe_attrs['loading'] ?? null ) + && $all_filtered_tags_unique, + 'wp_filter_content_tags transforms unique duplicate images/iframes once and replaces all copies', + array( + 'contentImgCalls' => $content_img_calls, + 'iframeCalls' => $iframe_loading_calls, + 'attachmentAttrs' => $attachment_attrs, + 'attachmentWidths' => $attachment_srcset_widths, + 'attachmentUrls' => $attachment_srcset_urls, + 'attachmentSizes' => $attachment_sizes, + 'nonAttachmentAttrs' => $non_attrs, + 'iframeAttrs' => $iframe_attrs, + 'filteredContent' => self::describe_string( $filtered_content ), + ) + ); + } finally { + \remove_filter( 'wp_content_img_tag', $content_img_filter, 10 ); + \remove_filter( 'wp_iframe_tag_add_loading_attr', $iframe_loading_attr_filter, 10 ); + \remove_filter( 'wp_img_tag_add_loading_attr', $img_loading_attr_filter, 10 ); + \remove_filter( 'wp_img_tag_add_decoding_attr', $decoding_attr_filter, 10 ); + \remove_filter( 'wp_lazy_loading_enabled', $lazy_enabled_filter, 10 ); + \remove_filter( 'get_post_metadata', $meta_filter, 10 ); + } + + return self::row( + $ctx, + 'images.content-tags.direct-helper-pipeline', + array() === $failures, + array( + 'attachmentId' => $attachment_id, + 'failures' => array_slice( $failures, 0, 8 ), + ) + ); + } + + private static function check_loading_optimization_attributes( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $attrs = array( + 'src' => 'https://example.test/image.jpg', + 'width' => 600 + $ctx->int( 0, 300 ), + 'height' => 400 + $ctx->int( 0, 200 ), + ); + + if ( class_exists( '\WP_Query' ) && ! isset( $GLOBALS['wp_query'] ) ) { + $GLOBALS['wp_query'] = new \WP_Query(); + } + + $first = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz' ); + $second = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz' ); + $template = \wp_get_loading_optimization_attributes( 'img', $attrs, 'template' ); + $span = \wp_get_loading_optimization_attributes( 'span', $attrs, 'component-fuzz' ); + $explicit = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'loading' => 'eager', 'decoding' => 'sync' ), 'component-fuzz' ); + $low = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'fetchpriority' => 'low' ), 'component-fuzz-low' ); + $auto = \wp_get_loading_optimization_attributes( 'img', $attrs + array( 'fetchpriority' => 'auto' ), 'component-fuzz-auto' ); + $iframe = \wp_get_loading_optimization_attributes( 'iframe', $attrs, 'component-fuzz-frame' ); + $missing_dimensions = \wp_get_loading_optimization_attributes( + 'img', + array( 'src' => 'https://example.test/missing-dimensions.jpg' ), + 'component-fuzz' + ); + $pre_filter = static function ( $loading_attrs, string $tag_name, array $attr, string $context ) { + if ( 'component-fuzz-short-circuit' === $context ) { + return array( + 'loading' => 'eager', + 'fetchpriority' => 'low', + ); + } + + return $loading_attrs; + }; + $lazy_filter = static function ( bool $default, string $tag_name, string $context ): bool { + if ( 'component-fuzz-no-lazy' === $context ) { + return false; + } + + return $default; + }; + \add_filter( 'pre_wp_get_loading_optimization_attributes', $pre_filter, 10, 4 ); + \add_filter( 'wp_lazy_loading_enabled', $lazy_filter, 10, 3 ); + try { + $short_circuit = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz-short-circuit' ); + $no_lazy = \wp_get_loading_optimization_attributes( 'img', $attrs, 'component-fuzz-no-lazy' ); + } finally { + \remove_filter( 'wp_lazy_loading_enabled', $lazy_filter, 10 ); + \remove_filter( 'pre_wp_get_loading_optimization_attributes', $pre_filter, 10 ); + } + + self::collect_failure( + $failures, + $first === $second + && isset( $first['decoding'] ) + && 'async' === $first['decoding'] + && array() === $template + && array() === $span + && isset( $explicit['decoding'] ) + && 'sync' === $explicit['decoding'] + && ! isset( $explicit['loading'] ) + && isset( $low['fetchpriority'] ) + && 'low' === $low['fetchpriority'] + && ! isset( $low['loading'] ) + && isset( $auto['fetchpriority'] ) + && 'auto' === $auto['fetchpriority'] + && isset( $auto['loading'] ) + && 'lazy' === $auto['loading'] + && ! isset( $iframe['decoding'] ) + && isset( $iframe['loading'] ) + && 'lazy' === $iframe['loading'] + && array( 'decoding' => 'async' ) === $missing_dimensions + && array( 'loading' => 'eager', 'fetchpriority' => 'low' ) === $short_circuit + && isset( $no_lazy['decoding'] ) + && 'async' === $no_lazy['decoding'] + && ! isset( $no_lazy['loading'] ), + 'wp_get_loading_optimization_attributes deterministic context behavior', + array( + 'attrs' => $attrs, + 'first' => $first, + 'second' => $second, + 'template' => $template, + 'span' => $span, + 'explicit' => $explicit, + 'low' => $low, + 'auto' => $auto, + 'iframe' => $iframe, + 'missingDimensions' => $missing_dimensions, + 'shortCircuit' => $short_circuit, + 'noLazy' => $no_lazy, + ) + ); + + return self::row( + $ctx, + 'images.loading-optimization.context-determinism', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_attachment_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $attachment_id = 100000 + $ctx->iteration(); + $meta = self::image_meta( $ctx ); + $src = 'http://example.test/wp-content/uploads/2026/06/photo-600x400.jpg'; + $full_src = 'http://example.test/wp-content/uploads/2026/06/photo.jpg'; + $attr_calls = array(); + + if ( class_exists( '\WP_Query' ) && ! isset( $GLOBALS['wp_query'] ) ) { + $GLOBALS['wp_query'] = new \WP_Query(); + } + + $source_filter = static function ( $image, int $id, $size ) use ( $attachment_id, $src, $full_src ) { + if ( $attachment_id !== $id ) { + return $image; + } + + if ( 'medium' === $size ) { + return array( $src, 600, 400, true ); + } + + if ( 'full' === $size ) { + return array( $full_src, 1200, 800, false ); + } + + if ( is_array( $size ) ) { + return array( $src, (int) $size[0], (int) $size[1], true ); + } + + return $image; + }; + $auto_sizes_filter = static fn() => false; + $meta_filter = static function ( $value, int $object_id, string $meta_key, bool $single ) use ( $attachment_id, $meta ) { + if ( $attachment_id !== $object_id || ! $single ) { + return $value; + } + + if ( '_wp_attachment_metadata' === $meta_key ) { + return array( $meta ); + } + + if ( '_wp_attachment_image_alt' === $meta_key ) { + return 'Default '; + } + + return $value; + }; + $attribute_filter = static function ( array $attr, $attachment, $size ) use ( &$attr_calls ): array { + $attr_calls[] = array( + 'attachmentClass' => is_object( $attachment ) ? get_class( $attachment ) : gettype( $attachment ), + 'size' => $size, + 'hasWidth' => isset( $attr['width'] ), + 'hasHeight' => isset( $attr['height'] ), + ); + + if ( 'medium' === $size ) { + $attr['class'] .= ' component-fuzzed'; + $attr['data-filtered'] = 'filter '; + } + + return $attr; + }; + + \add_filter( 'wp_get_attachment_image_src', $source_filter, 10, 3 ); + \add_filter( 'wp_img_tag_add_auto_sizes', $auto_sizes_filter ); + \add_filter( 'get_post_metadata', $meta_filter, 10, 4 ); + \add_filter( 'wp_get_attachment_image_attributes', $attribute_filter, 10, 3 ); + try { + $image_src = \wp_get_attachment_image_src( $attachment_id, 'medium' ); + $image_url = \wp_get_attachment_image_url( $attachment_id, 'medium' ); + $srcset = \wp_get_attachment_image_srcset( $attachment_id, 'medium', $meta ); + $sizes = \wp_get_attachment_image_sizes( $attachment_id, 'medium', $meta ); + $html = \wp_get_attachment_image( + $attachment_id, + 'medium', + false, + array( + 'alt' => 'Fuzz ', + 'loading' => false, + 'decoding' => 'sync', + 'data-extra' => 'raw " value', + ) + ); + } finally { + \remove_filter( 'wp_get_attachment_image_attributes', $attribute_filter, 10 ); + \remove_filter( 'get_post_metadata', $meta_filter, 10 ); + \remove_filter( 'wp_get_attachment_image_src', $source_filter, 10 ); + \remove_filter( 'wp_img_tag_add_auto_sizes', $auto_sizes_filter ); + } + + $full_match = \wp_image_file_matches_image_meta( $full_src . '?ver=1', $meta, $attachment_id ); + $thumbnail_match = \wp_image_file_matches_image_meta( '/var/www/wp-content/uploads/2026/06/photo-300x200.jpg', $meta, $attachment_id ); + $original_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/photo-original.jpg', $meta, $attachment_id ); + $missing_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/other.jpg', $meta, $attachment_id ); + $match_filter = static function ( bool $match, string $image_location, array $image_meta, int $id ) use ( $attachment_id ): bool { + if ( $attachment_id === $id && str_contains( $image_location, 'forced-match.jpg' ) ) { + return true; + } + + return $match; + }; + \add_filter( 'wp_image_file_matches_image_meta', $match_filter, 10, 4 ); + try { + $forced_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/forced-match.jpg', $meta, $attachment_id ); + } finally { + \remove_filter( 'wp_image_file_matches_image_meta', $match_filter, 10 ); + } + $after_forced_match = \wp_image_file_matches_image_meta( 'http://example.test/wp-content/uploads/2026/06/forced-match.jpg', $meta, $attachment_id ); + + self::collect_failure( + $failures, + array( $src, 600, 400, true ) === $image_src + && $src === $image_url + && is_string( $srcset ) + && str_contains( $srcset, 'photo-300x200.jpg 300w' ) + && str_contains( $srcset, 'photo-600x400.jpg 600w' ) + && str_contains( $srcset, 'photo.jpg 1200w' ) + && '(max-width: 600px) 100vw, 600px' === $sizes + && is_string( $html ) + && str_starts_with( $html, ' $image_src, + 'imageUrl' => $image_url, + 'srcset' => $srcset, + 'sizes' => $sizes, + 'html' => self::describe_string( $html ), + 'fullMatch' => $full_match, + 'thumbnailMatch' => $thumbnail_match, + 'originalMatch' => $original_match, + 'missingMatch' => $missing_match, + 'forcedMatch' => $forced_match, + 'afterForced' => $after_forced_match, + 'attrCalls' => $attr_calls, + ) + ); + + return self::row( + $ctx, + 'images.attachment.helpers-meta-agreement', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function check_filetype_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $mimes = array( + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'svg' => 'image/svg+xml', + ); + $cases = array( + array( 'name' => 'photo-' . $ctx->int( 100, 999 ) . '.JPG', 'ext' => 'jpg', 'type' => 'image/jpeg' ), + array( 'name' => 'nested/path/diagram.' . $ctx->choice( array( 'png', 'PNG' ) ), 'ext' => 'png', 'type' => 'image/png' ), + array( 'name' => 'vector.SVG', 'ext' => 'svg', 'type' => 'image/svg+xml' ), + array( 'name' => 'archive.jpg.php', 'ext' => false, 'type' => false ), + array( 'name' => 'query.webp?ver=1', 'ext' => false, 'type' => false ), + array( 'name' => 'no-extension', 'ext' => false, 'type' => false ), + ); + + foreach ( $cases as $index => $case ) { + $result = \wp_check_filetype( $case['name'], $mimes ); + self::collect_failure( + $failures, + $case['ext'] === ( is_string( $result['ext'] ) ? strtolower( $result['ext'] ) : $result['ext'] ) + && $case['type'] === $result['type'], + "wp_check_filetype maps extension case {$index}", + array( + 'case' => $case, + 'result' => $result, + ) + ); + } + + $hw_width_only = \image_hwstring( '640px', 0 ); + $hw_both = \image_hwstring( 320, '180px' ); + $mime_types = \wp_get_mime_types(); + $default_jpeg = \wp_get_default_extension_for_mime_type( 'image/jpeg' ); + + self::collect_failure( + $failures, + 'image' === \wp_ext2type( 'JPG' ) + && null === \wp_ext2type( 'component-fuzz-unknown' ) + && 'jpg' === $default_jpeg + && isset( $mime_types['jpg|jpeg|jpe'] ) + && 'image/jpeg' === $mime_types['jpg|jpeg|jpe'] + && 'width="640" ' === $hw_width_only + && 'width="320" height="180" ' === $hw_both, + 'image extension, MIME, and dimension-attribute helpers agree', + array( + 'defaultJpeg' => $default_jpeg, + 'hwWidthOnly' => $hw_width_only, + 'hwBoth' => $hw_both, + ) + ); + + return self::row( + $ctx, + 'images.filetypes.extensions-hwstring', + array() === $failures, + array( 'failures' => $failures ) + ); + } + + private static function image_meta( \ComponentFuzz\FuzzContext $ctx ): array { + unset( $ctx ); + return array( + 'width' => 1200, + 'height' => 800, + 'file' => '2026/06/photo.jpg', + 'original_image' => 'photo-original.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'file' => 'photo-300x200.jpg', + 'width' => 300, + 'height' => 200, + 'mime-type' => 'image/jpeg', + ), + 'medium' => array( + 'file' => 'photo-600x400.jpg', + 'width' => 600, + 'height' => 400, + 'mime-type' => 'image/jpeg', + ), + 'large' => array( + 'file' => 'photo-1200x800.jpg', + 'width' => 1200, + 'height' => 800, + 'mime-type' => 'image/jpeg', + ), + ), + ); + } + + private static function generated_image_meta( \ComponentFuzz\FuzzContext $ctx ): array { + $width = $ctx->int( 900, 2400 ); + $height = $ctx->int( 500, 1800 ); + $base = 'fuzz-' . $ctx->int( 1000, 9999 ); + $dirname = '2026/06'; + $sizes = array(); + + foreach ( + array( + 'thumbnail' => 0.25, + 'medium' => 0.5, + 'medium_large' => 0.625, + 'large' => 0.75, + ) as $name => $scale + ) { + $size_width = max( 1, (int) round( $width * $scale ) ); + $size_height = max( 1, (int) round( $height * $size_width / $width ) ); + $file = "{$base}-{$size_width}x{$size_height}.jpg"; + $sizes[ $name ] = array( + 'file' => $file, + 'width' => $size_width, + 'height' => $size_height, + 'mime-type' => 'image/jpeg', + 'path' => "{$dirname}/{$file}", + 'url' => "http://example.test/wp-content/uploads/{$dirname}/{$file}", + ); + } + + return array( + 'width' => $width, + 'height' => $height, + 'file' => "{$dirname}/{$base}.jpg", + 'original_image' => "{$base}-original.jpg", + 'sizes' => $sizes, + ); + } + + private static function image_url( array $meta, ?string $size ): string { + $dirname = dirname( $meta['file'] ); + $prefix = '.' === $dirname ? '' : trim( $dirname, '/' ) . '/'; + $file = null === $size ? basename( $meta['file'] ) : $meta['sizes'][ $size ]['file']; + + return "http://example.test/wp-content/uploads/{$prefix}{$file}"; + } + + private static function dimension_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'width' => 1200, 'height' => 800, 'maxWidth' => 600, 'maxHeight' => 600 ), + array( 'width' => 465, 'height' => 700, 'maxWidth' => 177, 'maxHeight' => 177 ), + array( 'width' => 1, 'height' => 400, 'maxWidth' => 100, 'maxHeight' => 100 ), + array( 'width' => 640, 'height' => 480, 'maxWidth' => 0, 'maxHeight' => 240 ), + array( 'width' => 4096, 'height' => 1, 'maxWidth' => 2048, 'maxHeight' => 99 ), + array( 'width' => 37, 'height' => 113, 'maxWidth' => 36, 'maxHeight' => 112 ), + array( 'width' => 1000, 'height' => 1000, 'maxWidth' => 333, 'maxHeight' => 0 ), + ); + + for ( $i = count( $cases ); $i < self::CASES; ++$i ) { + $case = $ctx->fork( 'dimension-' . $i ); + $width = $case->int( 1, 4000 ); + $height = $case->int( 1, 4000 ); + $cases[] = array( + 'width' => $width, + 'height' => $height, + 'maxWidth' => $case->choice( array( 0, $case->int( 1, $width ) ) ), + 'maxHeight' => $case->choice( array( 0, $case->int( 1, $height ) ) ), + ); + } + + return $cases; + } + + private static function resize_cases( \ComponentFuzz\FuzzContext $ctx ): array { + $cases = array( + array( 'origW' => 600, 'origH' => 300, 'destW' => 400, 'destH' => 400 ), + array( 'origW' => 300, 'origH' => 600, 'destW' => 200, 'destH' => 0 ), + array( 'origW' => 300, 'origH' => 200, 'destW' => 0, 'destH' => 100 ), + array( 'origW' => 64, 'origH' => 64, 'destW' => 128, 'destH' => 0 ), + ); + + for ( $i = count( $cases ); $i < self::CASES; ++$i ) { + $case = $ctx->fork( 'resize-' . $i ); + $orig_w = $case->int( 32, 4096 ); + $orig_h = $case->int( 32, 4096 ); + $cases[] = array( + 'origW' => $orig_w, + 'origH' => $orig_h, + 'destW' => $case->choice( array( 0, $case->int( 16, $orig_w ), $orig_w + $case->int( 1, 200 ) ) ), + 'destH' => $case->choice( array( 0, $case->int( 16, $orig_h ), $orig_h + $case->int( 1, 200 ) ) ), + ); + } + + return $cases; + } + + private static function expected_resize_result( array $case, $crop ) { + if ( self::resize_expected_false( $case, $crop ) ) { + return false; + } + + list( $new_w, $new_h ) = self::resize_target_dimensions( $case, $crop ); + + if ( $crop ) { + $size_ratio = max( $new_w / $case['origW'], $new_h / $case['origH'] ); + $crop_w = round( $new_w / $size_ratio ); + $crop_h = round( $new_h / $size_ratio ); + $crop = is_array( $crop ) && 2 === count( $crop ) ? $crop : array( 'center', 'center' ); + + if ( 'left' === $crop[0] ) { + $src_x = 0; + } elseif ( 'right' === $crop[0] ) { + $src_x = $case['origW'] - $crop_w; + } else { + $src_x = floor( ( $case['origW'] - $crop_w ) / 2 ); + } + + if ( 'top' === $crop[1] ) { + $src_y = 0; + } elseif ( 'bottom' === $crop[1] ) { + $src_y = $case['origH'] - $crop_h; + } else { + $src_y = floor( ( $case['origH'] - $crop_h ) / 2 ); + } + } else { + $crop_w = $case['origW']; + $crop_h = $case['origH']; + $src_x = 0; + $src_y = 0; + } + + return array( 0, 0, (int) $src_x, (int) $src_y, (int) $new_w, (int) $new_h, (int) $crop_w, (int) $crop_h ); + } + + private static function resize_expected_false( array $case, $crop ): bool { + if ( $case['origW'] <= 0 || $case['origH'] <= 0 ) { + return true; + } + + if ( $case['destW'] <= 0 && $case['destH'] <= 0 ) { + return true; + } + + if ( $case['destW'] <= 0 && $case['destH'] > $case['origH'] ) { + return true; + } + + if ( $case['destH'] <= 0 && $case['destW'] > $case['origW'] ) { + return true; + } + + if ( $case['destW'] > $case['origW'] && $case['destH'] > $case['origH'] ) { + return true; + } + + list( $new_w, $new_h ) = self::resize_target_dimensions( $case, $crop ); + return self::fuzzy_number_match( $new_w, $case['origW'] ) + && self::fuzzy_number_match( $new_h, $case['origH'] ); + } + + private static function resize_target_dimensions( array $case, $crop ): array { + if ( $crop ) { + $aspect_ratio = $case['origW'] / $case['origH']; + $new_w = min( $case['destW'], $case['origW'] ); + $new_h = min( $case['destH'], $case['origH'] ); + + if ( ! $new_w ) { + $new_w = (int) round( $new_h * $aspect_ratio ); + } + + if ( ! $new_h ) { + $new_h = (int) round( $new_w / $aspect_ratio ); + } + + return array( (int) $new_w, (int) $new_h ); + } + + $dimensions = \wp_constrain_dimensions( $case['origW'], $case['origH'], $case['destW'], $case['destH'] ); + return array( (int) $dimensions[0], (int) $dimensions[1] ); + } + + private static function fuzzy_number_match( $expected, $actual ): bool { + if ( function_exists( 'wp_fuzzy_number_match' ) ) { + return \wp_fuzzy_number_match( $expected, $actual ); + } + + return abs( (float) $expected - (float) $actual ) <= 1; + } + + private static function parse_srcset_widths( string $srcset ): array { + $widths = array(); + foreach ( explode( ',', $srcset ) as $candidate ) { + $candidate = trim( $candidate ); + if ( preg_match( '/\s(\d+)w$/', $candidate, $matches ) ) { + $widths[ (int) $matches[1] ] = $candidate; + } + } + + return $widths; + } + + private static function parse_srcset_width_urls( string $srcset ): array { + $urls = array(); + foreach ( explode( ',', $srcset ) as $candidate ) { + $candidate = trim( $candidate ); + if ( preg_match( '/^(.+)\s+(\d+)w$/', $candidate, $matches ) ) { + $urls[ (int) $matches[2] ] = $matches[1]; + } + } + + return $urls; + } + + private static function extract_tag_opens( string $html, string $tag_name ): array { + if ( ! preg_match_all( '/<' . preg_quote( $tag_name, '/' ) . '\s[^>]*>/i', $html, $matches ) ) { + return array(); + } + + return $matches[0]; + } + + private static function tag_attributes( string $tag, string $tag_name ): array { + $candidates = array( $tag ); + if ( 'img' !== strtolower( $tag_name ) && ! str_contains( strtolower( $tag ), '' ) ) { + $candidates[] = $tag . ''; + } + + foreach ( $candidates as $html ) { + $processor = new \WP_HTML_Tag_Processor( $html ); + if ( ! $processor->next_tag( array( 'tag_name' => strtoupper( $tag_name ) ) ) ) { + continue; + } + + $names = $processor->get_attribute_names_with_prefix( '' ); + if ( ! is_array( $names ) ) { + return array(); + } + + $attrs = array(); + foreach ( $names as $name ) { + $attrs[ $name ] = $processor->get_attribute( $name ); + } + + return $attrs; + } + + return array(); + } + + private static function tag_attribute_count( string $tag, string $name ): int { + return preg_match_all( '/(?<=\s)' . preg_quote( $name, '/' ) . '\s*=/i', $tag ); + } + + private static function tag_has_unique_attributes( string $tag, array $names ): bool { + foreach ( $names as $name ) { + if ( self::tag_attribute_count( $tag, (string) $name ) > 1 ) { + return false; + } + } + + return true; + } + + private static function expected_srcset_widths_present( array $meta, array $candidates, int $src_width, int $src_height ): bool { + $images = array_merge( + array( + array( + 'width' => (int) $meta['width'], + 'height' => (int) $meta['height'], + ), + ), + array_values( $meta['sizes'] ) + ); + + foreach ( $images as $image ) { + $width = (int) $image['width']; + $ratio_matches = \wp_image_matches_ratio( $src_width, $src_height, $width, (int) $image['height'] ); + $allowed_by_width = $width <= 2048 || $width === $src_width; + + if ( $ratio_matches && $allowed_by_width && ! isset( $candidates[ $width ] ) ) { + return false; + } + } + + return true; + } + + private static function expected_srcset_urls_present( array $meta, array $candidate_urls, int $src_width, int $src_height ): bool { + $images = array( + array( + 'width' => (int) $meta['width'], + 'height' => (int) $meta['height'], + 'url' => self::image_url( $meta, null ), + ), + ); + + foreach ( $meta['sizes'] as $size => $image ) { + $images[] = array( + 'width' => (int) $image['width'], + 'height' => (int) $image['height'], + 'url' => self::image_url( $meta, (string) $size ), + ); + } + + foreach ( $images as $image ) { + $width = (int) $image['width']; + $ratio_matches = \wp_image_matches_ratio( $src_width, $src_height, $width, (int) $image['height'] ); + $allowed_by_width = $width <= 2048 || $width === $src_width; + + if ( $ratio_matches && $allowed_by_width && ( ! isset( $candidate_urls[ $width ] ) || $image['url'] !== $candidate_urls[ $width ] ) ) { + return false; + } + } + + return true; + } + + private static function aspect_close( int $orig_w, int $orig_h, int $new_w, int $new_h ): bool { + if ( $orig_w <= 1 || $orig_h <= 1 || $new_w <= 2 || $new_h <= 2 ) { + return true; + } + + $diff = abs( ( $orig_w * $new_h ) - ( $new_w * $orig_h ) ); + $tolerance = 2 * max( $orig_w, $orig_h, $new_w, $new_h ); + return $diff <= $tolerance; + } + + 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 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 skip( \ComponentFuzz\FuzzContext $ctx, string $invariant, string $reason, array $data = array() ): array { + $data['reason'] = $reason; + return self::row( $ctx, $invariant, true, $data, 'skipped' ); + } + + 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 ); + } + + 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::PREVIEW_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; + } + + private static function snapshot_globals(): array { + $snapshot = array(); + foreach ( array( 'wp_filter', 'wp_filters', 'wp_actions', 'wp_current_filter', 'wp_query', '_wp_additional_image_sizes' ) 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 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; + } +} diff --git a/tools/component-fuzz/surfaces/ImportDiffSurface.php b/tools/component-fuzz/surfaces/ImportDiffSurface.php new file mode 100644 index 0000000000000..451f4cd40acc6 --- /dev/null +++ b/tools/component-fuzz/surfaces/ImportDiffSurface.php @@ -0,0 +1,1142 @@ +skip( + 'import-diff.bootstrap-apis-available', + 'Required import and diff APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + self::reset_runtime(); + + $rows[] = self::check_importer_registry( $ctx->fork( 'registry' ) ); + $rows[] = self::check_import_upload_form( $ctx->fork( 'upload-form' ) ); + $rows[] = self::check_import_upload_handler_and_cleanup( $ctx->fork( 'upload-handler' ) ); + $rows[] = self::check_text_diff_rendering( $ctx->fork( 'text-diff' ) ); + $rows[] = self::check_error_export_and_merge( $ctx->fork( 'error-export' ) ); + $rows[] = self::check_error_lifecycle_ordering( $ctx->fork( 'error-lifecycle' ) ); + $rows[] = self::check_imported_post_lookup( $ctx->fork( 'post-lookup' ) ); + $rows[] = self::check_imported_comment_lookup( $ctx->fork( 'comment-lookup' ) ); + $rows[] = self::check_importer_base_helpers( $ctx->fork( 'importer-base-helpers' ) ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'import-diff.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'import-diff.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot['globals'] ) ) + ); + + return $rows; + } + + public static function importer_callback(): void {} + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( array( 'Text_Diff', 'WP_Error', 'WP_Importer', 'WP_Post', 'WP_Text_Diff_Renderer_Table' ) as $class ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'add_filter', + 'get_importers', + 'get_post', + 'has_filter', + 'remove_filter', + 'register_importer', + 'wp_delete_attachment', + 'wp_import_cleanup', + 'wp_import_handle_upload', + 'wp_import_upload_form', + 'wp_text_diff', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_importer_registry( \ComponentFuzz\FuzzContext $ctx ): array { + global $wp_importers; + + $failures = array(); + $wp_importers = array(); + $callback = array( __CLASS__, 'importer_callback' ); + $registered = array( + 'component-fuzz-beta-10' => array( + 'Beta 10 ' . $ctx->identifier( 3, 6 ), + 'Imports beta-ten data & checks sorting.', + $callback, + ), + 'component-fuzz-alpha-2' => array( + 'alpha 2 ' . $ctx->identifier( 3, 6 ), + 'Imports alpha-two data.', + $callback, + ), + 'component-fuzz-alpha-10' => array( + 'Alpha 10 ' . $ctx->identifier( 3, 6 ), + 'Imports alpha-ten data.', + $callback, + ), + 'component-fuzz-zeta' => array( + 'Zeta ' . $ctx->identifier( 3, 6 ), + 'Imports zeta data.', + $callback, + ), + ); + + foreach ( $registered as $id => $entry ) { + $result = \register_importer( $id, $entry[0], $entry[1], $entry[2] ); + self::collect_failure( + $failures, + null === $result, + 'register_importer returns void for valid callbacks', + array( 'id' => $id, 'result' => self::describe_value( $result ) ) + ); + } + + $error = new \WP_Error( 'component_fuzz_importer_unavailable', 'Importer unavailable.' ); + $error_out = \register_importer( 'component-fuzz-error', 'Error', 'Should not register.', $error ); + $importers = \get_importers(); + $expected = $registered; + $sort_names = $expected; + uasort( $sort_names, '_usort_by_first_member' ); + + self::collect_failure( + $failures, + $error === $error_out + && ! isset( $importers['component-fuzz-error'] ) + && array_keys( $sort_names ) === array_keys( $importers ) + && self::importer_entries_match( $expected, $importers ), + 'importer registry stores valid importers, preserves callbacks, sorts by name, and rejects WP_Error callbacks', + array( + 'expectedOrder' => array_keys( $sort_names ), + 'actualOrder' => array_keys( is_array( $importers ) ? $importers : array() ), + 'errorOut' => self::describe_value( $error_out ), + 'importers' => self::describe_value( $importers ), + ) + ); + + return self::result( $ctx, 'import-diff.importer-registry.sorting-and-error-callbacks', $failures ); + } + + private static function check_import_upload_form( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $limit = 1024 * $ctx->int( 2, 256 ); + $limit_observed = array(); + $limit_filter = static function ( int $bytes ) use ( $limit, &$limit_observed ): int { + $limit_observed[] = $bytes; + return $limit; + }; + + $action = 'admin.php?import=component-fuzz&step=' . rawurlencode( $ctx->identifier( 3, 10 ) ) . '&bad='; + + \add_filter( 'import_upload_size_limit', $limit_filter ); + try { + ob_start(); + \wp_import_upload_form( $action ); + $output = ob_get_clean(); + } finally { + \remove_filter( 'import_upload_size_limit', $limit_filter ); + if ( ob_get_level() > 0 && false === isset( $output ) ) { + ob_end_clean(); + } + } + + self::collect_failure( + $failures, + isset( $output ) + && '' !== $output + && array() !== $limit_observed + && str_contains( $output, 'id="import-upload-form"' ) + && str_contains( $output, 'enctype="multipart/form-data"' ) + && str_contains( $output, 'name="import"' ) + && str_contains( $output, 'name="action" value="save"' ) + && str_contains( $output, '_wpnonce=' ) + && ! str_contains( $output, '' ), + 'import upload form applies size filters, emits expected controls, and escapes action data', + array( + 'action' => self::preview( $action ), + 'limit' => $limit, + 'limitObserved' => $limit_observed, + 'output' => self::preview( $output ?? '' ), + ) + ); + + return self::result( $ctx, 'import-diff.import-upload-form.controls-size-and-escaping', $failures ); + } + + private static function check_import_upload_handler_and_cleanup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.import-upload-handler.fail-closed-and-cleanup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before_counts = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before_counts ) !== 0 ) { + return $ctx->skip( + 'import-diff.import-upload-handler.fail-closed-and-cleanup', + 'The content stub was not empty before the import upload handler case.', + array( 'counts' => $before_counts ) + ); + } + + $failures = array(); + $token = $ctx->identifier( 5, 10 ); + $temp_dir = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'component-fuzz-import-upload-' . getmypid() . '-' . $token; + $tmp_file = $temp_dir . DIRECTORY_SEPARATOR . 'incoming-' . $token . '.xml'; + $attachment_file = $temp_dir . DIRECTORY_SEPARATOR . 'cleanup-' . $token . '.txt'; + $superglobal_state = array( + '_FILES' => $_FILES, + '_POST' => $_POST, + '_REQUEST' => $_REQUEST, + ); + $events = array( + 'prefilter' => array(), + 'overrides' => array(), + 'handler' => array(), + 'handle' => array(), + 'move' => array(), + ); + $prefilter = static function ( array $file ) use ( &$events, $token ): array { + $events['prefilter'][] = array( + 'name' => $file['name'] ?? null, + 'size' => $file['size'] ?? null, + 'error' => $file['error'] ?? null, + ); + $file['error'] = 'component fuzz blocked import upload ' . $token; + return $file; + }; + $overrides_filter = static function ( $overrides, array $file ) use ( &$events ): array { + $events['overrides'][] = array( + 'overrides' => $overrides, + 'name' => $file['name'] ?? null, + 'error' => $file['error'] ?? null, + ); + + $overrides = is_array( $overrides ) ? $overrides : array(); + $overrides['upload_error_handler'] = static function ( array &$file, string $message ) use ( &$events ): array { + $events['handler'][] = array( + 'name' => $file['name'] ?? null, + 'message' => $message, + ); + + return array( + 'error' => $message, + 'name' => $file['name'] ?? null, + 'size' => $file['size'] ?? null, + ); + }; + return $overrides; + }; + $handle_filter = static function ( array $upload, string $context ) use ( &$events ): array { + $events['handle'][] = array( + 'upload' => $upload, + 'context' => $context, + ); + return $upload; + }; + $move_filter = static function ( $move, array $file, string $new_file, string $type ) use ( &$events ) { + $events['move'][] = array( + 'name' => $file['name'] ?? null, + 'newFile' => $new_file, + 'type' => $type, + ); + return $move; + }; + $filters_after = array(); + + \add_filter( 'wp_handle_upload_prefilter', $prefilter ); + \add_filter( 'wp_handle_upload_overrides', $overrides_filter, 10, 2 ); + \add_filter( 'wp_handle_upload', $handle_filter, 10, 2 ); + \add_filter( 'pre_move_uploaded_file', $move_filter, 10, 4 ); + + try { + if ( ! is_dir( $temp_dir ) ) { + mkdir( $temp_dir, 0777, true ); + } + + unset( $_FILES['import'] ); + $missing_upload = \wp_import_handle_upload(); + + self::collect_failure( + $failures, + is_array( $missing_upload ) + && isset( $missing_upload['error'] ) + && is_string( $missing_upload['error'] ) + && str_contains( $missing_upload['error'], 'post_max_size' ) + && array() === $events['prefilter'] + && $before_counts === $wpdb->component_fuzz_content_counts(), + 'wp_import_handle_upload fails closed before upload hooks or DB writes when the import file is missing', + array( + 'missingUpload' => self::describe_value( $missing_upload ), + 'events' => $events, + 'counts' => $wpdb->component_fuzz_content_counts(), + ) + ); + + file_put_contents( $tmp_file, "component import fuzz {$token}\ndata\n" ); + $_POST = array( 'action' => 'unexpected-action' ); + $_REQUEST = $_POST; + $_FILES = array( + 'import' => array( + 'name' => 'component-import-' . $token . '.xml', + 'type' => 'text/xml', + 'tmp_name' => $tmp_file, + 'error' => 0, + 'size' => filesize( $tmp_file ), + ), + ); + + $blocked_upload = \wp_import_handle_upload(); + $counts_after_block = $wpdb->component_fuzz_content_counts(); + + self::collect_failure( + $failures, + is_array( $blocked_upload ) + && 'component fuzz blocked import upload ' . $token === ( $blocked_upload['error'] ?? null ) + && 'component-import-' . $token . '.xml.txt' === ( $blocked_upload['name'] ?? null ) + && array( 'test_form' => false, 'test_type' => false ) === ( $events['overrides'][0]['overrides'] ?? null ) + && 1 === count( $events['prefilter'] ) + && 1 === count( $events['overrides'] ) + && 1 === count( $events['handler'] ) + && array() === $events['handle'] + && array() === $events['move'] + && 'component-import-' . $token . '.xml.txt' === ( $_FILES['import']['name'] ?? null ) + && $before_counts === $counts_after_block, + 'wp_import_handle_upload appends .txt, passes import overrides to wp_handle_upload, and returns prefiltered upload errors without move or attachment effects', + array( + 'blockedUpload' => self::describe_value( $blocked_upload ), + 'events' => $events, + 'filesNameAfter' => $_FILES['import']['name'] ?? null, + 'countsAfterBlock' => $counts_after_block, + ) + ); + + file_put_contents( $attachment_file, 'component import cleanup ' . $token ); + $attachment_id = self::insert_import_attachment( $attachment_file, $token ); + $before_cleanup = is_int( $attachment_id ) ? \get_post( $attachment_id ) : null; + $cleanup_return = is_int( $attachment_id ) ? \wp_import_cleanup( $attachment_id ) : null; + $after_cleanup = is_int( $attachment_id ) ? \get_post( $attachment_id ) : null; + $cleanup_completed = ! $after_cleanup || ( $after_cleanup instanceof \WP_Post && 'private' !== $after_cleanup->post_status ); + + self::collect_failure( + $failures, + is_int( $attachment_id ) + && $attachment_id > 0 + && $before_cleanup instanceof \WP_Post + && 'attachment' === $before_cleanup->post_type + && null === $cleanup_return + && $cleanup_completed, + 'wp_import_cleanup delegates to attachment deletion and does not leave the generated import attachment live', + array( + 'attachmentId' => $attachment_id, + 'beforeCleanup' => $before_cleanup instanceof \WP_Post ? array( 'type' => $before_cleanup->post_type, 'status' => $before_cleanup->post_status ) : self::describe_value( $before_cleanup ), + 'cleanupReturn' => self::describe_value( $cleanup_return ), + 'afterCleanup' => $after_cleanup instanceof \WP_Post ? array( 'type' => $after_cleanup->post_type, 'status' => $after_cleanup->post_status ) : self::describe_value( $after_cleanup ), + 'countsAfterClean' => $wpdb->component_fuzz_content_counts(), + ) + ); + } finally { + \remove_filter( 'pre_move_uploaded_file', $move_filter, 10 ); + \remove_filter( 'wp_handle_upload', $handle_filter, 10 ); + \remove_filter( 'wp_handle_upload_overrides', $overrides_filter, 10 ); + \remove_filter( 'wp_handle_upload_prefilter', $prefilter, 10 ); + $filters_after = array( + 'prefilter' => \has_filter( 'wp_handle_upload_prefilter', $prefilter ), + 'overrides' => \has_filter( 'wp_handle_upload_overrides', $overrides_filter ), + 'handle' => \has_filter( 'wp_handle_upload', $handle_filter ), + 'move' => \has_filter( 'pre_move_uploaded_file', $move_filter ), + ); + + $_FILES = $superglobal_state['_FILES']; + $_POST = $superglobal_state['_POST']; + $_REQUEST = $superglobal_state['_REQUEST']; + $wpdb->component_fuzz_reset_content(); + self::delete_tree( $temp_dir ); + } + + self::collect_failure( + $failures, + array( 'prefilter' => false, 'overrides' => false, 'handle' => false, 'move' => false ) === $filters_after, + 'import upload handler filters are removed after the upload and cleanup checks', + array( 'filtersAfter' => $filters_after ) + ); + + return self::result( $ctx, 'import-diff.import-upload-handler.fail-closed-and-cleanup', $failures ); + } + + private static function check_text_diff_rendering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $token = $ctx->identifier( 3, 10 ); + $left = "common & {$token}\nremoved {$token}\nkept"; + $right = "common & {$token}\nadded & changed {$token}\nkept"; + $split = \wp_text_diff( + $left, + $right, + array( + 'title' => 'Component Fuzz Diff', + 'title_left' => 'Before', + 'title_right' => 'After', + 'show_split_view' => true, + ) + ); + $unified = \wp_text_diff( + $left, + $right, + array( + 'title_left' => 'Before', + 'title_right' => 'After', + 'show_split_view' => false, + ) + ); + $same = \wp_text_diff( "same\r\nwhitespace\tcase", "same\nwhitespace case" ); + + self::collect_failure( + $failures, + is_string( $split ) + && str_contains( $split, "" ) + && str_contains( $split, "class='diff-deletedline'" ) + && str_contains( $split, "class='diff-addedline'" ) + && str_contains( $split, 'common <tag>& ' . $token ) + && str_contains( $split, 'added & changed' ) + && str_contains( $split, $token ) + && ! str_contains( $split, 'common & ' . $token ), + 'wp_text_diff split view escapes changed content and marks added/deleted lines', + array( 'split' => self::preview( $split ) ) + ); + self::collect_failure( + $failures, + is_string( $unified ) + && str_contains( $unified, "
" ) + && ! str_contains( $unified, 'is-split-view' ) + && str_contains( $unified, 'Before' ) + && ! str_contains( $unified, '' ), + 'wp_text_diff unified view omits split class and right-side header', + array( 'unified' => self::preview( $unified ) ) + ); + self::collect_failure( + $failures, + '' === $same, + 'wp_text_diff returns an empty string after normalizing equivalent whitespace', + array( 'same' => self::preview( $same ) ) + ); + + return self::result( $ctx, 'import-diff.text-diff.rendering-and-normalization', $failures ); + } + + private static function check_error_export_and_merge( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $source = new \WP_Error(); + $target = new \WP_Error( 'existing', 'Existing target message.', array( 'kept' => true ) ); + $merged = new \WP_Error( 'merged', 'Existing merge message.' ); + $code = 'component_fuzz_' . $ctx->identifier( 4, 10 ); + $message = 'Message <' . $ctx->identifier( 3, 7 ) . '>'; + $data_one = array( + 'seed' => $ctx->seed(), + 'token' => $ctx->identifier( 4, 10 ), + ); + $data_two = array( + 'next' => $ctx->identifier( 4, 10 ), + ); + + $source->add( $code, $message ); + $source->add_data( $data_one, $code ); + $source->add_data( $data_two, $code ); + $source->export_to( $target ); + $merged->merge_from( $source ); + + self::collect_failure( + $failures, + in_array( 'existing', $target->get_error_codes(), true ) + && in_array( $code, $target->get_error_codes(), true ) + && array( $message ) === $target->get_error_messages( $code ) + && array( $data_one, $data_two ) === $target->get_all_error_data( $code ) + && array( $message ) === $merged->get_error_messages( $code ) + && array( $data_one, $data_two ) === $merged->get_all_error_data( $code ), + 'WP_Error export_to and merge_from copy messages and all error data without dropping existing target errors', + array( + 'code' => $code, + 'target' => self::describe_error( $target ), + 'merged' => self::describe_error( $merged ), + ) + ); + + $target->remove( $code ); + self::collect_failure( + $failures, + ! in_array( $code, $target->get_error_codes(), true ) + && array() === $target->get_all_error_data( $code ) + && in_array( 'existing', $target->get_error_codes(), true ), + 'WP_Error::remove deletes copied messages and data for one code only', + array( 'target' => self::describe_error( $target ) ) + ); + + return self::result( $ctx, 'import-diff.wp-error.export-merge-and-remove', $failures ); + } + + private static function check_error_lifecycle_ordering( \ComponentFuzz\FuzzContext $ctx ): array { + $failures = array(); + $code_a = 'component_fuzz_a_' . $ctx->identifier( 4, 10 ); + $code_b = 'component_fuzz_b_' . $ctx->identifier( 4, 10 ); + $message_a = 'Primary message ' . $ctx->identifier( 3, 7 ); + $message_a_2 = 'Secondary message ' . $ctx->identifier( 3, 7 ); + $message_b = 'Other code message ' . $ctx->identifier( 3, 7 ); + $data_a = array( 'phase' => 'construct', 'seed' => $ctx->seed() ); + $data_a_2 = array( 'phase' => 'add', 'token' => $ctx->identifier( 4, 9 ) ); + $data_default = array( 'phase' => 'default-code', 'iteration' => $ctx->iteration() ); + $data_b = array( 'phase' => 'second-code', 'flag' => true ); + $error = new \WP_Error( $code_a, $message_a, $data_a ); + + $error->add( $code_a, $message_a_2, $data_a_2 ); + $error->add_data( $data_default ); + $error->add( $code_b, $message_b, $data_b ); + + self::collect_failure( + $failures, + array( $code_a, $code_b ) === $error->get_error_codes() + && $code_a === $error->get_error_code() + && array( $message_a, $message_a_2 ) === $error->get_error_messages( $code_a ) + && array( $message_b ) === $error->get_error_messages( $code_b ) + && array( $message_a, $message_a_2, $message_b ) === $error->get_error_messages() + && $message_a === $error->get_error_message() + && $message_b === $error->get_error_message( $code_b ) + && $data_default === $error->get_error_data( $code_a ) + && $data_b === $error->get_error_data( $code_b ) + && array( $data_a, $data_a_2, $data_default ) === $error->get_all_error_data( $code_a ) + && array( $data_b ) === $error->get_all_error_data( $code_b ) + && $error->has_errors(), + 'WP_Error preserves code order, message order, newest data, and all-data history across add/add_data', + array( + 'codeA' => $code_a, + 'codeB' => $code_b, + 'error' => self::describe_error( $error ), + ) + ); + + $copy = new \WP_Error(); + $error->export_to( $copy ); + $error->remove( $code_a ); + + self::collect_failure( + $failures, + array( $code_b ) === $error->get_error_codes() + && $code_b === $error->get_error_code() + && array() === $error->get_error_messages( $code_a ) + && array() === $error->get_all_error_data( $code_a ) + && $message_b === $error->get_error_message() + && array( $code_a, $code_b ) === $copy->get_error_codes() + && array( $data_a, $data_a_2, $data_default ) === $copy->get_all_error_data( $code_a ) + && array( $data_b ) === $copy->get_all_error_data( $code_b ), + 'WP_Error::remove promotes the remaining first code and export_to creates an independent copy', + array( + 'removed' => self::describe_error( $error ), + 'copy' => self::describe_error( $copy ), + ) + ); + + return self::result( $ctx, 'import-diff.wp-error.lifecycle-ordering-and-default-code', $failures ); + } + + private static function check_imported_post_lookup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.importer.imported-post-lookup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before ) !== 0 ) { + return $ctx->skip( + 'import-diff.importer.imported-post-lookup', + 'The content stub was not empty before the importer post lookup case.', + array( 'counts' => $before ) + ); + } + + $failures = array(); + $importer_name = 'component_fuzz_' . $ctx->identifier( 4, 9 ); + $blog_id = (string) $ctx->int( 2, 20 ); + $other_blog_id = (string) ( (int) $blog_id + 200 ); + $expected = array(); + $matching_ids = array(); + + try { + for ( $i = 0; $i < 105; ++$i ) { + $permalink = 'https://example.test/imported/' . $i . '-' . rawurlencode( $ctx->identifier( 4, 10 ) ); + $matching_ids[] = self::insert_imported_post_meta( $importer_name, $blog_id, $permalink, 'match-' . $i ); + $expected[ $permalink ] = end( $matching_ids ); + } + + $duplicate_permalink = array_key_first( $expected ); + $duplicate_id = self::insert_imported_post_meta( $importer_name, $blog_id, (string) $duplicate_permalink, 'duplicate' ); + $expected[ $duplicate_permalink ] = $duplicate_id; + $other_permalink = 'https://other.test/imported/' . rawurlencode( $ctx->identifier( 4, 10 ) ); + $other_id = self::insert_imported_post_meta( $importer_name, $other_blog_id, $other_permalink, 'other-blog' ); + self::insert_imported_post_meta( $importer_name . '_other', $blog_id, (string) $duplicate_permalink, 'other-importer' ); + + $importer = new \WP_Importer(); + $lookup = $importer->get_imported_posts( $importer_name, $blog_id ); + $lookup_query = (string) $wpdb->last_query; + $count = $importer->count_imported_posts( $importer_name, $blog_id ); + $other = $importer->get_imported_posts( $importer_name, $other_blog_id ); + + self::collect_failure( + $failures, + $expected === $lookup + && 106 === $count + && array( $other_permalink => $other_id ) === $other + && str_contains( $lookup_query, 'LIMIT 100,100' ), + 'WP_Importer maps imported post permalinks to local post IDs by importer/blog meta key across chunks', + array( + 'blogId' => $blog_id, + 'importerName' => $importer_name, + 'lookupCount' => count( $lookup ), + 'lookupQuery' => $lookup_query, + 'count' => $count, + 'expectedCount' => count( $expected ), + 'otherLookup' => $other, + ) + ); + } finally { + $wpdb->component_fuzz_reset_content(); + } + + return self::result( $ctx, 'import-diff.importer.imported-post-lookup', $failures ); + } + + private static function check_imported_comment_lookup( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb; + + if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'component_fuzz_content_counts' ) ) { + return $ctx->skip( + 'import-diff.importer.imported-comment-lookup', + 'The in-memory wpdb content stub is unavailable.' + ); + } + + $before = $wpdb->component_fuzz_content_counts(); + if ( array_sum( $before ) !== 0 ) { + return $ctx->skip( + 'import-diff.importer.imported-comment-lookup', + 'The content stub was not empty before the importer lookup case.', + array( 'counts' => $before ) + ); + } + + $failures = array(); + $blog_id = $ctx->int( 2, 20 ); + $source_a = $ctx->int( 100, 999 ); + $source_b = $source_a + $ctx->int( 1, 20 ); + $other = $blog_id + 100; + $ids = array(); + + try { + $ids[] = self::insert_comment_agent( $blog_id . '-' . $source_a, 'first' ); + $ids[] = self::insert_comment_agent( $blog_id . '-' . $source_b, 'second' ); + $ids[] = self::insert_comment_agent( $other . '-' . $source_a, 'other-blog' ); + + $importer = new \WP_Importer(); + $lookup = $importer->get_imported_comments( (string) $blog_id ); + + self::collect_failure( + $failures, + array( + $source_a => $ids[0], + $source_b => $ids[1], + ) === $lookup, + 'WP_Importer maps source comment IDs to local comment IDs for the selected blog only', + array( + 'blogId' => $blog_id, + 'ids' => $ids, + 'lookup' => $lookup, + ) + ); + } finally { + $wpdb->component_fuzz_reset_content(); + } + + return self::result( $ctx, 'import-diff.importer.imported-comment-lookup', $failures ); + } + + private static function check_importer_base_helpers( \ComponentFuzz\FuzzContext $ctx ): array { + global $wpdb, $wp_actions; + + $failures = array(); + $importer = new \WP_Importer(); + $short = 'a' . $ctx->identifier( 2, 3 ); + $medium = 'mid-' . $ctx->identifier( 4, 7 ); + $long = 'long-' . $ctx->identifier( 8, 14 ); + $unicode = "gr\xC3\xA5-" . $ctx->identifier( 2, 5 ); + $space_token = $ctx->identifier( 3, 6 ); + $spaces = "alpha\t" . $space_token . "\n\n beta \r gamma"; + $sorted = array( $medium, $long, $short, $unicode ); + $expected_order = $sorted; + usort( + $expected_order, + static function ( string $a, string $b ): int { + return strlen( $b ) - strlen( $a ); + } + ); + + usort( $sorted, array( $importer, 'cmpr_strlen' ) ); + + self::collect_failure( + $failures, + $expected_order === $sorted + && 0 === $importer->cmpr_strlen( 'aa', 'bb' ) + && $importer->cmpr_strlen( $short, $long ) > 0 + && $importer->cmpr_strlen( $long, $short ) < 0, + 'WP_Importer::cmpr_strlen orders generated strings by descending byte length and treats equal byte lengths as equal', + array( + 'expected' => $expected_order, + 'actual' => $sorted, + 'lengths' => array_map( 'strlen', $sorted ), + ) + ); + + self::collect_failure( + $failures, + 'alpha ' . $space_token . ' beta gamma' === $importer->min_whitespace( $spaces ), + 'WP_Importer::min_whitespace collapses generated tabs, newlines, carriage returns, and repeated spaces', + array( + 'input' => self::preview( $spaces ), + 'output' => self::preview( (string) $importer->min_whitespace( $spaces ) ), + ) + ); + + $timeout_inputs = array( $ctx->int( 1, 30 ), 60, $ctx->int( 61, 120 ) ); + $timeout_results = array(); + foreach ( $timeout_inputs as $timeout_input ) { + $timeout_results[] = $importer->bump_request_timeout( $timeout_input ); + } + + self::collect_failure( + $failures, + array( 60, 60, 60 ) === $timeout_results, + 'WP_Importer::bump_request_timeout always returns the importer timeout for below, equal, and above-threshold inputs', + array( + 'inputs' => $timeout_inputs, + 'results' => $timeout_results, + ) + ); + + $quota_default = $importer->is_user_over_quota(); + $quota_output = ''; + $quota_true = null; + $option_filter = static function ( $pre, string $option ) { + if ( 'blog_upload_space' === $option ) { + return 1; + } + if ( 'upload_space_check_disabled' === $option ) { + return 0; + } + + return $pre; + }; + $allowed_filter = static function () { + return 1; + }; + $used_filter = static function () { + return 2; + }; + + \add_filter( 'pre_option_blog_upload_space', $allowed_filter ); + \add_filter( 'pre_site_option', $option_filter, 10, 2 ); + \add_filter( 'pre_get_space_used', $used_filter ); + $buffer_level = ob_get_level(); + try { + ob_start(); + $quota_true = $importer->is_user_over_quota(); + $quota_output = (string) ob_get_clean(); + } finally { + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + \remove_filter( 'pre_option_blog_upload_space', $allowed_filter ); + \remove_filter( 'pre_site_option', $option_filter, 10 ); + \remove_filter( 'pre_get_space_used', $used_filter ); + } + + self::collect_failure( + $failures, + false === $quota_default + && true === $quota_true + && '' !== $quota_output + && false === \has_filter( 'pre_option_blog_upload_space', $allowed_filter ) + && false === \has_filter( 'pre_site_option', $option_filter ) + && false === \has_filter( 'pre_get_space_used', $used_filter ), + 'WP_Importer::is_user_over_quota delegates to upload quota helpers for both default false and filtered true branches', + array( + 'default' => $quota_default, + 'true' => $quota_true, + 'output' => self::preview( $quota_output ), + ) + ); + + $previous_wpdb = $wpdb ?? null; + $had_wpdb = isset( $wpdb ); + $wpdb = new class() { + public $queries = array(); + }; + $wpdb->queries = array( + array( 'SELECT component fuzz', 0.1, 'component-fuzz' ), + ); + $wp_actions = array( + 'component_fuzz_import_action' => $ctx->int( 1, 5 ), + ); + + try { + $importer->stop_the_insanity(); + + self::collect_failure( + $failures, + array() === $wpdb->queries + && array() === $wp_actions, + 'WP_Importer::stop_the_insanity clears accumulated query logs and action counters', + array( + 'queries' => $wpdb->queries, + 'wpActions' => $wp_actions, + ) + ); + } finally { + if ( $had_wpdb ) { + $wpdb = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + + self::collect_failure( + $failures, + $had_wpdb ? $wpdb === $previous_wpdb : ! isset( $wpdb ), + 'temporary WPDB stand-in is restored before importer helper case returns', + array( + 'hadWpdb' => $had_wpdb, + 'restored' => $had_wpdb ? $wpdb === $previous_wpdb : ! isset( $wpdb ), + ) + ); + + return self::result( $ctx, 'import-diff.importer.base-helper-contracts', $failures ); + } + + private static function insert_imported_post_meta( string $importer_name, string $blog_id, string $permalink, string $label ): int { + global $wpdb; + + $wpdb->insert( + $wpdb->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-24 00:00:00', + 'post_date_gmt' => '2026-06-24 00:00:00', + 'post_content' => 'Imported post ' . $label, + 'post_title' => 'Imported ' . $label, + 'post_excerpt' => '', + 'post_status' => 'publish', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => 'imported-' . $label, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '2026-06-24 00:00:00', + 'post_modified_gmt' => '2026-06-24 00:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => $permalink, + 'menu_order' => 0, + 'post_type' => 'post', + 'post_mime_type' => '', + 'comment_count' => 0, + ) + ); + $post_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => $importer_name . '_' . $blog_id . '_permalink', + 'meta_value' => $permalink, + ) + ); + + return $post_id; + } + + private static function insert_import_attachment( string $file, string $token ): int { + global $wpdb; + + $url = 'https://example.test/imports/' . rawurlencode( basename( $file ) ); + $wpdb->insert( + $wpdb->posts, + array( + 'post_author' => 0, + 'post_date' => '2026-06-30 12:00:00', + 'post_date_gmt' => '2026-06-30 12:00:00', + 'post_content' => $url, + 'post_title' => basename( $file ), + 'post_excerpt' => '', + 'post_status' => 'private', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + 'post_password' => '', + 'post_name' => 'component-import-cleanup-' . $token, + 'to_ping' => '', + 'pinged' => '', + 'post_modified' => '2026-06-30 12:00:00', + 'post_modified_gmt' => '2026-06-30 12:00:00', + 'post_content_filtered' => '', + 'post_parent' => 0, + 'guid' => $url, + 'menu_order' => 0, + 'post_type' => 'attachment', + 'post_mime_type' => 'text/plain', + 'comment_count' => 0, + ) + ); + $post_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => '_wp_attached_file', + 'meta_value' => $file, + ) + ); + + return $post_id; + } + + private static function insert_comment_agent( string $comment_agent, string $label ): int { + global $wpdb; + + $wpdb->insert( + $wpdb->comments, + array( + 'comment_post_ID' => 0, + 'comment_author' => 'Component Fuzz ' . $label, + 'comment_author_email' => 'comment-' . $label . '@example.test', + 'comment_author_url' => '', + 'comment_author_IP' => '192.0.2.55', + 'comment_date' => '2026-06-23 00:00:00', + 'comment_date_gmt' => '2026-06-23 00:00:00', + 'comment_content' => 'Imported comment ' . $label, + 'comment_karma' => 0, + 'comment_approved' => '1', + 'comment_agent' => $comment_agent, + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'user_id' => 0, + ) + ); + + return (int) $wpdb->insert_id; + } + + private static function importer_entries_match( array $expected, $actual ): bool { + if ( ! is_array( $actual ) ) { + return false; + } + + foreach ( $expected as $id => $entry ) { + if ( ! isset( $actual[ $id ] ) || $entry !== $actual[ $id ] ) { + return false; + } + } + + return true; + } + + private static function reset_runtime(): void { + $GLOBALS['wp_importers'] = array(); + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + } + + private static function snapshot_state(): array { + $globals = array(); + foreach ( + array( + 'wp_actions', + 'wp_current_filter', + 'wp_filter', + 'wp_filters', + 'wp_importers', + 'wp_object_cache', + ) as $name + ) { + $globals[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => array_key_exists( $name, $GLOBALS ) ? self::clone_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 ] ); + } + } + if ( isset( $GLOBALS['wpdb'] ) && method_exists( $GLOBALS['wpdb'], 'component_fuzz_reset_content' ) ) { + $GLOBALS['wpdb']->component_fuzz_reset_content(); + } + } + + private static function state_matches( array $snapshot ): bool { + foreach ( $snapshot['globals'] as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + } + + return true; + } + + private static function result( \ComponentFuzz\FuzzContext $ctx, string $invariant, array $failures ): array { + return $ctx->result( + $invariant, + array() === $failures, + array( 'failures' => array_slice( $failures, 0, 8 ) ) + ); + } + + 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 describe_error( \WP_Error $error ): array { + $out = array(); + foreach ( $error->get_error_codes() as $code ) { + $out[ $code ] = array( + 'messages' => $error->get_error_messages( $code ), + 'data' => $error->get_all_error_data( $code ), + ); + } + return $out; + } + + private static function describe_value( $value ) { + if ( $value instanceof \WP_Error ) { + return self::describe_error( $value ); + } + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $key => $item ) { + $out[ $key ] = self::describe_value( $item ); + } + return $out; + } + if ( is_object( $value ) ) { + return '[object ' . get_class( $value ) . ']'; + } + return $value; + } + + private static function preview( string $value ): array { + return array( + 'bytes' => strlen( $value ), + 'preview' => \ComponentFuzz\preview_value( $value, 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 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 delete_tree( string $path ): void { + if ( is_file( $path ) || is_link( $path ) ) { + @unlink( $path ); + return; + } + + if ( ! is_dir( $path ) ) { + return; + } + + $items = scandir( $path ); + if ( false !== $items ) { + foreach ( $items as $item ) { + if ( '.' === $item || '..' === $item ) { + continue; + } + self::delete_tree( $path . DIRECTORY_SEPARATOR . $item ); + } + } + + @rmdir( $path ); + } +} diff --git a/tools/component-fuzz/surfaces/InstallSchemaSurface.php b/tools/component-fuzz/surfaces/InstallSchemaSurface.php new file mode 100644 index 0000000000000..efd55c6e509db --- /dev/null +++ b/tools/component-fuzz/surfaces/InstallSchemaSurface.php @@ -0,0 +1,1859 @@ +skip( + 'install-schema.bootstrap-apis-available', + 'Required WordPress install/schema APIs are unavailable.', + array( 'missing' => implode( ', ', $missing ) ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $rows = array(); + + try { + $rows[] = self::check_wp_get_db_schema_table_sets( $ctx ); + $rows[] = self::check_global_table_upgrade_gate( $ctx ); + $rows[] = self::check_make_db_current_scope_wrappers( $ctx ); + $rows[] = self::check_create_table_parser_variants( $ctx ); + $rows[] = self::check_dbdelta_equivalent_noops( $ctx ); + $rows[] = self::check_dbdelta_missing_table_creation_replay( $ctx ); + $rows[] = self::check_dbdelta_column_change_isolation( $ctx ); + $rows[] = self::check_dbdelta_index_normalization( $ctx ); + $rows[] = self::check_sql_allowlist_on_executed_diffs( $ctx ); + $rows[] = self::check_malformed_ddl_fails_closed( $ctx ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'install-schema.surface-no-throw', + array( 'throwable' => self::describe_throwable( $e ) ) + ); + } finally { + self::restore_state( $snapshot ); + } + + $rows[] = $ctx->result( + 'install-schema.global-state-restored', + self::state_matches( $snapshot ), + array( 'trackedGlobals' => array_keys( $snapshot ) ) + ); + + return $rows; + } + + private static function ensure_helpers_loaded(): void { + if ( function_exists( 'dbDelta' ) && function_exists( 'wp_get_db_schema' ) ) { + return; + } + + $previous_exists = array_key_exists( 'wpdb', $GLOBALS ); + $previous_wpdb = $GLOBALS['wpdb'] ?? null; + $GLOBALS['wpdb'] = new InstallSchemaWpdbDouble( 'wp_' ); + + try { + if ( ! function_exists( 'wp_get_db_schema' ) ) { + require_once ABSPATH . 'wp-admin/includes/schema.php'; + } + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + } finally { + if ( $previous_exists ) { + $GLOBALS['wpdb'] = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + } + + private static function missing_requirements(): array { + $missing = array(); + foreach ( + array( + 'add_filter', + 'apply_filters', + 'dbDelta', + 'has_filter', + 'is_main_network', + 'is_main_site', + 'is_multisite', + 'make_db_current', + 'make_db_current_silent', + 'remove_filter', + 'wp_get_db_schema', + 'wp_should_upgrade_global_tables', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_wp_get_db_schema_table_sets( \ComponentFuzz\FuzzContext $ctx ): array { + $prefix = self::prefix_for_context( $ctx->fork( 'schema-prefix' ) ); + $wpdb = new InstallSchemaWpdbDouble( $prefix ); + + $result = self::with_wpdb( + $wpdb, + static function () use ( $wpdb, $prefix ): array { + $blog_sql = wp_get_db_schema( 'blog' ); + $global_sql = wp_get_db_schema( 'global' ); + $all_sql = wp_get_db_schema( 'all' ); + $ms_global_sql = wp_get_db_schema( 'ms_global' ); + $blog_for_other_site = wp_get_db_schema( 'blog', 7 ); + $blog_tables = self::create_table_names( $blog_sql ); + $global_tables = self::create_table_names( $global_sql ); + $all_tables = self::create_table_names( $all_sql ); + $ms_global_tables = self::create_table_names( $ms_global_sql ); + $other_site_tables = self::create_table_names( $blog_for_other_site ); + $expected_blog = array_values( $wpdb->tables( 'blog' ) ); + $expected_global = array_values( $wpdb->tables( 'global' ) ); + $expected_all = array_values( $wpdb->tables( 'all' ) ); + $expected_ms_global = array_values( $wpdb->tables( 'ms_global' ) ); + $blog_id_was_restored = 1 === (int) $wpdb->blogid && $prefix === $wpdb->prefix; + + sort( $blog_tables ); + sort( $global_tables ); + sort( $all_tables ); + sort( $ms_global_tables ); + sort( $other_site_tables ); + sort( $expected_blog ); + sort( $expected_global ); + sort( $expected_all ); + sort( $expected_ms_global ); + + $global_contains_ms = array_intersect( $expected_ms_global, $global_tables ); + $all_contains_ms = array_intersect( $expected_ms_global, $all_tables ); + $expected_ms_in_all = is_multisite(); + + $ok = $expected_blog === $blog_tables + && $expected_global === $global_tables + && $expected_all === $all_tables + && $expected_ms_global === $ms_global_tables + && $blog_tables === $other_site_tables + && $blog_id_was_restored + && self::all_tables_have_allowed_prefix( $all_tables, array( $prefix ) ) + && self::all_tables_have_allowed_prefix( $ms_global_tables, array( $prefix ) ) + && ( $expected_ms_in_all || array() === $global_contains_ms ) + && ( $expected_ms_in_all || array() === $all_contains_ms ); + + return array( + 'ok' => $ok, + 'blogTables' => $blog_tables, + 'expectedBlogTables' => $expected_blog, + 'globalTables' => $global_tables, + 'expectedGlobalTables' => $expected_global, + 'allTables' => $all_tables, + 'expectedAllTables' => $expected_all, + 'msGlobalTables' => $ms_global_tables, + 'expectedMsGlobal' => $expected_ms_global, + 'otherSiteTables' => $other_site_tables, + 'blogIdRestored' => $blog_id_was_restored, + 'prefix' => $prefix, + 'isMultisite' => is_multisite(), + ); + } + ); + + return $ctx->result( + 'install-schema.wp-get-db-schema-table-sets-and-prefixes', + $result['ok'], + $result + ); + } + + private static function check_global_table_upgrade_gate( \ComponentFuzz\FuzzContext $ctx ): array { + if ( defined( 'DO_NOT_UPGRADE_GLOBAL_TABLES' ) ) { + return $ctx->skip( + 'install-schema.global-table-upgrade-gate-filter', + 'DO_NOT_UPGRADE_GLOBAL_TABLES is defined for this process.' + ); + } + + $baseline = wp_should_upgrade_global_tables(); + $seen = array(); + $deny = static function ( bool $should_upgrade ) use ( &$seen ): bool { + $seen[] = array( + 'filter' => 'deny', + 'input' => $should_upgrade, + ); + return false; + }; + $allow = static function ( bool $should_upgrade ) use ( &$seen ): bool { + $seen[] = array( + 'filter' => 'allow', + 'input' => $should_upgrade, + ); + return true; + }; + + \add_filter( 'wp_should_upgrade_global_tables', $deny ); + try { + $denied = wp_should_upgrade_global_tables(); + } finally { + \remove_filter( 'wp_should_upgrade_global_tables', $deny ); + } + + \add_filter( 'wp_should_upgrade_global_tables', $allow ); + try { + $allowed = wp_should_upgrade_global_tables(); + } finally { + \remove_filter( 'wp_should_upgrade_global_tables', $allow ); + } + + $after = wp_should_upgrade_global_tables(); + $ok = is_bool( $baseline ) + && false === $denied + && true === $allowed + && $baseline === $after + && array( + array( + 'filter' => 'deny', + 'input' => $baseline, + ), + array( + 'filter' => 'allow', + 'input' => $baseline, + ), + ) === $seen + && false === \has_filter( 'wp_should_upgrade_global_tables', $deny ) + && false === \has_filter( 'wp_should_upgrade_global_tables', $allow ); + + return $ctx->result( + 'install-schema.global-table-upgrade-gate-filter', + $ok, + array( + 'baseline' => $baseline, + 'denied' => $denied, + 'allowed' => $allowed, + 'after' => $after, + 'seen' => $seen, + 'denyHasFilter' => \has_filter( 'wp_should_upgrade_global_tables', $deny ), + 'allowHasFilter' => \has_filter( 'wp_should_upgrade_global_tables', $allow ), + ) + ); + } + + private static function check_make_db_current_scope_wrappers( \ComponentFuzz\FuzzContext $ctx ): array { + $prefix = self::prefix_for_context( $ctx->fork( 'make-db-current-prefix' ) ); + $wpdb = new InstallSchemaWpdbDouble( $prefix ); + $scopes = array_values( + array_unique( + array( + 'blog', + 'global', + 'ms_global', + 'all', + $ctx->choice( array( 'blog', 'global', 'ms_global', 'all' ) ), + ) + ) + ); + + $result = self::with_wpdb( + $wpdb, + static function () use ( $scopes ): array { + $observed = array(); + $failures = array(); + + foreach ( $scopes as $scope ) { + $expected_tables = self::create_table_names( wp_get_db_schema( $scope ) ); + sort( $expected_tables ); + + $silent = self::capture_make_db_current_call( + static function () use ( $scope ): void { + make_db_current_silent( $scope ); + } + ); + + $noisy = self::capture_make_db_current_call( + static function () use ( $scope ): void { + make_db_current( $scope ); + } + ); + + $silent_tables = $silent['tables']; + $noisy_tables = $noisy['tables']; + sort( $silent_tables ); + sort( $noisy_tables ); + + $observed[ $scope ] = array( + 'expectedTables' => $expected_tables, + 'silentTables' => $silent_tables, + 'noisyTables' => $noisy_tables, + 'silentOutput' => $silent['output'], + 'noisyOutput' => $noisy['output'], + 'silentCalls' => $silent['calls'], + 'noisyCalls' => $noisy['calls'], + ); + + if ( $expected_tables !== $silent_tables || $expected_tables !== $noisy_tables ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'schema-scope-table-set-mismatch', + 'expected' => $expected_tables, + 'silent' => $silent_tables, + 'noisy' => $noisy_tables, + ); + continue; + } + + if ( 1 !== $silent['calls'] || 1 !== $noisy['calls'] ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'dbdelta-filter-call-count-mismatch', + 'silentCalls' => $silent['calls'], + 'noisyCalls' => $noisy['calls'], + ); + continue; + } + + if ( '' !== $silent['output'] || "
    \n
\n" !== $noisy['output'] ) { + $failures[] = array( + 'scope' => $scope, + 'reason' => 'wrapper-output-mismatch', + 'silentOutput' => $silent['output'], + 'noisyOutput' => $noisy['output'], + ); + } + } + + return array( + 'ok' => array() === $failures, + 'observed' => $observed, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ); + } + ); + + return $ctx->result( + 'install-schema.make-db-current-wrappers-preserve-schema-scopes', + $result['ok'], + $result + ); + } + + private static function check_create_table_parser_variants( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'parser-variants' ) ); + $variants = array( + 'canonical' => self::ddl_from_spec( $spec, 'canonical' ), + 'backticks' => self::ddl_from_spec( $spec, 'backticks' ), + 'lowercase' => self::ddl_from_spec( $spec, 'lowercase' ), + 'index-synonyms' => self::ddl_from_spec( $spec, 'index-synonyms' ), + 'wide-spacing' => self::ddl_from_spec( $spec, 'wide-spacing' ), + ); + + $failures = array(); + $signature = null; + $tables = array(); + + foreach ( $variants as $label => $ddl ) { + $parsed = self::parse_create_tables( $ddl ); + $sig = self::schema_signature( $parsed ); + $names = array_keys( $parsed['tables'] ); + $tables[ $label ] = $names; + + if ( ! $parsed['ok'] ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'parse-failed', + 'errors' => $parsed['errors'], + ); + continue; + } + + if ( null === $signature ) { + $signature = $sig; + } elseif ( $signature !== $sig ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'signature-mismatch', + 'expected' => $signature, + 'actual' => $sig, + ); + } + + if ( ! self::all_tables_have_allowed_prefix( $names, array( 'wp_' ) ) ) { + $failures[] = array( + 'case' => $label, + 'reason' => 'table-prefix-escaped-allowlist', + 'tables' => $names, + ); + } + } + + return $ctx->result( + 'install-schema.create-table-ddl-parses-deterministically-across-formatting', + array() === $failures, + array( + 'table' => $spec['table'], + 'variants' => array_keys( $variants ), + 'tables' => $tables, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_equivalent_noops( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'dbdelta-noops' ) ); + $existing = self::parse_create_tables( self::ddl_from_spec( $spec, 'canonical' ) ); + $variants = array( + 'canonical' => self::ddl_from_spec( $spec, 'canonical' ), + 'backticks' => self::ddl_from_spec( $spec, 'backticks' ), + 'index-synonyms' => self::ddl_from_spec( $spec, 'index-synonyms' ), + 'wide-spacing' => self::ddl_from_spec( $spec, 'wide-spacing' ), + ); + $failures = array(); + $results = array(); + + foreach ( $variants as $label => $ddl ) { + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'] ); + $out = self::with_wpdb( + $wpdb, + static function () use ( $ddl ): array { + $first = dbDelta( $ddl, false ); + $second = dbDelta( $ddl, false ); + return array( $first, $second ); + } + ); + + $results[ $label ] = array( + 'first' => $out[0], + 'second' => $out[1], + 'introspectionSql' => $wpdb->introspection_log, + 'executedSql' => $wpdb->executed_queries, + 'violations' => $wpdb->violations, + ); + + if ( array() !== $out[0] || $out[0] !== $out[1] || array() !== $wpdb->executed_queries || array() !== $wpdb->violations ) { + $failures[] = array( + 'case' => $label, + 'first' => $out[0], + 'second' => $out[1], + 'executed' => $wpdb->executed_queries, + 'violations'=> $wpdb->violations, + ); + } + } + + return $ctx->result( + 'install-schema.dbdelta-equivalent-schemas-are-stable-noops', + array() === $failures, + array( + 'table' => $spec['table'], + 'cases' => array_keys( $variants ), + 'results' => $results, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_missing_table_creation_replay( \ComponentFuzz\FuzzContext $ctx ): array { + $primary_spec = self::schema_spec( $ctx->fork( 'missing-table-create-primary' ) ); + $secondary_spec = self::schema_spec( $ctx->fork( 'missing-table-create-secondary' ) ); + $secondary_spec['table'] .= '_replay'; + $specs = array( $primary_spec, $secondary_spec ); + $styles = array( 'canonical', 'wide-spacing' ); + $expected_tables = array_column( $specs, 'table' ); + $expected_messages = array(); + $ddl_parts = array(); + + foreach ( $specs as $index => $spec ) { + $expected_messages[ $spec['table'] ] = 'Created table ' . $spec['table']; + $ddl_parts[] = self::ddl_from_spec( $spec, $styles[ $index ] ); + } + + $ddl = "\n" . implode( "\n", $ddl_parts ); + $parsed = self::parse_create_tables( $ddl ); + + $create_wpdb = new InstallSchemaWpdbDouble( 'wp_', array(), $expected_tables ); + $create_result = self::with_wpdb( + $create_wpdb, + static function () use ( $ddl ): array { + return dbDelta( $ddl, true ); + } + ); + + $create_executed_queries = $create_wpdb->executed_queries; + $create_introspection_queries = $create_wpdb->introspection_log; + $create_violations = $create_wpdb->violations; + $created_tables = self::created_tables_from_queries( $create_executed_queries ); + $executed_escaped = self::queries_escape_table_allowlist( $create_executed_queries, $expected_tables ); + $create_introspection_escaped = self::queries_escape_table_allowlist( $create_introspection_queries, $expected_tables ); + $applied_tables = $create_wpdb->component_fuzz_apply_created_tables( $parsed['tables'], $create_executed_queries ); + $replay_executed_offset = count( $create_executed_queries ); + $replay_introspection_offset = count( $create_introspection_queries ); + + $replay_results = self::with_wpdb( + $create_wpdb, + static function () use ( $ddl ): array { + return array( + dbDelta( $ddl, true ), + dbDelta( $ddl, true ), + ); + } + ); + $replay_executed = array_slice( $create_wpdb->executed_queries, $replay_executed_offset ); + $replay_introspection = array_slice( $create_wpdb->introspection_log, $replay_introspection_offset ); + $replay_introspection_escaped = self::queries_escape_table_allowlist( $replay_introspection, $expected_tables ); + + $failures = array(); + + if ( ! $parsed['ok'] || $expected_tables !== array_keys( $parsed['tables'] ) ) { + $failures[] = array( + 'reason' => 'generated-ddl-parse-mismatch', + 'expectedTables' => $expected_tables, + 'parsedTables' => array_keys( $parsed['tables'] ), + 'parseErrors' => $parsed['errors'], + ); + } + + if ( $expected_messages !== $create_result ) { + $failures[] = array( + 'reason' => 'created-table-messages-mismatch', + 'expected' => $expected_messages, + 'actual' => $create_result, + ); + } + + if ( $expected_tables !== $created_tables || count( $created_tables ) !== count( array_unique( $created_tables ) ) ) { + $failures[] = array( + 'reason' => 'executed-create-tables-not-unique-or-deterministic', + 'expected' => $expected_tables, + 'actual' => $created_tables, + 'queries' => $create_wpdb->executed_queries, + ); + } + + if ( count( $create_executed_queries ) !== count( $expected_tables ) || array() !== $executed_escaped || array() !== $create_introspection_escaped || array() !== $create_violations ) { + $failures[] = array( + 'reason' => 'create-queries-escaped-allowlist-or-non-create', + 'executed' => $create_executed_queries, + 'introspection' => $create_introspection_queries, + 'created' => $created_tables, + 'executedEscaped' => $executed_escaped, + 'introspectionEscaped' => $create_introspection_escaped, + 'violations' => $create_violations, + ); + } + + if ( $expected_tables !== $applied_tables ) { + $failures[] = array( + 'reason' => 'executed-create-tables-not-applied-to-replay-double', + 'expected' => $expected_tables, + 'applied' => $applied_tables, + ); + } + + if ( array( array(), array() ) !== $replay_results || array() !== $replay_executed || array() !== $replay_introspection_escaped || $create_violations !== $create_wpdb->violations ) { + $failures[] = array( + 'reason' => 'same-double-replay-not-stable-noop', + 'replayResults' => $replay_results, + 'replayExecuted' => $replay_executed, + 'replayIntrospection' => $replay_introspection, + 'introspectionEscaped' => $replay_introspection_escaped, + 'createViolations' => $create_violations, + 'allViolations' => $create_wpdb->violations, + ); + } + + return $ctx->result( + 'install-schema.dbdelta-missing-tables-create-and-replay-idempotently', + array() === $failures, + array( + 'tables' => $expected_tables, + 'styles' => $styles, + 'createdMessages' => $create_result, + 'createdTables' => $created_tables, + 'appliedTables' => $applied_tables, + 'createSql' => $create_executed_queries, + 'createIntrospectionSql' => $create_introspection_queries, + 'replayResults' => $replay_results, + 'replaySql' => $replay_executed, + 'replayIntrospectionSql' => $replay_introspection, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_column_change_isolation( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'column-target' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $table = $target_spec['table']; + $cases = array( + 'type' => self::mutate_column( $target_spec, 'status', 'type', 'varchar(12)' ), + 'default' => self::mutate_column( $target_spec, 'status', 'default', 'archived' ), + 'null' => self::mutate_column( $target_spec, 'status', 'nullable', true ), + ); + $failures = array(); + $observed = array(); + + foreach ( $cases as $kind => $existing_spec ) { + $existing = self::parse_create_tables( self::ddl_from_spec( $existing_spec, 'canonical' ) ); + $target = self::parse_create_tables( $target_ddl ); + $diff = self::semantic_diff( $existing, $target, $table ); + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'] ); + $dbdelta = self::with_wpdb( + $wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, false ); + } + ); + + $expected_diff = array( "column:{$table}.status:{$kind}" ); + $dbdelta_keys = array_keys( $dbdelta ); + $observed[ $kind ] = array( + 'semanticDiff' => $diff, + 'dbDelta' => $dbdelta, + 'queries' => $wpdb->executed_queries, + ); + + if ( $expected_diff !== $diff ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'semantic-diff-not-isolated', + 'expected' => $expected_diff, + 'actual' => $diff, + ); + continue; + } + + if ( 'null' === $kind ) { + if ( array() !== $dbdelta ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'dbdelta-emitted-null-only-change', + 'actual' => $dbdelta, + ); + } + continue; + } + + if ( array( "{$table}.status" ) !== $dbdelta_keys ) { + $failures[] = array( + 'case' => $kind, + 'reason' => 'dbdelta-change-not-isolated-to-status-column', + 'keys' => $dbdelta_keys, + 'dbDelta' => $dbdelta, + ); + } + } + + return $ctx->result( + 'install-schema.column-type-default-null-diffs-are-isolated', + array() === $failures, + array( + 'table' => $table, + 'observed' => $observed, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_dbdelta_index_normalization( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'index-normalization' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $target_parsed = self::parse_create_tables( $target_ddl ); + $table = $target_spec['table']; + $subpart_existing = self::mutate_index_subparts( $target_spec, null ); + $subpart_parsed = self::parse_create_tables( self::ddl_from_spec( $subpart_existing, 'canonical' ) ); + $columns_only_spec = self::without_indexes( $target_spec ); + $columns_parsed = self::parse_create_tables( self::ddl_from_spec( $columns_only_spec, 'canonical' ) ); + $failures = array(); + + $subpart_wpdb = new InstallSchemaWpdbDouble( 'wp_', $subpart_parsed['tables'] ); + $subpart_diff = self::with_wpdb( + $subpart_wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, false ); + } + ); + + if ( array() !== $subpart_diff || array() !== $subpart_wpdb->executed_queries ) { + $failures[] = array( + 'case' => 'subparts', + 'reason' => 'equivalent-index-subparts-produced-change', + 'dbDelta' => $subpart_diff, + 'executed' => $subpart_wpdb->executed_queries, + ); + } + + $missing_wpdb = new InstallSchemaWpdbDouble( 'wp_', $columns_parsed['tables'], array( $table ) ); + $missing_result = self::with_wpdb( + $missing_wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, true ); + } + ); + $added_indexes = array_values( + array_filter( + $missing_result, + static function ( string $message ): bool { + return str_starts_with( $message, 'Added index ' ); + } + ) + ); + $unique_added = array_values( array_unique( $added_indexes ) ); + $expected_count = count( $target_parsed['tables'][ $table ]['indexes'] ); + + if ( $expected_count !== count( $added_indexes ) || $added_indexes !== $unique_added || array() !== $missing_wpdb->violations ) { + $failures[] = array( + 'case' => 'missing-indexes', + 'reason' => 'index-additions-not-unique-or-not-allowlisted', + 'expectedCount' => $expected_count, + 'added' => $added_indexes, + 'executed' => $missing_wpdb->executed_queries, + 'violations' => $missing_wpdb->violations, + ); + } + + return $ctx->result( + 'install-schema.dbdelta-indexes-and-subparts-normalize-without-duplicates', + array() === $failures, + array( + 'table' => $table, + 'subpartDiff' => $subpart_diff, + 'addedIndexMessages' => $added_indexes, + 'executedSql' => $missing_wpdb->executed_queries, + 'failures' => array_slice( $failures, 0, self::MAX_FAILURES ), + ) + ); + } + + private static function check_sql_allowlist_on_executed_diffs( \ComponentFuzz\FuzzContext $ctx ): array { + $target_spec = self::schema_spec( $ctx->fork( 'allowlist-target' ) ); + $existing_spec = self::without_indexes( self::remove_column( $target_spec, 'rating' ) ); + $target_ddl = self::ddl_from_spec( $target_spec, 'canonical' ); + $existing = self::parse_create_tables( self::ddl_from_spec( $existing_spec, 'canonical' ) ); + $table = $target_spec['table']; + $wpdb = new InstallSchemaWpdbDouble( 'wp_', $existing['tables'], array( $table ) ); + $result = self::with_wpdb( + $wpdb, + static function () use ( $target_ddl ): array { + return dbDelta( $target_ddl, true ); + } + ); + $escaped = self::queries_escape_table_allowlist( $wpdb->executed_queries, array( $table ) ); + $ok = array() === $escaped + && array() === $wpdb->violations + && isset( $result[ "{$table}.rating" ] ) + && str_starts_with( $result[ "{$table}.rating" ], 'Added column ' ); + + return $ctx->result( + 'install-schema.generated-sql-stays-inside-table-allowlist', + $ok, + array( + 'table' => $table, + 'dbDelta' => $result, + 'executed' => $wpdb->executed_queries, + 'escaped' => $escaped, + 'violations' => $wpdb->violations, + ) + ); + } + + private static function check_malformed_ddl_fails_closed( \ComponentFuzz\FuzzContext $ctx ): array { + $spec = self::schema_spec( $ctx->fork( 'malformed' ) ); + $table = $spec['table']; + $malformed = array( + "CREATE\tTABLE {$table} (id int(11) NOT NULL)", + "CREATE\nTABLE {$table} (id int(11) NOT NULL)", + "CREATE TABLE {$table} (id int(11) NOT NULL)", + "ALTER TABLE {$table} ADD COLUMN escaped int(11)", + "garbage {$table} CREATE TABLE", + ); + $parser_bad = array( + "CREATE TABLE {$table} id int(11) NOT NULL)", + "CREATE TABLE {$table} (id int(11) NOT NULL", + 'CREATE TABLE ../escape (id int(11) NOT NULL)', + ); + $warnings = array(); + $throwable = null; + $wpdb = new InstallSchemaWpdbDouble( 'wp_', array(), array( $table ) ); + $dbdelta = array(); + + $previous_handler = set_error_handler( + static function ( int $severity, string $message, string $file, int $line ) use ( &$warnings ): bool { + $warnings[] = compact( 'severity', 'message', 'file', 'line' ); + return true; + } + ); + unset( $previous_handler ); + + try { + $dbdelta = self::with_wpdb( + $wpdb, + static function () use ( $malformed ): array { + return dbDelta( $malformed, true ); + } + ); + } catch ( \Throwable $e ) { + $throwable = $e; + } finally { + restore_error_handler(); + } + + $parser_results = array(); + foreach ( $parser_bad as $case ) { + $parsed = self::parse_create_tables( $case ); + $parser_results[] = array( + 'ok' => $parsed['ok'], + 'errors' => $parsed['errors'], + ); + } + + $all_parser_cases_failed_closed = array() === array_filter( + $parser_results, + static function ( array $result ): bool { + return $result['ok']; + } + ); + + $ok = null === $throwable + && array() === $warnings + && array() === $dbdelta + && array() === $wpdb->executed_queries + && array() === $wpdb->violations + && $all_parser_cases_failed_closed; + + return $ctx->result( + 'install-schema.malformed-ddl-fails-closed-without-warnings', + $ok, + array( + 'table' => $table, + 'dbDelta' => $dbdelta, + 'executed' => $wpdb->executed_queries, + 'warnings' => $warnings, + 'throwable' => null === $throwable ? null : self::describe_throwable( $throwable ), + 'parserResults' => $parser_results, + ) + ); + } + + private static function schema_spec( \ComponentFuzz\FuzzContext $ctx ): array { + $suffix = strtolower( base_convert( $ctx->int( 100000, 999999 ), 10, 36 ) ); + $table = 'wp_cf_install_' . $ctx->iteration() . '_' . $suffix; + $status = $ctx->choice( array( 'draft', 'open', 'queued' ) ); + + return array( + 'table' => $table, + 'columns' => array( + 'id' => array( + 'type' => 'bigint(20) unsigned', + 'nullable' => false, + 'default' => null, + 'extra' => 'auto_increment', + ), + 'slug' => array( + 'type' => 'varchar(' . $ctx->int( 48, 96 ) . ')', + 'nullable' => false, + 'default' => '', + 'extra' => '', + ), + 'status' => array( + 'type' => 'varchar(20)', + 'nullable' => false, + 'default' => $status, + 'extra' => '', + ), + 'rating' => array( + 'type' => 'int(11)', + 'nullable' => false, + 'default' => '0', + 'extra' => '', + ), + 'payload' => array( + 'type' => 'longtext', + 'nullable' => false, + 'default' => null, + 'extra' => '', + ), + 'created_at' => array( + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + 'extra' => '', + ), + ), + 'indexes' => array( + array( + 'type' => 'PRIMARY KEY', + 'name' => '', + 'columns' => array( + array( 'name' => 'id', 'subpart' => null ), + ), + ), + array( + 'type' => 'UNIQUE KEY', + 'name' => 'slug_key', + 'columns' => array( + array( 'name' => 'slug', 'subpart' => null ), + ), + ), + array( + 'type' => 'KEY', + 'name' => 'status_created', + 'columns' => array( + array( 'name' => 'status', 'subpart' => null ), + array( 'name' => 'created_at', 'subpart' => null ), + ), + ), + array( + 'type' => 'KEY', + 'name' => 'payload_prefix', + 'columns' => array( + array( 'name' => 'payload', 'subpart' => 32 ), + ), + ), + ), + ); + } + + private static function ddl_from_spec( array $spec, string $style ): string { + $table = $spec['table']; + if ( 'backticks' === $style ) { + $table = '`' . $table . '`'; + } + + $lines = array(); + foreach ( $spec['columns'] as $name => $column ) { + $lines[] = self::column_ddl( $name, $column, $style ); + } + foreach ( $spec['indexes'] as $index ) { + $lines[] = self::index_ddl( $index, $style ); + } + + $create = 'lowercase' === $style ? 'create table' : 'CREATE TABLE'; + if ( 'wide-spacing' === $style ) { + return $create . ' ' . $table . " (\n " . implode( " ,\n ", $lines ) . "\n) DEFAULT CHARACTER SET utf8mb4;"; + } + + return $create . ' ' . $table . " (\n\t" . implode( ",\n\t", $lines ) . "\n) DEFAULT CHARACTER SET utf8mb4;"; + } + + private static function column_ddl( string $name, array $column, string $style ): string { + $identifier = 'backticks' === $style || 'wide-spacing' === $style ? '`' . $name . '`' : $name; + $type = ( 'backticks' === $style ) ? strtoupper( $column['type'] ) : $column['type']; + $null = $column['nullable'] ? 'NULL' : 'NOT NULL'; + $default = ''; + + if ( null !== $column['default'] ) { + $keyword = 'lowercase' === $style ? 'default' : 'DEFAULT'; + $default = ' ' . $keyword . " '" . str_replace( "'", "''", (string) $column['default'] ) . "'"; + } + + $extra = '' === $column['extra'] ? '' : ' ' . $column['extra']; + if ( 'backticks' === $style ) { + $null = strtolower( $null ); + $extra = strtoupper( $extra ); + } + + return trim( "{$identifier} {$type} {$null}{$default}{$extra}" ); + } + + private static function index_ddl( array $index, string $style ): string { + $type = $index['type']; + if ( 'index-synonyms' === $style && 'PRIMARY KEY' !== $type ) { + $type = str_replace( 'KEY', 'INDEX', $type ); + } elseif ( 'lowercase' === $style ) { + $type = strtolower( $type ); + } + + $name = ''; + if ( '' !== $index['name'] ) { + $name = 'backticks' === $style || 'wide-spacing' === $style ? '`' . $index['name'] . '` ' : $index['name'] . ' '; + } + + $columns = array(); + foreach ( $index['columns'] as $column ) { + $column_name = ( 'backticks' === $style || 'wide-spacing' === $style ) ? '`' . $column['name'] . '`' : $column['name']; + if ( null !== $column['subpart'] ) { + $column_name .= 'wide-spacing' === $style ? ' ( ' . $column['subpart'] . ' )' : '(' . $column['subpart'] . ')'; + } + $columns[] = $column_name; + } + + $space = 'wide-spacing' === $style ? ', ' : ','; + return trim( "{$type} {$name}(" . implode( $space, $columns ) . ')' ); + } + + private static function mutate_column( array $spec, string $column_name, string $property, $value ): array { + $mutated = $spec; + $mutated['columns'][ $column_name ][ $property ] = $value; + return $mutated; + } + + private static function mutate_index_subparts( array $spec, $subpart ): array { + $mutated = $spec; + foreach ( $mutated['indexes'] as &$index ) { + foreach ( $index['columns'] as &$column ) { + if ( null !== $column['subpart'] ) { + $column['subpart'] = $subpart; + } + } + } + unset( $index, $column ); + return $mutated; + } + + private static function without_indexes( array $spec ): array { + $spec['indexes'] = array(); + return $spec; + } + + private static function remove_column( array $spec, string $column_name ): array { + unset( $spec['columns'][ $column_name ] ); + foreach ( $spec['indexes'] as $index_id => $index ) { + foreach ( $index['columns'] as $column ) { + if ( $column_name === $column['name'] ) { + unset( $spec['indexes'][ $index_id ] ); + break; + } + } + } + $spec['indexes'] = array_values( $spec['indexes'] ); + return $spec; + } + + private static function parse_create_tables( string $sql ): array { + $tables = array(); + $errors = array(); + $offset = 0; + + while ( preg_match( '/\bCREATE\s+TABLE\s+(`?[A-Za-z0-9_]+`?)/i', $sql, $matches, PREG_OFFSET_CAPTURE, $offset ) ) { + $table_token = $matches[1][0]; + $table = self::clean_identifier( $table_token ); + $after_name = $matches[1][1] + strlen( $table_token ); + $open = strpos( $sql, '(', $after_name ); + + if ( false === $open ) { + $errors[] = "missing-open-paren:{$table}"; + $offset = $after_name; + continue; + } + + $close = self::find_matching_paren( $sql, $open ); + if ( null === $close ) { + $errors[] = "missing-close-paren:{$table}"; + $offset = $open + 1; + continue; + } + + if ( ! self::is_safe_table_name( $table ) ) { + $errors[] = "unsafe-table-name:{$table}"; + $offset = $close + 1; + continue; + } + + $body = substr( $sql, $open + 1, $close - $open - 1 ); + $parsed = self::parse_table_body( $body ); + if ( array() !== $parsed['errors'] ) { + foreach ( $parsed['errors'] as $error ) { + $errors[] = "{$table}:{$error}"; + } + } + $tables[ $table ] = array( + 'columns' => $parsed['columns'], + 'indexes' => $parsed['indexes'], + ); + $offset = $close + 1; + } + + if ( array() === $tables ) { + $errors[] = 'no-create-table'; + } + + return array( + 'ok' => array() !== $tables && array() === $errors, + 'tables' => $tables, + 'errors' => array_values( array_unique( $errors ) ), + ); + } + + private static function parse_table_body( string $body ): array { + $columns = array(); + $indexes = array(); + $errors = array(); + + foreach ( self::split_top_level_commas( $body ) as $line ) { + $line = trim( $line ); + if ( '' === $line ) { + continue; + } + + $index = self::parse_index_line( $line ); + if ( null !== $index ) { + $indexes[] = $index; + continue; + } + + $column = self::parse_column_line( $line ); + if ( null === $column ) { + $errors[] = 'unparsed-line:' . self::preview( $line ); + continue; + } + $columns[ $column['name'] ] = $column; + } + + if ( array() === $columns ) { + $errors[] = 'no-columns'; + } + + return array( + 'columns' => $columns, + 'indexes' => $indexes, + 'errors' => $errors, + ); + } + + private static function parse_column_line( string $line ): ?array { + if ( ! preg_match( '/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s', $line, $matches ) ) { + return null; + } + + $name = strtolower( $matches[1] ); + $rest = trim( $matches[2] ); + if ( ! preg_match( '/^([A-Za-z]+(?:\s*\([^)]*\))?(?:\s+unsigned)?)(?:\s+|$)/i', $rest, $type_matches ) ) { + return null; + } + + $type = self::normalize_type( $type_matches[1] ); + $nullable = ! preg_match( '/\bNOT\s+NULL\b/i', $rest ); + $default = null; + + if ( preg_match( "/\bDEFAULT\s+'((?:''|[^'])*)'/i", $rest, $default_matches ) ) { + $default = str_replace( "''", "'", $default_matches[1] ); + } elseif ( preg_match( '/\bDEFAULT\s+NULL\b/i', $rest ) ) { + $default = null; + } elseif ( preg_match( '/\bDEFAULT\s+([^\s,]+)/i', $rest, $default_matches ) ) { + $default = trim( $default_matches[1], "'\"" ); + } + + return array( + 'name' => $name, + 'type' => $type, + 'nullable' => $nullable, + 'default' => $default, + 'extra' => preg_match( '/\bauto_increment\b/i', $rest ) ? 'auto_increment' : '', + 'normalized' => self::normalize_sql_fragment( $line ), + ); + } + + private static function parse_index_line( string $line ): ?array { + if ( + ! preg_match( + '/^(PRIMARY\s+KEY|(?:UNIQUE|FULLTEXT|SPATIAL)\s+(?:KEY|INDEX)|KEY|INDEX)\s+(?:(`?[A-Za-z0-9$_-]+`?)\s+)?\((.+)\)$/is', + $line, + $matches + ) + ) { + return null; + } + + $type = strtoupper( preg_replace( '/\s+/', ' ', trim( $matches[1] ) ) ); + $type = str_replace( 'INDEX', 'KEY', $type ); + $name = 'PRIMARY KEY' === $type ? '' : strtolower( self::clean_identifier( $matches[2] ?? '' ) ); + if ( 'PRIMARY KEY' !== $type && '' === $name ) { + return null; + } + + $columns = array(); + foreach ( self::split_top_level_commas( $matches[3] ) as $column ) { + if ( ! preg_match( '/`?([A-Za-z0-9_]+)`?(?:\s*\(\s*(\d+)\s*\))?/i', trim( $column ), $column_matches ) ) { + return null; + } + $columns[] = array( + 'name' => strtolower( $column_matches[1] ), + 'subpart' => isset( $column_matches[2] ) && '' !== $column_matches[2] ? (int) $column_matches[2] : null, + ); + } + + return array( + 'type' => $type, + 'name' => $name, + 'columns' => $columns, + 'normalized' => self::normalized_index_definition( $type, $name, $columns, true ), + 'withoutSubparts' => self::normalized_index_definition( $type, $name, $columns, false ), + 'showIndexType' => str_contains( $type, 'FULLTEXT' ) ? 'FULLTEXT' : ( str_contains( $type, 'SPATIAL' ) ? 'SPATIAL' : 'BTREE' ), + 'showIndexNonUnique' => str_starts_with( $type, 'UNIQUE' ) || 'PRIMARY KEY' === $type ? '0' : '1', + 'showIndexKeyName' => 'PRIMARY KEY' === $type ? 'PRIMARY' : $name, + ); + } + + private static function normalized_index_definition( string $type, string $name, array $columns, bool $with_subparts ): string { + $index_name = 'PRIMARY KEY' === $type ? '' : '`' . $name . '`'; + $parts = array(); + foreach ( $columns as $column ) { + $part = '`' . $column['name'] . '`'; + if ( $with_subparts && null !== $column['subpart'] ) { + $part .= '(' . $column['subpart'] . ')'; + } + $parts[] = $part; + } + + return "{$type} {$index_name} (" . implode( ',', $parts ) . ')'; + } + + private static function semantic_diff( array $from, array $to, string $table ): array { + $diff = array(); + if ( ! isset( $from['tables'][ $table ], $to['tables'][ $table ] ) ) { + return array( "table:{$table}" ); + } + + $from_columns = $from['tables'][ $table ]['columns']; + $to_columns = $to['tables'][ $table ]['columns']; + foreach ( array_diff( array_keys( $to_columns ), array_keys( $from_columns ) ) as $column ) { + $diff[] = "column:{$table}.{$column}:added"; + } + foreach ( array_diff( array_keys( $from_columns ), array_keys( $to_columns ) ) as $column ) { + $diff[] = "column:{$table}.{$column}:removed"; + } + foreach ( array_intersect( array_keys( $from_columns ), array_keys( $to_columns ) ) as $column ) { + foreach ( array( 'type', 'default', 'nullable' ) as $property ) { + if ( $from_columns[ $column ][ $property ] !== $to_columns[ $column ][ $property ] ) { + $name = 'nullable' === $property ? 'null' : $property; + $diff[] = "column:{$table}.{$column}:{$name}"; + } + } + } + + $from_indexes = array_column( $from['tables'][ $table ]['indexes'], 'withoutSubparts' ); + $to_indexes = array_column( $to['tables'][ $table ]['indexes'], 'withoutSubparts' ); + foreach ( array_diff( $to_indexes, $from_indexes ) as $index ) { + $diff[] = "index:{$table}:added:" . $index; + } + foreach ( array_diff( $from_indexes, $to_indexes ) as $index ) { + $diff[] = "index:{$table}:removed:" . $index; + } + + sort( $diff ); + return $diff; + } + + private static function schema_signature( array $parsed ): string { + $signature = array(); + foreach ( $parsed['tables'] as $table => $schema ) { + $columns = $schema['columns']; + ksort( $columns ); + $signature[ $table ] = array( + 'columns' => array_map( + static function ( array $column ): array { + return array( + 'type' => $column['type'], + 'nullable' => $column['nullable'], + 'default' => $column['default'], + 'extra' => $column['extra'], + ); + }, + $columns + ), + 'indexes' => array_values( array_column( $schema['indexes'], 'normalized' ) ), + ); + sort( $signature[ $table ]['indexes'] ); + } + ksort( $signature ); + return (string) wp_json_encode( $signature ); + } + + private static function create_table_names( string $sql ): array { + $parsed = self::parse_create_tables( $sql ); + return array_keys( $parsed['tables'] ); + } + + private static function all_tables_have_allowed_prefix( array $tables, array $prefixes ): bool { + foreach ( $tables as $table ) { + $allowed = false; + foreach ( $prefixes as $prefix ) { + if ( str_starts_with( $table, $prefix ) && self::is_safe_table_name( $table ) ) { + $allowed = true; + break; + } + } + if ( ! $allowed ) { + return false; + } + } + return true; + } + + private static function queries_escape_table_allowlist( array $queries, array $allowed_tables ): array { + $allowed = array_fill_keys( $allowed_tables, true ); + $escaped = array(); + $patterns = array( + '/\bALTER\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bCREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bDESCRIBE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bSHOW\s+INDEX(?:ES)?\s+FROM\s+`?([A-Za-z0-9_]+)`?/i', + ); + + foreach ( $queries as $query ) { + foreach ( $patterns as $pattern ) { + if ( preg_match( $pattern, $query, $matches ) && ! isset( $allowed[ $matches[1] ] ) ) { + $escaped[] = array( + 'query' => $query, + 'table' => $matches[1], + ); + } + } + } + + return $escaped; + } + + private static function created_tables_from_queries( array $queries ): array { + $tables = array(); + foreach ( $queries as $query ) { + if ( ! preg_match( '/^\s*CREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?\s+\(/i', $query, $matches ) ) { + continue; + } + $tables[] = $matches[1]; + } + return $tables; + } + + private static function capture_make_db_current_call( callable $callback ): array { + $tables = array(); + $calls = 0; + $filter = static function ( array $queries ) use ( &$tables, &$calls ): array { + $calls++; + foreach ( $queries as $query ) { + $parsed = self::parse_create_tables( (string) $query ); + $tables = array_merge( $tables, array_keys( $parsed['tables'] ) ); + } + + return array(); + }; + + add_filter( 'dbdelta_queries', $filter, PHP_INT_MAX ); + $output = ''; + $buffer_started = false; + + try { + ob_start(); + $buffer_started = true; + $callback(); + $output = (string) ob_get_clean(); + $buffer_started = false; + } finally { + if ( $buffer_started && ob_get_level() > 0 ) { + ob_end_clean(); + } + remove_filter( 'dbdelta_queries', $filter, PHP_INT_MAX ); + } + + return array( + 'tables' => $tables, + 'calls' => $calls, + 'output' => $output, + ); + } + + private static function with_wpdb( InstallSchemaWpdbDouble $wpdb, callable $callback ) { + $previous_exists = array_key_exists( 'wpdb', $GLOBALS ); + $previous_wpdb = $GLOBALS['wpdb'] ?? null; + $GLOBALS['wpdb'] = $wpdb; + + try { + return $callback( $wpdb ); + } finally { + if ( $previous_exists ) { + $GLOBALS['wpdb'] = $previous_wpdb; + } else { + unset( $GLOBALS['wpdb'] ); + } + } + } + + private static function split_top_level_commas( string $value ): array { + $parts = array(); + $start = 0; + $depth = 0; + $quote = null; + $len = strlen( $value ); + + for ( $i = 0; $i < $len; $i++ ) { + $char = $value[ $i ]; + if ( null !== $quote ) { + if ( $char === $quote && ( 0 === $i || '\\' !== $value[ $i - 1 ] ) ) { + $quote = null; + } + continue; + } + if ( "'" === $char || '"' === $char || '`' === $char ) { + $quote = $char; + continue; + } + if ( '(' === $char ) { + ++$depth; + continue; + } + if ( ')' === $char ) { + $depth = max( 0, $depth - 1 ); + continue; + } + if ( ',' === $char && 0 === $depth ) { + $parts[] = substr( $value, $start, $i - $start ); + $start = $i + 1; + } + } + $parts[] = substr( $value, $start ); + return $parts; + } + + private static function find_matching_paren( string $sql, int $open ): ?int { + $depth = 0; + $quote = null; + $len = strlen( $sql ); + + for ( $i = $open; $i < $len; $i++ ) { + $char = $sql[ $i ]; + if ( null !== $quote ) { + if ( $char === $quote && ( 0 === $i || '\\' !== $sql[ $i - 1 ] ) ) { + $quote = null; + } + continue; + } + if ( "'" === $char || '"' === $char || '`' === $char ) { + $quote = $char; + continue; + } + if ( '(' === $char ) { + ++$depth; + continue; + } + if ( ')' === $char ) { + --$depth; + if ( 0 === $depth ) { + return $i; + } + } + } + + return null; + } + + private static function normalize_type( string $type ): string { + $type = strtolower( preg_replace( '/\s+/', ' ', trim( $type ) ) ); + $type = preg_replace( '/\s*\(\s*/', '(', $type ); + $type = preg_replace( '/\s*\)/', ')', $type ); + return (string) $type; + } + + private static function normalize_sql_fragment( string $fragment ): string { + $fragment = str_replace( '`', '', $fragment ); + $fragment = strtolower( preg_replace( '/\s+/', ' ', trim( $fragment ) ) ); + $fragment = preg_replace( '/\s*,\s*/', ',', $fragment ); + return (string) $fragment; + } + + private static function clean_identifier( string $identifier ): string { + return trim( $identifier, "` \t\n\r\0\x0B" ); + } + + private static function is_safe_table_name( string $table ): bool { + return 1 === preg_match( '/^[A-Za-z0-9_]+$/', $table ); + } + + private static function prefix_for_context( \ComponentFuzz\FuzzContext $ctx ): string { + return $ctx->choice( array( 'wp_', 'wpfuzz_', 'wpit_' ) ); + } + + private static function snapshot_state(): array { + $state = array(); + foreach ( array( 'wpdb', 'wp_queries', 'charset_collate', 'table_prefix' ) as $name ) { + $state[ $name ] = array( + 'exists' => array_key_exists( $name, $GLOBALS ), + 'value' => $GLOBALS[ $name ] ?? null, + ); + } + return $state; + } + + private static function restore_state( array $snapshot ): void { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] ) { + $GLOBALS[ $name ] = $entry['value']; + } else { + unset( $GLOBALS[ $name ] ); + } + } + } + + private static function state_matches( array $snapshot ): bool { + foreach ( $snapshot as $name => $entry ) { + if ( $entry['exists'] !== array_key_exists( $name, $GLOBALS ) ) { + return false; + } + if ( $entry['exists'] && $entry['value'] !== $GLOBALS[ $name ] ) { + return false; + } + } + return true; + } + + private static function describe_throwable( \Throwable $e ): array { + return array( + 'type' => get_class( $e ), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ); + } + + private static function preview( string $value ): string { + return strlen( $value ) > 160 ? substr( $value, 0, 160 ) . '...' : $value; + } +} + +final class InstallSchemaWpdbDouble { + public string $prefix; + public string $base_prefix; + public int $blogid = 1; + public int $siteid = 1; + public string $charset = 'utf8mb4'; + public string $collate = ''; + public bool $suppress_errors = false; + public string $last_query = ''; + public int $num_rows = 0; + public string $posts = ''; + public string $comments = ''; + public string $links = ''; + public string $options = ''; + public string $postmeta = ''; + public string $terms = ''; + public string $term_taxonomy = ''; + public string $term_relationships = ''; + public string $termmeta = ''; + public string $commentmeta = ''; + public string $users = ''; + public string $usermeta = ''; + public string $blogs = ''; + public string $blogmeta = ''; + public string $signups = ''; + public string $site = ''; + public string $sitemeta = ''; + public string $registration_log = ''; + public string $categories = ''; + public string $post2cat = ''; + public string $link2cat = ''; + public string $sitecategories = ''; + + public array $tables = array( + 'posts', + 'comments', + 'links', + 'options', + 'postmeta', + 'terms', + 'term_taxonomy', + 'term_relationships', + 'termmeta', + 'commentmeta', + ); + public array $old_tables = array( 'categories', 'post2cat', 'link2cat' ); + public array $global_tables = array( 'users', 'usermeta' ); + public array $ms_global_tables = array( 'blogs', 'blogmeta', 'signups', 'site', 'sitemeta', 'registration_log' ); + public array $old_ms_global_tables = array( 'sitecategories' ); + + public array $introspection_log = array(); + public array $executed_queries = array(); + public array $violations = array(); + + private array $schemas; + private array $allowed_tables; + + public function __construct( string $prefix = 'wp_', array $schemas = array(), array $allowed_tables = array() ) { + $this->prefix = $prefix; + $this->base_prefix = $prefix; + $this->schemas = $schemas; + $this->allowed_tables = array_fill_keys( $allowed_tables ? $allowed_tables : array_keys( $schemas ), true ); + $this->refresh_table_properties(); + } + + public function get_charset_collate(): string { + $charset_collate = ''; + if ( '' !== $this->charset ) { + $charset_collate = 'DEFAULT CHARACTER SET ' . $this->charset; + } + if ( '' !== $this->collate ) { + $charset_collate .= ' COLLATE ' . $this->collate; + } + return $charset_collate; + } + + public function set_blog_id( $blog_id, $network_id = 0 ) { + $old_blog_id = $this->blogid; + $this->blogid = (int) $blog_id; + if ( $network_id ) { + $this->siteid = (int) $network_id; + } + $this->prefix = $this->get_blog_prefix(); + $this->refresh_table_properties(); + return $old_blog_id; + } + + public function get_blog_prefix( $blog_id = null ): string { + unset( $blog_id ); + return $this->base_prefix; + } + + public function tables( $scope = 'all', $prefix = true, $blog_id = 0 ): array { + switch ( $scope ) { + case 'all': + $tables = array_merge( $this->global_tables, $this->tables ); + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->ms_global_tables ); + } + break; + case 'blog': + $tables = $this->tables; + break; + case 'global': + $tables = $this->global_tables; + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->ms_global_tables ); + } + break; + case 'ms_global': + $tables = $this->ms_global_tables; + break; + case 'old': + $tables = $this->old_tables; + if ( is_multisite() ) { + $tables = array_merge( $tables, $this->old_ms_global_tables ); + } + break; + default: + return array(); + } + + if ( ! $prefix ) { + return $tables; + } + + if ( ! $blog_id ) { + $blog_id = $this->blogid; + } + unset( $blog_id ); + + $mapped = array(); + $global_tables = array_merge( $this->global_tables, $this->ms_global_tables ); + foreach ( $tables as $table ) { + $mapped[ $table ] = ( in_array( $table, $global_tables, true ) ? $this->base_prefix : $this->prefix ) . $table; + } + + return $mapped; + } + + public function db_version(): string { + return '8.0.16'; + } + + public function db_server_info(): string { + return '8.0.16 ComponentFuzz'; + } + + public function suppress_errors( $suppress = true ) { + $previous = $this->suppress_errors; + $this->suppress_errors = (bool) $suppress; + return $previous; + } + + public function get_results( $query = null, $output = OBJECT ): array { + unset( $output ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + + if ( preg_match( '/^\s*DESCRIBE\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $this->last_query, $matches ) ) { + return $this->describe_table( $matches[1] ); + } + + if ( preg_match( '/^\s*SHOW\s+INDEX(?:ES)?\s+FROM\s+`?([A-Za-z0-9_]+)`?/i', $this->last_query, $matches ) ) { + return $this->show_index( $matches[1] ); + } + + return array(); + } + + public function get_var( $query = null, $x = 0, $y = 0 ) { + unset( $x, $y ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + if ( preg_match( "/SHOW\s+TABLES\s+LIKE\s+'([^']+)'/i", $this->last_query, $matches ) ) { + return isset( $this->schemas[ $matches[1] ] ) ? $matches[1] : null; + } + + return null; + } + + public function get_row( $query = null, $output = OBJECT, $y = 0 ) { + unset( $output, $y ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + $this->num_rows = 0; + return null; + } + + public function get_col( $query = null, $x = 0 ): array { + unset( $x ); + + $this->last_query = (string) $query; + $this->introspection_log[] = $this->last_query; + $this->num_rows = 0; + return array(); + } + + public function query( $query ) { + $this->last_query = (string) $query; + $this->executed_queries[] = $this->last_query; + $this->record_allowlist_violations( $this->last_query ); + return 0; + } + + public function component_fuzz_apply_created_tables( array $schemas, array $queries ): array { + $applied = array(); + foreach ( $queries as $query ) { + if ( ! preg_match( '/^\s*CREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?\s+\(/i', (string) $query, $matches ) ) { + continue; + } + + $table = $matches[1]; + if ( ! isset( $schemas[ $table ] ) ) { + continue; + } + + $this->schemas[ $table ] = $schemas[ $table ]; + $applied[] = $table; + } + + return $applied; + } + + public function _escape( $data ) { + if ( is_array( $data ) ) { + return array_map( array( $this, '_escape' ), $data ); + } + + return addslashes( (string) $data ); + } + + public function prepare( $query, ...$args ): string { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + $index = 0; + return (string) preg_replace_callback( + '/%[sd]/', + function () use ( &$index, $args ): string { + $value = $args[ $index++ ] ?? ''; + return "'" . addslashes( (string) $value ) . "'"; + }, + (string) $query + ); + } + + public function esc_like( $text ): string { + return addcslashes( (string) $text, '_%\\' ); + } + + private function refresh_table_properties(): void { + foreach ( $this->tables( 'all' ) + $this->tables( 'old' ) as $name => $table ) { + $this->$name = $table; + } + foreach ( $this->tables( 'ms_global' ) as $name => $table ) { + $this->$name = $table; + } + } + + private function describe_table( string $table ): array { + if ( ! isset( $this->schemas[ $table ] ) ) { + $this->num_rows = 0; + return array(); + } + + $rows = array(); + foreach ( $this->schemas[ $table ]['columns'] as $name => $column ) { + $rows[] = (object) array( + 'Field' => $name, + 'Type' => $column['type'], + 'Null' => $column['nullable'] ? 'YES' : 'NO', + 'Key' => $this->column_key( $table, $name ), + 'Default' => $column['default'], + 'Extra' => $column['extra'], + ); + } + $this->num_rows = count( $rows ); + return $rows; + } + + private function show_index( string $table ): array { + if ( ! isset( $this->schemas[ $table ] ) ) { + $this->num_rows = 0; + return array(); + } + + $rows = array(); + foreach ( $this->schemas[ $table ]['indexes'] as $index ) { + $sequence = 1; + foreach ( $index['columns'] as $column ) { + $rows[] = (object) array( + 'Table' => $table, + 'Non_unique' => $index['showIndexNonUnique'], + 'Key_name' => $index['showIndexKeyName'], + 'Seq_in_index' => $sequence++, + 'Column_name' => $column['name'], + 'Sub_part' => $column['subpart'], + 'Index_type' => $index['showIndexType'], + ); + } + } + $this->num_rows = count( $rows ); + return $rows; + } + + private function column_key( string $table, string $column_name ): string { + foreach ( $this->schemas[ $table ]['indexes'] ?? array() as $index ) { + if ( 'PRIMARY' === $index['showIndexKeyName'] && $column_name === $index['columns'][0]['name'] ) { + return 'PRI'; + } + } + return ''; + } + + private function record_allowlist_violations( string $query ): void { + $patterns = array( + '/\bALTER\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bCREATE\s+TABLE\s+`?([A-Za-z0-9_]+)`?/i', + '/\bINSERT\s+INTO\s+`?([A-Za-z0-9_]+)`?/i', + '/\bUPDATE\s+`?([A-Za-z0-9_]+)`?/i', + ); + + foreach ( $patterns as $pattern ) { + if ( preg_match( $pattern, $query, $matches ) && ! isset( $this->allowed_tables[ $matches[1] ] ) ) { + $this->violations[] = array( + 'table' => $matches[1], + 'query' => $query, + ); + } + } + } +} diff --git a/tools/component-fuzz/surfaces/InteractivitySurface.php b/tools/component-fuzz/surfaces/InteractivitySurface.php new file mode 100644 index 0000000000000..8fa77fa2deecc --- /dev/null +++ b/tools/component-fuzz/surfaces/InteractivitySurface.php @@ -0,0 +1,1458 @@ +skip( + 'interactivity.bootstrap-apis-available', + 'Required WordPress Interactivity APIs are unavailable.', + array( 'missing' => $missing ) + ), + ); + } + + $snapshot = self::snapshot_state(); + $ob_level = ob_get_level(); + $rows = array(); + + try { + $case = self::case_for_context( $ctx ); + + $rows[] = self::check_state_config_helpers( $ctx, $case ); + $rows[] = self::check_directive_processing( $ctx, $case ); + $rows[] = self::check_namespaced_directive_evaluation( $ctx->fork( 'namespaced-directives' ), $case ); + $rows[] = self::check_directive_syntax_ordering_matrix( $ctx->fork( 'directive-syntax-ordering' ), $case ); + $rows[] = self::check_context_and_element_helpers( $ctx, $case ); + $rows[] = self::check_context_namespace_stack_merge_sort_and_restore( $ctx->fork( 'context-stack' ), $case ); + $rows[] = self::check_derived_state_stack_recovery( $ctx->fork( 'derived-stack' ), $case ); + $rows[] = self::check_script_module_hooks( $ctx->fork( 'script-module-hooks' ), $case ); + $rows[] = self::check_each_edge_cases( $ctx->fork( 'each-edge-cases' ), $case ); + $rows[] = self::check_router_region( $ctx, $case ); + $rows[] = self::check_unbalanced_and_unsupported_fallbacks( $ctx, $case ); + } catch ( \Throwable $e ) { + $rows[] = $ctx->fail( + 'interactivity.surface-no-throw', + array( + 'throwable' => self::describe_throwable( $e ), + ) + ); + } finally { + while ( ob_get_level() > $ob_level ) { + ob_end_clean(); + } + self::restore_state( $snapshot ); + } + + return $rows; + } + + private static function missing_requirements(): array { + $missing = array(); + + foreach ( + array( + 'WP_HTML_Tag_Processor', + 'WP_Interactivity_API', + 'WP_Interactivity_API_Directives_Processor', + ) as $class + ) { + if ( ! class_exists( $class ) ) { + $missing[] = "class {$class}"; + } + } + + foreach ( + array( + 'wp_interactivity', + 'wp_interactivity_process_directives', + 'wp_interactivity_state', + 'wp_interactivity_config', + 'wp_interactivity_data_wp_context', + 'wp_interactivity_get_context', + 'wp_interactivity_get_element', + 'wp_json_encode', + 'add_action', + 'add_filter', + 'get_self_link', + 'esc_attr', + 'esc_html', + 'has_action', + 'has_filter', + 'do_action', + 'remove_action', + 'remove_filter', + 'wp_add_inline_style', + 'wp_enqueue_style', + 'wp_register_style', + 'wp_styles', + ) as $function + ) { + if ( ! function_exists( $function ) ) { + $missing[] = "function {$function}"; + } + } + + return $missing; + } + + private static function check_state_config_helpers( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + $api = self::install_fresh_api(); + + $state_first = array( + 'nested' => array( + 'kept' => $case['stateKept'], + 'replaced' => 'old', + ), + 'list' => array( 'first', 'second' ), + 'scalar' => 'before', + ); + $state_next = array( + 'nested' => array( + 'replaced' => $case['stateReplacement'], + 'added' => $case['stateAdded'], + ), + 'list' => array( 1 => $case['stateListReplacement'] ), + 'scalar' => array( 'after' => $case['stateScalarAfter'] ), + ); + + $config_first = array( + 'flags' => array( + 'enabled' => false, + 'stable' => $case['configStable'], + ), + 'limits' => array( + 'page' => 10, + ), + ); + $config_next = array( + 'flags' => array( + 'enabled' => true, + ), + 'limits' => array( + 'page' => $case['configLimit'], + ), + ); + + $initial_state = \wp_interactivity_state( $case['namespace'], $state_first ); + $merged_state = \wp_interactivity_state( $case['namespace'], $state_next ); + $read_state = \wp_interactivity_state( $case['namespace'] ); + $other_state = \wp_interactivity_state( $case['otherNamespace'], array( 'value' => $case['otherValue'] ) ); + + $initial_config = \wp_interactivity_config( $case['namespace'], $config_first ); + $merged_config = \wp_interactivity_config( $case['namespace'], $config_next ); + $read_config = \wp_interactivity_config( $case['namespace'] ); + $client_data = $api->filter_script_module_interactivity_data( array( 'existing' => true ) ); + + $expected_state = array_replace_recursive( $state_first, $state_next ); + $expected_config = array_replace_recursive( $config_first, $config_next ); + $context_attr = \wp_interactivity_data_wp_context( $case['context'], $case['namespace'] ); + + $ok = $initial_state === $state_first + && $merged_state === $expected_state + && $read_state === $expected_state + && array( 'value' => $case['otherValue'] ) === $other_state + && $initial_config === $config_first + && $merged_config === $expected_config + && $read_config === $expected_config + && isset( $client_data['state'][ $case['namespace'] ], $client_data['config'][ $case['namespace'] ] ) + && $client_data['state'][ $case['namespace'] ] === $expected_state + && $client_data['config'][ $case['namespace'] ] === $expected_config + && str_starts_with( $context_attr, "data-wp-context='" . $case['namespace'] . '::' ) + && ! str_contains( $context_attr, '<' ) + && ! str_contains( $context_attr, '>' ) + && ! str_contains( $context_attr, '&' ); + + return self::result( + $ctx, + 'interactivity.state-config.recursive-merge-and-client-data', + $ok, + array( + 'namespace' => $case['namespace'], + 'initialState' => $initial_state, + 'mergedState' => $merged_state, + 'expectedState' => $expected_state, + 'otherState' => $other_state, + 'initialConfig' => $initial_config, + 'mergedConfig' => $merged_config, + 'expectedConfig' => $expected_config, + 'clientKeys' => array_keys( $client_data ), + 'contextAttr' => $context_attr, + ) + ); + } + + private static function check_directive_processing( \ComponentFuzz\FuzzContext $ctx, array $case ): array { + self::install_fresh_api(); + \wp_interactivity_state( $case['namespace'], $case['directiveState'] ); + + $html = self::directive_html( $case ); + $processed = \wp_interactivity_process_directives( $html ); + $target = self::find_first_tag_by_attribute( $processed, 'data-case', 'target' ); + $context = self::find_element_body( $processed, 'span', 'data-case', 'context' ); + $target_body = self::find_element_body( $processed, 'a', 'data-case', 'target' ); + $items = self::find_each_children( $processed, $case['namespace'] . '::state.items' ); + + $target_style = is_array( $target ) ? self::parse_style( (string) ( $target['attributes']['style'] ?? '' ) ) : array(); + $item_failures = array(); + foreach ( $case['directiveState']['items'] as $index => $item ) { + $rendered = $items[ $index ] ?? null; + $class = is_array( $rendered ) ? (string) ( $rendered['attributes']['class'] ?? '' ) : ''; + self::collect_failure( + $item_failures, + is_array( $rendered ) + && esc_html( $item['title'] ) === $rendered['body'] + && self::class_present( $class, 'selected' ) === (bool) $item['active'], + "data-wp-each rendered item {$index}", + array( + 'item' => $item, + 'rendered' => $rendered, + ) + ); + } + + $ok = is_array( $target ) + && $target_body === esc_html( $case['directiveState']['text'] ) + && $context === esc_html( $case['context']['localText'] ) + && $case['directiveState']['href'] === ( $target['attributes']['href'] ?? null ) + && ! array_key_exists( 'hidden', $target['attributes'] ) + && ( $case['directiveState']['ariaOpen'] ? 'true' : 'false' ) === ( $target['attributes']['aria-expanded'] ?? null ) + && ( $case['directiveState']['isActive'] ? 'true' : 'false' ) === ( $target['attributes']['data-active'] ?? null ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['baseClass'] ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['activeClass'] ) + && self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['uniqueClass'] . '---' . $case['uniqueId'] ) + && ! self::class_present( (string) ( $target['attributes']['class'] ?? '' ), $case['staleClass'] ) + && ( $target_style['color'] ?? null ) === $case['directiveState']['color'] + && ( $target_style['background-color'] ?? null ) === $case['directiveState']['background'] + && ( $target_style['border-color'] ?? null ) === $case['initialBorderColor'] + && ! array_key_exists( 'margin', $target_style ) + && count( $items ) === count( $case['directiveState']['items'] ) + && array() === $item_failures + && 1 === substr_count( $processed, '
After