Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Switch DirectoryControl to use AsnWriter, AsnDecoder - #101512

Merged
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540
Dec 5, 2024
Merged

Switch DirectoryControl to use AsnWriter, AsnDecoder#101512
steveharter merged 24 commits into
dotnet:mainfrom
edwardneal:issue-97540

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Relates to #97540.

This PR replaces all references to BerConverter in LDAP directory control generation/parsing to use AsnWriter and AsnDecoder. SortRequestControl didn't use BerConverter directly - it called the OpenLDAP and WLDAP ldap_create_sort_control APIs instead. This class was the only thing in S.DS.P which referenced the ldap_create_sort_control and SortKeyInterop struct, so I deleted them both.

SortRequestControl

The change to SortRequestControl's generation mechanism might also resolve#34679, since there shouldn't be any mechanism for the heap corruption to occur.

Most of the SortRequestControl's new ASN.1 encoding is pretty uncontroversial, but there was a bit of discussion in PR #65548 around the encoding of the sort key's attribute name, and this was marshalled (as part of SortKeyInterop) with different encodings between Windows and Linux. In the RFC, this is defined (indirectly) as an LdapString; this is described as ISO10646 characters, encoded as a UTF-8 string and represented as an OCTET STRING. I'm fairly sure that UTF8Encoding.GetBytes fulfils this, and running the associated test case against a real AD domain controller passes.

Test changes

There are also test changes, but these are largely to change the special-casing of expected byte values between OpenLDAP and WLDAP - .NET now generates these values in a consistent format (the OpenLDAP format) regardless of platform. The .NET Framework tests continued to use the version of S.DS.P from the GAC in my environment, so I've special-cased by the framework version rather than by the platform.

Misc. optimizations

There were a handful of byte-by-byte array copies, which I've switched over to using span-based copies in hopes that they'll benefit slightly from vectorisation. TransformControls and GetValue have a related change: where they used to reference properties returning byte arrays (which took defensive copies) they now reference the property values directly. These should both reduce GC traffic slightly.

Replaced this with the managed AsnDecoder, removing PInvoke from a potential hot path.
Also removed the manual API calls to ldap_create_sort_control - this is now built in managed code.
This then has knock-on effects to eliminate the SortKeyInterop classes.
Most of the Control tests were hardcoded to the output of BerConverter, which uses four-byte lengths in all cases.
This behaviour is now different: the same output is returned across all platforms for .NET, and remains unchanged for .NET Framework.
This should also close issue 34679.
Reduce number of copies required in TransformControls, and enable these copies to take advantage of newer intrinsics where available.
Windows domain controllers may return a distinguished name starting with OU=, rather than ou=.
@PaulusParssinen

Copy link
Copy Markdown
Contributor

Out of curiosity, any benchmarks for perf. numbers before/after switching to AsnReader/AsnWriter?

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

I've not got benchmarks right now, but will write some in the next few days. In advance of these, I expect there'll be a modest reduction in managed and unmanaged memory usage, and that execution time will reduce (while remaining within the margin of error for the network request itself.)

Preallocating space for AsnWriter buffers to reduce memory usage.
Correctly handling attribute names in SortControls.
@edwardneal

edwardneal commented Apr 27, 2024

Copy link
Copy Markdown
ContributorAuthor

Benchmarks are below. To summarize:

  • As expected, the performance improvements are working in the margins. I fully expect most of the execution time variations to be lost in the noise of network traffic.
  • 35% median reduction in memory usage. One notable improvement on this in DirectoryControl.TransformControls, which is on the hot path for processing LDAP responses and reduces memory usage by about 75%.
  • Although the percentage reductions in memory usage are good, the absolute reductions are pretty small - the median reduction was of 144 bytes.
  • 88.5% median reduction in execution time, although the absolute reductions are often small - AsqRequest is reduced from 1.869 microseconds to 185.1 nanoseconds.
  • DirectoryControl.TransformControls is another notable exception to this, reducing from 6.596us to 1.588us.
  • Most of the original code's memory allocations stuck around for Gen1 GCs. This GC pressure no longer exists.
  • I've got no data on unmanaged memory usage. This is particularly relevant for SortRequest, which moved from 400 bytes to 416 bytes managed memory usage. I'm assuming that this lack of data is the reason for the increase in memory usage - it's not actually increasing, it's just now trackable in the managed counters.
  • Code size is 15 bytes in most places. The disassembly puts this at the size of the benchmark itself - just enough to return DirectoryControl.GetValue. I think this is just noise from the JIT inlining.
