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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/LdifDotNet.Schema/LdapAttributeType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ internal LdapAttributeType()
{
}

/// <summary>
/// Parses one bare parenthesized attribute type description (RFC 4512 §4.1.2),
/// 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 <see cref="LdapSchemaParseException"/>;
/// for input a server published, use the lenient <see cref="LdapSchema.ParseSubschema"/>.
/// </summary>
public static LdapAttributeType Parse(string definition)
{
ArgumentNullException.ThrowIfNull(definition);
return new SchemaParser().ParseAttributeTypeDefinition(definition, lenient: false);
}

/// <summary>The numeric OID that identifies this attribute type.</summary>
public string Oid { get; internal set; } = "";

Expand Down
14 changes: 14 additions & 0 deletions src/LdifDotNet.Schema/LdapObjectClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ internal LdapObjectClass()
{
}

/// <summary>
/// Parses one bare parenthesized object class description (RFC 4512 §4.1.1),
/// the form a subschema subentry publishes as objectClasses values, e.g.
/// "( 2.5.6.6 NAME 'person' SUP top STRUCTURAL MUST ( sn $ cn ) )". Strict: an
/// unknown keyword, a non-numeric OID, or trailing text throws
/// <see cref="LdapSchemaParseException"/>; for input a server published, use
/// the lenient <see cref="LdapSchema.ParseSubschema"/>.
/// </summary>
public static LdapObjectClass Parse(string definition)
{
ArgumentNullException.ThrowIfNull(definition);
return new SchemaParser().ParseObjectClassDefinition(definition, lenient: false);
}

/// <summary>The numeric OID that identifies this object class.</summary>
public string Oid { get; internal set; } = "";

