From 3f24378988c7f37426a0d78bc444fc6ef06f3310 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 28 May 2026 08:17:21 -0400 Subject: [PATCH] Support normal LDIF changes. The outcome is now an in-order set of change requests that can be iterated on. Add a separate method on the server for allowing these to be processed directly against the backend. The seed method should still be used for bulk imports for new entries, as it's atomic in nature and doesn't go through the full request flow that the applyChanges method does. --- docs/Server/General-Usage.md | 107 +++-- src/FreeDSx/Ldap/LdapServer.php | 49 ++- src/FreeDSx/Ldap/Ldif/LdifChanges.php | 139 +++++++ src/FreeDSx/Ldap/Ldif/LdifParser.php | 301 ++++---------- src/FreeDSx/Ldap/Ldif/Parser/ChangeType.php | 32 ++ .../Ldif/Parser/LdifChangeRecordParser.php | 393 ++++++++++++++++++ .../Ldap/Ldif/Parser/LdifDirective.php | 38 ++ .../Ldap/Ldif/Parser/LdifLineCursor.php | 218 ++++++++++ .../Ldap/Ldif/Parser/ModRdnDirective.php | 28 ++ src/FreeDSx/Ldap/Ldif/Parser/ModSpecOp.php | 28 ++ .../Backend/Write/WriteRequestReplayer.php | 60 +++ .../Ldif/LdapApplyChangesServerTest.php | 107 +++++ tests/resources/changes/apply-test.ldif | 25 ++ tests/support/LdapServerCommand.php | 12 + tests/unit/LdapServerTest.php | 48 +++ tests/unit/Ldif/LdifChangesTest.php | 129 ++++++ tests/unit/Ldif/LdifParserTest.php | 89 ++-- tests/unit/Ldif/LdifWriterTest.php | 2 +- .../Parser/LdifChangeRecordParserTest.php | 300 +++++++++++++ 19 files changed, 1823 insertions(+), 282 deletions(-) create mode 100644 src/FreeDSx/Ldap/Ldif/LdifChanges.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/ChangeType.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/LdifChangeRecordParser.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/LdifDirective.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/LdifLineCursor.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/ModRdnDirective.php create mode 100644 src/FreeDSx/Ldap/Ldif/Parser/ModSpecOp.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestReplayer.php create mode 100644 tests/integration/Ldif/LdapApplyChangesServerTest.php create mode 100644 tests/resources/changes/apply-test.ldif create mode 100644 tests/unit/Ldif/LdifChangesTest.php create mode 100644 tests/unit/Ldif/Parser/LdifChangeRecordParserTest.php diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index ef9f6242..c76153a4 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -17,6 +17,9 @@ General LDAP Server Usage * [MysqlStorage](#mysqlstorage) * [Proxy Backend](#proxy-backend) * [Custom Filter Evaluation](#custom-filter-evaluation) +* [Loading LDIF Data](#loading-ldif-data) + * [Seeding Initial Entries](#seeding-initial-entries) + * [Replaying LDIF Changelogs](#replaying-ldif-changelogs) * [Authentication](#authentication) * [Default Authentication](#default-authentication) * [Custom Bind Name Resolution](#custom-bind-name-resolution) @@ -607,38 +610,6 @@ final class PostgresStorage implements PdoStorageFactoryInterface } ``` -#### Seeding Initial Entries - -`LdapImporter` writes a batch of entries in one atomic transaction. Use it to populate a persistent storage backend -before `$server->run()`. The same pattern works for every adapter. - -```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\SqliteStorage; -use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; - -$storage = SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite'); - -(new LdapImporter($storage))->importEntries([ - 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 LdapServer())->useStorage($storage)->run(); -``` - -`importEntries()` is an upsert: existing entries at the same DN are replaced. Re-running is safe, but any changes made -between imports are overwritten — guard the call yourself if you only want to seed on first run. - -For Swoole factories (`::forSwoole()`), wrap the `importEntries()` call in `Swoole\Coroutine\run()` so the adapter's -coroutine-scoped connection is available during import. - ### Proxy Backend `ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server. @@ -706,6 +677,78 @@ class MySqlFilterEvaluator implements FilterEvaluatorInterface } ``` +## Loading LDIF Data + +### Seeding Initial Entries + +`LdapServer::seed()` bulk-imports RFC 2849 LDIF content records into the storage configured via `useStorage()` in one +atomic transaction, with schema validation and operational-attribute stamping (`createTimestamp`, `entryUUID`, etc.) +applied. Use it to populate a persistent storage backend before `$server->run()`. + +```php +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; + +$server = (new LdapServer()) + ->useStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite')) + ->seed( + new FileLdifLoader('/etc/myapp/initial-data.ldif'), + new Dn('cn=admin,dc=example,dc=com'), + ); + +$server->run(); +``` + +The optional second argument is the creator DN, stamped as `creatorsName`/`modifiersName` on each imported entry — +defaults to the empty (anonymous) DN. + +`seed()` accepts only content records (entries without `changetype:`). LDIF sources are pluggable via `LdifLoaderInterface`: +use `FileLdifLoader`, `StringLdifLoader`, or implement your own (e.g. fetching from a database or remote URL). The +operation itself is an upsert that overwrites. + +For Swoole factories (`::forSwoole()`), call `seed()` inside `Swoole\Coroutine\run()` so the adapter's +coroutine-scoped connection is available during import. + +### Replaying LDIF Changelogs + +`LdapServer::applyChanges()` replays an LDIF changelog through the live write path. Use it for applying diffs, migrations, +or administrative changes after the initial directory is populated. + +```ldif +version: 1 + +dn: cn=alice,dc=example,dc=com +changetype: modify +replace: sn +sn: Anderson +- + +dn: cn=bob,dc=example,dc=com +changetype: delete + +dn: cn=carol,dc=example,dc=com +changetype: modrdn +newrdn: cn=carolyn +deleteoldrdn: 1 +``` + +```php +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; + +(new LdapServer()) + ->useStorage($storage) + ->seed(new FileLdifLoader('/etc/myapp/initial-data.ldif')) + ->applyChanges(new FileLdifLoader('/etc/myapp/changes-today.ldif')) + ->run(); +``` + +Unlike `seed()`, `applyChanges()` dispatches each request through the same write path the live server uses for client +requests. Supported changetypes: `add`, `delete`, `modify` (`add:`/`delete:`/`replace:` mod-specs), and `modrdn`/`moddn` +(rename or move; supports optional `newsuperior:` for moving across subtrees). + ## Authentication The `PasswordAuthenticatableInterface` covers all bind types through two methods: diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index c02f8e72..1bf80fa2 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -33,6 +33,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestReplayer; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; @@ -148,10 +149,9 @@ public function useStorage(EntryStorageInterface $storage): self } /** - * Convenience method to bulk-load LDIF entries into the storage configured via {@see useStorage()}. + * Bulk-loads LDIF content records into the storage configured via {@see useStorage()} as one atomic batch. * - * The LDIF source is pluggable via {@see LdifLoaderInterface} (a file, a string, a database, etc.). Entries are - * validated and stamped with operational attributes the same way the live write path would. + * Use {@see applyChanges()} instead to replay a changelog (modify/delete/rename) through the live write path. * * @param Dn $creatorDn DN recorded as creatorsName/modifiersName on imported entries; defaults to the anonymous (empty) DN. * @throws LdifParseException when the LDIF cannot be parsed @@ -169,14 +169,53 @@ public function seed( throw new RuntimeException('seed() requires a storage backend configured via useStorage().'); } - $entries = (new LdifParser()) + $changes = (new LdifParser()) ->parse($loader->load()); + + if (!$changes->isAddOnly()) { + throw new RuntimeException( + 'seed() only accepts content records (adds). Use applyChanges() for modify/delete/rename.', + ); + } + (new LdapImporter( $backend->getStorage(), $backend->getOperationalAttributeGenerator(), $backend->getSchemaValidator(), $creatorDn, - ))->importEntries($entries->toArray()); + ))->importEntries($changes->entries()); + + return $this; + } + + /** + * Replays an LDIF changelog against the configured backend via the live write path. + * + * Use {@see seed()} instead for bulk initial provisioning of content records straight to storage. + * + * @throws LdifParseException when the LDIF cannot be parsed + * @throws RuntimeException when no writable backend is configured + * @throws OperationException when a write fails (no such entry, schema violation, etc.) + */ + public function applyChanges(LdifLoaderInterface $loader): self + { + $backend = $this->options->getBackend(); + + if (!$backend instanceof WriteHandlerInterface) { + throw new RuntimeException('applyChanges() requires a writable backend.'); + } + + $changes = (new LdifParser()) + ->parse($loader->load()); + + if (count($changes) === 0) { + return $this; + } + + (new WriteRequestReplayer( + $backend, + $this->options->getWriteHandlers(), + ))->apply($changes); return $this; } diff --git a/src/FreeDSx/Ldap/Ldif/LdifChanges.php b/src/FreeDSx/Ldap/Ldif/LdifChanges.php new file mode 100644 index 00000000..c2277429 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/LdifChanges.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif; + +use ArrayIterator; +use Countable; +use FreeDSx\Ldap\Entry\Entry; +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 IteratorAggregate; +use Traversable; + +use function array_filter; +use function array_map; +use function array_values; +use function count; + +/** + * The full outcome of an LDIF parse: write requests in original record order. + * + * @implements IteratorAggregate + * + * @author Chad Sikorra + */ +final readonly class LdifChanges implements Countable, IteratorAggregate +{ + /** + * @var array + */ + private array $requests; + + public function __construct(RequestInterface ...$requests) + { + $this->requests = $requests; + } + + /** + * @return RequestInterface[] + */ + public function toArray(): array + { + return $this->requests; + } + + public function count(): int + { + return count($this->requests); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->requests); + } + + /** + * @return list + */ + public function adds(): array + { + return array_values(array_filter( + $this->requests, + fn(RequestInterface $r): bool => $r instanceof AddRequest, + )); + } + + /** + * @return list + */ + public function modifies(): array + { + return array_values(array_filter( + $this->requests, + fn(RequestInterface $r): bool => $r instanceof ModifyRequest, + )); + } + + /** + * @return list + */ + public function deletes(): array + { + return array_values(array_filter( + $this->requests, + fn(RequestInterface $r): bool => $r instanceof DeleteRequest, + )); + } + + /** + * @return list + */ + public function modifyDns(): array + { + return array_values(array_filter( + $this->requests, + fn(RequestInterface $r): bool => $r instanceof ModifyDnRequest, + )); + } + + public function isAddOnly(): bool + { + foreach ($this->requests as $request) { + if (!($request instanceof AddRequest)) { + return false; + } + } + + return true; + } + + /** + * Extracts the Entry from every AddRequest, ignoring any non-add requests. + * + * @return list + */ + public function entries(): array + { + return array_map( + fn(AddRequest $r): Entry => $r->getEntry(), + $this->adds(), + ); + } +} diff --git a/src/FreeDSx/Ldap/Ldif/LdifParser.php b/src/FreeDSx/Ldap/Ldif/LdifParser.php index b3417c3b..7574c6a3 100644 --- a/src/FreeDSx/Ldap/Ldif/LdifParser.php +++ b/src/FreeDSx/Ldap/Ldif/LdifParser.php @@ -13,299 +13,164 @@ namespace FreeDSx\Ldap\Ldif; -use FreeDSx\Ldap\Entry\Entries; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\LdifParseException; +use FreeDSx\Ldap\Ldif\Parser\LdifChangeRecordParser; +use FreeDSx\Ldap\Ldif\Parser\LdifLineCursor; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\RequestInterface; +use FreeDSx\Ldap\Operations; -use function base64_decode; use function count; -use function explode; -use function ltrim; -use function str_replace; -use function strpos; -use function strtolower; -use function substr; +use function sprintf; /** - * Parses RFC 2849 LDIF content records (entries) into {@see Entries}. + * Parses RFC 2849 LDIF (content and change records) into a unified collection of write requests. * * @author Chad Sikorra */ final class LdifParser { - private const COMMENT = '#'; - - private const SEPARATOR = ':'; - - private const URL_MARKER = '<'; - private const DN = 'dn'; private const VERSION = 'version'; private const CHANGETYPE = 'changetype'; - /** - * @var string[] - */ - private array $lines = []; - - private int $pos = 0; - - /** - * @var Entry[] - */ - private array $entries = []; + public function __construct( + private readonly LdifChangeRecordParser $changeParser = new LdifChangeRecordParser(), + ) {} /** * @throws LdifParseException */ - public function parse(string $ldif): Entries + public function parse(string $ldif): LdifChanges { - $this->init($ldif); - - while (!$this->atEnd()) { - $this->parseLine(); - } + $cursor = LdifLineCursor::forInput($ldif); + $requests = []; - return new Entries(...$this->entries); - } - - private function parseLine(): void - { - $line = $this->current(); + while (!$cursor->atEnd()) { + $line = $cursor->current(); - if ($line === '') { - $this->pos++; + if ($line === '') { + $cursor->advance(); + continue; + } + if ($cursor->isComment($line)) { + $cursor->skipComment(); + continue; + } - return; - } - if ($this->isComment($line)) { - $this->skipComment(); + $key = $cursor->keyOf($line); - return; + if ($key === self::DN) { + $requests[] = $this->parseRecord($cursor); + } elseif ($key === self::VERSION) { + $this->assertVersion($cursor, count($requests)); + } else { + $cursor->error('Expected a "dn:" line to begin a record'); + } } - $key = $this->keyOf($line); - - if ($key === self::DN) { - $this->entries[] = $this->parseEntry(); - } elseif ($key === self::VERSION) { - $this->assertVersion(); - } else { - throw $this->error('Expected a "dn:" line to begin an entry'); - } + return new LdifChanges(...$requests); } /** * @throws LdifParseException */ - private function parseEntry(): Entry + private function parseRecord(LdifLineCursor $cursor): RequestInterface { - [, $dn] = $this->readDirective(); + $dn = $cursor->readDirective()->value; + + return $this->isAtChangetype($cursor) + ? $this->changeParser->parseRecord($cursor, $dn) + : $this->parseContentRecord($cursor, $dn); + } + /** + * @throws LdifParseException + */ + private function parseContentRecord( + LdifLineCursor $cursor, + string $dn, + ): AddRequest { /** @var array $attributes */ $attributes = []; - while (!$this->atEnd()) { - $line = $this->current(); + while (!$cursor->atEnd()) { + $line = $cursor->current(); if ($line === '') { break; } - if ($this->isComment($line)) { - $this->skipComment(); - + if ($cursor->isComment($line)) { + $cursor->skipComment(); continue; } - if ($this->keyOf($line) === self::DN) { + if ($cursor->keyOf($line) === self::DN) { break; } - $at = $this->pos; - [$attribute, $value] = $this->readDirective(); + $directive = $cursor->readDirective(); - if (strtolower($attribute) === self::CHANGETYPE) { - throw $this->errorAt( - $at, - 'LDIF change records are not yet supported.', + if ($directive->is(self::CHANGETYPE)) { + $cursor->errorAt( + $directive->position, + '"changetype:" must be the first directive after dn', ); } - $attributes[$attribute][] = $value; + $attributes[$directive->name][] = $directive->value; } - return Entry::create( + return Operations::add(Entry::create( $dn, $attributes, - ); + )); } /** - * Read the directive at the cursor, consuming any folded continuation lines. - * - * @return array{0: string, 1: string} - * @throws LdifParseException + * Peeks (after skipping comments) whether the next directive is "changetype:". */ - private function readDirective(): array + private function isAtChangetype(LdifLineCursor $cursor): bool { - $at = $this->pos; - $line = $this->lines[$at]; - $colon = strpos($line, self::SEPARATOR); - - if ($colon === false || $colon === 0) { - throw $this->errorAt( - $at, - 'Expected a "name: value" directive', - ); + while (!$cursor->atEnd() && $cursor->isComment($cursor->current())) { + $cursor->skipComment(); } - $name = substr($line, 0, $colon); - $marker = $line[$colon + 1] ?? ''; - $this->pos++; - - if ($marker === self::SEPARATOR) { - $value = $this->decodeBase64( - $this->readFolded(ltrim( - substr($line, $colon + 2), - ' ', - )), - $at, - ); - } elseif ($marker === self::URL_MARKER) { - throw $this->errorAt( - $at, - 'URL-referenced values ("name:< url") are not yet supported', - ); - } else { - $value = $this->readFolded(ltrim( - substr($line, $colon + 1), - ' ', - )); - } - - return [$name, $value]; - } - - /** - * Append any continuation lines (those beginning with a single space) to the value. - */ - private function readFolded(string $value): string - { - while ($this->isAtContinuation()) { - $value .= substr($this->current(), 1); - $this->pos++; + if ($cursor->atEnd() || $cursor->current() === '') { + return false; } - return $value; - } - - private function skipComment(): void - { - $this->pos++; - - while ($this->isAtContinuation()) { - $this->pos++; - } + return $cursor->keyOf($cursor->current()) === self::CHANGETYPE; } /** * @throws LdifParseException */ - private function assertVersion(): void - { - $at = $this->pos; - - if (count($this->entries) !== 0) { - throw $this->errorAt($at, 'The version directive must appear before any entries'); + private function assertVersion( + LdifLineCursor $cursor, + int $recordsSeen, + ): void { + $at = $cursor->position(); + + if ($recordsSeen !== 0) { + $cursor->errorAt( + $at, + 'The version directive must appear before any records', + ); } - [, $version] = $this->readDirective(); + $version = $cursor->readDirective()->value; if ($version !== '1') { - throw $this->errorAt($at, sprintf('Unsupported LDIF version "%s"', $version)); - } - } - - /** - * @throws LdifParseException - */ - private function decodeBase64( - string $raw, - int $at, - ): string { - $decoded = base64_decode($raw, true); - - if ($decoded === false) { - throw $this->errorAt( + $cursor->errorAt( $at, - 'A base64-encoded value is not valid', + sprintf( + 'Unsupported LDIF version "%s"', + $version, + ), ); } - - return $decoded; - } - - private function keyOf(string $line): string - { - $colon = strpos($line, self::SEPARATOR); - - return $colon === false - ? '' - : strtolower(substr($line, 0, $colon)); - } - - private function isComment(string $line): bool - { - return ($line[0] ?? '') === self::COMMENT; - } - - /** - * A continuation line (RFC 2849 §2): a non-empty current line that begins with a single space. - */ - private function isAtContinuation(): bool - { - return !$this->atEnd() - && $this->current() !== '' - && $this->current()[0] === ' '; - } - - private function atEnd(): bool - { - return $this->pos >= count($this->lines); - } - - private function current(): string - { - return $this->lines[$this->pos]; - } - - private function error(string $message): LdifParseException - { - return $this->errorAt($this->pos, $message); - } - - private function errorAt( - int $at, - string $message, - ): LdifParseException { - return new LdifParseException( - $message, - $at + 1, - $this->lines[$at] ?? null, - ); - } - - private function init(string $ldif): void - { - $this->lines = explode( - "\n", - str_replace( - ["\r\n", "\r"], - "\n", - $ldif, - ), - ); - $this->pos = 0; } } diff --git a/src/FreeDSx/Ldap/Ldif/Parser/ChangeType.php b/src/FreeDSx/Ldap/Ldif/Parser/ChangeType.php new file mode 100644 index 00000000..42fe4b41 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/ChangeType.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\Ldif\Parser; + +/** + * The set of RFC 2849 changetype values (moddn is an alias of modrdn). + * + * @author Chad Sikorra + */ +enum ChangeType: string +{ + case Add = 'add'; + + case Delete = 'delete'; + + case Modify = 'modify'; + + case ModRdn = 'modrdn'; + + case ModDn = 'moddn'; +} diff --git a/src/FreeDSx/Ldap/Ldif/Parser/LdifChangeRecordParser.php b/src/FreeDSx/Ldap/Ldif/Parser/LdifChangeRecordParser.php new file mode 100644 index 00000000..62f90a30 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/LdifChangeRecordParser.php @@ -0,0 +1,393 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Parser; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\LdifParseException; +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\Operations; + +use function sprintf; +use function strtolower; + +/** + * Parses one RFC 2849 LDIF change record into a write request, given a cursor positioned after the dn directive. + * + * @author Chad Sikorra + */ +final class LdifChangeRecordParser +{ + private const DN = 'dn'; + + private const CHANGETYPE = 'changetype'; + + private const MOD_TERMINATOR = '-'; + + /** + * @throws LdifParseException + */ + public function parseRecord( + LdifLineCursor $cursor, + string $dn, + ): RequestInterface { + $changetype = $this->readChangetype($cursor); + $type = ChangeType::tryFrom(strtolower($changetype)); + + return match ($type) { + ChangeType::Add => $this->parseAddRecord($cursor, $dn), + ChangeType::Delete => $this->parseDeleteRecord($cursor, $dn), + ChangeType::Modify => $this->parseModifyRecord($cursor, $dn), + ChangeType::ModRdn, ChangeType::ModDn => $this->parseModRdnRecord($cursor, $dn), + null => $cursor->error(sprintf( + 'Unsupported changetype "%s"', + $changetype, + )), + }; + } + + /** + * @throws LdifParseException + */ + private function readChangetype(LdifLineCursor $cursor): string + { + while (!$cursor->atEnd() && $cursor->isComment($cursor->current())) { + $cursor->skipComment(); + } + + if ($cursor->atEnd() || $cursor->current() === '') { + $cursor->error('Missing "changetype:" directive after DN'); + } + + $directive = $cursor->readDirective(); + + if (!$directive->is(self::CHANGETYPE)) { + $cursor->errorAt( + $directive->position, + 'Expected "changetype:" directive after DN', + ); + } + + return $directive->value; + } + + /** + * @throws LdifParseException + */ + private function parseAddRecord( + LdifLineCursor $cursor, + string $dn, + ): AddRequest { + $attributes = $this->readAttrvalBody($cursor); + + return Operations::add(Entry::create( + $dn, + $attributes, + )); + } + + /** + * @throws LdifParseException + */ + private function parseDeleteRecord( + LdifLineCursor $cursor, + string $dn, + ): DeleteRequest { + $this->expectEndOfRecord( + $cursor, + ChangeType::Delete->value, + ); + + return Operations::delete($dn); + } + + /** + * @throws LdifParseException + */ + private function parseModifyRecord( + LdifLineCursor $cursor, + string $dn, + ): ModifyRequest { + $changes = []; + + while (($directive = $this->advanceToNextDirective($cursor)) !== null) { + $changes[] = $this->parseModSpec($cursor, $directive); + } + + return Operations::modify( + $dn, + ...$changes, + ); + } + + /** + * @throws LdifParseException + */ + private function parseModSpec( + LdifLineCursor $cursor, + LdifDirective $directive, + ): Change { + $op = ModSpecOp::tryFrom(strtolower($directive->name)); + $attr = $directive->value; + + if ($op === null) { + $cursor->errorAt( + $directive->position, + sprintf( + 'Expected an add:, delete:, or replace: mod-spec, got "%s:"', + $directive->name, + ), + ); + } + + $values = $this->readModSpecValues( + $cursor, + $attr, + ); + + return match ($op) { + ModSpecOp::Add => Change::add($attr, ...$values), + ModSpecOp::Delete => Change::delete($attr, ...$values), + ModSpecOp::Replace => Change::replace($attr, ...$values), + }; + } + + /** + * @return list + * @throws LdifParseException + */ + private function readModSpecValues( + LdifLineCursor $cursor, + string $attr, + ): array { + $values = []; + + while (!$cursor->atEnd()) { + $line = $cursor->current(); + + if ($line === self::MOD_TERMINATOR) { + $cursor->advance(); + + return $values; + } + if ($cursor->isComment($line)) { + $cursor->skipComment(); + continue; + } + if ($line === '' || $cursor->keyOf($line) === self::DN) { + break; + } + + $directive = $cursor->readDirective(); + + if (!$directive->is($attr)) { + $cursor->errorAt( + $directive->position, + sprintf( + 'Mod-spec attribute "%s" does not match values for "%s"', + $directive->name, + $attr, + ), + ); + } + + $values[] = $directive->value; + } + + $cursor->error(sprintf( + 'Mod-spec for "%s" missing "-" terminator', + $attr, + )); + } + + /** + * @throws LdifParseException + */ + private function parseModRdnRecord( + LdifLineCursor $cursor, + string $dn, + ): ModifyDnRequest { + $newRdn = null; + $deleteOldRdn = null; + $newSuperior = null; + + while (($directive = $this->advanceToNextDirective($cursor)) !== null) { + $field = ModRdnDirective::tryFrom(strtolower($directive->name)); + + match ($field) { + ModRdnDirective::NewRdn => $newRdn = $this->assignFieldOnce( + $newRdn, + $directive, + $cursor, + ), + ModRdnDirective::DeleteOldRdn => $deleteOldRdn = $this->assignDeleteOldRdnOnce( + $deleteOldRdn, + $directive, + $cursor, + ), + ModRdnDirective::NewSuperior => $newSuperior = $this->assignFieldOnce( + $newSuperior, + $directive, + $cursor, + ), + null => $cursor->errorAt( + $directive->position, + sprintf( + 'Unexpected directive "%s:" in modrdn record', + $directive->name, + ), + ), + }; + } + + if ($newRdn === null) { + $cursor->error('Missing "newrdn:" in modrdn record'); + } + if ($deleteOldRdn === null) { + $cursor->error('Missing "deleteoldrdn:" in modrdn record'); + } + + return new ModifyDnRequest( + $dn, + $newRdn, + $deleteOldRdn, + $newSuperior, + ); + } + + /** + * @throws LdifParseException + */ + private function assignFieldOnce( + ?string $current, + LdifDirective $directive, + LdifLineCursor $cursor, + ): string { + if ($current !== null) { + $cursor->errorAt( + $directive->position, + sprintf( + 'Duplicate "%s:" in modrdn record', + strtolower($directive->name), + ), + ); + } + + return $directive->value; + } + + /** + * @throws LdifParseException + */ + private function assignDeleteOldRdnOnce( + ?bool $current, + LdifDirective $directive, + LdifLineCursor $cursor, + ): bool { + if ($current !== null) { + $cursor->errorAt( + $directive->position, + 'Duplicate "deleteoldrdn:" in modrdn record', + ); + } + if ($directive->value === '0') { + return false; + } + if ($directive->value === '1') { + return true; + } + + $cursor->errorAt( + $directive->position, + sprintf( + '"deleteoldrdn" must be 0 or 1, got "%s"', + $directive->value, + ), + ); + } + + /** + * @return array + * @throws LdifParseException + */ + private function readAttrvalBody(LdifLineCursor $cursor): array + { + $attributes = []; + + while (($directive = $this->advanceToNextDirective($cursor)) !== null) { + $attributes[$directive->name][] = $directive->value; + } + + return $attributes; + } + + /** + * @throws LdifParseException + */ + private function expectEndOfRecord( + LdifLineCursor $cursor, + string $changetype, + ): void { + while (!$cursor->atEnd()) { + $line = $cursor->current(); + + if ($line === '') { + return; + } + if ($cursor->isComment($line)) { + $cursor->skipComment(); + continue; + } + if ($cursor->keyOf($line) === self::DN) { + return; + } + + $cursor->error(sprintf( + 'Unexpected directive after "changetype: %s"', + $changetype, + )); + } + } + + /** + * Skips comments and returns the next directive, or null at end-of-record (blank line, dn:, or EOF). + * + * @throws LdifParseException + */ + private function advanceToNextDirective(LdifLineCursor $cursor): ?LdifDirective + { + while (!$cursor->atEnd()) { + $line = $cursor->current(); + + if ($line === '') { + return null; + } + if ($cursor->isComment($line)) { + $cursor->skipComment(); + continue; + } + if ($cursor->keyOf($line) === self::DN) { + return null; + } + + return $cursor->readDirective(); + } + + return null; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Parser/LdifDirective.php b/src/FreeDSx/Ldap/Ldif/Parser/LdifDirective.php new file mode 100644 index 00000000..346d4b71 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/LdifDirective.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Parser; + +use function strcasecmp; + +/** + * A name/value pair read from an LDIF directive line, paired with the cursor position it started at. + * + * @author Chad Sikorra + */ +final readonly class LdifDirective +{ + public function __construct( + public string $name, + public string $value, + public int $position, + ) {} + + /** + * Case-insensitive comparison of the directive name. + */ + public function is(string $name): bool + { + return strcasecmp($this->name, $name) === 0; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Parser/LdifLineCursor.php b/src/FreeDSx/Ldap/Ldif/Parser/LdifLineCursor.php new file mode 100644 index 00000000..1321ea6a --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/LdifLineCursor.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Parser; + +use FreeDSx\Ldap\Exception\LdifParseException; + +use function base64_decode; +use function count; +use function explode; +use function ltrim; +use function str_replace; +use function strpos; +use function strtolower; +use function substr; + +/** + * Cursor over LDIF lines exposing the shared low-level reading primitives. + * + * @author Chad Sikorra + */ +final class LdifLineCursor +{ + private const COMMENT = '#'; + + private const SEPARATOR = ':'; + + private const URL_MARKER = '<'; + + /** + * @param string[] $lines + */ + private function __construct( + private array $lines, + private int $pos = 0, + ) {} + + public static function forInput(string $ldif): self + { + return new self(explode( + "\n", + str_replace( + ["\r\n", "\r"], + "\n", + $ldif, + ), + )); + } + + public function atEnd(): bool + { + return $this->pos >= count($this->lines); + } + + public function current(): string + { + return $this->lines[$this->pos]; + } + + public function position(): int + { + return $this->pos; + } + + public function advance(): void + { + $this->pos++; + } + + /** + * Reads the directive at the cursor, consuming any folded continuation lines. + * + * @throws LdifParseException + */ + public function readDirective(): LdifDirective + { + $at = $this->pos; + $line = $this->lines[$at]; + $colon = strpos($line, self::SEPARATOR); + + if ($colon === false || $colon === 0) { + $this->errorAt( + $at, + 'Expected a "name: value" directive', + ); + } + + $name = substr($line, 0, $colon); + $marker = $line[$colon + 1] ?? ''; + $this->pos++; + + if ($marker === self::SEPARATOR) { + $value = $this->decodeBase64( + $this->readFolded(ltrim( + substr($line, $colon + 2), + ' ', + )), + $at, + ); + } elseif ($marker === self::URL_MARKER) { + $this->errorAt( + $at, + 'URL-referenced values ("name:< url") are not yet supported', + ); + } else { + $value = $this->readFolded(ltrim( + substr($line, $colon + 1), + ' ', + )); + } + + return new LdifDirective( + $name, + $value, + $at, + ); + } + + /** + * Appends any continuation lines (those beginning with a single space) to the value. + */ + public function readFolded(string $value): string + { + while ($this->isAtContinuation()) { + $value .= substr($this->current(), 1); + $this->pos++; + } + + return $value; + } + + public function skipComment(): void + { + $this->pos++; + + while ($this->isAtContinuation()) { + $this->pos++; + } + } + + public function isComment(string $line): bool + { + return ($line[0] ?? '') === self::COMMENT; + } + + /** + * A continuation line (RFC 2849 §2): a non-empty current line that begins with a single space. + */ + public function isAtContinuation(): bool + { + return !$this->atEnd() + && $this->current() !== '' + && $this->current()[0] === ' '; + } + + public function keyOf(string $line): string + { + $colon = strpos($line, self::SEPARATOR); + + return $colon === false + ? '' + : strtolower(substr($line, 0, $colon)); + } + + /** + * @throws LdifParseException + */ + public function decodeBase64( + string $raw, + int $at, + ): string { + $decoded = base64_decode($raw, true); + + if ($decoded === false) { + $this->errorAt( + $at, + 'A base64-encoded value is not valid', + ); + } + + return $decoded; + } + + /** + * @throws LdifParseException + */ + public function error(string $message): never + { + $this->errorAt( + $this->pos, + $message, + ); + } + + /** + * @throws LdifParseException + */ + public function errorAt( + int $at, + string $message, + ): never { + throw new LdifParseException( + $message, + $at + 1, + $this->lines[$at] ?? null, + ); + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Parser/ModRdnDirective.php b/src/FreeDSx/Ldap/Ldif/Parser/ModRdnDirective.php new file mode 100644 index 00000000..80698575 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/ModRdnDirective.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Parser; + +/** + * The set of directives permitted in a modrdn change record body. + * + * @author Chad Sikorra + */ +enum ModRdnDirective: string +{ + case NewRdn = 'newrdn'; + + case DeleteOldRdn = 'deleteoldrdn'; + + case NewSuperior = 'newsuperior'; +} diff --git a/src/FreeDSx/Ldap/Ldif/Parser/ModSpecOp.php b/src/FreeDSx/Ldap/Ldif/Parser/ModSpecOp.php new file mode 100644 index 00000000..322b3f9a --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Parser/ModSpecOp.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Parser; + +/** + * The set of LDIF modify mod-spec operations. + * + * @author Chad Sikorra + */ +enum ModSpecOp: string +{ + case Add = 'add'; + + case Delete = 'delete'; + + case Replace = 'replace'; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestReplayer.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestReplayer.php new file mode 100644 index 00000000..0a2bab21 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteRequestReplayer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write; + +use FreeDSx\Ldap\Control\ControlBag; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\RequestInterface; +use FreeDSx\Ldap\Server\Token\SystemToken; + +/** + * Replays a sequence of client write requests against a backend as system-initiated operations. + * + * @author Chad Sikorra + */ +final class WriteRequestReplayer +{ + private readonly WriteOperationDispatcher $dispatcher; + + /** + * @param WriteHandlerInterface[] $writeHandlers Additional handlers tried before the backend. + */ + public function __construct( + WriteHandlerInterface $backend, + array $writeHandlers = [], + private readonly WriteCommandFactory $commandFactory = new WriteCommandFactory(), + ) { + $writeHandlers[] = $backend; + $this->dispatcher = new WriteOperationDispatcher(...$writeHandlers); + } + + /** + * @param iterable $requests + * @throws OperationException + */ + public function apply(iterable $requests): void + { + $context = WriteContext::system( + new SystemToken(), + new ControlBag(), + ); + + foreach ($requests as $request) { + $this->dispatcher->dispatch( + $this->commandFactory->fromRequest($request), + $context, + ); + } + } +} diff --git a/tests/integration/Ldif/LdapApplyChangesServerTest.php b/tests/integration/Ldif/LdapApplyChangesServerTest.php new file mode 100644 index 00000000..b0ba3b3e --- /dev/null +++ b/tests/integration/Ldif/LdapApplyChangesServerTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Ldif; + +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; + +final class LdapApplyChangesServerTest extends ServerTestCase +{ + private const SEED_LDIF = __DIR__ . '/../../resources/seed/seed-test.ldif'; + + private const CHANGES_LDIF = __DIR__ . '/../../resources/changes/apply-test.ldif'; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer( + 'ldap-server', + 'tcp', + [ + '--seed=' . self::SEED_LDIF, + '--changes=' . self::CHANGES_LDIF, + ], + ); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + } + + public function setUp(): void + { + $this->setServerMode('ldap-server'); + + parent::setUp(); + + $this->authenticate(); + } + + public function test_a_modify_change_replaces_the_attribute_value(): void + { + $alice = $this->ldapClient()->read('cn=alice,dc=foo,dc=bar'); + + $this->assertNotNull($alice); + $this->assertSame( + 'Renamed', + $alice->get('sn')?->firstValue(), + ); + } + + public function test_a_delete_change_removes_the_entry(): void + { + $this->assertNull($this->ldapClient()->read('cn=bob,dc=foo,dc=bar')); + } + + public function test_a_modrdn_change_renames_the_entry(): void + { + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('objectClass', 'inetOrgPerson')) + ->base('dc=foo,dc=bar'), + ); + + $dns = []; + foreach ($entries as $entry) { + $dns[] = $entry->getDn()->toString(); + } + + $this->assertContains( + 'cn=carolyn,dc=foo,dc=bar', + $dns, + ); + $this->assertNotContains( + 'cn=carol,dc=foo,dc=bar', + $dns, + ); + } + + public function test_the_renamed_entry_carries_the_added_attributes(): void + { + $carolyn = $this->ldapClient()->read('cn=carolyn,dc=foo,dc=bar'); + + $this->assertNotNull($carolyn); + $this->assertSame( + 'Coder', + $carolyn->get('sn')?->firstValue(), + ); + } +} diff --git a/tests/resources/changes/apply-test.ldif b/tests/resources/changes/apply-test.ldif new file mode 100644 index 00000000..32497c22 --- /dev/null +++ b/tests/resources/changes/apply-test.ldif @@ -0,0 +1,25 @@ +version: 1 + +# Modify alice: replace sn +dn: cn=alice,dc=foo,dc=bar +changetype: modify +replace: sn +sn: Renamed +- + +# Delete bob +dn: cn=bob,dc=foo,dc=bar +changetype: delete + +# Add a new entry that will then be renamed below +dn: cn=carol,dc=foo,dc=bar +changetype: add +objectClass: inetOrgPerson +cn: carol +sn: Coder + +# Rename carol -> carolyn +dn: cn=carol,dc=foo,dc=bar +changetype: modrdn +newrdn: cn=carolyn +deleteoldrdn: 1 diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 2e28bf44..d4839e43 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -75,6 +75,13 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'Load directory data from an LDIF file via LdapServer::seed() instead of the built-in entries', '', + ) + ->addOption( + 'changes', + null, + InputOption::VALUE_REQUIRED, + 'After seeding, replay an LDIF changelog file via LdapServer::applyChanges()', + '', ); } @@ -89,6 +96,7 @@ protected function execute( $sasl = $input->getOption('sasl') === true; $allowAnonymous = $input->getOption('allow-anonymous') === true; $seedFile = $this->getStringOption($input, 'seed'); + $changesFile = $this->getStringOption($input, 'changes'); $useSsl = false; if (!in_array($storageType, self::VALID_STORAGE, true)) { @@ -147,6 +155,10 @@ protected function execute( (new LdapImporter($storage))->importEntries($entries); } + if ($changesFile !== '') { + $server->applyChanges(new FileLdifLoader($changesFile)); + } + $server->run(); return Command::SUCCESS; diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index 8a823dd5..db91098c 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -265,4 +265,52 @@ public function test_it_should_throw_when_seeding_without_a_storage_backend(): v $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); } + + public function test_it_should_reject_seeding_when_the_ldif_contains_change_records(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('only accepts content records'); + + $this->subject->seed(new StringLdifLoader("dn: cn=any,dc=x\nchangetype: delete\n")); + } + + public function test_it_should_apply_modify_changes_against_the_seeded_storage(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $this->subject->applyChanges(new StringLdifLoader( + "dn: cn=foo,dc=example,dc=com\nchangetype: modify\nreplace: sn\nsn: Updated\n-\n", + )); + + self::assertSame( + ['Updated'], + $storage->find(new Dn('cn=foo,dc=example,dc=com'))?->get('sn')?->getValues(), + ); + } + + public function test_it_should_apply_a_delete_change_against_the_seeded_storage(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $this->subject->applyChanges(new StringLdifLoader( + "dn: cn=foo,dc=example,dc=com\nchangetype: delete\n", + )); + + self::assertNull($storage->find(new Dn('cn=foo,dc=example,dc=com'))); + } + + public function test_it_should_throw_when_applying_changes_without_a_writable_backend(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires a writable backend'); + + $this->subject->applyChanges(new StringLdifLoader("dn: cn=x,dc=x\nchangetype: delete\n")); + } } diff --git a/tests/unit/Ldif/LdifChangesTest.php b/tests/unit/Ldif/LdifChangesTest.php new file mode 100644 index 00000000..8af7b298 --- /dev/null +++ b/tests/unit/Ldif/LdifChangesTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Ldif; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Ldif\LdifChanges; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; +use FreeDSx\Ldap\Operations; +use PHPUnit\Framework\TestCase; + +final class LdifChangesTest extends TestCase +{ + public function test_it_counts_and_iterates_in_construction_order(): void + { + $add = Operations::add(Entry::create('cn=a,dc=x', ['cn' => 'a'])); + $del = Operations::delete('cn=b,dc=x'); + + $changes = new LdifChanges( + $add, + $del, + ); + + self::assertCount( + 2, + $changes, + ); + self::assertSame( + [$add, $del], + $changes->toArray(), + ); + self::assertSame( + [$add, $del], + iterator_to_array($changes->getIterator()), + ); + } + + public function test_type_filters_split_by_request_class(): void + { + $add = Operations::add(Entry::create('cn=a,dc=x', ['cn' => 'a'])); + $modify = Operations::modify( + 'cn=a,dc=x', + Change::replace('sn', 'Z'), + ); + $delete = Operations::delete('cn=b,dc=x'); + $modDn = new ModifyDnRequest( + 'cn=c,dc=x', + 'cn=cc', + true, + ); + + $changes = new LdifChanges( + $add, + $modify, + $delete, + $modDn, + ); + + self::assertSame( + [$add], + $changes->adds(), + ); + self::assertSame( + [$modify], + $changes->modifies(), + ); + self::assertSame( + [$delete], + $changes->deletes(), + ); + self::assertSame( + [$modDn], + $changes->modifyDns(), + ); + } + + public function test_isAddOnly_is_true_when_every_request_is_an_add(): void + { + $changes = new LdifChanges( + Operations::add(Entry::create('cn=a,dc=x', ['cn' => 'a'])), + Operations::add(Entry::create('cn=b,dc=x', ['cn' => 'b'])), + ); + + self::assertTrue($changes->isAddOnly()); + } + + public function test_isAddOnly_is_false_when_any_request_is_not_an_add(): void + { + $changes = new LdifChanges( + Operations::add(Entry::create('cn=a,dc=x', ['cn' => 'a'])), + Operations::delete('cn=b,dc=x'), + ); + + self::assertFalse($changes->isAddOnly()); + } + + public function test_isAddOnly_is_true_for_an_empty_collection(): void + { + self::assertTrue((new LdifChanges())->isAddOnly()); + } + + public function test_entries_extracts_entry_from_every_add_request_ignoring_others(): void + { + $foo = Entry::create('cn=foo,dc=x', ['cn' => 'foo']); + $bar = Entry::create('cn=bar,dc=x', ['cn' => 'bar']); + + $changes = new LdifChanges( + Operations::add($foo), + Operations::delete('cn=zap,dc=x'), + Operations::add($bar), + ); + + self::assertSame( + [$foo, $bar], + $changes->entries(), + ); + } +} diff --git a/tests/unit/Ldif/LdifParserTest.php b/tests/unit/Ldif/LdifParserTest.php index e39baaac..3809451f 100644 --- a/tests/unit/Ldif/LdifParserTest.php +++ b/tests/unit/Ldif/LdifParserTest.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Exception\LdifParseException; use FreeDSx\Ldap\Ldif\LdifParser; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\ModifyRequest; use PHPUnit\Framework\TestCase; final class LdifParserTest extends TestCase @@ -26,36 +28,45 @@ protected function setUp(): void $this->subject = new LdifParser(); } - public function test_it_parses_a_single_entry_with_multi_valued_attributes(): void + public function test_it_parses_a_single_content_record_with_multi_valued_attributes(): void { - $entries = $this->subject->parse( + $result = $this->subject->parse( "dn: cn=foo,dc=example,dc=com\nobjectClass: top\nobjectClass: person\ncn: foo\nsn: Bar\n", ); - self::assertCount(1, $entries); - $entry = $entries->toArray()[0]; - self::assertSame('cn=foo,dc=example,dc=com', $entry->getDn()->toString()); + self::assertCount(1, $result); + $entry = $result->entries()[0]; + self::assertSame( + 'cn=foo,dc=example,dc=com', + $entry->getDn()->toString(), + ); self::assertSame( ['top', 'person'], $entry->get('objectClass')?->getValues(), ); - self::assertSame(['Bar'], $entry->get('sn')?->getValues()); + self::assertSame( + ['Bar'], + $entry->get('sn')?->getValues(), + ); } - public function test_it_parses_multiple_entries_separated_by_blank_lines(): void + public function test_it_parses_multiple_records_separated_by_blank_lines(): void { - $entries = $this->subject->parse( + $result = $this->subject->parse( "dn: cn=a,dc=x\ncn: a\n\ndn: cn=b,dc=x\ncn: b\n", ); - self::assertCount(2, $entries); + self::assertCount( + 2, + $result, + ); } public function test_it_unfolds_continued_lines(): void { $entry = $this->subject->parse( "dn: cn=foo,dc=x\ndescription: this is a long\n description value\n", - )->toArray()[0]; + )->entries()[0]; self::assertSame( ['this is a long description value'], @@ -67,28 +78,37 @@ public function test_it_decodes_a_base64_value(): void { $entry = $this->subject->parse( "dn: cn=foo,dc=x\ncn:: " . base64_encode('Bär') . "\n", - )->toArray()[0]; + )->entries()[0]; - self::assertSame(['Bär'], $entry->get('cn')?->getValues()); + self::assertSame( + ['Bär'], + $entry->get('cn')?->getValues(), + ); } public function test_it_decodes_a_base64_dn(): void { $entry = $this->subject->parse( "dn:: " . base64_encode('cn=Bär,dc=x') . "\ncn: x\n", - )->toArray()[0]; + )->entries()[0]; - self::assertSame('cn=Bär,dc=x', $entry->getDn()->toString()); + self::assertSame( + 'cn=Bär,dc=x', + $entry->getDn()->toString(), + ); } public function test_it_skips_comments_including_folded_ones(): void { - $entries = $this->subject->parse( + $result = $this->subject->parse( "# a top comment\ndn: cn=foo,dc=x\n# inline comment\n# folded\n more comment\ncn: foo\n", ); - self::assertCount(1, $entries); - self::assertSame(['foo'], $entries->toArray()[0]->get('cn')?->getValues()); + self::assertCount(1, $result); + self::assertSame( + ['foo'], + $result->entries()[0]->get('cn')?->getValues(), + ); } public function test_it_accepts_a_version_one_header(): void @@ -106,19 +126,30 @@ public function test_it_rejects_an_unsupported_version(): void $this->subject->parse("version: 2\ndn: cn=foo,dc=x\ncn: foo\n"); } - public function test_it_rejects_a_version_after_an_entry(): void + public function test_it_rejects_a_version_after_a_record(): void { $this->expectException(LdifParseException::class); $this->subject->parse("dn: cn=foo,dc=x\ncn: foo\n\nversion: 1\n"); } - public function test_it_rejects_change_records(): void + public function test_it_parses_a_mixed_file_with_content_and_change_records(): void { - $this->expectException(LdifParseException::class); - $this->expectExceptionMessage('change records'); + $result = $this->subject->parse( + "dn: cn=foo,dc=x\ncn: foo\nsn: Bar\n" + . "\n" + . "dn: cn=baz,dc=x\nchangetype: modify\nreplace: sn\nsn: Quux\n-\n", + ); - $this->subject->parse("dn: cn=foo,dc=x\nchangetype: add\ncn: foo\n"); + self::assertCount(2, $result); + self::assertInstanceOf( + AddRequest::class, + $result->toArray()[0], + ); + self::assertInstanceOf( + ModifyRequest::class, + $result->toArray()[1], + ); } public function test_it_rejects_url_referenced_values(): void @@ -136,18 +167,24 @@ public function test_it_reports_the_line_number_of_a_malformed_line(): void self::fail('Expected an LdifParseException.'); } catch (LdifParseException $e) { self::assertSame(3, $e->getLineNumber()); - self::assertSame('this-has-no-colon', $e->getSourceLine()); + self::assertSame( + 'this-has-no-colon', + $e->getSourceLine(), + ); } } public function test_it_parses_an_empty_value(): void { - $entry = $this->subject->parse("dn: cn=foo,dc=x\ndescription:\n")->toArray()[0]; + $entry = $this->subject->parse("dn: cn=foo,dc=x\ndescription:\n")->entries()[0]; - self::assertSame([''], $entry->get('description')?->getValues()); + self::assertSame( + [''], + $entry->get('description')?->getValues(), + ); } - public function test_it_returns_no_entries_for_empty_input(): void + public function test_it_returns_no_records_for_empty_input(): void { self::assertCount(0, $this->subject->parse('')); } diff --git a/tests/unit/Ldif/LdifWriterTest.php b/tests/unit/Ldif/LdifWriterTest.php index f84bb4ec..656f5217 100644 --- a/tests/unit/Ldif/LdifWriterTest.php +++ b/tests/unit/Ldif/LdifWriterTest.php @@ -107,7 +107,7 @@ public function test_it_round_trips_with_the_parser(): void ); $ldif = (new LdifWriter())->write($entries); - $parsed = (new LdifParser())->parse($ldif); + $parsed = new Entries(...(new LdifParser())->parse($ldif)->entries()); self::assertSame( $this->normalize($entries), diff --git a/tests/unit/Ldif/Parser/LdifChangeRecordParserTest.php b/tests/unit/Ldif/Parser/LdifChangeRecordParserTest.php new file mode 100644 index 00000000..76213f15 --- /dev/null +++ b/tests/unit/Ldif/Parser/LdifChangeRecordParserTest.php @@ -0,0 +1,300 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Ldif\Parser; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Exception\LdifParseException; +use FreeDSx\Ldap\Ldif\LdifParser; +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 PHPUnit\Framework\TestCase; + +final class LdifChangeRecordParserTest extends TestCase +{ + private LdifParser $subject; + + protected function setUp(): void + { + $this->subject = new LdifParser(); + } + + public function test_it_parses_a_changetype_add_record_into_an_add_request(): void + { + $result = $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: add\nobjectClass: top\nobjectClass: person\ncn: alice\nsn: A\n", + ); + + self::assertCount( + 1, + $result, + ); + $request = $result->toArray()[0]; + self::assertInstanceOf( + AddRequest::class, + $request, + ); + self::assertSame( + 'cn=alice,dc=x', + $request->getEntry()->getDn()->toString(), + ); + self::assertSame( + ['A'], + $request->getEntry()->get('sn')?->getValues(), + ); + } + + public function test_it_parses_a_changetype_delete_record_into_a_delete_request(): void + { + $result = $this->subject->parse("dn: cn=bob,dc=x\nchangetype: delete\n"); + + self::assertCount( + 1, + $result, + ); + $request = $result->toArray()[0]; + self::assertInstanceOf( + DeleteRequest::class, + $request, + ); + self::assertSame( + 'cn=bob,dc=x', + $request->getDn()->toString(), + ); + } + + public function test_it_rejects_content_after_a_delete_changetype(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('Unexpected directive after "changetype: delete"'); + + $this->subject->parse("dn: cn=bob,dc=x\nchangetype: delete\ncn: trailing\n"); + } + + public function test_it_parses_a_modify_record_with_a_single_replace_modspec(): void + { + $result = $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modify\nreplace: sn\nsn: Anderson\n-\n", + ); + + $request = $result->toArray()[0]; + self::assertInstanceOf( + ModifyRequest::class, + $request, + ); + self::assertSame( + 'cn=alice,dc=x', + $request->getDn()->toString(), + ); + self::assertCount( + 1, + $request->getChanges(), + ); + $change = $request->getChanges()[0]; + self::assertSame( + Change::TYPE_REPLACE, + $change->getType(), + ); + self::assertSame( + ['Anderson'], + $change->getAttribute()->getValues(), + ); + } + + public function test_it_parses_a_modify_record_with_multiple_modspecs_terminated_by_dash(): void + { + $result = $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modify\n" + . "add: telephoneNumber\ntelephoneNumber: 555-0100\n-\n" + . "delete: description\n-\n" + . "replace: sn\nsn: Anderson\n-\n", + ); + + $request = $result->toArray()[0]; + self::assertInstanceOf( + ModifyRequest::class, + $request, + ); + $changes = $request->getChanges(); + self::assertCount( + 3, + $changes, + ); + self::assertSame( + Change::TYPE_ADD, + $changes[0]->getType(), + ); + self::assertSame( + 'telephoneNumber', + $changes[0]->getAttribute()->getName(), + ); + self::assertSame( + Change::TYPE_DELETE, + $changes[1]->getType(), + ); + self::assertSame( + 'description', + $changes[1]->getAttribute()->getName(), + ); + self::assertSame( + [], + $changes[1]->getAttribute()->getValues(), + ); + self::assertSame( + Change::TYPE_REPLACE, + $changes[2]->getType(), + ); + } + + public function test_it_parses_a_modify_modspec_deleting_a_specific_value(): void + { + $result = $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modify\ndelete: telephoneNumber\ntelephoneNumber: 555-0100\n-\n", + ); + + $request = $result->toArray()[0]; + self::assertInstanceOf( + ModifyRequest::class, + $request, + ); + $change = $request->getChanges()[0]; + self::assertSame( + Change::TYPE_DELETE, + $change->getType(), + ); + self::assertSame( + ['555-0100'], + $change->getAttribute()->getValues(), + ); + } + + public function test_it_rejects_a_modspec_without_a_dash_terminator(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('missing "-" terminator'); + + $this->subject->parse("dn: cn=alice,dc=x\nchangetype: modify\nreplace: sn\nsn: Anderson\n"); + } + + public function test_it_rejects_a_modspec_value_with_a_mismatched_attribute(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('does not match values for'); + + $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modify\nreplace: sn\ncn: not-sn\n-\n", + ); + } + + public function test_it_rejects_an_unknown_modspec_op(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('Expected an add:, delete:, or replace:'); + + $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modify\nbogus: sn\nsn: x\n-\n", + ); + } + + public function test_it_parses_a_modrdn_record_without_newsuperior(): void + { + $result = $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modrdn\nnewrdn: cn=alicia\ndeleteoldrdn: 1\n", + ); + + $request = $result->toArray()[0]; + self::assertInstanceOf( + ModifyDnRequest::class, + $request, + ); + self::assertSame( + 'cn=alice,dc=x', + $request->getDn()->toString(), + ); + self::assertSame( + 'cn=alicia', + $request->getNewRdn()->toString(), + ); + self::assertTrue($request->getDeleteOldRdn()); + self::assertNull($request->getNewParentDn()); + } + + public function test_it_parses_a_modrdn_record_with_newsuperior_and_deleteoldrdn_zero(): void + { + $result = $this->subject->parse( + "dn: cn=alice,ou=old,dc=x\nchangetype: modrdn\nnewrdn: cn=alicia\ndeleteoldrdn: 0\nnewsuperior: ou=new,dc=x\n", + ); + + $request = $result->toArray()[0]; + self::assertInstanceOf( + ModifyDnRequest::class, + $request, + ); + self::assertFalse($request->getDeleteOldRdn()); + self::assertSame( + 'ou=new,dc=x', + $request->getNewParentDn()?->toString(), + ); + } + + public function test_it_decodes_a_base64_newrdn(): void + { + $ldif = "dn: cn=foo,dc=x\nchangetype: modrdn\nnewrdn:: " . base64_encode('cn=Bär') . "\ndeleteoldrdn: 1\n"; + + $request = $this->subject->parse($ldif)->toArray()[0]; + self::assertInstanceOf( + ModifyDnRequest::class, + $request, + ); + self::assertSame( + 'cn=Bär', + $request->getNewRdn()->toString(), + ); + } + + public function test_it_rejects_a_modrdn_record_missing_newrdn(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('Missing "newrdn:"'); + + $this->subject->parse("dn: cn=alice,dc=x\nchangetype: modrdn\ndeleteoldrdn: 1\n"); + } + + public function test_it_rejects_a_modrdn_record_missing_deleteoldrdn(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('Missing "deleteoldrdn:"'); + + $this->subject->parse("dn: cn=alice,dc=x\nchangetype: modrdn\nnewrdn: cn=alicia\n"); + } + + public function test_it_rejects_deleteoldrdn_that_is_not_zero_or_one(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('must be 0 or 1'); + + $this->subject->parse( + "dn: cn=alice,dc=x\nchangetype: modrdn\nnewrdn: cn=alicia\ndeleteoldrdn: 2\n", + ); + } + + public function test_it_rejects_an_unknown_changetype(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('Unsupported changetype "bogus"'); + + $this->subject->parse("dn: cn=alice,dc=x\nchangetype: bogus\n"); + } +}