| File: Acquisition\IdentityChannelReader.cs | Web Access |
| Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; using System.Reflection; using Aspire.Cli.Packaging; namespace Aspire.Cli.Acquisition; /// <summary> /// Reads the acquisition channel that the running CLI assembly was built for. /// </summary> /// <remarks> /// The channel is baked into the CLI assembly at build time as /// <c>[AssemblyMetadata("AspireCliChannel", "<value>")]</c>. The value is /// one of <c>stable</c>, <c>staging</c>, <c>daily</c>, <c>local</c> (the default /// for developer builds with no <c>/p:AspireCliChannel=</c> override), or the /// per-PR hive label <c>pr-<N></c> for PR builds (where <c><N></c> /// is the GitHub pull-request number, baked verbatim by CI; see /// <c>.github/workflows/build-cli-native-archives.yml</c> and /// <c>eng/pipelines/templates/build_sign_native.yml</c>). /// </remarks> internal interface IIdentityChannelReader { /// <summary> /// Attempts to read the channel baked into the CLI assembly. /// </summary> /// <param name="channel">When this method returns <see langword="true"/>, contains the resolved channel value.</param> /// <param name="error">When this method returns <see langword="false"/>, contains the error message describing the failure.</param> /// <returns><see langword="true"/> if the channel was successfully read; otherwise, <see langword="false"/>.</returns> bool TryReadChannel([NotNullWhen(true)] out string? channel, [NotNullWhen(false)] out string? error); } /// <summary> /// Default <see cref="IIdentityChannelReader"/> backed by an <see cref="Assembly"/>'s /// <see cref="AssemblyMetadataAttribute"/> values. /// </summary> /// <remarks> /// AOT-safe: enumerating <see cref="AssemblyMetadataAttribute"/> via /// <see cref="CustomAttributeExtensions"/> over a sealed, build-time-known /// attribute type is preserved by the trimmer / native compiler. No /// reflection-based JSON, no dynamic type loading. /// </remarks> internal sealed class IdentityChannelReader : IIdentityChannelReader { private const string ChannelMetadataKey = "AspireCliChannel"; private const string PrChannelPrefix = "pr-"; private readonly Assembly _assembly; private readonly Lazy<(bool Success, string? Channel, string? Error)> _cached; /// <summary> /// Initializes a new instance that reads metadata from the supplied /// <paramref name="assembly"/>. The assembly is required: defaulting to /// <see cref="Assembly.GetEntryAssembly()"/> is intentionally NOT supported /// because under <c>Microsoft.DotNet.RemoteExecutor</c> and other test /// hosts the entry assembly resolves to the host (not the CLI), which /// silently breaks <c>[AssemblyMetadata]</c> reads. Callers must pin the /// read explicitly: production passes <c>typeof(Program).Assembly</c>; /// tests pass a fake assembly carrying the desired metadata. /// </summary> /// <param name="assembly"> /// The assembly to read <c>AspireCliChannel</c> metadata from. Must not be /// <see langword="null"/>. /// </param> /// <exception cref="ArgumentNullException"> /// Thrown when <paramref name="assembly"/> is <see langword="null"/>. /// </exception> public IdentityChannelReader(Assembly assembly) { ArgumentNullException.ThrowIfNull(assembly); _assembly = assembly; _cached = new Lazy<(bool, string?, string?)>(() => { var success = TryResolveChannel(_assembly, out var ch, out var err); return (success, ch, err); }, LazyThreadSafetyMode.ExecutionAndPublication); } /// <inheritdoc /> public bool TryReadChannel([NotNullWhen(true)] out string? channel, [NotNullWhen(false)] out string? error) { var result = _cached.Value; channel = result.Channel; error = result.Error; return result.Success; } /// <summary> /// Attempts to resolve the channel from the specified assembly's metadata. /// </summary> /// <param name="assembly">The assembly to read <c>AspireCliChannel</c> metadata from.</param> /// <param name="channel">When this method returns <see langword="true"/>, contains the resolved channel value.</param> /// <param name="error">When this method returns <see langword="false"/>, contains the error message describing the failure.</param> /// <returns><see langword="true"/> if the channel was successfully resolved; otherwise, <see langword="false"/>.</returns> private static bool TryResolveChannel(Assembly assembly, [NotNullWhen(true)] out string? channel, [NotNullWhen(false)] out string? error) { ArgumentNullException.ThrowIfNull(assembly); var metadata = assembly .GetCustomAttributes<AssemblyMetadataAttribute>() .FirstOrDefault(a => string.Equals(a.Key, ChannelMetadataKey, StringComparison.Ordinal)); if (metadata is null || string.IsNullOrEmpty(metadata.Value)) { channel = null; error = $"Assembly metadata '{ChannelMetadataKey}' is missing or empty on '{assembly.GetName().Name}'. " + "The CLI must be built with /p:AspireCliChannel=<channel> (one of stable, staging, daily, local, or pr-<N>)."; return false; } var value = metadata.Value; if (!IsValidChannel(value)) { channel = null; error = $"Assembly metadata '{ChannelMetadataKey}' on '{assembly.GetName().Name}' has invalid value '{value}'. " + "Expected one of: stable, staging, daily, local, or pr-<N> where <N> is one or more ASCII digits."; return false; } channel = value; error = null; return true; } /// <summary> /// Returns <see langword="true"/> when <paramref name="value"/> is a valid /// baked channel: one of the fixed identity strings, or <c>pr-<N></c> /// with <c><N></c> one or more ASCII digits. Trailing/leading whitespace /// and any other shape (including the legacy literal <c>pr</c> without a /// suffix, which is how CI USED to bake PR builds) is rejected so misconfigured /// pipelines fail loudly here rather than producing a hive label of the /// literal <c>pr</c> and silently mis-routing packages. /// </summary> internal static bool IsValidChannel(string value) { if (value is PackageChannelNames.Stable or PackageChannelNames.Staging or PackageChannelNames.Daily or PackageChannelNames.Local) { return true; } if (!value.StartsWith(PrChannelPrefix, StringComparison.Ordinal)) { return false; } // Reject "pr-" with no suffix and "pr-<non-ASCII-digit-run>". MemoryExtensions // .ContainsAnyExceptInRange is AOT-safe and matches the ASCII-only contract // the build pipeline emits (never localized digits, never sign chars). var digits = value.AsSpan(PrChannelPrefix.Length); return !digits.IsEmpty && !digits.ContainsAnyExceptInRange('0', '9'); } }