From 8d27f7012824e419c526edc34b0db40d7bc0e854 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 12 Apr 2026 10:11:07 -0400 Subject: [PATCH] Add a sqlite / mysql storage adapter. Add a sqlite / mysql filter translator to translate an LDAP filter to a SQL where statement (as close as is possible anyway). Support filterable results by storage adapters. Pass storage adapters a broader list of options as a DTO. Return a result object used to indicate if filtering was done. --- .github/workflows/build.yml | 1 + UPGRADE-1.0.md | 42 +- docs/Server/General-Usage.md | 133 +++- src/FreeDSx/Ldap/Entry/Attribute.php | 50 +- src/FreeDSx/Ldap/Entry/Dn.php | 30 + .../ServerProtocolHandler/CollectedPage.php | 36 + .../ServerDispatchHandler.php | 33 +- .../ServerPagingHandler.php | 121 +-- .../ServerSearchHandler.php | 23 +- .../ServerSearchTrait.php | 33 +- .../Ldap/Server/Backend/GenericBackend.php | 15 +- .../Server/Backend/LdapBackendInterface.php | 19 +- .../Ldap/Server/Backend/SearchContext.php | 45 -- .../Adapter/ArrayEntryStorageTrait.php | 16 +- .../CoroutinePdoConnectionProvider.php | 87 +++ .../Adapter/DefaultHasChildrenTrait.php | 7 +- .../Storage/Adapter/Dialect/MysqlDialect.php | 71 ++ .../Adapter/Dialect/PdoDialectInterface.php | 98 +++ .../Adapter/Dialect/PdoDialectTrait.php | 75 ++ .../Storage/Adapter/Dialect/SqliteDialect.php | 55 ++ .../Storage/Adapter/InMemoryStorage.php | 18 +- .../Storage/Adapter/JsonEntryBuffer.php | 25 +- .../Storage/Adapter/JsonFileStorage.php | 18 +- .../Backend/Storage/Adapter/MysqlStorage.php | 100 +++ .../Adapter/Operation/UpdateOperation.php | 10 +- .../PdoConnectionProviderInterface.php | 28 + .../Backend/Storage/Adapter/PdoStorage.php | 376 ++++++++++ .../Adapter/PdoStorageFactoryInterface.php | 28 + .../Adapter/PdoStorageFactoryTrait.php | 62 ++ .../Backend/Storage/Adapter/PdoTxState.php | 29 + .../Adapter/SharedPdoConnectionProvider.php | 43 ++ .../SqlFilter/FilterTranslatorInterface.php | 35 + .../SqlFilter/MysqlFilterTranslator.php | 51 ++ .../Adapter/SqlFilter/SqlFilterResult.php | 36 + .../SqlFilter/SqlFilterTranslatorTrait.php | 372 +++++++++ .../Adapter/SqlFilter/SqlFilterUtility.php | 48 ++ .../SqlFilter/SqliteFilterTranslator.php | 41 + .../Backend/Storage/Adapter/SqliteStorage.php | 81 ++ .../Backend/Storage/EntryStorageInterface.php | 16 +- .../Server/Backend/Storage/EntryStream.php | 37 + .../Storage/Exception/DnTooLongException.php | 25 + .../Exception/InvalidAttributeException.php | 26 + .../Exception/TimeLimitExceededException.php | 25 + .../Backend/Storage/StorageListOptions.php | 53 ++ .../Storage/WritableStorageBackend.php | 107 ++- .../Server/RequestHandler/ProxyBackend.php | 50 +- src/FreeDSx/Ldap/Server/RequestHistory.php | 19 +- tests/bin/ldap-backend-storage.php | 18 + tests/bin/ldap-proxy.php | 7 +- tests/bin/ldap-server.php | 31 +- tests/integration/ServerTestCase.php | 48 +- .../Storage/LdapBackendSqliteStorageTest.php | 113 +++ tests/integration/Sync/SyncReplTest.php | 1 + tests/unit/Entry/AttributeTest.php | 18 + tests/unit/Entry/DnTest.php | 74 ++ .../ServerDispatchHandlerTest.php | 46 +- .../ServerPagingHandlerTest.php | 14 +- .../ServerSearchHandlerTest.php | 14 +- .../CoroutinePdoConnectionProviderTest.php | 219 ++++++ .../Storage/Adapter/InMemoryStorageTest.php | 33 +- .../Storage/Adapter/JsonFileStorageTest.php | 27 +- .../Adapter/MysqlFilterTranslatorTest.php | 604 +++++++++++++++ .../Adapter/Operation/UpdateOperationTest.php | 68 ++ .../SqlFilter/SqlFilterUtilityTest.php | 60 ++ .../Adapter/SqliteFilterTranslatorTest.php | 703 ++++++++++++++++++ .../Storage/Adapter/SqliteStorageTest.php | 601 +++++++++++++++ .../Adapter/WritableStorageBackendTest.php | 106 +-- .../RequestHandler/ProxyBackendTest.php | 80 +- 68 files changed, 5193 insertions(+), 441 deletions(-) create mode 100644 src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/CollectedPage.php delete mode 100644 src/FreeDSx/Ldap/Server/Backend/SearchContext.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProvider.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoConnectionProviderInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SharedPdoConnectionProvider.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterResult.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtility.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/EntryStream.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Exception/DnTooLongException.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Exception/InvalidAttributeException.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Exception/TimeLimitExceededException.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php create mode 100644 tests/integration/Storage/LdapBackendSqliteStorageTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProviderTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtilityTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 73050ce7..723bfffa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,6 +44,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} + extensions: pdo_sqlite coverage: none tools: composer:2.9 diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index fd116524..f5fddcfb 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -190,21 +190,31 @@ $server = new LdapServer( **After** (1.0 `LdapBackendInterface`): ```php +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use Generator; class MyBackend implements LdapBackendInterface { - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + // Return an EntryStream wrapping a generator of candidate entries. + // Paging is handled automatically. + return new EntryStream($this->yieldNothing()); + } + + private function yieldNothing(): Generator { - // Yield matching entries. Paging is handled automatically. yield from []; } @@ -241,20 +251,29 @@ and plaintext. replicate the old `bind()` behaviour: ```php +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use Generator; use SensitiveParameter; class MyBackend implements LdapBackendInterface, PasswordAuthenticatableInterface { - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + return new EntryStream($this->yieldNothing()); + } + + private function yieldNothing(): Generator { yield from []; } @@ -305,12 +324,14 @@ $server = (new LdapServer()) a typed command object. Use `WritableBackendTrait` to implement the dispatch automatically: ```php +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -323,7 +344,14 @@ class MyBackend implements WritableLdapBackendInterface { use WritableBackendTrait; - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + return new EntryStream($this->yieldNothing()); + } + + private function yieldNothing(): Generator { yield from []; } diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index fc717896..cb122a54 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -13,6 +13,8 @@ General LDAP Server Usage * [Built-In Storage Implementations](#built-in-storage-implementations) * [InMemoryStorage](#inmemorystorage) * [JsonFileStorage](#jsonfilestorage) + * [SqliteStorage](#sqlitestorage) + * [MysqlStorage](#mysqlstorage) * [Proxy Backend](#proxy-backend) * [Custom Filter Evaluation](#custom-filter-evaluation) * [Authentication](#authentication) @@ -240,31 +242,40 @@ Authentication is a **separate concern** handled by `PasswordAuthenticatableInte ```php namespace App; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use Generator; class MyReadOnlyBackend implements LdapBackendInterface { /** - * Yield entries that are candidates for the search. + * Return an EntryStream of candidate entries for the search. * - * The FilterEvaluator as a final pass, so you may pre-filter for efficiency or + * The FilterEvaluator runs as a final pass, so you may pre-filter for efficiency or * yield all entries in scope for simplicity. */ - public function search(SearchContext $context): Generator - { - // $context->baseDn — Dn: the search base - // $context->scope — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE - // $context->filter — FilterInterface: the requested LDAP filter - // $context->attributes — Attribute[]: requested attributes (empty = all) - // $context->typesOnly — bool: return only attribute names, not values + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + // $request->getBaseDn() — ?Dn: the search base + // $request->getScope() — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE + // $request->getFilter() — FilterInterface: the requested LDAP filter + // $request->getAttributes() — Attribute[]: requested attributes (empty = all) + // $request->getAttributesOnly() — bool: return only attribute names, not values + + return new EntryStream($this->yieldEntries()); + } + private function yieldEntries(): Generator + { yield Entry::fromArray('cn=Foo,dc=example,dc=com', ['cn' => 'Foo', 'sn' => 'Bar']); yield Entry::fromArray('cn=Bar,dc=example,dc=com', ['cn' => 'Bar', 'sn' => 'Baz']); } @@ -311,12 +322,14 @@ to implement the write dispatch — it routes each operation to a dedicated meth ```php namespace App; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -329,9 +342,17 @@ class MyBackend implements WritableLdapBackendInterface { use WritableBackendTrait; - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + // Return an EntryStream wrapping a generator of matching entries... + return new EntryStream($this->yieldNothing()); + } + + private function yieldNothing(): Generator { - // yield matching entries... + yield from []; } public function get(Dn $dn): ?Entry @@ -386,7 +407,7 @@ class MyBackend implements WritableLdapBackendInterface ### Built-In Storage Implementations -Two storage implementations are included for common use cases. Both are used via `useStorage()`. +Four storage implementations are included for common use cases. All are used via `useStorage()`. #### InMemoryStorage @@ -451,6 +472,90 @@ JSON format: } ``` +#### SqliteStorage + +A SQLite-backed storage implementation that persists the directory in a SQLite database file via PDO. Suitable for +use cases that need durable persistence across restarts with support for concurrent access: + +- **PCNTL**: each forked child process shares the same database file. +- **Swoole**: a coroutine `Channel` mutex serialises writes within the single-process coroutine runtime. + +Use the named constructor that matches your server runner: + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; + +// PCNTL runner — WAL journal mode, NoopMutex (OS handles locking) +$server = (new LdapServer())->useStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite')); +$server->run(); + +// Swoole runner — WAL journal mode, coroutine Channel mutex +$server = (new LdapServer())->useStorage(SqliteStorage::forSwoole('/var/lib/myapp/ldap.sqlite')); +$server->run(); +``` + +Use `:memory:` as the path to run a non-persistent, in-process SQLite database (useful for testing): + +```php +$server = (new LdapServer())->useStorage(SqliteStorage::forPcntl(':memory:')); +``` + +#### MysqlStorage + +A MySQL/MariaDB-backed storage implementation. Requires MySQL 8.0+ or MariaDB 10.6+. + +Use the named constructor that matches your server runner: + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; + +// PCNTL runner — OS-level locking, NoopMutex +$server = (new LdapServer())->useStorage( + MysqlStorage::forPcntl('mysql:host=localhost;dbname=ldap', 'user', 'secret') +); +$server->run(); + +// Swoole runner — coroutine Channel mutex +$server = (new LdapServer())->useStorage( + MysqlStorage::forSwoole('mysql:host=localhost;dbname=ldap', 'user', 'secret') +); +$server->run(); +``` + +The DSN follows the standard PDO MySQL format. The character set is automatically configured to `utf8mb4` +and the time zone to UTC on each connection. + +##### Custom PDO driver + +`SqliteStorage` and `MysqlStorage` are both factories for `PdoStorage`, which is the generic PDO-backed +implementation. To support a different database engine, implement `PdoStorageFactoryInterface` and +supply the appropriate `PdoDialectInterface` and filter translator: + +```php +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorageFactoryInterface; + +final class PostgresStorage implements PdoStorageFactoryInterface +{ + public function create(): PdoStorage + { + $pdo = new PDO('pgsql:host=localhost;dbname=ldap', 'user', 'secret'); + + $dialect = new MyPostgresDialect(); + PdoStorage::initialize($pdo, $dialect); + + return new PdoStorage( + $pdo, + new NoopMutex(), + new MyPostgresFilterTranslator(), + $dialect, + ); + } +} +``` + ### Proxy Backend `ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server. diff --git a/src/FreeDSx/Ldap/Entry/Attribute.php b/src/FreeDSx/Ldap/Entry/Attribute.php index 2cae08ac..cd751962 100644 --- a/src/FreeDSx/Ldap/Entry/Attribute.php +++ b/src/FreeDSx/Ldap/Entry/Attribute.php @@ -77,13 +77,25 @@ public function add(string ...$values): self /** * Check if the attribute has a specific value. */ - public function has(string $value): bool - { - return in_array( - $value, - $this->values, - true - ); + public function has( + string $value, + bool $caseSensitive = true, + ): bool { + if ($caseSensitive) { + return in_array( + $value, + $this->values, + true + ); + } + + foreach ($this->values as $existing) { + if (strcasecmp($existing, $value) === 0) { + return true; + } + } + + return false; } /** @@ -91,9 +103,29 @@ public function has(string $value): bool */ public function remove(string ...$values): self { + return $this->removeValues($values); + } + + /** + * @param string[] $values + */ + public function removeValues( + array $values, + bool $caseSensitive = true, + ): self { foreach ($values as $value) { - if (($i = array_search($value, $this->values, true)) !== false) { - unset($this->values[$i]); + if ($caseSensitive) { + if (($i = array_search($value, $this->values, true)) !== false) { + unset($this->values[$i]); + } + + continue; + } + + foreach ($this->values as $i => $existing) { + if (strcasecmp($existing, $value) === 0) { + unset($this->values[$i]); + } } } diff --git a/src/FreeDSx/Ldap/Entry/Dn.php b/src/FreeDSx/Ldap/Entry/Dn.php index 4556d8f5..aedcead1 100644 --- a/src/FreeDSx/Ldap/Entry/Dn.php +++ b/src/FreeDSx/Ldap/Entry/Dn.php @@ -159,6 +159,36 @@ public function isChildOf(Dn $parent): bool && strtolower($myParent->toString()) === strtolower($parentDn); } + /** + * Return true if this DN is the same as, or a descendant of, $base. + * + * @throws UnexpectedValueException + */ + public function isDescendantOf(Dn $base): bool + { + $baseDn = $base->toString(); + $thisDn = $this->toString(); + + if ($baseDn === '') { + return $thisDn !== ''; + } + + $baseLower = strtolower($baseDn); + if (strtolower($thisDn) === $baseLower) { + return true; + } + + $parent = $this->getParent(); + while ($parent !== null) { + if (strtolower($parent->toString()) === $baseLower) { + return true; + } + $parent = $parent->getParent(); + } + + return false; + } + /** * @todo This needs proper handling. But the regex would probably be rather crazy. * diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/CollectedPage.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/CollectedPage.php new file mode 100644 index 00000000..6630426d --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/CollectedPage.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; + +use FreeDSx\Ldap\Entry\Entry; + +/** + * Holds the result of a single generator collection pass in a paging operation. + * + * @internal + * + * @author Chad Sikorra + */ +final class CollectedPage +{ + /** + * @param Entry[] $entries + */ + public function __construct( + public readonly array $entries, + public readonly bool $isGeneratorExhausted, + public readonly bool $isSizeLimitExceeded, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php index 42b1f7c6..3b743e64 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -43,29 +43,36 @@ public function __construct( /** * {@inheritDoc} - * @throws OperationException * @throws EncoderException */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, ): void { - $request = $message->getRequest(); + try { + $request = $message->getRequest(); - if ($request instanceof Request\CompareRequest) { - $match = $this->backend->compare($request->getDn(), $request->getFilter()); + if ($request instanceof Request\CompareRequest) { + $match = $this->backend->compare($request->getDn(), $request->getFilter()); + $this->queue->sendMessage($this->responseFactory->getStandardResponse( + $message, + $match + ? ResultCode::COMPARE_TRUE + : ResultCode::COMPARE_FALSE, + )); + + return; + } + + $this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request)); + + $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); + } catch (OperationException $e) { $this->queue->sendMessage($this->responseFactory->getStandardResponse( $message, - $match - ? ResultCode::COMPARE_TRUE - : ResultCode::COMPARE_FALSE, + $e->getCode(), + $e->getMessage(), )); - - return; } - - $this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request)); - - $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 802285e4..d681b5bc 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -13,18 +13,16 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Ldap\Control\Control; -use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Control\PagingControl; use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\ProtocolException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Paging\PagingRequest; use FreeDSx\Ldap\Server\Paging\PagingRequestComparator; use FreeDSx\Ldap\Server\Paging\PagingResponse; @@ -142,31 +140,29 @@ private function handlePaging( private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse { $searchRequest = $pagingRequest->getSearchRequest(); - $context = $this->makeSearchContext($searchRequest); - $generator = $this->backend->search($context); + $this->assertBaseDnProvided($searchRequest); - [$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator( + $result = $this->backend->search( + $searchRequest, + $pagingRequest->controls(), + ); + $generator = $result->entries; + $isPreFiltered = $result->isPreFiltered; + + $collected = $this->collectFromGenerator( $generator, $pagingRequest->getSize(), - $context, + $searchRequest, 0, + $isPreFiltered, ); - if ($sizeLimitExceeded) { - return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); - } - - $nextCookie = $this->generateCookie(); - $pagingRequest->updateNextCookie($nextCookie); - - if ($generatorExhausted) { - return PagingResponse::makeFinal(new Entries(...$page)); - } - - $pagingRequest->incrementTotalSent(count($page)); - $this->requestHistory->storePagingGenerator($nextCookie, $generator); - - return PagingResponse::make(new Entries(...$page), 0); + return $this->buildPagingResponse( + $collected, + $pagingRequest, + $generator, + $isPreFiltered, + ); } /** @@ -188,8 +184,6 @@ private function handleExistingCookie( $pagingRequest->updatePagingControl($this->getPagingControlFromMessage($message)); if ($pagingRequest->isAbandonRequest()) { - $this->requestHistory->removePagingGenerator($pagingRequest->getNextCookie()); - return PagingResponse::makeFinal(new Entries()); } @@ -203,61 +197,83 @@ private function handleExistingCookie( ); } + $isPreFiltered = $this->requestHistory->getPagingGeneratorIsPreFiltered($currentCookie); $this->requestHistory->removePagingGenerator($currentCookie); - $searchRequest = $pagingRequest->getSearchRequest(); - $context = $this->makeSearchContext($searchRequest); - - [$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator( + $collected = $this->collectFromGenerator( $generator, $pagingRequest->getSize(), - $context, + $pagingRequest->getSearchRequest(), $pagingRequest->getTotalSent(), + $isPreFiltered, + ); + + return $this->buildPagingResponse( + $collected, + $pagingRequest, + $generator, + $isPreFiltered ); + } - if ($sizeLimitExceeded) { - return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + /** + * @throws OperationException + */ + private function buildPagingResponse( + CollectedPage $collected, + PagingRequest $pagingRequest, + Generator $generator, + bool $isPreFiltered, + ): PagingResponse { + if ($collected->isSizeLimitExceeded) { + return PagingResponse::makeSizeLimitExceeded(new Entries(...$collected->entries)); } $nextCookie = $this->generateCookie(); $pagingRequest->updateNextCookie($nextCookie); - if ($generatorExhausted) { - return PagingResponse::makeFinal(new Entries(...$page)); + if ($collected->isGeneratorExhausted) { + return PagingResponse::makeFinal(new Entries(...$collected->entries)); } - $pagingRequest->incrementTotalSent(count($page)); - $this->requestHistory->storePagingGenerator($nextCookie, $generator); + $pagingRequest->incrementTotalSent(count($collected->entries)); + $this->requestHistory->storePagingGenerator( + $nextCookie, + $generator, + $isPreFiltered, + ); - return PagingResponse::make(new Entries(...$page), 0); + return PagingResponse::make( + new Entries(...$collected->entries) + ); } /** * Advances the generator, collecting up to $pageSize entries that pass the filter. * - * Also enforces the client's sizeLimit from SearchContext. When the sizeLimit is - * reached before the generator is exhausted, $sizeLimitExceeded is true in the return. - * - * @return array{Entry[], bool, bool} [$page, $generatorExhausted, $sizeLimitExceeded] + * Also enforces the client's sizeLimit from the SearchRequest. When the sizeLimit is + * reached before the generator is exhausted, $isSizeLimitExceeded is true in the return. */ private function collectFromGenerator( Generator $generator, int $pageSize, - SearchContext $context, + SearchRequest $request, int $totalAlreadySent, - ): array { + bool $isPreFiltered = false, + ): CollectedPage { $page = []; $pageLimit = $pageSize > 0 ? $pageSize : PHP_INT_MAX; - $sizeLimit = $context->sizeLimit; + $sizeLimit = $request->getSizeLimit(); + $filter = $request->getFilter(); while ($generator->valid() && count($page) < $pageLimit) { $entry = $generator->current(); - if ($entry instanceof Entry && $this->filterEvaluator->evaluate($entry, $context->filter)) { + if ($entry instanceof Entry && ($isPreFiltered || $this->filterEvaluator->evaluate($entry, $filter))) { $page[] = $this->applyAttributeFilter( $entry, - $context->attributes, - $context->typesOnly + $request->getAttributes(), + $request->getAttributesOnly(), ); if ($sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit) { @@ -274,11 +290,11 @@ private function collectFromGenerator( && $sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit; - return [ + return new CollectedPage( $page, $generatorExhausted, $sizeLimitExceeded, - ]; + ); } /** @@ -307,17 +323,10 @@ private function makePagingRequest(LdapMessageRequest $message): PagingRequest $request = $this->getSearchRequestFromMessage($message); $pagingControl = $this->getPagingControlFromMessage($message); - $filteredControls = array_filter( - $message->controls()->toArray(), - static function (Control $control): bool { - return $control->getTypeOid() !== Control::OID_PAGING; - } - ); - return new PagingRequest( $pagingControl, $request, - new ControlBag(...$filteredControls), + $this->nonPagingControls($message), $this->generateCookie() ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index d38b9628..d307ce93 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -48,20 +48,27 @@ public function handleRequest( $request = $this->getSearchRequestFromMessage($message); try { - $context = $this->makeSearchContext($request); - $filter = $context->filter; - $attributes = $context->attributes; - $typesOnly = $context->typesOnly; - $sizeLimit = $context->sizeLimit; + $this->assertBaseDnProvided($request); $results = []; $sizeLimitExceeded = false; + $sizeLimit = $request->getSizeLimit(); - foreach ($this->backend->search($context) as $entry) { - if ($this->filterEvaluator->evaluate($entry, $filter)) { - $results[] = $this->applyAttributeFilter($entry, $attributes, $typesOnly); + $result = $this->backend->search( + $request, + $this->nonPagingControls($message), + ); + + foreach ($result->entries as $entry) { + if ($result->isPreFiltered || $this->filterEvaluator->evaluate($entry, $request->getFilter())) { + $results[] = $this->applyAttributeFilter( + $entry, + $request->getAttributes(), + $request->getAttributesOnly(), + ); if ($sizeLimit > 0 && count($results) >= $sizeLimit) { $sizeLimitExceeded = true; + break; } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php index c54f72e3..69e606b1 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Control\PagingControl; use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Entry; @@ -26,7 +27,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\Backend\SearchContext; trait ServerSearchTrait { @@ -92,23 +92,28 @@ private function getPagingControlFromMessage(LdapMessageRequest $message): Pagin /** * @throws OperationException */ - private function makeSearchContext(SearchRequest $request): SearchContext + private function assertBaseDnProvided(SearchRequest $request): void { - $baseDn = $request->getBaseDn(); - - if ($baseDn === null) { - throw new OperationException('No base DN provided.', ResultCode::PROTOCOL_ERROR); + if ($request->getBaseDn() === null) { + throw new OperationException( + 'No base DN provided.', + ResultCode::PROTOCOL_ERROR, + ); } + } - return new SearchContext( - baseDn: $baseDn, - scope: $request->getScope(), - filter: $request->getFilter(), - attributes: $request->getAttributes(), - typesOnly: $request->getAttributesOnly(), - sizeLimit: $request->getSizeLimit(), - timeLimit: $request->getTimeLimit(), + /** + * Returns a ControlBag containing the message controls minus the paging control, + * which the server consumes itself and must not forward to backends. + */ + private function nonPagingControls(LdapMessageRequest $message): ControlBag + { + $filtered = array_filter( + $message->controls()->toArray(), + static fn (Control $control): bool => $control->getTypeOid() !== Control::OID_PAGING, ); + + return new ControlBag(...$filtered); } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php index aeb951f7..989ae400 100644 --- a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php @@ -13,11 +13,14 @@ namespace FreeDSx\Ldap\Server\Backend; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use Generator; /** @@ -30,7 +33,17 @@ */ final class GenericBackend implements LdapBackendInterface { - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + return new EntryStream($this->yieldNothing()); + } + + /** + * @return Generator + */ + private function yieldNothing(): Generator { yield from []; } diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php index c8403f6c..f8259254 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -13,11 +13,13 @@ namespace FreeDSx\Ldap\Server\Backend; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Search\Filter\EqualityFilter; -use Generator; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; /** * The core contract for an LDAP backend storage implementation. @@ -38,14 +40,17 @@ interface LdapBackendInterface { /** - * Yield Entry objects matching (or potentially matching) the given search context. + * Return an EntryStream for the given search context. * - * Applies FilterEvaluator after receiving each entry, so implementations may pre-filter (for efficiency) or yield - * all in-scope candidates (for simplicity). - * - * @return Generator + * The result wraps a lazy generator of candidate entries and a flag indicating whether the backend has already + * applied the filter exactly. When EntryStream::$isPreFiltered is true, the caller may skip PHP-level + * FilterEvaluator evaluation. Otherwise, all yielded entries are passed through FilterEvaluator as a final + * correctness pass. */ - public function search(SearchContext $context): Generator; + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream; /** * Fetch a single entry by DN, or return null if it does not exist. diff --git a/src/FreeDSx/Ldap/Server/Backend/SearchContext.php b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php deleted file mode 100644 index 391cdfd8..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/SearchContext.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend; - -use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Search\Filter\FilterInterface; - -/** - * Encapsulates the parameters of an LDAP search operation for use by - * LdapBackendInterface::search(). - * - * The backend receives the complete context including the filter. It may - * translate the filter to a native query language (SQL, MongoDB, etc.) or - * ignore it and yield all in-scope entries for the framework to filter. - * - * @author Chad Sikorra - */ -final class SearchContext -{ - /** - * @param Attribute[] $attributes Requested attribute list; empty means all attributes. - */ - public function __construct( - readonly public Dn $baseDn, - readonly public int $scope, - readonly public FilterInterface $filter, - readonly public array $attributes, - readonly public bool $typesOnly, - readonly public int $sizeLimit = 0, - readonly public int $timeLimit = 0, - ) { - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php index 8a3c6994..b4c2f3b7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php @@ -39,19 +39,13 @@ private function yieldByScope( Dn $baseDn, bool $subtree, ): Generator { - $normBase = $baseDn->toString(); - foreach ($entries as $normDn => $entry) { - if ($normBase === '' && $subtree) { + $entryDn = new Dn($normDn); + + if ($subtree && $entryDn->isDescendantOf($baseDn)) { + yield $entry; + } elseif (!$subtree && $entryDn->isChildOf($baseDn)) { yield $entry; - } elseif ($subtree) { - if ($normDn === $normBase || str_ends_with($normDn, ',' . $normBase)) { - yield $entry; - } - } else { - if ((new Dn($normDn))->isChildOf($baseDn)) { - yield $entry; - } } } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProvider.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProvider.php new file mode 100644 index 00000000..36f48c83 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProvider.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use Closure; +use FreeDSx\Ldap\Exception\RuntimeException; +use PDO; +use Swoole\Coroutine; + +/** + * Creates and caches a fresh PDO connection per Swoole coroutine. + * + * @author Chad Sikorra + */ +final class CoroutinePdoConnectionProvider implements PdoConnectionProviderInterface +{ + /** + * @var array + */ + private array $connections = []; + + /** + * @var array + */ + private array $txStates = []; + + /** + * @param Closure(): PDO $factory Creates and fully initialises a fresh PDO each time it is invoked. + */ + public function __construct(private readonly Closure $factory) + { + } + + public function get(): PDO + { + $cid = $this->requireCoroutineId(); + + if (!isset($this->connections[$cid])) { + $this->connections[$cid] = ($this->factory)(); + $this->txStates[$cid] = new PdoTxState(); + + Coroutine::defer(function () use ($cid): void { + unset( + $this->connections[$cid], + $this->txStates[$cid], + ); + }); + } + + return $this->connections[$cid]; + } + + public function txState(): PdoTxState + { + $cid = $this->requireCoroutineId(); + + if (!isset($this->txStates[$cid])) { + $this->get(); + } + + return $this->txStates[$cid]; + } + + private function requireCoroutineId(): int + { + $cid = Coroutine::getCid(); + + if ($cid === -1) { + throw new RuntimeException( + self::class . ' can only be used inside a Swoole coroutine.', + ); + } + + return $cid; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php index 5b6e2f71..5a87337d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php @@ -14,7 +14,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; use FreeDSx\Ldap\Entry\Dn; -use Generator; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; /** * Provides a default hasChildren() implementation in terms of list(). @@ -27,11 +28,11 @@ */ trait DefaultHasChildrenTrait { - abstract public function list(Dn $baseDn, bool $subtree): Generator; + abstract public function list(StorageListOptions $options): EntryStream; public function hasChildren(Dn $dn): bool { - foreach ($this->list($dn, false) as $ignored) { + foreach ($this->list(StorageListOptions::matchAll(baseDn: $dn, subtree: false))->entries as $ignored) { return true; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php new file mode 100644 index 00000000..e3bf9fc7 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; + +/** + * MySQL/MariaDB-specific SQL for PdoStorage. + * + * Requires MySQL 8.0+ or MariaDB 10.6+. + * + * @author Chad Sikorra + */ +final class MysqlDialect implements PdoDialectInterface +{ + use PdoDialectTrait; + + /** + * The 768-character DN columns are sized to the maximum that still fits InnoDB's + * 3072-byte index key prefix under utf8mb4 (4 bytes/char), which is required for + * PRIMARY KEY (lc_dn). + */ + public function ddlCreateTable(): string + { + return << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; + +/** + * Provides the database-specific SQL strings used by PdoStorage. + * + * @author Chad Sikorra + */ +interface PdoDialectInterface +{ + /** + * DDL to create the entries table if it does not already exist. + * + * Required columns: + * lc_dn — primary key, lowercased DN string + * dn — original-case DN string + * lc_parent_dn — lowercased parent DN string (empty string for root entries) + * attributes — JSON object mapping lowercased attribute names to string-value arrays + */ + public function ddlCreateTable(): string; + + /** + * DDL to create an index on lc_parent_dn if it does not already exist. + * + * Return null if the index is already defined inline in ddlCreateTable() + * (e.g. MySQL includes it in the CREATE TABLE body). PdoStorage::initialize() skips + * execution when null is returned. + */ + public function ddlCreateIndex(): ?string; + + /** + * SELECT dn, attributes WHERE lc_dn = ? + * + * Parameters: [lc_dn] + */ + public function queryFetchEntry(): string; + + /** + * SELECT dn, attributes with no WHERE clause — returns all entries. + */ + public function queryFetchAll(): string; + + /** + * SELECT dn, attributes WHERE lc_parent_dn = ? + * + * Parameters: [lc_parent_dn] + */ + public function queryFetchChildren(): string; + + /** + * SELECT dn, attributes WHERE (lc_dn = ? OR lc_dn LIKE ? ESCAPE '!'). + * + * Used for subtree searches. PdoStorage appends `AND (filter)` when a translated + * filter is available, so the WHERE clause must be wrapped in parentheses to + * preserve AND/OR precedence when the filter is appended. + * + * Parameters: [lc_dn, like_pattern] + */ + public function querySubtree(): string; + + /** + * Returns at least one row when children exist under lc_parent_dn, zero rows otherwise. + * + * Parameters: [lc_parent_dn] + */ + public function queryHasChildren(): string; + + /** + * INSERT or UPDATE (upsert) a single entry. + * + * Parameters: [lc_dn, dn, lc_parent_dn, attributes] + */ + public function queryUpsert(): string; + + /** + * DELETE FROM entries WHERE lc_dn = ? + * + * Parameters: [lc_dn] + */ + public function queryDelete(): string; + + /** + * Maximum DN byte-length allowed by the storage backend, or null if there is no practical limit. + */ + public function maxDnLength(): ?int; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php new file mode 100644 index 00000000..32364e85 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; + +/** + * Standard SQL that should be cross-platform across the adapters. + * + * @author Chad Sikorra + */ +trait PdoDialectTrait +{ + public function queryFetchEntry(): string + { + return << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; + +/** + * SQLite-specific SQL for PdoStorage. + * + * @author Chad Sikorra + */ +final class SqliteDialect implements PdoDialectInterface +{ + use PdoDialectTrait; + + public function ddlCreateTable(): string + { + return <<entries[$dn->toString()] ?? null; } - /** - * @return Generator - */ - public function list(Dn $baseDn, bool $subtree): Generator + public function list(StorageListOptions $options): EntryStream { - return $this->yieldByScope( - $this->entries, - $baseDn, - $subtree + return new EntryStream( + $this->yieldByScope( + $this->entries, + $options->baseDn, + $options->subtree, + ), ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php index d362b5ac..9cb9cd5d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php @@ -16,7 +16,9 @@ use Closure; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; use Generator; /** @@ -61,24 +63,23 @@ public function find(Dn $dn): ?Entry return ($this->toEntry)($this->data[$key]); } + public function list(StorageListOptions $options): EntryStream + { + return new EntryStream($this->generateEntries($options)); + } + /** * @return Generator */ - public function list(Dn $baseDn, bool $subtree): Generator + private function generateEntries(StorageListOptions $options): Generator { - $normBase = $baseDn->toString(); - foreach ($this->data as $normDn => $entryData) { - if ($normBase === '' && $subtree) { + $entryDn = new Dn($normDn); + + if ($options->subtree && $entryDn->isDescendantOf($options->baseDn)) { + yield ($this->toEntry)($entryData); + } elseif (!$options->subtree && $entryDn->isChildOf($options->baseDn)) { yield ($this->toEntry)($entryData); - } elseif ($subtree) { - if ($normDn === $normBase || str_ends_with($normDn, ',' . $normBase)) { - yield ($this->toEntry)($entryData); - } - } else { - if ((new Dn($normDn))->isChildOf($baseDn)) { - yield ($this->toEntry)($entryData); - } } } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index 5c1a94ae..059fa4b8 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -19,8 +19,9 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Lock\CoroutineLock; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Lock\FileLock; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Lock\StorageLockInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use Generator; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; /** * A file-backed storage implementation that persists entries as a JSON file. @@ -90,15 +91,14 @@ public function find(Dn $dn): ?Entry return $this->read()[$dn->toString()] ?? null; } - /** - * @return Generator - */ - public function list(Dn $baseDn, bool $subtree): Generator + public function list(StorageListOptions $options): EntryStream { - return $this->yieldByScope( - $this->read(), - $baseDn, - $subtree + return new EntryStream( + $this->yieldByScope( + $this->read(), + $options->baseDn, + $options->subtree, + ), ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php new file mode 100644 index 00000000..31a0121b --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\MysqlDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; +use PDO; + +/** + * MySQL/MariaDB-specific factory for PdoStorage. + * + * Use the named constructors to select the appropriate runner: + * + * MysqlStorage::forPcntl('mysql:host=localhost;dbname=ldap', 'user', 'pass') + * MysqlStorage::forSwoole('mysql:host=localhost;dbname=ldap', 'user', 'pass') + * + * Requires MySQL 8.0+ or MariaDB 10.6+. + * + * @author Chad Sikorra + */ +final class MysqlStorage implements PdoStorageFactoryInterface +{ + use PdoStorageFactoryTrait; + + public function __construct( + private readonly string $dsn, + private readonly string $username, + #[\SensitiveParameter] + private readonly string $password, + ) { + } + + public static function forPcntl( + string $dsn, + string $username, + #[\SensitiveParameter] + string $password, + ): PdoStorage { + return (new self( + $dsn, + $username, + $password, + ))->createShared(); + } + + public static function forSwoole( + string $dsn, + string $username, + #[\SensitiveParameter] + string $password, + ): PdoStorage { + return (new self( + $dsn, + $username, + $password, + ))->createPerCoroutine(); + } + + protected function dialect(): PdoDialectInterface + { + return new MysqlDialect(); + } + + protected function translator(): FilterTranslatorInterface + { + return new MysqlFilterTranslator(); + } + + protected function openConnection(PdoDialectInterface $dialect): PDO + { + $pdo = new PDO( + $this->dsn, + $this->username, + $this->password, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ], + ); + $pdo->exec("SET NAMES utf8mb4"); + $pdo->exec("SET time_zone = '+00:00'"); + + PdoStorage::initialize($pdo, $dialect); + + return $pdo; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php index 1660e563..840f0d28 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php @@ -62,7 +62,7 @@ private function applyAdd( } foreach ($attribute->getValues() as $value) { - if ($existing->has($value)) { + if ($existing->has($value, caseSensitive: false)) { throw new OperationException( sprintf('Attribute "%s" already contains the value "%s".', $attrName, $value), ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, @@ -137,14 +137,14 @@ private function deleteSpecificValues( $rdnValue = $entry->getDn()->getRdn()->getValue(); foreach ($values as $value) { - if (!$existing->has($value)) { + if (!$existing->has($value, caseSensitive: false)) { throw new OperationException( sprintf('Value "%s" does not exist in attribute "%s".', $value, $attrName), ResultCode::NO_SUCH_ATTRIBUTE, ); } - if ($this->isRdnAttribute($entry, $attrName) && $value === $rdnValue) { + if ($this->isRdnAttribute($entry, $attrName) && strcasecmp($value, $rdnValue) === 0) { throw new OperationException( sprintf( 'Value "%s" is the RDN value for attribute "%s" and cannot be removed.', @@ -156,7 +156,7 @@ private function deleteSpecificValues( } } - $existing->remove(...$values); + $existing->removeValues($values, caseSensitive: false); } /** @@ -179,7 +179,7 @@ private function applyReplace(Entry $entry, Change $change): void $rdnValue = $entry->getDn()->getRdn()->getValue(); - if ($this->isRdnAttribute($entry, $attrName) && !in_array($rdnValue, $values, true)) { + if ($this->isRdnAttribute($entry, $attrName) && !$attribute->has($rdnValue, caseSensitive: false)) { throw new OperationException( sprintf( 'Replacing attribute "%s" must retain the RDN value "%s".', diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoConnectionProviderInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoConnectionProviderInterface.php new file mode 100644 index 00000000..69714002 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoConnectionProviderInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use PDO; + +/** + * Resolves a PDO connection and its transaction state for the current execution context. + * + * @author Chad Sikorra + */ +interface PdoConnectionProviderInterface +{ + public function get(): PDO; + + public function txState(): PdoTxState; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php new file mode 100644 index 00000000..888a412c --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -0,0 +1,376 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\DnTooLongException; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterResult; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterUtility; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; +use Generator; +use JsonException; +use PDO; +use PDOStatement; +use Throwable; + +/** + * A PDO-backed storage implementation that persists LDAP entries using a relational database. + * + * Pass a PdoDialectInterface to target the desired database engine and a PdoConnectionProviderInterface to resolve the + * PDO connection for the current execution context. + * + * When injecting a pre-existing PDO (e.g. for testing), wrap it in a SharedPdoConnectionProvider and call initialize() + * first: + * + * $dialect = new SqliteDialect(); + * PdoStorage::initialize($pdo, $dialect); + * $storage = new PdoStorage( + * new SharedPdoConnectionProvider($pdo), + * $translator, + * $dialect, + * ); + * + * @author Chad Sikorra + */ +final class PdoStorage implements EntryStorageInterface +{ + public function __construct( + private readonly PdoConnectionProviderInterface $provider, + private readonly FilterTranslatorInterface $translator, + private readonly PdoDialectInterface $dialect, + ) { + } + + public static function initialize( + PDO $pdo, + PdoDialectInterface $dialect, + ): void { + $pdo->setAttribute( + PDO::ATTR_ERRMODE, + PDO::ERRMODE_EXCEPTION, + ); + $pdo->setAttribute( + PDO::ATTR_DEFAULT_FETCH_MODE, + PDO::FETCH_ASSOC, + ); + + $pdo->exec($dialect->ddlCreateTable()); + + $indexDdl = $dialect->ddlCreateIndex(); + if ($indexDdl !== null) { + $pdo->exec($indexDdl); + } + } + + public function find(Dn $dn): ?Entry + { + $stmt = $this->prepareAndExecute( + $this->dialect->queryFetchEntry(), + [$dn->toString()], + ); + $row = $stmt->fetch(); + + return $row !== false + ? $this->rowToEntry($row) + : null; + } + + public function list(StorageListOptions $options): EntryStream + { + $filterResult = $this->translator->translate($options->filter); + $isPreFiltered = $filterResult !== null && $filterResult->isExact; + + $stmt = $this->buildListStatement( + $options->baseDn->toString(), + $options->subtree, + $filterResult, + ); + + $deadline = $options->timeLimit > 0 + ? microtime(true) + $options->timeLimit + : null; + + return new EntryStream( + $this->generateRows($stmt, $deadline), + $isPreFiltered, + ); + } + + /** + * @return Generator + */ + private function generateRows( + PDOStatement $stmt, + ?float $deadline, + ): Generator { + while (($row = $stmt->fetch()) !== false) { + if ($deadline !== null && microtime(true) >= $deadline) { + throw new TimeLimitExceededException(); + } + + $entry = $this->rowToEntry($row); + if ($entry !== null) { + yield $entry; + } + } + } + + public function store(Entry $entry): void + { + $normDn = $entry->getDn()->normalize(); + $dnString = $entry->getDn()->toString(); + + $this->assertDnFits($dnString); + + $this->prepareAndExecute($this->dialect->queryUpsert(), [ + $normDn->toString(), + $dnString, + $normDn->getParent()?->toString() ?? '', + $this->encodeAttributes($entry), + ]); + } + + /** + * @throws DnTooLongException when the DN exceeds the dialect's maximum supported length + */ + private function assertDnFits(string $dn): void + { + $max = $this->dialect->maxDnLength(); + if ($max === null) { + return; + } + + $length = strlen($dn); + if ($length <= $max) { + return; + } + + throw new DnTooLongException( + sprintf( + 'DN length %d exceeds the storage backend limit of %d bytes.', + $length, + $max, + ), + ); + } + + public function remove(Dn $dn): void + { + $this->prepareAndExecute( + $this->dialect->queryDelete(), + [$dn->toString()], + ); + } + + public function hasChildren(Dn $dn): bool + { + $stmt = $this->prepareAndExecute( + $this->dialect->queryHasChildren(), + [$dn->toString()], + ); + + return $stmt->fetch() !== false; + } + + public function atomic(callable $operation): void + { + $pdo = $this->provider->get(); + $txState = $this->provider->txState(); + + $depth = $txState->depth++; + $savepointCreated = false; + + try { + if ($depth === 0) { + $pdo->beginTransaction(); + } else { + $pdo->exec("SAVEPOINT {$this->savepointName($depth)}"); + $savepointCreated = true; + } + + $operation($this); + + if ($depth === 0 && $txState->broken) { + $pdo->rollBack(); + } elseif ($depth === 0) { + $pdo->commit(); + } else { + $pdo->exec("RELEASE SAVEPOINT {$this->savepointName($depth)}"); + } + } catch (Throwable $e) { + if ($depth === 0 && $pdo->inTransaction()) { + $pdo->rollBack(); + } elseif ($depth > 0 && $savepointCreated) { + $pdo->exec("ROLLBACK TO SAVEPOINT {$this->savepointName($depth)}"); + } elseif ($depth > 0) { + // Savepoint creation itself failed; the outer transaction is now in an unknown state and must not be committed. + $txState->broken = true; + } + + throw $e; + } finally { + $txState->depth--; + if ($txState->depth === 0) { + $txState->broken = false; + } + } + } + + private function buildListStatement( + string $base, + bool $subtree, + ?SqlFilterResult $filterResult, + ): PDOStatement { + if (!$subtree) { + return $this->buildChildQuery($base, $filterResult); + } + + if ($base === '') { + return $this->buildRootQuery($filterResult); + } + + return $this->buildSubtreeQuery($base, $filterResult); + } + + private function buildChildQuery( + string $base, + ?SqlFilterResult $filterResult, + ): PDOStatement { + if ($filterResult === null) { + return $this->prepareAndExecute( + $this->dialect->queryFetchChildren(), + [$base], + ); + } + + return $this->prepareAndExecute( + $this->dialect->queryFetchChildren() . ' AND (' . $filterResult->sql . ')', + array_merge([$base], $filterResult->params), + ); + } + + private function buildRootQuery(?SqlFilterResult $filterResult): PDOStatement + { + if ($filterResult === null) { + return $this->prepareAndExecute($this->dialect->queryFetchAll()); + } + + return $this->prepareAndExecute( + $this->dialect->queryFetchAll() . ' WHERE (' . $filterResult->sql . ')', + $filterResult->params, + ); + } + + private function buildSubtreeQuery( + string $base, + ?SqlFilterResult $filterResult, + ): PDOStatement { + $pattern = '%,' . SqlFilterUtility::escape($base); + $params = [$base, $pattern]; + $sql = $this->dialect->querySubtree(); + + if ($filterResult !== null) { + $sql .= ' AND (' . $filterResult->sql . ')'; + $params = array_merge($params, $filterResult->params); + } + + return $this->prepareAndExecute($sql, $params); + } + + private function encodeAttributes(Entry $entry): string + { + $attributes = []; + + foreach ($entry->getAttributes() as $attribute) { + $attributes[strtolower($attribute->getName())] = [ + 'name' => $attribute->getName(), + 'values' => array_values($attribute->getValues()), + ]; + } + + return json_encode( + $attributes, + JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + } + + private function rowToEntry(mixed $row): ?Entry + { + if (!is_array($row)) { + return null; + } + + $dn = isset($row['dn']) && is_string($row['dn']) ? $row['dn'] : ''; + $attributesJson = isset($row['attributes']) && is_string($row['attributes']) + ? $row['attributes'] + : '{}'; + + try { + $raw = json_decode($attributesJson, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (!is_array($raw)) { + return null; + } + + $attributes = []; + + foreach ($raw as $lcName => $slot) { + if (!is_string($lcName) || !is_array($slot)) { + continue; + } + + $displayName = isset($slot['name']) && is_string($slot['name']) + ? $slot['name'] + : $lcName; + $values = isset($slot['values']) && is_array($slot['values']) + ? $slot['values'] + : []; + + $stringValues = array_values( + array_filter($values, fn($v) => is_string($v)), + ); + $attributes[] = new Attribute($displayName, ...$stringValues); + } + + return new Entry(new Dn($dn), ...$attributes); + } + + /** + * @param list $params + */ + private function prepareAndExecute( + string $query, + array $params = [], + ): PDOStatement { + $stmt = $this->provider->get()->prepare($query); + $stmt->execute($params); + + return $stmt; + } + + private function savepointName(int $depth): string + { + return "sp_{$depth}"; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryInterface.php new file mode 100644 index 00000000..bf69a6bb --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +/** + * Constructs a configured PdoStorage instance for a specific database driver. + * + * Example: + * + * $storage = SqliteStorage::forPcntl('/path/to/db.sqlite') + * + * @author Chad Sikorra + */ +interface PdoStorageFactoryInterface +{ + public function create(): PdoStorage; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryTrait.php new file mode 100644 index 00000000..6b0b5bb1 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorageFactoryTrait.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use PDO; + +/** + * Shared factory wiring for PdoStorage. + * + * Concrete factories provide the dialect, translator, and connection opener; + * this trait assembles the PdoStorage with the correct connection provider. + * + * @author Chad Sikorra + */ +trait PdoStorageFactoryTrait +{ + abstract protected function dialect(): PdoDialectInterface; + + abstract protected function translator(): FilterTranslatorInterface; + + abstract protected function openConnection(PdoDialectInterface $dialect): PDO; + + public function create(): PdoStorage + { + return $this->createShared(); + } + + protected function createShared(): PdoStorage + { + $dialect = $this->dialect(); + + return new PdoStorage( + new SharedPdoConnectionProvider($this->openConnection($dialect)), + $this->translator(), + $dialect, + ); + } + + protected function createPerCoroutine(): PdoStorage + { + $dialect = $this->dialect(); + + return new PdoStorage( + new CoroutinePdoConnectionProvider(fn(): PDO => $this->openConnection($dialect)), + $this->translator(), + $dialect, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php new file mode 100644 index 00000000..07d73ecd --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +/** + * Mutable transaction-depth counter bound to a single PDO connection. + * + * @author Chad Sikorra + */ +final class PdoTxState +{ + public int $depth = 0; + + /** + * Set when a nested savepoint operation fails before establishing its savepoint. + */ + public bool $broken = false; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SharedPdoConnectionProvider.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SharedPdoConnectionProvider.php new file mode 100644 index 00000000..8168ebf4 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SharedPdoConnectionProvider.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use PDO; + +/** + * Returns a single shared PDO connection and transaction state. + * + * Used by the PCNTL runner and by unit tests that inject a pre-built PDO. + * + * @author Chad Sikorra + */ +final class SharedPdoConnectionProvider implements PdoConnectionProviderInterface +{ + private readonly PdoTxState $txState; + + public function __construct(private readonly PDO $pdo) + { + $this->txState = new PdoTxState(); + } + + public function get(): PDO + { + return $this->pdo; + } + + public function txState(): PdoTxState + { + return $this->txState; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php new file mode 100644 index 00000000..ffd5731f --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +use FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * Translates an LDAP FilterInterface into a SQL fragment for use in a WHERE clause. + * + * Return values: + * - null — filter cannot be expressed in SQL; all in-scope + * entries are returned; PHP eval must run over the + * full candidate set. + * - SqlFilterResult (isExact: true) — the SQL exactly captures the filter semantics; + * PHP eval can safely be skipped for these entries. + * - SqlFilterResult (isExact: false) — partial SQL (superset); PHP eval still runs + * but only over the already-reduced candidate set. + * + * @author Chad Sikorra + */ +interface FilterTranslatorInterface +{ + public function translate(FilterInterface $filter): ?SqlFilterResult; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php new file mode 100644 index 00000000..51203010 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +/** + * Translates LDAP filters into MySQL-compatible SQL WHERE clause fragments. + * + * Requires MySQL 8.0+ or MariaDB 10.6+. + * + * @author Chad Sikorra + */ +final class MysqlFilterTranslator implements FilterTranslatorInterface +{ + use SqlFilterTranslatorTrait; + + protected function buildPresenceCheck(string $attribute): string + { + return "JSON_CONTAINS_PATH(attributes, 'one', '$.\"{$attribute}\"')"; + } + + protected function buildValueExists( + string $attribute, + string $innerCondition, + ): string { + return << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +/** + * An immutable value object holding a translated SQL filter fragment and its bound parameters. + * + * @see FilterTranslatorInterface + * + * @author Chad Sikorra + */ +final class SqlFilterResult +{ + /** + * @param list $params + * @param list $referencedAttributes Attributes whose absence makes the filter undefined under RFC 4511 + */ + public function __construct( + public readonly string $sql, + public readonly array $params, + public readonly bool $isExact = true, + public readonly array $referencedAttributes = [], + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php new file mode 100644 index 00000000..f92f56d2 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php @@ -0,0 +1,372 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\LessThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\NotFilter; +use FreeDSx\Ldap\Search\Filter\OrFilter; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Search\Filter\SubstringFilter; + +/** + * Provides a reusable LDAP-filter-to-SQL translation implementation. + * + * Implementing classes must provide two driver-specific primitives: + * - buildPresenceCheck(string $attribute): SQL fragment testing whether an attribute key exists + * - buildValueExists(string $attribute, string $innerCondition): SQL fragment iterating attribute values + * + * All AND/OR/NOT composition, substring/LIKE handling, GTE/LTE comparisons, + * and LIKE escaping are generic and provided here. + * + * Translation rules: + * - AND: translates only the translatable children; partial results are allowed + * - OR: all children must be translatable; returns null if any child is not + * - NOT: translatable only if the child is. Honors RFC 4511 §4.5.1.7 + * - MatchingRuleFilter: always null / not supported + * + * @author Chad Sikorra + */ +trait SqlFilterTranslatorTrait +{ + /** + * Returns a SQL fragment that tests whether an attribute key is present. + * + * $attribute is pre-validated as [a-z][a-z0-9-]* and safe to embed in SQL. + * + * Example (SQLite): "json_type(attributes, '$.cn') IS NOT NULL" + * Example (MySQL): "JSON_CONTAINS_PATH(attributes, 'one', '$.cn')" + */ + abstract protected function buildPresenceCheck(string $attribute): string; + + /** + * Wraps an inner condition so that it is evaluated for each value of an attribute. + * + * $attribute is pre-validated as [a-z][a-z0-9-]* and safe to embed in SQL. + * $innerCondition references the current value via the alias returned by valueAlias(). + * + * Example (SQLite): "EXISTS (SELECT 1 FROM json_each(attributes, '$.cn') WHERE {$innerCondition})" + * Example (MySQL): "EXISTS (SELECT 1 FROM JSON_TABLE(attributes, '$.cn[*]' COLUMNS ...) WHERE {$innerCondition})" + */ + abstract protected function buildValueExists( + string $attribute, + string $innerCondition, + ): string; + + /** + * Returns the column alias used by the value-iteration function. + * + * Example (SQLite json_each): 'value' + * Example (MySQL JSON_TABLE): 'val' + */ + abstract protected function valueAlias(): string; + + public function translate(FilterInterface $filter): ?SqlFilterResult + { + return match (true) { + $filter instanceof AndFilter => $this->translateAnd($filter), + $filter instanceof OrFilter => $this->translateOr($filter), + $filter instanceof NotFilter => $this->translateNot($filter), + $filter instanceof PresentFilter => $this->translatePresent($filter), + $filter instanceof EqualityFilter => $this->translateEquality($filter), + $filter instanceof ApproximateFilter => $this->translateApproximate($filter), + $filter instanceof SubstringFilter => $this->translateSubstring($filter), + $filter instanceof GreaterThanOrEqualFilter => $this->translateGte($filter), + $filter instanceof LessThanOrEqualFilter => $this->translateLte($filter), + default => null, + }; + } + + private function translatePresent(PresentFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + return new SqlFilterResult( + $this->buildPresenceCheck($attribute), + [], + ); + } + + private function translateEquality(EqualityFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + $alias = $this->valueAlias(); + $value = $filter->getValue(); + + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) = lower(?)"), + [$value], + isExact: SqlFilterUtility::isAscii($value), + referencedAttributes: [$attribute], + ); + } + + private function translateApproximate(ApproximateFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + $alias = $this->valueAlias(); + + // Approximate matching is implementation-defined (RFC 4511 §4.5.1.7.6). + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) = lower(?)"), + [$filter->getValue()], + isExact: false, + referencedAttributes: [$attribute], + ); + } + + private function translateGte(GreaterThanOrEqualFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + $alias = $this->valueAlias(); + + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) >= lower(?)"), + [$filter->getValue()], + isExact: false, + referencedAttributes: [$attribute], + ); + } + + private function translateLte(LessThanOrEqualFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + $alias = $this->valueAlias(); + + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) <= lower(?)"), + [$filter->getValue()], + isExact: false, + referencedAttributes: [$attribute], + ); + } + + private function translateSubstring(SubstringFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + [$conditions, $params] = $this->buildSubstringParts($filter); + + if ($conditions === []) { + return null; + } + + // LDAP substring ordering (RFC 4511 §4.5.1.7.2) requires each `contains` + // to appear after the previous one. Independent AND'd LIKE clauses cannot + // preserve that ordering once there are 2+ `contains` fragments, so fall + // back to PHP re-evaluation by marking the result as inexact. + $isExact = count($filter->getContains()) < 2 + && SqlFilterUtility::isAscii((string) $filter->getStartsWith()) + && SqlFilterUtility::isAscii((string) $filter->getEndsWith()) + && $this->areAllAscii($filter->getContains()); + + return new SqlFilterResult( + $this->buildValueExists($attribute, implode(' AND ', $conditions)), + $params, + isExact: $isExact, + referencedAttributes: [$attribute], + ); + } + + /** + * @param array $values + */ + private function areAllAscii(array $values): bool + { + foreach ($values as $value) { + if (!SqlFilterUtility::isAscii($value)) { + return false; + } + } + + return true; + } + + /** + * @return array{list, list} + */ + private function buildSubstringParts(SubstringFilter $filter): array + { + $conditions = []; + $params = []; + $alias = $this->valueAlias(); + $likeClause = "lower($alias) LIKE lower(?) ESCAPE '!'"; + + if ($filter->getStartsWith() !== null) { + $conditions[] = $likeClause; + $params[] = SqlFilterUtility::escape($filter->getStartsWith()) . '%'; + } + + foreach ($filter->getContains() as $contains) { + $conditions[] = $likeClause; + $params[] = '%' . SqlFilterUtility::escape($contains) . '%'; + } + + if ($filter->getEndsWith() !== null) { + $conditions[] = $likeClause; + $params[] = '%' . SqlFilterUtility::escape($filter->getEndsWith()); + } + + return [$conditions, $params]; + } + + private function translateAnd(AndFilter $filter): ?SqlFilterResult + { + $parts = []; + $params = []; + $hasUntranslatable = false; + + foreach ($filter->get() as $child) { + $result = $this->translate($child); + if ($result === null) { + $hasUntranslatable = true; + continue; + } + if (!$result->isExact) { + $hasUntranslatable = true; + } + $parts[] = '(' . $result->sql . ')'; + $params = array_merge($params, $result->params); + } + + if ($parts === []) { + return null; + } + + return new SqlFilterResult( + implode(' AND ', $parts), + $params, + isExact: !$hasUntranslatable, + ); + } + + private function translateOr(OrFilter $filter): ?SqlFilterResult + { + $parts = []; + $params = []; + $hasInexact = false; + + foreach ($filter->get() as $child) { + $result = $this->translate($child); + if ($result === null) { + return null; + } + if (!$result->isExact) { + $hasInexact = true; + } + $parts[] = '(' . $result->sql . ')'; + $params = array_merge($params, $result->params); + } + + if ($parts === []) { + return null; + } + + return new SqlFilterResult( + implode(' OR ', $parts), + $params, + isExact: !$hasInexact, + ); + } + + private function translateNot(NotFilter $filter): ?SqlFilterResult + { + $inner = $filter->get(); + $result = $this->translate($inner); + + if ($result === null) { + return null; + } + + // NOT(present) is the one negation that legitimately matches absent + // attributes, so no presence guard is needed. + if ($inner instanceof PresentFilter) { + return new SqlFilterResult( + 'NOT (' . $result->sql . ')', + $result->params, + isExact: $result->isExact, + ); + } + + // RFC 4511 §4.5.1.7: NOT(undefined) = undefined. SQL `NOT EXISTS(...)` + // returns TRUE for rows missing the attribute, so for value-bearing + // simple filters (those that populated referencedAttributes) we AND + // in a presence guard so missing-attribute rows are excluded. + if ($result->referencedAttributes !== []) { + $guards = array_map( + fn (string $attribute): string => $this->buildPresenceCheck($attribute), + $result->referencedAttributes, + ); + + return new SqlFilterResult( + '(NOT (' . $result->sql . ') AND ' . implode(' AND ', $guards) . ')', + $result->params, + isExact: $result->isExact, + ); + } + + // Composite inner (AND/OR/NOT): tracking three-valued logic precisely + // through SQL composition is fragile. The plain `NOT (...)` SQL is a + // SUPERSET of the correct LDAP result for missing-attribute rows, so + // marking it inexact lets the PHP FilterEvaluator strip false positives. + return new SqlFilterResult( + 'NOT (' . $result->sql . ')', + $result->params, + isExact: false, + ); + } + + /** + * Validates an LDAP attribute description against the RFC 4512 syntax: + * + * attributedescription = attributetype options + * attributetype = oid + * oid = descr / numericoid + * descr = keystring (e.g. "cn", "userCertificate") + * numericoid = number 1*( DOT number ) (e.g. "2.5.4.3") + * options = *( ";" option ) + * option = 1*keychar + * + * Returns the lowercased base attribute name with options stripped, since + * attributes are stored under their base name only (e.g. "usercertificate", + * not "usercertificate;binary"). + * + * The regex still rejects SQL injection, null bytes, whitespace, quotes, + * and non-ASCII characters, so the returned attribute is safe to embed + * directly in a SQL literal. + * + * @throws InvalidAttributeException + */ + private function validateAttribute(string $attribute): string + { + $lower = strtolower($attribute); + + if (preg_match('/^([a-z][a-z0-9-]*|\d+(\.\d+)+)(;[a-z0-9-]+)*$/', $lower) !== 1) { + throw new InvalidAttributeException( + sprintf('Attribute description "%s" is not a valid RFC 4512 attribute description.', $attribute), + ); + } + + return explode(';', $lower, 2)[0]; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtility.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtility.php new file mode 100644 index 00000000..6121230c --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtility.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +/** + * Shared utilities for SQL-based storage adapters. + * + * @author Chad Sikorra + */ +final class SqlFilterUtility +{ + /** + * Escapes LIKE special characters using `!` as the escape character. + */ + public static function escape(string $value): string + { + return str_replace( + ['!', '%', '_'], + ['!!', '!%', '!_'], + $value, + ); + } + + /** + * Returns true if the value contains only 7-bit ASCII bytes. + * + * The SQL translator's case-insensitive comparisons use `lower()`, which + * is ASCII-only on SQLite and collation-dependent on MySQL. + */ + public static function isAscii(string $value): bool + { + return preg_match( + '/[\x80-\xff]/', + $value + ) !== 1; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php new file mode 100644 index 00000000..756db71d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +/** + * Translates LDAP filters into SQLite-compatible SQL WHERE clause fragments. + * + * @author Chad Sikorra + */ +final class SqliteFilterTranslator implements FilterTranslatorInterface +{ + use SqlFilterTranslatorTrait; + + protected function buildPresenceCheck(string $attribute): string + { + return "json_type(attributes, '$.\"{$attribute}\"') IS NOT NULL"; + } + + protected function buildValueExists( + string $attribute, + string $innerCondition, + ): string { + return "EXISTS (SELECT 1 FROM json_each(attributes, '$.\"{$attribute}\".values') WHERE {$innerCondition})"; + } + + protected function valueAlias(): string + { + return 'value'; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php new file mode 100644 index 00000000..9489a8f8 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use PDO; + +/** + * SQLite-specific factory for PdoStorage. + * + * Use the named constructors to select the appropriate runner: + * + * SqliteStorage::forPcntl('/path/to/db.sqlite') + * SqliteStorage::forSwoole('/path/to/db.sqlite') + * + * @author Chad Sikorra + */ +final class SqliteStorage implements PdoStorageFactoryInterface +{ + use PdoStorageFactoryTrait; + + private const DB_CONNECTION_PREFIX = 'sqlite:'; + + private const PRAGMA_JOURNAL_MODE_WAL = 'PRAGMA journal_mode = WAL'; + + private const PRAGMA_SYNCHRONOUS_NORMAL = 'PRAGMA synchronous = NORMAL'; + + public function __construct(private readonly string $dbPath) + { + } + + public static function forPcntl(string $dbPath): PdoStorage + { + return (new self($dbPath))->createShared(); + } + + public static function forSwoole(string $dbPath): PdoStorage + { + return (new self($dbPath))->createPerCoroutine(); + } + + protected function dialect(): PdoDialectInterface + { + return new SqliteDialect(); + } + + protected function translator(): FilterTranslatorInterface + { + return new SqliteFilterTranslator(); + } + + protected function openConnection(PdoDialectInterface $dialect): PDO + { + $pdo = new PDO( + self::DB_CONNECTION_PREFIX . $this->dbPath, + null, + null, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + $pdo->exec(self::PRAGMA_JOURNAL_MODE_WAL); + $pdo->exec(self::PRAGMA_SYNCHRONOUS_NORMAL); + + PdoStorage::initialize($pdo, $dialect); + + return $pdo; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php index f2baebd2..6248406b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -16,7 +16,6 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\DefaultHasChildrenTrait; -use Generator; /** * Primitive storage contract for directory entries. @@ -45,22 +44,17 @@ public function find(Dn $dn): ?Entry; public function hasChildren(Dn $dn): bool; /** - * Yield entries rooted at $baseDn. + * Yield entries according to the scope defined in $options. * - * When $subtree is false: yield only direct children (base entry not included). - * When $subtree is true: yield all entries whose DN matches or is subordinate to $baseDn. + * When $options->subtree is false: yield only direct children (base entry not included). + * When $options->subtree is true: yield all entries whose DN matches or is subordinate to $options->baseDn. * - * Pass a Dn with an empty string to list from the root of the tree. + * Pass a Dn with an empty string as baseDn to list from the root of the tree. * * Implementations should stream entries lazily where possible to avoid * loading the entire dataset into memory at once. - * - * @return Generator */ - public function list( - Dn $baseDn, - bool $subtree - ): Generator; + public function list(StorageListOptions $options): EntryStream; /** * Persist the entry, replacing any existing entry at the same DN. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStream.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStream.php new file mode 100644 index 00000000..4ca0aab6 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStream.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage; + +use FreeDSx\Ldap\Entry\Entry; +use Generator; + +/** + * The result of a storage list() or backend search() call. + * + * Wraps the lazy entry generator together with a flag indicating whether the adapter has already applied the LDAP + * filter exactly in its native query. + * + * @author Chad Sikorra + */ +final class EntryStream +{ + /** + * @param Generator $entries + */ + public function __construct( + public readonly Generator $entries, + public readonly bool $isPreFiltered = false, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/DnTooLongException.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/DnTooLongException.php new file mode 100644 index 00000000..e252874b --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/DnTooLongException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Exception; + +use FreeDSx\Ldap\Exception\RuntimeException; + +/** + * Thrown when attempting to store an entry whose DN exceeds the storage backend's supported maximum length. + * + * @author Chad Sikorra + */ +final class DnTooLongException extends RuntimeException +{ +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/InvalidAttributeException.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/InvalidAttributeException.php new file mode 100644 index 00000000..c246b1f9 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/InvalidAttributeException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Exception; + +use FreeDSx\Ldap\Exception\RuntimeException; + +/** + * Thrown by a storage adapter when a filter contains an attribute name that does not conform to the RFC 4512 attribute + * description syntax. + * + * @author Chad Sikorra + */ +final class InvalidAttributeException extends RuntimeException +{ +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/TimeLimitExceededException.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/TimeLimitExceededException.php new file mode 100644 index 00000000..85a3ee68 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Exception/TimeLimitExceededException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Exception; + +use FreeDSx\Ldap\Exception\RuntimeException; + +/** + * Thrown by a storage adapter when the time limit imposed by the caller is exceeded. + * + * @author Chad Sikorra + */ +final class TimeLimitExceededException extends RuntimeException +{ +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php new file mode 100644 index 00000000..be804974 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * A DTO encapsulating all parameters for a storage list operation, decoupled + * from LDAP protocol objects. + * + * @author Chad Sikorra + */ +final class StorageListOptions +{ + public function __construct( + public readonly Dn $baseDn, + public readonly bool $subtree, + public readonly FilterInterface $filter, + public readonly int $timeLimit = 0, + ) { + } + + /** + * Create options that match all entries within the given scope. + * + * Convenient for internal operations (e.g. hasChildren) and tests that do not need a meaningful filter. + */ + public static function matchAll( + Dn $baseDn, + bool $subtree, + int $timeLimit = 0, + ): self { + return new self( + baseDn: $baseDn, + subtree: $subtree, + filter: new AndFilter(), + timeLimit: $timeLimit, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 836b1427..5673d398 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -13,18 +13,21 @@ namespace FreeDSx\Ldap\Server\Backend\Storage; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\DnTooLongException; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use Generator; @@ -88,28 +91,78 @@ public function compare( /** * @throws OperationException */ - public function search(SearchContext $context): Generator - { - $normBase = $context->baseDn->normalize(); - - if ($context->scope === SearchRequest::SCOPE_BASE_OBJECT) { + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + $baseDn = $request->getBaseDn() ?? new Dn(''); + $normBase = $baseDn->normalize(); + + if ($request->getScope() === SearchRequest::SCOPE_BASE_OBJECT) { $entry = $this->storage->find($normBase); if ($entry === null) { - $this->throwNoSuchObject($context->baseDn); + $this->throwNoSuchObject($baseDn); } - yield $entry; - - return; + return new EntryStream($this->yieldSingle($entry)); } if ($this->storage->find($normBase) === null) { - $this->throwNoSuchObject($context->baseDn); + $this->throwNoSuchObject($baseDn); + } + + $subtree = $request->getScope() === SearchRequest::SCOPE_WHOLE_SUBTREE; + $options = new StorageListOptions( + baseDn: $normBase, + subtree: $subtree, + filter: $request->getFilter(), + timeLimit: $request->getTimeLimit(), + ); + + try { + $stream = $this->storage->list($options); + } catch (InvalidAttributeException $e) { + throw new OperationException( + $e->getMessage(), + ResultCode::PROTOCOL_ERROR, + $e, + ); } - foreach ($this->storage->list($normBase, $context->scope === SearchRequest::SCOPE_WHOLE_SUBTREE) as $entry) { - yield $entry; + return new EntryStream( + $this->wrapWithTimeLimitHandling($stream->entries), + $stream->isPreFiltered, + ); + } + + /** + * @return Generator + */ + private function yieldSingle(Entry $entry): Generator + { + yield $entry; + } + + /** + * Wraps a storage generator and converts TimeLimitExceededException to an + * OperationException so the protocol layer receives a well-formed LDAP error. + * + * @param Generator $generator + * @return Generator + * @throws OperationException + */ + private function wrapWithTimeLimitHandling(Generator $generator): Generator + { + try { + foreach ($generator as $entry) { + yield $entry; + } + } catch (TimeLimitExceededException) { + throw new OperationException( + 'Time limit exceeded.', + ResultCode::TIME_LIMIT_EXCEEDED, + ); } } @@ -118,7 +171,7 @@ public function search(SearchContext $context): Generator */ public function add(AddCommand $command): void { - $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { + $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->entry->getDn()->normalize(); $this->assertParentExists($storage, $dn); @@ -135,7 +188,7 @@ public function add(AddCommand $command): void */ public function delete(DeleteCommand $command): void { - $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { + $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $this->findOrFail($storage, $dn); @@ -158,7 +211,7 @@ public function delete(DeleteCommand $command): void */ public function update(UpdateCommand $command): void { - $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { + $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $entry = $this->findOrFail($storage, $dn); $storage->store($this->entryHandler->apply( @@ -173,7 +226,7 @@ public function update(UpdateCommand $command): void */ public function move(MoveCommand $command): void { - $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { + $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $normOld = $command->dn->normalize(); $entry = $this->findOrFail($storage, $normOld); @@ -198,6 +251,26 @@ public function move(MoveCommand $command): void }); } + /** + * Runs a write operation under the storage's atomic boundary and translates storage-layer + * exceptions into their corresponding LDAP OperationException result codes. + * + * @param callable(EntryStorageInterface): void $operation + * @throws OperationException + */ + private function writeAtomic(callable $operation): void + { + try { + $this->storage->atomic($operation); + } catch (DnTooLongException $e) { + throw new OperationException( + $e->getMessage(), + ResultCode::ADMIN_LIMIT_EXCEEDED, + $e, + ); + } + } + /** * @throws OperationException */ diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php index 6573e902..c665abee 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Server\RequestHandler; use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\BindException; @@ -22,16 +23,14 @@ use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\ModifyRequest; use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; -use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use Generator; @@ -64,10 +63,27 @@ public function __construct( } } - public function search(SearchContext $context): Generator - { - $request = $this->buildSearchRequest($context); - $entries = $this->ldap()->search($request); + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + return new EntryStream( + $this->yieldSearchResults($request, $controls), + isPreFiltered: true, + ); + } + + /** + * @return Generator + */ + private function yieldSearchResults( + SearchRequest $request, + ControlBag $controls, + ): Generator { + $entries = $this->ldap()->search( + $request, + ...$controls->toArray(), + ); foreach ($entries as $entry) { yield $entry; @@ -152,24 +168,4 @@ protected function ldap(): LdapClient return $this->ldap; } - private function buildSearchRequest(SearchContext $context): SearchRequest - { - // Request all attributes from upstream so the FilterEvaluator - // can evaluate the filter against full entries before applying attribute - // projection via applyAttributeFilter(). - $request = Operations::search($context->filter); - $request->base($context->baseDn->toString()); - - match ($context->scope) { - SearchRequest::SCOPE_BASE_OBJECT => $request->useBaseScope(), - SearchRequest::SCOPE_SINGLE_LEVEL => $request->useSingleLevelScope(), - default => $request->useSubtreeScope(), - }; - - if ($context->typesOnly) { - $request->setAttributesOnly(true); - } - - return $request; - } } diff --git a/src/FreeDSx/Ldap/Server/RequestHistory.php b/src/FreeDSx/Ldap/Server/RequestHistory.php index b642bc71..0ddcc5ff 100644 --- a/src/FreeDSx/Ldap/Server/RequestHistory.php +++ b/src/FreeDSx/Ldap/Server/RequestHistory.php @@ -39,6 +39,13 @@ final class RequestHistory */ private array $pagingGenerators = []; + /** + * Tracks whether each paging generator's entries are pre-filtered. + * + * @var array + */ + private array $pagingGeneratorPreFiltered = []; + public function __construct(?PagingRequests $pagingRequests = null) { $this->pagingRequests = $pagingRequests ?? new PagingRequests(); @@ -84,8 +91,10 @@ public function getIds(): array public function storePagingGenerator( string $cookie, Generator $generator, + bool $isPreFiltered = false, ): void { $this->pagingGenerators[$cookie] = $generator; + $this->pagingGeneratorPreFiltered[$cookie] = $isPreFiltered; } /** @@ -97,11 +106,19 @@ public function getPagingGenerator(string $cookie): ?Generator return $this->pagingGenerators[$cookie] ?? null; } + /** + * Returns true when the generator for the given cookie produced pre-filtered entries. + */ + public function getPagingGeneratorIsPreFiltered(string $cookie): bool + { + return $this->pagingGeneratorPreFiltered[$cookie] ?? false; + } + /** * Remove and discard the generator associated with the given cookie. */ public function removePagingGenerator(string $cookie): void { - unset($this->pagingGenerators[$cookie]); + unset($this->pagingGenerators[$cookie], $this->pagingGeneratorPreFiltered[$cookie]); } } diff --git a/tests/bin/ldap-backend-storage.php b/tests/bin/ldap-backend-storage.php index e0d77dd5..b56a0e90 100644 --- a/tests/bin/ldap-backend-storage.php +++ b/tests/bin/ldap-backend-storage.php @@ -12,6 +12,7 @@ use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; use FreeDSx\Ldap\ServerOptions; require __DIR__ . '/../../vendor/autoload.php'; @@ -51,6 +52,7 @@ (new ServerOptions()) ->setPort(10389) ->setTransport($transport) + ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) ); @@ -67,6 +69,22 @@ $storage->store($entry); } + $server->useStorage($storage); +} elseif ($handler === 'sqlite') { + $dbPath = sys_get_temp_dir() . '/ldap_test_backend_storage.sqlite'; + + foreach ([$dbPath, $dbPath . '-wal', $dbPath . '-shm'] as $path) { + if (file_exists($path)) { + unlink($path); + } + } + + $storage = SqliteStorage::forPcntl($dbPath); + + foreach ($entries as $entry) { + $storage->store($entry); + } + $server->useStorage($storage); } else { $server->useStorage(new InMemoryStorage($entries)); diff --git a/tests/bin/ldap-proxy.php b/tests/bin/ldap-proxy.php index f1b5761d..1b8f1916 100644 --- a/tests/bin/ldap-proxy.php +++ b/tests/bin/ldap-proxy.php @@ -9,9 +9,10 @@ $server = LdapServer::makeProxy( servers: 'localhost', - serverOptions: (new ServerOptions())->setPort(10389) + serverOptions: (new ServerOptions()) + ->setPort(10389) + ->setSocketAcceptTimeout(0.1) + ->setOnServerReady(fn () => fwrite(STDOUT, 'server starting...' . PHP_EOL)), ); -echo "server starting..." . PHP_EOL; - $server->run(); diff --git a/tests/bin/ldap-server.php b/tests/bin/ldap-server.php index 5c2e9dad..a17d28ac 100644 --- a/tests/bin/ldap-server.php +++ b/tests/bin/ldap-server.php @@ -8,8 +8,10 @@ use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Control\ControlBag; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -31,15 +33,24 @@ class LdapServerBackend implements WritableLdapBackendInterface, PasswordAuthent 'cn=user,dc=foo,dc=bar' => '12345', ]; - public function search(SearchContext $context): Generator - { + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + $baseDn = $request->getBaseDn()?->toString() ?? ''; + $this->logRequest( 'search', - "base-dn => {$context->baseDn->toString()}, filter => {$context->filter->toString()}" + "base-dn => {$baseDn}, filter => {$request->getFilter()->toString()}" ); + return new EntryStream($this->yieldSearchResults($baseDn)); + } + + private function yieldSearchResults(string $baseDn): Generator + { yield Entry::fromArray( - $context->baseDn->toString(), + $baseDn, [ 'objectClass' => 'inetOrgPerson', 'cn' => 'user', @@ -159,7 +170,14 @@ private function logRequest(string $type, string $message): void class LdapServerPagingBackend extends LdapServerBackend { - public function search(SearchContext $context): Generator + public function search( + SearchRequest $request, + ControlBag $controls = new ControlBag(), + ): EntryStream { + return new EntryStream($this->yieldPagingResults()); + } + + private function yieldPagingResults(): Generator { for ($i = 1; $i <= 300; $i++) { yield Entry::fromArray( @@ -196,6 +214,7 @@ public function search(SearchContext $context): Generator ->setSslCert($sslCert) ->setSslCertKey($sslKey) ->setUseSsl($useSsl) + ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); if ($handler === 'sasl') { diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 56466936..61f71e9b 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -24,8 +24,6 @@ class ServerTestCase extends LdapTestCase private const SERVER_POLL_INTERVAL_US = 15_000; // 15ms - private const SERVER_TCP_PORT = 10389; - /** * Shared server process — started once per test class via setUpBeforeClass. */ @@ -129,6 +127,7 @@ protected function createServerProcess( ): void { $processArgs = [ 'php', + '-dpcov.enabled=0', __DIR__ . '/../bin/' . $this->serverMode . '.php', $transport, ]; @@ -141,10 +140,6 @@ protected function createServerProcess( $process->start(); self::waitForProcess($process, 'server starting...'); - if ($transport !== 'unix') { - self::waitForPortOpen(self::SERVER_TCP_PORT); - } - $this->overrideProcess = $process; $this->client = $this->buildClient($transport); } @@ -204,6 +199,7 @@ private static function launchSharedProcess( ): void { $processArgs = [ 'php', + '-dpcov.enabled=0', __DIR__ . '/../bin/' . $mode . '.php', $transport, ]; @@ -216,49 +212,9 @@ private static function launchSharedProcess( $process->start(); self::waitForProcess($process, 'server starting...'); - if ($transport !== 'unix') { - self::waitForPortOpen(self::SERVER_TCP_PORT); - } - self::$sharedProcess = $process; } - /** - * Probes localhost:$port until a TCP connection succeeds or the timeout - * expires. Called after waitForProcess() because the server bootstrap - * script echoes "server starting..." before the socket is bound, so the - * process output marker alone is not sufficient to guarantee readiness. - */ - private static function waitForPortOpen(int $port): void - { - $deadline = microtime(true) + self::SERVER_MAX_WAIT_SECONDS; - - while (microtime(true) < $deadline) { - $socket = @fsockopen( - '127.0.0.1', - $port, - $errno, - $errstr, - 0.1 - ); - - if ($socket !== false) { - fclose($socket); - usleep(100_000); // 100ms stabilization after successful probe - - return; - } - - usleep(self::SERVER_POLL_INTERVAL_US); - } - - throw new Exception(sprintf( - 'Port %d was not ready after %d seconds.', - $port, - self::SERVER_MAX_WAIT_SECONDS, - )); - } - protected function buildClient(string $transport): LdapClient { $useSsl = false; diff --git a/tests/integration/Storage/LdapBackendSqliteStorageTest.php b/tests/integration/Storage/LdapBackendSqliteStorageTest.php new file mode 100644 index 00000000..6bb6a9f9 --- /dev/null +++ b/tests/integration/Storage/LdapBackendSqliteStorageTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Storage; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; + +/** + * Runs the full LdapBackendStorageTest suite against SqliteStorage, + * and adds a test that verifies writes persist across separate client connections. + * + * Uses the same ldap-backend-storage.php bootstrap script with the 'sqlite' handler, + * which seeds a SqliteStorage and recreates the database file on each startup. + * + * Each mutating test restarts the server so the database is recreated cleanly, + * preventing cross-test pollution. + */ +final class LdapBackendSqliteStorageTest extends LdapBackendStorageTest +{ + /** + * Tests that mutate the database and would pollute subsequent tests. + */ + private const MUTATING_TESTS = [ + 'testAddStoresEntry', + 'testDeleteRemovesEntry', + 'testModifyReplacesAttributeValue', + 'testRenameChangesRdn', + 'testWritesPersistAcrossConnections', + ]; + + public static function setUpBeforeClass(): void + { + // Intentionally skip LdapBackendStorageTest::setUpBeforeClass() to + // avoid starting the InMemory server. Start the SQLite-storage server. + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer( + 'ldap-backend-storage', + 'tcp', + 'sqlite', + ); + } + + public static function tearDownAfterClass(): void + { + LdapBackendSqliteStorageTest::tearDownSharedServer(); + } + + public function setUp(): void + { + parent::setUp(); + + if (in_array($this->name(), self::MUTATING_TESTS, true)) { + $this->stopServer(); + $this->createServerProcess( + 'tcp', + 'sqlite', + ); + } + } + + public function testWritesPersistAcrossConnections(): void + { + $this->ldapClient()->bind( + 'cn=user,dc=foo,dc=bar', + '12345' + ); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=persistent,dc=foo,dc=bar', + ['cn' => 'persistent', 'objectClass' => 'inetOrgPerson'] + )); + + $this->ldapClient()->unbind(); + + $secondClient = $this->buildClient('tcp'); + $secondClient->bind( + 'cn=user,dc=foo,dc=bar', + '12345' + ); + + $entries = $secondClient->search( + Operations::search(Filters::equal('cn', 'persistent')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + + $secondClient->unbind(); + + self::assertCount( + 1, + $entries + ); + self::assertSame( + 'cn=persistent,dc=foo,dc=bar', + $entries->first()?->getDn()->toString() + ); + } +} diff --git a/tests/integration/Sync/SyncReplTest.php b/tests/integration/Sync/SyncReplTest.php index 4ab3df4b..a3d22461 100644 --- a/tests/integration/Sync/SyncReplTest.php +++ b/tests/integration/Sync/SyncReplTest.php @@ -145,6 +145,7 @@ private function syncWriteProcess(): Process { $process = new Process([ 'php', + '-dpcov.enabled=0', __DIR__ . '/../../bin/ldap-sync-write.php', $this->syncSignalFile, ]); diff --git a/tests/unit/Entry/AttributeTest.php b/tests/unit/Entry/AttributeTest.php index b69b646d..33e58e09 100644 --- a/tests/unit/Entry/AttributeTest.php +++ b/tests/unit/Entry/AttributeTest.php @@ -168,6 +168,24 @@ public function test_it_should_check_if_a_value_exists(): void self::assertFalse($this->subject->has('bleh')); } + public function test_has_case_insensitive_matches_existing_value(): void + { + self::assertTrue($this->subject->has('foo', caseSensitive: false)); + self::assertTrue($this->subject->has('FOO', caseSensitive: false)); + self::assertTrue($this->subject->has('Foo', caseSensitive: false)); + } + + public function test_has_case_insensitive_returns_false_for_unknown_value(): void + { + self::assertFalse($this->subject->has('bleh', caseSensitive: false)); + self::assertFalse($this->subject->has('BLEH', caseSensitive: false)); + } + + public function test_has_defaults_to_byte_exact(): void + { + self::assertFalse($this->subject->has('FOO')); + } + public function test_it_should_check_if_it_equals_another_attribute(): void { self::assertTrue($this->subject->equals(new Attribute('cn'))); diff --git a/tests/unit/Entry/DnTest.php b/tests/unit/Entry/DnTest.php index 7faf49bb..79c4e0d7 100644 --- a/tests/unit/Entry/DnTest.php +++ b/tests/unit/Entry/DnTest.php @@ -167,4 +167,78 @@ public function test_is_child_of_handles_escaped_comma_in_rdn_value(): void $child->isChildOf(new Dn('dc=local,dc=example')), ); } + + public function test_is_descendant_of_returns_true_for_direct_child(): void + { + self::assertTrue( + (new Dn('cn=alice,dc=example,dc=com'))->isDescendantOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_descendant_of_returns_true_for_grandchild(): void + { + self::assertTrue( + (new Dn('cn=alice,ou=people,dc=example,dc=com'))->isDescendantOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_descendant_of_returns_true_for_identical_dn(): void + { + self::assertTrue( + (new Dn('dc=example,dc=com'))->isDescendantOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_descendant_of_is_case_insensitive(): void + { + self::assertTrue( + (new Dn('cn=alice,dc=example,dc=com'))->isDescendantOf(new Dn('DC=EXAMPLE,DC=COM')), + ); + } + + public function test_is_descendant_of_returns_false_for_unrelated_dn(): void + { + self::assertFalse( + (new Dn('cn=alice,dc=other,dc=org'))->isDescendantOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_descendant_of_returns_false_for_parent_dn(): void + { + self::assertFalse( + (new Dn('dc=example,dc=com'))->isDescendantOf(new Dn('cn=alice,dc=example,dc=com')), + ); + } + + public function test_is_descendant_of_empty_base_matches_any_non_empty_dn(): void + { + self::assertTrue( + (new Dn('dc=com'))->isDescendantOf(new Dn('')), + ); + self::assertTrue( + (new Dn('cn=alice,dc=example,dc=com'))->isDescendantOf(new Dn('')), + ); + } + + public function test_is_descendant_of_empty_dn_against_empty_base_is_false(): void + { + self::assertFalse( + (new Dn(''))->isDescendantOf(new Dn('')), + ); + } + + public function test_is_descendant_of_rejects_string_suffix_collision_from_escaped_comma(): void + { + // The entry parent is "dc=example,dc=com". A naive str_ends_with against + // ",John,dc=example,dc=com" would be true and let this entry slip into + // a subtree search whose base is "John,dc=example,dc=com". + $entry = new Dn('cn=Doe\,John,dc=example,dc=com'); + + self::assertFalse( + $entry->isDescendantOf(new Dn('John,dc=example,dc=com')), + ); + self::assertTrue( + $entry->isDescendantOf(new Dn('dc=example,dc=com')), + ); + } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php index 2e4f2f53..285a9026 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -21,7 +21,9 @@ use FreeDSx\Ldap\Operation\Request\CompareRequest; use FreeDSx\Ldap\Operation\Request\DeleteRequest; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; use FreeDSx\Ldap\Search\Filter\EqualityFilter; @@ -80,7 +82,7 @@ public function test_it_dispatches_write_requests_through_the_write_handler(): v $this->subject->handleRequest($add, $this->mockToken); } - public function test_it_propagates_operation_exceptions_from_the_write_handler(): void + public function test_it_sends_error_response_for_operation_exceptions_from_the_write_handler(): void { $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); @@ -91,8 +93,14 @@ public function test_it_propagates_operation_exceptions_from_the_write_handler() ResultCode::ENTRY_ALREADY_EXISTS, )); - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with(self::callback(function (LdapMessageResponse $msg): bool { + return $msg->getResponse() instanceof LdapResult + && $msg->getResponse()->getResultCode() === ResultCode::ENTRY_ALREADY_EXISTS; + })) + ->willReturnSelf(); $this->subject->handleRequest($add, $this->mockToken); } @@ -118,7 +126,7 @@ public function test_it_delegates_compare_to_the_backend(): void $this->subject->handleRequest($compare, $this->mockToken); } - public function test_it_propagates_operation_exceptions_from_backend_compare(): void + public function test_it_sends_error_response_for_operation_exceptions_from_backend_compare(): void { $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); @@ -129,13 +137,19 @@ public function test_it_propagates_operation_exceptions_from_backend_compare(): ResultCode::NO_SUCH_OBJECT, )); - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with(self::callback(function (LdapMessageResponse $msg): bool { + return $msg->getResponse() instanceof LdapResult + && $msg->getResponse()->getResultCode() === ResultCode::NO_SUCH_OBJECT; + })) + ->willReturnSelf(); $this->subject->handleRequest($compare, $this->mockToken); } - public function test_it_throws_unwilling_to_perform_when_no_write_handler_supports_the_operation(): void + public function test_it_sends_error_response_when_no_write_handler_supports_the_operation(): void { $subject = new ServerDispatchHandler( queue: $this->mockQueue, @@ -145,18 +159,26 @@ public function test_it_throws_unwilling_to_perform_when_no_write_handler_suppor $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with(self::callback(function (LdapMessageResponse $msg): bool { + return $msg->getResponse() instanceof LdapResult + && $msg->getResponse()->getResultCode() === ResultCode::UNWILLING_TO_PERFORM; + })) + ->willReturnSelf(); $subject->handleRequest($add, $this->mockToken); } - public function test_it_throws_an_operation_exception_for_unsupported_requests(): void + public function test_it_sends_error_response_for_unsupported_requests(): void { $request = new LdapMessageRequest(2, new AbandonRequest(1)); - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NO_SUCH_OPERATION); + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->willReturnSelf(); $this->subject->handleRequest($request, $this->mockToken); } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index ea72c3ba..81bba364 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -28,7 +28,7 @@ use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingHandler; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Paging\PagingRequest; use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; @@ -86,8 +86,8 @@ public function test_it_should_call_the_backend_search_on_paging_start_and_retur $this->mockBackend ->expects(self::once()) ->method('search') - ->with(self::isInstanceOf(SearchContext::class)) - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->with(self::isInstanceOf(SearchRequest::class)) + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $resultEntry1 = new LdapMessageResponse( 2, @@ -130,7 +130,7 @@ public function test_it_should_store_the_generator_and_return_a_cookie_when_more $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockQueue ->expects(self::once()) @@ -163,7 +163,7 @@ public function test_it_should_continue_from_the_stored_generator_on_subsequent_ $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $capturedCookie = ''; @@ -331,7 +331,7 @@ public function test_it_should_return_size_limit_exceeded_on_first_page_when_lim $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockQueue ->expects(self::once()) @@ -365,7 +365,7 @@ public function test_it_should_accumulate_size_limit_across_pages(): void $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2, $entry3)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2, $entry3))); $capturedCookie = ''; $sizeLimitExceededSeen = false; diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index da2a3a5e..86e5cb2c 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -25,7 +25,7 @@ use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSearchHandler; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; use Generator; @@ -76,8 +76,8 @@ public function test_it_should_send_entries_from_the_backend_to_the_client(): vo $this->mockBackend ->expects(self::once()) ->method('search') - ->with(self::isInstanceOf(SearchContext::class)) - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->with(self::isInstanceOf(SearchRequest::class)) + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockFilterEvaluator ->method('evaluate') @@ -114,7 +114,7 @@ public function test_it_should_filter_entries_that_do_not_match_the_filter(): vo $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockFilterEvaluator ->method('evaluate') @@ -189,7 +189,7 @@ public function test_it_should_return_size_limit_exceeded_with_partial_results_w $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockFilterEvaluator ->method('evaluate') @@ -224,7 +224,7 @@ public function test_it_should_not_enforce_size_limit_when_zero(): void $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockFilterEvaluator ->method('evaluate') @@ -252,7 +252,7 @@ public function test_it_should_send_a_successful_SearchResultDone_when_no_entrie $this->mockBackend ->expects(self::once()) ->method('search') - ->willReturn($this->makeGenerator()); + ->willReturn(new EntryStream($this->makeGenerator())); $this->mockQueue ->expects(self::once()) diff --git a/tests/unit/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProviderTest.php b/tests/unit/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProviderTest.php new file mode 100644 index 00000000..d3a352da --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/CoroutinePdoConnectionProviderTest.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\CoroutinePdoConnectionProvider; +use PDO; +use PHPUnit\Framework\TestCase; +use Swoole\Coroutine; +use Swoole\Coroutine\Channel; + +final class CoroutinePdoConnectionProviderTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('swoole')) { + $this->markTestSkipped('The swoole extension is required for this test.'); + } + } + + public function test_throws_when_called_outside_a_coroutine(): void + { + $provider = new CoroutinePdoConnectionProvider( + fn (): PDO => $this->newPdo(), + ); + + $this->expectException(RuntimeException::class); + $provider->get(); + } + + public function test_txState_throws_when_called_outside_a_coroutine(): void + { + $provider = new CoroutinePdoConnectionProvider( + fn (): PDO => $this->newPdo(), + ); + + $this->expectException(RuntimeException::class); + $provider->txState(); + } + + public function test_returns_same_pdo_on_repeat_calls_within_same_coroutine(): void + { + $provider = new CoroutinePdoConnectionProvider( + fn (): PDO => $this->newPdo(), + ); + + $captured = []; + + Coroutine\run(function () use ($provider, &$captured): void { + $first = $provider->get(); + $second = $provider->get(); + $captured['same'] = $first === $second; + $captured['txStateSame'] = $provider->txState() === $provider->txState(); + }); + + self::assertTrue($captured['same']); + self::assertTrue($captured['txStateSame']); + } + + public function test_returns_distinct_pdos_for_distinct_coroutines(): void + { + $factoryCount = 0; + $provider = new CoroutinePdoConnectionProvider( + function () use (&$factoryCount): PDO { + $factoryCount++; + + return $this->newPdo(); + }, + ); + + /** @var array $pdos */ + $pdos = []; + /** @var array $txStates */ + $txStates = []; + + Coroutine\run(function () use ($provider, &$pdos, &$txStates): void { + $pdoCh = new Channel(2); + $txCh = new Channel(2); + $release = new Channel(2); + + Coroutine::create(function () use ($provider, $pdoCh, $txCh, $release): void { + $pdoCh->push($provider->get()); + $txCh->push($provider->txState()); + $release->pop(); + }); + Coroutine::create(function () use ($provider, $pdoCh, $txCh, $release): void { + $pdoCh->push($provider->get()); + $txCh->push($provider->txState()); + $release->pop(); + }); + + for ($i = 0; $i < 2; $i++) { + $pdo = $pdoCh->pop(); + self::assertInstanceOf( + PDO::class, + $pdo, + ); + $pdos[] = $pdo; + + $tx = $txCh->pop(); + self::assertInstanceOf( + \FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoTxState::class, + $tx, + ); + $txStates[] = $tx; + } + + $release->push(1); + $release->push(1); + }); + + self::assertNotSame( + $pdos[0], + $pdos[1], + ); + self::assertNotSame( + $txStates[0], + $txStates[1], + ); + self::assertSame( + 2, + $factoryCount, + ); + } + + public function test_tx_state_depth_is_isolated_per_coroutine(): void + { + $provider = new CoroutinePdoConnectionProvider( + fn (): PDO => $this->newPdo(), + ); + + $depths = []; + + Coroutine\run(function () use ($provider, &$depths): void { + Coroutine::create(function () use ($provider, &$depths): void { + $provider->txState()->depth = 3; + Coroutine::sleep(0.01); + $depths['a'] = $provider->txState()->depth; + }); + Coroutine::create(function () use ($provider, &$depths): void { + $depths['b'] = $provider->txState()->depth; + }); + }); + + self::assertSame( + 3, + $depths['a'], + ); + self::assertSame( + 0, + $depths['b'], + ); + } + + public function test_connections_are_released_when_coroutine_exits(): void + { + $created = 0; + + $provider = new CoroutinePdoConnectionProvider( + function () use (&$created): PDO { + $created++; + + return $this->newPdo(); + }, + ); + + Coroutine\run(function () use ($provider): void { + Coroutine::create(function () use ($provider): void { + $provider->get(); + }); + Coroutine::create(function () use ($provider): void { + $provider->get(); + }); + }); + + Coroutine\run(function () use ($provider): void { + $provider->get(); + }); + + self::assertSame( + 3, + $created, + ); + + $reflection = new \ReflectionClass($provider); + $connectionsProp = $reflection->getProperty('connections'); + $txStatesProp = $reflection->getProperty('txStates'); + + self::assertSame( + [], + $connectionsProp->getValue($provider), + ); + self::assertSame( + [], + $txStatesProp->getValue($provider), + ); + } + + private function newPdo(): PDO + { + return new PDO( + 'sqlite::memory:', + null, + null, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php index 6fb0e64b..9175a6f2 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; use PHPUnit\Framework\TestCase; final class InMemoryStorageTest extends TestCase @@ -52,7 +53,7 @@ public function test_find_returns_null_for_unknown_norm_dn(): void public function test_list_returns_all_entries(): void { - $entries = iterator_to_array($this->subject->list(new Dn(''), true)); + $entries = iterator_to_array($this->subject->list(StorageListOptions::matchAll(new Dn(''), true))->entries); self::assertCount( 1, @@ -71,7 +72,7 @@ public function test_list_single_level_returns_direct_children_only(): void $grandchild = new Entry(new Dn('cn=Sub,cn=Bob,dc=example,dc=com'), new Attribute('cn', 'Sub')); $storage = new InMemoryStorage([$parent, $child, $grandchild]); - $entries = iterator_to_array($storage->list(new Dn('dc=example,dc=com'), false)); + $entries = iterator_to_array($storage->list(StorageListOptions::matchAll(new Dn('dc=example,dc=com'), false))->entries); self::assertCount( 1, @@ -90,7 +91,7 @@ public function test_list_recursive_includes_base_and_descendants(): void $grandchild = new Entry(new Dn('cn=Sub,cn=Bob,dc=example,dc=com'), new Attribute('cn', 'Sub')); $storage = new InMemoryStorage([$parent, $child, $grandchild]); - $entries = iterator_to_array($storage->list(new Dn('dc=example,dc=com'), true)); + $entries = iterator_to_array($storage->list(StorageListOptions::matchAll(new Dn('dc=example,dc=com'), true))->entries); self::assertCount( 3, @@ -98,6 +99,28 @@ public function test_list_recursive_includes_base_and_descendants(): void ); } + public function test_list_subtree_does_not_match_string_suffix_collision(): void + { + // The escaped comma in the RDN value would let a naive str_ends_with + // match consider this entry a descendant of "John,dc=example,dc=com", + // even though its actual parent is "dc=example,dc=com". + $entry = new Entry( + new Dn('cn=Doe\,John,dc=example,dc=com'), + new Attribute('cn', 'Doe,John'), + ); + $storage = new InMemoryStorage([$entry]); + + $entries = iterator_to_array($storage->list(StorageListOptions::matchAll( + new Dn('John,dc=example,dc=com'), + true, + ))->entries); + + self::assertCount( + 0, + $entries, + ); + } + public function test_has_children_returns_true_when_children_exist(): void { $parent = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); @@ -147,7 +170,7 @@ public function test_remove_is_noop_for_unknown_norm_dn(): void self::assertCount( 1, - iterator_to_array($this->subject->list(new Dn(''), true)), + iterator_to_array($this->subject->list(StorageListOptions::matchAll(new Dn(''), true))->entries), ); } @@ -165,7 +188,7 @@ public function test_empty_constructor_creates_empty_storage(): void self::assertCount( 0, - iterator_to_array($storage->list(new Dn(''), true)), + iterator_to_array($storage->list(StorageListOptions::matchAll(new Dn(''), true))->entries), ); } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php index 6cf790a4..9815a50e 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -20,7 +20,6 @@ use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Search\Filter\PresentFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use PHPUnit\Framework\TestCase; @@ -168,15 +167,10 @@ public function test_list_single_level_returns_direct_children_only(): void $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); $this->subject->add(new AddCommand($grandchild)); - $filter = new PresentFilter('objectClass'); - $context = new SearchContext( - new Dn('dc=example,dc=com'), - SearchRequest::SCOPE_SINGLE_LEVEL, - $filter, - [], - false, - ); - $results = iterator_to_array($this->subject->search($context)); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSingleLevelScope(); + $results = iterator_to_array($this->subject->search($request)->entries); self::assertCount( 1, @@ -195,15 +189,10 @@ public function test_list_recursive_includes_base_and_descendants(): void $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); $this->subject->add(new AddCommand($grandchild)); - $filter = new PresentFilter('objectClass'); - $context = new SearchContext( - new Dn('dc=example,dc=com'), - SearchRequest::SCOPE_WHOLE_SUBTREE, - $filter, - [], - false, - ); - $results = iterator_to_array($this->subject->search($context)); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSubtreeScope(); + $results = iterator_to_array($this->subject->search($request)->entries); self::assertCount( 3, diff --git a/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php new file mode 100644 index 00000000..6d68fc3b --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php @@ -0,0 +1,604 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\LessThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\MatchingRuleFilter; +use FreeDSx\Ldap\Search\Filter\NotFilter; +use FreeDSx\Ldap\Search\Filter\OrFilter; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Search\Filter\SubstringFilter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use PHPUnit\Framework\TestCase; + +final class MysqlFilterTranslatorTest extends TestCase +{ + private MysqlFilterTranslator $subject; + + protected function setUp(): void + { + $this->subject = new MysqlFilterTranslator(); + } + + public function test_present_filter_returns_json_contains_path(): void + { + $result = $this->subject->translate(new PresentFilter('cn')); + + self::assertNotNull($result); + self::assertSame( + "JSON_CONTAINS_PATH(attributes, 'one', '$.\"cn\"')", + $result->sql, + ); + self::assertSame( + [], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_present_filter_lowercases_attribute_name(): void + { + $result = $this->subject->translate(new PresentFilter('objectClass')); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."objectclass"', + $result->sql, + ); + self::assertStringNotContainsString( + '$."objectClass"', + $result->sql, + ); + } + + public function test_equality_filter_returns_json_table_exists(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."cn".values[*]', + $result->sql, + ); + self::assertStringContainsString( + 'lower(val) = lower(?)', + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_approximate_filter_translates_same_as_equality(): void + { + $result = $this->subject->translate(new ApproximateFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'JSON_TABLE', + $result->sql, + ); + self::assertStringContainsString( + 'lower(val) = lower(?)', + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + } + + public function test_approximate_filter_is_marked_inexact(): void + { + $result = $this->subject->translate(new ApproximateFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_equality_filter_with_non_ascii_value_is_inexact(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'cn', + 'Café', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_substring_with_non_ascii_fragment_is_inexact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'Café', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_gte_filter_returns_json_table_gte(): void + { + $result = $this->subject->translate(new GreaterThanOrEqualFilter( + 'age', + '30', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."age".values[*]', + $result->sql, + ); + self::assertStringContainsString( + 'lower(val) >= lower(?)', + $result->sql, + ); + self::assertSame( + ['30'], + $result->params, + ); + self::assertFalse($result->isExact); + } + + public function test_lte_filter_returns_json_table_lte(): void + { + $result = $this->subject->translate(new LessThanOrEqualFilter( + 'age', + '50', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."age".values[*]', + $result->sql, + ); + self::assertStringContainsString( + 'lower(val) <= lower(?)', + $result->sql, + ); + self::assertSame( + ['50'], + $result->params, + ); + self::assertFalse($result->isExact); + } + + public function test_attribute_with_option_strips_option_from_json_path(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'userCertificate;binary', + 'x', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."usercertificate"', + $result->sql, + ); + self::assertStringNotContainsString( + ';binary', + $result->sql, + ); + } + + public function test_attribute_with_multiple_options_strips_all_options(): void + { + $result = $this->subject->translate(new PresentFilter('cn;lang-en;binary')); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."cn"', + $result->sql, + ); + self::assertStringNotContainsString( + ';', + $result->sql, + ); + } + + public function test_numericoid_attribute_translates_with_quoted_path(): void + { + $result = $this->subject->translate(new PresentFilter('2.5.4.3')); + + self::assertNotNull($result); + self::assertSame( + "JSON_CONTAINS_PATH(attributes, 'one', '$.\"2.5.4.3\"')", + $result->sql, + ); + } + + public function test_numericoid_equality_translates_with_quoted_path(): void + { + $result = $this->subject->translate(new EqualityFilter( + '2.5.4.3', + 'Alice', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."2.5.4.3".values[*]', + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + } + + /** + * @dataProvider provideInvalidAttributeDescriptions + */ + public function test_invalid_attribute_description_throws(string $attribute): void + { + $this->expectException(InvalidAttributeException::class); + + $this->subject->translate(new EqualityFilter( + $attribute, + 'x', + )); + } + + /** + * @return array + */ + public static function provideInvalidAttributeDescriptions(): array + { + return [ + 'empty string' => [''], + 'starts with digit' => ['2cn'], + 'starts with hyphen' => ['-cn'], + 'contains space' => ['cn name'], + 'contains at-sign' => ['cn@dc'], + 'contains equals' => ['cn=value'], + 'contains single quote' => ["cn'"], + 'sql injection' => ["cn'; DROP TABLE entries--"], + 'null byte' => ["cn\0bad"], + 'non-ascii unicode' => ['ñame'], + 'trailing semicolon' => ['cn;'], + 'double semicolon' => ['cn;;lang'], + 'option with special chars' => ['cn;lang@en'], + ]; + } + + public function test_substring_with_no_components_returns_null(): void + { + $result = $this->subject->translate(new SubstringFilter('cn')); + + self::assertNull($result); + } + + public function test_substring_with_starts_with_only(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'Al', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'JSON_TABLE', + $result->sql, + ); + self::assertStringContainsString( + '$."cn".values[*]', + $result->sql, + ); + self::assertSame( + ['Al%'], + $result->params, + ); + } + + public function test_substring_with_single_contains_is_exact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'A', + 'e', + 'lic', + )); + + self::assertNotNull($result); + self::assertTrue($result->isExact); + } + + public function test_substring_with_multiple_contains_is_not_exact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'a', + 'd', + 'b', + 'c', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_substring_with_all_components(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'A', + 'e', + 'lic', + )); + + self::assertNotNull($result); + self::assertSame( + ['A%', '%lic%', '%e'], + $result->params, + ); + self::assertStringContainsString( + 'AND', + $result->sql, + ); + } + + public function test_substring_escapes_special_like_characters(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'al%ice', + )); + + self::assertNotNull($result); + self::assertSame( + ['al!%ice%'], + $result->params, + ); + } + + public function test_and_filter_all_translatable_returns_combined(): void + { + $result = $this->subject->translate(new AndFilter( + new PresentFilter('cn'), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertStringContainsString( + ' AND ', + $result->sql, + ); + self::assertSame( + ['person'], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_and_filter_partial_translatable_is_not_exact(): void + { + $result = $this->subject->translate(new AndFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + self::assertSame( + [], + $result->params, + ); + } + + public function test_and_filter_with_no_translatable_children_returns_null(): void + { + $result = $this->subject->translate(new AndFilter( + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNull($result); + } + + public function test_or_filter_all_translatable_is_exact(): void + { + $result = $this->subject->translate(new OrFilter( + new PresentFilter('cn'), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertStringContainsString( + ' OR ', + $result->sql, + ); + self::assertTrue($result->isExact); + } + + public function test_or_filter_with_one_untranslatable_child_returns_null(): void + { + $result = $this->subject->translate(new OrFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNull($result); + } + + public function test_not_filter_with_translatable_child_returns_not_sql(): void + { + $result = $this->subject->translate( + new NotFilter(new PresentFilter('cn')), + ); + + self::assertNotNull($result); + self::assertStringStartsWith( + 'NOT (', + $result->sql, + ); + self::assertSame( + [], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_not_present_emits_plain_not_without_presence_guard(): void + { + $result = $this->subject->translate( + new NotFilter(new PresentFilter('cn')), + ); + + self::assertNotNull($result); + self::assertSame( + "NOT (JSON_CONTAINS_PATH(attributes, 'one', '$.\"cn\"'))", + $result->sql, + ); + } + + public function test_not_equality_adds_presence_guard(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter( + 'cn', + 'Alice', + )), + ); + + self::assertNotNull($result); + self::assertStringStartsWith( + '(NOT (', + $result->sql, + ); + self::assertStringContainsString( + "JSON_CONTAINS_PATH(attributes, 'one', '$.\"cn\"')", + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_not_equality_with_non_ascii_value_is_inexact(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter( + 'cn', + 'Café', + )), + ); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_not_composite_inner_is_inexact(): void + { + $result = $this->subject->translate( + new NotFilter(new AndFilter( + new EqualityFilter( + 'cn', + 'Alice', + ), + new EqualityFilter( + 'sn', + 'Smith', + ), + )), + ); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + self::assertStringStartsWith( + 'NOT (', + $result->sql, + ); + } + + public function test_not_filter_with_untranslatable_child_returns_null(): void + { + $result = $this->subject->translate( + new NotFilter(new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + )), + ); + + self::assertNull($result); + } + + public function test_matching_rule_filter_always_returns_null(): void + { + $result = $this->subject->translate( + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + ); + + self::assertNull($result); + } + + public function test_and_params_are_merged_in_order(): void + { + $result = $this->subject->translate(new AndFilter( + new EqualityFilter( + 'cn', + 'Alice', + ), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertSame( + ['Alice', 'person'], + $result->params, + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php index e261d929..2d397273 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php @@ -298,4 +298,72 @@ public function test_type_replace_allows_replacing_rdn_attribute_when_rdn_value_ $cn->getValues(), ); } + + public function test_type_add_with_case_differing_value_throws_attribute_or_value_exists(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ATTRIBUTE_OR_VALUE_EXISTS); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'ALICE@EXAMPLE.COM')], + )); + } + + public function test_type_delete_specific_value_succeeds_with_case_differing_value(): void + { + $entry = new Entry( + new Dn('cn=bob,dc=example,dc=com'), + new Attribute('cn', 'bob'), + new Attribute('mail', 'Bob@Example.com'), + ); + + $result = $this->subject->execute($entry, new UpdateCommand( + new Dn('cn=bob,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'mail', 'bob@example.com')], + )); + + self::assertNull($result->get('mail')?->firstValue()); + } + + public function test_type_delete_rdn_value_with_case_differing_throws_not_allowed_on_rdn(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_RDN); + + $this->subject->execute($entry, new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'cn', 'alice')], + )); + } + + public function test_type_replace_rdn_attribute_with_case_differing_rdn_value_is_allowed(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + + $result = $this->subject->execute($entry, new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'alice', 'alicia')], + )); + + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertContains( + 'alice', + $cn->getValues(), + ); + self::assertContains( + 'alicia', + $cn->getValues(), + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtilityTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtilityTest.php new file mode 100644 index 00000000..2d47e0ba --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtilityTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; + +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterUtility; +use PHPUnit\Framework\TestCase; + +final class SqlFilterUtilityTest extends TestCase +{ + public function test_escapes_percent(): void + { + self::assertSame( + '50!%', + SqlFilterUtility::escape('50%'), + ); + } + + public function test_escapes_underscore(): void + { + self::assertSame( + 'a!_b', + SqlFilterUtility::escape('a_b'), + ); + } + + public function test_escapes_exclamation(): void + { + self::assertSame( + 'a!!b', + SqlFilterUtility::escape('a!b'), + ); + } + + public function test_escapes_multiple_special_chars(): void + { + self::assertSame( + 'a!%!_!!b', + SqlFilterUtility::escape('a%_!b'), + ); + } + + public function test_leaves_normal_chars_unchanged(): void + { + self::assertSame( + 'hello world', + SqlFilterUtility::escape('hello world'), + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php new file mode 100644 index 00000000..fed6c1a5 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -0,0 +1,703 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\LessThanOrEqualFilter; +use FreeDSx\Ldap\Search\Filter\MatchingRuleFilter; +use FreeDSx\Ldap\Search\Filter\NotFilter; +use FreeDSx\Ldap\Search\Filter\OrFilter; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Search\Filter\SubstringFilter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use PHPUnit\Framework\TestCase; + +final class SqliteFilterTranslatorTest extends TestCase +{ + private SqliteFilterTranslator $subject; + + protected function setUp(): void + { + $this->subject = new SqliteFilterTranslator(); + } + + public function test_present_filter_returns_json_type_check(): void + { + $result = $this->subject->translate(new PresentFilter('cn')); + + self::assertNotNull($result); + self::assertSame( + "json_type(attributes, '$.\"cn\"') IS NOT NULL", + $result->sql, + ); + self::assertSame( + [], + $result->params, + ); + } + + public function test_present_filter_lowercases_attribute_name(): void + { + $result = $this->subject->translate(new PresentFilter('objectClass')); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."objectclass"', + $result->sql, + ); + } + + public function test_equality_filter_returns_exists_lower_equals(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertSame( + "EXISTS (SELECT 1 FROM json_each(attributes, '$.\"cn\".values') WHERE lower(value) = lower(?))", + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + } + + public function test_approximate_filter_translates_same_as_equality(): void + { + $result = $this->subject->translate(new ApproximateFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertSame( + "EXISTS (SELECT 1 FROM json_each(attributes, '$.\"cn\".values') WHERE lower(value) = lower(?))", + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + } + + public function test_approximate_filter_is_marked_inexact(): void + { + $result = $this->subject->translate(new ApproximateFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_equality_filter_with_ascii_value_is_exact(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'cn', + 'Alice', + )); + + self::assertNotNull($result); + self::assertTrue($result->isExact); + } + + public function test_equality_filter_with_non_ascii_value_is_inexact(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'cn', + 'Café', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_substring_with_non_ascii_fragment_is_inexact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'Café', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_gte_filter_returns_exists_lower_gte(): void + { + $result = $this->subject->translate(new GreaterThanOrEqualFilter( + 'age', + '30', + )); + + self::assertNotNull($result); + self::assertSame( + "EXISTS (SELECT 1 FROM json_each(attributes, '$.\"age\".values') WHERE lower(value) >= lower(?))", + $result->sql, + ); + self::assertSame( + ['30'], + $result->params, + ); + self::assertFalse($result->isExact); + } + + public function test_lte_filter_returns_exists_lower_lte(): void + { + $result = $this->subject->translate(new LessThanOrEqualFilter( + 'age', + '50', + )); + + self::assertNotNull($result); + self::assertSame( + "EXISTS (SELECT 1 FROM json_each(attributes, '$.\"age\".values') WHERE lower(value) <= lower(?))", + $result->sql, + ); + self::assertSame( + ['50'], + $result->params, + ); + self::assertFalse($result->isExact); + } + + public function test_attribute_with_option_strips_option_from_json_path(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'userCertificate;binary', + 'x', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."usercertificate"', + $result->sql, + ); + self::assertStringNotContainsString( + ';binary', + $result->sql, + ); + } + + public function test_attribute_with_multiple_options_strips_all_options(): void + { + $result = $this->subject->translate(new PresentFilter('cn;lang-en;binary')); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."cn"', + $result->sql, + ); + self::assertStringNotContainsString( + ';', + $result->sql, + ); + } + + public function test_numericoid_attribute_translates_with_quoted_path(): void + { + $result = $this->subject->translate(new PresentFilter('2.5.4.3')); + + self::assertNotNull($result); + self::assertSame( + "json_type(attributes, '$.\"2.5.4.3\"') IS NOT NULL", + $result->sql, + ); + } + + public function test_numericoid_equality_attribute_translates_with_quoted_path(): void + { + $result = $this->subject->translate(new EqualityFilter( + '2.5.4.3', + 'Alice', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."2.5.4.3"', + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + } + + /** + * @dataProvider provideInvalidAttributeDescriptions + */ + public function test_invalid_attribute_description_throws(string $attribute): void + { + $this->expectException(InvalidAttributeException::class); + + $this->subject->translate(new EqualityFilter( + $attribute, + 'x', + )); + } + + /** + * @return array + */ + public static function provideInvalidAttributeDescriptions(): array + { + return [ + 'empty string' => [''], + 'starts with digit' => ['2cn'], + 'starts with hyphen' => ['-cn'], + 'contains space' => ['cn name'], + 'contains at-sign' => ['cn@dc'], + 'contains equals' => ['cn=value'], + 'contains single quote' => ["cn'"], + 'sql injection' => ["cn'; DROP TABLE entries--"], + 'null byte' => ["cn\0bad"], + 'non-ascii unicode' => ['ñame'], + 'trailing semicolon' => ['cn;'], + 'double semicolon' => ['cn;;lang'], + 'option with special chars' => ['cn;lang@en'], + ]; + } + + public function test_substring_with_no_components_returns_null(): void + { + $result = $this->subject->translate(new SubstringFilter('cn')); + + self::assertNull($result); + } + + public function test_substring_with_starts_with_only(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'Al', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'json_each', + $result->sql, + ); + self::assertSame( + ['Al%'], + $result->params, + ); + } + + public function test_substring_with_ends_with_only(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + null, + 'ice', + )); + + self::assertNotNull($result); + self::assertSame( + ['%ice'], + $result->params, + ); + } + + public function test_substring_with_contains_only(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + null, + null, + 'lic', + )); + + self::assertNotNull($result); + self::assertSame( + ['%lic%'], + $result->params, + ); + } + + public function test_substring_with_all_components(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'A', + 'e', + 'lic', + )); + + self::assertNotNull($result); + self::assertSame( + ['A%', '%lic%', '%e'], + $result->params, + ); + self::assertStringContainsString( + 'AND', + $result->sql, + ); + } + + public function test_substring_escapes_special_like_characters(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'al%ice', + )); + + self::assertNotNull($result); + self::assertSame( + ['al!%ice%'], + $result->params, + ); + } + + public function test_substring_with_single_contains_is_exact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'A', + 'e', + 'lic', + )); + + self::assertNotNull($result); + self::assertTrue($result->isExact); + } + + public function test_substring_with_multiple_contains_is_not_exact(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + 'a', + 'd', + 'b', + 'c', + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_and_filter_all_translatable_returns_combined(): void + { + $result = $this->subject->translate(new AndFilter( + new PresentFilter('cn'), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertStringContainsString( + ' AND ', + $result->sql, + ); + self::assertSame( + ['person'], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_and_filter_partial_translatable_returns_translatable_children_only(): void + { + $result = $this->subject->translate(new AndFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'json_type', + $result->sql, + ); + self::assertSame( + [], + $result->params, + ); + self::assertFalse($result->isExact); + } + + public function test_and_filter_with_no_translatable_children_returns_null(): void + { + $result = $this->subject->translate(new AndFilter( + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNull($result); + } + + public function test_or_filter_all_translatable_returns_combined(): void + { + $result = $this->subject->translate(new OrFilter( + new PresentFilter('cn'), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertStringContainsString( + ' OR ', + $result->sql, + ); + self::assertSame( + ['person'], + $result->params, + ); + } + + public function test_or_filter_all_translatable_is_exact(): void + { + $result = $this->subject->translate(new OrFilter( + new PresentFilter('cn'), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertTrue($result->isExact); + } + + public function test_or_filter_with_inexact_child_is_not_exact(): void + { + $result = $this->subject->translate(new OrFilter( + new AndFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + ), + new EqualityFilter( + 'sn', + 'Smith', + ), + )); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_or_filter_with_one_untranslatable_child_returns_null(): void + { + $result = $this->subject->translate(new OrFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )); + + self::assertNull($result); + } + + public function test_not_filter_with_translatable_child_returns_not_sql(): void + { + $result = $this->subject->translate( + new NotFilter(new PresentFilter('cn')), + ); + + self::assertNotNull($result); + self::assertStringContainsString( + 'NOT (', + $result->sql, + ); + self::assertSame( + [], + $result->params, + ); + } + + public function test_not_present_emits_plain_not_without_presence_guard(): void + { + $result = $this->subject->translate( + new NotFilter(new PresentFilter('cn')), + ); + + self::assertNotNull($result); + self::assertSame( + "NOT (json_type(attributes, '$.\"cn\"') IS NOT NULL)", + $result->sql, + ); + } + + public function test_not_equality_adds_presence_guard(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter( + 'cn', + 'Alice', + )), + ); + + self::assertNotNull($result); + self::assertSame( + "(NOT (EXISTS (SELECT 1 FROM json_each(attributes, '$.\"cn\".values') WHERE lower(value) = lower(?))) AND json_type(attributes, '$.\"cn\"') IS NOT NULL)", + $result->sql, + ); + self::assertSame( + ['Alice'], + $result->params, + ); + self::assertTrue($result->isExact); + } + + public function test_not_equality_with_non_ascii_value_is_inexact(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter( + 'cn', + 'Café', + )), + ); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + self::assertStringContainsString( + "json_type(attributes, '$.\"cn\"') IS NOT NULL", + $result->sql, + ); + } + + public function test_not_substring_adds_presence_guard(): void + { + $result = $this->subject->translate( + new NotFilter(new SubstringFilter( + 'cn', + 'A', + )), + ); + + self::assertNotNull($result); + self::assertStringContainsString( + 'NOT (', + $result->sql, + ); + self::assertStringContainsString( + "json_type(attributes, '$.\"cn\"') IS NOT NULL", + $result->sql, + ); + } + + public function test_not_filter_with_exact_child_is_exact(): void + { + $result = $this->subject->translate( + new NotFilter(new PresentFilter('cn')), + ); + + self::assertNotNull($result); + self::assertTrue($result->isExact); + } + + public function test_not_filter_with_inexact_child_is_not_exact(): void + { + $result = $this->subject->translate( + new NotFilter(new AndFilter( + new PresentFilter('cn'), + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + )), + ); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_not_filter_with_untranslatable_child_returns_null(): void + { + $result = $this->subject->translate( + new NotFilter(new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + )), + ); + + self::assertNull($result); + } + + public function test_matching_rule_filter_always_returns_null(): void + { + $result = $this->subject->translate( + new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'memberOf', + '2', + ), + ); + + self::assertNull($result); + } + + public function test_attribute_name_is_lowercased_in_sql(): void + { + $result = $this->subject->translate(new EqualityFilter( + 'objectClass', + 'person', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + '$."objectclass"', + $result->sql, + ); + self::assertStringNotContainsString( + '$."objectClass"', + $result->sql, + ); + } + + public function test_and_params_are_merged_in_order(): void + { + $result = $this->subject->translate(new AndFilter( + new EqualityFilter( + 'cn', + 'Alice', + ), + new EqualityFilter( + 'objectClass', + 'person', + ), + )); + + self::assertNotNull($result); + self::assertSame( + ['Alice', 'person'], + $result->params, + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php new file mode 100644 index 00000000..69c4259d --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php @@ -0,0 +1,601 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SharedPdoConnectionProvider; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\DnTooLongException; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use PDO; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class SqliteStorageTest extends TestCase +{ + private WritableStorageBackend $subject; + + private PdoStorage $storage; + + private Entry $alice; + + protected function setUp(): void + { + $this->alice = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('userPassword', 'secret'), + ); + + $this->storage = SqliteStorage::forPcntl(':memory:'); + $this->subject = new WritableStorageBackend($this->storage); + $this->subject->add(new AddCommand( + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + )); + $this->subject->add(new AddCommand($this->alice)); + } + + public function test_get_returns_entry_by_dn(): void + { + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + $entry->getDn()->toString(), + ); + } + + public function test_get_is_case_insensitive(): void + { + $entry = $this->subject->get(new Dn('CN=ALICE,DC=EXAMPLE,DC=COM')); + + self::assertNotNull($entry); + } + + public function test_get_returns_null_for_missing_dn(): void + { + self::assertNull($this->subject->get(new Dn('cn=Charlie,dc=example,dc=com'))); + } + + public function test_get_on_empty_database_returns_null(): void + { + $storage = SqliteStorage::forPcntl(':memory:'); + $backend = new WritableStorageBackend($storage); + + self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_add_persists_entry(): void + { + $entry = new Entry(new Dn('cn=Persistent,dc=example,dc=com'), new Attribute('cn', 'Persistent')); + $this->subject->add(new AddCommand($entry)); + + self::assertNotNull($this->subject->get(new Dn('cn=Persistent,dc=example,dc=com'))); + } + + public function test_delete_removes_entry(): void + { + $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_list_single_level_returns_direct_children_only(): void + { + $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); + $this->subject->add(new AddCommand($grandchild)); + + $request = (new SearchRequest(new AndFilter())) + ->base('dc=example,dc=com') + ->useSingleLevelScope(); + $results = iterator_to_array($this->subject->search($request)->entries); + + self::assertCount(1, $results); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + $results[0]->getDn()->toString(), + ); + } + + public function test_list_recursive_includes_base_and_descendants(): void + { + $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); + $this->subject->add(new AddCommand($grandchild)); + + $request = (new SearchRequest(new AndFilter())) + ->base('dc=example,dc=com') + ->useSubtreeScope(); + $results = iterator_to_array($this->subject->search($request)->entries); + + self::assertCount(3, $results); + } + + public function test_list_from_root_returns_all_entries(): void + { + // Test the storage interface directly with an empty base DN (root listing). + // WritableStorageBackend requires the base DN to exist, so bypass it here. + $results = iterator_to_array($this->storage->list(StorageListOptions::matchAll(new Dn(''), true))->entries); + + self::assertCount(2, $results); + } + + public function test_has_children_returns_true_when_children_exist(): void + { + self::assertTrue($this->storage->hasChildren(new Dn('dc=example,dc=com'))); + } + + public function test_has_children_returns_false_for_leaf_entry(): void + { + self::assertFalse($this->storage->hasChildren(new Dn('cn=alice,dc=example,dc=com'))); + } + + public function test_attributes_round_trip_through_storage(): void + { + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertSame(['Alice'], $entry->get('cn')?->getValues()); + self::assertSame(['secret'], $entry->get('userPassword')?->getValues()); + } + + public function test_attribute_name_casing_is_preserved_on_round_trip(): void + { + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertNotNull($entry); + + $names = []; + foreach ($entry->getAttributes() as $attribute) { + $names[] = $attribute->getName(); + } + + self::assertContains( + 'userPassword', + $names, + ); + self::assertNotContains( + 'userpassword', + $names, + ); + } + + public function test_search_matches_mixed_case_attribute_via_lowercase_filter(): void + { + $request = (new SearchRequest(Filters::equal('userpassword', 'secret'))) + ->base('dc=example,dc=com') + ->useSubtreeScope(); + + $results = iterator_to_array($this->subject->search($request)->entries); + + self::assertCount(1, $results); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + $results[0]->getDn()->toString(), + ); + } + + public function test_atomic_rolls_back_on_exception(): void + { + $threw = false; + + try { + $this->storage->atomic(function ($storage): void { + $storage->store(new Entry( + new Dn('cn=Rollback,dc=example,dc=com'), + new Attribute('cn', 'Rollback'), + )); + throw new \RuntimeException('intentional'); + }); + } catch (\RuntimeException) { + $threw = true; + } + + self::assertTrue($threw); + self::assertNull($this->storage->find(new Dn('cn=rollback,dc=example,dc=com'))); + } + + public function test_atomic_commits_on_success(): void + { + $this->storage->atomic(function ($storage): void { + $storage->store(new Entry( + new Dn('cn=Committed,dc=example,dc=com'), + new Attribute('cn', 'Committed'), + )); + }); + + self::assertNotNull($this->storage->find(new Dn('cn=committed,dc=example,dc=com'))); + } + + public function test_atomic_txDepth_is_not_corrupted_when_beginTransaction_fails(): void + { + /** @var PDO&MockObject $mockPdo */ + $mockPdo = $this->createMock(PDO::class); + + $beginTransactionCalls = 0; + $mockPdo->method('beginTransaction') + ->willReturnCallback(static function () use (&$beginTransactionCalls): bool { + if (++$beginTransactionCalls === 1) { + throw new RuntimeException('DB connection error'); + } + + return true; + }); + + $mockPdo->method('inTransaction')->willReturn(false); + $mockPdo->method('commit')->willReturn(true); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($mockPdo), + $this->createMock(FilterTranslatorInterface::class), + new SqliteDialect(), + ); + + // First call: beginTransaction throws; txDepth must recover to 0. + try { + $storage->atomic(fn() => null); + self::fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException $e) { + self::assertSame('DB connection error', $e->getMessage()); + } + + // Second call: txDepth is 0, so beginTransaction must be called again (not SAVEPOINT). + // A corrupted txDepth of 1 would issue SAVEPOINT sp_1 here instead. + $storage->atomic(fn() => null); + + self::assertSame(2, $beginTransactionCalls); + } + + public function test_atomic_savepoint_failure_preserves_original_exception(): void + { + /** @var PDO&MockObject $mockPdo */ + $mockPdo = $this->createMock(PDO::class); + $mockPdo->method('beginTransaction')->willReturn(true); + $mockPdo->method('inTransaction')->willReturn(true); + $mockPdo->method('rollBack')->willReturn(true); + + $execSqlCalls = []; + $mockPdo->method('exec') + ->willReturnCallback(static function (string $sql) use (&$execSqlCalls): int { + $execSqlCalls[] = $sql; + if (str_contains($sql, 'SAVEPOINT sp_1') && !str_contains($sql, 'ROLLBACK') && !str_contains($sql, 'RELEASE')) { + throw new RuntimeException('savepoint error'); + } + + return 0; + }); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($mockPdo), + $this->createMock(FilterTranslatorInterface::class), + new SqliteDialect(), + ); + + try { + $storage->atomic(function ($storage): void { + $storage->atomic(fn() => null); + }); + self::fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException $e) { + self::assertSame( + 'savepoint error', + $e->getMessage(), + ); + } + + self::assertEmpty( + array_filter($execSqlCalls, fn(string $s) => str_contains($s, 'ROLLBACK TO SAVEPOINT')), + 'ROLLBACK TO SAVEPOINT must not be attempted when SAVEPOINT creation itself failed.', + ); + } + + public function test_atomic_savepoint_failure_rolls_back_outer_transaction_when_caught(): void + { + /** @var PDO&MockObject $mockPdo */ + $mockPdo = $this->createMock(PDO::class); + $mockPdo->method('beginTransaction')->willReturn(true); + $mockPdo->method('inTransaction')->willReturn(true); + + $commitCalls = 0; + $rollBackCalls = 0; + $mockPdo->method('commit') + ->willReturnCallback(static function () use (&$commitCalls): bool { + $commitCalls++; + + return true; + }); + $mockPdo->method('rollBack') + ->willReturnCallback(static function () use (&$rollBackCalls): bool { + $rollBackCalls++; + + return true; + }); + + $mockPdo->method('exec') + ->willReturnCallback(static function (string $sql): int { + if (str_contains($sql, 'SAVEPOINT sp_1') && !str_contains($sql, 'ROLLBACK') && !str_contains($sql, 'RELEASE')) { + throw new RuntimeException('savepoint error'); + } + + return 0; + }); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($mockPdo), + $this->createMock(FilterTranslatorInterface::class), + new SqliteDialect(), + ); + + $storage->atomic(function ($storage): void { + try { + $storage->atomic(fn() => null); + } catch (RuntimeException) { + // Caller swallows the inner failure; outer must still abort. + } + }); + + self::assertSame( + 0, + $commitCalls, + 'Outer transaction must not commit after a nested savepoint creation failed.', + ); + self::assertSame( + 1, + $rollBackCalls, + 'Outer transaction must rollback when its broken flag is set.', + ); + } + + public function test_atomic_broken_flag_resets_between_unrelated_top_level_transactions(): void + { + /** @var PDO&MockObject $mockPdo */ + $mockPdo = $this->createMock(PDO::class); + $mockPdo->method('beginTransaction')->willReturn(true); + $mockPdo->method('inTransaction')->willReturn(true); + $mockPdo->method('commit')->willReturn(true); + $mockPdo->method('rollBack')->willReturn(true); + + $callCount = 0; + $mockPdo->method('exec') + ->willReturnCallback(static function (string $sql) use (&$callCount): int { + $callCount++; + if (str_contains($sql, 'SAVEPOINT sp_1') && !str_contains($sql, 'ROLLBACK') && !str_contains($sql, 'RELEASE')) { + throw new RuntimeException('savepoint error'); + } + + return 0; + }); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($mockPdo), + $this->createMock(FilterTranslatorInterface::class), + new SqliteDialect(), + ); + + $storage->atomic(function ($storage): void { + try { + $storage->atomic(fn() => null); + } catch (RuntimeException) { + } + }); + + $commitCalls = 0; + $mockPdo = $this->createMock(PDO::class); + $mockPdo->method('beginTransaction')->willReturn(true); + $mockPdo->method('inTransaction')->willReturn(true); + $mockPdo->method('commit') + ->willReturnCallback(static function () use (&$commitCalls): bool { + $commitCalls++; + + return true; + }); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($mockPdo), + $this->createMock(FilterTranslatorInterface::class), + new SqliteDialect(), + ); + + $storage->atomic(fn() => null); + + self::assertSame( + 1, + $commitCalls, + 'A fresh top-level transaction must commit normally; the broken flag must not leak.', + ); + } + + public function test_find_returns_null_for_entry_with_corrupted_json(): void + { + $pdo = new PDO('sqlite::memory:'); + $dialect = new SqliteDialect(); + PdoStorage::initialize($pdo, $dialect); + $pdo->exec( + "INSERT INTO entries (lc_dn, dn, lc_parent_dn, attributes) VALUES " . + "('cn=corrupt,dc=example,dc=com', 'cn=Corrupt,dc=example,dc=com', 'dc=example,dc=com', 'NOT_VALID_JSON')" + ); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($pdo), + $this->createMock(FilterTranslatorInterface::class), + $dialect, + ); + + self::assertNull($storage->find(new Dn('cn=corrupt,dc=example,dc=com'))); + } + + public function test_list_skips_entry_with_corrupted_json(): void + { + $pdo = new PDO('sqlite::memory:'); + $dialect = new SqliteDialect(); + PdoStorage::initialize($pdo, $dialect); + $pdo->exec( + "INSERT INTO entries (lc_dn, dn, lc_parent_dn, attributes) VALUES " . + "('cn=valid,dc=example,dc=com', 'cn=Valid,dc=example,dc=com', 'dc=example,dc=com', '{\"cn\":{\"name\":\"cn\",\"values\":[\"Valid\"]}}')" + ); + $pdo->exec( + "INSERT INTO entries (lc_dn, dn, lc_parent_dn, attributes) VALUES " . + "('cn=corrupt,dc=example,dc=com', 'cn=Corrupt,dc=example,dc=com', 'dc=example,dc=com', 'NOT_VALID_JSON')" + ); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider($pdo), + $this->createMock(FilterTranslatorInterface::class), + $dialect, + ); + + $results = iterator_to_array( + $storage->list(StorageListOptions::matchAll(new Dn('dc=example,dc=com'), false))->entries + ); + + self::assertCount( + 1, + $results, + ); + self::assertSame( + 'cn=Valid,dc=example,dc=com', + $results[0]->getDn()->toString(), + ); + } + + public function test_store_throws_dn_too_long_when_dn_exceeds_dialect_max(): void + { + $storage = $this->createPdoStorageWithMaxDnLength(10); + + $entry = new Entry( + new Dn('cn=VeryLongNameThatExceedsTheLimit,dc=example,dc=com'), + new Attribute('cn', 'VeryLongNameThatExceedsTheLimit'), + ); + + try { + $storage->store($entry); + self::fail('Expected DnTooLongException was not thrown.'); + } catch (DnTooLongException $e) { + self::assertStringContainsString( + 'exceeds the storage backend limit', + $e->getMessage(), + ); + } + } + + public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void + { + $storage = $this->createPdoStorageWithMaxDnLength(5); + $backend = new WritableStorageBackend($storage); + + // Use a root-level parent (dc=example) so assertParentExists skips the lookup + // and the path reaches PdoStorage::store() where the DnTooLongException fires. + $entry = new Entry( + new Dn('cn=TooLong,dc=example'), + new Attribute('cn', 'TooLong'), + ); + + try { + $backend->add(new AddCommand($entry)); + self::fail('Expected OperationException was not thrown.'); + } catch (OperationException $e) { + self::assertSame( + ResultCode::ADMIN_LIMIT_EXCEEDED, + $e->getCode(), + ); + self::assertInstanceOf( + DnTooLongException::class, + $e->getPrevious(), + ); + } + } + + public function test_store_allows_dn_when_dialect_has_no_length_limit(): void + { + $longDn = 'cn=' . str_repeat('a', 500) . ',dc=example,dc=com'; + + $this->storage->store(new Entry( + new Dn($longDn), + new Attribute('cn', str_repeat('a', 500)), + )); + + self::assertNotNull($this->storage->find(new Dn($longDn))); + } + + private function createPdoStorageWithMaxDnLength(int $max): PdoStorage + { + $pdo = new PDO('sqlite::memory:'); + + $sqlite = new SqliteDialect(); + $dialect = $this->createMock(PdoDialectInterface::class); + $dialect->method('ddlCreateTable') + ->willReturn($sqlite->ddlCreateTable()); + $dialect->method('ddlCreateIndex') + ->willReturn($sqlite->ddlCreateIndex()); + $dialect->method('queryUpsert') + ->willReturn($sqlite->queryUpsert()); + $dialect->method('queryFetchEntry') + ->willReturn($sqlite->queryFetchEntry()); + $dialect->method('queryFetchChildren') + ->willReturn($sqlite->queryFetchChildren()); + $dialect->method('maxDnLength') + ->willReturn($max); + + PdoStorage::initialize($pdo, $dialect); + + return new PdoStorage( + new SharedPdoConnectionProvider($pdo), + $this->createMock(FilterTranslatorInterface::class), + $dialect, + ); + } + + public function test_nested_atomic_rolls_back_inner_on_exception(): void + { + $threw = false; + + $this->storage->atomic(function ($storage) use (&$threw): void { + $storage->store(new Entry( + new Dn('cn=Outer,dc=example,dc=com'), + new Attribute('cn', 'Outer'), + )); + + try { + $storage->atomic(function ($storage): void { + $storage->store(new Entry( + new Dn('cn=Inner,dc=example,dc=com'), + new Attribute('cn', 'Inner'), + )); + throw new \RuntimeException('inner fail'); + }); + } catch (\RuntimeException) { + $threw = true; + } + }); + + self::assertTrue($threw); + self::assertNotNull($this->storage->find(new Dn('cn=outer,dc=example,dc=com'))); + self::assertNull($this->storage->find(new Dn('cn=inner,dc=example,dc=com'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index e16b9439..8e1ac832 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -22,9 +22,13 @@ use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\PresentFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use Generator; +use PHPUnit\Framework\MockObject\MockObject; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -91,13 +95,10 @@ public function test_get_returns_null_for_missing_dn(): void public function test_search_base_scope_returns_only_base(): void { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_BASE_OBJECT, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useBaseScope(); + $entries = iterator_to_array($this->subject->search($request)->entries); self::assertCount( 1, @@ -111,13 +112,10 @@ public function test_search_base_scope_returns_only_base(): void public function test_search_single_level_returns_direct_children(): void { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_SINGLE_LEVEL, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSingleLevelScope(); + $entries = iterator_to_array($this->subject->search($request)->entries); // Only alice is a direct child of dc=example,dc=com; bob is under ou=People self::assertCount( @@ -132,13 +130,10 @@ public function test_search_single_level_returns_direct_children(): void public function test_search_subtree_returns_base_and_all_descendants(): void { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_WHOLE_SUBTREE, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSubtreeScope(); + $entries = iterator_to_array($this->subject->search($request)->entries); self::assertCount( 3, @@ -151,13 +146,10 @@ public function test_search_base_scope_throws_no_such_object_when_base_does_not_ self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('cn=Missing,dc=example,dc=com'), - scope: SearchRequest::SCOPE_BASE_OBJECT, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('cn=Missing,dc=example,dc=com') + ->useBaseScope(); + $this->subject->search($request); } public function test_search_single_level_throws_no_such_object_when_base_does_not_exist(): void @@ -165,13 +157,10 @@ public function test_search_single_level_throws_no_such_object_when_base_does_no self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('cn=Missing,dc=example,dc=com'), - scope: SearchRequest::SCOPE_SINGLE_LEVEL, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('cn=Missing,dc=example,dc=com') + ->useSingleLevelScope(); + $this->subject->search($request); } public function test_search_subtree_throws_no_such_object_when_base_does_not_exist(): void @@ -179,13 +168,10 @@ public function test_search_subtree_throws_no_such_object_when_base_does_not_exi self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('cn=Missing,dc=example,dc=com'), - scope: SearchRequest::SCOPE_WHOLE_SUBTREE, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('cn=Missing,dc=example,dc=com') + ->useSubtreeScope(); + $this->subject->search($request); } public function test_add_stores_entry(): void @@ -474,6 +460,40 @@ public function test_supports_returns_true_for_move_command(): void ))); } + public function test_search_converts_time_limit_exception_to_operation_exception(): void + { + /** @var EntryStorageInterface&MockObject $storage */ + $storage = $this->createMock(EntryStorageInterface::class); + $storage->method('find')->willReturn( + new Entry( + new Dn('dc=example,dc=com'), + new Attribute('dc', 'example'), + ), + ); + $storage->method('list')->willReturn( + new EntryStream($this->makeTimeLimitStream()), + ); + + $subject = new WritableStorageBackend($storage); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::TIME_LIMIT_EXCEEDED); + + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSingleLevelScope(); + iterator_to_array($subject->search($request)->entries); + } + + /** + * @return Generator + */ + private function makeTimeLimitStream(): Generator + { + yield new Entry(new Dn('dc=example,dc=com')); + throw new TimeLimitExceededException(); + } + public function test_supports_returns_false_for_unknown_request(): void { $unknown = $this->createMock(WriteRequestInterface::class); diff --git a/tests/unit/Server/RequestHandler/ProxyBackendTest.php b/tests/unit/Server/RequestHandler/ProxyBackendTest.php index bc17f09a..022aa0a9 100644 --- a/tests/unit/Server/RequestHandler/ProxyBackendTest.php +++ b/tests/unit/Server/RequestHandler/ProxyBackendTest.php @@ -13,6 +13,8 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\RequestHandler; +use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; @@ -27,7 +29,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Search\Filter\PresentFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -52,15 +53,16 @@ protected function setUp(): void $this->subject->setLdapClient($this->mockLdap); } - private function makeContext(int $scope = SearchRequest::SCOPE_WHOLE_SUBTREE): SearchContext - { - return new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: $scope, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ); + private function makeRequest( + int $scope = SearchRequest::SCOPE_WHOLE_SUBTREE, + int $sizeLimit = 0, + int $timeLimit = 0, + ): SearchRequest { + return (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->setScope($scope) + ->sizeLimit($sizeLimit) + ->timeLimit($timeLimit); } public function test_search_yields_entries_from_upstream(): void @@ -72,7 +74,7 @@ public function test_search_yields_entries_from_upstream(): void ->method('search') ->willReturn(new Entries($entry)); - $results = iterator_to_array($this->subject->search($this->makeContext())); + $results = iterator_to_array($this->subject->search($this->makeRequest())->entries); self::assertCount(1, $results); self::assertSame($entry, $results[0]); @@ -86,7 +88,7 @@ public function test_search_uses_base_scope(): void ->with(self::callback(fn(SearchRequest $r) => $r->getScope() === SearchRequest::SCOPE_BASE_OBJECT)) ->willReturn(new Entries()); - iterator_to_array($this->subject->search($this->makeContext(SearchRequest::SCOPE_BASE_OBJECT))); + iterator_to_array($this->subject->search($this->makeRequest(SearchRequest::SCOPE_BASE_OBJECT))->entries); } public function test_search_uses_single_level_scope(): void @@ -97,7 +99,7 @@ public function test_search_uses_single_level_scope(): void ->with(self::callback(fn(SearchRequest $r) => $r->getScope() === SearchRequest::SCOPE_SINGLE_LEVEL)) ->willReturn(new Entries()); - iterator_to_array($this->subject->search($this->makeContext(SearchRequest::SCOPE_SINGLE_LEVEL))); + iterator_to_array($this->subject->search($this->makeRequest(SearchRequest::SCOPE_SINGLE_LEVEL))->entries); } public function test_search_uses_subtree_scope_by_default(): void @@ -108,7 +110,57 @@ public function test_search_uses_subtree_scope_by_default(): void ->with(self::callback(fn(SearchRequest $r) => $r->getScope() === SearchRequest::SCOPE_WHOLE_SUBTREE)) ->willReturn(new Entries()); - iterator_to_array($this->subject->search($this->makeContext())); + iterator_to_array($this->subject->search($this->makeRequest())->entries); + } + + public function test_search_marks_stream_pre_filtered(): void + { + $this->mockLdap + ->method('search') + ->willReturn(new Entries()); + + $stream = $this->subject->search($this->makeRequest()); + + self::assertTrue($stream->isPreFiltered); + } + + public function test_search_forwards_size_and_time_limits(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->with(self::callback( + fn(SearchRequest $r): bool => $r->getSizeLimit() === 25 && $r->getTimeLimit() === 7, + )) + ->willReturn(new Entries()); + + iterator_to_array( + $this->subject->search($this->makeRequest( + sizeLimit: 25, + timeLimit: 7, + ))->entries, + ); + } + + public function test_search_forwards_controls_to_upstream(): void + { + $control = new Control('1.2.840.113556.1.4.473'); + + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->with( + self::isInstanceOf(SearchRequest::class), + $control, + ) + ->willReturn(new Entries()); + + iterator_to_array( + $this->subject->search( + $this->makeRequest(), + new ControlBag($control), + )->entries, + ); } public function test_get_reads_entry_by_dn(): void