From 38aa38887c1acc572b4d72df314b3d28100539e4 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 10 Dec 2025 10:17:20 +0100 Subject: [PATCH 1/3] Extract driver options into value objects So we can unit test options, as we're transforming some of the options that are passed stuff --- src/Client.php | 1 + src/Model/AutoEncryptionOptions.php | 74 ++++++++++ src/Model/DriverOptions.php | 159 ++++++++++++++++++++++ tests/Model/AutoEncryptionOptionsTest.php | 82 +++++++++++ tests/Model/DriverOptionsTest.php | 135 ++++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 src/Model/AutoEncryptionOptions.php create mode 100644 src/Model/DriverOptions.php create mode 100644 tests/Model/AutoEncryptionOptionsTest.php create mode 100644 tests/Model/DriverOptionsTest.php diff --git a/src/Client.php b/src/Client.php index 938da2f1a..3611485a1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -141,6 +141,7 @@ public function __construct(?string $uri = null, array $uriOptions = [], array $ $driverOptions = array_diff_key($driverOptions, ['builderEncoder' => 1, 'typeMap' => 1]); $this->manager = new Manager($uri, $uriOptions, $driverOptions); + $this->readConcern = $this->manager->getReadConcern(); $this->readPreference = $this->manager->getReadPreference(); $this->writeConcern = $this->manager->getWriteConcern(); diff --git a/src/Model/AutoEncryptionOptions.php b/src/Model/AutoEncryptionOptions.php new file mode 100644 index 000000000..639b24549 --- /dev/null +++ b/src/Model/AutoEncryptionOptions.php @@ -0,0 +1,74 @@ + $provider) { + if ($provider === []) { + $options[self::KEY_KMS_PROVIDERS][$name] = new stdClass(); + } + } + } + + $keyVaultClient = $options[self::KEY_KEY_VAULT_CLIENT]; + + return new self( + keyVaultClient: $keyVaultClient instanceof Client ? $keyVaultClient->getManager() : $keyVaultClient, + kmsProviders: $options[self::KEY_KMS_PROVIDERS] ?? [], + miscOptions: array_diff_key($options, [self::KEY_KEY_VAULT_CLIENT => 1, self::KEY_KMS_PROVIDERS => 1]), + ); + } + + public function toArray(): array + { + return array_filter( + [ + self::KEY_KEY_VAULT_CLIENT => $this->keyVaultClient, + self::KEY_KMS_PROVIDERS => $this->kmsProviders, + ] + $this->miscOptions, + fn ($option) => $option !== null, + ); + } + + /** @throws InvalidArgumentException */ + private static function ensureValidArrayOptions(array $options): void + { + $keyVaultClient = $options[self::KEY_KEY_VAULT_CLIENT] ?? null; + + if ($keyVaultClient !== null && ! $keyVaultClient instanceof Client && ! $keyVaultClient instanceof Manager) { + throw InvalidArgumentException::invalidType( + sprintf('"%s" option', self::KEY_KEY_VAULT_CLIENT), + $keyVaultClient, + [Client::class, Manager::class], + ); + } + } +} diff --git a/src/Model/DriverOptions.php b/src/Model/DriverOptions.php new file mode 100644 index 000000000..34d2c6087 --- /dev/null +++ b/src/Model/DriverOptions.php @@ -0,0 +1,159 @@ + BSONArray::class, + 'document' => BSONDocument::class, + 'root' => BSONDocument::class, + ]; + + private const HANDSHAKE_SEPARATOR = '/'; + + private static ?string $version = null; + + private function __construct( + public readonly array $typeMap, + public readonly Encoder $builderEncoder, + public readonly ?array $autoEncryption, + public readonly array $driver, + private readonly array $miscOptions, + ) { + } + + public static function fromArray(array $options): self + { + $options += ['typeMap' => self::DEFAULT_TYPE_MAP]; + + self::ensureValidArrayOptions($options); + + $autoEncryption = isset($options[self::KEY_AUTO_ENCRYPTION]) + ? AutoEncryptionOptions::fromArray($options[self::KEY_AUTO_ENCRYPTION])->toArray() + : null; + + return (new self( + typeMap: $options[self::KEY_TYPE_MAP], + builderEncoder: $options[self::KEY_BUILDER_ENCODER] ?? new BuilderEncoder(), + autoEncryption: $autoEncryption, + driver: [], + miscOptions: array_diff_key($options, [ + self::KEY_TYPE_MAP => 1, + self::KEY_BUILDER_ENCODER => 1, + self::KEY_AUTO_ENCRYPTION => 1, + self::KEY_DRIVER => 1, + ]), + ))->withDriverInfo($options[self::KEY_DRIVER] ?? []); + } + + public function isAutoEncryptionEnabled(): bool + { + return isset($this->autoEncryption['keyVaultNamespace']); + } + + public function toArray(): array + { + return array_filter( + [ + 'typeMap' => $this->typeMap, + 'builderEncoder' => $this->builderEncoder, + 'autoEncryption' => $this->autoEncryption, + 'driver' => $this->driver, + ] + $this->miscOptions, + fn ($option) => $option !== null, + ); + } + + /** @throws InvalidArgumentException */ + private static function ensureValidArrayOptions(array $options): void + { + if (! is_array($options[self::KEY_TYPE_MAP])) { + throw InvalidArgumentException::invalidType( + sprintf('"%s" driver option', self::KEY_TYPE_MAP), + $options[self::KEY_TYPE_MAP], + 'array', + ); + } + + if (isset($options[self::KEY_BUILDER_ENCODER]) && ! $options[self::KEY_BUILDER_ENCODER] instanceof Encoder) { + throw InvalidArgumentException::invalidType( + sprintf('"%s" option', self::KEY_BUILDER_ENCODER), + $options[self::KEY_BUILDER_ENCODER], + Encoder::class, + ); + } + + $driver = $options[self::KEY_DRIVER] ?? []; + if (isset($driver['name'])) { + if (! is_string($driver['name'])) { + throw InvalidArgumentException::invalidType('"name" handshake option', $driver['name'], 'string'); + } + } + + if (isset($driver['version'])) { + if (! is_string($driver['version'])) { + throw InvalidArgumentException::invalidType('"version" handshake option', $driver['version'], 'string'); + } + } + } + + private static function getVersion(): string + { + if (self::$version === null) { + try { + self::$version = InstalledVersions::getPrettyVersion('mongodb/mongodb') ?? 'unknown'; + } catch (Throwable) { + self::$version = 'error'; + } + } + + return self::$version; + } + + private function withDriverInfo(array $driver): self + { + $mergedDriver = [ + 'name' => 'PHPLIB', + 'version' => self::getVersion(), + ]; + + if (isset($driver['name'])) { + $mergedDriver['name'] .= self::HANDSHAKE_SEPARATOR . $driver['name']; + } + + if (isset($driver['version'])) { + $mergedDriver['version'] .= self::HANDSHAKE_SEPARATOR . $driver['version']; + } + + if (isset($driver['platform'])) { + $mergedDriver['platform'] = $driver['platform']; + } + + return new self( + typeMap: $this->typeMap, + builderEncoder: $this->builderEncoder, + autoEncryption: $this->autoEncryption, + driver: $mergedDriver, + miscOptions: $this->miscOptions, + ); + } +} diff --git a/tests/Model/AutoEncryptionOptionsTest.php b/tests/Model/AutoEncryptionOptionsTest.php new file mode 100644 index 000000000..fa8b19792 --- /dev/null +++ b/tests/Model/AutoEncryptionOptionsTest.php @@ -0,0 +1,82 @@ +assertEquals($expected, $actual->toArray()); + } + + public function testFromArrayFailsForInvalidOptions(): void + { + $this->expectException(InvalidArgumentException::class); + + AutoEncryptionOptions::fromArray([ + 'keyVaultClient' => new StdClass(), + ]); + } + + public static function fromArrayProvider(): Generator + { + $client = new Client(); + + yield 'with manager passed for `keyVaultClient`' => [ + [ + 'keyVaultClient' => $client->getManager(), + 'kmsProviders' => [ + 'foo' => new StdClass(), + 'aws' => ['foo' => 'bar'], + ], + ], + [ + 'keyVaultClient' => $client->getManager(), + 'kmsProviders' => [ + 'foo' => new StdClass(), + 'aws' => ['foo' => 'bar'], + ], + ], + ]; + + yield 'with client passed for `keyVaultClient`' => [ + ['keyVaultClient' => $client], + [ + 'keyVaultClient' => $client->getManager(), + ], + ]; + + yield 'with extra options' => [ + [ + 'kmsProviders' => [ + 'foo' => [], + 'aws' => ['foo' => 'bar'], + ], + 'tlsProviders' => [ + ['foo' => 'bar'], + ], + 'disableClientPersistence' => false, + ], + [ + 'kmsProviders' => [ + 'foo' => new StdClass(), + 'aws' => ['foo' => 'bar'], + ], + 'tlsProviders' => [ + ['foo' => 'bar'], + ], + 'disableClientPersistence' => false, + ], + ]; + } +} diff --git a/tests/Model/DriverOptionsTest.php b/tests/Model/DriverOptionsTest.php new file mode 100644 index 000000000..1aad1981e --- /dev/null +++ b/tests/Model/DriverOptionsTest.php @@ -0,0 +1,135 @@ +toArray(); + // This changes per runtime, so is tested with regex separately in `testDriverInfo` + unset($actualArray['driver']['version']); + + $this->assertEquals($expected, $actualArray); + } + + #[DataProvider('provideInvalidOptions')] + public function testFromArrayFailsForInvalidOptions(array $options): void + { + $this->expectException(InvalidArgumentException::class); + DriverOptions::fromArray($options); + } + + public function testIsAutoEncryptionEnabled(): void + { + $enabled = DriverOptions::fromArray([ + 'kmsProviders' => [ + 'foo' => new StdClass(), + 'aws' => ['foo' => 'bar'], + ], + 'autoEncryption' => ['keyVaultNamespace' => 'foo'], + ]); + + $this->assertTrue($enabled->isAutoEncryptionEnabled()); + + $notEnabled = DriverOptions::fromArray([ + 'autoEncryption' => [ + 'kmsProviders' => [ + 'foo' => new StdClass(), + 'aws' => ['foo' => 'bar'], + ], + ], + ]); + + $this->assertFalse($notEnabled->isAutoEncryptionEnabled()); + } + + #[DataProvider('provideDriverInfo')] + public function testDriverInfo(array $options, string $name, string $versionRegex, ?string $platform): void + { + $options = DriverOptions::fromArray(['driver' => $options]); + + $this->assertEquals($name, $options->driver['name']); + $this->assertMatchesRegularExpression($versionRegex, $options->driver['version']); + $this->assertEquals($platform, $options->driver['platform']); + } + + public static function provideOptions(): Generator + { + yield 'defaults' => [ + [], + [ + 'typeMap' => [ + 'array' => BSONArray::class, + 'document' => BSONDocument::class, + 'root' => BSONDocument::class, + ], + 'builderEncoder' => new BuilderEncoder(), + 'driver' => ['name' => 'PHPLIB'], + ], + ]; + + + yield 'extra options' => [ + [ + 'typeMap' => [], + 'builderEncoder' => new BuilderEncoder(), + 'autoEncryption' => [], + 'some' => 'option', + 'some_other' => ['option' => 'too'], + 'driver' => ['platform' => 'foo'], + ], + [ + 'builderEncoder' => new BuilderEncoder(), + 'driver' => [ + 'name' => 'PHPLIB', + 'platform' => 'foo', + ], + 'some' => 'option', + 'some_other' => ['option' => 'too'], + ], + ]; + } + + public static function provideInvalidOptions(): Generator + { + yield 'invalid type for type map' => [ + ['typeMap' => null], + ]; + + yield 'invalid type for builder encoder' => [ + [ + 'builderEncoder' => new StdClass(), + ], + ]; + } + + public static function provideDriverInfo(): Generator + { + yield 'all' => [ + [ + 'name' => 'foo', + 'version' => 'bar', + 'platform' => 'baz', + ], + 'PHPLIB/foo', + '/^.+\/bar$/', + 'baz', + ]; + + yield 'default' => [[], 'PHPLIB', '/.+/', null]; + } +} From 39c9c0bc81bc51d3108f076810d5ca31564069c5 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 10 Dec 2025 10:17:20 +0100 Subject: [PATCH 2/3] Use `DriverOptions` value object in Client --- src/Client.php | 146 ++++++++----------------------------------------- 1 file changed, 22 insertions(+), 124 deletions(-) diff --git a/src/Client.php b/src/Client.php index 3611485a1..f2226abb2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,13 +17,8 @@ namespace MongoDB; -use Composer\InstalledVersions; use Iterator; -use MongoDB\BSON\Document; -use MongoDB\BSON\PackedArray; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Pipeline; -use MongoDB\Codec\Encoder; use MongoDB\Driver\BulkWriteCommand; use MongoDB\Driver\BulkWriteCommandResult; use MongoDB\Driver\ClientEncryption; @@ -38,36 +33,22 @@ use MongoDB\Exception\InvalidArgumentException; use MongoDB\Exception\UnexpectedValueException; use MongoDB\Exception\UnsupportedException; -use MongoDB\Model\BSONArray; -use MongoDB\Model\BSONDocument; +use MongoDB\Model\AutoEncryptionOptions; use MongoDB\Model\DatabaseInfo; +use MongoDB\Model\DriverOptions; use MongoDB\Operation\ClientBulkWriteCommand; use MongoDB\Operation\DropDatabase; use MongoDB\Operation\ListDatabaseNames; use MongoDB\Operation\ListDatabases; use MongoDB\Operation\Watch; -use stdClass; use Stringable; -use Throwable; use function array_diff_key; -use function is_array; -use function is_string; class Client implements Stringable { public const DEFAULT_URI = 'mongodb://127.0.0.1/'; - private const DEFAULT_TYPE_MAP = [ - 'array' => BSONArray::class, - 'document' => BSONDocument::class, - 'root' => BSONDocument::class, - ]; - - private const HANDSHAKE_SEPARATOR = '/'; - - private static ?string $version = null; - private Manager $manager; private ReadConcern $readConcern; @@ -76,14 +57,9 @@ class Client implements Stringable private string $uri; - private array $typeMap; - - /** @psalm-var Encoder */ - private readonly Encoder $builderEncoder; - private WriteConcern $writeConcern; - private bool $autoEncryptionEnabled; + private DriverOptions $driverOptions; /** * Constructs a new Client instance. @@ -113,32 +89,11 @@ class Client implements Stringable */ public function __construct(?string $uri = null, array $uriOptions = [], array $driverOptions = []) { - $driverOptions += ['typeMap' => self::DEFAULT_TYPE_MAP]; - - if (! is_array($driverOptions['typeMap'])) { - throw InvalidArgumentException::invalidType('"typeMap" driver option', $driverOptions['typeMap'], 'array'); - } - - if (isset($driverOptions['autoEncryption']) && is_array($driverOptions['autoEncryption'])) { - $driverOptions['autoEncryption'] = $this->prepareEncryptionOptions($driverOptions['autoEncryption']); - } - - if (isset($driverOptions['builderEncoder']) && ! $driverOptions['builderEncoder'] instanceof Encoder) { - throw InvalidArgumentException::invalidType('"builderEncoder" option', $driverOptions['builderEncoder'], Encoder::class); - } - - $driverOptions['driver'] = $this->mergeDriverInfo($driverOptions['driver'] ?? []); + $this->driverOptions = DriverOptions::fromArray($driverOptions); $this->uri = $uri ?? self::DEFAULT_URI; - $this->builderEncoder = $driverOptions['builderEncoder'] ?? new BuilderEncoder(); - $this->typeMap = $driverOptions['typeMap']; - - /* Database and Collection objects may need to know whether auto - * encryption is enabled for dropping collections. Track this via an - * internal option until PHPC-2615 is implemented. */ - $this->autoEncryptionEnabled = isset($driverOptions['autoEncryption']['keyVaultNamespace']); - $driverOptions = array_diff_key($driverOptions, ['builderEncoder' => 1, 'typeMap' => 1]); + $driverOptions = array_diff_key($this->driverOptions->toArray(), ['builderEncoder' => 1, 'typeMap' => 1]); $this->manager = new Manager($uri, $uriOptions, $driverOptions); @@ -157,8 +112,8 @@ public function __debugInfo(): array return [ 'manager' => $this->manager, 'uri' => $this->uri, - 'typeMap' => $this->typeMap, - 'builderEncoder' => $this->builderEncoder, + 'typeMap' => $this->driverOptions->typeMap, + 'builderEncoder' => $this->driverOptions->builderEncoder, 'writeConcern' => $this->writeConcern, ]; } @@ -230,9 +185,9 @@ public function bulkWrite(BulkWriteCommand|ClientBulkWrite $bulk, array $options */ public function createClientEncryption(array $options): ClientEncryption { - $options = $this->prepareEncryptionOptions($options); + $options = AutoEncryptionOptions::fromArray($options); - return $this->manager->createClientEncryption($options); + return $this->manager->createClientEncryption($options->toArray()); } /** @@ -269,7 +224,11 @@ public function dropDatabase(string $databaseName, array $options = []): void */ public function getCollection(string $databaseName, string $collectionName, array $options = []): Collection { - $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder, 'autoEncryptionEnabled' => $this->autoEncryptionEnabled]; + $options += [ + 'typeMap' => $this->driverOptions->typeMap, + 'builderEncoder' => $this->driverOptions->builderEncoder, + 'autoEncryptionEnabled' => $this->driverOptions->isAutoEncryptionEnabled(), + ]; return new Collection($this->manager, $databaseName, $collectionName, $options); } @@ -284,7 +243,11 @@ public function getCollection(string $databaseName, string $collectionName, arra */ public function getDatabase(string $databaseName, array $options = []): Database { - $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder, 'autoEncryptionEnabled' => $this->autoEncryptionEnabled]; + $options += [ + 'typeMap' => $this->driverOptions->typeMap, + 'builderEncoder' => $this->driverOptions->builderEncoder, + 'autoEncryptionEnabled' => $this->driverOptions->isAutoEncryptionEnabled(), + ]; return new Database($this->manager, $databaseName, $options); } @@ -320,7 +283,7 @@ public function getReadPreference(): ReadPreference */ public function getTypeMap(): array { - return $this->typeMap; + return $this->driverOptions->typeMap; } /** @@ -429,7 +392,7 @@ public function watch(array $pipeline = [], array $options = []): ChangeStream $pipeline = new Pipeline(...$pipeline); } - $pipeline = $this->builderEncoder->encodeIfSupported($pipeline); + $pipeline = $this->driverOptions->builderEncoder->encodeIfSupported($pipeline); if (! isset($options['readPreference']) && ! is_in_transaction($options)) { $options['readPreference'] = $this->readPreference; @@ -442,76 +405,11 @@ public function watch(array $pipeline = [], array $options = []): ChangeStream } if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; + $options['typeMap'] = $this->driverOptions->typeMap; } $operation = new Watch($this->manager, null, null, $pipeline, $options); return $operation->execute($server); } - - private static function getVersion(): string - { - if (self::$version === null) { - try { - self::$version = InstalledVersions::getPrettyVersion('mongodb/mongodb') ?? 'unknown'; - } catch (Throwable) { - self::$version = 'error'; - } - } - - return self::$version; - } - - private function mergeDriverInfo(array $driver): array - { - $mergedDriver = [ - 'name' => 'PHPLIB', - 'version' => self::getVersion(), - ]; - - if (isset($driver['name'])) { - if (! is_string($driver['name'])) { - throw InvalidArgumentException::invalidType('"name" handshake option', $driver['name'], 'string'); - } - - $mergedDriver['name'] .= self::HANDSHAKE_SEPARATOR . $driver['name']; - } - - if (isset($driver['version'])) { - if (! is_string($driver['version'])) { - throw InvalidArgumentException::invalidType('"version" handshake option', $driver['version'], 'string'); - } - - $mergedDriver['version'] .= self::HANDSHAKE_SEPARATOR . $driver['version']; - } - - if (isset($driver['platform'])) { - $mergedDriver['platform'] = $driver['platform']; - } - - return $mergedDriver; - } - - private function prepareEncryptionOptions(array $options): array - { - if (isset($options['keyVaultClient'])) { - if ($options['keyVaultClient'] instanceof self) { - $options['keyVaultClient'] = $options['keyVaultClient']->manager; - } elseif (! $options['keyVaultClient'] instanceof Manager) { - throw InvalidArgumentException::invalidType('"keyVaultClient" option', $options['keyVaultClient'], [self::class, Manager::class]); - } - } - - // The server requires an empty document for automatic credentials. - if (isset($options['kmsProviders']) && is_array($options['kmsProviders'])) { - foreach ($options['kmsProviders'] as $name => $provider) { - if ($provider === []) { - $options['kmsProviders'][$name] = new stdClass(); - } - } - } - - return $options; - } } From ddeaac1dbb75f85f2bde70bb3fb0e5cae3bdeadc Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 10 Dec 2025 10:17:20 +0100 Subject: [PATCH 3/3] Prepend encryption flag `iue` to platform metadata Sending extra metadata in the handshake will allow us to detect when encryption is enabled. `iue` for In-Use Ecnryption was chosen to save bytes (as metadata will be truncated if it exceeds byte limit) --- src/Model/DriverOptions.php | 4 ++++ tests/Model/DriverOptionsTest.php | 38 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Model/DriverOptions.php b/src/Model/DriverOptions.php index 34d2c6087..acbaa51ec 100644 --- a/src/Model/DriverOptions.php +++ b/src/Model/DriverOptions.php @@ -148,6 +148,10 @@ private function withDriverInfo(array $driver): self $mergedDriver['platform'] = $driver['platform']; } + if ($this->isAutoEncryptionEnabled()) { + $mergedDriver['platform'] = trim(sprintf('iue %s', $driver['platform'] ?? '')); + } + return new self( typeMap: $this->typeMap, builderEncoder: $this->builderEncoder, diff --git a/tests/Model/DriverOptionsTest.php b/tests/Model/DriverOptionsTest.php index 1aad1981e..aaf540413 100644 --- a/tests/Model/DriverOptionsTest.php +++ b/tests/Model/DriverOptionsTest.php @@ -82,6 +82,44 @@ public static function provideOptions(): Generator ], ]; + yield 'encryption enabled' => [ + [ + 'autoEncryption' => ['keyVaultNamespace' => 'foo'], + ], + [ + 'typeMap' => [ + 'array' => BSONArray::class, + 'document' => BSONDocument::class, + 'root' => BSONDocument::class, + ], + 'autoEncryption' => ['keyVaultNamespace' => 'foo'], + 'builderEncoder' => new BuilderEncoder(), + 'driver' => [ + 'name' => 'PHPLIB', + 'platform' => 'iue', + ], + ], + ]; + + yield 'encryption enabled with platform' => [ + [ + 'autoEncryption' => ['keyVaultNamespace' => 'foo'], + 'driver' => ['platform' => 'bar'], + ], + [ + 'typeMap' => [ + 'array' => BSONArray::class, + 'document' => BSONDocument::class, + 'root' => BSONDocument::class, + ], + 'autoEncryption' => ['keyVaultNamespace' => 'foo'], + 'builderEncoder' => new BuilderEncoder(), + 'driver' => [ + 'name' => 'PHPLIB', + 'platform' => 'iue bar', + ], + ], + ]; yield 'extra options' => [ [