diff --git a/composer.json b/composer.json index 8364545..9839cfa 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,9 @@ "require": { "php": "~8.1.0 || ~8.2.0 || ~8.3.0", "ext-redis": "^5.3.2 || ^6.0", - "laminas/laminas-cache": "^3.10" + "laminas/laminas-cache": "^3.10", + "symfony/serializer": "^6.4", + "ext-openssl": "*" }, "provide": { "laminas/laminas-cache-storage-implementation": "1.0" diff --git a/composer.lock b/composer.lock index 3227927..305ed61 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cd65ceb0d56b19d0c23d58920761c4f6", + "content-hash": "1c22207fd57d54064808919f15a835ad", "packages": [ { "name": "laminas/laminas-cache", @@ -566,6 +566,253 @@ }, "time": "2022-11-25T16:15:06+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "shasum": "" + }, + "require": { + "php": ">=8.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:55:41+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "symfony/serializer", + "version": "v6.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "88da7f8fe03c5f4c2a69da907f1de03fab2e6872" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/88da7f8fe03c5f4c2a69da907f1de03fab2e6872", + "reference": "88da7f8fe03c5f4c2a69da907f1de03fab2e6872", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/uid": "<5.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.26|^6.3|^7.0", + "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-22T20:27:10+00:00" + }, { "name": "webmozart/assert", "version": "1.11.0", @@ -4465,73 +4712,6 @@ ], "time": "2023-01-01T08:36:10+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "shasum": "" - }, - "require": { - "php": ">=8.0.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-01-02T09:55:41+00:00" - }, { "name": "symfony/filesystem", "version": "v6.0.19", @@ -4723,88 +4903,6 @@ ], "time": "2023-01-01T08:36:10+00:00" }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.28.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-01-26T09:26:14+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.28.0", diff --git a/src/RedisClusterOptions.php b/src/RedisClusterOptions.php index ce6ef66..66e9c5c 100644 --- a/src/RedisClusterOptions.php +++ b/src/RedisClusterOptions.php @@ -7,6 +7,8 @@ use Laminas\Cache\Exception\RuntimeException; use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException; +use function is_array; + final class RedisClusterOptions extends AdapterOptions { public const LIBRARY_OPTIONS = [ @@ -53,8 +55,8 @@ final class RedisClusterOptions extends AdapterOptions private string $password = ''; - /** @psalm-var array|null */ - private ?array $sslContext = null; + /** @psalm-var SslContext|null */ + private ?SslContext $sslContext = null; /** * @param iterable|null|AdapterOptions $options @@ -227,18 +229,23 @@ public function setPassword(string $password): void } /** - * @psalm-return array|null + * @psalm-return SslContext|null */ - public function getSslContext(): ?array + public function getSslContext(): ?SslContext { return $this->sslContext; } /** - * @psalm-param array|null $sslContext + * @psalm-param array|SslContext|null $sslContext */ - public function setSslContext(?array $sslContext): void + public function setSslContext(array|SslContext|null $sslContext): void { + if (is_array($sslContext)) { + $data = $sslContext; + $sslContext = new SslContext(); + $sslContext->exchangeArray($data); + } $this->sslContext = $sslContext; } } diff --git a/src/RedisClusterResourceManager.php b/src/RedisClusterResourceManager.php index ef7ca50..6691cae 100644 --- a/src/RedisClusterResourceManager.php +++ b/src/RedisClusterResourceManager.php @@ -98,7 +98,7 @@ private function createRedisResource(RedisClusterOptions $options): RedisCluster $options->getReadTimeout(), $options->isPersistent(), $password, - $options->getSslContext() + $options->getSslContext()?->getArrayCopy() ); } @@ -111,7 +111,7 @@ private function createRedisResourceFromName( float $fallbackReadTimeout, bool $persistent, string $fallbackPassword, - ?array $sslContext + ?SslContext $sslContext ): RedisClusterFromExtension { $options = new RedisClusterOptionsFromIni(); $seeds = $options->getSeeds($name); @@ -126,7 +126,7 @@ private function createRedisResourceFromName( $readTimeout, $persistent, $password, - $sslContext + $sslContext?->getArrayCopy() ); } diff --git a/src/SslContext.php b/src/SslContext.php new file mode 100644 index 0000000..059e15c --- /dev/null +++ b/src/SslContext.php @@ -0,0 +1,200 @@ +peerName = $peerName; + $this->verifyPeer = $verifyPeer; + $this->verifyPeerName = $verifyPeerName; + $this->allowSelfSigned = $allowSelfSigned; + $this->cafile = $cafile; + $this->capath = $capath; + $this->localCert = $localCert; + $this->localPk = $localPk; + $this->passphrase = $passphrase; + $this->verifyDepth = $verifyDepth; + $this->ciphers = $ciphers; + $this->sniEnabled = $sniEnabled; + $this->disableCompression = $disableCompression; + $this->peerFingerprint = $peerFingerprint; + $this->securityLevel = $securityLevel; + if ($sniEnabled === null) { + $this->sniEnabled = boolval(OPENSSL_TLSEXT_SERVER_NAME); + } + } + + public function exchangeArray(array $array): void + { + foreach ($array as $key => $value) { + $property = $this->mapArrayKeyToPropertyName($key); + if (! property_exists($this, $property)) { + throw new InvalidArgumentException( + sprintf( + '%s does not contain the property "%s" corresponding to the array key "%s"', + self::class, + $property, + $key + ) + ); + } + $this->$property = $value; + } + } + + public function getArrayCopy(): array + { + $array = []; + foreach (get_object_vars($this) as $property => $value) { + if ($value !== null) { + $key = $this->mapPropertyNameToArrayKey($property); + $array[$key] = $value; + } + } + return $array; + } + + private function mapArrayKeyToPropertyName(string $key): string + { + if ($key === 'SNI_enabled') { + return 'sniEnabled'; + } + return (new CamelCaseToSnakeCaseNameConverter())->denormalize($key); + } + + private function mapPropertyNameToArrayKey(string $property): string + { + if ($property === 'sniEnabled') { + return 'SNI_enabled'; + } + return (new CamelCaseToSnakeCaseNameConverter())->normalize($property); + } +} diff --git a/test/unit/RedisClusterOptionsTest.php b/test/unit/RedisClusterOptionsTest.php index 9a5c879..fb8c2a7 100644 --- a/test/unit/RedisClusterOptionsTest.php +++ b/test/unit/RedisClusterOptionsTest.php @@ -9,6 +9,7 @@ use Laminas\Cache\Storage\Adapter\AdapterOptions; use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException; use Laminas\Cache\Storage\Adapter\RedisClusterOptions; +use Laminas\Cache\Storage\Adapter\SslContext; use Redis as RedisFromExtension; use ReflectionClass; @@ -29,6 +30,28 @@ protected function createAdapterOptions(): AdapterOptions return new RedisClusterOptions(['seeds' => ['localhost']]); } + public function testCanHandleOptionsWithSslContextObject(): void + { + $options = new RedisClusterOptions([ + 'name' => 'foo', + 'ssl_context' => new SslContext(localCert: '/path/to/localcert'), + ]); + + $this->assertEquals('foo', $options->getName()); + $this->assertEquals(new SslContext(localCert: '/path/to/localcert'), $options->getSslContext()); + } + + public function testCanHandleOptionsWithSslContextArray(): void + { + $options = new RedisClusterOptions([ + 'name' => 'foo', + 'ssl_context' => ['local_cert' => '/path/to/localcert'], + ]); + + $this->assertEquals('foo', $options->getName()); + $this->assertEquals(new SslContext(localCert: '/path/to/localcert'), $options->getSslContext()); + } + public function testCanHandleOptionsWithNodename(): void { $options = new RedisClusterOptions([ @@ -38,7 +61,6 @@ public function testCanHandleOptionsWithNodename(): void 'persistent' => false, 'redis_version' => '1.0', 'password' => 'secret', - 'ssl_context' => ['verify_peer' => false], ]); $this->assertEquals('foo', $options->getName()); @@ -47,7 +69,6 @@ public function testCanHandleOptionsWithNodename(): void $this->assertEquals(false, $options->isPersistent()); $this->assertEquals('1.0', $options->getRedisVersion()); $this->assertEquals('secret', $options->getPassword()); - $this->assertEquals(['verify_peer' => false], $options->getSslContext()); } public function testCanHandleOptionsWithSeeds(): void @@ -59,7 +80,6 @@ public function testCanHandleOptionsWithSeeds(): void 'persistent' => false, 'redis_version' => '1.0', 'password' => 'secret', - 'ssl_context' => ['verify_peer' => false], ]); $this->assertEquals(['localhost:1234'], $options->getSeeds()); @@ -68,7 +88,6 @@ public function testCanHandleOptionsWithSeeds(): void $this->assertEquals(false, $options->isPersistent()); $this->assertEquals('1.0', $options->getRedisVersion()); $this->assertEquals('secret', $options->getPassword()); - $this->assertEquals(['verify_peer' => false], $options->getSslContext()); } public function testWillDetectSeedsAndNodenameConfiguration(): void diff --git a/test/unit/SslContextTest.php b/test/unit/SslContextTest.php new file mode 100644 index 0000000..c75f7a0 --- /dev/null +++ b/test/unit/SslContextTest.php @@ -0,0 +1,83 @@ +correspondingSslContextObject = new SslContext( + peerName: 'some peer name', + allowSelfSigned: true, + verifyDepth: 10, + peerFingerprint: ['md5' => 'some fingerprint'] + ); + + $this->correspondingSslContextArray = [ + 'peer_name' => 'some peer name', + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => true, + 'verify_depth' => 10, + 'ciphers' => OPENSSL_DEFAULT_STREAM_CIPHERS, + 'SNI_enabled' => boolval(OPENSSL_TLSEXT_SERVER_NAME), + 'disable_compression' => true, + 'peer_fingerprint' => ['md5' => 'some fingerprint'], + ]; + } + + public function testExchangeArraySetsPropertiesCorrectly(): void + { + $sslContextObject = new SslContext(); + $sslContextObject->exchangeArray($this->correspondingSslContextArray); + + $this->assertEquals( + $this->correspondingSslContextObject, + $sslContextObject + ); + } + + public function testGetArrayCopyReturnsAnArrayWithPropertyValues() + { + $sslContextArray = $this->correspondingSslContextObject->getArrayCopy(); + + $this->assertEquals($this->correspondingSslContextArray, $sslContextArray); + } + + public function testExchangeArrayThrowsExceptionWhenProvidingInvalidKeyName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches( + '/does not contain the property "someInvalidKey" corresponding to the array key "some_invalid_key"/' + ); + + $sslContextObject = new SslContext(); + $sslContextObject->exchangeArray(['some_invalid_key' => true]); + } + + public function testExchangeArrayThrowsExceptionWhenProvidingInvalidValueType(): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessageMatches( + '/\$verifyPeer of type bool/' + ); + + $sslContextObject = new SslContext(); + $sslContextObject->exchangeArray(['verify_peer' => 'invalid type']); + } +}