mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
Validate the complete RFC 5646 grammar of fallback locale ids
Review feedback: - TryParseLocaleId accepted tags that are not well-formed, such as en-US-x (a private use singleton without a subtag) and en-US-ab (a two-character variant), and registered them in globalization-invariant mode. It now follows the grammar of RFC 5646 section 2.1: the language, up to three extended language subtags, the script, the region, variants, extensions with their subtags, and a private use sequence, in this order. A tag that does not match is rejected; grandfathered tags and tags that consist of a private use sequence only stay rejected. - The test fixture is sealed. New invariant-mode cases: ten malformed tags are rejected (the old parser accepted six of them); five well-formed tags with extended language, variant, extension, or private use subtags are registered in the recommended casing (the old parser read zh-cmn-hans-cn as neutral); and two tags without a region but with a variant or an extension are rejected as neutral. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -615,59 +615,110 @@ namespace Opc.Ua.Server
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed locale id (an RFC 5646 language tag) into the casing
|
||||
/// of its culture, and returns whether it names a language without a region.
|
||||
/// Parses a well-formed locale id, a language tag that matches the grammar of
|
||||
/// RFC 5646 section 2.1, into the recommended casing of its subtags, and returns
|
||||
/// whether it names a language without a region.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Grandfathered tags and tags that consist of a private use sequence only are
|
||||
/// rejected. The subtags are not checked against the IANA registry.
|
||||
/// </remarks>
|
||||
private static bool TryParseLocaleId(string locale, out string localeId, out bool isNeutral)
|
||||
{
|
||||
localeId = string.Empty;
|
||||
isNeutral = false;
|
||||
|
||||
string[] subtags = locale.Split('-');
|
||||
|
||||
// language = 2*3ALPHA ["-" extlang] / 4ALPHA / 5*8ALPHA
|
||||
string language = subtags[0];
|
||||
if (language.Length is < 2 or > 8 || !IsAsciiLetters(language))
|
||||
if (!IsLetterSubtag(language, 2, 8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(locale.Length);
|
||||
builder.Append(language.ToLowerInvariant());
|
||||
int index = 1;
|
||||
|
||||
// the script and the region can only follow the language, in this order.
|
||||
int regionIndex = 1;
|
||||
bool hasRegion = false;
|
||||
bool isExtension = false;
|
||||
for (int ii = 1; ii < subtags.Length; ii++)
|
||||
// extlang = 3ALPHA *2("-" 3ALPHA), only after a language of two or three letters.
|
||||
if (language.Length <= 3)
|
||||
{
|
||||
string subtag = subtags[ii];
|
||||
if (subtag.Length is < 1 or > 8 || !IsAsciiLettersOrDigits(subtag))
|
||||
for (int count = 0;
|
||||
count < 3 && index < subtags.Length && IsLetterSubtag(subtags[index], 3, 3);
|
||||
count++)
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToLowerInvariant());
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
// script = 4ALPHA
|
||||
if (index < subtags.Length && IsLetterSubtag(subtags[index], 4, 4))
|
||||
{
|
||||
string script = subtags[index];
|
||||
builder.Append('-')
|
||||
.Append(char.ToUpperInvariant(script[0]))
|
||||
.Append(script[1..].ToLowerInvariant());
|
||||
index++;
|
||||
}
|
||||
|
||||
// region = 2ALPHA / 3DIGIT
|
||||
bool hasRegion = false;
|
||||
if (index < subtags.Length &&
|
||||
(IsLetterSubtag(subtags[index], 2, 2) ||
|
||||
(subtags[index].Length == 3 && IsAsciiDigits(subtags[index]))))
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToUpperInvariant());
|
||||
hasRegion = true;
|
||||
index++;
|
||||
}
|
||||
|
||||
// variant = 5*8alphanum / (DIGIT 3alphanum)
|
||||
while (index < subtags.Length && IsVariant(subtags[index]))
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToLowerInvariant());
|
||||
index++;
|
||||
}
|
||||
|
||||
// extension = singleton 1*("-" (2*8alphanum))
|
||||
while (index < subtags.Length && IsExtensionSingleton(subtags[index]))
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToLowerInvariant());
|
||||
index++;
|
||||
int payloadStart = index;
|
||||
while (index < subtags.Length && IsAlphanumericSubtag(subtags[index], 2, 8))
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToLowerInvariant());
|
||||
index++;
|
||||
}
|
||||
if (index == payloadStart)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('-');
|
||||
// privateuse = "x" 1*("-" (1*8alphanum))
|
||||
if (index < subtags.Length && subtags[index] is "x" or "X")
|
||||
{
|
||||
builder.Append("-x");
|
||||
index++;
|
||||
int payloadStart = index;
|
||||
while (index < subtags.Length && IsAlphanumericSubtag(subtags[index], 1, 8))
|
||||
{
|
||||
builder.Append('-').Append(subtags[index].ToLowerInvariant());
|
||||
index++;
|
||||
}
|
||||
if (index == payloadStart)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// a singleton starts an extension or a private use sequence.
|
||||
isExtension |= subtag.Length == 1;
|
||||
|
||||
if (!isExtension && ii == 1 && subtag.Length == 4 && IsAsciiLetters(subtag))
|
||||
{
|
||||
builder.Append(char.ToUpperInvariant(subtag[0]))
|
||||
.Append(subtag[1..].ToLowerInvariant());
|
||||
regionIndex = 2;
|
||||
}
|
||||
else if (!isExtension &&
|
||||
ii == regionIndex &&
|
||||
((subtag.Length == 2 && IsAsciiLetters(subtag)) ||
|
||||
(subtag.Length == 3 && IsAsciiDigits(subtag))))
|
||||
{
|
||||
builder.Append(subtag.ToUpperInvariant());
|
||||
hasRegion = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(subtag.ToLowerInvariant());
|
||||
}
|
||||
// a subtag that matches no rule, or that follows a later part of the tag.
|
||||
if (index != subtags.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
localeId = builder.ToString();
|
||||
@@ -675,6 +726,43 @@ namespace Opc.Ua.Server
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subtag has a length in the range and contains only
|
||||
/// ASCII letters.
|
||||
/// </summary>
|
||||
private static bool IsLetterSubtag(string subtag, int minLength, int maxLength)
|
||||
{
|
||||
return subtag.Length >= minLength && subtag.Length <= maxLength && IsAsciiLetters(subtag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subtag has a length in the range and contains only
|
||||
/// ASCII letters and digits.
|
||||
/// </summary>
|
||||
private static bool IsAlphanumericSubtag(string subtag, int minLength, int maxLength)
|
||||
{
|
||||
return subtag.Length >= minLength && subtag.Length <= maxLength && IsAsciiLettersOrDigits(subtag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subtag is a variant: five to eight letters or digits, or
|
||||
/// a digit followed by three letters or digits.
|
||||
/// </summary>
|
||||
private static bool IsVariant(string subtag)
|
||||
{
|
||||
return IsAlphanumericSubtag(subtag, 5, 8) ||
|
||||
(subtag.Length == 4 && subtag[0] is >= '0' and <= '9' && IsAsciiLettersOrDigits(subtag));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subtag is a singleton that starts an extension. The
|
||||
/// singleton x starts the private use sequence instead.
|
||||
/// </summary>
|
||||
private static bool IsExtensionSingleton(string subtag)
|
||||
{
|
||||
return subtag.Length == 1 && IsAsciiLettersOrDigits(subtag) && subtag is not ("x" or "X");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the language subtag of a locale id.
|
||||
/// </summary>
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace Opc.Ua.Server.Tests
|
||||
[Parallelizable]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
public class ResourceManagerInvariantGlobalizationTests
|
||||
public sealed class ResourceManagerInvariantGlobalizationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The ways the runtime can create the culture of a locale id.
|
||||
@@ -154,6 +154,8 @@ namespace Opc.Ua.Server.Tests
|
||||
[TestCase(CultureMode.Invariant, "de")]
|
||||
[TestCase(CultureMode.Normal, "zh-Hans")]
|
||||
[TestCase(CultureMode.Invariant, "zh-Hans")]
|
||||
[TestCase(CultureMode.Invariant, "sl-rozaj")]
|
||||
[TestCase(CultureMode.Invariant, "en-a-bbb-x-c")]
|
||||
public void AddRejectsNeutralLocale(CultureMode mode, string locale)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
@@ -177,6 +179,52 @@ namespace Opc.Ua.Server.Tests
|
||||
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In globalization-invariant mode the locale id is parsed, so a tag that does
|
||||
/// not match the RFC 5646 grammar is rejected, even where it starts with a
|
||||
/// language and a region.
|
||||
/// </summary>
|
||||
[TestCase("en-US-x")]
|
||||
[TestCase("en-US-ab")]
|
||||
[TestCase("en-US-ab1")]
|
||||
[TestCase("en-US-u")]
|
||||
[TestCase("en-US-u-a")]
|
||||
[TestCase("en-US-x-abcdefghi")]
|
||||
[TestCase("en-US-abcdefghi")]
|
||||
[TestCase("abcd-efg")]
|
||||
[TestCase("en--US")]
|
||||
[TestCase("en-US-")]
|
||||
public void AddRejectsMalformedLocaleInInvariantMode(string locale)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(CultureMode.Invariant);
|
||||
|
||||
Assert.That(
|
||||
() => resourceManager.Add("greeting", locale, "Hallo"),
|
||||
Throws.TypeOf<CultureNotFoundException>());
|
||||
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In globalization-invariant mode a well-formed locale id with extended
|
||||
/// language, variant, extension or private use subtags is registered in the
|
||||
/// recommended casing of RFC 5646.
|
||||
/// </summary>
|
||||
[TestCase("de-ch-1996", "de-CH-1996")]
|
||||
[TestCase("en-us-posix", "en-US-posix")]
|
||||
[TestCase("zh-cmn-hans-cn", "zh-cmn-Hans-CN")]
|
||||
[TestCase("en-us-u-ca-gregory", "en-US-u-ca-gregory")]
|
||||
[TestCase("en-us-x-twain", "en-US-x-twain")]
|
||||
public void AddAcceptsWellFormedLocaleInInvariantMode(string locale, string expected)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(CultureMode.Invariant);
|
||||
|
||||
resourceManager.Add("greeting", locale, "Hello");
|
||||
|
||||
string[] locales = resourceManager.GetAvailableLocales();
|
||||
Assert.That(locales, Has.Length.EqualTo(1));
|
||||
Assert.That(locales[0], Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
private static ResourceManager CreateResourceManager(CultureMode mode)
|
||||
{
|
||||
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
|
||||
|
||||
Reference in New Issue
Block a user