Skip to content
Merged
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"docker compose -f tests/resources/openldap/docker-compose.yml up -d --build --wait",
"LDAP_TESTS_ENABLED=1 php -d xdebug.mode=off vendor/bin/phpunit --testsuite integration"
],
"test-load": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-load-test.php --backend=sqlite --runner=swoole --duration=10 --warmup=2 --clients=8 --output=text",
"test-load": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit=off tests/bin/ldap-load-test.php --backend=sqlite --runner=pcntl --duration=10 --warmup=2 --clients=8 --output=text",
"test-load-compare": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-bench-compare.php",
"profile": "tests/profile/profile.sh",
"profile-up": "docker compose -f tests/profile/docker-compose.yml up -d --build --wait",
Expand Down
87 changes: 66 additions & 21 deletions src/FreeDSx/Ldap/Entry/Dn.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
use function implode;
use function ltrim;
use function preg_split;
use function strtolower;
use function str_ends_with;
use function strlen;
use function strpos;
use function substr;

/**
* Represents a Distinguished Name.
Expand All @@ -41,13 +44,26 @@ class Dn implements IteratorAggregate, Countable, Stringable
*/
private ?array $pieces = null;

private ?Dn $normalized = null;

public function __construct(private readonly string $dn) {}

public function __toString(): string
{
return $this->dn;
}

/**
* Wrap an already-canonical DN string, skipping re-normalization.
*/
public static function fromCanonical(string $canonical): self
{
$dn = new self($canonical);
$dn->normalized = $dn;

return $dn;
}

/**
* @throws UnexpectedValueException
*/
Expand Down Expand Up @@ -133,11 +149,18 @@ public static function isValid(Stringable|string $dn): bool
}

/**
* Return a normalised (lowercased) copy of this DN.
* Return the canonical (RFC 4518 caseIgnore) copy of this DN.
*/
public function normalize(): Dn
{
return new Dn(strtolower($this->dn));
if ($this->normalized !== null) {
return $this->normalized;
}
$canonical = DnNormalizer::canonicalize($this->dn);

return $this->normalized = $canonical === $this->dn
? $this
: self::fromCanonical($canonical);
}

