File: src\Shared\IntegrationPackageProbeManifest.cs
Web Access
Project: src\src\Aspire.Managed\Aspire.Managed.csproj (aspire-managed)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Represents the NuGet package-backed integration asset probe manifest emitted by the CLI.
/// </summary>
internal sealed class IntegrationPackageProbeManifest
{
    public const string FileName = "integration-package-probe-manifest.json";
 
    public static IntegrationPackageProbeManifest Empty { get; } = Create([], []);
 
    private readonly IReadOnlyDictionary<string, string> _managedAssemblyPaths;
    private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _nativeLibraryPaths;
 
    private IntegrationPackageProbeManifest(
        IReadOnlyList<IntegrationPackageManagedAssembly> managedAssemblies,
        IReadOnlyList<IntegrationPackageNativeLibrary> nativeLibraries)
    {
        ManagedAssemblies = managedAssemblies;
        NativeLibraries = nativeLibraries;
        _managedAssemblyPaths = CreateManagedAssemblyLookup(managedAssemblies);
        _nativeLibraryPaths = CreateNativeLibraryLookup(nativeLibraries);
        RuntimeAssemblyNames = GetRuntimeAssemblyNames(managedAssemblies);
    }
 
    public IReadOnlyList<IntegrationPackageManagedAssembly> ManagedAssemblies { get; }
 
    public IReadOnlyList<IntegrationPackageNativeLibrary> NativeLibraries { get; }
 
    public IReadOnlyList<string> RuntimeAssemblyNames { get; }
 
    public static IntegrationPackageProbeManifest Create(
        IEnumerable<IntegrationPackageManagedAssembly> managedAssemblies,
        IEnumerable<IntegrationPackageNativeLibrary> nativeLibraries)
    {
        ArgumentNullException.ThrowIfNull(managedAssemblies);
        ArgumentNullException.ThrowIfNull(nativeLibraries);
 
        var managedLookup = new Dictionary<string, IntegrationPackageManagedAssembly>(StringComparer.OrdinalIgnoreCase);
        foreach (var assembly in managedAssemblies)
        {
            var normalizedAssembly = new IntegrationPackageManagedAssembly
            {
                Name = NormalizeRequiredValue(assembly.Name, "managedAssemblies[].name"),
                Culture = NormalizeCulture(assembly.Culture),
                Path = NormalizeRequiredValue(assembly.Path, "managedAssemblies[].path")
            };
 
            managedLookup.TryAdd(
                CreateManagedAssemblyLookupKey(normalizedAssembly.Name, normalizedAssembly.Culture),
                normalizedAssembly);
        }
 
        var nativeLookup = new Dictionary<string, IntegrationPackageNativeLibrary>(StringComparer.OrdinalIgnoreCase);
        foreach (var nativeLibrary in nativeLibraries)
        {
            var normalizedNativeLibrary = new IntegrationPackageNativeLibrary
            {
                FileName = NormalizeRequiredValue(nativeLibrary.FileName, "nativeLibraries[].fileName"),
                Path = NormalizeRequiredValue(nativeLibrary.Path, "nativeLibraries[].path")
            };
 
            nativeLookup.TryAdd(
                $"{normalizedNativeLibrary.FileName}|{normalizedNativeLibrary.Path}",
                normalizedNativeLibrary);
        }
 
        var managedAssemblyList = managedLookup.Values.ToList();
        managedAssemblyList.Sort(static (left, right) =>
        {
            var nameComparison = StringComparer.OrdinalIgnoreCase.Compare(left.Name, right.Name);
            if (nameComparison != 0)
            {
                return nameComparison;
            }
 
            var cultureComparison = StringComparer.OrdinalIgnoreCase.Compare(left.Culture, right.Culture);
            return cultureComparison != 0
                ? cultureComparison
                : StringComparer.Ordinal.Compare(left.Path, right.Path);
        });
 
        var nativeLibraryList = nativeLookup.Values.ToList();
        nativeLibraryList.Sort(static (left, right) =>
        {
            var nameComparison = StringComparer.OrdinalIgnoreCase.Compare(left.FileName, right.FileName);
            return nameComparison != 0
                ? nameComparison
                : StringComparer.Ordinal.Compare(left.Path, right.Path);
        });
 
        return new IntegrationPackageProbeManifest(managedAssemblyList, nativeLibraryList);
    }
 
