// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
namespace Aspire.Hosting.Tasks;
/// <summary>
/// Resolves DCP and Dashboard paths from an Aspire CLI bundle layout.
/// </summary>
public sealed class ResolveAspireCliBundle : Microsoft.Build.Utilities.Task
{
private const string BundleDirectoryName = "bundle";
private const string VersionsDirectoryName = "versions";
private const string InstallSidecarFileName = ".aspire-install.json";
private const string AspireHomeEnvironmentVariable = "ASPIRE_HOME";
private const int MaxInstallSidecarBytes = 64 * 1024;
private static readonly Version s_unversionedDirectoryVersion = new(0, 0);
/// <summary>
/// Optional path to an Aspire CLI bundle or layout directory.
/// </summary>
public string? AspireCliBundlePath { get; set; }
/// <summary>
/// Optional path to an Aspire CLI executable.
/// </summary>
public string? AspireCliPath { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to search <c>PATH</c> for Aspire CLI executables.
/// </summary>
/// <remarks>
/// Explicit paths and the Aspire home directory are still evaluated when this value is <see langword="false"/>.
/// </remarks>
public bool SearchPathForAspireCli { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether invalid explicitly configured paths produce warnings.
/// </summary>
public bool WarnOnInvalidPaths { get; set; } = true;
/// <summary>
/// The Aspire home directory used to find and prepare extracted bundle layouts.
/// </summary>
[Output]
public string? AspireHomeDirectory { get; set; }
/// <summary>
/// The resolved DCP directory.
/// </summary>
[Output]
public string? DcpDir { get; set; }
/// <summary>
/// The resolved Dashboard directory.
/// </summary>
[Output]
public string? AspireDashboardDir { get; set; }
/// <summary>
/// The resolved Dashboard executable path.
/// </summary>
[Output]
public string? AspireDashboardPath { get; set; }
/// <summary>
/// The resolved TerminalHost directory.
/// </summary>
[Output]
public string? AspireTerminalHostDir { get; set; }
/// <summary>
/// The resolved TerminalHost executable path.
/// </summary>
[Output]
public string? AspireTerminalHostPath { get; set; }
/// <summary>
/// Invocation args that must be prepended when launching the TerminalHost binary.
/// In the CLI bundle case the binary is the multi-mode <c>aspire-managed</c> exe and
/// this is set to <c>"terminalhost"</c> so the dispatcher routes to <c>TerminalHostApp.RunAsync</c>.
/// Empty for the per-RID NuGet package case (the binary is a standalone TerminalHost exe).
/// </summary>
[Output]
public string? AspireTerminalHostInvocationArgs { get; set; }
public override bool Execute()
{
var aspireHomeDirectory = GetDefaultAspireHomeDirectory();
AspireHomeDirectory = aspireHomeDirectory;
if (!string.IsNullOrWhiteSpace(AspireCliBundlePath))
{
if (TryResolveFromLayoutPath(AspireCliBundlePath, out var explicitBundle))
{
SetOutputs(explicitBundle);
return true;
}
if (WarnOnInvalidPaths)
{
Log.LogWarning("The AspireCliBundlePath value '{0}' does not point to a valid Aspire CLI bundle layout.", AspireCliBundlePath);
}
return true;
}
if (!string.IsNullOrWhiteSpace(AspireCliPath))
{
if (File.Exists(AspireCliPath) && TryResolveFromCliPath(AspireCliPath, out var explicitCliBundle))
{
SetOutputs(explicitCliBundle);
return true;
}
if (WarnOnInvalidPaths)
{
Log.LogWarning("The AspireCliPath value '{0}' does not point to an existing Aspire CLI executable with a valid bundle layout.", AspireCliPath);
}
return true;
}
if (SearchPathForAspireCli && TryResolveFromPath(out var pathBundle))
{
SetOutputs(pathBundle);
return true;
}
if (TryResolveFromLayoutPath(aspireHomeDirectory, out var aspireHomeBundle))
{
SetOutputs(aspireHomeBundle);
return true;
}
return true;
}
private void SetOutputs(BundleResolution resolution)
{
DcpDir = EnsureTrailingDirectorySeparator(resolution.DcpDir);
AspireDashboardDir = EnsureTrailingDirectorySeparator(resolution.ManagedDir);
AspireDashboardPath = resolution.ManagedPath;
AspireTerminalHostDir = EnsureTrailingDirectorySeparator(resolution.ManagedDir);
AspireTerminalHostPath = resolution.ManagedPath;
AspireTerminalHostInvocationArgs = "terminalhost";
}
private static bool TryResolveFromPath(out BundleResolution resolution)
{
resolution = default!;
foreach (var cliPath in EnumerateAspireCliPaths())
{
if (TryResolveFromCliPath(cliPath, out resolution))
{
return true;
}
}
return false;
}
private static IEnumerable<string> EnumerateAspireCliPaths()
{
return CommandPathResolver.EnumerateFromPath("aspire");
}
private static bool TryResolveFromCliPath(string cliPath, out BundleResolution resolution, bool includeDefaultAspireHomeFallback = true)
{
resolution = default!;
var candidateCliPaths = EnumerateCandidateCliPaths(Path.GetFullPath(cliPath));
foreach (var candidateCliPath in candidateCliPaths)
{
var cliDirectory = Path.GetDirectoryName(candidateCliPath);
if (string.IsNullOrEmpty(cliDirectory))
{
continue;
}
foreach (var layoutPath in EnumerateLayoutPathsForCliDirectory(cliDirectory))
{
if (TryResolveFromLayoutPath(layoutPath, out resolution))
{
return true;
}
}
foreach (var dotnetToolCliPath in EnumerateDotNetToolStoreCliPaths(cliDirectory))
{
if (TryResolveFromCliPath(dotnetToolCliPath, out resolution, includeDefaultAspireHomeFallback: false))
{
return true;
}
}
}
if (includeDefaultAspireHomeFallback && TryResolveFromLayoutPath(GetDefaultAspireHomeDirectory(), out resolution))
{
return true;
}
return false;
}
private static bool TryResolveFromLayoutPath(string layoutPath, out BundleResolution resolution)
{
var fullPath = Path.GetFullPath(layoutPath);
if (TryResolveBundleRoot(fullPath, out resolution))
{
return true;
}
if (TryResolveBundleRoot(Path.Combine(fullPath, BundleDirectoryName), out resolution))
{
return true;
}
return TryResolveVersionedBundleRoot(fullPath, out resolution);
}
private static bool TryResolveBundleRoot(string bundleRoot, out BundleResolution resolution)
{
resolution = default!;
var dcpDir = Path.Combine(bundleRoot, "dcp");
var dcpPath = Path.Combine(dcpDir, IsWindows() ? "dcp.exe" : "dcp");
var managedDir = Path.Combine(bundleRoot, "managed");
var managedPath = Path.Combine(managedDir, IsWindows() ? "aspire-managed.exe" : "aspire-managed");
if (!File.Exists(dcpPath) || !File.Exists(managedPath))
{
return false;
}
resolution = new BundleResolution(dcpDir, managedDir, managedPath);
return true;
}
private static string EnsureTrailingDirectorySeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ||
path.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private static bool IsWindows() => Path.DirectorySeparatorChar == '\\';
private static IEnumerable<string> EnumerateCandidateCliPaths(string cliPath)
{
var seenPaths = new HashSet<string>(GetPathComparer());
if (seenPaths.Add(cliPath))
{
yield return cliPath;
}
var resolvedCliPath = ResolveSymlinkOrOriginalPath(cliPath);
if (seenPaths.Add(resolvedCliPath))
{
yield return resolvedCliPath;
}
}
private static IEnumerable<string> EnumerateLayoutPathsForCliDirectory(string cliDirectory)
{
var seenPaths = new HashSet<string>(GetPathComparer());
foreach (var layoutPath in EnumerateLayoutPathsForCliDirectoryCore(cliDirectory))
{
if (!string.IsNullOrEmpty(layoutPath) && seenPaths.Add(layoutPath))
{
yield return layoutPath;
}
}
}
private static IEnumerable<string?> EnumerateLayoutPathsForCliDirectoryCore(string cliDirectory)
{
yield return cliDirectory;
// Install-route sidecars are written as:
// { "source": "winget" }
// Known package-manager routes extract beside the binary and script/localhive
// routes extract under the parent prefix. Sidecar-less/unknown routes fall
// back to ASPIRE_HOME after any dotnet-tool store payload has been probed.
yield return TryReadInstallSource(cliDirectory) switch
{
"winget" or "brew" or "dotnet-tool" => cliDirectory,
"script" or "pr" or "localhive" => Path.GetDirectoryName(cliDirectory) ?? cliDirectory,
_ => null,
};
yield return Path.GetDirectoryName(cliDirectory);
}
private static IEnumerable<string> EnumerateDotNetToolStoreCliPaths(string cliDirectory)
{
var storeRoot = Path.Combine(cliDirectory, ".store", "aspire.cli");
if (!Directory.Exists(storeRoot))
{
yield break;
}
foreach (var executableName in GetNativeAspireExecutableNames())
{
foreach (var candidate in EnumerateFiles(storeRoot, executableName))
{
yield return candidate;
}
}
}
private static IEnumerable<string> EnumerateFiles(string path, string searchPattern)
{
try
{
return Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories);
}
catch (Exception ex) when (IsPathDiscoveryException(ex))
{
return [];
}
}
private static string[] GetNativeAspireExecutableNames()
{
return IsWindows()
? ["aspire.exe"]
: ["aspire"];
}
private static bool TryResolveVersionedBundleRoot(string layoutPath, out BundleResolution resolution)
{
var versionsRoot = Path.Combine(layoutPath, VersionsDirectoryName);
if (Directory.Exists(versionsRoot))
{
foreach (var versionDirectory in EnumerateDirectories(versionsRoot)
.Where(IsVersionDirectoryCandidate)
.OrderByDescending(GetVersionDirectoryVersion)
.ThenByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
{
if (TryResolveBundleRoot(versionDirectory, out resolution))
{
return true;
}
}
}
resolution = default!;
return false;
}
private static Version GetVersionDirectoryVersion(string path)
{
var directoryName = Path.GetFileName(path);
return Version.TryParse(GetLeadingVersion(StripVersionIdHash(directoryName)), out var version)
? version
: s_unversionedDirectoryVersion;
}
private static string GetLeadingVersion(string directoryName)
{
// BundleService.ComputeVersionId keeps the informational-version prefix and appends a hash:
// 13.10.0-preview.1.25301.1_gdae6efcb-bbbbbbbbbbbbbbbb
// System.Version cannot parse the prerelease/hash suffix, but it can compare the leading
// numeric version that determines which extracted bundle is newer.
var versionEnd = 0;
while (versionEnd < directoryName.Length && directoryName[versionEnd] is (>= '0' and <= '9') or '.')
{
versionEnd++;
}
return versionEnd == 0
? string.Empty
: directoryName.Substring(0, versionEnd).TrimEnd('.');
}
private static string StripVersionIdHash(string directoryName)
{
// BundleService version directories are named "<sanitized-version>-<16-hex-xxhash>",
// for example "9.10.0-bbbbbbbbbbbbbbbb". Strip the hash so System.Version
// compares the semantic version instead of the full directory name.
var separatorIndex = directoryName.LastIndexOf('-');
if (separatorIndex < 0 ||
directoryName.Length - separatorIndex - 1 != 16 ||
!ContainsOnlyHexDigits(directoryName, separatorIndex + 1))
{
return directoryName;
}
return directoryName.Substring(0, separatorIndex);
}
private static bool ContainsOnlyHexDigits(string value, int startIndex)
{
for (var i = startIndex; i < value.Length; i++)
{
var ch = value[i];
if (ch is not ((>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f')))
{
return false;
}
}
return true;
}
private static bool IsVersionDirectoryCandidate(string path)
{
var directoryName = Path.GetFileName(path);
return !directoryName.Contains(".tmp.", StringComparison.Ordinal)
&& !directoryName.Contains(".bad.", StringComparison.Ordinal)
&& !directoryName.Contains(".old.", StringComparison.Ordinal);
}
private static IEnumerable<string> EnumerateDirectories(string path)
{
try
{
return Directory.GetDirectories(path);
}
catch (Exception ex) when (IsPathDiscoveryException(ex))
{
return [];
}
}
private static string? TryReadInstallSource(string cliDirectory)
{
var sidecarPath = Path.Combine(cliDirectory, InstallSidecarFileName);
if (!File.Exists(sidecarPath))
{
return null;
}
try
{
if (new FileInfo(sidecarPath).Length > MaxInstallSidecarBytes)
{
return null;
}
var sidecar = File.ReadAllText(sidecarPath);
var sourceMatch = Regex.Match(sidecar, @"""source""\s*:\s*""(?<source>[^""]*)""");
return sourceMatch.Success ? sourceMatch.Groups["source"].Value : null;
}
catch (Exception ex) when (IsPathDiscoveryException(ex))
{
return null;
}
}
private static string GetDefaultAspireHomeDirectory()
{
var aspireHome = Environment.GetEnvironmentVariable(AspireHomeEnvironmentVariable);
return string.IsNullOrWhiteSpace(aspireHome)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire")
: aspireHome;
}
private static StringComparer GetPathComparer()
{
return IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
}
private static string ResolveSymlinkOrOriginalPath(string path)
{
#if NET8_0_OR_GREATER
try
{
return File.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName ?? path;
}
catch (Exception ex) when (IsPathDiscoveryException(ex))
{
return path;
}
#else
return path;
#endif
}
private static bool IsPathDiscoveryException(Exception ex)
{
return ex is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or PathTooLongException
or System.Security.SecurityException;
}
private sealed class BundleResolution(string dcpDir, string managedDir, string managedPath)
{
public string DcpDir { get; } = dcpDir;
public string ManagedDir { get; } = managedDir;
public string ManagedPath { get; } = managedPath;
}
}