File: DotNet\ProcessExecutionFactory.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;
using System.Text;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.DotNet;
 
/// <summary>
/// Creates process executions backed by real OS processes.
/// </summary>
internal sealed class ProcessExecutionFactory : IProcessExecutionFactory
{
    internal static IReadOnlyList<string> InvocationScopedEnvVarNames { get; } =
    [
        KnownConfigNames.CliAppHostSelectionOrigin
    ];
 
    // Strip ASPIRE_CLI_* identity overrides from every spawned process — both the isolated AppHost
    // run path and every non-isolated subprocess. These env vars are an in-process, parent-only test
    // affordance: a developer or test bench uses them to coerce the *current* CLI into pretending it
    // is a different channel/version/commit or to retarget its emitted nuget.config at a local proxy.
    // Letting them leak into child processes (apphost, dotnet, restore, peer probes) means any nested
    // `aspire` invocation inherits the parent's lie about its identity, which silently corrupts
    // `aspire doctor`, breaks peer probing, and undermines the "what is this binary actually" answer
    // we want callers to see on disk. See docs/specs/cli-identity-sidecar.md.
    //
    // Invocation-scoped values are stripped too so AppHost and build children cannot inherit them.
    // In both cases callers can still deliberately provide a value (e.g. to a detached child CLI).
    //
    // Case-insensitive because ProcessStartInfo.Environment is case-sensitive on Unix, while the child
    // CLI reads these through case-insensitive configuration, so no differently-cased copy may survive.
    private static readonly HashSet<string> s_strippedEnvVarNames = new(
        [.. Acquisition.IdentityResolver.IdentityEnvVarNames, .. InvocationScopedEnvVarNames],
        StringComparer.OrdinalIgnoreCase);
 
    private readonly IEnvironment _environment;
    private readonly ILogger<ProcessExecutionFactory> _logger;
 
    public ProcessExecutionFactory(IEnvironment environment, ILogger<ProcessExecutionFactory> logger)
    {
        _environment = environment;
        _logger = logger;
    }
 