    public static IntegrationPackageProbeManifest Load(string? manifestPath)
    {
        if (string.IsNullOrWhiteSpace(manifestPath))
        {
            return Empty;
        }
 
        var normalizedManifestPath = NormalizeManifestPath(manifestPath);
        if (!File.Exists(normalizedManifestPath))
        {
            throw new InvalidOperationException($"Integration package probe manifest '{normalizedManifestPath}' does not exist.");
        }
 
        using var stream = File.OpenRead(normalizedManifestPath);
        using var document = JsonDocument.Parse(stream);
        var managedAssemblies = ReadManagedAssemblies(document.RootElement);
        var nativeLibraries = ReadNativeLibraries(document.RootElement);
 
        return Create(managedAssemblies, nativeLibraries);
    }
 
    public static Task WriteAsync(
        string path,
        IntegrationPackageProbeManifest manifest,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrEmpty(path);
        ArgumentNullException.ThrowIfNull(manifest);
 
        using var stream = new MemoryStream();
        using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
        {
            writer.WriteStartObject();
            writer.WritePropertyName("managedAssemblies");
            writer.WriteStartArray();
            foreach (var managedAssembly in manifest.ManagedAssemblies)
            {
                writer.WriteStartObject();
                writer.WriteString("name", managedAssembly.Name);
                if (managedAssembly.Culture is not null)
                {
                    writer.WriteString("culture", managedAssembly.Culture);
                }
                writer.WriteString("path", managedAssembly.Path);
                writer.WriteEndObject();
            }
            writer.WriteEndArray();
 
            writer.WritePropertyName("nativeLibraries");
            writer.WriteStartArray();
            foreach (var nativeLibrary in manifest.NativeLibraries)
            {
                writer.WriteStartObject();
                writer.WriteString("fileName", nativeLibrary.FileName);
                writer.WriteString("path", nativeLibrary.Path);
                writer.WriteEndObject();
            }
            writer.WriteEndArray();
            writer.WriteEndObject();
        }
 
        return File.WriteAllBytesAsync(path, stream.ToArray(), cancellationToken);
    }
 
    public string? TryGetManagedAssemblyPath(AssemblyName assemblyName)
    {
        if (assemblyName.Name is null)
        {
            return null;
        }
 
        return _managedAssemblyPaths.TryGetValue(
            CreateManagedAssemblyLookupKey(assemblyName.Name, NormalizeCulture(assemblyName.CultureName)),
            out var path)
            ? path
            : null;
    }
 
    public IReadOnlyList<string> GetNativeLibraryPaths(string unmanagedDllName)
    {
        var candidatePaths = new List<string>();
        var seenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var key in GetNativeLibraryLookupKeys(unmanagedDllName))
        {
            if (!_nativeLibraryPaths.TryGetValue(key, out var paths))
            {
                continue;
            }
 
            foreach (var path in paths.OrderBy(GetNativePathPriority).ThenBy(static path => path, StringComparer.Ordinal))
            {
                if (seenPaths.Add(path))
                {
                    candidatePaths.Add(path);
                }
            }
        }
 
