File: src\Shared\UserSecrets\SecretsStore.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.Text.Encodings.Web;
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
{
    internal static readonly JsonSerializerOptions s_jsonOptions = new()
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };
 
    private readonly string _secretsFilePath;
    private readonly Dictionary<string, string> _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;
 
    /// <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);
 
    /// <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>
    /// Returns all secret key-value pairs.
    /// </summary>
    public IEnumerable<KeyValuePair<string, string>> AsEnumerable() => _secrets;
 
    /// <summary>
    /// Returns all secret key-value pairs as a list.
    /// </summary>
    public List<KeyValuePair<string, string>> ToList() => [.. _secrets];
 
    /// <summary>
    /// Persists the current secrets to disk.
    /// </summary>
    public void Save()
    {
        var directory = Path.GetDirectoryName(_secretsFilePath);
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
 
        var obj = new JsonObject();
        foreach (var (key, value) in _secrets)
        {
            obj[key] = value;
        }
 
        var json = obj.ToJsonString(s_jsonOptions);
 
        // Unix: write to temp file then move for atomicity (matches aspnetcore pattern)
        if (!OperatingSystem.IsWindows())
        {
            var tempFilename = Path.GetTempFileName();
            try
            {
                File.WriteAllText(tempFilename, json);
                File.Move(tempFilename, _secretsFilePath, overwrite: true);
            }
            finally
            {
                // Clean up temp file if move failed
                if (File.Exists(tempFilename))
                {
                    File.Delete(tempFilename);
                }
            }
        }
        else
        {
            File.WriteAllText(_secretsFilePath, json);
        }
    }
 
    /// <summary>
    /// Loads secrets from a JSON file, flattening any nested structure to colon-separated keys.
    /// </summary>
    private static Dictionary<string, string> Load(string path)
    {
        if (!File.Exists(path))
        {
            return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }
 
        var json = File.ReadAllText(path);
        if (string.IsNullOrWhiteSpace(json))
        {
            return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }
 
        var parsed = JsonNode.Parse(json)?.AsObject();
        if (parsed is null)
        {
            return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }
 
        // Flatten nested JSON to colon-separated keys
        var flat = JsonFlattener.FlattenJsonObject(parsed);
 
        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var kvp in flat)
        {
            var value = kvp.Value?.GetValue<string>();
            if (value is not null)
            {
                result[kvp.Key] = value;
            }
        }
 
        return result;
    }
}