From f57806c41f8b0b8f00fe25a770b3b833833edabc Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 17:11:49 -0400 Subject: [PATCH 1/6] Don't interpret SIGHUP as shutdown. Server reload on SIGHUP is still a TODO. --- .../Server/ServerRunner/PcntlServerRunner.php | 15 ++++++- .../ServerRunner/SwooleServerRunner.php | 13 ++++++ tests/integration/LdapServerTest.php | 43 +++++++++++++++++++ tests/integration/ServerTestCase.php | 24 +++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 5e05f053..e75009c8 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -82,7 +82,6 @@ public function __construct( pcntl_async_signals(true); $this->handledSignals = [ - SIGHUP, SIGINT, SIGTERM, SIGQUIT, @@ -239,6 +238,11 @@ function () use ($protocolHandler, $context) { }, ); } + // Children don't reload config; ignore SIGHUP so terminal hangups don't kill them. + pcntl_signal( + SIGHUP, + SIG_IGN, + ); } /** @@ -254,6 +258,15 @@ function () { }, ); } + pcntl_signal( + SIGHUP, + function () { + $this->logInfo( + 'Received SIGHUP. Configuration reload is not yet implemented.', + $this->defaultContext, + ); + }, + ); } /** diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 28b73eb6..4dc6d3d6 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -110,6 +110,19 @@ private function registerShutdownSignals(): void Process::signal(SIGTERM, $this->handleShutdownSignal(...)); Process::signal(SIGINT, $this->handleShutdownSignal(...)); Process::signal(SIGQUIT, $this->handleShutdownSignal(...)); + // SIGHUP is reserved for configuration reload, not shutdown. Stub it. + Process::signal( + SIGHUP, + $this->handleReloadSignal(...), + ); + } + + private function handleReloadSignal(int $signal): void + { + $this->getRunnerLogger()?->info( + 'Received SIGHUP. Configuration reload is not yet implemented.', + ['signal' => $signal], + ); } private function handleShutdownSignal(int $signal): void diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index 3acbc968..33c7c756 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -21,6 +21,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\Search\Filters; +use Throwable; final class LdapServerTest extends ServerTestCase { @@ -436,4 +437,46 @@ public function testItCanEndPagingEarly(): void $paging->end(); $this->assertFalse($paging->hasEntries()); } + + public function testSighupDoesNotShutdownTheServer(): void + { + if (!extension_loaded('posix')) { + $this->markTestSkipped('The posix extension is required to send signals.'); + } + + $this->authenticate(); + $this->assertSame( + 'dn:cn=user,dc=foo,dc=bar', + $this->ldapClient()->whoami(), + ); + + $this->sendServerSignal(SIGHUP); + + // Give the async signal handler a moment to run before checking state. + usleep(250_000); + + $this->assertTrue( + $this->isServerRunning(), + 'The server must remain running after SIGHUP.', + ); + + $newClient = $this->buildClient('tcp'); + + try { + $newClient->bind( + 'cn=user,dc=foo,dc=bar', + '12345', + ); + $this->assertSame( + 'dn:cn=user,dc=foo,dc=bar', + $newClient->whoami(), + ); + } finally { + try { + $newClient->unbind(); + } catch (Throwable) { + // Connection may already be closed; ignore unbind failures. + } + } + } } diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 56af1d2c..e2cce394 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -179,6 +179,30 @@ protected function waitForServerOutput(string $marker): string ); } + protected function sendServerSignal(int $signal): void + { + $process = $this->overrideProcess ?? self::$sharedProcess + ?? throw new RuntimeException('No server process is running.'); + + $pid = $process->getPid(); + + if ($pid === null) { + throw new RuntimeException('The server process has no PID.'); + } + + posix_kill( + $pid, + $signal, + ); + } + + protected function isServerRunning(): bool + { + $process = $this->overrideProcess ?? self::$sharedProcess; + + return $process?->isRunning() ?? false; + } + protected function authenticate(): void { $this->ldapClient()->bind( From c365b590fe66dae05fd666c471eeebebe2c2e936 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 18:25:56 -0400 Subject: [PATCH 2/6] Derive the naming context from what is in storage. Not from just whatever was configured in options. What is in storage for the NC will be restricted to system writes in a subsequent commit. --- CHANGELOG.md | 1 + docs/Server/Configuration.md | 8 +-- docs/Server/General-Usage.md | 23 +++---- src/FreeDSx/Ldap/LdapServer.php | 3 +- .../Factory/ProtocolHandlerProvider.php | 1 + .../ServerRootDseHandler.php | 8 ++- .../Server/Backend/LdapBackendInterface.php | 7 +++ .../Adapter/Dialect/PdoDialectInterface.php | 5 ++ .../Adapter/Dialect/PdoDialectTrait.php | 10 +++ .../Storage/Adapter/InMemoryStorage.php | 5 ++ .../Storage/Adapter/JsonFileStorage.php | 5 ++ .../Backend/Storage/Adapter/PdoStorage.php | 15 +++++ .../Support/ArrayEntryStorageTrait.php | 17 +++++ .../Adapter/Support/JsonEntryBuffer.php | 14 +++++ .../Writer/WriteSerializingStorage.php | 5 ++ .../Backend/Storage/EntryStorageInterface.php | 7 +++ .../Storage/Export/DirectoryDumper.php | 7 +-- .../Storage/WritableStorageBackend.php | 43 ++++++------- .../Server/RequestHandler/ProxyBackend.php | 19 ++++++ src/FreeDSx/Ldap/ServerOptions.php | 23 +------ tests/integration/Ldif/LdapDumpServerTest.php | 3 +- .../support/Backend/RecordingLdapBackend.php | 5 ++ tests/support/LdapServerCommand.php | 1 - tests/unit/LdapServerTest.php | 3 - .../ServerRootDseHandlerTest.php | 40 ++++++++---- .../Storage/Adapter/InMemoryStorageTest.php | 45 ++++++++++++++ .../Storage/Adapter/JsonFileStorageTest.php | 24 +++++++ .../Storage/Adapter/SqliteStorageTest.php | 29 +++++++++ .../Adapter/WritableStorageBackendTest.php | 62 ++++++++----------- .../Storage/Export/DirectoryDumperTest.php | 14 ++--- .../RequestHandler/ProxyBackendTest.php | 27 ++++++++ tests/unit/ServerOptionsTest.php | 18 ------ 32 files changed, 347 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d421646..9363037e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ CHANGELOG 1.0.0 (202x-xx-xx) ------------------ +* Removed `ServerOptions::setDseNamingContexts()`. RootDSE `namingContexts` is now derived from the backend. * Updated the minimum version of PHP to version 8.1. * All classes now use "strict_types=1". This internal change should not impact external usage. * Client and server options arrays have been replaced by ClientOptions and ServerOptions classes. diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index bb412c48..725eeb05 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -25,7 +25,6 @@ LDAP Server Configuration * [ServerOptions:setSchemaValidationMode](#setschemavalidationmode) * [ServerOptions:setSchema](#setschema) * [RootDSE Options](#rootdse-options) - * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) * [ServerOptions:setDseAltServer](#setdsealtserver) * [ServerOptions:setDseVendorName](#setdsevendorname) * [ServerOptions:setDseVendorVersion](#setdsevendorversion) @@ -423,12 +422,7 @@ Replaces the active schema used for validation and operational attributes. See ## RootDSE Options ------------------- -#### setDseNamingContexts - -The namingContexts attribute for the RootDSE as an array of strings. - -**Default**: `['dc=FreeDSx,dc=local']` +The `namingContexts` attribute is derived from the backend. No configuration is needed. ------------------ #### setDseAltServer diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 9ccd4f09..ccc61fc8 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -62,9 +62,7 @@ use FreeDSx\Ldap\ServerOptions; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); -$server = new LdapServer( - (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') -); +$server = new LdapServer(); $server->useStorage(new InMemoryStorage([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), @@ -94,9 +92,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\ServerOptions; $server = new LdapServer( - (new ServerOptions()) - ->setDseNamingContexts('dc=example,dc=com') - ->setSaslMechanisms(ServerOptions::SASL_PLAIN) + (new ServerOptions())->setSaslMechanisms(ServerOptions::SASL_PLAIN), ); $server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); @@ -152,12 +148,10 @@ class MyAuthenticator implements PasswordAuthenticatableInterface } $server = new LdapServer( - (new ServerOptions()) - ->setDseNamingContexts('dc=example,dc=com') - ->setSaslMechanisms( - ServerOptions::SASL_PLAIN, - ServerOptions::SASL_SCRAM_SHA_256, - ) + (new ServerOptions())->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_SCRAM_SHA_256, + ), ); $server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); @@ -919,8 +913,9 @@ $server = (new LdapServer())->usePasswordAuthenticator(new MyAuthenticator()); ## Handling the RootDSE -The server generates a default RootDSE from `ServerOptions` values (`setDseNamingContexts()`, `setDseVendorName()`, -etc.). For most deployments this is sufficient. The default entry always advertises: +The server generates a default RootDSE. `namingContexts` is derived from the backend (storage contents, or whatever a +custom backend declares); other attributes such as `vendorName` come from `ServerOptions`. For most deployments this is +sufficient. The default entry always advertises: - `supportedControl`: paging (RFC 2696) - `supportedExtension`: WhoAmI (RFC 4532), Password Modify (RFC 3062), and StartTLS (RFC 4511) if an SSL certificate is configured diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 122cfa94..e96b29e7 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -149,7 +149,6 @@ public function useStorage(EntryStorageInterface $storage): self storage: $storage, limits: $this->options->makeSearchLimits(), validator: $this->buildSchemaValidator(), - namingContexts: $this->options->getDseNamingContexts(), operationalAttrs: new OperationalAttributeGenerator($schema), )); } @@ -248,7 +247,7 @@ public function dump( $output->write((new DirectoryDumper( $backend, - $this->options->getDseNamingContexts(), + $backend->namingContexts(), $this->options->getFilterEvaluator(), ))->dump($options)); diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php index ac92171d..ab2ca8fd 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php @@ -142,6 +142,7 @@ private function getRootDseHandler(): ServerProtocolHandler\ServerRootDseHandler return new ServerProtocolHandler\ServerRootDseHandler( options: $this->options, queue: $this->queue, + backend: $this->handlerFactory->makeBackend(), rootDseHandler: $this->handlerFactory->makeRootDseHandler(), ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index 444cca25..fa3f5e39 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -26,6 +26,8 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestContext; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -53,6 +55,7 @@ class ServerRootDseHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerOptions $options, private readonly ServerQueue $queue, + private readonly LdapBackendInterface $backend, private readonly ?RootDseHandlerInterface $rootDseHandler = null, ) {} @@ -65,7 +68,10 @@ public function handleRequest( TokenInterface $token, ): OperationResult { $entry = Entry::fromArray('', [ - 'namingContexts' => $this->options->getDseNamingContexts(), + 'namingContexts' => array_map( + fn(Dn $dn): string => $dn->toString(), + $this->backend->namingContexts(), + ), 'subschemaSubentry' => [$this->options->getSubschemaEntry()->toString()], 'supportedControl' => [ Control::OID_PAGING, diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php index ae6b82b6..537eda02 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -47,4 +47,11 @@ public function compare( Dn $dn, EqualityFilter $filter, ): bool; + + /** + * Normalised DNs the backend hosts. Advertised by the server as RootDSE namingContexts. + * + * @return list + */ + public function namingContexts(): array; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php index 3900b5be..868d9628 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php @@ -98,6 +98,11 @@ public function querySubtree(): string; */ public function queryHasChildren(): string; + /** + * SELECT dn for entries whose parent is not in `entries` (i.e. naming-context roots). No parameters. + */ + public function queryNamingContexts(): string; + /** * Upsert a single entry. Parameters: [lc_dn, dn, lc_parent_dn, attributes] */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php index 68ff06ed..dbe08c45 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php @@ -99,6 +99,16 @@ public function queryHasChildren(): string SQL; } + public function queryNamingContexts(): string + { + return <<namingContextsFromArray($this->entries); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index c7cc2abf..ec8be603 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -121,6 +121,11 @@ public function atomic(callable $operation): void }); } + public function namingContexts(): array + { + return $this->namingContextsFromArray($this->read()); + } + /** * @param callable(string): string $mutation */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php index c7953e91..2ab48dad 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -311,6 +311,21 @@ public function hasChildren(Dn $dn): bool return $stmt->fetch() !== false; } + public function namingContexts(): array + { + $stmt = $this->prepareAndExecute($this->dialect->queryNamingContexts()); + + $contexts = []; + while (($row = $stmt->fetch()) !== false) { + if (!is_array($row) || !isset($row['dn']) || !is_string($row['dn'])) { + continue; + } + $contexts[] = (new Dn($row['dn']))->normalize(); + } + + return $contexts; + } + public function atomic(callable $operation): void { $pdo = $this->provider->get(); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php index 980b5671..ea2d746c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php @@ -78,6 +78,23 @@ private function sortedStreamFromArray( ); } + /** + * @param array $entries Entries keyed by normalised DN string + * @return list + */ + private function namingContextsFromArray(array $entries): array + { + $roots = []; + foreach (array_keys($entries) as $normDn) { + $parent = (new Dn($normDn))->getParent()?->normalize()->toString() ?? ''; + if ($parent === '' || !isset($entries[$parent])) { + $roots[] = new Dn($normDn); + } + } + + return $roots; + } + /** * @param array $entries Entries keyed by normalised DN string * @return Generator diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php index 166f5850..6bfa6c43 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php @@ -109,6 +109,20 @@ public function atomic(callable $operation): void $operation($this); } + public function namingContexts(): array + { + $roots = []; + + foreach (array_keys($this->data) as $normDn) { + $parent = (new Dn($normDn))->getParent()?->normalize()->toString() ?? ''; + if ($parent === '' || !isset($this->data[$parent])) { + $roots[] = new Dn($normDn); + } + } + + return $roots; + } + /** * @return array */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php index 2a3ace3d..c521fd44 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php @@ -77,6 +77,11 @@ public function atomic(callable $operation): void }); } + public function namingContexts(): array + { + return $this->reads->namingContexts(); + } + public function reset(): void { if ($this->reads instanceof ResettableInterface) { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php index 2c5a3589..da23f7b1 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -60,4 +60,11 @@ public function remove(Dn $dn): void; * @param callable(EntryStorageInterface): void $operation */ public function atomic(callable $operation): void; + + /** + * Normalised DNs of entries whose parent is not in storage. Advertised by the server as RootDSE namingContexts. + * + * @return list + */ + public function namingContexts(): array; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php index 1281c50a..146e7f72 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php @@ -32,7 +32,7 @@ final readonly class DirectoryDumper { /** - * @param string[] $namingContexts dump roots when DumpOptions::baseDn is not set + * @param list $namingContexts dump roots when DumpOptions::baseDn is not set */ public function __construct( private WritableStorageBackend $backend, @@ -71,10 +71,7 @@ private function resolveBases(DumpOptions $options): array return [$options->getBaseDn()]; } - return array_values(array_map( - fn(string $namingContext): Dn => new Dn($namingContext), - $this->namingContexts, - )); + return $this->namingContexts; } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 2e845a98..3c60c36e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -50,35 +50,26 @@ final class WritableStorageBackend implements WritableLdapBackendInterface, Rese { use WritableBackendTrait; - /** - * @var array normalised DN strings of protected naming contexts. - */ - private readonly array $namingContexts; - private readonly SearchStreamBuilder $searchStream; - /** - * @param string[] $namingContexts DN strings that may not be deleted. - */ public function __construct( private readonly EntryStorageInterface $storage, private readonly SearchLimits $limits = new SearchLimits(), private readonly ?SchemaValidator $validator = null, - array $namingContexts = [], private readonly OperationalAttributeGenerator $operationalAttrs = new OperationalAttributeGenerator(), private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), ) { - $normalised = []; - foreach ($namingContexts as $namingContext) { - $normalised[(new Dn($namingContext))->normalize()->toString()] = true; - } - $this->namingContexts = $normalised; $this->searchStream = new SearchStreamBuilder( $this->storage, $this->limits, ); } + public function namingContexts(): array + { + return $this->storage->namingContexts(); + } + public function reset(): void { if ($this->storage instanceof ResettableInterface) { @@ -242,8 +233,6 @@ public function delete( DeleteCommand $command, WriteContext $context, ): void { - $this->assertNotNamingContext($command->dn); - $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $this->findOrFail($storage, $dn); @@ -258,6 +247,11 @@ public function delete( ); } + $this->assertNotNamingContext( + $storage, + $command->dn, + ); + $storage->remove($dn); }); } @@ -383,8 +377,6 @@ public function move( MoveCommand $command, WriteContext $context, ): void { - $this->assertNotNamingContext($command->dn); - $this->writeAtomic(function (EntryStorageInterface $storage) use ($command, $context): void { $normOld = $command->dn->normalize(); $entry = $this->findOrFail($storage, $normOld); @@ -396,6 +388,11 @@ public function move( ); } + $this->assertNotNamingContext( + $storage, + $command->dn, + ); + $this->assertNewSuperiorExists($storage, $command); $newEntry = $this->entryHandler->apply($entry, $command); @@ -499,9 +496,13 @@ private function assertNewSuperiorExists(EntryStorageInterface $storage, MoveCom /** * @throws OperationException */ - private function assertNotNamingContext(Dn $dn): void - { - if (!isset($this->namingContexts[$dn->normalize()->toString()])) { + private function assertNotNamingContext( + EntryStorageInterface $storage, + Dn $dn, + ): void { + $parent = $dn->normalize()->getParent(); + + if ($parent !== null && $parent->toString() !== '' && $storage->exists($parent)) { return; } diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php index 4658a2e5..19433fec 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -57,8 +57,17 @@ class ProxyBackend implements WritableLdapBackendInterface, PasswordAuthenticata protected ClientOptions $options; + /** + * @var list Normalised DNs the proxy advertises as namingContexts. + */ + private readonly array $namingContexts; + + /** + * @param string[] $namingContexts DN strings the proxy exposes; advertised by the server as RootDSE namingContexts. + */ public function __construct( LdapClient|ClientOptions $clientOrOptions = new ClientOptions(), + array $namingContexts = [], ) { if ($clientOrOptions instanceof LdapClient) { $this->ldap = $clientOrOptions; @@ -66,6 +75,16 @@ public function __construct( } else { $this->options = $clientOrOptions; } + + $this->namingContexts = array_values(array_map( + fn(string $dn): Dn => (new Dn($dn))->normalize(), + $namingContexts, + )); + } + + public function namingContexts(): array + { + return $this->namingContexts; } public function search( diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 699d04b9..24e8ab20 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -120,11 +120,6 @@ final class ServerOptions private ?Dn $subschemaEntry = null; - /** - * @var string[] - */ - private array $dseNamingContexts = ['dc=FreeDSx,dc=local']; - private string $dseVendorName = 'FreeDSx'; private ?string $dseVendorVersion = null; @@ -355,21 +350,6 @@ public function setSubschemaEntry(Dn $subschemaEntry): self return $this; } - /** - * @return string[] - */ - public function getDseNamingContexts(): array - { - return $this->dseNamingContexts; - } - - public function setDseNamingContexts(string ...$dseNamingContexts): self - { - $this->dseNamingContexts = $dseNamingContexts; - - return $this; - } - public function getDseVendorName(): string { return $this->dseVendorName; @@ -782,7 +762,7 @@ public function setOnServerReady(?Closure $onServerReady): self } /** - * @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[]} + * @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_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} */ public function toArray(): array { @@ -802,7 +782,6 @@ public function toArray(): array 'ssl_cert_key' => $this->getSslCertKey(), 'ssl_cert_passphrase' => $this->getSslCertPassphrase(), 'dse_alt_server' => $this->getDseAltServer(), - 'dse_naming_contexts' => $this->getDseNamingContexts(), 'dse_vendor_name' => $this->getDseVendorName(), 'dse_vendor_version' => $this->getDseVendorVersion(), 'sasl_mechanisms' => $this->getSaslMechanisms(), diff --git a/tests/integration/Ldif/LdapDumpServerTest.php b/tests/integration/Ldif/LdapDumpServerTest.php index b3096a03..7afa7142 100644 --- a/tests/integration/Ldif/LdapDumpServerTest.php +++ b/tests/integration/Ldif/LdapDumpServerTest.php @@ -19,7 +19,6 @@ use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; -use FreeDSx\Ldap\ServerOptions; use Tests\Integration\FreeDSx\Ldap\ServerTestCase; final class LdapDumpServerTest extends ServerTestCase @@ -110,7 +109,7 @@ public function test_the_dump_file_parses_into_the_seeded_entries(): void public function test_a_fresh_server_seeded_from_the_dump_reconstructs_the_directory(): void { $storage = new InMemoryStorage(); - (new LdapServer((new ServerOptions())->setDseNamingContexts('dc=foo,dc=bar'))) + (new LdapServer()) ->useStorage($storage) ->seed(new StringLdifLoader((string) file_get_contents(self::$dumpPath))); diff --git a/tests/support/Backend/RecordingLdapBackend.php b/tests/support/Backend/RecordingLdapBackend.php index ebf3221a..e4013f8b 100644 --- a/tests/support/Backend/RecordingLdapBackend.php +++ b/tests/support/Backend/RecordingLdapBackend.php @@ -65,4 +65,9 @@ public function compare( ): bool { return false; } + + public function namingContexts(): array + { + return []; + } } diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index dd01d243..0ad57c75 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -143,7 +143,6 @@ protected function execute( ->setSslCertKey(self::SSL_KEY) ->setUseSsl($useSsl) ->setAllowAnonymous($allowAnonymous) - ->setDseNamingContexts('dc=foo,dc=bar') ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index f4c1d236..2b71d3fe 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -129,9 +129,6 @@ public function test_it_should_get_the_default_options(): void 'ssl_cert_key' => null, 'ssl_cert_passphrase' => null, 'dse_alt_server' => null, - 'dse_naming_contexts' => [ - 'dc=FreeDSx,dc=local', - ], 'dse_vendor_name' => 'FreeDSx', 'dse_vendor_version' => null, 'sasl_mechanisms' => [], diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index 1ad774c5..82c6a9c7 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -26,6 +26,7 @@ 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\RootDseHandlerInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -45,25 +46,42 @@ final class ServerRootDseHandlerTest extends TestCase private RootDseHandlerInterface&MockObject $mockDseHandler; + private LdapBackendInterface&MockObject $mockBackend; + protected function setUp(): void { $this->options = new ServerOptions(); $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockDseHandler = $this->createMock(RootDseHandlerInterface::class); + $this->withBackendNamingContexts([]); + } + + /** + * @param list $dns + */ + private function withBackendNamingContexts(array $dns): void + { + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockBackend + ->method('namingContexts') + ->willReturn(array_map( + fn(string $dn): Dn => new Dn($dn), + $dns, + )); $this->subject = new ServerRootDseHandler( $this->options, $this->mockQueue, + $this->mockBackend, null, ); } public function test_it_should_send_back_a_RootDSE(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, @@ -134,13 +152,13 @@ public function test_it_always_advertises_paging_and_password_modify(): void public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements_a_rootdse_aware_interface(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $this->subject = new ServerRootDseHandler( $this->options, $this->mockQueue, + $this->mockBackend, $this->mockDseHandler, ); @@ -234,9 +252,8 @@ public function test_it_should_include_supported_sasl_mechanisms_when_configured public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_requested(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, @@ -320,9 +337,8 @@ public function test_it_uses_configured_subschema_entry_dn(): void public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php index 65c9544f..223f4352 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -253,4 +253,49 @@ public function test_list_with_positive_time_limit_returns_entries_when_within_d self::assertCount(2, $entries); } + + public function test_naming_contexts_is_empty_when_storage_is_empty(): void + { + self::assertSame( + [], + (new InMemoryStorage())->namingContexts(), + ); + } + + public function test_naming_contexts_returns_entries_whose_parent_is_missing(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + new Entry(new Dn('dc=other,dc=org'), new Attribute('dc', 'other')), + ]); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_returns_orphans_whose_parent_is_not_in_storage(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + ]); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $storage->namingContexts(), + ); + + self::assertSame( + ['cn=alice,dc=example,dc=com'], + $contexts, + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php index d4bb80e0..943b00ea 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -272,4 +272,28 @@ public function test_has_children_returns_false_for_leaf_entry(): void { self::assertFalse($this->storage->hasChildren(new Dn('cn=alice,dc=example,dc=com'))); } + + public function test_naming_contexts_returns_top_most_entries_from_storage(): void + { + $this->subject->add( + new AddCommand( + new Entry( + new Dn('dc=other,dc=org'), + new Attribute('dc', 'other'), + ), + ), + $this->context(), + ); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $this->storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php index b9a9e4cd..57d93890 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php @@ -735,4 +735,33 @@ public function test_nested_atomic_rolls_back_inner_on_exception(): void self::assertNotNull($this->storage->find(new Dn('cn=outer,dc=example,dc=com'))); self::assertNull($this->storage->find(new Dn('cn=inner,dc=example,dc=com'))); } + + public function test_naming_contexts_returns_entries_whose_parent_is_missing_in_storage(): void + { + $this->storage->store(new Entry( + new Dn('dc=other,dc=org'), + new Attribute('dc', 'other'), + )); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $this->storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_is_empty_when_storage_is_empty(): void + { + $emptyStorage = SqliteStorage::forPcntl(':memory:'); + + self::assertSame( + [], + $emptyStorage->namingContexts(), + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 9827e99f..d237fcce 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -287,16 +287,13 @@ public function test_delete_throws_not_allowed_on_non_leaf_when_entry_has_subord ); } - public function test_delete_throws_unwilling_to_perform_for_configured_naming_context(): void + public function test_delete_throws_unwilling_to_perform_when_parent_is_not_in_storage(): void { $leaf = new Entry( new Dn('dc=example,dc=com'), new Attribute('dc', 'example'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$leaf])); self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); @@ -307,36 +304,13 @@ public function test_delete_throws_unwilling_to_perform_for_configured_naming_co ); } - public function test_delete_naming_context_check_is_case_insensitive(): void + public function test_move_throws_unwilling_to_perform_when_parent_is_not_in_storage(): void { $leaf = new Entry( new Dn('dc=example,dc=com'), new Attribute('dc', 'example'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['DC=Example,DC=Com'], - ); - - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); - - $backend->delete( - new DeleteCommand(new Dn('dc=example,dc=com')), - $this->context(), - ); - } - - public function test_move_throws_unwilling_to_perform_when_renaming_naming_context(): void - { - $leaf = new Entry( - new Dn('dc=example,dc=com'), - new Attribute('dc', 'example'), - ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$leaf])); self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); @@ -352,7 +326,7 @@ public function test_move_throws_unwilling_to_perform_when_renaming_naming_conte ); } - public function test_delete_allows_non_naming_context_entries_when_naming_context_configured(): void + public function test_delete_allows_entries_whose_parent_is_in_storage(): void { $base = new Entry( new Dn('dc=example,dc=com'), @@ -362,10 +336,7 @@ public function test_delete_allows_non_naming_context_entries_when_naming_contex new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$base, $leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$base, $leaf])); $backend->delete( new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com')), @@ -375,6 +346,27 @@ public function test_delete_allows_non_naming_context_entries_when_naming_contex self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); } + public function test_naming_contexts_delegates_to_storage(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + new Entry(new Dn('dc=other,dc=org'), new Attribute('dc', 'other')), + ]); + $backend = new WritableStorageBackend($storage); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $backend->namingContexts(), + ); + sort($contexts); + + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + public function test_update_add_attribute_value(): void { $this->subject->update( diff --git a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php index f05a5163..f4d0a9c6 100644 --- a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php +++ b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php @@ -36,7 +36,7 @@ public function test_it_yields_the_version_header_first_when_enabled(): void { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], ); $chunks = iterator_to_array( @@ -55,7 +55,7 @@ public function test_it_omits_the_version_header_when_disabled(): void $writer = new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)); $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: $writer, ); @@ -74,7 +74,7 @@ public function test_it_iterates_entries_across_naming_contexts_when_no_base_is_ { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -101,7 +101,7 @@ public function test_it_restricts_to_the_options_base_dn_when_set(): void { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -150,7 +150,7 @@ public function test_it_re_evaluates_the_filter_when_the_stream_is_not_preFilter $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -185,7 +185,7 @@ public function test_it_does_not_re_evaluate_the_filter_when_the_stream_is_preFi $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -213,7 +213,7 @@ public function test_it_passes_match_all_to_storage_when_no_filter_is_set(): voi $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], ); iterator_to_array( diff --git a/tests/unit/Server/RequestHandler/ProxyBackendTest.php b/tests/unit/Server/RequestHandler/ProxyBackendTest.php index c57fd840..4f83407e 100644 --- a/tests/unit/Server/RequestHandler/ProxyBackendTest.php +++ b/tests/unit/Server/RequestHandler/ProxyBackendTest.php @@ -13,6 +13,7 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\RequestHandler; +use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; @@ -272,4 +273,30 @@ public function test_move_sends_modify_dn_request_to_upstream(): void $this->context(), ); } + + public function test_naming_contexts_returns_configured_dns_normalised(): void + { + $subject = new ProxyBackend( + new ClientOptions(), + ['DC=Example,DC=Com', 'dc=other,dc=org'], + ); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $subject->namingContexts(), + ); + + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_is_empty_when_not_configured(): void + { + self::assertSame( + [], + $this->subject->namingContexts(), + ); + } } diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index 0e5df3b9..631360d1 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -303,24 +303,6 @@ public function test_it_can_set_subschema_entry(): void ); } - 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( From 3a194780ea825c102e98712d347e0c3e52ecdea3 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 18:43:19 -0400 Subject: [PATCH 3/6] Only system can create a new namingContext. --- .../Storage/WritableStorageBackend.php | 30 +++++++++----- .../Storage/Adapter/JsonFileStorageTest.php | 12 +++++- .../Storage/Adapter/SqliteStorageTest.php | 18 ++++++--- .../Adapter/WritableStorageBackendTest.php | 40 ++++++++++++++++++- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 3c60c36e..42bb3394 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -208,7 +208,11 @@ public function add( ): void { $this->writeAtomic(function (EntryStorageInterface $storage) use ($command, $context): void { $dn = $command->entry->getDn()->normalize(); - $this->assertParentExists($storage, $dn); + $this->assertParentExists( + $storage, + $dn, + $context, + ); if ($storage->exists($dn)) { $this->throwEntryAlreadyExists($command->entry->getDn()); @@ -456,22 +460,26 @@ private function findOrFail(EntryStorageInterface $storage, Dn $dn): Entry /** * @throws OperationException */ - private function assertParentExists(EntryStorageInterface $storage, Dn $dn): void - { + private function assertParentExists( + EntryStorageInterface $storage, + Dn $dn, + WriteContext $context, + ): 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) { + if ($parent !== null && $storage->exists($parent)) { return; } - if (!$storage->exists($parent)) { - $this->throwNoSuchObject( - $storage, - $parent, - ); + // New naming-context roots may only be created by system writes. + if ($context->isSystem()) { + return; } + + $this->throwNoSuchObject( + $storage, + $parent ?? $dn, + ); } /** diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php index 943b00ea..35dab2d7 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -57,7 +57,7 @@ protected function setUp(): void new Attribute('dc', 'example'), ), ), - $this->context(), + $this->systemContext(), ); $this->subject->add( new AddCommand($this->alice), @@ -73,6 +73,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + protected function tearDown(): void { if (file_exists($this->tempFile)) { @@ -282,7 +290,7 @@ public function test_naming_contexts_returns_top_most_entries_from_storage(): vo new Attribute('dc', 'other'), ), ), - $this->context(), + $this->systemContext(), ); $contexts = array_map( diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php index 57d93890..bc9d77a8 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php @@ -63,7 +63,7 @@ protected function setUp(): void new AddCommand( new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), ), - $this->context(), + $this->systemContext(), ); $this->subject->add( new AddCommand($this->alice), @@ -79,6 +79,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + public function test_get_returns_entry_by_dn(): void { $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); @@ -575,8 +583,6 @@ public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void $storage = $this->createPdoStorageWithMaxDnLength(5); $backend = new WritableStorageBackend($storage); - // Use a root-level parent (dc=example) so assertParentExists skips the lookup - // and the path reaches PdoStorage::store() where the DnTooLongException fires. $entry = new Entry( new Dn('cn=TooLong,dc=example'), new Attribute('cn', 'TooLong'), @@ -585,7 +591,7 @@ public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void try { $backend->add( new AddCommand($entry), - $this->context(), + $this->systemContext(), ); self::fail('Expected OperationException was not thrown.'); } catch (OperationException $e) { @@ -615,7 +621,7 @@ public function test_subtree_does_not_match_escaped_comma_suffix_collision(): vo ); $backend->add( new AddCommand($base), - $this->context(), + $this->systemContext(), ); $backend->add( new AddCommand($escaped), @@ -647,7 +653,7 @@ public function test_subtree_includes_entries_with_escaped_comma_under_correct_p ); $backend->add( new AddCommand($base), - $this->context(), + $this->systemContext(), ); $backend->add( new AddCommand($escaped), diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index d237fcce..011beb32 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -242,18 +242,46 @@ public function test_add_throws_no_such_object_when_parent_does_not_exist(): voi ); } - public function test_add_allows_root_naming_context_entry(): void + public function test_add_allows_root_naming_context_entry_for_system_context(): void { $backend = new WritableStorageBackend(new InMemoryStorage()); $root = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); $backend->add( new AddCommand($root), - $this->context(), + $this->systemContext(), ); self::assertNotNull($backend->get(new Dn('dc=example,dc=com'))); } + public function test_add_refuses_root_naming_context_entry_for_non_system_context(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage()); + $root = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $backend->add( + new AddCommand($root), + $this->context(), + ); + } + + public function test_add_refuses_single_rdn_root_entry_for_non_system_context(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage()); + $root = new Entry(new Dn('dc=com'), new Attribute('dc', 'com')); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $backend->add( + new AddCommand($root), + $this->context(), + ); + } + public function test_delete_removes_entry(): void { $this->subject->delete( @@ -668,6 +696,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + /** * @return Generator */ From a597c4d7b3343f753327549e02a92c634e218279 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 18:54:03 -0400 Subject: [PATCH 4/6] Make sure opcache + jit is tuned for load tests. --- .github/workflows/load-test.yml | 14 ++++++++------ composer.json | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index b85de9fd..998896fe 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -38,8 +38,8 @@ jobs: uses: shivammathur/cache-extensions@v1 with: php-version: '8.5' - extensions: swoole, pdo_sqlite, pcntl - key: load-test-ext-v1 + extensions: swoole, pdo_sqlite, pcntl, opcache + key: load-test-ext-v2 - name: Cache extensions uses: actions/cache@v3 @@ -52,9 +52,10 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' - extensions: swoole, pdo_sqlite, pcntl + extensions: swoole, pdo_sqlite, pcntl, opcache coverage: none tools: composer:2.9 + ini-values: opcache.enable_cli=1, opcache.jit_buffer_size=128M, opcache.jit=tracing - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader @@ -112,8 +113,8 @@ jobs: uses: shivammathur/cache-extensions@v1 with: php-version: '8.5' - extensions: swoole, pdo_mysql, pcntl - key: load-test-mysql-ext-v1 + extensions: swoole, pdo_mysql, pcntl, opcache + key: load-test-mysql-ext-v2 - name: Cache extensions uses: actions/cache@v3 @@ -126,9 +127,10 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' - extensions: swoole, pdo_mysql, pcntl + extensions: swoole, pdo_mysql, pcntl, opcache coverage: none tools: composer:2.9 + ini-values: opcache.enable_cli=1, opcache.jit_buffer_size=128M, opcache.jit=tracing - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader diff --git a/composer.json b/composer.json index 7534bd04..611be584 100644 --- a/composer.json +++ b/composer.json @@ -79,8 +79,8 @@ "docker compose -f tests/resources/openldap/docker-compose.yml up -d --build --wait", "LDAP_TESTS_ENABLED=1 php -d xdebug.mode=off vendor/bin/phpunit --testsuite integration" ], - "test-load": "@php -d xdebug.mode=off tests/bin/ldap-load-test.php --backend=sqlite --runner=swoole --duration=10 --warmup=2 --clients=8 --output=text", - "test-load-compare": "@php -d xdebug.mode=off tests/bin/ldap-bench-compare.php", + "test-load": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-load-test.php --backend=sqlite --runner=swoole --duration=10 --warmup=2 --clients=8 --output=text", + "test-load-compare": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-bench-compare.php", "profile": "tests/profile/profile.sh", "profile-up": "docker compose -f tests/profile/docker-compose.yml up -d --build --wait", "profile-down": "docker compose -f tests/profile/docker-compose.yml down", From f58187e8a852de173eb1a3bb73652ba31004bdf8 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 19:03:23 -0400 Subject: [PATCH 5/6] Adjust CI so we get less flakes based on random GH node noise. --- tests/performance/Threshold/CiThresholds.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/performance/Threshold/CiThresholds.php b/tests/performance/Threshold/CiThresholds.php index 40090139..9cdba9c6 100644 --- a/tests/performance/Threshold/CiThresholds.php +++ b/tests/performance/Threshold/CiThresholds.php @@ -56,9 +56,10 @@ public static function forProfile(string $key): ThresholdSet minThroughput: 1100.0, maxP99Ms: 150.0, ), + // No minThroughput: too tightly dependent on the GH Actions node. + // Detection on throughput stays on sqlite:pcntl. 'sqlite:swoole' => new ThresholdSet( maxErrors: 0, - minThroughput: 900.0, maxP99Ms: 500.0, ), 'mysql:pcntl' => new ThresholdSet( From bb26225cd1635b46b58ef2a1706a62303b7fb718 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 19:13:06 -0400 Subject: [PATCH 6/6] Adjust CI so we get less flakes based on random GH node noise. --- tests/performance/Threshold/CiThresholds.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/performance/Threshold/CiThresholds.php b/tests/performance/Threshold/CiThresholds.php index 9cdba9c6..398acb38 100644 --- a/tests/performance/Threshold/CiThresholds.php +++ b/tests/performance/Threshold/CiThresholds.php @@ -53,23 +53,22 @@ public static function forProfile(string $key): ThresholdSet ), 'sqlite:pcntl' => new ThresholdSet( maxErrors: 0, - minThroughput: 1100.0, + minThroughput: 935.0, maxP99Ms: 150.0, ), - // No minThroughput: too tightly dependent on the GH Actions node. - // Detection on throughput stays on sqlite:pcntl. 'sqlite:swoole' => new ThresholdSet( maxErrors: 0, + minThroughput: 700.0, maxP99Ms: 500.0, ), 'mysql:pcntl' => new ThresholdSet( maxErrors: 0, - minThroughput: 850.0, + minThroughput: 720.0, maxP99Ms: 200.0, ), 'mysql:swoole' => new ThresholdSet( maxErrors: 0, - minThroughput: 850.0, + minThroughput: 720.0, maxP99Ms: 200.0, ), default => throw new InvalidArgumentException(sprintf(