[php-src] master: ext/intl: introduce SpoofChecker::areBidiConfusable.

David Carlier <[email protected]> Mon, 3 Aug 2026 19:43:41 +0000
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: David Carlier (devnexen)
Date: 2026-08-03T20:43:27+01:00

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

ext/intl: introduce SpoofChecker::areBidiConfusable.

Adding a new more refined spoofchecker method in addition of the existing
Spoofchecker::areConfusable which takes in account the text direction
left to right and right to left, along with the Spoofchecker::LTR and
Spoofchecker::RTL constants.

The self and typed references tests are extended accordingly, guarded at
runtime rather than through a SKIPIF, so that the older ICU releases keep
their coverage.

Close GH-13469

Changed paths:
  A  ext/intl/tests/spoofchecker_ubidi.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
  M  ext/intl/tests/spoofchecker_self_references.phpt
  M  ext/intl/tests/spoofchecker_typed_references.phpt


Diff:

diff --git a/NEWS b/NEWS
index 9e4651c305af..186d6eb5fe63 100644
--- a/NEWS
+++ b/NEWS
@@ -20,6 +20,7 @@ PHP                                                                        NEWS
     after successful calls. (Weilin Du)
   . Implemented GH-20255 (Add a predefined calendar constant in
     IntlDateFormatter for the proleptic gregorian calendar). (David Carlier)
+  . Added SpoofChecker::areBidiConfusable(). (David Carlier)
 
 - Reflection:
   . Added ReflectionAttribute::inNamespace(),
diff --git a/UPGRADING b/UPGRADING
index 9ac2c2e6f2cb..b988847c8f53 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -328,6 +328,10 @@ PHP 8.6 UPGRADE NOTES
     IntlNumberRangeFormatter::IDENTITY_FALLBACK_APPROXIMATELY and
     IntlNumberRangeFormatter::IDENTITY_FALLBACK_RANGE identity fallbacks.
     It is supported from icu 63.
+  . Added SpoofChecker::areBidiConfusable() to check whether two strings are
+    confusable for a given text direction, along with the SpoofChecker::LTR
+    and SpoofChecker::RTL direction constants.
+    It is supported from icu 74.
 
 - IO:
   . Added new polling API.
@@ -483,6 +487,7 @@ PHP 8.6 UPGRADE NOTES
     RFC: https://wiki.php.net/rfc/grapheme_strrev
   . Locale::getDisplayKeyword() and Locale::getDisplayKeywordValue()
     RFC: https://wiki.php.net/rfc/getdisplaykeyword_and_getdisplaykeywordvalue
+  . SpoofChecker::areBidiConfusable()
 
 - 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 51a9c7d39074..f3de2fcfcf10 100644
--- a/ext/intl/spoofchecker/spoofchecker.stub.php
+++ b/ext/intl/spoofchecker/spoofchecker.stub.php
@@ -49,6 +49,13 @@ class Spoofchecker
     public const int SIMPLE_CASE_INSENSITIVE = UNKNOWN;
 #endif
 
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+    /** @cvalue UBIDI_LTR */
+    public const int LTR = UNKNOWN;
+    /** @cvalue UBIDI_RTL */
+    public const int RTL = UNKNOWN;
+#endif
+
     public function __construct() {}
 
     /**
@@ -72,4 +79,11 @@ public function setChecks(int $checks): void {}
     /** @tentative-return-type */
     public function setRestrictionLevel(int $level): void {}
     public function setAllowedChars(string $pattern, int $patternOptions = 0): void {}
+
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+    /**
+     * @param int $errorCode
+     */
+    public function areBidiConfusable(int $direction, string $string1, string $string2, &$errorCode = null): bool {}
+#endif
 }
diff --git a/ext/intl/spoofchecker/spoofchecker_arginfo.h b/ext/intl/spoofchecker/spoofchecker_arginfo.h
index 8704065e479b..cc4274d8bfe6 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: 4634f8ef9157fb3670a2ddc5e3246340660fc68c */
+ * Stub hash: 528a85d28312688d9ba2c78ede2f07356bbff0f6 */
 
 ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Spoofchecker___construct, 0, 0, 0)
 ZEND_END_ARG_INFO()
@@ -32,6 +32,15 @@ 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()
 
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Spoofchecker_areBidiConfusable, 0, 3, _IS_BOOL, 0)
+	ZEND_ARG_TYPE_INFO(0, direction, IS_LONG, 0)
+	ZEND_ARG_TYPE_INFO(0, string1, IS_STRING, 0)
+	ZEND_ARG_TYPE_INFO(0, string2, IS_STRING, 0)
+	ZEND_ARG_INFO_WITH_DEFAULT_VALUE(1, errorCode, "null")
+ZEND_END_ARG_INFO()
+#endif
+
 ZEND_METHOD(Spoofchecker, __construct);
 ZEND_METHOD(Spoofchecker, isSuspicious);
 ZEND_METHOD(Spoofchecker, areConfusable);
