Skip to content

Add an in-memory LDAP server to power S.DS.Protocols tests#126771

Open
bartonjs wants to merge 1 commit intodotnet:mainfrom
bartonjs:inmem_ldap_tests
Open

Add an in-memory LDAP server to power S.DS.Protocols tests#126771
bartonjs wants to merge 1 commit intodotnet:mainfrom
bartonjs:inmem_ldap_tests

Conversation

@bartonjs
Copy link
Copy Markdown
Member

The LDAP server is very simplified, and only has features that were already being tested when a manual server was specified. Further functionality can be added as it is needed to satisfy tests.

The server supports LDAP, LDAP+STARTTLS, and LDAPS.

The LDAP server is very simplified, and only has features that were already
being tested when a manual server was specified.  Further functionality can be
added as it is needed to satisfy tests.

The server supports LDAP, LDAP+STARTTLS, and LDAPS.
@bartonjs bartonjs added this to the 11.0.0 milestone Apr 10, 2026
@bartonjs bartonjs requested a review from tarekgh April 10, 2026 21:50
@bartonjs bartonjs self-assigned this Apr 10, 2026
Copilot AI review requested due to automatic review settings April 10, 2026 21:51
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-directoryservices
See info in area-owners.md if you want to be subscribed.

@bartonjs
Copy link
Copy Markdown
Member Author

The LDAP server was written by Copilot, and I didn't verify any of it against specs since it's only a test component. I did have opinions on things like "use enums, not magic literal ints", but otherwise let it do whatever it wanted.

The general flow in the DirectoryServicesProtocolsTests file is now

[ConditionalFact(ExternalServerSpecified)]
public void TestAThing()
{
    using (LdapConnection connection = ConnectExternal())
    {
        TestAThing(connection, s_externalConfig.BaseDN);
    }
}

[Fact]
public void TestAThing_Local()
{
    using (LdapTestServer testServer = MakeAServer())
    using (LdapConnection connection = ConnectLocally(testServer))
    {
        TestAThing(connection, testServer.BaseDN);
    }
}

private static void TestAThingCore(LdapConnection connection, string baseDN)
{
    // The body of the using from TestAThing before this change.
}

As such, I highly recommend viewing the diff in that file with whitespace ignored.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a simplified, in-memory LDAP server to enable System.DirectoryServices.Protocols tests to run without requiring an externally configured LDAP instance, including support for LDAP, LDAP+STARTTLS, and LDAPS.

Changes:

  • Introduces LdapTestServer with basic LDAP request handling and BER/ASN.1 encoding/decoding for the subset of operations used by current tests.
  • Updates DirectoryServicesProtocolsTests to run key scenarios against either a configured external server or the new local in-memory server.
  • Updates the test project file to compile the new server sources and adds required .NET Framework references for ASN.1 and certificate loading support.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/libraries/System.DirectoryServices.Protocols/tests/TestServer/LdapTestServer.cs Adds in-memory directory state, listener lifecycle, and basic CRUD/search helpers backing the test LDAP server.
src/libraries/System.DirectoryServices.Protocols/tests/TestServer/LdapTestServer.Protocol.cs Implements LDAP message framing + protocol operation parsing and response generation, including STARTTLS/LDAPS.
src/libraries/System.DirectoryServices.Protocols/tests/System.DirectoryServices.Protocols.Tests.csproj Includes the new server sources and adds .NET Framework project references needed by the test server.
src/libraries/System.DirectoryServices.Protocols/tests/DirectoryServicesProtocolsTests.cs Refactors existing tests into shared “Core” helpers and adds local-server variants plus certificate-callback tests for LDAPS/STARTTLS.

