File: Pipelines\Internal\FileDeploymentStateManager.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Pipelines.Internal;
 
/// <summary>
/// File-based deployment state manager for deployment scenarios.
/// </summary>
internal sealed partial class FileDeploymentStateManager(
    ILogger<FileDeploymentStateManager> logger,
    IConfiguration configuration,
    IHostEnvironment hostEnvironment,
    IOptions<PipelineOptions> pipelineOptions) : DeploymentStateManagerBase<FileDeploymentStateManager>(logger)
{
    // Regex pattern matching only alphanumeric characters, underscores, and hyphens
    [GeneratedRegex(@"^[a-zA-Z0-9_-]+$")]
    private static partial Regex ValidEnvironmentNameRegex();
 
    /// <inheritdoc/>
    public override string? StateFilePath => GetStatePath();
 
    /// <summary>
    /// Validates that the environment name contains only allowed characters and is safe for use in file paths.
    /// </summary>
    /// <param name="environmentName">The environment name to validate.</param>
    /// <returns><c>true</c> if the environment name is valid; otherwise, <c>false</c>.</returns>
    internal static bool IsValidEnvironmentName(string environmentName)
    {
        if (string.IsNullOrEmpty(environmentName))
        {
            return false;
        }
 
        // Validate against allowed characters: [a-zA-Z0-9_-]+
        // This pattern also guards against path traversal attacks since it doesn't allow
        // dots (.), slashes (/), or backslashes (\)
        return ValidEnvironmentNameRegex().IsMatch(environmentName);
    }
 
    /// <inheritdoc/>
    protected override string? GetStatePath()
    {
        // Use PathSha256 for deployment state to disambiguate projects with the same name in different locations
        var appHostSha = configuration["AppHost:PathSha256"];
        if (string.IsNullOrEmpty(appHostSha))
        {
            return null;
        }
 
        var environment = hostEnvironment.EnvironmentName.ToLowerInvariant();
 
        // Validate the environment name to ensure it only contains safe characters
        // and guard against path traversal attacks
        if (!IsValidEnvironmentName(environment))
        {
            throw new ArgumentException($"The environment name '{environment}' contains invalid characters. Environment names must only contain alphanumeric characters, underscores, and hyphens ([a-zA-Z0-9_-]+).", "EnvironmentName");
        }
 
        var aspireDir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments",
            appHostSha
        );
 
        return Path.Combine(aspireDir, $"{environment}.json");
    }
 
    /// <inheritdoc/>
    protected override async Task SaveStateToStorageAsync(JsonObject state, CancellationToken cancellationToken)
    {
        try
        {
            if (pipelineOptions.Value.ClearCache)
            {
                logger.LogInformation("Skipping deployment state save due to --clear-cache flag");
                return;
            }
 
            var deploymentStatePath = GetStatePath();
            if (deploymentStatePath is null)
            {
                logger.LogWarning("Cannot save deployment state: AppHostSha is not configured");
                return;
            }
 
            var flattenedSecrets = JsonFlattener.FlattenJsonObject(state);
            var deploymentStateDirectory = Path.GetDirectoryName(deploymentStatePath)!;
            if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
            {
                Directory.CreateDirectory(deploymentStateDirectory);
            }
            else
            {
                var expectedMode = UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead;
                // Always call CreateDirectory first to avoid race conditions.
                // CreateDirectory is a no-op if the directory already exists but won't change existing permissions.
                Directory.CreateDirectory(deploymentStateDirectory, expectedMode);
 
                try
                {
                    var currentMode = File.GetUnixFileMode(deploymentStateDirectory);
                    if ((currentMode & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
                                        UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) != 0)
                    {
                        logger.LogWarning(
                            "Deployment state directory '{Directory}' has permissions that allow access to other users. " +
                            "Consider restricting permissions to the current user only by running: chmod 700 {Directory}",
                            deploymentStateDirectory,
                            deploymentStateDirectory);
                    }
                }
                catch (Exception ex)
                {
                    logger.LogDebug(ex, "Unable to check permissions on deployment state directory '{Directory}'.", deploymentStateDirectory);
                }
            }
            await File.WriteAllTextAsync(
                deploymentStatePath,
                flattenedSecrets.ToJsonString(s_jsonSerializerOptions),
                cancellationToken).ConfigureAwait(false);
 
            logger.LogDebug("Deployment state saved to {Path}", deploymentStatePath);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Failed to save deployment state.");
            throw;
        }
    }
}