        return candidatePaths;
    }
 
    internal static IReadOnlyList<string> GetNativeLibraryLookupKeys(string libraryName)
    {
        ArgumentException.ThrowIfNullOrEmpty(libraryName);
 
        var keys = new List<string>();
        var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var normalizedFileName = Path.GetFileName(libraryName);
        var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(normalizedFileName);
 
        AddKey(normalizedFileName);
        AddKey(fileNameWithoutExtension);
 
        if (!normalizedFileName.Contains('.'))
        {
            AddKey($"{normalizedFileName}.dll");
            AddKey($"lib{normalizedFileName}.so");
            AddKey($"lib{normalizedFileName}.dylib");
        }
 
        if (fileNameWithoutExtension.StartsWith("lib", StringComparison.OrdinalIgnoreCase) &&
            fileNameWithoutExtension.Length > 3)
        {
            var withoutLibPrefix = fileNameWithoutExtension[3..];
            AddKey(withoutLibPrefix);
 
            if (!normalizedFileName.Contains('.'))
            {
                AddKey($"{withoutLibPrefix}.dll");
                AddKey($"lib{withoutLibPrefix}.so");
                AddKey($"lib{withoutLibPrefix}.dylib");
            }
        }
        else
        {
            AddKey($"lib{fileNameWithoutExtension}");
 
            if (!normalizedFileName.Contains('.'))
            {
                AddKey($"lib{fileNameWithoutExtension}.so");
                AddKey($"lib{fileNameWithoutExtension}.dylib");
            }
        }
 
        return keys;
 
        void AddKey(string key)
        {
            if (!string.IsNullOrWhiteSpace(key) && seenKeys.Add(key))
            {
                keys.Add(key);
            }
        }
    }
 
    private static IReadOnlyDictionary<string, string> CreateManagedAssemblyLookup(IEnumerable<IntegrationPackageManagedAssembly> managedAssemblies)
    {
        var lookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var assembly in managedAssemblies)
        {
            lookup[CreateManagedAssemblyLookupKey(assembly.Name, assembly.Culture)] = assembly.Path;
        }
 
        return lookup;
    }
 
    private static IReadOnlyDictionary<string, IReadOnlyList<string>> CreateNativeLibraryLookup(IEnumerable<IntegrationPackageNativeLibrary> nativeLibraries)
    {
        var lookup = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var nativeLibrary in nativeLibraries)
        {
            foreach (var key in GetNativeLibraryLookupKeys(nativeLibrary.FileName))
            {
                if (!lookup.TryGetValue(key, out var paths))
                {
                    paths = [];
                    lookup[key] = paths;
                }
 
                if (!paths.Contains(nativeLibrary.Path, StringComparer.OrdinalIgnoreCase))
                {
                    paths.Add(nativeLibrary.Path);
                }
            }
        }
 
        return lookup.ToDictionary(
            static pair => pair.Key,
            static pair => (IReadOnlyList<string>)pair.Value,
            StringComparer.OrdinalIgnoreCase);
    }
 
    private static IReadOnlyList<string> GetRuntimeAssemblyNames(IEnumerable<IntegrationPackageManagedAssembly> managedAssemblies)
    {
        var assemblyNames = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var assembly in managedAssemblies)
        {
            if (assembly.Culture is not null)
            {
                continue;
            }
 
            assemblyNames.Add(assembly.Name);
        }
 
        return assemblyNames.ToList();
    }
 
    private static string CreateManagedAssemblyLookupKey(string assemblyName, string? culture)
    {
        return $"{culture ?? "<neutral>"}|{assemblyName}";
    }
 
    private static int GetNativePathPriority(string path)
    {
        var normalizedPath = path.Replace('\\', '/');
        var ridMarker = $"/runtimes/{RuntimeInformation.RuntimeIdentifier}/";
        return normalizedPath.Contains(ridMarker, StringComparison.OrdinalIgnoreCase) ? 0 : 1;
    }
 
    private static string NormalizeAndValidatePath(string? path, string propertyName)
    {
        var normalizedPath = NormalizePath(
            NormalizeRequiredValue(path, propertyName),
            $"Integration package probe manifest entry path '{propertyName}' is invalid.");
        if (!File.Exists(normalizedPath))
        {
            throw new InvalidOperationException($"Integration package probe manifest path '{normalizedPath}' does not exist.");
        }
 
        return normalizedPath;
    }
 
    private static string NormalizeManifestPath(string manifestPath)
    {
        return NormalizePath(manifestPath, "Integration package probe manifest path is invalid.");
    }
 
    private static string NormalizePath(string path, string invalidPathMessage)
    {
        try
        {
            return Path.GetFullPath(path);
        }
        catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
        {
            throw new InvalidOperationException(invalidPathMessage, ex);
        }
    }
 
    private static string? NormalizeCulture(string? culture)
    {
        return string.IsNullOrWhiteSpace(culture) || string.Equals(culture, "neutral", StringComparison.OrdinalIgnoreCase)
            ? null
            : culture.Trim();
    }
 
    private static string NormalizeRequiredValue(string? value, string propertyName)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new InvalidOperationException($"Integration package probe manifest entry is missing required property '{propertyName}'.");
        }
 
        return value.Trim();
    }
 
    private static IReadOnlyList<IntegrationPackageManagedAssembly> ReadManagedAssemblies(JsonElement rootElement)
    {
        if (!rootElement.TryGetProperty("managedAssemblies", out var managedAssembliesElement) ||
            managedAssembliesElement.ValueKind != JsonValueKind.Array)
        {
            return [];
        }
 
        var managedAssemblies = new List<IntegrationPackageManagedAssembly>();
        foreach (var element in managedAssembliesElement.EnumerateArray())
        {
            managedAssemblies.Add(new IntegrationPackageManagedAssembly
            {
                Name = NormalizeRequiredValue(ReadStringProperty(element, "name"), "managedAssemblies[].name"),
                Culture = NormalizeCulture(ReadStringProperty(element, "culture", required: false)),
                Path = NormalizeAndValidatePath(ReadStringProperty(element, "path"), "managedAssemblies[].path")
            });
        }
 
        return managedAssemblies;
    }
 
    private static IReadOnlyList<IntegrationPackageNativeLibrary> ReadNativeLibraries(JsonElement rootElement)
    {
        if (!rootElement.TryGetProperty("nativeLibraries", out var nativeLibrariesElement) ||
            nativeLibrariesElement.ValueKind != JsonValueKind.Array)
        {
            return [];
        }
 
        var nativeLibraries = new List<IntegrationPackageNativeLibrary>();
        foreach (var element in nativeLibrariesElement.EnumerateArray())
        {
            nativeLibraries.Add(new IntegrationPackageNativeLibrary
            {
                FileName = NormalizeRequiredValue(ReadStringProperty(element, "fileName"), "nativeLibraries[].fileName"),
                Path = NormalizeAndValidatePath(ReadStringProperty(element, "path"), "nativeLibraries[].path")
            });
        }
 
        return nativeLibraries;
    }
 
    private static string? ReadStringProperty(JsonElement element, string propertyName, bool required = true)
    {
        if (!element.TryGetProperty(propertyName, out var propertyElement) ||
            propertyElement.ValueKind == JsonValueKind.Null)
        {
            if (required)
            {
                throw new InvalidOperationException($"Integration package probe manifest entry is missing required property '{propertyName}'.");
            }
 
            return null;
        }
 
        if (propertyElement.ValueKind != JsonValueKind.String)
        {
            throw new InvalidOperationException($"Integration package probe manifest property '{propertyName}' must be a string.");
        }
 
        return propertyElement.GetString();
    }
}
 
/// <summary>
/// Represents a package-backed managed assembly that should be loaded from the package cache.
/// </summary>
internal sealed class IntegrationPackageManagedAssembly
{
    public required string Name { get; init; }
 
    public string? Culture { get; init; }
 
    public required string Path { get; init; }
}
 
/// <summary>
/// Represents a package-backed native library that should be loaded from the package cache.
/// </summary>
internal sealed class IntegrationPackageNativeLibrary
{
    public required string FileName { get; init; }
 
    public required string Path { get; init; }
}