    public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary<string, string>? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options)
    {
        var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger;
 
        // `dotnet run --project AppHost.csproj -- <appHostArgs>` reaches this factory with the
        // forwarded AppHost arguments still attached, so redact past the separator before logging.
        // Direct AppHost launches have no separator at all and instead declare the boundary through
        // ProcessInvocationOptions.AppHostArgumentStartIndex.
        var loggableArgs = options.AppHostArgumentStartIndex is { } appHostArgumentStartIndex
            ? AppHostArgumentRedactor.RedactFromToString(args, appHostArgumentStartIndex)
            : AppHostArgumentRedactor.RedactToString(args);
        effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", fileName, workingDirectory.FullName, loggableArgs);
 
        if (env is not null)
        {
            foreach (var envKvp in env)
            {
                effectiveLogger.LogDebug("{FileName} env: {EnvKey}={EnvValue}", fileName, envKvp.Key, envKvp.Value);
            }
        }
 
        var startInfo = CreateProcessStartInfo(fileName, args, workingDirectory.FullName, options);
 
        // Touching Environment snapshots the parent env. Strip before overlaying explicit values so
        // callers can still deliberately provide a stripped value (e.g. to a detached child CLI).
        RemoveInheritedEnvironmentVariables(startInfo.Environment, options.EnvironmentVariableFilter);
 
        if (env is not null)
        {
            foreach (var envKvp in env)
            {
                startInfo.Environment[envKvp.Key] = envKvp.Value;
            }
        }
 
        return new ProcessExecution(startInfo, effectiveLogger, options, _environment);
    }
 
    public IProcessExecution CreateExecution(ProcessStartInfo startInfo, ProcessInvocationOptions options)
    {
        var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger;
 
        // Same redaction boundary as the ArgumentList-building overload: anything after the first
        // "--" is application input forwarded to the AppHost and must not be logged verbatim.
        effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", startInfo.FileName, startInfo.WorkingDirectory,
            string.IsNullOrEmpty(startInfo.Arguments) ? AppHostArgumentRedactor.RedactToString(startInfo.ArgumentList) : "[raw command arguments]");
 
        // Only the caller's command line and environment are used; the launch mode (stdio, console,
        // job, detach) always comes from the options so every child is spawned the same way.
        var childStartInfo = CreateProcessStartInfo(startInfo.FileName, startInfo.ArgumentList, startInfo.WorkingDirectory, options);
        // Batch shims carry cmd's command line in Arguments, not ArgumentList. Preserve it without
        // logging raw values, which do not have a reliable forwarded-AppHost redaction boundary.
        childStartInfo.Arguments = startInfo.Arguments;
 
        // Replace (not overlay) the env block so callers that did startInfo.Environment.Remove(key)
        // see that removal honored — e.g. PrebuiltAppHostServer.CreateStartInfo explicitly removes
        // KnownConfigNames.IntegrationLibsPath / IntegrationProbeManifestPath when they aren't
        // configured, to suppress any value the parent CLI happens to have set in its own env.
        // The caller's ProcessStartInfo.Environment is seeded from the parent, so it already is the
        // authoritative "what the child should see" view; a missing key means "do not pass it".
        childStartInfo.Environment.Clear();
        foreach (var (key, value) in startInfo.Environment)
        {
            // A null value means "do not set this variable in the child".
            if (value is not null)
            {
                childStartInfo.Environment[key] = value;
            }
        }
 
        // Strip after the copy so an ASPIRE_CLI_* var the parent happens to hold is not re-introduced
        // through the caller's parent-seeded environment.
        RemoveInheritedEnvironmentVariables(childStartInfo.Environment, options.EnvironmentVariableFilter);
 
        return new ProcessExecution(childStartInfo, effectiveLogger, options, _environment);
    }
 
    /// <summary>
    /// Maps the launch options onto a <see cref="ProcessStartInfo"/>. Standard handles are assigned by
    /// <see cref="Aspire.Shared.ChildProcess.StartAsync"/> so an execution that is never started holds no handles.
    /// </summary>
    internal static ProcessStartInfo CreateProcessStartInfo(string fileName, IEnumerable<string> arguments, string workingDirectory, ProcessInvocationOptions options)
    {
        var startInfo = new ProcessStartInfo(fileName, arguments)
        {
            WorkingDirectory = workingDirectory,
            CreateNoWindow = true,
        };
 
        if (!options.Detached)
        {
            startInfo.RedirectStandardOutput = true;
            startInfo.RedirectStandardError = true;
            // Pin encodings so process output decoding is stable regardless of the ambient
            // Console.OutputEncoding (e.g. on container hosts that leave it set to ASCII).
            startInfo.StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false);
            startInfo.StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false);
        }
        else if (!OperatingSystem.IsWindows())
        {
            // setsid(): a new session and process group, so the child outlives cleanup of the
            // launcher's process group/session and terminal hangup (#18484). It stays a direct child,
            // so its exit code is observable while the CLI is alive. Not used on Windows, where it
            // means DETACHED_PROCESS: without a console, `aspire stop` could not deliver CTRL+C.
            startInfo.StartDetached = true;
        }
 
        if (OperatingSystem.IsWindows() && (options.IsolateConsole || options.KillOnParentExit || options.Detached))
        {
            // CreateNoWindow (CREATE_NO_WINDOW) still allocates a new console for the child, just
            // without a visible window; unlike DETACHED_PROCESS, the child is attached to a console.
            // That lets DCP stop-process-tree AttachConsole to it and send CTRL_C_EVENT without also
            // signalling the CLI (covered by ProcessExecutionTests). Children that only need
            // parent-exit protection or detachment keep sharing the CLI's console.
            startInfo.CreateNoWindow = options.IsolateConsole;
            // KillOnParentExit assigns the child to a kill-on-close job atomically at creation. The
            // runtime's job also sets JOB_OBJECT_LIMIT_BREAKAWAY_OK, so DCP can outlive the CLI to
            // finish cleanup by spawning itself with CREATE_BREAKAWAY_FROM_JOB, provided no nested
            // job forbids breakaway (dotnet run's does, so DotNetAppHostProject opts out for it).
            // Ordinary owned helpers are wrapped in the shared guardian instead. This
            // breakaway policy applies to AppHosts so DCP can finish container cleanup.
            startInfo.KillOnParentExit = options.KillOnParentExit;
            // Long-lived children must not keep unrelated inheritable CLI handles (sockets, other
            // children's pipes) open, so inherit only the standard handles.
            startInfo.InheritedHandles = [];
        }
 
        return startInfo;
    }
 
    private static void RemoveInheritedEnvironmentVariables(IDictionary<string, string?> environment, Func<string, bool>? environmentVariableFilter)
    {
        foreach (var key in environment.Keys.ToArray())
        {
            if (s_strippedEnvVarNames.Contains(key) || environmentVariableFilter?.Invoke(key) == true)
            {
                environment.Remove(key);
            }
        }
    }
}