File: Resources\StringResourceManager.cs
Web Access
Project: Microsoft.DotNet.Cli.CoreUtils.csproj (Microsoft.DotNet.Cli.CoreUtils)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using System.Reflection;
using System.Resources;
using System.Threading;

namespace Microsoft.DotNet.Cli.Resources;

/// <summary>
///  Resolves strings from one binary <c>.resources</c> file, one embedded resource in an
///  already-loaded assembly, one managed assembly parsed as data, or a stream supplied on demand.
/// </summary>
/// <remarks>
///  <para>
///   Resource loading is deferred until the first lookup. The manager retains the indexed source
///   backing and decodes only the requested string on each lookup. Decoded values are not cached
///   by the manager; generated resource accessors cache their property values.
///  </para>
///  <para>
///   The <c>culture</c> argument to <see cref="GetString(string, CultureInfo?)"/> is
///   ignored because this manager represents exactly one resource table.
///  </para>
///  <para>
///   Resource data is expected to be a trusted output of the application's build and deployment
///   pipeline. A null resource always rejects the table. By default any non-string resource rejects
///   the table; <see cref="StringResourceManagerOptions.IgnoreNonStringResources"/> skips non-string
///   entries without retaining their names or values.
///  </para>
///  <para>
///   Files are memory-mapped and runtime manifest resources are read directly from their unmanaged
///   backing. Other stream-factory sources must be readable and seekable. A source backing is opened
///   on demand, retained after a successful load until <see cref="ReleaseAllResources()"/>, and
///   never copied in full merely to parse it. Failed loads throw directly and are not cached.
///  </para>
/// </remarks>
public class StringResourceManager
{
    private string? _baseName;
    private readonly object _source;
    private readonly bool _ownsNeutralResources;
    private readonly StringResourceManagerOptions _options;
    private readonly Lock _loadGate = new();
    private volatile IndexedStringResourceTable? _table;

    /// <summary>
    ///  Initializes a manager for the binary <c>.resources</c> file at <paramref name="resourcesFile"/>.
    /// </summary>
    /// <param name="resourcesFile">The path of a default-format version 2 <c>.resources</c> file.</param>
    /// <exception cref="ArgumentNullException"><paramref name="resourcesFile"/> is <see langword="null"/>.</exception>
    public StringResourceManager(string resourcesFile)
        : this(resourcesFile, StringResourceManagerOptions.None)
    {
    }

