Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Zend/zend_execute_API.c
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ void init_executor(void) /* {{{ */
EG(num_errors) = 0;
EG(errors) = NULL;

EG(transitive_compare_mode) = false;

EG(filename_override) = NULL;
EG(lineno_override) = -1;

Expand Down
4 changes: 4 additions & 0 deletions Zend/zend_globals.h
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ struct _zend_executor_globals {
uint32_t num_errors;
zend_error_info **errors;

/* If transitive_compare_mode is enabled, string comparisons in zendi_smart_strcmp
* will enforce transitivity by consistently ordering numeric vs non-numeric strings. */
bool transitive_compare_mode;

/* Override filename or line number of thrown errors and exceptions */
zend_string *filename_override;
zend_long lineno_override;
Expand Down
50 changes: 48 additions & 2 deletions Zend/zend_operators.c
Original file line number Diff line number Diff line change
Expand Up @@ -2271,6 +2271,18 @@ static int compare_long_to_string(zend_long lval, zend_string *str) /* {{{ */
return ZEND_THREEWAY_COMPARE((double) lval, str_dval);
}

/* String is non-numeric. In transitive mode, enforce consistent ordering.
* Empty string < numeric < non-numeric string.
* Since str is non-numeric, check if it's empty. */
if (UNEXPECTED(EG(transitive_compare_mode))) {
/* Empty string comes before everything */
if (ZSTR_LEN(str) == 0) {
return 1; /* lval > empty string */
}
/* Non-empty, non-numeric string comes after numbers */
return -1; /* lval < non-numeric string */
}

zend_string *lval_as_str = zend_long_to_str(lval);
int cmp_result = zend_binary_strcmp(
ZSTR_VAL(lval_as_str), ZSTR_LEN(lval_as_str), ZSTR_VAL(str), ZSTR_LEN(str));
Expand All @@ -2295,6 +2307,18 @@ static int compare_double_to_string(double dval, zend_string *str) /* {{{ */
return ZEND_THREEWAY_COMPARE(dval, str_dval);
}

/* String is non-numeric. In transitive mode, enforce consistent ordering.
* Empty string < numeric < non-numeric string.
* Since str is non-numeric, check if it's empty. */
if (UNEXPECTED(EG(transitive_compare_mode))) {
/* Empty string comes before everything */
if (ZSTR_LEN(str) == 0) {
return 1; /* dval > empty string */
}
/* Non-empty, non-numeric string comes after numbers */
return -1; /* dval < non-numeric string */
}

zend_string *dval_as_str = zend_double_to_str(dval);
int cmp_result = zend_binary_strcmp(
ZSTR_VAL(dval_as_str), ZSTR_LEN(dval_as_str), ZSTR_VAL(str), ZSTR_LEN(str));
Expand Down Expand Up @@ -3425,8 +3449,30 @@ ZEND_API int ZEND_FASTCALL zendi_smart_strcmp(zend_string *s1, zend_string *s2)
zend_long lval1 = 0, lval2 = 0;
double dval1 = 0.0, dval2 = 0.0;