Performance header
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3296/23H2/2023Update/SunValley3)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 8.0.200
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
AsqRequestControl.GetValue: -90% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.869 μs0.0373 μs0.0485 μs127 B0.10300.0992432 B
PR185.1 ns3.25 ns4.12 ns15 B0.0668280 B
CrossDomainMoveControl.GetValue: -54% execution time, -62% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original89.81 ns1.297 ns1.332 ns1,039 B0.0516216 B
PR41.24 ns0.469 ns0.438 ns2,577 B0.019180 B
DirSyncRequestControl.GetValue: -89% execution time, -35% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.587 μs0.0453 μs0.1329 μs191 B0.12210.1183520 B
PR162.4 ns2.20 ns2.06 ns15 B0.0782328 B
ExtendedDNControl.GetValue: -89% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.059 μs0.0213 μs0.0522 μs147 B0.08770.0858368 B
PR115.9 ns1.72 ns1.44 ns15 B0.0610256 B
PageResultRequestControl.GetValue: -90% execution time, -40% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.412 μs0.0283 μs0.0432 μs160 B0.10300.1011432 B
PR134.7 ns1.49 ns1.32 ns15 B0.0610256 B
QuotaControl.GetValue: -92% execution time, -28% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.607 μs0.0261 μs0.0232 μs127 B0.09160.0877392 B
PR120.4 ns1.65 ns1.29 ns15 B0.0668280 B
SearchOptionsControl.GetValue: -90% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.251 μs0.0223 μs0.0197 μs147 B0.08770.0858368 B
PR114.7 ns1.56 ns1.39 ns15 B0.0610256 B
SecurityDescriptorFlagControl.GetValue: -88% execution time, -30% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.021 μs0.0188 μs0.0460 μs147 B0.08770.0858368 B
PR115.8 ns1.47 ns1.37 ns15 B0.0610256 B
SortRequestControl.GetValue: -79% execution time, +4% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.893 μs0.0220 μs0.0195 μs15 B0.0954400 B
PR390.5 ns5.37 ns5.03 ns15 B0.0992416 B
DirectoryControl.TransformControls: -75% execution time, -76% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original6.596 μs0.1310 μs0.3164 μs3,603 B0.83920.01533.43 KB
PR1.588 μs0.0208 μs0.0255 μs12,854 B0.1945816 B
VerifyNameControl.GetValue: -87% execution time, -46% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.575 μs0.0480 μs0.1240 μs1,526 B0.14880.1469624 B
PR195.1 ns1.43 ns1.27 ns15 B0.0801336 B
VlvRequestControl.GetValue: -84% execution time, -69% Gen0 memory allocation
MethodMeanErrorStdDevCode SizeGen0Gen1Allocated
Original1.459 μs0.0276 μs0.0650 μs15 B0.22890.0038968 B
PR227.7 ns3.21 ns3.00 ns15 B0.0706296 B

I've made a performance adjustment by specifying the initial size of AsnWriter, since this is trivial to calculate (or always static.) AsnWriter grows in 1KB increments, which is much larger than the size of a normal directory control and causes memory usage to balloon.

One inefficiency which I couldn't eliminate is that when writing strings as ASN.1 octet strings, I want to manually select the encoding to use and encode directly into the AsnWriter buffer. This isn't possible, (probably to keep AsnWriter specification-compliant) so I have to reserve/allocate a byte array, encode into that and write that out as an octet string. An example of this behaviour is in VerifyNameControl.GetValue.

Edit: the updated build has completed and the test failures are unrelated, so I'm now happy that the benchmarks are valid @PaulusParssinen

Previously, a zero-length octet string interpreted via the "a" format string would have resulted in a null value in Windows 8.1, and an empty string in every other case. This now returns an empty string in all cases.
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Following the merge of #107201, I've updated this PR with three commits; it's ready for review at leisure.

Commits 1 & 3

  • Updated the various response control tests, accounting for cases where previously "nonconformant but valid" samples are now marked as invalid, and eliminating the OS-specific behaviour in these tests. Commit 3 just removes the OS-specific behaviour from the conformant control samples - I'd forgotten that Windows 8.1 handled the "a" parameter differently.
  • Corrected a handful of conformant values in the sort response control tests - I had specified the tag for an OCTET STRING (0x04), when the RFC actually gives the structure an explicit tag of [0] (0x80.)
  • Clarified the differing behaviour of text decoding when the bytes to be decoded aren't a valid input to Encoding.GetString. .NET Framework/BerConverter throws a DecoderFallbackException, .NET 10 throws a BerConversionException.

