From c64b8f70525303afdcce08da426033f216e442f4 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Tue, 4 Aug 2026 20:54:10 +0100 Subject: [PATCH] ext/intl: Add Spoofchecker::getSkeleton(). Also refactors getBidiSkeleton() onto the shared skeleton helper. --- UPGRADING | 3 + ext/intl/spoofchecker/spoofchecker.stub.php | 2 + ext/intl/spoofchecker/spoofchecker_arginfo.h | 8 +- ext/intl/spoofchecker/spoofchecker_main.cpp | 98 +++++++++++++------- ext/intl/tests/spoofchecker_skeleton.phpt | 76 +++++++++++++++ 5 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 ext/intl/tests/spoofchecker_skeleton.phpt 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 +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-- +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)