diff --git a/composer.json b/composer.json index db4840a7..4e072566 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ ], "require": { "php": ">=8.2", - "freedsx/asn1": "dev-main#b57a38f", + "freedsx/asn1": "dev-main#d6cee05", "freedsx/socket": "dev-main#df083d9", "freedsx/sasl": "dev-main#ebf53ad", "psr/log": "^3" diff --git a/src/FreeDSx/Ldap/Operation/Response/SearchResultEntry.php b/src/FreeDSx/Ldap/Operation/Response/SearchResultEntry.php index 79c2b0f8..455a021b 100644 --- a/src/FreeDSx/Ldap/Operation/Response/SearchResultEntry.php +++ b/src/FreeDSx/Ldap/Operation/Response/SearchResultEntry.php @@ -37,7 +37,7 @@ */ class SearchResultEntry implements ResponseInterface { - protected const TAG_NUMBER = 4; + public const TAG_NUMBER = 4; public function __construct(private readonly Entry $entry) {} diff --git a/src/FreeDSx/Ldap/Protocol/LdapQueue.php b/src/FreeDSx/Ldap/Protocol/LdapQueue.php index 55254b9f..2ad42f8c 100644 --- a/src/FreeDSx/Ldap/Protocol/LdapQueue.php +++ b/src/FreeDSx/Ldap/Protocol/LdapQueue.php @@ -13,14 +13,18 @@ namespace FreeDSx\Ldap\Protocol; +use FreeDSx\Asn1\Asn1; use FreeDSx\Asn1\Encoder\EncoderInterface; use FreeDSx\Asn1\Encoder\EncoderOptions; use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Asn1\Exception\PduLengthException; +use FreeDSx\Asn1\Helper\AttributeEntryEncoder; +use FreeDSx\Asn1\Type\SequenceOfType; use FreeDSx\Ldap\Exception\ProtocolException; use FreeDSx\Ldap\Exception\RequestSizeExceededException; use FreeDSx\Ldap\Exception\UnsolicitedNotificationException; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; +use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Protocol\Queue\MessageWrapperInterface; use FreeDSx\Socket\Exception\ConnectionException; use FreeDSx\Socket\Queue\Asn1MessageQueue; @@ -45,6 +49,8 @@ class LdapQueue extends Asn1MessageQueue private ?MessageWrapperInterface $messageWrapper = null; + private readonly AttributeEntryEncoder $entryEncoder; + public function __construct( Socket $socket, ?EncoderInterface $encoder = null, @@ -54,6 +60,7 @@ public function __construct( $socket, $encoder ?? new LdapEncoder(new EncoderOptions(maxLength: $maxReceiveSize)), ); + $this->entryEncoder = new AttributeEntryEncoder(); } /** @@ -165,7 +172,7 @@ protected function sendLdapMessage(iterable $messages): static $buffer = ''; foreach ($messages as $message) { - $encoded = $this->encoder->encode($message->toAsn1()); + $encoded = $this->encodeMessage($message); $this->onMessageEncoded($encoded); $buffer .= $this->messageWrapper !== null ? $this->messageWrapper->wrap($encoded) : $encoded; $bufferLen = strlen($buffer); @@ -250,4 +257,61 @@ protected function getAndValidateMessage(?int $id): LdapMessage return $message; } + + /** + * Encodes a message to BER, taking a dedicated fast path for search result entries. + * + * @throws EncoderException + */ + private function encodeMessage(LdapMessage $message): string + { + $response = $message instanceof LdapMessageResponse + ? $message->getResponse() + : null; + + if (!($response instanceof SearchResultEntry)) { + return $this->encoder->encode($message->toAsn1()); + } + + $envelope = Asn1::sequence( + Asn1::integer($message->getMessageId()), + Asn1::raw($this->encodeSearchResultEntry($response)), + ); + + $controls = $message->controls()->toArray(); + if ($controls !== []) { + /** @var SequenceOfType $context */ + $context = Asn1::context( + tagNumber: 0, + type: Asn1::sequenceOf(), + ); + foreach ($controls as $control) { + $context->addChild($control->toAsn1()); + } + $envelope->addChild($context); + } + + return $this->encoder->encode($envelope); + } + + /** + * @throws EncoderException + */ + private function encodeSearchResultEntry(SearchResultEntry $response): string + { + $entry = $response->getEntry(); + $attributes = []; + foreach ($entry->getAttributes() as $attribute) { + $attributes[] = [ + $attribute->getDescription(), + $attribute->getValues(), + ]; + } + + return $this->entryEncoder->encode( + SearchResultEntry::TAG_NUMBER, + $entry->getDn()->toString(), + $attributes, + ); + } } diff --git a/tests/unit/Protocol/Queue/ServerQueueTest.php b/tests/unit/Protocol/Queue/ServerQueueTest.php index 25a83c1d..5cedc280 100644 --- a/tests/unit/Protocol/Queue/ServerQueueTest.php +++ b/tests/unit/Protocol/Queue/ServerQueueTest.php @@ -17,10 +17,14 @@ use FreeDSx\Asn1\Encoder\EncoderInterface; use FreeDSx\Asn1\Type\IncompleteType; use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Control\Sync\SyncStateControl; +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Operation\Request\AbandonRequest; use FreeDSx\Ldap\Operation\Request\CancelRequest; use FreeDSx\Ldap\Operation\Request\DeleteRequest; use FreeDSx\Ldap\Operation\Response\DeleteResponse; +use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Protocol\LdapEncoder; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; @@ -418,6 +422,83 @@ public function test_it_runs_interceptors_in_order(): void ); } + public function test_it_encodes_a_search_result_entry_identically_to_the_generic_path(): void + { + $message = new LdapMessageResponse( + 2, + new SearchResultEntry(new Entry( + 'cn=foo,dc=example,dc=com', + new Attribute('cn', 'foo'), + new Attribute('objectClass', 'top', 'person'), + new Attribute('sn', 'Foo'), + new Attribute('userCertificate;binary', "\x00\x01\x02"), + new Attribute('emptyAttribute'), + )), + ); + + self::assertSame( + (new LdapEncoder())->encode($message->toAsn1()), + $this->captureSentBytes($message), + ); + } + + public function test_it_encodes_a_search_result_entry_with_controls_identically(): void + { + $message = new LdapMessageResponse( + 3, + new SearchResultEntry(new Entry( + 'cn=bar,dc=example,dc=com', + new Attribute('cn', 'bar'), + )), + new Control('1.2.3.4'), + ); + + self::assertSame( + (new LdapEncoder())->encode($message->toAsn1()), + $this->captureSentBytes($message), + ); + } + + public function test_it_encodes_a_sync_entry_with_a_state_control_identically(): void + { + $message = new LdapMessageResponse( + 4, + new SearchResultEntry(new Entry( + 'cn=synced,dc=example,dc=com', + new Attribute('cn', 'synced'), + new Attribute('objectClass', 'top', 'person'), + )), + new SyncStateControl( + SyncStateControl::STATE_ADD, + str_repeat("\x11", 16), + ), + ); + + self::assertSame( + (new LdapEncoder())->encode($message->toAsn1()), + $this->captureSentBytes($message), + ); + } + + /** + * Sends the message through a real-encoder queue and returns the bytes written to the socket. + */ + private function captureSentBytes(LdapMessageResponse $message): string + { + $written = ''; + $socket = $this->createMock(Socket::class); + $socket->method('write')->willReturnCallback(function (string $data) use (&$written, $socket): Socket { + $written .= $data; + + return $socket; + }); + + # A null encoder builds a real LdapEncoder, so the real search-entry fast path runs. + (new ServerQueue($socket))->sendMessage($message); + + return $written; + } + private function makeQueueWithEncodedRequest(LdapMessageRequest $message): ServerQueue { $encoder = new LdapEncoder();