From 3454a2509914462ce57bac96bedc058760081078 Mon Sep 17 00:00:00 2001 From: Joshua Toon Date: Mon, 3 Aug 2026 14:16:16 -0400 Subject: [PATCH 1/3] Schema: ldapSyntaxes, X- extension flags, SUP-chain syntax resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The neutral mechanics a consumer needs to classify attribute values from a server's schema (issue #61); the binary-vs-text policy itself stays with consumers by design. - LdapSyntax (RFC 4512 §4.1.5): OID, DESC, X- extensions, plus NAME — not in the RFC grammar, but slapd accepts it and its own shipped pmi.schema uses it (slaptest-verified; the vendored corpus caught this the moment the ldapsyntax directive started parsing). NotHumanReadable and BinaryTransferRequired read OpenLDAP's X- extensions with the TRUE-only rule implemented once: a published 'FALSE' is not an assertion. - Parsed from all three inputs: slapd.conf ldapsyntax directives (slaptest-verified that slapd accepts them), strict LdapSyntax.Parse, and a lenient ParseSubschema overload taking ldapSyntaxes values (bucketed into UnparsedDefinitions with the new Syntax kind on failure). - FindSyntax strips {bound} before lookup — OpenLDAP publishes bounded SYNTAX references, and the bound is not part of the OID's identity; one StripLengthBound implementation now serves the SYNTAX keyword parser and the lookup. - ResolveSyntaxOid walks the SUP chain (cycle-guarded with a visited set): OpenLDAP publishes cn as "SUP name" with no SYNTAX, so reading Syntax alone leaves most of a live schema unresolved. The generator's private depth-capped twin is deleted in favor of it. - AttributeDescription (core): TypeOf ("cn;lang-en" -> "cn"), HasOption (";binary", case-insensitive per RFC 4512 §2.5), and IsValid — the writer's private definition made public, with the writer now calling it. The issue's bare-OID accessor item needed no code: LdapAttributeType.Syntax has stripped bounds since the parser landed, pinned by an existing test. Proven against the captured OpenLDAP 2.6 fixture (33 ldapSyntaxes values, zero unparsed; cn resolves to Directory String through real published data) and against a live slapd in the differential suite, which was run in a Linux container before pushing. Closes #61. Co-Authored-By: Claude Fable 5 --- .../SchemaEntryGenerator.cs | 11 +- src/LdifDotNet.Schema/LdapAttributeType.cs | 3 +- src/LdifDotNet.Schema/LdapObjectClass.cs | 3 +- src/LdifDotNet.Schema/LdapSchema.cs | 97 ++++++++++-- .../LdapSchemaDefinitionKind.cs | 3 + src/LdifDotNet.Schema/LdapSyntax.cs | 65 ++++++++ .../LdapUnparsedDefinition.cs | 10 +- src/LdifDotNet.Schema/README.md | 12 +- src/LdifDotNet.Schema/SchemaParser.cs | 87 ++++++++-- src/LdifDotNet/AttributeDescription.cs | 81 ++++++++++ src/LdifDotNet/LdifWriter.cs | 37 +---- src/LdifDotNet/README.md | 6 + .../AttributeDescriptionTests.cs | 48 ++++++ tests/LdifDotNet.Tests/DifferentialTests.cs | 16 +- .../PublicApi.Schema.approved.txt | 17 ++ tests/LdifDotNet.Tests/PublicApi.approved.txt | 6 + tests/LdifDotNet.Tests/SchemaParserTests.cs | 37 +++++ tests/LdifDotNet.Tests/SubschemaParseTests.cs | 149 +++++++++++++++++- 18 files changed, 612 insertions(+), 76 deletions(-) create mode 100644 src/LdifDotNet.Schema/LdapSyntax.cs create mode 100644 src/LdifDotNet/AttributeDescription.cs create mode 100644 tests/LdifDotNet.Tests/AttributeDescriptionTests.cs diff --git a/src/LdifDotNet.Generator/SchemaEntryGenerator.cs b/src/LdifDotNet.Generator/SchemaEntryGenerator.cs index 7ddc6b3..6352690 100644 --- a/src/LdifDotNet.Generator/SchemaEntryGenerator.cs +++ b/src/LdifDotNet.Generator/SchemaEntryGenerator.cs @@ -557,16 +557,7 @@ private static bool IsPrintableChar(char c) => private (bool Found, string? Syntax) ResolveSyntax(string attributeName) { var definition = _schema.FindAttributeType(attributeName); - bool found = definition is not null; - for (int depth = 0; definition is not null && depth < 20; depth++) - { - if (definition.Syntax is not null) - return (true, definition.Syntax); - definition = definition.SuperiorName is { } superior - ? _schema.FindAttributeType(superior) - : null; - } - return (found, null); + return (definition is not null, definition is null ? null : _schema.ResolveSyntaxOid(definition)); } private LdifValue? SyntaxValue(string syntax, string parentDn) diff --git a/src/LdifDotNet.Schema/LdapAttributeType.cs b/src/LdifDotNet.Schema/LdapAttributeType.cs index e4c677e..367d438 100644 --- a/src/LdifDotNet.Schema/LdapAttributeType.cs +++ b/src/LdifDotNet.Schema/LdapAttributeType.cs @@ -12,7 +12,8 @@ internal LdapAttributeType() /// the form a subschema subentry publishes as attributeTypes values, e.g. /// "( 2.5.4.4 NAME ( 'sn' 'surname' ) SUP name )". Strict: an unknown keyword, /// a non-numeric OID, or trailing text throws ; - /// for input a server published, use the lenient . + /// for input a server published, use the lenient + /// . /// public static LdapAttributeType Parse(string definition) { diff --git a/src/LdifDotNet.Schema/LdapObjectClass.cs b/src/LdifDotNet.Schema/LdapObjectClass.cs index bfde905..8f7056a 100644 --- a/src/LdifDotNet.Schema/LdapObjectClass.cs +++ b/src/LdifDotNet.Schema/LdapObjectClass.cs @@ -15,7 +15,8 @@ internal LdapObjectClass() /// "( 2.5.6.6 NAME 'person' SUP top STRUCTURAL MUST ( sn $ cn ) )". Strict: an /// unknown keyword, a non-numeric OID, or trailing text throws /// ; for input a server published, use - /// the lenient . + /// the lenient + /// . /// public static LdapObjectClass Parse(string definition) { diff --git a/src/LdifDotNet.Schema/LdapSchema.cs b/src/LdifDotNet.Schema/LdapSchema.cs index dace219..82c681c 100644 --- a/src/LdifDotNet.Schema/LdapSchema.cs +++ b/src/LdifDotNet.Schema/LdapSchema.cs @@ -11,17 +11,21 @@ public sealed class LdapSchema { private readonly List _attributeTypes; private readonly List _objectClasses; + private readonly List _syntaxes; private readonly List _unparsedDefinitions; private readonly Dictionary _attributeIndex = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _classIndex = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _syntaxIndex = new(StringComparer.OrdinalIgnoreCase); private LdapSchema( List attributeTypes, List objectClasses, + List syntaxes, List? unparsedDefinitions = null) { _attributeTypes = attributeTypes; _objectClasses = objectClasses; + _syntaxes = syntaxes; _unparsedDefinitions = unparsedDefinitions ?? []; foreach (var attributeType in attributeTypes) @@ -36,6 +40,12 @@ private LdapSchema( foreach (string name in objectClass.Names) _classIndex.TryAdd(name, objectClass); } + foreach (var syntax in syntaxes) + { + _syntaxIndex.TryAdd(syntax.Oid, syntax); + foreach (string name in syntax.Names) + _syntaxIndex.TryAdd(name, syntax); + } } /// Loads and aggregates schema files in order (later files may reference earlier OID macros). @@ -45,6 +55,7 @@ public static LdapSchema Load(params string[] paths) var parser = new SchemaParser(); var attributeTypes = new List(); var objectClasses = new List(); + var syntaxes = new List(); foreach (string path in paths) { @@ -59,14 +70,14 @@ public static LdapSchema Load(params string[] paths) } try { - parser.ParseInto(text, attributeTypes, objectClasses); + parser.ParseInto(text, attributeTypes, objectClasses, syntaxes); } catch (LdapSchemaParseException e) { throw new LdapSchemaParseException($"{Path.GetFileName(path)}: {e.Message}", e.LineNumber); } } - return new LdapSchema(attributeTypes, objectClasses); + return new LdapSchema(attributeTypes, objectClasses, syntaxes); } /// Parses schema definitions from text in slapd.conf schema file format. @@ -75,8 +86,21 @@ public static LdapSchema Parse(string text) ArgumentNullException.ThrowIfNull(text); var attributeTypes = new List(); var objectClasses = new List(); - new SchemaParser().ParseInto(text, attributeTypes, objectClasses); - return new LdapSchema(attributeTypes, objectClasses); + var syntaxes = new List(); + new SchemaParser().ParseInto(text, attributeTypes, objectClasses, syntaxes); + return new LdapSchema(attributeTypes, objectClasses, syntaxes); + } + + /// + /// Parses attributeTypes and objectClasses subschema values; equivalent to + /// + /// with no ldapSyntaxes values. + /// + public static LdapSchema ParseSubschema( + IEnumerable attributeTypeDefinitions, + IEnumerable objectClassDefinitions) + { + return ParseSubschema(attributeTypeDefinitions, objectClassDefinitions, []); } /// @@ -90,14 +114,17 @@ public static LdapSchema Parse(string text) /// public static LdapSchema ParseSubschema( IEnumerable attributeTypeDefinitions, - IEnumerable objectClassDefinitions) + IEnumerable objectClassDefinitions, + IEnumerable ldapSyntaxDefinitions) { ArgumentNullException.ThrowIfNull(attributeTypeDefinitions); ArgumentNullException.ThrowIfNull(objectClassDefinitions); + ArgumentNullException.ThrowIfNull(ldapSyntaxDefinitions); var parser = new SchemaParser(); var attributeTypes = new List(); var objectClasses = new List(); + var syntaxes = new List(); var unparsed = new List(); foreach (string definition in attributeTypeDefinitions) @@ -126,7 +153,20 @@ public static LdapSchema ParseSubschema( unparsed.Add(new LdapUnparsedDefinition(LdapSchemaDefinitionKind.ObjectClass, definition, e.Message)); } } - return new LdapSchema(attributeTypes, objectClasses, unparsed); + foreach (string definition in ldapSyntaxDefinitions) + { + if (definition is null) + throw new ArgumentException("Definition values must not be null.", nameof(ldapSyntaxDefinitions)); + try + { + syntaxes.Add(parser.ParseSyntaxDefinition(definition, lenient: true)); + } + catch (LdapSchemaParseException e) + { + unparsed.Add(new LdapUnparsedDefinition(LdapSchemaDefinitionKind.Syntax, definition, e.Message)); + } + } + return new LdapSchema(attributeTypes, objectClasses, syntaxes, unparsed); } /// All attribute types in declaration order. @@ -135,10 +175,15 @@ public static LdapSchema ParseSubschema( /// All object classes in declaration order. public IReadOnlyList ObjectClasses => _objectClasses; + /// All syntax definitions in declaration order. + public IReadOnlyList Syntaxes => _syntaxes; + /// - /// Definitions could not parse, raw text - /// preserved. Always empty for the strict and - /// paths, which throw on the first error instead. + /// Definitions + /// + /// could not parse, raw text preserved. Always empty for the strict + /// and paths, which throw on the + /// first error instead. /// public IReadOnlyList UnparsedDefinitions => _unparsedDefinitions; @@ -156,6 +201,40 @@ public static LdapSchema ParseSubschema( return _classIndex.GetValueOrDefault(nameOrOid); } + /// + /// Finds a syntax by OID (or a slapd-extension name), or null. A length + /// bound is stripped before the lookup, so a raw SYNTAX reference like + /// "1.3.6.1.4.1.1466.115.121.1.15{32768}" finds the syntax it names — + /// OpenLDAP publishes bounded references, and the bound is not part of the + /// syntax's identity. + /// + public LdapSyntax? FindSyntax(string nameOrOid) + { + ArgumentNullException.ThrowIfNull(nameOrOid); + return _syntaxIndex.GetValueOrDefault(SchemaParser.StripLengthBound(nameOrOid, out _)); + } + + /// + /// The attribute type's syntax OID, inherited through its SUP chain when + /// the definition omits SYNTAX (RFC 4512 §2.5.1) — OpenLDAP publishes cn as + /// "SUP name" with no SYNTAX at all. Cycle-guarded; a superior missing from + /// this schema ends the walk. Null when no definition on the chain declares + /// a syntax. + /// + public string? ResolveSyntaxOid(LdapAttributeType attributeType) + { + ArgumentNullException.ThrowIfNull(attributeType); + + var visited = new HashSet(); + for (var current = attributeType; current is not null && visited.Add(current);) + { + if (current.Syntax is not null) + return current.Syntax; + current = current.SuperiorName is { } superior ? FindAttributeType(superior) : null; + } + return null; + } + /// /// Attribute names the class requires (MUST), including those inherited through /// its superior chain. Superiors missing from this schema are skipped. diff --git a/src/LdifDotNet.Schema/LdapSchemaDefinitionKind.cs b/src/LdifDotNet.Schema/LdapSchemaDefinitionKind.cs index c69ea2f..46dd1eb 100644 --- a/src/LdifDotNet.Schema/LdapSchemaDefinitionKind.cs +++ b/src/LdifDotNet.Schema/LdapSchemaDefinitionKind.cs @@ -8,4 +8,7 @@ public enum LdapSchemaDefinitionKind /// An object class description (RFC 4512 §4.1.1), from objectClasses values. ObjectClass, + + /// An LDAP syntax description (RFC 4512 §4.1.5), from ldapSyntaxes values. + Syntax, } diff --git a/src/LdifDotNet.Schema/LdapSyntax.cs b/src/LdifDotNet.Schema/LdapSyntax.cs new file mode 100644 index 0000000..0b71c0a --- /dev/null +++ b/src/LdifDotNet.Schema/LdapSyntax.cs @@ -0,0 +1,65 @@ +namespace LdifDotNet.Schema; + +/// An LDAP syntax definition (RFC 4512 §4.1.5). +public sealed class LdapSyntax +{ + internal LdapSyntax() + { + } + + /// + /// Parses one bare parenthesized syntax description, the form a subschema + /// subentry publishes as ldapSyntaxes values, e.g. + /// "( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )". + /// Strict: an unknown keyword, a non-numeric OID, or trailing text throws + /// ; for input a server published, use + /// the lenient . + /// + public static LdapSyntax Parse(string definition) + { + ArgumentNullException.ThrowIfNull(definition); + return new SchemaParser().ParseSyntaxDefinition(definition, lenient: false); + } + + /// The numeric OID that identifies this syntax. + public string Oid { get; internal set; } = ""; + + /// + /// All short names, in declaration order. Usually empty: RFC 4512's grammar + /// gives syntaxes no NAME, but slapd accepts one in ldapsyntax directives + /// and its own shipped pmi.schema uses it. + /// + public IReadOnlyList Names { get; internal set; } = []; + + /// The first short name, or the OID when the definition has no name. + public string Name => Names.Count > 0 ? Names[0] : Oid; + + /// The DESC text, if any. + public string? Description { get; internal set; } + + /// X-* extensions and their values (names are case-insensitive). + public IReadOnlyDictionary> Extensions { get; internal set; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + /// + /// Whether the definition asserts OpenLDAP's X-NOT-HUMAN-READABLE extension — + /// how a server declares octet-carrying syntaxes no RFC lists. Only an + /// explicit 'TRUE' asserts the flag; a published 'FALSE' is not an assertion. + /// + public bool NotHumanReadable => HasTrueExtension("X-NOT-HUMAN-READABLE"); + + /// + /// Whether the definition asserts OpenLDAP's X-BINARY-TRANSFER-REQUIRED + /// extension (values must transfer via the ;binary option, RFC 4522). Only + /// an explicit 'TRUE' asserts the flag. + /// + public bool BinaryTransferRequired => HasTrueExtension("X-BINARY-TRANSFER-REQUIRED"); + + private bool HasTrueExtension(string name) => + Extensions.TryGetValue(name, out var values) + && values.Count > 0 + && string.Equals(values[0], "TRUE", StringComparison.OrdinalIgnoreCase); + + /// Returns . + public override string ToString() => Name; +} diff --git a/src/LdifDotNet.Schema/LdapUnparsedDefinition.cs b/src/LdifDotNet.Schema/LdapUnparsedDefinition.cs index a6157e7..c81e27d 100644 --- a/src/LdifDotNet.Schema/LdapUnparsedDefinition.cs +++ b/src/LdifDotNet.Schema/LdapUnparsedDefinition.cs @@ -1,10 +1,12 @@ namespace LdifDotNet.Schema; /// -/// A definition value that could not -/// parse, preserved raw so nothing is silently dropped. A live server's schema -/// cannot be fixed by the consumer, so one malformed or vendor-specific -/// definition degrades into this bucket instead of failing the whole schema. +/// A definition value that +/// +/// could not parse, preserved raw so nothing is silently dropped. A live +/// server's schema cannot be fixed by the consumer, so one malformed or +/// vendor-specific definition degrades into this bucket instead of failing the +/// whole schema. /// public sealed class LdapUnparsedDefinition { diff --git a/src/LdifDotNet.Schema/README.md b/src/LdifDotNet.Schema/README.md index fbcaf61..b1787fd 100644 --- a/src/LdifDotNet.Schema/README.md +++ b/src/LdifDotNet.Schema/README.md @@ -23,13 +23,19 @@ consumer cannot fix a server's schema, so a definition that fails to parse is preserved raw in `UnparsedDefinitions` instead of failing the whole schema: ```csharp -// attributeTypes / objectClasses values fetched from the subschema subentry -var schema = LdapSchema.ParseSubschema(attributeTypeValues, objectClassValues); +// attributeTypes / objectClasses / ldapSyntaxes values from the subschema subentry +var schema = LdapSchema.ParseSubschema(attributeTypeValues, objectClassValues, ldapSyntaxValues); foreach (var bad in schema.UnparsedDefinitions) Console.WriteLine($"unparsed {bad.Kind}: {bad.Error}"); +// A live server publishes cn as "SUP name" with no SYNTAX; resolution walks the chain: +var cn = schema.FindAttributeType("cn"); +string? syntaxOid = schema.ResolveSyntaxOid(cn); // 1.3.6.1.4.1.1466.115.121.1.15 +var syntax = schema.FindSyntax(syntaxOid); // bounds like {32768} strip automatically +bool octets = syntax.NotHumanReadable; // OpenLDAP's X-NOT-HUMAN-READABLE 'TRUE' + // Strict single-definition parsing is also available: -var cn = LdapAttributeType.Parse("( 2.5.4.3 NAME ( 'cn' 'commonName' ) SUP name )"); +var sn = LdapAttributeType.Parse("( 2.5.4.4 NAME ( 'sn' 'surname' ) SUP name )"); ``` Proven against OpenLDAP's complete shipped schema set plus eduPerson, diff --git a/src/LdifDotNet.Schema/SchemaParser.cs b/src/LdifDotNet.Schema/SchemaParser.cs index 2820082..922ef15 100644 --- a/src/LdifDotNet.Schema/SchemaParser.cs +++ b/src/LdifDotNet.Schema/SchemaParser.cs @@ -12,7 +12,11 @@ internal sealed class SchemaParser { private readonly Dictionary _oidMacros = new(StringComparer.OrdinalIgnoreCase); - public void ParseInto(string text, List attributeTypes, List objectClasses) + public void ParseInto( + string text, + List attributeTypes, + List objectClasses, + List syntaxes) { foreach (var (directive, lineNumber) in Directives(text)) { @@ -28,6 +32,9 @@ public void ParseInto(string text, List attributeTypes, List< case "objectclass" or "objectclasses": objectClasses.Add(ParseObjectClass(new Cursor(body, lineNumber), lenient: false)); break; + case "ldapsyntax" or "ldapsyntaxes": + syntaxes.Add(ParseSyntax(new Cursor(body, lineNumber), lenient: false)); + break; case "objectidentifier": ParseOidMacro(body, lineNumber); break; @@ -61,6 +68,38 @@ public LdapObjectClass ParseObjectClassDefinition(string definition, bool lenien return result; } + /// + /// Parses one bare parenthesized syntax definition, as a subschema subentry + /// publishes them in ldapSyntaxes values. Lenient mode skips unknown + /// keywords instead of failing the definition. + /// + public LdapSyntax ParseSyntaxDefinition(string definition, bool lenient) + { + var cursor = new Cursor(definition, lineNumber: 1, locateErrors: false); + var result = ParseSyntax(cursor, lenient); + cursor.ExpectEnd(); + return result; + } + + /// + /// Strips an RFC 4512 length bound from a syntax reference: + /// "1.3.6.1.4.1.1466.115.121.1.15{32768}" yields the bare OID and 32768. + /// The one implementation shared by the SYNTAX keyword parser and + /// . + /// + public static string StripLengthBound(string syntax, out int? length) + { + length = null; + int brace = syntax.IndexOf('{', StringComparison.Ordinal); + if (brace >= 0 && syntax.EndsWith('}') + && int.TryParse(syntax[(brace + 1)..^1], NumberStyles.None, CultureInfo.InvariantCulture, out int bound)) + { + length = bound; + return syntax[..brace]; + } + return syntax; + } + /// /// Assembles logical directives: a directive starts at column 0; lines that /// begin with whitespace continue it; '#' lines are comments; blank lines end it. @@ -171,15 +210,8 @@ private LdapAttributeType ParseAttributeType(Cursor cursor, bool lenient) case "ORDERING": result.Ordering = cursor.ReadValue(); break; case "SUBSTR" or "SUBSTRINGS": result.Substring = cursor.ReadValue(); break; case "SYNTAX": - string syntax = cursor.ReadValue(); - int brace = syntax.IndexOf('{', StringComparison.Ordinal); - if (brace >= 0 && syntax.EndsWith('}') - && int.TryParse(syntax[(brace + 1)..^1], NumberStyles.None, CultureInfo.InvariantCulture, out int length)) - { - result.SyntaxLength = length; - syntax = syntax[..brace]; - } - result.Syntax = syntax; + result.Syntax = StripLengthBound(cursor.ReadValue(), out int? length); + result.SyntaxLength = length; break; case "SINGLE-VALUE": result.SingleValue = true; break; case "COLLECTIVE": result.Collective = true; break; @@ -240,6 +272,41 @@ private LdapObjectClass ParseObjectClass(Cursor cursor, bool lenient) return result; } + private LdapSyntax ParseSyntax(Cursor cursor, bool lenient) + { + cursor.Expect(TokenKind.LParen); + var result = new LdapSyntax { Oid = ResolveOid(cursor) }; + var extensions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + while (true) + { + var token = cursor.Next(); + if (token.Kind == TokenKind.RParen) + break; + if (token.Kind != TokenKind.Word) + throw cursor.Error($"unexpected token '{token.Value}' in ldapsyntax"); + + switch (token.Value.ToUpperInvariant()) + { + // NAME is slapd's extension to the RFC 4512 grammar; its own + // pmi.schema uses it in ldapsyntax directives. + case "NAME": result.Names = cursor.ReadValueList(); break; + case "DESC": result.Description = cursor.ReadValue(); break; + default: + if (token.Value.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) + extensions[token.Value] = cursor.ReadValueList(); + else if (lenient) + cursor.SkipUnknownValue(); + else + throw cursor.Error($"unexpected keyword '{token.Value}' in ldapsyntax"); + break; + } + } + + result.Extensions = extensions; + return result; + } + private enum TokenKind { LParen, diff --git a/src/LdifDotNet/AttributeDescription.cs b/src/LdifDotNet/AttributeDescription.cs new file mode 100644 index 0000000..60d0aeb --- /dev/null +++ b/src/LdifDotNet/AttributeDescription.cs @@ -0,0 +1,81 @@ +namespace LdifDotNet; + +/// +/// Helpers for LDAP attribute descriptions (RFC 4512 §2.5): an attribute type +/// name or OID followed by zero or more ";option" parts, e.g. "cn;lang-en" or +/// "userCertificate;binary" (the transfer option, RFC 4522). One definition of +/// "valid attribute description", shared with . +/// +public static class AttributeDescription +{ + /// + /// The attribute type part, without any options: "cn;lang-en" yields "cn". + /// A description with no options is returned unchanged. + /// + public static string TypeOf(string description) + { + ArgumentNullException.ThrowIfNull(description); + int semicolon = description.IndexOf(';', StringComparison.Ordinal); + return semicolon < 0 ? description : description[..semicolon]; + } + + /// + /// Whether the description carries the given option (bare name, no leading + /// semicolon), compared case-insensitively as RFC 4512 §2.5 requires: + /// HasOption("userCertificate;binary", "binary") is true. + /// + public static bool HasOption(string description, string option) + { + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(option); + + int start = description.IndexOf(';', StringComparison.Ordinal); + while (start >= 0) + { + int end = description.IndexOf(';', start + 1); + string candidate = end < 0 ? description[(start + 1)..] : description[(start + 1)..end]; + if (string.Equals(candidate, option, StringComparison.OrdinalIgnoreCase)) + return true; + start = end; + } + return false; + } + + /// + /// RFC 2849 AttributeDescription: a numeric OID or a descr (ALPHA then + /// ALPHA / DIGIT / "-"), followed by zero or more non-empty ";option" parts. + /// + public static bool IsValid(string description) + { + ArgumentNullException.ThrowIfNull(description); + + string[] parts = description.Split(';'); + if (!RfcGrammar.IsNumericOid(parts[0]) && !IsDescr(parts[0])) + return false; + for (int i = 1; i < parts.Length; i++) + { + if (parts[i].Length == 0) + return false; + foreach (char c in parts[i]) + { + if (!IsAttrTypeChar(c)) + return false; + } + } + return true; + } + + private static bool IsDescr(string text) + { + if (text.Length == 0 || !char.IsAsciiLetter(text[0])) + return false; + foreach (char c in text) + { + if (!IsAttrTypeChar(c)) + return false; + } + return true; + } + + private static bool IsAttrTypeChar(char c) => char.IsAsciiLetterOrDigit(c) || c == '-'; +} diff --git a/src/LdifDotNet/LdifWriter.cs b/src/LdifDotNet/LdifWriter.cs index 95588d0..f61e011 100644 --- a/src/LdifDotNet/LdifWriter.cs +++ b/src/LdifDotNet/LdifWriter.cs @@ -348,41 +348,8 @@ private static IEnumerable AllValues(LdifRecord record) return null; } - /// - /// RFC 2849 AttributeDescription: a numeric OID or a descr (ALPHA then - /// ALPHA / DIGIT / "-"), followed by zero or more non-empty ";option" parts. - /// - private static bool IsAttributeDescription(string name) - { - string[] parts = name.Split(';'); - if (!RfcGrammar.IsNumericOid(parts[0]) && !IsDescr(parts[0])) - return false; - for (int i = 1; i < parts.Length; i++) - { - if (parts[i].Length == 0) - return false; - foreach (char c in parts[i]) - { - if (!IsAttrTypeChar(c)) - return false; - } - } - return true; - } - - private static bool IsDescr(string text) - { - if (text.Length == 0 || !char.IsAsciiLetter(text[0])) - return false; - foreach (char c in text) - { - if (!IsAttrTypeChar(c)) - return false; - } - return true; - } - - private static bool IsAttrTypeChar(char c) => char.IsAsciiLetterOrDigit(c) || c == '-'; + /// One definition of "valid attribute description": . + private static bool IsAttributeDescription(string name) => AttributeDescription.IsValid(name); private void WriteAttributes(IReadOnlyList attributes) { diff --git a/src/LdifDotNet/README.md b/src/LdifDotNet/README.md index 23ff0db..9ad4036 100644 --- a/src/LdifDotNet/README.md +++ b/src/LdifDotNet/README.md @@ -23,3 +23,9 @@ string unwrapped = LdifWriter.WriteToString(records, new LdifWriterOptions { Wra Handles folding, comments, base64 values and DNs, URL value references (never auto-resolved), all changetypes, controls, and OpenLDAP's modify-increment extension (RFC 4525). + +Also includes `Dn` (RFC 4514 parsing/escaping) and `AttributeDescription` +(RFC 4512 §2.5 mechanics: `TypeOf("cn;lang-en")` is `"cn"`, +`HasOption("userCertificate;binary", "binary")` detects the RFC 4522 transfer +option, and `IsValid` is the writer's own definition of a legal attribute +description). diff --git a/tests/LdifDotNet.Tests/AttributeDescriptionTests.cs b/tests/LdifDotNet.Tests/AttributeDescriptionTests.cs new file mode 100644 index 0000000..b4c8309 --- /dev/null +++ b/tests/LdifDotNet.Tests/AttributeDescriptionTests.cs @@ -0,0 +1,48 @@ +namespace LdifDotNet.Tests; + +public class AttributeDescriptionTests +{ + [Theory] + [InlineData("cn", "cn")] + [InlineData("cn;lang-en", "cn")] + [InlineData("cn;lang-en;binary", "cn")] + [InlineData("userCertificate;binary", "userCertificate")] + [InlineData("2.5.4.3;binary", "2.5.4.3")] + public void TypeOf_strips_options(string description, string expected) => + Assert.Equal(expected, AttributeDescription.TypeOf(description)); + + [Theory] + [InlineData("userCertificate;binary", "binary", true)] + [InlineData("userCertificate;BINARY", "binary", true)] // options are case-insensitive (RFC 4512 §2.5) + [InlineData("userCertificate;binary", "BINARY", true)] + [InlineData("cn;lang-en;binary", "binary", true)] + [InlineData("cn;lang-en;binary", "lang-en", true)] + [InlineData("cn;lang-en", "binary", false)] + [InlineData("cn", "binary", false)] + [InlineData("cn;binary", "bin", false)] // whole-option match, never a prefix match + public void HasOption_matches_whole_options_case_insensitively(string description, string option, bool expected) => + Assert.Equal(expected, AttributeDescription.HasOption(description, option)); + + [Theory] + [InlineData("cn", true)] + [InlineData("2.5.4.3", true)] + [InlineData("userCertificate;binary", true)] + [InlineData("cn;lang-en;binary", true)] + [InlineData("", false)] + [InlineData(";binary", false)] + [InlineData("cn;", false)] + [InlineData("9cn", false)] // descr must start with a letter, and this is no numeric OID + [InlineData("cn name", false)] + [InlineData("cn;läng", false)] // ASCII only + public void IsValid_matches_rfc2849_attribute_description(string description, bool expected) => + Assert.Equal(expected, AttributeDescription.IsValid(description)); + + [Fact] + public void Null_arguments_throw() + { + Assert.Throws(() => AttributeDescription.TypeOf(null!)); + Assert.Throws(() => AttributeDescription.HasOption(null!, "binary")); + Assert.Throws(() => AttributeDescription.HasOption("cn", null!)); + Assert.Throws(() => AttributeDescription.IsValid(null!)); + } +} diff --git a/tests/LdifDotNet.Tests/DifferentialTests.cs b/tests/LdifDotNet.Tests/DifferentialTests.cs index 5072a11..fd41eb9 100644 --- a/tests/LdifDotNet.Tests/DifferentialTests.cs +++ b/tests/LdifDotNet.Tests/DifferentialTests.cs @@ -112,19 +112,23 @@ public void Live_subschema_parses_completely() try { var search = Run(Tool("ldapsearch"), "-LL", "-H", url, "-x", "-s", "base", - "-b", "cn=Subschema", "(objectClass=subschema)", "attributeTypes", "objectClasses"); + "-b", "cn=Subschema", "(objectClass=subschema)", + "attributeTypes", "objectClasses", "ldapSyntaxes"); Assert.True(search.ExitCode == 0, $"ldapsearch failed:\n{search.StdErr}"); // Dogfood: ldapsearch answers in LDIF, so our own reader unfolds it. var entry = Assert.IsType(Assert.Single(LdifReader.Parse(search.StdOut))); var attributeTypes = entry["attributeTypes"]; var objectClasses = entry["objectClasses"]; + var ldapSyntaxes = entry["ldapSyntaxes"]; Assert.NotNull(attributeTypes); Assert.NotNull(objectClasses); + Assert.NotNull(ldapSyntaxes); var schema = LdapSchema.ParseSubschema( attributeTypes.Values.Select(v => v.AsString()), - objectClasses.Values.Select(v => v.AsString())); + objectClasses.Values.Select(v => v.AsString()), + ldapSyntaxes.Values.Select(v => v.AsString())); Assert.True(schema.UnparsedDefinitions.Count == 0, "definitions a live server published failed to parse:\n" + string.Join( @@ -133,12 +137,20 @@ public void Live_subschema_parses_completely() $"expected the full published attribute set, got {schema.AttributeTypes.Count}"); Assert.True(schema.ObjectClasses.Count > 50, $"expected the full published class set, got {schema.ObjectClasses.Count}"); + Assert.True(schema.Syntaxes.Count > 25, + $"expected the full published syntax set, got {schema.Syntaxes.Count}"); // The shape schema files never contain: cn published with SUP and no SYNTAX. var cn = schema.FindAttributeType("cn"); Assert.NotNull(cn); Assert.Equal("name", cn.SuperiorName); Assert.Null(cn.Syntax); + Assert.Equal("1.3.6.1.4.1.1466.115.121.1.15", schema.ResolveSyntaxOid(cn)); + + // How a live server declares octet-carrying syntaxes. + var audio = schema.FindSyntax("1.3.6.1.4.1.1466.115.121.1.4"); + Assert.NotNull(audio); + Assert.True(audio.NotHumanReadable); } finally { diff --git a/tests/LdifDotNet.Tests/PublicApi.Schema.approved.txt b/tests/LdifDotNet.Tests/PublicApi.Schema.approved.txt index aad0484..a99c56c 100644 --- a/tests/LdifDotNet.Tests/PublicApi.Schema.approved.txt +++ b/tests/LdifDotNet.Tests/PublicApi.Schema.approved.txt @@ -46,25 +46,42 @@ namespace LdifDotNet.Schema { public System.Collections.Generic.IReadOnlyList AttributeTypes { get; } public System.Collections.Generic.IReadOnlyList ObjectClasses { get; } + public System.Collections.Generic.IReadOnlyList Syntaxes { get; } public System.Collections.Generic.IReadOnlyList UnparsedDefinitions { get; } public LdifDotNet.Schema.LdapAttributeType? FindAttributeType(string nameOrOid) { } public LdifDotNet.Schema.LdapObjectClass? FindObjectClass(string nameOrOid) { } + public LdifDotNet.Schema.LdapSyntax? FindSyntax(string nameOrOid) { } public System.Collections.Generic.IReadOnlyList OptionalAttributeNames(LdifDotNet.Schema.LdapObjectClass objectClass) { } public System.Collections.Generic.IReadOnlyList RequiredAttributeNames(LdifDotNet.Schema.LdapObjectClass objectClass) { } + public string? ResolveSyntaxOid(LdifDotNet.Schema.LdapAttributeType attributeType) { } public static LdifDotNet.Schema.LdapSchema Load(params string[] paths) { } public static LdifDotNet.Schema.LdapSchema Parse(string text) { } public static LdifDotNet.Schema.LdapSchema ParseSubschema(System.Collections.Generic.IEnumerable attributeTypeDefinitions, System.Collections.Generic.IEnumerable objectClassDefinitions) { } + public static LdifDotNet.Schema.LdapSchema ParseSubschema(System.Collections.Generic.IEnumerable attributeTypeDefinitions, System.Collections.Generic.IEnumerable objectClassDefinitions, System.Collections.Generic.IEnumerable ldapSyntaxDefinitions) { } } public enum LdapSchemaDefinitionKind { AttributeType = 0, ObjectClass = 1, + Syntax = 2, } public sealed class LdapSchemaParseException : System.Exception { public LdapSchemaParseException(string message, int lineNumber) { } public int LineNumber { get; } } + public sealed class LdapSyntax + { + public bool BinaryTransferRequired { get; } + public string? Description { get; } + public System.Collections.Generic.IReadOnlyDictionary> Extensions { get; } + public string Name { get; } + public System.Collections.Generic.IReadOnlyList Names { get; } + public bool NotHumanReadable { get; } + public string Oid { get; } + public override string ToString() { } + public static LdifDotNet.Schema.LdapSyntax Parse(string definition) { } + } public sealed class LdapUnparsedDefinition { public string Definition { get; } diff --git a/tests/LdifDotNet.Tests/PublicApi.approved.txt b/tests/LdifDotNet.Tests/PublicApi.approved.txt index 89d0502..ce1d2bb 100644 --- a/tests/LdifDotNet.Tests/PublicApi.approved.txt +++ b/tests/LdifDotNet.Tests/PublicApi.approved.txt @@ -1,5 +1,11 @@ namespace LdifDotNet { + public static class AttributeDescription + { + public static bool HasOption(string description, string option) { } + public static bool IsValid(string description) { } + public static string TypeOf(string description) { } + } public readonly struct AttributeTypeAndValue : System.IEquatable { public AttributeTypeAndValue(string Type, string Value) { } diff --git a/tests/LdifDotNet.Tests/SchemaParserTests.cs b/tests/LdifDotNet.Tests/SchemaParserTests.cs index 01403c2..8a974a9 100644 --- a/tests/LdifDotNet.Tests/SchemaParserTests.cs +++ b/tests/LdifDotNet.Tests/SchemaParserTests.cs @@ -12,6 +12,43 @@ public static TheoryData SchemaFiles() return data; } + [Fact] + public void Ldapsyntax_directive_parses_in_file_mode() + { + // slapd.conf accepts ldapsyntax directives (verified with slaptest + // against OpenLDAP 2.6), so the file parser does too. + var schema = LdapSchema.Parse( + "ldapsyntax ( 1.2.3.4 DESC 'Probe Syntax' X-NOT-HUMAN-READABLE 'TRUE' )\n"); + + var syntax = Assert.Single(schema.Syntaxes); + Assert.Equal("1.2.3.4", syntax.Oid); + Assert.Equal("Probe Syntax", syntax.Description); + Assert.True(syntax.NotHumanReadable); + } + + [Fact] + public void Ldapsyntax_directive_rejects_unknown_keywords_in_file_mode() => + Assert.Throws(() => LdapSchema.Parse( + "ldapsyntax ( 1.2.3.4 DESC 'x' VENDORFLAG )\n")); + + [Fact] + public void Ldapsyntax_name_is_a_supported_slapd_extension() + { + // Verbatim from OpenLDAP's shipped pmi.schema (slaptest-verified to + // load): NAME on a syntax is slapd's extension to RFC 4512. + var schema = LdapSchema.Parse( + "ldapsyntax ( 1.3.6.1.4.1.4203.666.11.10.2.4\n" + + "\tNAME 'AttCertPath'\n" + + "\tDESC 'X.509 PMI attribute certificate path: SEQUENCE OF AttributeCertificate'\n" + + "\tX-SUBST '1.3.6.1.4.1.1466.115.121.1.15' )\n"); + + var syntax = Assert.Single(schema.Syntaxes); + Assert.Equal("AttCertPath", syntax.Name); + Assert.Same(syntax, schema.FindSyntax("AttCertPath")); + Assert.Same(syntax, schema.FindSyntax("1.3.6.1.4.1.4203.666.11.10.2.4")); + Assert.Equal(["1.3.6.1.4.1.1466.115.121.1.15"], syntax.Extensions["X-SUBST"]); + } + [Fact] public void Rejects_undeclared_oid_macro_reference() { diff --git a/tests/LdifDotNet.Tests/SubschemaParseTests.cs b/tests/LdifDotNet.Tests/SubschemaParseTests.cs index d33dee9..7364004 100644 --- a/tests/LdifDotNet.Tests/SubschemaParseTests.cs +++ b/tests/LdifDotNet.Tests/SubschemaParseTests.cs @@ -281,6 +281,134 @@ public void Subschema_null_arguments_throw() Assert.Throws(() => LdapSchema.ParseSubschema([], [null!])); } + [Fact] + public void Ldap_syntax_parses_from_bare_definition() + { + // Verbatim OpenLDAP 2.6 shapes: only an explicit 'TRUE' asserts a flag. + var audio = LdapSyntax.Parse( + "( 1.3.6.1.4.1.1466.115.121.1.4 DESC 'Audio' X-NOT-HUMAN-READABLE 'TRUE' )"); + Assert.Equal("1.3.6.1.4.1.1466.115.121.1.4", audio.Oid); + Assert.Equal("Audio", audio.Description); + Assert.True(audio.NotHumanReadable); + Assert.False(audio.BinaryTransferRequired); + + var certificate = LdapSyntax.Parse( + "( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' " + + "X-BINARY-TRANSFER-REQUIRED 'TRUE' X-NOT-HUMAN-READABLE 'TRUE' )"); + Assert.True(certificate.NotHumanReadable); + Assert.True(certificate.BinaryTransferRequired); + Assert.Equal(["TRUE"], certificate.Extensions["X-BINARY-TRANSFER-REQUIRED"]); + + var directoryString = LdapSyntax.Parse( + "( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )"); + Assert.False(directoryString.NotHumanReadable); + Assert.False(directoryString.BinaryTransferRequired); + Assert.Empty(directoryString.Extensions); + } + + [Fact] + public void Ldap_syntax_published_false_is_not_an_assertion() + { + var syntax = LdapSyntax.Parse( + "( 1.2.3 DESC 'x' X-NOT-HUMAN-READABLE 'FALSE' )"); + + Assert.False(syntax.NotHumanReadable); + // The extension value itself stays available for consumers that care. + Assert.Equal(["FALSE"], syntax.Extensions["X-NOT-HUMAN-READABLE"]); + } + + [Fact] + public void Ldap_syntax_true_is_case_insensitive() + { + var syntax = LdapSyntax.Parse("( 1.2.3 DESC 'x' X-NOT-HUMAN-READABLE 'true' )"); + + Assert.True(syntax.NotHumanReadable); + } + + [Fact] + public void Ldap_syntax_strict_parse_rejects_bad_input() + { + Assert.Throws(() => LdapSyntax.Parse( + "( 1.2.3 DESC 'x' VENDORFLAG )")); + Assert.Throws(() => LdapSyntax.Parse( + "( 1.2.3 DESC 'x' ) trailing")); + Assert.Throws(() => LdapSyntax.Parse( + "( notAnOid DESC 'x' )")); + Assert.Throws(() => LdapSyntax.Parse(null!)); + } + + [Fact] + public void Subschema_buckets_bad_syntax_with_its_kind() + { + var schema = LdapSchema.ParseSubschema([], [], ["( 1.2.3 DESC 'unterminated"]); + + Assert.Empty(schema.Syntaxes); + var unparsed = Assert.Single(schema.UnparsedDefinitions); + Assert.Equal(LdapSchemaDefinitionKind.Syntax, unparsed.Kind); + } + + [Fact] + public void Subschema_skips_unknown_keywords_in_syntax_definitions() + { + var schema = LdapSchema.ParseSubschema( + [], [], ["( 1.2.3 VENDORFLAG DESC 'x' VENDORKEY 'v' )"]); + + Assert.Empty(schema.UnparsedDefinitions); + Assert.Equal("x", Assert.Single(schema.Syntaxes).Description); + } + + [Fact] + public void Find_syntax_strips_length_bounds() + { + var schema = LdapSchema.ParseSubschema( + [], [], ["( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )"]); + + var syntax = schema.FindSyntax("1.3.6.1.4.1.1466.115.121.1.15"); + Assert.NotNull(syntax); + // A raw bounded SYNTAX reference finds the same syntax: the {bound} is + // not part of the OID's identity. + Assert.Same(syntax, schema.FindSyntax("1.3.6.1.4.1.1466.115.121.1.15{32768}")); + Assert.Null(schema.FindSyntax("1.2.3")); + } + + [Fact] + public void Resolve_syntax_oid_walks_the_sup_chain() + { + var schema = LdapSchema.ParseSubschema([NameDefinition, CnDefinition], []); + var cn = schema.FindAttributeType("cn"); + var name = schema.FindAttributeType("name"); + Assert.NotNull(cn); + Assert.NotNull(name); + + // cn declares no SYNTAX; it inherits Directory String through SUP name. + Assert.Null(cn.Syntax); + Assert.Equal("1.3.6.1.4.1.1466.115.121.1.15", schema.ResolveSyntaxOid(cn)); + Assert.Equal("1.3.6.1.4.1.1466.115.121.1.15", schema.ResolveSyntaxOid(name)); + } + + [Fact] + public void Resolve_syntax_oid_returns_null_for_missing_superior() + { + var schema = LdapSchema.ParseSubschema([CnDefinition], []); + var cn = schema.FindAttributeType("cn"); + Assert.NotNull(cn); + + Assert.Null(schema.ResolveSyntaxOid(cn)); + } + + [Fact] + public void Resolve_syntax_oid_survives_a_sup_cycle() + { + // A malformed schema with a SUP loop must terminate with null, not hang. + var schema = LdapSchema.ParseSubschema( + ["( 1.2.3.1 NAME 'a' SUP b )", "( 1.2.3.2 NAME 'b' SUP a )"], + []); + var a = schema.FindAttributeType("a"); + Assert.NotNull(a); + + Assert.Null(schema.ResolveSyntaxOid(a)); + } + [Fact] public void Real_openldap_subschema_capture_parses_completely() { @@ -302,13 +430,16 @@ public void Real_openldap_subschema_capture_parses_completely() var schema = LdapSchema.ParseSubschema( ValuesOf(entry, "attributeTypes"), - ValuesOf(entry, "objectClasses")); + ValuesOf(entry, "objectClasses"), + ValuesOf(entry, "ldapSyntaxes")); Assert.Empty(schema.UnparsedDefinitions); Assert.True(schema.AttributeTypes.Count > 200, $"expected the full published attribute set, got {schema.AttributeTypes.Count}"); Assert.True(schema.ObjectClasses.Count > 50, $"expected the full published class set, got {schema.ObjectClasses.Count}"); + Assert.True(schema.Syntaxes.Count > 25, + $"expected the full published syntax set, got {schema.Syntaxes.Count}"); // Shapes a schema file never contains: cn published as SUP name with no // SYNTAX, and bounded syntax OIDs stripped to the bare OID. @@ -320,9 +451,25 @@ public void Real_openldap_subschema_capture_parses_completely() var name = schema.FindAttributeType("name"); Assert.NotNull(name); + Assert.NotNull(name.Syntax); Assert.Equal("1.3.6.1.4.1.1466.115.121.1.15", name.Syntax); Assert.Equal(32768, name.SyntaxLength); + // Real-data SUP-chain resolution: cn's syntax comes from name, and the + // resolved OID finds the published Directory String syntax definition. + Assert.Equal(name.Syntax, schema.ResolveSyntaxOid(cn)); + var directoryString = schema.FindSyntax(name.Syntax); + Assert.NotNull(directoryString); + Assert.Equal("Directory String", directoryString.Description); + + // How a real server declares octet-carrying syntaxes. + var audio = schema.FindSyntax("1.3.6.1.4.1.1466.115.121.1.4"); + Assert.NotNull(audio); + Assert.True(audio.NotHumanReadable); + var certificate = schema.FindSyntax("1.3.6.1.4.1.1466.115.121.1.8"); + Assert.NotNull(certificate); + Assert.True(certificate.BinaryTransferRequired); + var person = schema.FindObjectClass("person"); Assert.NotNull(person); Assert.Equal(["sn", "cn"], person.Must); From 0ada36b292af0c731ade5f7cb1a846cd3e259ac8 Mon Sep 17 00:00:00 2001 From: Joshua Toon Date: Mon, 3 Aug 2026 14:38:01 -0400 Subject: [PATCH 2/3] Syntax review fixes: corpus counts syntaxes, slaptest in CI, flag-rule pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From adversarial review of the previous commit (4 of 9 findings accepted, the rest refuted or surfaced as pre-existing questions): - Parses_every_schema_in_corpus claimed "nothing silently dropped" while its line-scan counted only attribute types and object classes; ldapsyntax definitions are now counted and asserted the same way. - The "slaptest-verified" comments are now a CI assertion instead: a new differential test runs slaptest against the vendored pmi.schema — the file whose ldapsyntax NAME extension forced the parser beyond RFC 4512 — pinning both sides of the "we accept what slapd accepts" claim. - The undefined multi-valued flag-extension case is pinned: the first value is the assertion, matching the consumer parser this API supersedes. - The three-parameter ParseSubschema overload's null guards get direct tests, matching the two-parameter overload's. Surfaced, not fixed (pre-existing, deliberate-looking): RfcGrammar's IsNumericOid implements RFC 2849's ldap-oid (leading-zero arcs allowed), which is looser than RFC 4512's numericoid; tightening it would be a cross-cutting behavior change shared by LDIF and schema parsing. Co-Authored-By: Claude Fable 5 --- tests/LdifDotNet.Tests/DifferentialTests.cs | 23 +++++++++++++++++++ tests/LdifDotNet.Tests/SchemaCorpusTests.cs | 16 +++++++------ tests/LdifDotNet.Tests/SchemaParserTests.cs | 5 +++- tests/LdifDotNet.Tests/SubschemaParseTests.cs | 17 ++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/tests/LdifDotNet.Tests/DifferentialTests.cs b/tests/LdifDotNet.Tests/DifferentialTests.cs index fd41eb9..c49ac56 100644 --- a/tests/LdifDotNet.Tests/DifferentialTests.cs +++ b/tests/LdifDotNet.Tests/DifferentialTests.cs @@ -158,6 +158,29 @@ public void Live_subschema_parses_completely() } } + /// + /// The file parser's contract is "what slapd accepts". pmi.schema is the + /// corpus file whose ldapsyntax NAME extension forced the parser to go + /// beyond RFC 4512's grammar — this pins slapd's side of that claim in CI + /// instead of in a comment, against the exact vendored file we parse. + /// + [DifferentialFact] + public void Slapd_accepts_the_vendored_pmi_schema() + { + string pmiSchema = Fixtures.PathOf("schemas/openldap/pmi.schema"); + string work = Directory.CreateTempSubdirectory("ldifdotnet-slaptest").FullName; + string confFile = WriteSlapdConf(work, [$"{SchemaDir}/core.schema", pmiSchema]); + + var slaptest = Run(Tool("slaptest"), "-f", confFile, "-u"); + Assert.True(slaptest.ExitCode == 0, + $"slapd rejected a schema file our parser accepts:\n{slaptest.StdOut}{slaptest.StdErr}"); + + // And our side of the same claim, on the same file. + var schema = LdapSchema.Load(pmiSchema); + Assert.Equal(3, schema.Syntaxes.Count); + Assert.NotNull(schema.FindSyntax("AttCertPath")); + } + private static void AssertLoadsAndRoundTrips( IReadOnlyList records, IEnumerable schemaIncludes) { diff --git a/tests/LdifDotNet.Tests/SchemaCorpusTests.cs b/tests/LdifDotNet.Tests/SchemaCorpusTests.cs index 1a8db38..2b75a5e 100644 --- a/tests/LdifDotNet.Tests/SchemaCorpusTests.cs +++ b/tests/LdifDotNet.Tests/SchemaCorpusTests.cs @@ -19,11 +19,11 @@ public static TheoryData SchemaFiles() [MemberData(nameof(SchemaFiles))] public void Schema_file_contains_definitions(string relativePath) { - var (attributeTypes, objectClasses) = CountDefinitions(Fixtures.PathOf(relativePath)); + var (attributeTypes, objectClasses, syntaxes) = CountDefinitions(Fixtures.PathOf(relativePath)); Assert.True( - attributeTypes + objectClasses > 0, - $"{relativePath} contains no attributetype/objectclass definitions — corrupt fetch?"); + attributeTypes + objectClasses + syntaxes > 0, + $"{relativePath} contains no schema definitions — corrupt fetch?"); } [Fact] @@ -46,7 +46,7 @@ public void Corpus_contains_expected_schema_sets() int totalAttributeTypes = 0, totalObjectClasses = 0; foreach (string file in files) { - var (attributeTypes, objectClasses) = CountDefinitions(Fixtures.PathOf(file)); + var (attributeTypes, objectClasses, _) = CountDefinitions(Fixtures.PathOf(file)); totalAttributeTypes += attributeTypes; totalObjectClasses += objectClasses; } @@ -60,9 +60,9 @@ internal static IEnumerable AllSchemaFiles() => .Select(p => Path.GetRelativePath(Fixtures.Root, p).Replace('\\', '/')) .OrderBy(p => p, StringComparer.Ordinal); - internal static (int AttributeTypes, int ObjectClasses) CountDefinitions(string path) + internal static (int AttributeTypes, int ObjectClasses, int Syntaxes) CountDefinitions(string path) { - int attributeTypes = 0, objectClasses = 0; + int attributeTypes = 0, objectClasses = 0, syntaxes = 0; foreach (string line in File.ReadLines(path)) { string trimmed = line.TrimStart(); @@ -70,7 +70,9 @@ internal static (int AttributeTypes, int ObjectClasses) CountDefinitions(string attributeTypes++; else if (trimmed.StartsWith("objectclass", StringComparison.OrdinalIgnoreCase)) objectClasses++; + else if (trimmed.StartsWith("ldapsyntax", StringComparison.OrdinalIgnoreCase)) + syntaxes++; } - return (attributeTypes, objectClasses); + return (attributeTypes, objectClasses, syntaxes); } } diff --git a/tests/LdifDotNet.Tests/SchemaParserTests.cs b/tests/LdifDotNet.Tests/SchemaParserTests.cs index 8a974a9..6cde7cc 100644 --- a/tests/LdifDotNet.Tests/SchemaParserTests.cs +++ b/tests/LdifDotNet.Tests/SchemaParserTests.cs @@ -109,12 +109,15 @@ public void Parses_every_schema_in_corpus(string relativePath) { string path = Fixtures.PathOf(relativePath); var schema = LdapSchema.Load(path); - var (expectedAttributeTypes, expectedObjectClasses) = SchemaCorpusTests.CountDefinitions(path); + var (expectedAttributeTypes, expectedObjectClasses, expectedSyntaxes) = + SchemaCorpusTests.CountDefinitions(path); Assert.Equal(expectedAttributeTypes, schema.AttributeTypes.Count); Assert.Equal(expectedObjectClasses, schema.ObjectClasses.Count); + Assert.Equal(expectedSyntaxes, schema.Syntaxes.Count); Assert.All(schema.AttributeTypes, a => Assert.NotEqual("", a.Oid)); Assert.All(schema.ObjectClasses, c => Assert.NotEqual("", c.Oid)); + Assert.All(schema.Syntaxes, s => Assert.NotEqual("", s.Oid)); } [Theory] diff --git a/tests/LdifDotNet.Tests/SubschemaParseTests.cs b/tests/LdifDotNet.Tests/SubschemaParseTests.cs index 7364004..56ec7a0 100644 --- a/tests/LdifDotNet.Tests/SubschemaParseTests.cs +++ b/tests/LdifDotNet.Tests/SubschemaParseTests.cs @@ -277,8 +277,10 @@ public void Subschema_null_arguments_throw() { Assert.Throws(() => LdapSchema.ParseSubschema(null!, [])); Assert.Throws(() => LdapSchema.ParseSubschema([], null!)); + Assert.Throws(() => LdapSchema.ParseSubschema([], [], null!)); Assert.Throws(() => LdapSchema.ParseSubschema([null!], [])); Assert.Throws(() => LdapSchema.ParseSubschema([], [null!])); + Assert.Throws(() => LdapSchema.ParseSubschema([], [], [null!])); } [Fact] @@ -325,6 +327,21 @@ public void Ldap_syntax_true_is_case_insensitive() Assert.True(syntax.NotHumanReadable); } + [Fact] + public void Ldap_syntax_flag_reads_the_first_extension_value_only() + { + // Pins the rule for the undefined multi-valued case: the first value is + // the assertion (flag extensions are single-valued in every observed + // server; this matches the consumer parser this API supersedes). All + // values stay available in Extensions for consumers that disagree. + var trueFirst = LdapSyntax.Parse("( 1.2.3 X-NOT-HUMAN-READABLE ( 'TRUE' 'note' ) )"); + Assert.True(trueFirst.NotHumanReadable); + + var trueSecond = LdapSyntax.Parse("( 1.2.3 X-NOT-HUMAN-READABLE ( 'note' 'TRUE' ) )"); + Assert.False(trueSecond.NotHumanReadable); + Assert.Equal(["note", "TRUE"], trueSecond.Extensions["X-NOT-HUMAN-READABLE"]); + } + [Fact] public void Ldap_syntax_strict_parse_rejects_bad_input() { From c22adf10da25173b1d03933865d70426b6c2aece Mon Sep 17 00:00:00 2001 From: Joshua Toon Date: Mon, 3 Aug 2026 14:42:32 -0400 Subject: [PATCH 3/3] Review round 2: scope the slaptest claim, pin both flags' first-value rule The flag-extension pin now exercises BinaryTransferRequired alongside NotHumanReadable, its comment claims only what is witnessed (the captured corpus and the consumer parser's read of values[0], not "every observed server"), and the slaptest test's comment says what it actually pins: slapd's acceptance of the one file that motivated the NAME extension, not the parser's general compatibility. Co-Authored-By: Claude Fable 5 --- tests/LdifDotNet.Tests/DifferentialTests.cs | 9 +++++---- tests/LdifDotNet.Tests/SubschemaParseTests.cs | 17 +++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/LdifDotNet.Tests/DifferentialTests.cs b/tests/LdifDotNet.Tests/DifferentialTests.cs index c49ac56..b012586 100644 --- a/tests/LdifDotNet.Tests/DifferentialTests.cs +++ b/tests/LdifDotNet.Tests/DifferentialTests.cs @@ -159,10 +159,11 @@ public void Live_subschema_parses_completely() } /// - /// The file parser's contract is "what slapd accepts". pmi.schema is the - /// corpus file whose ldapsyntax NAME extension forced the parser to go - /// beyond RFC 4512's grammar — this pins slapd's side of that claim in CI - /// instead of in a comment, against the exact vendored file we parse. + /// pmi.schema is the corpus file whose ldapsyntax NAME extension forced the + /// parser to go beyond RFC 4512's grammar. This pins, in CI, that slapd + /// accepts this specific file — the runtime witness for that one design + /// decision, not a proof of the parser's general slapd compatibility (the + /// rest of the corpus and the round-trip tests carry that weight). /// [DifferentialFact] public void Slapd_accepts_the_vendored_pmi_schema() diff --git a/tests/LdifDotNet.Tests/SubschemaParseTests.cs b/tests/LdifDotNet.Tests/SubschemaParseTests.cs index 56ec7a0..7f34e8b 100644 --- a/tests/LdifDotNet.Tests/SubschemaParseTests.cs +++ b/tests/LdifDotNet.Tests/SubschemaParseTests.cs @@ -328,17 +328,22 @@ public void Ldap_syntax_true_is_case_insensitive() } [Fact] - public void Ldap_syntax_flag_reads_the_first_extension_value_only() + public void Ldap_syntax_flags_read_the_first_extension_value_only() { // Pins the rule for the undefined multi-valued case: the first value is - // the assertion (flag extensions are single-valued in every observed - // server; this matches the consumer parser this API supersedes). All - // values stay available in Extensions for consumers that disagree. - var trueFirst = LdapSyntax.Parse("( 1.2.3 X-NOT-HUMAN-READABLE ( 'TRUE' 'note' ) )"); + // the assertion, for both flags. Every flag extension in the captured + // OpenLDAP corpus is single-valued, and first-value-only matches the + // consumer parser this API supersedes (AspireLdapAdmin's IsTrue reads + // values[0]). All values stay available in Extensions regardless. + var trueFirst = LdapSyntax.Parse( + "( 1.2.3 X-NOT-HUMAN-READABLE ( 'TRUE' 'note' ) X-BINARY-TRANSFER-REQUIRED ( 'TRUE' 'note' ) )"); Assert.True(trueFirst.NotHumanReadable); + Assert.True(trueFirst.BinaryTransferRequired); - var trueSecond = LdapSyntax.Parse("( 1.2.3 X-NOT-HUMAN-READABLE ( 'note' 'TRUE' ) )"); + var trueSecond = LdapSyntax.Parse( + "( 1.2.3 X-NOT-HUMAN-READABLE ( 'note' 'TRUE' ) X-BINARY-TRANSFER-REQUIRED ( 'note' 'TRUE' ) )"); Assert.False(trueSecond.NotHumanReadable); + Assert.False(trueSecond.BinaryTransferRequired); Assert.Equal(["note", "TRUE"], trueSecond.Extensions["X-NOT-HUMAN-READABLE"]); }