|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * WP_Encoding class for interacting with character encoding names and byte streams, |
| 5 | + * according to the WHATWG Encoding specification. |
| 6 | + * |
| 7 | + * @link https://encoding.spec.whatwg.org/ |
| 8 | + * |
| 9 | + * @group encoding |
| 10 | + */ |
| 11 | +final class WP_Encoding { |
| 12 | + private static ?bool $mb_present = null; |
| 13 | + |
| 14 | + /** |
| 15 | + * Returns a UTF-8 string loaded from an encoded byte stream. |
| 16 | + * |
| 17 | + * @see self::export_from_utf8() for exporting from UTF-8. |
| 18 | + * |
| 19 | + * @since 7.2.0 |
| 20 | + * |
| 21 | + * @param string $source_encoding |
| 22 | + * @param string $encoded_bytes |
| 23 | + * @return string|null |
| 24 | + */ |
| 25 | + public static function load_into_utf8( string $source_encoding, string $encoded_bytes ): ?string { |
| 26 | + $encoding = self::encoding_from_label( $source_encoding ); |
| 27 | + if ( null === $encoding ) { |
| 28 | + return null; |
| 29 | + } |
| 30 | + |
| 31 | + if ( 'replacement' === $encoding ) { |
| 32 | + return ''; |
| 33 | + } |
| 34 | + |
| 35 | + self::$mb_present ??= \function_exists( '\mb_convert_encoding' ); |
| 36 | + |
| 37 | + /* |
| 38 | + * In a future expansion, when the decoders from the WHATWG spec are |
| 39 | + * introduced, this will return according to those fallbacks. For now, |
| 40 | + * without the `mbstring` extension, this will fail. |
| 41 | + */ |
| 42 | + if ( ! self::$mb_present ) { |
| 43 | + return null; |
| 44 | + } |
| 45 | + |
| 46 | + try { |
| 47 | + return \mb_convert_encoding( $encoded_bytes, $encoding, 'UTF-8' ); |
| 48 | + } catch ( \Throwable $e ) { |
| 49 | + return null; |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + /** |
| 54 | + * Returns an encoded byte stream from a source UTF-8 string. |
| 55 | + * |
| 56 | + * @see self::load_into_utf8() for importing into UTF-8. |
| 57 | + * |
| 58 | + * @since 7.2.0 |
| 59 | + * |
| 60 | + * @param string $destination_encoding |
| 61 | + * @param string $utf8_bytes |
| 62 | + * @return string|null |
| 63 | + */ |
| 64 | + public static function export_from_utf8( string $destination_encoding, string $utf8_bytes ): ?string { |
| 65 | + $encoding = self::encoding_from_label( $destination_encoding ); |
| 66 | + if ( null === $encoding || 'replacement' === $encoding ) { |
| 67 | + return null; |
| 68 | + } |
| 69 | + |
| 70 | + self::$mb_present ??= \function_exists( '\mb_convert_encoding' ); |
| 71 | + |
| 72 | + /* |
| 73 | + * In a future expansion, when the encoders from the WHATWG spec are |
| 74 | + * introduced, this will return according to those fallbacks. For now, |
| 75 | + * without the `mbstring` extension, this will fail. |
| 76 | + */ |
| 77 | + if ( ! self::$mb_present ) { |
| 78 | + return null; |
| 79 | + } |
| 80 | + |
| 81 | + try { |
| 82 | + return \mb_convert_encoding( $utf8_bytes, 'UTF-8', $encoding ); |
| 83 | + } catch ( \Throwable $e ) { |
| 84 | + return null; |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + /** |
| 89 | + * Canonicalizes a character-encoding label into a supported encoding name. |
| 90 | + * |
| 91 | + * Note! “Supported” does not mean that there’s a way to definitive way to |
| 92 | + * convert the encoding; it means that the encoding is not rejected |
| 93 | + * by WordPress as unsupported. Actual support currently depends on |
| 94 | + * the presence of the `mbstring` extension with support built-in |
| 95 | + * for the returned encoding. |
| 96 | + * |
| 97 | + * The `replacement` encoding implies that a decoder always returns the empty |
| 98 | + * string. This catches encodings which pose security risks and should never |
| 99 | + * be attempted to be decoded. The `x-user-defined` is a special form of |
| 100 | + * decoding byte streams into UTF-8 while preserving the source bytes. |
| 101 | + * |
| 102 | + * Example: |
| 103 | + * |
| 104 | + * // Common labels map to well-known encoding names. |
| 105 | + * 'UTF-8' === WP_Encoding::encoding_from_label( 'utf8' ); |
| 106 | + * 'windows-1252' === WP_Encoding::encoding_from_label( 'ISO-8859-1' ); |
| 107 | + * 'windows-1252' === WP_Encoding::encoding_from_label( 'cp1252' ); |
| 108 | + * |
| 109 | + * // Leading and trailing whitespace is trimmed; matches are ASCII-case-insensitive. |
| 110 | + * 'UTF-8' === WP_Encoding::encoding_from_label( 'utf8' ); |
| 111 | + * 'UTF-8' === WP_Encoding::encoding_from_label( 'UTF8' ); |
| 112 | + * 'UTF-8' === WP_Encoding::encoding_from_label( " utf-8\t" ); |
| 113 | + * |
| 114 | + * // For security purposes, some labels map to a rejected pseudo-encoding. |
| 115 | + * 'replacement' === WP_Encoding::encoding_from_label( 'iso-2022-cn' ); |
| 116 | + * |
| 117 | + * // Unknown or unrecognized labels are rejected entirely. |
| 118 | + * null === WP_Encoding::encoding_from_label( 'utf-7' ); |
| 119 | + * null === WP_Encoding::encoding_from_label( 'UTF-8; latin1' ); |
| 120 | + * null === WP_Encoding::encoding_from_label( '<meta charset="utf-8">' ); |
| 121 | + * |
| 122 | + * @see self::load_into_utf8() for importing into UTF-8 from these encodings. |
| 123 | + * @see self::export_from_utf8() for exporting from UTF-8 into these encodings. |
| 124 | + * |
| 125 | + * @link https://encoding.spec.whatwg.org/#names-and-labels |
| 126 | + * |
| 127 | + * @since 7.2.0 |
| 128 | + * |
| 129 | + * @param string $label Candidate encoding name, e.g. "latin1" or "UTF-8". |
| 130 | + * @return string|'replacment'|null Canonical name of encoding, if supported, else `null`. |
| 131 | + * `'replacement'` means that the decode should always be |
| 132 | + * the empty string, as a security precaution. |
| 133 | + */ |
| 134 | + public static function encoding_from_label( string $label ): ?string { |
| 135 | + /* |
| 136 | + * > To get an encoding from a string label, run these steps: |
| 137 | + * > 1. Remove any leading and trailing ASCII whitespace from label. |
| 138 | + * > 2. If label is an ASCII case-insensitive match for any of the labels listed in the table below, |
| 139 | + * > then return the corresponding encoding; otherwise return failure. |
| 140 | + */ |
| 141 | + $label = \trim( $label, " \t\f\r\n" ); |
| 142 | + $label = \strtolower( $label ); |
| 143 | + |
| 144 | + /* |
| 145 | + * Pre-filter on the most-common reported names to avoid additional processing. |
| 146 | + * A survey of 231,566 root-domain pages from the top ten-million visited sites |
| 147 | + * revealed that 13,402 (5.8%) reported a charset. Among those, these case-sensitive |
| 148 | + * labels accounted for the overwhelming majority of reports: |
| 149 | + * |
| 150 | + * | Label | Count | % | |
| 151 | + * | UTF-8 | 9,295 | 69 % | |
| 152 | + * | iso-8859-1 | 2,150 | 16 % | |
| 153 | + * | windows-1252 | 663 | 4.9% | |
| 154 | + * | us-ascii | 470 | 3.5% | |
| 155 | + * | windows-1251 | 162 | 1.2% | |
| 156 | + * |
| 157 | + * These account for 95% of reported charset labels, comprising only three distinct |
| 158 | + * supported charsets. This is worth the quick-pass even though it induces a tiny |
| 159 | + * amount of re-computation in the case that none of these match. |
| 160 | + */ |
| 161 | + if ( 'utf-8' === $label ) { |
| 162 | + return 'UTF-8'; |
| 163 | + } |
| 164 | + |
| 165 | + if ( 'iso-8859-1' === $label || 'windows-1252' === $label || 'us-ascii' === $label ) { |
| 166 | + return 'windows-1252'; |
| 167 | + } |
| 168 | + |
| 169 | + if ( 'windows-1251' === $label ) { |
| 170 | + return 'windows-1251'; |
| 171 | + } |
| 172 | + |
| 173 | + /* |
| 174 | + * If whitespace exists within the label, it’s probably the accidental concatenation |
| 175 | + * of multiple labels and no disambiguation is possible. If a semicolon is trapped |
| 176 | + * in the name then this probably comes from mis-parsing of a `charset` property on |
| 177 | + * a MIME content-type. |
| 178 | + */ |
| 179 | + if ( \strlen( $label ) !== \strcspn( $label, " \t\f\r\n;" ) ) { |
| 180 | + return null; |
| 181 | + } |
| 182 | + |
| 183 | + $label = " {$label} "; |
| 184 | + |
| 185 | + /** |
| 186 | + * Mapping from encoding name to space-separated list of its labels. |
| 187 | + * |
| 188 | + * > The table below lists all encodings and their labels user agents must |
| 189 | + * > support. User agents must not support any other encodings or labels. |
| 190 | + * |
| 191 | + * Every label will be surrounded on each side by spaces, as labels containing |
| 192 | + * spaces are rejected. This allows for efficient string lookup. |
| 193 | + * |
| 194 | + * @see \generate_charset_labels_table() |
| 195 | + * |
| 196 | + * @since 7.2.0 |
| 197 | + * |
| 198 | + * @var $table non-empty-array<non-empty-string, non-empty-string> |
| 199 | + */ |
| 200 | + $table = require __DIR__ . '/wp-supported-encodings.php'; |
| 201 | + |
| 202 | + foreach ( $table as $name => $labels ) { |
| 203 | + if ( \str_contains( $labels, $label ) ) { |
| 204 | + return $name; |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + return null; |
| 209 | + } |
| 210 | +} |
0 commit comments