    /// <summary>
    ///  Initializes a manager for the binary <c>.resources</c> file at <paramref name="resourcesFile"/>.
    /// </summary>
    /// <param name="resourcesFile">The path of a default-format version 2 <c>.resources</c> file.</param>
    /// <param name="options">The resource loading options.</param>
    /// <exception cref="ArgumentNullException"><paramref name="resourcesFile"/> is <see langword="null"/>.</exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    public StringResourceManager(
        string resourcesFile,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(resourcesFile);
        options.Validate();
        _source = resourcesFile;
        _options = options;
    }

    /// <inheritdoc cref="FromAssemblyFile(string, string, StringResourceManagerOptions)"/>
    public static StringResourceManager FromAssemblyFile(
        string baseName,
        string assemblyFile) => FromAssemblyFile(
            baseName,
            assemblyFile,
            StringResourceManagerOptions.None);

    /// <summary>
    ///  Creates a manager that parses an embedded resource from a managed assembly file without
    ///  loading the assembly.
    /// </summary>
    /// <param name="baseName">The root name of the resource, without the <c>.resources</c> extension.</param>
    /// <param name="assemblyFile">The managed assembly file containing the neutral resource.</param>
    /// <param name="options">The resource loading options.</param>
    /// <returns>A lazy file-backed string resource manager.</returns>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/> or <paramref name="assemblyFile"/> is <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    public static StringResourceManager FromAssemblyFile(
        string baseName,
        string assemblyFile,
        StringResourceManagerOptions options) => FromAssemblyFile(
            baseName,
            assemblyFile,
            MappedMemoryManager.CreateFromFile,
            options);

    /// <inheritdoc cref="ValidateAssemblyFile(string, string, Assembly, StringResourceManagerOptions)"/>
    public static void ValidateAssemblyFile(
        string baseName,
        string assemblyFile,
        Assembly expectedAssembly) => ValidateAssemblyFile(
            baseName,
            assemblyFile,
            expectedAssembly,
            StringResourceManagerOptions.None);

    /// <inheritdoc cref="ValidateAssemblyFile(string, string, Assembly, string, StringResourceManagerOptions)"/>
    public static void ValidateAssemblyFile(
        string baseName,
        string assemblyFile,
        Assembly generatedOwnerAssembly,
        string externalOwnerSimpleName) => ValidateAssemblyFile(
            baseName,
            assemblyFile,
            generatedOwnerAssembly,
            externalOwnerSimpleName,
            StringResourceManagerOptions.None);

    /// <summary>
    ///  Strictly validates an external managed owner assembly, its identity, and its neutral string
    ///  resource table without loading the assembly.
    /// </summary>
    /// <param name="baseName">The root name of the resource, without the <c>.resources</c> extension.</param>
    /// <param name="assemblyFile">The managed owner assembly file.</param>
    /// <param name="expectedAssembly">The generated accessor's expected owner assembly.</param>
    /// <param name="options">The resource loading options.</param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/>, <paramref name="assemblyFile"/>, or
    ///  <paramref name="expectedAssembly"/> is <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    /// <exception cref="FileLoadException">The external owner identity does not match.</exception>
    /// <exception cref="MissingManifestResourceException">The neutral resource table is absent.</exception>
    /// <exception cref="BadImageFormatException">The assembly or resource table is malformed.</exception>
    /// <exception cref="NotSupportedException">The resource table format or contents are unsupported.</exception>
    /// <exception cref="IOException">The assembly file cannot be read.</exception>
    public static void ValidateAssemblyFile(
        string baseName,
        string assemblyFile,
        Assembly expectedAssembly,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(expectedAssembly);
        string expectedSimpleName = expectedAssembly.GetName().Name
            ?? throw new BadImageFormatException("The expected assembly does not have a simple name.");

        ValidateAssemblyFile(
            baseName,
            assemblyFile,
            expectedAssembly,
            expectedSimpleName,
            options);
    }

    /// <summary>
    ///  Strictly validates an external managed owner assembly using an aliased simple name and the
    ///  generated owner's remaining identity.
    /// </summary>
    /// <param name="baseName">The root name of the resource, without the <c>.resources</c> extension.</param>
    /// <param name="assemblyFile">The external managed owner assembly file.</param>
    /// <param name="generatedOwnerAssembly">The generated accessor's owner assembly.</param>
    /// <param name="externalOwnerSimpleName">The expected external owner simple name.</param>
    /// <param name="options">The resource loading options.</param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/>, <paramref name="assemblyFile"/>,
    ///  <paramref name="generatedOwnerAssembly"/>, or <paramref name="externalOwnerSimpleName"/> is
    ///  <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentException"><paramref name="externalOwnerSimpleName"/> is empty.</exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    /// <exception cref="FileLoadException">The external owner identity does not match.</exception>
    /// <exception cref="MissingManifestResourceException">The neutral resource table is absent.</exception>
    /// <exception cref="BadImageFormatException">The assembly or resource table is malformed.</exception>
    /// <exception cref="NotSupportedException">The resource table format or contents are unsupported.</exception>
    /// <exception cref="IOException">The assembly file cannot be read.</exception>
    public static void ValidateAssemblyFile(
        string baseName,
        string assemblyFile,
        Assembly generatedOwnerAssembly,
        string externalOwnerSimpleName,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(baseName);
        ArgumentNullException.ThrowIfNull(assemblyFile);
        ArgumentNullException.ThrowIfNull(generatedOwnerAssembly);
        ArgumentNullException.ThrowIfNull(externalOwnerSimpleName);
        if (externalOwnerSimpleName.Length == 0)
        {
            throw new ArgumentException("The external owner simple name cannot be empty.", nameof(externalOwnerSimpleName));
        }

        options.Validate();

        string fullPath = Path.GetFullPath(assemblyFile);
        MappedMemoryManager assembly = MappedMemoryManager.CreateFromFile(fullPath);
        (
            IndexedStringResourceTable? table,
            SatelliteStringResourceSourceMetadata metadata
        ) = StringResourceTableLoader.LoadIndexedTableAndMetadataFromAssembly(
            assembly.Memory,
            $"{baseName}.resources",
            options,
            assembly);

        try
        {
            ManagedAssemblyIdentity actualIdentity = metadata.ResourceAssemblyIdentity
                ?? throw new BadImageFormatException("The resource assembly identity is missing.");

            ManagedAssemblyIdentity expectedIdentity = ManagedAssemblyIdentity.FromAssemblyName(
                generatedOwnerAssembly.GetName())
                .WithName(externalOwnerSimpleName);

            actualIdentity.ValidateMatches(expectedIdentity);
            if (table is null)
            {
                throw new MissingManifestResourceException(
                    $"The assembly '{fullPath}' does not contain '{baseName}.resources'.");
            }

            _ = StringResourceTableLoader.LoadStringTableFromAssembly(
                assembly.Memory,
                $"{baseName}.resources",
                options)
                ?? throw new MissingManifestResourceException(
                    $"The assembly '{fullPath}' does not contain '{baseName}.resources'.");
        }
        finally
        {
            table?.Dispose();
        }
    }

    /// <summary>
    ///  Creates a managed assembly file manager that opens through <paramref name="openFile"/>.
    /// </summary>
    /// <param name="baseName">The root name of the resource table.</param>
    /// <param name="assemblyFile">The managed assembly file.</param>
    /// <param name="openFile">The function that opens the file as mapped memory.</param>
    /// <param name="options">The resource loading options.</param>
    /// <returns>A lazy file-backed string resource manager.</returns>
    internal static StringResourceManager FromAssemblyFile(
        string baseName,
        string assemblyFile,
        Func<string, MappedMemoryManager> openFile,
        StringResourceManagerOptions options = StringResourceManagerOptions.None)
    {
        ArgumentNullException.ThrowIfNull(baseName);
        options.Validate();
        return new(
            baseName,
            new ManagedAssemblyStringResourceSource(assemblyFile, openFile),
            options);
    }

    /// <summary>
    ///  Initializes a manager for the named resource table in an already-loaded assembly.
    /// </summary>
    /// <param name="baseName">The root name of the resource, without the <c>.resources</c> extension.</param>
    /// <param name="assembly">The loaded assembly containing the resource.</param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/> or <paramref name="assembly"/> is <see langword="null"/>.
    /// </exception>
    public StringResourceManager(string baseName, Assembly assembly)
        : this(baseName, assembly, StringResourceManagerOptions.None)
    {
    }

    /// <summary>
    ///  Initializes a manager for the named resource table in an already-loaded assembly.
    /// </summary>
    /// <param name="baseName">The root name of the resource, without the <c>.resources</c> extension.</param>
    /// <param name="assembly">The loaded assembly containing the resource.</param>
    /// <param name="options">The resource loading options.</param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/> or <paramref name="assembly"/> is <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    public StringResourceManager(
        string baseName,
        Assembly assembly,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(baseName);
        ArgumentNullException.ThrowIfNull(assembly);
        options.Validate();
        _baseName = baseName;
        _source = assembly;
        _options = options;
    }

    /// <summary>
    ///  Initializes a manager for a binary <c>.resources</c> stream supplied on demand.
    /// </summary>
    /// <param name="baseName">The base name of the resource table.</param>
    /// <param name="streamFactory">
    ///  A factory that returns the resource stream positioned at the first byte to parse.
    /// </param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/> or <paramref name="streamFactory"/> is <see langword="null"/>.
    /// </exception>
    public StringResourceManager(string baseName, Func<Stream> streamFactory)
        : this(baseName, streamFactory, StringResourceManagerOptions.None)
    {
    }

    /// <summary>
    ///  Initializes a manager for a binary <c>.resources</c> stream supplied on demand.
    /// </summary>
    /// <param name="baseName">The base name of the resource table.</param>
    /// <param name="streamFactory">
    ///  A factory that returns the resource stream positioned at the first byte to parse.
    /// </param>
    /// <param name="options">The resource loading options.</param>
    /// <exception cref="ArgumentNullException">
    ///  <paramref name="baseName"/> or <paramref name="streamFactory"/> is <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException"><paramref name="options"/> contains an unknown flag.</exception>
    public StringResourceManager(
        string baseName,
        Func<Stream> streamFactory,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(baseName);
        ArgumentNullException.ThrowIfNull(streamFactory);
        options.Validate();
        _baseName = baseName;
        _source = streamFactory;
        _options = options;
    }

    /// <summary>
    ///  Initializes a manager that delegates neutral lookups to <paramref name="neutralResources"/>.
    /// </summary>
    /// <param name="baseName">The base name of the resource table.</param>
    /// <param name="neutralResources">The manager that supplies neutral strings.</param>
    /// <param name="ownsNeutralResources">
    ///  Whether <see cref="ReleaseAllResources()"/> releases <paramref name="neutralResources"/>.
    /// </param>
    /// <param name="options">The resource loading options.</param>
    protected StringResourceManager(
        string baseName,
        StringResourceManager neutralResources,
        bool ownsNeutralResources,
        StringResourceManagerOptions options)
    {
        ArgumentNullException.ThrowIfNull(baseName);
        ArgumentNullException.ThrowIfNull(neutralResources);
        options.Validate();
        _baseName = baseName;
        _source = neutralResources;
        _ownsNeutralResources = ownsNeutralResources;
        _options = options;
    }

    private StringResourceManager(
        string baseName,
        ManagedAssemblyStringResourceSource source,
        StringResourceManagerOptions options)
    {
        _baseName = baseName;
        _source = source;
        _options = options;
    }

    /// <summary>
    ///  The base name of the resource table.
    /// </summary>
    public virtual string BaseName => _baseName ??= Path.GetFileNameWithoutExtension((string)_source);

    /// <summary>
    ///  The resource loading options.
    /// </summary>
    public StringResourceManagerOptions Options => _options;

    /// <summary>
    ///  Gets whether resource names are matched without regard to case. Assigning a value is not
    ///  supported.
    /// </summary>
    /// <exception cref="NotSupportedException">The property is assigned a value.</exception>
    public bool IgnoreCase
    {
        get => false;
        set => throw new NotSupportedException("Case-insensitive resource lookup is not supported.");
    }

    /// <summary>
    ///  Gets the string with the given name.
    /// </summary>
    /// <param name="name">The resource name.</param>
    /// <returns>The string value, or <see langword="null"/> when the resource does not exist.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
    /// <exception cref="ArgumentException">The configured source is not a valid resource file.</exception>
    /// <exception cref="BadImageFormatException">
    ///  The configured source contains malformed data or a null resource.
    /// </exception>
    /// <exception cref="NotSupportedException">
    ///  The source format is unsupported, or the table contains a non-string resource and
    ///  <see cref="StringResourceManagerOptions.IgnoreNonStringResources"/> is not set.
    /// </exception>
    /// <exception cref="IOException">The configured file or stream cannot be read.</exception>
    /// <exception cref="ObjectDisposedException">The resource backing has been disposed.</exception>
    public virtual string? GetString(string name) => GetString(name, culture: null);

    /// <summary>
    ///  Gets the string with the given name. The culture is ignored because this manager represents
    ///  exactly one table.
    /// </summary>
    /// <param name="name">The resource name.</param>
    /// <param name="culture">Ignored.</param>
    /// <returns>The string value, or <see langword="null"/> when the resource does not exist.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
    /// <exception cref="ArgumentException">The configured source is not a valid resource file.</exception>
    /// <exception cref="BadImageFormatException">
    ///  The configured source contains malformed data or a null resource.
    /// </exception>
    /// <exception cref="NotSupportedException">
    ///  The source format is unsupported, or the table contains a non-string resource and
    ///  <see cref="StringResourceManagerOptions.IgnoreNonStringResources"/> is not set.
    /// </exception>
    /// <exception cref="IOException">The configured file or stream cannot be read.</exception>
    /// <exception cref="ObjectDisposedException">The resource backing has been disposed.</exception>
    public virtual string? GetString(string name, CultureInfo? culture)
    {
        ArgumentNullException.ThrowIfNull(name);
        if (_source is StringResourceManager neutralResources)
        {
            return neutralResources.GetString(name, culture);
        }

        IndexedStringResourceTable table = _table ?? GetOrLoadTable();
        return table.Lookup(name);
    }

    /// <summary>
    ///  Releases the cached string table. The next lookup reloads it from the configured source.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   Callers must ensure no resource lookups are in progress when releasing resources.
    ///  </para>
    /// </remarks>
    public virtual void ReleaseAllResources()
    {
        if (_source is StringResourceManager neutralResources)
        {
            if (_ownsNeutralResources)
            {
                neutralResources.ReleaseAllResources();
            }

            return;
        }

        lock (_loadGate)
        {
            IndexedStringResourceTable? table = _table;
            _table = null;
            table?.Dispose();
        }
    }

    /// <summary>
    ///  The source assembly, if this manager is assembly-backed.
    /// </summary>
    internal Assembly? SourceAssembly => _source is StringResourceManager neutralResources
        ? neutralResources.SourceAssembly
        : _source as Assembly;

    /// <summary>
    ///  The managed assembly file source, if this manager parses an assembly as data.
    /// </summary>
    internal ManagedAssemblyStringResourceSource? ManagedAssemblySource =>
        _source is StringResourceManager neutralResources
            ? neutralResources.ManagedAssemblySource
            : _source as ManagedAssemblyStringResourceSource;

    /// <summary>
    ///  Gets metadata for this manager's managed assembly file after ensuring its table is loaded.
    /// </summary>
    /// <param name="source">The expected managed assembly file source.</param>
    /// <returns>The parsed resource assembly metadata.</returns>
    internal SatelliteStringResourceSourceMetadata GetManagedAssemblySourceMetadata(
        ManagedAssemblyStringResourceSource source)
    {
        if (_source is StringResourceManager neutralResources)
        {
            return neutralResources.GetManagedAssemblySourceMetadata(source);
        }

        if (!ReferenceEquals(_source, source))
        {
            throw new InvalidOperationException("The managed assembly source does not belong to this manager.");
        }

        _ = GetOrLoadTable();
        return source.Metadata;
    }

    private IndexedStringResourceTable GetOrLoadTable()
    {
        lock (_loadGate)
        {
            return _table ??= LoadTable();
        }
    }

    private IndexedStringResourceTable LoadTable()
    {
        if (_source is Assembly assembly)
        {
            string resourceName = $"{BaseName}.resources";

            return StringResourceTableLoader.LoadIndexedTableFromAssembly(
                assembly,
                resourceName,
                _options)
                ?? throw new MissingManifestResourceException(
                    $"The assembly '{assembly.FullName}' does not contain '{resourceName}'.");
        }

        if (_source is string resourcesFile)
        {
            return IndexedStringResourceTable.Create(
                RawResourceReader.CreateFromFile(resourcesFile),
                _options);
        }

        if (_source is ManagedAssemblyStringResourceSource managedAssembly)
        {
            return managedAssembly.LoadTable($"{BaseName}.resources", _options);
        }

        Func<Stream> streamFactory = (Func<Stream>)_source;
        Stream stream = streamFactory()
            ?? throw new InvalidOperationException("The resource stream factory returned null.");

        return StringResourceTableLoader.LoadIndexedTableFromResourcesStream(stream, _options);
    }

}