Merge branch '7.4' into 8.0

* 7.4:
  [Serializer] Fix handling of constructor enum denormalization errors
  [Console] ProgressIndicator console helper display with multiple processes
  [HttpFoundation] Handle empty session data in updateTimestamp() to fix compat with PHP 8.6
  [Console] Fix arguments set via #[Ask] wrongly considered null in profiler
  [Cache] Wrap `DoctrineDbalAdapter::doSave()` in savepoint to prevent transaction poisoning
  Update security-1.0.xsd with missing oauth2 element
  [Console] Silence shell_exec warning in hasSttyAvailable
This commit is contained in:
Nicolas Grekas
2026-02-21 17:28:39 +01:00
2 changed files with 69 additions and 3 deletions

View File

@@ -38,6 +38,8 @@ class DoctrineDbalAdapter extends AbstractAdapter implements PruneableInterface
{
private const MAX_KEY_LENGTH = 255;
private static int $savepointCounter = 0;
private MarshallerInterface $marshaller;
private Connection $conn;
private string $platformName;
@@ -241,6 +243,26 @@ class DoctrineDbalAdapter extends AbstractAdapter implements PruneableInterface
return $failed;
}
if ($this->conn->isTransactionActive() && $this->conn->getDatabasePlatform()->supportsSavepoints()) {
$savepoint = 'cache_save_'.++self::$savepointCounter;
try {
$this->conn->createSavepoint($savepoint);
$failed = $this->doSaveInner($values, $lifetime, $failed);
$this->conn->releaseSavepoint($savepoint);
return $failed;
} catch (\Throwable $e) {
$this->conn->rollbackSavepoint($savepoint);
throw $e;
}
}
return $this->doSaveInner($values, $lifetime, $failed);
}
private function doSaveInner(array $values, int $lifetime, array $failed): array|bool
{
$platformName = $this->getPlatformName();
$insertSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?)";

View File

@@ -17,6 +17,7 @@ use Doctrine\DBAL\Driver\AbstractMySQLDriver;
use Doctrine\DBAL\Driver\Middleware;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory;
use Doctrine\DBAL\Schema\Schema;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -82,7 +83,7 @@ class DoctrineDbalAdapterTest extends AdapterTestCase
$schema = new Schema();
$adapter = new DoctrineDbalAdapter($connection);
$adapter->configureSchema($schema, $connection, fn () => true);
$adapter->configureSchema($schema, $connection, static fn () => true);
$this->assertTrue($schema->hasTable('cache_items'));
}
@@ -96,7 +97,7 @@ class DoctrineDbalAdapterTest extends AdapterTestCase
$schema = new Schema();
$adapter = $this->createCachePool();
$adapter->configureSchema($schema, $otherConnection, fn () => false);
$adapter->configureSchema($schema, $otherConnection, static fn () => false);
$this->assertFalse($schema->hasTable('cache_items'));
}
@@ -111,7 +112,7 @@ class DoctrineDbalAdapterTest extends AdapterTestCase
$schema->createTable('cache_items');
$adapter = new DoctrineDbalAdapter($connection);
$adapter->configureSchema($schema, $connection, fn () => true);
$adapter->configureSchema($schema, $connection, static fn () => true);
$table = $schema->getTable('cache_items');
$this->assertSame([], $table->getColumns(), 'The table was not overwritten');
}
@@ -160,6 +161,49 @@ class DoctrineDbalAdapterTest extends AdapterTestCase
}
}
public function testSaveWithinActiveTransactionUsesSavepoint()
{
$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_savepoint');
try {
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $dbFile], $this->getDbalConfig());
$adapter = new DoctrineDbalAdapter($connection);
$adapter->createTable();
$connection->beginTransaction();
$item = $adapter->getItem('savepoint_key');
$item->set('savepoint_value');
$adapter->save($item);
$this->assertTrue($connection->isTransactionActive(), 'Outer transaction must still be active after cache save');
$connection->commit();
$this->assertSame('savepoint_value', $adapter->getItem('savepoint_key')->get());
} finally {
@unlink($dbFile);
}
}
public function testSavepointIsRolledBackOnFailure()
{
$platform = $this->createMock(AbstractPlatform::class);
$platform->method('supportsSavepoints')->willReturn(true);
$conn = $this->createMock(Connection::class);
$conn->method('isTransactionActive')->willReturn(true);
$conn->method('getDatabasePlatform')->willReturn($platform);
$conn->expects($this->once())->method('createSavepoint')->with($this->stringStartsWith('cache_save_'));
$conn->expects($this->once())->method('rollbackSavepoint')->with($this->stringStartsWith('cache_save_'));
$conn->expects($this->never())->method('releaseSavepoint');
$conn->method('prepare')->willThrowException(new \RuntimeException('DB error'));
$adapter = new DoctrineDbalAdapter($conn);
$doSave = new \ReflectionMethod($adapter, 'doSave');
$this->expectException(\RuntimeException::class);
$doSave->invoke($adapter, ['key' => 'value'], 0);
}
protected function isPruned(DoctrineDbalAdapter $cache, string $name): bool
{
$o = new \ReflectionObject($cache);