File: src\Shared\UserSecrets\SecretsStore.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.
 
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Shared.Json;
 
namespace Aspire.Shared.UserSecrets;
 
/// <summary>
/// Provides CRUD operations over a dotnet user-secrets JSON file.
/// </summary>
internal sealed class SecretsStore
{
    private readonly string _secretsFilePath;
    private readonly Dictionary<string, JsonNode?> _secrets;
 
    /// <summary>
    /// Creates a new SecretsStore backed by the specified file path.
    /// </summary>
    public SecretsStore(string secretsFilePath)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(secretsFilePath);
 
        _secretsFilePath = secretsFilePath;
        _secrets = Load(secretsFilePath);
    }
 
    /// <summary>
    /// Gets the file path of the secrets file.
    /// </summary>
    public string FilePath => _secretsFilePath;
 
    /// <summary>
    /// Gets the number of secrets in the store.
    /// </summary>
    public int Count => _secrets.Count(entry => entry.Value is not null);
 
    /// <summary>
    /// Sets a secret value. Call <see cref="Save"/> to persist.
    /// </summary>
    public void Set(string key, string value) => _secrets[key] = value;
 
    /// <summary>
    /// Gets a secret value by key, or null if not found.
    /// </summary>
    public string? Get(string key) => _secrets.GetValueOrDefault(key)?.ToString();
 
    /// <summary>
    /// Removes a secret by key. Call <see cref="Save"/> to persist.
    /// </summary>
    public bool Remove(string key) => _secrets.Remove(key);
 
    /// <summary>
    /// Returns true if the store contains the specified key.
    /// </summary>
    public bool ContainsKey(string key) => _secrets.ContainsKey(key);
 
    /// <summary>
    /// Removes all secrets from the store. Call <see cref="Save"/> to persist.
    /// </summary>
    public void Clear() => _secrets.Clear();
 
    /// <summary>
    /// Returns all secret key-value pairs.
    /// </summary>
    public IEnumerable<KeyValuePair<string, string>> AsEnumerable()
    {
        foreach (var (key, value) in _secrets)
        {
            if (value is not null)
            {
                yield return KeyValuePair.Create(key, value.ToString());
            }
        }
    }
 
    /// <summary>
    /// Returns all secret key-value pairs as a list.
    /// </summary>
    public List<KeyValuePair<string, string>> ToList() => [.. AsEnumerable()];
 
    /// <summary>
    /// Imports missing values without changing existing values or their JSON types.
    /// </summary>
    public void MergeFrom(SecretsStore source)
    {
        foreach (var (key, value) in source._secrets)
        {
            if (!_secrets.ContainsKey(key))
            {
                _secrets.Add(key, value?.DeepClone());
            }
        }
    }
 
    /// <summary>
    /// Persists the current secrets to disk.
    /// </summary>
    public void Save()
    {
        var obj = new JsonObject();
        foreach (var (key, value) in _secrets)
        {
            obj[key] = value?.DeepClone();
        }
 
        SaveJson(_secretsFilePath, obj);
    }
 
    /// <summary>
    /// Atomically persists JSON without converting its values to strings.
    /// </summary>
    internal static void SaveJson(string filePath, JsonObject obj)
    {
        var directory = Path.GetDirectoryName(filePath);
        if (!string.IsNullOrEmpty(directory))
        {
            if (OperatingSystem.IsWindows())
            {
                Directory.CreateDirectory(directory);
            }
            else
            {
                // Explicit secrets files can live in caller-owned shared directories.
                // Apply private permissions only when creating directories, never chmod existing parents.
                Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
            }
        }
 
        var json = obj.ToJsonString(UserSecretsJsonOptions.s_instance);
 
        if (!string.IsNullOrEmpty(directory))
        {
            var tempFilename = Path.Combine(directory, $".{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp");
            try
            {
                var options = new FileStreamOptions
                {
                    Mode = FileMode.CreateNew,
                    Access = FileAccess.Write,
                    Share = FileShare.None
                };
 
                if (!OperatingSystem.IsWindows())
                {
                    options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
                }
 
                using (var stream = new FileStream(tempFilename, options))
                {
                    if (!OperatingSystem.IsWindows())
                    {
                        File.SetUnixFileMode(tempFilename, UnixFileMode.UserRead | UnixFileMode.UserWrite);
                    }
 
                    using var writer = new StreamWriter(stream);
                    writer.Write(json);
                }
 
                if (OperatingSystem.IsWindows() && File.Exists(filePath))
                {
                    // Replacement preserves the destination DACL; a move would inherit the
                    // temporary file's permissions from a potentially broader-access directory.
                    File.Replace(tempFilename, filePath, destinationBackupFileName: null);
                }
                else
                {
                    File.Move(tempFilename, filePath, overwrite: true);
                }
 
                if (!OperatingSystem.IsWindows())
                {
                    File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
                }
            }
            finally
            {
                if (File.Exists(tempFilename))
                {
                    File.Delete(tempFilename);
                }
            }
        }
        else
        {
            File.WriteAllText(filePath, json);
        }
    }
 
    /// <summary>
    /// Loads secrets from a JSON file, flattening any nested structure to colon-separated keys.
    /// </summary>
    private static Dictionary<string, JsonNode?> Load(string path)
    {
        if (!File.Exists(path))
        {
            return new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase);
        }
 
        var json = File.ReadAllText(path);
        if (string.IsNullOrWhiteSpace(json))
        {
            return new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase);
        }
 
        var parsed = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions
        {
            AllowTrailingCommas = true,
            CommentHandling = JsonCommentHandling.Skip
        })?.AsObject();
        if (parsed is null)
        {
            return new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase);
        }
 
        // Flatten nested JSON to colon-separated keys
        var flat = JsonFlattener.FlattenJsonObject(parsed);
 
        // The same file also holds deployment state. Keep raw nodes for persistence;
        // only the string-facing CRUD and listing APIs should stringify values.
        var result = new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase);
        foreach (var kvp in flat)
        {
            result[kvp.Key] = kvp.Value;
        }
 
        return result;
    }
}