Commit 357fcf12f4e for php
commit 357fcf12f4e9a610745d1922c93ab795909e90a2
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Tue Aug 4 22:18:51 2026 -0400
ext/pdo: Make PDOStatement::setFetchMode() updates atomic
pdo_stmt_setup_fetch_mode() cleared the active fetch state before validating
the replacement, so a failed call lost FETCH_INTO objects and FETCH_CLASS
constructor arguments, and autoload re-entry could observe a mismatched
tagged union. Validate the candidate in local storage and replace the active
payload only after validation succeeds.
Closes GH-23188
diff --git a/NEWS b/NEWS
index 1e1f3f6c8fe..9aa7771c056 100644
--- a/NEWS
+++ b/NEWS
@@ -137,6 +137,8 @@ PHP NEWS
options value. (Ilia Alshanetsky)
. Fixed PDOStatement not reporting its bound parameters and columns to the
cycle collector. (Ilia Alshanetsky)
+ . Fixed PDOStatement::setFetchMode() not applying changes atomically.
+ (Ilia Alshanetsky)
- PDO_DBLIB:
. Fixed bug GH-23741 (segfault after a failed query inside a PDO
diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c
index 9afdc52a229..b5b74351419 100644
--- a/ext/pdo/pdo_stmt.c
+++ b/ext/pdo/pdo_stmt.c
@@ -1772,19 +1772,16 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
uint32_t arg1_arg_num = mode_arg_num + 1;
uint32_t constructor_arg_num = mode_arg_num + 2;
uint32_t total_num_args = mode_arg_num + variadic_num_args;
+ zend_long fetch_type = mode & ~PDO_FETCH_FLAGS;
+ zend_long fetch_column = 0;
+ zend_class_entry *fetch_class = NULL;
+ zend_array *fetch_ctor_args = NULL;
+ zend_object *fetch_into = NULL;
+ zval old_ctor_args;
+ zval old_into;
- switch (stmt->default_fetch_type) {
- case PDO_FETCH_INTO:
- if (!Z_ISUNDEF(stmt->fetch.into)) {
- zval_ptr_dtor(&stmt->fetch.into);
- ZVAL_UNDEF(&stmt->fetch.into);
- }
- break;
- default:
- ;
- }
-
- stmt->default_fetch_type = stmt->dbh->default_fetch_type;
+ ZVAL_UNDEF(&old_ctor_args);
+ ZVAL_UNDEF(&old_into);
flags = mode & PDO_FETCH_FLAGS;
@@ -1792,9 +1789,7 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
return false;
}
- bool use_default = (mode & ~PDO_FETCH_FLAGS) == PDO_FETCH_USE_DEFAULT;
-
- switch (mode & ~PDO_FETCH_FLAGS) {
+ switch (fetch_type) {
case PDO_FETCH_USE_DEFAULT:
case PDO_FETCH_LAZY:
case PDO_FETCH_ASSOC:
@@ -1829,13 +1824,11 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
zend_argument_value_error(arg1_arg_num, "must be greater than or equal to 0");
return false;
}
- stmt->fetch.column = Z_LVAL(args[0]);
+ fetch_column = Z_LVAL(args[0]);
break;
case PDO_FETCH_CLASS: {
HashTable *constructor_args = NULL;
- /* Undef constructor arguments */
- ZVAL_UNDEF(&stmt->fetch.cls.ctor_args);
/* Gets its class name from 1st column */
if ((flags & PDO_FETCH_CLASSTYPE) == PDO_FETCH_CLASSTYPE) {
if (variadic_num_args != 0) {
@@ -1845,7 +1838,6 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
zend_string_release(func);
return false;
}
- stmt->fetch.cls.ce = NULL;
} else {
zend_class_entry *cep;
if (variadic_num_args == 0) {
@@ -1884,15 +1876,17 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
constructor_args = Z_ARRVAL(args[1]);
}
}
- stmt->fetch.cls.ce = cep;
+ fetch_class = cep;
/* If constructor arguments are present and not empty */
if (constructor_args) {
- ZVAL_ARR(&stmt->fetch.cls.ctor_args, zend_array_dup(constructor_args));
+ if (!cep->constructor) {
+ zend_throw_error(NULL, "User-supplied statement does not accept constructor arguments");
+ return false;
+ }
+ fetch_ctor_args = zend_array_dup(constructor_args);
}
}
-
- do_fetch_class_prepare(stmt);
break;
}
case PDO_FETCH_INTO:
@@ -1908,17 +1902,57 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a
return false;
}
- ZVAL_COPY(&stmt->fetch.into, &args[0]);
+ fetch_into = Z_OBJ(args[0]);
break;
default:
zend_argument_value_error(mode_arg_num, "must be one of the PDO::FETCH_* constants");
return false;
}
- if (!use_default) {
+ if ((stmt->default_fetch_type & ~PDO_FETCH_FLAGS) == PDO_FETCH_INTO) {
+ ZVAL_COPY_VALUE(&old_into, &stmt->fetch.into);
+ ZVAL_UNDEF(&stmt->fetch.into);
+ } else if ((stmt->default_fetch_type & ~PDO_FETCH_FLAGS) == PDO_FETCH_CLASS) {
+ do_fetch_opt_finish(stmt, 0);
+ ZVAL_COPY_VALUE(&old_ctor_args, &stmt->fetch.cls.ctor_args);
+ ZVAL_UNDEF(&stmt->fetch.cls.ctor_args);
+ } else {
+ do_fetch_opt_finish(stmt, 1);
+ }
+
+ switch (fetch_type) {
+ case PDO_FETCH_COLUMN:
+ stmt->fetch.column = fetch_column;
+ break;
+ case PDO_FETCH_CLASS:
+ stmt->fetch.cls.ce = fetch_class;
+ if (fetch_ctor_args) {
+ ZVAL_ARR(&stmt->fetch.cls.ctor_args, fetch_ctor_args);
+ } else {
+ ZVAL_UNDEF(&stmt->fetch.cls.ctor_args);
+ }
+ do_fetch_class_prepare(stmt);
+ break;
+ case PDO_FETCH_INTO:
+ ZVAL_OBJ_COPY(&stmt->fetch.into, fetch_into);
+ break;
+ default:
+ break;
+ }
+
+ if (fetch_type == PDO_FETCH_USE_DEFAULT) {
+ stmt->default_fetch_type = stmt->dbh->default_fetch_type;
+ } else {
stmt->default_fetch_type = mode;
}
+ if (!Z_ISUNDEF(old_into)) {
+ zval_ptr_dtor(&old_into);
+ }
+ if (!Z_ISUNDEF(old_ctor_args)) {
+ zval_ptr_dtor(&old_ctor_args);
+ }
+
return true;
}
@@ -1934,8 +1968,6 @@ PHP_METHOD(PDOStatement, setFetchMode)
PHP_STMT_GET_OBJ;
- do_fetch_opt_finish(stmt, 1);
-
if (!pdo_stmt_setup_fetch_mode(stmt, fetch_mode, 1, args, num_args)) {
RETURN_THROWS();
}
diff --git a/ext/pdo/tests/pdo_setfetchmode_atomic.phpt b/ext/pdo/tests/pdo_setfetchmode_atomic.phpt
new file mode 100644
index 00000000000..8751c27673a
--- /dev/null
+++ b/ext/pdo/tests/pdo_setfetchmode_atomic.phpt
@@ -0,0 +1,73 @@
+--TEST--
+PDO Common: Failed setFetchMode must preserve the prior fetch mode and payload
+--EXTENSIONS--
+pdo
+--SKIPIF--
+<?php
+$dir = getenv('REDIR_TEST_DIR');
+if ($dir === false) {
+ die('skip no driver');
+}
+require_once $dir . 'pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+if (getenv('REDIR_TEST_DIR') === false) {
+ putenv('REDIR_TEST_DIR=' . __DIR__ . '/../../pdo/tests/');
+}
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+
+class FetchClass
+{
+ public int $i;
+
+ public function __construct(public string $marker)
+ {
+ }
+}
+
+$pdo->exec('CREATE TABLE pdo_setfetchmode_atomic (i INT)');
+$pdo->exec('INSERT INTO pdo_setfetchmode_atomic (i) VALUES (1)');
+$stmt = $pdo->query('SELECT i FROM pdo_setfetchmode_atomic');
+$row = new stdClass;
+$stmt->setFetchMode(PDO::FETCH_INTO, $row);
+try {
+ $stmt->setFetchMode(PDO::FETCH_CLASS);
+} catch (Throwable $e) {
+ echo "into error: ", $e::class, "\n";
+}
+$r = $stmt->fetch();
+
+echo "into object preserved: ";
+var_dump($r === $row);
+echo "into value: ", $row->i, "\n";
+
+$stmt = $pdo->query('SELECT i FROM pdo_setfetchmode_atomic');
+$stmt->setFetchMode(PDO::FETCH_CLASS, FetchClass::class, ['kept']);
+try {
+ $stmt->setFetchMode(PDO::FETCH_CLASS);
+} catch (Throwable $e) {
+ echo "class error: ", $e::class, "\n";
+}
+$r = $stmt->fetch();
+
+echo "class preserved: ", $r::class, "\n";
+echo "constructor argument: ", $r->marker, "\n";
+echo "class value: ", $r->i, "\n";
+?>
+--CLEAN--
+<?php
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+PDOTest::dropTableIfExists($pdo, 'pdo_setfetchmode_atomic');
+?>
+--EXPECT--
+into error: ArgumentCountError
+into object preserved: bool(true)
+into value: 1
+class error: ArgumentCountError
+class preserved: FetchClass
+constructor argument: kept
+class value: 1
diff --git a/ext/pdo/tests/pdo_setfetchmode_reentrant_fetch.phpt b/ext/pdo/tests/pdo_setfetchmode_reentrant_fetch.phpt
new file mode 100644
index 00000000000..bc163366fa8
--- /dev/null
+++ b/ext/pdo/tests/pdo_setfetchmode_reentrant_fetch.phpt
@@ -0,0 +1,75 @@
+--TEST--
+PDO Common: setFetchMode autoload re-entry observes the prior complete mode
+--EXTENSIONS--
+pdo
+--SKIPIF--
+<?php
+$dir = getenv('REDIR_TEST_DIR');
+if ($dir === false) {
+ die('skip no driver');
+}
+require_once $dir . 'pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+if (getenv('REDIR_TEST_DIR') === false) {
+ putenv('REDIR_TEST_DIR=' . __DIR__ . '/../../pdo/tests/');
+}
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+
+class PriorFetchClass
+{
+ public int $i;
+
+ public function __construct(public string $marker)
+ {
+ }
+}
+
+$pdo->exec('CREATE TABLE pdo_setfetchmode_reentrant_fetch (i INT)');
+$pdo->exec('INSERT INTO pdo_setfetchmode_reentrant_fetch (i) VALUES (1)');
+$pdo->exec('INSERT INTO pdo_setfetchmode_reentrant_fetch (i) VALUES (2)');
+
+$stmt = $pdo->query('SELECT i FROM pdo_setfetchmode_reentrant_fetch ORDER BY i');
+$into = new stdClass;
+$stmt->setFetchMode(PDO::FETCH_INTO, $into);
+$autoload = function (string $class) use ($stmt, $into): void {
+ $row = $stmt->fetch();
+ echo "into re-entry preserved: ";
+ var_dump($row === $into);
+ eval("class $class { public int \$i; }");
+};
+spl_autoload_register($autoload);
+$stmt->setFetchMode(PDO::FETCH_CLASS, 'ReenteredIntoClass');
+spl_autoload_unregister($autoload);
+$row = $stmt->fetch();
+echo "into re-entry value: ", $into->i, "\n";
+echo "outer into class: ", $row::class, " ", $row->i, "\n";
+
+$stmt = $pdo->query('SELECT i FROM pdo_setfetchmode_reentrant_fetch ORDER BY i');
+$stmt->setFetchMode(PDO::FETCH_CLASS, PriorFetchClass::class, ['kept']);
+$autoload = function (string $class) use ($stmt): void {
+ $row = $stmt->fetch();
+ echo "class re-entry: ", $row::class, " ", $row->marker, " ", $row->i, "\n";
+ eval("class $class { public int \$i; }");
+};
+spl_autoload_register($autoload);
+$stmt->setFetchMode(PDO::FETCH_CLASS, 'ReenteredFetchClass');
+spl_autoload_unregister($autoload);
+$row = $stmt->fetch();
+echo "outer class: ", $row::class, " ", $row->i, "\n";
+?>
+--CLEAN--
+<?php
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+PDOTest::dropTableIfExists($pdo, 'pdo_setfetchmode_reentrant_fetch');
+?>
+--EXPECT--
+into re-entry preserved: bool(true)
+into re-entry value: 1
+outer into class: ReenteredIntoClass 2
+class re-entry: PriorFetchClass kept 1
+outer class: ReenteredFetchClass 2
diff --git a/ext/pdo/tests/pdo_setfetchmode_reentrant_setmode.phpt b/ext/pdo/tests/pdo_setfetchmode_reentrant_setmode.phpt
new file mode 100644
index 00000000000..7eb68e10136
--- /dev/null
+++ b/ext/pdo/tests/pdo_setfetchmode_reentrant_setmode.phpt
@@ -0,0 +1,53 @@
+--TEST--
+PDO Common: setFetchMode autoload re-entry may change the statement safely
+--EXTENSIONS--
+pdo
+--SKIPIF--
+<?php
+$dir = getenv('REDIR_TEST_DIR');
+if ($dir === false) {
+ die('skip no driver');
+}
+require_once $dir . 'pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+if (getenv('REDIR_TEST_DIR') === false) {
+ putenv('REDIR_TEST_DIR=' . __DIR__ . '/../../pdo/tests/');
+}
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+
+$pdo->exec('CREATE TABLE pdo_setfetchmode_reentrant_setmode (i INT)');
+$pdo->exec('INSERT INTO pdo_setfetchmode_reentrant_setmode (i) VALUES (1)');
+$stmt = $pdo->query('SELECT i FROM pdo_setfetchmode_reentrant_setmode');
+$first = new stdClass;
+$second = new stdClass;
+$stmt->setFetchMode(PDO::FETCH_INTO, $first);
+
+$autoload = function (string $class) use ($stmt, $second): void {
+ $stmt->setFetchMode(PDO::FETCH_INTO, $second);
+ eval("class $class { public int \$i; }");
+};
+spl_autoload_register($autoload);
+$stmt->setFetchMode(PDO::FETCH_CLASS, 'ReenteredSetModeClass');
+spl_autoload_unregister($autoload);
+
+$row = $stmt->fetch();
+echo $row::class, " ", $row->i, "\n";
+echo "first changed: ";
+var_dump(isset($first->i));
+echo "second changed: ";
+var_dump(isset($second->i));
+?>
+--CLEAN--
+<?php
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$pdo = PDOTest::factory();
+PDOTest::dropTableIfExists($pdo, 'pdo_setfetchmode_reentrant_setmode');
+?>
+--EXPECT--
+ReenteredSetModeClass 1
+first changed: bool(false)
+second changed: bool(false)