Expand Down
64 changes: 63 additions & 1 deletion src/LdifDotNet.Schema/LdapSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ public sealed class LdapSchema
{
private readonly List<LdapAttributeType> _attributeTypes;
private readonly List<LdapObjectClass> _objectClasses;
private readonly List<LdapUnparsedDefinition> _unparsedDefinitions;
private readonly Dictionary<string, LdapAttributeType> _attributeIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LdapObjectClass> _classIndex = new(StringComparer.OrdinalIgnoreCase);

private LdapSchema(List<LdapAttributeType> attributeTypes, List<LdapObjectClass> objectClasses)
private LdapSchema(
List<LdapAttributeType> attributeTypes,
List<LdapObjectClass> objectClasses,
List<LdapUnparsedDefinition>? unparsedDefinitions = null)
{
_attributeTypes = attributeTypes;
_objectClasses = objectClasses;
_unparsedDefinitions = unparsedDefinitions ?? [];

foreach (var attributeType in attributeTypes)
{
Expand Down Expand Up @@ -74,12 +79,69 @@ public static LdapSchema Parse(string text)
return new LdapSchema(attributeTypes, objectClasses);
}

/// <summary>
/// Parses definition values as a server publishes them in its subschema
/// subentry (RFC 4512 §4.2): each value is one bare parenthesized definition,
/// e.g. "( 2.5.6.6 NAME 'person' ... )". Lenient, because a live server's
/// schema cannot be fixed by the consumer: a definition that fails to parse
/// is preserved in <see cref="UnparsedDefinitions"/> instead of failing the
/// whole schema, and an unknown keyword inside a definition is skipped
/// rather than failing that definition.
/// </summary>
public static LdapSchema ParseSubschema(
IEnumerable<string> attributeTypeDefinitions,
IEnumerable<string> objectClassDefinitions)
{
ArgumentNullException.ThrowIfNull(attributeTypeDefinitions);
ArgumentNullException.ThrowIfNull(objectClassDefinitions);

var parser = new SchemaParser();
var attributeTypes = new List<LdapAttributeType>();
var objectClasses = new List<LdapObjectClass>();
var unparsed = new List<LdapUnparsedDefinition>();

foreach (string definition in attributeTypeDefinitions)
{
if (definition is null)
throw new ArgumentException("Definition values must not be null.", nameof(attributeTypeDefinitions));
try
{
attributeTypes.Add(parser.ParseAttributeTypeDefinition(definition, lenient: true));
}
catch (LdapSchemaParseException e)
{
unparsed.Add(new LdapUnparsedDefinition(LdapSchemaDefinitionKind.AttributeType, definition, e.Message));
}
}
foreach (string definition in objectClassDefinitions)
{
if (definition is null)
throw new ArgumentException("Definition values must not be null.", nameof(objectClassDefinitions));
try
{
objectClasses.Add(parser.ParseObjectClassDefinition(definition, lenient: true));
}
catch (LdapSchemaParseException e)
{
unparsed.Add(new LdapUnparsedDefinition(LdapSchemaDefinitionKind.ObjectClass, definition, e.Message));
}
}
return new LdapSchema(attributeTypes, objectClasses, unparsed);
}

/// <summary>All attribute types in declaration order.</summary>
public IReadOnlyList<LdapAttributeType> AttributeTypes => _attributeTypes;

/// <summary>All object classes in declaration order.</summary>
public IReadOnlyList<LdapObjectClass> ObjectClasses => _objectClasses;

/// <summary>
/// Definitions <see cref="ParseSubschema"/> could not parse, raw text
/// preserved. Always empty for the strict <see cref="Load"/> and
/// <see cref="Parse"/> paths, which throw on the first error instead.
/// </summary>
public IReadOnlyList<LdapUnparsedDefinition> UnparsedDefinitions => _unparsedDefinitions;

/// <summary>Finds an attribute type by any of its names or its OID, or null.</summary>
public LdapAttributeType? FindAttributeType(string nameOrOid)
{
Expand Down
11 changes: 11 additions & 0 deletions src/LdifDotNet.Schema/LdapSchemaDefinitionKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace LdifDotNet.Schema;

/// <summary>The kind of schema definition a subschema value was presented as.</summary>
public enum LdapSchemaDefinitionKind
{
/// <summary>An attribute type description (RFC 4512 §4.1.2), from attributeTypes values.</summary>
AttributeType,

/// <summary>An object class description (RFC 4512 §4.1.1), from objectClasses values.</summary>
ObjectClass,
}
29 changes: 29 additions & 0 deletions src/LdifDotNet.Schema/LdapUnparsedDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace LdifDotNet.Schema;

/// <summary>
/// A definition value that <see cref="LdapSchema.ParseSubschema"/> 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.
/// </summary>
public sealed class LdapUnparsedDefinition
{
internal LdapUnparsedDefinition(LdapSchemaDefinitionKind kind, string definition, string error)
{
Kind = kind;
Definition = definition;
Error = error;
}

/// <summary>Which definition kind the value was presented as.</summary>
public LdapSchemaDefinitionKind Kind { get; }

/// <summary>The raw definition text, exactly as supplied.</summary>
public string Definition { get; }

/// <summary>Why the definition could not be parsed.</summary>
public string Error { get; }

/// <summary>Returns the raw definition text.</summary>
public override string ToString() => Definition;
}
20 changes: 18 additions & 2 deletions src/LdifDotNet.Schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Parser for LDAP schema definitions: RFC 4512 `attributetype` / `objectclass`
descriptions in slapd.conf schema-file format, including `objectidentifier`
OID macros. Dependency-free.
OID macros — and the bare definition values a live server publishes in its
subschema subentry. Dependency-free.

```csharp
using LdifDotNet.Schema;
Expand All @@ -17,5 +18,20 @@ var sn = schema.FindAttributeType("surname"); // lookup by any name o
Console.WriteLine(sn.Syntax); // 1.3.6.1.4.1.1466.115.121.1.15
```

Schema read from a live server's `cn=Subschema` entry parses leniently — the
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);
foreach (var bad in schema.UnparsedDefinitions)
Console.WriteLine($"unparsed {bad.Kind}: {bad.Error}");

// Strict single-definition parsing is also available:
var cn = LdapAttributeType.Parse("( 2.5.4.3 NAME ( 'cn' 'commonName' ) SUP name )");
```

Proven against OpenLDAP's complete shipped schema set plus eduPerson,
rfc2307bis, sudo, and openssh-lpk.
rfc2307bis, sudo, and openssh-lpk — and, for subschema input, against the
`cn=Subschema` entry a real OpenLDAP 2.6 server publishes.
81 changes: 75 additions & 6 deletions src/LdifDotNet.Schema/SchemaParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ public void ParseInto(string text, List<LdapAttributeType> attributeTypes, List<
switch (keyword)
{
case "attributetype" or "attributetypes":
attributeTypes.Add(ParseAttributeType(new Cursor(body, lineNumber)));
attributeTypes.Add(ParseAttributeType(new Cursor(body, lineNumber), lenient: false));
break;
case "objectclass" or "objectclasses":
objectClasses.Add(ParseObjectClass(new Cursor(body, lineNumber)));
objectClasses.Add(ParseObjectClass(new Cursor(body, lineNumber), lenient: false));
break;
case "objectidentifier":
ParseOidMacro(body, lineNumber);
Expand All @@ -35,6 +35,32 @@ public void ParseInto(string text, List<LdapAttributeType> attributeTypes, List<
}
}

/// <summary>
/// Parses one bare parenthesized attribute type definition, as a subschema
/// subentry publishes them in attributeTypes values. Lenient mode skips
/// unknown keywords instead of failing the definition.
/// </summary>
public LdapAttributeType ParseAttributeTypeDefinition(string definition, bool lenient)
{
var cursor = new Cursor(definition, lineNumber: 1, locateErrors: false);
var result = ParseAttributeType(cursor, lenient);
cursor.ExpectEnd();
return result;
}

/// <summary>
/// Parses one bare parenthesized object class definition, as a subschema
/// subentry publishes them in objectClasses values. Lenient mode skips
/// unknown keywords instead of failing the definition.
/// </summary>
public LdapObjectClass ParseObjectClassDefinition(string definition, bool lenient)
{
var cursor = new Cursor(definition, lineNumber: 1, locateErrors: false);
var result = ParseObjectClass(cursor, lenient);
cursor.ExpectEnd();
return result;
}

/// <summary>
/// Assembles logical directives: a directive starts at column 0; lines that
/// begin with whitespace continue it; '#' lines are comments; blank lines end it.
Expand Down Expand Up @@ -121,7 +147,7 @@ private string ExpandOidMacros(string oid)
return oid;
}

private LdapAttributeType ParseAttributeType(Cursor cursor)
private LdapAttributeType ParseAttributeType(Cursor cursor, bool lenient)
{
cursor.Expect(TokenKind.LParen);
var result = new LdapAttributeType { Oid = ResolveOid(cursor) };
Expand Down Expand Up @@ -162,6 +188,8 @@ private LdapAttributeType ParseAttributeType(Cursor cursor)
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 attributetype");
break;
Expand All @@ -172,7 +200,7 @@ private LdapAttributeType ParseAttributeType(Cursor cursor)
return result;
}

private LdapObjectClass ParseObjectClass(Cursor cursor)
private LdapObjectClass ParseObjectClass(Cursor cursor, bool lenient)
{
cursor.Expect(TokenKind.LParen);
var result = new LdapObjectClass { Oid = ResolveOid(cursor) };
Expand Down Expand Up @@ -200,6 +228,8 @@ private LdapObjectClass ParseObjectClass(Cursor cursor)
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 objectclass");
break;
Expand All @@ -222,7 +252,7 @@ private enum TokenKind

private readonly record struct Token(TokenKind Kind, string Value);

private sealed class Cursor(string text, int lineNumber)
private sealed class Cursor(string text, int lineNumber, bool locateErrors = true)
{
private int _position;
private Token? _peeked;
Expand Down Expand Up @@ -280,8 +310,47 @@ public void Expect(TokenKind kind)
throw Error($"expected {kind}, got '{token.Value}'");
}

/// <summary>Requires that nothing follows the definition's closing paren.</summary>
public void ExpectEnd()
{
var token = Next();
if (token.Kind != TokenKind.End)
throw Error($"unexpected '{token.Value}' after the definition");
}

/// <summary>
/// Skips the value of an unknown keyword in lenient mode. A quoted string
/// or a balanced parenthesized group is consumed; a bare word is left in
/// place, because it is more likely the next keyword than a value (unknown
/// flag keywords take no value at all).
/// </summary>
public void SkipUnknownValue()
{
var next = Peek();
if (next.Kind == TokenKind.Quoted)
{
Next();
return;
}
if (next.Kind != TokenKind.LParen)
return;

Next();
int depth = 1;
while (depth > 0)
{
depth += Next().Kind switch
{
TokenKind.LParen => 1,
TokenKind.RParen => -1,
TokenKind.End => throw Error("unterminated parenthesized group"),
_ => 0,
};
}
}

public LdapSchemaParseException Error(string message) =>
new($"line {lineNumber}: {message}", lineNumber);
new(locateErrors ? $"line {lineNumber}: {message}" : message, lineNumber);

/// <summary>
/// Decodes RFC 4512 qdstring escapes: "\27" is an apostrophe and
Expand Down
Loading