| File: src\Shared\UserSecrets\AspireSecretsPathHelper.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.IO.Hashing; using System.Text; namespace Aspire.Shared.UserSecrets; /// <summary> /// Helpers for resolving Aspire environment secrets file paths. /// </summary> internal static class AspireSecretsPathHelper { internal const string SecretsDirectoryName = "secrets"; /// <summary> /// Returns the Aspire secrets file path for an AppHost/environment pair. /// </summary> public static string GetSecretsFilePath(DirectoryInfo homeDirectory, string appHostId, string environmentName) { ArgumentNullException.ThrowIfNull(homeDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(appHostId); ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); ValidateEnvironmentName(environmentName); var directory = GetSecretsDirectoryPath(homeDirectory, appHostId); if (Directory.Exists(directory)) { // Environment identity must not depend on the filesystem's case sensitivity. // Retain the existing filename spelling so list --all reports the original name. var existingPaths = Directory.EnumerateFiles(directory) .Where(path => string.Equals(Path.GetFileName(path), $"{environmentName}.json", StringComparison.OrdinalIgnoreCase)) .ToArray(); if (existingPaths.Length > 1) { throw new InvalidOperationException($"Multiple Aspire secrets files exist for environment '{environmentName}' in '{directory}'. Consolidate the files before continuing."); } if (existingPaths.Length == 1) { return existingPaths[0]; } } return Path.Combine(directory, $"{environmentName}.json"); } /// <summary> /// Returns whether an environment name can be used as a portable secrets filename. /// </summary> public static bool IsValidEnvironmentName(string? environmentName) { if (environmentName is null || environmentName.Length is < 1 or > 80 || !char.IsAsciiLetterOrDigit(environmentName[0]) || environmentName.Any(ch => !char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')) { return false; } return !IsWindowsReservedDeviceName(environmentName); } /// <summary> /// Rejects environment names that cannot be used as portable secrets filenames. /// </summary> public static void ValidateEnvironmentName(string environmentName) { if (!IsValidEnvironmentName(environmentName)) { throw new ArgumentException("Environment names must contain 1-80 ASCII letters, digits, hyphens, or underscores, start with a letter or digit, and must not be Windows reserved device names.", nameof(environmentName)); } } /// <summary> /// Returns the Aspire secrets directory path for an AppHost. /// </summary> public static string GetSecretsDirectoryPath(DirectoryInfo homeDirectory, string appHostId) { ArgumentNullException.ThrowIfNull(homeDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(appHostId); var safeAppHostId = CreateSafePathSegment(appHostId); return Path.Combine(homeDirectory.FullName, ".aspire", SecretsDirectoryName, safeAppHostId); } /// <summary> /// Returns the current user's home directory used for Aspire secrets. /// </summary> public static DirectoryInfo GetDefaultHomeDirectory() { var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrWhiteSpace(homeDirectory)) { throw new InvalidOperationException("Unable to determine the user profile directory for Aspire secrets."); } return new DirectoryInfo(homeDirectory); } /// <summary> /// Computes a deterministic synthetic AppHost ID from a file path. /// </summary> public static string ComputeSyntheticAppHostId(string appHostPath) { ArgumentException.ThrowIfNullOrWhiteSpace(appHostPath); // Match platform path comparison without merging distinct Unix AppHosts. var normalizedPath = OperatingSystem.IsWindows() ? appHostPath.ToLowerInvariant() : appHostPath; return $"apphost-{ComputeStableHash(normalizedPath)}"; } internal static string CreateSafePathSegment(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); var builder = new StringBuilder(value.Length); var changed = false; foreach (var ch in value) { if (IsSafePathSegmentCharacter(ch)) { builder.Append(ch); } else { builder.Append('_'); changed = true; } } var segment = builder.ToString(); var sanitized = segment.Trim('.'); changed |= sanitized.Length != segment.Length; if (string.IsNullOrWhiteSpace(sanitized)) { sanitized = "value"; changed = true; } if (IsWindowsReservedDeviceName(sanitized.Split('.')[0])) { sanitized = $"_{sanitized}"; changed = true; } if (!changed && sanitized.Length <= 80) { return sanitized; } var readablePrefix = sanitized.Length <= 48 ? sanitized : sanitized[..48]; return $"{readablePrefix}-{ComputeStableHash(value)}"; } private static bool IsSafePathSegmentCharacter(char ch) => ch is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_' or '.'; // Windows reserves these basenames even when a file extension is present. // https://learn.microsoft.com/windows/win32/fileio/naming-a-file#naming-conventions private static bool IsWindowsReservedDeviceName(string name) => name.Equals("CON", StringComparison.OrdinalIgnoreCase) || name.Equals("PRN", StringComparison.OrdinalIgnoreCase) || name.Equals("AUX", StringComparison.OrdinalIgnoreCase) || name.Equals("NUL", StringComparison.OrdinalIgnoreCase) || (name.Length == 4 && name[3] is >= '1' and <= '9' && (name.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || name.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))); private static string ComputeStableHash(string value) { var hashBytes = XxHash3.Hash(Encoding.UTF8.GetBytes(value)); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } }