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"); + } +}