diff --git a/docs/NativeAoT.md b/docs/NativeAoT.md
index 7dc63fba81..12a00dbfca 100644
--- a/docs/NativeAoT.md
+++ b/docs/NativeAoT.md
@@ -99,11 +99,11 @@ publish reports for your code. The following features are AOT-compatible:
In your own code, avoid unbounded reflection and runtime code generation; see
[Keep code AOT-compatible](#3-keep-code-aot-compatible).
-Do not enable globalization-invariant mode for a server, a common size
-optimization for native executables. With the `InvariantGlobalization` property
-or the `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` environment variable set, the
-server does not start: it reports a `CultureNotFoundException` for the `en-US`
-culture. Clients run in this mode.
+Clients and servers run in globalization-invariant mode, which the
+`InvariantGlobalization` property enables to make native executables smaller.
+In this mode a server still selects translations by locale id and falls back to
+another region of the same language. Arguments in translated texts, such as
+numbers and dates, use the invariant format for every locale.
## Test harness overview
diff --git a/src/Opc.Ua.Server/NodeManager/ResourceManager.cs b/src/Opc.Ua.Server/NodeManager/ResourceManager.cs
index 6b5a6e5834..5d55a6aa9c 100644
--- a/src/Opc.Ua.Server/NodeManager/ResourceManager.cs
+++ b/src/Opc.Ua.Server/NodeManager/ResourceManager.cs
@@ -30,6 +30,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Text;
using System.Threading;
using System.Xml;
#if !NET6_0_OR_GREATER
@@ -47,12 +48,27 @@ namespace Opc.Ua.Server
/// Initializes the resource manager with the server instance that owns it.
///
public ResourceManager(ApplicationConfiguration configuration)
+ : this(configuration, static locale => new CultureInfo(locale))
+ {
+ }
+
+ ///
+ /// Initializes the resource manager with the factory that creates the
+ /// culture of a locale id.
+ ///
+ /// The configuration of the server.
+ /// Creates the culture of a locale id, or throws
+ /// when the runtime cannot create it.
+ internal ResourceManager(
+ ApplicationConfiguration configuration,
+ Func createCulture)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
+ m_createCulture = createCulture ?? throw new ArgumentNullException(nameof(createCulture));
m_translationTables = [];
}
@@ -205,7 +221,7 @@ namespace Opc.Ua.Server
for (int ii = 0; ii < m_translationTables.Count; ii++)
{
- availableLocales[ii] = m_translationTables![ii].Locale!.Name;
+ availableLocales[ii] = m_translationTables[ii].Locale;
}
return availableLocales;
@@ -234,9 +250,9 @@ namespace Opc.Ua.Server
throw new ArgumentNullException(nameof(text));
}
- var culture = new CultureInfo(locale);
+ string localeId = GetLocaleId(locale, out bool isNeutral);
- if (culture.IsNeutralCulture)
+ if (isNeutral)
{
throw new ArgumentException(
"Cannot specify neutral locales for translation tables.",
@@ -245,8 +261,8 @@ namespace Opc.Ua.Server
lock (m_lock)
{
- TranslationTable? table = GetTable(culture.Name);
- table!.Translations[key] = text;
+ TranslationTable table = GetTable(localeId);
+ table.Translations[key] = text;
}
}
@@ -267,9 +283,9 @@ namespace Opc.Ua.Server
throw new ArgumentNullException(nameof(translations));
}
- var culture = new CultureInfo(locale);
+ string localeId = GetLocaleId(locale, out bool isNeutral);
- if (culture.IsNeutralCulture)
+ if (isNeutral)
{
throw new ArgumentException(
"Cannot specify neutral locales for translation tables.",
@@ -278,11 +294,11 @@ namespace Opc.Ua.Server
lock (m_lock)
{
- TranslationTable? table = GetTable(culture.Name);
+ TranslationTable table = GetTable(localeId);
foreach (KeyValuePair translation in translations)
{
- table!.Translations[translation.Key] = translation.Value;
+ table.Translations[translation.Key] = translation.Value;
}
}
}
@@ -415,7 +431,7 @@ namespace Opc.Ua.Server
if (table.Translations
.TryGetValue((info.Key ?? info.Text)!, out string? translation))
{
- translations[table!.Locale!.Name] = translation!;
+ translations[table.Locale] = translation!;
}
}
}
@@ -430,13 +446,13 @@ namespace Opc.Ua.Server
string? translation = FindBestTranslation(
preferredLocales.Slice(i, 1),
(info.Key ?? info.Text)!,
- out CultureInfo culture);
+ out string locale);
if (translation != null)
{
// label the text with the locale of the table it came
// from, which may be another region of the requested
// language.
- translations[culture.Name] = translation;
+ translations[locale] = translation;
}
}
}
@@ -450,15 +466,15 @@ namespace Opc.Ua.Server
else
{
// find the best translation.
- string? translatedText = info.Text;
- CultureInfo culture = CultureInfo.InvariantCulture;
+ string? translatedText;
+ string locale;
lock (m_lock)
{
translatedText = FindBestTranslation(
preferredLocales,
(info.Key ?? info.Text)!,
- out culture);
+ out locale);
// use the default if no translation available.
if (translatedText == null)
@@ -470,23 +486,44 @@ namespace Opc.Ua.Server
}
// construct translated localized text.
- return new LocalizedText(culture.Name, translatedText, info);
+ return new LocalizedText(locale, translatedText, info);
}
}
///
/// Stores the translations for a locale.
///
- private class TranslationTable
+ private sealed class TranslationTable
{
- public CultureInfo? Locale;
- public SortedDictionary Translations = [];
+ ///
+ /// Creates an empty table for a locale id.
+ ///
+ public TranslationTable(string locale)
+ {
+ Locale = locale;
+ Language = GetLanguage(locale);
+ }
+
+ ///
+ /// The locale id in the casing of its culture.
+ ///
+ public string Locale { get; }
+
+ ///
+ /// The language of the locale id, used to fall back to another region.
+ ///
+ public string Language { get; }
+
+ ///
+ /// The translations by key.
+ ///
+ public SortedDictionary Translations { get; } = [];
}
///
/// Finds the translation table for the locale. Creates a new table if it does not exist.
///
- private TranslationTable? GetTable(string locale)
+ private TranslationTable GetTable(string locale)
{
lock (m_lock)
{
@@ -495,20 +532,172 @@ namespace Opc.Ua.Server
{
TranslationTable translationTable = m_translationTables[ii];
- if (translationTable!.Locale!.Name == locale)
+ if (translationTable.Locale == locale)
{
return translationTable;
}
}
// add table.
- var table = new TranslationTable { Locale = new CultureInfo(locale) };
+ var table = new TranslationTable(locale);
m_translationTables.Add(table);
return table;
}
}
+ ///
+ /// Returns the locale id of a translation table in the casing of its culture,
+ /// and whether the locale id names a language without a region.
+ ///
+ ///
+ /// When the runtime cannot create the culture, as in globalization-invariant
+ /// mode, where it creates no culture other than the invariant culture, a
+ /// well-formed locale id is parsed instead.
+ ///
+ /// The locale id is malformed.
+ private string GetLocaleId(string locale, out bool isNeutral)
+ {
+ lock (m_lock)
+ {
+ if (m_localeIds.TryGetValue(locale, out (string LocaleId, bool IsNeutral) cached))
+ {
+ isNeutral = cached.IsNeutral;
+ return cached.LocaleId;
+ }
+
+ string localeId;
+ try
+ {
+ CultureInfo culture = m_createCulture(locale);
+ localeId = culture.Name;
+ isNeutral = culture.IsNeutralCulture;
+ }
+ catch (CultureNotFoundException) when (TryParseLocaleId(locale, out localeId, out isNeutral))
+ {
+ // the runtime cannot create the culture of a well-formed locale id.
+ }
+
+ m_localeIds[locale] = (localeId, isNeutral);
+ return localeId;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private static bool TryParseLocaleId(string locale, out string localeId, out bool isNeutral)
+ {
+ localeId = string.Empty;
+ isNeutral = false;
+
+ string[] subtags = locale.Split('-');
+ string language = subtags[0];
+ if (language.Length is < 2 or > 8 || !IsAsciiLetters(language))
+ {
+ return false;
+ }
+
+ var builder = new StringBuilder(locale.Length);
+ builder.Append(language.ToLowerInvariant());
+
+ // 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++)
+ {
+ string subtag = subtags[ii];
+ if (subtag.Length is < 1 or > 8 || !IsAsciiLettersOrDigits(subtag))
+ {
+ return false;
+ }
+
+ builder.Append('-');
+
+ // 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());
+ }
+ }
+
+ localeId = builder.ToString();
+ isNeutral = !hasRegion;
+ return true;
+ }
+
+ ///
+ /// Returns the language subtag of a locale id.
+ ///
+ private static string GetLanguage(string locale)
+ {
+ int index = locale.IndexOf('-', StringComparison.Ordinal);
+ return index == -1 ? locale : locale[..index];
+ }
+
+ ///
+ /// Returns true if the value contains only ASCII letters.
+ ///
+ private static bool IsAsciiLetters(string value)
+ {
+ foreach (char c in value)
+ {
+ if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z')))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// Returns true if the value contains only ASCII digits.
+ ///
+ private static bool IsAsciiDigits(string value)
+ {
+ foreach (char c in value)
+ {
+ if (c is not (>= '0' and <= '9'))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// Returns true if the value contains only ASCII letters and digits.
+ ///
+ private static bool IsAsciiLettersOrDigits(string value)
+ {
+ foreach (char c in value)
+ {
+ if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9')))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
///
/// Removes the null (unknown) entries from the requested locales.
///
@@ -572,9 +761,9 @@ namespace Opc.Ua.Server
private string? FindBestTranslation(
ArrayOf preferredLocales,
string key,
- out CultureInfo culture)
+ out string locale)
{
- culture = null!;
+ locale = string.Empty;
TranslationTable? match = null;
if (preferredLocales.Count == 0)
@@ -608,24 +797,24 @@ namespace Opc.Ua.Server
// all done if exact match found (locale ids are case-insensitive, RFC 5646).
if (string.Equals(
- translationTable!.Locale!.Name,
+ translationTable.Locale,
preferredLocales[jj],
StringComparison.OrdinalIgnoreCase) &&
translationTable.Translations.TryGetValue(key, out string? exactMatch))
{
- culture = translationTable.Locale;
+ locale = translationTable.Locale;
return exactMatch;
}
// check for matching language but different region.
if (match == null &&
string.Equals(
- translationTable.Locale.TwoLetterISOLanguageName,
+ translationTable.Language,
language,
StringComparison.OrdinalIgnoreCase) &&
translationTable.Translations.TryGetValue(key, out translatedText))
{
- culture = translationTable.Locale;
+ locale = translationTable.Locale;
match = translationTable;
}
}
@@ -705,6 +894,9 @@ namespace Opc.Ua.Server
}
private readonly Lock m_lock = new();
+ private readonly Func m_createCulture;
+ private readonly Dictionary m_localeIds =
+ new(StringComparer.Ordinal);
private readonly List m_translationTables;
private Dictionary? m_statusCodeMapping;
private Dictionary? m_symbolicIdMapping;
diff --git a/tests/Opc.Ua.Server.Tests/ResourceManagerInvariantGlobalizationTests.cs b/tests/Opc.Ua.Server.Tests/ResourceManagerInvariantGlobalizationTests.cs
new file mode 100644
index 0000000000..d414fcb250
--- /dev/null
+++ b/tests/Opc.Ua.Server.Tests/ResourceManagerInvariantGlobalizationTests.cs
@@ -0,0 +1,228 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using System.Xml;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Tests that the resource manager behaves the same when the runtime runs in
+ /// globalization-invariant mode, which is common for Native AOT and container
+ /// deployments.
+ ///
+ ///
+ /// The globalization mode of a process is fixed when it starts, so the tests
+ /// reproduce each mode with the factory that creates the culture of a locale id.
+ ///
+ [TestFixture]
+ [Category("ResourceManager")]
+ [Parallelizable]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ public class ResourceManagerInvariantGlobalizationTests
+ {
+ ///
+ /// The ways the runtime can create the culture of a locale id.
+ ///
+ public enum CultureMode
+ {
+ ///
+ /// The runtime creates every culture with its data.
+ ///
+ Normal,
+
+ ///
+ /// Globalization-invariant mode: the runtime creates only the invariant
+ /// culture and throws for any
+ /// other locale id.
+ ///
+ Invariant,
+
+ ///
+ /// Globalization-invariant mode with predefined cultures turned off: the
+ /// runtime creates every culture, but with the data of the invariant
+ /// culture, for example the language "iv".
+ ///
+ InvariantData
+ }
+
+ [TestCase(CultureMode.Normal)]
+ [TestCase(CultureMode.Invariant)]
+ [TestCase(CultureMode.InvariantData)]
+ public void LoadDefaultTextRegistersEnglishStatusTexts(CultureMode mode)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+
+ resourceManager.LoadDefaultText();
+
+ var input = new ServiceResult(
+ StatusCodes.BadTimeout,
+ new XmlQualifiedName("BadTimeout"),
+ LocalizedText.Null);
+ LocalizedText translated = resourceManager.Translate(["en-US"], input).LocalizedText;
+ string[] locales = resourceManager.GetAvailableLocales();
+ Assert.That(locales, Has.Length.EqualTo(1));
+ Assert.That(locales[0], Is.EqualTo("en-US"));
+ Assert.That(translated.Locale, Is.EqualTo("en-US"));
+ Assert.That(translated.Text, Is.EqualTo("BadTimeout"));
+ }
+
+ [TestCase(CultureMode.Normal, "de-de", "de-DE")]
+ [TestCase(CultureMode.Invariant, "de-de", "de-DE")]
+ [TestCase(CultureMode.Normal, "sr-latn-rs", "sr-Latn-RS")]
+ [TestCase(CultureMode.Invariant, "sr-latn-rs", "sr-Latn-RS")]
+ [TestCase(CultureMode.Normal, "es-419", "es-419")]
+ [TestCase(CultureMode.Invariant, "es-419", "es-419")]
+ public void AddUsesTheCanonicalCasingOfTheLocaleId(
+ CultureMode mode,
+ string locale,
+ string expected)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+
+ resourceManager.Add("greeting", locale, "Hello");
+
+ LocalizedText translated = resourceManager.Translate([expected], "greeting", "Hi");
+ string[] locales = resourceManager.GetAvailableLocales();
+ Assert.That(locales, Has.Length.EqualTo(1));
+ Assert.That(locales[0], Is.EqualTo(expected));
+ Assert.That(translated.Locale, Is.EqualTo(expected));
+ Assert.That(translated.Text, Is.EqualTo("Hello"));
+ }
+
+ [TestCase(CultureMode.Normal)]
+ [TestCase(CultureMode.Invariant)]
+ [TestCase(CultureMode.InvariantData)]
+ public void TranslateFallsBackToAnotherRegionOfTheLanguage(CultureMode mode)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+ resourceManager.Add("greeting", "de-DE", "Hallo");
+
+ LocalizedText translated = resourceManager.Translate(["de-AT"], "greeting", "Hello");
+
+ Assert.That(translated.Locale, Is.EqualTo("de-DE"));
+ Assert.That(translated.Text, Is.EqualTo("Hallo"));
+ }
+
+ [TestCase(CultureMode.Normal)]
+ [TestCase(CultureMode.Invariant)]
+ [TestCase(CultureMode.InvariantData)]
+ public void TranslateMulLabelsRegionFallbackWithItsOwnLocale(CultureMode mode)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+ resourceManager.Add("greeting", "de-DE", "Hallo");
+
+ LocalizedText translated = resourceManager.Translate(
+ ["mul", "de-AT"],
+ new LocalizedText("greeting", "en-US", "Hello"));
+
+ Assert.That(translated.Locale, Is.EqualTo("de-DE"));
+ Assert.That(translated.Text, Is.EqualTo("Hallo"));
+ }
+
+ [TestCase(CultureMode.Normal, "de")]
+ [TestCase(CultureMode.Invariant, "de")]
+ [TestCase(CultureMode.Normal, "zh-Hans")]
+ [TestCase(CultureMode.Invariant, "zh-Hans")]
+ public void AddRejectsNeutralLocale(CultureMode mode, string locale)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+
+ Assert.That(
+ () => resourceManager.Add("greeting", locale, "Hallo"),
+ Throws.ArgumentException);
+ Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
+ }
+
+ [TestCase(CultureMode.Normal)]
+ [TestCase(CultureMode.Invariant)]
+ [TestCase(CultureMode.InvariantData)]
+ public void AddRejectsMalformedLocale(CultureMode mode)
+ {
+ using ResourceManager resourceManager = CreateResourceManager(mode);
+
+ Assert.That(
+ () => resourceManager.Add("greeting", "!!", "Hallo"),
+ Throws.TypeOf());
+ Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
+ }
+
+ private static ResourceManager CreateResourceManager(CultureMode mode)
+ {
+ var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
+ return mode switch
+ {
+ CultureMode.Normal => new ResourceManager(configuration),
+ CultureMode.Invariant => new ResourceManager(configuration, CreateInvariantModeCulture),
+ CultureMode.InvariantData => new ResourceManager(configuration, CreateInvariantDataCulture),
+ _ => throw new ArgumentOutOfRangeException(nameof(mode))
+ };
+ }
+
+ private static CultureInfo CreateInvariantModeCulture(string name)
+ {
+ if (name.Length == 0)
+ {
+ return CultureInfo.InvariantCulture;
+ }
+ throw new CultureNotFoundException(
+ nameof(name),
+ name,
+ "Only the invariant culture is supported in globalization-invariant mode.");
+ }
+
+ private static CultureInfo CreateInvariantDataCulture(string name)
+ {
+ return name.Length == 0 ? CultureInfo.InvariantCulture : new InvariantDataCulture(name);
+ }
+
+ ///
+ /// A culture that carries the data of the invariant culture, as the runtime
+ /// creates it in globalization-invariant mode when predefined cultures are
+ /// turned off.
+ ///
+ private sealed class InvariantDataCulture : CultureInfo
+ {
+ ///
+ /// Creates the culture of a locale id.
+ ///
+ public InvariantDataCulture(string name)
+ : base(name)
+ {
+ }
+
+ ///
+ public override string TwoLetterISOLanguageName => "iv";
+ }
+ }
+}