diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 713fdc0d..7b56d15d 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -8,16 +8,41 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Setup extension cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: '8.5' + 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' - extension-csv: openssl,mbstring + php-version: '8.5' + extensions: openssl,mbstring,swoole 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 @@ -40,9 +65,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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e745ebd3..73050ce7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,11 +13,38 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Setup PHP + - 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 + 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..fd116524 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,255 @@ $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 backend 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\Exception\OperationException; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\ServerOptions; -use Foo\LdapProxyHandler; +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; -$server = new LdapServer( - (new ServerOptions) - ->setRequestHandler(new LdapProxyHandler()) -); -$server->run(); +class MyBackend implements LdapBackendInterface +{ + public function search(SearchContext $context): Generator + { + // Yield matching entries. Paging is handled automatically. + yield from []; + } + + 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, + ); + } +} + +$server = (new LdapServer()) + ->useBackend(new MyBackend()); ``` -## Request Handler Search Return Type +### Authentication -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). +The `bind()` method on `GenericRequestHandler` has been replaced by `PasswordAuthenticatableInterface`. Authentication +is now a separate concern decoupled from the backend. -**Before**: +**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. -```php -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; +**Custom authentication**: implement `PasswordAuthenticatableInterface` on your backend (or on a dedicated class) to +replicate the old `bind()` behaviour: -public function search(RequestContext $context, SearchRequest $search): Entries +```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 Generator; +use SensitiveParameter; + +class MyBackend implements LdapBackendInterface, PasswordAuthenticatableInterface { - return new Entries(/* ... */); + public function search(SearchContext $context): Generator + { + yield from []; + } + + 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, + ); + } + + public function verifyPassword( + string $name, + #[SensitiveParameter] string $password, + ): bool { + 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; + } } ``` -**After**: +The use of `PasswordAuthenticatableInterface` on the backend is automatically detected. Alternatively, register a +standalone authenticator: ```php -use FreeDSx\Ldap\Entry\Entries; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestContext; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; +$server = (new LdapServer()) + ->useBackend(new MyBackend()) + ->usePasswordAuthenticator(new MyAuthenticator()); +``` -public function search(RequestContext $context, SearchRequest $search): SearchResult +### 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\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 { - return SearchResult::make(new Entries(/* ... */)); + use WritableBackendTrait; + + 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 + { + // $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 +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 + +`ProxyRequestHandler` has been replaced by `ProxyBackend`. Extend it and provide an `LdapClient` instance: + +```php +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\RequestHandler\ProxyBackend; + +$server = (new LdapServer()) + ->useBackend(new ProxyBackend( + (new ClientOptions)->setServers(['ldap.example.com']) + )); +``` + +Or use the convenience factory (unchanged from 0.x): + +```php +$server = LdapServer::makeProxy('ldap.example.com'); +``` diff --git a/composer.json b/composer.json index e50080dd..bb4f7baf 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" }, @@ -29,11 +29,13 @@ "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.", - "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..2b2ec876 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -10,10 +10,13 @@ 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) + * [ServerOptions:setSocketAcceptTimeout](#setsocketaccepttimeout) +* [Backend](#backend) + * [ServerOptions:setBackend](#setbackend) + * [ServerOptions:setFilterEvaluator](#setfilterevaluator) + * [ServerOptions:setRootDseHandler](#setrootdsehandler) + * [ServerOptions:setPasswordAuthenticator](#setpasswordauthenticator) + * [ServerOptions:setBindNameResolver](#setbindnameresolver) * [RootDSE Options](#rootdse-options) * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) * [ServerOptions:setDseAltServer](#setdsealtserver) @@ -36,7 +39,7 @@ use FreeDSx\Ldap\LdapServer; $options = (new ServerOptions) ->setDseAltServer('dc2.local') ->setPort(33389); - + $ldap = new LdapServer($options); ``` @@ -64,7 +67,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` @@ -123,73 +126,191 @@ 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` -## LDAP Protocol Handlers -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). +## Backend + +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\RequestHandler\RequestHandlerInterface`. Server -request operations are then passed to this class along with the request context. +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 — no separate paging handler is needed. -This request handler is used for each client connection. +You can also use the fluent `useBackend()` method on `LdapServer` instead of setting it in `ServerOptions`: + +```php +use FreeDSx\Ldap\LdapServer; +use App\MyDirectoryBackend; + +$server = (new LdapServer()) + ->useBackend(new MyDirectoryBackend()); +``` + +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) + +------------------ +#### setFilterEvaluator + +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). + +```php +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\LdapServer; +use App\MyFilterEvaluator; + +$server = (new LdapServer()) + ->useFilterEvaluator(new MyFilterEvaluator()); +``` + +**Default**: `FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator` +------------------ #### setRootDseHandler -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\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. + +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\MySpecialRootDseHandler; +use App\MyRootDseHandler; $server = new LdapServer( (new ServerOptions) - ->setRootDseHandler(new MySpecialRootDseHandler()) + ->setRootDseHandler(new MyRootDseHandler()) ); ``` **Default**: `null` -#### setPagingHandler +------------------ +#### 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 -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. +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; -use App\MySpecialPagingHandler; $server = new LdapServer( (new ServerOptions) - ->setPagingHandler(new MySpecialPagingHandler()) + ->setPasswordAuthenticator(new ExternalAuthenticator()) ); ``` -**Default**: `null` +**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 + +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 @@ -242,7 +363,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 +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` | `RequestHandlerInterface` (existing `bind()` method) | -| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | `SaslHandlerInterface` | -| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | `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; @@ -278,7 +415,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..fc717896 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -1,436 +1,538 @@ 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) -* [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 Implementations](#built-in-storage-implementations) + * [InMemoryStorage](#inmemorystorage) + * [JsonFileStorage](#jsonfilestorage) + * [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) -* [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 logic for your use +case. Authentication is a separate, independently configurable concern. See [Providing a Backend](#providing-a-backend) +and [Authentication](#authentication) for details. -## Running The Server +## 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. -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. +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\InMemoryStorage; +use FreeDSx\Ldap\ServerOptions; -$server = (new LdapServer())->run(); +$passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); + +$server = new LdapServer( + (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') +); + +$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'), + new Attribute('cn', 'admin'), + new Attribute('userPassword', $passwordHash), + ), +])); + +$server->run(); ``` -### Creating a Proxy Server +Clients bind as `cn=admin,dc=example,dc=com` with password `secret`. No further configuration needed. -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: +### 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\ClientOptions; -use FreeDSx\Ldap\ServerOptions; 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\JsonFileStorage; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\ServerOptions; -$server = LdapServer::makeProxy( - // The LDAP server to proxy connections to... - servers: 'ldap.example.com', - // Any additional LdapClient options for the proxy... - clientOptions: new ClientOptions(), - // Any additional LdapServer options. In this case, run over port 3389 - serverOptions: (new ServerOptions)->setPort(3389), +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->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); $server->run(); ``` -## Handling Client Requests +The custom resolver drives all auth paths: simple bind, SASL PLAIN, and (if enabled) challenge SASL all use it +through the built-in `PasswordAuthenticator`. -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: +--- -* WhoAmI -* StartTLS -* RootDSE -* Unbind +### Challenge SASL with a Custom Authenticator -All other requests are sent to handler you define. The handler must implement `FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface`. -The interface has the following methods: +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: -```php - public function add(RequestContext $context, AddRequest $add); +- `verifyPassword()` is called for simple binds and SASL PLAIN +- `getPassword()` is called for challenge mechanisms (CRAM-MD5, DIGEST-MD5, SCRAM-*) - public function compare(RequestContext $context, CompareRequest $compare) : bool; - - public function delete(RequestContext $context, DeleteRequest $delete); +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; +use FreeDSx\Ldap\ServerOptions; +use SensitiveParameter; - public function extended(RequestContext $context, ExtendedRequest $extended); +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 modify(RequestContext $context, ModifyRequest $modify); + 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); + } +} - public function modifyDn(RequestContext $context, ModifyDnRequest $modifyDn); +$server = new LdapServer( + (new ServerOptions()) + ->setDseNamingContexts('dc=example,dc=com') + ->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_SCRAM_SHA_256, + ) +); - public function search(RequestContext $context, SearchRequest $search) : SearchResult; - - public function bind(string $username, string $password) : bool; +$server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); +$server->usePasswordAuthenticator(new MyAuthenticator()); +$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 +The backend handles directory data (search, writes). The authenticator handles credentials. Neither needs to know +about the other. -**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 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. +## Running The Server -1. Create your own class extending the ProxyRequestHandler: +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 -namespace Foo; - -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; +use FreeDSx\Ldap\LdapServer; -class LdapProxyHandler extends ProxyRequestHandler -{ - /** - * 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') - ); - } -} +$server = (new LdapServer())->run(); ``` -2. Create the server and run it with the request handler above: +## Creating a Proxy Server + +The server can act as a transparent proxy to another LDAP server via `LdapServer::makeProxy()`: ```php +use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use Foo\LdapProxyHandler; -$server = new LdapServer( - (new ServerOptions)->setRequestHandler(new LdapProxyHandler()) +$server = LdapServer::makeProxy( + // The LDAP server to proxy connections to... + servers: 'ldap.example.com', + // Any additional LdapClient options for the proxy... + clientOptions: new ClientOptions(), + // Any additional LdapServer options. In this case, run over port 3389 + serverOptions: (new ServerOptions)->setPort(3389), ); + $server->run(); ``` -### Generic Request Handler +For a customisable proxy, extend `ProxyBackend` directly. See [Proxy Backend](#proxy-backend). + +## Providing a Backend + +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; -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: +$server = (new LdapServer())->useStorage(new InMemoryStorage($entries)); +$server->run(); +``` -1. Create your own class extending the GenericRequestHandler: +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 -namespace Foo; +use FreeDSx\Ldap\LdapServer; + +$server = (new LdapServer())->useBackend(new MyBackend()); +$server->run(); +``` + +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. -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\SearchResult; +Authentication is a **separate concern** handled by `PasswordAuthenticatableInterface`. See [Authentication](#authentication). -class LdapRequestHandler extends GenericRequestHandler +### Read-Only Backend + +`LdapBackendInterface` requires two methods: + +```php +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; + +class MyReadOnlyBackend implements LdapBackendInterface { /** - * @var array + * 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. */ - protected $users = [ - 'user' => '12345', - ]; + public function search(SearchContext $context): Generator + { + // $context->baseDn — Dn: the search base + // $context->scope — int: SearchRequest::SCOPE_BASE_OBJECT | SCOPE_SINGLE_LEVEL | SCOPE_WHOLE_SUBTREE + // $context->filter — FilterInterface: the requested LDAP filter + // $context->attributes — Attribute[]: requested attributes (empty = all) + // $context->typesOnly — bool: return only attribute names, not values + + 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']); + } /** - * Validates the username/password of a simple bind request - * - * @param string $username - * @param string $password - * @return bool + * Return a single entry by DN, or null if it does not exist. + * Used for compare operations and bind name resolution. */ - public function bind(string $username, string $password): bool + public function get(Dn $dn): ?Entry { - return isset($this->users[$username]) && $this->users[$username] === $password; + // ... + return null; } /** - * Override the search request. Return a SearchResult with the entries to send back. - * - * @param RequestContext $context - * @param SearchRequest $search - * @return SearchResult + * 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 search(RequestContext $context, SearchRequest $search): SearchResult - { - 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', - ]) - ) - ); + 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()); } } ``` -2. Create the server and run it with the request handler above: +### 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: ```php -use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Ldap\LdapServer; -use Foo\LdapRequestHandler; +namespace App; -$server = new LdapServer( - (new ServerOptions)->setRequestHandler(new LdapRequestHandler()) -); -``` +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; -## Handling the RootDSE + public function search(SearchContext $context): Generator + { + // yield matching entries... + } -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. + public function get(Dn $dn): ?Entry + { + // ... + return null; + } -An example of using it to proxy RootDSE requests to a different LDAP server... + 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, + ); + } -1. Create a class implementing `FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface`: + $attribute = $entry->get($filter->getAttribute()); -```php -namespace Foo; + return $attribute !== null && $attribute->has($filter->getValue()); + } -use FreeDSx\Ldap\ClientOptions;use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Server\RequestHandler\ProxyRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; + public function add(AddCommand $command): void + { + // $command->entry — Entry to persist + } -class RootDseProxyHandler extends ProxyRequestHandler implements RootDseHandlerInterface -{ - /** - * Set the options for the LdapClient in the constructor. - */ - public function __construct() + public function delete(DeleteCommand $command): void { - parent::__construct( - (new ClientOptions) - ->setServers([ - 'dc1.domain.local', - 'dc2.domain.local', - ]) - ->setBaseDn('dc=domain,dc=local') - ); + // $command->dn — Dn of the entry to remove } - - public function rootDse( - RequestContext $context, - SearchRequest $request, - Entry $rootDse - ): Entry { - return $this->ldap() - ->search( - $request, - ...$context->controls()->toArray() - ) - ->first(); + + public function update(UpdateCommand $command): void + { + // $command->dn — Dn of the entry to modify + // $command->changes — Change[] of attribute changes to apply + } + + public function move(MoveCommand $command): void + { + // $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) } } ``` -2. Use the implemented class when instantiating the LDAP server: +### Built-In Storage Implementations + +Two storage implementations are included for common use cases. Both are used via `useStorage()`. + +#### InMemoryStorage + +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. + +**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\RootDseProxyHandler; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; + +$passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); + +$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->run(); +``` -$server = new LdapServer( - (new ServerOptions)->setRootDseHandler(new RootDseProxyHandler()) -); +#### JsonFileStorage + +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\JsonFileStorage; + +// PCNTL runner — uses flock() to serialise writes across forked processes +$server = (new LdapServer())->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); $server->run(); -``` -The above would pass on a RootDSE request as a proxy and send it back to the client. +// Swoole runner — uses a coroutine Channel mutex and non-blocking file I/O +$server = (new LdapServer())->useStorage(JsonFileStorage::forSwoole('/var/lib/myapp/ldap.json')); +$server->run(); +``` -## Handling Client Paging Requests +JSON format: -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`. +```json +{ + "cn=admin,dc=example,dc=com": { + "dn": "cn=admin,dc=example,dc=com", + "attributes": { + "cn": ["admin"], + "userPassword": ["{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g="] + } + } +} +``` -An example of using it to handle a client paging request... +### Proxy Backend -1. Create a class implementing `FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface`: +`ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server. +Extend it to add custom logic: ```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\ClientOptions; +use FreeDSx\Ldap\Server\RequestHandler\ProxyBackend; -class MyPagingHandler implements PagingHandlerInterface +class MyProxyBackend extends ProxyBackend { - /** - * @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', - ]) + public function __construct() + { + parent::__construct( + (new ClientOptions) + ->setServers(['dc1.domain.local', 'dc2.domain.local']) + ->setBaseDn('dc=domain,dc=local') ); - - // 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. } } ``` -2. Use the implemented class when instantiating the LDAP server: - ```php -use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; -use Foo\MyPagingHandler; - -$server = new LdapServer( - (new ServerOptions)->setPagingHandler(new MyPagingHandler()) -); +use App\MyProxyBackend; +$server = (new LdapServer())->useBackend(new MyProxyBackend()); $server->run(); ``` -## SASL Authentication +### Custom Filter Evaluation -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. +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: ```php -use FreeDSx\Ldap\ServerOptions; use FreeDSx\Ldap\LdapServer; +use App\MyBackend; +use App\MySqlFilterEvaluator; -$server = new LdapServer( - (new ServerOptions) - ->setSaslMechanisms( - ServerOptions::SASL_PLAIN, - ServerOptions::SASL_CRAM_MD5, - ServerOptions::SASL_SCRAM_SHA_256, - ) - ->setRequestHandler(new MyRequestHandler()) -); -``` - -### PLAIN Mechanism +$server = (new LdapServer()) + ->useBackend(new MyBackend()) + ->useFilterEvaluator(new MySqlFilterEvaluator()); -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. +$server->run(); +``` -**Note**: PLAIN transmits credentials in cleartext. Only enable it when the connection is protected by TLS (StartTLS -or `setUseSsl`). +The custom evaluator must implement `FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface`: ```php -namespace Foo; - -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; -class MyRequestHandler extends GenericRequestHandler +class MySqlFilterEvaluator implements FilterEvaluatorInterface { - public function bind(string $username, string $password): bool + public function evaluate(Entry $entry, FilterInterface $filter): bool { - // Called for both simple binds and SASL PLAIN binds. - return $username === 'user' && $password === 'secret'; + // Custom matching logic (e.g. bitwise matching rules for AD compatibility). + return true; } } ``` -### 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. +## Authentication -To support these mechanisms, your request handler must additionally implement -`FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface`: +The `PasswordAuthenticatableInterface` covers all bind types through two methods: ```php -interface SaslHandlerInterface +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 @@ -438,66 +540,175 @@ interface SaslHandlerInterface } ``` -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. +### Default Authentication -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. +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. -Example handler supporting both simple binds and challenge-based SASL: +This means simple bind authentication works out of the box with any backend that stores `userPassword` on entries — +no additional configuration required. -```php -namespace Foo; +### 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`: -use FreeDSx\Ldap\Server\RequestHandler\GenericRequestHandler; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +```php +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Entry\Entry; -class MyRequestHandler extends GenericRequestHandler implements SaslHandlerInterface +class UidBindNameResolver implements BindNameResolverInterface { - private array $users = [ - 'alice' => 'her-plaintext-password', - 'bob' => 'his-plaintext-password', - ]; + 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()) +); +``` - // Used for simple binds and SASL PLAIN. - public function bind(string $username, string $password): bool +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 { - return isset($this->users[$username]) - && $this->users[$username] === $password; + // Your credential verification logic. } - // Used for CRAM-MD5, DIGEST-MD5, and all SCRAM variants. public function getPassword(string $username, string $mechanism): ?string { - return $this->users[$username] ?? null; + // 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()); ``` -Then enable the desired mechanisms on the server: +## Handling the RootDSE + +The server generates a default RootDSE from `ServerOptions` values (`setDseNamingContexts()`, `setDseVendorName()`, +etc.). For most deployments this is sufficient. + +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. + +The simplest way to proxy RootDSE requests is `ProxyHandler`, which bundles `ProxyBackend` with `RootDseHandlerInterface` +in one class and is used by `LdapServer::makeProxy()` automatically. + +For a custom handler: + +```php +namespace App; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Server\RequestContext; +use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; + +class MyRootDseHandler implements RootDseHandlerInterface +{ + 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; + } +} +``` + +Register it with the server: + +```php +use FreeDSx\Ldap\LdapServer; +use App\MyRootDseHandler; + +$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()` — it will be used automatically. + +## SASL Authentication + +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; -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()) + (new ServerOptions)->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_SCRAM_SHA_256, + ) ); - -$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). +All SASL mechanisms are handled through `PasswordAuthenticatableInterface`. No separate interface or backend +modification is needed. + +### PLAIN Mechanism + +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 response against a digest computed from the user's plaintext password. The server calls +`PasswordAuthenticatableInterface::getPassword()` to retrieve the password. + +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. + +**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. + +**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 | |--------------------------------------|---------------------------------| @@ -510,14 +721,10 @@ 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 `bind()`. - ## 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/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/Container.php b/src/FreeDSx/Ldap/Container.php index aa7fdace..ed42b9d3 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,20 @@ private function makeHandlerFactory(): HandlerFactory private function makeServerRunner(): ServerRunnerInterface { + $options = $this->get(ServerOptions::class); + + if ($options->getUseSwooleRunner()) { + return new SwooleServerRunner( + serverProtocolFactory: $this->get(ServerProtocolFactory::class), + options: $options, + socketServerFactory: $this->get(SocketServerFactory::class), + ); + } + return new PcntlServerRunner( serverProtocolFactory: $this->get(ServerProtocolFactory::class), - logger: $this->get(ServerOptions::class)->getLogger(), + options: $options, + socketServerFactory: $this->get(SocketServerFactory::class), ); } 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 87b28d01..7de855ba 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -13,15 +13,16 @@ namespace FreeDSx\Ldap; -use FreeDSx\Ldap\Exception\InvalidArgumentException; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; +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; +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\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; @@ -44,22 +45,15 @@ 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 */ public function run(): void { - $this->validateSaslConfiguration(); - - $socketServer = $this->container - ->get(SocketServerFactory::class) - ->makeAndBind(); - $runner = $this->options->getServerRunner() ?? $this->container->get(ServerRunnerInterface::class); - $runner->run($socketServer); + $runner->run(); } /** @@ -71,11 +65,52 @@ public function getOptions(): ServerOptions } /** - * Specify an instance of a request handler to use for incoming LDAP requests. + * Specify an entry storage implementation to back the LDAP server. + * + * Use useBackend() instead when implementing a fully custom backend (e.g. + * a proxy) that handles LDAP semantics itself. */ - public function useRequestHandler(RequestHandlerInterface $requestHandler): self + public function useStorage(EntryStorageInterface $storage): self { - $this->options->setRequestHandler($requestHandler); + return $this->useBackend(new WritableStorageBackend($storage)); + } + + /** + * Specify a backend to use for incoming LDAP requests. + */ + public function useBackend(LdapBackendInterface $backend): self + { + $this->options->setBackend($backend); + + 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. + */ + public function usePasswordAuthenticator(PasswordAuthenticatableInterface $authenticator): self + { + $this->options->setPasswordAuthenticator($authenticator); + + return $this; + } + + /** + * Register a handler for one or more LDAP write operations. + */ + public function useWriteHandler(WriteHandlerInterface $handler): self + { + $this->options->addWriteHandler($handler); return $this; } @@ -91,11 +126,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; } @@ -111,30 +147,16 @@ public function useLogger(LoggerInterface $logger): self } /** - * Validates that the SASL configuration is consistent before the server starts. + * Configure the server to use the Swoole coroutine runner instead of the default PCNTL process runner. * - * @throws InvalidArgumentException + * 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. */ - private function validateSaslConfiguration(): void + public function useSwooleRunner(): self { - $challengeMechanisms = array_diff( - $this->options->getSaslMechanisms(), - [ServerOptions::SASL_PLAIN], - ); - - if (empty($challengeMechanisms)) { - return; - } + $this->options->setUseSwooleRunner(true); - $handler = $this->options->getRequestHandler(); - - if (!$handler instanceof SaslHandlerInterface) { - throw new InvalidArgumentException(sprintf( - 'The SASL mechanism(s) [%s] require the request handler to implement %s.', - implode(', ', $challengeMechanisms), - SaslHandlerInterface::class, - )); - } + return $this; } /** @@ -155,11 +177,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/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 c93c22ce..cafe0df3 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php @@ -13,10 +13,10 @@ 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\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; use FreeDSx\Sasl\Mechanism\PlainMechanism; @@ -29,24 +29,28 @@ */ final class MechanismOptionsBuilderFactory { + public function __construct( + private readonly PasswordAuthenticatableInterface $authenticator, + ) { + } + /** - * @throws RuntimeException if no builder is registered for the given mechanism. + * @throws OperationException if the mechanism is unsupported. */ - public function make( - string $mechanism, - RequestHandlerInterface $dispatcher, - ): MechanismOptionsBuilderInterface { + public function make(string $mechanism): 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($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" is not supported.', $mechanism), + 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/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 7e39c7ea..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\RequestHandler\RequestHandlerInterface; 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 RequestHandlerInterface $dispatcher, ) { } @@ -50,7 +48,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); $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..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,14 +25,11 @@ 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\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; /** @@ -45,27 +41,17 @@ class SaslBind implements BindInterface { use VersionValidatorTrait; - private readonly SaslExchange $exchange; - /** * @param string[] $mechanisms */ public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + 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 = new MechanismOptionsBuilderFactory(), ) { - $this->exchange = $exchange ?? new SaslExchange( - $this->queue, - $this->responseFactory, - $optionsBuilderFactory, - $this->dispatcher, - ); } /** @@ -131,17 +117,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..e9514684 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ServerProtocolHandlerFactory.php @@ -54,23 +54,41 @@ 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) { 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(), + writeDispatcher: $this->handlerFactory->makeWriteDispatcher(), ); } } + 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) { @@ -102,18 +120,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..42b1f7c6 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -20,12 +20,13 @@ 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\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 +34,9 @@ class ServerDispatchHandler implements ServerProtocolHandlerInterface { public function __construct( private readonly ServerQueue $queue, - private readonly RequestHandlerInterface $dispatcher, + private readonly LdapBackendInterface $backend, + private readonly WriteOperationDispatcher $writeDispatcher, + private readonly WriteCommandFactory $commandFactory = new WriteCommandFactory(), private readonly ResponseFactory $responseFactory = new ResponseFactory(), ) { } @@ -45,30 +48,24 @@ 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 { - throw new OperationException( - 'The requested operation is not supported.', - ResultCode::NO_SUCH_OPERATION - ); + if ($request instanceof Request\CompareRequest) { + $match = $this->backend->compare($request->getDn(), $request->getFilter()); + $this->queue->sendMessage($this->responseFactory->getStandardResponse( + $message, + $match + ? ResultCode::COMPARE_TRUE + : ResultCode::COMPARE_FALSE, + )); + + return; } + $this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request)); + $this->queue->sendMessage($this->responseFactory->getStandardResponse($message)); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 21520ce8..802285e4 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,41 +60,40 @@ 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 - ); - $searchResult = SearchResult::makeSuccessResult( - $response->getEntries(), - (string) $searchRequest->getBaseDn() - ); - $controls[] = new PagingControl( - $response->getRemaining(), - $response->isComplete() - ? '' - : $pagingRequest->getNextCookie() - ); + $response = $this->handlePaging($pagingRequest, $message); + 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(), (string) $searchRequest->getBaseDn(), $e->getMessage() ); - $controls[] = new PagingControl( - 0, - '' - ); + $controls[] = new PagingControl(0, ''); } $pagingRequest->markProcessed(); @@ -103,15 +106,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 +126,47 @@ 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, $sizeLimitExceeded] = $this->collectFromGenerator( + $generator, + $pagingRequest->getSize(), + $context, + 0, + ); + + if ($sizeLimitExceeded) { + return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + } + + $nextCookie = $this->generateCookie(); + $pagingRequest->updateNextCookie($nextCookie); + + if ($generatorExhausted) { + return PagingResponse::makeFinal(new Entries(...$page)); } + + $pagingRequest->incrementTotalSent(count($page)); + $this->requestHistory->storePagingGenerator($nextCookie, $generator); + + return PagingResponse::make(new Entries(...$page), 0); } /** @@ -149,7 +174,6 @@ private function handlePaging( */ private function handleExistingCookie( PagingRequest $pagingRequest, - RequestContext $context, LdapMessageRequest $message ): PagingResponse { $newPagingRequest = $this->makePagingRequest($message); @@ -164,32 +188,97 @@ 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, $sizeLimitExceeded] = $this->collectFromGenerator( + $generator, + $pagingRequest->getSize(), + $context, + $pagingRequest->getTotalSent(), + ); + + if ($sizeLimitExceeded) { + return PagingResponse::makeSizeLimitExceeded(new Entries(...$page)); + } + + $nextCookie = $this->generateCookie(); + $pagingRequest->updateNextCookie($nextCookie); + + if ($generatorExhausted) { + return PagingResponse::makeFinal(new Entries(...$page)); + } + + $pagingRequest->incrementTotalSent(count($page)); + $this->requestHistory->storePagingGenerator($nextCookie, $generator); + + return PagingResponse::make(new Entries(...$page), 0); } /** - * @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 $pageSize entries that pass the filter. + * + * Also enforces the client's sizeLimit from SearchContext. When the sizeLimit is + * reached before the generator is exhausted, $sizeLimitExceeded is true in the return. + * + * @return array{Entry[], bool, bool} [$page, $generatorExhausted, $sizeLimitExceeded] */ - 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 $pageSize, + SearchContext $context, + int $totalAlreadySent, + ): array { + $page = []; + $pageLimit = $pageSize > 0 ? $pageSize : PHP_INT_MAX; + $sizeLimit = $context->sizeLimit; + + 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 + ); + + if ($sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit) { + $generator->next(); + break; + } + } + + $generator->next(); } + + $generatorExhausted = !$generator->valid(); + $sizeLimitExceeded = !$generatorExhausted + && $sizeLimit > 0 + && ($totalAlreadySent + count($page)) >= $sizeLimit; + + return [ + $page, + $generatorExhausted, + $sizeLimitExceeded, + ]; } /** @@ -220,7 +309,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 +342,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..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, ], @@ -66,7 +67,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 +85,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 +93,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..d38b9628 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\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,45 @@ 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; + $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 = $handlerResult->getResultCode() === ResultCode::SUCCESS - ? SearchResult::makeSuccessResult( - $handlerResult->getEntries(), - $baseDn, - $handlerResult->getDiagnosticMessage(), + $entries = new Entries(...$results); + $searchResult = $sizeLimitExceeded + ? SearchResult::makeErrorResult( + ResultCode::SIZE_LIMIT_EXCEEDED, + (string) $request->getBaseDn(), + '', + $entries, ) - : SearchResult::makeErrorResult( - $handlerResult->getResultCode(), - $baseDn, - $handlerResult->getDiagnosticMessage(), - $handlerResult->getEntries(), + : SearchResult::makeSuccessResult( + $entries, + (string) $request->getBaseDn() ); } catch (OperationException $e) { $searchResult = SearchResult::makeErrorResult( $e->getCode(), (string) $request->getBaseDn(), - $e->getMessage() + $e->getMessage(), ); } @@ -80,7 +91,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..c54f72e3 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,71 @@ 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(), + sizeLimit: $request->getSizeLimit(), + timeLimit: $request->getTimeLimit(), + ); + } + + /** + * 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/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/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php new file mode 100644 index 00000000..04f3e3ec --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/BindNameResolverInterface.php @@ -0,0 +1,33 @@ + + * + * 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; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; + +/** + * Translates a raw LDAP bind name into an Entry. + * + * @author Chad Sikorra + */ +interface BindNameResolverInterface +{ + /** + * Resolve the bind name to an {@see Entry}, or return null if no match is found. + */ + 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 new file mode 100644 index 00000000..959e6941 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/NameResolver/DnBindNameResolver.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\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(). + * + * @author Chad Sikorra + */ +final class DnBindNameResolver implements BindNameResolverInterface +{ + public function resolve( + string $name, + LdapBackendInterface $backend, + ): ?Entry { + return $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..ef3fe3f7 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticatableInterface.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\Auth; + +use SensitiveParameter; + +/** + * Implemented by anything that can handle bind authentication. + * + * @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; + + /** + * 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, + string $mechanism, + ): ?string; +} 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..351fdadb --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/PasswordAuthenticator.php @@ -0,0 +1,109 @@ + + * + * 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 FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +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, + private readonly LdapBackendInterface $backend, + ) { + } + + public function verifyPassword( + string $name, + #[SensitiveParameter] + string $password, + ): bool { + $entry = $this->nameResolver->resolve( + $name, + $this->backend + ); + + 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; + } + + 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. + * + * 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..aeb951f7 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/GenericBackend.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 FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +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; + } + + 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 new file mode 100644 index 00000000..c8403f6c --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -0,0 +1,67 @@ + + * + * 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 FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Search\Filter\EqualityFilter; +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; + + /** + * 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/SearchContext.php b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php new file mode 100644 index 00000000..391cdfd8 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/SearchContext.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * Encapsulates the parameters of an LDAP search operation for use by + * LdapBackendInterface::search(). + * + * The backend receives the complete context including the filter. It may + * translate the filter to a native query language (SQL, MongoDB, etc.) or + * ignore it and yield all in-scope entries for the framework to filter. + * + * @author Chad Sikorra + */ +final class SearchContext +{ + /** + * @param Attribute[] $attributes Requested attribute list; empty means all attributes. + */ + public function __construct( + readonly public Dn $baseDn, + readonly public int $scope, + readonly public FilterInterface $filter, + readonly public array $attributes, + readonly public bool $typesOnly, + readonly public int $sizeLimit = 0, + readonly public int $timeLimit = 0, + ) { + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/ArrayEntryStorageTrait.php 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..83ccf28c --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -0,0 +1,88 @@ + + * + * 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()]); + } + + 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 new file mode 100644 index 00000000..d362b5ac --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonEntryBuffer.php @@ -0,0 +1,108 @@ + + * + * 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()]); + } + + public function atomic(callable $operation): void + { + $operation($this); + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php new file mode 100644 index 00000000..5c1a94ae --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -0,0 +1,266 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\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\EntryStorageInterface; +use Generator; + +/** + * A file-backed storage implementation that persists entries as a JSON file. + * + * Use the named constructors to select the appropriate locking strategy: + * + * 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: + * { + * "cn=admin,dc=example,dc=com": { + * "dn": "cn=admin,dc=example,dc=com", + * "attributes": { + * "cn": ["admin"], + * "userPassword": ["{SHA}..."] + * } + * } + * } + * + * @author Chad Sikorra + */ +final class JsonFileStorage implements EntryStorageInterface +{ + use ArrayEntryStorageTrait; + + /** + * @var array|null + */ + private ?array $cache = null; + + private int $cacheMtime = 0; + + 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 find(Dn $dn): ?Entry + { + return $this->read()[$dn->toString()] ?? null; + } + + /** + * @return Generator + */ + public function list(Dn $baseDn, bool $subtree): Generator + { + return $this->yieldByScope( + $this->read(), + $baseDn, + $subtree + ); + } + + public function store(Entry $entry): void + { + $this->withMutation(function (string $contents) use ($entry): string { + $data = $this->decodeContents($contents); + $data[$entry->getDn()->normalize()->toString()] = $this->entryToArray($entry); + + return $this->encodeContents($data); + }); + } + + public function remove(Dn $dn): void + { + $this->withMutation(function (string $contents) use ($dn): string { + $data = $this->decodeContents($contents); + unset($data[$dn->toString()]); + + return $this->encodeContents($data); + }); + } + + public function atomic(callable $operation): void + { + $this->withMutation(function (string $contents) use ($operation): string { + $data = $this->decodeContents($contents); + $buffer = new JsonEntryBuffer( + $data, + $this->arrayToEntry(...), + $this->entryToArray(...), + ); + $operation($buffer); + + return $this->encodeContents($buffer->getData()); + }); + } + + /** + * @param callable(string): string $mutation + */ + private function withMutation(callable $mutation): void + { + try { + $this->lock->withLock($mutation); + } finally { + $this->cache = null; + } + } + + /** + * @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; + } + + $entries = []; + foreach ($this->decodeContents($contents) as $normDn => $data) { + $entries[$normDn] = $this->arrayToEntry($data); + } + + $this->cache = $entries; + $this->cacheMtime = $mtime; + + return $this->cache; + } + + /** + * @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, + ]; + } + + /** + * @return array + */ + private function decodeContents(string $contents): array + { + if ($contents === '') { + return []; + } + + $raw = json_decode($contents, true); + + if (!is_array($raw)) { + return []; + } + + return array_filter($raw, function ($key) { + return is_string($key); + }, ARRAY_FILTER_USE_KEY); + } + + /** + * @param array $data + */ + private function encodeContents(array $data): string + { + return json_encode($data, JSON_UNESCAPED_UNICODE) ?: '{}'; + } + + 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/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/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..1660e563 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/UpdateOperation.php @@ -0,0 +1,222 @@ + + * + * 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\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; + +/** + * Applies attribute changes (ADD / DELETE / REPLACE) to an Entry. + * + * @author Chad Sikorra + */ +final class UpdateOperation +{ + /** + * @throws OperationException + */ + public function execute( + Entry $entry, + UpdateCommand $command, + ): Entry { + foreach ($command->changes as $change) { + 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/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/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php new file mode 100644 index 00000000..f2baebd2 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage; + +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; + + /** + * 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/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php new file mode 100644 index 00000000..9ebe7ca2 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -0,0 +1,342 @@ + + * + * 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\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; +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 => throw new OperationException( + sprintf('Unrecognized filter type: %s', get_class($filter)), + ResultCode::PROTOCOL_ERROR, + ), + }; + } + + 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) { + if ($this->substringMatches(strtolower($value), $startsWith, $endsWith, $contains)) { + return true; + } + } + + return false; + } + + /** + * 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; + } + + // 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); + } + + 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( + 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 => throw new OperationException( + sprintf('Unsupported matching rule: %s', $rule), + ResultCode::INAPPROPRIATE_MATCHING, + ), + }; + } +} 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/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php new file mode 100644 index 00000000..836b1427 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -0,0 +1,270 @@ + + * + * 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\Search\Filter\EqualityFilter; +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. + * + * 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 + */ +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()); + } + + /** + * @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; + } + + /** + * @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) { + $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; + } + } + + /** + * @throws OperationException + */ + public function add(AddCommand $command): void + { + $this->storage->atomic(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->storage->atomic(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->storage->atomic(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->storage->atomic(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, + ); + } +} 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..cf33d9bb --- /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 public ?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/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/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..4fb4afa5 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,81 @@ */ 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; + $nameResolver = $this->options->getBindNameResolver() ?? new DnBindNameResolver(); + + return new PasswordAuthenticator( + $nameResolver, + $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..6573e902 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -0,0 +1,175 @@ + + * + * 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\Search\Filter\EqualityFilter; +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 compare( + Dn $dn, + EqualityFilter $filter, + ): bool { + return $this->ldap()->compare( + $dn, + $filter->getAttribute(), + $filter->getValue(), + ); + } + + 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 getPassword(string $username, string $mechanism): ?string + { + return null; + } + + 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/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/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..1b69db2c 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -15,8 +15,11 @@ 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; @@ -38,23 +41,30 @@ 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), ]; $saslMechanisms = $this->options->getSaslMechanisms(); if (!empty($saslMechanisms)) { + $responseFactory = new ResponseFactory(); $authenticators[] = new SaslBind( queue: $serverQueue, - dispatcher: $requestHandler, + exchange: new SaslExchange( + $serverQueue, + $responseFactory, + new MechanismOptionsBuilderFactory($passwordAuthenticator), + ), sasl: new Sasl(['supported' => $saslMechanisms]), mechanisms: $saslMechanisms, + responseFactory: $responseFactory, ); } diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index ce29e892..05c8122a 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -17,11 +17,14 @@ 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\Ldap\Server\SocketServerFactory; 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,17 +33,7 @@ */ 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; + use ServerRunnerLoggerTrait; private SocketServer $server; @@ -72,7 +65,8 @@ class PcntlServerRunner implements ServerRunnerInterface */ public function __construct( private readonly ServerProtocolFactory $serverProtocolFactory, - private readonly ?LoggerInterface $logger, + private readonly ServerOptions $options, + private readonly SocketServerFactory $socketServerFactory, ) { if (!extension_loaded('pcntl')) { throw new RuntimeException( @@ -99,12 +93,16 @@ 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(); + } catch (Throwable $e) { + $this->logAcceptError($e, $this->defaultContext); + + throw $e; } finally { if ($this->isMainProcess) { $this->handleServerShutdown(); @@ -149,20 +147,15 @@ 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 { - $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT); + $socket = $this->server->accept($this->options->getSocketAcceptTimeout()); 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(); } @@ -176,6 +169,16 @@ private function acceptClients(): void continue; } + $maxConnections = $this->options->getMaxConnections(); + if ($maxConnections > 0 && count($this->childProcesses) >= $maxConnections) { + $this->logConnectionLimitReached($this->defaultContext); + + $this->server->removeClient($socket); + $socket->close(); + + continue; + } + $pid = pcntl_fork(); if ($pid == -1) { // In parent process, but could not fork... @@ -225,7 +228,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); + } } ); } @@ -263,10 +270,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) { @@ -279,8 +283,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; @@ -295,10 +299,7 @@ private function handleServerShutdown(): void } $this->server->close(); - $this->logInfo( - 'The server shutdown process has completed.', - $this->defaultContext - ); + $this->logShutdownCompleted($this->defaultContext); } /** @@ -382,11 +383,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(); @@ -413,4 +413,36 @@ private function forceEndChildProcesses(): void ); $this->cleanUpChildProcesses(); } + + private function getRunnerLogger(): ?LoggerInterface + { + return $this->options->getLogger(); + } + + /** + * @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/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/ServerRunnerLoggerTrait.php b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php new file mode 100644 index 00000000..ac3ba01e --- /dev/null +++ b/src/FreeDSx/Ldap/Server/ServerRunner/ServerRunnerLoggerTrait.php @@ -0,0 +1,180 @@ + + * + * 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 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 + */ + private function logConnectionLimitReached(array $context = []): void + { + $this->getRunnerLogger()?->log( + LogLevel::WARNING, + 'Connection limit reached, dropping new connection.', + $context, + ); + } + + /** + * @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 + */ + 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 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 + */ + private function logShutdownNotifyError( + Throwable $e, + array $context = [], + ): void { + $this->getRunnerLogger()?->log( + LogLevel::WARNING, + 'Unexpected error while notifying client of shutdown.', + 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 new file mode 100644 index 00000000..2e0aa5e6 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -0,0 +1,217 @@ + + * + * 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\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\SocketServerFactory; +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; + +/** + * 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 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. + * + * @author Chad Sikorra + */ +class SwooleServerRunner implements ServerRunnerInterface +{ + use ServerRunnerLoggerTrait; + + 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 = []; + + /** + * 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, + private readonly SocketServerFactory $socketServerFactory, + ) { + 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+)' + ); + } + + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + $this->waitGroup = new WaitGroup(); + } + + public function run(): void + { + Coroutine\run(function (): void { + $this->server = $this->socketServerFactory->makeAndBind(); + $this->registerShutdownSignals(); + $this->acceptClients(); + }); + + $this->logShutdownCompleted(); + } + + private function getRunnerLogger(): ?LoggerInterface + { + return $this->options->getLogger(); + } + + 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->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 { + if (empty($this->activeSockets)) { + return; + } + + $allClosed = $this->waitGroup->wait((float) $this->options->getShutdownTimeout()); + + if ($allClosed) { + return; + } + + $this->logShutdownForceClose(count($this->activeSockets)); + + foreach ($this->activeSockets as $socket) { + $socket->close(); + } + }); + } + + private function acceptClients(): void + { + $this->logServerStarted(); + $this->options->getOnServerReady()?->__invoke(); + + while (!$this->isShuttingDown) { + try { + $socket = $this->server->accept($this->options->getSocketAcceptTimeout()); + } catch (Throwable $e) { + $this->logAcceptError($e); + + break; + } + + if ($socket === null) { + continue; + } + + $maxConnections = $this->options->getMaxConnections(); + if ($maxConnections > 0 && count($this->activeSockets) >= $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, $socketId); + } finally { + $this->server->removeClient($socket); + unset($this->activeSockets[$socketId]); + unset($this->activeHandlers[$socketId]); + $this->waitGroup->done(); + } + }); + } + + $this->getRunnerLogger()?->info('Accept loop ended, draining active connections.'); + } + + 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->logClientError($e); + } finally { + $this->logClientClosed(); + $socket->close(); + } + } +} diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index cdcc93f6..5635e9b5 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -13,11 +13,15 @@ namespace FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\InvalidArgumentException; -use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +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; 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 @@ -94,6 +98,8 @@ final class ServerOptions private ?string $dseAltServer = null; + private ?Dn $subschemaEntry = null; + /** * @var string[] */ @@ -103,16 +109,35 @@ final class ServerOptions private ?string $dseVendorVersion = null; - private ?RequestHandlerInterface $requestHandler = null; + private ?LdapBackendInterface $backend = null; + + private ?PasswordAuthenticatableInterface $passwordAuthenticator = null; + + private ?BindNameResolverInterface $bindNameResolver = 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; + + private int $maxConnections = 0; + + private ?\Closure $onServerReady = null; + + private int $shutdownTimeout = 15; + + private float $socketAcceptTimeout = 0.5; + /** * @var string[] */ @@ -262,6 +287,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[] */ @@ -301,14 +338,38 @@ 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->passwordAuthenticator; + } + + public function setPasswordAuthenticator(?PasswordAuthenticatableInterface $passwordAuthenticator): self + { + $this->passwordAuthenticator = $passwordAuthenticator; + + return $this; + } + + public function getBindNameResolver(): ?BindNameResolverInterface { - return $this->requestHandler; + return $this->bindNameResolver; } - public function setRequestHandler(?RequestHandlerInterface $requestHandler): self + public function setBindNameResolver(?BindNameResolverInterface $bindNameResolver): self { - $this->requestHandler = $requestHandler; + $this->bindNameResolver = $bindNameResolver; return $this; } @@ -325,14 +386,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 +462,79 @@ public function getServerRunner(): ?ServerRunnerInterface return $this->serverRunner; } + public function setUseSwooleRunner(bool $use): self + { + $this->useSwooleRunner = $use; + + 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; + } + + /** + * 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; + } + + 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, 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 +546,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/ldap-backend-storage.php b/tests/bin/ldap-backend-storage.php new file mode 100644 index 00000000..e0d77dd5 --- /dev/null +++ b/tests/bin/ldap-backend-storage.php @@ -0,0 +1,79 @@ +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'; + + if (file_exists($filePath)) { + unlink($filePath); + } + + $storage = JsonFileStorage::forPcntl($filePath); + + foreach ($entries as $entry) { + $storage->store($entry); + } + + $server->useStorage($storage); +} else { + $server->useStorage(new InMemoryStorage($entries)); +} + +if ($handler === 'swoole') { + $server->useSwooleRunner(); +} + +$server->run(); 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/ldap-server.php b/tests/bin/ldap-server.php new file mode 100644 index 00000000..5c2e9dad --- /dev/null +++ b/tests/bin/ldap-server.php @@ -0,0 +1,211 @@ + + */ + private array $users = [ + 'cn=user,dc=foo,dc=bar' => '12345', + ]; + + 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 get(Dn $dn): ?Entry + { + return Entry::fromArray( + $dn->toString(), + [ + 'foo' => 'bar', + ] + ); + } + + 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, + ): bool { + + $this->logRequest( + 'bind', + "username => $name, password => $password" + ); + + return isset($this->users[$name]) && $this->users[$name] === $password; + } + + public function add(AddCommand $command): void + { + $entry = $command->entry; + $attrLog = []; + foreach ($entry->getAttributes() as $attribute) { + $attrLog[] = "{$attribute->getName()} => " . implode(', ', $attribute->getValues()); + } + + $this->logRequest( + 'add', + "dn => {$entry->getDn()->toString()}, Attributes: " . implode(', ', $attrLog) + ); + } + + public function delete(DeleteCommand $command): void + { + $this->logRequest('delete', "dn => {$command->dn->toString()}"); + } + + public function update(UpdateCommand $command): void + { + $modLog = []; + foreach ($command->changes as $change) { + $attribute = $change->getAttribute(); + $modLog[] = "({$change->getType()}){$attribute->getName()} => " . implode( + ', ', + $attribute->getValues() + ); + } + + $this->logRequest( + 'modify', + "dn => {$command->dn->toString()}, Changes: " . implode(', ', $modLog) + ); + } + + public function move(MoveCommand $command): void + { + $dnLog = 'ParentDn => ' . $command->newParent?->toString(); + $dnLog .= ', ParentRdn => ' . $command->newRdn->toString(); + + $this->logRequest('modify-dn', "dn => {$command->dn->toString()}, $dnLog"); + } + + public function getPassword( + string $username, + string $mechanism, + ): ?string { + return $this->users[$username] ?? null; + } + + 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'; + +$transport = $argv[1] ?? 'tcp'; +$handler = $argv[2] ?? null; +$useSsl = false; + +if ($transport === 'ssl') { + $transport = 'tcp'; + $useSsl = true; +} + +$backend = $handler === 'paging' + ? new LdapServerPagingBackend() + : new LdapServerBackend(); + +$options = (new ServerOptions()) + ->setPort(10389) + ->setTransport($transport) + ->setUnixSocket(sys_get_temp_dir() . '/ldap.socket') + ->setSslCert($sslCert) + ->setSslCertKey($sslKey) + ->setUseSsl($useSsl) + ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); + +if ($handler === 'sasl') { + $options->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_CRAM_MD5, + ServerOptions::SASL_SCRAM_SHA_256, + ); +} + +$server = (new LdapServer($options))->useBackend($backend); + +$server->run(); 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/bin/ldapserver.php b/tests/bin/ldapserver.php deleted file mode 100644 index 52914d54..00000000 --- a/tests/bin/ldapserver.php +++ /dev/null @@ -1,244 +0,0 @@ -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; - } -} - -class LdapServerRequestHandler extends GenericRequestHandler implements SaslHandlerInterface -{ - /** - * @var array - */ - private array $users = [ - 'cn=user,dc=foo,dc=bar' => '12345', - ]; - - public function getPassword( - string $username, - string $mechanism, - ): ?string { - return $this->users[$username] ?? null; - } - - public function bind(string $username, string $password): bool - { - $this->logRequest( - 'bind', - "username => $username, password => $password" - ); - - return isset($this->users[$username]) - && $this->users[$username] === $password; - } - - public function add(RequestContext $context, AddRequest $add): void - { - $attrLog = []; - foreach ($add->getEntry()->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()}" - ); - - return true; - } - - public function delete(RequestContext $context, DeleteRequest $delete): void - { - $this->logRequest( - 'delete', - "dn => {$delete->getDn()->toString()}" - ); - } - - public function modify(RequestContext $context, ModifyRequest $modify): void - { - $modLog = []; - foreach ($modify->getChanges() 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" - ); - } - - public function modifyDn(RequestContext $context, ModifyDnRequest $modifyDn): void - { - $dnLog = 'ParentDn => ' . $modifyDn->getNewParentDn()?->toString(); - $dnLog .= ', ParentRdn => ' . $modifyDn->getNewRdn()->toString(); - - $this->logRequest( - 'modify-dn', - "dn => {$modifyDn->getDn()->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', - ] - ) - ) - ); - } - - private function logRequest( - string $type, - string $message - ): void { - echo "---$type--- $message" . PHP_EOL; - } -} - -$sslKey = __DIR__ . '/../resources/cert/slapd.key'; -$sslCert = __DIR__ . '/../resources/cert/slapd.crt'; - -$transport = $argv[1] ?? 'tcp'; -$handler = $argv[2] ?? null; -$useSsl = false; - -if ($transport === 'ssl') { - $transport = 'tcp'; - $useSsl = true; -} - -$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 - ); - -if ($handler === 'sasl') { - $options->setSaslMechanisms( - ServerOptions::SASL_PLAIN, - ServerOptions::SASL_CRAM_MD5, - ServerOptions::SASL_SCRAM_SHA_256, - ); -} - -$server = new LdapServer($options); - -echo "server starting..." . PHP_EOL; - -$server->run(); 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 f1604a5a..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(); } @@ -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 @@ -221,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', @@ -231,6 +222,9 @@ public function testItCanRetrieveTheRootDSE(): void 'vendorName' => [ 'FreeDSx', ], + 'supportedControl' => [ + '1.2.840.113556.1.4.319', + ], ], $rootDse->toArray() ); @@ -309,49 +303,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..56466936 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. */ @@ -32,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'; @@ -53,7 +55,7 @@ class ServerTestCase extends LdapTestCase */ private bool $needsSharedRestart = false; - private string $serverMode = 'ldapserver'; + private string $serverMode = 'ldap-server'; public function setUp(): void { @@ -75,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) { @@ -135,13 +141,21 @@ 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); } 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) { @@ -200,15 +214,52 @@ 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; } - private function buildClient(string $transport): LdapClient + /** + * Probes localhost:$port until a TCP connection succeeds or the timeout + * expires. Called after waitForProcess() because the server bootstrap + * script echoes "server starting..." before the socket is bound, so the + * process output marker alone is not sufficient to guarantee readiness. + */ + private static function waitForPortOpen(int $port): void + { + $deadline = microtime(true) + self::SERVER_MAX_WAIT_SECONDS; + + while (microtime(true) < $deadline) { + $socket = @fsockopen( + '127.0.0.1', + $port, + $errno, + $errstr, + 0.1 + ); + + if ($socket !== false) { + fclose($socket); + usleep(100_000); // 100ms stabilization after successful probe + + return; + } + + usleep(self::SERVER_POLL_INTERVAL_US); + } + + throw new Exception(sprintf( + 'Port %d was not ready after %d seconds.', + $port, + self::SERVER_MAX_WAIT_SECONDS, + )); + } + + protected function buildClient(string $transport): LdapClient { $useSsl = false; $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..40ea2ca5 --- /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 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 + * 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('ldap-backend-storage', '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/Storage/LdapBackendStorageSwooleTest.php b/tests/integration/Storage/LdapBackendStorageSwooleTest.php new file mode 100644 index 00000000..ff46bc01 --- /dev/null +++ b/tests/integration/Storage/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\Storage; + +/** + * 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( + 'ldap-backend-storage', + '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(); + + 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'); + } + } +} diff --git a/tests/integration/Storage/LdapBackendStorageTest.php b/tests/integration/Storage/LdapBackendStorageTest.php new file mode 100644 index 00000000..9feb7bc8 --- /dev/null +++ b/tests/integration/Storage/LdapBackendStorageTest.php @@ -0,0 +1,329 @@ + + * + * 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\Exception\BindException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; + +class LdapBackendStorageTest extends ServerTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer('ldap-backend-storage', 'tcp'); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + } + + public function setUp(): void + { + $this->setServerMode('ldap-backend-storage'); + + 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); + } + + 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/integration/Sync/SyncReplTest.php b/tests/integration/Sync/SyncReplTest.php index 31ef83fc..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); @@ -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, )); } } 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 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/LdapServerTest.php b/tests/unit/LdapServerTest.php index 489ccd40..967c873b 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -13,29 +13,19 @@ 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\RequestHandler\PagingHandlerInterface; +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\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; use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Socket\SocketServer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -/** - * Combined interface so PHPUnit can mock both at once. - */ -interface SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} - class LdapServerTest extends TestCase { private LdapServer $subject; @@ -59,22 +49,20 @@ 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(); } - 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 +74,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 +86,7 @@ public function test_it_should_use_the_logger_specified(): void self::assertSame( $logger, - $this->subject->getOptions() - ->getLogger() + $this->subject->getOptions()->getLogger() ); } @@ -121,16 +94,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 +110,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,80 +120,69 @@ 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_does_not_throw_for_sasl_mechanisms_without_a_sasl_backend(): void { - $this->options->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5); + $this->mockServerRunner->method('run'); - self::expectException(InvalidArgumentException::class); + $this->options->setSaslMechanisms(ServerOptions::SASL_PLAIN); $this->subject->run(); + + $this->expectNotToPerformAssertions(); } - public function test_it_throws_when_challenge_mechanisms_are_configured_with_a_non_sasl_handler(): void + public function test_it_should_use_the_password_authenticator_specified(): void { - $this->options - ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setRequestHandler($this->createMock(RequestHandlerInterface::class)); + $authenticator = $this->createMock(PasswordAuthenticatableInterface::class); - self::expectException(InvalidArgumentException::class); + $this->subject->usePasswordAuthenticator($authenticator); - $this->subject->run(); + self::assertSame( + $authenticator, + $this->subject->getOptions()->getPasswordAuthenticator() + ); } - public function test_it_does_not_throw_when_challenge_mechanisms_are_configured_with_a_sasl_handler(): void + public function test_it_should_use_the_write_handler_specified(): void { - /** @var RequestHandlerInterface&SaslHandlerInterface&MockObject $mockSaslHandler */ - $mockSaslHandler = $this->createMock(SaslRequestHandlerInterface::class); + $handler = $this->createMock(WriteHandlerInterface::class); - $this->mockServerRunner->method('run'); + $this->subject->useWriteHandler($handler); - $this->options - ->setSaslMechanisms(ServerOptions::SASL_CRAM_MD5) - ->setRequestHandler($mockSaslHandler); - - $this->subject->run(); - - $this->expectNotToPerformAssertions(); + self::assertContains( + $handler, + $this->subject->getOptions()->getWriteHandlers() + ); } - public function test_it_does_not_throw_for_plain_mechanism_without_a_sasl_handler(): void + public function test_it_should_use_the_filter_evaluator_specified(): void { - $this->mockServerRunner->method('run'); + $evaluator = $this->createMock(FilterEvaluatorInterface::class); - $this->options->setSaslMechanisms(ServerOptions::SASL_PLAIN); + $this->subject->useFilterEvaluator($evaluator); - $this->subject->run(); + self::assertSame( + $evaluator, + $this->subject->getOptions()->getFilterEvaluator() + ); + } - $this->expectNotToPerformAssertions(); + 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 { - $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() - ); - self::assertInstanceOf( - ProxyRequestHandler::class, - $proxyOptions->getRootDseHandler(), + ProxyHandler::class, + $proxyOptions->getBackend() ); + + self::assertNull($proxyOptions->getRootDseHandler()); } } 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 3029a280..3473fe6a 100644 --- a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactoryTest.php @@ -13,88 +13,66 @@ 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\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -/** - * Combined interface so PHPUnit can mock both at once. - */ -interface SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} - final class MechanismOptionsBuilderFactoryTest extends TestCase { - private MechanismOptionsBuilderFactory $subject; + private PasswordAuthenticatableInterface&MockObject $mockAuthenticator; - private RequestHandlerInterface&MockObject $mockDispatcher; - - private SaslRequestHandlerInterface&MockObject $mockSaslDispatcher; + private MechanismOptionsBuilderFactory $subject; protected function setUp(): void { - $this->subject = new MechanismOptionsBuilderFactory(); - $this->mockDispatcher = $this->createMock(RequestHandlerInterface::class); - $this->mockSaslDispatcher = $this->createMock(SaslRequestHandlerInterface::class); - } - - public function test_make_plain_with_non_sasl_dispatcher_returns_plain_builder(): void - { - self::assertInstanceOf( - PlainMechanismOptionsBuilder::class, - $this->subject->make('PLAIN', $this->mockDispatcher) - ); + $this->mockAuthenticator = $this->createMock(PasswordAuthenticatableInterface::class); + $this->subject = new MechanismOptionsBuilderFactory($this->mockAuthenticator); } - public function test_make_plain_with_sasl_dispatcher_returns_plain_builder(): void + public function test_make_plain_returns_plain_builder(): void { self::assertInstanceOf( PlainMechanismOptionsBuilder::class, - $this->subject->make('PLAIN', $this->mockSaslDispatcher) + $this->subject->make('PLAIN') ); } - public function test_make_cram_md5_with_sasl_dispatcher_returns_cram_md5_builder(): void + public function test_make_cram_md5_returns_cram_md5_builder(): void { self::assertInstanceOf( CramMD5MechanismOptionsBuilder::class, - $this->subject->make('CRAM-MD5', $this->mockSaslDispatcher) + $this->subject->make('CRAM-MD5') ); } - public function test_make_digest_md5_with_sasl_dispatcher_returns_digest_md5_builder(): void + public function test_make_digest_md5_returns_digest_md5_builder(): void { self::assertInstanceOf( DigestMD5MechanismOptionsBuilder::class, - $this->subject->make('DIGEST-MD5', $this->mockSaslDispatcher) + $this->subject->make('DIGEST-MD5') ); } - public function test_make_scram_sha256_with_sasl_dispatcher_returns_scram_builder(): void + public function test_make_scram_sha256_returns_scram_builder(): void { self::assertInstanceOf( ScramMechanismOptionsBuilder::class, - $this->subject->make('SCRAM-SHA-256', $this->mockSaslDispatcher) + $this->subject->make('SCRAM-SHA-256') ); } - public function test_make_cram_md5_with_non_sasl_dispatcher_throws(): void - { - self::expectException(RuntimeException::class); - - $this->subject->make('CRAM-MD5', $this->mockDispatcher); - } - public function test_make_unknown_mechanism_throws(): void { - self::expectException(RuntimeException::class); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::OTHER); - $this->subject->make('UNKNOWN', $this->mockSaslDispatcher); + $this->subject->make('UNKNOWN'); } } 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/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 7c88a6be..497a21c6 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,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 RequestHandlerInterface, - * 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,12 @@ 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(), - dispatcher: $this->createMock(RequestHandlerInterface::class), + optionsBuilderFactory: new MechanismOptionsBuilderFactory($mockAuthenticator), ); } diff --git a/tests/unit/Protocol/Bind/SaslBindTest.php b/tests/unit/Protocol/Bind/SaslBindTest.php index 908b99bb..6f902ede 100644 --- a/tests/unit/Protocol/Bind/SaslBindTest.php +++ b/tests/unit/Protocol/Bind/SaslBindTest.php @@ -19,37 +19,38 @@ 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\RequestHandler\RequestHandlerInterface; -use FreeDSx\Ldap\Server\RequestHandler\SaslHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; 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 SaslRequestHandlerInterface extends RequestHandlerInterface, SaslHandlerInterface {} - final class SaslBindTest extends TestCase { private SaslBind $subject; 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, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_PLAIN], ); } @@ -104,35 +105,14 @@ 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, - dispatcher: $this->mockDispatcher, - mechanisms: [ServerOptions::SASL_CRAM_MD5], - ); - - $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) $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 +133,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 +154,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); - $subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $mockSaslDispatcher, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_CRAM_MD5], ); // 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 + $this->mockAuthenticator ->method('getPassword') ->willReturn('correctpassword'); @@ -231,12 +212,13 @@ 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); - $subject = new SaslBind( queue: $this->mockQueue, - dispatcher: $mockSaslDispatcher, + exchange: new SaslExchange( + $this->mockQueue, + new ResponseFactory(), + new MechanismOptionsBuilderFactory($this->mockAuthenticator), + ), mechanisms: [ServerOptions::SASL_CRAM_MD5], ); 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..223bc2b3 100644 --- a/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php +++ b/tests/unit/Protocol/Factory/ServerProtocolHandlerFactoryTest.php @@ -22,17 +22,18 @@ 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\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\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\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -50,6 +51,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 +95,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 +104,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,36 +117,24 @@ 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 + public function test_it_should_get_a_root_dse_handler(): 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, + ServerRootDseHandler::class, $this->subject->get( - Operations::list(new EqualityFilter('foo', 'bar'), 'cn=foo'), - $controls - ) + Operations::read(''), + new ControlBag() + ), ); } - public function test_it_should_get_a_root_dse_handler(): void + public function test_it_should_get_a_subschema_handler(): void { self::assertInstanceOf( - ServerRootDseHandler::class, + ServerSubschemaHandler::class, $this->subject->get( - Operations::read(''), - new ControlBag() + Operations::read('cn=Subschema'), + new ControlBag(), ), ); } @@ -162,10 +152,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..2e4f2f53 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -13,21 +13,23 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; -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\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\Filter\EqualityFilter; use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +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 +38,9 @@ final class ServerDispatchHandlerTest extends TestCase { private ServerDispatchHandler $subject; - private RequestHandlerInterface&MockObject $mockRequestHandler; + private LdapBackendInterface&MockObject $mockBackend; + + private WriteHandlerInterface&MockObject $mockWriteHandler; private ServerQueue&MockObject $mockQueue; @@ -46,118 +50,126 @@ 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->mockQueue ->method('sendMessage') ->willReturnSelf(); + $this->mockWriteHandler + ->method('supports') + ->willReturn(true); + $this->subject = new ServerDispatchHandler( - $this->mockQueue, - $this->mockRequestHandler, + queue: $this->mockQueue, + backend: $this->mockBackend, + 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_delegates_compare_to_the_backend(): void { - $modify = new LdapMessageRequest(1, new ModifyRequest('cn=foo,dc=bar', Change::add('foo', 'bar'))); + $filter = Filters::equal('foo', 'bar'); + $compare = new LdapMessageRequest(1, new CompareRequest('cn=foo,dc=bar', $filter)); - $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('compare') + ->with( + self::isInstanceOf(Dn::class), + self::isInstanceOf(EqualityFilter::class), + ) + ->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_propagates_operation_exceptions_from_backend_compare(): 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('compare') + ->willThrowException(new OperationException( + 'No such object: cn=foo,dc=bar', + ResultCode::NO_SUCH_OBJECT, + )); - $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, + writeDispatcher: new WriteOperationDispatcher(), + ); - $this->mockRequestHandler - ->expects($this->once()) - ->method('extended') - ->with(self::anything(), $ext->getRequest()); + $add = new LdapMessageRequest(1, new AddRequest(Entry::create('cn=foo,dc=bar'))); - $this->subject->handleRequest( - $ext, - $this->mockToken, - ); + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); + + $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..ea72c3ba 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 + { + yield from $entries; + } + + public function test_it_should_call_the_backend_search_on_paging_start_and_return_entries(): void { - $message = $this->makeSearchMessage(); + $message = $this->makeSearchMessage(size: 10); - $entries = new Entries( - Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), - Entry::create('dc=bar,dc=foo', ['cn' => 'bar']) - ); + $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'])) - ); - - $response = PagingResponse::makeFinal($entries); + // Request only 1 entry, but backend yields 2, so generator is NOT exhausted. + $message = $this->makeSearchMessage(size: 1); - $this->mockPagingHandler - ->expects($this->once()) - ->method('page') - ->willReturn($response); + $entry1 = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); + $entry2 = Entry::create('dc=bar,dc=foo', ['cn' => 'bar']); - $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( @@ -229,12 +259,55 @@ 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( - 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.")); @@ -245,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', @@ -262,6 +424,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..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; @@ -25,8 +26,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 +44,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); @@ -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, ], @@ -94,12 +96,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 +129,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 @@ -146,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, ], @@ -234,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' => [], @@ -247,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/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index baca0029..da2a3a5e 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']); + + $search = new LdapMessageRequest( + 2, + (new SearchRequest(Filters::equal('foo', 'bar')))->base('dc=foo,dc=bar') + ); - $this->mockRequestHandler - ->expects($this->once()) + $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,93 @@ 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_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') + (new SearchRequest(Filters::equal('foo', 'bar'))) + ->base('dc=foo,dc=bar') + ->sizeLimit(1) ); - $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($entry1, $entry2)); + + $this->mockFilterEvaluator + ->method('evaluate') + ->willReturn(true); $this->mockQueue - ->expects($this->once()) + ->expects(self::once()) ->method('sendMessage') ->with( - $resultEntry, + new LdapMessageResponse(2, new SearchResultEntry($entry1)), new LdapMessageResponse( 2, - new SearchResultDone( - ResultCode::SIZE_LIMIT_EXCEEDED, - 'dc=foo,dc=bar', - 'Result set truncated.', - ), + new SearchResultDone(ResultCode::SIZE_LIMIT_EXCEEDED, 'dc=foo,dc=bar') ), ); - $this->subject->handleRequest( - $search, - $this->mockToken, + $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_pass_response_controls_from_the_handler_result_to_the_client(): 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(); - $control = new Control('1.2.3.4'); - - $this->mockRequestHandler - ->expects($this->once()) + $this->mockBackend + ->expects(self::once()) ->method('search') - ->willReturn(SearchResult::make($entries, $control)); + ->willReturn($this->makeGenerator()); $this->mockQueue - ->expects($this->once()) + ->expects(self::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/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); + } +} 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..90668f6e --- /dev/null +++ b/tests/unit/Server/Backend/Auth/NameResolver/DnBindNameResolverTest.php @@ -0,0 +1,69 @@ + + * + * 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(); + $result = $subject->resolve( + 'cn=Alice,dc=example,dc=com', + $this->mockBackend + ); + + self::assertSame( + $entry, + $result + ); + } + + public function test_resolve_returns_null_when_entry_not_found(): void + { + $this->mockBackend + ->method('get') + ->willReturn(null); + + $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 new file mode 100644 index 00000000..c2fcfbe2 --- /dev/null +++ b/tests/unit/Server/Backend/Auth/NameResolver/PasswordAuthenticatorTest.php @@ -0,0 +1,246 @@ + + * + * 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 FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +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, + $this->mockBackend + ); + } + + 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') + ); + } + + 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') + ); + } + + 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') + ); + } +} 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/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/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..e261d929 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/UpdateOperationTest.php @@ -0,0 +1,301 @@ + + * + * 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\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; + +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, 'userPassword', 'newpassword')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + ['newpassword'], + $result->get('userPassword')?->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, 'mail', 'new@example.com'), + new Change(Change::TYPE_ADD, 'mail', 'alice@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_returns_the_same_entry_instance(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], + ); + + $result = $this->subject->execute($this->entry, $command); + + self::assertSame( + $this->entry, + $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/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php new file mode 100644 index 00000000..e16b9439 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -0,0 +1,524 @@ + + * + * 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\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; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; +use PHPUnit\Framework\TestCase; + +final class WritableStorageBackendTest extends TestCase +{ + private WritableStorageBackend $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 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(), + ); + } + + 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_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')); + $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); + + $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); + 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_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_attribute_value(): void + { + $this->subject->update(new UpdateCommand( + new Dn('cn=Alice,dc=example,dc=com'), + [new Change(Change::TYPE_REPLACE, 'userPassword', 'newpassword')], + )); + + $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertSame( + ['newpassword'], + $entry->get('userPassword')?->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_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_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_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 + { + // 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_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))); + } + + 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, 'userPassword', 'newpassword')], + )); + + self::assertSame( + ['newpassword'], + $this->subject->get(new Dn('cn=Alice,dc=example,dc=com'))?->get('userPassword')?->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/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php new file mode 100644 index 00000000..39c7814a --- /dev/null +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -0,0 +1,527 @@ + + * + * 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\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; +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_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')) + ->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_throws_inappropriate_matching(): void + { + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::INAPPROPRIATE_MATCHING); + + $this->subject->evaluate( + $this->entry, + new MatchingRuleFilter('1.2.3.4.5.unknown', 'cn', 'Alice'), + ); + } + + 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_throws_protocol_error(): void + { + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::PROTOCOL_ERROR); + + $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..cd2d1ccb --- /dev/null +++ b/tests/unit/Server/ServerRunner/SwooleServerRunnerTest.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 Tests\Unit\FreeDSx\Ldap\Server\ServerRunner; + +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; + +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( + serverProtocolFactory: $this->createMock(ServerProtocolFactory::class), + options: new ServerOptions(), + socketServerFactory: $this->createMock(SocketServerFactory::class), + ); + } +} diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index ae2f7e93..923eaafd 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -13,9 +13,18 @@ namespace Tests\Unit\FreeDSx\Ldap; +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; +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 { @@ -61,6 +70,471 @@ 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_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_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()); + } + + 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_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()); + } + + 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(