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..b012586 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
{
@@ -146,6 +158,30 @@ public void Live_subschema_parses_completely()
}
}
+ ///
+ /// 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()
+ {
+ 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/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/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 01403c2..6cde7cc 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()
{
@@ -72,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 d33dee9..7f34e8b 100644
--- a/tests/LdifDotNet.Tests/SubschemaParseTests.cs
+++ b/tests/LdifDotNet.Tests/SubschemaParseTests.cs
@@ -277,8 +277,158 @@ 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]
+ 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_flags_read_the_first_extension_value_only()
+ {
+ // Pins the rule for the undefined multi-valued case: the first value is
+ // 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' ) X-BINARY-TRANSFER-REQUIRED ( 'note' 'TRUE' ) )");
+ Assert.False(trueSecond.NotHumanReadable);
+ Assert.False(trueSecond.BinaryTransferRequired);
+ Assert.Equal(["note", "TRUE"], trueSecond.Extensions["X-NOT-HUMAN-READABLE"]);
+ }
+
+ [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]
@@ -302,13 +452,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 +473,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);