From 197f520ddd5cdf9e5c8b80aa37c3fc0a7c6d93d7 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 29 Mar 2026 16:22:25 -0400 Subject: [PATCH 01/35] Add an extensible storage backend system for the LDAP server. --- .github/workflows/analysis.yml | 2 +- .github/workflows/build.yml | 14 +- UPGRADE-1.0.md | 124 ++-- composer.json | 3 +- docs/Server/Configuration.md | 110 ++-- docs/Server/General-Usage.md | 580 +++++++++--------- src/FreeDSx/Ldap/Container.php | 12 +- src/FreeDSx/Ldap/LdapServer.php | 80 ++- .../MechanismOptionsBuilderFactory.php | 43 +- .../PlainMechanismOptionsBuilder.php | 9 +- .../Ldap/Protocol/Bind/Sasl/SaslExchange.php | 6 +- src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php | 19 +- src/FreeDSx/Ldap/Protocol/Bind/SimpleBind.php | 6 +- .../Factory/ServerProtocolHandlerFactory.php | 19 +- .../ServerDispatchHandler.php | 58 +- .../ServerPagingHandler.php | 176 ++++-- .../ServerPagingUnsupportedHandler.php | 105 ---- .../ServerRootDseHandler.php | 6 +- .../ServerSearchHandler.php | 49 +- .../ServerSearchTrait.php | 68 ++ .../BindNameResolverInterface.php | 29 + .../Auth/NameResolver/DnBindNameResolver.php | 41 ++ .../Auth/PasswordAuthenticatableInterface.php | 39 ++ .../Backend/Auth/PasswordAuthenticator.php | 95 +++ .../Ldap/Server/Backend/GenericBackend.php | 39 ++ .../Server/Backend/LdapBackendInterface.php | 52 ++ .../Ldap/Server/Backend/SearchContext.php | 43 ++ .../Adapter/InMemoryStorageAdapter.php | 210 +++++++ .../Adapter/JsonFileStorageAdapter.php | 400 ++++++++++++ .../Storage/Adapter/StorageAdapterTrait.php | 82 +++ .../Backend/Storage/FilterEvaluator.php | 315 ++++++++++ .../Storage/FilterEvaluatorInterface.php | 37 ++ .../Backend/Write/Command/AddCommand.php | 29 + .../Backend/Write/Command/DeleteCommand.php | 29 + .../Backend/Write/Command/MoveCommand.php | 34 + .../Backend/Write/Command/UpdateCommand.php | 35 ++ .../Backend/Write/WritableBackendTrait.php | 58 ++ .../Write/WritableLdapBackendInterface.php | 32 + .../Backend/Write/WriteCommandFactory.php | 59 ++ .../Backend/Write/WriteHandlerInterface.php | 29 + .../Write/WriteOperationDispatcher.php | 58 ++ .../Backend/Write/WriteRequestInterface.php | 21 + .../Ldap/Server/HandlerFactoryInterface.php | 37 +- .../RequestHandler/GenericRequestHandler.php | 115 ---- .../Server/RequestHandler/HandlerFactory.php | 76 ++- .../RequestHandler/PagingHandlerInterface.php | 49 -- .../Server/RequestHandler/ProxyBackend.php | 155 +++++ .../Server/RequestHandler/ProxyHandler.php | 5 +- .../RequestHandler/ProxyPagingHandler.php | 87 --- .../RequestHandler/ProxyRequestHandler.php | 169 ----- .../RequestHandlerInterface.php | 116 ---- .../Server/RequestHandler/SearchResult.php | 102 --- src/FreeDSx/Ldap/Server/RequestHistory.php | 37 ++ .../Ldap/Server/ServerProtocolFactory.php | 9 +- .../ServerRunner/SwooleServerRunner.php | 132 ++++ src/FreeDSx/Ldap/ServerOptions.php | 79 ++- tests/bin/ldapbackendstorage.php | 75 +++ tests/bin/ldapserver.php | 234 +++---- .../LdapBackendStorageSwooleTest.php | 82 +++ tests/integration/LdapBackendStorageTest.php | 303 +++++++++ tests/integration/LdapServerTest.php | 49 +- tests/integration/ServerTestCase.php | 51 +- tests/unit/LdapServerTest.php | 92 +-- .../MechanismOptionsBuilderFactoryTest.php | 66 +- .../PlainMechanismOptionsBuilderTest.php | 20 +- .../Protocol/Bind/Sasl/SaslExchangeTest.php | 6 +- tests/unit/Protocol/Bind/SaslBindTest.php | 39 +- tests/unit/Protocol/Bind/SimpleBindTest.php | 16 +- .../ServerProtocolHandlerFactoryTest.php | 60 +- .../ServerDispatchHandlerTest.php | 166 ++--- .../ServerPagingHandlerTest.php | 189 +++--- .../ServerPagingUnsupportedHandlerTest.php | 168 ----- .../ServerRootDseHandlerTest.php | 44 +- .../ServerSearchHandlerTest.php | 148 ++--- .../NameResolver/DnBindNameResolverTest.php | 63 ++ .../PasswordAuthenticatorTest.php | 110 ++++ .../Adapter/InMemoryStorageAdapterTest.php | 234 +++++++ .../Adapter/JsonFileStorageAdapterTest.php | 285 +++++++++ .../Backend/Storage/FilterEvaluatorTest.php | 464 ++++++++++++++ .../Backend/Write/WriteCommandFactoryTest.php | 90 +++ .../Write/WriteOperationDispatcherTest.php | 89 +++ .../GenericRequestHandlerTest.php | 81 --- .../RequestHandler/HandlerFactoryTest.php | 135 +++- .../RequestHandler/ProxyBackendTest.php | 199 ++++++ .../RequestHandler/ProxyPagingHandlerTest.php | 129 ---- .../ProxyRequestHandlerTest.php | 271 -------- .../unit/Server/ServerProtocolFactoryTest.php | 22 +- .../ServerRunner/SwooleServerRunnerTest.php | 36 ++ 88 files changed, 5813 insertions(+), 2626 deletions(-) delete mode 100644 src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandler.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/GenericBackend.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/SearchContext.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluatorInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/Command/AddCommand.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/Command/DeleteCommand.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/Command/UpdateCommand.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WritableBackendTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WritableLdapBackendInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WriteCommandFactory.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WriteHandlerInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WriteOperationDispatcher.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestInterface.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/GenericRequestHandler.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/PagingHandlerInterface.php create mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/ProxyPagingHandler.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/ProxyRequestHandler.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/RequestHandlerInterface.php delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/SearchResult.php create mode 100644 src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php create mode 100644 tests/bin/ldapbackendstorage.php create mode 100644 tests/integration/LdapBackendStorageSwooleTest.php create mode 100644 tests/integration/LdapBackendStorageTest.php delete mode 100644 tests/unit/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandlerTest.php create mode 100644 tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php create mode 100644 tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php create mode 100644 tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php create mode 100644 tests/unit/Server/Backend/Write/WriteCommandFactoryTest.php create mode 100644 tests/unit/Server/Backend/Write/WriteOperationDispatcherTest.php delete mode 100644 tests/unit/Server/RequestHandler/GenericRequestHandlerTest.php create mode 100644 tests/unit/Server/RequestHandler/ProxyBackendTest.php delete mode 100644 tests/unit/Server/RequestHandler/ProxyPagingHandlerTest.php delete mode 100644 tests/unit/Server/RequestHandler/ProxyRequestHandlerTest.php create mode 100644 tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 713fdc0d..5377f12c 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -12,7 +12,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extension-csv: openssl,mbstring + extensions: openssl,mbstring,swoole-5.1.6 coverage: pcov tools: composer:2.9 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e745ebd3..c7118270 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,11 +13,21 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Setup PHP + - name: Setup PHP (Linux) + if: runner.os == 'Linux' + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: swoole-5.1.6 + coverage: none + tools: composer:2.9 + + - name: Setup PHP (Windows) + if: runner.os == 'Windows' uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - coverage: xdebug + coverage: none tools: composer:2.9 # Linux: OpenLDAP via Docker diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index 56b35924..6487e5f0 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -7,7 +7,7 @@ Upgrading from 0.x to 1.0 * [Server Options](#server-options) * [Constructing a Proxy Server](#constructing-a-proxy-server) * [Using a Custom ServerRunner](#using-a-custom-serverrunner) - * [Request Handler Search Return Type](#request-handler-search-return-type) + * [Migrating from RequestHandlerInterface to LdapBackendInterface](#migrating-from-requesthandlerinterface-to-ldapbackendinterface) ## Client Changes @@ -148,65 +148,109 @@ $ldap = new LdapServer( ); ``` -## Using Request Handlers +## Migrating from RequestHandlerInterface to LdapBackendInterface -Previously, you could have set request handlers using the fully qualified class name. These must now be class instances. +`RequestHandlerInterface`, `GenericRequestHandler`, `PagingHandlerInterface`, and related classes have been removed. +The server now works with a single backend object that implements `LdapBackendInterface` (read-only) or +`WritableLdapBackendInterface` (read-write). Paging is handled automatically by the framework via PHP generators — +no separate paging handler is needed. -**Before**: +**Before** (0.x `RequestHandlerInterface`): ```php -use FreeDSx\Ldap\LdapServer; -use Foo\LdapProxyHandler; +use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Server\RequestContext; +use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -$server = new LdapServer([ - 'request_handler' => LdapProxyHandler::class, -]); -$server->run(); +class MyHandler extends GenericRequestHandler +{ + public function bind( + RequestContext $context, + string $username, + string $password, + ): bool { + return $username === 'admin' && $password === 'secret'; + } + + public function search( + RequestContext $context, + SearchRequest $request, + ): Entries { + return new Entries(); + } +} + +$server = new LdapServer( + (new ServerOptions) + ->setRequestHandler(new MyHandler()) +); ``` -**After**: +**After** (1.0 `LdapBackendInterface`): ```php +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\ServerOptions; -use Foo\LdapProxyHandler; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use Generator; -$server = new LdapServer( - (new ServerOptions) - ->setRequestHandler(new LdapProxyHandler()) -); -$server->run(); +class MyBackend implements LdapBackendInterface +{ + public function search(SearchContext $context): Generator + { + // Yield matching entries. The framework handles paging automatically. + yield from []; + } + + public function get(Dn $dn): ?Entry + { + return null; + } + + public function verifyPassword( + Dn $dn, + string $password, + ): bool { + return $dn->toString() === 'cn=admin,dc=example,dc=com' + && $password === 'secret'; + } +} + +$server = (new LdapServer()) + ->useBackend(new MyBackend()); ``` -## Request Handler Search Return Type +### Write operations -The `search()` method of `RequestHandlerInterface` now returns `SearchResult` instead of `Entries`. Wrap your returned -entries in `SearchResult::make()`. The new return type also allows returning response controls and non-success result -codes (e.g. for partial results). +`add()`, `delete()`, `update()`, and `move()` are now part of `WritableLdapBackendInterface`. Implement that interface +instead of `LdapBackendInterface` when your backend supports write operations. -**Before**: +### Paging + +Paging no longer requires a `PagingHandlerInterface` implementation. Return a `Generator` from `search()` and the +framework automatically slices it into pages when a client sends a paged search control. The `supportedControl` +attribute of the RootDSE is populated automatically when a backend is configured. + +### Proxy server + +`ProxyRequestHandler` has been replaced by `ProxyBackend`. Extend it and provide an `LdapClient` instance: ```php -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\RequestHandler\ProxyBackend; -public function search(RequestContext $context, SearchRequest $search): Entries -{ - return new Entries(/* ... */); -} +$server = (new LdapServer()) + ->useBackend(new ProxyBackend( + (new ClientOptions)->setServers(['ldap.example.com']) + )); ``` -**After**: +Or use the convenience factory (unchanged from 0.x): ```php -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; - -public function search(RequestContext $context, SearchRequest $search): SearchResult -{ - return SearchResult::make(new Entries(/* ... */)); -} +$server = LdapServer::makeProxy('ldap.example.com'); ``` diff --git a/composer.json b/composer.json index e50080dd..a2db98a8 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ }, "suggest": { "ext-openssl": "For SSL/TLS support and some SASL mechanisms.", - "ext-pcntl": "For LDAP server functionality." + "ext-pcntl": "For LDAP server functionality.", + "ext-swoole": "For the SwooleServerRunner, enabling coroutine-based connection handling with shared in-memory storage." }, "autoload": { "psr-4": {"FreeDSx\\Ldap\\": "src/FreeDSx/Ldap"} diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index 55c7cc62..78e36ddc 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -10,10 +10,10 @@ LDAP Server Configuration * [ServerOptions:setIdleTimeout](#setidletimeout) * [ServerOptions:setRequireAuthentication](#setrequireauthentication) * [ServerOptions:setAllowAnonymous](#setallowanonymous) -* [LDAP Protocol Handlers](#ldap-protocol-handlers) - * [ServerOptions:setRequestHandler](#setrequesthandler) - * [ServerOptions:setRootDseHandler](#setrootdsehandler) - * [ServerOptions:setPagingHandler](#setpaginghandler) +* [Backend](#backend) + * [ServerOptions:setBackend](#setbackend) + * [ServerOptions:setFilterEvaluator](#setfilterevaluator) + * [ServerOptions:setRootDseHandler](#setrootdsehandler) * [RootDSE Options](#rootdse-options) * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) * [ServerOptions:setDseAltServer](#setdsealtserver) @@ -36,7 +36,7 @@ use FreeDSx\Ldap\LdapServer; $options = (new ServerOptions) ->setDseAltServer('dc2.local') ->setPort(33389); - + $ldap = new LdapServer($options); ``` @@ -64,7 +64,7 @@ than 1024 instead if needed. ------------------ #### setUnixSocket -When using `unix` as the transport type, this is the full path to the socket file the client must interact with. +When using `unix` as the transport type, this is the full path to the socket file the client must interact with. **Default**: `/var/run/ldap.socket` @@ -124,68 +124,82 @@ Whether anonymous binds should be allowed. **Default**: `false` -## LDAP Protocol Handlers +## Backend -The LDAP server works by being provided "handler" classes. These classes implement interfaces to handle specific LDAP -client requests and finish responses to them. You can either define a fully qualified class name for the handler in the -option, or provide an instance of the class. There are also methods available on the server for setting instances of these -handlers (which will be detailed below). +The LDAP server works by being provided a backend that implements `LdapBackendInterface` (or the writable extension +`WritableLdapBackendInterface`). The backend is responsible for handling directory data (search, authentication, and +optionally write operations). You can also plug in a custom filter evaluator or a custom RootDSE handler. ------------------ -#### setRequestHandler +#### setBackend + +This should be an object instance that implements `FreeDSx\Ldap\Server\Backend\LdapBackendInterface`. All directory +operations (search, authenticate, and optionally write) are dispatched to this backend. Paging is handled automatically +by the framework — no separate paging handler is needed. + +You can also use the fluent `useBackend()` method on `LdapServer` instead of setting it in `ServerOptions`: + +```php +use FreeDSx\Ldap\LdapServer; +use App\MyDirectoryBackend; -This should be an object instance that implements `FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface`. Server -request operations are then passed to this class along with the request context. +$server = (new LdapServer()) + ->useBackend(new MyDirectoryBackend()); +``` -This request handler is used for each client connection. +Or via `ServerOptions`: ```php use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use App\MySpecialRequestHandler; +use App\MyDirectoryBackend; $server = new LdapServer( (new ServerOptions) - ->setRequestHandler(new MySpecialRequestHandler()) + ->setBackend(new MyDirectoryBackend()) ); ``` -**Default**: `FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler` +**Default**: `null` (a no-op backend that returns errors for all operations) -#### setRootDseHandler +------------------ +#### setFilterEvaluator -This should be an object instance that implements `FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface`. If this is defined, -the server will use it when responding to RootDSE requests from clients. If it is not defined, then the server will always -respond with a default RootDSE entry composed of values provided in the `ServerOptions::getDse*()` config options. +This should be an object instance that implements `FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface`. If provided, +the server uses it when evaluating LDAP search filters against candidate entries returned by the backend. The default +evaluator covers all standard LDAP filter types. A custom evaluator is useful when you need non-standard matching rules +(for example, bitwise matching rules for Active Directory compatibility). ```php use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use App\MySpecialRootDseHandler; +use App\MyFilterEvaluator; -$server = new LdapServer( - (new ServerOptions) - ->setRootDseHandler(new MySpecialRootDseHandler()) -); +$server = (new LdapServer()) + ->useFilterEvaluator(new MyFilterEvaluator()); ``` -**Default**: `null` +**Default**: `FreeDSx\Ldap\Server\Storage\FilterEvaluator` + +------------------ +#### setRootDseHandler -#### setPagingHandler +This should be an object instance that implements `FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface`. If +defined, the server calls it when responding to RootDSE requests from clients, passing the pre-built default entry so +the handler can inspect or augment it. If not defined, the server responds with a default RootDSE entry composed of +values from the `ServerOptions::getDse*()` configuration options. -This should be an object instance that implements `FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface`. If this is defined, -the server will use it when responding to client paged search requests. If it is not defined, then the server may -send an operation error to the client if it requested a paged search as critical. If the paged search was not marked as -critical, then the server will ignore the client paging control and send the search through the standard `ServerOptions::getRequestHandler()` class instance. +When a backend is provided and implements `RootDseHandlerInterface`, it is used automatically — no separate +`setRootDseHandler()` call is needed. ```php use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use App\MySpecialPagingHandler; +use App\MyRootDseHandler; $server = new LdapServer( (new ServerOptions) - ->setPagingHandler(new MySpecialPagingHandler()) + ->setRootDseHandler(new MyRootDseHandler()) ); ``` @@ -242,7 +256,7 @@ The server certificate private key. This can also be bundled with the certificat ------------------ #### setSslCertPassphrase -The passphrase needed for the server certificate's private key. +The passphrase needed for the server certificate's private key. **Default**: `(null)` @@ -264,11 +278,23 @@ to use an encrypted stream only for communication to the server. The SASL mechanisms the server should support and advertise to clients via the `supportedSaslMechanisms` RootDSE attribute. Use the constants defined on `ServerOptions` to specify mechanisms: -| Constant | Mechanism | Handler Required | -|----------------------------------|--------------|------------------------------------------------------| -| `ServerOptions::SASL_PLAIN` | `PLAIN` | `RequestHandlerInterface` (existing `bind()` method) | -| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | `SaslHandlerInterface` | -| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | `SaslHandlerInterface` | +| Constant | Mechanism | Handler Required | +|-------------------------------------------|-----------------------|-------------------------------------------------------------| +| `ServerOptions::SASL_PLAIN` | `PLAIN` | `LdapBackendInterface` (existing `verifyPassword()` method) | +| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | `SaslHandlerInterface` | +| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_1` | `SCRAM-SHA-1` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_1_PLUS` | `SCRAM-SHA-1-PLUS` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_224` | `SCRAM-SHA-224` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_224_PLUS` | `SCRAM-SHA-224-PLUS` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_256` | `SCRAM-SHA-256` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_256_PLUS` | `SCRAM-SHA-256-PLUS` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_384` | `SCRAM-SHA-384` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_384_PLUS` | `SCRAM-SHA-384-PLUS` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_512` | `SCRAM-SHA-512` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA_512_PLUS` | `SCRAM-SHA-512-PLUS` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA3_512` | `SCRAM-SHA3-512` | `SaslHandlerInterface` | +| `ServerOptions::SASL_SCRAM_SHA3_512_PLUS` | `SCRAM-SHA3-512-PLUS` | `SaslHandlerInterface` | ```php use FreeDSx\Ldap\ServerOptions; @@ -278,7 +304,7 @@ $server = new LdapServer( (new ServerOptions) ->setSaslMechanisms( ServerOptions::SASL_PLAIN, - ServerOptions::SASL_DIGEST_MD5, + ServerOptions::SASL_SCRAM_SHA_256, ) ); ``` diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index d9ee5094..ae207983 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -3,28 +3,30 @@ General LDAP Server Usage * [Running the Server](#running-the-server) * [Creating a Proxy Server](#creating-a-proxy-server) -* [Handling Client Requests](#handling-client-requests) - * [Proxy Request Handler](#proxy-request-handler) - * [Generic Request Handler](#generic-request-handler) +* [Providing a Backend](#providing-a-backend) + * [Read-Only Backend](#read-only-backend) + * [Writable Backend](#writable-backend) + * [Built-In Storage Adapters](#built-in-storage-adapters) + * [InMemoryStorageAdapter](#inmemorystorageadapter) + * [JsonFileStorageAdapter](#jsonfilestorageadapter) + * [Proxy Backend](#proxy-backend) + * [Custom Filter Evaluation](#custom-filter-evaluation) * [Handling the RootDSE](#handling-the-rootdse) -* [Handling Client Paging Requests](#handling-client-paging-requests) * [StartTLS SSL Certificate Support](#starttls-ssl-certificate-support) * [SASL Authentication](#sasl-authentication) * [PLAIN Mechanism](#plain-mechanism) * [Challenge-Based Mechanisms (CRAM-MD5, DIGEST-MD5, and SCRAM)](#challenge-based-mechanisms-cram-md5-digest-md5-and-scram) -The LdapServer class is used to run a LDAP server process that accepts client requests and sends back a response. It -defaults to using a forking method for client requests, which is only available on Linux. +The LdapServer class runs an LDAP server process that accepts client requests and sends back responses. It defaults to +using a forking method (PCNTL) for handling client connections, which is only available on Linux. -The LDAP server has no entry database/schema persistence by itself. It is currently up to the implementor to determine -how to create / update / delete / search for entries that are requested from the client. See the [Handling Client Requests](#handling-client-requests) -section for more details. +The server has no built-in entry persistence. You provide a backend that implements the storage and authentication +logic for your use case. See the [Providing a Backend](#providing-a-backend) section for details. ## Running The Server -In its most simple form you can run the LDAP server by constructing the class and calling the `run()` method. This will -bind to port 389 to accept clients from any IP on the server. It will use the GenericRequestHandler which by default -rejects client requests for any operation. +In its simplest form you construct the server and call `run()`. Without a backend configured, the server accepts +connections but rejects all operations with `unwillingToPerform`. ```php use FreeDSx\Ldap\LdapServer; @@ -32,13 +34,9 @@ use FreeDSx\Ldap\LdapServer; $server = (new LdapServer())->run(); ``` -### Creating a Proxy Server +## Creating a Proxy Server -The LDAP server is capable of acting as a proxy between other LDAP servers through the use of the handlers. There is a -helper factory method defined on the LdapServer class that makes creating a proxy server very easy. However, it provides -no real extensibility if you aren't defining your own handlers. - -The proxy server can be constructed as follows: +The server can act as a transparent proxy to another LDAP server via `LdapServer::makeProxy()`: ```php use FreeDSx\Ldap\ClientOptions; @@ -57,367 +55,353 @@ $server = LdapServer::makeProxy( $server->run(); ``` -## Handling Client Requests - -When the server receives a client request it will get sent to the request handler defined for the server. There are only -a few types of requests not sent to the request handler: +For a customisable proxy, extend `ProxyBackend` directly. See [Proxy Backend](#proxy-backend). -* WhoAmI -* StartTLS -* RootDSE -* Unbind +## Providing a Backend -All other requests are sent to handler you define. The handler must implement `FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface`. -The interface has the following methods: +A backend is a class implementing `LdapBackendInterface` (read-only) or `WritableLdapBackendInterface` (read + write). +It is registered with `LdapServer::useBackend()`: ```php - public function add(RequestContext $context, AddRequest $add); - - public function compare(RequestContext $context, CompareRequest $compare) : bool; - - public function delete(RequestContext $context, DeleteRequest $delete); - - public function extended(RequestContext $context, ExtendedRequest $extended); - - public function modify(RequestContext $context, ModifyRequest $modify); - - public function modifyDn(RequestContext $context, ModifyDnRequest $modifyDn); +use FreeDSx\Ldap\LdapServer; - public function search(RequestContext $context, SearchRequest $search) : SearchResult; - - public function bind(string $username, string $password) : bool; +$server = (new LdapServer())->useBackend(new MyBackend()); +$server->run(); ``` -However, there is a generic request handler you can extend to implement only what you want. Or a proxy handler to forward -requests to a separate LDAP server. - -### Proxy Request Handler - -**Note**: If you just want to create an LDAP server that serves as a proxy, see the section on [creating a proxy server](#creating-a-proxy-server). +The framework routes all client LDAP operations — search, bind, add, delete, modify, rename, compare — to the backend. +Paging is handled automatically: the framework stores a PHP Generator per connection and resumes it for each page +request. Your `search()` implementation simply yields entries. -The proxy request handler simply forwards the LDAP request from the FreeDSx server to a different LDAP server, then sends -the response back to the client. You should extend the ProxyRequestHandler class and add your own client options to be -used. +### Read-Only Backend -1. Create your own class extending the ProxyRequestHandler: +`LdapBackendInterface` covers the three operations needed for a read-only server: ```php -namespace Foo; +namespace App; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use Generator; -class LdapProxyHandler extends ProxyRequestHandler +class MyReadOnlyBackend implements LdapBackendInterface { /** - * Set the options for the LdapClient in the constructor. + * Yield entries that are candidates for the search. The framework applies + * FilterEvaluator as a final pass, so you may pre-filter for efficiency or + * yield all entries in scope for simplicity. */ - public function __construct() + public function search(SearchContext $context): Generator { - parent::__construct( - (new ClientOptions) - ->setServers([ - 'dc1.domain.local', - 'dc2.domain.local', - ]) - ->setBaseDn('dc=domain,dc=local') - ); + // $context->baseDn — Dn: the search base + // $context->scope — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE + // $context->filter — FilterInterface: the requested LDAP filter + // $context->attributes — Attribute[]: requested attributes (empty = all) + // $context->typesOnly — bool: return only attribute names, not values + + yield Entry::fromArray('cn=Foo,dc=example,dc=com', ['cn' => 'Foo', 'sn' => 'Bar']); + yield Entry::fromArray('cn=Bar,dc=example,dc=com', ['cn' => 'Bar', 'sn' => 'Baz']); + } + + /** + * Return a single entry by DN, or null if it does not exist. + * Used for compare operations and internal lookups. + */ + public function get(Dn $dn): ?Entry + { + // ... + return null; + } + + /** + * Verify a plaintext password for a simple bind request. + * Return true if the credentials are valid, false otherwise. + */ + public function verifyPassword(Dn $dn, string $password): bool + { + return $dn->toString() === 'cn=admin,dc=example,dc=com' + && $password === 'secret'; } } ``` -2. Create the server and run it with the request handler above: +### Writable Backend -```php -use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Ldap\LdapServer; -use Foo\LdapProxyHandler; +`WritableLdapBackendInterface` extends `LdapBackendInterface` with write operations: -$server = new LdapServer( - (new ServerOptions)->setRequestHandler(new LdapProxyHandler()) -); -$server->run(); -``` +```php +namespace App; -### Generic Request Handler +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use Generator; -The generic request handler implements the needed RequestHandlerInterface, but rejects all request types by default. You -should extend this class and override the methods for the requests you want to support: +class MyBackend implements WritableLdapBackendInterface +{ + public function search(SearchContext $context): Generator + { + // yield matching entries... + } -1. Create your own class extending the GenericRequestHandler: + public function get(Dn $dn): ?Entry + { + // ... + } -```php -namespace Foo; + public function verifyPassword(Dn $dn, string $password): bool + { + // ... + } -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; + public function add(Entry $entry): void + { + // Persist the new entry. Throw OperationException(ENTRY_ALREADY_EXISTS) if it exists. + } -class LdapRequestHandler extends GenericRequestHandler -{ - /** - * @var array - */ - protected $users = [ - 'user' => '12345', - ]; + public function delete(Dn $dn): void + { + // Remove the entry. Throw OperationException(NOT_ALLOWED_ON_NON_LEAF) if it has children. + } /** - * Validates the username/password of a simple bind request - * - * @param string $username - * @param string $password - * @return bool + * @param Change[] $changes */ - public function bind(string $username, string $password): bool + public function update(Dn $dn, array $changes): void { - return isset($this->users[$username]) && $this->users[$username] === $password; + // Apply attribute changes to the entry. } - /** - * Override the search request. Return a SearchResult with the entries to send back. - * - * @param RequestContext $context - * @param SearchRequest $search - * @return SearchResult - */ - public function search(RequestContext $context, SearchRequest $search): SearchResult + public function move(Dn $dn, Rdn $newRdn, bool $deleteOldRdn, ?Dn $newParent): void { - return SearchResult::make( - new Entries( - Entry::fromArray('cn=Foo,dc=FreeDSx,dc=local', [ - 'cn' => 'Foo', - 'sn' => 'Bar', - 'givenName' => 'Foo', - ]), - Entry::fromArray('cn=Chad,dc=FreeDSx,dc=local', [ - 'cn' => 'Chad', - 'sn' => 'Sikorra', - 'givenName' => 'Chad', - ]) - ) - ); + // Rename or move the entry (ModifyDN operation). } } ``` -2. Create the server and run it with the request handler above: +### Built-In Storage Adapters + +Two adapters are included for common use cases. + +#### InMemoryStorageAdapter + +An in-memory, array-backed storage adapter. Suitable for: + +- **Swoole**: all connections share the same process memory. +- **PCNTL** with pre-seeded, read-only data: data seeded before `run()` is inherited by all forked child processes. + +**Note**: With PCNTL, write operations performed by one child process are not visible to other children. ```php -use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use Foo\LdapRequestHandler; +use FreeDSx\Ldap\Server\Storage\Adapter\InMemoryStorageAdapter; -$server = new LdapServer( - (new ServerOptions)->setRequestHandler(new LdapRequestHandler()) +$passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); + +$adapter = new InMemoryStorageAdapter( + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry( + new Dn('cn=admin,dc=example,dc=com'), + new Attribute('cn', 'admin'), + new Attribute('userPassword', $passwordHash), + ), ); + +$server = (new LdapServer())->useBackend($adapter); +$server->run(); ``` -## Handling the RootDSE +Password verification supports `{SHA}` hashed values stored in the `userPassword` attribute, as well as plaintext +comparisons. No password scheme is set automatically — the hash format must be present in the stored value. -If you need more control over the RootDSE that gets returned, you can implement the `RootDseHandlerInterface`. This -allows you to modify / return your own RootDSE in response to a client request for one. +#### JsonFileStorageAdapter -An example of using it to proxy RootDSE requests to a different LDAP server... +A file-backed adapter that persists the directory as a JSON file. Safe for PCNTL (write operations are serialised with +`flock(LOCK_EX)` and the in-memory cache is invalidated via `filemtime` checks). -1. Create a class implementing `FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface`: +**Note**: Not suitable for Swoole — the blocking `flock`/`fread` calls will stall the event loop. Use +`InMemoryStorageAdapter` with Swoole instead. ```php -namespace Foo; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Storage\Adapter\JsonFileStorageAdapter; -use FreeDSx\Ldap\ClientOptions;use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; +$adapter = new JsonFileStorageAdapter('/var/lib/myapp/ldap.json'); -class RootDseProxyHandler extends ProxyRequestHandler implements RootDseHandlerInterface +$server = (new LdapServer())->useBackend($adapter); +$server->run(); +``` + +JSON format: + +```json +{ + "cn=admin,dc=example,dc=com": { + "dn": "cn=admin,dc=example,dc=com", + "attributes": { + "cn": ["admin"], + "userPassword": ["{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g="] + } + } +} +``` + +### Proxy Backend + +`ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server. +Extend it to add custom logic: + +```php +namespace App; + +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Server\RequestHandler\ProxyBackend; + +class MyProxyBackend extends ProxyBackend { - /** - * Set the options for the LdapClient in the constructor. - */ public function __construct() { parent::__construct( (new ClientOptions) - ->setServers([ - 'dc1.domain.local', - 'dc2.domain.local', - ]) - ->setBaseDn('dc=domain,dc=local') + ->setServers(['dc1.domain.local', 'dc2.domain.local']) + ->setBaseDn('dc=domain,dc=local') ); } - - public function rootDse( - RequestContext $context, - SearchRequest $request, - Entry $rootDse - ): Entry { - return $this->ldap() - ->search( - $request, - ...$context->controls()->toArray() - ) - ->first(); - } } ``` -2. Use the implemented class when instantiating the LDAP server: +```php +use FreeDSx\Ldap\LdapServer; +use App\MyProxyBackend; + +$server = (new LdapServer())->useBackend(new MyProxyBackend()); +$server->run(); +``` + +### Custom Filter Evaluation + +By default the framework applies a pure-PHP `FilterEvaluator` to entries yielded by `search()` as a correctness +guarantee. For backends that translate LDAP filters to a native query language (SQL, MongoDB, etc.) and return +pre-filtered results, you can replace the evaluator: ```php -use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use Foo\RootDseProxyHandler; +use App\MyBackend; +use App\MySqlFilterEvaluator; -$server = new LdapServer( - (new ServerOptions)->setRootDseHandler(new RootDseProxyHandler()) -); +$server = (new LdapServer()) + ->useBackend(new MyBackend()) + ->useFilterEvaluator(new MySqlFilterEvaluator()); $server->run(); ``` -The above would pass on a RootDSE request as a proxy and send it back to the client. +The custom evaluator must implement `FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface`: + +```php +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface; + +class MySqlFilterEvaluator implements FilterEvaluatorInterface +{ + public function evaluate(Entry $entry, FilterInterface $filter): bool + { + // Custom matching logic (e.g. bitwise matching rules for AD compatibility). + return true; + } +} +``` + +## Handling the RootDSE -## Handling Client Paging Requests +The server generates a default RootDSE from `ServerOptions` values (`setDseNamingContexts()`, `setDseVendorName()`, +etc.). For most deployments this is sufficient. -The basic RequestHandlerInterface by default will not support paging requests from clients. To support client paging -search requests you must create a class that implements `FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface`. +If you need full control — for example to proxy RootDSE requests to an upstream server, or to add custom attributes — +implement `RootDseHandlerInterface`. Your implementation receives the default-generated entry and returns a (possibly +modified) entry to send back to the client. -An example of using it to handle a client paging request... +The simplest way to proxy RootDSE requests is `ProxyHandler`, which bundles `ProxyBackend` with `RootDseHandlerInterface` +in one class and is used by `LdapServer::makeProxy()` automatically. -1. Create a class implementing `FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface`: +For a custom handler: ```php -namespace Foo; +namespace App; -use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\Paging\PagingResponse; use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; +use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; -class MyPagingHandler implements PagingHandlerInterface +class MyRootDseHandler implements RootDseHandlerInterface { - /** - * @inheritDoc - */ - public function page( - PagingRequest $pagingRequest, - RequestContext $context - ): PagingResponse { - // Every time a client asks for a new "page" of results, this method will be called. - - // Get the unique ID of the paging request. - // This will remain the same across the series of paging requests. - $uniqueId = $pagingRequest->getUniqueId(); - // Get the iteration of the "page" set we are on... - $iteration = $pagingRequest->getIteration(); - // Get the size of the results the client is requesting... - $size = $pagingRequest->getSize(); - // The actual search request... - $search = $pagingRequest->getSearchRequest(); - - // Perform the logic necessary to build up an Entries object with the results - // Just an example. Populate this based on actual search, size, and iteration. - $entries = new Entries( - Entry::fromArray('cn=Foo,dc=FreeDSx,dc=local', [ - 'cn' => 'Foo', - 'sn' => 'Bar', - 'givenName' => 'Foo', - ]), - Entry::fromArray('cn=Chad,dc=FreeDSx,dc=local', [ - 'cn' => 'Chad', - 'sn' => 'Sikorra', - 'givenName' => 'Chad', - ]) - ); - - // Perform actual logic to determine if it is the final result set... - $isFinalResponse = false; - - // ... - - if ($isFinalResponse) { - // If this is the final result in the paging set, make a response indicating as such: - return PagingResponse::makeFinal($entries); - } else { - // If you know an estimate of the remaining entries, pass it as a second param here. - return PagingResponse::make($entries); - } - } - - /** - * @inheritDoc - */ - public function remove( - PagingRequest $pagingRequest, - RequestContext $context - ): void { - // This is to indicate that the client is done paging. - // Use this to clean up any resources involved in handling the paging request. + public function rootDse( + RequestContext $context, + SearchRequest $request, + Entry $rootDse, + ): Entry { + // Modify the default entry or return a completely custom one. + $rootDse->set('namingContexts', 'dc=example,dc=com'); + + return $rootDse; } } ``` -2. Use the implemented class when instantiating the LDAP server: +Register it with the server: ```php -use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use Foo\MyPagingHandler; +use App\MyRootDseHandler; -$server = new LdapServer( - (new ServerOptions)->setPagingHandler(new MyPagingHandler()) -); +$server = (new LdapServer()) + ->useBackend(new MyBackend()) + ->useRootDseHandler(new MyRootDseHandler()); $server->run(); ``` +If your backend class also implements `RootDseHandlerInterface`, you do not need to call `useRootDseHandler()` — the +framework detects and uses it automatically. + ## SASL Authentication -The server supports SASL (Simple Authentication and Security Layer) bind requests. SASL must be explicitly enabled by -configuring the mechanisms you want to support via `ServerOptions::setSaslMechanisms()`. The configured mechanisms are -advertised to clients through the `supportedSaslMechanisms` RootDSE attribute. +The server supports SASL bind requests. SASL must be explicitly enabled by configuring the mechanisms you want to +support via `ServerOptions::setSaslMechanisms()`. The configured mechanisms are advertised to clients through the +`supportedSaslMechanisms` RootDSE attribute. ```php use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -$server = new LdapServer( - (new ServerOptions) - ->setSaslMechanisms( - ServerOptions::SASL_PLAIN, - ServerOptions::SASL_CRAM_MD5, - ServerOptions::SASL_SCRAM_SHA_256, - ) - ->setRequestHandler(new MyRequestHandler()) -); +$server = (new LdapServer( + (new ServerOptions)->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_CRAM_MD5, + ServerOptions::SASL_SCRAM_SHA_256, + ) +))->useBackend(new MyBackend()); ``` ### PLAIN Mechanism -The `PLAIN` mechanism reuses your existing `RequestHandlerInterface::bind()` method. When a client authenticates with -SASL PLAIN, the server extracts the username and password from the SASL credentials and calls `bind()` exactly as it -would for a simple bind. +The `PLAIN` mechanism reuses your existing `LdapBackendInterface::verifyPassword()` method. When a client +authenticates with SASL PLAIN, the server extracts the username and password from the SASL credentials and calls +`verifyPassword()` exactly as it would for a simple bind. **Note**: PLAIN transmits credentials in cleartext. Only enable it when the connection is protected by TLS (StartTLS or `setUseSsl`). -```php -namespace Foo; - -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; - -class MyRequestHandler extends GenericRequestHandler -{ - public function bind(string $username, string $password): bool - { - // Called for both simple binds and SASL PLAIN binds. - return $username === 'user' && $password === 'secret'; - } -} -``` - ### Challenge-Based Mechanisms (CRAM-MD5, DIGEST-MD5, and SCRAM) `CRAM-MD5`, `DIGEST-MD5`, and the `SCRAM-*` family are challenge-response mechanisms. The server issues a challenge to @@ -425,7 +409,7 @@ the client and verifies the client's response against a digest computed from the verification is cryptographic, the server must be able to look up the plaintext (or equivalent) password for a given username. -To support these mechanisms, your request handler must additionally implement +To support these mechanisms, your backend must additionally implement `FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface`: ```php @@ -433,38 +417,52 @@ interface SaslHandlerInterface { public function getPassword( string $username, - string $mechanism + string $mechanism, ): ?string; } ``` Return the user's plaintext password for the given username and mechanism, or `null` if the user does not exist or -should not be permitted to authenticate. Returning `null` results in a generic `invalidCredentials` error — the -mechanism name and `null`/wrong-password cases are not distinguished in the response to avoid user enumeration. +should not be permitted to authenticate. Returning `null` results in a generic `invalidCredentials` error. The `$mechanism` parameter lets you apply per-mechanism policy if needed (e.g. disallow weak mechanisms for certain users), but in most cases you can ignore it and return the same password regardless. -Example handler supporting both simple binds and challenge-based SASL: +Example backend supporting both simple binds and challenge-based SASL: ```php -namespace Foo; +namespace App; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use Generator; -class MyRequestHandler extends GenericRequestHandler implements SaslHandlerInterface +class MyBackend implements LdapBackendInterface, SaslHandlerInterface { private array $users = [ 'alice' => 'her-plaintext-password', 'bob' => 'his-plaintext-password', ]; + public function search(SearchContext $context): Generator + { + // yield entries... + } + + public function get(Dn $dn): ?Entry + { + return null; + } + // Used for simple binds and SASL PLAIN. - public function bind(string $username, string $password): bool + public function verifyPassword(Dn $dn, string $password): bool { - return isset($this->users[$username]) - && $this->users[$username] === $password; + $user = $this->users[$dn->toString()] ?? null; + + return $user !== null && $user === $password; } // Used for CRAM-MD5, DIGEST-MD5, and all SCRAM variants. @@ -480,17 +478,15 @@ Then enable the desired mechanisms on the server: ```php use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use Foo\MyRequestHandler; - -$server = new LdapServer( - (new ServerOptions) - ->setSaslMechanisms( - ServerOptions::SASL_CRAM_MD5, - ServerOptions::SASL_DIGEST_MD5, - ServerOptions::SASL_SCRAM_SHA_256, - ) - ->setRequestHandler(new MyRequestHandler()) -); +use App\MyBackend; + +$server = (new LdapServer( + (new ServerOptions)->setSaslMechanisms( + ServerOptions::SASL_CRAM_MD5, + ServerOptions::SASL_DIGEST_MD5, + ServerOptions::SASL_SCRAM_SHA_256, + ) +))->useBackend(new MyBackend()); $server->run(); ``` @@ -512,12 +508,12 @@ TLS channel binding is required. **Note**: Because `getPassword()` must return the plaintext password, you cannot store passwords as one-way hashes (e.g. bcrypt) when supporting CRAM-MD5, DIGEST-MD5, or SCRAM. If one-way hashing is a requirement, use `PLAIN` over -TLS instead, which allows password verification via `bind()`. +TLS instead, which allows password verification via `verifyPassword()`. ## StartTLS SSL Certificate Support To allow clients to issue a StartTLS command against the LDAP server you need to provide an SSL certificate, key, and -key passphrase/password (if needed) when constructing the server class. If these are not present then the StartTLS +key passphrase/password (if needed) when constructing the server class. If these are not present then the StartTLS request will not be supported. Adding the generated certs and keys on construction: diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index aa7fdace..761d09dc 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -25,6 +25,7 @@ use FreeDSx\Ldap\Server\ServerProtocolFactory; use FreeDSx\Ldap\Server\ServerRunner\PcntlServerRunner; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; +use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; use FreeDSx\Ldap\Server\SocketServerFactory; use FreeDSx\Socket\SocketPool; @@ -206,9 +207,18 @@ private function makeHandlerFactory(): HandlerFactory private function makeServerRunner(): ServerRunnerInterface { + $options = $this->get(ServerOptions::class); + + if ($options->getUseSwooleRunner()) { + return new SwooleServerRunner( + serverProtocolFactory: $this->get(ServerProtocolFactory::class), + logger: $options->getLogger(), + ); + } + return new PcntlServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), - logger: $this->get(ServerOptions::class)->getLogger(), + logger: $options->getLogger(), ); } diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 87b28d01..b866c1e9 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -14,14 +14,15 @@ namespace FreeDSx\Ldap; use FreeDSx\Ldap\Exception\InvalidArgumentException; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; -use FreeDSx\Ldap\Server\RequestHandler\ProxyPagingHandler; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\Server\SocketServerFactory; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Socket\Exception\ConnectionException; use Psr\Log\LoggerInterface; @@ -71,11 +72,47 @@ public function getOptions(): ServerOptions } /** - * Specify an instance of a request handler to use for incoming LDAP requests. + * Specify a backend to use for incoming LDAP requests. + * + * The backend handles search and optionally write operations (add, delete, + * modify, rename) if it implements WritableLdapBackendInterface. Bind + * authentication is handled by a PasswordAuthenticatableInterface — either + * one registered via usePasswordAuthenticator(), the backend itself if it + * implements that interface, or the default PasswordAuthenticator (which + * reads the userPassword attribute from entries returned by the backend). */ - public function useRequestHandler(RequestHandlerInterface $requestHandler): self + public function useBackend(LdapBackendInterface $backend): self { - $this->options->setRequestHandler($requestHandler); + $this->options->setBackend($backend); + + return $this; + } + + /** + * Override the password authenticator used for simple bind and SASL PLAIN. + * + * By default the server constructs a PasswordAuthenticator that reads the + * userPassword attribute from entries returned by the backend. Use this + * method to supply a custom implementation — for example, to support + * additional hash formats or to delegate verification to an external service. + */ + public function usePasswordAuthenticator(PasswordAuthenticatableInterface $authenticator): self + { + $this->options->setPasswordAuthenticator($authenticator); + + return $this; + } + + /** + * Register a handler for one or more LDAP write operations. + * + * Handlers are tried in registration order, before the backend is used as + * a fallback. Multiple calls register multiple handlers, allowing each + * write operation to be handled by a separate class. + */ + public function useWriteHandler(WriteHandlerInterface $handler): self + { + $this->options->addWriteHandler($handler); return $this; } @@ -91,11 +128,12 @@ public function useRootDseHandler(RootDseHandlerInterface $rootDseHandler): self } /** - * Specify an instance of a paging handler to use for paged search requests. + * Override the filter evaluator used by the server when applying LDAP filters + * to entries returned by the backend's search() generator. */ - public function usePagingHandler(PagingHandlerInterface $pagingHandler): self + public function useFilterEvaluator(FilterEvaluatorInterface $evaluator): self { - $this->options->setPagingHandler($pagingHandler); + $this->options->setFilterEvaluator($evaluator); return $this; } @@ -110,6 +148,19 @@ public function useLogger(LoggerInterface $logger): self return $this; } + /** + * Configure the server to use the Swoole coroutine runner instead of the default PCNTL process runner. + * + * Requires the swoole PHP extension. The SwooleServerRunner handles each client connection in its own + * coroutine within a single process, making in-memory storage adapters safe to use concurrently. + */ + public function useSwooleRunner(): self + { + $this->options->setUseSwooleRunner(true); + + return $this; + } + /** * Validates that the SASL configuration is consistent before the server starts. * @@ -126,11 +177,11 @@ private function validateSaslConfiguration(): void return; } - $handler = $this->options->getRequestHandler(); + $backend = $this->options->getBackend(); - if (!$handler instanceof SaslHandlerInterface) { + if (!$backend instanceof SaslHandlerInterface) { throw new InvalidArgumentException(sprintf( - 'The SASL mechanism(s) [%s] require the request handler to implement %s.', + 'The SASL mechanism(s) [%s] require the backend to implement %s.', implode(', ', $challengeMechanisms), SaslHandlerInterface::class, )); @@ -155,11 +206,8 @@ public static function makeProxy( $clientOptions->setServers((array) $servers) ); - $proxyRequestHandler = new ProxyHandler($client); $server = new LdapServer($serverOptions); - $server->useRequestHandler($proxyRequestHandler); - $server->useRootDseHandler($proxyRequestHandler); - $server->usePagingHandler(new ProxyPagingHandler($client)); + $server->useBackend(new ProxyHandler($client)); return $server; } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php index c93c22ce..b1f200ca 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php @@ -13,9 +13,12 @@ namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; -use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\UsernameExtractor\UsernameFieldExtractor; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\GenericBackend; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; @@ -25,28 +28,42 @@ /** * Creates a single MechanismOptionsBuilderInterface instance for the requested SASL mechanism. * + * Challenge-based mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM) require the backend to implement + * SaslHandlerInterface. An OperationException is thrown if the requirement is not met, so + * the client receives a well-formed LDAP error response. + * * @author Chad Sikorra */ final class MechanismOptionsBuilderFactory { + public function __construct( + private readonly LdapBackendInterface $backend = new GenericBackend(), + ) { + } + /** - * @throws RuntimeException if no builder is registered for the given mechanism. + * @throws OperationException if the mechanism is unsupported or requires SaslHandlerInterface. */ public function make( string $mechanism, - RequestHandlerInterface $dispatcher, + PasswordAuthenticatableInterface $authenticator, ): MechanismOptionsBuilderInterface { return match (true) { $mechanism === PlainMechanism::NAME - => new PlainMechanismOptionsBuilder($dispatcher), - $mechanism === CramMD5Mechanism::NAME && $dispatcher instanceof SaslHandlerInterface - => new CramMD5MechanismOptionsBuilder($dispatcher), - $mechanism === DigestMD5Mechanism::NAME && $dispatcher instanceof SaslHandlerInterface - => new DigestMD5MechanismOptionsBuilder($dispatcher, new UsernameFieldExtractor()), - in_array($mechanism, ScramMechanism::VARIANTS, true) && $dispatcher instanceof SaslHandlerInterface - => new ScramMechanismOptionsBuilder($dispatcher), - default => throw new RuntimeException( - sprintf('No options builder is registered for the SASL mechanism "%s".', $mechanism) + => new PlainMechanismOptionsBuilder($authenticator), + $mechanism === CramMD5Mechanism::NAME && $this->backend instanceof SaslHandlerInterface + => new CramMD5MechanismOptionsBuilder($this->backend), + $mechanism === DigestMD5Mechanism::NAME && $this->backend instanceof SaslHandlerInterface + => new DigestMD5MechanismOptionsBuilder($this->backend, new UsernameFieldExtractor()), + in_array($mechanism, ScramMechanism::VARIANTS, true) && $this->backend instanceof SaslHandlerInterface + => new ScramMechanismOptionsBuilder($this->backend), + default => throw new OperationException( + sprintf( + 'The SASL mechanism "%s" requires the request handler to implement %s.', + $mechanism, + SaslHandlerInterface::class, + ), + ResultCode::OTHER, ), }; } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilder.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilder.php index 24c5a9d2..407614f0 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilder.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilder.php @@ -13,7 +13,7 @@ namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\PlainMechanism; /** @@ -23,7 +23,7 @@ */ class PlainMechanismOptionsBuilder implements MechanismOptionsBuilderInterface { - public function __construct(private readonly RequestHandlerInterface $dispatcher) + public function __construct(private readonly PasswordAuthenticatableInterface $authenticator) { } @@ -36,7 +36,10 @@ public function buildOptions( ): array { return [ 'validate' => fn (?string $authzId, string $authcId, string $password): bool => - $this->dispatcher->bind($authcId, $password), + $this->authenticator->verifyPassword( + $authcId, + $password + ), ]; } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php index 7e39c7ea..86e86340 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php @@ -24,7 +24,7 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\SaslContext; /** @@ -38,7 +38,7 @@ public function __construct( private readonly ServerQueue $queue, private readonly ResponseFactory $responseFactory, private readonly MechanismOptionsBuilderFactory $optionsBuilderFactory, - private readonly RequestHandlerInterface $dispatcher, + private readonly PasswordAuthenticatableInterface $authenticator, ) { } @@ -50,7 +50,7 @@ public function __construct( public function run(SaslExchangeInput $input): SaslExchangeResult { $mechName = $input->getMechName(); - $optionsBuilder = $this->optionsBuilderFactory->make($mechName, $this->dispatcher); + $optionsBuilder = $this->optionsBuilderFactory->make($mechName, $this->authenticator); $challenge = $input->getChallenge(); $message = $input->getInitialMessage(); $received = $input->getInitialCredentials(); diff --git a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php index 0a98f0f2..09e427fd 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php @@ -26,14 +26,12 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\MessageWrapper\SaslMessageWrapper; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\Operation\Request\SaslBindRequest; use FreeDSx\Sasl\Challenge\ChallengeInterface; use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\PlainMechanism; use FreeDSx\Sasl\Sasl; /** @@ -52,7 +50,7 @@ class SaslBind implements BindInterface */ public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + private readonly PasswordAuthenticatableInterface $authenticator, private readonly Sasl $sasl = new Sasl(), private readonly array $mechanisms = [], private readonly ResponseFactory $responseFactory = new ResponseFactory(), @@ -64,7 +62,7 @@ public function __construct( $this->queue, $this->responseFactory, $optionsBuilderFactory, - $this->dispatcher, + $this->authenticator, ); } @@ -131,17 +129,6 @@ private function validateMechanism(string $mechName): void ResultCode::AUTH_METHOD_UNSUPPORTED ); } - - if ($mechName !== PlainMechanism::NAME && !$this->dispatcher instanceof SaslHandlerInterface) { - throw new OperationException( - sprintf( - 'The SASL mechanism "%s" requires the request handler to implement %s.', - $mechName, - SaslHandlerInterface::class - ), - ResultCode::OTHER - ); - } } private function getServerChallenge(string $mechName): ChallengeInterface diff --git a/src/FreeDSx/Ldap/Protocol/Bind/SimpleBind.php b/src/FreeDSx/Ldap/Protocol/Bind/SimpleBind.php index 97a4848f..4c6b2485 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/SimpleBind.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/SimpleBind.php @@ -21,7 +21,7 @@ use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -36,7 +36,7 @@ class SimpleBind implements BindInterface public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + private readonly PasswordAuthenticatableInterface $authenticator, private readonly ResponseFactory $responseFactory = new ResponseFactory(), ) { } @@ -67,7 +67,7 @@ public function bind(LdapMessageRequest $message): TokenInterface */ private function simpleBind(SimpleBindRequest $request): TokenInterface { - if (!$this->dispatcher->bind($request->getUsername(), $request->getPassword())) { + if (!$this->authenticator->verifyPassword($request->getUsername(), $request->getPassword())) { throw new OperationException( 'Invalid credentials.', ResultCode::INVALID_CREDENTIALS diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php index 1c0bd592..66e8b915 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php @@ -59,14 +59,17 @@ public function get( } elseif ($request instanceof SearchRequest) { return new ServerProtocolHandler\ServerSearchHandler( queue: $this->queue, - dispatcher: $this->handlerFactory->makeRequestHandler(), + backend: $this->handlerFactory->makeBackend(), + filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), ); } elseif ($request instanceof UnbindRequest) { return new ServerProtocolHandler\ServerUnbindHandler($this->queue); } else { return new ServerProtocolHandler\ServerDispatchHandler( queue: $this->queue, - dispatcher: $this->handlerFactory->makeRequestHandler(), + backend: $this->handlerFactory->makeBackend(), + filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), + writeDispatcher: $this->handlerFactory->makeWriteDispatcher(), ); } } @@ -102,18 +105,10 @@ private function getRootDseHandler(): ServerProtocolHandler\ServerRootDseHandler private function getPagingHandler(): ServerProtocolHandlerInterface { - $pagingHandler = $this->handlerFactory->makePagingHandler(); - - if (!$pagingHandler) { - return new ServerProtocolHandler\ServerPagingUnsupportedHandler( - queue: $this->queue, - dispatcher: $this->handlerFactory->makeRequestHandler(), - ); - } - return new ServerProtocolHandler\ServerPagingHandler( queue: $this->queue, - pagingHandler: $pagingHandler, + backend: $this->handlerFactory->makeBackend(), + filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), requestHistory: $this->requestHistory, ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php index f394eaa8..52138185 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -20,12 +20,14 @@ use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteCommandFactory; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Token\TokenInterface; /** - * Handles generic requests that can be sent to the user supplied dispatcher / handler. + * Handles generic requests that are dispatched to the backend. * * @author Chad Sikorra */ @@ -33,7 +35,10 @@ class ServerDispatchHandler implements ServerProtocolHandlerInterface { public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + private readonly LdapBackendInterface $backend, + private readonly FilterEvaluatorInterface $filterEvaluator, + private readonly WriteOperationDispatcher $writeDispatcher, + private readonly WriteCommandFactory $commandFactory = new WriteCommandFactory(), private readonly ResponseFactory $responseFactory = new ResponseFactory(), ) { } @@ -45,30 +50,39 @@ public function __construct( */ public function handleRequest( LdapMessageRequest $message, - TokenInterface $token + TokenInterface $token, ): void { - $context = new RequestContext($message->controls(), $token); $request = $message->getRequest(); - if ($request instanceof Request\AddRequest) { - $this->dispatcher->add($context, $request); - } elseif ($request instanceof Request\CompareRequest) { - $this->dispatcher->compare($context, $request); - } elseif ($request instanceof Request\DeleteRequest) { - $this->dispatcher->delete($context, $request); - } elseif ($request instanceof Request\ModifyDnRequest) { - $this->dispatcher->modifyDn($context, $request); - } elseif ($request instanceof Request\ModifyRequest) { - $this->dispatcher->modify($context, $request); - } elseif ($request instanceof Request\ExtendedRequest) { - $this->dispatcher->extended($context, $request); - } else { + if ($request instanceof Request\CompareRequest) { + $compareMatch = $this->handleCompare($request); + $this->queue->sendMessage($this->responseFactory->getStandardResponse( + $message, + $compareMatch ? ResultCode::COMPARE_TRUE : ResultCode::COMPARE_FALSE, + )); + + return; + } + + $this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request)); + + $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); + } + + /** + * @throws OperationException + */ + private function handleCompare(Request\CompareRequest $request): bool + { + $entry = $this->backend->get($request->getDn()); + + if ($entry === null) { throw new OperationException( - 'The requested operation is not supported.', - ResultCode::NO_SUCH_OPERATION + sprintf('No such object: %s', $request->getDn()->toString()), + ResultCode::NO_SUCH_OBJECT, ); } - $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); + return $this->filterEvaluator->evaluate($entry, $request->getFilter()); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 21520ce8..a717159b 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -17,22 +17,25 @@ use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Control\PagingControl; use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\ProtocolException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Paging\PagingRequest; use FreeDSx\Ldap\Server\Paging\PagingRequestComparator; use FreeDSx\Ldap\Server\Paging\PagingResponse; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; use FreeDSx\Ldap\Server\RequestHistory; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; +use Generator; use Throwable; /** - * Handles paging search request logic. + * Handles paging search request logic using per-connection generator state. * * @author Chad Sikorra */ @@ -42,7 +45,8 @@ class ServerPagingHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerQueue $queue, - private readonly PagingHandlerInterface $pagingHandler, + private readonly LdapBackendInterface $backend, + private readonly FilterEvaluatorInterface $filterEvaluator, private readonly RequestHistory $requestHistory, private readonly PagingRequestComparator $requestComparator = new PagingRequestComparator(), ) { @@ -56,21 +60,13 @@ public function handleRequest( LdapMessageRequest $message, TokenInterface $token ): void { - $context = new RequestContext( - $message->controls(), - $token - ); $pagingRequest = $this->findOrMakePagingRequest($message); $searchRequest = $this->getSearchRequestFromMessage($message); $response = null; $controls = []; try { - $response = $this->handlePaging( - $context, - $pagingRequest, - $message - ); + $response = $this->handlePaging($pagingRequest, $message); $searchResult = SearchResult::makeSuccessResult( $response->getEntries(), (string) $searchRequest->getBaseDn() @@ -87,10 +83,7 @@ public function handleRequest( (string) $searchRequest->getBaseDn(), $e->getMessage() ); - $controls[] = new PagingControl( - 0, - '' - ); + $controls[] = new PagingControl(0, ''); } $pagingRequest->markProcessed(); @@ -103,15 +96,12 @@ public function handleRequest( * searchResultDone entry. If this occurs, both client and server should * assume the paged result set is closed and no longer resumable. * - * If a search result is anything other than success, we remove the paging request. + * If a search result is anything other than success, or the paging is complete, + * remove the paging request and discard the generator. */ if (($response && $response->isComplete()) || $searchResult->getResultCode() !== ResultCode::SUCCESS) { - $this->requestHistory->pagingRequest() - ->remove($pagingRequest); - $this->pagingHandler->remove( - $pagingRequest, - $context - ); + $this->requestHistory->pagingRequest()->remove($pagingRequest); + $this->requestHistory->removePagingGenerator($pagingRequest->getNextCookie()); } $this->sendEntriesToClient( @@ -126,22 +116,41 @@ public function handleRequest( * @throws OperationException */ private function handlePaging( - RequestContext $context, PagingRequest $pagingRequest, LdapMessageRequest $message ): PagingResponse { if (!$pagingRequest->isPagingStart()) { - return $this->handleExistingCookie( - $pagingRequest, - $context, - $message - ); - } else { - return $this->handlePagingStart( - $pagingRequest, - $context - ); + return $this->handleExistingCookie($pagingRequest, $message); + } + + return $this->handlePagingStart($pagingRequest); + } + + /** + * @throws OperationException + */ + private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse + { + $searchRequest = $pagingRequest->getSearchRequest(); + $context = $this->makeSearchContext($searchRequest); + $generator = $this->backend->search($context); + + [$page, $generatorExhausted] = $this->collectFromGenerator( + $generator, + $pagingRequest->getSize(), + $context, + ); + + $nextCookie = $this->generateCookie(); + $pagingRequest->updateNextCookie($nextCookie); + + if ($generatorExhausted) { + return PagingResponse::makeFinal(new Entries(...$page)); } + + $this->requestHistory->storePagingGenerator($nextCookie, $generator); + + return PagingResponse::make(new Entries(...$page), 0); } /** @@ -149,7 +158,6 @@ private function handlePaging( */ private function handleExistingCookie( PagingRequest $pagingRequest, - RequestContext $context, LdapMessageRequest $message ): PagingResponse { $newPagingRequest = $this->makePagingRequest($message); @@ -164,32 +172,69 @@ private function handleExistingCookie( $pagingRequest->updatePagingControl($this->getPagingControlFromMessage($message)); if ($pagingRequest->isAbandonRequest()) { - $response = PagingResponse::makeFinal(new Entries()); - } else { - $response = $this->pagingHandler->page( - $pagingRequest, - $context + $this->requestHistory->removePagingGenerator($pagingRequest->getNextCookie()); + + return PagingResponse::makeFinal(new Entries()); + } + + $currentCookie = $pagingRequest->getNextCookie(); + $generator = $this->requestHistory->getPagingGenerator($currentCookie); + + if ($generator === null) { + throw new OperationException( + 'The paging session could not be resumed.', + ResultCode::OPERATIONS_ERROR ); - $pagingRequest->updateNextCookie($this->generateCookie()); } - return $response; + $this->requestHistory->removePagingGenerator($currentCookie); + + $searchRequest = $pagingRequest->getSearchRequest(); + $context = $this->makeSearchContext($searchRequest); + + [$page, $generatorExhausted] = $this->collectFromGenerator( + $generator, + $pagingRequest->getSize(), + $context, + ); + + $nextCookie = $this->generateCookie(); + $pagingRequest->updateNextCookie($nextCookie); + + if ($generatorExhausted) { + return PagingResponse::makeFinal(new Entries(...$page)); + } + + $this->requestHistory->storePagingGenerator($nextCookie, $generator); + + return PagingResponse::make(new Entries(...$page), 0); } /** - * @todo It would be useful to prefix these by a unique client ID or something else somewhat identifiable. - * @throws OperationException + * Advances the generator, collecting up to $size entries that pass the filter. + * Returns the collected entries and whether the generator is exhausted. + * + * @return array{Entry[], bool} */ - private function generateCookie(): string - { - try { - return random_bytes(16); - } catch (Throwable) { - throw new OperationException( - 'Internal server error.', - ResultCode::OPERATIONS_ERROR - ); + private function collectFromGenerator( + Generator $generator, + int $size, + SearchContext $context, + ): array { + $page = []; + $limit = $size > 0 ? $size : PHP_INT_MAX; + + while ($generator->valid() && count($page) < $limit) { + $entry = $generator->current(); + + if ($entry instanceof Entry && $this->filterEvaluator->evaluate($entry, $context->filter)) { + $page[] = $this->applyAttributeFilter($entry, $context->attributes, $context->typesOnly); + } + + $generator->next(); } + + return [$page, !$generator->valid()]; } /** @@ -220,7 +265,7 @@ private function makePagingRequest(LdapMessageRequest $message): PagingRequest $filteredControls = array_filter( $message->controls()->toArray(), - function (Control $control) { + static function (Control $control): bool { return $control->getTypeOid() !== Control::OID_PAGING; } ); @@ -253,16 +298,15 @@ private function findPagingRequestOrThrow(string $cookie): PagingRequest /** * @throws OperationException */ - private function handlePagingStart( - PagingRequest $pagingRequest, - RequestContext $context - ): PagingResponse { - $response = $this->pagingHandler->page( - $pagingRequest, - $context - ); - $pagingRequest->updateNextCookie($this->generateCookie()); - - return $response; + private function generateCookie(): string + { + try { + return random_bytes(16); + } catch (Throwable) { + throw new OperationException( + 'Internal server error.', + ResultCode::OPERATIONS_ERROR + ); + } } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandler.php deleted file mode 100644 index bd32cfc0..00000000 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandler.php +++ /dev/null @@ -1,105 +0,0 @@ - - * - * 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\Exception\OperationException; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult as HandlerSearchResult; -use FreeDSx\Ldap\Server\Token\TokenInterface; - -/** - * Determines whether we can page results if no paging handler is defined. - * - * @author Chad Sikorra - */ -class ServerPagingUnsupportedHandler implements ServerProtocolHandlerInterface -{ - use ServerSearchTrait; - - public function __construct( - private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, - ) { - } - - /** - * @inheritDoc - */ - public function handleRequest( - LdapMessageRequest $message, - TokenInterface $token - ): void { - $context = new RequestContext( - $message->controls(), - $token - ); - $request = $this->getSearchRequestFromMessage($message); - $pagingControl = $this->getPagingControlFromMessage($message); - - /** - * RFC 2696, Section 3: - * - * If the server does not support this control, the server - * MUST return an error of unsupportedCriticalExtension if the client - * requested it as critical, otherwise the server SHOULD ignore the - * control. - */ - if ($pagingControl->getCriticality()) { - throw new OperationException( - 'The server does not support the paging control.', - ResultCode::UNAVAILABLE_CRITICAL_EXTENSION - ); - } - - $handlerResult = null; - - try { - $handlerResult = $this->dispatcher->search( - $context, - $request, - ); - $baseDn = (string) $request->getBaseDn(); - - $searchResult = $handlerResult->getResultCode() === ResultCode::SUCCESS - ? SearchResult::makeSuccessResult( - $handlerResult->getEntries(), - $baseDn, - $handlerResult->getDiagnosticMessage(), - ) - : SearchResult::makeErrorResult( - $handlerResult->getResultCode(), - $baseDn, - $handlerResult->getDiagnosticMessage(), - $handlerResult->getEntries(), - ); - } catch (OperationException $e) { - $searchResult = SearchResult::makeErrorResult( - $e->getCode(), - (string) $request->getBaseDn(), - $e->getMessage() - ); - } - - $this->sendEntriesToClient( - $searchResult, - $message, - $this->queue, - ...($handlerResult instanceof HandlerSearchResult ? $handlerResult->getControls() : []), - ); - } -} diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index 4bc94120..6fcaede7 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -66,7 +66,7 @@ public function handleRequest( ExtendedRequest::OID_START_TLS ); } - if ($this->options->getPagingHandler()) { + if ($this->options->getBackend() !== null) { $entry->add( 'supportedControl', Control::OID_PAGING @@ -84,8 +84,6 @@ public function handleRequest( /** @var SearchRequest $request */ $request = $message->getRequest(); - $this->filterEntryAttributes($request, $entry); - if ($this->rootDseHandler) { $entry = $this->rootDseHandler->rootDse( new RequestContext($message->controls(), $token), @@ -94,6 +92,8 @@ public function handleRequest( ); } + $this->filterEntryAttributes($request, $entry); + $this->queue->sendMessage( new LdapMessageResponse( $message->getMessageId(), diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index 6a544713..daa6d0ab 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -13,13 +13,13 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult as HandlerSearchResult; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -33,7 +33,8 @@ class ServerSearchHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + private readonly LdapBackendInterface $backend, + private readonly FilterEvaluatorInterface $filterEvaluator, ) { } @@ -44,35 +45,30 @@ public function handleRequest( LdapMessageRequest $message, TokenInterface $token ): void { - $context = new RequestContext( - $message->controls(), - $token - ); $request = $this->getSearchRequestFromMessage($message); - $handlerResult = null; - try { - $handlerResult = $this->dispatcher->search($context, $request); - $baseDn = (string) $request->getBaseDn(); + $context = $this->makeSearchContext($request); + $filter = $context->filter; + $attributes = $context->attributes; + $typesOnly = $context->typesOnly; + + $results = []; + foreach ($this->backend->search($context) as $entry) { + if ($this->filterEvaluator->evaluate($entry, $filter)) { + $results[] = $this->applyAttributeFilter($entry, $attributes, $typesOnly); + } + } - $searchResult = $handlerResult->getResultCode() === ResultCode::SUCCESS - ? SearchResult::makeSuccessResult( - $handlerResult->getEntries(), - $baseDn, - $handlerResult->getDiagnosticMessage(), - ) - : SearchResult::makeErrorResult( - $handlerResult->getResultCode(), - $baseDn, - $handlerResult->getDiagnosticMessage(), - $handlerResult->getEntries(), - ); + $searchResult = SearchResult::makeSuccessResult( + new Entries(...$results), + (string) $request->getBaseDn(), + ); } catch (OperationException $e) { $searchResult = SearchResult::makeErrorResult( $e->getCode(), (string) $request->getBaseDn(), - $e->getMessage() + $e->getMessage(), ); } @@ -80,7 +76,6 @@ public function handleRequest( $searchResult, $message, $this->queue, - ...($handlerResult instanceof HandlerSearchResult ? $handlerResult->getControls() : []), ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php index f0c66776..420f4a77 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\PagingControl; +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Operation\Request\SearchRequest; @@ -24,6 +26,7 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\Backend\SearchContext; trait ServerSearchTrait { @@ -85,4 +88,69 @@ private function getPagingControlFromMessage(LdapMessageRequest $message): Pagin return $pagingControl; } + + /** + * @throws OperationException + */ + private function makeSearchContext(SearchRequest $request): SearchContext + { + $baseDn = $request->getBaseDn(); + + if ($baseDn === null) { + throw new OperationException('No base DN provided.', ResultCode::PROTOCOL_ERROR); + } + + return new SearchContext( + baseDn: $baseDn, + scope: $request->getScope(), + filter: $request->getFilter(), + attributes: $request->getAttributes(), + typesOnly: $request->getAttributesOnly(), + ); + } + + /** + * Filter the attributes on an entry according to the requested attribute list. + * + * An empty list means return all attributes. The special value "*" also means + * all attributes. "1.1" means return no attributes (just the DN). + * + * @param Attribute[] $requestedAttrs + */ + private function applyAttributeFilter( + Entry $entry, + array $requestedAttrs, + bool $typesOnly, + ): Entry { + $names = array_map( + static fn(Attribute $a): string => strtolower($a->getDescription()), + $requestedAttrs + ); + + $returnAll = count($names) === 0 || in_array('*', $names, true); + $returnNone = count($names) === 1 && $names[0] === '1.1'; + + $filteredAttributes = []; + + foreach ($entry->getAttributes() as $attribute) { + if ($returnNone) { + break; + } + + if (!$returnAll && !in_array(strtolower($attribute->getDescription()), $names, true)) { + continue; + } + + if ($typesOnly) { + $filteredAttributes[] = new Attribute($attribute->getName()); + } else { + $filteredAttributes[] = $attribute; + } + } + + return new Entry( + $entry->getDn(), + ...$filteredAttributes + ); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php new file mode 100644 index 00000000..97827f89 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Auth\NameResolver; + +use FreeDSx\Ldap\Entry\Entry; + +/** + * Translates a raw LDAP bind name into an Entry. + * + * @author Chad Sikorra + */ +interface BindNameResolverInterface +{ + /** + * Resolve the bind name to an Entry, or return null if no match is found. + */ + public function resolve(string $name): ?Entry; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php new file mode 100644 index 00000000..020cc915 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.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\Auth\NameResolver; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; + +/** + * The default bind-name resolver. Treats the bind name as a DN and delegates + * to LdapBackendInterface::get(). + * + * This covers the common convention where clients bind with a full DN such as + * "cn=admin,dc=example,dc=com". For non-DN bind names (email addresses, bare + * usernames, etc.) provide a custom BindNameResolverInterface implementation. + * + * @author Chad Sikorra + */ +final class DnBindNameResolver implements BindNameResolverInterface +{ + public function __construct( + private readonly LdapBackendInterface $backend, + ) { + } + + public function resolve(string $name): ?Entry + { + return $this->backend->get(new Dn($name)); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php new file mode 100644 index 00000000..77fc7dfc --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Auth; + +use SensitiveParameter; + +/** + * Implemented by anything that can verify a bind password. + * + * Decoupled from LdapBackendInterface so that backends are not forced to + * provide authentication logic. The framework supplies a default + * PasswordAuthenticator that delegates entry lookup to any LdapBackendInterface. + * + * @author Chad Sikorra + */ +interface PasswordAuthenticatableInterface +{ + /** + * Return true if the supplied password is valid for the given bind name. + * + * The name is the raw value from the LDAP bind request — it need not be a DN. + */ + public function verifyPassword( + string $name, + #[SensitiveParameter] + string $password, + ): bool; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php new file mode 100644 index 00000000..f8af00ca --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.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\Auth; + +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use SensitiveParameter; + +/** + * Default password authenticator. Resolves the bind name to an Entry via a + * BindNameResolverInterface, then verifies the supplied password against the + * entry's userPassword attribute. + * + * Supported storage schemes: {SHA}, {SSHA}, {MD5}, {SMD5}, and plain-text. + * + * @author Chad Sikorra + */ +final class PasswordAuthenticator implements PasswordAuthenticatableInterface +{ + public function __construct( + private readonly BindNameResolverInterface $nameResolver, + ) { + } + + public function verifyPassword( + string $name, + #[SensitiveParameter] + string $password, + ): bool { + $entry = $this->nameResolver->resolve($name); + + if ($entry === null) { + return false; + } + + $attr = $entry->get('userPassword'); + + if ($attr === null) { + return false; + } + + foreach ($attr->getValues() as $stored) { + if ($this->checkPassword($password, $stored)) { + return true; + } + } + + return false; + } + + /** + * Verify a plain-text password against a (possibly hashed) stored value. + * + * Supports {SHA}, {SSHA}, {MD5}, {SMD5}, and plain-text storage. + */ + private function checkPassword( + #[SensitiveParameter] + string $plain, + string $stored, + ): bool { + if (str_starts_with($stored, '{SHA}')) { + return base64_encode(sha1($plain, true)) === substr($stored, 5); + } + + if (str_starts_with($stored, '{SSHA}')) { + $decoded = base64_decode(substr($stored, 6)); + $salt = substr($decoded, 20); + + return substr($decoded, 0, 20) === sha1($plain . $salt, true); + } + + if (str_starts_with($stored, '{MD5}')) { + return base64_encode(md5($plain, true)) === substr($stored, 5); + } + + if (str_starts_with($stored, '{SMD5}')) { + $decoded = base64_decode(substr($stored, 6)); + $salt = substr($decoded, 16); + + return substr($decoded, 0, 16) === md5($plain . $salt, true); + } + + return $plain === $stored; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php new file mode 100644 index 00000000..6054b369 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use Generator; + +/** + * A no-op backend used when no backend has been configured via LdapServer::useBackend(). + * + * Read operations return empty / false results. Write operations are handled by + * an empty WriteOperationDispatcher, which returns UNWILLING_TO_PERFORM. + * + * @author Chad Sikorra + */ +final class GenericBackend implements LdapBackendInterface +{ + public function search(SearchContext $context): Generator + { + yield from []; + } + + public function get(Dn $dn): ?Entry + { + return null; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php new file mode 100644 index 00000000..8b7b4c5d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use Generator; + +/** + * The core contract for an LDAP backend storage implementation. + * + * Implement this interface to provide a read-only backend. + * + * For a writable backend (add, delete, modify, rename) implement WritableLdapBackendInterface. + * + * The search() method returns a Generator that yields Entry objects. This library applies its own FilterEvaluator as a + * final pass, so the backend may pre-filter for efficiency (e.g. translate to a SQL WHERE clause) or yield all + * candidates in scope and let the framework filter. + * + * Each generator is scoped to a single client connection and a single paging session. It is paused between pages and + * garbage-collected when the session ends, so no external paging state management is needed. + * + * @author Chad Sikorra + */ +interface LdapBackendInterface +{ + /** + * Yield Entry objects matching (or potentially matching) 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 + */ + public function search(SearchContext $context): Generator; + + /** + * Fetch a single entry by DN, or return null if it does not exist. + */ + public function get(Dn $dn): ?Entry; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/SearchContext.php b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php new file mode 100644 index 00000000..a038694a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/SearchContext.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; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * Encapsulates the parameters of an LDAP search operation for use by + * LdapBackendInterface::search(). + * + * The backend receives the complete context including the filter. It may + * translate the filter to a native query language (SQL, MongoDB, etc.) or + * ignore it and yield all in-scope entries for the framework to filter. + * + * @author Chad Sikorra + */ +final class SearchContext +{ + /** + * @param Attribute[] $attributes Requested attribute list; empty means all attributes. + */ + public function __construct( + readonly public Dn $baseDn, + readonly public int $scope, + readonly public FilterInterface $filter, + readonly public array $attributes, + readonly public bool $typesOnly, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php new file mode 100644 index 00000000..84f7616f --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php @@ -0,0 +1,210 @@ + + * + * 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\Change; +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\PresentFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use Generator; + +/** + * An in-memory storage adapter backed by a plain PHP array. + * + * Suitable for single-process use cases: the Swoole server runner (all + * connections share the same process memory), or pre-seeded read-only + * use with the PCNTL runner (data seeded before run() is inherited by + * all forked child processes). + * + * With the PCNTL runner, write operations performed by one child process + * are not visible to other children or the parent. + * + * @author Chad Sikorra + */ +class InMemoryStorageAdapter implements WritableLdapBackendInterface +{ + use StorageAdapterTrait; + use WritableBackendTrait; + + /** + * Entries keyed by their normalised (lowercased) DN string. + * + * @var array + */ + private array $entries = []; + + /** + * Pre-populate the adapter with a set of entries. + */ + public function __construct(Entry ...$entries) + { + foreach ($entries as $entry) { + $this->entries[$this->normalise($entry->getDn())] = $entry; + } + } + + public function get(Dn $dn): ?Entry + { + return $this->entries[$this->normalise($dn)] ?? null; + } + + public function search(SearchContext $context): Generator + { + $normBase = $this->normalise($context->baseDn); + + foreach ($this->entries as $normDn => $entry) { + if ($this->isInScope($normDn, $normBase, $context->scope)) { + yield $entry; + } + } + } + + /** + * @throws OperationException + */ + public function add(AddCommand $command): void + { + $entry = $command->entry; + $normDn = $this->normalise($entry->getDn()); + + if (isset($this->entries[$normDn])) { + throw new OperationException( + sprintf('Entry already exists: %s', $entry->getDn()->toString()), + ResultCode::ENTRY_ALREADY_EXISTS, + ); + } + + $this->entries[$normDn] = $entry; + } + + /** + * @throws OperationException + */ + public function delete(DeleteCommand $command): void + { + $dn = $command->dn; + $childGenerator = $this->search(new SearchContext( + baseDn: $dn, + scope: SearchRequest::SCOPE_SINGLE_LEVEL, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + )); + + if ($childGenerator->valid()) { + throw new OperationException( + sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } + + unset($this->entries[$this->normalise($dn)]); + } + + public function update(UpdateCommand $command): void + { + $entry = $this->get($command->dn); + + if ($entry === null) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + foreach ($command->changes as $change) { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $values = $attribute->getValues(); + + switch ($change->getType()) { + case Change::TYPE_ADD: + $existing = $entry->get($attrName); + if ($existing !== null) { + $entry->get($attrName)?->add(...$values); + } else { + $entry->add($attribute); + } + break; + + case Change::TYPE_DELETE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->get($attrName)?->remove(...$values); + } + break; + + case Change::TYPE_REPLACE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->set($attribute); + } + break; + } + } + } + + public function move(MoveCommand $command): void + { + $dn = $command->dn; + $entry = $this->get($dn); + + if ($entry === null) { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $parent = $command->newParent ?? $dn->getParent(); + $newDnString = $parent !== null + ? $command->newRdn->toString() . ',' . $parent->toString() + : $command->newRdn->toString(); + + $newDn = new Dn($newDnString); + $newEntry = new Entry($newDn, ...$entry->getAttributes()); + + if ($command->deleteOldRdn) { + $oldRdn = $dn->getRdn(); + $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); + } + + $rdnName = $command->newRdn->getName(); + $rdnValue = $command->newRdn->getValue(); + $existing = $newEntry->get($rdnName); + if ($existing !== null) { + if (!$existing->has($rdnValue)) { + $existing->add($rdnValue); + } + } else { + $newEntry->set(new Attribute($rdnName, $rdnValue)); + } + + $this->delete(new DeleteCommand($dn)); + $this->add(new AddCommand($newEntry)); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php new file mode 100644 index 00000000..afab7757 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php @@ -0,0 +1,400 @@ + + * + * 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\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use Generator; + +/** + * A file-backed storage adapter that persists the directory as a JSON file. + * + * Safe for use with the PCNTL server runner: write operations are serialised + * using flock(LOCK_EX), so concurrent child processes do not corrupt the file. + * An in-memory cache is invalidated via filemtime checks to avoid re-reading + * the file on every read operation within a single forked process. + * + * Note: when used with the Swoole server runner, standard flock/fread calls + * are blocking and will stall the event loop. Use the InMemoryStorageAdapter + * with Swoole, or a Swoole-coroutine-aware file I/O layer instead. + * + * JSON format: + * { + * "cn=admin,dc=example,dc=com": { + * "dn": "cn=admin,dc=example,dc=com", + * "attributes": { + * "cn": ["admin"], + * "userPassword": ["{SHA}..."] + * } + * } + * } + * + * @author Chad Sikorra + */ +class JsonFileStorageAdapter implements WritableLdapBackendInterface +{ + use StorageAdapterTrait; + use WritableBackendTrait; + + /** + * @var array|null + */ + private ?array $cache = null; + + private int $cacheMtime = 0; + + public function __construct(private readonly string $filePath) + { + } + + public function get(Dn $dn): ?Entry + { + return $this->read()[$this->normalise($dn)] ?? null; + } + + public function search(SearchContext $context): Generator + { + $normBase = $this->normalise($context->baseDn); + + foreach ($this->read() as $normDn => $entry) { + if ($this->isInScope($normDn, $normBase, $context->scope)) { + yield $entry; + } + } + } + + /** + * @throws OperationException + */ + public function add(AddCommand $command): void + { + $entry = $command->entry; + $this->withLock(function (array $data) use ($entry): array { + $normDn = $this->normalise($entry->getDn()); + + if (isset($data[$normDn])) { + throw new OperationException( + sprintf('Entry already exists: %s', $entry->getDn()->toString()), + ResultCode::ENTRY_ALREADY_EXISTS, + ); + } + + $data[$normDn] = $this->entryToArray($entry); + + return $data; + }); + } + + public function delete(DeleteCommand $command): void + { + $normDn = $this->normalise($command->dn); + $this->withLock(function (array $data) use ($normDn, $command): array { + foreach (array_keys($data) as $key) { + if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { + throw new OperationException( + sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } + } + + unset($data[$normDn]); + + return $data; + }); + } + + public function update(UpdateCommand $command): void + { + $normDn = $this->normalise($command->dn); + $this->withLock(function (array $data) use ($normDn, $command): array { + if (!isset($data[$normDn])) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $entry = $this->arrayToEntry($data[$normDn]); + + foreach ($command->changes as $change) { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $values = $attribute->getValues(); + + switch ($change->getType()) { + case Change::TYPE_ADD: + $existing = $entry->get($attrName); + if ($existing !== null) { + $existing->add(...$values); + } else { + $entry->add($attribute); + } + break; + + case Change::TYPE_DELETE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->get($attrName)?->remove(...$values); + } + break; + + case Change::TYPE_REPLACE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->set($attribute); + } + break; + } + } + + $data[$normDn] = $this->entryToArray($entry); + + return $data; + }); + } + + public function move(MoveCommand $command): void + { + $normOld = $this->normalise($command->dn); + $this->withLock(function (array $data) use ($normOld, $command): array { + if (!isset($data[$normOld])) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $entry = $this->arrayToEntry($data[$normOld]); + + $parent = $command->newParent ?? $command->dn->getParent(); + $newDnString = $parent !== null + ? $command->newRdn->toString() . ',' . $parent->toString() + : $command->newRdn->toString(); + + $newDn = new Dn($newDnString); + $newEntry = new Entry($newDn, ...$entry->getAttributes()); + + if ($command->deleteOldRdn) { + $oldRdn = $command->dn->getRdn(); + $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); + } + + $rdnName = $command->newRdn->getName(); + $rdnValue = $command->newRdn->getValue(); + $existing = $newEntry->get($rdnName); + if ($existing !== null) { + if (!$existing->has($rdnValue)) { + $existing->add($rdnValue); + } + } else { + $newEntry->set(new Attribute($rdnName, $rdnValue)); + } + + unset($data[$normOld]); + $data[$this->normalise($newDn)] = $this->entryToArray($newEntry); + + return $data; + }); + } + + /** + * @return array + */ + private function read(): array + { + if (!file_exists($this->filePath)) { + $this->cache = []; + $this->cacheMtime = 0; + + return $this->cache; + } + + $mtime = (int) filemtime($this->filePath); + + if ($this->cache !== null && $this->cacheMtime === $mtime) { + return $this->cache; + } + + $contents = file_get_contents($this->filePath); + + if ($contents === false || $contents === '') { + $this->cache = []; + $this->cacheMtime = $mtime; + + return $this->cache; + } + + $raw = json_decode($contents, true); + + if (!is_array($raw)) { + $this->cache = []; + $this->cacheMtime = $mtime; + + return $this->cache; + } + + $entries = []; + foreach ($raw as $normDn => $data) { + if (!is_string($normDn)) { + continue; + } + $entries[$normDn] = $this->arrayToEntry($data); + } + + $this->cache = $entries; + $this->cacheMtime = $mtime; + + return $this->cache; + } + + /** + * Open the file with an exclusive lock, call $mutation with the current + * data array, write back the result, then release the lock. + * + * @param callable(array): array $mutation + */ + private function withLock(callable $mutation): void + { + $handle = fopen($this->filePath, 'c+'); + + if ($handle === false) { + throw new RuntimeException(sprintf( + 'Unable to open storage file: %s', + $this->filePath + )); + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + throw new RuntimeException(sprintf( + 'Unable to acquire exclusive lock on storage file: %s', + $this->filePath + )); + } + + try { + $data = $mutation($this->readFromHandle($handle)); + $this->writeToHandle($handle, $data); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + $this->cache = null; + } + } + + /** + * Read and decode the current JSON contents from an open file handle. + * + * @param resource $handle + * @return array + */ + private function readFromHandle(mixed $handle): array + { + $size = fstat($handle)['size'] ?? 0; + $contents = $size > 0 ? fread($handle, $size) : ''; + $rawDecoded = ($contents !== '' && $contents !== false) + ? json_decode($contents, true) + : null; + + if (!is_array($rawDecoded)) { + return []; + } + + $data = []; + foreach ($rawDecoded as $key => $value) { + if (is_string($key)) { + $data[$key] = $value; + } + } + + return $data; + } + + /** + * Encode and write data back to an open file handle, truncating first. + * + * @param resource $handle + * @param array $data + */ + private function writeToHandle( + mixed $handle, + array $data, + ): void { + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}'); + } + + /** + * @return array{dn: string, attributes: array>} + */ + private function entryToArray(Entry $entry): array + { + $attributes = []; + foreach ($entry->getAttributes() as $attribute) { + $attributes[$attribute->getName()] = array_values($attribute->getValues()); + } + + return [ + 'dn' => $entry->getDn()->toString(), + 'attributes' => $attributes, + ]; + } + + private function arrayToEntry(mixed $data): Entry + { + if (!is_array($data)) { + return new Entry(new Dn('')); + } + + $dn = isset($data['dn']) && is_string($data['dn']) ? $data['dn'] : ''; + + $attributes = []; + if (isset($data['attributes']) && is_array($data['attributes'])) { + foreach ($data['attributes'] as $name => $values) { + if (!is_string($name) || !is_array($values)) { + continue; + } + $stringValues = []; + foreach ($values as $v) { + if (is_string($v)) { + $stringValues[] = $v; + } + } + $attributes[] = new Attribute($name, ...$stringValues); + } + } + + return new Entry( + new Dn($dn), + ...$attributes + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php new file mode 100644 index 00000000..6a9ae277 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php @@ -0,0 +1,82 @@ + + * + * 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\Dn; +use FreeDSx\Ldap\Operation\Request\SearchRequest; + +/** + * Shared helper methods for storage adapter implementations. + * + * @author Chad Sikorra + */ +trait StorageAdapterTrait +{ + private function normalise(Dn $dn): string + { + return strtolower($dn->toString()); + } + + private function isInScope( + string $normDn, + string $normBase, + int $scope, + ): bool { + return match ($scope) { + SearchRequest::SCOPE_BASE_OBJECT => $normDn === $normBase, + SearchRequest::SCOPE_SINGLE_LEVEL => $this->isDirectChild( + $normDn, + $normBase, + ), + SearchRequest::SCOPE_WHOLE_SUBTREE => $this->isAtOrBelow( + $normDn, + $normBase, + ), + default => false, + }; + } + + private function isAtOrBelow( + string $normDn, + string $normBase, + ): bool { + if ($normDn === $normBase) { + return true; + } + + return str_ends_with( + $normDn, + ',' . $normBase + ); + } + + private function isDirectChild( + string $normDn, + string $normBase, + ): bool { + if (!str_ends_with($normDn, ',' . $normBase)) { + return false; + } + + // Strip the base suffix and check there is exactly one RDN component left + $prefix = substr( + $normDn, + 0, + strlen($normDn) - strlen(',' . $normBase) + ); + + return !str_contains($prefix, ','); + } + +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php new file mode 100644 index 00000000..47c742fb --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -0,0 +1,315 @@ + + * + * 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 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\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; + +/** + * Pure-PHP implementation of FilterEvaluatorInterface. + * + * Evaluates LDAP filters against in-memory Entry objects. Attribute name + * comparisons are always case-insensitive. Value comparisons default to + * case-insensitive string comparison, matching the caseIgnoreMatch semantics + * used by the majority of LDAP schema attribute types. + * + * @author Chad Sikorra + */ +final class FilterEvaluator implements FilterEvaluatorInterface +{ + private const MATCHING_RULE_CASE_IGNORE = '2.5.13.2'; + + private const MATCHING_RULE_CASE_EXACT = '2.5.13.5'; + + private const MATCHING_RULE_BIT_AND = '1.2.840.113556.1.4.803'; + + private const MATCHING_RULE_BIT_OR = '1.2.840.113556.1.4.804'; + + public function evaluate( + Entry $entry, + FilterInterface $filter, + ): bool { + return match (true) { + $filter instanceof AndFilter => $this->evaluateAnd($entry, $filter), + $filter instanceof OrFilter => $this->evaluateOr($entry, $filter), + $filter instanceof NotFilter => $this->evaluateNot($entry, $filter), + $filter instanceof PresentFilter => $this->evaluatePresent($entry, $filter), + $filter instanceof EqualityFilter => $this->evaluateEquality($entry, $filter), + $filter instanceof SubstringFilter => $this->evaluateSubstring($entry, $filter), + $filter instanceof GreaterThanOrEqualFilter => $this->evaluateGreaterOrEqual($entry, $filter), + $filter instanceof LessThanOrEqualFilter => $this->evaluateLessOrEqual($entry, $filter), + $filter instanceof ApproximateFilter => $this->evaluateApproximate($entry, $filter), + $filter instanceof MatchingRuleFilter => $this->evaluateMatchingRule($entry, $filter), + default => false, + }; + } + + private function evaluateAnd( + Entry $entry, + AndFilter $filter, + ): bool { + foreach ($filter->get() as $child) { + if (!$this->evaluate($entry, $child)) { + return false; + } + } + + return true; + } + + private function evaluateOr( + Entry $entry, + OrFilter $filter, + ): bool { + foreach ($filter->get() as $child) { + if ($this->evaluate($entry, $child)) { + return true; + } + } + + return false; + } + + private function evaluateNot( + Entry $entry, + NotFilter $filter, + ): bool { + return !$this->evaluate($entry, $filter->get()); + } + + private function evaluatePresent( + Entry $entry, + PresentFilter $filter, + ): bool { + return $entry->has($filter->getAttribute()); + } + + private function evaluateEquality( + Entry $entry, + EqualityFilter $filter, + ): bool { + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + $filterValue = strtolower($filter->getValue()); + + foreach ($attribute->getValues() as $value) { + if (strtolower($value) === $filterValue) { + return true; + } + } + + return false; + } + + private function evaluateSubstring( + Entry $entry, + SubstringFilter $filter, + ): bool { + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + $startsWith = $filter->getStartsWith() !== null + ? strtolower($filter->getStartsWith()) + : null; + $endsWith = $filter->getEndsWith() !== null + ? strtolower($filter->getEndsWith()) + : null; + $contains = array_map('strtolower', $filter->getContains()); + + foreach ($attribute->getValues() as $value) { + $lowerValue = strtolower($value); + + if ($startsWith !== null && !str_starts_with($lowerValue, $startsWith)) { + continue; + } + + if ($endsWith !== null && !str_ends_with($lowerValue, $endsWith)) { + continue; + } + + $pos = 0; + $allFound = true; + foreach ($contains as $substr) { + $found = strpos($lowerValue, $substr, $pos); + if ($found === false) { + $allFound = false; + break; + } + $pos = $found + strlen($substr); + } + + if ($allFound) { + return true; + } + } + + return false; + } + + private function evaluateGreaterOrEqual( + Entry $entry, + GreaterThanOrEqualFilter $filter, + ): bool { + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + $filterValue = $filter->getValue(); + + foreach ($attribute->getValues() as $value) { + if ($this->compareOrdered($value, $filterValue) >= 0) { + return true; + } + } + + return false; + } + + private function evaluateLessOrEqual( + Entry $entry, + LessThanOrEqualFilter $filter, + ): bool { + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + $filterValue = $filter->getValue(); + + foreach ($attribute->getValues() as $value) { + if ($this->compareOrdered($value, $filterValue) <= 0) { + return true; + } + } + + return false; + } + + private function compareOrdered( + string $value, + string $filterValue, + ): int { + if (ctype_digit($value) && ctype_digit($filterValue)) { + return (int) $value <=> (int) $filterValue; + } + + return strcasecmp($value, $filterValue); + } + + private function evaluateApproximate( + Entry $entry, + ApproximateFilter $filter, + ): bool { + // The LDAP spec does not define approximate matching precisely. + // Most servers treat it as case-insensitive equality, which we mirror here. + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + $filterValue = strtolower($filter->getValue()); + + foreach ($attribute->getValues() as $value) { + if (strtolower($value) === $filterValue) { + return true; + } + } + + return false; + } + + private function evaluateMatchingRule( + Entry $entry, + MatchingRuleFilter $filter, + ): bool { + $filterValue = $filter->getValue(); + + foreach ($this->collectValuesToTest($entry, $filter) as $value) { + if ($this->matchByRule($filter->getMatchingRule(), $value, $filterValue)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function collectValuesToTest( + Entry $entry, + MatchingRuleFilter $filter, + ): array { + $filterAttributeName = $filter->getAttribute(); + $values = []; + + if ($filterAttributeName !== null) { + $attribute = $entry->get($filterAttributeName); + if ($attribute !== null) { + $values = $attribute->getValues(); + } + } else { + // Null attribute name: match against all attributes + foreach ($entry->getAttributes() as $attribute) { + foreach ($attribute->getValues() as $v) { + $values[] = $v; + } + } + } + + // Optionally also match against RDN component values from the entry's DN + if ($filter->getUseDnAttributes()) { + foreach ($entry->getDn()->toArray() as $rdn) { + $values[] = $rdn->getValue(); + } + } + + return $values; + } + + private function matchByRule( + ?string $rule, + string $value, + string $filterValue, + ): bool { + return match ($rule) { + null, self::MATCHING_RULE_CASE_IGNORE => strtolower($value) === strtolower($filterValue), + self::MATCHING_RULE_CASE_EXACT => $value === $filterValue, + self::MATCHING_RULE_BIT_AND => ((int) $value & (int) $filterValue) === (int) $filterValue, + self::MATCHING_RULE_BIT_OR => ((int) $value & (int) $filterValue) !== 0, + default => false, + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluatorInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluatorInterface.php new file mode 100644 index 00000000..d8fbfbf5 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluatorInterface.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 FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * Evaluates an LDAP filter against an entry. + * + * Implementations may choose different strategies — pure-PHP evaluation, + * SQL translation, etc. — without coupling the request handler to any + * specific approach. + * + * @author Chad Sikorra + */ +interface FilterEvaluatorInterface +{ + /** + * Returns true if the entry satisfies the filter, false otherwise. + */ + public function evaluate( + Entry $entry, + FilterInterface $filter, + ): bool; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Command/AddCommand.php b/src/FreeDSx/Ldap/Server/Backend/Write/Command/AddCommand.php new file mode 100644 index 00000000..da762cdb --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Command/AddCommand.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\Command; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; + +/** + * Write command DTO for an LDAP add operation. + * + * @author Chad Sikorra + */ +final class AddCommand implements WriteRequestInterface +{ + public function __construct(readonly public Entry $entry) + { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Command/DeleteCommand.php b/src/FreeDSx/Ldap/Server/Backend/Write/Command/DeleteCommand.php new file mode 100644 index 00000000..57927b81 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Command/DeleteCommand.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\Command; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; + +/** + * Write command DTO for an LDAP delete operation. + * + * @author Chad Sikorra + */ +final class DeleteCommand implements WriteRequestInterface +{ + public function __construct(readonly public Dn $dn) + { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php b/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php new file mode 100644 index 00000000..f4698475 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.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\Write\Command; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; + +/** + * Write command DTO for an LDAP modifyDn (rename/move) operation. + * + * @author Chad Sikorra + */ +final class MoveCommand implements WriteRequestInterface +{ + public function __construct( + readonly public Dn $dn, + readonly public Rdn $newRdn, + readonly public bool $deleteOldRdn, + readonly ?Dn $newParent, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Command/UpdateCommand.php b/src/FreeDSx/Ldap/Server/Backend/Write/Command/UpdateCommand.php new file mode 100644 index 00000000..20163451 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Command/UpdateCommand.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\Write\Command; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; + +/** + * Write command DTO for an LDAP modify operation. + * + * @author Chad Sikorra + */ +final class UpdateCommand implements WriteRequestInterface +{ + /** + * @param Change[] $changes + */ + public function __construct( + readonly public Dn $dn, + readonly public array $changes, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WritableBackendTrait.php b/src/FreeDSx/Ldap/Server/Backend/Write/WritableBackendTrait.php new file mode 100644 index 00000000..11d39196 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WritableBackendTrait.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; + +/** + * Use this trait in classes that implement WritableLdapBackendInterface and + * want to handle all four write operations via distinct, methods + * rather than a single handle() with instanceof checks. + * + * @author Chad Sikorra + */ +trait WritableBackendTrait +{ + public function supports(WriteRequestInterface $request): bool + { + return $request instanceof AddCommand + || $request instanceof DeleteCommand + || $request instanceof UpdateCommand + || $request instanceof MoveCommand; + } + + public function handle(WriteRequestInterface $request): void + { + if ($request instanceof AddCommand) { + $this->add($request); + } elseif ($request instanceof DeleteCommand) { + $this->delete($request); + } elseif ($request instanceof UpdateCommand) { + $this->update($request); + } elseif ($request instanceof MoveCommand) { + $this->move($request); + } + } + + abstract public function add(AddCommand $command): void; + + abstract public function delete(DeleteCommand $command): void; + + abstract public function update(UpdateCommand $command): void; + + abstract public function move(MoveCommand $command): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WritableLdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/Write/WritableLdapBackendInterface.php new file mode 100644 index 00000000..0318739a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WritableLdapBackendInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; + +/** + * Marker interface for backends that support all LDAP write operations. + * + * Implementing this interface signals full CRUD capability. The optional + * WritableBackendTrait bridges the WriteHandlerInterface contract (supports() + * and handle()) to four typed methods (add, delete, update, move), which is + * the recommended approach for full-CRUD backends. + * + * For partial write support — where a backend or standalone class handles only + * a subset of write operations — implement WriteHandlerInterface directly and + * register the handler via LdapServer::useWriteHandler(). + * + * @author Chad Sikorra + */ +interface WritableLdapBackendInterface extends LdapBackendInterface, WriteHandlerInterface {} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteCommandFactory.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteCommandFactory.php new file mode 100644 index 00000000..664818d3 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteCommandFactory.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\DeleteRequest; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; +use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\Request\RequestInterface; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; + +/** + * Translates LDAP protocol request objects into write command DTOs. + * + * @author Chad Sikorra + */ +final class WriteCommandFactory +{ + /** + * @throws OperationException + */ + public function fromRequest(RequestInterface $request): WriteRequestInterface + { + return match (true) { + $request instanceof AddRequest => new AddCommand($request->getEntry()), + $request instanceof DeleteRequest => new DeleteCommand($request->getDn()), + $request instanceof ModifyRequest => new UpdateCommand( + $request->getDn(), + $request->getChanges(), + ), + $request instanceof ModifyDnRequest => new MoveCommand( + $request->getDn(), + $request->getNewRdn(), + $request->getDeleteOldRdn(), + $request->getNewParentDn(), + ), + default => throw new OperationException( + 'The requested operation is not supported.', + ResultCode::NO_SUCH_OPERATION, + ), + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteHandlerInterface.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteHandlerInterface.php new file mode 100644 index 00000000..c21d89c8 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteHandlerInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +/** + * Implemented by any class that handles one or more LDAP write operations. + * + * The framework calls supports() first and only calls handle() when it returns + * true, so implementations never need no-op stubs for unsupported operations. + * + * @author Chad Sikorra + */ +interface WriteHandlerInterface +{ + public function supports(WriteRequestInterface $request): bool; + + public function handle(WriteRequestInterface $request): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteOperationDispatcher.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteOperationDispatcher.php new file mode 100644 index 00000000..0de8bd94 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteOperationDispatcher.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; + +/** + * Holds an ordered list of write handlers and routes each write command to + * the first handler that declares support for it. + * + * Handlers are tried in registration order. Explicitly registered handlers + * take priority when the backend is appended last as a fallback. + * + * @author Chad Sikorra + */ +final class WriteOperationDispatcher +{ + /** + * @var WriteHandlerInterface[] + */ + private array $handlers; + + public function __construct(WriteHandlerInterface ...$handlers) + { + $this->handlers = $handlers; + } + + /** + * @throws OperationException + */ + public function dispatch(WriteRequestInterface $request): void + { + foreach ($this->handlers as $handler) { + if ($handler->supports($request)) { + $handler->handle($request); + + return; + } + } + + throw new OperationException( + 'This operation is not supported.', + ResultCode::UNWILLING_TO_PERFORM + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestInterface.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestInterface.php new file mode 100644 index 00000000..f17874b3 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +/** + * Marker interface for all write command DTOs. + * + * @author Chad Sikorra + */ +interface WriteRequestInterface {} diff --git a/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php b/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php index c103b2de..6c593bc8 100644 --- a/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php +++ b/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php @@ -13,10 +13,11 @@ namespace FreeDSx\Ldap\Server; -use FreeDSx\Ldap\Exception\RuntimeException; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; /** * Responsible for instantiating classes needed by the core server logic. @@ -26,17 +27,37 @@ interface HandlerFactoryInterface { /** - * @throws RuntimeException + * Return the configured backend, or a GenericBackend no-op if none is set. */ - public function makeRequestHandler(): RequestHandlerInterface; + public function makeBackend(): LdapBackendInterface; /** - * @throws RuntimeException + * Return the configured filter evaluator, or the default FilterEvaluator. + */ + public function makeFilterEvaluator(): FilterEvaluatorInterface; + + /** + * Return the optional root DSE handler, or null if not configured. */ public function makeRootDseHandler(): ?RootDseHandlerInterface; /** - * @throws RuntimeException + * Build the write operation dispatcher. + * + * Explicit write handlers (registered via LdapServer::useWriteHandler()) are + * added first (higher priority). The backend is appended as a fallback if it + * implements WriteHandlerInterface. + */ + public function makeWriteDispatcher(): WriteOperationDispatcher; + + /** + * Return a PasswordAuthenticatableInterface for simple-bind and SASL PLAIN. + * + * Resolution order: + * 1. An explicitly configured authenticator (via ServerOptions::setPasswordAuthenticator()). + * 2. The backend itself, if it implements PasswordAuthenticatableInterface. + * 3. The default PasswordAuthenticator, which reads userPassword from entries + * returned by the backend's get() method. */ - public function makePagingHandler(): ?PagingHandlerInterface; + public function makePasswordAuthenticator(): PasswordAuthenticatableInterface; } diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/GenericRequestHandler.php b/src/FreeDSx/Ldap/Server/RequestHandler/GenericRequestHandler.php deleted file mode 100644 index 4890e721..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/GenericRequestHandler.php +++ /dev/null @@ -1,115 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\AddRequest; -use FreeDSx\Ldap\Operation\Request\CompareRequest; -use FreeDSx\Ldap\Operation\Request\DeleteRequest; -use FreeDSx\Ldap\Operation\Request\ExtendedRequest; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\ModifyRequest; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; -use SensitiveParameter; - -/** - * This allows the LDAP server to run, but rejects everything. You can extend and selectively override specific request - * types to support what you want. - * - * @author Chad Sikorra - */ -class GenericRequestHandler implements RequestHandlerInterface -{ - /** - * {@inheritdoc} - */ - public function add( - RequestContext $context, - AddRequest $add - ): void { - throw new OperationException('The add operation is not supported.'); - } - - public function bind( - string $username, - #[SensitiveParameter] - string $password, - ): bool { - return false; - } - - /** - * {@inheritdoc} - */ - public function compare( - RequestContext $context, - CompareRequest $compare - ): bool { - throw new OperationException('The compare operation is not supported.'); - } - - /** - * {@inheritdoc} - */ - public function delete( - RequestContext $context, - DeleteRequest $delete, - ): void { - throw new OperationException('The delete operation is not supported.'); - } - - /** - * {@inheritdoc} - */ - public function extended( - RequestContext $context, - ExtendedRequest $extended - ): void { - throw new OperationException(sprintf( - 'The extended operation %s is not supported.', - $extended->getName() - )); - } - - /** - * {@inheritdoc} - */ - public function modify( - RequestContext $context, - ModifyRequest $modify - ): void { - throw new OperationException('The modify operation is not supported.'); - } - - /** - * {@inheritdoc} - */ - public function modifyDn( - RequestContext $context, - ModifyDnRequest $modifyDn - ): void { - throw new OperationException('The modify dn operation is not supported.'); - } - - /** - * {@inheritdoc} - */ - public function search( - RequestContext $context, - SearchRequest $search - ): SearchResult { - throw new OperationException('The search operation is not supported.'); - } -} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php index f5b09686..209cb588 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php @@ -13,7 +13,16 @@ namespace FreeDSx\Ldap\Server\RequestHandler; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\DnBindNameResolver; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; +use FreeDSx\Ldap\Server\Backend\GenericBackend; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\HandlerFactoryInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\ServerOptions; /** @@ -24,59 +33,76 @@ */ class HandlerFactory implements HandlerFactoryInterface { - private ?RequestHandlerInterface $requestHandler = null; - - private ?RootDseHandlerInterface $rootdseHandler = null; + public function __construct(private readonly ServerOptions $options) + { + } - private ?PagingHandlerInterface $pagingHandler = null; + /** + * @inheritDoc + */ + public function makeBackend(): LdapBackendInterface + { + return $this->options->getBackend() ?? new GenericBackend(); + } - public function __construct(private readonly ServerOptions $options) + /** + * @inheritDoc + */ + public function makeFilterEvaluator(): FilterEvaluatorInterface { + return $this->options->getFilterEvaluator() ?? new FilterEvaluator(); } /** * @inheritDoc */ - public function makeRequestHandler(): RequestHandlerInterface + public function makeRootDseHandler(): ?RootDseHandlerInterface { - if (!$this->requestHandler) { - $this->requestHandler = $this->options->getRequestHandler() ?? new GenericRequestHandler(); + $explicit = $this->options->getRootDseHandler(); + if ($explicit !== null) { + return $explicit; + } + + $backend = $this->options->getBackend(); + if ($backend instanceof RootDseHandlerInterface) { + return $backend; } - return $this->requestHandler; + return null; } /** * @inheritDoc */ - public function makeRootDseHandler(): ?RootDseHandlerInterface + public function makePasswordAuthenticator(): PasswordAuthenticatableInterface { - if ($this->rootdseHandler) { - return $this->rootdseHandler; + $explicit = $this->options->getPasswordAuthenticator(); + + if ($explicit !== null) { + return $explicit; } - $handler = $this->makeRequestHandler(); - $this->rootdseHandler = $handler instanceof RootDseHandlerInterface - ? $handler - : null; - if ($this->rootdseHandler) { - return $this->rootdseHandler; + $backend = $this->makeBackend(); + + if ($backend instanceof PasswordAuthenticatableInterface) { + return $backend; } - $this->rootdseHandler = $this->options->getRootDseHandler(); - return $this->rootdseHandler; + return new PasswordAuthenticator(new DnBindNameResolver($backend)); } /** * @inheritDoc */ - public function makePagingHandler(): ?PagingHandlerInterface + public function makeWriteDispatcher(): WriteOperationDispatcher { - if ($this->pagingHandler) { - return $this->pagingHandler; + $handlers = $this->options->getWriteHandlers(); + + $backend = $this->options->getBackend(); + if ($backend instanceof WriteHandlerInterface) { + $handlers[] = $backend; } - $this->pagingHandler = $this->options->getPagingHandler(); - return $this->pagingHandler; + return new WriteOperationDispatcher(...$handlers); } } diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/PagingHandlerInterface.php b/src/FreeDSx/Ldap/Server/RequestHandler/PagingHandlerInterface.php deleted file mode 100644 index 3467dac6..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/PagingHandlerInterface.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\Paging\PagingResponse; -use FreeDSx\Ldap\Server\RequestContext; - -/** - * Server implementations that wish to support paging must use a class implementing this interface. - * - * @author Chad Sikorra - */ -interface PagingHandlerInterface -{ - /** - * Indicates a paging request that has been received and needs a response. - */ - public function page( - PagingRequest $pagingRequest, - RequestContext $context - ): PagingResponse; - - /** - * Indicates that a paging request is to be removed / abandoned. No further attempts will be made to complete it. - * Any resources involved in its processing, if they still exist, should now be cleaned-up. - * - * This could be called in a couple of different contexts: - * - * 1. The client is explicitly asking to abandon the paging request. - * 2. The client paging request is being removed due to server resource constraints (request age, max outstanding requests, etc) - * 3. The paging request has been successfully completed and all results have been returned. - */ - public function remove( - PagingRequest $pagingRequest, - RequestContext $context - ): void; -} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php new file mode 100644 index 00000000..3e17f12e --- /dev/null +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\RequestHandler; + +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\LdapClient; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; +use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use Generator; +use SensitiveParameter; + +/** + * Proxies requests to an upstream LDAP server. + * + * Extend this class and provide the LdapClient via the constructor or by + * overriding the ldap() method to customise connection options. + * + * @author Chad Sikorra + */ +class ProxyBackend implements WritableLdapBackendInterface, PasswordAuthenticatableInterface +{ + use WritableBackendTrait; + + protected ?LdapClient $ldap = null; + + protected ClientOptions $options; + + public function __construct( + LdapClient|ClientOptions $clientOrOptions = new ClientOptions(), + ) { + if ($clientOrOptions instanceof LdapClient) { + $this->ldap = $clientOrOptions; + $this->options = new ClientOptions(); + } else { + $this->options = $clientOrOptions; + } + } + + public function search(SearchContext $context): Generator + { + $request = $this->buildSearchRequest($context); + $entries = $this->ldap()->search($request); + + foreach ($entries as $entry) { + yield $entry; + } + } + + public function get(Dn $dn): ?Entry + { + return $this->ldap()->read($dn->toString()); + } + + public function verifyPassword( + string $name, + #[SensitiveParameter] + string $password, + ): bool { + try { + return (bool) $this->ldap()->bind($name, $password); + } catch (BindException $e) { + throw new OperationException($e->getMessage(), $e->getCode()); + } + } + + public function add(AddCommand $command): void + { + $this->ldap()->sendAndReceive(Operations::add($command->entry)); + } + + public function delete(DeleteCommand $command): void + { + $this->ldap()->sendAndReceive(Operations::delete($command->dn->toString())); + } + + public function update(UpdateCommand $command): void + { + $this->ldap()->sendAndReceive(new ModifyRequest( + $command->dn->toString(), + ...$command->changes, + )); + } + + public function move(MoveCommand $command): void + { + $this->ldap()->sendAndReceive(new ModifyDnRequest( + dn: $command->dn->toString(), + newRdn: $command->newRdn->toString(), + deleteOldRdn: $command->deleteOldRdn, + newParentDn: $command->newParent?->toString(), + )); + } + + public function setLdapClient(LdapClient $client): void + { + $this->ldap = $client; + } + + protected function ldap(): LdapClient + { + if ($this->ldap === null) { + $this->ldap = new LdapClient($this->options); + } + + return $this->ldap; + } + + private function buildSearchRequest(SearchContext $context): SearchRequest + { + // Request all attributes from upstream so the FilterEvaluator + // can evaluate the filter against full entries before applying attribute + // projection via applyAttributeFilter(). + $request = Operations::search($context->filter); + $request->base($context->baseDn->toString()); + + match ($context->scope) { + SearchRequest::SCOPE_BASE_OBJECT => $request->useBaseScope(), + SearchRequest::SCOPE_SINGLE_LEVEL => $request->useSingleLevelScope(), + default => $request->useSubtreeScope(), + }; + + if ($context->typesOnly) { + $request->setAttributesOnly(true); + } + + return $request; + } +} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyHandler.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyHandler.php index 2012bcef..c8eb272f 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyHandler.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyHandler.php @@ -25,11 +25,11 @@ * * @author Chad Sikorra */ -class ProxyHandler extends ProxyRequestHandler implements RootDseHandlerInterface +class ProxyHandler extends ProxyBackend implements RootDseHandlerInterface { public function __construct(LdapClient $client) { - $this->ldap = $client; + parent::__construct($client); } /** @@ -47,7 +47,6 @@ public function rootDse( ) ->first(); - // This technically could happen...but should not. if (!$rootDse) { throw new OperationException( 'Entry not found.', diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyPagingHandler.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyPagingHandler.php deleted file mode 100644 index 18724952..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyPagingHandler.php +++ /dev/null @@ -1,87 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\LdapClient; -use FreeDSx\Ldap\Search\Paging; -use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\Paging\PagingResponse; -use FreeDSx\Ldap\Server\RequestContext; - -/** - * Proxies paging requests to an an existing LDAP client. - * - * @author Chad Sikorra - */ -class ProxyPagingHandler implements PagingHandlerInterface -{ - private LdapClient $client; - - /** - * @var array - */ - private array $pagers = []; - - public function __construct(LdapClient $client) - { - $this->client = $client; - } - - /** - * @inheritDoc - */ - public function page( - PagingRequest $pagingRequest, - RequestContext $context - ): PagingResponse { - $paging = $this->getPagerForClient($pagingRequest); - $entries = $paging->getEntries($pagingRequest->getSize()); - - if (!$paging->hasEntries()) { - return PagingResponse::makeFinal($entries); - } else { - return PagingResponse::make( - $entries, - $paging->sizeEstimate() ?? 0 - ); - } - } - - /** - * @inheritDoc - */ - public function remove( - PagingRequest $pagingRequest, - RequestContext $context - ): void { - if (isset($this->pagers[$pagingRequest->getUniqueId()])) { - unset($this->pagers[$pagingRequest->getUniqueId()]); - } - } - - private function getPagerForClient(PagingRequest $pagingRequest): Paging - { - if (!isset($this->pagers[$pagingRequest->getUniqueId()])) { - $this->pagers[$pagingRequest->getUniqueId()] = $this->client->paging( - $pagingRequest->getSearchRequest(), - $pagingRequest->getSize() - ); - $this->pagers[$pagingRequest->getUniqueId()]->isCritical( - $pagingRequest->isCritical() - ); - } - - return $this->pagers[$pagingRequest->getUniqueId()]; - } -} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyRequestHandler.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyRequestHandler.php deleted file mode 100644 index b2410a87..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyRequestHandler.php +++ /dev/null @@ -1,169 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\ClientOptions; -use FreeDSx\Ldap\Exception\BindException; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\LdapClient; -use FreeDSx\Ldap\Operation\LdapResult; -use FreeDSx\Ldap\Operation\Request\AddRequest; -use FreeDSx\Ldap\Operation\Request\CompareRequest; -use FreeDSx\Ldap\Operation\Request\DeleteRequest; -use FreeDSx\Ldap\Operation\Request\ExtendedRequest; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\ModifyRequest; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\Response\SearchResponse; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Server\RequestContext; -use SensitiveParameter; - -/** - * Proxies requests to an LDAP server and returns the response. You should extend this to add your own constructor and - * set the LDAP client options variable. - * - * @author Chad Sikorra - */ -class ProxyRequestHandler implements RequestHandlerInterface -{ - protected ?LdapClient $ldap = null; - - protected ClientOptions $options; - - public function __construct(ClientOptions $options = new ClientOptions()) - { - $this->options = $options; - } - - /** - * @inheritDoc - */ - public function bind( - string $username, - #[SensitiveParameter] - string $password, - ): bool { - try { - return (bool) $this->ldap()->bind($username, $password); - } catch (BindException $e) { - throw new OperationException($e->getMessage(), $e->getCode()); - } - } - - /** - * @inheritDoc - */ - public function modify( - RequestContext $context, - ModifyRequest $modify - ): void { - $this->ldap()->sendAndReceive($modify, ...$context->controls()->toArray()); - } - - /** - * @inheritDoc - */ - public function modifyDn( - RequestContext $context, - ModifyDnRequest $modifyDn - ): void { - $this->ldap()->sendAndReceive($modifyDn, ...$context->controls()->toArray()); - } - - /** - * @inheritDoc - */ - public function delete( - RequestContext $context, - DeleteRequest $delete - ): void { - $this->ldap()->sendAndReceive($delete, ...$context->controls()->toArray()); - } - - /** - * @inheritDoc - */ - public function add( - RequestContext $context, - AddRequest $add - ): void { - $this->ldap()->sendAndReceive($add, ...$context->controls()->toArray()); - } - - /** - * @inheritDoc - */ - public function search( - RequestContext $context, - SearchRequest $search - ): SearchResult { - /** @var SearchResponse $response */ - $response = $this->ldap() - ->sendAndReceive( - $search, - ...$context->controls()->toArray() - ) - ->getResponse(); - - if ($response->getResultCode() === ResultCode::SUCCESS) { - return SearchResult::make($response->getEntries()); - } - - return SearchResult::makeWithResultCode( - $response->getEntries(), - $response->getResultCode(), - $response->getDiagnosticMessage(), - ); - } - - /** - * @inheritDoc - */ - public function compare( - RequestContext $context, - CompareRequest $compare - ): bool { - $response = $this->ldap()->sendAndReceive($compare, ...$context->controls()->toArray())->getResponse(); - if (!$response instanceof LdapResult) { - throw new OperationException('The result was malformed.', ResultCode::PROTOCOL_ERROR); - } - - return $response->getResultCode() === ResultCode::COMPARE_TRUE; - } - - /** - * @inheritDoc - */ - public function extended( - RequestContext $context, - ExtendedRequest $extended - ): void { - $this->ldap()->send($extended, ...$context->controls()->toArray()); - } - - public function setLdapClient(LdapClient $client): void - { - $this->ldap = $client; - } - - protected function ldap(): LdapClient - { - if ($this->ldap === null) { - $this->ldap = new LdapClient($this->options); - } - - return $this->ldap; - } -} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/RequestHandlerInterface.php b/src/FreeDSx/Ldap/Server/RequestHandler/RequestHandlerInterface.php deleted file mode 100644 index d6d664e7..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/RequestHandlerInterface.php +++ /dev/null @@ -1,116 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\AddRequest; -use FreeDSx\Ldap\Operation\Request\CompareRequest; -use FreeDSx\Ldap\Operation\Request\DeleteRequest; -use FreeDSx\Ldap\Operation\Request\ExtendedRequest; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\ModifyRequest; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; -use SensitiveParameter; - -/** - * Handler methods for LDAP server requests. - * - * @author Chad Sikorra - */ -interface RequestHandlerInterface -{ - /** - * An add request. - * - * @throws OperationException - */ - public function add( - RequestContext $context, - AddRequest $add, - ): void; - - /** - * A compare request. This should return true or false for whether the compare matches or not. - * - * @throws OperationException - */ - public function compare( - RequestContext $context, - CompareRequest $compare, - ): bool; - - /** - * A delete request. - * - * @throws OperationException - */ - public function delete( - RequestContext $context, - DeleteRequest $delete, - ): void; - - /** - * An extended operation request. - * - * @throws OperationException - */ - public function extended( - RequestContext $context, - ExtendedRequest $extended, - ): void; - - /** - * A request to modify an entry. - * - * @throws OperationException - */ - public function modify( - RequestContext $context, - ModifyRequest $modify, - ): void; - - /** - * A request to modify the DN of an entry. - * - * @throws OperationException - */ - public function modifyDn( - RequestContext $context, - ModifyDnRequest $modifyDn, - ): void; - - /** - * A search request. This should return a SearchResult object. - * - * @throws OperationException - */ - public function search( - RequestContext $context, - SearchRequest $search, - ): SearchResult; - - /** - * A simple username/password bind. It should simply return true or false for whether the username and password is - * valid. You can also throw an operations exception, which is implicitly false, and provide an additional result - * code and diagnostics. - * - * @throws OperationException - */ - public function bind( - string $username, - #[SensitiveParameter] - string $password, - ): bool; -} diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/SearchResult.php b/src/FreeDSx/Ldap/Server/RequestHandler/SearchResult.php deleted file mode 100644 index 6a0f2c39..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/SearchResult.php +++ /dev/null @@ -1,102 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -use FreeDSx\Ldap\Control\Control; -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Operation\ResultCode; - -/** - * Represents the search result to be returned from a request handler search. - * - * @author Chad Sikorra - */ -final class SearchResult -{ - /** - * @param Entries $entries - * @param Control[] $controls - */ - private function __construct( - private readonly Entries $entries, - private readonly int $resultCode, - private readonly string $diagnosticMessage, - private readonly array $controls, - ) { - } - - /** - * Make a successful search result — the common case. - * - * @param Entries $entries - */ - public static function make( - Entries $entries, - Control ...$controls, - ): self { - return new self( - $entries, - ResultCode::SUCCESS, - '', - $controls, - ); - } - - /** - * Make a search result with an explicit result code. Use for partial results, size/time limit exceeded, referrals, - * etc. Entries are still returned to the client before the SearchResultDone. - * - * @param Entries $entries - */ - public static function makeWithResultCode( - Entries $entries, - int $resultCode, - string $diagnosticMessage = '', - Control ...$controls, - ): self { - return new self( - $entries, - $resultCode, - $diagnosticMessage, - $controls, - ); - } - - /** - * @return Entries - */ - public function getEntries(): Entries - { - return $this->entries; - } - - public function getResultCode(): int - { - return $this->resultCode; - } - - public function getDiagnosticMessage(): string - { - return $this->diagnosticMessage; - } - - /** - * @return Control[] - */ - public function getControls(): array - { - return $this->controls; - } -} diff --git a/src/FreeDSx/Ldap/Server/RequestHistory.php b/src/FreeDSx/Ldap/Server/RequestHistory.php index 3422edee..b642bc71 100644 --- a/src/FreeDSx/Ldap/Server/RequestHistory.php +++ b/src/FreeDSx/Ldap/Server/RequestHistory.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Exception\ProtocolException; use FreeDSx\Ldap\Server\Paging\PagingRequests; +use Generator; /** * Used to retain history regarding certain client request details. @@ -30,6 +31,14 @@ final class RequestHistory private PagingRequests $pagingRequests; + /** + * Per-connection generator store for active paging sessions. + * Keyed by the cookie that the client will send on the next request. + * + * @var array + */ + private array $pagingGenerators = []; + public function __construct(?PagingRequests $pagingRequests = null) { $this->pagingRequests = $pagingRequests ?? new PagingRequests(); @@ -67,4 +76,32 @@ public function getIds(): array { return $this->ids; } + + /** + * Store a generator for the given paging cookie (the cookie that will be + * sent to the client and returned on the next page request). + */ + public function storePagingGenerator( + string $cookie, + Generator $generator, + ): void { + $this->pagingGenerators[$cookie] = $generator; + } + + /** + * Retrieve the generator associated with the given cookie, or null if + * the cookie is not found. + */ + public function getPagingGenerator(string $cookie): ?Generator + { + return $this->pagingGenerators[$cookie] ?? null; + } + + /** + * Remove and discard the generator associated with the given cookie. + */ + public function removePagingGenerator(string $cookie): void + { + unset($this->pagingGenerators[$cookie]); + } } diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index 2d45a487..2623da53 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Protocol\Authenticator; use FreeDSx\Ldap\Protocol\Bind\AnonymousBind; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; use FreeDSx\Ldap\Protocol\Bind\SaslBind; use FreeDSx\Ldap\Protocol\Bind\SimpleBind; use FreeDSx\Sasl\Sasl; @@ -38,12 +39,13 @@ public function make(Socket $socket): ServerProtocolHandler { $serverQueue = new ServerQueue($socket); - $requestHandler = $this->handlerFactory->makeRequestHandler(); + $backend = $this->handlerFactory->makeBackend(); + $passwordAuthenticator = $this->handlerFactory->makePasswordAuthenticator(); $authenticators = [ new SimpleBind( queue: $serverQueue, - dispatcher: $requestHandler, + authenticator: $passwordAuthenticator, ), new AnonymousBind($serverQueue), ]; @@ -52,9 +54,10 @@ public function make(Socket $socket): ServerProtocolHandler if (!empty($saslMechanisms)) { $authenticators[] = new SaslBind( queue: $serverQueue, - dispatcher: $requestHandler, + authenticator: $passwordAuthenticator, sasl: new Sasl(['supported' => $saslMechanisms]), mechanisms: $saslMechanisms, + optionsBuilderFactory: new MechanismOptionsBuilderFactory($backend), ); } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php new file mode 100644 index 00000000..3aa5f1bc --- /dev/null +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\ServerRunner; + +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Socket\Socket; +use FreeDSx\Socket\SocketServer; +use Psr\Log\LoggerInterface; +use Swoole\Coroutine; +use Swoole\Process; +use Swoole\Runtime; +use Throwable; + +/** + * A server runner that uses Swoole coroutines instead of forked processes. + * + * Each incoming client connection is handled in its own coroutine within a + * single PHP process. This means all coroutines share the same memory, making + * in-memory storage adapters safe to use with concurrent clients. + * + * Note on JsonFileStorageAdapter: although Swoole hooks make file reads + * coroutine-friendly, flock() may still cause brief stalls under high write + * concurrency. For write-heavy workloads prefer the InMemoryStorageAdapter. + * + * @author Chad Sikorra + */ +class SwooleServerRunner implements ServerRunnerInterface +{ + private const SOCKET_ACCEPT_TIMEOUT = 5; + + private SocketServer $server; + + private bool $isShuttingDown = false; + + public function __construct( + private readonly ServerProtocolFactory $serverProtocolFactory, + private readonly ?LoggerInterface $logger = null, + ) { + if (!extension_loaded('swoole')) { + throw new RuntimeException( + 'The Swoole extension is required to use SwooleServerRunner. ' + . 'Install via PECL: pecl install swoole ' + . '(^5.1 for PHP 8.3/8.4, ^6.0 for PHP 8.5+)' + ); + } + } + + public function run(SocketServer $server): void + { + $this->server = $server; + + // Hook all standard PHP blocking I/O so it works inside coroutines. + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + + Coroutine\run(function (): void { + $this->registerShutdownSignals(); + $this->acceptClients(); + }); + + $this->logger?->info('SwooleServerRunner: all connections drained, shutdown complete.'); + } + + private function registerShutdownSignals(): void + { + Process::signal(SIGTERM, $this->handleShutdownSignal(...)); + Process::signal(SIGINT, $this->handleShutdownSignal(...)); + Process::signal(SIGQUIT, $this->handleShutdownSignal(...)); + } + + private function handleShutdownSignal(int $signal): void + { + if ($this->isShuttingDown) { + return; + } + $this->isShuttingDown = true; + $this->logger?->info(sprintf( + 'SwooleServerRunner: received signal %d, closing server.', + $signal, + )); + $this->server->close(); + } + + private function acceptClients(): void + { + $this->logger?->info('SwooleServerRunner: accepting clients.'); + + while ($this->server->isConnected()) { + $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + + if ($socket === null) { + continue; + } + + if ($this->isShuttingDown) { + $socket->close(); + break; + } + + Coroutine::create(function () use ($socket): void { + $this->handleClient($socket); + }); + } + + $this->logger?->info('SwooleServerRunner: accept loop ended, draining active connections.'); + } + + private function handleClient(Socket $socket): void + { + try { + $handler = $this->serverProtocolFactory->make($socket); + $handler->handle(); + } catch (Throwable $e) { + $this->logger?->error( + 'SwooleServerRunner: unhandled error in client coroutine: ' . $e->getMessage() + ); + } finally { + $socket->close(); + } + } +} diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index cdcc93f6..0ef4af42 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -14,10 +14,12 @@ namespace FreeDSx\Ldap; use FreeDSx\Ldap\Exception\InvalidArgumentException; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use Psr\Log\LoggerInterface; final class ServerOptions @@ -103,16 +105,25 @@ final class ServerOptions private ?string $dseVendorVersion = null; - private ?RequestHandlerInterface $requestHandler = null; + private ?LdapBackendInterface $backend = null; + + private ?PasswordAuthenticatableInterface $passwordAuthenticator = null; private ?RootDseHandlerInterface $rootDseHandler = null; - private ?PagingHandlerInterface $pagingHandler = null; + /** + * @var WriteHandlerInterface[] + */ + private array $writeHandlers = []; + + private ?FilterEvaluatorInterface $filterEvaluator = null; private ?LoggerInterface $logger = null; private ?ServerRunnerInterface $serverRunner = null; + private bool $useSwooleRunner = false; + /** * @var string[] */ @@ -301,14 +312,26 @@ public function setDseVendorVersion(?string $dseVendorVersion): self return $this; } - public function getRequestHandler(): ?RequestHandlerInterface + public function getBackend(): ?LdapBackendInterface + { + return $this->backend; + } + + public function setBackend(?LdapBackendInterface $backend): self + { + $this->backend = $backend; + + return $this; + } + + public function getPasswordAuthenticator(): ?PasswordAuthenticatableInterface { - return $this->requestHandler; + return $this->passwordAuthenticator; } - public function setRequestHandler(?RequestHandlerInterface $requestHandler): self + public function setPasswordAuthenticator(?PasswordAuthenticatableInterface $passwordAuthenticator): self { - $this->requestHandler = $requestHandler; + $this->passwordAuthenticator = $passwordAuthenticator; return $this; } @@ -325,14 +348,29 @@ public function setRootDseHandler(?RootDseHandlerInterface $rootDseHandler): sel return $this; } - public function getPagingHandler(): ?PagingHandlerInterface + /** + * @return WriteHandlerInterface[] + */ + public function getWriteHandlers(): array { - return $this->pagingHandler; + return $this->writeHandlers; } - public function setPagingHandler(?PagingHandlerInterface $pagingHandler): self + public function addWriteHandler(WriteHandlerInterface $handler): self { - $this->pagingHandler = $pagingHandler; + $this->writeHandlers[] = $handler; + + return $this; + } + + public function getFilterEvaluator(): ?FilterEvaluatorInterface + { + return $this->filterEvaluator; + } + + public function setFilterEvaluator(?FilterEvaluatorInterface $filterEvaluator): self + { + $this->filterEvaluator = $filterEvaluator; return $this; } @@ -386,8 +424,20 @@ public function getServerRunner(): ?ServerRunnerInterface return $this->serverRunner; } + public function setUseSwooleRunner(bool $use): self + { + $this->useSwooleRunner = $use; + + return $this; + } + + public function getUseSwooleRunner(): bool + { + return $this->useSwooleRunner; + } + /** - * @return array{ip: string, port: int, unix_socket: string, transport: string, idle_timeout: int, require_authentication: bool, allow_anonymous: bool, request_handler: ?RequestHandlerInterface, rootdse_handler: ?RootDseHandlerInterface, paging_handler: ?PagingHandlerInterface, logger: ?LoggerInterface, use_ssl: bool, ssl_cert: ?string, ssl_cert_key: ?string, ssl_cert_passphrase: ?string, dse_alt_server: ?string, dse_naming_contexts: string[], dse_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} + * @return array{ip: string, port: int, unix_socket: string, transport: string, idle_timeout: int, require_authentication: bool, allow_anonymous: bool, backend: ?LdapBackendInterface, rootdse_handler: ?RootDseHandlerInterface, logger: ?LoggerInterface, use_ssl: bool, ssl_cert: ?string, ssl_cert_key: ?string, ssl_cert_passphrase: ?string, dse_alt_server: ?string, dse_naming_contexts: string[], dse_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} */ public function toArray(): array { @@ -399,9 +449,8 @@ public function toArray(): array 'idle_timeout' => $this->getIdleTimeout(), 'require_authentication' => $this->isRequireAuthentication(), 'allow_anonymous' => $this->isAllowAnonymous(), - 'request_handler' => $this->getRequestHandler(), + 'backend' => $this->getBackend(), 'rootdse_handler' => $this->getRootDseHandler(), - 'paging_handler' => $this->getPagingHandler(), 'logger' => $this->getLogger(), 'use_ssl' => $this->isUseSsl(), 'ssl_cert' => $this->getSslCert(), diff --git a/tests/bin/ldapbackendstorage.php b/tests/bin/ldapbackendstorage.php new file mode 100644 index 00000000..8357bf93 --- /dev/null +++ b/tests/bin/ldapbackendstorage.php @@ -0,0 +1,75 @@ +setPort(10389) + ->setTransport($transport) +))->useBackend($adapter); + +if ($runner === 'swoole') { + $server->useSwooleRunner(); +} + +echo 'server starting...' . PHP_EOL; + +$server->run(); diff --git a/tests/bin/ldapserver.php b/tests/bin/ldapserver.php index 52914d54..895fc015 100644 --- a/tests/bin/ldapserver.php +++ b/tests/bin/ldapserver.php @@ -2,89 +2,25 @@ declare(strict_types=1); -use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Operation\Request\AddRequest; -use FreeDSx\Ldap\Operation\Request\CompareRequest; -use FreeDSx\Ldap\Operation\Request\DeleteRequest; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\ModifyRequest; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\Paging\PagingResponse; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; use FreeDSx\Ldap\ServerOptions; require __DIR__ . '/../../vendor/autoload.php'; -class LdapServerPagingHandler implements PagingHandlerInterface +class LdapServerBackend implements WritableLdapBackendInterface, SaslHandlerInterface { - public function page( - PagingRequest $pagingRequest, - RequestContext $context - ): PagingResponse { - $i = 0; - - $entries = []; - while ($i < $pagingRequest->getSize()) { - $i++; - $entries[] = Entry::fromArray( - "cn=foo$i,dc=foo,dc=bar", - [ - 'foo' => (string) $i, - 'bar' => (string) $i, - ] - ); - } - $entries = new Entries(...$entries); - - if ($pagingRequest->getIteration() === 3) { - $this->logRequest( - 'paging', - 'Final response with ' . $entries->count() . ' entries' - ); - - return PagingResponse::makeFinal($entries); - } else { - $this->logRequest( - 'paging', - 'Regular response with ' - . $entries->count() . ' entries, iteration ' - . $pagingRequest->getIteration() - ); - - return PagingResponse::make( - $entries, - ($pagingRequest->getSize() * 3) - ($pagingRequest->getIteration() * $pagingRequest->getSize()) - ); - } - } - - public function remove( - PagingRequest $pagingRequest, - RequestContext $context - ): void { - $this->logRequest( - 'remove', - 'On iteration ' . $pagingRequest->getIteration() - ); - } - - private function logRequest( - string $type, - string $message - ): void { - echo "---$type--- $message" . PHP_EOL; - } -} + use WritableBackendTrait; -class LdapServerRequestHandler extends GenericRequestHandler implements SaslHandlerInterface -{ /** * @var array */ @@ -92,117 +28,121 @@ class LdapServerRequestHandler extends GenericRequestHandler implements SaslHand 'cn=user,dc=foo,dc=bar' => '12345', ]; - public function getPassword( - string $username, - string $mechanism, - ): ?string { - return $this->users[$username] ?? null; + public function search(SearchContext $context): Generator + { + $this->logRequest( + 'search', + "base-dn => {$context->baseDn->toString()}, filter => {$context->filter->toString()}" + ); + + yield Entry::fromArray( + $context->baseDn->toString(), + [ + 'objectClass' => 'inetOrgPerson', + 'cn' => 'user', + 'name' => 'user', + 'foo' => 'bar', + ] + ); } - public function bind(string $username, string $password): bool + public function get(Dn $dn): ?Entry { + return Entry::fromArray( + $dn->toString(), + [ + 'foo' => 'bar', + ] + ); + } + + public function verifyPassword( + Dn $dn, + string $password, + ): bool { + $username = $dn->toString(); + $this->logRequest( 'bind', "username => $username, password => $password" ); - return isset($this->users[$username]) - && $this->users[$username] === $password; + return isset($this->users[$username]) && $this->users[$username] === $password; } - public function add(RequestContext $context, AddRequest $add): void + public function add(AddCommand $command): void { + $entry = $command->entry; $attrLog = []; - foreach ($add->getEntry()->getAttributes() as $attribute) { - $attrLog[] = "{$attribute->getName()} => " . implode( - ', ', - $attribute->getValues() - ); + foreach ($entry->getAttributes() as $attribute) { + $attrLog[] = "{$attribute->getName()} => " . implode(', ', $attribute->getValues()); } - $attrLog = implode(', ', $attrLog); $this->logRequest( 'add', - "dn => {$add->getEntry()->getDn()->toString()}, Attributes: {$attrLog}" - ); - } - - public function compare(RequestContext $context, CompareRequest $compare): bool - { - $filter = $compare->getFilter(); - - $this->logRequest( - 'compare', - "dn => {$compare->getDn()->toString()}, Name => {$filter->getAttribute()}, Value => {$filter->getValue()}" + "dn => {$entry->getDn()->toString()}, Attributes: " . implode(', ', $attrLog) ); - - return true; } - public function delete(RequestContext $context, DeleteRequest $delete): void + public function delete(DeleteCommand $command): void { - $this->logRequest( - 'delete', - "dn => {$delete->getDn()->toString()}" - ); + $this->logRequest('delete', "dn => {$command->dn->toString()}"); } - public function modify(RequestContext $context, ModifyRequest $modify): void + public function update(UpdateCommand $command): void { $modLog = []; - foreach ($modify->getChanges() as $change) { + foreach ($command->changes as $change) { $attribute = $change->getAttribute(); $modLog[] = "({$change->getType()}){$attribute->getName()} => " . implode( ', ', $attribute->getValues() ); } - $modLog = implode(', ', $modLog); $this->logRequest( 'modify', - "dn => {$modify->getDn()->toString()}, Changes: $modLog" + "dn => {$command->dn->toString()}, Changes: " . implode(', ', $modLog) ); } - public function modifyDn(RequestContext $context, ModifyDnRequest $modifyDn): void + public function move(MoveCommand $command): void { - $dnLog = 'ParentDn => ' . $modifyDn->getNewParentDn()?->toString(); - $dnLog .= ', ParentRdn => ' . $modifyDn->getNewRdn()->toString(); + $dnLog = 'ParentDn => ' . $command->newParent?->toString(); + $dnLog .= ', ParentRdn => ' . $command->newRdn->toString(); - $this->logRequest( - 'modify-dn', - "dn => {$modifyDn->getDn()->toString()}, $dnLog" - ); + $this->logRequest('modify-dn', "dn => {$command->dn->toString()}, $dnLog"); } - public function search(RequestContext $context, SearchRequest $search): SearchResult - { - $this->logRequest( - 'search', - "base-dn => {$search->getBaseDn()?->toString()}, filter => {$search->getFilter()->toString()}" - ); - - return SearchResult::make( - new Entries( - Entry::fromArray( - 'cn=user,dc=foo,dc=bar', - [ - 'name' => 'user', - ] - ) - ) - ); + public function getPassword( + string $username, + string $mechanism, + ): ?string { + return $this->users[$username] ?? null; } - private function logRequest( - string $type, - string $message - ): void { + private function logRequest(string $type, string $message): void + { echo "---$type--- $message" . PHP_EOL; } } +class LdapServerPagingBackend extends LdapServerBackend +{ + public function search(SearchContext $context): Generator + { + for ($i = 1; $i <= 300; $i++) { + yield Entry::fromArray( + "cn=foo$i,dc=foo,dc=bar", + [ + 'foo' => (string) $i, + 'bar' => (string) $i, + ] + ); + } + } +} + $sslKey = __DIR__ . '/../resources/cert/slapd.key'; $sslCert = __DIR__ . '/../resources/cert/slapd.crt'; @@ -215,19 +155,17 @@ private function logRequest( $useSsl = true; } +$backend = $handler === 'paging' + ? new LdapServerPagingBackend() + : new LdapServerBackend(); + $options = (new ServerOptions()) - ->setRequestHandler(new LdapServerRequestHandler()) ->setPort(10389) ->setTransport($transport) ->setUnixSocket(sys_get_temp_dir() . '/ldap.socket') ->setSslCert($sslCert) ->setSslCertKey($sslKey) - ->setUseSsl($useSsl) - ->setPagingHandler( - $handler === 'paging' - ? new LdapServerPagingHandler() - : null - ); + ->setUseSsl($useSsl); if ($handler === 'sasl') { $options->setSaslMechanisms( @@ -237,8 +175,8 @@ private function logRequest( ); } -$server = new LdapServer($options); +$server = (new LdapServer($options))->useBackend($backend); -echo "server starting..." . PHP_EOL; +echo 'server starting...' . PHP_EOL; $server->run(); diff --git a/tests/integration/LdapBackendStorageSwooleTest.php b/tests/integration/LdapBackendStorageSwooleTest.php new file mode 100644 index 00000000..fc901073 --- /dev/null +++ b/tests/integration/LdapBackendStorageSwooleTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap; + +/** + * Runs the full LdapBackendStorageTest suite against the SwooleServerRunner. + * + * Read-only tests share a single Swoole server process started in + * setUpBeforeClass(). Write tests that mutate the InMemoryStorageAdapter + * state stop the shared server and spin up a fresh one in setUp() — giving + * Swoole's coroutine startup the same natural gap between setUp() and the + * first LDAP call that the original per-test approach relied on. tearDown() + * then restarts the shared server so subsequent tests see a clean seed. + * + * Skipped automatically when the swoole extension is not loaded. + */ +final class LdapBackendStorageSwooleTest extends LdapBackendStorageTest +{ + /** + * Tests that write to the InMemoryStorageAdapter and would pollute the + * shared server's state for subsequent tests. + */ + private const MUTATING_TESTS = [ + 'testAddStoresEntry', + 'testDeleteRemovesEntry', + 'testModifyReplacesAttributeValue', + 'testRenameChangesRdn', + ]; + + /** + * Start a single shared Swoole server instead of the PCNTL server that + * LdapBackendStorageTest::setUpBeforeClass() would launch. + */ + public static function setUpBeforeClass(): void + { + // Intentionally skip parent::setUpBeforeClass() to avoid starting the + // shared PCNTL server that LdapBackendStorageTest would launch. + if (!extension_loaded('swoole')) { + return; + } + + static::initSharedServer( + 'ldapbackendstorage', + 'tcp', + 'swoole', + ); + } + + public static function tearDownAfterClass(): void + { + static::tearDownSharedServer(); + } + + public function setUp(): void + { + if (!extension_loaded('swoole')) { + $this->markTestSkipped('The swoole extension is required to run SwooleServerRunner tests.'); + } + + // parent::setUp() connects the per-test client to the shared server. + parent::setUp(); + + // Mutating tests need a fresh isolated server. Doing this in setUp() + // rather than inline in the test method ensures Swoole's coroutine has + // the same natural setUp→test gap to finish binding the socket. + if (in_array($this->name(), self::MUTATING_TESTS, true)) { + $this->stopServer(); + $this->createServerProcess('tcp', 'swoole'); + } + } +} diff --git a/tests/integration/LdapBackendStorageTest.php b/tests/integration/LdapBackendStorageTest.php new file mode 100644 index 00000000..5ac72742 --- /dev/null +++ b/tests/integration/LdapBackendStorageTest.php @@ -0,0 +1,303 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; + +/** + * End-to-end integration tests for BackendStorageRequestHandler wired via + * LdapServer::useStorageAdapter() with an InMemoryStorageAdapter. + * + * A single server process is started for the test class. With the PCNTL runner, + * each client connection is handled in a forked child process, so write + * operations in one test do not affect subsequent tests. + * + * The server is started via tests/bin/ldapbackendstorage.php, seeded with: + * + * dc=foo,dc=bar objectClass=domain + * cn=user,dc=foo,dc=bar userPassword={SHA}<12345> + * ou=people,dc=foo,dc=bar objectClass=organizationalUnit + * cn=alice,ou=people,dc=foo,dc=bar sn=Smith, mail=alice@foo.bar + */ +class LdapBackendStorageTest extends ServerTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer('ldapbackendstorage', 'tcp'); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + } + + public function setUp(): void + { + $this->setServerMode('ldapbackendstorage'); + + parent::setUp(); + } + + protected function authenticateUser(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + } + + public function testBindWithCorrectCredentials(): void + { + // No exception thrown — bind succeeded; verify the session is usable + $this->authenticateUser(); + + self::assertTrue( + $this->ldapClient()->compare('cn=user,dc=foo,dc=bar', 'cn', 'user') + ); + } + + public function testBindWithWrongCredentials(): void + { + $this->expectException(BindException::class); + + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', 'wrongpassword'); + } + + public function testBindWithUnknownDn(): void + { + $this->expectException(BindException::class); + + $this->ldapClient()->bind('cn=nobody,dc=foo,dc=bar', '12345'); + } + + public function testSearchBaseObjectReturnsBaseEntry(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useBaseScope() + ); + + self::assertCount(1, $entries); + self::assertSame('dc=foo,dc=bar', $entries->first()?->getDn()->toString()); + } + + public function testSearchSingleLevelReturnsDirectChildrenOnly(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSingleLevelScope() + ); + + // cn=user and ou=people are direct children; cn=alice is not + self::assertCount(2, $entries); + } + + public function testSearchSubtreeWithFilterReturnsMatchingEntry(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString() + ); + } + + public function testSearchReturnsAttributeValues(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + + $alice = $entries->first(); + self::assertNotNull($alice); + self::assertSame(['Smith'], $alice->get('sn')?->getValues()); + } + + public function testSearchTypesOnlyReturnsAttributeNamesWithoutValues(): void + { + $this->authenticateUser(); + + $request = Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + $request->setAttributesOnly(true); + + $entries = $this->ldapClient()->search($request); + + $alice = $entries->first(); + self::assertNotNull($alice); + // sn attribute should be present but with no values + $sn = $alice->get('sn'); + self::assertNotNull($sn); + self::assertEmpty($sn->getValues()); + } + + public function testSearchWithNoMatchReturnsEmptyResult(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'nobody')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + + self::assertCount(0, $entries); + } + + public function testAddStoresEntry(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=charlie,dc=foo,dc=bar', + ['cn' => 'charlie', 'objectClass' => 'inetOrgPerson'] + )); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'charlie')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + self::assertCount(1, $entries); + } + + public function testAddDuplicateDnFails(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=user,dc=foo,dc=bar', + ['cn' => 'user'] + )); + } + + public function testDeleteRemovesEntry(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + $this->ldapClient()->delete('cn=alice,ou=people,dc=foo,dc=bar'); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + self::assertCount(0, $entries); + } + + public function testDeleteNonLeafEntryFails(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); + + // ou=people still has cn=alice as a child + $this->ldapClient()->delete('ou=people,dc=foo,dc=bar'); + } + + public function testModifyReplacesAttributeValue(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + + $entry = Entry::fromArray('cn=alice,ou=people,dc=foo,dc=bar'); + $entry->set('sn', 'Jones'); + $this->ldapClient()->update($entry); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('sn', 'Jones')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + self::assertCount(1, $entries); + self::assertSame(['Jones'], $entries->first()?->get('sn')?->getValues()); + } + + public function testRenameChangesRdn(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + $this->ldapClient()->rename('cn=alice,ou=people,dc=foo,dc=bar', 'cn=bob', true); + + $found = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'bob')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + self::assertCount(1, $found); + self::assertSame('cn=bob,ou=people,dc=foo,dc=bar', $found->first()?->getDn()->toString()); + + // Old DN should no longer exist + $notFound = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ); + self::assertCount(0, $notFound); + } + + public function testCompareReturnsTrueForMatchingValue(): void + { + $this->authenticateUser(); + + $result = $this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'sn', + 'Smith' + ); + + self::assertTrue($result); + } + + public function testCompareReturnsFalseForNonMatchingValue(): void + { + $this->authenticateUser(); + + $result = $this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'sn', + 'Jones' + ); + + self::assertFalse($result); + } +} diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index f1604a5a..37584fdf 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -166,25 +166,13 @@ public function testItCanPerformCompare(): void { $this->authenticate(); - $this->ldapClient()->compare( + $result = $this->ldapClient()->compare( 'cn=meh,dc=foo,dc=bar', 'foo', 'bar' ); - $output = $this->waitForServerOutput('---compare---'); - $this->assertStringContainsString( - 'dn => cn=meh,dc=foo,dc=bar', - $output - ); - $this->assertStringContainsString( - 'Name => foo', - $output - ); - $this->assertStringContainsString( - 'Value => bar', - $output - ); + $this->assertTrue($result); } public function testItCanModifyDn(): void @@ -231,6 +219,9 @@ public function testItCanRetrieveTheRootDSE(): void 'vendorName' => [ 'FreeDSx', ], + 'supportedControl' => [ + '1.2.840.113556.1.4.319', + ], ], $rootDse->toArray() ); @@ -309,49 +300,29 @@ public function testItCanHandlingPaging(): void $this->authenticate(); $allEntries = []; - $i = 0; + $iterations = 0; - $search = Operations::search(Filters::raw('(cn=foo)')); + $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); $paging = $this->ldapClient()->paging($search); while ($paging->hasEntries()) { - $i++; + $iterations++; $entries = $paging->getEntries(100); $allEntries = array_merge( $allEntries, $entries->toArray() ); - - $output = $this->waitForServerOutput('---paging---'); - - if ($i === 3) { - $this->assertStringContainsString('Final response', $output); - } else { - $this->assertStringContainsString('Regular response', $output); - } } + $this->assertSame(3, $iterations); $this->assertCount(300, $allEntries); } - public function testItThrowsAnExceptionForPagingWhenNotSupported(): void - { - $this->authenticate(); - - $this->expectExceptionMessage('The server does not support the paging control.'); - $this->expectExceptionCode(ResultCode::UNAVAILABLE_CRITICAL_EXTENSION); - - $search = Operations::search(Filters::raw('(cn=foo)')); - $this->ldapClient()->paging($search) - ->isCritical() - ->getEntries(); - } - public function testItDoesASearchWhenPagingIsNotMarkedAsCritical(): void { $this->authenticate(); - $search = Operations::search(Filters::raw('(cn=foo)')); + $search = Operations::search(Filters::raw('(name=user)'))->base('dc=foo,dc=bar'); $paging = $this->ldapClient()->paging($search); $result = $paging->getEntries(); diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 1816e613..6665b7a8 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -24,6 +24,8 @@ 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. */ @@ -135,6 +137,10 @@ 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); } @@ -200,14 +206,51 @@ private static function launchSharedProcess( $process = new Process($processArgs); $process->start(); - self::waitForProcess( - $process, - 'server starting...' - ); + 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, + )); + } + private function buildClient(string $transport): LdapClient { $useSsl = false; diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index 489ccd40..b68301b2 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -17,11 +17,8 @@ use FreeDSx\Ldap\Exception\InvalidArgumentException; use FreeDSx\Ldap\LdapClient; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; -use FreeDSx\Ldap\Server\RequestHandler\ProxyPagingHandler; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; @@ -32,9 +29,9 @@ use Psr\Log\LoggerInterface; /** - * Combined interface so PHPUnit can mock both at once. + * Combined interface so PHPUnit can mock a backend that also handles SASL. */ -interface SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} +interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} class LdapServerTest extends TestCase { @@ -65,16 +62,15 @@ public function test_it_should_run_the_server(): void $this->subject->run(); } - public function test_it_should_use_the_request_handler_specified(): void + public function test_it_should_use_the_backend_specified(): void { - $requestHandler = $this->createMock(RequestHandlerInterface::class); + $backend = $this->createMock(LdapBackendInterface::class); - $this->subject->useRequestHandler($requestHandler); + $this->subject->useBackend($backend); self::assertSame( - $requestHandler, - $this->subject->getOptions() - ->getRequestHandler() + $backend, + $this->subject->getOptions()->getBackend() ); } @@ -86,21 +82,7 @@ public function test_it_should_use_the_rootdse_handler_specified(): void self::assertSame( $rootDseHandler, - $this->subject->getOptions() - ->getRootDseHandler() - ); - } - - public function test_it_should_use_the_paging_handler_specified(): void - { - $pagingHandler = $this->createMock(PagingHandlerInterface::class); - - $this->subject->usePagingHandler($pagingHandler); - - self::assertSame( - $pagingHandler, - $this->subject->getOptions() - ->getPagingHandler() + $this->subject->getOptions()->getRootDseHandler() ); } @@ -112,8 +94,7 @@ public function test_it_should_use_the_logger_specified(): void self::assertSame( $logger, - $this->subject->getOptions() - ->getLogger() + $this->subject->getOptions()->getLogger() ); } @@ -121,16 +102,15 @@ public function test_it_should_get_the_default_options(): void { self::assertEquals( [ - 'ip' => "0.0.0.0", + 'ip' => '0.0.0.0', 'port' => 33389, - 'unix_socket' => "/var/run/ldap.socket", - 'transport' => "tcp", + 'unix_socket' => '/var/run/ldap.socket', + 'transport' => 'tcp', 'idle_timeout' => 600, 'require_authentication' => true, 'allow_anonymous' => false, - 'request_handler' => null, + 'backend' => null, 'rootdse_handler' => null, - 'paging_handler' => null, 'logger' => null, 'use_ssl' => false, 'ssl_cert' => null, @@ -138,9 +118,9 @@ public function test_it_should_get_the_default_options(): void 'ssl_cert_passphrase' => null, 'dse_alt_server' => null, 'dse_naming_contexts' => [ - "dc=FreeDSx,dc=local" + 'dc=FreeDSx,dc=local', ], - 'dse_vendor_name' => "FreeDSx", + 'dse_vendor_name' => 'FreeDSx', 'dse_vendor_version' => null, 'sasl_mechanisms' => [], ], @@ -148,7 +128,7 @@ public function test_it_should_get_the_default_options(): void ); } - public function test_it_throws_when_challenge_mechanisms_are_configured_without_a_sasl_handler(): void + public function test_it_throws_when_challenge_mechanisms_are_configured_without_a_backend(): void { $this->options->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5); @@ -157,34 +137,34 @@ public function test_it_throws_when_challenge_mechanisms_are_configured_without_ $this->subject->run(); } - public function test_it_throws_when_challenge_mechanisms_are_configured_with_a_non_sasl_handler(): void + public function test_it_throws_when_challenge_mechanisms_are_configured_with_a_non_sasl_backend(): void { $this->options ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setRequestHandler($this->createMock(RequestHandlerInterface::class)); + ->setBackend($this->createMock(LdapBackendInterface::class)); self::expectException(InvalidArgumentException::class); $this->subject->run(); } - public function test_it_does_not_throw_when_challenge_mechanisms_are_configured_with_a_sasl_handler(): void + public function test_it_does_not_throw_when_challenge_mechanisms_are_configured_with_a_sasl_backend(): void { - /** @var RequestHandlerInterface&SaslHandlerInterface&MockObject $mockSaslHandler */ - $mockSaslHandler = $this->createMock(SaslRequestHandlerInterface::class); + /** @var LdapBackendInterface&SaslHandlerInterface&MockObject $mockSaslBackend */ + $mockSaslBackend = $this->createMock(SaslBackendInterface::class); $this->mockServerRunner->method('run'); $this->options ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setRequestHandler($mockSaslHandler); + ->setBackend($mockSaslBackend); $this->subject->run(); $this->expectNotToPerformAssertions(); } - public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_handler(): void + public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_backend(): void { $this->mockServerRunner->method('run'); @@ -197,30 +177,14 @@ public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_handle public function test_it_should_make_a_proxy_server(): void { - $client = new LdapClient( - (new ClientOptions()) - ->setServers(['localhost']) - ); - $serverOptions = new ServerOptions(); - $proxyRequestHandler = new ProxyHandler($client); - $server = new LdapServer($serverOptions); - $server->useRequestHandler($proxyRequestHandler); - $server->useRootDseHandler($proxyRequestHandler); - $server->usePagingHandler(new ProxyPagingHandler($client)); - - $proxyOptions = LdapServer::makeProxy('localhost') - ->getOptions(); + $proxyOptions = LdapServer::makeProxy('localhost')->getOptions(); self::assertInstanceOf( - ProxyPagingHandler::class, - $proxyOptions->getPagingHandler() - ); - self::assertInstanceOf( - ProxyRequestHandler::class, - $proxyOptions->getRequestHandler() + ProxyHandler::class, + $proxyOptions->getBackend() ); self::assertInstanceOf( - ProxyRequestHandler::class, + ProxyHandler::class, $proxyOptions->getRootDseHandler(), ); } diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php index 3029a280..83c39d31 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php @@ -13,13 +13,15 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; -use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\CramMD5MechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\DigestMD5MechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\PlainMechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\ScramMechanismOptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -27,74 +29,90 @@ /** * Combined interface so PHPUnit can mock both at once. */ -interface SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} +interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} final class MechanismOptionsBuilderFactoryTest extends TestCase { - private MechanismOptionsBuilderFactory $subject; + private LdapBackendInterface&MockObject $mockBackend; - private RequestHandlerInterface&MockObject $mockDispatcher; + private SaslBackendInterface&MockObject $mockSaslBackend; - private SaslRequestHandlerInterface&MockObject $mockSaslDispatcher; + private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; protected function setUp(): void { - $this->subject = new MechanismOptionsBuilderFactory(); - $this->mockDispatcher = $this->createMock(RequestHandlerInterface::class); - $this->mockSaslDispatcher = $this->createMock(SaslRequestHandlerInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockSaslBackend = $this->createMock(SaslBackendInterface::class); + $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); } - public function test_make_plain_with_non_sasl_dispatcher_returns_plain_builder(): void + public function test_make_plain_with_non_sasl_backend_returns_plain_builder(): void { + $subject = new MechanismOptionsBuilderFactory($this->mockBackend); + self::assertInstanceOf( PlainMechanismOptionsBuilder::class, - $this->subject->make('PLAIN', $this->mockDispatcher) + $subject->make('PLAIN', $this->mockAuthenticator) ); } - public function test_make_plain_with_sasl_dispatcher_returns_plain_builder(): void + public function test_make_plain_with_sasl_backend_returns_plain_builder(): void { + $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); + self::assertInstanceOf( PlainMechanismOptionsBuilder::class, - $this->subject->make('PLAIN', $this->mockSaslDispatcher) + $subject->make('PLAIN', $this->mockAuthenticator) ); } - public function test_make_cram_md5_with_sasl_dispatcher_returns_cram_md5_builder(): void + public function test_make_cram_md5_with_sasl_backend_returns_cram_md5_builder(): void { + $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); + self::assertInstanceOf( CramMD5MechanismOptionsBuilder::class, - $this->subject->make('CRAM-MD5', $this->mockSaslDispatcher) + $subject->make('CRAM-MD5', $this->mockAuthenticator) ); } - public function test_make_digest_md5_with_sasl_dispatcher_returns_digest_md5_builder(): void + public function test_make_digest_md5_with_sasl_backend_returns_digest_md5_builder(): void { + $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); + self::assertInstanceOf( DigestMD5MechanismOptionsBuilder::class, - $this->subject->make('DIGEST-MD5', $this->mockSaslDispatcher) + $subject->make('DIGEST-MD5', $this->mockAuthenticator) ); } - public function test_make_scram_sha256_with_sasl_dispatcher_returns_scram_builder(): void + public function test_make_scram_sha256_with_sasl_backend_returns_scram_builder(): void { + $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); + self::assertInstanceOf( ScramMechanismOptionsBuilder::class, - $this->subject->make('SCRAM-SHA-256', $this->mockSaslDispatcher) + $subject->make('SCRAM-SHA-256', $this->mockAuthenticator) ); } - public function test_make_cram_md5_with_non_sasl_dispatcher_throws(): void + public function test_make_cram_md5_with_non_sasl_backend_throws(): void { - self::expectException(RuntimeException::class); + $subject = new MechanismOptionsBuilderFactory($this->mockBackend); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::OTHER); - $this->subject->make('CRAM-MD5', $this->mockDispatcher); + $subject->make('CRAM-MD5', $this->mockAuthenticator); } public function test_make_unknown_mechanism_throws(): void { - self::expectException(RuntimeException::class); + $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::OTHER); - $this->subject->make('UNKNOWN', $this->mockSaslDispatcher); + $subject->make('UNKNOWN', $this->mockAuthenticator); } } diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilderTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilderTest.php index 130c46e1..cf53ef71 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilderTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/PlainMechanismOptionsBuilderTest.php @@ -14,21 +14,21 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\PlainMechanismOptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\PlainMechanism; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class PlainMechanismOptionsBuilderTest extends TestCase { - private RequestHandlerInterface&MockObject $mockDispatcher; + private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; private PlainMechanismOptionsBuilder $subject; protected function setUp(): void { - $this->mockDispatcher = $this->createMock(RequestHandlerInterface::class); - $this->subject = new PlainMechanismOptionsBuilder($this->mockDispatcher); + $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); + $this->subject = new PlainMechanismOptionsBuilder($this->mockAuthenticator); } public function test_it_supports_the_plain_mechanism(): void @@ -52,11 +52,11 @@ public function test_it_builds_options_with_a_validate_callable(): void self::assertIsCallable($options['validate']); } - public function test_the_validate_callable_delegates_to_dispatcher_bind(): void + public function test_the_validate_callable_delegates_to_backend_verify_password(): void { - $this->mockDispatcher + $this->mockAuthenticator ->expects(self::once()) - ->method('bind') + ->method('verifyPassword') ->with('cn=user,dc=foo,dc=bar', '12345') ->willReturn(true); @@ -68,10 +68,10 @@ public function test_the_validate_callable_delegates_to_dispatcher_bind(): void self::assertTrue($result); } - public function test_the_validate_callable_returns_false_when_bind_fails(): void + public function test_the_validate_callable_returns_false_when_verify_password_fails(): void { - $this->mockDispatcher - ->method('bind') + $this->mockAuthenticator + ->method('verifyPassword') ->willReturn(false); $options = $this->subject->buildOptions(null, PlainMechanism::NAME); diff --git a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php index 7c88a6be..3252ea71 100644 --- a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php +++ b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php @@ -23,7 +23,7 @@ use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Challenge\ChallengeInterface; use FreeDSx\Sasl\SaslContext; use PHPUnit\Framework\MockObject\MockObject; @@ -33,7 +33,7 @@ final class SaslExchangeTest extends TestCase { /** * Using 'PLAIN' as the mechanism name so that the real MechanismOptionsBuilderFactory - * can produce a PlainMechanismOptionsBuilder (which only requires RequestHandlerInterface, + * can produce a PlainMechanismOptionsBuilder (which only requires LdapBackendInterface, * not SaslHandlerInterface). The mock challenge ignores the option array, so the * specific mechanism does not affect the exchange-loop behavior under test. */ @@ -54,7 +54,7 @@ protected function setUp(): void queue: $this->mockQueue, responseFactory: new ResponseFactory(), optionsBuilderFactory: new MechanismOptionsBuilderFactory(), - dispatcher: $this->createMock(RequestHandlerInterface::class), + authenticator: $this->createMock(PasswordAuthenticatableInterface::class), ); } diff --git a/tests/unit/Protocol/Bind/SaslBindTest.php b/tests/unit/Protocol/Bind/SaslBindTest.php index 908b99bb..29bd2c67 100644 --- a/tests/unit/Protocol/Bind/SaslBindTest.php +++ b/tests/unit/Protocol/Bind/SaslBindTest.php @@ -19,10 +19,12 @@ use FreeDSx\Ldap\Operation\Request\SaslBindRequest; use FreeDSx\Ldap\Operation\Request\SimpleBindRequest; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; use FreeDSx\Ldap\Protocol\Bind\SaslBind; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\ServerOptions; @@ -32,7 +34,7 @@ /** * Combined interface so PHPUnit can mock both at once. */ -interface SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} +interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} final class SaslBindTest extends TestCase { @@ -40,16 +42,16 @@ final class SaslBindTest extends TestCase private ServerQueue&MockObject $mockQueue; - private RequestHandlerInterface&MockObject $mockDispatcher; + private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; protected function setUp(): void { $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockDispatcher = $this->createMock(RequestHandlerInterface::class); + $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); $this->subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $this->mockDispatcher, + authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_PLAIN], ); } @@ -108,8 +110,9 @@ public function test_it_throws_when_challenge_based_mechanism_is_used_without_sa { $subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $this->mockDispatcher, + authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_CRAM_MD5], + optionsBuilderFactory: new MechanismOptionsBuilderFactory($this->createMock(LdapBackendInterface::class)), ); $this->mockQueue @@ -130,9 +133,9 @@ public function test_it_can_authenticate_with_plain(): void // PLAIN credential format: "authzid\x00authcid\x00passwd" (all three parts must be non-empty) $credentials = "user\x00cn=user,dc=foo,dc=bar\x0012345"; - $this->mockDispatcher + $this->mockAuthenticator ->expects(self::once()) - ->method('bind') + ->method('verifyPassword') ->with('cn=user,dc=foo,dc=bar', '12345') ->willReturn(true); @@ -153,9 +156,9 @@ public function test_it_throws_invalid_credentials_when_plain_authentication_fai { $credentials = "user\x00cn=user,dc=foo,dc=bar\x00wrong"; - $this->mockDispatcher + $this->mockAuthenticator ->expects(self::once()) - ->method('bind') + ->method('verifyPassword') ->with('cn=user,dc=foo,dc=bar', 'wrong') ->willReturn(false); @@ -174,18 +177,19 @@ public function test_it_throws_invalid_credentials_when_plain_authentication_fai public function test_challenge_mechanism_sends_invalid_credentials_on_authentication_failure(): void { - /** @var SaslRequestHandlerInterface&MockObject $mockSaslDispatcher */ - $mockSaslDispatcher = $this->createMock(SaslRequestHandlerInterface::class); + /** @var SaslBackendInterface&MockObject $mockSaslBackend */ + $mockSaslBackend = $this->createMock(SaslBackendInterface::class); $subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $mockSaslDispatcher, + authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_CRAM_MD5], + optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockSaslBackend), ); // Return a real password so the HMAC callable runs; the client digest is wrong so // the challenge will set isAuthenticated=false and isComplete=true. - $mockSaslDispatcher + $mockSaslBackend ->method('getPassword') ->willReturn('correctpassword'); @@ -231,13 +235,14 @@ public function test_it_throws_runtime_exception_when_bind_is_called_with_a_non_ public function test_it_throws_protocol_error_when_non_sasl_request_received_during_exchange(): void { - /** @var SaslRequestHandlerInterface&MockObject $mockSaslDispatcher */ - $mockSaslDispatcher = $this->createMock(SaslRequestHandlerInterface::class); + /** @var SaslBackendInterface&MockObject $mockSaslBackend */ + $mockSaslBackend = $this->createMock(SaslBackendInterface::class); $subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $mockSaslDispatcher, + authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_CRAM_MD5], + optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockSaslBackend), ); // First sendMessage is the SASL_BIND_IN_PROGRESS challenge; second is the error response. diff --git a/tests/unit/Protocol/Bind/SimpleBindTest.php b/tests/unit/Protocol/Bind/SimpleBindTest.php index e31bbd79..65a2b8ac 100644 --- a/tests/unit/Protocol/Bind/SimpleBindTest.php +++ b/tests/unit/Protocol/Bind/SimpleBindTest.php @@ -23,7 +23,7 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Token\BindToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -32,26 +32,26 @@ final class SimpleBindTest extends TestCase { private SimpleBind $subject; - private RequestHandlerInterface&MockObject $mockDispatcher; + private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; private ServerQueue&MockObject $mockQueue; protected function setUp(): void { $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockDispatcher = $this->createMock(RequestHandlerInterface::class); + $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); $this->subject = new SimpleBind( $this->mockQueue, - $this->mockDispatcher, + $this->mockAuthenticator, ); } public function test_it_should_return_a_token_on_success(): void { - $this->mockDispatcher + $this->mockAuthenticator ->expects(self::once()) - ->method('bind') + ->method('verifyPassword') ->with('foo@bar', 'bar') ->willReturn(true); @@ -82,9 +82,9 @@ public function test_it_should_return_a_token_on_success(): void public function test_it_should_throw_an_operations_exception_with_invalid_credentials_if_they_are_wrong(): void { - $this->mockDispatcher + $this->mockAuthenticator ->expects(self::once()) - ->method('bind') + ->method('verifyPassword') ->with('foo@bar', 'bar') ->willReturn(false); diff --git a/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php b/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php index a9661356..04260794 100644 --- a/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php +++ b/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php @@ -22,17 +22,19 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingHandler; -use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingUnsupportedHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerRootDseHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSearchHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerStartTlsHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerUnbindHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerWhoAmIHandler; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Server\Backend\GenericBackend; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\HandlerFactoryInterface; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; use FreeDSx\Ldap\Server\RequestHistory; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -50,6 +52,18 @@ protected function setUp(): void $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockHandlerFactory = $this->createMock(HandlerFactoryInterface::class); + $this->mockHandlerFactory + ->method('makeBackend') + ->willReturn(new GenericBackend()); + + $this->mockHandlerFactory + ->method('makeFilterEvaluator') + ->willReturn(new FilterEvaluator()); + + $this->mockHandlerFactory + ->method('makeWriteDispatcher') + ->willReturn(new WriteOperationDispatcher()); + $this->subject = new ServerProtocolHandlerFactory( $this->mockHandlerFactory, new ServerOptions(), @@ -82,10 +96,6 @@ public function test_it_should_get_a_whoami_handler(): void public function test_it_should_get_a_search_handler(): void { - $this->mockHandlerFactory - ->expects(self::once())->method('makeRequestHandler') - ->willReturn(new GenericRequestHandler()); - self::assertInstanceof( ServerSearchHandler::class, $this->subject->get( @@ -95,17 +105,10 @@ public function test_it_should_get_a_search_handler(): void ); } - public function test_it_should_get_a_paging_handler_when_supported(): void + public function test_it_should_get_a_paging_handler_when_a_paging_control_is_present(): void { $controls = new ControlBag(new PagingControl(10)); - $mockPagingHandler = $this->createMock(PagingHandlerInterface::class); - - $this->mockHandlerFactory - ->expects(self::once()) - ->method('makePagingHandler') - ->willReturn($mockPagingHandler); - self::assertInstanceOf( ServerPagingHandler::class, $this->subject->get( @@ -115,29 +118,6 @@ public function test_it_should_get_a_paging_handler_when_supported(): void ); } - public function test_it_should_get_a_paging_unsupported_handler_when_no_paging_handler_exists(): void - { - $controls = new ControlBag(new PagingControl(10)); - - $this->mockHandlerFactory - ->expects(self::once()) - ->method('makePagingHandler') - ->willReturn(null); - - $this->mockHandlerFactory - ->expects(self::once()) - ->method('makeRequestHandler') - ->willReturn(new GenericRequestHandler()); - - self::assertInstanceOf( - ServerPagingUnsupportedHandler::class, - $this->subject->get( - Operations::list(new EqualityFilter('foo', 'bar'), 'cn=foo'), - $controls - ) - ); - } - public function test_it_should_get_a_root_dse_handler(): void { self::assertInstanceOf( @@ -162,10 +142,6 @@ public function test_it_should_get_an_unbind_handler(): void public function test_it_should_get_the_dispatch_handler_for_common_requests(): void { - $this->mockHandlerFactory - ->method('makeRequestHandler') - ->willReturn(new GenericRequestHandler()); - self::assertInstanceOf( ServerDispatchHandler::class, $this->subject->get(Operations::delete('cn=foo'), new ControlBag()) diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php index bde858a6..08f890a2 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -13,21 +13,22 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\AbandonRequest; use FreeDSx\Ldap\Operation\Request\AddRequest; use FreeDSx\Ldap\Operation\Request\CompareRequest; use FreeDSx\Ldap\Operation\Request\DeleteRequest; -use FreeDSx\Ldap\Operation\Request\ExtendedRequest; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -36,7 +37,11 @@ final class ServerDispatchHandlerTest extends TestCase { private ServerDispatchHandler $subject; - private RequestHandlerInterface&MockObject $mockRequestHandler; + private LdapBackendInterface&MockObject $mockBackend; + + private WriteHandlerInterface&MockObject $mockWriteHandler; + + private FilterEvaluatorInterface&MockObject $mockFilterEvaluator; private ServerQueue&MockObject $mockQueue; @@ -46,118 +51,127 @@ protected function setUp(): void { $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockRequestHandler = $this->createMock(RequestHandlerInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockWriteHandler = $this->createMock(WriteHandlerInterface::class); + $this->mockFilterEvaluator = $this->createMock(FilterEvaluatorInterface::class); $this->mockQueue ->method('sendMessage') ->willReturnSelf(); + $this->mockWriteHandler + ->method('supports') + ->willReturn(true); + $this->subject = new ServerDispatchHandler( - $this->mockQueue, - $this->mockRequestHandler, + queue: $this->mockQueue, + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, + writeDispatcher: new WriteOperationDispatcher($this->mockWriteHandler), ); } - public function test_it_should_send_an_add_request_to_the_request_handler(): void + public function test_it_dispatches_write_requests_through_the_write_handler(): void { $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); - $this->mockRequestHandler - ->expects($this->once()) - ->method('add') - ->with(self::anything(), $add->getRequest()); + $this->mockWriteHandler + ->expects(self::once()) + ->method('handle') + ->with(self::isInstanceOf(WriteRequestInterface::class)); - $this->subject->handleRequest( - $add, - $this->mockToken, - ); + $this->subject->handleRequest($add, $this->mockToken); } - public function test_it_should_send_a_delete_request_to_the_request_handler(): void + public function test_it_propagates_operation_exceptions_from_the_write_handler(): void { - $delete = new LdapMessageRequest(1, new DeleteRequest('cn=foo,dc=bar')); + $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); - $this->mockRequestHandler - ->expects($this->once()) - ->method('delete') - ->with(self::anything(), $delete->getRequest()); + $this->mockWriteHandler + ->method('handle') + ->willThrowException(new OperationException( + 'Entry already exists.', + ResultCode::ENTRY_ALREADY_EXISTS, + )); - $this->subject->handleRequest( - $delete, - $this->mockToken, - ); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $this->subject->handleRequest($add, $this->mockToken); } - public function test_it_should_send_a_modify_request_to_the_request_handler(): void + public function test_it_sends_a_compare_request_using_the_backend_and_filter_evaluator(): void { - $modify = new LdapMessageRequest(1, new ModifyRequest('cn=foo,dc=bar', Change::add('foo', 'bar'))); + $entry = Entry::create('cn=foo,dc=bar'); + $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); - $this->mockRequestHandler - ->expects($this->once()) - ->method('modify') - ->with(self::anything(), $modify->getRequest()); + $this->mockWriteHandler + ->expects(self::never()) + ->method('handle'); - $this->subject->handleRequest( - $modify, - $this->mockToken, - ); + $this->mockBackend + ->expects(self::once()) + ->method('get') + ->willReturn($entry); + + $this->mockFilterEvaluator + ->expects(self::once()) + ->method('evaluate') + ->willReturn(true); + + $this->subject->handleRequest($compare, $this->mockToken); } - public function test_it_should_send_a_modify_dn_request_to_the_request_handler(): void + public function test_it_throws_no_such_object_when_comparing_a_non_existent_entry(): void { - $modifyDn = new LdapMessageRequest(1, new ModifyDnRequest('cn=foo,dc=bar', 'cn=bar', true)); + $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); - $this->mockRequestHandler - ->expects($this->once()) - ->method('modifyDn') - ->with(self::anything(), $modifyDn->getRequest()); + $this->mockBackend + ->method('get') + ->willReturn(null); - $this->subject->handleRequest( - $modifyDn, - $this->mockToken, - ); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->handleRequest($compare, $this->mockToken); } - public function test_it_should_send_an_extended_request_to_the_request_handler(): void + public function test_it_throws_unwilling_to_perform_when_no_write_handler_supports_the_operation(): void { - $ext = new LdapMessageRequest(1, new ExtendedRequest('foo', 'bar')); + $subject = new ServerDispatchHandler( + queue: $this->mockQueue, + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, + writeDispatcher: new WriteOperationDispatcher(), + ); + + $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); - $this->mockRequestHandler - ->expects($this->once()) - ->method('extended') - ->with(self::anything(), $ext->getRequest()); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); - $this->subject->handleRequest( - $ext, - $this->mockToken, - ); + $subject->handleRequest($add, $this->mockToken); } - public function test_it_should_send_a_compare_request_to_the_request_handler(): void + public function test_it_throws_an_operation_exception_for_unsupported_requests(): void { - $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); + $request = new LdapMessageRequest(2, new AbandonRequest(1)); - $this->mockRequestHandler - ->expects($this->once()) - ->method('compare') - ->with(self::anything(), $compare->getRequest()) - ->willReturn(true); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OPERATION); - $this->subject->handleRequest( - $compare, - $this->mockToken, - ); + $this->subject->handleRequest($request, $this->mockToken); } - public function test_it_should_throw_an_operation_exception_if_the_request_is_unsupported(): void + public function test_it_sends_delete_through_the_write_handler(): void { - $request = new LdapMessageRequest(2, new AbandonRequest(1)); + $delete = new LdapMessageRequest(1, new DeleteRequest('cn=foo,dc=bar')); - self::expectException(OperationException::class); + $this->mockWriteHandler + ->expects(self::once()) + ->method('handle') + ->with(self::isInstanceOf(WriteRequestInterface::class)); - $this->subject->handleRequest( - $request, - $this->mockToken - ); + $this->subject->handleRequest($delete, $this->mockToken); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index 94e81225..cab0d02e 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -16,7 +16,6 @@ use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Control\PagingControl; -use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\SearchRequest; @@ -28,11 +27,13 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingHandler; use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\Paging\PagingResponse; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; use FreeDSx\Ldap\Server\RequestHistory; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; +use Generator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -42,7 +43,9 @@ class ServerPagingHandlerTest extends TestCase private ServerQueue&MockObject $mockQueue; - private PagingHandlerInterface&MockObject $mockPagingHandler; + private LdapBackendInterface&MockObject $mockBackend; + + private FilterEvaluatorInterface&MockObject $mockFilterEvaluator; private ServerPagingHandler $subject; @@ -52,43 +55,51 @@ protected function setUp(): void { $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockPagingHandler = $this->createMock(PagingHandlerInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockFilterEvaluator = $this->createMock(FilterEvaluatorInterface::class); $this->requestHistory = new RequestHistory(); + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturn(true); + $this->subject = new ServerPagingHandler( - $this->mockQueue, - $this->mockPagingHandler, - $this->requestHistory, + queue: $this->mockQueue, + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, + requestHistory: $this->requestHistory, ); } - public function test_it_should_send_a_request_to_the_paging_handler_on_paging_start(): void + private function makeGenerator(Entry ...$entries): Generator { - $message = $this->makeSearchMessage(); + yield from $entries; + } - $entries = new Entries( - Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), - Entry::create('dc=bar,dc=foo', ['cn' => 'bar']) - ); + public function test_it_should_call_the_backend_search_on_paging_start_and_return_entries(): void + { + $message = $this->makeSearchMessage(size: 10); + + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->with(self::isInstanceOf(SearchContext::class)) + ->willReturn($this->makeGenerator($entry1, $entry2)); $resultEntry1 = new LdapMessageResponse( 2, - new SearchResultEntry(Entry::create('dc=foo,dc=bar', ['cn' => 'foo'])) + new SearchResultEntry($entry1) ); $resultEntry2 = new LdapMessageResponse( 2, - new SearchResultEntry(Entry::create('dc=bar,dc=foo', ['cn' => 'bar'])) + new SearchResultEntry($entry2) ); - $response = PagingResponse::make($entries); - - $this->mockPagingHandler - ->expects($this->once()) - ->method('page') - ->willReturn($response); - $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with( $resultEntry1, @@ -97,7 +108,8 @@ public function test_it_should_send_a_request_to_the_paging_handler_on_paging_st /** @var PagingControl $paging */ $paging = $response->controls()->get(Control::OID_PAGING); - return $paging && $paging->getCookie() !== ''; + // Generator was exhausted with only 2 entries, so paging is complete (cookie='') + return $paging && $paging->getCookie() === ''; }) ); @@ -107,46 +119,30 @@ public function test_it_should_send_a_request_to_the_paging_handler_on_paging_st ); } - public function test_it_should_send_the_correct_response_if_paging_is_complete(): void + public function test_it_should_store_the_generator_and_return_a_cookie_when_more_entries_remain(): void { - $message = $this->makeSearchMessage(); - - $entries = new Entries( - Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), - Entry::create('dc=bar,dc=foo', ['cn' => 'bar']) - ); - - $resultEntry1 = new LdapMessageResponse( - 2, - new SearchResultEntry(Entry::create('dc=foo,dc=bar', ['cn' => 'foo'])) - ); - $resultEntry2 = new LdapMessageResponse( - 2, - new SearchResultEntry(Entry::create('dc=bar,dc=foo', ['cn' => 'bar'])) - ); + // Request only 1 entry, but backend yields 2, so generator is NOT exhausted. + $message = $this->makeSearchMessage(size: 1); - $response = PagingResponse::makeFinal($entries); + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); - $this->mockPagingHandler - ->expects($this->once()) - ->method('page') - ->willReturn($response); - - $this->mockPagingHandler - ->expects($this->once()) - ->method('remove'); + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2)); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with( - $resultEntry1, - $resultEntry2, + new LdapMessageResponse(2, new SearchResultEntry($entry1)), self::callback(function (LdapMessageResponse $response) { /** @var PagingControl $paging */ $paging = $response->controls()->get(Control::OID_PAGING); - return $paging && $paging->getCookie() === ''; + // Non-empty cookie means more entries remain. + return $paging && $paging->getCookie() !== ''; }) ); @@ -156,25 +152,62 @@ public function test_it_should_send_the_correct_response_if_paging_is_complete() ); } + public function test_it_should_continue_from_the_stored_generator_on_subsequent_pages(): void + { + // First page: size=1 with 2 entries in the backend. + $firstMessage = $this->makeSearchMessage(size: 1); + + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $capturedCookie = ''; + + $this->mockQueue + ->method('sendMessage') + ->willReturnCallback(function (LdapMessageResponse ...$responses) use (&$capturedCookie) { + foreach ($responses as $response) { + $paging = $response->controls()->get(Control::OID_PAGING); + if ($paging instanceof PagingControl && $paging->getCookie() !== '') { + $capturedCookie = $paging->getCookie(); + } + } + + return $this->mockQueue; + }); + + $this->subject->handleRequest($firstMessage, $this->mockToken); + + // Second page: use the captured cookie. + $pagingReq = $this->requestHistory->pagingRequest()->findByNextCookie($capturedCookie); + $secondMessage = $this->makeSearchMessage( + size: 10, + cookie: $capturedCookie, + searchRequest: $pagingReq->getSearchRequest(), + ); + + $this->subject->handleRequest($secondMessage, $this->mockToken); + } + public function test_it_should_send_the_correct_response_if_paging_is_abandoned(): void { $pagingReq = $this->makeExistingPagingRequest(); $message = $this->makeSearchMessage( - 0, - 'foo', - $pagingReq->getSearchRequest() + size: 0, + cookie: $pagingReq->getNextCookie(), + searchRequest: $pagingReq->getSearchRequest(), ); - $this->mockPagingHandler - ->expects($this->never()) - ->method('page'); - - $this->mockPagingHandler - ->expects($this->once()) - ->method('remove'); + $this->mockBackend + ->expects(self::never()) + ->method('search'); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with(self::callback(function (LdapMessageResponse $response) { /** @var PagingControl $paging */ @@ -191,22 +224,19 @@ public function test_it_should_send_the_correct_response_if_paging_is_abandoned( public function test_it_sends_a_result_code_error_in_SearchResultDone_if_the_old_and_new_paging_requests_are_different(): void { - $this->makeExistingPagingRequest(); + $pagingReq = $this->makeExistingPagingRequest(); $message = $this->makeSearchMessage( - 0, - 'foo', - $this->makeSearchRequest('(oh=no)') + size: 10, + cookie: $pagingReq->getNextCookie(), + searchRequest: $this->makeSearchRequest('(oh=no)'), ); - $this->mockPagingHandler - ->expects($this->never()) - ->method('page'); - $this->mockPagingHandler - ->expects($this->once()) - ->method('remove'); + $this->mockBackend + ->expects(self::never()) + ->method('search'); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with(self::equalTo( new LdapMessageResponse( @@ -232,9 +262,9 @@ public function test_it_sends_a_result_code_error_in_SearchResultDone_if_the_old public function test_it_throws_an_exception_if_the_paging_cookie_does_not_exist(): void { $message = $this->makeSearchMessage( - 0, - 'foo', - $this->makeSearchRequest('(oh=no)') + size: 10, + cookie: 'nonexistent-cookie', + searchRequest: $this->makeSearchRequest('(oh=no)'), ); self::expectExceptionObject(new OperationException("The supplied cookie is invalid.")); @@ -262,6 +292,7 @@ private function makeExistingPagingRequest( $pagingReq->markProcessed(); $this->requestHistory->pagingRequest()->add($pagingReq); + $this->requestHistory->storePagingGenerator($nextCookie, $this->makeGenerator()); return $pagingReq; } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandlerTest.php deleted file mode 100644 index 1f263316..00000000 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingUnsupportedHandlerTest.php +++ /dev/null @@ -1,168 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; - -use FreeDSx\Ldap\Control\PagingControl; -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\Response\SearchResultDone; -use FreeDSx\Ldap\Operation\Response\SearchResultEntry; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingUnsupportedHandler; -use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; -use FreeDSx\Ldap\Server\Token\TokenInterface; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -final class ServerPagingUnsupportedHandlerTest extends TestCase -{ - private ServerPagingUnsupportedHandler $subject; - - private ServerQueue&MockObject $mockQueue; - - private RequestHandlerInterface&MockObject $mockRequestHandler; - - private TokenInterface&MockObject $mockToken; - - protected function setUp(): void - { - $this->mockToken = $this->createMock(TokenInterface::class); - $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockRequestHandler = $this->createMock(RequestHandlerInterface::class); - - $this->subject = new ServerPagingUnsupportedHandler( - $this->mockQueue, - $this->mockRequestHandler, - ); - } - - public function test_it_should_send_a_search_request_to_the_request_handler_if_paging_is_not_critical(): void - { - $search = new LdapMessageRequest( - 2, - (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar'), - (new PagingControl(10, ''))->setCriticality(false) - ); - - $entries = new Entries(Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), Entry::create('dc=bar,dc=foo', ['cn' => 'bar'])); - $resultEntry1 = new LdapMessageResponse( - 2, - new SearchResultEntry(Entry::create('dc=foo,dc=bar', ['cn' => 'foo'])) - ); - $resultEntry2 = new LdapMessageResponse( - 2, - new SearchResultEntry(Entry::create('dc=bar,dc=foo', ['cn' => 'bar'])) - ); - - $this->mockRequestHandler - ->expects($this->once()) - ->method('search') - ->with(self::anything(), $search->getRequest()) - ->willReturn(SearchResult::make($entries)); - - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - $resultEntry1, - $resultEntry2, - new LdapMessageResponse( - 2, - new SearchResultDone( - 0, - 'dc=foo,dc=bar' - ) - ), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, - ); - } - - public function test_it_should_throw_an_unavailable_critical_extension_if_paging_is_marked_critical(): void - { - $search = new LdapMessageRequest( - 2, - (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar'), - (new PagingControl(10, ''))->setCriticality(true) - ); - - $this->mockRequestHandler - ->expects($this->never()) - ->method('search') - ->with(self::anything(), $search->getRequest()); - - self::expectExceptionObject( - new OperationException( - 'The server does not support the paging control.', - ResultCode::UNAVAILABLE_CRITICAL_EXTENSION, - ), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, - ); - } - - public function test_it_should_send_a_SearchResultDone_with_an_operation_exception_thrown_from_the_handler(): void - { - $search = new LdapMessageRequest( - 2, - (new SearchRequest(Filters::equal( - 'foo', - 'bar' - )))->base('dc=foo,dc=bar'), - new PagingControl( - 10, - '' - ) - ); - - $this->mockRequestHandler - ->method('search') - ->willThrowException( - new OperationException( - 'Fail', - ResultCode::OPERATIONS_ERROR - ) - ); - - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with(self::equalTo(new LdapMessageResponse( - 2, - new SearchResultDone( - ResultCode::OPERATIONS_ERROR, - 'dc=foo,dc=bar', - "Fail" - ) - ))); - - $this->subject->handleRequest( - $search, - $this->mockToken, - ); - } -} diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index 7715fc35..1914bd13 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -25,8 +25,8 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerRootDseHandler; use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\ServerOptions; @@ -43,14 +43,14 @@ final class ServerRootDseHandlerTest extends TestCase private ServerOptions $options; - private PagingHandlerInterface&MockObject $mockPagingHandler; + private LdapBackendInterface&MockObject $mockBackend; private RootDseHandlerInterface&MockObject $mockDseHandler; protected function setUp(): void { $this->options = new ServerOptions(); - $this->mockPagingHandler = $this->createMock(PagingHandlerInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockDseHandler = $this->createMock(RootDseHandlerInterface::class); @@ -94,12 +94,12 @@ public function test_it_should_send_back_a_RootDSE(): void ); } - public function test_it_should_send_back_a_RootDSE_with_paging_support_if_the_paging_handler_is_set(): void + public function test_it_should_send_back_a_RootDSE_with_paging_support_if_a_backend_is_set(): void { $this->options ->setDseVendorName('Foo') ->setDseNamingContexts('dc=Foo,dc=Bar') - ->setPagingHandler($this->mockPagingHandler); + ->setBackend($this->mockBackend); $search = new LdapMessageRequest( 1, @@ -127,6 +127,40 @@ public function test_it_should_send_back_a_RootDSE_with_paging_support_if_the_pa ); } + public function test_it_should_not_advertise_paging_support_if_no_backend_is_set(): void + { + $this->options + ->setDseVendorName('Foo') + ->setDseNamingContexts('dc=Foo,dc=Bar'); + + $search = new LdapMessageRequest( + 1, + (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope() + ); + + $this->mockQueue + ->expects($this->once()) + ->method('sendMessage') + ->with( + self::callback(function (LdapMessageResponse $response) { + /** @var SearchResultEntry $search */ + $search = $response->getResponse(); + $entry = $search->getEntry(); + + $supportedControl = $entry->get('supportedControl'); + + return $supportedControl === null + || !$supportedControl->has(Control::OID_PAGING); + }), + new LdapMessageResponse(1, new SearchResultDone(0)), + ); + + $this->subject->handleRequest( + $search, + $this->mockToken, + ); + } + public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements_a_rootdse_aware_interface(): void { $this->options diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index baca0029..db159e15 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -13,8 +13,6 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Ldap\Control\Control; -use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\SearchRequest; @@ -26,9 +24,11 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSearchHandler; use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; +use Generator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -38,7 +38,9 @@ final class ServerSearchHandlerTest extends TestCase private ServerQueue&MockObject $mockQueue; - private RequestHandlerInterface&MockObject $mockRequestHandler; + private LdapBackendInterface&MockObject $mockBackend; + + private FilterEvaluatorInterface&MockObject $mockFilterEvaluator; private TokenInterface&MockObject $mockToken; @@ -46,37 +48,83 @@ protected function setUp(): void { $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); - $this->mockRequestHandler = $this->createMock(RequestHandlerInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockFilterEvaluator = $this->createMock(FilterEvaluatorInterface::class); $this->subject = new ServerSearchHandler( - $this->mockQueue, - $this->mockRequestHandler, + queue: $this->mockQueue, + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, ); } - public function test_it_should_send_a_search_request_to_the_request_handler(): void + private function makeGenerator(Entry ...$entries): Generator + { + yield from $entries; + } + + public function test_it_should_send_entries_from_the_backend_to_the_client(): void { + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); + $search = new LdapMessageRequest( 2, (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar') ); - $entries = new Entries(Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), Entry::create('dc=bar,dc=foo', ['cn' => 'bar'])); - $resultEntry1 = new LdapMessageResponse(2, new SearchResultEntry(Entry::create('dc=foo,dc=bar', ['cn' => 'foo']))); - $resultEntry2 = new LdapMessageResponse(2, new SearchResultEntry(Entry::create('dc=bar,dc=foo', ['cn' => 'bar']))); + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->with(self::isInstanceOf(SearchContext::class)) + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturn(true); + + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with( + new LdapMessageResponse(2, new SearchResultEntry($entry1)), + new LdapMessageResponse(2, new SearchResultEntry($entry2)), + new LdapMessageResponse( + 2, + new SearchResultDone(0, 'dc=foo,dc=bar') + ), + ); + + $this->subject->handleRequest( + $search, + $this->mockToken, + ); + } + + public function test_it_should_filter_entries_that_do_not_match_the_filter(): void + { + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); - $this->mockRequestHandler - ->expects($this->once()) + $search = new LdapMessageRequest( + 2, + (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar') + ); + + $this->mockBackend + ->expects(self::once()) ->method('search') - ->with(self::anything(), $search->getRequest()) - ->willReturn(SearchResult::make($entries)); + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturnOnConsecutiveCalls(true, false); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with( - $resultEntry1, - $resultEntry2, + new LdapMessageResponse(2, new SearchResultEntry($entry1)), new LdapMessageResponse( 2, new SearchResultDone(0, 'dc=foo,dc=bar') @@ -89,7 +137,7 @@ public function test_it_should_send_a_search_request_to_the_request_handler(): v ); } - public function test_it_should_send_a_SearchResultDone_with_an_operation_exception_thrown_from_the_handler(): void + public function test_it_should_send_a_SearchResultDone_with_an_operation_exception_thrown_from_the_backend(): void { $search = new LdapMessageRequest( 2, @@ -99,7 +147,7 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti )))->base('dc=foo,dc=bar') ); - $this->mockRequestHandler + $this->mockBackend ->method('search') ->willThrowException( new OperationException( @@ -109,7 +157,7 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti ); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with(self::equalTo(new LdapMessageResponse( 2, @@ -126,69 +174,25 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti ); } - public function test_it_should_use_the_result_code_from_a_non_success_handler_result(): void + public function test_it_should_send_a_successful_SearchResultDone_when_no_entries_match(): void { $search = new LdapMessageRequest( 2, (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar') ); - $entries = new Entries(Entry::create('dc=foo,dc=bar', ['cn' => 'foo'])); - $resultEntry = new LdapMessageResponse(2, new SearchResultEntry(Entry::create('dc=foo,dc=bar', ['cn' => 'foo']))); - - $this->mockRequestHandler - ->expects($this->once()) + $this->mockBackend + ->expects(self::once()) ->method('search') - ->willReturn(SearchResult::makeWithResultCode( - $entries, - ResultCode::SIZE_LIMIT_EXCEEDED, - 'Result set truncated.', - )); + ->willReturn($this->makeGenerator()); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with( - $resultEntry, new LdapMessageResponse( 2, - new SearchResultDone( - ResultCode::SIZE_LIMIT_EXCEEDED, - 'dc=foo,dc=bar', - 'Result set truncated.', - ), - ), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, - ); - } - - public function test_it_should_pass_response_controls_from_the_handler_result_to_the_client(): void - { - $search = new LdapMessageRequest( - 2, - (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar') - ); - - $entries = new Entries(); - $control = new Control('1.2.3.4'); - - $this->mockRequestHandler - ->expects($this->once()) - ->method('search') - ->willReturn(SearchResult::make($entries, $control)); - - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - new LdapMessageResponse( - 2, - new SearchResultDone(0, 'dc=foo,dc=bar'), - $control, + new SearchResultDone(0, 'dc=foo,dc=bar') ), ); diff --git a/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php b/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php new file mode 100644 index 00000000..13f76b05 --- /dev/null +++ b/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php @@ -0,0 +1,63 @@ + + * + * 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\Auth\NameResolver; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\DnBindNameResolver; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class DnBindNameResolverTest extends TestCase +{ + private LdapBackendInterface&MockObject $mockBackend; + + protected function setUp(): void + { + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + } + + public function test_resolve_calls_backend_get_with_dn(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + + $this->mockBackend + ->expects(self::once()) + ->method('get') + ->with(new Dn('cn=Alice,dc=example,dc=com')) + ->willReturn($entry); + + $subject = new DnBindNameResolver($this->mockBackend); + $result = $subject->resolve('cn=Alice,dc=example,dc=com'); + + self::assertSame($entry, $result); + } + + public function test_resolve_returns_null_when_entry_not_found(): void + { + $this->mockBackend + ->method('get') + ->willReturn(null); + + $subject = new DnBindNameResolver($this->mockBackend); + $result = $subject->resolve('cn=Unknown,dc=example,dc=com'); + + self::assertNull($result); + } +} diff --git a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php new file mode 100644 index 00000000..e801e469 --- /dev/null +++ b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php @@ -0,0 +1,110 @@ + + * + * 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\Auth\NameResolver; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class PasswordAuthenticatorTest extends TestCase +{ + private BindNameResolverInterface&MockObject $mockResolver; + + protected function setUp(): void + { + $this->mockResolver = $this->createMock(BindNameResolverInterface::class); + } + + private function subject(?Entry $resolvedEntry = null): PasswordAuthenticator + { + $this->mockResolver + ->method('resolve') + ->willReturn($resolvedEntry); + + return new PasswordAuthenticator($this->mockResolver); + } + + public function test_returns_false_when_entry_not_found(): void + { + self::assertFalse( + $this->subject(null)->verifyPassword('cn=Unknown,dc=example,dc=com', 'secret') + ); + } + + public function test_returns_false_when_entry_has_no_user_password(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Alice,dc=example,dc=com', 'secret') + ); + } + + public function test_returns_true_for_correct_plain_text_password(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('userPassword', 'secret'), + ); + + self::assertTrue( + $this->subject($entry)->verifyPassword('cn=Alice,dc=example,dc=com', 'secret') + ); + } + + public function test_returns_false_for_wrong_plain_text_password(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('userPassword', 'secret'), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Alice,dc=example,dc=com', 'wrong') + ); + } + + public function test_returns_true_for_correct_sha_password(): void + { + $hashed = '{SHA}' . base64_encode(sha1('mypassword', true)); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertTrue( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'mypassword') + ); + } + + public function test_returns_false_for_wrong_sha_password(): void + { + $hashed = '{SHA}' . base64_encode(sha1('mypassword', true)); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php new file mode 100644 index 00000000..50d65a40 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php @@ -0,0 +1,234 @@ + + * + * 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\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use PHPUnit\Framework\TestCase; + +final class InMemoryStorageAdapterTest extends TestCase +{ + private InMemoryStorageAdapter $subject; + + private Entry $alice; + + private Entry $bob; + + private Entry $base; + + protected function setUp(): void + { + $this->base = new Entry( + new Dn('dc=example,dc=com'), + new Attribute('dc', 'example'), + new Attribute('objectClass', 'dcObject'), + ); + $this->alice = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('userPassword', 'secret'), + ); + $this->bob = new Entry( + new Dn('cn=Bob,ou=People,dc=example,dc=com'), + new Attribute('cn', 'Bob'), + ); + + $this->subject = new InMemoryStorageAdapter( + $this->base, + $this->alice, + $this->bob, + ); + } + + 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_search_base_scope_returns_only_base(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_BASE_OBJECT, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + self::assertCount(1, $entries); + self::assertSame('dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + } + + public function test_search_single_level_returns_direct_children(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_SINGLE_LEVEL, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + // Only alice is a direct child of dc=example,dc=com; bob is under ou=People + self::assertCount(1, $entries); + self::assertSame('cn=Alice,dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + } + + public function test_search_subtree_returns_base_and_all_descendants(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_WHOLE_SUBTREE, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + self::assertCount(3, $entries); + } + + public function test_add_stores_entry(): void + { + $adapter = new InMemoryStorageAdapter(); + $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); + $adapter->add(new AddCommand($entry)); + + self::assertNotNull($adapter->get(new Dn('cn=New,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_delete_throws_not_allowed_on_non_leaf_when_entry_has_subordinates(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); + + // dc=example,dc=com has cn=Alice as a direct child — cannot be deleted + $this->subject->delete(new DeleteCommand(new Dn('dc=example,dc=com'))); + } + + public function test_update_add_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'alice@example.com')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNotNull($entry); + self::assertTrue($entry->get('mail')?->has('alice@example.com')); + } + + public function test_update_replace_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertSame(['Alicia'], $entry?->get('cn')?->getValues()); + } + + public function test_update_delete_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'userPassword')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNull($entry?->get('userPassword')); + } + + public function test_move_renames_entry(): void + { + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), + true, + null, + )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); + } + + public function test_update_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->update(new UpdateCommand( + new Dn('cn=Nobody,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Nobody')], + )); + } + + public function test_move_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->move(new MoveCommand( + new Dn('cn=Nobody,dc=example,dc=com'), + Rdn::create('cn=Ghost'), + true, + null, + )); + } + + public function test_move_to_new_parent(): void + { + $ou = new Entry(new Dn('ou=People,dc=example,dc=com'), new Attribute('ou', 'People')); + $this->subject->add(new AddCommand($ou)); + + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alice'), + false, + new Dn('ou=People,dc=example,dc=com'), + )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php new file mode 100644 index 00000000..bcad385a --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php @@ -0,0 +1,285 @@ + + * + * 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\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use PHPUnit\Framework\TestCase; + +final class JsonFileStorageAdapterTest extends TestCase +{ + private JsonFileStorageAdapter $subject; + + private string $tempFile; + + private Entry $base; + + private Entry $alice; + + private Entry $bob; + + protected function setUp(): void + { + $this->tempFile = sys_get_temp_dir() . '/ldap_test_' . uniqid() . '.json'; + + $this->base = new Entry( + new Dn('dc=example,dc=com'), + new Attribute('dc', 'example'), + new Attribute('objectClass', 'dcObject'), + ); + $this->alice = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('userPassword', 'secret'), + ); + $this->bob = new Entry( + new Dn('cn=Bob,ou=People,dc=example,dc=com'), + new Attribute('cn', 'Bob'), + ); + + $this->subject = new JsonFileStorageAdapter($this->tempFile); + $this->subject->add(new AddCommand($this->base)); + $this->subject->add(new AddCommand($this->alice)); + $this->subject->add(new AddCommand($this->bob)); + } + + protected function tearDown(): void + { + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + 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_nonexistent_file_returns_null(): void + { + $adapter = new JsonFileStorageAdapter($this->tempFile . '.nonexistent'); + self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_search_base_scope_returns_only_base(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_BASE_OBJECT, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + self::assertCount(1, $entries); + self::assertSame('dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + } + + public function test_search_single_level_returns_direct_children(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_SINGLE_LEVEL, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + // Only alice is a direct child of dc=example,dc=com; bob is under ou=People + self::assertCount(1, $entries); + self::assertSame('cn=Alice,dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + } + + public function test_search_subtree_returns_base_and_all_descendants(): void + { + $entries = iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: SearchRequest::SCOPE_WHOLE_SUBTREE, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + self::assertCount(3, $entries); + } + + public function test_add_stores_entry(): void + { + $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); + $this->subject->add(new AddCommand($entry)); + + self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); + } + + public function test_add_persists_to_file(): void + { + $entry = new Entry(new Dn('cn=Persistent,dc=example,dc=com'), new Attribute('cn', 'Persistent')); + $this->subject->add(new AddCommand($entry)); + + // A second independent adapter instance reading the same file should see the new entry + $adapter2 = new JsonFileStorageAdapter($this->tempFile); + self::assertNotNull($adapter2->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_delete_persists_to_file(): void + { + $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); + + $adapter2 = new JsonFileStorageAdapter($this->tempFile); + self::assertNull($adapter2->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_delete_throws_not_allowed_on_non_leaf_when_entry_has_subordinates(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); + + // dc=example,dc=com has cn=Alice as a direct child — cannot be deleted + $this->subject->delete(new DeleteCommand(new Dn('dc=example,dc=com'))); + } + + public function test_update_add_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'alice@example.com')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNotNull($entry); + self::assertTrue($entry->get('mail')?->has('alice@example.com')); + } + + public function test_update_replace_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertSame(['Alicia'], $entry?->get('cn')?->getValues()); + } + + public function test_update_delete_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'userPassword')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNull($entry?->get('userPassword')); + } + + public function test_update_delete_specific_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'a@b.com', 'c@d.com')], + )); + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'mail', 'a@b.com')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + $mail = $entry?->get('mail'); + self::assertNotNull($mail); + self::assertFalse($mail->has('a@b.com')); + self::assertTrue($mail->has('c@d.com')); + } + + public function test_move_renames_entry(): void + { + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), + true, + null, + )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); + } + + public function test_update_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->update(new UpdateCommand( + new Dn('cn=Nobody,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Nobody')], + )); + } + + public function test_move_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->move(new MoveCommand( + new Dn('cn=Nobody,dc=example,dc=com'), + Rdn::create('cn=Ghost'), + true, + null, + )); + } + + public function test_move_to_new_parent(): void + { + $ou = new Entry(new Dn('ou=People,dc=example,dc=com'), new Attribute('ou', 'People')); + $this->subject->add(new AddCommand($ou)); + + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alice'), + false, + new Dn('ou=People,dc=example,dc=com'), + )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php new file mode 100644 index 00000000..3ad6950b --- /dev/null +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -0,0 +1,464 @@ + + * + * 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; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\MatchingRuleFilter; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Search\Filter\SubstringFilter; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use PHPUnit\Framework\TestCase; + +final class FilterEvaluatorTest extends TestCase +{ + private FilterEvaluator $subject; + + private Entry $entry; + + protected function setUp(): void + { + $this->subject = new FilterEvaluator(); + + $this->entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('sn', 'Smith'), + new Attribute('mail', 'alice@example.com'), + new Attribute('objectClass', 'inetOrgPerson', 'person', 'top'), + new Attribute('uid', 'asmith'), + new Attribute('uidNumber', '1001'), + ); + } + + public function test_present_returns_true_when_attribute_exists(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, new PresentFilter('cn')) + ); + } + + public function test_present_returns_false_when_attribute_missing(): void + { + self::assertFalse( + $this->subject->evaluate($this->entry, new PresentFilter('telephoneNumber')) + ); + } + + public function test_present_is_case_insensitive_for_attribute_name(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, new PresentFilter('CN')) + ); + } + + public function test_equality_matches_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::equal('cn', 'Alice')) + ); + } + + public function test_equality_is_case_insensitive(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::equal('cn', 'ALICE')) + ); + } + + public function test_equality_returns_false_when_no_match(): void + { + self::assertFalse( + $this->subject->evaluate($this->entry, Filters::equal('cn', 'Bob')) + ); + } + + public function test_equality_returns_false_when_attribute_missing(): void + { + self::assertFalse( + $this->subject->evaluate($this->entry, Filters::equal('telephoneNumber', '555')) + ); + } + + public function test_equality_matches_any_value_in_multivalued_attribute(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::equal('objectClass', 'person')) + ); + } + + public function test_substring_startswith(): void + { + $filter = (new SubstringFilter('cn'))->setStartsWith('Ali'); + self::assertTrue($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_endswith(): void + { + $filter = (new SubstringFilter('cn'))->setEndsWith('ice'); + self::assertTrue($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_contains(): void + { + $filter = (new SubstringFilter('mail'))->setContains('example'); + self::assertTrue($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_all_parts_must_match_same_value(): void + { + // startsWith from cn, endsWith from sn — can't match same value + $filter = (new SubstringFilter('cn')) + ->setStartsWith('Ali') + ->setEndsWith('Smith'); + self::assertFalse($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_is_case_insensitive(): void + { + $filter = (new SubstringFilter('cn'))->setStartsWith('ALI'); + self::assertTrue($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_returns_false_when_no_match(): void + { + $filter = (new SubstringFilter('cn'))->setStartsWith('Bob'); + self::assertFalse($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_ordered_contains(): void + { + $filter = (new SubstringFilter('mail')) + ->setContains('alice', 'example'); + self::assertTrue($this->subject->evaluate($this->entry, $filter)); + } + + public function test_substring_ordered_contains_rejects_wrong_order(): void + { + $filter = (new SubstringFilter('mail')) + ->setContains('example', 'alice'); + self::assertFalse($this->subject->evaluate($this->entry, $filter)); + } + + public function test_gte_matches_equal_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::greaterThanOrEqual('uidNumber', '1001')) + ); + } + + public function test_gte_matches_greater_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::greaterThanOrEqual('uidNumber', '1000')) + ); + } + + public function test_gte_returns_false_when_less(): void + { + self::assertFalse( + $this->subject->evaluate($this->entry, Filters::greaterThanOrEqual('uidNumber', '2000')) + ); + } + + public function test_lte_matches_equal_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::lessThanOrEqual('uidNumber', '1001')) + ); + } + + public function test_lte_matches_lesser_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, Filters::lessThanOrEqual('uidNumber', '9999')) + ); + } + + public function test_lte_returns_false_when_greater(): void + { + self::assertFalse( + $this->subject->evaluate($this->entry, Filters::lessThanOrEqual('uidNumber', '500')) + ); + } + + public function test_gte_compares_numbers_as_integers(): void + { + // '10' >= '5' must be true numerically; lexicographically '10' < '5' + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('count', '10'), + ); + + self::assertTrue( + $this->subject->evaluate($entry, Filters::greaterThanOrEqual('count', '5')) + ); + } + + public function test_gte_does_not_treat_scientific_notation_as_numeric(): void + { + // '1e1' is not ctype_digit, so it falls back to lexicographic comparison. + // Lexicographically '1e1' < '5', so gte('5') should be false. + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('count', '1e1'), + ); + + self::assertFalse( + $this->subject->evaluate( + $entry, + Filters::greaterThanOrEqual('count', '5') + ), + ); + } + + public function test_gte_preserves_leading_zeros_as_strings(): void + { + // '007' has a leading zero, so ctype_digit still returns true but the value + // integer-compares as 7, which is less than 10 — gte('10') must be false. + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('employeeId', '007'), + ); + + self::assertFalse( + $this->subject->evaluate( + $entry, + Filters::greaterThanOrEqual('employeeId', '10') + ) + ); + } + + public function test_approximate_matches_equal_value(): void + { + self::assertTrue( + $this->subject->evaluate($this->entry, new ApproximateFilter('cn', 'Alice')) + ); + } + + public function test_approximate_is_case_insensitive(): void + { + self::assertTrue( + $this->subject->evaluate( + $this->entry, + new ApproximateFilter('cn', 'ALICE') + ) + ); + } + + public function test_and_returns_true_when_all_match(): void + { + $filter = Filters::and( + Filters::equal('cn', 'Alice'), + Filters::equal('sn', 'Smith'), + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter, + )); + } + + public function test_and_returns_false_when_one_does_not_match(): void + { + $filter = Filters::and( + Filters::equal('cn', 'Alice'), + Filters::equal('sn', 'Jones'), + ); + + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_or_returns_true_when_one_matches(): void + { + $filter = Filters::or( + Filters::equal('cn', 'Bob'), + Filters::equal('cn', 'Alice'), + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_or_returns_false_when_none_match(): void + { + $filter = Filters::or( + Filters::equal('cn', 'Bob'), + Filters::equal('cn', 'Charlie'), + ); + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_not_negates_match(): void + { + $filter = Filters::not(Filters::equal('cn', 'Alice')); + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_not_negates_non_match(): void + { + $filter = Filters::not(Filters::equal('cn', 'Bob')); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_nested_and_or(): void + { + $filter = Filters::and( + Filters::equal('objectClass', 'inetOrgPerson'), + Filters::or( + Filters::equal('cn', 'Bob'), + Filters::equal('sn', 'Smith'), + ), + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_default_case_ignore(): void + { + $filter = new MatchingRuleFilter( + null, + 'cn', + 'ALICE', + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_case_exact_match(): void + { + $filter = new MatchingRuleFilter( + '2.5.13.5', + 'cn', + 'Alice', + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_case_exact_no_match(): void + { + $filter = new MatchingRuleFilter( + '2.5.13.5', + 'cn', + 'ALICE', + ); + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_bit_and(): void + { + // uidNumber = 1001 = 0b1111101001 + // filter value = 8 = 0b0000001000 (bit 3 set) + // 1001 & 8 = 8 => true + $filter = new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'uidNumber', + '8', + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_bit_and_no_match(): void + { + // 1001 & 2 = 0 => false + $filter = new MatchingRuleFilter( + '1.2.840.113556.1.4.803', + 'uidNumber', + '2', + ); + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_unknown_returns_false(): void + { + $filter = new MatchingRuleFilter( + '1.2.3.4.5.unknown', + 'cn', + 'Alice', + ); + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_dn_attributes(): void + { + $filter = new MatchingRuleFilter( + null, + 'cn', + 'alice', + true, + ); + // The DN is cn=Alice,dc=example,dc=com — 'alice' matches the RDN value + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_matching_rule_null_attribute_matches_all(): void + { + // No attribute name set — should match against all attribute values + $filter = new MatchingRuleFilter( + null, + null, + 'Smith', + ); + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter + )); + } + + public function test_unknown_filter_type_returns_false(): void + { + self::assertFalse($this->subject->evaluate( + $this->entry, + $this->createMock(FilterInterface::class) + )); + } +} diff --git a/tests/unit/Server/Backend/Write/WriteCommandFactoryTest.php b/tests/unit/Server/Backend/Write/WriteCommandFactoryTest.php new file mode 100644 index 00000000..fde61725 --- /dev/null +++ b/tests/unit/Server/Backend/Write/WriteCommandFactoryTest.php @@ -0,0 +1,90 @@ + + * + * 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\Write; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\AbandonRequest; +use FreeDSx\Ldap\Operation\Request\DeleteRequest; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; +use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteCommandFactory; +use PHPUnit\Framework\TestCase; + +final class WriteCommandFactoryTest extends TestCase +{ + private WriteCommandFactory $subject; + + protected function setUp(): void + { + $this->subject = new WriteCommandFactory(); + } + + public function test_it_creates_add_command_from_add_request(): void + { + $entry = Entry::create('cn=foo,dc=bar'); + $command = $this->subject->fromRequest(new AddRequest($entry)); + + self::assertInstanceOf(AddCommand::class, $command); + self::assertSame($entry, $command->entry); + } + + public function test_it_creates_delete_command_from_delete_request(): void + { + $command = $this->subject->fromRequest(new DeleteRequest('cn=foo,dc=bar')); + + self::assertInstanceOf(DeleteCommand::class, $command); + self::assertSame('cn=foo,dc=bar', $command->dn->toString()); + } + + public function test_it_creates_update_command_from_modify_request(): void + { + $changes = [Change::add('mail', 'foo@bar.com')]; + $command = $this->subject->fromRequest(new ModifyRequest('cn=foo,dc=bar', ...$changes)); + + self::assertInstanceOf(UpdateCommand::class, $command); + self::assertSame('cn=foo,dc=bar', $command->dn->toString()); + self::assertSame($changes, $command->changes); + } + + public function test_it_creates_move_command_from_modify_dn_request(): void + { + $command = $this->subject->fromRequest( + new ModifyDnRequest('cn=foo,dc=bar', 'cn=bar', true, 'ou=people,dc=bar') + ); + + self::assertInstanceOf(MoveCommand::class, $command); + self::assertSame('cn=foo,dc=bar', $command->dn->toString()); + self::assertSame('cn=bar', $command->newRdn->toString()); + self::assertTrue($command->deleteOldRdn); + self::assertSame('ou=people,dc=bar', $command->newParent?->toString()); + } + + public function test_it_throws_for_unsupported_request(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OPERATION); + + $this->subject->fromRequest(new AbandonRequest(1)); + } +} diff --git a/tests/unit/Server/Backend/Write/WriteOperationDispatcherTest.php b/tests/unit/Server/Backend/Write/WriteOperationDispatcherTest.php new file mode 100644 index 00000000..b8a8da3a --- /dev/null +++ b/tests/unit/Server/Backend/Write/WriteOperationDispatcherTest.php @@ -0,0 +1,89 @@ + + * + * 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\Write; + +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class WriteOperationDispatcherTest extends TestCase +{ + private WriteRequestInterface&MockObject $mockRequest; + + protected function setUp(): void + { + $this->mockRequest = $this->createMock(WriteRequestInterface::class); + } + + private function handler(bool $supports): WriteHandlerInterface&MockObject + { + $handler = $this->createMock(WriteHandlerInterface::class); + $handler->method('supports')->willReturn($supports); + + return $handler; + } + + public function test_throws_unwilling_to_perform_when_no_handlers_registered(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); + + (new WriteOperationDispatcher())->dispatch($this->mockRequest); + } + + public function test_throws_unwilling_to_perform_when_no_handler_supports_request(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); + + (new WriteOperationDispatcher( + $this->handler(false), + $this->handler(false), + ))->dispatch($this->mockRequest); + } + + public function test_dispatches_to_first_supporting_handler(): void + { + $handler = $this->handler(true); + $handler->expects(self::once())->method('handle')->with($this->mockRequest); + + (new WriteOperationDispatcher($handler))->dispatch($this->mockRequest); + } + + public function test_stops_after_first_matching_handler(): void + { + $first = $this->handler(true); + $first->expects(self::once())->method('handle'); + + $second = $this->handler(true); + $second->expects(self::never())->method('handle'); + + (new WriteOperationDispatcher($first, $second))->dispatch($this->mockRequest); + } + + public function test_skips_non_supporting_handlers_before_matching(): void + { + $skip = $this->handler(false); + $skip->expects(self::never())->method('handle'); + + $match = $this->handler(true); + $match->expects(self::once())->method('handle')->with($this->mockRequest); + + (new WriteOperationDispatcher($skip, $match))->dispatch($this->mockRequest); + } +} diff --git a/tests/unit/Server/RequestHandler/GenericRequestHandlerTest.php b/tests/unit/Server/RequestHandler/GenericRequestHandlerTest.php deleted file mode 100644 index 8b7b8714..00000000 --- a/tests/unit/Server/RequestHandler/GenericRequestHandlerTest.php +++ /dev/null @@ -1,81 +0,0 @@ - - * - * 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\RequestHandler; - -use FreeDSx\Ldap\Entry\Change; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\RequestInterface; -use FreeDSx\Ldap\Operations; -use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -final class GenericRequestHandlerTest extends TestCase -{ - private GenericRequestHandler $subject; - - private RequestContext&MockObject $mockContext; - - protected function setUp(): void - { - $this->mockContext = $this->createMock(RequestContext::class); - - $this->subject = new GenericRequestHandler(); - } - - - /** - * @dataProvider unsupportedRequestDataProvider - */ - public function test_it_should_throw_an_operations_exception_on_unsupported_requests( - callable $method, - RequestInterface $request - ): void { - self::expectException(OperationException::class); - - $method( - $this->mockContext, - $request, - ); - } - - public function test_it_should_return_false_on_a_bind_request(): void - { - self::assertFalse( - $this->subject->bind( - 'foo', - 'bar', - ), - ); - } - - /** - * @return array - */ - public static function unsupportedRequestDataProvider(): array - { - return [ - [(new GenericRequestHandler())->add(...), Operations::add(Entry::fromArray(''))], - [(new GenericRequestHandler())->delete(...), Operations::delete('foo')], - [(new GenericRequestHandler())->modify(...), Operations::modify('foo', Change::reset('foo'))], - [(new GenericRequestHandler())->modifyDn(...), Operations::rename('foo', 'cn=bar')], - [(new GenericRequestHandler())->search(...), Operations::search(Filters::equal('foo', 'bar'))], - [(new GenericRequestHandler())->compare(...), Operations::compare('foo', 'bar', 'baz')], - [(new GenericRequestHandler())->extended(...), Operations::extended('foo')], - ]; - } -} diff --git a/tests/unit/Server/RequestHandler/HandlerFactoryTest.php b/tests/unit/Server/RequestHandler/HandlerFactoryTest.php index 85f39b6a..6e11d200 100644 --- a/tests/unit/Server/RequestHandler/HandlerFactoryTest.php +++ b/tests/unit/Server/RequestHandler/HandlerFactoryTest.php @@ -14,32 +14,69 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\RequestHandler; use FreeDSx\Ldap\LdapClient; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; +use FreeDSx\Ldap\Server\Backend\GenericBackend; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestHandler\HandlerFactory; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; +use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\ServerOptions; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class HandlerFactoryTest extends TestCase { private HandlerFactory $subject; - public function test_it_should_allow_a_request_handler_as_an_object(): void + public function test_it_should_return_a_generic_backend_when_none_is_configured(): void { - $handler = new GenericRequestHandler(); - $this->subject = new HandlerFactory((new ServerOptions())->setRequestHandler($handler)); + $this->subject = new HandlerFactory(new ServerOptions()); + + self::assertInstanceOf( + GenericBackend::class, + $this->subject->makeBackend() + ); + } + + public function test_it_should_allow_a_backend_as_an_object(): void + { + $backend = $this->createMock(LdapBackendInterface::class); + $this->subject = new HandlerFactory((new ServerOptions())->setBackend($backend)); + + self::assertSame( + $backend, + $this->subject->makeBackend() + ); + } + + public function test_it_should_return_a_default_filter_evaluator_when_none_is_configured(): void + { + $this->subject = new HandlerFactory(new ServerOptions()); + + self::assertInstanceOf( + FilterEvaluator::class, + $this->subject->makeFilterEvaluator() + ); + } + + public function test_it_should_allow_a_filter_evaluator_as_an_object(): void + { + $evaluator = $this->createMock(FilterEvaluatorInterface::class); + $this->subject = new HandlerFactory((new ServerOptions())->setFilterEvaluator($evaluator)); self::assertSame( - $handler, - $this->subject->makeRequestHandler() + $evaluator, + $this->subject->makeFilterEvaluator() ); } public function test_it_should_allow_a_rootdse_handler_as_an_object(): void { $rootDseHandler = new ProxyHandler(new LdapClient()); - $this->subject = new HandlerFactory((new ServerOptions())->setRootDseHandler($rootDseHandler));; + $this->subject = new HandlerFactory((new ServerOptions())->setRootDseHandler($rootDseHandler)); self::assertSame( $rootDseHandler, @@ -56,25 +93,87 @@ public function test_it_should_allow_a_null_rootdse_handler(): void self::assertNull($this->subject->makeRootDseHandler()); } - public function test_it_should_allow_a_paging_handler_as_an_object(): void + public function test_it_should_return_the_backend_as_rootdse_handler_if_it_implements_the_interface(): void { - $pagingHandler = $this->createMock(PagingHandlerInterface::class); - $this->subject = new HandlerFactory( - (new ServerOptions())->setPagingHandler($pagingHandler) - ); + /** @var LdapBackendInterface&RootDseHandlerInterface&MockObject $backend */ + $backend = $this->createMockForIntersectionOfInterfaces([ + LdapBackendInterface::class, + RootDseHandlerInterface::class, + ]); + + $this->subject = new HandlerFactory((new ServerOptions())->setBackend($backend)); self::assertSame( - $pagingHandler, - $this->subject->makePagingHandler() + $backend, + $this->subject->makeRootDseHandler() ); } - public function test_it_should_allow_a_null_paging_handler(): void + public function test_it_returns_explicit_root_dse_handler_over_backend(): void { + /** @var LdapBackendInterface&RootDseHandlerInterface&MockObject $backend */ + $backend = $this->createMockForIntersectionOfInterfaces([ + LdapBackendInterface::class, + RootDseHandlerInterface::class, + ]); + $explicit = $this->createMock(RootDseHandlerInterface::class); + $this->subject = new HandlerFactory( - (new ServerOptions())->setPagingHandler(null) + (new ServerOptions()) + ->setBackend($backend) + ->setRootDseHandler($explicit) + ); + + self::assertSame($explicit, $this->subject->makeRootDseHandler()); + } + + public function test_it_returns_default_password_authenticator_when_none_is_configured(): void + { + $subject = new HandlerFactory(new ServerOptions()); + + self::assertInstanceOf(PasswordAuthenticator::class, $subject->makePasswordAuthenticator()); + } + + public function test_it_prefers_explicitly_configured_password_authenticator(): void + { + $authenticator = $this->createMock(PasswordAuthenticatableInterface::class); + + $subject = new HandlerFactory( + (new ServerOptions())->setPasswordAuthenticator($authenticator) + ); + + self::assertSame($authenticator, $subject->makePasswordAuthenticator()); + } + + public function test_it_returns_backend_as_password_authenticator_if_it_implements_the_interface(): void + { + /** @var LdapBackendInterface&PasswordAuthenticatableInterface&MockObject $backend */ + $backend = $this->createMockForIntersectionOfInterfaces([ + LdapBackendInterface::class, + PasswordAuthenticatableInterface::class, + ]); + + $subject = new HandlerFactory((new ServerOptions())->setBackend($backend)); + + self::assertSame($backend, $subject->makePasswordAuthenticator()); + } + + public function test_explicit_password_authenticator_takes_precedence_over_backend(): void + { + $authenticator = $this->createMock(PasswordAuthenticatableInterface::class); + + /** @var LdapBackendInterface&PasswordAuthenticatableInterface&MockObject $backend */ + $backend = $this->createMockForIntersectionOfInterfaces([ + LdapBackendInterface::class, + PasswordAuthenticatableInterface::class, + ]); + + $subject = new HandlerFactory( + (new ServerOptions()) + ->setBackend($backend) + ->setPasswordAuthenticator($authenticator) ); - self::assertNull($this->subject->makePagingHandler()); + self::assertSame($authenticator, $subject->makePasswordAuthenticator()); } } diff --git a/tests/unit/Server/RequestHandler/ProxyBackendTest.php b/tests/unit/Server/RequestHandler/ProxyBackendTest.php new file mode 100644 index 00000000..bc17f09a --- /dev/null +++ b/tests/unit/Server/RequestHandler/ProxyBackendTest.php @@ -0,0 +1,199 @@ + + * + * 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\RequestHandler; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\LdapClient; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\DeleteRequest; +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\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Entry\Attribute; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use FreeDSx\Ldap\Server\RequestHandler\ProxyBackend; + +final class ProxyBackendTest extends TestCase +{ + private LdapClient&MockObject $mockLdap; + + private ProxyBackend $subject; + + protected function setUp(): void + { + $this->mockLdap = $this->createMock(LdapClient::class); + $this->subject = new ProxyBackend(); + $this->subject->setLdapClient($this->mockLdap); + } + + private function makeContext(int $scope = SearchRequest::SCOPE_WHOLE_SUBTREE): SearchContext + { + return new SearchContext( + baseDn: new Dn('dc=example,dc=com'), + scope: $scope, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ); + } + + public function test_search_yields_entries_from_upstream(): void + { + $entry = new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')); + + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->willReturn(new Entries($entry)); + + $results = iterator_to_array($this->subject->search($this->makeContext())); + + self::assertCount(1, $results); + self::assertSame($entry, $results[0]); + } + + public function test_search_uses_base_scope(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->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))); + } + + public function test_search_uses_single_level_scope(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->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))); + } + + public function test_search_uses_subtree_scope_by_default(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('search') + ->with(self::callback(fn(SearchRequest $r) => $r->getScope() === SearchRequest::SCOPE_WHOLE_SUBTREE)) + ->willReturn(new Entries()); + + iterator_to_array($this->subject->search($this->makeContext())); + } + + public function test_get_reads_entry_by_dn(): void + { + $entry = new Entry(new Dn('cn=Alice,dc=example,dc=com')); + + $this->mockLdap + ->expects(self::once()) + ->method('read') + ->with('cn=Alice,dc=example,dc=com') + ->willReturn($entry); + + self::assertSame($entry, $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_verify_password_returns_true_on_successful_bind(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('bind') + ->with('cn=Alice,dc=example,dc=com', 'secret') + ->willReturn($this->createMock(LdapMessageResponse::class)); + + self::assertTrue($this->subject->verifyPassword('cn=Alice,dc=example,dc=com', 'secret')); + } + + public function test_verify_password_rethrows_bind_exception_as_operation_exception(): void + { + $this->mockLdap + ->method('bind') + ->willThrowException(new BindException('Invalid credentials.', ResultCode::INVALID_CREDENTIALS)); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::INVALID_CREDENTIALS); + + $this->subject->verifyPassword('cn=Alice,dc=example,dc=com', 'wrong'); + } + + public function test_add_sends_add_request_to_upstream(): void + { + $entry = Entry::create('cn=Alice,dc=example,dc=com'); + + $this->mockLdap + ->expects(self::once()) + ->method('sendAndReceive') + ->with(self::isInstanceOf(AddRequest::class)); + + $this->subject->add(new AddCommand($entry)); + } + + public function test_delete_sends_delete_request_to_upstream(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('sendAndReceive') + ->with(self::isInstanceOf(DeleteRequest::class)); + + $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_update_sends_modify_request_to_upstream(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('sendAndReceive') + ->with(self::isInstanceOf(ModifyRequest::class)); + + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [Change::replace('cn', 'Alicia')], + )); + } + + public function test_move_sends_modify_dn_request_to_upstream(): void + { + $this->mockLdap + ->expects(self::once()) + ->method('sendAndReceive') + ->with(self::isInstanceOf(ModifyDnRequest::class)); + + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), + true, + null, + )); + } +} diff --git a/tests/unit/Server/RequestHandler/ProxyPagingHandlerTest.php b/tests/unit/Server/RequestHandler/ProxyPagingHandlerTest.php deleted file mode 100644 index 3e385392..00000000 --- a/tests/unit/Server/RequestHandler/ProxyPagingHandlerTest.php +++ /dev/null @@ -1,129 +0,0 @@ - - * - * 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\RequestHandler; - -use FreeDSx\Ldap\Control\ControlBag; -use FreeDSx\Ldap\Control\PagingControl; -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\LdapClient; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Search\Paging; -use FreeDSx\Ldap\Server\Paging\PagingRequest; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\ProxyPagingHandler; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -final class ProxyPagingHandlerTest extends TestCase -{ - private ProxyPagingHandler $subject; - - private LdapClient&MockObject $mockClient; - - private RequestContext&MockObject $mockContext; - - protected function setUp(): void - { - $this->mockContext = $this->createMock(RequestContext::class); - $this->mockClient = $this->createMock(LdapClient::class); - - $this->mockContext - ->method('controls') - ->willReturn(new ControlBag()); - - $this->subject = new ProxyPagingHandler($this->mockClient); - } - - public function test_it_should_handle_a_paging_request_when_paging_is_still_going(): void - { - $paging = $this->createMock(Paging::class); - - $request = new SearchRequest(Filters::equal('cn', 'foo')); - $entries = new Entries(new Entry('cn=foo,dc=foo,dc=bar')); - - $this->mockClient - ->expects($this->once()) - ->method('paging') - ->willReturn($paging); - - $paging->method('isCritical') - ->willReturn($paging); - $paging->method('getEntries') - ->with(25) - ->willReturn($entries); - $paging->method('hasEntries') - ->willReturn(true); - $paging->method('sizeEstimate') - ->willReturn(25); - - $pagingRequest = new PagingRequest( - new PagingControl(25, ''), - $request, - new ControlBag(), - 'foo' - ); - - self::assertFalse( - $this->subject->page($pagingRequest, $this->mockContext)->isComplete(), - ); - self::assertEquals( - $entries, - $this->subject->page($pagingRequest, $this->mockContext)->getEntries(), - ); - self::assertSame( - 25, - $this->subject->page($pagingRequest, $this->mockContext)->getRemaining(), - ); - } - - public function test_it_should_handle_a_paging_request_when_paging_is_complete(): void - { - $paging = $this->createMock(Paging::class); - - $request = new SearchRequest(Filters::equal('cn', 'foo')); - $entries = new Entries(new Entry('cn=foo,dc=foo,dc=bar')); - - $this->mockClient - ->expects($this->once()) - ->method('paging') - ->willReturn($paging); - - $paging->method('isCritical') - ->willReturn($paging); - $paging->method('getEntries') - ->with(25) - ->willReturn($entries); - $paging->method('hasEntries') - ->willReturn(false); - - $pagingRequest = new PagingRequest( - new PagingControl(25, ''), - $request, - new ControlBag(), - 'foo' - ); - - self::assertTrue($this->subject->page($pagingRequest, $this->mockContext)->isComplete()); - self::assertEquals( - $entries, - $this->subject->page($pagingRequest, $this->mockContext)->getEntries(), - ); - self::assertSame( - 0, - $this->subject->page($pagingRequest, $this->mockContext)->getRemaining(), - ); - } -} diff --git a/tests/unit/Server/RequestHandler/ProxyRequestHandlerTest.php b/tests/unit/Server/RequestHandler/ProxyRequestHandlerTest.php deleted file mode 100644 index 551fce2a..00000000 --- a/tests/unit/Server/RequestHandler/ProxyRequestHandlerTest.php +++ /dev/null @@ -1,271 +0,0 @@ - - * - * 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\RequestHandler; - -use FreeDSx\Ldap\ClientOptions; -use FreeDSx\Ldap\Control\ControlBag; -use FreeDSx\Ldap\Entry\Change; -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Exception\BindException; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\LdapClient; -use FreeDSx\Ldap\Operation\LdapResult; -use FreeDSx\Ldap\Operation\Response\BindResponse; -use FreeDSx\Ldap\Operation\Response\CompareResponse; -use FreeDSx\Ldap\Operation\Response\SearchResponse; -use FreeDSx\Ldap\Operation\Response\SearchResultEntry; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Operations; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Search\Result\EntryResult; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; -use FreeDSx\Ldap\Server\Token\BindToken; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -final class ProxyRequestHandlerTest extends TestCase -{ - private ProxyRequestHandler $subject; - - private LdapClient&MockObject $mockClient; - - private RequestContext&MockObject $mockContext; - - protected function setUp(): void - { - $this->mockClient = $this->createMock(LdapClient::class); - $this->mockContext = $this->createMock(RequestContext::class); - - $this->mockContext - ->method('controls') - ->willReturn(new ControlBag()); - - $this->mockContext - ->method('token') - ->willReturn(new BindToken('foo', 'bar')); - - $this->subject = new ProxyRequestHandler(new ClientOptions()); - $this->subject->setLdapClient($this->mockClient);; - } - - public function test_it_should_send_an_add_request(): void - { - $add = Operations::add(Entry::create('cn=foo,dc=freedsx,dc=local')); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($add); - - $this->subject->add( - $this->mockContext, - $add, - ); - } - - public function test_it_should_send_a_delete_request(): void - { - $delete = Operations::delete('cn=foo,dc=freedsx,dc=local'); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($delete); - - $this->subject->delete( - $this->mockContext, - $delete - ); - } - - public function test_it_should_send_a_modify_request(): void - { - $modify = Operations::modify('cn=foo,dc=freedsx,dc=local', Change::add('foo', 'bar')); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($modify); - - $this->subject->modify( - $this->mockContext, - $modify, - ); - } - - public function test_it_should_send_a_modify_dn_request(): void - { - $modifyDn = Operations::rename('cn=foo,dc=freedsx,dc=local', 'cn=bar'); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($modifyDn); - - $this->subject->modifyDn( - $this->mockContext, - $modifyDn, - ); - } - - public function test_it_should_send_a_search_request(): void - { - $search = Operations::search( - Filters::present('objectClass'), - 'cn' - )->base('dc=foo'); - $entry = Entry::create('dc=foo'); - $entries = new Entries($entry); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($search) - ->willReturn(new LdapMessageResponse( - 1, - new SearchResponse( - new LdapResult(ResultCode::SUCCESS), - [new EntryResult(new LdapMessageResponse(1, new SearchResultEntry($entry)))], - ) - )); - - self::assertEquals( - SearchResult::make($entries), - $this->subject->search($this->mockContext, $search), - ); - } - - public function test_it_should_pass_through_a_non_success_result_code_from_the_proxied_search(): void - { - $search = Operations::search( - Filters::present('objectClass'), - 'cn' - )->base('dc=foo'); - $entry = Entry::create('dc=foo'); - $entries = new Entries($entry); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($search) - ->willReturn(new LdapMessageResponse( - 1, - new SearchResponse( - new LdapResult(ResultCode::SIZE_LIMIT_EXCEEDED, '', 'Result set truncated.'), - [new EntryResult(new LdapMessageResponse(1, new SearchResultEntry($entry)))], - ) - )); - - self::assertEquals( - SearchResult::makeWithResultCode( - $entries, - ResultCode::SIZE_LIMIT_EXCEEDED, - 'Result set truncated.', - ), - $this->subject->search($this->mockContext, $search), - ); - } - - public function test_it_should_send_a_compare_request_and_return_false_on_no_match(): void - { - $compare = Operations::compare('foo', 'foo', 'bar'); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($compare) - ->willReturn(new LdapMessageResponse( - 1, - new CompareResponse(ResultCode::COMPARE_FALSE) - )); - - self::assertFalse( - $this->subject->compare( - $this->mockContext, - $compare - ) - ); - } - - public function test_it_should_send_a_compare_request_and_return_true_on_match(): void - { - $compare = Operations::compare('foo', 'foo', 'bar'); - - $this->mockClient - ->expects($this->once()) - ->method('sendAndReceive') - ->with($compare) - ->willReturn(new LdapMessageResponse( - 1, - new CompareResponse(ResultCode::COMPARE_TRUE) - )); - - self::assertTrue( - $this->subject->compare( - $this->mockContext, - $compare, - ) - ); - } - - public function test_it_should_send_an_extended_request(): void - { - $extended = Operations::extended('foo', 'bar'); - - $this->mockClient - ->expects($this->once()) - ->method('send') - ->with($extended); - - $this->subject->extended( - $this->mockContext, - $extended, - ); - } - - public function test_it_should_handle_a_bind_request(): void - { - $this->mockClient - ->expects($this->once()) - ->method('bind') - ->willReturn(new LdapMessageResponse( - 1, - new BindResponse(new LdapResult(0)), - )); - - self::assertTrue($this->subject->bind( - 'foo', - 'bar' - )); - } - - public function test_it_should_handle_a_bind_request_failure(): void - { - self::expectException(OperationException::class); - - $this->mockClient - ->expects($this->once()) - ->method('bind') - ->willThrowException(new BindException('Foo!', 49)); - - $this->subject->bind( - 'foo', - 'bar', - ); - } -} diff --git a/tests/unit/Server/ServerProtocolFactoryTest.php b/tests/unit/Server/ServerProtocolFactoryTest.php index 47e6d547..e4e9b451 100644 --- a/tests/unit/Server/ServerProtocolFactoryTest.php +++ b/tests/unit/Server/ServerProtocolFactoryTest.php @@ -15,9 +15,10 @@ use FreeDSx\Ldap\Protocol\ServerAuthorization; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Server\Backend\GenericBackend; use FreeDSx\Ldap\Server\HandlerFactoryInterface; -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; use PHPUnit\Framework\MockObject\MockObject; @@ -36,6 +37,14 @@ protected function setUp(): void $this->mockHandlerFactory = $this->createMock(HandlerFactoryInterface::class); $this->mockServerAuthorization = $this->createMock(ServerAuthorization::class); + $this->mockHandlerFactory + ->method('makeBackend') + ->willReturn(new GenericBackend()); + + $this->mockHandlerFactory + ->method('makeFilterEvaluator') + ->willReturn(new FilterEvaluator()); + $this->subject = new ServerProtocolFactory( $this->mockHandlerFactory, new ServerOptions(), @@ -49,20 +58,17 @@ public function test_it_should_make_a_ServerProtocolInstance(): void $this->mockHandlerFactory ->expects($this->once()) - ->method('makeRequestHandler') - ->willReturn(new GenericRequestHandler()); + ->method('makeBackend') + ->willReturn(new GenericBackend()); $this->subject->make($mockSocket); } public function test_it_includes_sasl_when_mechanisms_are_configured(): void { - $mockSocket = $this->createMock(Socket::class); + $this->expectNotToPerformAssertions(); - $this->mockHandlerFactory - ->expects($this->once()) - ->method('makeRequestHandler') - ->willReturn(new GenericRequestHandler()); + $mockSocket = $this->createMock(Socket::class); $subject = new ServerProtocolFactory( $this->mockHandlerFactory, diff --git a/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php new file mode 100644 index 00000000..ca51bd1b --- /dev/null +++ b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.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 Tests\Unit\FreeDSx\Ldap\Server\ServerRunner; + +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; +use PHPUnit\Framework\TestCase; + +final class SwooleServerRunnerTest extends TestCase +{ + public function test_constructor_throws_when_swoole_not_loaded(): void + { + if (extension_loaded('swoole')) { + $this->markTestSkipped('This test requires the swoole extension to NOT be loaded.'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Swoole extension/'); + + new SwooleServerRunner( + $this->createMock(ServerProtocolFactory::class), + ); + } +} From 961cd332da48ab71fbc595e3c3e2b5611848d67a Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 16:26:07 -0400 Subject: [PATCH 02/35] Add a subschema handler and make it configurable. Only returns a stub for now. No proper schema handling. --- .../Factory/ServerProtocolHandlerFactory.php | 16 +++ .../ServerRootDseHandler.php | 1 + .../ServerSubschemaHandler.php | 62 +++++++++++ src/FreeDSx/Ldap/ServerOptions.php | 15 +++ .../ServerProtocolHandlerFactoryTest.php | 14 ++- .../ServerRootDseHandlerTest.php | 54 +++++++++ .../ServerSubschemaHandlerTest.php | 103 ++++++++++++++++++ 7 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php create mode 100644 tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php index 66e8b915..8c5d420a 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php @@ -54,6 +54,11 @@ public function get( ); } elseif ($this->isRootDseSearch($request)) { return $this->getRootDseHandler(); + } elseif ($this->isSubschemaSearch($request)) { + return new ServerProtocolHandler\ServerSubschemaHandler( + options: $this->options, + queue: $this->queue, + ); } elseif ($this->isPagingSearch($request, $controls)) { return $this->getPagingHandler(); } elseif ($request instanceof SearchRequest) { @@ -74,6 +79,17 @@ public function get( } } + private function isSubschemaSearch(RequestInterface $request): bool + { + if (!$request instanceof SearchRequest) { + return false; + } + $subschemaEntry = $this->options->getSubschemaEntry()->toString(); + + return $request->getScope() === SearchRequest::SCOPE_BASE_OBJECT + && strtolower((string) $request->getBaseDn()) === strtolower($subschemaEntry); + } + private function isRootDseSearch(RequestInterface $request): bool { if (!$request instanceof SearchRequest) { diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index 6fcaede7..c465e4d0 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -54,6 +54,7 @@ public function handleRequest( ): void { $entry = Entry::fromArray('', [ 'namingContexts' => $this->options->getDseNamingContexts(), + 'subschemaSubentry' => [$this->options->getSubschemaEntry()->toString()], 'supportedExtension' => [ ExtendedRequest::OID_WHOAMI, ], diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php new file mode 100644 index 00000000..40df2c1d --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\Response\SearchResultDone; +use FreeDSx\Ldap\Operation\Response\SearchResultEntry; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\Token\TokenInterface; +use FreeDSx\Ldap\ServerOptions; + +/** + * Returns a minimal stub subschema entry, satisfying clients that follow up on the subschemaSubentry DN advertised in the RootDSE. + * + * @author Chad Sikorra + */ +class ServerSubschemaHandler implements ServerProtocolHandlerInterface +{ + public function __construct( + private readonly ServerOptions $options, + private readonly ServerQueue $queue, + ) { + } + + public function handleRequest( + LdapMessageRequest $message, + TokenInterface $token, + ): void { + $schemaDn = $this->options->getSubschemaEntry(); + $rdn = $schemaDn->getRdn(); + + $entry = Entry::fromArray($schemaDn->toString(), [ + 'objectClass' => ['top', 'subschema'], + $rdn->getName() => [$rdn->getValue()], + ]); + + $this->queue->sendMessage( + new LdapMessageResponse( + $message->getMessageId(), + new SearchResultEntry($entry), + ), + new LdapMessageResponse( + $message->getMessageId(), + new SearchResultDone(ResultCode::SUCCESS), + ), + ); + } +} diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 0ef4af42..063611ee 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -13,6 +13,7 @@ namespace FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\InvalidArgumentException; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; @@ -96,6 +97,8 @@ final class ServerOptions private ?string $dseAltServer = null; + private ?Dn $subschemaEntry = null; + /** * @var string[] */ @@ -273,6 +276,18 @@ public function setDseAltServer(?string $dseAlServer): self return $this; } + public function getSubschemaEntry(): Dn + { + return $this->subschemaEntry ?? new Dn('cn=Subschema'); + } + + public function setSubschemaEntry(Dn $subschemaEntry): self + { + $this->subschemaEntry = $subschemaEntry; + + return $this; + } + /** * @return string[] */ diff --git a/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php b/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php index 04260794..223bc2b3 100644 --- a/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php +++ b/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php @@ -25,16 +25,15 @@ use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerRootDseHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSearchHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerStartTlsHandler; +use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSubschemaHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerUnbindHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerWhoAmIHandler; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\GenericBackend; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\HandlerFactoryInterface; use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -129,6 +128,17 @@ public function test_it_should_get_a_root_dse_handler(): void ); } + public function test_it_should_get_a_subschema_handler(): void + { + self::assertInstanceOf( + ServerSubschemaHandler::class, + $this->subject->get( + Operations::read('cn=Subschema'), + new ControlBag(), + ), + ); + } + public function test_it_should_get_an_unbind_handler(): void { self::assertInstanceOf( diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index 1914bd13..cc768067 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Request\SearchRequest; @@ -79,6 +80,7 @@ public function test_it_should_send_back_a_RootDSE(): void ->with( self::equalTo(new LdapMessageResponse(1, new SearchResultEntry(Entry::create('', [ 'namingContexts' => 'dc=Foo,dc=Bar', + 'subschemaSubentry' => ['cn=Subschema'], 'supportedExtension' => [ ExtendedRequest::OID_WHOAMI, ], @@ -180,6 +182,7 @@ public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements ); $rootDse = Entry::create('', [ 'namingContexts' => 'dc=Foo,dc=Bar', + 'subschemaSubentry' => ['cn=Subschema'], 'supportedExtension' => [ ExtendedRequest::OID_WHOAMI, ], @@ -268,6 +271,7 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r ->with( new LdapMessageResponse(1, new SearchResultEntry(Entry::create('', [ 'namingContexts' => [], + 'subschemaSubentry' => [], 'supportedExtension' => [], 'supportedLDAPVersion' => [], 'vendorName' => [], @@ -281,6 +285,56 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r ); } + public function test_it_advertises_subschema_subentry_in_rootdse(): void + { + $search = new LdapMessageRequest( + 1, + (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope() + ); + + $this->mockQueue + ->expects($this->once()) + ->method('sendMessage') + ->with( + self::callback(function (LdapMessageResponse $response) { + /** @var SearchResultEntry $result */ + $result = $response->getResponse(); + $attr = $result->getEntry()->get('subschemaSubentry'); + + return $attr !== null && $attr->has('cn=Subschema'); + }), + self::anything(), + ); + + $this->subject->handleRequest($search, $this->mockToken); + } + + public function test_it_uses_configured_subschema_entry_dn(): void + { + $this->options->setSubschemaEntry(new Dn('cn=schema,dc=example,dc=com')); + + $search = new LdapMessageRequest( + 1, + (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope() + ); + + $this->mockQueue + ->expects($this->once()) + ->method('sendMessage') + ->with( + self::callback(function (LdapMessageResponse $response) { + /** @var SearchResultEntry $result */ + $result = $response->getResponse(); + $attr = $result->getEntry()->get('subschemaSubentry'); + + return $attr !== null && $attr->has('cn=schema,dc=example,dc=com'); + }), + self::anything(), + ); + + $this->subject->handleRequest($search, $this->mockToken); + } + public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void { $this->options diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php new file mode 100644 index 00000000..ecbc9f7f --- /dev/null +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Operation\Response\SearchResultDone; +use FreeDSx\Ldap\Operation\Response\SearchResultEntry; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSubschemaHandler; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Server\Token\TokenInterface; +use FreeDSx\Ldap\ServerOptions; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class ServerSubschemaHandlerTest extends TestCase +{ + private ServerQueue&MockObject $mockQueue; + + private TokenInterface&MockObject $mockToken; + + private ServerOptions $options; + + private ServerSubschemaHandler $subject; + + protected function setUp(): void + { + $this->options = new ServerOptions(); + $this->mockQueue = $this->createMock(ServerQueue::class); + $this->mockToken = $this->createMock(TokenInterface::class); + + $this->subject = new ServerSubschemaHandler( + options: $this->options, + queue: $this->mockQueue, + ); + } + + private function makeMessage(): LdapMessageRequest + { + return new LdapMessageRequest( + 1, + (new SearchRequest(Filters::present('objectClass')))->base('cn=Subschema')->useBaseScope(), + ); + } + + public function test_it_returns_a_stub_subschema_entry(): void + { + $this->mockQueue + ->expects($this->once()) + ->method('sendMessage') + ->with( + self::callback(function (LdapMessageResponse $response) { + /** @var SearchResultEntry $result */ + $result = $response->getResponse(); + $entry = $result->getEntry(); + + return $entry->getDn()->toString() === 'cn=Subschema' + && ($entry->get('objectClass')?->has('subschema') ?? false) + && ($entry->get('cn')?->has('Subschema') ?? false); + }), + self::equalTo(new LdapMessageResponse(1, new SearchResultDone(ResultCode::SUCCESS))), + ); + + $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + } + + public function test_it_uses_the_configured_subschema_entry_dn(): void + { + $this->options->setSubschemaEntry(new Dn('cn=schema,dc=example,dc=com')); + + $this->mockQueue + ->expects($this->once()) + ->method('sendMessage') + ->with( + self::callback(function (LdapMessageResponse $response) { + /** @var SearchResultEntry $result */ + $result = $response->getResponse(); + $entry = $result->getEntry(); + + return $entry->getDn()->toString() === 'cn=schema,dc=example,dc=com' + && ($entry->get('cn')?->has('schema') ?? false); + }), + self::anything(), + ); + + $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + } +} From bb361ebd5e4d3da6197bb442d1f99677c94537e8 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 17:07:29 -0400 Subject: [PATCH 03/35] Handle max connections / shutdown timeouts in both Swoole and PCNTL runners. --- src/FreeDSx/Ldap/Container.php | 4 +- .../Server/ServerRunner/PcntlServerRunner.php | 57 +++++++++++--- .../ServerRunner/SwooleServerRunner.php | 78 ++++++++++++++++--- src/FreeDSx/Ldap/ServerOptions.php | 36 +++++++++ tests/unit/LdapServerTest.php | 6 +- .../ServerRunner/SwooleServerRunnerTest.php | 4 +- tests/unit/ServerOptionsTest.php | 24 ++++++ 7 files changed, 180 insertions(+), 29 deletions(-) diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index 761d09dc..dc03e145 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -212,13 +212,13 @@ private function makeServerRunner(): ServerRunnerInterface if ($options->getUseSwooleRunner()) { return new SwooleServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), - logger: $options->getLogger(), + options: $options, ); } return new PcntlServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), - logger: $options->getLogger(), + options: $options, ); } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index ce29e892..b3de4403 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -17,11 +17,11 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Server\ChildProcess; -use FreeDSx\Ldap\Server\LoggerTrait; use FreeDSx\Ldap\Server\ServerProtocolFactory; use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; -use Psr\Log\LoggerInterface; +use FreeDSx\Ldap\ServerOptions; +use Psr\Log\LogLevel; /** * Uses PNCTL to fork incoming requests and send them to the server protocol handler. @@ -30,18 +30,11 @@ */ class PcntlServerRunner implements ServerRunnerInterface { - use LoggerTrait; - /** * The time to wait, in seconds, before we run some clean-up tasks to then wait again. */ private const SOCKET_ACCEPT_TIMEOUT = 5; - /** - * The max time to wait (in seconds) for any child processes before we force kill them. - */ - private const MAX_SHUTDOWN_WAIT_TIME = 15; - private SocketServer $server; /** @@ -72,7 +65,7 @@ class PcntlServerRunner implements ServerRunnerInterface */ public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, - private readonly ?LoggerInterface $logger, + private readonly ServerOptions $options, ) { if (!extension_loaded('pcntl')) { throw new RuntimeException( @@ -176,6 +169,19 @@ private function acceptClients(): void continue; } + $maxConnections = $this->options->getMaxConnections(); + if ($maxConnections > 0 && count($this->childProcesses) >= $maxConnections) { + $this->logInfo( + 'Connection limit reached, dropping new connection.', + $this->defaultContext, + ); + + $this->server->removeClient($socket); + $socket->close(); + + continue; + } + $pid = pcntl_fork(); if ($pid == -1) { // In parent process, but could not fork... @@ -279,8 +285,8 @@ private function handleServerShutdown(): void $waitTime = 0; while (!empty($this->childProcesses)) { - // If we reach max wait time, attempt to force end them and then stop. - if ($waitTime >= self::MAX_SHUTDOWN_WAIT_TIME) { + // If we reach the shutdown timeout, attempt to force end them and then stop. + if ($waitTime >= $this->options->getShutdownTimeout()) { $this->forceEndChildProcesses(); break; @@ -413,4 +419,31 @@ private function forceEndChildProcesses(): void ); $this->cleanUpChildProcesses(); } + + /** + * @param array $context + */ + private function logInfo( + string $message, + array $context = [], + ): void { + $this->options->getLogger()?->log( + LogLevel::INFO, + $message, + $context, + ); + } + + /** + * @param array $context + * @throws RuntimeException + */ + private function logAndThrow( + string $message, + array $context = [], + ): never { + $this->options->getLogger()?->log(LogLevel::ERROR, $message, $context); + + throw new RuntimeException($message); + } } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 3aa5f1bc..519f36b6 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -15,9 +15,9 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; -use Psr\Log\LoggerInterface; use Swoole\Coroutine; use Swoole\Process; use Swoole\Runtime; @@ -38,15 +38,27 @@ */ class SwooleServerRunner implements ServerRunnerInterface { - private const SOCKET_ACCEPT_TIMEOUT = 5; + /** + * Seconds to wait for a new client before re-checking the server connection state. + */ + private const SOCKET_ACCEPT_TIMEOUT = 1; private SocketServer $server; private bool $isShuttingDown = false; + /** + * Active client sockets keyed by spl_object_id. + * + * Used to force-close lingering connections when the drain timeout expires. + * + * @var array + */ + private array $activeSockets = []; + public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, - private readonly ?LoggerInterface $logger = null, + private readonly ServerOptions $options, ) { if (!extension_loaded('swoole')) { throw new RuntimeException( @@ -69,7 +81,7 @@ public function run(SocketServer $server): void $this->acceptClients(); }); - $this->logger?->info('SwooleServerRunner: all connections drained, shutdown complete.'); + $this->options->getLogger()?->info('SwooleServerRunner: all connections drained, shutdown complete.'); } private function registerShutdownSignals(): void @@ -85,19 +97,48 @@ private function handleShutdownSignal(int $signal): void return; } $this->isShuttingDown = true; - $this->logger?->info(sprintf( + $this->options->getLogger()?->info(sprintf( 'SwooleServerRunner: received signal %d, closing server.', $signal, )); $this->server->close(); + $this->startDrainTimeout(); + } + + private function startDrainTimeout(): void + { + Coroutine::create(function (): void { + Coroutine::sleep($this->options->getShutdownTimeout()); + + if (empty($this->activeSockets)) { + return; + } + + $this->options->getLogger()?->warning(sprintf( + 'SwooleServerRunner: shutdown timeout (%d s) exceeded with %d active connection(s), forcing close.', + $this->options->getShutdownTimeout(), + count($this->activeSockets), + )); + + foreach ($this->activeSockets as $socket) { + $socket->close(); + } + }); } private function acceptClients(): void { - $this->logger?->info('SwooleServerRunner: accepting clients.'); + $this->options->getLogger()?->info('SwooleServerRunner: accepting clients.'); while ($this->server->isConnected()) { - $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + try { + $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + } catch (Throwable $e) { + $this->options->getLogger()?->error( + 'SwooleServerRunner: accept() failed: ' . $e->getMessage() + ); + break; + } if ($socket === null) { continue; @@ -108,12 +149,29 @@ private function acceptClients(): void break; } + $maxConnections = $this->options->getMaxConnections(); + if ($maxConnections > 0 && count($this->activeSockets) >= $maxConnections) { + $this->options->getLogger()?->warning(sprintf( + 'SwooleServerRunner: connection limit (%d) reached, dropping new connection.', + $maxConnections, + )); + $socket->close(); + continue; + } + Coroutine::create(function () use ($socket): void { - $this->handleClient($socket); + $socketId = spl_object_id($socket); + $this->activeSockets[$socketId] = $socket; + try { + $this->handleClient($socket); + } finally { + $this->server->removeClient($socket); + unset($this->activeSockets[$socketId]); + } }); } - $this->logger?->info('SwooleServerRunner: accept loop ended, draining active connections.'); + $this->options->getLogger()?->info('SwooleServerRunner: accept loop ended, draining active connections.'); } private function handleClient(Socket $socket): void @@ -122,7 +180,7 @@ private function handleClient(Socket $socket): void $handler = $this->serverProtocolFactory->make($socket); $handler->handle(); } catch (Throwable $e) { - $this->logger?->error( + $this->options->getLogger()?->error( 'SwooleServerRunner: unhandled error in client coroutine: ' . $e->getMessage() ); } finally { diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 063611ee..e0006372 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -127,6 +127,10 @@ final class ServerOptions private bool $useSwooleRunner = false; + private int $maxConnections = 0; + + private int $shutdownTimeout = 15; + /** * @var string[] */ @@ -446,6 +450,38 @@ public function setUseSwooleRunner(bool $use): self return $this; } + /** + * The maximum number of concurrent connections the server will accept. + * + * Zero (the default) means no limit. + */ + public function getMaxConnections(): int + { + return $this->maxConnections; + } + + public function setMaxConnections(int $maxConnections): self + { + $this->maxConnections = $maxConnections; + + return $this; + } + + /** + * Seconds to wait for active connections to close gracefully before forcing them closed on shutdown. + */ + public function getShutdownTimeout(): int + { + return $this->shutdownTimeout; + } + + public function setShutdownTimeout(int $shutdownTimeout): self + { + $this->shutdownTimeout = $shutdownTimeout; + + return $this; + } + public function getUseSwooleRunner(): bool { return $this->useSwooleRunner; diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index b68301b2..b5c89594 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -183,9 +183,7 @@ public function test_it_should_make_a_proxy_server(): void ProxyHandler::class, $proxyOptions->getBackend() ); - self::assertInstanceOf( - ProxyHandler::class, - $proxyOptions->getRootDseHandler(), - ); + + self::assertNull($proxyOptions->getRootDseHandler()); } } diff --git a/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php index ca51bd1b..6e2c8ba2 100644 --- a/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php +++ b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Server\ServerProtocolFactory; use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; +use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\TestCase; final class SwooleServerRunnerTest extends TestCase @@ -30,7 +31,8 @@ public function test_constructor_throws_when_swoole_not_loaded(): void $this->expectExceptionMessageMatches('/Swoole extension/'); new SwooleServerRunner( - $this->createMock(ServerProtocolFactory::class), + serverProtocolFactory: $this->createMock(ServerProtocolFactory::class), + options: new ServerOptions(), ); } } diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index ae2f7e93..45cbe836 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -61,6 +61,30 @@ public function test_it_throws_for_any_unsupported_mechanism_in_the_list(): void $this->subject->setSaslMechanisms(ServerOptions::SASL_PLAIN, 'GSSAPI'); } + public function test_max_connections_defaults_to_zero(): void + { + self::assertSame(0, $this->subject->getMaxConnections()); + } + + public function test_it_can_set_max_connections(): void + { + $this->subject->setMaxConnections(500); + + self::assertSame(500, $this->subject->getMaxConnections()); + } + + public function test_shutdown_timeout_defaults_to_fifteen_seconds(): void + { + self::assertSame(15, $this->subject->getShutdownTimeout()); + } + + public function test_it_can_set_shutdown_timeout(): void + { + $this->subject->setShutdownTimeout(30); + + self::assertSame(30, $this->subject->getShutdownTimeout()); + } + public function test_it_accepts_all_defined_mechanism_constants(): void { $this->subject->setSaslMechanisms( From 172343b0d3352c3a95a89678e91152d4cecfef8c Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 17:33:42 -0400 Subject: [PATCH 04/35] We need different swoole versions per php version. Fix tests. Fix where coroutine hooks are enabled. --- .github/workflows/analysis.yml | 17 ++++++++++++++++- .github/workflows/build.yml | 19 ++++++++++++++++++- src/FreeDSx/Ldap/LdapServer.php | 4 ++-- .../Server/ServerRunner/PcntlServerRunner.php | 1 + .../ServerRunner/SwooleServerRunner.php | 13 ++++++++++--- src/FreeDSx/Ldap/ServerOptions.php | 14 ++++++++++++++ tests/bin/ldapbackendstorage.php | 3 +-- tests/bin/ldapserver.php | 15 +++++++-------- tests/integration/LdapServerTest.php | 3 +++ tests/integration/ServerTestCase.php | 12 ++++++++++-- 10 files changed, 82 insertions(+), 19 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 5377f12c..cbbb31e0 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -8,11 +8,26 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Setup extension cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: '8.1' + extensions: openssl,mbstring,swoole + key: ext-cache-v1 + + - name: Cache extensions + uses: actions/cache@v3 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: openssl,mbstring,swoole-5.1.6 + extensions: openssl,mbstring,swoole coverage: pcov tools: composer:2.9 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7118270..73050ce7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,29 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Setup extension cache environment + if: runner.os == 'Linux' + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php-versions }} + extensions: swoole + key: ext-cache-v1 + + - name: Cache extensions + if: runner.os == 'Linux' + uses: actions/cache@v3 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + - name: Setup PHP (Linux) if: runner.os == 'Linux' uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: swoole-5.1.6 + extensions: swoole coverage: none tools: composer:2.9 diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index b866c1e9..8d5fef52 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -54,12 +54,12 @@ public function run(): void { $this->validateSaslConfiguration(); + $runner = $this->options->getServerRunner() ?? $this->container->get(ServerRunnerInterface::class); + $socketServer = $this->container ->get(SocketServerFactory::class) ->makeAndBind(); - $runner = $this->options->getServerRunner() ?? $this->container->get(ServerRunnerInterface::class); - $runner->run($socketServer); } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index b3de4403..7ceb9330 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -146,6 +146,7 @@ private function acceptClients(): void 'The server process has started and is now accepting clients.', $this->defaultContext ); + $this->options->getOnServerReady()?->__invoke(); do { $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 519f36b6..dc6cca7a 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -67,15 +67,17 @@ public function __construct( . '(^5.1 for PHP 8.3/8.4, ^6.0 for PHP 8.5+)' ); } + + // Enable coroutine hooks before any socket is created so that + // stream_socket_accept (and related functions) are coroutine-aware + // for the server socket that makeAndBind() is about to create. + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); } public function run(SocketServer $server): void { $this->server = $server; - // Hook all standard PHP blocking I/O so it works inside coroutines. - Runtime::enableCoroutine(SWOOLE_HOOK_ALL); - Coroutine\run(function (): void { $this->registerShutdownSignals(); $this->acceptClients(); @@ -108,6 +110,10 @@ private function handleShutdownSignal(int $signal): void private function startDrainTimeout(): void { Coroutine::create(function (): void { + if (empty($this->activeSockets)) { + return; + } + Coroutine::sleep($this->options->getShutdownTimeout()); if (empty($this->activeSockets)) { @@ -129,6 +135,7 @@ private function startDrainTimeout(): void private function acceptClients(): void { $this->options->getLogger()?->info('SwooleServerRunner: accepting clients.'); + $this->options->getOnServerReady()?->__invoke(); while ($this->server->isConnected()) { try { diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index e0006372..4a9d81a4 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -129,6 +129,8 @@ final class ServerOptions private int $maxConnections = 0; + private ?\Closure $onServerReady = null; + private int $shutdownTimeout = 15; /** @@ -487,6 +489,18 @@ public function getUseSwooleRunner(): bool return $this->useSwooleRunner; } + public function getOnServerReady(): ?\Closure + { + return $this->onServerReady; + } + + public function setOnServerReady(?\Closure $onServerReady): self + { + $this->onServerReady = $onServerReady; + + return $this; + } + /** * @return array{ip: string, port: int, unix_socket: string, transport: string, idle_timeout: int, require_authentication: bool, allow_anonymous: bool, backend: ?LdapBackendInterface, rootdse_handler: ?RootDseHandlerInterface, logger: ?LoggerInterface, use_ssl: bool, ssl_cert: ?string, ssl_cert_key: ?string, ssl_cert_passphrase: ?string, dse_alt_server: ?string, dse_naming_contexts: string[], dse_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} */ diff --git a/tests/bin/ldapbackendstorage.php b/tests/bin/ldapbackendstorage.php index 8357bf93..64eac685 100644 --- a/tests/bin/ldapbackendstorage.php +++ b/tests/bin/ldapbackendstorage.php @@ -64,12 +64,11 @@ (new ServerOptions()) ->setPort(10389) ->setTransport($transport) + ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) ))->useBackend($adapter); if ($runner === 'swoole') { $server->useSwooleRunner(); } -echo 'server starting...' . PHP_EOL; - $server->run(); diff --git a/tests/bin/ldapserver.php b/tests/bin/ldapserver.php index 895fc015..c909d489 100644 --- a/tests/bin/ldapserver.php +++ b/tests/bin/ldapserver.php @@ -5,6 +5,7 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; @@ -17,7 +18,7 @@ require __DIR__ . '/../../vendor/autoload.php'; -class LdapServerBackend implements WritableLdapBackendInterface, SaslHandlerInterface +class LdapServerBackend implements WritableLdapBackendInterface, SaslHandlerInterface, PasswordAuthenticatableInterface { use WritableBackendTrait; @@ -57,17 +58,16 @@ public function get(Dn $dn): ?Entry } public function verifyPassword( - Dn $dn, + string $name, string $password, ): bool { - $username = $dn->toString(); $this->logRequest( 'bind', - "username => $username, password => $password" + "username => $name, password => $password" ); - return isset($this->users[$username]) && $this->users[$username] === $password; + return isset($this->users[$name]) && $this->users[$name] === $password; } public function add(AddCommand $command): void @@ -165,7 +165,8 @@ public function search(SearchContext $context): Generator ->setUnixSocket(sys_get_temp_dir() . '/ldap.socket') ->setSslCert($sslCert) ->setSslCertKey($sslKey) - ->setUseSsl($useSsl); + ->setUseSsl($useSsl) + ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); if ($handler === 'sasl') { $options->setSaslMechanisms( @@ -177,6 +178,4 @@ public function search(SearchContext $context): Generator $server = (new LdapServer($options))->useBackend($backend); -echo 'server starting...' . PHP_EOL; - $server->run(); diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index 37584fdf..813adff5 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -209,6 +209,9 @@ public function testItCanRetrieveTheRootDSE(): void 'namingContexts' => [ 'dc=FreeDSx,dc=local', ], + 'subschemaSubentry' => [ + 'cn=Subschema', + ], 'supportedExtension' => [ '1.3.6.1.4.1.4203.1.11.3', '1.3.6.1.4.1.1466.20037', diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 6665b7a8..4f2b92ff 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -77,7 +77,11 @@ public function tearDown(): void { parent::tearDown(); - $this->client?->unbind(); + try { + $this->client?->unbind(); + } catch (\Throwable) { + // Server may have already closed; ignore unbind failures during teardown. + } $this->client = null; if ($this->overrideProcess !== null) { @@ -147,7 +151,11 @@ protected function createServerProcess( protected function stopServer(): void { - $this->client?->unbind(); + try { + $this->client?->unbind(); + } catch (\Throwable) { + // Connection may already be closed; ignore unbind failures. + } $this->client = null; if ($this->overrideProcess !== null) { From 880b5e8c19f9c2f28fed2efed26dbb899758dd38 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 20:13:23 -0400 Subject: [PATCH 05/35] Unify server runner logging. Notify of disconnect on swoole server shutdown. --- .../Server/ServerRunner/PcntlServerRunner.php | 45 +++--- .../ServerRunner/ServerRunnerLoggerTrait.php | 130 ++++++++++++++++++ .../ServerRunner/SwooleServerRunner.php | 67 +++++++-- .../LdapBackendStorageSwooleTest.php | 8 +- 4 files changed, 207 insertions(+), 43 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 7ceb9330..5384c5a5 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -21,7 +21,9 @@ use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; use FreeDSx\Ldap\ServerOptions; +use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use Throwable; /** * Uses PNCTL to fork incoming requests and send them to the server protocol handler. @@ -30,6 +32,8 @@ */ class PcntlServerRunner implements ServerRunnerInterface { + use ServerRunnerLoggerTrait; + /** * The time to wait, in seconds, before we run some clean-up tasks to then wait again. */ @@ -142,10 +146,7 @@ private function cleanUpChildProcesses(): void */ private function acceptClients(): void { - $this->logInfo( - 'The server process has started and is now accepting clients.', - $this->defaultContext - ); + $this->logServerStarted($this->defaultContext); $this->options->getOnServerReady()?->__invoke(); do { @@ -153,10 +154,7 @@ private function acceptClients(): void if ($this->isShuttingDown) { if ($socket) { - $this->logInfo( - 'A client was accepted, but the server is shutting down. Closing connection.', - $this->defaultContext - ); + $this->logClientRejectedDuringShutdown($this->defaultContext); $socket->close(); } @@ -172,10 +170,7 @@ private function acceptClients(): void $maxConnections = $this->options->getMaxConnections(); if ($maxConnections > 0 && count($this->childProcesses) >= $maxConnections) { - $this->logInfo( - 'Connection limit reached, dropping new connection.', - $this->defaultContext, - ); + $this->logConnectionLimitReached($this->defaultContext); $this->server->removeClient($socket); $socket->close(); @@ -232,7 +227,11 @@ function () use ($protocolHandler, $context) { 'The child process has received a signal to stop.', $context ); - $protocolHandler->shutdown($context); + try { + $protocolHandler->shutdown($context); + } catch (Throwable $e) { + $this->logShutdownNotifyError($e, $context); + } } ); } @@ -270,10 +269,7 @@ private function handleServerShutdown(): void return; } $this->isShuttingDown = true; - $this->logInfo( - 'The server shutdown process has started.', - $this->defaultContext - ); + $this->logShutdownStarted($this->defaultContext); // We can't do anything else without the posix ext ... :( if (!$this->isPosixExtLoaded) { @@ -302,10 +298,7 @@ private function handleServerShutdown(): void } $this->server->close(); - $this->logInfo( - 'The server shutdown process has completed.', - $this->defaultContext - ); + $this->logShutdownCompleted($this->defaultContext); } /** @@ -389,11 +382,10 @@ private function runAfterChildStarted( $pid, $socket ); - $this->logInfo( - 'A new client has connected.', + $this->logClientConnected( array_merge( ['child_pid' => $pid], - $this->defaultContext + $this->defaultContext, ) ); $this->cleanUpChildProcesses(); @@ -421,6 +413,11 @@ private function forceEndChildProcesses(): void $this->cleanUpChildProcesses(); } + private function getRunnerLogger(): ?LoggerInterface + { + return $this->options->getLogger(); + } + /** * @param array $context */ diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php new file mode 100644 index 00000000..76643ed0 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\ServerRunner; + +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Throwable; + +/** + * Shared lifecycle log messages for server runners. + * + * @author Chad Sikorra + */ +trait ServerRunnerLoggerTrait +{ + abstract private function getRunnerLogger(): ?LoggerInterface; + + /** + * @param array $context + */ + private function logServerStarted(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'The server process has started and is now accepting clients.', + $context, + ); + } + + /** + * @param array $context + */ + private function logClientConnected(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'A new client has connected.', + $context, + ); + } + + /** + * @param array $context + */ + private function logClientClosed(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'The client connection has closed.', + $context, + ); + } + + /** + * @param array $context + */ + private function logClientRejectedDuringShutdown(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'A client was accepted, but the server is shutting down. Closing connection.', + $context, + ); + } + + /** + * @param array $context + */ + private function logConnectionLimitReached(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::WARNING, + 'Connection limit reached, dropping new connection.', + $context, + ); + } + + /** + * @param array $context + */ + private function logShutdownStarted(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'The server shutdown process has started.', + $context, + ); + } + + /** + * @param array $context + */ + private function logShutdownCompleted(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::INFO, + 'The server shutdown process has completed.', + $context, + ); + } + + /** + * @param array $context + */ + private function logShutdownNotifyError( + Throwable $e, + array $context = [], + ): void { + $this->getRunnerLogger()?->log( + LogLevel::WARNING, + 'Unexpected error while notifying client of shutdown.', + array_merge($context, [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_stacktrace' => $e->getTraceAsString(), + ]), + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index dc6cca7a..eb21a8e5 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -14,11 +14,14 @@ namespace FreeDSx\Ldap\Server\ServerRunner; use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Server\ServerProtocolFactory; use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; +use Psr\Log\LoggerInterface; use Swoole\Coroutine; +use Swoole\Coroutine\WaitGroup; use Swoole\Process; use Swoole\Runtime; use Throwable; @@ -38,6 +41,8 @@ */ class SwooleServerRunner implements ServerRunnerInterface { + use ServerRunnerLoggerTrait; + /** * Seconds to wait for a new client before re-checking the server connection state. */ @@ -56,6 +61,17 @@ class SwooleServerRunner implements ServerRunnerInterface */ private array $activeSockets = []; + /** + * Active protocol handlers keyed by spl_object_id of their socket. + * + * Used to send a Notice of Disconnect to each client on shutdown. + * + * @var array + */ + private array $activeHandlers = []; + + private WaitGroup $waitGroup; + public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, private readonly ServerOptions $options, @@ -72,6 +88,7 @@ public function __construct( // stream_socket_accept (and related functions) are coroutine-aware // for the server socket that makeAndBind() is about to create. Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + $this->waitGroup = new WaitGroup(); } public function run(SocketServer $server): void @@ -83,7 +100,12 @@ public function run(SocketServer $server): void $this->acceptClients(); }); - $this->options->getLogger()?->info('SwooleServerRunner: all connections drained, shutdown complete.'); + $this->logShutdownCompleted(); + } + + private function getRunnerLogger(): ?LoggerInterface + { + return $this->options->getLogger(); } private function registerShutdownSignals(): void @@ -99,14 +121,23 @@ private function handleShutdownSignal(int $signal): void return; } $this->isShuttingDown = true; - $this->options->getLogger()?->info(sprintf( - 'SwooleServerRunner: received signal %d, closing server.', - $signal, - )); + $this->logShutdownStarted(['signal' => $signal]); $this->server->close(); + $this->notifyClientsOfShutdown(); $this->startDrainTimeout(); } + private function notifyClientsOfShutdown(): void + { + foreach ($this->activeHandlers as $handler) { + try { + $handler->shutdown(); + } catch (Throwable $e) { + $this->logShutdownNotifyError($e); + } + } + } + private function startDrainTimeout(): void { Coroutine::create(function (): void { @@ -114,9 +145,9 @@ private function startDrainTimeout(): void return; } - Coroutine::sleep($this->options->getShutdownTimeout()); + $allClosed = $this->waitGroup->wait((float) $this->options->getShutdownTimeout()); - if (empty($this->activeSockets)) { + if ($allClosed) { return; } @@ -134,7 +165,7 @@ private function startDrainTimeout(): void private function acceptClients(): void { - $this->options->getLogger()?->info('SwooleServerRunner: accepting clients.'); + $this->logServerStarted(); $this->options->getOnServerReady()?->__invoke(); while ($this->server->isConnected()) { @@ -152,28 +183,29 @@ private function acceptClients(): void } if ($this->isShuttingDown) { + $this->logClientRejectedDuringShutdown(); $socket->close(); break; } $maxConnections = $this->options->getMaxConnections(); if ($maxConnections > 0 && count($this->activeSockets) >= $maxConnections) { - $this->options->getLogger()?->warning(sprintf( - 'SwooleServerRunner: connection limit (%d) reached, dropping new connection.', - $maxConnections, - )); + $this->logConnectionLimitReached(['max_connections' => $maxConnections]); $socket->close(); continue; } + $this->waitGroup->add(); Coroutine::create(function () use ($socket): void { $socketId = spl_object_id($socket); $this->activeSockets[$socketId] = $socket; try { - $this->handleClient($socket); + $this->handleClient($socket, $socketId); } finally { $this->server->removeClient($socket); unset($this->activeSockets[$socketId]); + unset($this->activeHandlers[$socketId]); + $this->waitGroup->done(); } }); } @@ -181,16 +213,21 @@ private function acceptClients(): void $this->options->getLogger()?->info('SwooleServerRunner: accept loop ended, draining active connections.'); } - private function handleClient(Socket $socket): void - { + private function handleClient( + Socket $socket, + int $socketId, + ): void { try { $handler = $this->serverProtocolFactory->make($socket); + $this->activeHandlers[$socketId] = $handler; + $this->logClientConnected(); $handler->handle(); } catch (Throwable $e) { $this->options->getLogger()?->error( 'SwooleServerRunner: unhandled error in client coroutine: ' . $e->getMessage() ); } finally { + $this->logClientClosed(); $socket->close(); } } diff --git a/tests/integration/LdapBackendStorageSwooleTest.php b/tests/integration/LdapBackendStorageSwooleTest.php index fc901073..668ac776 100644 --- a/tests/integration/LdapBackendStorageSwooleTest.php +++ b/tests/integration/LdapBackendStorageSwooleTest.php @@ -68,13 +68,13 @@ public function setUp(): void $this->markTestSkipped('The swoole extension is required to run SwooleServerRunner tests.'); } - // parent::setUp() connects the per-test client to the shared server. parent::setUp(); - // Mutating tests need a fresh isolated server. Doing this in setUp() - // rather than inline in the test method ensures Swoole's coroutine has - // the same natural setUp→test gap to finish binding the socket. if (in_array($this->name(), self::MUTATING_TESTS, true)) { + // Stop the shared server and spin up a fresh one for each mutating + // test. The LdapClient created by parent::setUp() is lazy (no TCP + // connection yet), so stopServer() will find no active Swoole + // connections and the drain coroutine exits immediately. $this->stopServer(); $this->createServerProcess('tcp', 'swoole'); } From e8ae4d27d73fff82d4a22a8eeafd8f483bd5b8ea Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 20:59:31 -0400 Subject: [PATCH 06/35] Lower the socket accept timeout. --- composer.json | 2 +- src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php | 2 +- src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index a2db98a8..89b296b6 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require": { "php": ">=8.1", "freedsx/asn1": ">=0.4.0", - "freedsx/socket": ">=0.5.0", + "freedsx/socket": ">=0.6.2", "freedsx/sasl": ">=0.2.1", "psr/log": "^3" }, diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 5384c5a5..37121d9e 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -37,7 +37,7 @@ class PcntlServerRunner implements ServerRunnerInterface /** * The time to wait, in seconds, before we run some clean-up tasks to then wait again. */ - private const SOCKET_ACCEPT_TIMEOUT = 5; + private const SOCKET_ACCEPT_TIMEOUT = 0.5; private SocketServer $server; diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index eb21a8e5..362fb777 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -46,7 +46,7 @@ class SwooleServerRunner implements ServerRunnerInterface /** * Seconds to wait for a new client before re-checking the server connection state. */ - private const SOCKET_ACCEPT_TIMEOUT = 1; + private const SOCKET_ACCEPT_TIMEOUT = 0.5; private SocketServer $server; From f87253be52edd43e2f98bbde68f90f532993ca49 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 22:32:29 -0400 Subject: [PATCH 07/35] Fix the coroutine interaction between socker server and swoole. --- src/FreeDSx/Ldap/Container.php | 1 + .../ServerRunner/SwooleServerRunner.php | 28 +++++++++++-------- .../ServerRunner/SwooleServerRunnerTest.php | 2 ++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index dc03e145..bb850e56 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -213,6 +213,7 @@ private function makeServerRunner(): ServerRunnerInterface return new SwooleServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), options: $options, + socketServerFactory: $this->get(SocketServerFactory::class), ); } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 362fb777..a9200492 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\SocketServerFactory; use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; @@ -37,6 +38,13 @@ * coroutine-friendly, flock() may still cause brief stalls under high write * concurrency. For write-heavy workloads prefer the InMemoryStorageAdapter. * + * Note on socket creation: the SocketServer must be created inside Coroutine\run() + * for Swoole's stream hooks to intercept stream_socket_accept() as a yielding call. + * If the socket is created before the event loop starts, stream_socket_accept() + * blocks the event loop entirely and signals are never delivered. The run() method + * therefore closes the passed SocketServer immediately and uses the injected + * SocketServerFactory to recreate it inside the coroutine. + * * @author Chad Sikorra */ class SwooleServerRunner implements ServerRunnerInterface @@ -75,6 +83,7 @@ class SwooleServerRunner implements ServerRunnerInterface public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, private readonly ServerOptions $options, + private readonly SocketServerFactory $socketServerFactory, ) { if (!extension_loaded('swoole')) { throw new RuntimeException( @@ -84,18 +93,21 @@ public function __construct( ); } - // Enable coroutine hooks before any socket is created so that - // stream_socket_accept (and related functions) are coroutine-aware - // for the server socket that makeAndBind() is about to create. Runtime::enableCoroutine(SWOOLE_HOOK_ALL); $this->waitGroup = new WaitGroup(); } public function run(SocketServer $server): void { - $this->server = $server; + // Close the socket that was created outside the coroutine context. + // Swoole's stream hook only intercepts stream_socket_accept() as a + // yielding call when the socket is created inside Coroutine\run(). + // A socket created before the event loop starts causes stream_socket_accept() + // to block the loop entirely, preventing signal delivery. + $server->close(); Coroutine\run(function (): void { + $this->server = $this->socketServerFactory->makeAndBind(); $this->registerShutdownSignals(); $this->acceptClients(); }); @@ -168,7 +180,7 @@ private function acceptClients(): void $this->logServerStarted(); $this->options->getOnServerReady()?->__invoke(); - while ($this->server->isConnected()) { + while (!$this->isShuttingDown) { try { $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); } catch (Throwable $e) { @@ -182,12 +194,6 @@ private function acceptClients(): void continue; } - if ($this->isShuttingDown) { - $this->logClientRejectedDuringShutdown(); - $socket->close(); - break; - } - $maxConnections = $this->options->getMaxConnections(); if ($maxConnections > 0 && count($this->activeSockets) >= $maxConnections) { $this->logConnectionLimitReached(['max_connections' => $maxConnections]); diff --git a/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php index 6e2c8ba2..cd2d1ccb 100644 --- a/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php +++ b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Server\ServerProtocolFactory; use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; +use FreeDSx\Ldap\Server\SocketServerFactory; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\TestCase; @@ -33,6 +34,7 @@ public function test_constructor_throws_when_swoole_not_loaded(): void new SwooleServerRunner( serverProtocolFactory: $this->createMock(ServerProtocolFactory::class), options: new ServerOptions(), + socketServerFactory: $this->createMock(SocketServerFactory::class), ); } } From d0ac1e8e3119cc55a4f111b999cf4a2db9d2669a Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 23:39:38 -0400 Subject: [PATCH 08/35] Increase the syncrepl wait. --- tests/integration/Sync/SyncReplTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/Sync/SyncReplTest.php b/tests/integration/Sync/SyncReplTest.php index 31ef83fc..579bc759 100644 --- a/tests/integration/Sync/SyncReplTest.php +++ b/tests/integration/Sync/SyncReplTest.php @@ -159,7 +159,7 @@ private function waitForServer(): void $server = (string) (getenv('LDAP_SERVER') ?: 'localhost'); $port = (int) (getenv('LDAP_PORT') ?: 389); - for ($i = 0; $i < 30; $i++) { + for ($i = 0; $i < 35; $i++) { $connection = @fsockopen( $server, $port, @@ -181,7 +181,7 @@ private function waitForServer(): void 'LDAP server at %s:%d did not become available within %d seconds.', $server, $port, - 30, + 35, )); } } From dbea09f9f490983b4db2eee30269865d334d7703 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 23:40:22 -0400 Subject: [PATCH 09/35] Use the latest php version in the analysis run. --- .github/workflows/analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index cbbb31e0..c28e589c 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -12,7 +12,7 @@ jobs: id: extcache uses: shivammathur/cache-extensions@v1 with: - php-version: '8.1' + php-version: '8.5' extensions: openssl,mbstring,swoole key: ext-cache-v1 @@ -26,7 +26,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.5' extensions: openssl,mbstring,swoole coverage: pcov tools: composer:2.9 From 52c1d5d5c4ca1f36de1394b3efd3141eaa1e9787 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 23:43:10 -0400 Subject: [PATCH 10/35] Remove superuser on analysis. --- .github/workflows/analysis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index c28e589c..2d450355 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -55,9 +55,7 @@ jobs: composer run-script analyse-tests - name: Run Test Coverage - env: - COMPOSER_ALLOW_SUPERUSER: 1 - run: sudo composer run-script --timeout=0 test-coverage + run: composer run-script --timeout=0 test-coverage - name: Upload Unit Coverage to Codecov uses: codecov/codecov-action@v3 From 6b9462b414ca64f62700e880b755566062127756 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 5 Apr 2026 23:48:48 -0400 Subject: [PATCH 11/35] Mirror docker setup on analysis. --- .github/workflows/analysis.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 2d450355..7b56d15d 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -31,8 +31,18 @@ jobs: coverage: pcov tools: composer:2.9 - - name: Install OpenLDAP - run: sudo ./tests/resources/openldap/setup.sh + - name: Create slapd socket directory + run: sudo mkdir -p /var/run/slapd && sudo chmod 777 /var/run/slapd + + - name: Start OpenLDAP + run: docker compose -f tests/resources/openldap/docker-compose.yml up -d --build --wait + + - name: Show OpenLDAP logs on failure + if: failure() + run: docker compose -f tests/resources/openldap/docker-compose.yml logs --tail=100 + + - name: Add foo.com hosts entry + run: echo "127.0.0.1 foo.com" | sudo tee -a /etc/hosts - name: Get Composer Cache Directory id: composer-cache From a77bbf4d93916a1ae519d377897233e566115c0e Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Mon, 6 Apr 2026 22:13:56 -0400 Subject: [PATCH 12/35] Add missing tests. --- tests/unit/LdapServerTest.php | 48 +- .../ServerPagingHandlerTest.php | 43 ++ .../PasswordAuthenticatorTest.php | 82 ++++ .../Adapter/InMemoryStorageAdapterTest.php | 65 +++ .../Adapter/JsonFileStorageAdapterTest.php | 53 +++ tests/unit/ServerOptionsTest.php | 414 ++++++++++++++++++ 6 files changed, 703 insertions(+), 2 deletions(-) diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index b5c89594..0ff68775 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -13,11 +13,12 @@ namespace Tests\Unit\FreeDSx\Ldap; -use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\Exception\InvalidArgumentException; -use FreeDSx\Ldap\LdapClient; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; @@ -175,6 +176,49 @@ public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_backen $this->expectNotToPerformAssertions(); } + public function test_it_should_use_the_password_authenticator_specified(): void + { + $authenticator = $this->createMock(PasswordAuthenticatableInterface::class); + + $this->subject->usePasswordAuthenticator($authenticator); + + self::assertSame( + $authenticator, + $this->subject->getOptions()->getPasswordAuthenticator() + ); + } + + public function test_it_should_use_the_write_handler_specified(): void + { + $handler = $this->createMock(WriteHandlerInterface::class); + + $this->subject->useWriteHandler($handler); + + self::assertContains( + $handler, + $this->subject->getOptions()->getWriteHandlers() + ); + } + + public function test_it_should_use_the_filter_evaluator_specified(): void + { + $evaluator = $this->createMock(FilterEvaluatorInterface::class); + + $this->subject->useFilterEvaluator($evaluator); + + self::assertSame( + $evaluator, + $this->subject->getOptions()->getFilterEvaluator() + ); + } + + public function test_it_should_enable_swoole_runner(): void + { + $this->subject->useSwooleRunner(); + + self::assertTrue($this->subject->getOptions()->getUseSwooleRunner()); + } + public function test_it_should_make_a_proxy_server(): void { $proxyOptions = LdapServer::makeProxy('localhost')->getOptions(); diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index cab0d02e..534b5bc1 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -259,6 +259,49 @@ public function test_it_sends_a_result_code_error_in_SearchResultDone_if_the_old ); } + public function test_it_sends_an_operations_error_when_the_paging_generator_has_expired(): void + { + // A paging request exists and has been processed, but its generator was never stored + // (simulating a session that expired or was evicted). + $searchRequest = $this->makeSearchRequest(); + + $pagingReq = new PagingRequest( + new PagingControl(10, ''), + $searchRequest, + new ControlBag(), + 'expiredcookie', + ); + $pagingReq->markProcessed(); + $this->requestHistory->pagingRequest()->add($pagingReq); + + $message = $this->makeSearchMessage( + cookie: 'expiredcookie', + searchRequest: $searchRequest, + ); + + $this->mockBackend + ->expects(self::never()) + ->method('search'); + + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with(self::callback(function (LdapMessageResponse $response): bool { + $paging = $response->controls()->get(Control::OID_PAGING); + $done = $response->getResponse(); + + return $paging instanceof PagingControl + && $paging->getCookie() === '' + && $done instanceof SearchResultDone + && $done->getResultCode() === ResultCode::OPERATIONS_ERROR; + })); + + $this->subject->handleRequest( + $message, + $this->mockToken + ); + } + public function test_it_throws_an_exception_if_the_paging_cookie_does_not_exist(): void { $message = $this->makeSearchMessage( diff --git a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php index e801e469..02f67430 100644 --- a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php +++ b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php @@ -107,4 +107,86 @@ public function test_returns_false_for_wrong_sha_password(): void $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') ); } + + public function test_returns_true_for_correct_ssha_password(): void + { + $salt = 'salt'; + $hashed = '{SSHA}' . base64_encode(sha1('mypassword' . $salt, true) . $salt); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertTrue( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'mypassword') + ); + } + + public function test_returns_false_for_wrong_ssha_password(): void + { + $salt = 'salt'; + $hashed = '{SSHA}' . base64_encode(sha1('mypassword' . $salt, true) . $salt); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') + ); + } + + public function test_returns_true_for_correct_md5_password(): void + { + $hashed = '{MD5}' . base64_encode(md5('mypassword', true)); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertTrue( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'mypassword') + ); + } + + public function test_returns_false_for_wrong_md5_password(): void + { + $hashed = '{MD5}' . base64_encode(md5('mypassword', true)); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') + ); + } + + public function test_returns_true_for_correct_smd5_password(): void + { + $salt = 'salt'; + $hashed = '{SMD5}' . base64_encode(md5('mypassword' . $salt, true) . $salt); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertTrue( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'mypassword') + ); + } + + public function test_returns_false_for_wrong_smd5_password(): void + { + $salt = 'salt'; + $hashed = '{SMD5}' . base64_encode(md5('mypassword' . $salt, true) . $salt); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertFalse( + $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php index 50d65a40..cc3fe165 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php @@ -203,6 +203,55 @@ public function test_update_throws_no_such_object_for_missing_entry(): void )); } + public function test_add_throws_entry_already_exists(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $this->subject->add(new AddCommand($this->alice)); + } + + public function test_update_add_value_to_existing_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'cn', 'Alicia')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNotNull($entry); + + $cn = $entry->get('cn'); + self::assertNotNull($cn); + + self::assertTrue($cn->has('Alice')); + self::assertTrue($cn->has('Alicia')); + } + + public function test_update_delete_specific_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'userPassword', 'secret')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertFalse($entry?->get('userPassword')?->has('secret') ?? false); + } + + public function test_update_replace_with_no_values_clears_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword')], + )); + + self::assertNull( + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword') + ); + } + public function test_move_throws_no_such_object_for_missing_entry(): void { self::expectException(OperationException::class); @@ -216,6 +265,22 @@ public function test_move_throws_no_such_object_for_missing_entry(): void )); } + public function test_move_creates_new_rdn_attribute_when_it_does_not_exist_in_entry(): void + { + // Alice has no 'uid' attribute; renaming to uid=alice should create it. + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('uid=alice'), + false, + null, + )); + + $entry = $this->subject->get(new Dn('uid=alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertTrue($entry->get('uid')?->has('alice')); + } + public function test_move_to_new_parent(): void { $ou = new Entry(new Dn('ou=People,dc=example,dc=com'), new Attribute('ou', 'People')); diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php index bcad385a..1e5a6b1f 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php @@ -254,6 +254,43 @@ public function test_update_throws_no_such_object_for_missing_entry(): void )); } + public function test_add_throws_entry_already_exists(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $this->subject->add(new AddCommand($this->alice)); + } + + public function test_update_add_value_to_existing_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'cn', 'Alicia')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNotNull($entry); + + $cn = $entry->get('cn'); + self::assertNotNull($cn); + + self::assertTrue($cn->has('Alice')); + self::assertTrue($cn->has('Alicia')); + } + + public function test_update_replace_with_no_values_clears_attribute(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword')], + )); + + self::assertNull( + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword') + ); + } + public function test_move_throws_no_such_object_for_missing_entry(): void { self::expectException(OperationException::class); @@ -267,6 +304,22 @@ public function test_move_throws_no_such_object_for_missing_entry(): void )); } + public function test_move_creates_new_rdn_attribute_when_it_does_not_exist_in_entry(): void + { + // Alice has no 'uid' attribute; renaming to uid=alice should create it. + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('uid=alice'), + false, + null, + )); + + $entry = $this->subject->get(new Dn('uid=alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertTrue($entry->get('uid')?->has('alice')); + } + public function test_move_to_new_parent(): void { $ou = new Entry(new Dn('ou=People,dc=example,dc=com'), new Attribute('ou', 'People')); diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index 45cbe836..c9557a4f 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -13,9 +13,17 @@ namespace Tests\Unit\FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; +use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; +use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; final class ServerOptionsTest extends TestCase { @@ -85,6 +93,412 @@ public function test_it_can_set_shutdown_timeout(): void self::assertSame(30, $this->subject->getShutdownTimeout()); } + public function test_ip_defaults_to_all_interfaces(): void + { + self::assertSame( + '0.0.0.0', + $this->subject->getIp() + ); + } + + public function test_it_can_set_ip(): void + { + $this->subject->setIp('127.0.0.1'); + + self::assertSame( + '127.0.0.1', + $this->subject->getIp() + ); + } + + public function test_port_defaults_to_389(): void + { + self::assertSame( + 389, + $this->subject->getPort() + ); + } + + public function test_it_can_set_port(): void + { + $this->subject->setPort(33389); + + self::assertSame( + 33389, + $this->subject->getPort() + ); + } + + public function test_transport_defaults_to_tcp(): void + { + self::assertSame( + 'tcp', + $this->subject->getTransport() + ); + } + + public function test_it_can_set_transport(): void + { + $this->subject->setTransport('unix'); + + self::assertSame( + 'unix', + $this->subject->getTransport() + ); + } + + public function test_unix_socket_has_a_default(): void + { + self::assertSame( + '/var/run/ldap.socket', + $this->subject->getUnixSocket() + ); + } + + public function test_it_can_set_unix_socket(): void + { + $this->subject->setUnixSocket('/tmp/ldap.sock'); + + self::assertSame('/tmp/ldap.sock', $this->subject->getUnixSocket()); + } + + public function test_idle_timeout_defaults_to_600(): void + { + self::assertSame( + 600, + $this->subject->getIdleTimeout() + ); + } + + public function test_it_can_set_idle_timeout(): void + { + $this->subject->setIdleTimeout(120); + + self::assertSame( + 120, + $this->subject->getIdleTimeout() + ); + } + + public function test_require_authentication_defaults_to_true(): void + { + self::assertTrue($this->subject->isRequireAuthentication()); + } + + public function test_it_can_disable_require_authentication(): void + { + $this->subject->setRequireAuthentication(false); + + self::assertFalse($this->subject->isRequireAuthentication()); + } + + public function test_allow_anonymous_defaults_to_false(): void + { + self::assertFalse($this->subject->isAllowAnonymous()); + } + + public function test_it_can_allow_anonymous(): void + { + $this->subject->setAllowAnonymous(true); + + self::assertTrue($this->subject->isAllowAnonymous()); + } + + public function test_ssl_is_disabled_by_default(): void + { + self::assertFalse($this->subject->isUseSsl()); + } + + public function test_it_can_enable_ssl(): void + { + $this->subject->setUseSsl(true); + + self::assertTrue($this->subject->isUseSsl()); + } + + public function test_ssl_cert_is_null_by_default(): void + { + self::assertNull($this->subject->getSslCert()); + } + + public function test_it_can_set_ssl_cert(): void + { + $this->subject->setSslCert('/path/to/cert.pem'); + + self::assertSame( + '/path/to/cert.pem', + $this->subject->getSslCert() + ); + } + + public function test_ssl_cert_key_is_null_by_default(): void + { + self::assertNull($this->subject->getSslCertKey()); + } + + public function test_it_can_set_ssl_cert_key(): void + { + $this->subject->setSslCertKey('/path/to/key.pem'); + + self::assertSame( + '/path/to/key.pem', + $this->subject->getSslCertKey() + ); + } + + public function test_ssl_cert_passphrase_is_null_by_default(): void + { + self::assertNull($this->subject->getSslCertPassphrase()); + } + + public function test_it_can_set_ssl_cert_passphrase(): void + { + $this->subject->setSslCertPassphrase('secret'); + + self::assertSame( + 'secret', + $this->subject->getSslCertPassphrase() + ); + } + + public function test_dse_alt_server_is_null_by_default(): void + { + self::assertNull($this->subject->getDseAltServer()); + } + + public function test_it_can_set_dse_alt_server(): void + { + $this->subject->setDseAltServer('ldap://backup.example.com'); + + self::assertSame( + 'ldap://backup.example.com', + $this->subject->getDseAltServer() + ); + } + + public function test_subschema_entry_defaults_to_cn_subschema(): void + { + self::assertSame( + 'cn=Subschema', + $this->subject->getSubschemaEntry()->toString() + ); + } + + public function test_it_can_set_subschema_entry(): void + { + $this->subject->setSubschemaEntry(new Dn('cn=Subschema,dc=example,dc=com')); + + self::assertSame( + 'cn=Subschema,dc=example,dc=com', + $this->subject->getSubschemaEntry()->toString() + ); + } + + public function test_dse_naming_contexts_has_a_default(): void + { + self::assertSame( + ['dc=FreeDSx,dc=local'], + $this->subject->getDseNamingContexts() + ); + } + + public function test_it_can_set_dse_naming_contexts(): void + { + $this->subject->setDseNamingContexts('dc=example,dc=com', 'dc=other,dc=com'); + + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=com'], + $this->subject->getDseNamingContexts() + ); + } + + public function test_dse_vendor_name_defaults_to_freedsx(): void + { + self::assertSame( + 'FreeDSx', + $this->subject->getDseVendorName() + ); + } + + public function test_it_can_set_dse_vendor_name(): void + { + $this->subject->setDseVendorName('Acme'); + + self::assertSame( + 'Acme', + $this->subject->getDseVendorName() + ); + } + + public function test_dse_vendor_version_is_null_by_default(): void + { + self::assertNull($this->subject->getDseVendorVersion()); + } + + public function test_it_can_set_dse_vendor_version(): void + { + $this->subject->setDseVendorVersion('1.0.0'); + + self::assertSame( + '1.0.0', + $this->subject->getDseVendorVersion() + ); + } + + public function test_backend_is_null_by_default(): void + { + self::assertNull($this->subject->getBackend()); + } + + public function test_it_can_set_backend(): void + { + $backend = $this->createMock(LdapBackendInterface::class); + + $this->subject->setBackend($backend); + + self::assertSame( + $backend, + $this->subject->getBackend() + ); + } + + public function test_password_authenticator_is_null_by_default(): void + { + self::assertNull($this->subject->getPasswordAuthenticator()); + } + + public function test_it_can_set_password_authenticator(): void + { + $authenticator = $this->createMock(PasswordAuthenticatableInterface::class); + + $this->subject->setPasswordAuthenticator($authenticator); + + self::assertSame( + $authenticator, + $this->subject->getPasswordAuthenticator() + ); + } + + public function test_root_dse_handler_is_null_by_default(): void + { + self::assertNull($this->subject->getRootDseHandler()); + } + + public function test_it_can_set_root_dse_handler(): void + { + $handler = $this->createMock(RootDseHandlerInterface::class); + + $this->subject->setRootDseHandler($handler); + + self::assertSame( + $handler, + $this->subject->getRootDseHandler() + ); + } + + public function test_write_handlers_are_empty_by_default(): void + { + self::assertSame( + [], + $this->subject->getWriteHandlers() + ); + } + + public function test_it_can_add_write_handlers(): void + { + $handler1 = $this->createMock(WriteHandlerInterface::class); + $handler2 = $this->createMock(WriteHandlerInterface::class); + + $this->subject + ->addWriteHandler($handler1) + ->addWriteHandler($handler2); + + self::assertSame( + [$handler1, $handler2], + $this->subject->getWriteHandlers() + ); + } + + public function test_filter_evaluator_is_null_by_default(): void + { + self::assertNull($this->subject->getFilterEvaluator()); + } + + public function test_it_can_set_filter_evaluator(): void + { + $evaluator = $this->createMock(FilterEvaluatorInterface::class); + + $this->subject->setFilterEvaluator($evaluator); + + self::assertSame( + $evaluator, + $this->subject->getFilterEvaluator() + ); + } + + public function test_logger_is_null_by_default(): void + { + self::assertNull($this->subject->getLogger()); + } + + public function test_it_can_set_logger(): void + { + $logger = $this->createMock(LoggerInterface::class); + + $this->subject->setLogger($logger); + + self::assertSame( + $logger, + $this->subject->getLogger() + ); + } + + public function test_server_runner_is_null_by_default(): void + { + self::assertNull($this->subject->getServerRunner()); + } + + public function test_it_can_set_server_runner(): void + { + $runner = $this->createMock(ServerRunnerInterface::class); + + $this->subject->setServerRunner($runner); + + self::assertSame( + $runner, + $this->subject->getServerRunner() + ); + } + + public function test_swoole_runner_is_disabled_by_default(): void + { + self::assertFalse($this->subject->getUseSwooleRunner()); + } + + public function test_it_can_enable_swoole_runner(): void + { + $this->subject->setUseSwooleRunner(true); + + self::assertTrue($this->subject->getUseSwooleRunner()); + } + + public function test_on_server_ready_is_null_by_default(): void + { + self::assertNull($this->subject->getOnServerReady()); + } + + public function test_it_can_set_on_server_ready(): void + { + $callback = static function (): void {}; + + $this->subject->setOnServerReady($callback); + + self::assertSame( + $callback, + $this->subject->getOnServerReady() + ); + } + public function test_it_accepts_all_defined_mechanism_constants(): void { $this->subject->setSaslMechanisms( From 3286483cb4bad9e0b2f76001f4f18e10e5f2e9f8 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Mon, 6 Apr 2026 23:06:13 -0400 Subject: [PATCH 13/35] Reorganize and add missing tests. --- tests/bin/ldapbackendstorage.php | 42 +++---- tests/integration/ServerTestCase.php | 2 +- .../Storage/LdapBackendFileStorageTest.php | 103 ++++++++++++++++++ .../LdapBackendStorageSwooleTest.php | 2 +- .../{ => Storage}/LdapBackendStorageTest.php | 58 +++++++--- .../Adapter/InMemoryStorageAdapterTest.php | 79 ++++++++++++++ .../Adapter/JsonFileStorageAdapterTest.php | 90 +++++++++++++++ 7 files changed, 339 insertions(+), 37 deletions(-) create mode 100644 tests/integration/Storage/LdapBackendFileStorageTest.php rename tests/integration/{ => Storage}/LdapBackendStorageSwooleTest.php (98%) rename tests/integration/{ => Storage}/LdapBackendStorageTest.php (86%) diff --git a/tests/bin/ldapbackendstorage.php b/tests/bin/ldapbackendstorage.php index 64eac685..4f325e3f 100644 --- a/tests/bin/ldapbackendstorage.php +++ b/tests/bin/ldapbackendstorage.php @@ -4,20 +4,6 @@ /** * Server bootstrap script for LdapBackendStorageTest. - * - * Seeds an InMemoryStorageAdapter with a small directory and starts the server - * using LdapServer::useBackend(). This exercises the full stack: - * ServerSearchHandler + ServerDispatchHandler + FilterEvaluator + InMemoryStorageAdapter. - * - * Entries seeded: - * dc=foo,dc=bar (base) - * cn=user,dc=foo,dc=bar (password: 12345) - * ou=people,dc=foo,dc=bar - * cn=alice,ou=people,dc=foo,dc=bar (sn=Smith, mail=alice@foo.bar) - * - * Usage: php ldapbackendstorage.php [transport] [runner] - * transport: tcp (default), unix - * runner: pcntl (default), swoole */ use FreeDSx\Ldap\Entry\Attribute; @@ -25,13 +11,18 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\ServerOptions; require __DIR__ . '/../../vendor/autoload.php'; $passwordHash = '{SHA}' . base64_encode(sha1('12345', true)); -$adapter = new InMemoryStorageAdapter( +$transport = $argv[1] ?? 'tcp'; +$handler = $argv[2] ?? null; + +$entries = [ new Entry( new Dn('dc=foo,dc=bar'), new Attribute('dc', 'foo'), @@ -55,10 +46,23 @@ new Attribute('sn', 'Smith'), new Attribute('mail', 'alice@foo.bar'), ), -); +]; -$transport = $argv[1] ?? 'tcp'; -$runner = $argv[2] ?? 'pcntl'; +if ($handler === 'file') { + $filePath = sys_get_temp_dir() . '/ldap_test_backend_storage.json'; + + if (file_exists($filePath)) { + unlink($filePath); + } + + $adapter = new JsonFileStorageAdapter($filePath); + + foreach ($entries as $entry) { + $adapter->add(new AddCommand($entry)); + } +} else { + $adapter = new InMemoryStorageAdapter(...$entries); +} $server = (new LdapServer( (new ServerOptions()) @@ -67,7 +71,7 @@ ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) ))->useBackend($adapter); -if ($runner === 'swoole') { +if ($handler === 'swoole') { $server->useSwooleRunner(); } diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 4f2b92ff..1b7ca650 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -259,7 +259,7 @@ private static function waitForPortOpen(int $port): void )); } - private function buildClient(string $transport): LdapClient + protected function buildClient(string $transport): LdapClient { $useSsl = false; $servers = '127.0.0.1'; diff --git a/tests/integration/Storage/LdapBackendFileStorageTest.php b/tests/integration/Storage/LdapBackendFileStorageTest.php new file mode 100644 index 00000000..b7357d2a --- /dev/null +++ b/tests/integration/Storage/LdapBackendFileStorageTest.php @@ -0,0 +1,103 @@ + + * + * 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 JsonFileStorageAdapter, + * and adds a test that verifies writes persist across separate client connections. + * + * Uses the same ldapbackendstorage.php bootstrap script with the 'file' handler, + * which seeds a JsonFileStorageAdapter and recreates the JSON file on each startup. + * + * Each mutating test restarts the server so the seeded JSON file is recreated + * cleanly, preventing cross-test pollution. + */ +final class LdapBackendFileStorageTest extends LdapBackendStorageTest +{ + /** + * Tests that mutate the JSON file 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 file-storage server. + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer('ldapbackendstorage', 'tcp', 'file'); + } + + public static function tearDownAfterClass(): void + { + static::tearDownSharedServer(); + } + + public function setUp(): void + { + parent::setUp(); + + if (in_array($this->name(), self::MUTATING_TESTS, true)) { + // Stop the shared server so the bin script recreates the JSON file. + $this->stopServer(); + $this->createServerProcess( + 'tcp', + 'file' + ); + } + } + + 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'] + )); + + // Close the first connection so the PCNTL child exits and flushes. + $this->ldapClient()->unbind(); + + // A brand-new connection to the same server must see the persisted entry. + $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/LdapBackendStorageSwooleTest.php b/tests/integration/Storage/LdapBackendStorageSwooleTest.php similarity index 98% rename from tests/integration/LdapBackendStorageSwooleTest.php rename to tests/integration/Storage/LdapBackendStorageSwooleTest.php index 668ac776..31f9cc25 100644 --- a/tests/integration/LdapBackendStorageSwooleTest.php +++ b/tests/integration/Storage/LdapBackendStorageSwooleTest.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Tests\Integration\FreeDSx\Ldap; +namespace Tests\Integration\FreeDSx\Ldap\Storage; /** * Runs the full LdapBackendStorageTest suite against the SwooleServerRunner. diff --git a/tests/integration/LdapBackendStorageTest.php b/tests/integration/Storage/LdapBackendStorageTest.php similarity index 86% rename from tests/integration/LdapBackendStorageTest.php rename to tests/integration/Storage/LdapBackendStorageTest.php index 5ac72742..0b5ef2a8 100644 --- a/tests/integration/LdapBackendStorageTest.php +++ b/tests/integration/Storage/LdapBackendStorageTest.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Tests\Integration\FreeDSx\Ldap; +namespace Tests\Integration\FreeDSx\Ldap\Storage; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\BindException; @@ -19,22 +19,8 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\Search\Filters; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; -/** - * End-to-end integration tests for BackendStorageRequestHandler wired via - * LdapServer::useStorageAdapter() with an InMemoryStorageAdapter. - * - * A single server process is started for the test class. With the PCNTL runner, - * each client connection is handled in a forked child process, so write - * operations in one test do not affect subsequent tests. - * - * The server is started via tests/bin/ldapbackendstorage.php, seeded with: - * - * dc=foo,dc=bar objectClass=domain - * cn=user,dc=foo,dc=bar userPassword={SHA}<12345> - * ou=people,dc=foo,dc=bar objectClass=organizationalUnit - * cn=alice,ou=people,dc=foo,dc=bar sn=Smith, mail=alice@foo.bar - */ class LdapBackendStorageTest extends ServerTestCase { public static function setUpBeforeClass(): void @@ -300,4 +286,44 @@ public function testCompareReturnsFalseForNonMatchingValue(): void self::assertFalse($result); } + + public function testPagingReturnsAllEntriesAcrossMultiplePages(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + $paging = $this->ldapClient()->paging($search, 2); + + $allEntries = []; + + while ($paging->hasEntries()) { + foreach ($paging->getEntries() as $entry) { + $allEntries[] = $entry->getDn()->toString(); + } + } + + // Seed has 4 entries: dc=foo,dc=bar + cn=user + ou=people + cn=alice + self::assertCount(4, $allEntries); + } + + public function testPagingCanBeAbandoned(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + $paging = $this->ldapClient()->paging($search, 1); + + // Get the first page only, then abandon + $paging->getEntries(); + $paging->end(); + + // After abandonment, hasEntries() must return false + self::assertFalse($paging->hasEntries()); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php index cc3fe165..6392e1b6 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php @@ -28,6 +28,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; use PHPUnit\Framework\TestCase; final class InMemoryStorageAdapterTest extends TestCase @@ -296,4 +297,82 @@ public function test_move_to_new_parent(): void self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); } + + public function test_supports_returns_true_for_add_command(): void + { + self::assertTrue($this->subject->supports(new AddCommand($this->alice))); + } + + public function test_supports_returns_true_for_delete_command(): void + { + self::assertTrue($this->subject->supports( + new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com') + ))); + } + + public function test_supports_returns_true_for_update_command(): void + { + self::assertTrue($this->subject->supports(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [], + ))); + } + + public function test_supports_returns_true_for_move_command(): void + { + self::assertTrue($this->subject->supports(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alice'), + false, + null, + ))); + } + + public function test_supports_returns_false_for_unknown_request(): void + { + $unknown = $this->createMock(WriteRequestInterface::class); + + self::assertFalse($this->subject->supports($unknown)); + } + + public function test_handle_dispatches_add_command(): void + { + $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); + $this->subject->handle(new AddCommand($entry)); + + self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); + } + + public function test_handle_dispatches_delete_command(): void + { + $this->subject->handle(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_handle_dispatches_update_command(): void + { + $this->subject->handle(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], + )); + + self::assertSame( + ['Alicia'], + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('cn')?->getValues() + ); + } + + public function test_handle_dispatches_move_command(): void + { + $this->subject->handle(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), + true, + null, + )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php index 1e5a6b1f..23132c35 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php @@ -28,6 +28,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; use PHPUnit\Framework\TestCase; final class JsonFileStorageAdapterTest extends TestCase @@ -335,4 +336,93 @@ public function test_move_to_new_parent(): void self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); } + + public function test_get_on_empty_file_returns_null(): void + { + file_put_contents($this->tempFile, ''); + + $adapter = new JsonFileStorageAdapter($this->tempFile); + + self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_get_on_invalid_json_returns_null(): void + { + file_put_contents($this->tempFile, 'not valid json {{{'); + + $adapter = new JsonFileStorageAdapter($this->tempFile); + + self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_get_uses_in_memory_cache_on_subsequent_calls(): void + { + // First call populates the cache. + $first = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + // Corrupt the file — a cache-bypassing read would return null. + file_put_contents($this->tempFile, 'corrupted'); + + // Touch the file so its mtime changes and the adapter re-reads it. + // We want to verify only that a second add() does not use stale cache; + // here we instead verify the same-mtime cache hit path: restore the + // file, then call get() again without advancing mtime. + // Since we just wrote garbage, the cache was invalidated by withLock. + // Instead test the cache HIT by doing two get() calls without any write. + $adapter = new JsonFileStorageAdapter($this->tempFile); + + // Prime the cache on first call (returns null from corrupted file). + $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); + + // Second call on same adapter+same mtime must use the in-memory cache. + // We verify this indirectly: overwriting the file again would change its + // mtime and cause a re-read, so we simply assert consistency. + $result = $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertNull($result); + self::assertNotNull($first); + } + + public function test_get_cache_is_invalidated_when_file_mtime_changes(): void + { + $adapter = new JsonFileStorageAdapter($this->tempFile); + + // Prime the cache with a valid file (contains Alice). + self::assertNotNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); + + // A write operation (add) changes the file — cache is cleared. + $extra = new Entry(new Dn('cn=Extra,dc=example,dc=com'), new Attribute('cn', 'Extra')); + $adapter->add(new AddCommand($extra)); + + // The adapter must re-read the file and see the new entry. + self::assertNotNull($adapter->get(new Dn('cn=Extra,dc=example,dc=com'))); + } + + public function test_supports_returns_true_for_all_write_commands(): void + { + self::assertTrue($this->subject->supports(new AddCommand($this->alice))); + self::assertTrue($this->subject->supports(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com')))); + self::assertTrue($this->subject->supports(new UpdateCommand(new Dn('cn=Alice,dc=example,dc=com'), []))); + self::assertTrue($this->subject->supports(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alice'), + false, + null, + ))); + } + + public function test_supports_returns_false_for_unknown_request(): void + { + $unknown = $this->createMock(WriteRequestInterface::class); + + self::assertFalse($this->subject->supports($unknown)); + } + + public function test_handle_dispatches_to_correct_method(): void + { + $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); + $this->subject->handle(new AddCommand($entry)); + + self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); + } } From 7b07ad2754e5456b6bd46b6d97a2d118707497f3 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 10:32:55 -0400 Subject: [PATCH 14/35] Make socket accept timeout configurable. --- docs/Server/Configuration.md | 10 ++++++++++ .../Server/ServerRunner/PcntlServerRunner.php | 7 +------ .../Server/ServerRunner/SwooleServerRunner.php | 7 +------ src/FreeDSx/Ldap/ServerOptions.php | 17 +++++++++++++++++ tests/unit/ServerOptionsTest.php | 18 ++++++++++++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index 78e36ddc..dc299af3 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -10,6 +10,7 @@ LDAP Server Configuration * [ServerOptions:setIdleTimeout](#setidletimeout) * [ServerOptions:setRequireAuthentication](#setrequireauthentication) * [ServerOptions:setAllowAnonymous](#setallowanonymous) + * [ServerOptions:setSocketAcceptTimeout](#setsocketaccepttimeout) * [Backend](#backend) * [ServerOptions:setBackend](#setbackend) * [ServerOptions:setFilterEvaluator](#setfilterevaluator) @@ -123,6 +124,15 @@ Whether anonymous binds should be allowed. **Default**: `false` +------------------ +#### setSocketAcceptTimeout + +The number of seconds (fractional) to wait for a new client connection before re-checking server state. Lower values +make the server more responsive to shutdown signals and connection-limit changes at the cost of slightly more CPU usage +in the accept loop. + +**Default**: `0.5` + ## Backend diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 37121d9e..7b920321 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -34,11 +34,6 @@ class PcntlServerRunner implements ServerRunnerInterface { use ServerRunnerLoggerTrait; - /** - * The time to wait, in seconds, before we run some clean-up tasks to then wait again. - */ - private const SOCKET_ACCEPT_TIMEOUT = 0.5; - private SocketServer $server; /** @@ -150,7 +145,7 @@ private function acceptClients(): void $this->options->getOnServerReady()?->__invoke(); do { - $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + $socket = $this->server->accept($this->options->getSocketAcceptTimeout()); if ($this->isShuttingDown) { if ($socket) { diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index a9200492..fe2a4bcd 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -51,11 +51,6 @@ class SwooleServerRunner implements ServerRunnerInterface { use ServerRunnerLoggerTrait; - /** - * Seconds to wait for a new client before re-checking the server connection state. - */ - private const SOCKET_ACCEPT_TIMEOUT = 0.5; - private SocketServer $server; private bool $isShuttingDown = false; @@ -182,7 +177,7 @@ private function acceptClients(): void while (!$this->isShuttingDown) { try { - $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + $socket = $this->server->accept($this->options->getSocketAcceptTimeout()); } catch (Throwable $e) { $this->options->getLogger()?->error( 'SwooleServerRunner: accept() failed: ' . $e->getMessage() diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 4a9d81a4..6e5a57e9 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -133,6 +133,8 @@ final class ServerOptions private int $shutdownTimeout = 15; + private float $socketAcceptTimeout = 0.5; + /** * @var string[] */ @@ -484,6 +486,21 @@ public function setShutdownTimeout(int $shutdownTimeout): self return $this; } + /** + * Seconds (fractional) to wait for a new client connection before re-checking server state. + */ + public function getSocketAcceptTimeout(): float + { + return $this->socketAcceptTimeout; + } + + public function setSocketAcceptTimeout(float $socketAcceptTimeout): self + { + $this->socketAcceptTimeout = $socketAcceptTimeout; + + return $this; + } + public function getUseSwooleRunner(): bool { return $this->useSwooleRunner; diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index c9557a4f..e44d42ba 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -482,6 +482,24 @@ public function test_it_can_enable_swoole_runner(): void self::assertTrue($this->subject->getUseSwooleRunner()); } + public function test_socket_accept_timeout_defaults_to_half_second(): void + { + self::assertSame( + 0.5, + $this->subject->getSocketAcceptTimeout() + ); + } + + public function test_it_can_set_socket_accept_timeout(): void + { + $this->subject->setSocketAcceptTimeout(1.0); + + self::assertSame( + 1.0, + $this->subject->getSocketAcceptTimeout() + ); + } + public function test_on_server_ready_is_null_by_default(): void { self::assertNull($this->subject->getOnServerReady()); From 42fc26bd3fdc38ad6d121302158b09b854e27f20 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 11:32:18 -0400 Subject: [PATCH 15/35] Move the socket server factory into the server runners. Remove it from the interface. --- src/FreeDSx/Ldap/Container.php | 1 + src/FreeDSx/Ldap/LdapServer.php | 9 ++------- .../Ldap/Server/ServerRunner/PcntlServerRunner.php | 6 ++++-- .../Server/ServerRunner/ServerRunnerInterface.php | 4 +--- .../Ldap/Server/ServerRunner/SwooleServerRunner.php | 13 +------------ tests/unit/LdapServerTest.php | 4 +--- 6 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index bb850e56..ed42b9d3 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -220,6 +220,7 @@ private function makeServerRunner(): ServerRunnerInterface return new PcntlServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), options: $options, + socketServerFactory: $this->get(SocketServerFactory::class), ); } diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 8d5fef52..f7cc328e 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -21,7 +21,6 @@ use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; -use FreeDSx\Ldap\Server\SocketServerFactory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Socket\Exception\ConnectionException; use Psr\Log\LoggerInterface; @@ -45,7 +44,7 @@ public function __construct( } /** - * Runs the LDAP server. Binds the socket on the request IP/port and sends it to the server runner. + * Runs the LDAP server. Binds the socket and starts accepting client connections. * * @throws ConnectionException * @throws InvalidArgumentException @@ -56,11 +55,7 @@ public function run(): void $runner = $this->options->getServerRunner() ?? $this->container->get(ServerRunnerInterface::class); - $socketServer = $this->container - ->get(SocketServerFactory::class) - ->makeAndBind(); - - $runner->run($socketServer); + $runner->run(); } /** diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 7b920321..a9c03a56 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Server\ChildProcess; use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\SocketServerFactory; use FreeDSx\Socket\Socket; use FreeDSx\Socket\SocketServer; use FreeDSx\Ldap\ServerOptions; @@ -65,6 +66,7 @@ class PcntlServerRunner implements ServerRunnerInterface public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, private readonly ServerOptions $options, + private readonly SocketServerFactory $socketServerFactory, ) { if (!extension_loaded('pcntl')) { throw new RuntimeException( @@ -91,9 +93,9 @@ public function __construct( /** * @throws EncoderException */ - public function run(SocketServer $server): void + public function run(): void { - $this->server = $server; + $this->server = $this->socketServerFactory->makeAndBind(); try { $this->acceptClients(); diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerInterface.php b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerInterface.php index 221d8bf4..aa4c98d9 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerInterface.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerInterface.php @@ -13,8 +13,6 @@ namespace FreeDSx\Ldap\Server\ServerRunner; -use FreeDSx\Socket\SocketServer; - /** * Runs the TCP server, accepts client connections, dispatches client connections to the server protocol handler. * @@ -25,5 +23,5 @@ interface ServerRunnerInterface /** * Runs the socket server to accept incoming client connections and dispatch them to the protocol handler. */ - public function run(SocketServer $server): void; + public function run(): void; } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index fe2a4bcd..3ccb7a41 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -40,10 +40,6 @@ * * Note on socket creation: the SocketServer must be created inside Coroutine\run() * for Swoole's stream hooks to intercept stream_socket_accept() as a yielding call. - * If the socket is created before the event loop starts, stream_socket_accept() - * blocks the event loop entirely and signals are never delivered. The run() method - * therefore closes the passed SocketServer immediately and uses the injected - * SocketServerFactory to recreate it inside the coroutine. * * @author Chad Sikorra */ @@ -92,15 +88,8 @@ public function __construct( $this->waitGroup = new WaitGroup(); } - public function run(SocketServer $server): void + public function run(): void { - // Close the socket that was created outside the coroutine context. - // Swoole's stream hook only intercepts stream_socket_accept() as a - // yielding call when the socket is created inside Coroutine\run(). - // A socket created before the event loop starts causes stream_socket_accept() - // to block the loop entirely, preventing signal delivery. - $server->close(); - Coroutine\run(function (): void { $this->server = $this->socketServerFactory->makeAndBind(); $this->registerShutdownSignals(); diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index 0ff68775..fa745b14 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -24,7 +24,6 @@ use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Socket\SocketServer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -57,8 +56,7 @@ public function test_it_should_run_the_server(): void { $this->mockServerRunner ->expects(self::once()) - ->method('run') - ->with(self::isInstanceOf(SocketServer::class)); + ->method('run'); $this->subject->run(); } From 8a08723204b6f62533d93c9786d9222abf4a9d1e Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 11:32:42 -0400 Subject: [PATCH 16/35] Add swoole ide-helper as a dev dependency for IDE support. --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 89b296b6..bb4f7baf 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "squizlabs/php_codesniffer": "^3.7", "slevomat/coding-standard": "^7.2", "phpstan/phpstan-phpunit": "^2.0", - "phpstan/extension-installer": "^1.4" + "phpstan/extension-installer": "^1.4", + "swoole/ide-helper": "^6.0" }, "suggest": { "ext-openssl": "For SSL/TLS support and some SASL mechanisms.", From 83da1f6a544985422f9d24067d2fb7d8f6964928 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 11:47:49 -0400 Subject: [PATCH 17/35] Rename the various integration scripts, so it's more obvious what they are doing (as there are quite a few of them now). --- .../bin/{ldapbackendstorage.php => ldap-backend-storage.php} | 0 tests/bin/{ldapproxy.php => ldap-proxy.php} | 0 tests/bin/{ldapserver.php => ldap-server.php} | 0 tests/bin/{ldapsyncwrite.php => ldap-sync-write.php} | 2 +- tests/integration/LdapProxyTest.php | 4 ++-- tests/integration/LdapSaslServerTest.php | 2 +- tests/integration/LdapServerTest.php | 4 ++-- tests/integration/ServerTestCase.php | 4 ++-- tests/integration/Storage/LdapBackendFileStorageTest.php | 4 ++-- tests/integration/Storage/LdapBackendStorageSwooleTest.php | 2 +- tests/integration/Storage/LdapBackendStorageTest.php | 4 ++-- tests/integration/Sync/SyncReplTest.php | 2 +- tests/resources/openldap/docker-entrypoint.sh | 4 ++-- 13 files changed, 16 insertions(+), 16 deletions(-) rename tests/bin/{ldapbackendstorage.php => ldap-backend-storage.php} (100%) rename tests/bin/{ldapproxy.php => ldap-proxy.php} (100%) rename tests/bin/{ldapserver.php => ldap-server.php} (100%) rename tests/bin/{ldapsyncwrite.php => ldap-sync-write.php} (95%) diff --git a/tests/bin/ldapbackendstorage.php b/tests/bin/ldap-backend-storage.php similarity index 100% rename from tests/bin/ldapbackendstorage.php rename to tests/bin/ldap-backend-storage.php diff --git a/tests/bin/ldapproxy.php b/tests/bin/ldap-proxy.php similarity index 100% rename from tests/bin/ldapproxy.php rename to tests/bin/ldap-proxy.php diff --git a/tests/bin/ldapserver.php b/tests/bin/ldap-server.php similarity index 100% rename from tests/bin/ldapserver.php rename to tests/bin/ldap-server.php diff --git a/tests/bin/ldapsyncwrite.php b/tests/bin/ldap-sync-write.php similarity index 95% rename from tests/bin/ldapsyncwrite.php rename to tests/bin/ldap-sync-write.php index 42bdc66f..065379af 100644 --- a/tests/bin/ldapsyncwrite.php +++ b/tests/bin/ldap-sync-write.php @@ -20,7 +20,7 @@ $signalFile = $argv[1] ?? null; if ($signalFile === null) { - fwrite(STDERR, 'Usage: ldapsyncwrite.php ' . PHP_EOL); + fwrite(STDERR, 'Usage: ldap-sync-write.php ' . PHP_EOL); exit(1); } diff --git a/tests/integration/LdapProxyTest.php b/tests/integration/LdapProxyTest.php index 525f32ed..5b500a31 100644 --- a/tests/integration/LdapProxyTest.php +++ b/tests/integration/LdapProxyTest.php @@ -27,7 +27,7 @@ public static function setUpBeforeClass(): void } static::initSharedServer( - 'ldapproxy', + 'ldap-proxy', 'tcp', ); } @@ -40,7 +40,7 @@ public static function tearDownAfterClass(): void public function setUp(): void { - $this->setServerMode('ldapproxy'); + $this->setServerMode('ldap-proxy'); parent::setUp(); } diff --git a/tests/integration/LdapSaslServerTest.php b/tests/integration/LdapSaslServerTest.php index 73ce4e8a..f62452e2 100644 --- a/tests/integration/LdapSaslServerTest.php +++ b/tests/integration/LdapSaslServerTest.php @@ -21,7 +21,7 @@ final class LdapSaslServerTest extends ServerTestCase { public function setUp(): void { - $this->setServerMode('ldapserver'); + $this->setServerMode('ldap-server'); parent::setUp(); diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index 813adff5..702543ad 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void return; } - static::initSharedServer('ldapserver', 'tcp'); + static::initSharedServer('ldap-server', 'tcp'); } public static function tearDownAfterClass(): void @@ -41,7 +41,7 @@ public static function tearDownAfterClass(): void public function setUp(): void { - $this->setServerMode('ldapserver'); + $this->setServerMode('ldap-server'); parent::setUp(); } diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 1b7ca650..56466936 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -34,7 +34,7 @@ class ServerTestCase extends LdapTestCase /** * Stored so the shared server can be restarted after a test that stops it. */ - private static string $sharedMode = 'ldapserver'; + private static string $sharedMode = 'ldap-server'; private static string $sharedTransport = 'tcp'; @@ -55,7 +55,7 @@ class ServerTestCase extends LdapTestCase */ private bool $needsSharedRestart = false; - private string $serverMode = 'ldapserver'; + private string $serverMode = 'ldap-server'; public function setUp(): void { diff --git a/tests/integration/Storage/LdapBackendFileStorageTest.php b/tests/integration/Storage/LdapBackendFileStorageTest.php index b7357d2a..40ea2ca5 100644 --- a/tests/integration/Storage/LdapBackendFileStorageTest.php +++ b/tests/integration/Storage/LdapBackendFileStorageTest.php @@ -21,7 +21,7 @@ * Runs the full LdapBackendStorageTest suite against JsonFileStorageAdapter, * and adds a test that verifies writes persist across separate client connections. * - * Uses the same ldapbackendstorage.php bootstrap script with the 'file' handler, + * Uses the same ldap-backend-storage.php bootstrap script with the 'file' handler, * which seeds a JsonFileStorageAdapter and recreates the JSON file on each startup. * * Each mutating test restarts the server so the seeded JSON file is recreated @@ -48,7 +48,7 @@ public static function setUpBeforeClass(): void return; } - static::initSharedServer('ldapbackendstorage', 'tcp', 'file'); + static::initSharedServer('ldap-backend-storage', 'tcp', 'file'); } public static function tearDownAfterClass(): void diff --git a/tests/integration/Storage/LdapBackendStorageSwooleTest.php b/tests/integration/Storage/LdapBackendStorageSwooleTest.php index 31f9cc25..ff46bc01 100644 --- a/tests/integration/Storage/LdapBackendStorageSwooleTest.php +++ b/tests/integration/Storage/LdapBackendStorageSwooleTest.php @@ -51,7 +51,7 @@ public static function setUpBeforeClass(): void } static::initSharedServer( - 'ldapbackendstorage', + 'ldap-backend-storage', 'tcp', 'swoole', ); diff --git a/tests/integration/Storage/LdapBackendStorageTest.php b/tests/integration/Storage/LdapBackendStorageTest.php index 0b5ef2a8..9feb7bc8 100644 --- a/tests/integration/Storage/LdapBackendStorageTest.php +++ b/tests/integration/Storage/LdapBackendStorageTest.php @@ -31,7 +31,7 @@ public static function setUpBeforeClass(): void return; } - static::initSharedServer('ldapbackendstorage', 'tcp'); + static::initSharedServer('ldap-backend-storage', 'tcp'); } public static function tearDownAfterClass(): void @@ -42,7 +42,7 @@ public static function tearDownAfterClass(): void public function setUp(): void { - $this->setServerMode('ldapbackendstorage'); + $this->setServerMode('ldap-backend-storage'); parent::setUp(); } diff --git a/tests/integration/Sync/SyncReplTest.php b/tests/integration/Sync/SyncReplTest.php index 579bc759..4ab3df4b 100644 --- a/tests/integration/Sync/SyncReplTest.php +++ b/tests/integration/Sync/SyncReplTest.php @@ -145,7 +145,7 @@ private function syncWriteProcess(): Process { $process = new Process([ 'php', - __DIR__ . '/../../bin/ldapsyncwrite.php', + __DIR__ . '/../../bin/ldap-sync-write.php', $this->syncSignalFile, ]); $process->setTimeout(60); diff --git a/tests/resources/openldap/docker-entrypoint.sh b/tests/resources/openldap/docker-entrypoint.sh index 5500173d..181bda0b 100755 --- a/tests/resources/openldap/docker-entrypoint.sh +++ b/tests/resources/openldap/docker-entrypoint.sh @@ -72,8 +72,8 @@ chown openldap:openldap /var/run/slapd # Copy certs to the volume mount if provided. # ca.crt - needed by the PHP test runner to validate TLS connections to OpenLDAP -# slapd.crt - used by the PHP LDAP server (ldapserver.php) for TLS -# slapd.key - used by the PHP LDAP server (ldapserver.php) for TLS +# slapd.crt - used by the PHP LDAP server (ldap-server.php) for TLS +# slapd.key - used by the PHP LDAP server (ldap-server.php) for TLS if [ -d "/cert-output" ]; then cp ${CA_CERT} /cert-output/ca.crt cp ${SLAPD_CERT} /cert-output/slapd.crt From 719e104d726134fc5bfd93cd18e741edd35a3e96 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 12:47:39 -0400 Subject: [PATCH 18/35] Add a locking mechanism so the json file storage mech is safe in either pcntl or swoole. --- docs/Server/General-Usage.md | 11 +- phpstan.neon | 2 + .../Adapter/JsonFileStorageAdapter.php | 424 ++++++++++-------- .../Storage/Adapter/Lock/CoroutineLock.php | 89 ++++ .../Backend/Storage/Adapter/Lock/FileLock.php | 101 +++++ .../Adapter/Lock/StorageLockInterface.php | 30 ++ tests/bin/ldap-backend-storage.php | 2 +- .../Adapter/JsonFileStorageAdapterTest.php | 47 +- 8 files changed, 487 insertions(+), 219 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/CoroutineLock.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/FileLock.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/StorageLockInterface.php diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index ae207983..265e177d 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -227,14 +227,17 @@ comparisons. No password scheme is set automatically — the hash format must be A file-backed adapter that persists the directory as a JSON file. Safe for PCNTL (write operations are serialised with `flock(LOCK_EX)` and the in-memory cache is invalidated via `filemtime` checks). -**Note**: Not suitable for Swoole — the blocking `flock`/`fread` calls will stall the event loop. Use -`InMemoryStorageAdapter` with Swoole instead. +Use the named constructor that matches your server runner: ```php use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; -$adapter = new JsonFileStorageAdapter('/var/lib/myapp/ldap.json'); +// PCNTL runner — uses flock() to serialise writes across forked processes +$adapter = JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json'); + +// Swoole runner — uses a coroutine Channel mutex and non-blocking file I/O +$adapter = JsonFileStorageAdapter::forSwoole('/var/lib/myapp/ldap.json'); $server = (new LdapServer())->useBackend($adapter); $server->run(); diff --git a/phpstan.neon b/phpstan.neon index 1b26d416..d23dec32 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,8 @@ parameters: level: 9 paths: - %currentWorkingDirectory%/src + scanDirectories: + - %currentWorkingDirectory%/vendor/swoole/ide-helper/src treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false ignoreErrors: diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php index afab7757..a3283917 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php @@ -18,10 +18,12 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\SearchContext; +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\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -33,14 +35,10 @@ /** * A file-backed storage adapter that persists the directory as a JSON file. * - * Safe for use with the PCNTL server runner: write operations are serialised - * using flock(LOCK_EX), so concurrent child processes do not corrupt the file. - * An in-memory cache is invalidated via filemtime checks to avoid re-reading - * the file on every read operation within a single forked process. + * Use the named constructors to select the correct locking strategy: * - * Note: when used with the Swoole server runner, standard flock/fread calls - * are blocking and will stall the event loop. Use the InMemoryStorageAdapter - * with Swoole, or a Swoole-coroutine-aware file I/O layer instead. + * JsonFileStorageAdapter::forPcntl('/path/to/file.json') + * JsonFileStorageAdapter::forSwoole('/path/to/file.json') * * JSON format: * { @@ -67,8 +65,30 @@ class JsonFileStorageAdapter implements WritableLdapBackendInterface private int $cacheMtime = 0; - public function __construct(private readonly string $filePath) - { + private function __construct( + private readonly string $filePath, + private readonly StorageLockInterface $lock, + ) { + } + + public static function forPcntl( + string $filePath, + ?StorageLockInterface $lock = null, + ): self { + return new self( + $filePath, + $lock ?? new FileLock($filePath) + ); + } + + public static function forSwoole( + string $filePath, + ?StorageLockInterface $lock = null, + ): self { + return new self( + $filePath, + $lock ?? new CoroutineLock($filePath) + ); } public function get(Dn $dn): ?Entry @@ -92,136 +112,206 @@ public function search(SearchContext $context): Generator */ public function add(AddCommand $command): void { - $entry = $command->entry; - $this->withLock(function (array $data) use ($entry): array { - $normDn = $this->normalise($entry->getDn()); + $this->withMutation( + fn(string $contents): string => $this->executeAdd( + $contents, + $command + ) + ); + } - if (isset($data[$normDn])) { - throw new OperationException( - sprintf('Entry already exists: %s', $entry->getDn()->toString()), - ResultCode::ENTRY_ALREADY_EXISTS, - ); - } + public function delete(DeleteCommand $command): void + { + $this->withMutation( + fn(string $contents): string => $this->executeDelete( + $contents, + $command + ) + ); + } - $data[$normDn] = $this->entryToArray($entry); + public function update(UpdateCommand $command): void + { + $this->withMutation( + fn(string $contents): string => $this->executeUpdate( + $contents, + $command + ) + ); + } - return $data; - }); + public function move(MoveCommand $command): void + { + $this->withMutation( + fn(string $contents): string => $this->executeMove( + $contents, + $command + ) + ); } - public function delete(DeleteCommand $command): void + /** + * @param callable(string): string $mutation + */ + private function withMutation(callable $mutation): void { - $normDn = $this->normalise($command->dn); - $this->withLock(function (array $data) use ($normDn, $command): array { - foreach (array_keys($data) as $key) { - if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { - throw new OperationException( - sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), - ResultCode::NOT_ALLOWED_ON_NON_LEAF, - ); - } - } + try { + $this->lock->withLock($mutation); + } finally { + $this->cache = null; + } + } + + /** + * @throws OperationException + */ + private function executeAdd( + string $contents, + AddCommand $command, + ): string { + $data = $this->decodeContents($contents); + $normDn = $this->normalise($command->entry->getDn()); + + if (isset($data[$normDn])) { + throw new OperationException( + sprintf('Entry already exists: %s', $command->entry->getDn()->toString()), + ResultCode::ENTRY_ALREADY_EXISTS, + ); + } - unset($data[$normDn]); + $data[$normDn] = $this->entryToArray($command->entry); - return $data; - }); + return $this->encodeContents($data); } - public function update(UpdateCommand $command): void - { + /** + * @throws OperationException + */ + private function executeDelete( + string $contents, + DeleteCommand $command, + ): string { + $data = $this->decodeContents($contents); $normDn = $this->normalise($command->dn); - $this->withLock(function (array $data) use ($normDn, $command): array { - if (!isset($data[$normDn])) { + + foreach (array_keys($data) as $key) { + if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, + sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, ); } + } - $entry = $this->arrayToEntry($data[$normDn]); - - foreach ($command->changes as $change) { - $attribute = $change->getAttribute(); - $attrName = $attribute->getName(); - $values = $attribute->getValues(); - - switch ($change->getType()) { - case Change::TYPE_ADD: - $existing = $entry->get($attrName); - if ($existing !== null) { - $existing->add(...$values); - } else { - $entry->add($attribute); - } - break; - - case Change::TYPE_DELETE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->get($attrName)?->remove(...$values); - } - break; - - case Change::TYPE_REPLACE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->set($attribute); - } - break; - } + unset($data[$normDn]); + + return $this->encodeContents($data); + } + + /** + * @throws OperationException + */ + private function executeUpdate( + string $contents, + UpdateCommand $command, + ): string { + $data = $this->decodeContents($contents); + $normDn = $this->normalise($command->dn); + + if (!isset($data[$normDn])) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $entry = $this->arrayToEntry($data[$normDn]); + + foreach ($command->changes as $change) { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $values = $attribute->getValues(); + + switch ($change->getType()) { + case Change::TYPE_ADD: + $existing = $entry->get($attrName); + if ($existing !== null) { + $existing->add(...$values); + } else { + $entry->add($attribute); + } + break; + + case Change::TYPE_DELETE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->get($attrName)?->remove(...$values); + } + break; + + case Change::TYPE_REPLACE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->set($attribute); + } + break; } + } - $data[$normDn] = $this->entryToArray($entry); + $data[$normDn] = $this->entryToArray($entry); - return $data; - }); + return $this->encodeContents($data); } - public function move(MoveCommand $command): void - { + /** + * @throws OperationException + */ + private function executeMove( + string $contents, + MoveCommand $command, + ): string { + $data = $this->decodeContents($contents); $normOld = $this->normalise($command->dn); - $this->withLock(function (array $data) use ($normOld, $command): array { - if (!isset($data[$normOld])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - $entry = $this->arrayToEntry($data[$normOld]); + if (!isset($data[$normOld])) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } - $parent = $command->newParent ?? $command->dn->getParent(); - $newDnString = $parent !== null - ? $command->newRdn->toString() . ',' . $parent->toString() - : $command->newRdn->toString(); + $entry = $this->arrayToEntry($data[$normOld]); - $newDn = new Dn($newDnString); - $newEntry = new Entry($newDn, ...$entry->getAttributes()); + $parent = $command->newParent ?? $command->dn->getParent(); + $newDnString = $parent !== null + ? $command->newRdn->toString() . ',' . $parent->toString() + : $command->newRdn->toString(); - if ($command->deleteOldRdn) { - $oldRdn = $command->dn->getRdn(); - $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); - } + $newDn = new Dn($newDnString); + $newEntry = new Entry($newDn, ...$entry->getAttributes()); - $rdnName = $command->newRdn->getName(); - $rdnValue = $command->newRdn->getValue(); - $existing = $newEntry->get($rdnName); - if ($existing !== null) { - if (!$existing->has($rdnValue)) { - $existing->add($rdnValue); - } - } else { - $newEntry->set(new Attribute($rdnName, $rdnValue)); + if ($command->deleteOldRdn) { + $oldRdn = $command->dn->getRdn(); + $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); + } + + $rdnName = $command->newRdn->getName(); + $rdnValue = $command->newRdn->getValue(); + $existing = $newEntry->get($rdnName); + if ($existing !== null) { + if (!$existing->has($rdnValue)) { + $existing->add($rdnValue); } + } else { + $newEntry->set(new Attribute($rdnName, $rdnValue)); + } - unset($data[$normOld]); - $data[$this->normalise($newDn)] = $this->entryToArray($newEntry); + unset($data[$normOld]); + $data[$this->normalise($newDn)] = $this->entryToArray($newEntry); - return $data; - }); + return $this->encodeContents($data); } /** @@ -251,20 +341,8 @@ private function read(): array return $this->cache; } - $raw = json_decode($contents, true); - - if (!is_array($raw)) { - $this->cache = []; - $this->cacheMtime = $mtime; - - return $this->cache; - } - $entries = []; - foreach ($raw as $normDn => $data) { - if (!is_string($normDn)) { - continue; - } + foreach ($this->decodeContents($contents) as $normDn => $data) { $entries[$normDn] = $this->arrayToEntry($data); } @@ -275,97 +353,50 @@ private function read(): array } /** - * Open the file with an exclusive lock, call $mutation with the current - * data array, write back the result, then release the lock. - * - * @param callable(array): array $mutation + * @return array{dn: string, attributes: array>} */ - private function withLock(callable $mutation): void + private function entryToArray(Entry $entry): array { - $handle = fopen($this->filePath, 'c+'); - - if ($handle === false) { - throw new RuntimeException(sprintf( - 'Unable to open storage file: %s', - $this->filePath - )); - } - - if (!flock($handle, LOCK_EX)) { - fclose($handle); - throw new RuntimeException(sprintf( - 'Unable to acquire exclusive lock on storage file: %s', - $this->filePath - )); + $attributes = []; + foreach ($entry->getAttributes() as $attribute) { + $attributes[$attribute->getName()] = array_values($attribute->getValues()); } - try { - $data = $mutation($this->readFromHandle($handle)); - $this->writeToHandle($handle, $data); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - $this->cache = null; - } + return [ + 'dn' => $entry->getDn()->toString(), + 'attributes' => $attributes, + ]; } /** - * Read and decode the current JSON contents from an open file handle. - * - * @param resource $handle * @return array */ - private function readFromHandle(mixed $handle): array + private function decodeContents(string $contents): array { - $size = fstat($handle)['size'] ?? 0; - $contents = $size > 0 ? fread($handle, $size) : ''; - $rawDecoded = ($contents !== '' && $contents !== false) - ? json_decode($contents, true) - : null; - - if (!is_array($rawDecoded)) { + if ($contents === '') { return []; } - $data = []; - foreach ($rawDecoded as $key => $value) { - if (is_string($key)) { - $data[$key] = $value; - } + $raw = json_decode($contents, true); + + if (!is_array($raw)) { + return []; } - return $data; + return array_filter($raw, function ($key) { + return is_string($key); + }, ARRAY_FILTER_USE_KEY); } /** - * Encode and write data back to an open file handle, truncating first. - * - * @param resource $handle * @param array $data */ - private function writeToHandle( - mixed $handle, - array $data, - ): void { - ftruncate($handle, 0); - rewind($handle); - fwrite($handle, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}'); - } - - /** - * @return array{dn: string, attributes: array>} - */ - private function entryToArray(Entry $entry): array + private function encodeContents(array $data): string { - $attributes = []; - foreach ($entry->getAttributes() as $attribute) { - $attributes[$attribute->getName()] = array_values($attribute->getValues()); - } - - return [ - 'dn' => $entry->getDn()->toString(), - 'attributes' => $attributes, - ]; + return json_encode( + $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + ) ?: '{}'; } private function arrayToEntry(mixed $data): Entry @@ -388,7 +419,10 @@ private function arrayToEntry(mixed $data): Entry $stringValues[] = $v; } } - $attributes[] = new Attribute($name, ...$stringValues); + $attributes[] = new Attribute( + $name, + ...$stringValues + ); } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/CoroutineLock.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/CoroutineLock.php new file mode 100644 index 00000000..35daddec --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/CoroutineLock.php @@ -0,0 +1,89 @@ + + * + * 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\Lock; + +use FreeDSx\Ldap\Exception\RuntimeException; +use Swoole\Coroutine; +use Swoole\Coroutine\Channel; + +/** + * Locking strategy for the Swoole server runner. + * + * Uses a Swoole\Coroutine\Channel(1) as a coroutine-safe mutex. + * + * @author Chad Sikorra + */ +final class CoroutineLock implements StorageLockInterface +{ + /** @var Channel|null */ + private ?Channel $mutex = null; + + public function __construct(private readonly string $filePath) + { + } + + public function withLock(callable $mutation): void + { + $mutex = $this->getOrCreateMutex(); + $mutex->pop(); + + try { + $result = $mutation($this->read()); + $this->write($result); + } finally { + $mutex->push(true); + } + } + + /** @return Channel */ + private function getOrCreateMutex(): Channel + { + if ($this->mutex === null) { + $this->mutex = self::createMutex(); + } + + return $this->mutex; + } + + /** @return Channel */ + private static function createMutex(): Channel + { + $mutex = new Channel(1); + $mutex->push(true); + + return $mutex; + } + + private function read(): string + { + $contents = Coroutine\System::readFile($this->filePath); + + return ($contents !== false) ? $contents : ''; + } + + private function write(string $contents): void + { + $result = Coroutine\System::writeFile( + $this->filePath, + $contents + ); + + if ($result === false) { + throw new RuntimeException(sprintf( + 'Unable to write to storage file: %s', + $this->filePath + )); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/FileLock.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/FileLock.php new file mode 100644 index 00000000..8561a0dc --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/FileLock.php @@ -0,0 +1,101 @@ + + * + * 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\Lock; + +use FreeDSx\Ldap\Exception\RuntimeException; + +/** + * Locking strategy for the PCNTL server runner. + * + * Uses fopen + flock(LOCK_EX) to serialize concurrent writes across forked child processes. + * + * @author Chad Sikorra + */ +class FileLock implements StorageLockInterface +{ + public function __construct(private readonly string $filePath) + { + } + + public function withLock(callable $mutation): void + { + $handle = fopen( + $this->filePath, + 'c+' + ); + + if ($handle === false) { + throw new RuntimeException(sprintf( + 'Unable to open storage file: %s', + $this->filePath + )); + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + + throw new RuntimeException(sprintf( + 'Unable to acquire exclusive lock on storage file: %s', + $this->filePath + )); + } + + try { + $result = $mutation($this->readFromHandle($handle)); + + $this->writeToHandle( + $handle, + $result + ); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * Read the raw file contents from an open file handle. + * + * @param resource $handle + */ + private function readFromHandle(mixed $handle): string + { + $size = fstat($handle)['size'] ?? 0; + + if ($size <= 0) { + return ''; + } + + $contents = fread( + $handle, + $size + ); + + return $contents !== false ? $contents : ''; + } + + /** + * Write a string back to an open file handle, truncating first. + * + * @param resource $handle + */ + private function writeToHandle( + mixed $handle, + string $contents, + ): void { + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, $contents); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/StorageLockInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/StorageLockInterface.php new file mode 100644 index 00000000..9b4020cc --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Lock/StorageLockInterface.php @@ -0,0 +1,30 @@ + + * + * 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\Lock; + +/** + * Defines the strategy for atomically reading, mutating, and writing data. + * + * @author Chad Sikorra + */ +interface StorageLockInterface +{ + /** + * Acquire exclusive access to the storage, pass the raw contents to the mutation callable, then persist the + * returned string and release the lock. + * + * @param callable(string): string $mutation + */ + public function withLock(callable $mutation): void; +} diff --git a/tests/bin/ldap-backend-storage.php b/tests/bin/ldap-backend-storage.php index 4f325e3f..2d452263 100644 --- a/tests/bin/ldap-backend-storage.php +++ b/tests/bin/ldap-backend-storage.php @@ -55,7 +55,7 @@ unlink($filePath); } - $adapter = new JsonFileStorageAdapter($filePath); + $adapter = JsonFileStorageAdapter::forPcntl($filePath); foreach ($entries as $entry) { $adapter->add(new AddCommand($entry)); diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php index 23132c35..edba2663 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php @@ -62,7 +62,7 @@ protected function setUp(): void new Attribute('cn', 'Bob'), ); - $this->subject = new JsonFileStorageAdapter($this->tempFile); + $this->subject = JsonFileStorageAdapter::forPcntl($this->tempFile); $this->subject->add(new AddCommand($this->base)); $this->subject->add(new AddCommand($this->alice)); $this->subject->add(new AddCommand($this->bob)); @@ -95,7 +95,8 @@ public function test_get_returns_null_for_missing_dn(): void public function test_get_on_nonexistent_file_returns_null(): void { - $adapter = new JsonFileStorageAdapter($this->tempFile . '.nonexistent'); + $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile . '.nonexistent'); + self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); } @@ -108,8 +109,12 @@ public function test_search_base_scope_returns_only_base(): void attributes: [], typesOnly: false, ))); + self::assertCount(1, $entries); - self::assertSame('dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + self::assertSame( + 'dc=example,dc=com', + array_values($entries)[0]->getDn()->toString() + ); } public function test_search_single_level_returns_direct_children(): void @@ -121,9 +126,13 @@ public function test_search_single_level_returns_direct_children(): void attributes: [], typesOnly: false, ))); + // Only alice is a direct child of dc=example,dc=com; bob is under ou=People self::assertCount(1, $entries); - self::assertSame('cn=Alice,dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + array_values($entries)[0]->getDn()->toString() + ); } public function test_search_subtree_returns_base_and_all_descendants(): void @@ -135,7 +144,11 @@ public function test_search_subtree_returns_base_and_all_descendants(): void attributes: [], typesOnly: false, ))); - self::assertCount(3, $entries); + + self::assertCount( + 3, + $entries + ); } public function test_add_stores_entry(): void @@ -152,13 +165,15 @@ public function test_add_persists_to_file(): void $this->subject->add(new AddCommand($entry)); // A second independent adapter instance reading the same file should see the new entry - $adapter2 = new JsonFileStorageAdapter($this->tempFile); + $adapter2 = JsonFileStorageAdapter::forPcntl($this->tempFile); + self::assertNotNull($adapter2->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'))); } @@ -166,7 +181,8 @@ public function test_delete_persists_to_file(): void { $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); - $adapter2 = new JsonFileStorageAdapter($this->tempFile); + $adapter2 = JsonFileStorageAdapter::forPcntl($this->tempFile); + self::assertNull($adapter2->get(new Dn('cn=Alice,dc=example,dc=com'))); } @@ -226,6 +242,7 @@ public function test_update_delete_specific_attribute_value(): void $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); $mail = $entry?->get('mail'); + self::assertNotNull($mail); self::assertFalse($mail->has('a@b.com')); self::assertTrue($mail->has('c@d.com')); @@ -341,7 +358,7 @@ public function test_get_on_empty_file_returns_null(): void { file_put_contents($this->tempFile, ''); - $adapter = new JsonFileStorageAdapter($this->tempFile); + $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); } @@ -350,7 +367,7 @@ public function test_get_on_invalid_json_returns_null(): void { file_put_contents($this->tempFile, 'not valid json {{{'); - $adapter = new JsonFileStorageAdapter($this->tempFile); + $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); } @@ -363,20 +380,12 @@ public function test_get_uses_in_memory_cache_on_subsequent_calls(): void // Corrupt the file — a cache-bypassing read would return null. file_put_contents($this->tempFile, 'corrupted'); - // Touch the file so its mtime changes and the adapter re-reads it. - // We want to verify only that a second add() does not use stale cache; - // here we instead verify the same-mtime cache hit path: restore the - // file, then call get() again without advancing mtime. - // Since we just wrote garbage, the cache was invalidated by withLock. - // Instead test the cache HIT by doing two get() calls without any write. - $adapter = new JsonFileStorageAdapter($this->tempFile); + $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); // Prime the cache on first call (returns null from corrupted file). $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); // Second call on same adapter+same mtime must use the in-memory cache. - // We verify this indirectly: overwriting the file again would change its - // mtime and cause a re-read, so we simply assert consistency. $result = $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); self::assertNull($result); @@ -385,7 +394,7 @@ public function test_get_uses_in_memory_cache_on_subsequent_calls(): void public function test_get_cache_is_invalidated_when_file_mtime_changes(): void { - $adapter = new JsonFileStorageAdapter($this->tempFile); + $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); // Prime the cache with a valid file (contains Alice). self::assertNotNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); From bc308405ee6188687a1ec0be5d60b017d81bd423 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 13:15:50 -0400 Subject: [PATCH 19/35] Make the bind name resolver a configurable option. Pass the backend into at resolution time to make custom ones less awkward. --- docs/Server/Configuration.md | 43 +++++++++++++++++++ .../BindNameResolverInterface.php | 8 +++- .../Auth/NameResolver/DnBindNameResolver.php | 17 +++----- .../Backend/Auth/PasswordAuthenticator.php | 7 ++- .../Server/RequestHandler/HandlerFactory.php | 7 ++- src/FreeDSx/Ldap/ServerOptions.php | 15 +++++++ .../NameResolver/DnBindNameResolverTest.php | 16 ++++--- .../PasswordAuthenticatorTest.php | 10 ++++- tests/unit/ServerOptionsTest.php | 18 ++++++++ 9 files changed, 119 insertions(+), 22 deletions(-) diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index dc299af3..f97f399d 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -15,6 +15,7 @@ LDAP Server Configuration * [ServerOptions:setBackend](#setbackend) * [ServerOptions:setFilterEvaluator](#setfilterevaluator) * [ServerOptions:setRootDseHandler](#setrootdsehandler) + * [ServerOptions:setBindNameResolver](#setbindnameresolver) * [RootDSE Options](#rootdse-options) * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) * [ServerOptions:setDseAltServer](#setdsealtserver) @@ -215,6 +216,48 @@ $server = new LdapServer( **Default**: `null` +------------------ +#### setBindNameResolver + +This should be an object instance that implements `FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface`. +It translates a raw LDAP bind name into an `Entry` so the built-in `PasswordAuthenticator` can locate and verify credentials. + +The default resolver (`DnBindNameResolver`) treats the bind name as a literal DN and delegates to `LdapBackendInterface::get()`. +Supply a custom resolver when clients bind with something other than a full DN — for example, a bare username or an email address: + +```php +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Entry\Entry; + +class UidBindNameResolver implements BindNameResolverInterface +{ + public function resolve( + string $name, + LdapBackendInterface $backend + ): ?Entry { + // Search for an entry whose uid attribute matches the bind name + // ... + } +} +``` + +```php +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\LdapServer; + +$server = new LdapServer( + (new ServerOptions) + ->setBackend(new MyDirectoryBackend()) + ->setBindNameResolver(new UidBindNameResolver()) +); +``` + +**Note**: This option is only used when the built-in `PasswordAuthenticator` is active. If you provide a fully custom +authenticator via `setPasswordAuthenticator()`, name resolution is entirely your responsibility and this option has no effect. + +**Default**: `null` (`DnBindNameResolver` is used, treating the bind name as a literal DN) + ## RootDSE Options ------------------ diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php index 97827f89..04f3e3ec 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Server\Backend\Auth\NameResolver; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; /** * Translates a raw LDAP bind name into an Entry. @@ -23,7 +24,10 @@ interface BindNameResolverInterface { /** - * Resolve the bind name to an Entry, or return null if no match is found. + * Resolve the bind name to an {@see Entry}, or return null if no match is found. */ - public function resolve(string $name): ?Entry; + public function resolve( + string $name, + LdapBackendInterface $backend + ): ?Entry; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php index 020cc915..959e6941 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.php @@ -21,21 +21,14 @@ * The default bind-name resolver. Treats the bind name as a DN and delegates * to LdapBackendInterface::get(). * - * This covers the common convention where clients bind with a full DN such as - * "cn=admin,dc=example,dc=com". For non-DN bind names (email addresses, bare - * usernames, etc.) provide a custom BindNameResolverInterface implementation. - * * @author Chad Sikorra */ final class DnBindNameResolver implements BindNameResolverInterface { - public function __construct( - private readonly LdapBackendInterface $backend, - ) { - } - - public function resolve(string $name): ?Entry - { - return $this->backend->get(new Dn($name)); + public function resolve( + string $name, + LdapBackendInterface $backend, + ): ?Entry { + return $backend->get(new Dn($name)); } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php index f8af00ca..ec2aac23 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Server\Backend\Auth; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use SensitiveParameter; /** @@ -29,6 +30,7 @@ final class PasswordAuthenticator implements PasswordAuthenticatableInterface { public function __construct( private readonly BindNameResolverInterface $nameResolver, + private readonly LdapBackendInterface $backend, ) { } @@ -37,7 +39,10 @@ public function verifyPassword( #[SensitiveParameter] string $password, ): bool { - $entry = $this->nameResolver->resolve($name); + $entry = $this->nameResolver->resolve( + $name, + $this->backend + ); if ($entry === null) { return false; diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php index 209cb588..4fb4afa5 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php @@ -88,7 +88,12 @@ public function makePasswordAuthenticator(): PasswordAuthenticatableInterface return $backend; } - return new PasswordAuthenticator(new DnBindNameResolver($backend)); + $nameResolver = $this->options->getBindNameResolver() ?? new DnBindNameResolver(); + + return new PasswordAuthenticator( + $nameResolver, + $backend + ); } /** diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 6e5a57e9..5635e9b5 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; @@ -112,6 +113,8 @@ final class ServerOptions private ?PasswordAuthenticatableInterface $passwordAuthenticator = null; + private ?BindNameResolverInterface $bindNameResolver = null; + private ?RootDseHandlerInterface $rootDseHandler = null; /** @@ -359,6 +362,18 @@ public function setPasswordAuthenticator(?PasswordAuthenticatableInterface $pass return $this; } + public function getBindNameResolver(): ?BindNameResolverInterface + { + return $this->bindNameResolver; + } + + public function setBindNameResolver(?BindNameResolverInterface $bindNameResolver): self + { + $this->bindNameResolver = $bindNameResolver; + + return $this; + } + public function getRootDseHandler(): ?RootDseHandlerInterface { return $this->rootDseHandler; diff --git a/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php b/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php index 13f76b05..90668f6e 100644 --- a/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php +++ b/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php @@ -43,10 +43,16 @@ public function test_resolve_calls_backend_get_with_dn(): void ->with(new Dn('cn=Alice,dc=example,dc=com')) ->willReturn($entry); - $subject = new DnBindNameResolver($this->mockBackend); - $result = $subject->resolve('cn=Alice,dc=example,dc=com'); + $subject = new DnBindNameResolver(); + $result = $subject->resolve( + 'cn=Alice,dc=example,dc=com', + $this->mockBackend + ); - self::assertSame($entry, $result); + self::assertSame( + $entry, + $result + ); } public function test_resolve_returns_null_when_entry_not_found(): void @@ -55,8 +61,8 @@ public function test_resolve_returns_null_when_entry_not_found(): void ->method('get') ->willReturn(null); - $subject = new DnBindNameResolver($this->mockBackend); - $result = $subject->resolve('cn=Unknown,dc=example,dc=com'); + $subject = new DnBindNameResolver(); + $result = $subject->resolve('cn=Unknown,dc=example,dc=com', $this->mockBackend); self::assertNull($result); } diff --git a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php index 02f67430..8a7b83c3 100644 --- a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php +++ b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,18 +26,25 @@ final class PasswordAuthenticatorTest extends TestCase { private BindNameResolverInterface&MockObject $mockResolver; + private LdapBackendInterface&MockObject $mockBackend; + protected function setUp(): void { $this->mockResolver = $this->createMock(BindNameResolverInterface::class); + $this->mockBackend = $this->createMock(LdapBackendInterface::class); } private function subject(?Entry $resolvedEntry = null): PasswordAuthenticator { $this->mockResolver ->method('resolve') + ->with(self::isType('string'), $this->mockBackend) ->willReturn($resolvedEntry); - return new PasswordAuthenticator($this->mockResolver); + return new PasswordAuthenticator( + $this->mockResolver, + $this->mockBackend + ); } public function test_returns_false_when_entry_not_found(): void diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index e44d42ba..923eaafd 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; @@ -379,6 +380,23 @@ public function test_it_can_set_password_authenticator(): void ); } + public function test_bind_name_resolver_is_null_by_default(): void + { + self::assertNull($this->subject->getBindNameResolver()); + } + + public function test_it_can_set_bind_name_resolver(): void + { + $resolver = $this->createMock(BindNameResolverInterface::class); + + $this->subject->setBindNameResolver($resolver); + + self::assertSame( + $resolver, + $this->subject->getBindNameResolver() + ); + } + public function test_root_dse_handler_is_null_by_default(): void { self::assertNull($this->subject->getRootDseHandler()); From 86009bef1d243bb5d91622a54043fc79c800d750 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 14:08:33 -0400 Subject: [PATCH 20/35] Unify SASL auth and the password authenticatable interface. Makes configuration way less awkward. --- src/FreeDSx/Ldap/LdapServer.php | 32 ---------- .../CramMD5MechanismOptionsBuilder.php | 4 +- .../DigestMD5MechanismOptionsBuilder.php | 4 +- .../MechanismOptionsBuilderFactory.php | 37 ++++------- .../ScramMechanismOptionsBuilder.php | 4 +- .../Ldap/Protocol/Bind/Sasl/SaslExchange.php | 2 +- src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php | 6 +- .../Auth/PasswordAuthenticatableInterface.php | 15 +++-- .../Backend/Auth/PasswordAuthenticator.php | 9 +++ .../Server/RequestHandler/ProxyBackend.php | 10 ++- .../RequestHandler/SaslHandlerInterface.php | 34 ---------- .../Ldap/Server/ServerProtocolFactory.php | 2 +- tests/unit/LdapServerTest.php | 45 +------------ .../CramMD5MechanismOptionsBuilderTest.php | 6 +- .../DigestMD5MechanismOptionsBuilderTest.php | 6 +- .../MechanismOptionsBuilderFactoryTest.php | 64 ++++--------------- .../ScramMechanismOptionsBuilderTest.php | 6 +- .../Protocol/Bind/Sasl/SaslExchangeTest.php | 11 ++-- tests/unit/Protocol/Bind/SaslBindTest.php | 40 +----------- .../PasswordAuthenticatorTest.php | 46 +++++++++++++ 20 files changed, 127 insertions(+), 256 deletions(-) delete mode 100644 src/FreeDSx/Ldap/Server/RequestHandler/SaslHandlerInterface.php diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index f7cc328e..531c51e2 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -13,13 +13,11 @@ namespace FreeDSx\Ldap; -use FreeDSx\Ldap\Exception\InvalidArgumentException; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Socket\Exception\ConnectionException; @@ -47,12 +45,9 @@ public function __construct( * Runs the LDAP server. Binds the socket and starts accepting client connections. * * @throws ConnectionException - * @throws InvalidArgumentException */ public function run(): void { - $this->validateSaslConfiguration(); - $runner = $this->options->getServerRunner() ?? $this->container->get(ServerRunnerInterface::class); $runner->run(); @@ -156,33 +151,6 @@ public function useSwooleRunner(): self return $this; } - /** - * Validates that the SASL configuration is consistent before the server starts. - * - * @throws InvalidArgumentException - */ - private function validateSaslConfiguration(): void - { - $challengeMechanisms = array_diff( - $this->options->getSaslMechanisms(), - [ServerOptions::SASL_PLAIN], - ); - - if (empty($challengeMechanisms)) { - return; - } - - $backend = $this->options->getBackend(); - - if (!$backend instanceof SaslHandlerInterface) { - throw new InvalidArgumentException(sprintf( - 'The SASL mechanism(s) [%s] require the backend to implement %s.', - implode(', ', $challengeMechanisms), - SaslHandlerInterface::class, - )); - } - } - /** * Convenience method for generating an LDAP server instance that will proxy client request's to an LDAP server. * diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilder.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilder.php index 1396d331..5654eb09 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilder.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilder.php @@ -13,7 +13,7 @@ namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; /** @@ -25,7 +25,7 @@ class CramMD5MechanismOptionsBuilder implements MechanismOptionsBuilderInterface { use RequiresPasswordTrait; - public function __construct(private readonly SaslHandlerInterface $handler) + public function __construct(private readonly PasswordAuthenticatableInterface $handler) { } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilder.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilder.php index ea0ee4aa..11ca9a84 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilder.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilder.php @@ -14,7 +14,7 @@ namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Protocol\Bind\Sasl\UsernameExtractor\SaslUsernameExtractorInterface; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; @@ -28,7 +28,7 @@ class DigestMD5MechanismOptionsBuilder implements MechanismOptionsBuilderInterfa use RequiresPasswordTrait; public function __construct( - private readonly SaslHandlerInterface $handler, + private readonly PasswordAuthenticatableInterface $handler, private readonly SaslUsernameExtractorInterface $usernameExtractor, ) { } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php index b1f200ca..cafe0df3 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php @@ -17,9 +17,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\UsernameExtractor\UsernameFieldExtractor; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; -use FreeDSx\Ldap\Server\Backend\GenericBackend; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; use FreeDSx\Sasl\Mechanism\PlainMechanism; @@ -28,41 +25,31 @@ /** * Creates a single MechanismOptionsBuilderInterface instance for the requested SASL mechanism. * - * Challenge-based mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM) require the backend to implement - * SaslHandlerInterface. An OperationException is thrown if the requirement is not met, so - * the client receives a well-formed LDAP error response. - * * @author Chad Sikorra */ final class MechanismOptionsBuilderFactory { public function __construct( - private readonly LdapBackendInterface $backend = new GenericBackend(), + private readonly PasswordAuthenticatableInterface $authenticator, ) { } /** - * @throws OperationException if the mechanism is unsupported or requires SaslHandlerInterface. + * @throws OperationException if the mechanism is unsupported. */ - public function make( - string $mechanism, - PasswordAuthenticatableInterface $authenticator, - ): MechanismOptionsBuilderInterface { + public function make(string $mechanism): MechanismOptionsBuilderInterface + { return match (true) { $mechanism === PlainMechanism::NAME - => new PlainMechanismOptionsBuilder($authenticator), - $mechanism === CramMD5Mechanism::NAME && $this->backend instanceof SaslHandlerInterface - => new CramMD5MechanismOptionsBuilder($this->backend), - $mechanism === DigestMD5Mechanism::NAME && $this->backend instanceof SaslHandlerInterface - => new DigestMD5MechanismOptionsBuilder($this->backend, new UsernameFieldExtractor()), - in_array($mechanism, ScramMechanism::VARIANTS, true) && $this->backend instanceof SaslHandlerInterface - => new ScramMechanismOptionsBuilder($this->backend), + => new PlainMechanismOptionsBuilder($this->authenticator), + $mechanism === CramMD5Mechanism::NAME + => new CramMD5MechanismOptionsBuilder($this->authenticator), + $mechanism === DigestMD5Mechanism::NAME + => new DigestMD5MechanismOptionsBuilder($this->authenticator, new UsernameFieldExtractor()), + in_array($mechanism, ScramMechanism::VARIANTS, true) + => new ScramMechanismOptionsBuilder($this->authenticator), default => throw new OperationException( - sprintf( - 'The SASL mechanism "%s" requires the request handler to implement %s.', - $mechanism, - SaslHandlerInterface::class, - ), + sprintf('The SASL mechanism "%s" is not supported.', $mechanism), ResultCode::OTHER, ), }; diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilder.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilder.php index 97d4ff9d..30b16518 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilder.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilder.php @@ -15,7 +15,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\ScramMechanism; /** @@ -33,7 +33,7 @@ class ScramMechanismOptionsBuilder implements MechanismOptionsBuilderInterface private ?string $username = null; - public function __construct(private readonly SaslHandlerInterface $handler) + public function __construct(private readonly PasswordAuthenticatableInterface $handler) { } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php index 86e86340..cd99d770 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php @@ -50,7 +50,7 @@ public function __construct( public function run(SaslExchangeInput $input): SaslExchangeResult { $mechName = $input->getMechName(); - $optionsBuilder = $this->optionsBuilderFactory->make($mechName, $this->authenticator); + $optionsBuilder = $this->optionsBuilderFactory->make($mechName); $challenge = $input->getChallenge(); $message = $input->getInitialMessage(); $received = $input->getInitialCredentials(); diff --git a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php index 09e427fd..41111b8e 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php @@ -56,12 +56,14 @@ public function __construct( private readonly ResponseFactory $responseFactory = new ResponseFactory(), private readonly SaslUsernameExtractorFactory $usernameExtractorFactory = new SaslUsernameExtractorFactory(), ?SaslExchange $exchange = null, - MechanismOptionsBuilderFactory $optionsBuilderFactory = new MechanismOptionsBuilderFactory(), + ?MechanismOptionsBuilderFactory $optionsBuilderFactory = null, ) { + $factory = $optionsBuilderFactory ?? new MechanismOptionsBuilderFactory($this->authenticator); + $this->exchange = $exchange ?? new SaslExchange( $this->queue, $this->responseFactory, - $optionsBuilderFactory, + $factory, $this->authenticator, ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php index 77fc7dfc..54e6038f 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php @@ -16,11 +16,7 @@ use SensitiveParameter; /** - * Implemented by anything that can verify a bind password. - * - * Decoupled from LdapBackendInterface so that backends are not forced to - * provide authentication logic. The framework supplies a default - * PasswordAuthenticator that delegates entry lookup to any LdapBackendInterface. + * Implemented by anything that can handle bind authentication. * * @author Chad Sikorra */ @@ -36,4 +32,13 @@ public function verifyPassword( #[SensitiveParameter] string $password, ): bool; + + /** + * Return the plaintext password for the given username and SASL mechanism, + * or null if the user does not exist or should not authenticate. + */ + public function getPassword( + string $username, + string $mechanism, + ): ?string; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php index ec2aac23..351fdadb 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php @@ -63,6 +63,15 @@ public function verifyPassword( return false; } + public function getPassword( + string $username, + string $mechanism, + ): ?string { + $entry = $this->nameResolver->resolve($username, $this->backend); + + return $entry?->get('userPassword')?->getValues()[0] ?? null; + } + /** * Verify a plain-text password against a (possibly hashed) stored value. * diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php index 3e17f12e..e26a25e3 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -86,10 +86,18 @@ public function verifyPassword( try { return (bool) $this->ldap()->bind($name, $password); } catch (BindException $e) { - throw new OperationException($e->getMessage(), $e->getCode()); + throw new OperationException( + $e->getMessage(), + $e->getCode(), + ); } } + public function getPassword(string $username, string $mechanism): ?string + { + return null; + } + public function add(AddCommand $command): void { $this->ldap()->sendAndReceive(Operations::add($command->entry)); diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/SaslHandlerInterface.php b/src/FreeDSx/Ldap/Server/RequestHandler/SaslHandlerInterface.php deleted file mode 100644 index b6ab01b3..00000000 --- a/src/FreeDSx/Ldap/Server/RequestHandler/SaslHandlerInterface.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\RequestHandler; - -/** - * Implement this interface alongside RequestHandlerInterface to support server-side SASL authentication - * with challenge-based mechanisms (DIGEST-MD5, CRAM-MD5) that require plaintext password lookup. - * - * @author Chad Sikorra - */ -interface SaslHandlerInterface -{ - /** - * Return the plaintext password for a given username and SASL mechanism. This is needed so the server - * can compute and verify the expected digest / response from the client. - * - * Return null if the user does not exist or should not be allowed to authenticate. - */ - public function getPassword( - string $username, - string $mechanism, - ): ?string; -} diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index 2623da53..4f395be1 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -57,7 +57,7 @@ public function make(Socket $socket): ServerProtocolHandler authenticator: $passwordAuthenticator, sasl: new Sasl(['supported' => $saslMechanisms]), mechanisms: $saslMechanisms, - optionsBuilderFactory: new MechanismOptionsBuilderFactory($backend), + optionsBuilderFactory: new MechanismOptionsBuilderFactory($passwordAuthenticator), ); } diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index fa745b14..967c873b 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -13,7 +13,6 @@ namespace Tests\Unit\FreeDSx\Ldap; -use FreeDSx\Ldap\Exception\InvalidArgumentException; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; @@ -21,18 +20,12 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -/** - * Combined interface so PHPUnit can mock a backend that also handles SASL. - */ -interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} - class LdapServerTest extends TestCase { private LdapServer $subject; @@ -127,43 +120,7 @@ public function test_it_should_get_the_default_options(): void ); } - public function test_it_throws_when_challenge_mechanisms_are_configured_without_a_backend(): void - { - $this->options->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5); - - self::expectException(InvalidArgumentException::class); - - $this->subject->run(); - } - - public function test_it_throws_when_challenge_mechanisms_are_configured_with_a_non_sasl_backend(): void - { - $this->options - ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setBackend($this->createMock(LdapBackendInterface::class)); - - self::expectException(InvalidArgumentException::class); - - $this->subject->run(); - } - - public function test_it_does_not_throw_when_challenge_mechanisms_are_configured_with_a_sasl_backend(): void - { - /** @var LdapBackendInterface&SaslHandlerInterface&MockObject $mockSaslBackend */ - $mockSaslBackend = $this->createMock(SaslBackendInterface::class); - - $this->mockServerRunner->method('run'); - - $this->options - ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setBackend($mockSaslBackend); - - $this->subject->run(); - - $this->expectNotToPerformAssertions(); - } - - public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_backend(): void + public function test_it_does_not_throw_for_sasl_mechanisms_without_a_sasl_backend(): void { $this->mockServerRunner->method('run'); diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilderTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilderTest.php index 9ad3c732..1670c196 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilderTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/CramMD5MechanismOptionsBuilderTest.php @@ -16,20 +16,20 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\CramMD5MechanismOptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class CramMD5MechanismOptionsBuilderTest extends TestCase { - private SaslHandlerInterface&MockObject $mockHandler; + private PasswordAuthenticatableInterface&MockObject $mockHandler; private CramMD5MechanismOptionsBuilder $subject; protected function setUp(): void { - $this->mockHandler = $this->createMock(SaslHandlerInterface::class); + $this->mockHandler = $this->createMock(PasswordAuthenticatableInterface::class); $this->subject = new CramMD5MechanismOptionsBuilder($this->mockHandler); } diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilderTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilderTest.php index 1e83a0d8..7624c191 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilderTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/DigestMD5MechanismOptionsBuilderTest.php @@ -17,14 +17,14 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\DigestMD5MechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\UsernameExtractor\SaslUsernameExtractorInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class DigestMD5MechanismOptionsBuilderTest extends TestCase { - private SaslHandlerInterface&MockObject $mockHandler; + private PasswordAuthenticatableInterface&MockObject $mockHandler; private SaslUsernameExtractorInterface&MockObject $mockUsernameExtractor; @@ -32,7 +32,7 @@ final class DigestMD5MechanismOptionsBuilderTest extends TestCase protected function setUp(): void { - $this->mockHandler = $this->createMock(SaslHandlerInterface::class); + $this->mockHandler = $this->createMock(PasswordAuthenticatableInterface::class); $this->mockUsernameExtractor = $this->createMock(SaslUsernameExtractorInterface::class); $this->subject = new DigestMD5MechanismOptionsBuilder( diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php index 83c39d31..3473fe6a 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php @@ -21,98 +21,58 @@ use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\PlainMechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\ScramMechanismOptionsBuilder; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -/** - * Combined interface so PHPUnit can mock both at once. - */ -interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} - final class MechanismOptionsBuilderFactoryTest extends TestCase { - private LdapBackendInterface&MockObject $mockBackend; - - private SaslBackendInterface&MockObject $mockSaslBackend; - private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; + private MechanismOptionsBuilderFactory $subject; + protected function setUp(): void { - $this->mockBackend = $this->createMock(LdapBackendInterface::class); - $this->mockSaslBackend = $this->createMock(SaslBackendInterface::class); $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); + $this->subject = new MechanismOptionsBuilderFactory($this->mockAuthenticator); } - public function test_make_plain_with_non_sasl_backend_returns_plain_builder(): void + public function test_make_plain_returns_plain_builder(): void { - $subject = new MechanismOptionsBuilderFactory($this->mockBackend); - self::assertInstanceOf( PlainMechanismOptionsBuilder::class, - $subject->make('PLAIN', $this->mockAuthenticator) + $this->subject->make('PLAIN') ); } - public function test_make_plain_with_sasl_backend_returns_plain_builder(): void + public function test_make_cram_md5_returns_cram_md5_builder(): void { - $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); - - self::assertInstanceOf( - PlainMechanismOptionsBuilder::class, - $subject->make('PLAIN', $this->mockAuthenticator) - ); - } - - public function test_make_cram_md5_with_sasl_backend_returns_cram_md5_builder(): void - { - $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); - self::assertInstanceOf( CramMD5MechanismOptionsBuilder::class, - $subject->make('CRAM-MD5', $this->mockAuthenticator) + $this->subject->make('CRAM-MD5') ); } - public function test_make_digest_md5_with_sasl_backend_returns_digest_md5_builder(): void + public function test_make_digest_md5_returns_digest_md5_builder(): void { - $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); - self::assertInstanceOf( DigestMD5MechanismOptionsBuilder::class, - $subject->make('DIGEST-MD5', $this->mockAuthenticator) + $this->subject->make('DIGEST-MD5') ); } - public function test_make_scram_sha256_with_sasl_backend_returns_scram_builder(): void + public function test_make_scram_sha256_returns_scram_builder(): void { - $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); - self::assertInstanceOf( ScramMechanismOptionsBuilder::class, - $subject->make('SCRAM-SHA-256', $this->mockAuthenticator) + $this->subject->make('SCRAM-SHA-256') ); } - public function test_make_cram_md5_with_non_sasl_backend_throws(): void - { - $subject = new MechanismOptionsBuilderFactory($this->mockBackend); - - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::OTHER); - - $subject->make('CRAM-MD5', $this->mockAuthenticator); - } - public function test_make_unknown_mechanism_throws(): void { - $subject = new MechanismOptionsBuilderFactory($this->mockSaslBackend); - self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::OTHER); - $subject->make('UNKNOWN', $this->mockAuthenticator); + $this->subject->make('UNKNOWN'); } } diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilderTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilderTest.php index ea1c3cbb..981ef2d5 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilderTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ScramMechanismOptionsBuilderTest.php @@ -16,20 +16,20 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\ScramMechanismOptionsBuilder; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\ScramMechanism; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class ScramMechanismOptionsBuilderTest extends TestCase { - private SaslHandlerInterface&MockObject $mockHandler; + private PasswordAuthenticatableInterface&MockObject $mockHandler; private ScramMechanismOptionsBuilder $subject; protected function setUp(): void { - $this->mockHandler = $this->createMock(SaslHandlerInterface::class); + $this->mockHandler = $this->createMock(PasswordAuthenticatableInterface::class); $this->subject = new ScramMechanismOptionsBuilder($this->mockHandler); } diff --git a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php index 3252ea71..3accd420 100644 --- a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php +++ b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php @@ -33,9 +33,8 @@ final class SaslExchangeTest extends TestCase { /** * Using 'PLAIN' as the mechanism name so that the real MechanismOptionsBuilderFactory - * can produce a PlainMechanismOptionsBuilder (which only requires LdapBackendInterface, - * not SaslHandlerInterface). The mock challenge ignores the option array, so the - * specific mechanism does not affect the exchange-loop behavior under test. + * can produce a PlainMechanismOptionsBuilder. The mock challenge ignores the option + * array, so the specific mechanism does not affect the exchange-loop behavior under test. */ private const MECH = 'PLAIN'; @@ -50,11 +49,13 @@ protected function setUp(): void $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockChallenge = $this->createMock(ChallengeInterface::class); + $mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); + $this->subject = new SaslExchange( queue: $this->mockQueue, responseFactory: new ResponseFactory(), - optionsBuilderFactory: new MechanismOptionsBuilderFactory(), - authenticator: $this->createMock(PasswordAuthenticatableInterface::class), + optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockAuthenticator), + authenticator: $mockAuthenticator, ); } diff --git a/tests/unit/Protocol/Bind/SaslBindTest.php b/tests/unit/Protocol/Bind/SaslBindTest.php index 29bd2c67..7ac791c7 100644 --- a/tests/unit/Protocol/Bind/SaslBindTest.php +++ b/tests/unit/Protocol/Bind/SaslBindTest.php @@ -19,23 +19,15 @@ use FreeDSx\Ldap\Operation\Request\SaslBindRequest; use FreeDSx\Ldap\Operation\Request\SimpleBindRequest; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; use FreeDSx\Ldap\Protocol\Bind\SaslBind; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -/** - * Combined interface so PHPUnit can mock both at once. - */ -interface SaslBackendInterface extends LdapBackendInterface, SaslHandlerInterface {} - final class SaslBindTest extends TestCase { private SaslBind $subject; @@ -106,28 +98,6 @@ public function test_it_throws_for_an_unsupported_mechanism(): void )); } - public function test_it_throws_when_challenge_based_mechanism_is_used_without_sasl_handler(): void - { - $subject = new SaslBind( - queue: $this->mockQueue, - authenticator: $this->mockAuthenticator, - mechanisms: [ServerOptions::SASL_CRAM_MD5], - optionsBuilderFactory: new MechanismOptionsBuilderFactory($this->createMock(LdapBackendInterface::class)), - ); - - $this->mockQueue - ->expects(self::never()) - ->method('sendMessage'); - - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::OTHER); - - $subject->bind(new LdapMessageRequest( - 1, - new SaslBindRequest('CRAM-MD5'), - )); - } - public function test_it_can_authenticate_with_plain(): void { // PLAIN credential format: "authzid\x00authcid\x00passwd" (all three parts must be non-empty) @@ -177,19 +147,15 @@ public function test_it_throws_invalid_credentials_when_plain_authentication_fai public function test_challenge_mechanism_sends_invalid_credentials_on_authentication_failure(): void { - /** @var SaslBackendInterface&MockObject $mockSaslBackend */ - $mockSaslBackend = $this->createMock(SaslBackendInterface::class); - $subject = new SaslBind( queue: $this->mockQueue, authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_CRAM_MD5], - optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockSaslBackend), ); // Return a real password so the HMAC callable runs; the client digest is wrong so // the challenge will set isAuthenticated=false and isComplete=true. - $mockSaslBackend + $this->mockAuthenticator ->method('getPassword') ->willReturn('correctpassword'); @@ -235,14 +201,10 @@ public function test_it_throws_runtime_exception_when_bind_is_called_with_a_non_ public function test_it_throws_protocol_error_when_non_sasl_request_received_during_exchange(): void { - /** @var SaslBackendInterface&MockObject $mockSaslBackend */ - $mockSaslBackend = $this->createMock(SaslBackendInterface::class); - $subject = new SaslBind( queue: $this->mockQueue, authenticator: $this->mockAuthenticator, mechanisms: [ServerOptions::SASL_CRAM_MD5], - optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockSaslBackend), ); // First sendMessage is the SASL_BIND_IN_PROGRESS challenge; second is the error response. diff --git a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php index 8a7b83c3..c2fcfbe2 100644 --- a/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php +++ b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php @@ -197,4 +197,50 @@ public function test_returns_false_for_wrong_smd5_password(): void $this->subject($entry)->verifyPassword('cn=Test,dc=example,dc=com', 'wrong') ); } + + public function test_get_password_returns_null_when_entry_not_found(): void + { + self::assertNull( + $this->subject()->getPassword('unknown', 'SCRAM-SHA-256') + ); + } + + public function test_get_password_returns_null_when_entry_has_no_user_password(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + + self::assertNull( + $this->subject($entry)->getPassword('cn=Alice,dc=example,dc=com', 'SCRAM-SHA-256') + ); + } + + public function test_get_password_returns_raw_value_for_plaintext_password(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('userPassword', 'secret'), + ); + + self::assertSame( + 'secret', + $this->subject($entry)->getPassword('cn=Alice,dc=example,dc=com', 'SCRAM-SHA-256') + ); + } + + public function test_get_password_returns_raw_value_for_hashed_password(): void + { + $hashed = '{SHA}' . base64_encode(sha1('secret', true)); + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('userPassword', $hashed), + ); + + self::assertSame( + $hashed, + $this->subject($entry)->getPassword('cn=Alice,dc=example,dc=com', 'SCRAM-SHA-256') + ); + } } From 32becb6e50815b3695c31f0984c423f9f13d81e6 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 14:34:56 -0400 Subject: [PATCH 21/35] Update the docs. --- docs/Server/Configuration.md | 100 ++++++-- docs/Server/General-Usage.md | 447 +++++++++++++++++++++++------------ 2 files changed, 379 insertions(+), 168 deletions(-) diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index f97f399d..2b2ec876 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -15,6 +15,7 @@ LDAP Server Configuration * [ServerOptions:setBackend](#setbackend) * [ServerOptions:setFilterEvaluator](#setfilterevaluator) * [ServerOptions:setRootDseHandler](#setrootdsehandler) + * [ServerOptions:setPasswordAuthenticator](#setpasswordauthenticator) * [ServerOptions:setBindNameResolver](#setbindnameresolver) * [RootDSE Options](#rootdse-options) * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) @@ -145,8 +146,7 @@ optionally write operations). You can also plug in a custom filter evaluator or #### setBackend This should be an object instance that implements `FreeDSx\Ldap\Server\Backend\LdapBackendInterface`. All directory -operations (search, authenticate, and optionally write) are dispatched to this backend. Paging is handled automatically -by the framework — no separate paging handler is needed. +operations (search, authenticate, and optionally write) are dispatched to this backend. Paging is handled automatically — no separate paging handler is needed. You can also use the fluent `useBackend()` method on `LdapServer` instead of setting it in `ServerOptions`: @@ -176,7 +176,7 @@ $server = new LdapServer( ------------------ #### setFilterEvaluator -This should be an object instance that implements `FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface`. If provided, +This should be an object instance that implements `FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface`. If provided, the server uses it when evaluating LDAP search filters against candidate entries returned by the backend. The default evaluator covers all standard LDAP filter types. A custom evaluator is useful when you need non-standard matching rules (for example, bitwise matching rules for Active Directory compatibility). @@ -190,7 +190,7 @@ $server = (new LdapServer()) ->useFilterEvaluator(new MyFilterEvaluator()); ``` -**Default**: `FreeDSx\Ldap\Server\Storage\FilterEvaluator` +**Default**: `FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator` ------------------ #### setRootDseHandler @@ -216,6 +216,60 @@ $server = new LdapServer( **Default**: `null` +------------------ +#### setPasswordAuthenticator + +This should be an object instance that implements `FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface`. +It handles all password-based bind authentication — both simple binds and SASL mechanisms — through two methods: + +* `verifyPassword(string $name, string $password): bool` — called for simple binds and SASL PLAIN +* `getPassword(string $username, string $mechanism): ?string` — called for challenge-based SASL mechanisms + (CRAM-MD5, DIGEST-MD5, SCRAM-*) that need a server-side credential to compute a digest + +The server resolves an authenticator in this order: + +1. An explicit instance set via `setPasswordAuthenticator()` +2. The backend, if it implements `PasswordAuthenticatableInterface` +3. A built-in `PasswordAuthenticator` that resolves the bind name to an entry via the configured + `BindNameResolverInterface` and verifies the entry's `userPassword` attribute + +Use this option when you need to delegate authentication to an external system (a database, an upstream LDAP server, +an identity provider, etc.) without implementing the storage backend interface: + +```php +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; + +class ExternalAuthenticator implements PasswordAuthenticatableInterface +{ + public function verifyPassword(string $name, #[\SensitiveParameter] string $password): bool + { + // delegate to your auth system + } + + public function getPassword(string $username, string $mechanism): ?string + { + // return plaintext password for SASL challenge mechanisms, + // or null to disable challenge SASL for this user + return null; + } +} +``` + +```php +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\LdapServer; + +$server = new LdapServer( + (new ServerOptions) + ->setPasswordAuthenticator(new ExternalAuthenticator()) +); +``` + +**Note**: Challenge-based SASL mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM-*) require `getPassword()` to return a +plaintext (or recoverable) credential. If `getPassword()` returns `null`, the mechanism will fail for that user. + +**Default**: `null` (resolved automatically as described above) + ------------------ #### setBindNameResolver @@ -331,23 +385,27 @@ to use an encrypted stream only for communication to the server. The SASL mechanisms the server should support and advertise to clients via the `supportedSaslMechanisms` RootDSE attribute. Use the constants defined on `ServerOptions` to specify mechanisms: -| Constant | Mechanism | Handler Required | -|-------------------------------------------|-----------------------|-------------------------------------------------------------| -| `ServerOptions::SASL_PLAIN` | `PLAIN` | `LdapBackendInterface` (existing `verifyPassword()` method) | -| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | `SaslHandlerInterface` | -| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_1` | `SCRAM-SHA-1` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_1_PLUS` | `SCRAM-SHA-1-PLUS` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_224` | `SCRAM-SHA-224` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_224_PLUS` | `SCRAM-SHA-224-PLUS` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_256` | `SCRAM-SHA-256` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_256_PLUS` | `SCRAM-SHA-256-PLUS` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_384` | `SCRAM-SHA-384` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_384_PLUS` | `SCRAM-SHA-384-PLUS` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_512` | `SCRAM-SHA-512` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA_512_PLUS` | `SCRAM-SHA-512-PLUS` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA3_512` | `SCRAM-SHA3-512` | `SaslHandlerInterface` | -| `ServerOptions::SASL_SCRAM_SHA3_512_PLUS` | `SCRAM-SHA3-512-PLUS` | `SaslHandlerInterface` | +| Constant | Mechanism | Auth method called on `PasswordAuthenticatableInterface` | +|-------------------------------------------|-----------------------|----------------------------------------------------------| +| `ServerOptions::SASL_PLAIN` | `PLAIN` | `verifyPassword()` | +| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_1` | `SCRAM-SHA-1` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_1_PLUS` | `SCRAM-SHA-1-PLUS` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_224` | `SCRAM-SHA-224` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_224_PLUS` | `SCRAM-SHA-224-PLUS` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_256` | `SCRAM-SHA-256` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_256_PLUS` | `SCRAM-SHA-256-PLUS` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_384` | `SCRAM-SHA-384` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_384_PLUS` | `SCRAM-SHA-384-PLUS` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_512` | `SCRAM-SHA-512` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA_512_PLUS` | `SCRAM-SHA-512-PLUS` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA3_512` | `SCRAM-SHA3-512` | `getPassword()` (plaintext credential required) | +| `ServerOptions::SASL_SCRAM_SHA3_512_PLUS` | `SCRAM-SHA3-512-PLUS` | `getPassword()` (plaintext credential required) | + +All mechanisms are handled through `PasswordAuthenticatableInterface` — no separate handler interface is required. +Configure authentication via `setPasswordAuthenticator()` or by implementing `PasswordAuthenticatableInterface` +on your backend. See [Authentication](General-Usage.md#authentication) for details. ```php use FreeDSx\Ldap\ServerOptions; diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 265e177d..8d60d901 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -1,6 +1,10 @@ General LDAP Server Usage =================== +* [Quick Start Scenarios](#quick-start-scenarios) + * [In-Memory Server](#in-memory-server) + * [File-Backed Server with Custom Bind Names](#file-backed-server-with-custom-bind-names) + * [Challenge SASL with a Custom Authenticator](#challenge-sasl-with-a-custom-authenticator) * [Running the Server](#running-the-server) * [Creating a Proxy Server](#creating-a-proxy-server) * [Providing a Backend](#providing-a-backend) @@ -11,6 +15,10 @@ General LDAP Server Usage * [JsonFileStorageAdapter](#jsonfilestorageadapter) * [Proxy Backend](#proxy-backend) * [Custom Filter Evaluation](#custom-filter-evaluation) +* [Authentication](#authentication) + * [Default Authentication](#default-authentication) + * [Custom Bind Name Resolution](#custom-bind-name-resolution) + * [Custom Authenticator](#custom-authenticator) * [Handling the RootDSE](#handling-the-rootdse) * [StartTLS SSL Certificate Support](#starttls-ssl-certificate-support) * [SASL Authentication](#sasl-authentication) @@ -20,8 +28,146 @@ General LDAP Server Usage The LdapServer class runs an LDAP server process that accepts client requests and sends back responses. It defaults to using a forking method (PCNTL) for handling client connections, which is only available on Linux. -The server has no built-in entry persistence. You provide a backend that implements the storage and authentication -logic for your use case. See the [Providing a Backend](#providing-a-backend) section for details. +The server has no built-in entry persistence. You provide a backend that implements the storage logic for your use +case. Authentication is a separate, independently configurable concern. See [Providing a Backend](#providing-a-backend) +and [Authentication](#authentication) for details. + +## Quick Start Scenarios + +### In-Memory Server + +The simplest useful setup: an in-memory directory pre-seeded with entries. Ideal for testing or applications that +reconstruct the directory on each start. + +The built-in `PasswordAuthenticator` handles bind authentication automatically — it reads the `userPassword` attribute +from entries and verifies credentials against it. Supported hash schemes: `{SHA}`, `{SSHA}`, `{MD5}`, `{SMD5}`, and +plaintext. + +```php +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\ServerOptions; + +$passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); + +$server = new LdapServer( + (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') +); + +$server->useBackend(new InMemoryStorageAdapter( + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry( + new Dn('cn=admin,dc=example,dc=com'), + new Attribute('cn', 'admin'), + new Attribute('userPassword', $passwordHash), + ), +)); + +$server->run(); +``` + +Clients bind as `cn=admin,dc=example,dc=com` with password `secret`. No further configuration needed. + +--- + +### File-Backed Server with Custom Bind Names + +A persistent directory stored in a JSON file. Clients bind with a bare username (`alice`) instead of a full DN — a +custom `BindNameResolverInterface` translates the username to an entry. + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\ServerOptions; + +class UidBindNameResolver implements BindNameResolverInterface +{ + public function resolve(string $name, LdapBackendInterface $backend): ?Entry + { + foreach ($backend->search(/* uid=$name search context */) as $entry) { + return $entry; + } + + return null; + } +} + +$server = new LdapServer( + (new ServerOptions()) + ->setDseNamingContexts('dc=example,dc=com') + ->setSaslMechanisms(ServerOptions::SASL_PLAIN) + ->setBindNameResolver(new UidBindNameResolver()) +); + +$server->useBackend(JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json')); +$server->run(); +``` + +The custom resolver drives all auth paths: simple bind, SASL PLAIN, and (if enabled) challenge SASL all use it +through the built-in `PasswordAuthenticator`. + +--- + +### Challenge SASL with a Custom Authenticator + +For full control over credential storage — for example, delegating to an external user store or database — implement +`PasswordAuthenticatableInterface` directly. This single interface covers all bind types: + +- `verifyPassword()` is called for simple binds and SASL PLAIN +- `getPassword()` is called for challenge mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM-*) + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\ServerOptions; +use SensitiveParameter; + +class MyAuthenticator implements PasswordAuthenticatableInterface +{ + public function verifyPassword( + string $name, + #[SensitiveParameter] + string $password, + ): bool { + // Verify against your user store — hashed comparison is fine here. + $stored = $this->lookupPassword($name); + + return $stored !== null && password_verify($password, $stored); + } + + public function getPassword(string $username, string $mechanism): ?string + { + // Challenge SASL requires a plaintext (or recoverable) password. + // Passwords stored with one-way hashing (bcrypt, argon2) cannot be used here. + return $this->lookupPlaintextPassword($username); + } +} + +$server = new LdapServer( + (new ServerOptions()) + ->setDseNamingContexts('dc=example,dc=com') + ->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_SCRAM_SHA_256, + ) +); + +$server->useBackend(JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json')); +$server->usePasswordAuthenticator(new MyAuthenticator()); +$server->run(); +``` + +The backend handles directory data (search, writes). The authenticator handles credentials. Neither needs to know +about the other. + +--- ## Running The Server @@ -69,13 +215,15 @@ $server = (new LdapServer())->useBackend(new MyBackend()); $server->run(); ``` -The framework routes all client LDAP operations — search, bind, add, delete, modify, rename, compare — to the backend. -Paging is handled automatically: the framework stores a PHP Generator per connection and resumes it for each page +All client LDAP operations — search, add, delete, modify, rename, compare — are routed to the backend. +Paging is handled automatically: a PHP generator is stored per connection and resumes it for each page request. Your `search()` implementation simply yields entries. +Authentication is a **separate concern** handled by `PasswordAuthenticatableInterface`. See [Authentication](#authentication). + ### Read-Only Backend -`LdapBackendInterface` covers the three operations needed for a read-only server: +`LdapBackendInterface` requires two methods: ```php namespace App; @@ -89,15 +237,16 @@ use Generator; class MyReadOnlyBackend implements LdapBackendInterface { /** - * Yield entries that are candidates for the search. The framework applies - * FilterEvaluator as a final pass, so you may pre-filter for efficiency or + * Yield entries that are candidates for the search. + * + * The FilterEvaluator as a final pass, so you may pre-filter for efficiency or * yield all entries in scope for simplicity. */ public function search(SearchContext $context): Generator { - // $context->baseDn — Dn: the search base - // $context->scope — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE - // $context->filter — FilterInterface: the requested LDAP filter + // $context->baseDn — Dn: the search base + // $context->scope — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE + // $context->filter — FilterInterface: the requested LDAP filter // $context->attributes — Attribute[]: requested attributes (empty = all) // $context->typesOnly — bool: return only attribute names, not values @@ -107,43 +256,39 @@ class MyReadOnlyBackend implements LdapBackendInterface /** * Return a single entry by DN, or null if it does not exist. - * Used for compare operations and internal lookups. + * Used for compare operations and bind name resolution. */ public function get(Dn $dn): ?Entry { // ... return null; } - - /** - * Verify a plaintext password for a simple bind request. - * Return true if the credentials are valid, false otherwise. - */ - public function verifyPassword(Dn $dn, string $password): bool - { - return $dn->toString() === 'cn=admin,dc=example,dc=com' - && $password === 'secret'; - } } ``` ### Writable Backend -`WritableLdapBackendInterface` extends `LdapBackendInterface` with write operations: +`WritableLdapBackendInterface` extends `LdapBackendInterface` with write operations. Use `WritableBackendTrait` to +implement the write dispatch — it routes each operation to a dedicated method receiving a typed command object: ```php namespace App; -use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Entry\Rdn; -use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use Generator; class MyBackend implements WritableLdapBackendInterface { + use WritableBackendTrait; + public function search(SearchContext $context): Generator { // yield matching entries... @@ -152,34 +297,31 @@ class MyBackend implements WritableLdapBackendInterface public function get(Dn $dn): ?Entry { // ... + return null; } - public function verifyPassword(Dn $dn, string $password): bool - { - // ... - } - - public function add(Entry $entry): void + public function add(AddCommand $command): void { - // Persist the new entry. Throw OperationException(ENTRY_ALREADY_EXISTS) if it exists. + // $command->entry — Entry to persist } - public function delete(Dn $dn): void + public function delete(DeleteCommand $command): void { - // Remove the entry. Throw OperationException(NOT_ALLOWED_ON_NON_LEAF) if it has children. + // $command->dn — Dn of the entry to remove } - /** - * @param Change[] $changes - */ - public function update(Dn $dn, array $changes): void + public function update(UpdateCommand $command): void { - // Apply attribute changes to the entry. + // $command->dn — Dn of the entry to modify + // $command->changes — Change[] of attribute changes to apply } - public function move(Dn $dn, Rdn $newRdn, bool $deleteOldRdn, ?Dn $newParent): void + public function move(MoveCommand $command): void { - // Rename or move the entry (ModifyDN operation). + // $command->dn — Dn: current entry DN + // $command->newRdn — Rdn: new relative DN + // $command->deleteOldRdn — bool: whether to remove the old RDN attribute value + // $command->newParent — ?Dn: new parent DN (null = same parent) } } ``` @@ -202,7 +344,7 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); @@ -219,9 +361,6 @@ $server = (new LdapServer())->useBackend($adapter); $server->run(); ``` -Password verification supports `{SHA}` hashed values stored in the `userPassword` attribute, as well as plaintext -comparisons. No password scheme is set automatically — the hash format must be present in the stored value. - #### JsonFileStorageAdapter A file-backed adapter that persists the directory as a JSON file. Safe for PCNTL (write operations are serialised with @@ -291,7 +430,7 @@ $server->run(); ### Custom Filter Evaluation -By default the framework applies a pure-PHP `FilterEvaluator` to entries yielded by `search()` as a correctness +By default, a pure-PHP `FilterEvaluator` is applied to entries yielded by `search()` as a correctness guarantee. For backends that translate LDAP filters to a native query language (SQL, MongoDB, etc.) and return pre-filtered results, you can replace the evaluator: @@ -307,12 +446,12 @@ $server = (new LdapServer()) $server->run(); ``` -The custom evaluator must implement `FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface`: +The custom evaluator must implement `FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface`: ```php use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Search\Filter\FilterInterface; -use FreeDSx\Ldap\Server\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; class MySqlFilterEvaluator implements FilterEvaluatorInterface { @@ -324,6 +463,99 @@ class MySqlFilterEvaluator implements FilterEvaluatorInterface } ``` +## Authentication + +The `PasswordAuthenticatableInterface` covers all bind types through two methods: + +```php +interface PasswordAuthenticatableInterface +{ + // Called for simple binds and SASL PLAIN. + public function verifyPassword( + string $name, + string $password + ): bool; + + // Called for challenge-based SASL (CRAM-MD5, DIGEST-MD5, SCRAM-*). + // Return the plaintext password, or null to reject. + public function getPassword( + string $username, + string $mechanism + ): ?string; +} +``` + +### Default Authentication + +When no explicit authenticator is registered, we build a `PasswordAuthenticator` automatically. It +resolves the bind name to an entry via the backend's `get()` method (or a custom resolver — see below), then verifies +the supplied password against the `userPassword` attribute. Supported schemes: `{SHA}`, `{SSHA}`, `{MD5}`, `{SMD5}`, +and plaintext. + +This means simple bind authentication works out of the box with any backend that stores `userPassword` on entries — +no additional configuration required. + +### Custom Bind Name Resolution + +By default, the built-in `PasswordAuthenticator` treats the bind name as a literal DN. If clients bind with something +else — a bare username, an email address, etc. — supply a custom `BindNameResolverInterface`: + +```php +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Entry\Entry; + +class UidBindNameResolver implements BindNameResolverInterface +{ + public function resolve( + string $name, + LdapBackendInterface $backend + ): ?Entry { + // Translate the bind name to an entry however you like. + // Return null to reject authentication. + } +} +``` + +```php +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\LdapServer; + +$server = new LdapServer( + (new ServerOptions)->setBindNameResolver(new UidBindNameResolver()) +); +``` + +The resolver is used for all auth paths (simple bind, SASL PLAIN, and challenge SASL) through the built-in +`PasswordAuthenticator`. + +### Custom Authenticator + +For full control — external auth services, custom credential storage, etc. — implement +`PasswordAuthenticatableInterface` and register it: + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use SensitiveParameter; + +class MyAuthenticator implements PasswordAuthenticatableInterface +{ + public function verifyPassword(string $name, #[SensitiveParameter] string $password): bool + { + // Your credential verification logic. + } + + public function getPassword(string $username, string $mechanism): ?string + { + // Return plaintext password for challenge SASL, or null to reject. + // Return null here if you don't support challenge SASL. + } +} + +$server = (new LdapServer())->usePasswordAuthenticator(new MyAuthenticator()); +``` + ## Handling the RootDSE The server generates a default RootDSE from `ServerOptions` values (`setDseNamingContexts()`, `setDseVendorName()`, @@ -374,8 +606,7 @@ $server = (new LdapServer()) $server->run(); ``` -If your backend class also implements `RootDseHandlerInterface`, you do not need to call `useRootDseHandler()` — the -framework detects and uses it automatically. +If your backend class also implements `RootDseHandlerInterface`, you do not need to call `useRootDseHandler()` — it will be used automatically. ## SASL Authentication @@ -387,116 +618,42 @@ support via `ServerOptions::setSaslMechanisms()`. The configured mechanisms are use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -$server = (new LdapServer( +$server = new LdapServer( (new ServerOptions)->setSaslMechanisms( ServerOptions::SASL_PLAIN, - ServerOptions::SASL_CRAM_MD5, ServerOptions::SASL_SCRAM_SHA_256, ) -))->useBackend(new MyBackend()); +); ``` +All SASL mechanisms are handled through `PasswordAuthenticatableInterface`. No separate interface or backend +modification is needed. + ### PLAIN Mechanism -The `PLAIN` mechanism reuses your existing `LdapBackendInterface::verifyPassword()` method. When a client -authenticates with SASL PLAIN, the server extracts the username and password from the SASL credentials and calls -`verifyPassword()` exactly as it would for a simple bind. +The `PLAIN` mechanism extracts the username and password from the SASL credentials and calls +`PasswordAuthenticatableInterface::verifyPassword()` — the same path as a simple bind. The built-in +`PasswordAuthenticator` handles this automatically, so no additional configuration is needed beyond enabling the +mechanism. **Note**: PLAIN transmits credentials in cleartext. Only enable it when the connection is protected by TLS (StartTLS or `setUseSsl`). ### Challenge-Based Mechanisms (CRAM-MD5, DIGEST-MD5, and SCRAM) -`CRAM-MD5`, `DIGEST-MD5`, and the `SCRAM-*` family are challenge-response mechanisms. The server issues a challenge to -the client and verifies the client's response against a digest computed from the user's plaintext password. Because the -verification is cryptographic, the server must be able to look up the plaintext (or equivalent) password for a given -username. +`CRAM-MD5`, `DIGEST-MD5`, and the `SCRAM-*` family are challenge-response mechanisms. The server issues a challenge +to the client and verifies the response against a digest computed from the user's plaintext password. The server calls +`PasswordAuthenticatableInterface::getPassword()` to retrieve the password. -To support these mechanisms, your backend must additionally implement -`FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface`: +The built-in `PasswordAuthenticator` implements `getPassword()` by reading the raw value of the `userPassword` +attribute from the resolved entry. This works when passwords are stored in plaintext. If passwords are stored as +one-way hashes (bcrypt, argon2) you must supply a custom authenticator that can return a recoverable value. -```php -interface SaslHandlerInterface -{ - public function getPassword( - string $username, - string $mechanism, - ): ?string; -} -``` - -Return the user's plaintext password for the given username and mechanism, or `null` if the user does not exist or -should not be permitted to authenticate. Returning `null` results in a generic `invalidCredentials` error. - -The `$mechanism` parameter lets you apply per-mechanism policy if needed (e.g. disallow weak mechanisms for certain -users), but in most cases you can ignore it and return the same password regardless. - -Example backend supporting both simple binds and challenge-based SASL: - -```php -namespace App; - -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\SearchContext; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; -use Generator; - -class MyBackend implements LdapBackendInterface, SaslHandlerInterface -{ - private array $users = [ - 'alice' => 'her-plaintext-password', - 'bob' => 'his-plaintext-password', - ]; - - public function search(SearchContext $context): Generator - { - // yield entries... - } - - public function get(Dn $dn): ?Entry - { - return null; - } +**Note**: Because challenge mechanisms require a recoverable password, they are fundamentally incompatible with +one-way hashing. If one-way hashing is a hard requirement, use `PLAIN` over TLS instead. - // Used for simple binds and SASL PLAIN. - public function verifyPassword(Dn $dn, string $password): bool - { - $user = $this->users[$dn->toString()] ?? null; - - return $user !== null && $user === $password; - } - - // Used for CRAM-MD5, DIGEST-MD5, and all SCRAM variants. - public function getPassword(string $username, string $mechanism): ?string - { - return $this->users[$username] ?? null; - } -} -``` - -Then enable the desired mechanisms on the server: - -```php -use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Ldap\LdapServer; -use App\MyBackend; - -$server = (new LdapServer( - (new ServerOptions)->setSaslMechanisms( - ServerOptions::SASL_CRAM_MD5, - ServerOptions::SASL_DIGEST_MD5, - ServerOptions::SASL_SCRAM_SHA_256, - ) -))->useBackend(new MyBackend()); - -$server->run(); -``` - -**SCRAM variants**: The following constants are available for the SCRAM family. `SCRAM-SHA-256` is the recommended -choice for new deployments ([RFC 7677](https://www.rfc-editor.org/rfc/rfc7677) standardises it as the preferred -mechanism, citing SHA-1's known weaknesses). +**SCRAM variants**: The following constants are available. `SCRAM-SHA-256` is the recommended choice for new +deployments ([RFC 7677](https://www.rfc-editor.org/rfc/rfc7677) standardises it as the preferred mechanism). | Constant | Mechanism | |--------------------------------------|---------------------------------| @@ -509,10 +666,6 @@ mechanism, citing SHA-1's known weaknesses). Channel-binding (`-PLUS`) variants of each are also available (e.g. `SASL_SCRAM_SHA_256_PLUS`) for environments where TLS channel binding is required. -**Note**: Because `getPassword()` must return the plaintext password, you cannot store passwords as one-way hashes -(e.g. bcrypt) when supporting CRAM-MD5, DIGEST-MD5, or SCRAM. If one-way hashing is a requirement, use `PLAIN` over -TLS instead, which allows password verification via `verifyPassword()`. - ## StartTLS SSL Certificate Support To allow clients to issue a StartTLS command against the LDAP server you need to provide an SSL certificate, key, and From 669ba9e9b75756122fd179d73ff47204dd7b8d4a Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 14:38:51 -0400 Subject: [PATCH 22/35] Fix various tests / analysis issues after changes. --- src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php | 2 -- tests/bin/ldap-server.php | 3 +-- tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php index cd99d770..d3385af3 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php @@ -24,7 +24,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\SaslContext; /** @@ -38,7 +37,6 @@ public function __construct( private readonly ServerQueue $queue, private readonly ResponseFactory $responseFactory, private readonly MechanismOptionsBuilderFactory $optionsBuilderFactory, - private readonly PasswordAuthenticatableInterface $authenticator, ) { } diff --git a/tests/bin/ldap-server.php b/tests/bin/ldap-server.php index c909d489..b6486bd9 100644 --- a/tests/bin/ldap-server.php +++ b/tests/bin/ldap-server.php @@ -13,12 +13,11 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; use FreeDSx\Ldap\ServerOptions; require __DIR__ . '/../../vendor/autoload.php'; -class LdapServerBackend implements WritableLdapBackendInterface, SaslHandlerInterface, PasswordAuthenticatableInterface +class LdapServerBackend implements WritableLdapBackendInterface, PasswordAuthenticatableInterface { use WritableBackendTrait; diff --git a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php index 3accd420..497a21c6 100644 --- a/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php +++ b/tests/unit/Protocol/Bind/Sasl/SaslExchangeTest.php @@ -55,7 +55,6 @@ protected function setUp(): void queue: $this->mockQueue, responseFactory: new ResponseFactory(), optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockAuthenticator), - authenticator: $mockAuthenticator, ); } From 420de4b42f2251280c5d48fa6bd095c8fbe4f929 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 7 Apr 2026 14:50:21 -0400 Subject: [PATCH 23/35] Clean up the SaslBind constructor. --- src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php | 16 +------------- .../Ldap/Server/ServerProtocolFactory.php | 11 ++++++++-- tests/unit/Protocol/Bind/SaslBindTest.php | 21 ++++++++++++++++--- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php index 41111b8e..e1cfbee3 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php @@ -17,7 +17,6 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchange; use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchangeInput; use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchangeResult; @@ -26,7 +25,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\MessageWrapper\SaslMessageWrapper; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\Operation\Request\SaslBindRequest; @@ -43,29 +41,17 @@ class SaslBind implements BindInterface { use VersionValidatorTrait; - private readonly SaslExchange $exchange; - /** * @param string[] $mechanisms */ public function __construct( private readonly ServerQueue $queue, - private readonly PasswordAuthenticatableInterface $authenticator, + private readonly SaslExchange $exchange, private readonly Sasl $sasl = new Sasl(), private readonly array $mechanisms = [], private readonly ResponseFactory $responseFactory = new ResponseFactory(), private readonly SaslUsernameExtractorFactory $usernameExtractorFactory = new SaslUsernameExtractorFactory(), - ?SaslExchange $exchange = null, - ?MechanismOptionsBuilderFactory $optionsBuilderFactory = null, ) { - $factory = $optionsBuilderFactory ?? new MechanismOptionsBuilderFactory($this->authenticator); - - $this->exchange = $exchange ?? new SaslExchange( - $this->queue, - $this->responseFactory, - $factory, - $this->authenticator, - ); } /** diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index 4f395be1..1b69db2c 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -16,8 +16,10 @@ use FreeDSx\Ldap\Protocol\Authenticator; use FreeDSx\Ldap\Protocol\Bind\AnonymousBind; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; +use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchange; use FreeDSx\Ldap\Protocol\Bind\SaslBind; use FreeDSx\Ldap\Protocol\Bind\SimpleBind; +use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Sasl\Sasl; use FreeDSx\Ldap\Protocol\Factory\ServerProtocolHandlerFactory; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; @@ -52,12 +54,17 @@ public function make(Socket $socket): ServerProtocolHandler $saslMechanisms = $this->options->getSaslMechanisms(); if (!empty($saslMechanisms)) { + $responseFactory = new ResponseFactory(); $authenticators[] = new SaslBind( queue: $serverQueue, - authenticator: $passwordAuthenticator, + exchange: new SaslExchange( + $serverQueue, + $responseFactory, + new MechanismOptionsBuilderFactory($passwordAuthenticator), + ), sasl: new Sasl(['supported' => $saslMechanisms]), mechanisms: $saslMechanisms, - optionsBuilderFactory: new MechanismOptionsBuilderFactory($passwordAuthenticator), + responseFactory: $responseFactory, ); } diff --git a/tests/unit/Protocol/Bind/SaslBindTest.php b/tests/unit/Protocol/Bind/SaslBindTest.php index 7ac791c7..6f902ede 100644 --- a/tests/unit/Protocol/Bind/SaslBindTest.php +++ b/tests/unit/Protocol/Bind/SaslBindTest.php @@ -19,7 +19,10 @@ use FreeDSx\Ldap\Operation\Request\SaslBindRequest; use FreeDSx\Ldap\Operation\Request\SimpleBindRequest; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; +use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchange; use FreeDSx\Ldap\Protocol\Bind\SaslBind; +use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; @@ -43,7 +46,11 @@ protected function setUp(): void $this->subject = new SaslBind( queue: $this->mockQueue, - authenticator: $this->mockAuthenticator, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_PLAIN], ); } @@ -149,7 +156,11 @@ public function test_challenge_mechanism_sends_invalid_credentials_on_authentica { $subject = new SaslBind( queue: $this->mockQueue, - authenticator: $this->mockAuthenticator, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_CRAM_MD5], ); @@ -203,7 +214,11 @@ public function test_it_throws_protocol_error_when_non_sasl_request_received_dur { $subject = new SaslBind( queue: $this->mockQueue, - authenticator: $this->mockAuthenticator, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_CRAM_MD5], ); From 4eb831435195ecd888e2063cd1d9a4cf6ad3426b Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 07:37:12 -0400 Subject: [PATCH 24/35] More logging consistency fixes --- .../Server/ServerRunner/PcntlServerRunner.php | 4 ++ .../ServerRunner/ServerRunnerLoggerTrait.php | 60 +++++++++++++++++-- .../ServerRunner/SwooleServerRunner.php | 17 ++---- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index a9c03a56..05c8122a 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -99,6 +99,10 @@ public function run(): void try { $this->acceptClients(); + } catch (Throwable $e) { + $this->logAcceptError($e, $this->defaultContext); + + throw $e; } finally { if ($this->isMainProcess) { $this->handleServerShutdown(); diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php index 76643ed0..ac3ba01e 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php @@ -74,6 +74,20 @@ private function logClientRejectedDuringShutdown(array $context = []): void ); } + /** + * @param array $context + */ + private function logClientError( + Throwable $e, + array $context = [], + ): void { + $this->getRunnerLogger()?->log( + LogLevel::ERROR, + 'Unhandled error while handling client connection.', + array_merge($context, self::exceptionContext($e)), + ); + } + /** * @param array $context */ @@ -86,6 +100,20 @@ private function logConnectionLimitReached(array $context = []): void ); } + /** + * @param array $context + */ + private function logAcceptError( + Throwable $e, + array $context = [], + ): void { + $this->getRunnerLogger()?->log( + LogLevel::ERROR, + 'Failed to accept incoming connection.', + array_merge($context, self::exceptionContext($e)), + ); + } + /** * @param array $context */ @@ -110,6 +138,20 @@ private function logShutdownCompleted(array $context = []): void ); } + /** + * @param array $context + */ + private function logShutdownForceClose( + int $activeConnections, + array $context = [], + ): void { + $this->getRunnerLogger()?->log( + LogLevel::WARNING, + 'Shutdown timeout exceeded, forcing close of active connections.', + array_merge($context, ['active_connections' => $activeConnections]), + ); + } + /** * @param array $context */ @@ -120,11 +162,19 @@ private function logShutdownNotifyError( $this->getRunnerLogger()?->log( LogLevel::WARNING, 'Unexpected error while notifying client of shutdown.', - array_merge($context, [ - 'exception_message' => $e->getMessage(), - 'exception_class' => $e::class, - 'exception_stacktrace' => $e->getTraceAsString(), - ]), + array_merge($context, self::exceptionContext($e)), ); } + + /** + * @return array{exception_message: string, exception_class: string, exception_stacktrace: string} + */ + private static function exceptionContext(Throwable $e): array + { + return [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_stacktrace' => $e->getTraceAsString(), + ]; + } } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 3ccb7a41..e1eba5ee 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -147,11 +147,7 @@ private function startDrainTimeout(): void return; } - $this->options->getLogger()?->warning(sprintf( - 'SwooleServerRunner: shutdown timeout (%d s) exceeded with %d active connection(s), forcing close.', - $this->options->getShutdownTimeout(), - count($this->activeSockets), - )); + $this->logShutdownForceClose(count($this->activeSockets)); foreach ($this->activeSockets as $socket) { $socket->close(); @@ -168,9 +164,8 @@ private function acceptClients(): void try { $socket = $this->server->accept($this->options->getSocketAcceptTimeout()); } catch (Throwable $e) { - $this->options->getLogger()?->error( - 'SwooleServerRunner: accept() failed: ' . $e->getMessage() - ); + $this->logAcceptError($e); + break; } @@ -200,7 +195,7 @@ private function acceptClients(): void }); } - $this->options->getLogger()?->info('SwooleServerRunner: accept loop ended, draining active connections.'); + $this->getRunnerLogger()?->info('Accept loop ended, draining active connections.'); } private function handleClient( @@ -213,9 +208,7 @@ private function handleClient( $this->logClientConnected(); $handler->handle(); } catch (Throwable $e) { - $this->options->getLogger()?->error( - 'SwooleServerRunner: unhandled error in client coroutine: ' . $e->getMessage() - ); + $this->logClientError($e); } finally { $this->logClientClosed(); $socket->close(); From 7717b6cb2fcb0cfb19f2330aaa4dedf25d05ed2e Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 07:44:23 -0400 Subject: [PATCH 25/35] Adjust the upgrade doc. --- UPGRADE-1.0.md | 112 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 10 deletions(-) diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index 6487e5f0..dcbcd34c 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -152,7 +152,7 @@ $ldap = new LdapServer( `RequestHandlerInterface`, `GenericRequestHandler`, `PagingHandlerInterface`, and related classes have been removed. The server now works with a single backend object that implements `LdapBackendInterface` (read-only) or -`WritableLdapBackendInterface` (read-write). Paging is handled automatically by the framework via PHP generators — +`WritableLdapBackendInterface` (read-write). Paging is handled automatically by the backend via PHP generators — no separate paging handler is needed. **Before** (0.x `RequestHandlerInterface`): @@ -201,7 +201,45 @@ class MyBackend implements LdapBackendInterface { public function search(SearchContext $context): Generator { - // Yield matching entries. The framework handles paging automatically. + // Yield matching entries. Paging is handled automatically. + yield from []; + } + + public function get(Dn $dn): ?Entry + { + return null; + } +} + +$server = (new LdapServer()) + ->useBackend(new MyBackend()); +``` + +### Authentication + +The `bind()` method on `GenericRequestHandler` has been replaced by `PasswordAuthenticatableInterface`. Authentication +is now a separate concern decoupled from the backend. + +**Default behaviour**: if your backend stores a `userPassword` attribute on entries, the built-in `PasswordAuthenticator` +verifies credentials automatically — no extra code needed. Supported schemes: `{SHA}`, `{SSHA}`, `{MD5}`, `{SMD5}`, +and plaintext. + +**Custom authentication**: implement `PasswordAuthenticatableInterface` on your backend (or on a dedicated class) to +replicate the old `bind()` behaviour: + +```php +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use Generator; +use SensitiveParameter; + +class MyBackend implements LdapBackendInterface, PasswordAuthenticatableInterface +{ + public function search(SearchContext $context): Generator + { yield from []; } @@ -211,27 +249,81 @@ class MyBackend implements LdapBackendInterface } public function verifyPassword( - Dn $dn, - string $password, + string $name, + #[SensitiveParameter] string $password, ): bool { - return $dn->toString() === 'cn=admin,dc=example,dc=com' - && $password === 'secret'; + return $name === 'cn=admin,dc=example,dc=com' && $password === 'secret'; + } + + public function getPassword(string $username, string $mechanism): ?string + { + // Return a plaintext password for challenge SASL mechanisms (CRAM-MD5, SCRAM-*, etc.), + // or null to disable challenge SASL for this user. + return null; } } +``` + +The use of `PasswordAuthenticatableInterface` on the backend is automatically detected. Alternatively, register a +standalone authenticator: +```php $server = (new LdapServer()) - ->useBackend(new MyBackend()); + ->useBackend(new MyBackend()) + ->usePasswordAuthenticator(new MyAuthenticator()); ``` ### Write operations -`add()`, `delete()`, `update()`, and `move()` are now part of `WritableLdapBackendInterface`. Implement that interface -instead of `LdapBackendInterface` when your backend supports write operations. +`add()`, `delete()`, `update()`, and `move()` are now part of `WritableLdapBackendInterface`. Each operation receives +a typed command object. Use `WritableBackendTrait` to implement the dispatch automatically: + +```php +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; + +class MyBackend implements WritableLdapBackendInterface +{ + use WritableBackendTrait; + + // search() and get() as above ... + + public function add(AddCommand $command): void + { + // $command->entry — Entry to persist + } + + public function delete(DeleteCommand $command): void + { + // $command->dn — Dn of the entry to remove + } + + public function update(UpdateCommand $command): void + { + // $command->dn — Dn of the entry to modify + // $command->changes — Change[] of attribute changes + } + + public function move(MoveCommand $command): void + { + // $command->dn — current entry Dn + // $command->newRdn — new relative Dn + // $command->deleteOldRdn — bool + // $command->newParent — ?Dn new parent + } +} +``` + +Implement `WritableLdapBackendInterface` instead of `LdapBackendInterface` when your backend supports write operations. ### Paging Paging no longer requires a `PagingHandlerInterface` implementation. Return a `Generator` from `search()` and the -framework automatically slices it into pages when a client sends a paged search control. The `supportedControl` +the backend automatically slices it into pages when a client sends a paged search control. The `supportedControl` attribute of the RootDSE is populated automatically when a backend is configured. ### Proxy server From e1ab2cdf574c97008ac3a229fec178adb1c0cb61 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 09:36:48 -0400 Subject: [PATCH 26/35] Remove duplication between storage adapter logic. --- docs/Server/General-Usage.md | 8 +- .../Adapter/InMemoryStorageAdapter.php | 115 +++------ .../Adapter/JsonFileStorageAdapter.php | 243 ++++++------------ .../Adapter/Operation/MoveOperation.php | 59 +++++ .../Adapter/Operation/UpdateOperation.php | 66 +++++ .../Operation/WriteEntryOperationHandler.php | 41 +++ .../Storage/Adapter/StorageAdapterTrait.php | 5 +- tests/bin/ldap-backend-storage.php | 2 +- .../Adapter/InMemoryStorageAdapterTest.php | 4 +- .../Adapter/Operation/MoveOperationTest.php | 200 ++++++++++++++ .../Adapter/Operation/UpdateOperationTest.php | 177 +++++++++++++ 11 files changed, 660 insertions(+), 260 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/MoveOperation.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/Operation/MoveOperationTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 8d60d901..00fcb8e5 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -57,14 +57,14 @@ $server = new LdapServer( (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') ); -$server->useBackend(new InMemoryStorageAdapter( +$server->useBackend(new InMemoryStorageAdapter([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), new Entry( new Dn('cn=admin,dc=example,dc=com'), new Attribute('cn', 'admin'), new Attribute('userPassword', $passwordHash), ), -)); +])); $server->run(); ``` @@ -348,14 +348,14 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); -$adapter = new InMemoryStorageAdapter( +$adapter = new InMemoryStorageAdapter([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), new Entry( new Dn('cn=admin,dc=example,dc=com'), new Attribute('cn', 'admin'), new Attribute('userPassword', $passwordHash), ), -); +]); $server = (new LdapServer())->useBackend($adapter); $server->run(); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php index 84f7616f..ab0831e2 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php @@ -13,15 +13,13 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; -use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Change; 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\PresentFilter; use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -57,9 +55,13 @@ class InMemoryStorageAdapter implements WritableLdapBackendInterface /** * Pre-populate the adapter with a set of entries. + * + * @param Entry[] $entries */ - public function __construct(Entry ...$entries) - { + public function __construct( + array $entries = [], + private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), + ) { foreach ($entries as $entry) { $this->entries[$this->normalise($entry->getDn())] = $entry; } @@ -104,107 +106,54 @@ public function add(AddCommand $command): void */ public function delete(DeleteCommand $command): void { - $dn = $command->dn; - $childGenerator = $this->search(new SearchContext( - baseDn: $dn, - scope: SearchRequest::SCOPE_SINGLE_LEVEL, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - )); - - if ($childGenerator->valid()) { - throw new OperationException( - sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $dn->toString()), - ResultCode::NOT_ALLOWED_ON_NON_LEAF, - ); + $normDn = $this->normalise($command->dn); + + foreach (array_keys($this->entries) as $key) { + if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { + throw new OperationException( + sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } } - unset($this->entries[$this->normalise($dn)]); + unset($this->entries[$normDn]); } public function update(UpdateCommand $command): void { - $entry = $this->get($command->dn); + $normDn = $this->normalise($command->dn); - if ($entry === null) { + if (!isset($this->entries[$normDn])) { throw new OperationException( sprintf('No such object: %s', $command->dn->toString()), ResultCode::NO_SUCH_OBJECT, ); } - foreach ($command->changes as $change) { - $attribute = $change->getAttribute(); - $attrName = $attribute->getName(); - $values = $attribute->getValues(); - - switch ($change->getType()) { - case Change::TYPE_ADD: - $existing = $entry->get($attrName); - if ($existing !== null) { - $entry->get($attrName)?->add(...$values); - } else { - $entry->add($attribute); - } - break; - - case Change::TYPE_DELETE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->get($attrName)?->remove(...$values); - } - break; - - case Change::TYPE_REPLACE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->set($attribute); - } - break; - } - } + $this->entries[$normDn] = $this->entryHandler->apply( + $this->entries[$normDn], + $command, + ); } public function move(MoveCommand $command): void { - $dn = $command->dn; - $entry = $this->get($dn); + $normOld = $this->normalise($command->dn); - if ($entry === null) { + if (!isset($this->entries[$normOld])) { throw new OperationException( - sprintf('No such object: %s', $dn->toString()), + sprintf('No such object: %s', $command->dn->toString()), ResultCode::NO_SUCH_OBJECT, ); } - $parent = $command->newParent ?? $dn->getParent(); - $newDnString = $parent !== null - ? $command->newRdn->toString() . ',' . $parent->toString() - : $command->newRdn->toString(); - - $newDn = new Dn($newDnString); - $newEntry = new Entry($newDn, ...$entry->getAttributes()); - - if ($command->deleteOldRdn) { - $oldRdn = $dn->getRdn(); - $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); - } - - $rdnName = $command->newRdn->getName(); - $rdnValue = $command->newRdn->getValue(); - $existing = $newEntry->get($rdnName); - if ($existing !== null) { - if (!$existing->has($rdnValue)) { - $existing->add($rdnValue); - } - } else { - $newEntry->set(new Attribute($rdnName, $rdnValue)); - } + $newEntry = $this->entryHandler->apply( + $this->entries[$normOld], + $command, + ); + unset($this->entries[$normOld]); - $this->delete(new DeleteCommand($dn)); - $this->add(new AddCommand($newEntry)); + $this->entries[$this->normalise($newEntry->getDn())] = $newEntry; } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php index a3283917..36eb454e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php @@ -14,7 +14,6 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; @@ -24,6 +23,7 @@ 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\Adapter\Operation\WriteEntryOperationHandler; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; @@ -68,6 +68,7 @@ class JsonFileStorageAdapter implements WritableLdapBackendInterface private function __construct( private readonly string $filePath, private readonly StorageLockInterface $lock, + private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), ) { } @@ -77,7 +78,7 @@ public static function forPcntl( ): self { return new self( $filePath, - $lock ?? new FileLock($filePath) + $lock ?? new FileLock($filePath), ); } @@ -87,7 +88,7 @@ public static function forSwoole( ): self { return new self( $filePath, - $lock ?? new CoroutineLock($filePath) + $lock ?? new CoroutineLock($filePath), ); } @@ -112,206 +113,110 @@ public function search(SearchContext $context): Generator */ public function add(AddCommand $command): void { - $this->withMutation( - fn(string $contents): string => $this->executeAdd( - $contents, - $command - ) - ); - } + $this->withMutation(function (string $contents) use ($command): string { + $data = $this->decodeContents($contents); + $normDn = $this->normalise($command->entry->getDn()); - public function delete(DeleteCommand $command): void - { - $this->withMutation( - fn(string $contents): string => $this->executeDelete( - $contents, - $command - ) - ); - } + if (isset($data[$normDn])) { + throw new OperationException( + sprintf('Entry already exists: %s', $command->entry->getDn()->toString()), + ResultCode::ENTRY_ALREADY_EXISTS, + ); + } - public function update(UpdateCommand $command): void - { - $this->withMutation( - fn(string $contents): string => $this->executeUpdate( - $contents, - $command - ) - ); - } + $data[$normDn] = $this->entryToArray($command->entry); - public function move(MoveCommand $command): void - { - $this->withMutation( - fn(string $contents): string => $this->executeMove( - $contents, - $command - ) - ); - } - - /** - * @param callable(string): string $mutation - */ - private function withMutation(callable $mutation): void - { - try { - $this->lock->withLock($mutation); - } finally { - $this->cache = null; - } + return $this->encodeContents($data); + }); } /** * @throws OperationException */ - private function executeAdd( - string $contents, - AddCommand $command, - ): string { - $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->entry->getDn()); - - if (isset($data[$normDn])) { - throw new OperationException( - sprintf('Entry already exists: %s', $command->entry->getDn()->toString()), - ResultCode::ENTRY_ALREADY_EXISTS, - ); - } + public function delete(DeleteCommand $command): void + { + $this->withMutation(function (string $contents) use ($command): string { + $data = $this->decodeContents($contents); + $normDn = $this->normalise($command->dn); + + foreach (array_keys($data) as $key) { + if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { + throw new OperationException( + sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } + } - $data[$normDn] = $this->entryToArray($command->entry); + unset($data[$normDn]); - return $this->encodeContents($data); + return $this->encodeContents($data); + }); } /** * @throws OperationException */ - private function executeDelete( - string $contents, - DeleteCommand $command, - ): string { - $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->dn); - - foreach (array_keys($data) as $key) { - if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { + public function update(UpdateCommand $command): void + { + $this->withMutation(function (string $contents) use ($command): string { + $data = $this->decodeContents($contents); + $normDn = $this->normalise($command->dn); + + if (!isset($data[$normDn])) { throw new OperationException( - sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), - ResultCode::NOT_ALLOWED_ON_NON_LEAF, + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, ); } - } - unset($data[$normDn]); + $entry = $this->entryHandler->apply( + $this->arrayToEntry($data[$normDn]), + $command, + ); + $data[$normDn] = $this->entryToArray($entry); - return $this->encodeContents($data); + return $this->encodeContents($data); + }); } /** * @throws OperationException */ - private function executeUpdate( - string $contents, - UpdateCommand $command, - ): string { - $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->dn); - - if (!isset($data[$normDn])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $entry = $this->arrayToEntry($data[$normDn]); - - foreach ($command->changes as $change) { - $attribute = $change->getAttribute(); - $attrName = $attribute->getName(); - $values = $attribute->getValues(); - - switch ($change->getType()) { - case Change::TYPE_ADD: - $existing = $entry->get($attrName); - if ($existing !== null) { - $existing->add(...$values); - } else { - $entry->add($attribute); - } - break; - - case Change::TYPE_DELETE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->get($attrName)?->remove(...$values); - } - break; + public function move(MoveCommand $command): void + { + $this->withMutation(function (string $contents) use ($command): string { + $data = $this->decodeContents($contents); + $normOld = $this->normalise($command->dn); - case Change::TYPE_REPLACE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->set($attribute); - } - break; + if (!isset($data[$normOld])) { + throw new OperationException( + sprintf('No such object: %s', $command->dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); } - } - $data[$normDn] = $this->entryToArray($entry); + $newEntry = $this->entryHandler->apply( + $this->arrayToEntry($data[$normOld]), + $command, + ); + unset($data[$normOld]); + $data[$this->normalise($newEntry->getDn())] = $this->entryToArray($newEntry); - return $this->encodeContents($data); + return $this->encodeContents($data); + }); } /** - * @throws OperationException + * @param callable(string): string $mutation */ - private function executeMove( - string $contents, - MoveCommand $command, - ): string { - $data = $this->decodeContents($contents); - $normOld = $this->normalise($command->dn); - - if (!isset($data[$normOld])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $entry = $this->arrayToEntry($data[$normOld]); - - $parent = $command->newParent ?? $command->dn->getParent(); - $newDnString = $parent !== null - ? $command->newRdn->toString() . ',' . $parent->toString() - : $command->newRdn->toString(); - - $newDn = new Dn($newDnString); - $newEntry = new Entry($newDn, ...$entry->getAttributes()); - - if ($command->deleteOldRdn) { - $oldRdn = $command->dn->getRdn(); - $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); - } - - $rdnName = $command->newRdn->getName(); - $rdnValue = $command->newRdn->getValue(); - $existing = $newEntry->get($rdnName); - if ($existing !== null) { - if (!$existing->has($rdnValue)) { - $existing->add($rdnValue); - } - } else { - $newEntry->set(new Attribute($rdnName, $rdnValue)); + private function withMutation(callable $mutation): void + { + try { + $this->lock->withLock($mutation); + } finally { + $this->cache = null; } - - unset($data[$normOld]); - $data[$this->normalise($newDn)] = $this->entryToArray($newEntry); - - return $this->encodeContents($data); } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/MoveOperation.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/MoveOperation.php new file mode 100644 index 00000000..5d65fb59 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/MoveOperation.php @@ -0,0 +1,59 @@ + + * + * 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\Operation; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; + +/** + * Constructs a new Entry from the given RDN / parent, handling old RDN + * deletion and new RDN attribute assignment. + * + * @author Chad Sikorra + */ +final class MoveOperation +{ + public function execute( + Entry $entry, + MoveCommand $command, + ): Entry { + $parent = $command->newParent ?? $command->dn->getParent(); + $newDnString = $parent !== null + ? $command->newRdn->toString() . ',' . $parent->toString() + : $command->newRdn->toString(); + + $newDn = new Dn($newDnString); + $newEntry = new Entry($newDn, ...$entry->getAttributes()); + + if ($command->deleteOldRdn) { + $oldRdn = $command->dn->getRdn(); + $newEntry->get($oldRdn->getName())?->remove($oldRdn->getValue()); + } + + $rdnName = $command->newRdn->getName(); + $rdnValue = $command->newRdn->getValue(); + $existing = $newEntry->get($rdnName); + if ($existing !== null) { + if (!$existing->has($rdnValue)) { + $existing->add($rdnValue); + } + } else { + $newEntry->set(new Attribute($rdnName, $rdnValue)); + } + + return $newEntry; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php new file mode 100644 index 00000000..5c023a5d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php @@ -0,0 +1,66 @@ + + * + * 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\Operation; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; + +/** + * Applies attribute changes (ADD / DELETE / REPLACE) to an Entry. + * + * @author Chad Sikorra + */ +final class UpdateOperation +{ + public function execute( + Entry $entry, + UpdateCommand $command, + ): Entry { + foreach ($command->changes as $change) { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $values = $attribute->getValues(); + + switch ($change->getType()) { + case Change::TYPE_ADD: + $existing = $entry->get($attrName); + if ($existing !== null) { + $existing->add(...$values); + } else { + $entry->add($attribute); + } + break; + + case Change::TYPE_DELETE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->get($attrName)?->remove(...$values); + } + break; + + case Change::TYPE_REPLACE: + if (count($values) === 0) { + $entry->reset($attrName); + } else { + $entry->set($attribute); + } + break; + } + } + + return $entry; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php new file mode 100644 index 00000000..64807e96 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.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\Operation; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; +use LogicException; + +/** + * Routes a write command to the appropriate entry-level operation. + * + * @author Chad Sikorra + */ +final class WriteEntryOperationHandler +{ + public function apply( + Entry $entry, + WriteRequestInterface $command, + ): Entry { + return match (true) { + $command instanceof UpdateCommand => (new UpdateOperation())->execute($entry, $command), + $command instanceof MoveCommand => (new MoveOperation())->execute($entry, $command), + default => throw new LogicException( + sprintf('No entry operation handler for %s', $command::class), + ), + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php index 6a9ae277..fb774e85 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php @@ -76,7 +76,10 @@ private function isDirectChild( strlen($normDn) - strlen(',' . $normBase) ); - return !str_contains($prefix, ','); + return !str_contains( + $prefix, + ',' + ); } } diff --git a/tests/bin/ldap-backend-storage.php b/tests/bin/ldap-backend-storage.php index 2d452263..a4936a74 100644 --- a/tests/bin/ldap-backend-storage.php +++ b/tests/bin/ldap-backend-storage.php @@ -61,7 +61,7 @@ $adapter->add(new AddCommand($entry)); } } else { - $adapter = new InMemoryStorageAdapter(...$entries); + $adapter = new InMemoryStorageAdapter($entries); } $server = (new LdapServer( diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php index 6392e1b6..89d16367 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php @@ -58,11 +58,11 @@ protected function setUp(): void new Attribute('cn', 'Bob'), ); - $this->subject = new InMemoryStorageAdapter( + $this->subject = new InMemoryStorageAdapter([ $this->base, $this->alice, $this->bob, - ); + ]); } public function test_get_returns_entry_by_dn(): void diff --git a/tests/unit/Server/Backend/Storage/Adapter/Operation/MoveOperationTest.php b/tests/unit/Server/Backend/Storage/Adapter/Operation/MoveOperationTest.php new file mode 100644 index 00000000..b53455b4 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/MoveOperationTest.php @@ -0,0 +1,200 @@ + + * + * 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\Operation; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\MoveOperation; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use PHPUnit\Framework\TestCase; + +final class MoveOperationTest extends TestCase +{ + private MoveOperation $subject; + + private Entry $entry; + + protected function setUp(): void + { + $this->subject = new MoveOperation(); + $this->entry = new Entry( + new Dn('cn=alice,dc=example,dc=com'), + new Attribute('cn', 'alice'), + new Attribute('mail', 'alice@example.com'), + ); + } + + public function test_returned_entry_has_new_dn(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alicia'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + 'cn=alicia,dc=example,dc=com', + $result->getDn()->toString() + ); + } + + public function test_delete_old_rdn_removes_old_rdn_value(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alicia'), + deleteOldRdn: true, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertNotContains( + 'alice', + $cn->getValues() + ); + self::assertContains( + 'alicia', + $cn->getValues() + ); + } + + public function test_preserve_old_rdn_keeps_old_rdn_value(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alicia'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertContains( + 'alice', + $cn->getValues() + ); + self::assertContains( + 'alicia', + $cn->getValues() + ); + } + + public function test_new_rdn_attribute_added_when_absent(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('uid', 'alice'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + ['alice'], + $result->get('uid')?->getValues() + ); + } + + public function test_new_rdn_value_appended_when_attribute_exists_without_it(): void + { + $entry = new Entry( + new Dn('cn=alice,dc=example,dc=com'), + new Attribute('cn', 'alice'), + new Attribute('uid', 'existing'), + ); + + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('uid', 'alice'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($entry, $command); + $uid = $result->get('uid'); + + self::assertNotNull($uid); + self::assertContains( + 'existing', + $uid->getValues() + ); + self::assertContains( + 'alice', + $uid->getValues() + ); + } + + public function test_move_to_new_parent(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alice'), + deleteOldRdn: false, + newParent: new Dn('ou=People,dc=example,dc=com'), + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + 'cn=alice,ou=People,dc=example,dc=com', + $result->getDn()->toString() + ); + } + + public function test_rename_in_place_without_new_parent(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alicia'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + 'cn=alicia,dc=example,dc=com', + $result->getDn()->toString() + ); + } + + public function test_original_attributes_are_preserved(): void + { + $command = new MoveCommand( + dn: new Dn('cn=alice,dc=example,dc=com'), + newRdn: new Rdn('cn', 'alicia'), + deleteOldRdn: false, + newParent: null, + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + ['alice@example.com'], + $result->get('mail')?->getValues() + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php new file mode 100644 index 00000000..7b2a0b2e --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php @@ -0,0 +1,177 @@ + + * + * 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\Operation; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\UpdateOperation; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use PHPUnit\Framework\TestCase; + +final class UpdateOperationTest extends TestCase +{ + private UpdateOperation $subject; + + private Entry $entry; + + protected function setUp(): void + { + $this->subject = new UpdateOperation(); + $this->entry = new Entry( + new Dn('cn=alice,dc=example,dc=com'), + new Attribute('cn', 'alice'), + new Attribute('mail', 'alice@example.com', 'a@b.com'), + new Attribute('userPassword', '{SHA}secret'), + ); + } + + public function test_type_add_appends_value_to_existing_attribute(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'new@example.com')], + ); + + $result = $this->subject->execute($this->entry, $command); + + $mail = $result->get('mail'); + self::assertNotNull($mail); + self::assertContains( + 'new@example.com', + $mail->getValues() + ); + self::assertContains( + 'alice@example.com', + $mail->getValues() + ); + } + + public function test_type_add_creates_attribute_when_absent(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'telephoneNumber', '+1 555 0100')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + ['+1 555 0100'], + $result->get('telephoneNumber')?->getValues() + ); + } + + public function test_type_delete_with_no_values_removes_attribute(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'userPassword')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertNull($result->get('userPassword')); + } + + public function test_type_delete_with_values_removes_specific_values(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'mail', 'a@b.com')], + ); + + $result = $this->subject->execute($this->entry, $command); + + $mail = $result->get('mail'); + + self::assertNotNull($mail); + self::assertNotContains( + 'a@b.com', + $mail->getValues() + ); + self::assertContains( + 'alice@example.com', + $mail->getValues() + ); + } + + public function test_type_replace_with_no_values_clears_attribute(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertNull($result->get('userPassword')); + } + + public function test_type_replace_sets_attribute_values(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'alicia')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + ['alicia'], + $result->get('cn')?->getValues() + ); + } + + public function test_multiple_changes_applied_in_order(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [ + new Change(Change::TYPE_REPLACE, 'cn', 'alicia'), + new Change(Change::TYPE_ADD, 'cn', 'alice'), + ], + ); + + $result = $this->subject->execute($this->entry, $command); + + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertContains( + 'alicia', + $cn->getValues() + ); + self::assertContains( + 'alice', + $cn->getValues() + ); + } + + public function test_returns_the_same_entry_instance(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'alicia')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + $this->entry, + $result + ); + } +} From df6003e3411745fe2b6f0f45baaeec0007ae7ddd Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 16:24:49 -0400 Subject: [PATCH 27/35] Decouple the storage implementation from the backend. --- docs/Server/General-Usage.md | 69 +-- src/FreeDSx/Ldap/Entry/Dn.php | 27 ++ src/FreeDSx/Ldap/LdapServer.php | 29 +- .../Adapter/ArrayEntryStorageTrait.php | 58 +++ .../Adapter/DefaultHasChildrenTrait.php | 40 ++ .../Storage/Adapter/InMemoryStorage.php | 83 ++++ .../Adapter/InMemoryStorageAdapter.php | 159 ------- .../Storage/Adapter/JsonEntryBuffer.php | 103 +++++ ...StorageAdapter.php => JsonFileStorage.php} | 141 ++---- .../Adapter/Operation/UpdateOperation.php | 216 +++++++-- .../Storage/Adapter/StorageAdapterTrait.php | 85 ---- .../Storage/AtomicStorageInterface.php | 34 ++ .../Backend/Storage/EntryStorageInterface.php | 77 +++ .../Storage/WritableStorageBackend.php | 244 ++++++++++ .../ServerRunner/SwooleServerRunner.php | 6 +- tests/bin/ldap-backend-storage.php | 27 +- tests/unit/Entry/DnTest.php | 72 +++ .../Storage/Adapter/InMemoryStorageTest.php | 171 +++++++ .../Adapter/JsonFileStorageAdapterTest.php | 437 ------------------ .../Storage/Adapter/JsonFileStorageTest.php | 224 +++++++++ .../Adapter/Operation/UpdateOperationTest.php | 148 +++++- ...est.php => WritableStorageBackendTest.php} | 240 +++++++--- 22 files changed, 1732 insertions(+), 958 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php delete mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php rename src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/{JsonFileStorageAdapter.php => JsonFileStorage.php} (54%) delete mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php delete mode 100644 tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php rename tests/unit/Server/Backend/Storage/Adapter/{InMemoryStorageAdapterTest.php => WritableStorageBackendTest.php} (74%) diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 00fcb8e5..c121c300 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -10,9 +10,9 @@ General LDAP Server Usage * [Providing a Backend](#providing-a-backend) * [Read-Only Backend](#read-only-backend) * [Writable Backend](#writable-backend) - * [Built-In Storage Adapters](#built-in-storage-adapters) - * [InMemoryStorageAdapter](#inmemorystorageadapter) - * [JsonFileStorageAdapter](#jsonfilestorageadapter) + * [Built-In Storage Implementations](#built-in-storage-implementations) + * [InMemoryStorage](#inmemorystorage) + * [JsonFileStorage](#jsonfilestorage) * [Proxy Backend](#proxy-backend) * [Custom Filter Evaluation](#custom-filter-evaluation) * [Authentication](#authentication) @@ -48,7 +48,7 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\ServerOptions; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); @@ -57,7 +57,7 @@ $server = new LdapServer( (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') ); -$server->useBackend(new InMemoryStorageAdapter([ +$server->useStorage(new InMemoryStorage([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), new Entry( new Dn('cn=admin,dc=example,dc=com'), @@ -82,7 +82,7 @@ custom `BindNameResolverInterface` translates the username to an entry. use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\ServerOptions; @@ -105,7 +105,7 @@ $server = new LdapServer( ->setBindNameResolver(new UidBindNameResolver()) ); -$server->useBackend(JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json')); +$server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); $server->run(); ``` @@ -125,7 +125,7 @@ For full control over credential storage — for example, delegating to an exter ```php use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\ServerOptions; use SensitiveParameter; @@ -159,7 +159,7 @@ $server = new LdapServer( ) ); -$server->useBackend(JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json')); +$server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); $server->usePasswordAuthenticator(new MyAuthenticator()); $server->run(); ``` @@ -205,8 +205,20 @@ For a customisable proxy, extend `ProxyBackend` directly. See [Proxy Backend](#p ## Providing a Backend -A backend is a class implementing `LdapBackendInterface` (read-only) or `WritableLdapBackendInterface` (read + write). -It is registered with `LdapServer::useBackend()`: +For storage-backed servers, provide an `EntryStorageInterface` implementation via `useStorage()`. FreeDSx LDAP +wraps it in `WritableStorageBackend`, which handles all LDAP semantics — validation, error codes, scope checking, +and entry transformation. Your storage implementation handles only raw persistence. + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; + +$server = (new LdapServer())->useStorage(new InMemoryStorage($entries)); +$server->run(); +``` + +For backends that implement full LDAP semantics themselves (e.g. a proxy), use `useBackend()` with a class +implementing `LdapBackendInterface` (read-only) or `WritableLdapBackendInterface` (read + write): ```php use FreeDSx\Ldap\LdapServer; @@ -268,8 +280,8 @@ class MyReadOnlyBackend implements LdapBackendInterface ### Writable Backend -`WritableLdapBackendInterface` extends `LdapBackendInterface` with write operations. Use `WritableBackendTrait` to -implement the write dispatch — it routes each operation to a dedicated method receiving a typed command object: +`WritableLdapBackendInterface` extends `LdapBackendInterface` with write operations. Use `WritableBackendTrait` +to implement the write dispatch — it routes each operation to a dedicated method receiving a typed command object: ```php namespace App; @@ -326,13 +338,13 @@ class MyBackend implements WritableLdapBackendInterface } ``` -### Built-In Storage Adapters +### Built-In Storage Implementations -Two adapters are included for common use cases. +Two storage implementations are included for common use cases. Both are used via `useStorage()`. -#### InMemoryStorageAdapter +#### InMemoryStorage -An in-memory, array-backed storage adapter. Suitable for: +An in-memory, array-backed storage implementation. Suitable for: - **Swoole**: all connections share the same process memory. - **PCNTL** with pre-seeded, read-only data: data seeded before `run()` is inherited by all forked child processes. @@ -344,41 +356,38 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); -$adapter = new InMemoryStorageAdapter([ +$server = (new LdapServer())->useStorage(new InMemoryStorage([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), new Entry( new Dn('cn=admin,dc=example,dc=com'), new Attribute('cn', 'admin'), new Attribute('userPassword', $passwordHash), ), -]); - -$server = (new LdapServer())->useBackend($adapter); +])); $server->run(); ``` -#### JsonFileStorageAdapter +#### JsonFileStorage -A file-backed adapter that persists the directory as a JSON file. Safe for PCNTL (write operations are serialised with -`flock(LOCK_EX)` and the in-memory cache is invalidated via `filemtime` checks). +A file-backed storage implementation that persists the directory as a JSON file. Safe for PCNTL (write operations are +serialised with `flock(LOCK_EX)` and the in-memory read cache is invalidated via `filemtime` checks). Use the named constructor that matches your server runner: ```php use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; // PCNTL runner — uses flock() to serialise writes across forked processes -$adapter = JsonFileStorageAdapter::forPcntl('/var/lib/myapp/ldap.json'); +$server = (new LdapServer())->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); +$server->run(); // Swoole runner — uses a coroutine Channel mutex and non-blocking file I/O -$adapter = JsonFileStorageAdapter::forSwoole('/var/lib/myapp/ldap.json'); - -$server = (new LdapServer())->useBackend($adapter); +$server = (new LdapServer())->useStorage(JsonFileStorage::forSwoole('/var/lib/myapp/ldap.json')); $server->run(); ``` diff --git a/src/FreeDSx/Ldap/Entry/Dn.php b/src/FreeDSx/Ldap/Entry/Dn.php index c7c833cb..4556d8f5 100644 --- a/src/FreeDSx/Ldap/Entry/Dn.php +++ b/src/FreeDSx/Ldap/Entry/Dn.php @@ -25,6 +25,7 @@ use function implode; use function ltrim; use function preg_split; +use function strtolower; /** * Represents a Distinguished Name. @@ -132,6 +133,32 @@ public static function isValid(Stringable|string $dn): bool } } + /** + * Return a normalised (lowercased) copy of this DN. + */ + public function normalize(): Dn + { + return new Dn(strtolower($this->dn)); + } + + /** + * Return true if this DN is a direct child of $parent. + * + * @throws UnexpectedValueException + */ + public function isChildOf(Dn $parent): bool + { + $parentDn = $parent->toString(); + if ($parentDn === '') { + return $this->getParent() === null + && $this->toString() !== ''; + } + $myParent = $this->getParent(); + + return $myParent !== null + && strtolower($myParent->toString()) === strtolower($parentDn); + } + /** * @todo This needs proper handling. But the regex would probably be rather crazy. * diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 531c51e2..499d71b9 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; @@ -62,14 +64,18 @@ public function getOptions(): ServerOptions } /** - * Specify a backend to use for incoming LDAP requests. + * Specify an entry storage implementation to back the LDAP server. * - * The backend handles search and optionally write operations (add, delete, - * modify, rename) if it implements WritableLdapBackendInterface. Bind - * authentication is handled by a PasswordAuthenticatableInterface — either - * one registered via usePasswordAuthenticator(), the backend itself if it - * implements that interface, or the default PasswordAuthenticator (which - * reads the userPassword attribute from entries returned by the backend). + * Use useBackend() instead when implementing a fully custom backend (e.g. + * a proxy) that handles LDAP semantics itself. + */ + public function useStorage(EntryStorageInterface $storage): self + { + return $this->useBackend(new WritableStorageBackend($storage)); + } + + /** + * Specify a backend to use for incoming LDAP requests. */ public function useBackend(LdapBackendInterface $backend): self { @@ -80,11 +86,6 @@ public function useBackend(LdapBackendInterface $backend): self /** * Override the password authenticator used for simple bind and SASL PLAIN. - * - * By default the server constructs a PasswordAuthenticator that reads the - * userPassword attribute from entries returned by the backend. Use this - * method to supply a custom implementation — for example, to support - * additional hash formats or to delegate verification to an external service. */ public function usePasswordAuthenticator(PasswordAuthenticatableInterface $authenticator): self { @@ -95,10 +96,6 @@ public function usePasswordAuthenticator(PasswordAuthenticatableInterface $authe /** * Register a handler for one or more LDAP write operations. - * - * Handlers are tried in registration order, before the backend is used as - * a fallback. Multiple calls register multiple handlers, allowing each - * write operation to be handled by a separate class. */ public function useWriteHandler(WriteHandlerInterface $handler): self { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php new file mode 100644 index 00000000..8a3c6994 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php @@ -0,0 +1,58 @@ + + * + * 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\Dn; +use FreeDSx\Ldap\Entry\Entry; +use Generator; + +/** + * Provides scope-filtered list helpers for array-backed EntryStorageInterface implementations. + * + * Includes DefaultHasChildrenTrait for the default hasChildren() implementation. + * Use DefaultHasChildrenTrait directly if you only need the hasChildren() default + * (e.g. for a database-backed adapter that handles list() natively). + * + * @author Chad Sikorra + */ +trait ArrayEntryStorageTrait +{ + use DefaultHasChildrenTrait; + + /** + * @param array $entries Entries keyed by normalised DN string + * @return Generator + */ + private function yieldByScope( + array $entries, + Dn $baseDn, + bool $subtree, + ): Generator { + $normBase = $baseDn->toString(); + + foreach ($entries as $normDn => $entry) { + if ($normBase === '' && $subtree) { + yield $entry; + } elseif ($subtree) { + if ($normDn === $normBase || str_ends_with($normDn, ',' . $normBase)) { + yield $entry; + } + } else { + if ((new Dn($normDn))->isChildOf($baseDn)) { + yield $entry; + } + } + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php new file mode 100644 index 00000000..5b6e2f71 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/DefaultHasChildrenTrait.php @@ -0,0 +1,40 @@ + + * + * 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\Dn; +use Generator; + +/** + * Provides a default hasChildren() implementation in terms of list(). + * + * Suitable for any EntryStorageInterface implementation. Adapters that can + * answer the question more efficiently (e.g. via an EXISTS query on a database) + * should override hasChildren() directly. + * + * @author Chad Sikorra + */ +trait DefaultHasChildrenTrait +{ + abstract public function list(Dn $baseDn, bool $subtree): Generator; + + public function hasChildren(Dn $dn): bool + { + foreach ($this->list($dn, false) as $ignored) { + return true; + } + + return false; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php new file mode 100644 index 00000000..9af5e81b --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -0,0 +1,83 @@ + + * + * 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\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use Generator; + +/** + * An in-memory storage implementation backed by a plain PHP array. + * + * Suitable for single-process use cases: the Swoole server runner (all + * connections share the same process memory), or pre-seeded read-only + * use with the PCNTL runner (data seeded before run() is inherited by + * all forked child processes). + * + * With the PCNTL runner, write operations performed by one child process + * are not visible to other children or the parent. + * + * @author Chad Sikorra + */ +final class InMemoryStorage implements EntryStorageInterface +{ + use ArrayEntryStorageTrait; + + /** + * Entries keyed by their normalised (lowercased) DN string. + * + * @var array + */ + private array $entries = []; + + /** + * Pre-populate the storage with a set of entries. + * + * @param Entry[] $entries + */ + public function __construct(array $entries = []) + { + foreach ($entries as $entry) { + $this->entries[$entry->getDn()->normalize()->toString()] = $entry; + } + } + + public function find(Dn $dn): ?Entry + { + return $this->entries[$dn->toString()] ?? null; + } + + /** + * @return Generator + */ + public function list(Dn $baseDn, bool $subtree): Generator + { + return $this->yieldByScope( + $this->entries, + $baseDn, + $subtree + ); + } + + public function store(Entry $entry): void + { + $this->entries[$entry->getDn()->normalize()->toString()] = $entry; + } + + public function remove(Dn $dn): void + { + unset($this->entries[$dn->toString()]); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php deleted file mode 100644 index ab0831e2..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorageAdapter.php +++ /dev/null @@ -1,159 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; - -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Server\Backend\SearchContext; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; -use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; -use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; -use Generator; - -/** - * An in-memory storage adapter backed by a plain PHP array. - * - * Suitable for single-process use cases: the Swoole server runner (all - * connections share the same process memory), or pre-seeded read-only - * use with the PCNTL runner (data seeded before run() is inherited by - * all forked child processes). - * - * With the PCNTL runner, write operations performed by one child process - * are not visible to other children or the parent. - * - * @author Chad Sikorra - */ -class InMemoryStorageAdapter implements WritableLdapBackendInterface -{ - use StorageAdapterTrait; - use WritableBackendTrait; - - /** - * Entries keyed by their normalised (lowercased) DN string. - * - * @var array - */ - private array $entries = []; - - /** - * Pre-populate the adapter with a set of entries. - * - * @param Entry[] $entries - */ - public function __construct( - array $entries = [], - private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), - ) { - foreach ($entries as $entry) { - $this->entries[$this->normalise($entry->getDn())] = $entry; - } - } - - public function get(Dn $dn): ?Entry - { - return $this->entries[$this->normalise($dn)] ?? null; - } - - public function search(SearchContext $context): Generator - { - $normBase = $this->normalise($context->baseDn); - - foreach ($this->entries as $normDn => $entry) { - if ($this->isInScope($normDn, $normBase, $context->scope)) { - yield $entry; - } - } - } - - /** - * @throws OperationException - */ - public function add(AddCommand $command): void - { - $entry = $command->entry; - $normDn = $this->normalise($entry->getDn()); - - if (isset($this->entries[$normDn])) { - throw new OperationException( - sprintf('Entry already exists: %s', $entry->getDn()->toString()), - ResultCode::ENTRY_ALREADY_EXISTS, - ); - } - - $this->entries[$normDn] = $entry; - } - - /** - * @throws OperationException - */ - public function delete(DeleteCommand $command): void - { - $normDn = $this->normalise($command->dn); - - foreach (array_keys($this->entries) as $key) { - if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { - throw new OperationException( - sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), - ResultCode::NOT_ALLOWED_ON_NON_LEAF, - ); - } - } - - unset($this->entries[$normDn]); - } - - public function update(UpdateCommand $command): void - { - $normDn = $this->normalise($command->dn); - - if (!isset($this->entries[$normDn])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $this->entries[$normDn] = $this->entryHandler->apply( - $this->entries[$normDn], - $command, - ); - } - - public function move(MoveCommand $command): void - { - $normOld = $this->normalise($command->dn); - - if (!isset($this->entries[$normOld])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $newEntry = $this->entryHandler->apply( - $this->entries[$normOld], - $command, - ); - unset($this->entries[$normOld]); - - $this->entries[$this->normalise($newEntry->getDn())] = $newEntry; - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php new file mode 100644 index 00000000..bc44d34d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php @@ -0,0 +1,103 @@ + + * + * 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\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use Generator; + +/** + * A transient EntryStorageInterface view over an in-flight JSON data buffer. + * + * Created by JsonFileStorage::atomic() for the duration of a single locked + * read-modify-write cycle. Changes are held in memory and written back when + * the atomic operation completes. + * + * @internal + * @author Chad Sikorra + */ +final class JsonEntryBuffer implements EntryStorageInterface +{ + use DefaultHasChildrenTrait; + + /** + * @var array + */ + private array $data; + + /** + * @param array $data + * @param Closure(mixed): Entry $toEntry + * @param Closure(Entry): array $fromEntry + */ + public function __construct( + array $data, + private readonly Closure $toEntry, + private readonly Closure $fromEntry, + ) { + $this->data = $data; + } + + public function find(Dn $dn): ?Entry + { + $key = $dn->toString(); + if (!isset($this->data[$key])) { + return null; + } + + return ($this->toEntry)($this->data[$key]); + } + + /** + * @return Generator + */ + public function list(Dn $baseDn, bool $subtree): Generator + { + $normBase = $baseDn->toString(); + + foreach ($this->data as $normDn => $entryData) { + if ($normBase === '' && $subtree) { + yield ($this->toEntry)($entryData); + } elseif ($subtree) { + if ($normDn === $normBase || str_ends_with($normDn, ',' . $normBase)) { + yield ($this->toEntry)($entryData); + } + } else { + if ((new Dn($normDn))->isChildOf($baseDn)) { + yield ($this->toEntry)($entryData); + } + } + } + } + + public function store(Entry $entry): void + { + $this->data[$entry->getDn()->normalize()->toString()] = ($this->fromEntry)($entry); + } + + public function remove(Dn $dn): void + { + unset($this->data[$dn->toString()]); + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php similarity index 54% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index 36eb454e..77368afc 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorageAdapter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -16,29 +16,25 @@ 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\Server\Backend\SearchContext; 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\Adapter\Operation\WriteEntryOperationHandler; -use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; -use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\AtomicStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use Generator; /** - * A file-backed storage adapter that persists the directory as a JSON file. + * A file-backed storage implementation that persists entries as a JSON file. * - * Use the named constructors to select the correct locking strategy: + * Use the named constructors to select the appropriate locking strategy: * - * JsonFileStorageAdapter::forPcntl('/path/to/file.json') - * JsonFileStorageAdapter::forSwoole('/path/to/file.json') + * JsonFileStorage::forPcntl('/path/to/file.json') + * JsonFileStorage::forSwoole('/path/to/file.json') + * + * Reads are non-transactional (no lock acquired). Write operations performed + * through WritableStorageBackend are always routed through atomic(), which + * acquires the lock, loads the entire file into an in-memory buffer, passes + * that buffer to the operation, then writes the result back atomically. * * JSON format: * { @@ -53,10 +49,9 @@ * * @author Chad Sikorra */ -class JsonFileStorageAdapter implements WritableLdapBackendInterface +final class JsonFileStorage implements EntryStorageInterface, AtomicStorageInterface { - use StorageAdapterTrait; - use WritableBackendTrait; + use ArrayEntryStorageTrait; /** * @var array|null @@ -68,7 +63,6 @@ class JsonFileStorageAdapter implements WritableLdapBackendInterface private function __construct( private readonly string $filePath, private readonly StorageLockInterface $lock, - private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), ) { } @@ -92,118 +86,55 @@ public static function forSwoole( ); } - public function get(Dn $dn): ?Entry + public function find(Dn $dn): ?Entry { - return $this->read()[$this->normalise($dn)] ?? null; - } - - public function search(SearchContext $context): Generator - { - $normBase = $this->normalise($context->baseDn); - - foreach ($this->read() as $normDn => $entry) { - if ($this->isInScope($normDn, $normBase, $context->scope)) { - yield $entry; - } - } + return $this->read()[$dn->toString()] ?? null; } /** - * @throws OperationException + * @return Generator */ - public function add(AddCommand $command): void + public function list(Dn $baseDn, bool $subtree): Generator { - $this->withMutation(function (string $contents) use ($command): string { - $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->entry->getDn()); - - if (isset($data[$normDn])) { - throw new OperationException( - sprintf('Entry already exists: %s', $command->entry->getDn()->toString()), - ResultCode::ENTRY_ALREADY_EXISTS, - ); - } - - $data[$normDn] = $this->entryToArray($command->entry); - - return $this->encodeContents($data); - }); + return $this->yieldByScope( + $this->read(), + $baseDn, + $subtree + ); } - /** - * @throws OperationException - */ - public function delete(DeleteCommand $command): void + public function store(Entry $entry): void { - $this->withMutation(function (string $contents) use ($command): string { + $this->withMutation(function (string $contents) use ($entry): string { $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->dn); - - foreach (array_keys($data) as $key) { - if ($this->isInScope($key, $normDn, SearchRequest::SCOPE_SINGLE_LEVEL)) { - throw new OperationException( - sprintf('Entry "%s" has subordinate entries and cannot be deleted.', $command->dn->toString()), - ResultCode::NOT_ALLOWED_ON_NON_LEAF, - ); - } - } - - unset($data[$normDn]); + $data[$entry->getDn()->normalize()->toString()] = $this->entryToArray($entry); return $this->encodeContents($data); }); } - /** - * @throws OperationException - */ - public function update(UpdateCommand $command): void + public function remove(Dn $dn): void { - $this->withMutation(function (string $contents) use ($command): string { + $this->withMutation(function (string $contents) use ($dn): string { $data = $this->decodeContents($contents); - $normDn = $this->normalise($command->dn); - - if (!isset($data[$normDn])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $entry = $this->entryHandler->apply( - $this->arrayToEntry($data[$normDn]), - $command, - ); - $data[$normDn] = $this->entryToArray($entry); + unset($data[$dn->toString()]); return $this->encodeContents($data); }); } - /** - * @throws OperationException - */ - public function move(MoveCommand $command): void + public function atomic(callable $operation): void { - $this->withMutation(function (string $contents) use ($command): string { + $this->withMutation(function (string $contents) use ($operation): string { $data = $this->decodeContents($contents); - $normOld = $this->normalise($command->dn); - - if (!isset($data[$normOld])) { - throw new OperationException( - sprintf('No such object: %s', $command->dn->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - $newEntry = $this->entryHandler->apply( - $this->arrayToEntry($data[$normOld]), - $command, + $buffer = new JsonEntryBuffer( + $data, + $this->arrayToEntry(...), + $this->entryToArray(...), ); - unset($data[$normOld]); - $data[$this->normalise($newEntry->getDn())] = $this->entryToArray($newEntry); + $operation($buffer); - return $this->encodeContents($data); + return $this->encodeContents($buffer->getData()); }); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php index 5c023a5d..1660e563 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; /** @@ -24,43 +26,197 @@ */ final class UpdateOperation { + /** + * @throws OperationException + */ public function execute( Entry $entry, UpdateCommand $command, ): Entry { foreach ($command->changes as $change) { - $attribute = $change->getAttribute(); - $attrName = $attribute->getName(); - $values = $attribute->getValues(); - - switch ($change->getType()) { - case Change::TYPE_ADD: - $existing = $entry->get($attrName); - if ($existing !== null) { - $existing->add(...$values); - } else { - $entry->add($attribute); - } - break; - - case Change::TYPE_DELETE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->get($attrName)?->remove(...$values); - } - break; - - case Change::TYPE_REPLACE: - if (count($values) === 0) { - $entry->reset($attrName); - } else { - $entry->set($attribute); - } - break; - } + match ($change->getType()) { + Change::TYPE_ADD => $this->applyAdd($entry, $change), + Change::TYPE_DELETE => $this->applyDelete($entry, $change), + Change::TYPE_REPLACE => $this->applyReplace($entry, $change), + default => throw new OperationException( + sprintf('Unknown modify change type: %d.', $change->getType()), + ResultCode::PROTOCOL_ERROR, + ), + }; } return $entry; } + + private function applyAdd( + Entry $entry, + Change $change + ): void { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $existing = $entry->get($attrName); + + if ($existing === null) { + $entry->add($attribute); + return; + } + + foreach ($attribute->getValues() as $value) { + if ($existing->has($value)) { + throw new OperationException( + sprintf('Attribute "%s" already contains the value "%s".', $attrName, $value), + ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, + ); + } + } + + $existing->add(...$attribute->getValues()); + } + + /** + * @throws OperationException + */ + private function applyDelete( + Entry $entry, + Change $change + ): void { + $attrName = $change->getAttribute()->getName(); + $values = $change->getAttribute()->getValues(); + + if (count($values) === 0) { + $this->deleteWholeAttribute($entry, $attrName); + return; + } + + $this->deleteSpecificValues($entry, $attrName, $values); + } + + /** + * @throws OperationException + */ + private function deleteWholeAttribute( + Entry $entry, + string $attrName + ): void { + if ($entry->get($attrName) === null) { + throw new OperationException( + sprintf('Attribute "%s" does not exist.', $attrName), + ResultCode::NO_SUCH_ATTRIBUTE, + ); + } + + if ($this->isRdnAttribute($entry, $attrName)) { + throw new OperationException( + sprintf('Attribute "%s" is the RDN attribute and cannot be removed.', $attrName), + ResultCode::NOT_ALLOWED_ON_RDN, + ); + } + + $entry->reset($attrName); + } + + /** + * @param string[] $values + * + * @throws OperationException + */ + private function deleteSpecificValues( + Entry $entry, + string $attrName, + array $values, + ): void { + $existing = $entry->get($attrName); + + if ($existing === null) { + throw new OperationException( + sprintf('Attribute "%s" does not exist.', $attrName), + ResultCode::NO_SUCH_ATTRIBUTE, + ); + } + + $rdnValue = $entry->getDn()->getRdn()->getValue(); + + foreach ($values as $value) { + if (!$existing->has($value)) { + throw new OperationException( + sprintf('Value "%s" does not exist in attribute "%s".', $value, $attrName), + ResultCode::NO_SUCH_ATTRIBUTE, + ); + } + + if ($this->isRdnAttribute($entry, $attrName) && $value === $rdnValue) { + throw new OperationException( + sprintf( + 'Value "%s" is the RDN value for attribute "%s" and cannot be removed.', + $value, + $attrName, + ), + ResultCode::NOT_ALLOWED_ON_RDN, + ); + } + } + + $existing->remove(...$values); + } + + /** + * @throws OperationException + */ + private function applyReplace(Entry $entry, Change $change): void + { + $attribute = $change->getAttribute(); + $attrName = $attribute->getName(); + $values = $attribute->getValues(); + + if (count($values) === 0) { + $this->clearAttribute( + $entry, + $attrName + ); + + return; + } + + $rdnValue = $entry->getDn()->getRdn()->getValue(); + + if ($this->isRdnAttribute($entry, $attrName) && !in_array($rdnValue, $values, true)) { + throw new OperationException( + sprintf( + 'Replacing attribute "%s" must retain the RDN value "%s".', + $attrName, + $rdnValue, + ), + ResultCode::NOT_ALLOWED_ON_RDN, + ); + } + + $entry->set($attribute); + } + + /** + * @throws OperationException + */ + private function clearAttribute( + Entry $entry, + string $attrName + ): void { + if ($this->isRdnAttribute($entry, $attrName)) { + throw new OperationException( + sprintf('Attribute "%s" is the RDN attribute and cannot be cleared.', $attrName), + ResultCode::NOT_ALLOWED_ON_RDN, + ); + } + + $entry->reset($attrName); + } + + private function isRdnAttribute( + Entry $entry, + string $attrName + ): bool { + return strcasecmp( + $entry->getDn()->getRdn()->getName(), + $attrName + ) === 0; + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php deleted file mode 100644 index fb774e85..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/StorageAdapterTrait.php +++ /dev/null @@ -1,85 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; - -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Operation\Request\SearchRequest; - -/** - * Shared helper methods for storage adapter implementations. - * - * @author Chad Sikorra - */ -trait StorageAdapterTrait -{ - private function normalise(Dn $dn): string - { - return strtolower($dn->toString()); - } - - private function isInScope( - string $normDn, - string $normBase, - int $scope, - ): bool { - return match ($scope) { - SearchRequest::SCOPE_BASE_OBJECT => $normDn === $normBase, - SearchRequest::SCOPE_SINGLE_LEVEL => $this->isDirectChild( - $normDn, - $normBase, - ), - SearchRequest::SCOPE_WHOLE_SUBTREE => $this->isAtOrBelow( - $normDn, - $normBase, - ), - default => false, - }; - } - - private function isAtOrBelow( - string $normDn, - string $normBase, - ): bool { - if ($normDn === $normBase) { - return true; - } - - return str_ends_with( - $normDn, - ',' . $normBase - ); - } - - private function isDirectChild( - string $normDn, - string $normBase, - ): bool { - if (!str_ends_with($normDn, ',' . $normBase)) { - return false; - } - - // Strip the base suffix and check there is exactly one RDN component left - $prefix = substr( - $normDn, - 0, - strlen($normDn) - strlen(',' . $normBase) - ); - - return !str_contains( - $prefix, - ',' - ); - } - -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php new file mode 100644 index 00000000..58c4a1a5 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.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; + +/** + * Optional interface for storage implementations that require transactional + * read-modify-write semantics (e.g. file locking, database transactions). + * + * @author Chad Sikorra + */ +interface AtomicStorageInterface +{ + /** + * Execute $operation as an atomic read-modify-write cycle. + * + * The EntryStorageInterface instance passed to $operation is a writable view + * over the in-flight data for the duration of the call. Changes made to it + * are committed when $operation returns. + * + * @param callable(EntryStorageInterface): void $operation + */ + public function atomic(callable $operation): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php new file mode 100644 index 00000000..d869db4a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -0,0 +1,77 @@ + + * + * 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\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\DefaultHasChildrenTrait; +use Generator; + +/** + * Primitive storage contract for directory entries. + * + * Implementations handle raw data persistence only — all LDAP semantics (validation, error codes, scope checking) are + * handled by WritableStorageBackend. + * + * Where methods accept a Dn parameter, it is a normalised (lowercased) Dn as returned by Dn::normalize(). Use + * $dn->toString() as the internal string key. + * + * @author Chad Sikorra + */ +interface EntryStorageInterface +{ + /** + * Return the entry for the given normalised DN, or null if not found. + */ + public function find(Dn $dn): ?Entry; + + /** + * Return true if the given normalised DN has any direct children. + * + * Implementations may use {@see DefaultHasChildrenTrait} for a default implementation built on top of list(). + * Backends that can answer more efficiently (e.g. an EXISTS query on a database) should override it directly. + */ + public function hasChildren(Dn $dn): bool; + + /** + * Yield entries rooted at $baseDn. + * + * 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. + * + * Pass a Dn with an empty string 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; + + /** + * Persist the entry, replacing any existing entry at the same DN. + * + * The entry must be keyed by its normalised DN. Use $entry->getDn()->normalize()->toString() as the storage key, + * as the Entry's own DN may retain the original client-supplied casing. + */ + public function store(Entry $entry): void; + + /** + * Remove the entry for the given normalised DN. A no-op if the entry does not exist. + */ + public function remove(Dn $dn): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php new file mode 100644 index 00000000..480ea6b7 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -0,0 +1,244 @@ + + * + * 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\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; +use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use Generator; + +/** + * Orchestrates LDAP directory operations over a pluggable EntryStorageInterface. + * + * Handles all LDAP semantics — existence checks, subordinate checks, scope + * filtering, DN normalisation, and entry transformation — so that storage + * implementations only need to provide raw persistence primitives. + * + * When the storage also implements AtomicStorageInterface, write operations + * are wrapped in atomic() to ensure transactional consistency (e.g. file + * locking, database transactions). Storage implementations that do not need + * atomicity may implement EntryStorageInterface alone. + * + * @author Chad Sikorra + */ +final class WritableStorageBackend implements WritableLdapBackendInterface +{ + use WritableBackendTrait; + + public function __construct( + private readonly EntryStorageInterface $storage, + private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), + ) { + } + + public function get(Dn $dn): ?Entry + { + return $this->storage->find($dn->normalize()); + } + + public function search(SearchContext $context): Generator + { + $normBase = $context->baseDn->normalize(); + + if ($context->scope === SearchRequest::SCOPE_BASE_OBJECT) { + $entry = $this->storage->find($normBase); + if ($entry !== null) { + yield $entry; + } + + return; + } + + foreach ($this->storage->list($normBase, $context->scope === SearchRequest::SCOPE_WHOLE_SUBTREE) as $entry) { + yield $entry; + } + } + + /** + * @throws OperationException + */ + public function add(AddCommand $command): void + { + $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $dn = $command->entry->getDn()->normalize(); + $this->assertParentExists($storage, $dn); + + if ($storage->find($dn) !== null) { + $this->throwEntryAlreadyExists($command->entry->getDn()); + } + + $storage->store($command->entry); + }); + } + + /** + * @throws OperationException + */ + public function delete(DeleteCommand $command): void + { + $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $dn = $command->dn->normalize(); + $this->findOrFail($storage, $dn); + + if ($storage->hasChildren($dn)) { + throw new OperationException( + sprintf( + 'Entry "%s" has subordinate entries and cannot be deleted.', + $command->dn->toString() + ), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } + + $storage->remove($dn); + }); + } + + /** + * @throws OperationException + */ + public function update(UpdateCommand $command): void + { + $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $dn = $command->dn->normalize(); + $entry = $this->findOrFail($storage, $dn); + $storage->store($this->entryHandler->apply( + $entry, + $command, + )); + }); + } + + /** + * @throws OperationException + */ + public function move(MoveCommand $command): void + { + $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $normOld = $command->dn->normalize(); + $entry = $this->findOrFail($storage, $normOld); + + if ($storage->hasChildren($normOld)) { + throw new OperationException( + sprintf('Entry "%s" has subordinate entries and cannot be moved.', $command->dn->toString()), + ResultCode::NOT_ALLOWED_ON_NON_LEAF, + ); + } + + $this->assertNewSuperiorExists($storage, $command); + + $newEntry = $this->entryHandler->apply($entry, $command); + $normNew = $newEntry->getDn()->normalize(); + + if ($storage->find($normNew) !== null) { + $this->throwEntryAlreadyExists($newEntry->getDn()); + } + + $storage->remove($normOld); + $storage->store($newEntry); + }); + } + + /** + * @throws OperationException + */ + private function findOrFail(EntryStorageInterface $storage, Dn $dn): Entry + { + $entry = $storage->find($dn); + + if ($entry === null) { + $this->throwNoSuchObject($dn); + } + + return $entry; + } + + /** + * @throws OperationException + */ + private function assertParentExists(EntryStorageInterface $storage, Dn $dn): void + { + $parent = $dn->getParent(); + + // Skip check when the immediate parent is a root-level entry (single-component + // DN), which is a valid naming context that may not be stored on this server. + if ($parent === null || $parent->getParent() === null) { + return; + } + + if ($storage->find($parent) === null) { + $this->throwNoSuchObject($parent); + } + } + + /** + * @throws OperationException + */ + private function assertNewSuperiorExists(EntryStorageInterface $storage, MoveCommand $command): void + { + if ($command->newParent === null) { + return; + } + + $normNewParent = $command->newParent->normalize(); + + if ($normNewParent->getParent() !== null && $storage->find($normNewParent) === null) { + $this->throwNoSuchObject($command->newParent); + } + } + + /** + * @throws OperationException + */ + private function throwNoSuchObject(Dn $dn): never + { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + /** + * @throws OperationException + */ + private function throwEntryAlreadyExists(Dn $dn): never + { + throw new OperationException( + sprintf('Entry already exists: %s', $dn->toString()), + ResultCode::ENTRY_ALREADY_EXISTS, + ); + } + + /** + * @param callable(EntryStorageInterface): void $operation + */ + private function runWriteOperation(callable $operation): void + { + if ($this->storage instanceof AtomicStorageInterface) { + $this->storage->atomic($operation); + } else { + $operation($this->storage); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index e1eba5ee..2e0aa5e6 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -34,9 +34,9 @@ * single PHP process. This means all coroutines share the same memory, making * in-memory storage adapters safe to use with concurrent clients. * - * Note on JsonFileStorageAdapter: although Swoole hooks make file reads - * coroutine-friendly, flock() may still cause brief stalls under high write - * concurrency. For write-heavy workloads prefer the InMemoryStorageAdapter. + * Note on JsonFileStorage: although Swoole hooks make file reads coroutine-friendly, + * flock() may still cause brief stalls under high write concurrency. For write-heavy + * workloads prefer InMemoryStorage. * * Note on socket creation: the SocketServer must be created inside Coroutine\run() * for Swoole's stream hooks to intercept stream_socket_accept() as a yielding call. diff --git a/tests/bin/ldap-backend-storage.php b/tests/bin/ldap-backend-storage.php index a4936a74..e0d77dd5 100644 --- a/tests/bin/ldap-backend-storage.php +++ b/tests/bin/ldap-backend-storage.php @@ -10,9 +10,8 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; -use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\ServerOptions; require __DIR__ . '/../../vendor/autoload.php'; @@ -48,6 +47,13 @@ ), ]; +$server = new LdapServer( + (new ServerOptions()) + ->setPort(10389) + ->setTransport($transport) + ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) +); + if ($handler === 'file') { $filePath = sys_get_temp_dir() . '/ldap_test_backend_storage.json'; @@ -55,22 +61,17 @@ unlink($filePath); } - $adapter = JsonFileStorageAdapter::forPcntl($filePath); + $storage = JsonFileStorage::forPcntl($filePath); foreach ($entries as $entry) { - $adapter->add(new AddCommand($entry)); + $storage->store($entry); } + + $server->useStorage($storage); } else { - $adapter = new InMemoryStorageAdapter($entries); + $server->useStorage(new InMemoryStorage($entries)); } -$server = (new LdapServer( - (new ServerOptions()) - ->setPort(10389) - ->setTransport($transport) - ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) -))->useBackend($adapter); - if ($handler === 'swoole') { $server->useSwooleRunner(); } diff --git a/tests/unit/Entry/DnTest.php b/tests/unit/Entry/DnTest.php index 517dee62..7faf49bb 100644 --- a/tests/unit/Entry/DnTest.php +++ b/tests/unit/Entry/DnTest.php @@ -95,4 +95,76 @@ public function test_it_should_handle_a_rootdse_as_a_dn(): void ); self::assertNull($this->subject->getParent()); } + + public function test_normalize_returns_lowercased_copy(): void + { + $dn = new Dn('CN=Alice,DC=Example,DC=Com'); + $normalized = $dn->normalize(); + + self::assertSame( + 'cn=alice,dc=example,dc=com', + $normalized->toString(), + ); + self::assertSame( + 'CN=Alice,DC=Example,DC=Com', + $dn->toString(), + ); + } + + public function test_is_child_of_returns_true_for_direct_child(): void + { + $child = new Dn('cn=alice,dc=example,dc=com'); + + self::assertTrue( + $child->isChildOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_child_of_returns_false_for_grandchild(): void + { + $grandchild = new Dn('cn=alice,ou=people,dc=example,dc=com'); + + self::assertFalse( + $grandchild->isChildOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_child_of_returns_false_for_unrelated_dn(): void + { + $dn = new Dn('cn=alice,dc=other,dc=com'); + + self::assertFalse( + $dn->isChildOf(new Dn('dc=example,dc=com')), + ); + } + + public function test_is_child_of_root_returns_true_for_single_component_dn(): void + { + self::assertTrue( + (new Dn('dc=com'))->isChildOf(new Dn('')), + ); + } + + public function test_is_child_of_root_returns_false_for_multi_component_dn(): void + { + self::assertFalse( + (new Dn('dc=example,dc=com'))->isChildOf(new Dn('')), + ); + } + + public function test_is_child_of_is_case_insensitive(): void + { + self::assertTrue( + (new Dn('cn=alice,dc=example,dc=com'))->isChildOf(new Dn('DC=EXAMPLE,DC=COM')), + ); + } + + public function test_is_child_of_handles_escaped_comma_in_rdn_value(): void + { + $child = new Dn('cn=fo\,o,dc=local,dc=example'); + + self::assertTrue( + $child->isChildOf(new Dn('dc=local,dc=example')), + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php new file mode 100644 index 00000000..6fb0e64b --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -0,0 +1,171 @@ + + * + * 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\Server\Backend\Storage\Adapter\InMemoryStorage; +use PHPUnit\Framework\TestCase; + +final class InMemoryStorageTest extends TestCase +{ + private InMemoryStorage $subject; + + private Entry $alice; + + protected function setUp(): void + { + $this->alice = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + ); + $this->subject = new InMemoryStorage([$this->alice]); + } + + public function test_find_returns_entry_by_norm_dn(): void + { + $entry = $this->subject->find(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_find_returns_null_for_unknown_norm_dn(): void + { + self::assertNull($this->subject->find(new Dn('cn=nobody,dc=example,dc=com'))); + } + + public function test_list_returns_all_entries(): void + { + $entries = iterator_to_array($this->subject->list(new Dn(''), true)); + + self::assertCount( + 1, + $entries, + ); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + $entries[0]->getDn()->toString(), + ); + } + + public function test_list_single_level_returns_direct_children_only(): void + { + $parent = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + $child = new Entry(new Dn('cn=Bob,dc=example,dc=com'), new Attribute('cn', 'Bob')); + $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)); + + self::assertCount( + 1, + $entries, + ); + self::assertSame( + 'cn=Bob,dc=example,dc=com', + $entries[0]->getDn()->toString(), + ); + } + + public function test_list_recursive_includes_base_and_descendants(): void + { + $parent = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + $child = new Entry(new Dn('cn=Bob,dc=example,dc=com'), new Attribute('cn', 'Bob')); + $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)); + + self::assertCount( + 3, + $entries, + ); + } + + public function test_has_children_returns_true_when_children_exist(): void + { + $parent = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + $child = new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')); + $storage = new InMemoryStorage([$parent, $child]); + + self::assertTrue($storage->hasChildren(new Dn('dc=example,dc=com'))); + } + + public function test_has_children_returns_false_for_leaf_entry(): void + { + self::assertFalse($this->subject->hasChildren(new Dn('cn=alice,dc=example,dc=com'))); + } + + public function test_store_adds_entry(): void + { + $bob = new Entry(new Dn('cn=Bob,dc=example,dc=com'), new Attribute('cn', 'Bob')); + $this->subject->store($bob); + + self::assertNotNull($this->subject->find(new Dn('cn=bob,dc=example,dc=com'))); + } + + public function test_store_replaces_existing_entry(): void + { + $updated = new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alicia')); + $this->subject->store($updated); + + $entry = $this->subject->find(new Dn('cn=alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertSame( + ['Alicia'], + $entry->get('cn')?->getValues(), + ); + } + + public function test_remove_deletes_entry(): void + { + $this->subject->remove(new Dn('cn=alice,dc=example,dc=com')); + + self::assertNull($this->subject->find(new Dn('cn=alice,dc=example,dc=com'))); + } + + public function test_remove_is_noop_for_unknown_norm_dn(): void + { + $this->subject->remove(new Dn('cn=nobody,dc=example,dc=com')); + + self::assertCount( + 1, + iterator_to_array($this->subject->list(new Dn(''), true)), + ); + } + + public function test_constructor_normalises_dn_keys(): void + { + $entry = new Entry(new Dn('CN=ALICE,DC=EXAMPLE,DC=COM'), new Attribute('cn', 'Alice')); + $storage = new InMemoryStorage([$entry]); + + self::assertNotNull($storage->find(new Dn('cn=alice,dc=example,dc=com'))); + } + + public function test_empty_constructor_creates_empty_storage(): void + { + $storage = new InMemoryStorage(); + + self::assertCount( + 0, + iterator_to_array($storage->list(new Dn(''), true)), + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php deleted file mode 100644 index edba2663..00000000 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageAdapterTest.php +++ /dev/null @@ -1,437 +0,0 @@ - - * - * 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\Change; -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Entry\Rdn; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Search\Filter\PresentFilter; -use FreeDSx\Ldap\Server\Backend\SearchContext; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorageAdapter; -use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; -use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; -use PHPUnit\Framework\TestCase; - -final class JsonFileStorageAdapterTest extends TestCase -{ - private JsonFileStorageAdapter $subject; - - private string $tempFile; - - private Entry $base; - - private Entry $alice; - - private Entry $bob; - - protected function setUp(): void - { - $this->tempFile = sys_get_temp_dir() . '/ldap_test_' . uniqid() . '.json'; - - $this->base = new Entry( - new Dn('dc=example,dc=com'), - new Attribute('dc', 'example'), - new Attribute('objectClass', 'dcObject'), - ); - $this->alice = new Entry( - new Dn('cn=Alice,dc=example,dc=com'), - new Attribute('cn', 'Alice'), - new Attribute('userPassword', 'secret'), - ); - $this->bob = new Entry( - new Dn('cn=Bob,ou=People,dc=example,dc=com'), - new Attribute('cn', 'Bob'), - ); - - $this->subject = JsonFileStorageAdapter::forPcntl($this->tempFile); - $this->subject->add(new AddCommand($this->base)); - $this->subject->add(new AddCommand($this->alice)); - $this->subject->add(new AddCommand($this->bob)); - } - - protected function tearDown(): void - { - if (file_exists($this->tempFile)) { - unlink($this->tempFile); - } - } - - 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_nonexistent_file_returns_null(): void - { - $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile . '.nonexistent'); - - self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); - } - - public function test_search_base_scope_returns_only_base(): void - { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_BASE_OBJECT, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); - - self::assertCount(1, $entries); - self::assertSame( - 'dc=example,dc=com', - array_values($entries)[0]->getDn()->toString() - ); - } - - public function test_search_single_level_returns_direct_children(): void - { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_SINGLE_LEVEL, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); - - // Only alice is a direct child of dc=example,dc=com; bob is under ou=People - self::assertCount(1, $entries); - self::assertSame( - 'cn=Alice,dc=example,dc=com', - array_values($entries)[0]->getDn()->toString() - ); - } - - public function test_search_subtree_returns_base_and_all_descendants(): void - { - $entries = iterator_to_array($this->subject->search(new SearchContext( - baseDn: new Dn('dc=example,dc=com'), - scope: SearchRequest::SCOPE_WHOLE_SUBTREE, - filter: new PresentFilter('objectClass'), - attributes: [], - typesOnly: false, - ))); - - self::assertCount( - 3, - $entries - ); - } - - public function test_add_stores_entry(): void - { - $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); - $this->subject->add(new AddCommand($entry)); - - self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); - } - - public function test_add_persists_to_file(): void - { - $entry = new Entry(new Dn('cn=Persistent,dc=example,dc=com'), new Attribute('cn', 'Persistent')); - $this->subject->add(new AddCommand($entry)); - - // A second independent adapter instance reading the same file should see the new entry - $adapter2 = JsonFileStorageAdapter::forPcntl($this->tempFile); - - self::assertNotNull($adapter2->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_delete_persists_to_file(): void - { - $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); - - $adapter2 = JsonFileStorageAdapter::forPcntl($this->tempFile); - - self::assertNull($adapter2->get(new Dn('cn=Alice,dc=example,dc=com'))); - } - - public function test_delete_throws_not_allowed_on_non_leaf_when_entry_has_subordinates(): void - { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); - - // dc=example,dc=com has cn=Alice as a direct child — cannot be deleted - $this->subject->delete(new DeleteCommand(new Dn('dc=example,dc=com'))); - } - - public function test_update_add_attribute_value(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_ADD, 'mail', 'alice@example.com')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertNotNull($entry); - self::assertTrue($entry->get('mail')?->has('alice@example.com')); - } - - public function test_update_replace_attribute_value(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertSame(['Alicia'], $entry?->get('cn')?->getValues()); - } - - public function test_update_delete_attribute(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_DELETE, 'userPassword')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertNull($entry?->get('userPassword')); - } - - public function test_update_delete_specific_attribute_value(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_ADD, 'mail', 'a@b.com', 'c@d.com')], - )); - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_DELETE, 'mail', 'a@b.com')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - $mail = $entry?->get('mail'); - - self::assertNotNull($mail); - self::assertFalse($mail->has('a@b.com')); - self::assertTrue($mail->has('c@d.com')); - } - - public function test_move_renames_entry(): void - { - $this->subject->move(new MoveCommand( - new Dn('cn=Alice,dc=example,dc=com'), - Rdn::create('cn=Alicia'), - true, - null, - )); - - self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); - self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); - } - - public function test_update_throws_no_such_object_for_missing_entry(): void - { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - - $this->subject->update(new UpdateCommand( - new Dn('cn=Nobody,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'Nobody')], - )); - } - - public function test_add_throws_entry_already_exists(): void - { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); - - $this->subject->add(new AddCommand($this->alice)); - } - - public function test_update_add_value_to_existing_attribute(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_ADD, 'cn', 'Alicia')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertNotNull($entry); - - $cn = $entry->get('cn'); - self::assertNotNull($cn); - - self::assertTrue($cn->has('Alice')); - self::assertTrue($cn->has('Alicia')); - } - - public function test_update_replace_with_no_values_clears_attribute(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'userPassword')], - )); - - self::assertNull( - $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword') - ); - } - - public function test_move_throws_no_such_object_for_missing_entry(): void - { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - - $this->subject->move(new MoveCommand( - new Dn('cn=Nobody,dc=example,dc=com'), - Rdn::create('cn=Ghost'), - true, - null, - )); - } - - public function test_move_creates_new_rdn_attribute_when_it_does_not_exist_in_entry(): void - { - // Alice has no 'uid' attribute; renaming to uid=alice should create it. - $this->subject->move(new MoveCommand( - new Dn('cn=Alice,dc=example,dc=com'), - Rdn::create('uid=alice'), - false, - null, - )); - - $entry = $this->subject->get(new Dn('uid=alice,dc=example,dc=com')); - - self::assertNotNull($entry); - self::assertTrue($entry->get('uid')?->has('alice')); - } - - public function test_move_to_new_parent(): void - { - $ou = new Entry(new Dn('ou=People,dc=example,dc=com'), new Attribute('ou', 'People')); - $this->subject->add(new AddCommand($ou)); - - $this->subject->move(new MoveCommand( - new Dn('cn=Alice,dc=example,dc=com'), - Rdn::create('cn=Alice'), - false, - new Dn('ou=People,dc=example,dc=com'), - )); - - self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); - self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); - } - - public function test_get_on_empty_file_returns_null(): void - { - file_put_contents($this->tempFile, ''); - - $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); - - self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); - } - - public function test_get_on_invalid_json_returns_null(): void - { - file_put_contents($this->tempFile, 'not valid json {{{'); - - $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); - - self::assertNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); - } - - public function test_get_uses_in_memory_cache_on_subsequent_calls(): void - { - // First call populates the cache. - $first = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - - // Corrupt the file — a cache-bypassing read would return null. - file_put_contents($this->tempFile, 'corrupted'); - - $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); - - // Prime the cache on first call (returns null from corrupted file). - $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); - - // Second call on same adapter+same mtime must use the in-memory cache. - $result = $adapter->get(new Dn('cn=Alice,dc=example,dc=com')); - - self::assertNull($result); - self::assertNotNull($first); - } - - public function test_get_cache_is_invalidated_when_file_mtime_changes(): void - { - $adapter = JsonFileStorageAdapter::forPcntl($this->tempFile); - - // Prime the cache with a valid file (contains Alice). - self::assertNotNull($adapter->get(new Dn('cn=Alice,dc=example,dc=com'))); - - // A write operation (add) changes the file — cache is cleared. - $extra = new Entry(new Dn('cn=Extra,dc=example,dc=com'), new Attribute('cn', 'Extra')); - $adapter->add(new AddCommand($extra)); - - // The adapter must re-read the file and see the new entry. - self::assertNotNull($adapter->get(new Dn('cn=Extra,dc=example,dc=com'))); - } - - public function test_supports_returns_true_for_all_write_commands(): void - { - self::assertTrue($this->subject->supports(new AddCommand($this->alice))); - self::assertTrue($this->subject->supports(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com')))); - self::assertTrue($this->subject->supports(new UpdateCommand(new Dn('cn=Alice,dc=example,dc=com'), []))); - self::assertTrue($this->subject->supports(new MoveCommand( - new Dn('cn=Alice,dc=example,dc=com'), - Rdn::create('cn=Alice'), - false, - null, - ))); - } - - public function test_supports_returns_false_for_unknown_request(): void - { - $unknown = $this->createMock(WriteRequestInterface::class); - - self::assertFalse($this->subject->supports($unknown)); - } - - public function test_handle_dispatches_to_correct_method(): void - { - $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); - $this->subject->handle(new AddCommand($entry)); - - self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); - } -} diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php new file mode 100644 index 00000000..6cf790a4 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -0,0 +1,224 @@ + + * + * 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\Server\Backend\Storage\Adapter\JsonFileStorage; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Search\Filter\PresentFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; +use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; +use PHPUnit\Framework\TestCase; + +final class JsonFileStorageTest extends TestCase +{ + private WritableStorageBackend $subject; + + private JsonFileStorage $storage; + + private string $tempFile; + + private Entry $alice; + + protected function setUp(): void + { + $this->tempFile = sys_get_temp_dir() . '/ldap_test_' . uniqid() . '.json'; + $this->alice = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('userPassword', 'secret'), + ); + + $this->storage = JsonFileStorage::forPcntl($this->tempFile); + $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)); + } + + protected function tearDown(): void + { + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + 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_nonexistent_file_returns_null(): void + { + $storage = JsonFileStorage::forPcntl($this->tempFile . '.nonexistent'); + $backend = new WritableStorageBackend($storage); + + self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_get_on_empty_file_returns_null(): void + { + file_put_contents($this->tempFile, ''); + $storage = JsonFileStorage::forPcntl($this->tempFile); + $backend = new WritableStorageBackend($storage); + + self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_get_on_invalid_json_returns_null(): void + { + file_put_contents($this->tempFile, 'not valid json {{{'); + $storage = JsonFileStorage::forPcntl($this->tempFile); + $backend = new WritableStorageBackend($storage); + + self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_add_persists_to_file(): void + { + $entry = new Entry(new Dn('cn=Persistent,dc=example,dc=com'), new Attribute('cn', 'Persistent')); + $this->subject->add(new AddCommand($entry)); + + // A second independent backend reading the same file should see the new entry. + $backend2 = new WritableStorageBackend(JsonFileStorage::forPcntl($this->tempFile)); + + self::assertNotNull($backend2->get(new Dn('cn=Persistent,dc=example,dc=com'))); + } + + public function test_delete_persists_to_file(): void + { + $this->subject->delete(new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com'))); + + $backend2 = new WritableStorageBackend(JsonFileStorage::forPcntl($this->tempFile)); + + self::assertNull($backend2->get(new Dn('cn=Alice,dc=example,dc=com'))); + } + + public function test_get_uses_in_memory_cache_on_subsequent_calls(): void + { + // Corrupt the file after the first read — a cache-bypassing adapter would return null. + $this->storage->find(new Dn('cn=alice,dc=example,dc=com')); + file_put_contents($this->tempFile, 'corrupted'); + + $storage2 = JsonFileStorage::forPcntl($this->tempFile); + + // Prime the cache on first call (returns null from corrupted file). + $storage2->find(new Dn('cn=alice,dc=example,dc=com')); + + // Second call on same storage instance with same mtime must use the in-memory cache. + $result = $storage2->find(new Dn('cn=alice,dc=example,dc=com')); + + self::assertNull($result); + } + + public function test_cache_is_invalidated_after_write(): void + { + $storage = JsonFileStorage::forPcntl($this->tempFile); + $backend = new WritableStorageBackend($storage); + + // Prime the cache with a valid file (contains Alice). + self::assertNotNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); + + // A write operation changes the file — cache must be cleared. + $extra = new Entry(new Dn('cn=Extra,dc=example,dc=com'), new Attribute('cn', 'Extra')); + $backend->add(new AddCommand($extra)); + + // The backend must re-read the file and see the new entry. + self::assertNotNull($backend->get(new Dn('cn=Extra,dc=example,dc=com'))); + } + + public function test_list_single_level_returns_direct_children_only(): void + { + // dc=example,dc=com and Alice are already in storage from setUp. + // Add a grandchild to verify it is excluded from single-level results. + $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); + $this->subject->add(new AddCommand($grandchild)); + + $filter = new PresentFilter('objectClass'); + $context = new SearchContext( + new Dn('dc=example,dc=com'), + SearchRequest::SCOPE_SINGLE_LEVEL, + $filter, + [], + false, + ); + $results = iterator_to_array($this->subject->search($context)); + + 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 + { + // dc=example,dc=com and Alice are already in storage from setUp. + // Add a grandchild; the subtree search should return all three entries. + $grandchild = new Entry(new Dn('cn=Sub,cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Sub')); + $this->subject->add(new AddCommand($grandchild)); + + $filter = new PresentFilter('objectClass'); + $context = new SearchContext( + new Dn('dc=example,dc=com'), + SearchRequest::SCOPE_WHOLE_SUBTREE, + $filter, + [], + false, + ); + $results = iterator_to_array($this->subject->search($context)); + + self::assertCount( + 3, + $results, + ); + } + + public function test_has_children_returns_true_when_children_exist(): void + { + // Alice (cn=Alice,dc=example,dc=com) was added in setUp as a child of dc=example,dc=com. + 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'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php index 7b2a0b2e..e261d929 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php @@ -17,6 +17,8 @@ use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\UpdateOperation; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use PHPUnit\Framework\TestCase; @@ -124,14 +126,14 @@ public function test_type_replace_sets_attribute_values(): void { $command = new UpdateCommand( new Dn('cn=alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'alicia')], + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], ); $result = $this->subject->execute($this->entry, $command); self::assertSame( - ['alicia'], - $result->get('cn')?->getValues() + ['newpassword'], + $result->get('userPassword')?->getValues() ); } @@ -140,23 +142,23 @@ public function test_multiple_changes_applied_in_order(): void $command = new UpdateCommand( new Dn('cn=alice,dc=example,dc=com'), [ - new Change(Change::TYPE_REPLACE, 'cn', 'alicia'), - new Change(Change::TYPE_ADD, 'cn', 'alice'), + new Change(Change::TYPE_REPLACE, 'mail', 'new@example.com'), + new Change(Change::TYPE_ADD, 'mail', 'alice@example.com'), ], ); $result = $this->subject->execute($this->entry, $command); - $cn = $result->get('cn'); + $mail = $result->get('mail'); - self::assertNotNull($cn); + self::assertNotNull($mail); self::assertContains( - 'alicia', - $cn->getValues() + 'new@example.com', + $mail->getValues() ); self::assertContains( - 'alice', - $cn->getValues() + 'alice@example.com', + $mail->getValues() ); } @@ -164,7 +166,7 @@ public function test_returns_the_same_entry_instance(): void { $command = new UpdateCommand( new Dn('cn=alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'alicia')], + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], ); $result = $this->subject->execute($this->entry, $command); @@ -174,4 +176,126 @@ public function test_returns_the_same_entry_instance(): void $result ); } + + public function test_type_add_throws_attribute_or_value_exists_for_duplicate_value(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ATTRIBUTE_OR_VALUE_EXISTS); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'alice@example.com')], + )); + } + + public function test_type_delete_throws_no_such_attribute_for_missing_attribute(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_ATTRIBUTE); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'telephoneNumber')], + )); + } + + public function test_type_delete_throws_no_such_attribute_for_missing_value(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_ATTRIBUTE); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'mail', 'nobody@example.com')], + )); + } + + public function test_type_delete_whole_attribute_throws_not_allowed_on_rdn_for_rdn_attribute(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_RDN); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'cn')], + )); + } + + public function test_type_delete_specific_value_throws_not_allowed_on_rdn_for_rdn_value(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_RDN); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'cn', 'alice')], + )); + } + + public function test_type_delete_specific_value_allows_removing_non_rdn_value_from_rdn_attribute(): void + { + $entry = new Entry( + new Dn('cn=alice,dc=example,dc=com'), + new Attribute('cn', 'alice', 'alicia'), + ); + + $result = $this->subject->execute($entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_DELETE, 'cn', 'alicia')], + )); + + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertNotContains( + 'alicia', + $cn->getValues(), + ); + self::assertContains( + 'alice', + $cn->getValues(), + ); + } + + public function test_type_replace_clear_throws_not_allowed_on_rdn_for_rdn_attribute(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_RDN); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn')], + )); + } + + public function test_type_replace_throws_not_allowed_on_rdn_when_new_values_omit_rdn_value(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_RDN); + + $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'alicia')], + )); + } + + public function test_type_replace_allows_replacing_rdn_attribute_when_rdn_value_is_retained(): void + { + $result = $this->subject->execute($this->entry, new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'cn', 'alice', 'alicia')], + )); + + $cn = $result->get('cn'); + + self::assertNotNull($cn); + self::assertContains( + 'alice', + $cn->getValues(), + ); + self::assertContains( + 'alicia', + $cn->getValues(), + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php similarity index 74% rename from tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php rename to tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 89d16367..db22bdc8 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageAdapterTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -23,7 +23,8 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\PresentFilter; use FreeDSx\Ldap\Server\Backend\SearchContext; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorageAdapter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; 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,9 +32,9 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; use PHPUnit\Framework\TestCase; -final class InMemoryStorageAdapterTest extends TestCase +final class WritableStorageBackendTest extends TestCase { - private InMemoryStorageAdapter $subject; + private WritableStorageBackend $subject; private Entry $alice; @@ -58,23 +59,28 @@ protected function setUp(): void new Attribute('cn', 'Bob'), ); - $this->subject = new InMemoryStorageAdapter([ + $this->subject = new WritableStorageBackend(new InMemoryStorage([ $this->base, $this->alice, $this->bob, - ]); + ])); } 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()); + 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); } @@ -92,8 +98,15 @@ public function test_search_base_scope_returns_only_base(): void attributes: [], typesOnly: false, ))); - self::assertCount(1, $entries); - self::assertSame('dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + + self::assertCount( + 1, + $entries, + ); + self::assertSame( + 'dc=example,dc=com', + array_values($entries)[0]->getDn()->toString(), + ); } public function test_search_single_level_returns_direct_children(): void @@ -105,9 +118,16 @@ public function test_search_single_level_returns_direct_children(): void attributes: [], typesOnly: false, ))); + // Only alice is a direct child of dc=example,dc=com; bob is under ou=People - self::assertCount(1, $entries); - self::assertSame('cn=Alice,dc=example,dc=com', array_values($entries)[0]->getDn()->toString()); + self::assertCount( + 1, + $entries, + ); + self::assertSame( + 'cn=Alice,dc=example,dc=com', + array_values($entries)[0]->getDn()->toString(), + ); } public function test_search_subtree_returns_base_and_all_descendants(): void @@ -119,24 +139,62 @@ public function test_search_subtree_returns_base_and_all_descendants(): void attributes: [], typesOnly: false, ))); - self::assertCount(3, $entries); + + self::assertCount( + 3, + $entries, + ); } public function test_add_stores_entry(): void { - $adapter = new InMemoryStorageAdapter(); $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); - $adapter->add(new AddCommand($entry)); + $this->subject->add(new AddCommand($entry)); + + self::assertNotNull($this->subject->get(new Dn('cn=New,dc=example,dc=com'))); + } + + public function test_add_throws_entry_already_exists(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); - self::assertNotNull($adapter->get(new Dn('cn=New,dc=example,dc=com'))); + $this->subject->add(new AddCommand($this->alice)); + } + + public function test_add_throws_no_such_object_when_parent_does_not_exist(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $entry = new Entry(new Dn('cn=New,ou=Missing,dc=example,dc=com'), new Attribute('cn', 'New')); + $this->subject->add(new AddCommand($entry)); + } + + public function test_add_allows_root_naming_context_entry(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage()); + $root = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + $backend->add(new AddCommand($root)); + + self::assertNotNull($backend->get(new Dn('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_delete_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->delete(new DeleteCommand(new Dn('cn=Nobody,dc=example,dc=com'))); + } + public function test_delete_throws_not_allowed_on_non_leaf_when_entry_has_subordinates(): void { self::expectException(OperationException::class); @@ -154,79 +212,54 @@ public function test_update_add_attribute_value(): void )); $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + self::assertNotNull($entry); self::assertTrue($entry->get('mail')?->has('alice@example.com')); } - public function test_update_replace_attribute_value(): void - { - $this->subject->update(new UpdateCommand( - new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], - )); - - $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertSame(['Alicia'], $entry?->get('cn')?->getValues()); - } - - public function test_update_delete_attribute(): void + public function test_update_add_value_to_existing_attribute(): void { $this->subject->update(new UpdateCommand( new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_DELETE, 'userPassword')], + [new Change(Change::TYPE_ADD, 'cn', 'Alicia')], )); $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertNull($entry?->get('userPassword')); - } - public function test_move_renames_entry(): void - { - $this->subject->move(new MoveCommand( - new Dn('cn=Alice,dc=example,dc=com'), - Rdn::create('cn=Alicia'), - true, - null, - )); + self::assertNotNull($entry); - self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); - self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); + $cn = $entry->get('cn'); + self::assertNotNull($cn); + self::assertTrue($cn->has('Alice')); + self::assertTrue($cn->has('Alicia')); } - public function test_update_throws_no_such_object_for_missing_entry(): void + public function test_update_replace_attribute_value(): void { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - $this->subject->update(new UpdateCommand( - new Dn('cn=Nobody,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'Nobody')], + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], )); - } - public function test_add_throws_entry_already_exists(): void - { - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - $this->subject->add(new AddCommand($this->alice)); + self::assertNotNull($entry); + self::assertSame( + ['newpassword'], + $entry->get('userPassword')?->getValues(), + ); } - public function test_update_add_value_to_existing_attribute(): void + public function test_update_delete_attribute(): void { $this->subject->update(new UpdateCommand( new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_ADD, 'cn', 'Alicia')], + [new Change(Change::TYPE_DELETE, 'userPassword')], )); $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); - self::assertNotNull($entry); - $cn = $entry->get('cn'); - self::assertNotNull($cn); - - self::assertTrue($cn->has('Alice')); - self::assertTrue($cn->has('Alicia')); + self::assertNull($entry?->get('userPassword')); } public function test_update_delete_specific_attribute_value(): void @@ -249,21 +282,32 @@ public function test_update_replace_with_no_values_clears_attribute(): void )); self::assertNull( - $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword') + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword'), ); } - public function test_move_throws_no_such_object_for_missing_entry(): void + public function test_update_throws_no_such_object_for_missing_entry(): void { self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); - $this->subject->move(new MoveCommand( + $this->subject->update(new UpdateCommand( new Dn('cn=Nobody,dc=example,dc=com'), - Rdn::create('cn=Ghost'), + [new Change(Change::TYPE_REPLACE, 'cn', 'Nobody')], + )); + } + + public function test_move_renames_entry(): void + { + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), true, null, )); + + self::assertNull($this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))); + self::assertNotNull($this->subject->get(new Dn('cn=Alicia,dc=example,dc=com'))); } public function test_move_creates_new_rdn_attribute_when_it_does_not_exist_in_entry(): void @@ -298,6 +342,66 @@ public function test_move_to_new_parent(): void self::assertNotNull($this->subject->get(new Dn('cn=Alice,ou=People,dc=example,dc=com'))); } + public function test_move_throws_no_such_object_for_missing_entry(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->move(new MoveCommand( + new Dn('cn=Nobody,dc=example,dc=com'), + Rdn::create('cn=Ghost'), + true, + null, + )); + } + + public function test_move_throws_not_allowed_on_non_leaf_when_entry_has_children(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); + + // dc=example,dc=com has cn=Alice as a direct child — cannot be moved + $this->subject->move(new MoveCommand( + new Dn('dc=example,dc=com'), + Rdn::create('dc=example'), + false, + null, + )); + } + + public function test_move_throws_no_such_object_when_new_superior_does_not_exist(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $this->subject->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alice'), + false, + new Dn('ou=Missing,dc=example,dc=com'), + )); + } + + public function test_move_throws_entry_already_exists_when_target_dn_exists(): void + { + $alicia = new Entry(new Dn('cn=Alicia,dc=example,dc=com'), new Attribute('cn', 'Alicia')); + $backend = new WritableStorageBackend(new InMemoryStorage([ + $this->base, + $this->alice, + $alicia, + ])); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $backend->move(new MoveCommand( + new Dn('cn=Alice,dc=example,dc=com'), + Rdn::create('cn=Alicia'), + true, + null, + )); + } + public function test_supports_returns_true_for_add_command(): void { self::assertTrue($this->subject->supports(new AddCommand($this->alice))); @@ -306,8 +410,8 @@ public function test_supports_returns_true_for_add_command(): void public function test_supports_returns_true_for_delete_command(): void { self::assertTrue($this->subject->supports( - new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com') - ))); + new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com')), + )); } public function test_supports_returns_true_for_update_command(): void @@ -354,12 +458,12 @@ public function test_handle_dispatches_update_command(): void { $this->subject->handle(new UpdateCommand( new Dn('cn=Alice,dc=example,dc=com'), - [new Change(Change::TYPE_REPLACE, 'cn', 'Alicia')], + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], )); self::assertSame( - ['Alicia'], - $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('cn')?->getValues() + ['newpassword'], + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword')?->getValues(), ); } From 4a408d0dfe4dfa7598e22956a3ded81cc62d7750 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 17:39:37 -0400 Subject: [PATCH 28/35] A few small consistency fixes. --- src/FreeDSx/Ldap/LdapServer.php | 11 +++++++++++ .../ServerProtocolHandler/ServerSearchTrait.php | 2 ++ src/FreeDSx/Ldap/Server/Backend/SearchContext.php | 2 ++ 3 files changed, 15 insertions(+) diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 499d71b9..7de855ba 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -13,6 +13,7 @@ namespace FreeDSx\Ldap; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; @@ -84,6 +85,16 @@ public function useBackend(LdapBackendInterface $backend): self return $this; } + /** + * Override the bind name resolver used to translate a raw bind name to an Entry. + */ + public function useBindNameResolver(BindNameResolverInterface $resolver): self + { + $this->options->setBindNameResolver($resolver); + + return $this; + } + /** * Override the password authenticator used for simple bind and SASL PLAIN. */ diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php index 420f4a77..c54f72e3 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php @@ -106,6 +106,8 @@ private function makeSearchContext(SearchRequest $request): SearchContext filter: $request->getFilter(), attributes: $request->getAttributes(), typesOnly: $request->getAttributesOnly(), + sizeLimit: $request->getSizeLimit(), + timeLimit: $request->getTimeLimit(), ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/SearchContext.php b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php index a038694a..391cdfd8 100644 --- a/src/FreeDSx/Ldap/Server/Backend/SearchContext.php +++ b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php @@ -38,6 +38,8 @@ public function __construct( readonly public FilterInterface $filter, readonly public array $attributes, readonly public bool $typesOnly, + readonly public int $sizeLimit = 0, + readonly public int $timeLimit = 0, ) { } } From e3af982cc7db9b2e6163d2e46c497c6a87b1ec6c Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 17:59:21 -0400 Subject: [PATCH 29/35] Make atomic required on storage. --- .../Storage/Adapter/InMemoryStorage.php | 5 +++ .../Storage/Adapter/JsonEntryBuffer.php | 5 +++ .../Storage/Adapter/JsonFileStorage.php | 3 +- .../Storage/AtomicStorageInterface.php | 34 ------------------- .../Backend/Storage/EntryStorageInterface.php | 10 ++++++ .../Storage/WritableStorageBackend.php | 27 ++++----------- 6 files changed, 28 insertions(+), 56 deletions(-) delete mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php index 9af5e81b..83ccf28c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -80,4 +80,9 @@ public function remove(Dn $dn): void { unset($this->entries[$dn->toString()]); } + + public function atomic(callable $operation): void + { + $operation($this); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php index bc44d34d..d362b5ac 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php @@ -93,6 +93,11 @@ public function remove(Dn $dn): void unset($this->data[$dn->toString()]); } + public function atomic(callable $operation): void + { + $operation($this); + } + /** * @return array */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index 77368afc..6a7998db 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -19,7 +19,6 @@ 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\AtomicStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use Generator; @@ -49,7 +48,7 @@ * * @author Chad Sikorra */ -final class JsonFileStorage implements EntryStorageInterface, AtomicStorageInterface +final class JsonFileStorage implements EntryStorageInterface { use ArrayEntryStorageTrait; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php deleted file mode 100644 index 58c4a1a5..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/AtomicStorageInterface.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage; - -/** - * Optional interface for storage implementations that require transactional - * read-modify-write semantics (e.g. file locking, database transactions). - * - * @author Chad Sikorra - */ -interface AtomicStorageInterface -{ - /** - * Execute $operation as an atomic read-modify-write cycle. - * - * The EntryStorageInterface instance passed to $operation is a writable view - * over the in-flight data for the duration of the call. Changes made to it - * are committed when $operation returns. - * - * @param callable(EntryStorageInterface): void $operation - */ - public function atomic(callable $operation): void; -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php index d869db4a..f2baebd2 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -74,4 +74,14 @@ public function store(Entry $entry): void; * Remove the entry for the given normalised DN. A no-op if the entry does not exist. */ public function remove(Dn $dn): void; + + /** + * Execute $operation as an atomic read-modify-write cycle. + * + * Implementations backed by a file, database, or any resource accessible to concurrent connections must acquire + * an appropriate lock or open a transaction before executing $operation, then commit or release on completion. + * + * @param callable(EntryStorageInterface): void $operation + */ + public function atomic(callable $operation): void; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 480ea6b7..777b4fbd 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -35,10 +35,9 @@ * filtering, DN normalisation, and entry transformation — so that storage * implementations only need to provide raw persistence primitives. * - * When the storage also implements AtomicStorageInterface, write operations - * are wrapped in atomic() to ensure transactional consistency (e.g. file - * locking, database transactions). Storage implementations that do not need - * atomicity may implement EntryStorageInterface alone. + * All write operations are routed through EntryStorageInterface::atomic() to + * ensure transactional consistency. See EntryStorageInterface::atomic() for the + * contract each storage implementation must satisfy. * * @author Chad Sikorra */ @@ -80,7 +79,7 @@ public function search(SearchContext $context): Generator */ public function add(AddCommand $command): void { - $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->entry->getDn()->normalize(); $this->assertParentExists($storage, $dn); @@ -97,7 +96,7 @@ public function add(AddCommand $command): void */ public function delete(DeleteCommand $command): void { - $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $this->findOrFail($storage, $dn); @@ -120,7 +119,7 @@ public function delete(DeleteCommand $command): void */ public function update(UpdateCommand $command): void { - $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $entry = $this->findOrFail($storage, $dn); $storage->store($this->entryHandler->apply( @@ -135,7 +134,7 @@ public function update(UpdateCommand $command): void */ public function move(MoveCommand $command): void { - $this->runWriteOperation(function (EntryStorageInterface $storage) use ($command): void { + $this->storage->atomic(function (EntryStorageInterface $storage) use ($command): void { $normOld = $command->dn->normalize(); $entry = $this->findOrFail($storage, $normOld); @@ -229,16 +228,4 @@ private function throwEntryAlreadyExists(Dn $dn): never ResultCode::ENTRY_ALREADY_EXISTS, ); } - - /** - * @param callable(EntryStorageInterface): void $operation - */ - private function runWriteOperation(callable $operation): void - { - if ($this->storage instanceof AtomicStorageInterface) { - $this->storage->atomic($operation); - } else { - $operation($this->storage); - } - } } From 17a13c8d2ba4ff804eabadf2881b1a7384fa1e49 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 18:17:05 -0400 Subject: [PATCH 30/35] Make compare extensible / bake it into the LdapBackendInterface. --- .../Factory/ServerProtocolHandlerFactory.php | 1 - .../ServerDispatchHandler.php | 25 +++----------- .../Auth/PasswordAuthenticatableInterface.php | 8 +++++ .../Ldap/Server/Backend/GenericBackend.php | 13 +++++++ .../Server/Backend/LdapBackendInterface.php | 15 ++++++++ .../Storage/WritableStorageBackend.php | 29 ++++++++++++++++ .../Server/RequestHandler/ProxyBackend.php | 12 +++++++ tests/bin/ldap-server.php | 31 +++++++++++++++++ .../ServerDispatchHandlerTest.php | 34 +++++++++---------- 9 files changed, 128 insertions(+), 40 deletions(-) diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php index 8c5d420a..e9514684 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php @@ -73,7 +73,6 @@ public function get( return new ServerProtocolHandler\ServerDispatchHandler( queue: $this->queue, backend: $this->handlerFactory->makeBackend(), - filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), writeDispatcher: $this->handlerFactory->makeWriteDispatcher(), ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php index 52138185..42b1f7c6 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -21,7 +21,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteCommandFactory; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -36,7 +35,6 @@ class ServerDispatchHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerQueue $queue, private readonly LdapBackendInterface $backend, - private readonly FilterEvaluatorInterface $filterEvaluator, private readonly WriteOperationDispatcher $writeDispatcher, private readonly WriteCommandFactory $commandFactory = new WriteCommandFactory(), private readonly ResponseFactory $responseFactory = new ResponseFactory(), @@ -55,10 +53,12 @@ public function handleRequest( $request = $message->getRequest(); if ($request instanceof Request\CompareRequest) { - $compareMatch = $this->handleCompare($request); + $match = $this->backend->compare($request->getDn(), $request->getFilter()); $this->queue->sendMessage($this->responseFactory->getStandardResponse( $message, - $compareMatch ? ResultCode::COMPARE_TRUE : ResultCode::COMPARE_FALSE, + $match + ? ResultCode::COMPARE_TRUE + : ResultCode::COMPARE_FALSE, )); return; @@ -68,21 +68,4 @@ public function handleRequest( $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); } - - /** - * @throws OperationException - */ - private function handleCompare(Request\CompareRequest $request): bool - { - $entry = $this->backend->get($request->getDn()); - - if ($entry === null) { - throw new OperationException( - sprintf('No such object: %s', $request->getDn()->toString()), - ResultCode::NO_SUCH_OBJECT, - ); - } - - return $this->filterEvaluator->evaluate($entry, $request->getFilter()); - } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php index 54e6038f..ef3fe3f7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.php @@ -36,6 +36,14 @@ public function verifyPassword( /** * Return the plaintext password for the given username and SASL mechanism, * or null if the user does not exist or should not authenticate. + * + * The $mechanism parameter receives the exact SASL mechanism name (e.g. + * 'SCRAM-SHA-256', 'CRAM-MD5'). For all currently supported SASL mechanisms + * the return value must be the user's plaintext password: the underlying SASL + * library derives the required keys (PBKDF2 for SCRAM, HMAC for CRAM-MD5) + * from plaintext at authentication time. + * + * RFC 5803 stored-key format (pre-computed StoredKey/ServerKey) is not currently supported. */ public function getPassword( string $username, diff --git a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php index 6054b369..aeb951f7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.php @@ -15,6 +15,9 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use Generator; /** @@ -36,4 +39,14 @@ public function get(Dn $dn): ?Entry { return null; } + + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php index 8b7b4c5d..c8403f6c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use Generator; /** @@ -49,4 +51,17 @@ public function search(SearchContext $context): Generator; * Fetch a single entry by DN, or return null if it does not exist. */ public function get(Dn $dn): ?Entry; + + /** + * Evaluate a compare assertion against an entry. + * + * Return true if the attribute-value assertion matches, false if it does not. + * Throw OperationException(NO_SUCH_OBJECT) if the entry does not exist. + * + * @throws OperationException + */ + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 777b4fbd..b163a73c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -24,6 +24,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use Generator; @@ -56,6 +57,34 @@ public function get(Dn $dn): ?Entry return $this->storage->find($dn->normalize()); } + /** + * @throws OperationException + */ + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + $entry = $this->get($dn); + + if ($entry === null) { + $this->throwNoSuchObject($dn); + } + + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + foreach ($attribute->getValues() as $value) { + if (strcasecmp($value, $filter->getValue()) === 0) { + return true; + } + } + + return false; + } + public function search(SearchContext $context): Generator { $normBase = $context->baseDn->normalize(); diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php index e26a25e3..6573e902 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -30,6 +30,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; @@ -78,6 +79,17 @@ public function get(Dn $dn): ?Entry return $this->ldap()->read($dn->toString()); } + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + return $this->ldap()->compare( + $dn, + $filter->getAttribute(), + $filter->getValue(), + ); + } + public function verifyPassword( string $name, #[SensitiveParameter] diff --git a/tests/bin/ldap-server.php b/tests/bin/ldap-server.php index b6486bd9..5c2e9dad 100644 --- a/tests/bin/ldap-server.php +++ b/tests/bin/ldap-server.php @@ -4,7 +4,10 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; @@ -56,6 +59,34 @@ public function get(Dn $dn): ?Entry ); } + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + $entry = $this->get($dn); + + if ($entry === null) { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $attribute = $entry->get($filter->getAttribute()); + + if ($attribute === null) { + return false; + } + + foreach ($attribute->getValues() as $value) { + if (strcasecmp($value, $filter->getValue()) === 0) { + return true; + } + } + + return false; + } + public function verifyPassword( string $name, string $password, diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php index 08f890a2..2e4f2f53 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -13,6 +13,7 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\AbandonRequest; @@ -23,9 +24,9 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; @@ -41,8 +42,6 @@ final class ServerDispatchHandlerTest extends TestCase private WriteHandlerInterface&MockObject $mockWriteHandler; - private FilterEvaluatorInterface&MockObject $mockFilterEvaluator; - private ServerQueue&MockObject $mockQueue; private TokenInterface&MockObject $mockToken; @@ -53,7 +52,6 @@ protected function setUp(): void $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockBackend = $this->createMock(LdapBackendInterface::class); $this->mockWriteHandler = $this->createMock(WriteHandlerInterface::class); - $this->mockFilterEvaluator = $this->createMock(FilterEvaluatorInterface::class); $this->mockQueue ->method('sendMessage') @@ -66,7 +64,6 @@ protected function setUp(): void $this->subject = new ServerDispatchHandler( queue: $this->mockQueue, backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, writeDispatcher: new WriteOperationDispatcher($this->mockWriteHandler), ); } @@ -100,10 +97,10 @@ public function test_it_propagates_operation_exceptions_from_the_write_handler() $this->subject->handleRequest($add, $this->mockToken); } - public function test_it_sends_a_compare_request_using_the_backend_and_filter_evaluator(): void + public function test_it_delegates_compare_to_the_backend(): void { - $entry = Entry::create('cn=foo,dc=bar'); - $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); + $filter = Filters::equal('foo', 'bar'); + $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', $filter)); $this->mockWriteHandler ->expects(self::never()) @@ -111,24 +108,26 @@ public function test_it_sends_a_compare_request_using_the_backend_and_filter_eva $this->mockBackend ->expects(self::once()) - ->method('get') - ->willReturn($entry); - - $this->mockFilterEvaluator - ->expects(self::once()) - ->method('evaluate') + ->method('compare') + ->with( + self::isInstanceOf(Dn::class), + self::isInstanceOf(EqualityFilter::class), + ) ->willReturn(true); $this->subject->handleRequest($compare, $this->mockToken); } - public function test_it_throws_no_such_object_when_comparing_a_non_existent_entry(): void + public function test_it_propagates_operation_exceptions_from_backend_compare(): void { $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', Filters::equal('foo', 'bar'))); $this->mockBackend - ->method('get') - ->willReturn(null); + ->method('compare') + ->willThrowException(new OperationException( + 'No such object: cn=foo,dc=bar', + ResultCode::NO_SUCH_OBJECT, + )); self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); @@ -141,7 +140,6 @@ public function test_it_throws_unwilling_to_perform_when_no_write_handler_suppor $subject = new ServerDispatchHandler( queue: $this->mockQueue, backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, writeDispatcher: new WriteOperationDispatcher(), ); From b025360ba8c9055adea96671a5daa62d59f6766b Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 18:28:19 -0400 Subject: [PATCH 31/35] Don't pretty print the json. --- .../Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index 6a7998db..5c1a94ae 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -228,10 +228,7 @@ private function decodeContents(string $contents): array */ private function encodeContents(array $data): string { - return json_encode( - $data, - JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE - ) ?: '{}'; + return json_encode($data, JSON_UNESCAPED_UNICODE) ?: '{}'; } private function arrayToEntry(mixed $data): Entry From bb4e4553476f20f15cfcfbf722046a42a8523042 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 18:35:56 -0400 Subject: [PATCH 32/35] Fix unknown filter evaluation. --- .../Backend/Storage/FilterEvaluator.php | 12 ++++++-- .../Backend/Storage/FilterEvaluatorTest.php | 29 ++++++++++--------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 47c742fb..5d4812bf 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -14,6 +14,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\ApproximateFilter; use FreeDSx\Ldap\Search\Filter\EqualityFilter; @@ -61,7 +63,10 @@ public function evaluate( $filter instanceof LessThanOrEqualFilter => $this->evaluateLessOrEqual($entry, $filter), $filter instanceof ApproximateFilter => $this->evaluateApproximate($entry, $filter), $filter instanceof MatchingRuleFilter => $this->evaluateMatchingRule($entry, $filter), - default => false, + default => throw new OperationException( + sprintf('Unrecognized filter type: %s', get_class($filter)), + ResultCode::PROTOCOL_ERROR, + ), }; } @@ -309,7 +314,10 @@ private function matchByRule( self::MATCHING_RULE_CASE_EXACT => $value === $filterValue, self::MATCHING_RULE_BIT_AND => ((int) $value & (int) $filterValue) === (int) $filterValue, self::MATCHING_RULE_BIT_OR => ((int) $value & (int) $filterValue) !== 0, - default => false, + default => throw new OperationException( + sprintf('Unsupported matching rule: %s', $rule), + ResultCode::INAPPROPRIATE_MATCHING, + ), }; } } diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index 3ad6950b..c5869d53 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -16,6 +16,8 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filter\ApproximateFilter; use FreeDSx\Ldap\Search\Filter\MatchingRuleFilter; @@ -412,17 +414,15 @@ public function test_matching_rule_bit_and_no_match(): void )); } - public function test_matching_rule_unknown_returns_false(): void + public function test_matching_rule_unknown_throws_inappropriate_matching(): void { - $filter = new MatchingRuleFilter( - '1.2.3.4.5.unknown', - 'cn', - 'Alice', - ); - self::assertFalse($this->subject->evaluate( + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::INAPPROPRIATE_MATCHING); + + $this->subject->evaluate( $this->entry, - $filter - )); + new MatchingRuleFilter('1.2.3.4.5.unknown', 'cn', 'Alice'), + ); } public function test_matching_rule_dn_attributes(): void @@ -454,11 +454,14 @@ public function test_matching_rule_null_attribute_matches_all(): void )); } - public function test_unknown_filter_type_returns_false(): void + public function test_unknown_filter_type_throws_protocol_error(): void { - self::assertFalse($this->subject->evaluate( + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::PROTOCOL_ERROR); + + $this->subject->evaluate( $this->entry, - $this->createMock(FilterInterface::class) - )); + $this->createMock(FilterInterface::class), + ); } } From 85667684c2bf59def5ce18432e2a5b167d1deae8 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 19:08:22 -0400 Subject: [PATCH 33/35] Various RFC correctness related fixes. --- .../ServerPagingHandler.php | 84 ++++++++++++----- .../ServerSearchHandler.php | 25 ++++-- .../Backend/Storage/FilterEvaluator.php | 59 +++++++----- .../Storage/WritableStorageBackend.php | 14 ++- .../Ldap/Server/Paging/PagingRequest.php | 18 ++++ .../Ldap/Server/Paging/PagingResponse.php | 24 ++++- .../ServerPagingHandlerTest.php | 89 +++++++++++++++++++ .../ServerSearchHandlerTest.php | 68 ++++++++++++++ .../Adapter/WritableStorageBackendTest.php | 42 +++++++++ .../Backend/Storage/FilterEvaluatorTest.php | 60 +++++++++++++ 10 files changed, 435 insertions(+), 48 deletions(-) diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index a717159b..802285e4 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -67,16 +67,26 @@ public function handleRequest( $controls = []; try { $response = $this->handlePaging($pagingRequest, $message); - $searchResult = SearchResult::makeSuccessResult( - $response->getEntries(), - (string) $searchRequest->getBaseDn() - ); - $controls[] = new PagingControl( - $response->getRemaining(), - $response->isComplete() - ? '' - : $pagingRequest->getNextCookie() - ); + if ($response->isSizeLimitExceeded()) { + $searchResult = SearchResult::makeErrorResult( + ResultCode::SIZE_LIMIT_EXCEEDED, + (string) $searchRequest->getBaseDn(), + '', + $response->getEntries(), + ); + $controls[] = new PagingControl(0, ''); + } else { + $searchResult = SearchResult::makeSuccessResult( + $response->getEntries(), + (string) $searchRequest->getBaseDn() + ); + $controls[] = new PagingControl( + $response->getRemaining(), + $response->isComplete() + ? '' + : $pagingRequest->getNextCookie() + ); + } } catch (OperationException $e) { $searchResult = SearchResult::makeErrorResult( $e->getCode(), @@ -135,12 +145,17 @@ private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse $context = $this->makeSearchContext($searchRequest); $generator = $this->backend->search($context); - [$page, $generatorExhausted] = $this->collectFromGenerator( + [$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator( $generator, $pagingRequest->getSize(), $context, + 0, ); + if ($sizeLimitExceeded) { + return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + } + $nextCookie = $this->generateCookie(); $pagingRequest->updateNextCookie($nextCookie); @@ -148,6 +163,7 @@ private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse return PagingResponse::makeFinal(new Entries(...$page)); } + $pagingRequest->incrementTotalSent(count($page)); $this->requestHistory->storePagingGenerator($nextCookie, $generator); return PagingResponse::make(new Entries(...$page), 0); @@ -192,12 +208,17 @@ private function handleExistingCookie( $searchRequest = $pagingRequest->getSearchRequest(); $context = $this->makeSearchContext($searchRequest); - [$page, $generatorExhausted] = $this->collectFromGenerator( + [$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator( $generator, $pagingRequest->getSize(), $context, + $pagingRequest->getTotalSent(), ); + if ($sizeLimitExceeded) { + return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + } + $nextCookie = $this->generateCookie(); $pagingRequest->updateNextCookie($nextCookie); @@ -205,36 +226,59 @@ private function handleExistingCookie( return PagingResponse::makeFinal(new Entries(...$page)); } + $pagingRequest->incrementTotalSent(count($page)); $this->requestHistory->storePagingGenerator($nextCookie, $generator); return PagingResponse::make(new Entries(...$page), 0); } /** - * Advances the generator, collecting up to $size entries that pass the filter. - * Returns the collected entries and whether the generator is exhausted. + * Advances the generator, collecting up to $pageSize entries that pass the filter. * - * @return array{Entry[], bool} + * 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] */ private function collectFromGenerator( Generator $generator, - int $size, + int $pageSize, SearchContext $context, + int $totalAlreadySent, ): array { $page = []; - $limit = $size > 0 ? $size : PHP_INT_MAX; + $pageLimit = $pageSize > 0 ? $pageSize : PHP_INT_MAX; + $sizeLimit = $context->sizeLimit; - while ($generator->valid() && count($page) < $limit) { + while ($generator->valid() && count($page) < $pageLimit) { $entry = $generator->current(); if ($entry instanceof Entry && $this->filterEvaluator->evaluate($entry, $context->filter)) { - $page[] = $this->applyAttributeFilter($entry, $context->attributes, $context->typesOnly); + $page[] = $this->applyAttributeFilter( + $entry, + $context->attributes, + $context->typesOnly + ); + + if ($sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit) { + $generator->next(); + break; + } } $generator->next(); } - return [$page, !$generator->valid()]; + $generatorExhausted = !$generator->valid(); + $sizeLimitExceeded = !$generatorExhausted + && $sizeLimit > 0 + && ($totalAlreadySent + count($page)) >= $sizeLimit; + + return [ + $page, + $generatorExhausted, + $sizeLimitExceeded, + ]; } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index daa6d0ab..d38b9628 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -14,8 +14,8 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; @@ -52,18 +52,33 @@ public function handleRequest( $filter = $context->filter; $attributes = $context->attributes; $typesOnly = $context->typesOnly; + $sizeLimit = $context->sizeLimit; $results = []; + $sizeLimitExceeded = false; + foreach ($this->backend->search($context) as $entry) { if ($this->filterEvaluator->evaluate($entry, $filter)) { $results[] = $this->applyAttributeFilter($entry, $attributes, $typesOnly); + if ($sizeLimit > 0 && count($results) >= $sizeLimit) { + $sizeLimitExceeded = true; + break; + } } } - $searchResult = SearchResult::makeSuccessResult( - new Entries(...$results), - (string) $request->getBaseDn(), - ); + $entries = new Entries(...$results); + $searchResult = $sizeLimitExceeded + ? SearchResult::makeErrorResult( + ResultCode::SIZE_LIMIT_EXCEEDED, + (string) $request->getBaseDn(), + '', + $entries, + ) + : SearchResult::makeSuccessResult( + $entries, + (string) $request->getBaseDn() + ); } catch (OperationException $e) { $searchResult = SearchResult::makeErrorResult( $e->getCode(), diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 5d4812bf..9ebe7ca2 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -150,33 +150,52 @@ private function evaluateSubstring( $contains = array_map('strtolower', $filter->getContains()); foreach ($attribute->getValues() as $value) { - $lowerValue = strtolower($value); - - if ($startsWith !== null && !str_starts_with($lowerValue, $startsWith)) { - continue; + if ($this->substringMatches(strtolower($value), $startsWith, $endsWith, $contains)) { + return true; } + } - if ($endsWith !== null && !str_ends_with($lowerValue, $endsWith)) { - continue; - } + return false; + } - $pos = 0; - $allFound = true; - foreach ($contains as $substr) { - $found = strpos($lowerValue, $substr, $pos); - if ($found === false) { - $allFound = false; - break; - } - $pos = $found + strlen($substr); - } + /** + * RFC 4511 §4.5.1.7.1 — evaluate one attribute value against the decomposed substring components. + * + * Each matched portion must start strictly after the end of the previous one: + * initial occupies [0, len(initial)-1], so 'any' searches start at len(initial). + * After all 'any' matches, 'final' must begin at a position >= $pos. + * + * @param string[] $contains + */ + private function substringMatches( + string $value, + ?string $startsWith, + ?string $endsWith, + array $contains, + ): bool { + if ($startsWith !== null && !str_starts_with($value, $startsWith)) { + return false; + } - if ($allFound) { - return true; + // Start 'any' searches after the initial match, not at position 0. + $pos = $startsWith !== null ? strlen($startsWith) : 0; + + foreach ($contains as $substr) { + $found = strpos($value, $substr, $pos); + if ($found === false) { + return false; } + $pos = $found + strlen($substr); } - return false; + if ($endsWith === null) { + return true; + } + + // 'final' must be at the end of the value AND must not overlap the previous match. + $endsWithStart = strlen($value) - strlen($endsWith); + + return $endsWithStart >= $pos && str_ends_with($value, $endsWith); } private function evaluateGreaterOrEqual( diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index b163a73c..836b1427 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -85,19 +85,29 @@ public function compare( return false; } + /** + * @throws OperationException + */ public function search(SearchContext $context): Generator { $normBase = $context->baseDn->normalize(); if ($context->scope === SearchRequest::SCOPE_BASE_OBJECT) { $entry = $this->storage->find($normBase); - if ($entry !== null) { - yield $entry; + + if ($entry === null) { + $this->throwNoSuchObject($context->baseDn); } + yield $entry; + return; } + 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; } diff --git a/src/FreeDSx/Ldap/Server/Paging/PagingRequest.php b/src/FreeDSx/Ldap/Server/Paging/PagingRequest.php index 0f2cac4b..e2eb7ba2 100644 --- a/src/FreeDSx/Ldap/Server/Paging/PagingRequest.php +++ b/src/FreeDSx/Ldap/Server/Paging/PagingRequest.php @@ -34,6 +34,8 @@ final class PagingRequest private bool $hasProcessed = false; + private int $totalSent = 0; + public function __construct( private PagingControl $control, private readonly SearchRequest $request, @@ -154,6 +156,22 @@ public function updateNextCookie(string $cookie): void $this->nextCookie = $cookie; } + /** + * Total entries sent to the client across all completed pages of this paging session. + */ + public function getTotalSent(): int + { + return $this->totalSent; + } + + /** + * @internal + */ + public function incrementTotalSent(int $count): void + { + $this->totalSent += $count; + } + /** * @internal */ diff --git a/src/FreeDSx/Ldap/Server/Paging/PagingResponse.php b/src/FreeDSx/Ldap/Server/Paging/PagingResponse.php index 6906f3ec..d8dc91fd 100644 --- a/src/FreeDSx/Ldap/Server/Paging/PagingResponse.php +++ b/src/FreeDSx/Ldap/Server/Paging/PagingResponse.php @@ -29,7 +29,8 @@ final class PagingResponse public function __construct( private readonly Entries $entries, private readonly bool $isComplete = false, - private readonly int $remaining = 0 + private readonly int $remaining = 0, + private readonly bool $sizeLimitExceeded = false, ) { } @@ -51,6 +52,11 @@ public function isComplete(): bool return $this->isComplete; } + public function isSizeLimitExceeded(): bool + { + return $this->sizeLimitExceeded; + } + /** * Make a standard paging response that indicates that are still results left to return. * @@ -80,4 +86,20 @@ public static function makeFinal(Entries $entries): self true ); } + + /** + * Make a terminal paging response indicating that the client's sizeLimit was reached + * before the full result set was returned. The entries collected up to the limit are included. + * + * @param Entries $entries + */ + public static function makeSizeLimitExceeded(Entries $entries): self + { + return new self( + $entries, + true, + 0, + true, + ); + } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index 534b5bc1..ea72c3ba 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -318,6 +318,95 @@ public function test_it_throws_an_exception_if_the_paging_cookie_does_not_exist( ); } + public function test_it_should_return_size_limit_exceeded_on_first_page_when_limit_is_hit(): void + { + $searchRequest = (new SearchRequest(Filters::raw('(foo=bar)'))) + ->base('dc=foo,dc=bar') + ->sizeLimit(1); + $message = $this->makeSearchMessage(size: 10, searchRequest: $searchRequest); + + $entry1 = Entry::create('cn=1,dc=foo,dc=bar', ['cn' => '1']); + $entry2 = Entry::create('cn=2,dc=foo,dc=bar', ['cn' => '2']); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with( + new LdapMessageResponse(2, new SearchResultEntry($entry1)), + self::callback(static function (LdapMessageResponse $response): bool { + $paging = $response->controls()->get(Control::OID_PAGING); + $done = $response->getResponse(); + + return $paging instanceof PagingControl + && $paging->getCookie() === '' + && $done instanceof SearchResultDone + && $done->getResultCode() === ResultCode::SIZE_LIMIT_EXCEEDED; + }), + ); + + $this->subject->handleRequest($message, $this->mockToken); + } + + public function test_it_should_accumulate_size_limit_across_pages(): void + { + $searchRequest = (new SearchRequest(Filters::raw('(foo=bar)'))) + ->base('dc=foo,dc=bar') + ->sizeLimit(2); + + $entry1 = Entry::create('cn=1,dc=foo,dc=bar', ['cn' => '1']); + $entry2 = Entry::create('cn=2,dc=foo,dc=bar', ['cn' => '2']); + $entry3 = Entry::create('cn=3,dc=foo,dc=bar', ['cn' => '3']); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2, $entry3)); + + $capturedCookie = ''; + $sizeLimitExceededSeen = false; + + $this->mockQueue + ->method('sendMessage') + ->willReturnCallback( + function (LdapMessageResponse ...$responses) use (&$capturedCookie, &$sizeLimitExceededSeen) { + foreach ($responses as $response) { + $paging = $response->controls()->get(Control::OID_PAGING); + if ($paging instanceof PagingControl && $paging->getCookie() !== '') { + $capturedCookie = $paging->getCookie(); + } + $done = $response->getResponse(); + if ($done instanceof SearchResultDone && $done->getResultCode() === ResultCode::SIZE_LIMIT_EXCEEDED) { + $sizeLimitExceededSeen = true; + } + } + + return $this->mockQueue; + } + ); + + // First page: pageSize=1, sizeLimit=2 — gets entry1, stores generator + $this->subject->handleRequest( + $this->makeSearchMessage(size: 1, searchRequest: $searchRequest), + $this->mockToken, + ); + + self::assertNotEmpty($capturedCookie, 'Expected a non-empty cookie after the first page.'); + + // Second page: totalSent=1, gets entry2 — hits sizeLimit(2), returns SIZE_LIMIT_EXCEEDED + $pagingReq = $this->requestHistory->pagingRequest()->findByNextCookie($capturedCookie); + $this->subject->handleRequest( + $this->makeSearchMessage(size: 10, cookie: $capturedCookie, searchRequest: $pagingReq->getSearchRequest()), + $this->mockToken, + ); + + self::assertTrue($sizeLimitExceededSeen, 'Expected SIZE_LIMIT_EXCEEDED on the second page.'); + } + private function makeExistingPagingRequest( int $size = 10, string $cookie = 'bar', diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index db159e15..da2a3a5e 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -174,6 +174,74 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti ); } + public function test_it_should_return_size_limit_exceeded_with_partial_results_when_limit_is_hit(): void + { + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); + + $search = new LdapMessageRequest( + 2, + (new SearchRequest(Filters::equal('foo', 'bar'))) + ->base('dc=foo,dc=bar') + ->sizeLimit(1) + ); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturn(true); + + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with( + new LdapMessageResponse(2, new SearchResultEntry($entry1)), + new LdapMessageResponse( + 2, + new SearchResultDone(ResultCode::SIZE_LIMIT_EXCEEDED, 'dc=foo,dc=bar') + ), + ); + + $this->subject->handleRequest($search, $this->mockToken); + } + + public function test_it_should_not_enforce_size_limit_when_zero(): void + { + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); + + $search = new LdapMessageRequest( + 2, + (new SearchRequest(Filters::equal('foo', 'bar'))) + ->base('dc=foo,dc=bar') + ->sizeLimit(0) + ); + + $this->mockBackend + ->expects(self::once()) + ->method('search') + ->willReturn($this->makeGenerator($entry1, $entry2)); + + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturn(true); + + $this->mockQueue + ->expects(self::once()) + ->method('sendMessage') + ->with( + new LdapMessageResponse(2, new SearchResultEntry($entry1)), + new LdapMessageResponse(2, new SearchResultEntry($entry2)), + new LdapMessageResponse(2, new SearchResultDone(0, 'dc=foo,dc=bar')), + ); + + $this->subject->handleRequest($search, $this->mockToken); + } + public function test_it_should_send_a_successful_SearchResultDone_when_no_entries_match(): void { $search = new LdapMessageRequest( diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index db22bdc8..e16b9439 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -146,6 +146,48 @@ public function test_search_subtree_returns_base_and_all_descendants(): void ); } + public function test_search_base_scope_throws_no_such_object_when_base_does_not_exist(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('cn=Missing,dc=example,dc=com'), + scope: SearchRequest::SCOPE_BASE_OBJECT, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + } + + public function test_search_single_level_throws_no_such_object_when_base_does_not_exist(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('cn=Missing,dc=example,dc=com'), + scope: SearchRequest::SCOPE_SINGLE_LEVEL, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + } + + public function test_search_subtree_throws_no_such_object_when_base_does_not_exist(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + iterator_to_array($this->subject->search(new SearchContext( + baseDn: new Dn('cn=Missing,dc=example,dc=com'), + scope: SearchRequest::SCOPE_WHOLE_SUBTREE, + filter: new PresentFilter('objectClass'), + attributes: [], + typesOnly: false, + ))); + } + public function test_add_stores_entry(): void { $entry = new Entry(new Dn('cn=New,dc=example,dc=com'), new Attribute('cn', 'New')); diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index c5869d53..39c7814a 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -143,6 +143,66 @@ public function test_substring_returns_false_when_no_match(): void self::assertFalse($this->subject->evaluate($this->entry, $filter)); } + public function test_substring_any_does_not_match_within_initial_portion(): void + { + // RFC 4511 §4.5.1.7.1: 'any' must appear AFTER the initial match. + // "testing" starts with "test" (positions 0-3); the 'any' "t" must be + // found at position 4+. "ing" has no "t", so the filter must not match. + $entry = new Entry( + new Dn('cn=testing,dc=example,dc=com'), + new Attribute('cn', 'testing'), + ); + $filter = (new SubstringFilter('cn')) + ->setStartsWith('test') + ->setContains('t'); + + self::assertFalse($this->subject->evaluate($entry, $filter)); + } + + public function test_substring_any_matches_after_initial_portion(): void + { + // "testting" has a second "t" at position 4, which is after the "test" initial. + $entry = new Entry( + new Dn('cn=testting,dc=example,dc=com'), + new Attribute('cn', 'testting'), + ); + $filter = (new SubstringFilter('cn')) + ->setStartsWith('test') + ->setContains('t'); + + self::assertTrue($this->subject->evaluate($entry, $filter)); + } + + public function test_substring_final_requires_distinct_occurrence_from_any(): void + { + // RFC 4511 §4.5.1.7.1: 'final' must start after the last 'any' match. + // Filter *foo*foo against value "foo": the 'any' match consumes "foo" at [0,2], + // leaving nothing for 'final' to start at position >= 3 — no match. + $entry = new Entry( + new Dn('cn=foo,dc=example,dc=com'), + new Attribute('cn', 'foo'), + ); + $filter = (new SubstringFilter('cn')) + ->setContains('foo') + ->setEndsWith('foo'); + + self::assertFalse($this->subject->evaluate($entry, $filter)); + } + + public function test_substring_final_matches_after_any_when_two_occurrences_exist(): void + { + // "foofoo" has two non-overlapping occurrences: 'any' at [0,2], 'final' at [3,5]. + $entry = new Entry( + new Dn('cn=foofoo,dc=example,dc=com'), + new Attribute('cn', 'foofoo'), + ); + $filter = (new SubstringFilter('cn')) + ->setContains('foo') + ->setEndsWith('foo'); + + self::assertTrue($this->subject->evaluate($entry, $filter)); + } + public function test_substring_ordered_contains(): void { $filter = (new SubstringFilter('mail')) From f3a793dbaddcf5353396b304ae30d12188448407 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 19:17:41 -0400 Subject: [PATCH 34/35] Fix property declaration. --- src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php b/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php index f4698475..cf33d9bb 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Command/MoveCommand.php @@ -28,7 +28,7 @@ public function __construct( readonly public Dn $dn, readonly public Rdn $newRdn, readonly public bool $deleteOldRdn, - readonly ?Dn $newParent, + readonly public ?Dn $newParent, ) { } } From 2d9061dbe67c1f67d5ecbde853cbf2cd601f9753 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Apr 2026 19:17:53 -0400 Subject: [PATCH 35/35] Fix the docs for related changes. --- UPGRADE-1.0.md | 60 ++++++++++++++++++++++++++++++++++-- docs/Server/General-Usage.md | 52 +++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index dcbcd34c..fd116524 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -192,7 +192,10 @@ $server = new LdapServer( ```php use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; use Generator; @@ -209,6 +212,16 @@ class MyBackend implements LdapBackendInterface { return null; } + + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } } $server = (new LdapServer()) @@ -228,11 +241,14 @@ and plaintext. replicate the old `bind()` behaviour: ```php +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Entry\Entry; use Generator; use SensitiveParameter; @@ -248,6 +264,16 @@ class MyBackend implements LdapBackendInterface, PasswordAuthenticatableInterfac return null; } + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + public function verifyPassword( string $name, #[SensitiveParameter] string $password, @@ -279,18 +305,46 @@ $server = (new LdapServer()) a typed command object. Use `WritableBackendTrait` to implement the dispatch automatically: ```php +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\WritableBackendTrait; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; +use Generator; class MyBackend implements WritableLdapBackendInterface { use WritableBackendTrait; - // search() and get() as above ... + public function search(SearchContext $context): Generator + { + yield from []; + } + + public function get(Dn $dn): ?Entry + { + return null; + } + + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + // $dn — Dn of the entry to compare against + // $filter — EqualityFilter: the attribute-value assertion + // Throw OperationException(NO_SUCH_OBJECT) if the entry does not exist. + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } public function add(AddCommand $command): void { diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index c121c300..fc717896 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -242,6 +242,9 @@ namespace App; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\SearchContext; use Generator; @@ -275,6 +278,28 @@ class MyReadOnlyBackend implements LdapBackendInterface // ... return null; } + + /** + * Return true if the attribute-value assertion matches the entry, false if not. + * Throw OperationException(NO_SUCH_OBJECT) if the entry does not exist. + */ + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + $entry = $this->get($dn); + + if ($entry === null) { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $attribute = $entry->get($filter->getAttribute()); + + return $attribute !== null && $attribute->has($filter->getValue()); + } } ``` @@ -288,6 +313,9 @@ namespace App; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\SearchContext; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; @@ -312,6 +340,24 @@ class MyBackend implements WritableLdapBackendInterface return null; } + public function compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + $entry = $this->get($dn); + + if ($entry === null) { + throw new OperationException( + sprintf('No such object: %s', $dn->toString()), + ResultCode::NO_SUCH_OBJECT, + ); + } + + $attribute = $entry->get($filter->getAttribute()); + + return $attribute !== null && $attribute->has($filter->getValue()); + } + public function add(AddCommand $command): void { // $command->entry — Entry to persist @@ -330,10 +376,10 @@ class MyBackend implements WritableLdapBackendInterface public function move(MoveCommand $command): void { - // $command->dn — Dn: current entry DN - // $command->newRdn — Rdn: new relative DN + // $command->dn — Dn: current entry DN + // $command->newRdn — Rdn: new relative DN // $command->deleteOldRdn — bool: whether to remove the old RDN attribute value - // $command->newParent — ?Dn: new parent DN (null = same parent) + // $command->newParent — ?Dn: new parent DN (null = same parent) } } ```