Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
extensions: pdo_sqlite
coverage: none
tools: composer:2.9

Expand Down
88 changes: 87 additions & 1 deletion docs/Server/General-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ General LDAP Server Usage
* [Built-In Storage Implementations](#built-in-storage-implementations)
* [InMemoryStorage](#inmemorystorage)
* [JsonFileStorage](#jsonfilestorage)
* [SqliteStorage](#sqlitestorage)
* [MysqlStorage](#mysqlstorage)
* [Proxy Backend](#proxy-backend)
* [Custom Filter Evaluation](#custom-filter-evaluation)
* [Authentication](#authentication)
Expand Down Expand Up @@ -386,7 +388,7 @@ class MyBackend implements WritableLdapBackendInterface

### Built-In Storage Implementations

Two storage implementations are included for common use cases. Both are used via `useStorage()`.
Four storage implementations are included for common use cases. All are used via `useStorage()`.

#### InMemoryStorage

Expand Down Expand Up @@ -451,6 +453,90 @@ JSON format:
}
```

#### SqliteStorage

A SQLite-backed storage implementation that persists the directory in a SQLite database file via PDO. Suitable for
use cases that need durable persistence across restarts with support for concurrent access:

- **PCNTL**: each forked child process shares the same database file.
- **Swoole**: a coroutine `Channel` mutex serialises writes within the single-process coroutine runtime.

Use the named constructor that matches your server runner:

```php
use FreeDSx\Ldap\LdapServer;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage;

// PCNTL runner — WAL journal mode, NoopMutex (OS handles locking)
$server = (new LdapServer())->useStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite'));
$server->run();

// Swoole runner — WAL journal mode, coroutine Channel mutex
$server = (new LdapServer())->useStorage(SqliteStorage::forSwoole('/var/lib/myapp/ldap.sqlite'));
$server->run();
```

Use `:memory:` as the path to run a non-persistent, in-process SQLite database (useful for testing):

```php
$server = (new LdapServer())->useStorage(SqliteStorage::forPcntl(':memory:'));
```

#### MysqlStorage

A MySQL/MariaDB-backed storage implementation. Requires MySQL 8.0+ or MariaDB 10.6+.

Use the named constructor that matches your server runner:

```php
use FreeDSx\Ldap\LdapServer;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage;

// PCNTL runner — OS-level locking, NoopMutex
$server = (new LdapServer())->useStorage(
MysqlStorage::forPcntl('mysql:host=localhost;dbname=ldap', 'user', 'secret')
);
$server->run();

// Swoole runner — coroutine Channel mutex
$server = (new LdapServer())->useStorage(
MysqlStorage::forSwoole('mysql:host=localhost;dbname=ldap', 'user', 'secret')
);
$server->run();
```

The DSN follows the standard PDO MySQL format. The character set is automatically configured to `utf8mb4`
and the time zone to UTC on each connection.

##### Custom PDO driver

`SqliteStorage` and `MysqlStorage` are both factories for `PdoStorage`, which is the generic PDO-backed
implementation. To support a different database engine, implement `PdoStorageFactoryInterface` and
supply the appropriate `PdoDialectInterface` and filter translator:

```php
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorageFactoryInterface;

final class PostgresStorage implements PdoStorageFactoryInterface
{
public function create(): PdoStorage
{
$pdo = new PDO('pgsql:host=localhost;dbname=ldap', 'user', 'secret');

$dialect = new MyPostgresDialect();
PdoStorage::initialize($pdo, $dialect);

return new PdoStorage(
$pdo,
new NoopMutex(),
new MyPostgresFilterTranslator(),
$dialect,
);
}
}
```

### Proxy Backend

`ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server.
Expand Down
36 changes: 36 additions & 0 deletions src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/CollectedPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler;

use FreeDSx\Ldap\Entry\Entry;

/**
* Holds the result of a single generator collection pass in a paging operation.
*
* @internal
*
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
final class CollectedPage
{
/**
* @param Entry[] $entries
*/
public function __construct(
public readonly array $entries,
public readonly bool $isGeneratorExhausted,
public readonly bool $isSizeLimitExceeded,
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,36 @@ public function __construct(

/**
* {@inheritDoc}
* @throws OperationException
* @throws EncoderException
*/
public function handleRequest(
LdapMessageRequest $message,
TokenInterface $token,
): void {
$request = $message->getRequest();
try {
$request = $message->getRequest();

if ($request instanceof Request\CompareRequest) {
$match = $this->backend->compare($request->getDn(), $request->getFilter());
if ($request instanceof Request\CompareRequest) {
$match = $this->backend->compare($request->getDn(), $request->getFilter());
$this->queue->sendMessage($this->responseFactory->getStandardResponse(
$message,
$match
? ResultCode::COMPARE_TRUE
: ResultCode::COMPARE_FALSE,
));

return;
}

$this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request));

$this->queue->sendMessage($this->responseFactory->getStandardResponse($message));
} catch (OperationException $e) {
$this->queue->sendMessage($this->responseFactory->getStandardResponse(
$message,
$match
? ResultCode::COMPARE_TRUE
: ResultCode::COMPARE_FALSE,
$e->getCode(),
$e->getMessage(),
));

return;
}

$this->writeDispatcher->dispatch($this->commandFactory->fromRequest($request));

$this->queue->sendMessage($this->responseFactory->getStandardResponse($message));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,30 +143,24 @@ private function handlePagingStart(PagingRequest $pagingRequest): PagingResponse
{
$searchRequest = $pagingRequest->getSearchRequest();
$context = $this->makeSearchContext($searchRequest);
$generator = $this->backend->search($context);
$result = $this->backend->search($context);
$generator = $result->entries;
$isPreFiltered = $result->isPreFiltered;

[$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator(
$collected = $this->collectFromGenerator(
$generator,
$pagingRequest->getSize(),
$context,
0,
$isPreFiltered,
);

if ($sizeLimitExceeded) {
return PagingResponse::makeSizeLimitExceeded(new Entries(...$page));
}

$nextCookie = $this->generateCookie();
$pagingRequest->updateNextCookie($nextCookie);

if ($generatorExhausted) {
return PagingResponse::makeFinal(new Entries(...$page));
}

$pagingRequest->incrementTotalSent(count($page));
$this->requestHistory->storePagingGenerator($nextCookie, $generator);

return PagingResponse::make(new Entries(...$page), 0);
return $this->buildPagingResponse(
$collected,
$pagingRequest,
$generator,
$isPreFiltered,
);
}

/**
Expand Down Expand Up @@ -203,61 +197,85 @@ private function handleExistingCookie(
);
}

$isPreFiltered = $this->requestHistory->getPagingGeneratorIsPreFiltered($currentCookie);
$this->requestHistory->removePagingGenerator($currentCookie);

$searchRequest = $pagingRequest->getSearchRequest();
$context = $this->makeSearchContext($searchRequest);

[$page, $generatorExhausted, $sizeLimitExceeded] = $this->collectFromGenerator(
$collected = $this->collectFromGenerator(
$generator,
$pagingRequest->getSize(),
$context,
$pagingRequest->getTotalSent(),
$isPreFiltered,
);

if ($sizeLimitExceeded) {
return PagingResponse::makeSizeLimitExceeded(new Entries(...$page));
return $this->buildPagingResponse(
$collected,
$pagingRequest,
$generator,
$isPreFiltered
);
}

/**
* @throws OperationException
*/
private function buildPagingResponse(
CollectedPage $collected,
PagingRequest $pagingRequest,
Generator $generator,
bool $isPreFiltered,
): PagingResponse {
if ($collected->isSizeLimitExceeded) {
return PagingResponse::makeSizeLimitExceeded(new Entries(...$collected->entries));
}

$nextCookie = $this->generateCookie();
$pagingRequest->updateNextCookie($nextCookie);

if ($generatorExhausted) {
return PagingResponse::makeFinal(new Entries(...$page));
if ($collected->isGeneratorExhausted) {
return PagingResponse::makeFinal(new Entries(...$collected->entries));
}

$pagingRequest->incrementTotalSent(count($page));
$this->requestHistory->storePagingGenerator($nextCookie, $generator);
$pagingRequest->incrementTotalSent(count($collected->entries));
$this->requestHistory->storePagingGenerator(
$nextCookie,
$generator,
$isPreFiltered,
);

return PagingResponse::make(new Entries(...$page), 0);
return PagingResponse::make(
new Entries(...$collected->entries)
);
}

/**
* Advances the generator, collecting up to $pageSize entries that pass the filter.
*
* Also enforces the client's sizeLimit from SearchContext. When the sizeLimit is
* reached before the generator is exhausted, $sizeLimitExceeded is true in the return.
*
* @return array{Entry[], bool, bool} [$page, $generatorExhausted, $sizeLimitExceeded]
* reached before the generator is exhausted, $isSizeLimitExceeded is true in the return.
*/
private function collectFromGenerator(
Generator $generator,
int $pageSize,
SearchContext $context,
int $totalAlreadySent,
): array {
bool $isPreFiltered = false,
): CollectedPage {
$page = [];
$pageLimit = $pageSize > 0 ? $pageSize : PHP_INT_MAX;
$sizeLimit = $context->sizeLimit;

while ($generator->valid() && count($page) < $pageLimit) {
$entry = $generator->current();

if ($entry instanceof Entry && $this->filterEvaluator->evaluate($entry, $context->filter)) {
if ($entry instanceof Entry && ($isPreFiltered || $this->filterEvaluator->evaluate($entry, $context->filter))) {
$page[] = $this->applyAttributeFilter(
$entry,
$context->attributes,
$context->typesOnly
$context->typesOnly,
);

if ($sizeLimit > 0 && ($totalAlreadySent + count($page)) >= $sizeLimit) {
Expand All @@ -274,11 +292,11 @@ private function collectFromGenerator(
&& $sizeLimit > 0
&& ($totalAlreadySent + count($page)) >= $sizeLimit;

return [
return new CollectedPage(
$page,
$generatorExhausted,
$sizeLimitExceeded,
];
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,18 @@ public function handleRequest(
$results = [];
$sizeLimitExceeded = false;

foreach ($this->backend->search($context) as $entry) {
if ($this->filterEvaluator->evaluate($entry, $filter)) {
$results[] = $this->applyAttributeFilter($entry, $attributes, $typesOnly);
$result = $this->backend->search($context);

foreach ($result->entries as $entry) {
if ($result->isPreFiltered || $this->filterEvaluator->evaluate($entry, $filter)) {
$results[] = $this->applyAttributeFilter(
$entry,
$attributes,
$typesOnly,
);
if ($sizeLimit > 0 && count($results) >= $sizeLimit) {
$sizeLimitExceeded = true;

break;
}
}
Expand Down
Loading
Loading