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)