Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
48 changes: 46 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,28 @@ 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. */
if (UNEXPECTED(EG(transitive_compare_mode)) && (ret1 != 0) != (ret2 != 0)) {
/* 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
88 changes: 88 additions & 0 deletions ext/standard/tests/array/sort_regular_transitive.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
--TEST--
SORT_REGULAR uses transitive comparison for consistent sorting
--FILE--
<?php
echo "Test 1: Scalar mixed numeric/non-numeric strings\n";
$arr1 = ["5", "10", "3A"];
sort($arr1, SORT_REGULAR);
var_dump($arr1);

echo "\nTest 2: Same values, different order (should match)\n";
$arr2 = ["3A", "10", "5"];
sort($arr2, SORT_REGULAR);
var_dump($arr2);

echo "\nTest 3: Nested arrays with mixed strings\n";
$arr3 = [
['streetNumber' => '5'],
['streetNumber' => '10'],
['streetNumber' => '3A'],
];
sort($arr3, SORT_REGULAR);
foreach ($arr3 as $item) {
echo $item['streetNumber'];
if ($item !== end($arr3)) echo " ";
}
echo "\n";

echo "\nTest 4: Same nested arrays, reversed (should match)\n";
$arr4 = [
['streetNumber' => '3A'],
['streetNumber' => '10'],
['streetNumber' => '5'],
];
sort($arr4, SORT_REGULAR);
foreach ($arr4 as $item) {
echo $item['streetNumber'];
if ($item !== end($arr4)) echo " ";
}
echo "\n";

echo "\nTest 5: array_unique with nested arrays\n";
$arr5 = [
['number' => '5'],
['number' => '10'],
['number' => '5'],
['number' => '3A'],
['number' => '5'],
];
$unique = array_unique($arr5, SORT_REGULAR);
echo "Unique count: " . count($unique) . "\n";

echo "\nTest 6: Comparison operators remain unchanged\n";
echo '"5" <=> "3A": ' . ("5" <=> "3A") . "\n";
echo '"10" <=> "3A": ' . ("10" <=> "3A") . "\n";
?>
--EXPECT--
Test 1: Scalar mixed numeric/non-numeric strings
array(3) {
[0]=>
string(1) "5"
[1]=>
string(2) "10"
[2]=>
string(2) "3A"
}

Test 2: Same values, different order (should match)
array(3) {
[0]=>
string(1) "5"
[1]=>
string(2) "10"
[2]=>
string(2) "3A"
}

Test 3: Nested arrays with mixed strings
5 10 3A

Test 4: Same nested arrays, reversed (should match)
5 10 3A

Test 5: array_unique with nested arrays
Unique count: 3

Test 6: Comparison operators remain unchanged
"5" <=> "3A": 1
"10" <=> "3A": -1
81 changes: 81 additions & 0 deletions ext/standard/tests/array/sort_regular_transitive_objects.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
--TEST--
SORT_REGULAR with objects containing mixed numeric/non-numeric strings is transitive
--FILE--
<?php
class Address {
public function __construct(
public string $streetNumber,
public string $streetName
) {}
}

echo "Test 1: Objects with mixed numeric/non-numeric string properties\n";
$addresses = [
new Address('5', 'Main St'),
new Address('10', 'Main St'),
new Address('3A', 'Main St'),
];
sort($addresses, SORT_REGULAR);
echo "Result:";
foreach ($addresses as $addr) {
echo " " . $addr->streetNumber;
}
echo "\n";

echo "\nTest 2: Same objects, different order (should match Test 1)\n";
$addresses2 = [
new Address('3A', 'Main St'),
new Address('10', 'Main St'),
new Address('5', 'Main St'),
];
sort($addresses2, SORT_REGULAR);
echo "Result:";
foreach ($addresses2 as $addr) {
echo " " . $addr->streetNumber;
}
echo "\n";

echo "\nTest 3: Another permutation (should match Test 1)\n";
$addresses3 = [
new Address('10', 'Main St'),
new Address('5', 'Main St'),
new Address('3A', 'Main St'),
];
sort($addresses3, SORT_REGULAR);
echo "Result:";
foreach ($addresses3 as $addr) {
echo " " . $addr->streetNumber;
}
echo "\n";

echo "\nTest 4: array_unique() with objects\n";
$addresses_with_dupes = [
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_with_dupes, SORT_REGULAR);
echo "Input: 5 objects (with duplicates)\n";
echo "Output: " . count($unique) . " unique objects\n";
echo "Street numbers:";
foreach ($unique as $addr) {
echo " " . $addr->streetNumber;
}
echo "\n";
?>
--EXPECT--
Test 1: Objects with mixed numeric/non-numeric string properties
Result: 5 10 3A

Test 2: Same objects, different order (should match Test 1)
Result: 5 10 3A

Test 3: Another permutation (should match Test 1)
Result: 5 10 3A

Test 4: array_unique() with objects
Input: 5 objects (with duplicates)
Output: 3 unique objects
Street numbers: 5 10 3A