File: RequiredCommandValidator.cs
Web Access
Project: src\src\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj (Aspire.Hosting.DevTunnels)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.DevTunnels;
 
/// <summary>
/// Base class that extends <see cref="CoalescingAsyncOperation"/> with validation logic
/// ensuring that a command (executable) path supplied by an implementation is valid
/// for launching a new process. The command is considered valid if either:
/// 1. It is an absolute or relative path (contains a directory separator) that points to an existing file, or
/// 2. It is discoverable on the current process PATH (respecting PATHEXT on Windows).
///
/// Once validated, the resolved full path is passed to <see cref="OnValidatedAsync"/> for
/// any additional (optional) work by derived classes.
///
/// Use the inherited RunAsync method to coalesce concurrent validation requests.
/// </summary>
// Suppress experimental interaction API warnings locally.
#pragma warning disable ASPIREINTERACTION001
internal abstract class RequiredCommandValidator(IInteractionService interactionService, ILogger logger) : CoalescingAsyncOperation
{
    private readonly IInteractionService _interactionService = interactionService;
    private readonly ILogger _logger = logger;
 
    private Task? _notificationTask;
 
    /// <summary>
    /// Returns the command string (file name or path) that should be validated.
    /// </summary>
    protected abstract string GetCommandPath();
 
    /// <summary>
    /// Called after the command has been successfully validated and resolved to a full path.
    /// Default implementation does nothing.
    /// </summary>
    /// <param name="resolvedCommandPath">The resolved full filesystem path to the executable.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    protected virtual Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken) => Task.CompletedTask;
 
    /// <inheritdoc />
    protected sealed override async Task ExecuteCoreAsync(CancellationToken cancellationToken)
    {
        var command = GetCommandPath();
 
        var notificationTask = _notificationTask;
        if (notificationTask is { IsCompleted: false })
        {
            // Failure notification is still being shown so just throw again.
            throw GetCommandNotFoundException(command);
        }
 
        if (string.IsNullOrWhiteSpace(command))
        {
            throw new InvalidOperationException("Command path cannot be null or empty.");
        }
        var resolved = ResolveCommand(command);
        if (resolved is null)
        {
            var link = GetHelpLink();
            var message = link is null
                ? $"Required command '{command}' was not found on PATH or at a specified location."
                : $"Required command '{command}' was not found. See installation instructions for more details.";
 
            _logger.LogWarning("{Message}", message);
 
            if (_interactionService.IsAvailable == true)
            {
                try
                {
                    var options = new NotificationInteractionOptions
                    {
                        Intent = MessageIntent.Warning,
                        // Provide a link only if we have one.
                        LinkText = link is null ? null : "Installation instructions",
                        LinkUrl = link,
                        ShowDismiss = true,
                        ShowSecondaryButton = false
                    };
 
                    _notificationTask = _interactionService.PromptNotificationAsync(
                        title: "Missing command",
                        message: message,
                        options,
                        cancellationToken);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Failed to show missing command notification");
                }
            }
            throw GetCommandNotFoundException(command);
        }
 
        await OnValidatedAsync(resolved, cancellationToken).ConfigureAwait(false);
    }
 
    private static DistributedApplicationException GetCommandNotFoundException(string command) =>
        new($"Required command '{command}' was not found on PATH or at the specified location.");
 
    /// <summary>
    /// Optional link returned to guide users when the command is missing. Return null for no link.
    /// </summary>
    protected virtual string? GetHelpLink() => null;
 
    /// <summary>
    /// Attempts to resolve a command (file name or path) to a full path.
    /// </summary>
    /// <param name="command">The command string.</param>
    /// <returns>Full path if resolved; otherwise null.</returns>
    protected static string? ResolveCommand(string command)
    {
        // If the command includes any directory separator, treat it as a path (relative or absolute)
        if (command.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0)
        {
            var candidate = Path.GetFullPath(command);
            return File.Exists(candidate) ? candidate : null;
        }
 
        // Search PATH
        var pathEnv = Environment.GetEnvironmentVariable("PATH");
        if (string.IsNullOrEmpty(pathEnv))
        {
            return null;
        }
 
        var paths = pathEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // On Windows consider PATHEXT if no extension specified
            var hasExtension = Path.HasExtension(command);
            var pathext = Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
            var exts = pathext.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 
            foreach (var dir in paths)
            {
                if (hasExtension)
                {
                    var candidate = Path.Combine(dir, command);
                    if (File.Exists(candidate))
                    {
                        return candidate;
                    }
                }
                else
                {
                    foreach (var ext in exts)
                    {
                        var candidate = Path.Combine(dir, command + ext);
                        if (File.Exists(candidate))
                        {
                            return candidate;
                        }
                    }
                }
            }
        }
        else
        {
            foreach (var dir in paths)
            {
                var candidate = Path.Combine(dir, command);
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }
        }
 
        return null;
    }
}
#pragma warning restore ASPIREINTERACTION001