if ((ret1 = is_numeric_string_ex(s1->val, s1->len, &lval1, &dval1, false, &oflow1, NULL)) &&
(ret2 = is_numeric_string_ex(s2->val, s2->len, &lval2, &dval2, false, &oflow2, NULL))) {
ret1 = is_numeric_string_ex(s1->val, s1->len, &lval1, &dval1, false, &oflow1, NULL);
ret2 = is_numeric_string_ex(s2->val, s2->len, &lval2, &dval2, false, &oflow2, NULL);

/* When in transitive comparison mode (used by SORT_REGULAR), enforce transitivity
* by consistently ordering numeric vs non-numeric strings. */
bool num1 = ret1 != 0;
bool num2 = ret2 != 0;
if (UNEXPECTED(EG(transitive_compare_mode)) && (num1 ^ num2)) {
/* One is numeric, one is not.
* Special case: empty strings are non-numeric but sort BEFORE numeric strings.
* Order: empty < numeric < non-numeric (matches PHP 8+ comparison semantics) */
bool is_empty1 = (s1->len == 0);
bool is_empty2 = (s2->len == 0);

if (is_empty1 || is_empty2) {
/* If one is empty, empty comes first */
return is_empty1 ? -1 : 1;
}

/* Neither is empty: numeric < non-numeric */
return ret1 ? -1 : 1;
}

if (ret1 && ret2) {
#if ZEND_ULONG_MAX == 0xFFFFFFFF
if (oflow1 != 0 && oflow1 == oflow2 && dval1 - dval2 == 0. &&
((oflow1 == 1 && dval1 > 9007199254740991. /*0x1FFFFFFFFFFFFF*/)
Expand Down
9 changes: 9 additions & 0 deletions ext/standard/array.c
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,16 @@ static zend_always_inline int php_array_key_compare_string_locale_unstable_i(Buc

static zend_always_inline int php_array_data_compare_unstable_i(Bucket *f, Bucket *s) /* {{{ */
{
/* Enable transitive comparison mode for this comparison tree.
* Save the previous state to handle reentrancy (e.g., usort with callback that calls sort). */
bool old_transitive_mode = EG(transitive_compare_mode);
EG(transitive_compare_mode) = true;

int result = zend_compare(&f->val, &s->val);

/* Restore previous state */
EG(transitive_compare_mode) = old_transitive_mode;

/* Special enums handling for array_unique. We don't want to add this logic to zend_compare as
* that would be observable via comparison operators. */
zval *rhs = &s->val;
Expand Down
93 changes: 93 additions & 0 deletions ext/standard/tests/array/gh20262.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
--TEST--
GH-20262 (array_unique() with SORT_REGULAR returns duplicate values)
--FILE--
<?php
echo "Test 1: Scalar array (original bug report)\n";
$units = ['5', '10', '5', '3A', '5', '5'];
$unique = array_unique($units, SORT_REGULAR);
print_r($unique);

echo "\nTest 2: Same array with SORT_STRING\n";
$unique = array_unique($units, SORT_STRING);
print_r($unique);

echo "\nTest 3: Objects\n";
class Address {
public function __construct(
public string $streetNumber,
public string $streetName
) {}
}

$addresses = [
new Address('5', 'Main St'),
new Address('10', 'Main St'),
new Address('10', 'Main St'),
new Address('3A', 'Main St'),
new Address('5', 'Main St'),
];

$unique = array_unique($addresses, SORT_REGULAR);
echo "Unique count: " . count($unique) . " (expected 3)\n";
echo "Street numbers:";
foreach ($unique as $addr) {
echo " " . $addr->streetNumber;
}
echo "\n";

echo "\nTest 4: Nested arrays\n";
$addresses = [
['streetNumber' => '5', 'streetName' => 'Main St'],
['streetNumber' => '10', 'streetName' => 'Main St'],
['streetNumber' => '10', 'streetName' => 'Main St'],
['streetNumber' => '3A', 'streetName' => 'Main St'],
['streetNumber' => '5', 'streetName' => 'Main St'],
];

$unique = array_unique($addresses, SORT_REGULAR);
echo "Unique count: " . count($unique) . " (expected 3)\n";
echo "Street numbers:";
foreach ($unique as $addr) {
echo " " . $addr['streetNumber'];
}
echo "\n";

echo "\nTest 5: sort() consistency with SORT_REGULAR\n";
$arr1 = ["5", "10", "3A"];
$arr2 = ["3A", "10", "5"];
sort($arr1, SORT_REGULAR);
sort($arr2, SORT_REGULAR);
echo "arr1 sorted: ['" . implode("', '", $arr1) . "']\n";
echo "arr2 sorted: ['" . implode("', '", $arr2) . "']\n";
echo "Results match: " . ($arr1 === $arr2 ? "yes" : "no") . "\n";

?>
--EXPECT--
Test 1: Scalar array (original bug report)
Array
(
[0] => 5
[1] => 10
[3] => 3A
)

Test 2: Same array with SORT_STRING
Array
(
[0] => 5
[1] => 10
[3] => 3A
)

Test 3: Objects
Unique count: 3 (expected 3)
Street numbers: 5 10 3A

Test 4: Nested arrays
Unique count: 3 (expected 3)
Street numbers: 5 10 3A

Test 5: sort() consistency with SORT_REGULAR
arr1 sorted: ['5', '10', '3A']
arr2 sorted: ['5', '10', '3A']
Results match: yes
Loading