diff --git a/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php b/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php index 319e68a2..1e2bc0b8 100644 --- a/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php +++ b/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php @@ -28,6 +28,8 @@ final class LdifOutputOptions private string $lineEnding = "\n"; + private bool $emitChangetypeForAdds = false; + public function isIncludeVersion(): bool { return $this->includeVersion; @@ -75,4 +77,16 @@ public function setLineEnding(string $lineEnding): self return $this; } + + public function isEmitChangetypeForAdds(): bool + { + return $this->emitChangetypeForAdds; + } + + public function setEmitChangetypeForAdds(bool $emitChangetypeForAdds): self + { + $this->emitChangetypeForAdds = $emitChangetypeForAdds; + + return $this; + } } diff --git a/src/FreeDSx/Ldap/Ldif/LdifWriter.php b/src/FreeDSx/Ldap/Ldif/LdifWriter.php index 60e80317..52a8d848 100644 --- a/src/FreeDSx/Ldap/Ldif/LdifWriter.php +++ b/src/FreeDSx/Ldap/Ldif/LdifWriter.php @@ -14,7 +14,16 @@ namespace FreeDSx\Ldap\Ldif; use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Ldif\Parser\ChangeType; +use FreeDSx\Ldap\Ldif\Parser\ModSpecOp; +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 LogicException; use function array_map; use function array_merge; @@ -23,12 +32,13 @@ use function implode; use function max; use function preg_match; +use function sprintf; use function str_split; use function strlen; use function substr; /** - * Serializes entries to RFC 2849 LDIF content records. + * Serializes write requests to RFC 2849 LDIF (content and change records). * * @author Chad Sikorra */ @@ -37,14 +47,15 @@ public function __construct(private LdifOutputOptions $options = new LdifOutputOptions()) {} /** - * @param iterable $entries + * @param iterable $requests + * @throws InvalidArgumentException when a request type is not serializable to LDIF */ - public function write(iterable $entries): string + public function write(iterable $requests): string { $blocks = []; - foreach ($entries as $entry) { - $blocks[] = $this->entryBlock($entry); + foreach ($requests as $request) { + $blocks[] = $this->requestBlock($request); } $body = implode($this->options->getLineEnding(), $blocks); @@ -54,10 +65,37 @@ public function write(iterable $entries): string : $body; } - private function entryBlock(Entry $entry): string + /** + * @throws InvalidArgumentException + */ + private function requestBlock(RequestInterface $request): string + { + return match (true) { + $request instanceof AddRequest => $this->addBlock($request), + $request instanceof DeleteRequest => $this->deleteBlock($request), + $request instanceof ModifyRequest => $this->modifyBlock($request), + $request instanceof ModifyDnRequest => $this->modRdnBlock($request), + default => throw new InvalidArgumentException(sprintf( + 'Unsupported request type for LDIF output: %s', + $request::class, + )), + }; + } + + private function addBlock(AddRequest $request): string { + $entry = $request->getEntry(); + $prelude = [$this->line('dn', $entry->getDn()->toString())]; + + if ($this->options->isEmitChangetypeForAdds()) { + $prelude[] = $this->line( + 'changetype', + ChangeType::Add->value, + ); + } + $lines = array_merge( - [$this->line('dn', $entry->getDn()->toString())], + $prelude, ...array_map( $this->attributeLines(...), $entry->getAttributes(), @@ -67,6 +105,85 @@ private function entryBlock(Entry $entry): string return implode($this->options->getLineEnding(), $lines) . $this->options->getLineEnding(); } + private function deleteBlock(DeleteRequest $request): string + { + $lines = [ + $this->line('dn', $request->getDn()->toString()), + $this->line('changetype', ChangeType::Delete->value), + ]; + + return implode($this->options->getLineEnding(), $lines) . $this->options->getLineEnding(); + } + + private function modifyBlock(ModifyRequest $request): string + { + $lines = [ + $this->line('dn', $request->getDn()->toString()), + $this->line('changetype', ChangeType::Modify->value), + ]; + + foreach ($request->getChanges() as $change) { + $lines = array_merge( + $lines, + $this->modSpecLines($change), + ); + } + + return implode($this->options->getLineEnding(), $lines) . $this->options->getLineEnding(); + } + + /** + * @return list + */ + private function modSpecLines(Change $change): array + { + $op = match ($change->getType()) { + Change::TYPE_ADD => ModSpecOp::Add->value, + Change::TYPE_DELETE => ModSpecOp::Delete->value, + Change::TYPE_REPLACE => ModSpecOp::Replace->value, + default => throw new LogicException(sprintf( + 'Unknown Change type %d.', + $change->getType(), + )), + }; + $attribute = $change->getAttribute(); + $attrName = $attribute->getDescription(); + + $lines = [$this->line($op, $attrName)]; + + foreach ($attribute->getValues() as $value) { + $lines[] = $this->line( + $attrName, + $value, + ); + } + + $lines[] = '-'; + + return $lines; + } + + private function modRdnBlock(ModifyDnRequest $request): string + { + $lines = [ + $this->line('dn', $request->getDn()->toString()), + $this->line('changetype', ChangeType::ModRdn->value), + $this->line('newrdn', $request->getNewRdn()->toString()), + $this->line('deleteoldrdn', $request->getDeleteOldRdn() ? '1' : '0'), + ]; + + $newParent = $request->getNewParentDn(); + + if ($newParent !== null) { + $lines[] = $this->line( + 'newsuperior', + $newParent->toString(), + ); + } + + return implode($this->options->getLineEnding(), $lines) . $this->options->getLineEnding(); + } + /** * @return list */ diff --git a/tests/unit/Ldif/LdifWriterTest.php b/tests/unit/Ldif/LdifWriterTest.php index 656f5217..6548767f 100644 --- a/tests/unit/Ldif/LdifWriterTest.php +++ b/tests/unit/Ldif/LdifWriterTest.php @@ -13,44 +13,101 @@ namespace Tests\Unit\FreeDSx\Ldap\Ldif; -use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Ldif\LdifChanges; use FreeDSx\Ldap\Ldif\LdifOutputOptions; use FreeDSx\Ldap\Ldif\LdifParser; use FreeDSx\Ldap\Ldif\LdifWriter; +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 PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class LdifWriterTest extends TestCase { - public function test_it_writes_an_entry_with_a_version_header(): void + public function test_it_writes_an_add_request_with_a_version_header(): void { $ldif = (new LdifWriter())->write([ - Entry::create('cn=foo,dc=x', ['cn' => 'foo', 'sn' => 'Bar']), + Operations::add(Entry::create( + 'cn=foo,dc=x', + [ + 'cn' => 'foo', + 'sn' => 'Bar', + ], + )), ]); - self::assertStringStartsWith("version: 1\n\ndn: cn=foo,dc=x\n", $ldif); - self::assertStringContainsString("\ncn: foo\n", $ldif); - self::assertStringContainsString("\nsn: Bar\n", $ldif); + self::assertStringStartsWith( + "version: 1\n\ndn: cn=foo,dc=x\n", + $ldif, + ); + self::assertStringContainsString( + "\ncn: foo\n", + $ldif, + ); + self::assertStringContainsString( + "\nsn: Bar\n", + $ldif, + ); } public function test_it_omits_the_version_header_when_disabled(): void { - $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ - Entry::create('cn=foo,dc=x', ['cn' => 'foo']), + $ldif = $this->writer()->write([ + Operations::add(Entry::create('cn=foo,dc=x', ['cn' => 'foo'])), ]); - self::assertStringStartsWith('dn: cn=foo,dc=x', $ldif); + self::assertStringStartsWith( + 'dn: cn=foo,dc=x', + $ldif, + ); + } + + public function test_an_add_request_emits_as_a_content_record_by_default(): void + { + $ldif = $this->writer()->write([ + Operations::add(Entry::create('cn=foo,dc=x', ['cn' => 'foo'])), + ]); + + self::assertStringNotContainsString( + 'changetype:', + $ldif, + ); + } + + public function test_an_add_request_emits_changetype_add_when_the_option_is_enabled(): void + { + $options = (new LdifOutputOptions()) + ->setIncludeVersion(false) + ->setEmitChangetypeForAdds(true); + + $ldif = (new LdifWriter($options))->write([ + Operations::add(Entry::create('cn=foo,dc=x', ['cn' => 'foo'])), + ]); + + self::assertStringStartsWith( + "dn: cn=foo,dc=x\nchangetype: add\n", + $ldif, + ); } #[DataProvider('unsafeValues')] public function test_it_base64_encodes_values_that_are_not_safe_strings(string $value): void { - $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ - Entry::create('cn=foo,dc=x', ['cn' => $value]), + $ldif = $this->writer()->write([ + Operations::add(Entry::create('cn=foo,dc=x', ['cn' => $value])), ]); - self::assertStringContainsString('cn:: ' . base64_encode($value), $ldif); + self::assertStringContainsString( + 'cn:: ' . base64_encode($value), + $ldif, + ); } /** @@ -70,11 +127,14 @@ public static function unsafeValues(): array public function test_it_writes_a_safe_value_in_plain_form(): void { - $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ - Entry::create('cn=foo,dc=x', ['cn' => 'plainValue']), + $ldif = $this->writer()->write([ + Operations::add(Entry::create('cn=foo,dc=x', ['cn' => 'plainValue'])), ]); - self::assertStringContainsString("cn: plainValue\n", $ldif); + self::assertStringContainsString( + "cn: plainValue\n", + $ldif, + ); } public function test_it_folds_lines_longer_than_the_max_length(): void @@ -82,54 +142,282 @@ public function test_it_folds_lines_longer_than_the_max_length(): void $options = (new LdifOutputOptions()) ->setIncludeVersion(false) ->setMaxLineLength(20); + $ldif = (new LdifWriter($options))->write([ - Entry::create('cn=foo,dc=x', ['description' => str_repeat('a', 60)]), + Operations::add(Entry::create( + 'cn=foo,dc=x', + ['description' => str_repeat('a', 60)], + )), ]); - self::assertStringContainsString("\n ", $ldif); + self::assertStringContainsString( + "\n ", + $ldif, + ); foreach (explode("\n", $ldif) as $line) { - self::assertLessThanOrEqual(20, strlen($line)); + self::assertLessThanOrEqual( + 20, + strlen($line), + ); } } - public function test_it_round_trips_with_the_parser(): void + public function test_it_writes_a_delete_request(): void { - $entries = new Entries( - Entry::create('cn=foo,dc=example,dc=com', [ + $ldif = $this->writer()->write([ + Operations::delete('cn=foo,dc=x'), + ]); + + self::assertSame( + "dn: cn=foo,dc=x\nchangetype: delete\n", + $ldif, + ); + } + + public function test_it_writes_a_modify_request_with_a_single_replace_modspec(): void + { + $ldif = $this->writer()->write([ + Operations::modify( + 'cn=alice,dc=x', + Change::replace('sn', 'Anderson'), + ), + ]); + + self::assertSame( + "dn: cn=alice,dc=x\nchangetype: modify\nreplace: sn\nsn: Anderson\n-\n", + $ldif, + ); + } + + public function test_it_writes_a_modify_request_with_multiple_modspecs_in_order(): void + { + $ldif = $this->writer()->write([ + Operations::modify( + 'cn=alice,dc=x', + Change::add('telephoneNumber', '555-0100'), + Change::delete('description'), + Change::replace('sn', 'Anderson'), + ), + ]); + + self::assertSame( + "dn: cn=alice,dc=x\n" + . "changetype: modify\n" + . "add: telephoneNumber\n" + . "telephoneNumber: 555-0100\n" + . "-\n" + . "delete: description\n" + . "-\n" + . "replace: sn\n" + . "sn: Anderson\n" + . "-\n", + $ldif, + ); + } + + public function test_a_modspec_with_no_values_emits_only_the_op_and_terminator(): void + { + $ldif = $this->writer()->write([ + Operations::modify( + 'cn=alice,dc=x', + Change::delete('description'), + ), + ]); + + self::assertStringContainsString( + "delete: description\n-\n", + $ldif, + ); + } + + public function test_it_writes_a_modrdn_request_with_newsuperior(): void + { + $ldif = $this->writer()->write([ + new ModifyDnRequest( + 'cn=alice,ou=old,dc=x', + 'cn=alicia', + false, + 'ou=new,dc=x', + ), + ]); + + self::assertSame( + "dn: cn=alice,ou=old,dc=x\n" + . "changetype: modrdn\n" + . "newrdn: cn=alicia\n" + . "deleteoldrdn: 0\n" + . "newsuperior: ou=new,dc=x\n", + $ldif, + ); + } + + public function test_it_writes_a_modrdn_request_without_newsuperior(): void + { + $ldif = $this->writer()->write([ + new ModifyDnRequest( + 'cn=alice,dc=x', + 'cn=alicia', + true, + ), + ]); + + self::assertSame( + "dn: cn=alice,dc=x\n" + . "changetype: modrdn\n" + . "newrdn: cn=alicia\n" + . "deleteoldrdn: 1\n", + $ldif, + ); + } + + public function test_a_modrdn_request_base64_encodes_a_non_ascii_newrdn(): void + { + $ldif = $this->writer()->write([ + new ModifyDnRequest( + 'cn=foo,dc=x', + 'cn=Bär', + true, + ), + ]); + + self::assertStringContainsString( + 'newrdn:: ' . base64_encode('cn=Bär') . "\n", + $ldif, + ); + } + + public function test_it_preserves_input_order_across_mixed_request_types(): void + { + $ldif = $this->writer()->write([ + Operations::add(Entry::create('cn=a,dc=x', ['cn' => 'a'])), + Operations::delete('cn=b,dc=x'), + Operations::modify( + 'cn=c,dc=x', + Change::replace('sn', 'C'), + ), + ]); + + $aPos = strpos($ldif, 'dn: cn=a,dc=x'); + $bPos = strpos($ldif, 'dn: cn=b,dc=x'); + $cPos = strpos($ldif, 'dn: cn=c,dc=x'); + + self::assertNotFalse($aPos); + self::assertLessThan( + $bPos, + $aPos, + ); + self::assertLessThan( + $cPos, + $bPos, + ); + } + + public function test_it_rejects_an_unsupported_request_type(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported request type for LDIF output'); + + $this->writer()->write([ + Operations::bindAnonymously(), + ]); + } + + public function test_it_round_trips_through_the_parser_for_all_changetypes(): void + { + $options = (new LdifOutputOptions()) + ->setEmitChangetypeForAdds(true); + + $changes = new LdifChanges( + Operations::add(Entry::create('cn=a,dc=x', [ 'objectClass' => ['top', 'person'], - 'cn' => 'foo', + 'cn' => 'a', 'sn' => ['Bär', ' spaced '], - ]), - Entry::create('cn=baz,dc=example,dc=com', [ - 'cn' => 'baz', - 'description' => str_repeat('x', 200), - ]), + ])), + Operations::delete('cn=b,dc=x'), + Operations::modify( + 'cn=c,dc=x', + Change::add('telephoneNumber', '555-0100'), + Change::delete('description'), + Change::replace('sn', 'C'), + ), + new ModifyDnRequest( + 'cn=d,ou=old,dc=x', + 'cn=dd', + true, + 'ou=new,dc=x', + ), ); - $ldif = (new LdifWriter())->write($entries); - $parsed = new Entries(...(new LdifParser())->parse($ldif)->entries()); + $ldif = (new LdifWriter($options))->write($changes); + $parsed = (new LdifParser())->parse($ldif); self::assertSame( - $this->normalize($entries), + $this->normalize($changes), $this->normalize($parsed), ); } + private function writer(): LdifWriter + { + return new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)); + } + /** - * @return array> + * @param iterable $requests + * @return list> */ - private function normalize(Entries $entries): array + private function normalize(iterable $requests): array { $out = []; - foreach ($entries->toArray() as $entry) { - $attributes = []; - foreach ($entry->getAttributes() as $attribute) { - $attributes[$attribute->getDescription()] = $attribute->getValues(); - } - $out[$entry->getDn()->toString()] = $attributes; + foreach ($requests as $request) { + $out[] = match (true) { + $request instanceof AddRequest => [ + 'type' => 'add', + 'dn' => $request->getEntry()->getDn()->toString(), + 'attrs' => $this->attributesOf($request->getEntry()), + ], + $request instanceof DeleteRequest => [ + 'type' => 'delete', + 'dn' => $request->getDn()->toString(), + ], + $request instanceof ModifyRequest => [ + 'type' => 'modify', + 'dn' => $request->getDn()->toString(), + 'changes' => array_map( + fn(Change $c): array => [ + 'type' => $c->getType(), + 'attr' => $c->getAttribute()->getDescription(), + 'values' => $c->getAttribute()->getValues(), + ], + $request->getChanges(), + ), + ], + $request instanceof ModifyDnRequest => [ + 'type' => 'modrdn', + 'dn' => $request->getDn()->toString(), + 'newRdn' => $request->getNewRdn()->toString(), + 'deleteOldRdn' => $request->getDeleteOldRdn(), + 'newSuperior' => $request->getNewParentDn()?->toString(), + ], + default => ['type' => 'unknown'], + }; } return $out; } + + /** + * @return array + */ + private function attributesOf(Entry $entry): array + { + $attrs = []; + + foreach ($entry->getAttributes() as $attribute) { + $attrs[$attribute->getDescription()] = $attribute->getValues(); + } + + return $attrs; + } }