Commit 2

  • Slight tightening of the validation when comparing the nonconformant but valid samples between response tests: added a check to ensure that there's no trailing data inside the ASN sequence when parsing an AsqResponseControl.
  • Also changed all five response control tests to make sure that there's no trailing data after the end of the RFC-compliant response control value. I think this tightens up the last piece of loose parsing of trailing data, and matches the RFCs/specs. I've tested this against an OpenLDAP server and can run the existing tests.

@ericstj
ericstj requested review from a team and removed request for buyaa-nNovember 4, 2024 16:30

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@edwardneal I verified test coverage for the new code, and that looks good with the exception of the two cases I mentioned.

Reading a long attribute name would have failed due to an invalid expected ASN.1 tag. Correct, and added a test.
Added test to validate that passing an invalid UTF8 string as the target parameter of a VlvRequestControl will now throw an EncoderFallbackException.
No longer null coalescing _directoryControlValue; replaced with a Debug.Assert that it's not null.

@stevehartersteveharter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Marking approved; will wait for a bit for any additional feedback from @bartonjs or others before merging. I think moving this code forward is a good thing even though there some risk. It also has the potential to fix issues including #34679.

{
internal static class AsnWriterExtensions
{
public static void WriteLdapString(this AsnWriter writer, string value, Encoding stringEncoding, bool mandatory = true, Asn1Tag? tag = null)

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.

I'm not sure this is a good name. There's no construct (that I see) called LdapString, and not all strings in LDAP are sent as "A Utf8String, except using tag 04 instead of 0C".

WriteUtf8OctetString, maybe?

The bool mandatory has no peer on AsnWriter methods. I recommend removing it here (making it always behave as true, and making the one "optional" caller bring that logic closer to home... so it looks like any other conditional write for an ASN OPTIONAL or DEFAULT value.

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.

I guess one caller passes Encoding.Unicode. So either two functions, or "WriteStringAsOctetString" might be a better name for the current shape.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The name LdapString partially comes from RFC2251, as the backing type for AttributeDescription. Do you still want the name to change?

It was primarily used for writing the sort controls, and the other control logic piggybacks on the same method by explicitly specifying the encoding. I'll see if two methods would be clearer for this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've tried a couple of different methods to see what the semantics look like, and agree - WriteStringAsOctetString it is. That's rolled up and done now.

[ThreadStatic]
private static AsnWriter? t_writer;

[MemberNotNull(nameof(t_writer))]

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.

I don't know how well MemberNotNull behaves with ThreadStatic. No one should be touching t_writer except this function, so why is the annotation needed/warranted at all?

@edwardnealedwardnealNov 28, 2024

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought it'd help bridge the gap between a nullable local variable and a non-nullable return value. I've removed it.


[MemberNotNull(nameof(t_writer))]
internal static AsnWriter GetWriter()
=> t_writer ??= new AsnWriter(AsnEncodingRules.BER);

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 correct behavior for every caller is to call Reset() on the writer when they get it, because they don't know if they have one that was abandoned due to an exception.

Maybe GetWriter should do that for them, instead?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Thanks, I've shifted this around.

@bartonjs

Copy link
Copy Markdown
Member

any additional feedback from @bartonjs

Had a few small things. Looked at all the commits since my last review.

This change doesn't take effect on .NET Framework, so any test expecting an exception will fail.
Removed the unnecessary nullability annotations, and moved the Reset call into GetWriter.
Also adjusted method signature to better align to the rest of the AsnWriter API surface.
@steveharter

Copy link
Copy Markdown
Contributor

@edwardneal do you have any further action items or planned changes? If not, I'll merge. Thanks.

@stevehartersteveharter removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Dec 5, 2024
@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks - I've responded to bartonjs' code review in-line, so don't have any further code changes planned.

An earlier comment asked for a breaking change doc to be created though, and with the work settled I'll do this today/tomorrow.

@edwardneal

Copy link
Copy Markdown
ContributorAuthor

Thanks @steveharter and @bartonjs for your reviews. The breaking change doc is dotnet/docs#43885.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jan 5, 2025
@ericstjericstj removed the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Oct 2, 2025
@ericstj

Copy link
Copy Markdown
Member

Removing needs-breaking-change-doc-created label as this PR already has a documentation issue: dotnet/docs#43885

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.DirectoryServicesbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.community-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[mono] Test failed on windows: System.DirectoryServices.Protocols.Tests.SortRequestControlTests

6 participants

@edwardneal@PaulusParssinen@bartonjs@ericstj@buyaa-n@steveharter