Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 93 additions & 8 deletions src/wp-includes/class-wp-token-map.php
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,9 @@ public function contains( string $word, string $case_sensitivity = 'case-sensiti
}

$term = str_pad( $word, $this->key_length + 1, "\x00", STR_PAD_RIGHT );
$word_at = $ignore_case ? stripos( $this->small_words, $term ) : strpos( $this->small_words, $term );
$word_at = $ignore_case
? strpos( self::ascii_uppercase( $this->small_words ), self::ascii_uppercase( $term ) )
: strpos( $this->small_words, $term );
if ( false === $word_at ) {
return false;
}
Expand All @@ -461,7 +463,9 @@ public function contains( string $word, string $case_sensitivity = 'case-sensiti
}

$group_key = substr( $word, 0, $this->key_length );
$group_at = $ignore_case ? stripos( $this->groups, $group_key ) : strpos( $this->groups, $group_key );
$group_at = $ignore_case
? strpos( self::ascii_uppercase( $this->groups ), self::ascii_uppercase( $group_key ) )
: strpos( $this->groups, $group_key );
if ( false === $group_at ) {
return false;
}
Expand All @@ -478,7 +482,12 @@ public function contains( string $word, string $case_sensitivity = 'case-sensiti
$mapping_length = unpack( 'C', $group[ $at++ ] )[1];
$mapping_at = $at;

if ( $token_length === $length && 0 === substr_compare( $group, $slug, $token_at, $token_length, $ignore_case ) ) {
if (
$token_length === $length &&
( $ignore_case
? self::matches_ascii_case_insensitively( $group, $slug, $token_at )
: 0 === substr_compare( $group, $slug, $token_at, $token_length ) )
) {
return true;
}

Expand Down Expand Up @@ -547,7 +556,9 @@ public function read_token( string $text, int $offset = 0, &$matched_token_byte_
}

$group_key = substr( $text, $offset, $this->key_length );
$group_at = $ignore_case ? stripos( $this->groups, $group_key ) : strpos( $this->groups, $group_key );
$group_at = $ignore_case
? strpos( self::ascii_uppercase( $this->groups ), self::ascii_uppercase( $group_key ) )
: strpos( $this->groups, $group_key );
if ( false === $group_at ) {
// Perhaps a short word then.
return strlen( $this->small_words ) > 0
Expand All @@ -565,7 +576,11 @@ public function read_token( string $text, int $offset = 0, &$matched_token_byte_
$mapping_length = unpack( 'C', $group[ $at++ ] )[1];
$mapping_at = $at;

if ( 0 === substr_compare( $text, $token, $offset + $this->key_length, $token_length, $ignore_case ) ) {
$token_matches = $ignore_case
? self::matches_ascii_case_insensitively( $text, $token, $offset + $this->key_length )
: 0 === substr_compare( $text, $token, $offset + $this->key_length, $token_length );

if ( $token_matches ) {
$matched_token_byte_length = $this->key_length + $token_length;
return substr( $group, $mapping_at, $mapping_length );
}
Expand Down Expand Up @@ -597,15 +612,15 @@ private function read_small_token( string $text, int $offset = 0, &$matched_toke
$small_length = strlen( $this->small_words );
$search_text = substr( $text, $offset, $this->key_length );
if ( $ignore_case ) {
$search_text = strtoupper( $search_text );
$search_text = self::ascii_uppercase( $search_text );
}
$starting_char = $search_text[0];

$at = 0;
while ( $at < $small_length ) {
if (
$starting_char !== $this->small_words[ $at ] &&
( ! $ignore_case || strtoupper( $this->small_words[ $at ] ) !== $starting_char )
( ! $ignore_case || self::ascii_uppercase( $this->small_words[ $at ] ) !== $starting_char )
) {
$at += $this->key_length + 1;
continue;
Expand All @@ -619,7 +634,7 @@ private function read_small_token( string $text, int $offset = 0, &$matched_toke

if (
$search_text[ $adjust ] !== $this->small_words[ $at + $adjust ] &&
( ! $ignore_case || strtoupper( $this->small_words[ $at + $adjust ] !== $search_text[ $adjust ] ) )
( ! $ignore_case || self::ascii_uppercase( $this->small_words[ $at + $adjust ] ) !== $search_text[ $adjust ] )
) {
$at += $this->key_length + 1;
continue 2;
Expand All @@ -633,6 +648,76 @@ private function read_small_token( string $text, int $offset = 0, &$matched_toke
return null;
}

/**
* Returns the ASCII uppercase version of a given string.
*
* Only the ASCII lowercase letters a-z are folded onto their uppercase
* counterparts; all other bytes are left unchanged. This differs from
* `strtoupper()`, which before PHP 8.2 folds bytes according to the
* current process locale. Lookup must be locale-independent.
*
* @since 7.1.0
*
* @param string $text Text to fold.
* @return string ASCII-uppercase version of the given text.
*/
private static function ascii_uppercase( string $text ): string {
return strtr( $text, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' );
}

/**
* Indicates if a span of text matches a given string, ignoring ASCII case.
*
* Matching is performed byte-by-byte and folds only the ASCII letters A-Z
* and a-z onto each other, regardless of the process locale. This differs
* from `substr_compare()` with its case-insensitivity flag, which folds
* bytes according to the current process locale and may treat non-ASCII
* bytes as case variants of each other.
*
* The span begins at the given byte offset into the text and runs for the
* byte length of the search string. A span extending past the end of the
* text never matches.
*
* @since 7.1.0
*
* @param string $text Text possibly containing the search string.
* @param string $search Search string; its byte length determines the span length.
* @param int $offset Byte offset into the text where the span begins.
* @return bool Whether the span is an ASCII case-insensitive match for the search string.
*/
private static function matches_ascii_case_insensitively( string $text, string $search, int $offset ): bool {
$length = strlen( $search );
if ( $offset < 0 || $offset + $length > strlen( $text ) ) {
return false;
}

for ( $at = 0; $at < $length; $at++ ) {
$text_byte = $text[ $offset + $at ];
$search_byte = $search[ $at ];

if ( $text_byte === $search_byte ) {
continue;
}

$text_ord = ord( $text_byte );
$search_ord = ord( $search_byte );

// Fold uppercase ASCII letters onto their lowercase counterparts; no other bytes fold.
if ( $text_ord >= 0x41 && $text_ord <= 0x5A ) {
$text_ord += 0x20;
}
if ( $search_ord >= 0x41 && $search_ord <= 0x5A ) {
$search_ord += 0x20;
}

if ( $text_ord !== $search_ord ) {
return false;
}
}

return true;
}

/**
* Exports the token map into an associate array of key/value pairs.
*
Expand Down
117 changes: 115 additions & 2 deletions src/wp-includes/html-api/class-wp-html-decoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,116 @@
* @since 6.6.0
*/
class WP_HTML_Decoder {
/**
* Indicates if a span of text matches a given string, ignoring ASCII case.
*
* Matching is performed byte-by-byte and folds only the ASCII uppercase letters
* A-Z onto their lowercase counterparts, regardless of the process locale.
*
* This exists because PHP's built-in case-insensitive comparisons are locale
* sensitive: `substr_compare()` with its case-insensitivity flag and (before
* PHP 8.2) `strcasecmp()`, `strtolower()`, and friends fold bytes according
* to the current process locale, which may treat non-ASCII bytes as case
* variants of each other or of ASCII letters. HTML requires ASCII
* case insensitivity regardless of locale.
*
* The span begins at the given byte offset into the text and runs for the
* byte length of the search string. A span extending past the end of the
* text never matches, even if the available bytes match the search string.
*
* Example:
*
* true === WP_HTML_Decoder::matches_ascii_case_insensitively( 'DIV', 'div' );
* true === WP_HTML_Decoder::matches_ascii_case_insensitively( '<!doctype html>', 'HTML', 10 );
* false === WP_HTML_Decoder::matches_ascii_case_insensitively( "\xCC", "\xEC" );
* false === WP_HTML_Decoder::matches_ascii_case_insensitively( 'DIVERT', 'div', 3 );
*
* @since 7.1.0
*
* @access private
*
* @see https://infra.spec.whatwg.org/#ascii-case-insensitive
*
* @param string $text Text possibly containing the search string.
* @param string $search Search string; its byte length determines the span length.
* @param int $offset Optional. Byte offset into the text where the span begins. Default 0.
* @return bool Whether the span is an ASCII case-insensitive match for the search string.
*/
public static function matches_ascii_case_insensitively( string $text, string $search, int $offset = 0 ): bool {
$length = strlen( $search );
if ( $offset < 0 || $offset + $length > strlen( $text ) ) {
return false;
}

for ( $at = 0; $at < $length; $at++ ) {
$text_byte = $text[ $offset + $at ];
$search_byte = $search[ $at ];

if ( $text_byte === $search_byte ) {
continue;
}

$text_ord = ord( $text_byte );
$search_ord = ord( $search_byte );

// Fold uppercase ASCII letters onto their lowercase counterparts; no other bytes fold.
if ( $text_ord >= 0x41 && $text_ord <= 0x5A ) {
$text_ord += 0x20;
}
if ( $search_ord >= 0x41 && $search_ord <= 0x5A ) {
$search_ord += 0x20;
}

if ( $text_ord !== $search_ord ) {
return false;
}
}

return true;
}

/**
* Returns the ASCII lowercase version of a given string.
*
* Only the ASCII uppercase letters A-Z are folded onto their lowercase
* counterparts; all other bytes are left unchanged. This differs from
* `strtolower()`, which before PHP 8.2 folds bytes according to the
* current process locale.
*
* @since 7.1.0
*
* @access private
*
* @see https://infra.spec.whatwg.org/#ascii-lowercase
*
* @param string $text Text to fold.
* @return string ASCII-lowercase version of the given text.
*/
public static function ascii_lowercase( string $text ): string {
return strtr( $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' );
}

/**
* Returns the ASCII uppercase version of a given string.
*
* Only the ASCII lowercase letters a-z are folded onto their uppercase
* counterparts; all other bytes are left unchanged. This differs from
* `strtoupper()`, which before PHP 8.2 folds bytes according to the
* current process locale.
*
* @since 7.1.0
*
* @access private
*
* @see https://infra.spec.whatwg.org/#ascii-uppercase
*
* @param string $text Text to fold.
* @return string ASCII-uppercase version of the given text.
*/
public static function ascii_uppercase( string $text ): string {
return strtr( $text, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' );
}

/**
* Indicates if an attribute value starts with a given raw string value.
*
Expand Down Expand Up @@ -40,7 +150,7 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen

while ( $search_at < $search_length && $haystack_at < $haystack_end ) {
$chars_match = $loose_case
? strtolower( $haystack[ $haystack_at ] ) === strtolower( $search_text[ $search_at ] )
? self::matches_ascii_case_insensitively( $haystack, $search_text[ $search_at ], $haystack_at )
: $haystack[ $haystack_at ] === $search_text[ $search_at ];

$is_introducer = '&' === $haystack[ $haystack_at ];
Expand All @@ -61,7 +171,10 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen
}

// If there is a character reference, then the decoded value must exactly match what follows in the search string.
if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, strlen( $next_chunk ), $loose_case ) ) {
$chunk_matches = $loose_case
? self::matches_ascii_case_insensitively( $search_text, $next_chunk, $search_at )
: 0 === substr_compare( $search_text, $next_chunk, $search_at, strlen( $next_chunk ) );
if ( ! $chunk_matches ) {
return false;
}

Expand Down
12 changes: 6 additions & 6 deletions src/wp-includes/html-api/class-wp-html-doctype-info.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ private function __construct(
* > The system identifier and public identifier strings must be compared...
* > in an ASCII case-insensitive manner.
*/
$public_identifier = null === $public_identifier ? '' : strtolower( $public_identifier );
$system_identifier = null === $system_identifier ? '' : strtolower( $system_identifier );
$public_identifier = null === $public_identifier ? '' : WP_HTML_Decoder::ascii_lowercase( $public_identifier );
$system_identifier = null === $system_identifier ? '' : WP_HTML_Decoder::ascii_lowercase( $system_identifier );

/*
* > The public identifier is set to…
Expand Down Expand Up @@ -436,7 +436,7 @@ public static function from_doctype_token( string $doctype_html ): ?self {
*/
if (
$end < 9 ||
0 !== substr_compare( $doctype_html, '<!DOCTYPE', 0, 9, true )
! WP_HTML_Decoder::matches_ascii_case_insensitively( $doctype_html, '<!DOCTYPE' )
) {
return null;
}
Expand Down Expand Up @@ -489,7 +489,7 @@ public static function from_doctype_token( string $doctype_html ): ?self {
}

$name_length = strcspn( $doctype_html, " \t\n\f\r", $at, $end - $at );
$doctype_name = str_replace( "\0", "\u{FFFD}", strtolower( substr( $doctype_html, $at, $name_length ) ) );
$doctype_name = str_replace( "\0", "\u{FFFD}", WP_HTML_Decoder::ascii_lowercase( substr( $doctype_html, $at, $name_length ) ) );

$at += $name_length;
$at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at );
Expand All @@ -514,7 +514,7 @@ public static function from_doctype_token( string $doctype_html ): ?self {
* > case-insensitive match for the word "PUBLIC", then consume those characters
* > and switch to the after DOCTYPE public keyword state.
*/
if ( 0 === substr_compare( $doctype_html, 'PUBLIC', $at, 6, true ) ) {
if ( WP_HTML_Decoder::matches_ascii_case_insensitively( $doctype_html, 'PUBLIC', $at ) ) {
$at += 6;
$at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at );
if ( $at >= $end ) {
Expand All @@ -528,7 +528,7 @@ public static function from_doctype_token( string $doctype_html ): ?self {
* > case-insensitive match for the word "SYSTEM", then consume those characters and switch
* > to the after DOCTYPE system keyword state.
*/
if ( 0 === substr_compare( $doctype_html, 'SYSTEM', $at, 6, true ) ) {
if ( WP_HTML_Decoder::matches_ascii_case_insensitively( $doctype_html, 'SYSTEM', $at ) ) {
$at += 6;
$at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at );
if ( $at >= $end ) {
Expand Down
7 changes: 6 additions & 1 deletion src/wp-includes/html-api/class-wp-html-open-elements.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,12 @@ public function current_node_is( string $identity ): bool {
return (
$current_node_name === $identity ||
( '#doctype' === $identity && 'html' === $current_node_name ) ||
( '#tag' === $identity && ctype_upper( $current_node_name ) )
/*
* Elements are identified by their uppercase ASCII names. This check
* avoids `ctype_upper()`, whose classification of bytes outside of
* ASCII depends on the process locale.
*/
( '#tag' === $identity && strlen( $current_node_name ) === strspn( $current_node_name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ) )
);
}

Expand Down
Loading
Loading