Comment on lines +189 to +201
else
{
int numLenBytes = firstLenByte & 0x7F;
extraLenBytes = new byte[numLenBytes];
await ReadExactlyAsync(stream, extraLenBytes, 0, numLenBytes);

contentLength = 0;

for (int i = 0; i < numLenBytes; i++)
{
contentLength = (contentLength << 8) | extraLenBytes[i];
}
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadLdapMessageAsync treats a BER length byte of 0x80 (indefinite length) as numLenBytes == 0 and then proceeds with contentLength == 0, which will mis-frame the message and likely cause downstream ASN.1 parsing failures. Even if LDAP messages are expected to be definite-length, this should be rejected explicitly (e.g., throw/close when numLenBytes == 0) to avoid incorrect reads.

Copilot uses AI. Check for mistakes.
Comment on lines +581 to +583
AsnReader boolReader = new AsnReader(sortKeyReader.ReadEncodedValue(), AsnEncodingRules.BER);
byte[] boolBytes = boolReader.ReadOctetString(reverseTag);
sortReverse = boolBytes.Length > 0 && boolBytes[0] != 0;
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sort control parser reads reverseOrder using ReadOctetString(reverseTag) and then inspects the first byte. reverseOrder is an implicitly-tagged BOOLEAN; using the boolean reader (ReadBoolean(reverseTag)) would be correct and avoids relying on OCTET STRING decoding behavior for non-octet types.

Suggested change
AsnReader boolReader = new AsnReader(sortKeyReader.ReadEncodedValue(), AsnEncodingRules.BER);
byte[] boolBytes = boolReader.ReadOctetString(reverseTag);
sortReverse = boolBytes.Length > 0 && boolBytes[0] != 0;
sortReverse = sortKeyReader.ReadBoolean(reverseTag);

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +162
try
{
while (!_cts.IsCancellationRequested)
{
byte[] message = await ReadLdapMessageAsync(stream);

if (message is null)
{
break;
}

byte[][] responses = ProcessMessage(message, out bool upgradeToTls);

if (responses is null)
{
break;
}

foreach (byte[] response in responses)
{
await stream.WriteAsync(response, 0, response.Length);
}

if (upgradeToTls && _certificate is not null)
{
SslStream sslStream = new SslStream(stream, leaveInnerStreamOpen: true);
await sslStream.AuthenticateAsServerAsync(_certificate);
stream = sslStream;
}
}
}
catch (IOException) { }
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandleConnectionAsync only catches IO/cancellation/disposal exceptions. If ASN.1 decoding fails (e.g., malformed/unexpected client message causing AsnContentException) or TLS authentication throws, the exception will escape the fire-and-forget task and can surface as an unobserved task exception during test runs. Consider catching AsnContentException (or a broader Exception) and closing the connection cleanly.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +156
if (_useLdaps && _certificate is not null)
{
SslStream sslStream = new SslStream(stream, leaveInnerStreamOpen: false);
await sslStream.AuthenticateAsServerAsync(_certificate);
stream = sslStream;
}

try
{
while (!_cts.IsCancellationRequested)
{
byte[] message = await ReadLdapMessageAsync(stream);

if (message is null)
{
break;
}

byte[][] responses = ProcessMessage(message, out bool upgradeToTls);

if (responses is null)
{
break;
}

foreach (byte[] response in responses)
{
await stream.WriteAsync(response, 0, response.Length);
}

if (upgradeToTls && _certificate is not null)
{
SslStream sslStream = new SslStream(stream, leaveInnerStreamOpen: true);
await sslStream.AuthenticateAsServerAsync(_certificate);
stream = sslStream;
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SslStream instances created for LDAPS/STARTTLS are assigned to stream but never disposed. Even though the underlying socket will be closed when TcpClient is disposed, not disposing SslStream can delay cleanup and may leave handshake/close-notify work unflushed. Consider wrapping the active stream in a using/await using (or ensuring the current SslStream is disposed when replaced/when the loop exits).

Copilot uses AI. Check for mistakes.
@bartonjs
Copy link
Copy Markdown
Member Author

I expect this iteration to fail tests, by the way, since the LDAP+STARTTLS or LDAPS certificate acceptance callback is only wired up on Windows. I'll condition away those tests after seeing them fail in CI.

Copy link
Copy Markdown
Member

@tarekgh tarekgh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR - the approach of an in-memory LDAP test server is the right call here. A few observations beyond what the copilot flagged:

while (!_cts.IsCancellationRequested)
{
TcpClient client = await _listener.AcceptTcpClientAsync();
_ = HandleConnectionAsync(client);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both AcceptLoopAsync() (line 39) and this HandleConnectionAsync() call are fire-and-forget (_ = ...). If either throws an unexpected exception (e.g., something not caught by the three catch clauses in HandleConnectionAsync), it becomes an unobserved Task exception that is silently swallowed. In a test context, this means a server-side bug could go unnoticed and a test could pass while the server is actually broken.

Consider storing these tasks (e.g., in a ConcurrentBag<Task>) and observing/awaiting them in Dispose(), so test failures surface clearly when the server had errors.

return Start();
}

public void Dispose()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispose() cancels the token and stops the listener, but any in-flight HandleConnectionAsync tasks are orphaned - there is no tracking or awaiting of active connections. If a test disposes the server while a request is mid-flight, the TCP connection gets torn out from under the handler.

Consider tracking active handler tasks and awaiting them (with a short timeout) here, so disposal is clean and any late exceptions are observed.

internal int StartLdaps(X509Certificate2 certificate)
{
_certificate = certificate;
_useLdaps = true;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit/theoretical: _certificate and _useLdaps are set here before Start() calls _listener.Start() + AcceptLoopAsync(). There is no explicit memory barrier between these writes and the reads in HandleConnectionAsync on the thread-pool. In practice the timing makes a race extremely unlikely, but initializing these in the constructor (or making them readonly and selecting the start mode via a parameter) would be structurally cleaner.


internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;

internal string BaseDn => "dc=test";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseDn is hardcoded to dc=test, but no corresponding root entry is ever seeded into _entries. This means a baseObject-scope search on the root DN itself will return zero results. If any test ever does that, it would silently get an empty result set rather than hitting the base entry. Worth either seeding a root entry in the constructor or adding a comment noting this limitation.

return entry.HasAttribute(attrDesc);
}

return false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter evaluator only handles AND, OR, NOT, equalityMatch (tag 3), and present (tag 7). Any other filter type - substring (tag 4), greaterOrEqual (tag 5), lessOrEqual (tag 6), approxMatch (tag 8), extensibleMatch (tag 9) - silently returns false, which means matching entries are quietly excluded with no error.

This is fine for the current test set, but it is a subtle trap for anyone adding future tests. Consider either returning an UnwillingToPerform result for unrecognized filter tags, or adding a comment here documenting which filter types are intentionally unsupported.

{
pageResults = new List<Entry>();
}
else
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paging implementation uses a simple byte-offset cookie into the current result list. Since the entry store (_entries) can change between paged search requests (entries added or deleted by other test operations), offsets from a previous page can become stale - potentially skipping or duplicating entries.

This is fine for current tests, but worth a brief comment noting that this paging implementation assumes a stable dataset across pages.

}

[ConditionalFact(typeof(DirectoryServicesTestHelpers), nameof(DirectoryServicesTestHelpers.IsWindowsOrLibLdapIsInstalled))]
public void TestInvalidFilter_Local()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three-method pattern (TestX for external, TestX_Local for local, TestXCore for shared logic) is consistent and clear, but it triples the test surface area with identical wiring boilerplate. Across the file this adds significant churn.

Have you considered a data-driven approach (e.g., [MemberData] yielding connection factories, or a shared helper that runs the same core logic against both connection types)? That could cut the boilerplate substantially while preserving the dual-path coverage. Happy to discuss if you want to keep it explicit for readability - just raising it as an option.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd been thinking about it during the change, and think the best answer is probably to use inheritance, like we do in other libraries for getting coverage across array and span overloads.

So I'll see what

[ConditionalClass(typeof(DirectoryServicesTestHelpers), nameof(DirectoryServicesTestHelpers.IsWindowsOrLibLdapIsInstalled))]
public sealed class DirectoryServicesProtocolsTests_Local : DirectoryServicesProtocolsTests
{
    private class LocalConnectionState : ConnectionState
    {
        internal LdapTestServer TestServer { get; }
        
        internal LocalConnectionState(LdapConnection connection, LdapTestServer testServer)
            : base(connection)
        {
            TestServer = testServer;
        }

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);

            if (disposing)
            {
                TestServer.Dispose();
            }
        }
    }

    protected override ConnectionState OpenConnection()
    {
        LdapTestServer server = StartLocalServer(out int port);
        LdapConnection connection = GetLocalConnection(port);

        return new LocalConnectionState(connection, server);
    }
}

[ConditionalClass(typeof(DirectoryServicesProtocolsTests), nameof(LdapConfigurationExists))]
public sealed class DirectoryServicesProtocolsTests_External : DirectoryServicesProtocolsTests
{
    protected override ConnectionState OpenConnection()
    {
        return new ConnectionState(GetConnection());
    }
}


public abstract partial class DirectoryServicesProtocolsTests
{
    [Fact]
    public void TestInvalidFilter()
    {
        using (ConnectionState state = OpenConnection())
        {
            LdapConnection connection = state.Connection;
            // rest of code
        }
    }
}

ends up looking like

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO your suggestion is a better design.

@danmoseley
Copy link
Copy Markdown
Member

This doesn't replace the existing "infra" to run tests against real servers, right (mostly containerized ones)
https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/DirectoryServices/LDAP.Configuration.xml

The big advantages of what you have here are we control it, we could potentially use it to mock error paths and it can run in CI. But the existing infra ensures we work against real servers.

@bartonjs
Copy link
Copy Markdown
Member Author

This doesn't replace the existing "infra" to run tests against real servers, right

Correct. It's purely additive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants