[php-src] master: ext/intl: Add Spoofchecker::getSkeleton().

David Carlier <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: David Carlier (devnexen)
Date: 2026-08-04T21:28:19+01:00

Commit: https://github.com/php/php-src/commit/8ecc1a67f6bed1592926ff0e39ac9d28494de182
Raw diff: https://github.com/php/php-src/commit/8ecc1a67f6bed1592926ff0e39ac9d28494de182.diff

ext/intl: Add Spoofchecker::getSkeleton().

Also refactors getBidiSkeleton() onto the shared skeleton helper.

Close GH-23049

Changed paths:
  A  ext/intl/tests/spoofchecker_skeleton.phpt
  M  NEWS
  M  UPGRADING
  M  ext/intl/spoofchecker/spoofchecker.stub.php
  M  ext/intl/spoofchecker/spoofchecker_arginfo.h
  M  ext/intl/spoofchecker/spoofchecker_main.cpp


Diff:

diff --git a/NEWS b/NEWS
index 51ba7d5a208d..16a4ae4e99b8 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 9bbe6d42c804..9389c7b04b20 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 1ffc61faaaf5..160ad63128b8 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 380f27213558..236a292767ca 100644
--- a/ext/intl/spoofchecker/spoofchecker_arginfo.h
+++ b/ext/intl/spoofchecker/spoofchecker_arginfo.h
@@ -1,5 +1,5 @@
 /* This is a generated file, edit spoofchecker.stub.php instead.
- * Stub hash: cb99c1be997a0b11e23b6ae61c27fa2ecd6e403f */
+ * Stub hash: e701a18d8fd0c9bba50ee184e700263bffd882f8 */
 
 ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Spoofchecker___construct, 0, 0, 0)
 ZEND_END_ARG_INFO()
@@ -32,6 +32,10 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Spoofchecker_setAllowedCha
 	ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, patternOptions, IS_LONG, 0, "0")
 ZEND_END_ARG_INFO()
 
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_class_Spoofchecker_getSkeleton, 0, 1, MAY_BE_STRING|MAY_BE_FALSE)
+	ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
 #if U_ICU_VERSION_MAJOR_NUM >= 74
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_class_Spoofchecker_getBidiSkeleton, 0, 2, MAY_BE_STRING|MAY_BE_FALSE)
 	ZEND_ARG_TYPE_INFO(0, direction, IS_LONG, 0)
@@ -53,6 +57,7 @@ ZEND_METHOD(Spoofchecker, setAllowedLocales);
 ZEND_METHOD(Spoofchecker, setChecks);
 ZEND_METHOD(Spoofchecker, setRestrictionLevel);
 ZEND_METHOD(Spoofchecker, setAllowedChars);
+ZEND_METHOD(Spoofchecker, getSkeleton);
 #if U_ICU_VERSION_MAJOR_NUM >= 74
 ZEND_METHOD(Spoofchecker, getBidiSkeleton);
 ZEND_METHOD(Spoofchecker, areBidiConfusable);
@@ -66,6 +71,7 @@ static const zend_function_entry class_Spoofchecker_methods[] = {
 	ZEND_ME(Spoofchecker, setChecks, arginfo_class_Spoofchecker_setChecks, ZEND_ACC_PUBLIC)
 	ZEND_ME(Spoofchecker, setRestrictionLevel, arginfo_class_Spoofchecker_setRestrictionLevel, ZEND_ACC_PUBLIC)
 	ZEND_ME(Spoofchecker, setAllowedChars, arginfo_class_Spoofchecker_setAllowedChars, ZEND_ACC_PUBLIC)
+	ZEND_ME(Spoofchecker, getSkeleton, arginfo_class_Spoofchecker_getSkeleton, ZEND_ACC_PUBLIC)
 #if U_ICU_VERSION_MAJOR_NUM >= 74
 	ZEND_ME(Spoofchecker, getBidiSkeleton, arginfo_class_Spoofchecker_getBidiSkeleton, ZEND_ACC_PUBLIC)
 	ZEND_ME(Spoofchecker, areBidiConfusable, arginfo_class_Spoofchecker_areBidiConfusable, ZEND_ACC_PUBLIC)
diff --git a/ext/intl/spoofchecker/spoofchecker_main.cpp b/ext/intl/spoofchecker/spoofchecker_main.cpp
index bb1dfe6bfc76..d7e1b4e0553d 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 000000000000..41fec1eab413
--- /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)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.