Commit 5b765f4b68c for php
commit 5b765f4b68cbfceed8847352b076cd2916dac301
Author: Nicolas Grekas <nicolas.grekas@gmail.com>
Date: Wed Oct 14 19:22:20 2026 +0200
ext/standard: Return the input of htmlspecialchars() when nothing needs encoding (#23957)
htmlspecialchars() allocated a buffer of twice the input and decoded it
character by character, even when the result was the input itself.
The input is now scanned first for the characters that need encoding and
for invalid multi-byte sequences, with SSE2 on 16-byte blocks. When there
is none, the input string is returned, as html_entity_decode() and
htmlspecialchars_decode() already do since 68dc7549988. Otherwise, encoding
starts after the part that was scanned.
The @refcount 1 annotations of htmlspecialchars() and htmlentities() are
removed since they can now return their argument.
Co-authored-by: Arnaud Le Blanc <365207+arnaud-lb@users.noreply.github.com>
diff --git a/NEWS b/NEWS
index 93421d4bb4a..9258c398f1e 100644
--- a/NEWS
+++ b/NEWS
@@ -127,6 +127,8 @@ PHP NEWS
(mehmetcansahin)
. Improved performance of resolving the charset of HTML functions,
especially UTF-8. (Nicolas Grekas)
+ . Improved performance of htmlspecialchars() when nothing needs encoding.
+ (Nicolas Grekas)
- URI:
. Fix casing of enum cases in UriHostType and UrlHostType to match the RFC
diff --git a/UPGRADING b/UPGRADING
index 5257f89ed87..ac335b4e5aa 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -115,6 +115,8 @@ PHP 8.7 UPGRADE NOTES
. Improved performance of htmlspecialchars(), htmlentities(),
html_entity_decode() and get_html_translation_table(), especially with
UTF-8.
+ . htmlspecialchars() returns its input without copying it when nothing needs
+ encoding.
- MBString:
. Improved performance of mb_strlen for UTF-8 strings.
diff --git a/Zend/Optimizer/zend_func_infos.h b/Zend/Optimizer/zend_func_infos.h
index 0add5a32f91..9e2c1b35aee 100644
--- a/Zend/Optimizer/zend_func_infos.h
+++ b/Zend/Optimizer/zend_func_infos.h
@@ -463,8 +463,6 @@ static const func_info_t func_infos[] = {
F1("inet_pton", MAY_BE_STRING|MAY_BE_FALSE),
F1("metaphone", MAY_BE_STRING),
F1("headers_list", MAY_BE_ARRAY|MAY_BE_ARRAY_KEY_LONG|MAY_BE_ARRAY_OF_STRING),
- F1("htmlspecialchars", MAY_BE_STRING),
- F1("htmlentities", MAY_BE_STRING),
F1("get_html_translation_table", MAY_BE_ARRAY|MAY_BE_ARRAY_KEY_STRING|MAY_BE_ARRAY_OF_STRING),
F1("bin2hex", MAY_BE_STRING),
F1("hex2bin", MAY_BE_STRING|MAY_BE_FALSE),
diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php
index e55993eeb23..7292122abae 100644
--- a/ext/standard/basic_functions.stub.php
+++ b/ext/standard/basic_functions.stub.php
@@ -2273,14 +2273,12 @@ function headers_list(): array {}
/* {{{ html.c */
-/** @refcount 1 */
function htmlspecialchars(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, ?string $encoding = null, bool $double_encode = true): string {}
function htmlspecialchars_decode(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401): string {}
function html_entity_decode(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, ?string $encoding = null): string {}
-/** @refcount 1 */
function htmlentities(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, ?string $encoding = null, bool $double_encode = true): string {}
/**
diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h
index a4bab4cbbb9..21a14c0bfed 100644
Binary files a/ext/standard/basic_functions_arginfo.h and b/ext/standard/basic_functions_arginfo.h differ
diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h
index 5f1ea26850c..e1f012c2637 100644
Binary files a/ext/standard/basic_functions_decl.h and b/ext/standard/basic_functions_decl.h differ
diff --git a/ext/standard/html.c b/ext/standard/html.c
index 1381536dd3f..075d629f10b 100644
--- a/ext/standard/html.c
+++ b/ext/standard/html.c
@@ -43,6 +43,8 @@
#include <locale.h>
#include <zend_hash.h>
+#include "zend_bitset.h"
+#include "zend_simd.h"
#include "html_tables.h"
/* Macro for disabling flag of translation of non-basic entities where this isn't supported.
@@ -83,6 +85,88 @@ static char *get_default_charset(void) {
}
/* }}} */
+/* UTF-8 case of get_next_char(), for a cursor before the end of str and a status set to SUCCESS */
+static zend_always_inline unsigned int get_next_char_utf8(
+ const unsigned char *str,
+ size_t str_len,
+ size_t *cursor,
+ zend_result *status)
+{
+ size_t pos = *cursor;
+ unsigned int this_char = 0;
+
+ /* We'll follow strategy 2. from section 3.6.1 of UTR #36:
+ * "In a reported illegal byte sequence, do not include any
+ * non-initial byte that encodes a valid character or is a leading
+ * byte for a valid sequence." */
+ unsigned char c;
+ c = str[pos];
+ if (c < 0x80) {
+ this_char = c;
+ pos++;
+ } else if (c < 0xc2) {
+ MB_FAILURE(pos, 1);
+ } else if (c < 0xe0) {
+ if (!CHECK_LEN(pos, 2))
+ MB_FAILURE(pos, 1);
+
+ if (!utf8_trail(str[pos + 1])) {
+ MB_FAILURE(pos, utf8_lead(str[pos + 1]) ? 1 : 2);
+ }
+ this_char = ((c & 0x1f) << 6) | (str[pos + 1] & 0x3f);
+ if (this_char < 0x80) { /* non-shortest form */
+ MB_FAILURE(pos, 2);
+ }
+ pos += 2;
+ } else if (c < 0xf0) {
+ size_t avail = str_len - pos;
+
+ if (avail < 3 ||
+ !utf8_trail(str[pos + 1]) || !utf8_trail(str[pos + 2])) {
+ if (avail < 2 || utf8_lead(str[pos + 1]))
+ MB_FAILURE(pos, 1);
+ else if (avail < 3 || utf8_lead(str[pos + 2]))
+ MB_FAILURE(pos, 2);
+ else
+ MB_FAILURE(pos, 3);
+ }
+
+ this_char = ((c & 0x0f) << 12) | ((str[pos + 1] & 0x3f) << 6) | (str[pos + 2] & 0x3f);
+ if (this_char < 0x800) { /* non-shortest form */
+ MB_FAILURE(pos, 3);
+ } else if (this_char >= 0xd800 && this_char <= 0xdfff) { /* surrogate */
+ MB_FAILURE(pos, 3);
+ }
+ pos += 3;
+ } else if (c < 0xf5) {
+ size_t avail = str_len - pos;
+
+ if (avail < 4 ||
+ !utf8_trail(str[pos + 1]) || !utf8_trail(str[pos + 2]) ||
+ !utf8_trail(str[pos + 3])) {
+ if (avail < 2 || utf8_lead(str[pos + 1]))
+ MB_FAILURE(pos, 1);
+ else if (avail < 3 || utf8_lead(str[pos + 2]))
+ MB_FAILURE(pos, 2);
+ else if (avail < 4 || utf8_lead(str[pos + 3]))
+ MB_FAILURE(pos, 3);
+ else
+ MB_FAILURE(pos, 4);
+ }
+
+ this_char = ((c & 0x07) << 18) | ((str[pos + 1] & 0x3f) << 12) | ((str[pos + 2] & 0x3f) << 6) | (str[pos + 3] & 0x3f);
+ if (this_char < 0x10000 || this_char > 0x10FFFF) { /* non-shortest form or outside range */
+ MB_FAILURE(pos, 4);
+ }
+ pos += 4;
+ } else {
+ MB_FAILURE(pos, 1);
+ }
+
+ *cursor = pos;
+ return this_char;
+}
+
/* {{{ get_next_char */
static inline unsigned int get_next_char(
enum entity_charset charset,
@@ -102,76 +186,7 @@ static inline unsigned int get_next_char(
switch (charset) {
case cs_utf_8:
- {
- /* We'll follow strategy 2. from section 3.6.1 of UTR #36:
- * "In a reported illegal byte sequence, do not include any
- * non-initial byte that encodes a valid character or is a leading
- * byte for a valid sequence." */
- unsigned char c;
- c = str[pos];
- if (c < 0x80) {
- this_char = c;
- pos++;
- } else if (c < 0xc2) {
- MB_FAILURE(pos, 1);
- } else if (c < 0xe0) {
- if (!CHECK_LEN(pos, 2))
- MB_FAILURE(pos, 1);
-
- if (!utf8_trail(str[pos + 1])) {
- MB_FAILURE(pos, utf8_lead(str[pos + 1]) ? 1 : 2);
- }
- this_char = ((c & 0x1f) << 6) | (str[pos + 1] & 0x3f);
- if (this_char < 0x80) { /* non-shortest form */
- MB_FAILURE(pos, 2);
- }
- pos += 2;
- } else if (c < 0xf0) {
- size_t avail = str_len - pos;
-
- if (avail < 3 ||
- !utf8_trail(str[pos + 1]) || !utf8_trail(str[pos + 2])) {
- if (avail < 2 || utf8_lead(str[pos + 1]))
- MB_FAILURE(pos, 1);
- else if (avail < 3 || utf8_lead(str[pos + 2]))
- MB_FAILURE(pos, 2);
- else
- MB_FAILURE(pos, 3);
- }
-
- this_char = ((c & 0x0f) << 12) | ((str[pos + 1] & 0x3f) << 6) | (str[pos + 2] & 0x3f);
- if (this_char < 0x800) { /* non-shortest form */
- MB_FAILURE(pos, 3);
- } else if (this_char >= 0xd800 && this_char <= 0xdfff) { /* surrogate */
- MB_FAILURE(pos, 3);
- }
- pos += 3;
- } else if (c < 0xf5) {
- size_t avail = str_len - pos;
-
- if (avail < 4 ||
- !utf8_trail(str[pos + 1]) || !utf8_trail(str[pos + 2]) ||
- !utf8_trail(str[pos + 3])) {
- if (avail < 2 || utf8_lead(str[pos + 1]))
- MB_FAILURE(pos, 1);
- else if (avail < 3 || utf8_lead(str[pos + 2]))
- MB_FAILURE(pos, 2);
- else if (avail < 4 || utf8_lead(str[pos + 3]))
- MB_FAILURE(pos, 3);
- else
- MB_FAILURE(pos, 4);
- }
-
- this_char = ((c & 0x07) << 18) | ((str[pos + 1] & 0x3f) << 12) | ((str[pos + 2] & 0x3f) << 6) | (str[pos + 3] & 0x3f);
- if (this_char < 0x10000 || this_char > 0x10FFFF) { /* non-shortest form or outside range */
- MB_FAILURE(pos, 4);
- }
- pos += 4;
- } else {
- MB_FAILURE(pos, 1);
- }
- }
- break;
+ return get_next_char_utf8(str, str_len, cursor, status);
case cs_big5:
/* reference http://demo.icu-project.org/icu-bin/convexp?conv=big5 */
@@ -1157,12 +1172,127 @@ static inline void find_entity_for_char_basic(
}
/* }}} */
-/* {{{ php_escape_html_entities */
-PHPAPI zend_string *php_escape_html_entities_ex(const unsigned char *old, size_t oldlen, int all, int flags, const char *hint_charset, bool double_encode, bool quiet)
+/* Character classes used by html_verbatim_prefix_len(). The quote classes are the flags
+ * that make the quotes encoded. */
+#define HTML_CC_SQUOTE (1<<0) /* ' */
+#define HTML_CC_DQUOTE (1<<1) /* " */
+#define HTML_CC_BASIC (1<<2) /* &, < and >, always encoded */
+#define HTML_CC_NON_ASCII (1<<3) /* bytes 0x80-0xFF */
+
+static_assert(ENT_HTML_QUOTE_SINGLE == HTML_CC_SQUOTE && ENT_HTML_QUOTE_DOUBLE == HTML_CC_DQUOTE, "the quote classes must match the quote flags");
+
+#define N HTML_CC_NON_ASCII
+static const unsigned char html_char_class[256] = {
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, HTML_CC_DQUOTE, 0, 0, 0, HTML_CC_BASIC, HTML_CC_SQUOTE, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, HTML_CC_BASIC, 0, HTML_CC_BASIC, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+ N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N,
+};
+#undef N
+
+/* Moves *pos after the UTF-8 character at *pos, or returns false if it is invalid */
+static zend_always_inline bool html_skip_utf8_char(const unsigned char *str, size_t str_len, size_t *pos)
+{
+ size_t cursor = *pos;
+ zend_result status = SUCCESS;
+
+ get_next_char_utf8(str, str_len, &cursor, &status);
+ if (status == FAILURE) {
+ return false;
+ }
+ *pos = cursor;
+ return true;
+}
+
+#ifdef XSSE2
+/* Returns the bit mask of the bytes of in that html_verbatim_prefix_len() stops at. '<' and '>'
+ * differ only by bit 1, and '&' and '\'' only by bit 0, which apos_mask clears when '\'' is
+ * encoded. quot is '"', or '&' when '"' is not encoded, and high is 0x80, or 0 in single-byte
+ * charsets. */
+static zend_always_inline int html_stop_mask(__m128i in, __m128i apos_mask, __m128i quot, __m128i high)
+{
+ __m128i m = _mm_cmpeq_epi8(_mm_and_si128(in, _mm_set1_epi8(~2)), _mm_set1_epi8('<'));
+ m = _mm_or_si128(m, _mm_cmpeq_epi8(_mm_and_si128(in, apos_mask), _mm_set1_epi8('&')));
+ m = _mm_or_si128(m, _mm_or_si128(_mm_cmpeq_epi8(in, quot), _mm_and_si128(in, high)));
+ return _mm_movemask_epi8(m);
+}
+#endif
+
+/* Returns the length of the longest prefix of old that escape_html_entities_from() copies
+ * unchanged when only the basic entities are encoded and disallowed characters are not
+ * substituted: the prefix contains no character to encode and no invalid multi-byte
+ * sequence, and ends on a character boundary. */
+static zend_always_inline size_t html_verbatim_prefix_len(const unsigned char *old, size_t oldlen, int flags, enum entity_charset charset)
{
- size_t cursor, maxlen, len;
+ /* In all supported charsets, bytes below 0x80 are single-byte characters. Bytes above
+ * 0x7F are characters of their own in single-byte charsets. In UTF-8 they are validated
+ * below, and the prefix stops at the first of them in the other multi-byte charsets. */
+ const unsigned char stop = HTML_CC_BASIC
+ | (flags & (ENT_HTML_QUOTE_SINGLE | ENT_HTML_QUOTE_DOUBLE))
+ | (CHARSET_SINGLE_BYTE(charset) ? 0 : HTML_CC_NON_ASCII);
+ size_t pos = 0;
+
+#ifdef XSSE2
+ if (oldlen >= sizeof(__m128i)) {
+ /* Scan blocks of 16 bytes, the last one ending at the end of old */
+ const __m128i apos_mask = (flags & ENT_HTML_QUOTE_SINGLE) ? _mm_set1_epi8(~1) : _mm_set1_epi8(~0);
+ const __m128i quot = (flags & ENT_HTML_QUOTE_DOUBLE) ? _mm_set1_epi8('"') : _mm_set1_epi8('&');
+ const __m128i high = (stop & HTML_CC_NON_ASCII) ? _mm_set1_epi8((char) 0x80) : _mm_setzero_si128();
+ const size_t last = oldlen - sizeof(__m128i);
+
+ while (1) {
+ int mask;
+
+ if (pos < last) {
+ mask = html_stop_mask(_mm_loadu_si128((const __m128i *) (old + pos)), apos_mask, quot, high);
+ if (!mask) {
+ pos += sizeof(__m128i);
+ continue;
+ }
+ } else {
+ /* the last block, whose bytes before pos are already scanned */
+ mask = html_stop_mask(_mm_loadu_si128((const __m128i *) (old + last)), apos_mask, quot, high) >> (pos - last);
+ if (!mask) {
+ return oldlen;
+ }
+ }
+ pos += zend_ulong_ntz(mask);
+ if (old[pos] < 0x80 || charset != cs_utf_8 || !html_skip_utf8_char(old, oldlen, &pos)) {
+ return pos;
+ }
+ }
+ }
+#endif
+
+ while (pos < oldlen && !(html_char_class[old[pos]] & stop)) {
+ pos++;
+ }
+ while (pos < oldlen && old[pos] >= 0x80 && charset == cs_utf_8 && html_skip_utf8_char(old, oldlen, &pos)) {
+ while (pos < oldlen && !(html_char_class[old[pos]] & stop)) {
+ pos++;
+ }
+ }
+
+ return pos;
+}
+
+/* Encodes old, whose first cursor bytes are known to be copied unchanged */
+static zend_string *escape_html_entities_from(const unsigned char *old, size_t oldlen, size_t cursor, int all, int flags, enum entity_charset charset, bool double_encode)
+{
+ size_t maxlen, len;
zend_string *replaced;
- enum entity_charset charset = determine_charset(hint_charset, quiet);
int doctype = flags & ENT_HTML_DOC_TYPE_MASK;
entity_table_opt entity_table;
const enc_to_uni *to_uni_table = NULL;
@@ -1171,14 +1301,6 @@ PHPAPI zend_string *php_escape_html_entities_ex(const unsigned char *old, size_t
const unsigned char *replacement = NULL;
size_t replacement_len = 0;
- if (all) { /* replace with all named entities */
- if (!quiet && CHARSET_PARTIAL_SUPPORT(charset)) {
- php_error_docref(NULL, E_NOTICE, "Only basic entities "
- "substitution is supported for multi-byte encodings other than UTF-8; "
- "functionality is equivalent to htmlspecialchars");
- }
- LIMIT_ALL(all, doctype, charset);
- }
entity_table = determine_entity_table(all, doctype);
if (all && !CHARSET_UNICODE_COMPAT(charset)) {
to_uni_table = enc_to_uni_index[charset];
@@ -1208,8 +1330,8 @@ PHPAPI zend_string *php_escape_html_entities_ex(const unsigned char *old, size_t
}
replaced = zend_string_alloc(maxlen, 0);
- len = 0;
- cursor = 0;
+ memcpy(ZSTR_VAL(replaced), old, cursor);
+ len = cursor;
while (cursor < oldlen) {
const unsigned char *mbsequence = NULL;
size_t mbseqlen = 0,
@@ -1368,6 +1490,37 @@ PHPAPI zend_string *php_escape_html_entities_ex(const unsigned char *old, size_t
return replaced;
}
+
+/* {{{ php_escape_html_entities */
+static zend_always_inline zend_string *escape_html_entities(const unsigned char *old, size_t oldlen, zend_string *old_str, int all, int flags, const char *hint_charset, bool double_encode, bool quiet)
+{
+ enum entity_charset charset = determine_charset(hint_charset, quiet);
+ size_t verbatim_len = 0;
+
+ if (all) { /* replace with all named entities */
+ if (!quiet && CHARSET_PARTIAL_SUPPORT(charset)) {
+ php_error_docref(NULL, E_NOTICE, "Only basic entities "
+ "substitution is supported for multi-byte encodings other than UTF-8; "
+ "functionality is equivalent to htmlspecialchars");
+ }
+ LIMIT_ALL(all, flags & ENT_HTML_DOC_TYPE_MASK, charset);
+ }
+
+ if (!all && !(flags & ENT_HTML_SUBSTITUTE_DISALLOWED_CHARS)) {
+ verbatim_len = html_verbatim_prefix_len(old, oldlen, flags, charset);
+ if (verbatim_len == oldlen) {
+ /* nothing to encode */
+ return old_str ? zend_string_copy(old_str) : zend_string_init((const char *) old, oldlen, 0);
+ }
+ }
+
+ return escape_html_entities_from(old, oldlen, verbatim_len, all, flags, charset, double_encode);
+}
+
+PHPAPI zend_string *php_escape_html_entities_ex(const unsigned char *old, size_t oldlen, int all, int flags, const char *hint_charset, bool double_encode, bool quiet)
+{
+ return escape_html_entities(old, oldlen, NULL, all, flags, hint_charset, double_encode, quiet);
+}
/* }}} */
/* {{{ php_html_entities */
@@ -1389,8 +1542,8 @@ static void php_html_entities(INTERNAL_FUNCTION_PARAMETERS, int all)
if (ZSTR_LEN(str) == 0) {
RETURN_EMPTY_STRING();
}
- replaced = php_escape_html_entities_ex(
- (unsigned char*)ZSTR_VAL(str), ZSTR_LEN(str), all, (int) flags,
+ replaced = escape_html_entities(
+ (unsigned char*)ZSTR_VAL(str), ZSTR_LEN(str), str, all, (int) flags,
hint_charset ? ZSTR_VAL(hint_charset) : NULL, double_encode, /* quiet */ 0);
RETVAL_STR(replaced);
}
diff --git a/ext/standard/tests/strings/htmlspecialchars_unchanged.phpt b/ext/standard/tests/strings/htmlspecialchars_unchanged.phpt
new file mode 100644
index 00000000000..383235dc31a
--- /dev/null
+++ b/ext/standard/tests/strings/htmlspecialchars_unchanged.phpt
@@ -0,0 +1,52 @@
+--TEST--
+htmlspecialchars() returns the input string when there is nothing to encode
+--FILE--
+<?php
+$s = str_repeat('abc', 10);
+$s[0] = 'x'; // not interned
+$r = htmlspecialchars($s);
+debug_zval_dump($s);
+$r = htmlspecialchars("caf\u{E9}");
+debug_zval_dump($r);
+$s = str_repeat('"it\'s" ', 4); // quotes that are not encoded
+$s[0] = '"'; // not interned
+$r = htmlspecialchars($s, ENT_NOQUOTES);
+debug_zval_dump($s);
+
+// encoding starts after the part that is copied unchanged
+foreach ([0, 1, 15, 16, 17, 31, 32, 33] as $n) {
+ $p = str_repeat('a', $n);
+ echo htmlspecialchars("$p<", ENT_QUOTES), ' ', htmlspecialchars("$p>'", ENT_QUOTES), ' ', htmlspecialchars("$p\"'", ENT_NOQUOTES), ' ', htmlspecialchars("$p\"'", ENT_COMPAT), "\n";
+}
+
+// invalid UTF-8 after valid multi-byte characters
+$p = str_repeat("\u{E9}\u{20AC}\u{1F600}", 3);
+var_dump(htmlspecialchars("$p\xED\xA0\x80<", ENT_QUOTES));
+var_dump(htmlspecialchars("$p\xED\xA0\x80<", ENT_QUOTES | ENT_IGNORE));
+var_dump(htmlspecialchars("$p\xF4\x90\x80\x80<", ENT_QUOTES | ENT_SUBSTITUTE));
+
+// bytes above 0x7F in other charsets
+var_dump(bin2hex(htmlspecialchars(str_repeat("\xE9", 20) . '<', ENT_QUOTES, 'ISO-8859-1')));
+var_dump(htmlspecialchars(str_repeat('a', 20) . "\x81<", ENT_QUOTES | ENT_SUBSTITUTE, 'Shift_JIS'));
+
+// double_encode = false
+var_dump(htmlspecialchars(str_repeat('a', 20) . '&&foo '', ENT_QUOTES, 'UTF-8', false));
+?>
+--EXPECT--
+string(30) "xbcabcabcabcabcabcabcabcabcabc" refcount(3)
+string(5) "café" interned
+string(28) ""it's" "it's" "it's" "it's" " refcount(3)
+< >' "' "'
+a< a>' a"' a"'
+aaaaaaaaaaaaaaa< aaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaa"'
+aaaaaaaaaaaaaaaa< aaaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaaa"'
+aaaaaaaaaaaaaaaaa< aaaaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaaaa"'
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa< aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"'
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa< aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"'
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa< aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"'
+string(0) ""
+string(31) "é€ðŸ˜€é€ðŸ˜€é€ðŸ˜€<"
+string(34) "é€ðŸ˜€é€ðŸ˜€é€ðŸ˜€ï¿½<"
+string(48) "e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9e9266c743b"
+string(32) "aaaaaaaaaaaaaaaaaaaa�<"
+string(39) "aaaaaaaaaaaaaaaaaaaa&&foo '"