Commit 8ecc1a67f6b for php.net
commit 8ecc1a67f6bed1592926ff0e39ac9d28494de182
Author: David Carlier <devnexen@gmail.com>
Date: Tue Aug 4 20:54:10 2026 +0100
ext/intl: Add Spoofchecker::getSkeleton().
Also refactors getBidiSkeleton() onto the shared skeleton helper.
Close GH-23049
diff --git a/NEWS b/NEWS
index 51ba7d5a208..16a4ae4e99b 100644
--- a/NEWS
+++ b/NEWS
@@ -22,6 +22,7 @@ PHP NEWS
IntlDateFormatter for the proleptic gregorian calendar). (David Carlier)
. Added SpoofChecker::areBidiConfusable(). (David Carlier)
. Added SpoofChecker::getBidiSkeleton(). (Weilin Du)
+ . Added SpoofChecker::getSkeleton(). (David Carlier)
- PDO_ODBC:
. Fixed bug GH-23016 (NULL values in long columns come back as garbage
diff --git a/UPGRADING b/UPGRADING
index 9bbe6d42c80..9389c7b04b2 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -334,6 +334,8 @@ PHP 8.6 UPGRADE NOTES
It is supported from icu 74.
. Added SpoofChecker::getBidiSkeleton() to generate a confusable skeleton for
a given text direction. It is supported from icu 74.
+ . Added SpoofChecker::getSkeleton() to generate a confusable skeleton for a
+ given string.
- IO:
. Added new polling API.
@@ -491,6 +493,7 @@ PHP 8.6 UPGRADE NOTES
RFC: https://wiki.php.net/rfc/getdisplaykeyword_and_getdisplaykeywordvalue
. SpoofChecker::areBidiConfusable()
. SpoofChecker::getBidiSkeleton()
+ . SpoofChecker::getSkeleton()
- mysqli:
. Added mysqli::quote_string() and mysqli_quote_string().
diff --git a/ext/intl/spoofchecker/spoofchecker.stub.php b/ext/intl/spoofchecker/spoofchecker.stub.php
index 1ffc61faaaf..160ad63128b 100644
--- a/ext/intl/spoofchecker/spoofchecker.stub.php
+++ b/ext/intl/spoofchecker/spoofchecker.stub.php
@@ -80,6 +80,8 @@ public function setChecks(int $checks): void {}
public function setRestrictionLevel(int $level): void {}
public function setAllowedChars(string $pattern, int $patternOptions = 0): void {}
+ public function getSkeleton(string $string): string|false {}
+
#if U_ICU_VERSION_MAJOR_NUM >= 74
public function getBidiSkeleton(int $direction, string $string): string|false {}
diff --git a/ext/intl/spoofchecker/spoofchecker_arginfo.h b/ext/intl/spoofchecker/spoofchecker_arginfo.h
index 380f2721355..236a292767c 100644
Binary files a/ext/intl/spoofchecker/spoofchecker_arginfo.h and b/ext/intl/spoofchecker/spoofchecker_arginfo.h differ
diff --git a/ext/intl/spoofchecker/spoofchecker_main.cpp b/ext/intl/spoofchecker/spoofchecker_main.cpp
index bb1dfe6bfc7..d7e1b4e0553 100644
--- a/ext/intl/spoofchecker/spoofchecker_main.cpp
+++ b/ext/intl/spoofchecker/spoofchecker_main.cpp
@@ -224,67 +224,103 @@ U_CFUNC PHP_METHOD(Spoofchecker, setAllowedChars)
}
}
-#if U_ICU_VERSION_MAJOR_NUM >= 74
-/* {{{ Get the confusable skeleton for an identifier in a given text direction */
-U_CFUNC PHP_METHOD(Spoofchecker, getBidiSkeleton)
+/* {{{ Runs an ICU skeleton generator over a UTF-8 string, preflighting the result buffer */
+template <typename F>
+static zend_string *spoofchecker_skeleton(Spoofchecker_object *co, zend_string *string, F&& skeletonfn)
{
- zend_long direction;
- zend_string *string;
- SPOOFCHECKER_METHOD_INIT_VARS;
-
- ZEND_PARSE_PARAMETERS_START(2, 2)
- Z_PARAM_LONG(direction)
- Z_PARAM_STR(string)
- ZEND_PARSE_PARAMETERS_END();
-
- SPOOFCHECKER_METHOD_FETCH_OBJECT;
-
- if (direction != UBIDI_LTR && direction != UBIDI_RTL) {
- zend_argument_value_error(1, "must be either Spoofchecker::LTR or Spoofchecker::RTL");
- RETURN_THROWS();
- }
-
if (UNEXPECTED(ZSTR_LEN(string) > INT32_MAX)) {
SPOOFCHECKER_ERROR_CODE(co) = U_BUFFER_OVERFLOW_ERROR;
intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
"Failed to convert input string to UTF-16");
- RETURN_FALSE;
+ return nullptr;
}
- int32_t utf16_len;
- u_strFromUTF8(nullptr, 0, &utf16_len, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
+ u_strFromUTF8(nullptr, 0, nullptr, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
SPOOFCHECKER_ERROR_CODE_P(co));
if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co)) && SPOOFCHECKER_ERROR_CODE(co) != U_BUFFER_OVERFLOW_ERROR) {
intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
"Failed to convert input string to UTF-16");
- RETURN_FALSE;
+ return nullptr;
}
SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
- int32_t result_len = uspoof_getBidiSkeletonUTF8(
- co->uspoof, (UBiDiDirection) direction, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
- nullptr, 0, SPOOFCHECKER_ERROR_CODE_P(co));
+ int32_t result_len = skeletonfn(nullptr, 0, SPOOFCHECKER_ERROR_CODE_P(co));
if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co)) && SPOOFCHECKER_ERROR_CODE(co) != U_BUFFER_OVERFLOW_ERROR) {
intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
"Failed to generate skeleton");
- RETURN_FALSE;
+ return nullptr;
}
zend_string *result = zend_string_alloc(result_len, false);
int32_t result_capacity = result_len < INT32_MAX ? result_len + 1 : result_len;
SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
- result_len = uspoof_getBidiSkeletonUTF8(
- co->uspoof, (UBiDiDirection) direction, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
- ZSTR_VAL(result), result_capacity, SPOOFCHECKER_ERROR_CODE_P(co));
+ result_len = skeletonfn(ZSTR_VAL(result), result_capacity, SPOOFCHECKER_ERROR_CODE_P(co));
if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co))) {
zend_string_release(result);
intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
"Failed to generate skeleton");
- RETURN_FALSE;
+ return nullptr;
}
SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
ZSTR_LEN(result) = result_len;
ZSTR_VAL(result)[result_len] = '\0';
+ return result;
+}
+/* }}} */
+
+/* {{{ Get the confusable skeleton for an identifier */
+U_CFUNC PHP_METHOD(Spoofchecker, getSkeleton)
+{
+ zend_string *string;
+ SPOOFCHECKER_METHOD_INIT_VARS;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(string)
+ ZEND_PARSE_PARAMETERS_END();
+
+ SPOOFCHECKER_METHOD_FETCH_OBJECT;
+
+ zend_string *result = spoofchecker_skeleton(co, string,
+ [&](char *dest, int32_t capacity, UErrorCode *status) {
+ /* The type parameter is deprecated since ICU 58 and must be 0. */
+ return uspoof_getSkeletonUTF8(co->uspoof, 0, ZSTR_VAL(string),
+ (int32_t) ZSTR_LEN(string), dest, capacity, status);
+ });
+ if (result == nullptr) {
+ RETURN_FALSE;
+ }
+ RETURN_STR(result);
+}
+/* }}} */
+
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+/* {{{ Get the confusable skeleton for an identifier in a given text direction */
+U_CFUNC PHP_METHOD(Spoofchecker, getBidiSkeleton)
+{
+ zend_long direction;
+ zend_string *string;
+ SPOOFCHECKER_METHOD_INIT_VARS;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_LONG(direction)
+ Z_PARAM_STR(string)
+ ZEND_PARSE_PARAMETERS_END();
+
+ SPOOFCHECKER_METHOD_FETCH_OBJECT;
+
+ if (direction != UBIDI_LTR && direction != UBIDI_RTL) {
+ zend_argument_value_error(1, "must be either Spoofchecker::LTR or Spoofchecker::RTL");
+ RETURN_THROWS();
+ }
+
+ zend_string *result = spoofchecker_skeleton(co, string,
+ [&](char *dest, int32_t capacity, UErrorCode *status) {
+ return uspoof_getBidiSkeletonUTF8(co->uspoof, (UBiDiDirection) direction,
+ ZSTR_VAL(string), (int32_t) ZSTR_LEN(string), dest, capacity, status);
+ });
+ if (result == nullptr) {
+ RETURN_FALSE;
+ }
RETURN_STR(result);
}
/* }}} */
diff --git a/ext/intl/tests/spoofchecker_skeleton.phpt b/ext/intl/tests/spoofchecker_skeleton.phpt
new file mode 100644
index 00000000000..41fec1eab41
--- /dev/null
+++ b/ext/intl/tests/spoofchecker_skeleton.phpt
@@ -0,0 +1,76 @@
+--TEST--
+Spoofchecker::getSkeleton()
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+$checker = new Spoofchecker();
+
+var_dump($checker->getSkeleton(""));
+var_dump($checker->getSkeleton("abc"));
+
+try {
+ $checker->getSkeleton();
+} catch (ArgumentCountError $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+/* Cyrillic es is confusable with Latin c, so both share a skeleton. */
+$latin = "c";
+$cyrillic = "\u{0441}";
+var_dump($checker->getSkeleton($latin) === $checker->getSkeleton($cyrillic));
+var_dump($checker->areConfusable($latin, $cyrillic));
+
+/* Unrelated identifiers must not collapse onto the same skeleton. */
+var_dump($checker->getSkeleton("abc") === $checker->getSkeleton("xyz"));
+
+/* A skeleton is its own skeleton. */
+var_dump($checker->getSkeleton($checker->getSkeleton($cyrillic)) === $checker->getSkeleton($cyrillic));
+
+/* The mapping may expand, which exercises the preflighted result buffer. */
+var_dump($checker->getSkeleton("\u{FB01}"));
+
+/* Skeletons are binary safe. */
+var_dump(bin2hex($checker->getSkeleton("a\0b")));
+
+/* The skeleton is derived from the confusable data only, never from the
+ checker configuration. */
+$configured = new Spoofchecker();
+$configured->setChecks(Spoofchecker::SINGLE_SCRIPT);
+$configured->setRestrictionLevel(Spoofchecker::ASCII);
+$configured->setAllowedChars("[a-z]");
+var_dump($configured->getSkeleton($cyrillic) === $checker->getSkeleton($cyrillic));
+
+/* Ill-formed UTF-8 is rejected instead of being substituted. */
+foreach (["\x80", "\xC3", "\xE2\x82", "\xED\xA0\x80", "abc\xFF"] as $malformed) {
+ var_dump($checker->getSkeleton($malformed));
+ var_dump(intl_get_error_code() === U_INVALID_CHAR_FOUND);
+}
+
+/* The error state does not leak into the next call. */
+var_dump($checker->getSkeleton("abc"));
+var_dump(intl_get_error_code() === U_ZERO_ERROR);
+?>
+--EXPECT--
+string(0) ""
+string(3) "abc"
+ArgumentCountError: Spoofchecker::getSkeleton() expects exactly 1 argument, 0 given
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+string(2) "fi"
+string(6) "610062"
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+string(3) "abc"
+bool(true)