Fix NLS ordinal casing of supplementary characters to agree with OrdinalIgnoreCase - #134149
Conversation
…nalIgnoreCase Under Windows NLS, ordinal casing (ToUpperOrdinal/ToLowerOrdinal) uses LCMapStringEx while ordinal comparison uses CompareStringOrdinal. These two Windows APIs disagree for some supplementary scalars: LCMapStringEx maps the Deseret upper/lower pairs (for example U+10428 to U+10400), but CompareStringOrdinal with IgnoreCase treats those pairs as unequal. That broke the canonicalization contract where ToUpperOrdinal(a) equals ToUpperOrdinal(b) if and only if OrdinalIgnoreCase considers a and b equal. The fix is NLS only and adds no casing tables. After casing, changed supplementary pairs are validated against CompareStringOrdinal and restored to their original scalar when the mapping moved them outside their OrdinalIgnoreCase class. ASCII keeps its fast path, pure BMP input adds a single vectorized surrogate scan, and unchanged surrogate runs are skipped in bulk. ICU mode is untouched.
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @dotnet/area-system-globalization |
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes NLS ordinal casing so supplementary-character casing agrees with OrdinalIgnoreCase.
Changes:
- Adds NLS validation/restoration for supplementary casing.
- Updates
Runeordinal casing. - Adds comprehensive Deseret NLS tests.
File summaries
| File | Description |
|---|---|
| src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/OrdinalCasingTests.cs | Updated as part of this pull request. |
| src/libraries/System.Private.CoreLib/src/System/Text/Rune.cs | Updated as part of this pull request. |
| src/libraries/System.Private.CoreLib/src/System/Globalization/Ordinal.cs | Updated as part of this pull request. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
|
CC @javiercn |
There was a problem hiding this comment.
🔵 Needs a closer look
The changes require final human review because they are too complex or risky for automated approval.
Review details
Suppressed comments (1)
src/libraries/System.Private.CoreLib/src/System/Text/Rune.cs:1616
- The preceding comment is now inaccurate for the NLS branch:
CharUnicodeInfo.ToUpperis only the initial scalar mapping, and this new code may restore the original scalar becauseCompareStringOrdinalcan disagree with it. Please update the comment to explain that NLS validates the mapping against theOrdinalIgnoreCaseclass.
// Supplementary characters use the same simple scalar mapping as OrdinalIgnoreCase comparisons.
uint upper = CharUnicodeInfo.ToUpper(value._value);
return UnsafeCreate(GlobalizationMode.UseNls
? Ordinal.PreserveNlsOrdinalCasingClass(value._value, upper)
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ing-supplementary
vcsjones
left a comment
There was a problem hiding this comment.
This looks acceptable. I did some additional tests using Adlam and Vithkuqi.
|
/backport to release/11.0 |
|
Started backporting to |
…agree with OrdinalIgnoreCase (#134229) Backport of #134149 to release/11.0 /cc @tarekgh ## Customer Impact - [ ] Customer reported - [x] Found internally Ordinal casing is a new feature introduced in .NET 11. Customers using the new APIs on Windows with NLS globalization can receive incorrect results for supplementary-plane characters, including scripts such as Deseret, Adlam, and Vithkuqi. Two strings considered distinct by OrdinalIgnoreCase can be converted to the same ordinal-cased value, violating the documented canonicalization contract and potentially causing incorrect matching, deduplication, hashing, or key normalization. The issue is difficult for applications to work around and creates behavior inconsistent with ICU-based platforms. The fix is narrowly scoped to NLS mode, introduces no additional API changes, and aligns casing with the existing CompareStringOrdinal behavior. The issue found by aspnet. Look at the comment for more info dotnet/aspnetcore#68912 (comment). #133950 ## Regression - [ ] Yes - [x] No [If yes, specify when the regression was introduced. Provide the PR or commit if known.] ## Testing All regression tests are passed, additionally added more tests to cover the issue we are fixing ## Risk Low, this fixing issue in a new feature introduced in net11, additionally the change is scoped to NLS mode which is not the default mode (ICU mode is the default mode). Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
Summary
Under Windows NLS, ordinal casing and ordinal comparison are backed by two different Windows APIs that disagree for some supplementary scalars:
ToUpperOrdinal/ToLowerOrdinalmap viaLCMapStringEx, which casts the Deseret upper/lower pairs (for exampleU+10428->U+10400).OrdinalIgnoreCasecomparison usesCompareStringOrdinal, which treats those same supplementary pairs as not equal.Because of this Windows-level incompatibility, the canonicalization contract was broken in NLS mode:
ToUpperOrdinal(a) == ToUpperOrdinal(b)no longer matchedstring.Equals(a, b, StringComparison.OrdinalIgnoreCase). Two strings that ordinal-ignore-case comparison considered different could be folded to the same value by ordinal casing (and vice versa).Fix
The change is NLS only and adds no casing tables (no binary size impact). After the normal casing pass, any supplementary pair that casing changed is validated against
CompareStringOrdinal; if the cased value fell outside itsOrdinalIgnoreCaseclass, the original scalar is restored. This reconciles ordinal casing output with the comparison oracle without carrying static data.Applies to the
string/SpanToUpperOrdinal/ToLowerOrdinalpaths and toRune.ToUpperOrdinal/Rune.ToLowerOrdinal.ICU mode is not touched at all.
Performance (NLS mode only)
Release benchmarks, matched hosts differing only by
System.Private.CoreLib,DOTNET_SYSTEM_GLOBALIZATION_USENLS=1:The added cost is confined to NLS mode. ASCII keeps its fast path, pure BMP adds a single vectorized scan, and unchanged surrogate runs are skipped in bulk so surrogate-dense text does not pay per-pair. The overhead is acceptable: this behavior ships as part of a new feature in .NET 11, the common ICU path is unaffected, and the alternative (embedding casing data) would increase binary size, which we want to avoid.
Tests
Added an NLS-specific test covering the full Deseret block
U+10400..U+1044Fthat assertsString/Span/Runeordinal casing agree withOrdinalIgnoreCaseequality (canonicalization and collision), plus mixed-string and unpaired-high-surrogate cases. FocusedOrdinalCasingTests: 91 passed, 0 failed.Resolves #133950