@@ -39,6 +48,9 @@ ZEND_METHOD(Spoofchecker, setAllowedLocales);
 ZEND_METHOD(Spoofchecker, setChecks);
 ZEND_METHOD(Spoofchecker, setRestrictionLevel);
 ZEND_METHOD(Spoofchecker, setAllowedChars);
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+ZEND_METHOD(Spoofchecker, areBidiConfusable);
+#endif
 
 static const zend_function_entry class_Spoofchecker_methods[] = {
 	ZEND_ME(Spoofchecker, __construct, arginfo_class_Spoofchecker___construct, ZEND_ACC_PUBLIC)
@@ -48,6 +60,9 @@ 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)
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+	ZEND_ME(Spoofchecker, areBidiConfusable, arginfo_class_Spoofchecker_areBidiConfusable, ZEND_ACC_PUBLIC)
+#endif
 	ZEND_FE_END
 };
 
@@ -175,6 +190,20 @@ static zend_class_entry *register_class_Spoofchecker(void)
 	zend_declare_typed_class_constant(class_entry, const_SIMPLE_CASE_INSENSITIVE_name, &const_SIMPLE_CASE_INSENSITIVE_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG));
 	zend_string_release_ex(const_SIMPLE_CASE_INSENSITIVE_name, true);
 #endif
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+
+	zval const_LTR_value;
+	ZVAL_LONG(&const_LTR_value, UBIDI_LTR);
+	zend_string *const_LTR_name = zend_string_init_interned("LTR", sizeof("LTR") - 1, true);
+	zend_declare_typed_class_constant(class_entry, const_LTR_name, &const_LTR_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG));
+	zend_string_release_ex(const_LTR_name, true);
+
+	zval const_RTL_value;
+	ZVAL_LONG(&const_RTL_value, UBIDI_RTL);
+	zend_string *const_RTL_name = zend_string_init_interned("RTL", sizeof("RTL") - 1, true);
+	zend_declare_typed_class_constant(class_entry, const_RTL_name, &const_RTL_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG));
+	zend_string_release_ex(const_RTL_name, true);
+#endif
 
 	return class_entry;
 }
diff --git a/ext/intl/spoofchecker/spoofchecker_main.cpp b/ext/intl/spoofchecker/spoofchecker_main.cpp
index 1e8e9ac2a78d..d80ada93660f 100644
--- a/ext/intl/spoofchecker/spoofchecker_main.cpp
+++ b/ext/intl/spoofchecker/spoofchecker_main.cpp
@@ -223,3 +223,46 @@ U_CFUNC PHP_METHOD(Spoofchecker, setAllowedChars)
 		php_error_docref(NULL, E_WARNING, "(%d) %s", SPOOFCHECKER_ERROR_CODE(co), u_errorName(SPOOFCHECKER_ERROR_CODE(co)));
 	}
 }
+
+#if U_ICU_VERSION_MAJOR_NUM >= 74
+/* {{{ Checks if a given text contains any confusable characters, for a given text direction */
+U_CFUNC PHP_METHOD(Spoofchecker, areBidiConfusable)
+{
+	uint32_t ret = 0;
+	zend_long direction;
+	zend_string *s1, *s2;
+	zval *error_code = NULL;
+	SPOOFCHECKER_METHOD_INIT_VARS;
+
+	ZEND_PARSE_PARAMETERS_START(3, 4)
+		Z_PARAM_LONG(direction)
+		Z_PARAM_STR(s1)
+		Z_PARAM_STR(s2)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_ZVAL(error_code)
+	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(s1) > INT32_MAX || ZSTR_LEN(s2) > INT32_MAX)) {
+		SPOOFCHECKER_ERROR_CODE(co) = U_BUFFER_OVERFLOW_ERROR;
+	} else {
+		ret = uspoof_areBidiConfusableUTF8(co->uspoof, (UBiDiDirection)direction, ZSTR_VAL(s1), (int32_t)ZSTR_LEN(s1), ZSTR_VAL(s2), (int32_t)ZSTR_LEN(s2), SPOOFCHECKER_ERROR_CODE_P(co));
+	}
+	if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co))) {
+		php_error_docref(NULL, E_WARNING, "(%d) %s", SPOOFCHECKER_ERROR_CODE(co), u_errorName(SPOOFCHECKER_ERROR_CODE(co)));
+		RETURN_TRUE;
+	}
+
+	if (error_code) {
+		ZEND_TRY_ASSIGN_REF_LONG(error_code, ret);
+	}
+	RETVAL_BOOL(ret != 0);
+}
+/* }}} */
+#endif
diff --git a/ext/intl/tests/spoofchecker_self_references.phpt b/ext/intl/tests/spoofchecker_self_references.phpt
index d2feaa77a32f..7b1dc3778839 100644
--- a/ext/intl/tests/spoofchecker_self_references.phpt
+++ b/ext/intl/tests/spoofchecker_self_references.phpt
@@ -11,6 +11,11 @@ $checker->isSuspicious("", $checker);
 $checker = new Spoofchecker();
 $checker->areConfusable("", "", $checker);
 