/**
Expand All @@ -147,15 +170,13 @@ public function normalize(): Dn
*/
public function isChildOf(Dn $parent): bool
{
$parentDn = $parent->toString();
if ($parentDn === '') {
return $this->getParent() === null
&& $this->toString() !== '';
$thisDn = $this->normalize()->toString();

if ($thisDn === '') {
return false;
}
$myParent = $this->getParent();

return $myParent !== null
&& strtolower($myParent->toString()) === strtolower($parentDn);
return self::canonicalParent($thisDn) === $parent->normalize()->toString();
}

/**
Expand All @@ -165,27 +186,51 @@ public function isChildOf(Dn $parent): bool
*/
public function isDescendantOf(Dn $base): bool
{
$baseDn = $base->toString();
$thisDn = $this->toString();
$baseDn = $base->normalize()->toString();
$thisDn = $this->normalize()->toString();

if ($baseDn === '') {
return $thisDn !== '';
}

$baseLower = strtolower($baseDn);
if (strtolower($thisDn) === $baseLower) {
if ($thisDn === $baseDn) {
return true;
}
$ancestorSuffix = ',' . $baseDn;

$parent = $this->getParent();
while ($parent !== null) {
if (strtolower($parent->toString()) === $baseLower) {
return true;
return str_ends_with($thisDn, $ancestorSuffix)
&& self::isUnescaped($thisDn, strlen($thisDn) - strlen($ancestorSuffix));
}

/**
* Parent of an already-canonical DN: the substring after the first unescaped RDN separator.
*/
private static function canonicalParent(string $canonical): string
{
$offset = 0;
while (($pos = strpos($canonical, ',', $offset)) !== false) {
if (self::isUnescaped($canonical, $pos)) {
return substr($canonical, $pos + 1);
}
$parent = $parent->getParent();
$offset = $pos + 1;
}

return '';
}

/**
* Whether the character at $pos is preceded by an even number of backslashes (i.e. not escaped).
*/
private static function isUnescaped(
string $value,
int $pos,
): bool {
$slashes = 0;

for ($i = $pos - 1; $i >= 0 && $value[$i] === '\\'; $i--) {
$slashes++;
}

return false;
return $slashes % 2 === 0;
}

/**
Expand Down
135 changes: 135 additions & 0 deletions src/FreeDSx/Ldap/Entry/DnNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?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\Entry;

use FreeDSx\Ldap\Exception\InvalidArgumentException;
use FreeDSx\Ldap\Exception\UnexpectedValueException;
use FreeDSx\Ldap\Schema\Matching\Prep\StringPrep;
use FreeDSx\Ldap\Schema\Text;

use function count;
use function implode;
use function preg_replace;
use function sort;
use function str_replace;
use function strtolower;
use function trim;

/**
* Canonicalizes a DN for comparison and keying using the pragmatic RFC 4518 caseIgnore profile.
*/
final class DnNormalizer
{
private static ?self $instance = null;

private readonly StringPrep $prep;

public function __construct()
{
$this->prep = new StringPrep(foldCase: true);
}

/**
* Canonical DN form.
*
* Values are not unescaped, so an escaped edge space (cn=\ x\ ) does not fold like an unescaped one.
*/
public static function canonicalize(string $dn): string
{
return (self::$instance ??= new self())->doCanonicalize($dn);
}

private function doCanonicalize(string $dn): string
{
if ($dn === '') {
return '';
}

try {
$rdns = (new Dn($dn))->toArray();
} catch (UnexpectedValueException|InvalidArgumentException) {
return strtolower($dn);
}

$ascii = Text::isAscii($dn);
$parts = [];
foreach ($rdns as $rdn) {
$parts[] = $this->canonicalizeRdn(
$rdn,
$ascii,
);
}

return implode(
',',
$parts,
);
}

private function canonicalizeRdn(
Rdn $rdn,
bool $ascii,
): string {
$components = [];

foreach ($rdn->getAll() as $component) {
$components[] = strtolower(trim($component->getName()))
. '='
. $this->canonicalizeValue(
$component->getValue(),
$ascii,
);
}

// Components of a multivalued RDN are an unordered set. Sort for a stable canonical form.
if (count($components) > 1) {
sort($components);
}

return implode(
'+',
$components,
);
}

private function canonicalizeValue(
string $value,
bool $ascii,
): string {
if ($ascii) {
return $this->canonicalizeAsciiValue($value);
}

return $this->prep->prepareForEquality($value);
}

private function canonicalizeAsciiValue(string $value): string
{
$folded = strtolower(str_replace(
"\0",
'',
$value,
));
$collapsed = preg_replace(
'/[\x09-\x0D ]+/',
' ',
$folded,
) ?? $folded;

return trim(
$collapsed,
' ',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ public function substringMatches(

private function normalize(string $dn): string
{
return strtolower(
(new Dn($dn))->normalize()->toString(),
);
return (new Dn($dn))->normalize()->toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ final class DnSubjectMatcher implements SubjectMatcherInterface

public function __construct(string $dn)
{
$this->normalizedDn = strtolower($dn);
$this->normalizedDn = (new Dn($dn))->normalize()->toString();
}

public function matches(
Expand All @@ -39,6 +39,6 @@ public function matches(
return false;
}

return strtolower($token->getResolvedDn()->toString()) === $this->normalizedDn;
return $token->getResolvedDn()->normalize()->toString() === $this->normalizedDn;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,22 +77,17 @@ public function matches(
return false;
}

$resolvedDn = self::normalizeDn($token->getResolvedDn()->toString());
$resolvedDn = $token->getResolvedDn()->normalize()->toString();

foreach ($memberAttr->getValues() as $value) {
if (self::normalizeDn($value) === $resolvedDn) {
if ((new Dn($value))->normalize()->toString() === $resolvedDn) {
return true;
}
}

return false;
}

private static function normalizeDn(string $dn): string
{
return strtolower(preg_replace('/\s*([,=])\s*/', '$1', $dn) ?? $dn);
}

private function getGroupEntry(TokenInterface $token): ?Entry
{
if ($this->maxCacheSize === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ public function matches(
return false;
}

return strtolower($token->getResolvedDn()->toString()) === strtolower($targetDn->toString());
return $token->getResolvedDn()->normalize()->toString() === $targetDn->normalize()->toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ final class DnTargetMatcher implements TargetMatcherInterface

public function __construct(string $dn)
{
$this->normalizedDn = strtolower($dn);
$this->normalizedDn = (new Dn($dn))->normalize()->toString();
}

public function matches(Dn $dn): bool
{
return strtolower($dn->toString()) === $this->normalizedDn;
return $dn->normalize()->toString() === $this->normalizedDn;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ public function __construct(array $entries = [])

public function find(Dn $dn): ?Entry
{
return $this->entries[$dn->toString()] ?? null;
return $this->entries[$dn->normalize()->toString()] ?? null;
}

public function exists(Dn $dn): bool
{
return isset($this->entries[$dn->toString()]);
return isset($this->entries[$dn->normalize()->toString()]);
}

public function list(StorageListOptions $options): EntryStream
Expand All @@ -69,7 +69,7 @@ public function store(Entry $entry): void

public function remove(Dn $dn): void
{
unset($this->entries[$dn->toString()]);
unset($this->entries[$dn->normalize()->toString()]);
}

public function atomic(callable $operation): void
Expand Down
Loading
Loading