|
12 | 12 | * @coversDefaultClass WP_HTML_Decoder |
13 | 13 | */ |
14 | 14 | class Tests_HtmlApi_WpHtmlDecoder extends WP_UnitTestCase { |
| 15 | + /** |
| 16 | + * Original LC_CTYPE locale. |
| 17 | + * |
| 18 | + * @var string|bool |
| 19 | + */ |
| 20 | + private static $original_lc_ctype = false; |
| 21 | + |
| 22 | + /** |
| 23 | + * Locale where ctype_alnum() classifies high-bit bytes as alphanumeric. |
| 24 | + * |
| 25 | + * @var string|null |
| 26 | + */ |
| 27 | + private static ?string $problematic_lc_ctype = null; |
| 28 | + |
| 29 | + public static function set_up_before_class() { |
| 30 | + parent::set_up_before_class(); |
| 31 | + |
| 32 | + self::$original_lc_ctype = setlocale( LC_CTYPE, 0 ); |
| 33 | + |
| 34 | + // Find a locale where ctype_alnum() classifies high-bit bytes as alphanumeric. |
| 35 | + $locale_candidates = array( |
| 36 | + 'C.UTF-8', |
| 37 | + 'C.utf8', |
| 38 | + 'en_US.UTF-8', |
| 39 | + 'en_US.utf8', |
| 40 | + 'en_GB.UTF-8', |
| 41 | + 'en_GB.utf8', |
| 42 | + ); |
| 43 | + foreach ( $locale_candidates as $locale ) { |
| 44 | + $candidate_locale = setlocale( LC_CTYPE, $locale ); |
| 45 | + |
| 46 | + if ( false !== $candidate_locale && ctype_alnum( "\xC2" ) ) { |
| 47 | + self::$problematic_lc_ctype = $candidate_locale; |
| 48 | + break; |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + if ( self::$original_lc_ctype ) { |
| 53 | + setlocale( LC_CTYPE, self::$original_lc_ctype ); |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + public function tear_down() { |
| 58 | + if ( self::$original_lc_ctype ) { |
| 59 | + setlocale( LC_CTYPE, self::$original_lc_ctype ); |
| 60 | + } |
| 61 | + parent::tear_down(); |
| 62 | + } |
| 63 | + |
15 | 64 | /** |
16 | 65 | * Ensures proper decoding of edge cases. |
17 | 66 | * |
@@ -61,6 +110,115 @@ static function ( int $errno, string $errstr ) use ( &$errors ) { |
61 | 110 | $this->assertSame( "&\x00b", $decoded, 'Should have decoded the text without changing it.' ); |
62 | 111 | } |
63 | 112 |
|
| 113 | + /** |
| 114 | + * Ensures semicolonless legacy references decode before non-ASCII UTF-8 bytes in attributes. |
| 115 | + * |
| 116 | + * @dataProvider data_semicolonless_attribute_behaviors |
| 117 | + * |
| 118 | + * @ticket 65372 |
| 119 | + */ |
| 120 | + public function test_semicolonless_legacy_reference_before_multibyte_attribute_follower( string $encoded_attribute_value, string $expected, string $expected_decode, int $expected_byte_length ): void { |
| 121 | + if ( null !== self::$problematic_lc_ctype ) { |
| 122 | + setlocale( LC_CTYPE, self::$problematic_lc_ctype ); |
| 123 | + } |
| 124 | + |
| 125 | + $this->assertSame( |
| 126 | + $expected, |
| 127 | + WP_HTML_Decoder::decode_attribute( $encoded_attribute_value ), |
| 128 | + 'Failed to decode the full attribute value as expected.' |
| 129 | + ); |
| 130 | + |
| 131 | + $match_byte_length = null; |
| 132 | + $this->assertSame( |
| 133 | + $expected_decode, |
| 134 | + WP_HTML_Decoder::read_character_reference( 'attribute', $encoded_attribute_value, 0, $match_byte_length ), |
| 135 | + 'Failed to decode the character reference as expected.' |
| 136 | + ); |
| 137 | + $this->assertSame( $expected_byte_length, $match_byte_length, 'Failed to produce expected byte length.' ); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Data provider. |
| 142 | + * |
| 143 | + * Attribute values encoded with character references including followers that are |
| 144 | + * treated as alphanumerics by `ctype_alnum()` on some systems, but should never |
| 145 | + * be recognized as ASCII Alphanumerics according to the HTML standards. |
| 146 | + * |
| 147 | + * @see https://html.spec.whatwg.org/#named-character-reference-state |
| 148 | + * |
| 149 | + * @return array<array{ |
| 150 | + * string, // Encoded attribute value. |
| 151 | + * string, // Expected full decode. |
| 152 | + * string, // Expected character decode. |
| 153 | + * int, // Replaced character reference byte length. |
| 154 | + * }> Test cases. |
| 155 | + */ |
| 156 | + public static function data_semicolonless_attribute_behaviors(): array { |
| 157 | + return array( |
| 158 | + array( '©¯\_(ツ)_/¯', '©¯\_(ツ)_/¯', '©', 5 ), |
| 159 | + array( '¬ಠ_ಠ', '¬ಠ_ಠ', '¬', 4 ), |
| 160 | + array( ' £20', "\u{00A0}£20", "\u{00A0}", 5 ), |
| 161 | + array( ' 🎉', "\u{00A0}🎉", "\u{00A0}", 5 ), |
| 162 | + array( '®™', '®™', '®', 4 ), |
| 163 | + ); |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Ensures ambiguous ampersand is recognized with trailing ASCII alphanumerics. |
| 168 | + * |
| 169 | + * @dataProvider data_semicolonless_attribute_character_reference_no_decode_followers |
| 170 | + * |
| 171 | + * @ticket 65372 |
| 172 | + * |
| 173 | + * @param string $raw_attribute Raw attribute value with an ambiguous legacy reference follower. |
| 174 | + */ |
| 175 | + public function test_ascii_alphanumeric_attribute_follower_is_ambiguous( string $raw_attribute ): void { |
| 176 | + $this->assertSame( |
| 177 | + $raw_attribute, |
| 178 | + WP_HTML_Decoder::decode_attribute( $raw_attribute ), |
| 179 | + 'Should not have decoded an ambiguous semicolonless legacy reference.' |
| 180 | + ); |
| 181 | + |
| 182 | + $match_byte_length = 'sentinel'; |
| 183 | + $this->assertNull( |
| 184 | + WP_HTML_Decoder::read_character_reference( 'attribute', $raw_attribute, 0, $match_byte_length ), |
| 185 | + 'Should not have matched an ambiguous semicolonless legacy reference.' |
| 186 | + ); |
| 187 | + $this->assertSame( 'sentinel', $match_byte_length ); |
| 188 | + } |
| 189 | + |
| 190 | + /** |
| 191 | + * Data provider. |
| 192 | + * |
| 193 | + * HTML character references with followers that trigger the literal flush behavior |
| 194 | + * when parsing attribute values. HTML defines this as `=` or an ASCII alphanumeric character. |
| 195 | + * |
| 196 | + * > An ASCII alphanumeric is an ASCII digit or ASCII alpha. |
| 197 | + * > An ASCII alpha is an ASCII upper alpha or ASCII lower alpha. |
| 198 | + * |
| 199 | + * @see https://html.spec.whatwg.org/#named-character-reference-state |
| 200 | + * |
| 201 | + * @return Generator<string, array{ string }> Test cases. |
| 202 | + */ |
| 203 | + public static function data_semicolonless_attribute_character_reference_no_decode_followers(): Generator { |
| 204 | + yield "Equals sign follower '='" => array( 'Á=' ); |
| 205 | + // > An ASCII digit is a code point in the range U+0030 (0) to U+0039 (9), inclusive. |
| 206 | + for ( $i = 0x30; $i <= 0x39; $i++ ) { |
| 207 | + $char = chr( $i ); |
| 208 | + yield "ASCII digit follower '{$char}'" => array( "Á{$char}" ); |
| 209 | + } |
| 210 | + // > An ASCII upper alpha is a code point in the range U+0041 (A) to U+005A (Z), inclusive. |
| 211 | + for ( $i = 0x41; $i <= 0x5A; $i++ ) { |
| 212 | + $char = chr( $i ); |
| 213 | + yield "ASCII upper alpha follower '{$char}'" => array( "Á{$char}" ); |
| 214 | + } |
| 215 | + // > An ASCII lower alpha is a code point in the range U+0061 (a) to U+007A (z), inclusive. |
| 216 | + for ( $i = 0x61; $i <= 0x7A; $i++ ) { |
| 217 | + $char = chr( $i ); |
| 218 | + yield "ASCII lower alpha follower '{$char}'" => array( "Á{$char}" ); |
| 219 | + } |
| 220 | + } |
| 221 | + |
64 | 222 | /** |
65 | 223 | * Ensures proper detection of attribute prefixes ignoring ASCII case. |
66 | 224 | * |
|
0 commit comments