File: Secrets\RadiusSecretStoreNaming.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// 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;
 
namespace Aspire.Hosting.Radius.Secrets;
 
/// <summary>
/// Naming helpers for Radius secret stores: validating a store name.
/// </summary>
/// <remarks>
/// A secret-store name is used verbatim as a Bicep symbol/resource name, a UCP-ID segment,
/// a Radius-created <c>Secret</c> name, and — in publish mode — as an Aspire resource name
/// (the store builder calls <c>AddResource</c>, which enforces <see cref="ApplicationModel.ModelName"/>).
/// To avoid a mode-dependent contract (run mode uses an unregistered builder and skips that check),
/// the grammar is a strict subset of Aspire's resource-name grammar: 1-64 characters of
/// <em>lowercase</em> ASCII letters, digits, and <c>-</c>, starting with a letter, with no
/// consecutive hyphens and no trailing hyphen. Lowercase is required (rather than the mixed case
/// Aspire otherwise permits) because an inline store has no <c>properties.resource</c>, so Radius
/// uses the store name directly as the backing Kubernetes <c>Secret</c> name and Kubernetes rejects
/// non-DNS-1123 (uppercase) Secret names at deploy time. Keeping the grammar lowercase-only fails
/// such names fast at the API/publish boundary instead. It additionally rejects Windows reserved
/// device names, because the name can also become a filesystem path segment for a copied manifest
/// and must be materializable on every platform.
/// </remarks>
internal static class RadiusSecretStoreNaming
{
    /// <summary>
    /// The maximum length of a Radius secret-store name, matching Aspire's resource-name limit
    /// (<c>ModelName.DefaultMaxLength</c>).
    /// </summary>
    internal const int MaxNameLength = 64;
 
    // Windows reserved device names. A store name can be used as a `<name>` artifact path
    // segment, so a name like `CON` or `NUL` cannot be materialized on Windows even though
    // Radius/UCP itself would accept it. Consecutive-hyphen/dot forms cannot occur under the
    // resource-name grammar, so a plain case-insensitive comparison of the whole name suffices.
    private static readonly HashSet<string> s_reservedDeviceNames = new(StringComparer.OrdinalIgnoreCase)
    {
        "CON", "PRN", "AUX", "NUL",
        "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
        "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
    };
 
    /// <summary>
    /// Validates that <paramref name="name"/> is a safe Radius secret-store name.
    /// </summary>
    internal static bool IsValidName([NotNullWhen(true)] string? name)
    {
        if (string.IsNullOrEmpty(name) || name.Length > MaxNameLength)
        {
            return false;
        }
 
        // Mirror ModelName.TryValidateName's default rules, but restrict letters to lowercase so a
        // name accepted here is also accepted by AddResource in publish mode AND is a valid DNS-1123
        // label — an inline store's name becomes the backing Kubernetes Secret name verbatim, and
        // Kubernetes rejects uppercase Secret names at deploy time.
        if (!char.IsAsciiLetterLower(name[0]) || name[^1] == '-')
        {
            return false;
        }
 
        var previousHyphen = false;
        foreach (var c in name)
        {
            if (c == '-')
            {
                if (previousHyphen)
                {
                    return false;
                }
                previousHyphen = true;
            }
            else if (char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c))
            {
                previousHyphen = false;
            }
            else
            {
                return false;
            }
        }
 
        return !s_reservedDeviceNames.Contains(name);
    }
}