// 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.Globalization;
using System.IO.Hashing;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Configuration;
using Aspire.Cli.Resources;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Win32.SafeHandles;
namespace Aspire.Cli.Utils;
internal static partial class ConfigurationHelper
{
internal const string IntegrationCacheFolderName = "integrations";
private static readonly TimeSpan s_workspaceConfigLockTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Standard options for parsing JSON that may contain non-spec features like comments and trailing commas.
/// </summary>
public static readonly JsonDocumentOptions ParseOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile)
=> RegisterSettingsFiles(configuration, workingDirectory, globalSettingsFile, persistNormalization: true);
internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile, bool persistNormalization)
{
var currentDirectory = workingDirectory;
// Find the nearest local settings file (prefer aspire.config.json, fall back to .aspire/settings.json)
FileInfo? localSettingsFile = null;
while (currentDirectory is not null)
{
// Check for aspire.config.json first (new format)
var newSettingsPath = Path.Combine(currentDirectory.FullName, AspireConfigFile.FileName);
if (File.Exists(newSettingsPath))
{
localSettingsFile = new FileInfo(newSettingsPath);
break;
}
// TODO: Remove legacy .aspire/settings.json fallback once confident most users have migrated.
// Tracked by https://github.com/microsoft/aspire/issues/15239
// Fall back to .aspire/settings.json (legacy format).
//
// Startup is shared by every command — including read-only ones (aspire ls, ps,
// doctor, describe, --version). Earlier versions eagerly migrated the legacy file
// to aspire.config.json here so the workspace would move forward on the user's
// first run of a newer CLI (https://github.com/microsoft/aspire/issues/15488),
// but that broke the "read commands don't mutate the working tree" contract:
// running aspire ls in a workspace that only had .aspire/settings.json was
// silently writing aspire.config.json, polluting git status and tripping CI
// dirty-tree checks (https://github.com/microsoft/aspire/issues/17615).
//
// Migration is now deferred to commands that already mutate the workspace
// (aspire run/add/init/update/etc. via ProjectLocator.CreateSettingsFileAsync ->
// AspireConfigFile.LoadOrCreate). Read commands continue to work against the
// legacy file directly: AppHostPathConfigurationPolicy.TryFindAppHostPathKey
// accepts both the legacy flat "appHostPath" key and the modern "appHost:path"
// hierarchical key, and ProjectLocator's settings-file reader has its own legacy
// fallback that does not write.
var legacySettingsPath = BuildPathToSettingsJsonFile(currentDirectory.FullName);
if (File.Exists(legacySettingsPath))
{
localSettingsFile = new FileInfo(legacySettingsPath);
break;
}
currentDirectory = currentDirectory.Parent;
}
// Add global settings first (if it exists) - lower precedence
if (File.Exists(globalSettingsFile.FullName))
{
AddSettingsFile(configuration, globalSettingsFile.FullName, persistNormalization);
}
// Then add local settings (if found) - this will override global settings
if (localSettingsFile is not null)
{
AddSettingsFile(configuration, localSettingsFile.FullName, persistNormalization);
}
}
// Both global formats must contend on one destination, even before migration creates it.
internal static FileInfo GetCanonicalGlobalSettingsFile(FileInfo settingsFile) =>
settingsFile.Name.Equals("globalsettings.json", StringComparison.OrdinalIgnoreCase)
? new FileInfo(Path.Combine(settingsFile.Directory!.FullName, AspireConfigFile.FileName))
: settingsFile;
internal static Task<FileLock?> TryAcquireWorkspaceConfigLockAsync(
CliExecutionContext executionContext, FileInfo settingsFile, ILogger logger, CancellationToken cancellationToken)
=> TryAcquireWorkspaceConfigLockAsync(executionContext.CacheDirectory, settingsFile, logger, cancellationToken);
internal static async Task<FileLock?> TryAcquireWorkspaceConfigLockAsync(
DirectoryInfo cacheDirectory, FileInfo settingsFile, ILogger logger, CancellationToken cancellationToken)
{
// Lock files live in the cache so read-only workspaces remain usable. Canonicalizing and
// folding the config path makes aliases on symlinked or case-insensitive volumes contend;
// on a case-sensitive volume this can only serialize two otherwise independent writes.
var normalizedSettingsPath = PathNormalizer.ResolveSymlinks(settingsFile.FullName)
.Normalize(NormalizationForm.FormC)
.ToUpperInvariant();
var lockFileName = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(normalizedSettingsPath))).ToLowerInvariant();
var lockPath = Path.Combine(cacheDirectory.FullName, "workspace-config-locks", $"{lockFileName}.lock");
try
{
return await FileLock.AcquireAsync(lockPath, cancellationToken, s_workspaceConfigLockTimeout);
}
catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException)
{
// Preserve the existing best-effort workspace bookkeeping when the cache
// cannot host a lock. Reads use the same policy as workspace-default writes.
logger.LogDebug(ex, "Could not acquire the workspace config lock at {LockPath}.", lockPath);
return null;
}
}
internal static string BuildPathToSettingsJsonFile(string workingDirectory)
{
return Path.Combine(workingDirectory, ".aspire", "settings.json");
}
internal static DirectoryInfo? GetLegacySettingsRootDirectory(FileInfo settingsFile)
{
if (!string.Equals(settingsFile.Name, AspireJsonConfiguration.FileName, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var settingsDirectory = settingsFile.Directory;
if (settingsDirectory is null || !string.Equals(settingsDirectory.Name, AspireJsonConfiguration.SettingsFolder, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return settingsDirectory.Parent;
}
internal static DirectoryInfo GetConfigRootDirectory(DirectoryInfo startDirectory)
{
ArgumentNullException.ThrowIfNull(startDirectory);
var configPath = FindNearestConfigFilePath(startDirectory);
if (configPath is null)
{
return startDirectory;
}
var configFile = new FileInfo(configPath);
// Legacy layout: <root>/.aspire/settings.json -> <root>
var legacyRoot = GetLegacySettingsRootDirectory(configFile);
if (legacyRoot is not null)
{
return legacyRoot;
}
// Modern layout: <root>/aspire.config.json -> <root>
return configFile.Directory is { Exists: true } configDirectory
? configDirectory
: startDirectory;
}
internal static DirectoryInfo GetWorkspaceAspireDirectory(DirectoryInfo startDirectory)
{
var configRoot = GetConfigRootDirectory(startDirectory);
return new DirectoryInfo(Path.Combine(configRoot.FullName, AspireJsonConfiguration.SettingsFolder));
}
internal static DirectoryInfo GetIntegrationCacheDirectory(DirectoryInfo startDirectory)
{
var workspaceAspireDirectory = GetWorkspaceAspireDirectory(startDirectory);
return new DirectoryInfo(Path.Combine(workspaceAspireDirectory.FullName, IntegrationCacheFolderName));
}
/// <summary>
/// Searches upward from <paramref name="startDirectory"/> for the nearest
/// <c>aspire.config.json</c> or legacy <c>.aspire/settings.json</c>.
/// </summary>
/// <returns>The full path to the config file, or <c>null</c> if none is found.</returns>
internal static string? FindNearestConfigFilePath(DirectoryInfo startDirectory)
{
var searchDir = startDirectory;
while (searchDir is not null)
{
var configPath = Path.Combine(searchDir.FullName, AspireConfigFile.FileName);
if (File.Exists(configPath))
{
return configPath;
}
var legacyPath = BuildPathToSettingsJsonFile(searchDir.FullName);
if (File.Exists(legacyPath))
{
return legacyPath;
}
searchDir = searchDir.Parent;
}
return null;
}
internal static bool TryLoadSettingsFile(string filePath, out IConfigurationRoot configuration)
{
configuration = new ConfigurationRoot([]);
if (!File.Exists(filePath))
{
return false;
}
try
{
var content = File.ReadAllText(filePath);
if (string.IsNullOrWhiteSpace(content))
{
return false;
}
var node = JsonNode.Parse(content, documentOptions: ParseOptions);
if (node is not JsonObject)
{
return false;
}
var cleanJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var bytes = System.Text.Encoding.UTF8.GetBytes(cleanJson);
configuration = new ConfigurationBuilder()
.AddJsonStream(new MemoryStream(bytes))
.Build();
return true;
}
catch (JsonException)
{
return false;
}
catch (InvalidDataException)
{
return false;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
/// <summary>
/// Returns JSON that <see cref="JsonSerializer"/> produced with the final newline it does not write.
/// </summary>
internal static string EnsureFinalNewline(string json)
{
// System.Text.Json already indents with Environment.NewLine, so serialized text needs no line break
// normalization and only lacks the trailing newline. Text that was not serialized, such as a file copied
// from the embedded templates, goes through NormalizeJsonFileContent instead.
//
// An existing final line break (LF or CRLF) is kept as is, so the result never ends with a second one.
if (json.EndsWith('\n'))
{
return json;
}
return json + Environment.NewLine;
}
/// <summary>
/// Returns JSON that was not serialized by the CLI, such as a template file copied from the embedded
/// resources, with the OS line break and exactly one trailing newline.
/// </summary>
internal static string NormalizeJsonFileContent(string json) => NormalizeJsonFileContent(json, Environment.NewLine);
/// <summary>
/// Returns JSON with <paramref name="newLine"/> as every line break and exactly one trailing
/// <paramref name="newLine"/>.
/// </summary>
internal static string NormalizeJsonFileContent(string json, string newLine)
{
// Config files get the OS line break, so they match what an editor on that OS produces and git
// (core.autocrlf, .gitattributes) can normalize them like any other text file. A file copied from the
// embedded templates keeps the line breaks of the checkout that built the CLI (CRLF from a Windows
// checkout with core.autocrlf, LF otherwise), and some templates have no trailing newline, so it is
// rewritten to match the files the CLI serializes.
//
// Only CRLF and LF are rewritten, not everything string.ReplaceLineEndings treats as a line break:
// JSON forbids raw control characters inside strings, so a raw CRLF or LF can only be whitespace
// between tokens and replacing it cannot change a value, whereas U+0085, U+2028 and U+2029 are legal
// raw inside a string.
var lf = json.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n') + "\n";
return newLine == "\n" ? lf : lf.Replace("\n", newLine, StringComparison.Ordinal);
}
/// <summary>
/// Serializes a JsonObject and writes it to a settings file, creating the directory if needed.
/// </summary>
internal static async Task WriteSettingsFileAsync(string filePath, JsonObject settings, CancellationToken cancellationToken = default)
{
var jsonContent = EnsureFinalNewline(JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject));
EnsureDirectoryExists(filePath);
await File.WriteAllTextAsync(filePath, jsonContent, cancellationToken);
}
internal static async Task WriteSettingsFileAtomicallyAsync(
string filePath, JsonObject settings, CancellationToken cancellationToken,
bool rejectSymbolicLinks = false, bool preserveOwnershipAndAcl = false,
Func<CancellationToken, Task>? beforeReplace = null)
{
ValidateSymbolicLinkPolicy(filePath, rejectSymbolicLinks);
// Resolve before choosing the sibling so replacement updates the target, not the link.
// This does not prevent filesystem races if links change during the write;
// concurrent link changes are not expected during normal CLI usage.
if (!rejectSymbolicLinks)
{
if (!PathNormalizer.TryResolveSymlinks(filePath, out var resolvedPath))
{
throw new IOException($"Unable to resolve symbolic links for settings file '{filePath}'.");
}
filePath = resolvedPath;
}
EnsureDirectoryExists(filePath);
// A sibling is required for atomic replacement even when the workspace and
// system temp directory are on different volumes. CreateNew rejects existing links/files.
var temporaryPath = Path.Combine(Path.GetDirectoryName(filePath)!, $".{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp");
var created = false;
var destinationExists = File.Exists(filePath);
string? unixMetadata = null;
try
{
UnixFileMode? destinationMode = null;
if (!OperatingSystem.IsWindows())
{
destinationMode = destinationExists ? File.GetUnixFileMode(filePath) : null;
}
await using (var stream = CreateSettingsStagingFile(temporaryPath, filePath, destinationExists, preserveOwnershipAndAcl))
{
created = true;
if (preserveOwnershipAndAcl && destinationExists && !OperatingSystem.IsWindows())
{
unixMetadata = await ReadUnixMetadataAsync(filePath, cancellationToken);
await PreserveUnixMetadataAsync(filePath, temporaryPath, stream, cancellationToken);
stream.SetLength(0);
}
// Serialize to text rather than to the stream so the final newline is added the same way as in the
// other settings writers. Encoding.UTF8.GetBytes writes no byte order mark, like File.WriteAllText.
var jsonContent = EnsureFinalNewline(JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject));
await stream.WriteAsync(Encoding.UTF8.GetBytes(jsonContent), cancellationToken);
}
if (!preserveOwnershipAndAcl && !OperatingSystem.IsWindows() && destinationMode is { } mode)
{
File.SetUnixFileMode(temporaryPath, mode);
}
if (unixMetadata is not null && unixMetadata != await ReadUnixMetadataAsync(filePath, cancellationToken))
{
throw SettingsWriteRefused(filePath);
}
if (beforeReplace is not null)
{
await beforeReplace(cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
if (OperatingSystem.IsWindows() && destinationExists)
{
// Replace preserves the destination DACL and other Windows file metadata.
File.Replace(temporaryPath, filePath, destinationBackupFileName: null);
}
else
{
// A tracked edit must not overwrite a new file created after validation.
// Untracked workspace writes retain their existing overwrite behavior.
File.Move(temporaryPath, filePath, overwrite: destinationExists || beforeReplace is null);
}
}
finally
{
if (created)
{
File.Delete(temporaryPath);
}
}
}
private static FileStream CreateSettingsStagingFile(string temporary, string destination, bool existed, bool preserveOwnershipAndAcl)
{
if (OperatingSystem.IsWindows())
{
if (preserveOwnershipAndAcl)
{
return CreateWindowsStagingFile(temporary, destination, existed);
}
if (existed)
{
return new FileInfo(temporary).Create(FileMode.CreateNew, FileSystemRights.Write, FileShare.None,
4096, FileOptions.Asynchronous, new FileInfo(destination).GetAccessControl(AccessControlSections.Access));
}
}
var options = new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = preserveOwnershipAndAcl ? FileAccess.ReadWrite : FileAccess.Write,
Share = FileShare.None,
Options = FileOptions.Asynchronous
};
if (!OperatingSystem.IsWindows())
{
// Protect the temporary contents from creation, even in a shared directory.
options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
}
return new FileStream(temporary, options);
}
[SupportedOSPlatform("windows")]
private static FileStream CreateWindowsStagingFile(string temporary, string destination, bool existed)
{
var security = existed
? new FileInfo(destination).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group)
: new FileSecurity();
if (!existed)
{
using var identity = WindowsIdentity.GetCurrent();
var owner = identity.User ?? throw new UnauthorizedAccessException();
security.SetOwner(owner);
security.AddAccessRule(new FileSystemAccessRule(owner, FileSystemRights.FullControl, AccessControlType.Allow));
}
// Freeze the original effective ACL before creating any credential-bearing copy.
// Parent inheritance must not grant readers access while validation awaits.
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true);
return new FileInfo(temporary).Create(FileMode.CreateNew, FileSystemRights.Read | FileSystemRights.Write,
FileShare.None, 4096, FileOptions.Asynchronous, security);
}
[UnsupportedOSPlatform("windows")]
private static async Task PreserveUnixMetadataAsync(
string source, string destination, FileStream stream, CancellationToken cancellationToken)
{
if (OperatingSystem.IsLinux())
{
// BusyBox cp replaces the staging inode and drops ACLs. Set metadata on the
// open, still-empty file instead, before writing any credential-bearing JSON.
var owner = await RunMetadataToolAsync(StatPath, ["-c", "%u:%g", source], source, cancellationToken);
// stat emits numeric "uid:gid\n", for example "1000:1000\n".
var parts = owner.Split(':', StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
!uint.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var uid) ||
!uint.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var gid) ||
ChangeOwner(stream.SafeFileHandle, uid, gid) != 0)
{
throw SettingsWriteRefused(source);
}
File.SetUnixFileMode(stream.SafeFileHandle, File.GetUnixFileMode(source));
var acl = ReadLinuxAccessAcl(source);
if (acl.Length > 0 && SetExtendedAttribute(stream.SafeFileHandle, "system.posix_acl_access", acl, (nuint)acl.Length, 0) != 0)
{
throw SettingsWriteRefused(source);
}
// A source without an ACL must not inherit named entries from the staging directory.
if (acl.Length == 0 && RemoveExtendedAttribute(stream.SafeFileHandle, "system.posix_acl_access") != 0 &&
Marshal.GetLastPInvokeError() is not (61 or 95))
{
throw SettingsWriteRefused(source);
}
return;
}
// macOS cp -p preserves ACLs but can report success without preserving uid/gid.
// https://www.manpagez.com/man/1/cp/
await RunMetadataToolAsync("/bin/cp", ["-p", source, destination], source, cancellationToken);
var owners = await RunMetadataToolAsync(StatPath,
[OperatingSystem.IsMacOS() ? "-f" : "-c", "%u:%g", source, destination], source, cancellationToken);
var values = owners.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (values.Length != 2 || values[0] != values[1] || File.GetUnixFileMode(source) != File.GetUnixFileMode(destination))
{
throw SettingsWriteRefused(source);
}
}
private static async Task<string> ReadUnixMetadataAsync(string path, CancellationToken cancellationToken)
{
// Compare actual ACLs rather than ctime, which can have coarse filesystem precision.
if (OperatingSystem.IsMacOS())
{
// -e includes access ACL entries; -n includes numeric ownership alongside the mode.
// https://github.com/apple-oss-distributions/file_cmds/blob/main/ls/ls.1
return await RunMetadataToolAsync("/bin/ls", ["-lnde", path], path, cancellationToken);
}
var metadata = await RunMetadataToolAsync(StatPath, ["-c", "%u:%g:%a", path], path, cancellationToken);
return metadata + Convert.ToHexString(ReadLinuxAccessAcl(path));
}
private static byte[] ReadLinuxAccessAcl(string path)
{
const string aclAttribute = "system.posix_acl_access";
var size = GetExtendedAttribute(path, aclAttribute, null, 0);
if (size < 0)
{
// Linux ENODATA/EOPNOTSUPP mean there is no POSIX ACL to preserve.
// https://man7.org/linux/man-pages/man2/getxattr.2.html
if (Marshal.GetLastPInvokeError() is 61 or 95)
{
return [];
}
throw SettingsWriteRefused(path);
}
var acl = new byte[checked((int)size)];
if (GetExtendedAttribute(path, aclAttribute, acl, (nuint)acl.Length) != size)
{
throw SettingsWriteRefused(path);
}
return acl;
}
// BusyBox installs stat in /bin. Keep metadata tools independent of the caller's PATH.
private static string StatPath => File.Exists("/usr/bin/stat") ? "/usr/bin/stat" : "/bin/stat";
private static async Task<string> RunMetadataToolAsync(
string executable, string[] arguments, string source, CancellationToken cancellationToken)
{
var start = new ProcessStartInfo(executable)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
var result = await ProcessCaptureRunner.RunAsync(start, TimeSpan.FromSeconds(10),
(process, token) => Task.WhenAll(process.StandardOutput.ReadToEndAsync(token), process.StandardError.ReadToEndAsync(token)),
() => [], NullLogger.Instance, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
if (result.FailureKind is not null || result.ExitCode != 0 ||
result.Capture.Length != 2 || !string.IsNullOrWhiteSpace(result.Capture[1]))
{
throw SettingsWriteRefused(source);
}
return result.Capture[0];
}
internal static void ValidateSymbolicLinkPolicy(string path, bool rejectSymbolicLinks)
{
if (rejectSymbolicLinks && new FileInfo(path).LinkTarget is not null)
{
throw SettingsWriteRefused(path);
}
}
private static IOException SettingsWriteRefused(string path)
=> new(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.ConfigurationWriteRefused, path));
[LibraryImport("libc", EntryPoint = "getxattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
private static partial nint GetExtendedAttribute(
string path, string name, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)] byte[]? value, nuint size);
[LibraryImport("libc", EntryPoint = "fsetxattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
private static partial int SetExtendedAttribute(
SafeFileHandle descriptor, string name, byte[] value, nuint size, int flags);
[LibraryImport("libc", EntryPoint = "fremovexattr", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
private static partial int RemoveExtendedAttribute(SafeFileHandle descriptor, string name);
[LibraryImport("libc", EntryPoint = "fchown", SetLastError = true)]
private static partial int ChangeOwner(SafeFileHandle descriptor, uint owner, uint group);
/// <summary>
/// Serializes a JsonObject and writes it to a settings file, creating the directory if needed.
/// </summary>
internal static void WriteSettingsFile(string filePath, JsonObject settings)
{
var jsonContent = EnsureFinalNewline(JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject));
EnsureDirectoryExists(filePath);
File.WriteAllText(filePath, jsonContent);
}
private static void EnsureDirectoryExists(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
if (directory is not null && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
private static JsonObject? AddSettingsFile(IConfigurationBuilder configuration, string filePath, bool persistNormalization)
{
// Proactively normalize the settings file to prevent duplicate key errors.
// This handles files corrupted by mixing colon and dot notation
// (e.g., both "features:key" flat entry and "features" nested object).
if (persistNormalization)
{
TryNormalizeSettingsFile(filePath);
}
// Pre-process the file to handle comments and trailing commas.
// Microsoft.Extensions.Configuration.Json doesn't support JSON comments,
// so we parse with comment support and load the clean JSON via stream.
try
{
var content = File.ReadAllText(filePath);
var node = JsonNode.Parse(content, documentOptions: ParseOptions);
if (!persistNormalization && node is JsonObject settings)
{
// Read-only commands must resolve the same flat/nested key collisions
// without rewriting configuration as a side effect of startup.
NormalizeSettings(settings);
}
if (node is not null)
{
var cleanJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var bytes = System.Text.Encoding.UTF8.GetBytes(cleanJson);
configuration.AddJsonStream(new MemoryStream(bytes));
return node as JsonObject;
}
}
catch (JsonException ex)
{
throw new InvalidOperationException(
string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, filePath, ex.Message),
ex);
}
configuration.AddJsonFile(filePath, optional: true);
return null;
}
/// <summary>
/// Normalizes a settings file by converting flat colon-separated keys to nested JSON objects.
/// </summary>
internal static bool TryNormalizeSettingsFile(string filePath)
{
try
{
var content = File.ReadAllText(filePath);
if (string.IsNullOrWhiteSpace(content))
{
return false;
}
var settings = JsonNode.Parse(content, documentOptions: ParseOptions)?.AsObject();
if (settings is null || !NormalizeSettings(settings))
{
return false;
}
WriteSettingsFile(filePath, settings);
return true;
}
catch
{
return false;
}
}
internal static bool NormalizeSettings(JsonObject settings)
{
// Only root keys use legacy flat notation. Nested keys such as
// {"environmentVariables":{"Logging:LogLevel:Default":"Debug"}} can be literal names.
var colonKeys = settings
.Where(static property => property.Key.Contains(':'))
.Select(static property => (property.Key, Value: property.Value?.DeepClone()))
.ToArray();
if (colonKeys.Length == 0)
{
return false;
}
// Rebuild once: removing flat keys individually shifts JsonObject's ordered storage.
// Keep the original non-flat nodes and their order.
var retainedProperties = settings.Where(static property => !property.Key.Contains(':')).ToArray();
settings.Clear();
foreach (var property in retainedProperties)
{
settings.Add(property.Key, property.Value);
}
var keyLookups = new Dictionary<JsonObject, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
string GetKey(JsonObject obj, string key)
{
if (!keyLookups.TryGetValue(obj, out var keys))
{
keys = new Dictionary<string, string>(obj.Count, StringComparer.OrdinalIgnoreCase);
foreach (var property in obj)
{
// Preserve the first matching spelling if case-equivalent keys already exist.
keys.TryAdd(property.Key, property.Key);
}
keyLookups.Add(obj, keys);
}
if (keys.TryGetValue(key, out var existing))
{
return existing;
}
keys.Add(key, key);
return key;
}
// For {"DOCS":{"custom":"local"},"docs":{"api":{"sitemapUrl":"nested"}}},
// the complete docs:api:sitemapUrl path belongs to the second section.
var propertiesByPath = new Dictionary<string, SettingsProperty>(StringComparer.OrdinalIgnoreCase);
void IndexProperties(JsonObject obj, string? prefix)
{
foreach (var property in obj)
{
var path = prefix is null ? property.Key : $"{prefix}:{property.Key}";
if (!propertiesByPath.TryGetValue(path, out var existing)
|| (existing.Parent[existing.Key] is null && property.Value is not null))
{
propertiesByPath[path] = new SettingsProperty(obj, property.Key);
}
if (property.Value is JsonObject section)
{
IndexProperties(section, path);
}
}
}
SettingsProperty? ResolveProperty(string path)
{
if (propertiesByPath.TryGetValue(path, out var existing))
{
return existing;
}
// Keep the first matching segment when creating a previously absent path.
var parts = path.Split(':');
var parent = settings;
for (var i = 0; i < parts.Length - 1; i++)
{
var key = GetKey(parent, parts[i]);
var child = parent[key];
if (child is null)
{
child = new JsonObject();
parent[key] = child;
}
if (child is not JsonObject section)
{
return null;
}
parent = section;
}
return new SettingsProperty(parent, GetKey(parent, parts[^1]));
}
IndexProperties(settings, prefix: null);
foreach (var (key, value) in colonKeys)
{
if (ResolveProperty(key) is not { } property || property.Parent[property.Key] is not null)
{
continue;
}
property.Parent[property.Key] = value;
propertiesByPath[key] = property;
if (value is JsonObject section)
{
// Later flat keys must also respect nested values introduced by this object.
IndexProperties(section, key);
}
}
return true;
}
private readonly record struct SettingsProperty(JsonObject Parent, string Key);
}