Commit f985f774c6c for php.net

commit f985f774c6c3c84bc98ec71d85b2e818d2f5019a
Author: David Carlier <devnexen@gmail.com>
Date:   Mon Aug 3 11:54:37 2026 +0100

    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

diff --git a/NEWS b/NEWS
index 9e4651c305a..186d6eb5fe6 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 9ac2c2e6f2c..b988847c8f5 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 51a9c7d3907..f3de2fcfcf1 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 8704065e479..cc4274d8bfe 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 1e8e9ac2a78..d80ada93660 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 d2feaa77a32..7b1dc377883 100644
--- a/ext/intl/tests/spoofchecker_self_references.phpt
+++ b/ext/intl/tests/spoofchecker_self_references.phpt
@@ -11,6 +11,11 @@
 $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 5508497072d..5d1fa4554ce 100644
--- a/ext/intl/tests/spoofchecker_typed_references.phpt
+++ b/ext/intl/tests/spoofchecker_typed_references.phpt
@@ -23,6 +23,20 @@ class Test {
 $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 00000000000..6e1f7b35fc6
--- /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)