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/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index fc717896..c9d02697 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) @@ -386,7 +388,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 +453,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/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..d62bf2b4 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -143,30 +143,24 @@ private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse { $searchRequest = $pagingRequest->getSearchRequest(); $context = $this->makeSearchContext($searchRequest); - $generator = $this->backend->search($context); + $result = $this->backend->search($context); + $generator = $result->entries; + $isPreFiltered = $result->isPreFiltered; - [$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator( + $collected = $this->collectFromGenerator( $generator, $pagingRequest->getSize(), $context, 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, + ); } /** @@ -203,49 +197,73 @@ 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->getTotalSent(), + $isPreFiltered, ); - if ($sizeLimitExceeded) { - return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + return $this->buildPagingResponse( + $collected, + $pagingRequest, + $generator, + $isPreFiltered + ); + } + + /** + * @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] + * reached before the generator is exhausted, $isSizeLimitExceeded is true in the return. */ private function collectFromGenerator( Generator $generator, int $pageSize, SearchContext $context, int $totalAlreadySent, - ): array { + bool $isPreFiltered = false, + ): CollectedPage { $page = []; $pageLimit = $pageSize > 0 ? $pageSize : PHP_INT_MAX; $sizeLimit = $context->sizeLimit; @@ -253,11 +271,11 @@ private function collectFromGenerator( 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, $context->filter))) { $page[] = $this->applyAttributeFilter( $entry, $context->attributes, - $context->typesOnly + $context->typesOnly, ); if ($sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit) { @@ -274,11 +292,11 @@ private function collectFromGenerator( && $sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit; - return [ + return new CollectedPage( $page, $generatorExhausted, $sizeLimitExceeded, - ]; + ); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index d38b9628..120a41a8 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -57,11 +57,18 @@ public function handleRequest( $results = []; $sizeLimitExceeded = false; - foreach ($this->backend->search($context) as $entry) { - if ($this->filterEvaluator->evaluate($entry, $filter)) { - $results[] = $this->applyAttributeFilter($entry, $attributes, $typesOnly); + $result = $this->backend->search($context); + + foreach ($result->entries as $entry) { + if ($result->isPreFiltered || $this->filterEvaluator->evaluate($entry, $filter)) { + $results[] = $this->applyAttributeFilter( + $entry, + $attributes, + $typesOnly, + ); if ($sizeLimit > 0 && count($results) >= $sizeLimit) { $sizeLimitExceeded = true; + break; } } diff --git a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php index aeb951f7..4984f527 100644 --- a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use Generator; /** @@ -30,7 +31,15 @@ */ final class GenericBackend implements LdapBackendInterface { - public function search(SearchContext $context): Generator + public function search(SearchContext $context): 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..068cc24d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -17,7 +17,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; 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 +38,14 @@ 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(SearchContext $context): EntryStream; /** * Fetch a single entry by DN, or return null if it does not exist. 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..482c3e22 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,22 +63,27 @@ 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(); + $normBase = $options->baseDn->toString(); foreach ($this->data as $normDn => $entryData) { - if ($normBase === '' && $subtree) { + if ($normBase === '' && $options->subtree) { yield ($this->toEntry)($entryData); - } elseif ($subtree) { + } elseif ($options->subtree) { if ($normDn === $normBase || str_ends_with($normDn, ',' . $normBase)) { yield ($this->toEntry)($entryData); } } else { - if ((new Dn($normDn))->isChildOf($baseDn)) { + if ((new Dn($normDn))->isChildOf($options->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..ce589c14 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php @@ -0,0 +1,114 @@ + + * + * 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\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 +{ + 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(); + } + + public function create(): PdoStorage + { + return $this->createShared(); + } + + private function createShared(): PdoStorage + { + $dialect = new MysqlDialect(); + $pdo = $this->openConnection($dialect); + + return new PdoStorage( + new SharedPdoConnectionProvider($pdo), + new MysqlFilterTranslator(), + $dialect, + ); + } + + private function createPerCoroutine(): PdoStorage + { + $dialect = new MysqlDialect(); + + return new PdoStorage( + new CoroutinePdoConnectionProvider(fn(): PDO => $this->openConnection($dialect)), + new MysqlFilterTranslator(), + $dialect, + ); + } + + private function openConnection(MysqlDialect $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/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..cfe365c0 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -0,0 +1,368 @@ + + * + * 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) { + $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)}"); + } + + throw $e; + } finally { + $txState->depth--; + } + } + + 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/PdoTxState.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php new file mode 100644 index 00000000..dabdecf8 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoTxState.php @@ -0,0 +1,24 @@ + + * + * 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; +} 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 + */ + public function __construct( + public readonly string $sql, + public readonly array $params, + public readonly bool $isExact = true, + ) { + } +} 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..54c5abee --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php @@ -0,0 +1,314 @@ + + * + * 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 + * - 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(); + + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) = lower(?)"), + [$filter->getValue()], + ); + } + + private function translateApproximate(ApproximateFilter $filter): ?SqlFilterResult + { + $attribute = $this->validateAttribute($filter->getAttribute()); + + $alias = $this->valueAlias(); + + return new SqlFilterResult( + $this->buildValueExists($attribute, "lower($alias) = lower(?)"), + [$filter->getValue()], + ); + } + + 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, + ); + } + + 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, + ); + } + + 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; + + return new SqlFilterResult( + $this->buildValueExists($attribute, implode(' AND ', $conditions)), + $params, + isExact: $isExact, + ); + } + + /** + * @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 + { + $result = $this->translate($filter->get()); + + if ($result === null) { + return null; + } + + return new SqlFilterResult( + 'NOT (' . $result->sql . ')', + $result->params, + isExact: $result->isExact, + ); + } + + /** + * 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..3beabb3e --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterUtility.php @@ -0,0 +1,34 @@ + + * + * 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, + ); + } +} 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..c720a406 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php @@ -0,0 +1,95 @@ + + * + * 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\SqliteDialect; +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, then call create(): + * + * SqliteStorage::forPcntl('/path/to/db.sqlite') + * SqliteStorage::forSwoole('/path/to/db.sqlite') + * + * @author Chad Sikorra + */ +final class SqliteStorage implements PdoStorageFactoryInterface +{ + 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(); + } + + public function create(): PdoStorage + { + return $this->createShared(); + } + + private function createShared(): PdoStorage + { + $dialect = new SqliteDialect(); + $pdo = $this->openConnection($dialect); + + return new PdoStorage( + new SharedPdoConnectionProvider($pdo), + new SqliteFilterTranslator(), + $dialect, + ); + } + + private function createPerCoroutine(): PdoStorage + { + $dialect = new SqliteDialect(); + + return new PdoStorage( + new CoroutinePdoConnectionProvider(fn(): PDO => $this->openConnection($dialect)), + new SqliteFilterTranslator(), + $dialect, + ); + } + + private function openConnection(SqliteDialect $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..ecbecc7a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -25,6 +25,9 @@ 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,7 +91,7 @@ public function compare( /** * @throws OperationException */ - public function search(SearchContext $context): Generator + public function search(SearchContext $context): EntryStream { $normBase = $context->baseDn->normalize(); @@ -99,17 +102,64 @@ public function search(SearchContext $context): Generator $this->throwNoSuchObject($context->baseDn); } - yield $entry; - - return; + return new EntryStream($this->yieldSingle($entry)); } if ($this->storage->find($normBase) === null) { $this->throwNoSuchObject($context->baseDn); } - foreach ($this->storage->list($normBase, $context->scope === SearchRequest::SCOPE_WHOLE_SUBTREE) as $entry) { - yield $entry; + $subtree = $context->scope === SearchRequest::SCOPE_WHOLE_SUBTREE; + $options = new StorageListOptions( + baseDn: $normBase, + subtree: $subtree, + filter: $context->filter, + timeLimit: $context->timeLimit, + ); + + try { + $stream = $this->storage->list($options); + } catch (InvalidAttributeException $e) { + throw new OperationException( + $e->getMessage(), + ResultCode::PROTOCOL_ERROR, + $e, + ); + } + + 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 +168,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 +185,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 +208,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 +223,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 +248,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..13728b84 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -22,9 +22,7 @@ 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; @@ -32,6 +30,7 @@ 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,7 +63,15 @@ public function __construct( } } - public function search(SearchContext $context): Generator + public function search(SearchContext $context): EntryStream + { + return new EntryStream($this->yieldSearchResults($context)); + } + + /** + * @return Generator + */ + private function yieldSearchResults(SearchContext $context): Generator { $request = $this->buildSearchRequest($context); $entries = $this->ldap()->search($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..b33d6227 100644 --- a/tests/bin/ldap-server.php +++ b/tests/bin/ldap-server.php @@ -10,6 +10,7 @@ use FreeDSx\Ldap\Search\Filter\EqualityFilter; 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,13 +32,18 @@ class LdapServerBackend implements WritableLdapBackendInterface, PasswordAuthent 'cn=user,dc=foo,dc=bar' => '12345', ]; - public function search(SearchContext $context): Generator + public function search(SearchContext $context): EntryStream { $this->logRequest( 'search', "base-dn => {$context->baseDn->toString()}, filter => {$context->filter->toString()}" ); + return new EntryStream($this->yieldSearchResults($context)); + } + + private function yieldSearchResults(SearchContext $context): Generator + { yield Entry::fromArray( $context->baseDn->toString(), [ @@ -159,7 +165,12 @@ private function logRequest(string $type, string $message): void class LdapServerPagingBackend extends LdapServerBackend { - public function search(SearchContext $context): Generator + public function search(SearchContext $context): EntryStream + { + return new EntryStream($this->yieldPagingResults()); + } + + private function yieldPagingResults(): Generator { for ($i = 1; $i <= 300; $i++) { yield Entry::fromArray( @@ -196,6 +207,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/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..edc89312 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -29,6 +29,7 @@ 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; @@ -87,7 +88,7 @@ public function test_it_should_call_the_backend_search_on_paging_start_and_retur ->expects(self::once()) ->method('search') ->with(self::isInstanceOf(SearchContext::class)) - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $resultEntry1 = new LdapMessageResponse( 2, @@ -130,7 +131,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 +164,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 +332,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 +366,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..b7da12c4 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -26,6 +26,7 @@ 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; @@ -77,7 +78,7 @@ public function test_it_should_send_entries_from_the_backend_to_the_client(): vo ->expects(self::once()) ->method('search') ->with(self::isInstanceOf(SearchContext::class)) - ->willReturn($this->makeGenerator($entry1, $entry2)); + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $this->mockFilterEvaluator ->method('evaluate') @@ -114,7 +115,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 +190,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 +225,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 +253,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..86f38f37 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, @@ -147,7 +148,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 +166,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..4b86fbce 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -176,7 +176,7 @@ public function test_list_single_level_returns_direct_children_only(): void [], false, ); - $results = iterator_to_array($this->subject->search($context)); + $results = iterator_to_array($this->subject->search($context)->entries); self::assertCount( 1, @@ -203,7 +203,7 @@ public function test_list_recursive_includes_base_and_descendants(): void [], false, ); - $results = iterator_to_array($this->subject->search($context)); + $results = iterator_to_array($this->subject->search($context)->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..62e32e4c --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php @@ -0,0 +1,497 @@ + + * + * 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_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_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/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..e9aa4832 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -0,0 +1,588 @@ + + * + * 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_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_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..462c8458 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php @@ -0,0 +1,498 @@ + + * + * 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\SearchContext; +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)); + + $context = new SearchContext( + new Dn('dc=example,dc=com'), + SearchRequest::SCOPE_SINGLE_LEVEL, + new AndFilter(), + [], + false, + ); + $results = iterator_to_array($this->subject->search($context)->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)); + + $context = new SearchContext( + new Dn('dc=example,dc=com'), + SearchRequest::SCOPE_WHOLE_SUBTREE, + new AndFilter(), + [], + false, + ); + $results = iterator_to_array($this->subject->search($context)->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 + { + $context = new SearchContext( + new Dn('dc=example,dc=com'), + SearchRequest::SCOPE_WHOLE_SUBTREE, + Filters::equal('userpassword', 'secret'), + [], + false, + ); + + $results = iterator_to_array($this->subject->search($context)->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_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..651a0e53 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -24,7 +24,12 @@ 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; @@ -97,7 +102,7 @@ public function test_search_base_scope_returns_only_base(): void filter: new PresentFilter('objectClass'), attributes: [], typesOnly: false, - ))); + ))->entries); self::assertCount( 1, @@ -117,7 +122,7 @@ public function test_search_single_level_returns_direct_children(): void filter: new PresentFilter('objectClass'), attributes: [], typesOnly: false, - ))); + ))->entries); // Only alice is a direct child of dc=example,dc=com; bob is under ou=People self::assertCount( @@ -138,7 +143,7 @@ public function test_search_subtree_returns_base_and_all_descendants(): void filter: new PresentFilter('objectClass'), attributes: [], typesOnly: false, - ))); + ))->entries); self::assertCount( 3, @@ -151,13 +156,13 @@ 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( + $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, - ))); + )); } public function test_search_single_level_throws_no_such_object_when_base_does_not_exist(): void @@ -165,13 +170,13 @@ 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( + $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, - ))); + )); } public function test_search_subtree_throws_no_such_object_when_base_does_not_exist(): void @@ -179,13 +184,13 @@ 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( + $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, - ))); + )); } public function test_add_stores_entry(): void @@ -474,6 +479,43 @@ 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); + + iterator_to_array($subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_SINGLE_LEVEL, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))->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..98dd3c7b 100644 --- a/tests/unit/Server/RequestHandler/ProxyBackendTest.php +++ b/tests/unit/Server/RequestHandler/ProxyBackendTest.php @@ -72,7 +72,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->makeContext())->entries); self::assertCount(1, $results); self::assertSame($entry, $results[0]); @@ -86,7 +86,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->makeContext(SearchRequest::SCOPE_BASE_OBJECT))->entries); } public function test_search_uses_single_level_scope(): void @@ -97,7 +97,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->makeContext(SearchRequest::SCOPE_SINGLE_LEVEL))->entries); } public function test_search_uses_subtree_scope_by_default(): void @@ -108,7 +108,7 @@ 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->makeContext())->entries); } public function test_get_reads_entry_by_dn(): void