+if (version_compare(INTL_ICU_VERSION, '74.0') >= 0) {
+    $checker = new Spoofchecker();
+    $checker->areBidiConfusable(Spoofchecker::LTR, "", "", $checker);
+}
+
 echo "Done\n";
 
 ?>
diff --git a/ext/intl/tests/spoofchecker_typed_references.phpt b/ext/intl/tests/spoofchecker_typed_references.phpt
index 5508497072da..5d1fa4554ce0 100644
--- a/ext/intl/tests/spoofchecker_typed_references.phpt
+++ b/ext/intl/tests/spoofchecker_typed_references.phpt
@@ -23,6 +23,20 @@ $checker = new Spoofchecker();
 $checker->areConfusable("", "", $test->x);
 var_dump($test);
 
+if (version_compare(INTL_ICU_VERSION, '74.0') >= 0) {
+    $test = new Test;
+    $test->x = "";
+
+    $checker = new Spoofchecker();
+    $checker->areBidiConfusable(Spoofchecker::LTR, "", "", $test->x);
+    /* Asserted quietly rather than dumped, so that the expected output stays
+       the same on ICU < 74, where the method does not exist. */
+    if ($test->x !== "1") {
+        echo "unexpected value: ";
+        var_dump($test->x);
+    }
+}
+
 ?>
 --EXPECT--
 object(Test)#1 (1) {
diff --git a/ext/intl/tests/spoofchecker_ubidi.phpt b/ext/intl/tests/spoofchecker_ubidi.phpt
new file mode 100644
index 000000000000..6e1f7b35fc64
--- /dev/null
+++ b/ext/intl/tests/spoofchecker_ubidi.phpt
@@ -0,0 +1,54 @@
+--TEST--
+Spoofchecker::areBidiConfusable() checks if strings are confusable in a given direction.
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php if (version_compare(INTL_ICU_VERSION, '74.0') < 0) die('skip for ICU >= 74.0'); ?>
+--FILE--
+<?php
+$s = new Spoofchecker();
+
+try {
+    $s->areBidiConfusable(Spoofchecker::RTL + 1, "a", "a");
+} catch (ValueError $e) {
+    echo $e->getMessage() . PHP_EOL;
+}
+
+/* "A1<aleph>" and "A<aleph>1" both display as "A1<aleph>" in a left to right
+ * context, but differ in a right to left one. */
+var_dump($s->areBidiConfusable(Spoofchecker::LTR, "A1\u{05D0}", "A\u{05D0}1"));
+var_dump($s->areBidiConfusable(Spoofchecker::RTL, "A1\u{05D0}", "A\u{05D0}1"));
+
+/* Mirror case: confusable in a right to left context only. */
+var_dump($s->areBidiConfusable(Spoofchecker::LTR, "\u{05D0}A_1", "\u{05D0}1_A"));
+var_dump($s->areBidiConfusable(Spoofchecker::RTL, "\u{05D0}A_1", "\u{05D0}1_A"));
+
+/* Neither direction reorders these into each other. */
+var_dump($s->areBidiConfusable(Spoofchecker::LTR, "Mark_", "_Mark"));
+var_dump($s->areBidiConfusable(Spoofchecker::RTL, "Mark_", "_Mark"));
+
+/* areConfusable() ignores the text direction and misses both cases above. */
+var_dump($s->areConfusable("A1\u{05D0}", "A\u{05D0}1"));
+var_dump($s->areConfusable("\u{05D0}A_1", "\u{05D0}1_A"));
+
+$errorCode = null;
+var_dump($s->areBidiConfusable(Spoofchecker::LTR, "A1\u{05D0}", "A\u{05D0}1", $errorCode));
+var_dump($errorCode === Spoofchecker::MIXED_SCRIPT_CONFUSABLE);
+
+var_dump($s->areBidiConfusable(Spoofchecker::LTR, "Mark_", "_Mark", $errorCode));
+var_dump($errorCode);
+?>
+--EXPECT--
+Spoofchecker::areBidiConfusable(): Argument #1 ($direction) must be either Spoofchecker::LTR or Spoofchecker::RTL
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+bool(false)
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(false)
+int(0)