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"; + } + } +}