From 5504116f65f658bb81d5bb09357845373eabe441 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 13 Aug 2026 17:48:35 -0400 Subject: [PATCH 1/4] Make invalid assertion values now evaluate to undefined (per the RFC). --- .../Schema/Validation/SchemaValidator.php | 35 +------- .../Syntax/AttributeSyntaxResolver.php | 83 +++++++++++++++++ .../Ldap/Search/Filter/ApproximateFilter.php | 2 +- .../AttributeValueAssertionInterface.php | 30 +++++++ .../Ldap/Search/Filter/EqualityFilter.php | 2 +- .../Filter/GreaterThanOrEqualFilter.php | 2 +- .../Search/Filter/LessThanOrEqualFilter.php | 2 +- .../SqlFilter/SqlFilterTranslatorTrait.php | 30 ++++++- .../FilterAttributeContextInterface.php | 8 ++ .../Backend/Storage/FilterEvaluator.php | 36 ++++++++ .../Backend/Storage/StorageListOptions.php | 19 +++- .../Schema/LdapLoadedSchemaTest.php | 18 +++- .../Storage/Concern/QueryTestsTrait.php | 40 ++++++++- .../Adapter/MysqlFilterTranslatorTest.php | 1 + .../Adapter/SqliteFilterTranslatorTest.php | 42 +++++++++ .../Backend/Storage/FilterEvaluatorTest.php | 88 +++++++++++++++++++ 16 files changed, 393 insertions(+), 45 deletions(-) create mode 100644 src/FreeDSx/Ldap/Schema/Validation/Syntax/AttributeSyntaxResolver.php create mode 100644 src/FreeDSx/Ldap/Search/Filter/AttributeValueAssertionInterface.php diff --git a/src/FreeDSx/Ldap/Schema/Validation/SchemaValidator.php b/src/FreeDSx/Ldap/Schema/Validation/SchemaValidator.php index 621ba80a..d8abb4d9 100644 --- a/src/FreeDSx/Ldap/Schema/Validation/SchemaValidator.php +++ b/src/FreeDSx/Ldap/Schema/Validation/SchemaValidator.php @@ -18,14 +18,13 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Schema\Definition\AttributeType; use FreeDSx\Ldap\Schema\Definition\AttributeUsage; use FreeDSx\Ldap\Schema\Definition\ObjectClass; use FreeDSx\Ldap\Schema\Definition\ObjectClassType; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Schema\Validation\Syntax\AttributeSyntaxResolver; use FreeDSx\Ldap\Schema\Validation\Syntax\SyntaxValidatorInterface; -use FreeDSx\Ldap\Schema\Validation\Syntax\SyntaxValidatorRegistry; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; /** @@ -37,14 +36,13 @@ final class SchemaValidator { private const EXTENSIBLE_OBJECT = 'extensibleObject'; - private readonly SyntaxValidatorRegistry $syntaxValidators; + private readonly AttributeSyntaxResolver $syntaxResolver; public function __construct( private readonly Schema $schema, private readonly SchemaValidationMode $mode, - ?SyntaxValidatorRegistry $syntaxValidators = null, ) { - $this->syntaxValidators = $syntaxValidators ?? SyntaxValidatorRegistry::default(); + $this->syntaxResolver = new AttributeSyntaxResolver($schema); } public function mode(): SchemaValidationMode @@ -334,10 +332,7 @@ private function checkAttributeSyntaxes(Entry $entry): void continue; } - $syntaxOid = $this->resolveSyntaxOid($attrType); - $validator = $syntaxOid === null - ? null - : $this->syntaxValidators->get($syntaxOid); + $validator = $this->syntaxResolver->validatorFor($attrType); if ($validator === null) { continue; } @@ -371,28 +366,6 @@ private function checkValuesConform( } } - /** - * Resolves the effective syntax OID, walking the SUP chain when not set directly. - */ - private function resolveSyntaxOid(AttributeType $type): ?string - { - $visited = []; - $current = $type; - - while ($current !== null && !isset($visited[$current->oid])) { - if ($current->syntaxOid !== null) { - return $current->syntaxOid; - } - - $visited[$current->oid] = true; - $current = $current->superTypeOid !== null - ? $this->schema->getAttributeType($current->superTypeOid) - : null; - } - - return null; - } - /** * @throws OperationException */ diff --git a/src/FreeDSx/Ldap/Schema/Validation/Syntax/AttributeSyntaxResolver.php b/src/FreeDSx/Ldap/Schema/Validation/Syntax/AttributeSyntaxResolver.php new file mode 100644 index 00000000..153f27a4 --- /dev/null +++ b/src/FreeDSx/Ldap/Schema/Validation/Syntax/AttributeSyntaxResolver.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Schema\Validation\Syntax; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Schema\Definition\AttributeType; +use FreeDSx\Ldap\Schema\Schema; + +/** + * Answers whether a value conforms to the syntax an attribute type carries, following the SUP chain to find it. + * + * @author Chad Sikorra + */ +final readonly class AttributeSyntaxResolver +{ + private SyntaxValidatorRegistry $registry; + + public function __construct(private Schema $schema) + { + $this->registry = SyntaxValidatorRegistry::default(); + } + + /** + * Whether a value conforms; true when the type is unknown or its syntax carries no validator. + */ + public function conforms( + string $attributeDescription, + string $value, + ): bool { + $type = $this->schema->getAttributeType(Attribute::normalizeName($attributeDescription)); + + if ($type === null) { + return true; + } + + return $this->validatorFor($type)?->isValid($value) ?? true; + } + + /** + * The validator enforcing the type's effective syntax, or null when it is unconstrained. + */ + public function validatorFor(AttributeType $type): ?SyntaxValidatorInterface + { + $syntaxOid = $this->resolveSyntaxOid($type); + + return $syntaxOid === null + ? null + : $this->registry->get($syntaxOid); + } + + /** + * Resolves the effective syntax OID, walking the SUP chain when not set directly. + */ + private function resolveSyntaxOid(AttributeType $type): ?string + { + $visited = []; + $current = $type; + + while ($current !== null && !isset($visited[$current->oid])) { + if ($current->syntaxOid !== null) { + return $current->syntaxOid; + } + + $visited[$current->oid] = true; + $current = $current->superTypeOid !== null + ? $this->schema->getAttributeType($current->superTypeOid) + : null; + } + + return null; + } +} diff --git a/src/FreeDSx/Ldap/Search/Filter/ApproximateFilter.php b/src/FreeDSx/Ldap/Search/Filter/ApproximateFilter.php index 240497f8..4659ad64 100644 --- a/src/FreeDSx/Ldap/Search/Filter/ApproximateFilter.php +++ b/src/FreeDSx/Ldap/Search/Filter/ApproximateFilter.php @@ -20,7 +20,7 @@ * * @author Chad Sikorra */ -class ApproximateFilter implements FilterInterface, FilterAttributeInterface, Stringable +class ApproximateFilter implements FilterInterface, AttributeValueAssertionInterface, Stringable { use AttributeValueAssertionTrait; diff --git a/src/FreeDSx/Ldap/Search/Filter/AttributeValueAssertionInterface.php b/src/FreeDSx/Ldap/Search/Filter/AttributeValueAssertionInterface.php new file mode 100644 index 00000000..ac621277 --- /dev/null +++ b/src/FreeDSx/Ldap/Search/Filter/AttributeValueAssertionInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Search\Filter; + +/** + * A filter asserting one whole value against an attribute type. + */ +interface AttributeValueAssertionInterface extends FilterAttributeInterface +{ + /** + * The attribute type asserted against, which is always named for these filters. + */ + public function getAttribute(): string; + + /** + * The value being asserted. + */ + public function getValue(): string; +} diff --git a/src/FreeDSx/Ldap/Search/Filter/EqualityFilter.php b/src/FreeDSx/Ldap/Search/Filter/EqualityFilter.php index 84a5da34..013f1cde 100644 --- a/src/FreeDSx/Ldap/Search/Filter/EqualityFilter.php +++ b/src/FreeDSx/Ldap/Search/Filter/EqualityFilter.php @@ -20,7 +20,7 @@ * * @author Chad Sikorra */ -class EqualityFilter implements FilterInterface, FilterAttributeInterface, Stringable +class EqualityFilter implements FilterInterface, AttributeValueAssertionInterface, Stringable { use AttributeValueAssertionTrait; diff --git a/src/FreeDSx/Ldap/Search/Filter/GreaterThanOrEqualFilter.php b/src/FreeDSx/Ldap/Search/Filter/GreaterThanOrEqualFilter.php index bc4d15f8..454bae3a 100644 --- a/src/FreeDSx/Ldap/Search/Filter/GreaterThanOrEqualFilter.php +++ b/src/FreeDSx/Ldap/Search/Filter/GreaterThanOrEqualFilter.php @@ -20,7 +20,7 @@ * * @author Chad Sikorra */ -class GreaterThanOrEqualFilter implements FilterInterface, FilterAttributeInterface, Stringable +class GreaterThanOrEqualFilter implements FilterInterface, AttributeValueAssertionInterface, Stringable { use AttributeValueAssertionTrait; diff --git a/src/FreeDSx/Ldap/Search/Filter/LessThanOrEqualFilter.php b/src/FreeDSx/Ldap/Search/Filter/LessThanOrEqualFilter.php index bafdafe2..4f94258f 100644 --- a/src/FreeDSx/Ldap/Search/Filter/LessThanOrEqualFilter.php +++ b/src/FreeDSx/Ldap/Search/Filter/LessThanOrEqualFilter.php @@ -20,7 +20,7 @@ * * @author Chad Sikorra */ -class LessThanOrEqualFilter implements FilterInterface, FilterAttributeInterface, Stringable +class LessThanOrEqualFilter implements FilterInterface, AttributeValueAssertionInterface, Stringable { use AttributeValueAssertionTrait; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php index ca7efbef..ede5d4a9 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\AttributeValueAssertionInterface; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Search\Filter\FilterAttributeInterface; use FreeDSx\Ldap\Search\Filter\FilterInterface; @@ -62,14 +63,35 @@ private function filterSupport(string $attribute): AttributeFilterSupport ?? AttributeFilterSupport::Exact; } - private function dispatch(FilterInterface $filter): ?SqlFilterResult + /** + * An assertion value the type's syntax rejects is Undefined for every entry, whatever the attribute supports. + */ + private function supportFor(FilterInterface $filter): AttributeFilterSupport { + if ($filter instanceof AttributeValueAssertionInterface && !$this->assertionValueConforms($filter)) { + return AttributeFilterSupport::NeverMatches; + } + $attribute = $filter instanceof FilterAttributeInterface ? $filter->getAttribute() : null; - $support = $attribute !== null - ? $this->filterSupport($attribute) - : AttributeFilterSupport::Exact; + + return $attribute === null + ? AttributeFilterSupport::Exact + : $this->filterSupport($attribute); + } + + private function assertionValueConforms(AttributeValueAssertionInterface $filter): bool + { + return $this->attributeContext?->assertionValueConforms( + $filter->getAttribute(), + $filter->getValue(), + ) ?? true; + } + + private function dispatch(FilterInterface $filter): ?SqlFilterResult + { + $support = $this->supportFor($filter); // Rows under this name alone are not the whole answer, so leave the item to the evaluator. if ($support === AttributeFilterSupport::NeedsEvaluator) { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php index fb5a923f..27d191f3 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php @@ -34,4 +34,12 @@ public function isCaseInsensitive(string $attribute): ?bool; * How faithfully SQL alone can answer an assertion on the attribute. */ public function filterSupport(string $attribute): AttributeFilterSupport; + + /** + * Whether a value conforms to the attribute's syntax (false makes the item Undefined per RFC 4511 4.5.1.7). + */ + public function assertionValueConforms( + string $attribute, + string $value, + ): bool; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 0331c39b..d398acf0 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -24,8 +24,10 @@ use FreeDSx\Ldap\Schema\Matching\MatchingRuleComparatorInterface; use FreeDSx\Ldap\Schema\Matching\SubstringAssertion; use FreeDSx\Ldap\Schema\Schema; +use FreeDSx\Ldap\Schema\Validation\Syntax\AttributeSyntaxResolver; use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\ApproximateFilter; +use FreeDSx\Ldap\Search\Filter\AttributeValueAssertionInterface; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; @@ -78,16 +80,25 @@ final class FilterEvaluator implements FilterEvaluatorInterface */ private WeakMap $orderedDigitCache; + /** + * @var WeakMap + */ + private WeakMap $assertionSyntaxCache; + private readonly CaseIgnoreComparator $defaultComparator; private readonly IntegerComparator $integerComparator; + private readonly AttributeSyntaxResolver $syntaxResolver; + public function __construct(private readonly Schema $schema) { $this->defaultComparator = new CaseIgnoreComparator(); $this->integerComparator = new IntegerComparator(); + $this->syntaxResolver = new AttributeSyntaxResolver($schema); $this->substringCache = new WeakMap(); $this->orderedDigitCache = new WeakMap(); + $this->assertionSyntaxCache = new WeakMap(); } public function evaluate( @@ -195,6 +206,10 @@ private function evaluateEquality( Entry $entry, EqualityFilter $filter, ): FilterResult { + if (!$this->assertionSyntaxIsValid($filter)) { + return FilterResult::Undefined; + } + $values = $this->valuesForAssertion( $entry, $filter->getAttribute(), @@ -245,6 +260,10 @@ private function evaluateOrdered( Entry $entry, GreaterThanOrEqualFilter|LessThanOrEqualFilter $filter, ): FilterResult { + if (!$this->assertionSyntaxIsValid($filter)) { + return FilterResult::Undefined; + } + $values = $this->valuesForAssertion( $entry, $filter->getAttribute(), @@ -287,6 +306,10 @@ private function evaluateApproximate( Entry $entry, ApproximateFilter $filter, ): FilterResult { + if (!$this->assertionSyntaxIsValid($filter)) { + return FilterResult::Undefined; + } + $values = $this->valuesForAssertion($entry, $filter->getAttribute()); if ($values === []) { @@ -584,4 +607,17 @@ private function orderedFilterValueIsDigit( ): bool { return $this->orderedDigitCache[$filter] ??= ctype_digit($filter->getValue()); } + + /** + * An assertion value that does not conform to the type's syntax makes the item Undefined (RFC 4511 4.5.1.7). + * + * The answer depends only on the filter, so it is memoized rather than recomputed for every entry tested. + */ + private function assertionSyntaxIsValid(AttributeValueAssertionInterface $filter): bool + { + return $this->assertionSyntaxCache[$filter] ??= $this->syntaxResolver->conforms( + $filter->getAttribute(), + $filter->getValue(), + ); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php index 4548fdc9..d6e5b755 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php @@ -19,6 +19,7 @@ use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Schema\Schema; +use FreeDSx\Ldap\Schema\Validation\Syntax\AttributeSyntaxResolver; use FreeDSx\Ldap\Server\Subentry\SubentryVisibility; /** @@ -28,6 +29,8 @@ */ final readonly class StorageListOptions implements FilterAttributeContextInterface { + private ?AttributeSyntaxResolver $syntaxResolver; + /** * @param SortKey[] $sortKeys * @param list|null $attributes Lowercase base attribute names to materialize, or null for all. @@ -44,7 +47,21 @@ public function __construct( public ?array $attributes = null, public SubentryVisibility $subentries = SubentryVisibility::All, public ?Schema $schema = null, - ) {} + ) { + $this->syntaxResolver = $schema === null + ? null + : new AttributeSyntaxResolver($schema); + } + + /** + * Whether a value conforms to the attribute's syntax; true when no schema can say otherwise. + */ + public function assertionValueConforms( + string $attribute, + string $value, + ): bool { + return $this->syntaxResolver?->conforms($attribute, $value) ?? true; + } /** * How faithfully SQL alone can answer an assertion on the attribute. diff --git a/tests/integration/Schema/LdapLoadedSchemaTest.php b/tests/integration/Schema/LdapLoadedSchemaTest.php index 9474f891..b8874038 100644 --- a/tests/integration/Schema/LdapLoadedSchemaTest.php +++ b/tests/integration/Schema/LdapLoadedSchemaTest.php @@ -209,20 +209,32 @@ public function test_numeric_string_substring_spans_a_space(): void ); } - public function test_bit_string_matches_a_bare_bit_sequence(): void + public function test_bit_string_matches_the_quoted_form(): void { $this->createRecord( 'bit-string', ['projectFlags' => "'0101'B"], ); - // A plain string comparison would not match the quoted form. self::assertSame( 'bit-string', - $this->findOne(Filters::equal('projectFlags', '0101')), + $this->findOne(Filters::equal('projectFlags', "'0101'B")), ); } + /** + * RFC 4517 3.3.2 requires the quoted form, so a bare sequence is an invalid assertion value and is Undefined. + */ + public function test_bit_string_rejects_a_bare_bit_sequence(): void + { + $this->createRecord( + 'bit-string-bare', + ['projectFlags' => "'0101'B"], + ); + + self::assertNull($this->findOne(Filters::equal('projectFlags', '0101'))); + } + public function test_bit_string_does_not_match_different_bits(): void { $this->createRecord( diff --git a/tests/integration/Storage/Concern/QueryTestsTrait.php b/tests/integration/Storage/Concern/QueryTestsTrait.php index c962c77a..26e88520 100644 --- a/tests/integration/Storage/Concern/QueryTestsTrait.php +++ b/tests/integration/Storage/Concern/QueryTestsTrait.php @@ -84,9 +84,14 @@ public static function filterProvider(): iterable Filters::equal('uidNumber', '99abc'), 0, ]; - yield 'leading zeros are the same integer' => [ + // RFC 4517 3.3.16 forbids a leading zero, so the assertion value is invalid and the item is Undefined. + yield 'an integer assertion with a leading zero is undefined' => [ Filters::equal('uidNumber', '099'), - 1, + 0, + ]; + yield 'a negated integer assertion with a leading zero is still undefined' => [ + Filters::not(Filters::equal('uidNumber', '099')), + 0, ]; // RFC 4511 4.5.1.7: an assertion on an unrecognized attribute type is Undefined, and NOT of Undefined stays @@ -189,6 +194,37 @@ public static function filterProvider(): iterable Filters::approximate('employeeNumber', 'A1b2C3'), 1, ]; + + // An assertion value the type's syntax rejects is Undefined, which is excluded under negation as well. The + // valid-but-unmatched pairs are the control: those negate to every entry, the invalid ones to none. + yield 'an assertion value the syntax rejects matches nothing' => [ + Filters::equal('member', '%%%'), + 0, + ]; + yield 'a negated assertion value the syntax rejects still matches nothing' => [ + Filters::not(Filters::equal('member', '%%%')), + 0, + ]; + yield 'a valid assertion matching no entry matches nothing' => [ + Filters::equal('member', 'cn=nobody,dc=foo,dc=bar'), + 0, + ]; + yield 'a negated valid assertion matching no entry matches every entry' => [ + Filters::not(Filters::equal('member', 'cn=nobody,dc=foo,dc=bar')), + 8, + ]; + yield 'an ordered assertion the syntax rejects matches nothing' => [ + Filters::greaterThanOrEqual('member', '%%%'), + 0, + ]; + yield 'a negated ordered assertion the syntax rejects matches nothing' => [ + Filters::not(Filters::greaterThanOrEqual('member', '%%%')), + 0, + ]; + yield 'a substring fragment is not held to the assertion syntax' => [ + Filters::startsWith('member', 'cn=user,'), + 2, + ]; } #[DataProvider('filterProvider')] diff --git a/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php index 574e6acc..483e2a9c 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php @@ -211,6 +211,7 @@ public function test_gte_emits_numeric_cast_for_integer_ordered_attribute(): voi $context = $this->createMock(FilterAttributeContextInterface::class); $context->method('isIntegerOrdered')->willReturn(true); $context->method('filterSupport')->willReturn(AttributeFilterSupport::Exact); + $context->method('assertionValueConforms')->willReturn(true); $result = $this->subject->translate( new GreaterThanOrEqualFilter('uidNumber', '30'), diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php index f64f5cd1..50f38fee 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -280,6 +280,46 @@ public function test_negating_an_attribute_the_schema_does_not_define_can_never_ ); } + public function test_an_assertion_value_the_syntax_rejects_selects_nothing_and_stays_inexact(): void + { + $result = $this->subject->translate( + new EqualityFilter('uidNumber', 'abc'), + $this->attributeContext(assertionConforms: false), + ); + + self::assertNotNull($result); + self::assertStringContainsString( + '1 = 0', + $result->sql, + ); + self::assertFalse($result->isExact); + } + + public function test_a_negated_assertion_the_syntax_rejects_stays_inexact_so_the_evaluator_decides(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter('uidNumber', 'abc')), + $this->attributeContext(assertionConforms: false), + ); + + self::assertNotNull($result); + self::assertFalse($result->isExact); + } + + public function test_a_substring_filter_is_not_subject_to_the_assertion_syntax_check(): void + { + $result = $this->subject->translate( + new SubstringFilter('uidNumber', '1'), + $this->attributeContext(assertionConforms: false), + ); + + self::assertNotNull($result); + self::assertStringNotContainsString( + '1 = 0', + $result->sql, + ); + } + public function test_an_attribute_with_subtypes_is_left_to_the_evaluator(): void { $result = $this->subject->translate( @@ -1027,10 +1067,12 @@ public function test_or_exposes_no_drivable_leaves(): void private function attributeContext( ?bool $integerOrdered = null, AttributeFilterSupport $support = AttributeFilterSupport::Exact, + bool $assertionConforms = true, ): FilterAttributeContextInterface { $context = $this->createMock(FilterAttributeContextInterface::class); $context->method('isIntegerOrdered')->willReturn($integerOrdered); $context->method('filterSupport')->willReturn($support); + $context->method('assertionValueConforms')->willReturn($assertionConforms); return $context; } diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index 63ddbc0e..1518e774 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -26,6 +26,8 @@ use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use Generator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class FilterEvaluatorTest extends TestCase @@ -986,4 +988,90 @@ public function test_schema_gte_infers_numeric_ordering_from_integer_syntax(): v Filters::greaterThanOrEqual('uidNumber', '100'), )); } + + #[DataProvider('invalidAssertionValueProvider')] + public function test_an_assertion_value_the_syntax_rejects_is_undefined(FilterInterface $filter): void + { + self::assertFalse($this->integerSchemaEvaluator()->evaluate( + $this->uidNumberEntry(), + $filter, + )); + } + + /** + * Undefined is excluded under negation too, so both polarities answer the same way (RFC 4511 4.5.1.7). + */ + #[DataProvider('invalidAssertionValueProvider')] + public function test_a_negated_assertion_value_the_syntax_rejects_is_still_undefined(FilterInterface $filter): void + { + self::assertFalse($this->integerSchemaEvaluator()->evaluate( + $this->uidNumberEntry(), + Filters::not($filter), + )); + } + + public static function invalidAssertionValueProvider(): Generator + { + yield 'equality' => [Filters::equal('uidNumber', 'abc')]; + yield 'greaterThanOrEqual' => [Filters::greaterThanOrEqual('uidNumber', 'abc')]; + yield 'lessThanOrEqual' => [Filters::lessThanOrEqual('uidNumber', 'abc')]; + yield 'approximate' => [new ApproximateFilter('uidNumber', 'abc')]; + } + + public function test_a_conforming_assertion_value_is_unaffected(): void + { + $subject = $this->integerSchemaEvaluator(); + $entry = $this->uidNumberEntry(); + + self::assertTrue($subject->evaluate( + $entry, + Filters::equal('uidNumber', '100'), + )); + self::assertFalse($subject->evaluate( + $entry, + Filters::not(Filters::equal('uidNumber', '100')), + )); + } + + /** + * A present filter carries no assertion value, so it cannot be Undefined for this reason. + */ + public function test_a_present_filter_is_not_subject_to_the_assertion_syntax_check(): void + { + self::assertTrue($this->integerSchemaEvaluator()->evaluate( + $this->uidNumberEntry(), + Filters::present('uidNumber'), + )); + } + + /** + * A substring fragment is a portion of a value rather than a whole one, so it is not held to the type's syntax. + */ + public function test_a_substring_fragment_is_not_subject_to_the_assertion_syntax_check(): void + { + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('uidNumber', '-50'), + ); + + self::assertTrue($this->integerSchemaEvaluator()->evaluate( + $entry, + Filters::startsWith('uidNumber', '-'), + )); + } + + private function integerSchemaEvaluator(): FilterEvaluator + { + return new FilterEvaluator( + SchemaResource::Core->load()->merge(SchemaResource::Nis->load()), + ); + } + + private function uidNumberEntry(): Entry + { + return new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('uidNumber', '100'), + ); + } } From 0f58a79d28754991e8b05d2884ece3e459e5f5ba Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 13 Aug 2026 18:12:17 -0400 Subject: [PATCH 2/4] Correctly filter entryDN. --- .../Backend/Storage/EntryDnAttributeTrait.php | 38 +++++++ .../Backend/Storage/FilterEvaluator.php | 11 ++- .../Backend/Storage/SearchStreamBuilder.php | 9 +- .../Backend/Storage/StorageListOptions.php | 7 ++ .../Storage/Concern/QueryTestsTrait.php | 34 +++++++ .../Backend/Storage/FilterEvaluatorTest.php | 99 ++++++++++++++++++- 6 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/EntryDnAttributeTrait.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryDnAttributeTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryDnAttributeTrait.php new file mode 100644 index 00000000..4fabe4c4 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryDnAttributeTrait.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\Server\Backend\Storage; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; + +use function strcasecmp; + +/** + * Recognizes the entryDN description, whose value comes from the entry itself rather than from stored values. + * + * @author Chad Sikorra + */ +trait EntryDnAttributeTrait +{ + /** + * Whether a description names entryDN, by either its name or its OID. + */ + private static function isEntryDnAttribute(string $attributeDescription): bool + { + $type = Attribute::normalizeName($attributeDescription); + + return strcasecmp($type, AttributeTypeOid::NAME_ENTRY_DN) === 0 + || $type === AttributeTypeOid::OID_ENTRY_DN; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index d398acf0..6fcdba18 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -48,6 +48,8 @@ */ final class FilterEvaluator implements FilterEvaluatorInterface { + use EntryDnAttributeTrait; + private const MATCHING_RULE_CASE_IGNORE = '2.5.13.2'; private const MATCHING_RULE_CASE_EXACT = '2.5.13.5'; @@ -371,7 +373,9 @@ private function collectValuesToTest( $values = []; if ($filterAttributeName !== null) { - $values = $this->lookupAttribute($entry, $filterAttributeName)?->getValues() ?? []; + $values = self::isEntryDnAttribute($filterAttributeName) + ? [$entry->getDn()->toString()] + : $this->lookupAttribute($entry, $filterAttributeName)?->getValues() ?? []; } else { foreach ($entry->getAttributes() as $attribute) { array_push( @@ -558,6 +562,11 @@ private function valuesForAssertion( Entry $entry, string $filterAttributeName, ): array { + // RFC 5020: derived from the entry, so it is matchable whether or not the request asked for it. + if (self::isEntryDnAttribute($filterAttributeName)) { + return [$entry->getDn()->toString()]; + } + if (self::descriptionHasOptions($filterAttributeName)) { return $entry->get($filterAttributeName)?->getValues() ?? []; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php index 35c8c29d..ad938c55 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php @@ -31,6 +31,8 @@ */ final readonly class SearchStreamBuilder { + use EntryDnAttributeTrait; + /** * @param FilterEvaluatorInterface $filterEvaluator Must know the configured schema, or matching rules are ignored. */ @@ -110,7 +112,7 @@ private function isHasSubordinatesAttribute(Attribute $attr): bool private function requestsEntryDn(SearchRequest $request): bool { foreach ($request->getAttributes() as $attr) { - if ($this->isEntryDnAttribute($attr)) { + if ($this->requestsEntryDnAttribute($attr)) { return true; } } @@ -118,11 +120,10 @@ private function requestsEntryDn(SearchRequest $request): bool return false; } - private function isEntryDnAttribute(Attribute $attr): bool + private function requestsEntryDnAttribute(Attribute $attr): bool { return strcasecmp($attr->getName(), '+') === 0 - || strcasecmp($attr->getName(), AttributeTypeOid::NAME_ENTRY_DN) === 0 - || $attr->getName() === AttributeTypeOid::OID_ENTRY_DN; + || self::isEntryDnAttribute($attr->getName()); } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php index d6e5b755..71c56106 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php @@ -29,6 +29,8 @@ */ final readonly class StorageListOptions implements FilterAttributeContextInterface { + use EntryDnAttributeTrait; + private ?AttributeSyntaxResolver $syntaxResolver; /** @@ -68,6 +70,11 @@ public function assertionValueConforms( */ public function filterSupport(string $attribute): AttributeFilterSupport { + // Derived on read rather than stored, so no rows answer it and only the evaluator can. + if (self::isEntryDnAttribute($attribute)) { + return AttributeFilterSupport::NeedsEvaluator; + } + if ($this->schema === null) { return AttributeFilterSupport::Exact; } diff --git a/tests/integration/Storage/Concern/QueryTestsTrait.php b/tests/integration/Storage/Concern/QueryTestsTrait.php index 26e88520..c391037f 100644 --- a/tests/integration/Storage/Concern/QueryTestsTrait.php +++ b/tests/integration/Storage/Concern/QueryTestsTrait.php @@ -225,6 +225,40 @@ public static function filterProvider(): iterable Filters::startsWith('member', 'cn=user,'), 2, ]; + + // RFC 5020: entryDN is derived on read, so it must match without having been requested or stored. + yield 'entryDN matches the entry it names' => [ + Filters::equal( + 'entryDN', + 'cn=alice,ou=people,dc=foo,dc=bar', + ), + 1, + ]; + yield 'entryDN compares as a dn rather than a string' => [ + Filters::equal( + 'entryDN', + 'CN=alice,OU=people,DC=foo,DC=bar', + ), + 1, + ]; + yield 'entryDN matches an escaped rdn by its unescaped value' => [ + Filters::equal( + 'entryDN', + 'cn=Smith\, John,dc=foo,dc=bar', + ), + 1, + ]; + yield 'entryDN matches nothing for an absent dn' => [ + Filters::equal( + 'entryDN', + 'cn=nobody,dc=foo,dc=bar', + ), + 0, + ]; + yield 'entryDN is present on every entry' => [ + Filters::present('entryDN'), + 8, + ]; } #[DataProvider('filterProvider')] diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index 1518e774..9edafd9e 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -1012,10 +1012,30 @@ public function test_a_negated_assertion_value_the_syntax_rejects_is_still_undef public static function invalidAssertionValueProvider(): Generator { - yield 'equality' => [Filters::equal('uidNumber', 'abc')]; - yield 'greaterThanOrEqual' => [Filters::greaterThanOrEqual('uidNumber', 'abc')]; - yield 'lessThanOrEqual' => [Filters::lessThanOrEqual('uidNumber', 'abc')]; - yield 'approximate' => [new ApproximateFilter('uidNumber', 'abc')]; + yield 'equality' => [ + Filters::equal( + 'uidNumber', + 'abc', + ), + ]; + yield 'greaterThanOrEqual' => [ + Filters::greaterThanOrEqual( + 'uidNumber', + 'abc', + ), + ]; + yield 'lessThanOrEqual' => [ + Filters::lessThanOrEqual( + 'uidNumber', + 'abc', + ), + ]; + yield 'approximate' => [ + new ApproximateFilter( + 'uidNumber', + 'abc', + ), + ]; } public function test_a_conforming_assertion_value_is_unaffected(): void @@ -1060,6 +1080,77 @@ public function test_a_substring_fragment_is_not_subject_to_the_assertion_syntax )); } + /** + * RFC 5020: entryDN is derived from the entry, so it matches whether or not the request asked for it. + */ + #[DataProvider('entryDnFilterProvider')] + public function test_entry_dn_is_filterable( + FilterInterface $filter, + bool $expected, + ): void { + $entry = new Entry( + new Dn('cn=Smith\, John,dc=foo,dc=bar'), + new Attribute('cn', 'Smith, John'), + ); + + self::assertSame( + $expected, + $this->subject->evaluate($entry, $filter), + ); + } + + public static function entryDnFilterProvider(): Generator + { + yield 'exact' => [ + Filters::equal( + 'entryDN', + 'cn=Smith\, John,dc=foo,dc=bar', + ), + true, + ]; + yield 'compared as a dn rather than a string' => [ + Filters::equal( + 'entryDN', + 'CN=Smith\, John,DC=foo,DC=bar', + ), + true, + ]; + yield 'by oid' => [ + Filters::equal( + '1.3.6.1.1.20', + 'cn=Smith\, John,dc=foo,dc=bar', + ), + true, + ]; + yield 'another entry dn' => [ + Filters::equal( + 'entryDN', + 'cn=other,dc=foo,dc=bar', + ), + false, + ]; + yield 'negated another entry dn' => [ + Filters::not( + Filters::equal( + 'entryDN', + 'cn=other,dc=foo,dc=bar', + ), + ), + true, + ]; + yield 'present' => [ + Filters::present('entryDN'), + true, + ]; + yield 'substring' => [ + Filters::endsWith( + 'entryDN', + 'dc=foo,dc=bar', + ), + true, + ]; + } + private function integerSchemaEvaluator(): FilterEvaluator { return new FilterEvaluator( From 60dc5b2e7e29fe24546a5f954dbe157420c88dde Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 13 Aug 2026 18:30:05 -0400 Subject: [PATCH 3/4] Correctly handle filters referencing undefined schema attributes (even if the entry has said attribute in storage). --- .../Backend/Storage/FilterEvaluator.php | 72 ++++++++++--------- tests/integration/LdapCancelServerTest.php | 2 +- tests/integration/LdapServerTest.php | 8 +-- .../Storage/Concern/QueryTestsTrait.php | 23 ++++++ .../resources/seed/backend-storage-seed.ldif | 2 + tests/support/LdapServerCommand.php | 3 +- .../Backend/Storage/FilterEvaluatorTest.php | 38 ++++------ 7 files changed, 82 insertions(+), 66 deletions(-) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 6fcdba18..04356d50 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -29,6 +29,7 @@ use FreeDSx\Ldap\Search\Filter\ApproximateFilter; use FreeDSx\Ldap\Search\Filter\AttributeValueAssertionInterface; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filter\FilterAttributeInterface; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; use FreeDSx\Ldap\Search\Filter\LessThanOrEqualFilter; @@ -80,12 +81,12 @@ final class FilterEvaluator implements FilterEvaluatorInterface /** * @var WeakMap */ - private WeakMap $orderedDigitCache; + private WeakMap $assertionSyntaxCache; /** * @var WeakMap */ - private WeakMap $assertionSyntaxCache; + private WeakMap $recognizedAttributeCache; private readonly CaseIgnoreComparator $defaultComparator; @@ -99,8 +100,8 @@ public function __construct(private readonly Schema $schema) $this->integerComparator = new IntegerComparator(); $this->syntaxResolver = new AttributeSyntaxResolver($schema); $this->substringCache = new WeakMap(); - $this->orderedDigitCache = new WeakMap(); $this->assertionSyntaxCache = new WeakMap(); + $this->recognizedAttributeCache = new WeakMap(); } public function evaluate( @@ -117,6 +118,10 @@ private function evaluateFilter( Entry $entry, FilterInterface $filter, ): FilterResult { + if ($filter instanceof FilterAttributeInterface && !$this->attributeIsRecognized($filter)) { + return FilterResult::Undefined; + } + return match (true) { $filter instanceof AndFilter => $this->evaluateAnd($entry, $filter), $filter instanceof OrFilter => $this->evaluateOr($entry, $filter), @@ -201,7 +206,7 @@ private function evaluatePresent( // RFC 4511 4.5.1.7.5: true when the type or a subtype of it is present. return $this->valuesForAssertion($entry, $filter->getAttribute()) !== [] ? FilterResult::True - : $this->absentResult($filter->getAttribute()); + : FilterResult::False; } private function evaluateEquality( @@ -217,8 +222,9 @@ private function evaluateEquality( $filter->getAttribute(), ); + // A type the schema defines but the entry lacks is False; an undefined type was answered upstream. if ($values === []) { - return $this->absentResult($filter->getAttribute()); + return FilterResult::False; } $comparator = $this->resolveEqualityComparator($filter->getAttribute()); @@ -239,8 +245,9 @@ private function evaluateSubstring( ): FilterResult { $values = $this->valuesForAssertion($entry, $filter->getAttribute()); + // A type the schema defines but the entry lacks is False; an undefined type was answered upstream. if ($values === []) { - return $this->absentResult($filter->getAttribute()); + return FilterResult::False; } $comparator = $this->resolveSubstringComparator($filter->getAttribute()); @@ -271,18 +278,17 @@ private function evaluateOrdered( $filter->getAttribute(), ); + // A type the schema defines but the entry lacks is False; an undefined type was answered upstream. if ($values === []) { - return $this->absentResult($filter->getAttribute()); + return FilterResult::False; } $filterValue = $filter->getValue(); $comparator = $this->resolveOrderingComparator($filter->getAttribute()); - $filterIsDigit = $comparator === null && $this->orderedFilterValueIsDigit($filter); $atLeast = $filter instanceof GreaterThanOrEqualFilter; foreach ($values as $value) { - $cmp = $comparator?->compare($value, $filterValue) - ?? $this->compareOrdered($value, $filterValue, $filterIsDigit); + $cmp = $comparator->compare($value, $filterValue); if ($atLeast ? $cmp >= 0 : $cmp <= 0) { return FilterResult::True; @@ -292,18 +298,6 @@ private function evaluateOrdered( return FilterResult::False; } - private function compareOrdered( - string $value, - string $filterValue, - bool $filterValueIsDigit, - ): int { - if ($filterValueIsDigit && ctype_digit($value)) { - return (int) $value <=> (int) $filterValue; - } - - return strcasecmp($value, $filterValue); - } - private function evaluateApproximate( Entry $entry, ApproximateFilter $filter, @@ -314,8 +308,9 @@ private function evaluateApproximate( $values = $this->valuesForAssertion($entry, $filter->getAttribute()); + // A type the schema defines but the entry lacks is False; an undefined type was answered upstream. if ($values === []) { - return $this->absentResult($filter->getAttribute()); + return FilterResult::False; } // No approximate rule is implemented, so this falls back to the type's own equality rather than a case @@ -488,12 +483,12 @@ private function resolveSubstringComparator(string $attrName): MatchingRuleCompa /** * Returns null when the attribute is unknown, so the caller falls back to the digit heuristic. */ - private function resolveOrderingComparator(string $attrName): ?MatchingRuleComparatorInterface + private function resolveOrderingComparator(string $attrName): MatchingRuleComparatorInterface { $attrType = $this->schema->getAttributeType($attrName); if ($attrType === null) { - return null; + return $this->defaultComparator; } // An explicit, registered ordering rule wins @@ -601,20 +596,27 @@ private function describesSameType( } /** - * An item is Undefined only when the server cannot determine an answer, which for an attribute description means - * the schema does not define it (RFC 4511 4.5.1.7). A defined type the entry simply lacks is False. + * An attribute description the schema does not define makes the item Undefined (RFC 4511 4.5.1.7), whether or + * not an entry happens to carry values under that name. + * + * The answer depends only on the filter, so it is memoized rather than recomputed for every entry tested. */ - private function absentResult(string $filterAttributeName): FilterResult + private function attributeIsRecognized(FilterAttributeInterface $filter): bool { - return $this->schema->getAttributeType(Attribute::normalizeName($filterAttributeName)) === null - ? FilterResult::Undefined - : FilterResult::False; + return $this->recognizedAttributeCache[$filter] ??= $this->schemaDefines($filter->getAttribute()); } - private function orderedFilterValueIsDigit( - GreaterThanOrEqualFilter|LessThanOrEqualFilter $filter, - ): bool { - return $this->orderedDigitCache[$filter] ??= ctype_digit($filter->getValue()); + /** + * An extensibleMatch may name no type at all, which this rule has nothing to say about. + */ + private function schemaDefines(?string $attributeDescription): bool + { + if ($attributeDescription === null) { + return true; + } + + return self::isEntryDnAttribute($attributeDescription) + || $this->schema->getAttributeType(Attribute::normalizeName($attributeDescription)) !== null; } /** diff --git a/tests/integration/LdapCancelServerTest.php b/tests/integration/LdapCancelServerTest.php index 68f0f645..32e92b22 100644 --- a/tests/integration/LdapCancelServerTest.php +++ b/tests/integration/LdapCancelServerTest.php @@ -38,7 +38,7 @@ public function testMidStreamCancelStopsSearchAndReturnsSuccess(): void { $entriesReceived = 0; - $request = Operations::search(Filters::present('foo')) + $request = Operations::search(Filters::present('employeeNumber')) ->base('dc=foo,dc=bar') ->useEntryHandler(function (EntryResult $result) use (&$entriesReceived): void { $entriesReceived++; diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index bf9f167e..e75f8d2e 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -402,7 +402,7 @@ public function testItCanHandlingPaging(): void $allEntries = []; $iterations = 0; - $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); + $search = Operations::search(Filters::raw('(employeeNumber=*)'))->base('dc=foo,dc=bar'); $paging = $this->ldapClient()->paging($search); while ($paging->hasEntries()) { @@ -428,7 +428,7 @@ public function testPagedSearchHonorsAGenerousSeparatePagedLookthroughLimit(): v ]); $this->authenticateUser(); - $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); + $search = Operations::search(Filters::raw('(employeeNumber=*)'))->base('dc=foo,dc=bar'); $paging = $this->ldapClient()->paging($search); $count = 0; @@ -455,7 +455,7 @@ public function testPagedSearchFallsBackToRegularLookthroughWhenPagedUnset(): vo $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); - $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); + $search = Operations::search(Filters::raw('(employeeNumber=*)'))->base('dc=foo,dc=bar'); $paging = $this->ldapClient()->paging($search); while ($paging->hasEntries()) { $paging->getEntries(500); @@ -476,7 +476,7 @@ public function testPerIdentityRuleAppliesAuthenticatedLookthrough(): void $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); $this->ldapClient()->search( - Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar')->useSubtreeScope(), + Operations::search(Filters::raw('(employeeNumber=*)'))->base('dc=foo,dc=bar')->useSubtreeScope(), ); } diff --git a/tests/integration/Storage/Concern/QueryTestsTrait.php b/tests/integration/Storage/Concern/QueryTestsTrait.php index c391037f..48042d50 100644 --- a/tests/integration/Storage/Concern/QueryTestsTrait.php +++ b/tests/integration/Storage/Concern/QueryTestsTrait.php @@ -259,6 +259,29 @@ public static function filterProvider(): iterable Filters::present('entryDN'), 8, ]; + + // cn=alice stores shoeSize, which the configured schema does not define. Every assertion on it is Undefined + // regardless of that stored value, and Undefined is excluded under negation too (RFC 4511 4.5.1.7). + yield 'an unrecognized type an entry stores is undefined' => [ + Filters::equal( + 'shoeSize', + '12', + ), + 0, + ]; + yield 'a negated unrecognized type an entry stores is still undefined' => [ + Filters::not( + Filters::equal( + 'shoeSize', + '12', + ), + ), + 0, + ]; + yield 'a present filter on an unrecognized type an entry stores is undefined' => [ + Filters::present('shoeSize'), + 0, + ]; } #[DataProvider('filterProvider')] diff --git a/tests/resources/seed/backend-storage-seed.ldif b/tests/resources/seed/backend-storage-seed.ldif index 8c7ac20e..cac80cfa 100644 --- a/tests/resources/seed/backend-storage-seed.ldif +++ b/tests/resources/seed/backend-storage-seed.ldif @@ -42,6 +42,8 @@ mail;lang-en: alice-en@foo.bar uidNumber: 99 # Matched case-exactly by the schema, so searches on it prove the configured schema is in play. employeeNumber: A1b2C3 +# Stored but absent from the schema, so assertions on it must be Undefined rather than answered from the value. +shoeSize: 12 dn: cn=nosn,dc=foo,dc=bar objectClass: groupOfNames diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 72772de1..56161ec6 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -287,7 +287,8 @@ protected function execute( 'cn' => "entry-{$i}", 'objectClass' => 'inetOrgPerson', 'sn' => 'Entry', - 'foo' => (string) $i, + // Must be a type the schema defines, or every assertion on it is Undefined (RFC 4511 4.5.1.7). + 'employeeNumber' => (string) $i, ], ); } diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index 9edafd9e..b6429ef2 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -38,7 +38,10 @@ final class FilterEvaluatorTest extends TestCase protected function setUp(): void { - $this->subject = new FilterEvaluator(SchemaResource::Core->load()); + // NIS is merged in for uidNumber: an unrecognized type is Undefined, so the fixture must define what it asserts on. + $this->subject = new FilterEvaluator( + SchemaResource::Core->load()->merge(SchemaResource::Nis->load()), + ); $this->entry = new Entry( new Dn('cn=Alice,dc=example,dc=com'), @@ -262,33 +265,18 @@ public function test_lte_returns_false_when_greater(): void ); } - public function test_gte_compares_numbers_as_integers(): void - { - // '10' >= '5' must be true numerically; lexicographically '10' < '5' - $entry = new Entry( - new Dn('cn=Test,dc=example,dc=com'), - new Attribute('count', '10'), - ); - - self::assertTrue( - $this->subject->evaluate($entry, Filters::greaterThanOrEqual('count', '5')), - ); - } - - public function test_gte_does_not_treat_scientific_notation_as_numeric(): void + /** + * Ordering follows the schema, so a type with no ORDERING rule and a non-integer syntax orders as a string. + */ + public function test_gte_orders_as_a_string_when_the_type_has_no_ordering_rule(): void { - // '1e1' is not ctype_digit, so it falls back to lexicographic comparison. - // Lexicographically '1e1' < '5', so gte('5') should be false. $entry = new Entry( new Dn('cn=Test,dc=example,dc=com'), - new Attribute('count', '1e1'), + new Attribute('description', '10'), ); self::assertFalse( - $this->subject->evaluate( - $entry, - Filters::greaterThanOrEqual('count', '5'), - ), + $this->subject->evaluate($entry, Filters::greaterThanOrEqual('description', '5')), ); } @@ -737,12 +725,12 @@ public function test_equality_multi_valued_attribute_matches_second_value(): voi { $entry = new Entry( new Dn('cn=Multi,dc=example,dc=com'), - new Attribute('mailAlias', 'a@foo.bar', 'b@foo.bar', 'c@foo.bar'), + new Attribute('mail', 'a@foo.bar', 'b@foo.bar', 'c@foo.bar'), ); self::assertTrue($this->subject->evaluate( $entry, - Filters::equal('mailAlias', 'b@foo.bar'), + Filters::equal('mail', 'b@foo.bar'), )); } @@ -929,7 +917,7 @@ public function test_schema_substring_matches_across_collapsed_spaces(): void public function test_schema_matching_rule_filter_resolves_non_hardcoded_oid(): void { - $subject = new FilterEvaluator(SchemaResource::Core->load()); + $subject = $this->integerSchemaEvaluator(); $entry = new Entry( new Dn('cn=Test,dc=example,dc=com'), new Attribute('uidNumber', '1001'), From 5c1b6c4a504fc21a23395a528e69708255295470 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 13 Aug 2026 19:03:19 -0400 Subject: [PATCH 4/4] Correctly handle attributes based on whether substring rule is declared for them. --- src/FreeDSx/Ldap/Schema/Schema.php | 22 +++ .../SqlFilter/SqlFilterTranslatorTrait.php | 15 +- .../FilterAttributeContextInterface.php | 5 + .../Backend/Storage/FilterEvaluator.php | 45 ++++-- .../Backend/Storage/StorageListOptions.php | 13 ++ .../Security/AclIntegrationTest.php | 20 +++ .../Storage/Concern/QueryTestsTrait.php | 66 ++++++++- tests/resources/schema/acl-secret-code.ldif | 2 +- .../Adapter/SqliteFilterTranslatorTest.php | 17 +++ .../Backend/Storage/FilterEvaluatorTest.php | 135 +++++++++++++++++- 10 files changed, 320 insertions(+), 20 deletions(-) diff --git a/src/FreeDSx/Ldap/Schema/Schema.php b/src/FreeDSx/Ldap/Schema/Schema.php index f87b95df..324573d8 100644 --- a/src/FreeDSx/Ldap/Schema/Schema.php +++ b/src/FreeDSx/Ldap/Schema/Schema.php @@ -126,6 +126,28 @@ public function isIntegerOrdered(string $nameOrOid): ?bool return $attributeType->syntaxOid === SyntaxOid::OID_INTEGER; } + /** + * The effective SUBSTR rule OID, walking the SUP chain when the type declares none directly. + */ + public function getSubstringRuleOid(string $nameOrOid): ?string + { + $attributeType = $this->getAttributeType($nameOrOid); + $seen = []; + + while ($attributeType !== null && !isset($seen[$attributeType->oid])) { + if ($attributeType->substringOid !== null) { + return $attributeType->substringOid; + } + + $seen[$attributeType->oid] = true; + $attributeType = $attributeType->superTypeOid !== null + ? $this->getAttributeType($attributeType->superTypeOid) + : null; + } + + return null; + } + /** * Whether one attribute type is the other, or descends from it through the SUP chain. * diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php index ede5d4a9..acf9ef9a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php @@ -64,7 +64,7 @@ private function filterSupport(string $attribute): AttributeFilterSupport } /** - * An assertion value the type's syntax rejects is Undefined for every entry, whatever the attribute supports. + * An item the type cannot answer is Undefined for every entry, whatever the attribute otherwise supports. */ private function supportFor(FilterInterface $filter): AttributeFilterSupport { @@ -72,6 +72,11 @@ private function supportFor(FilterInterface $filter): AttributeFilterSupport return AttributeFilterSupport::NeverMatches; } + // A substring item applies the type's SUBSTR rule, so a type declaring none cannot answer it. + if ($filter instanceof SubstringFilter && !$this->hasSubstringRule($filter->getAttribute())) { + return AttributeFilterSupport::NeverMatches; + } + $attribute = $filter instanceof FilterAttributeInterface ? $filter->getAttribute() : null; @@ -89,6 +94,14 @@ private function assertionValueConforms(AttributeValueAssertionInterface $filter ) ?? true; } + /** + * Whether the attribute defines a SUBSTR rule; true when the caller had no schema. + */ + private function hasSubstringRule(string $attribute): bool + { + return $this->attributeContext?->hasSubstringRule($attribute) ?? true; + } + private function dispatch(FilterInterface $filter): ?SqlFilterResult { $support = $this->supportFor($filter); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php index 27d191f3..43880cf1 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php @@ -42,4 +42,9 @@ public function assertionValueConforms( string $attribute, string $value, ): bool; + + /** + * Whether the attribute defines a SUBSTR rule (false makes a substring item Undefined per RFC 4511 4.5.1.7). + */ + public function hasSubstringRule(string $attribute): bool; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 04356d50..1c1254ab 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -88,6 +88,13 @@ final class FilterEvaluator implements FilterEvaluatorInterface */ private WeakMap $recognizedAttributeCache; + /** + * Resolved SUBSTR rule OID per filter, with false standing for a type that declares none. + * + * @var WeakMap + */ + private WeakMap $substringRuleCache; + private readonly CaseIgnoreComparator $defaultComparator; private readonly IntegerComparator $integerComparator; @@ -102,6 +109,7 @@ public function __construct(private readonly Schema $schema) $this->substringCache = new WeakMap(); $this->assertionSyntaxCache = new WeakMap(); $this->recognizedAttributeCache = new WeakMap(); + $this->substringRuleCache = new WeakMap(); } public function evaluate( @@ -243,6 +251,14 @@ private function evaluateSubstring( Entry $entry, SubstringFilter $filter, ): FilterResult { + // RFC 4511 4.5.1.7: the rule a substring item applies is the type's SUBSTR, so a type declaring none has + // no way to answer the assertion and the item is Undefined. + $substringRuleOid = $this->substringRuleOid($filter); + + if ($substringRuleOid === null) { + return FilterResult::Undefined; + } + $values = $this->valuesForAssertion($entry, $filter->getAttribute()); // A type the schema defines but the entry lacks is False; an undefined type was answered upstream. @@ -250,7 +266,8 @@ private function evaluateSubstring( return FilterResult::False; } - $comparator = $this->resolveSubstringComparator($filter->getAttribute()); + $comparator = $this->schema->getComparator($substringRuleOid) + ?? $this->defaultComparator; $assertion = $this->buildSubstringAssertion($filter); foreach ($values as $value) { @@ -470,16 +487,6 @@ private function resolveEqualityComparator(string $attrName): MatchingRuleCompar return $comparator ?? $this->defaultComparator; } - private function resolveSubstringComparator(string $attrName): MatchingRuleComparatorInterface - { - $attrType = $this->schema->getAttributeType($attrName); - $comparator = $attrType?->substringOid !== null - ? $this->schema->getComparator($attrType->substringOid) - : null; - - return $comparator ?? $this->defaultComparator; - } - /** * Returns null when the attribute is unknown, so the caller falls back to the digit heuristic. */ @@ -631,4 +638,20 @@ private function assertionSyntaxIsValid(AttributeValueAssertionInterface $filter $filter->getValue(), ); } + + /** + * The SUBSTR rule the filter's type resolves to, or null when it declares none. + * + * The answer depends only on the filter, so it is memoized rather than recomputed for every entry tested. + */ + private function substringRuleOid(SubstringFilter $filter): ?string + { + $ruleOid = $this->substringRuleCache[$filter] ??= $this->schema->getSubstringRuleOid( + Attribute::normalizeName($filter->getAttribute()), + ) ?? false; + + return $ruleOid === false + ? null + : $ruleOid; + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php index 71c56106..b2607b99 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php @@ -65,6 +65,19 @@ public function assertionValueConforms( return $this->syntaxResolver?->conforms($attribute, $value) ?? true; } + /** + * Whether the attribute defines a SUBSTR rule; true when no schema can say otherwise. + */ + public function hasSubstringRule(string $attribute): bool + { + if ($this->schema === null) { + return true; + } + + // Options are not part of the type, so they are dropped before asking the schema about it. + return $this->schema->getSubstringRuleOid(Attribute::normalizeName($attribute)) !== null; + } + /** * How faithfully SQL alone can answer an assertion on the attribute. */ diff --git a/tests/integration/Security/AclIntegrationTest.php b/tests/integration/Security/AclIntegrationTest.php index 79010a75..a8a2fe98 100644 --- a/tests/integration/Security/AclIntegrationTest.php +++ b/tests/integration/Security/AclIntegrationTest.php @@ -631,6 +631,26 @@ public function testFilteringOnACustomConfidentialAttributeMatchesNothing(): voi ); } + public function testFilteringOnACustomConfidentialAttributePrefixMatchesNothing(): void + { + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', '12345'); + + // secretCode declares a SUBSTR rule, so only the confidential rewrite can be keeping this from matching. + $results = $this->ldapClient()->search( + Operations::search(Filters::startsWith( + 'secretCode', + substr(LdapAclCommand::SECRET_CODE, 0, 4), + )) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + 0, + $results, + ); + } + public function testAGrantDoesNotExtendToOtherConfidentialAttributes(): void { $this->authenticateAdmin(); diff --git a/tests/integration/Storage/Concern/QueryTestsTrait.php b/tests/integration/Storage/Concern/QueryTestsTrait.php index 48042d50..20b8c6b9 100644 --- a/tests/integration/Storage/Concern/QueryTestsTrait.php +++ b/tests/integration/Storage/Concern/QueryTestsTrait.php @@ -221,9 +221,69 @@ public static function filterProvider(): iterable Filters::not(Filters::greaterThanOrEqual('member', '%%%')), 0, ]; - yield 'a substring fragment is not held to the assertion syntax' => [ - Filters::startsWith('member', 'cn=user,'), - 2, + // RFC 4511 4.5.1.7: a substring item applies the type's SUBSTR rule, so a type declaring none is Undefined + // for every entry, negated or not. The pairs below are the control: types that do declare one still answer. + yield 'a substring on a type with no substring rule matches nothing' => [ + Filters::startsWith( + 'member', + 'cn=user,', + ), + 0, + ]; + yield 'a negated substring on a type with no substring rule still matches nothing' => [ + Filters::not( + Filters::startsWith( + 'member', + 'cn=user,', + ), + ), + 0, + ]; + yield 'a substring on an integer type matches nothing' => [ + Filters::startsWith( + 'uidNumber', + '9', + ), + 0, + ]; + yield 'a negated substring on an integer type still matches nothing' => [ + Filters::not( + Filters::startsWith( + 'uidNumber', + '9', + ), + ), + 0, + ]; + yield 'a substring on entryDN matches nothing' => [ + Filters::endsWith( + 'entryDN', + 'dc=foo,dc=bar', + ), + 0, + ]; + yield 'a substring on a type declaring a substring rule matches' => [ + Filters::startsWith( + 'cn', + 'al', + ), + 1, + ]; + yield 'a substring on an ia5 type declaring a substring rule matches' => [ + Filters::endsWith( + 'mail', + '@foo.bar', + ), + 1, + ]; + yield 'a negated substring on a type declaring a substring rule matches the rest' => [ + Filters::not( + Filters::startsWith( + 'cn', + 'al', + ), + ), + 7, ]; // RFC 5020: entryDN is derived on read, so it must match without having been requested or stored. diff --git a/tests/resources/schema/acl-secret-code.ldif b/tests/resources/schema/acl-secret-code.ldif index d269ab65..fadd4120 100644 --- a/tests/resources/schema/acl-secret-code.ldif +++ b/tests/resources/schema/acl-secret-code.ldif @@ -2,4 +2,4 @@ dn: cn=Subschema objectClass: top objectClass: subschema cn: Subschema -attributeTypes: ( 1.3.6.1.4.1.99999.1.1 NAME 'secretCode' DESC 'An operator-defined confidential attribute' EQUALITY 2.5.13.5 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-CONFIDENTIAL 'TRUE' ) +attributeTypes: ( 1.3.6.1.4.1.99999.1.1 NAME 'secretCode' DESC 'An operator-defined confidential attribute' EQUALITY 2.5.13.5 SUBSTR 2.5.13.7 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-CONFIDENTIAL 'TRUE' ) diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php index 50f38fee..537997b2 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -320,6 +320,21 @@ public function test_a_substring_filter_is_not_subject_to_the_assertion_syntax_c ); } + public function test_a_substring_on_a_type_with_no_substring_rule_selects_nothing(): void + { + $result = $this->subject->translate( + new SubstringFilter('uidNumber', '1'), + $this->attributeContext(hasSubstringRule: false), + ); + + self::assertNotNull($result); + self::assertSame( + '1 = 0', + $result->sql, + ); + self::assertFalse($result->isExact); + } + public function test_an_attribute_with_subtypes_is_left_to_the_evaluator(): void { $result = $this->subject->translate( @@ -1068,11 +1083,13 @@ private function attributeContext( ?bool $integerOrdered = null, AttributeFilterSupport $support = AttributeFilterSupport::Exact, bool $assertionConforms = true, + bool $hasSubstringRule = true, ): FilterAttributeContextInterface { $context = $this->createMock(FilterAttributeContextInterface::class); $context->method('isIntegerOrdered')->willReturn($integerOrdered); $context->method('filterSupport')->willReturn($support); $context->method('assertionValueConforms')->willReturn($assertionConforms); + $context->method('hasSubstringRule')->willReturn($hasSubstringRule); return $context; } diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index b6429ef2..80d128b7 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -24,6 +24,9 @@ use FreeDSx\Ldap\Search\Filter\PresentFilter; use FreeDSx\Ldap\Search\Filter\SubstringFilter; use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Schema\Definition\AttributeType; +use FreeDSx\Ldap\Schema\Definition\MatchingRuleOid; +use FreeDSx\Ldap\Schema\Definition\SyntaxOid; use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use Generator; @@ -1054,17 +1057,131 @@ public function test_a_present_filter_is_not_subject_to_the_assertion_syntax_che /** * A substring fragment is a portion of a value rather than a whole one, so it is not held to the type's syntax. + * + * The type is built here because no shipped schema pairs a SUBSTR rule with a syntax a fragment can violate. */ public function test_a_substring_fragment_is_not_subject_to_the_assertion_syntax_check(): void { + $subject = new FilterEvaluator( + SchemaResource::Core->load()->addAttributeType(new AttributeType( + oid: '1.3.6.1.4.1.99999.5.1', + names: ['serialCode'], + equalityOid: MatchingRuleOid::OID_INTEGER_MATCH, + substringOid: MatchingRuleOid::OID_CASE_IGNORE_SUBSTRINGS_MATCH, + syntaxOid: SyntaxOid::OID_INTEGER, + )), + ); $entry = new Entry( new Dn('cn=Test,dc=example,dc=com'), - new Attribute('uidNumber', '-50'), + new Attribute('serialCode', '-50'), ); - self::assertTrue($this->integerSchemaEvaluator()->evaluate( + // A lone hyphen is not a valid INTEGER, so an equality assertion carrying it would be Undefined. + self::assertTrue($subject->evaluate( + $entry, + Filters::startsWith('serialCode', '-'), + )); + } + + /** + * RFC 4511 4.5.1.7: a substring item applies the type's SUBSTR rule, so a type declaring none is Undefined. + */ + #[DataProvider('substringWithoutRuleProvider')] + public function test_a_substring_on_a_type_with_no_substring_rule_is_undefined(FilterInterface $filter): void + { + self::assertFalse($this->subject->evaluate( + $this->entry, + $filter, + )); + } + + /** + * Undefined is excluded under negation too, so both polarities answer the same way. + */ + #[DataProvider('substringWithoutRuleProvider')] + public function test_a_negated_substring_on_a_type_with_no_substring_rule_is_still_undefined( + FilterInterface $filter, + ): void { + self::assertFalse($this->subject->evaluate( + $this->entry, + Filters::not($filter), + )); + } + + public static function substringWithoutRuleProvider(): Generator + { + yield 'integer syntax' => [ + Filters::startsWith( + 'uidNumber', + '1', + ), + ]; + yield 'dn syntax' => [ + Filters::startsWith( + 'member', + 'cn=', + ), + ]; + yield 'derived from the entry' => [ + Filters::endsWith( + 'entryDN', + 'dc=com', + ), + ]; + } + + /** + * The control for the case above: a type that does declare a SUBSTR rule still answers normally. + */ + #[DataProvider('substringWithRuleProvider')] + public function test_a_substring_on_a_type_declaring_a_substring_rule_still_matches(FilterInterface $filter): void + { + self::assertTrue($this->subject->evaluate( + $this->entry, + $filter, + )); + } + + public static function substringWithRuleProvider(): Generator + { + yield 'declared directly' => [ + Filters::startsWith( + 'cn', + 'Al', + ), + ]; + yield 'declared on an ia5 type' => [ + Filters::endsWith( + 'mail', + '@example.com', + ), + ]; + yield 'covering a subtype value' => [ + Filters::startsWith( + 'name', + 'Al', + ), + ]; + } + + public function test_a_substring_rule_is_inherited_through_the_sup_chain(): void + { + $subject = new FilterEvaluator( + SchemaResource::Core->load()->addAttributeType(new AttributeType( + oid: '1.3.6.1.4.1.99999.5.2', + names: ['nickName'], + equalityOid: MatchingRuleOid::OID_CASE_IGNORE_MATCH, + superTypeOid: '2.5.4.41', + )), + ); + $entry = new Entry( + new Dn('cn=Test,dc=example,dc=com'), + new Attribute('nickName', 'Ali'), + ); + + self::assertTrue($subject->evaluate( $entry, - Filters::startsWith('uidNumber', '-'), + Filters::startsWith('nickName', 'Al'), )); } @@ -1130,12 +1247,22 @@ public static function entryDnFilterProvider(): Generator Filters::present('entryDN'), true, ]; + // entryDN declares no SUBSTR rule, so a substring item on it is Undefined in both polarities. yield 'substring' => [ Filters::endsWith( 'entryDN', 'dc=foo,dc=bar', ), - true, + false, + ]; + yield 'negated substring' => [ + Filters::not( + Filters::endsWith( + 'entryDN', + 'dc=foo,dc=bar', + ), + ), + false, ]; }