File: Configuration\ConfigSourceService.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.CommandLine;
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Commands;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Aspire.Shared.Telemetry;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Configuration;
 
internal sealed class ConfigSourceService(
    CliExecutionContext executionContext,
    IConfigurationService configurationService,
    IEnvironment environment,
    IInternalMicrosoftDetector detector,
    IGitClient git,
    TimeProvider timeProvider,
    IInteractionService interaction,
    ICliHostEnvironment hostEnvironment,
    ILogger<ConfigSourceService> logger) : IDisposable
{
    internal const string MicrosoftRepository = "https://msft.ghe.com/coreai/aspire-1p-init";
    private static readonly string[][] s_allowedSettings = [["docs", "llmsTxtUrl"], ["docs", "api", "sitemapUrl"]];
    private static readonly TimeSpan s_gitTimeout = TimeSpan.FromSeconds(30);
    private readonly Dictionary<ConfigSourceIdentity, ConfigSourceSnapshot> _snapshots = [];
    private string? _projectDirectory;
    private bool _projectChecked;
    private readonly List<(string Message, string? GitOutput)> _notifications = [];
    private readonly Lock _checksLock = new();
    private readonly CancellationTokenSource _stopping = new();
    private Task? _check;
 
    internal static readonly Option<bool?> s_useSourceOption = new("--use-config-source")
    {
        Description = ConfigSourceStrings.UseSourceOptionDescription,
        Recursive = true,
        Arity = ArgumentArity.ExactlyOne
    };
 
    /// <summary>
    /// Checks saved sources in the nearest local and global configuration files without applying changes.
    /// Runs in the background and defers advisory messages until command exit.
    /// </summary>
    /// <param name="projectDirectory">The directory of the project already resolved by the caller.</param>
    /// <param name="cancellationToken">A token to cancel the source update check.</param>
    /// <exception cref="InvalidOperationException">The service has already been used for a different project.</exception>
    public void CheckForConfigSourceUpdates(DirectoryInfo projectDirectory, CancellationToken cancellationToken)
    {
        lock (_checksLock)
        {
            EnsureSameProject(projectDirectory);
            // Project selection can reach this through multiple paths. Schedule at most one
            // advisory task per command, even after it completes. `update --config-source`
            // performs its own forced refresh and must not start a competing advisory check.
            if (_check is not null || _stopping.IsCancellationRequested || executionContext.Command is UpdateCommand { IsConfigSourceRefresh: true })
            {
                return;
            }
 
            _check = Task.Run(async () =>
            {
                using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _stopping.Token);
                try
                {
                    await CheckProjectAsync(projectDirectory, force: false, cancellation.Token);
                }
                catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
                {
                    logger.LogTrace("Configuration source check cancelled because the CLI is shutting down.");
                }
                catch (Exception ex)
                {
                    // Advisory work must never fail the command. Log only the type: source
                    // failures can contain private repository URLs or credentials.
                    logger.LogDebug("Non-fatal configuration source check failure ({FailureType}).", ex.GetType().Name);
                }
            }, CancellationToken.None);
        }
    }
 
    public async Task StopChecksAsync()
    {
        lock (_checksLock)
        {
            _stopping.Cancel();
        }
 
        // Cancel and join, rather than abandoning Git or credential-helper child processes.
        await WaitForChecksAsync();
    }
 
    internal Task WaitForChecksAsync()
    {
        lock (_checksLock)
        {
            return _check ?? Task.CompletedTask;
        }
    }
 
    public void NotifyIfUpdateAvailable()
    {
        (string Message, string? GitOutput)[] notifications;
        lock (_checksLock)
        {
            notifications = _notifications.ToArray();
            _notifications.Clear();
        }
        foreach (var pending in notifications)
        {
            interaction.DisplayMessage(KnownEmojis.Warning, pending.Message, consoleOverride: ConsoleOutput.Error);
            DisplayGitTimeoutOutput(interaction, pending.GitOutput);
        }
    }
 
    internal static void DisplayGitTimeoutOutput(IInteractionService interaction, string? output)
    {
        const int outputLimit = 64 * 1024;
        if (output is { Length: > 0 })
        {
            interaction.DisplayRawText(output.Length > outputLimit ? output[^outputLimit..] : output, ConsoleOutput.Error);
        }
    }
 
    public void Dispose()
    {
        StopChecksAsync().GetAwaiter().GetResult();
        _stopping.Dispose();
    }
 
    /// <summary>
    /// Resolves a candidate configuration source from the environment or employee detection,
    /// obtains consent, and acquires the accepted source.
    /// </summary>
    public async Task<ConfigSource?> ResolveAndAcquireConfigSourceAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var repository = environment.GetEnvironmentVariable(CliConfigNames.InitSourceUrl);
        var explicitSource = repository is not null;
        if (!explicitSource)
        {
            var detection = await detector.IsInternalMicrosoftMachineAsync(cancellationToken);
            if (!detection.IsInternalMicrosoft || detection.Outcome != InternalMicrosoftDetectorOutcome.Detected)
            {
                if (parseResult.GetValue(s_useSourceOption) is true)
                {
                    throw new ConfigSourceException(string.Format(CultureInfo.CurrentCulture,
                        ConfigSourceStrings.NoCandidate, CliConfigNames.InitSourceUrl));
                }
                return null;
            }
            repository = MicrosoftRepository;
        }
 
        var source = new ConfigSource
        {
            Repository = repository!,
            Path = explicitSource
                ? environment.GetEnvironmentVariable(CliConfigNames.InitSourcePath) ?? AspireConfigFile.FileName
                : AspireConfigFile.FileName,
            Ref = explicitSource ? environment.GetEnvironmentVariable(CliConfigNames.InitSourceRef) : null
        };
        source.Validate();
        interaction.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, ConfigSourceStrings.Candidate,
            source.Repository, source.Path, source.Ref ?? ConfigSourceStrings.DefaultBranch,
            explicitSource ? CliConfigNames.InitSourceUrl : ConfigSourceStrings.EmployeeOrigin));
 
        var accepted = parseResult.GetValue(s_useSourceOption);
        if (accepted is null)
        {
            if (!hostEnvironment.SupportsInteractiveInput)
            {
                throw new ConfigSourceException(ConfigSourceStrings.ConsentRequired);
            }
            // Boolean choices let the prompt select default(bool), highlighting decline
            // even when accept is first. String choices preserve the first-item default.
            var selection = await interaction.PromptForSelectionAsync(ConfigSourceStrings.UseSourcePrompt,
                new[] { ConfigSourceStrings.Accept, ConfigSourceStrings.Decline }, choice => choice,
                cancellationToken: cancellationToken);
            accepted = selection == ConfigSourceStrings.Accept;
        }
        if (!accepted.Value)
        {
            return null;
        }
 
        await AcquireAsync(source, force: false, cancellationToken);
        return source;
    }
 
    public async Task<ConfigSource?> SelectForInitializationAsync(
        DirectoryInfo projectDirectory, ParseResult parseResult, CancellationToken cancellationToken)
    {
        EnsureSameProject(projectDirectory);
        var path = Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName);
        var configuration = await ReadProjectConfigurationAsync(path, cancellationToken);
        if (configuration is not null && TryGetValue(configuration, [ConfigSource.SectionName], out _))
        {
            CheckForConfigSourceUpdates(projectDirectory, cancellationToken);
            return null;
        }
 
        return await ResolveAndAcquireConfigSourceAsync(parseResult, cancellationToken);
    }
 
    public async Task MaterializeAsync(ConfigSource source, DirectoryInfo projectDirectory, CancellationToken cancellationToken)
    {
        EnsureSameProject(projectDirectory);
        var snapshot = await AcquireAsync(source, force: false, cancellationToken);
        await ApplySnapshotAsync(source, snapshot, Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName), cancellationToken);
    }
 
    private async Task ApplySnapshotAsync(ConfigSource source, ConfigSourceSnapshot snapshot, string path, CancellationToken cancellationToken)
    {
        using var configLock = await ConfigurationHelper.TryAcquireWorkspaceConfigLockAsync(
            executionContext, new FileInfo(path), logger, cancellationToken)
            ?? throw new ConfigSourceException(ConfigSourceStrings.ConfigLockFailed);
        // Re-read after fetching and under the workspace-writer lock, so intervening
        // changes to unrelated settings survive this read/merge/write operation.
        // Users should not edit aspire.config.json while the command is running.
        // Concurrent edits can cause unexpected behavior, such as restoring a
        // configSource binding removed while fetching.
        var configuration = await ReadConfigurationFileAsync(path, cancellationToken) ?? new JsonObject();
        if (ReadBinding(configuration) is { } current && !ConfigSource.HasSameIdentity(current, source))
        {
            throw new ConfigSourceException(ConfigSourceStrings.BindingChanged);
        }
        var previous = configuration.ToJsonString();
        ConfigurationHelper.NormalizeSettings(configuration);
        foreach (var setting in s_allowedSettings)
        {
            if (TryGetValue(snapshot.Configuration, setting, out var value))
            {
                SetValue(configuration, setting, value);
            }
        }
        var binding = new ConfigSource
        {
            Repository = source.Repository,
            Path = source.Path,
            Ref = source.Ref,
            AppliedVersion = snapshot.Metadata.Version
        };
        configuration[GetKey(configuration, ConfigSource.SectionName)] = JsonSerializer.SerializeToNode(binding, JsonSourceGenerationContext.Default.ConfigSource);
        var changed = previous != configuration.ToJsonString();
        if (changed)
        {
            try
            {
                // A preserved nested literal key can collide with a newly supplied leaf:
                // {"docs":{"api:sitemapUrl":"local","api":{"sitemapUrl":"remote"}}}.
                // Reject the merge rather than persisting settings startup cannot load.
                using var parsed = new ConfigurationManager();
                using var stream = new MemoryStream(Encoding.UTF8.GetBytes(configuration.ToJsonString()));
                parsed.AddJsonStream(stream);
            }
            catch (FormatException ex)
            {
                throw new ConfigSourceException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, path, ex.Message));
            }
            await ConfigurationHelper.WriteSettingsFileAtomicallyAsync(path, configuration, cancellationToken);
        }
        lock (_checksLock)
        {
            _projectChecked = true;
        }
        interaction.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, ConfigSourceStrings.UseNotice,
            source.Repository, source.Path, snapshot.Metadata.Commit,
            changed ? ConfigSourceStrings.Refreshed : ConfigSourceStrings.RefreshedUnchanged, path), consoleOverride: ConsoleOutput.Error);
    }
 
    public async Task CheckProjectAsync(DirectoryInfo directory, bool force, CancellationToken cancellationToken)
    {
        EnsureSameProject(directory);
        if (!force && executionContext.Command is UpdateCommand { IsConfigSourceRefresh: true })
        {
            // Project selection must not perform an automatic fetch immediately before
            // refresh-only mode makes its one unconditional fetch.
            return;
        }
        try
        {
            lock (_checksLock)
            {
                if (!force && _projectChecked)
                {
                    return;
                }
                _projectChecked = true;
            }
            var foundBinding = false;
            var refreshedSources = new HashSet<ConfigSourceIdentity>();
            var sources = new List<ConfigSource>();
            var configPaths = new[]
            {
                ConfigurationHelper.FindNearestConfigFilePath(directory),
                configurationService.GetSettingsFilePath(isGlobal: true)
            }.OfType<string>().Distinct(StringComparers.FileSystemPath);
            // Use the same local/global files as settings lookup, even if the nearest
            // file has no binding. Await each source so Git terminal input cannot compete.
            foreach (var configPath in configPaths)
            {
                cancellationToken.ThrowIfCancellationRequested();
                var source = await ReadBindingAsync(configPath, cancellationToken);
                if (source is null)
                {
                    continue;
                }
                foundBinding = true;
                var identity = new ConfigSourceIdentity(source.Repository, source.Path, source.Ref);
                if (force)
                {
                    var snapshot = await AcquireAsync(source, force: refreshedSources.Add(identity), cancellationToken);
                    await ApplySnapshotAsync(source, snapshot, configPath, cancellationToken);
                }
                else
                {
                    sources.Add(source);
                }
            }
            foreach (var group in sources.GroupBy(source => new ConfigSourceIdentity(source.Repository, source.Path, source.Ref)))
            {
                if (group.Any(source => string.IsNullOrEmpty(source.AppliedVersion)))
                {
                    ReportOnce(ConfigSourceStrings.AppliedVersionMissing);
                }
                var versionedSources = group.Where(source => !string.IsNullOrEmpty(source.AppliedVersion)).ToArray();
                if (versionedSources.Length > 0)
                {
                    var observed = await CheckVersionAsync(versionedSources, cancellationToken);
                    if (versionedSources.Any(source => observed.Version != source.AppliedVersion))
                    {
                        ReportOnce(ConfigSourceStrings.UpdateAvailable);
                    }
                }
            }
            if (force && !foundBinding)
            {
                interaction.DisplayMessage(KnownEmojis.Information, ConfigSourceStrings.NoBinding, consoleOverride: ConsoleOutput.Error);
            }
        }
        catch (Exception ex) when (!force && ex is ConfigSourceException or JsonException or IOException or UnauthorizedAccessException or TimeoutException)
        {
            ReportCheckFailure(ex);
        }
    }
 
    private void ReportCheckFailure(Exception exception)
    {
        logger.LogDebug("Configuration source version check failed ({FailureType}).", exception.GetType().Name);
        var message = exception is ConfigSourceException
            ? string.Format(CultureInfo.CurrentCulture, ConfigSourceStrings.CheckFailed, exception.Message)
            : ConfigSourceStrings.CheckFailedWithoutDetails;
        ReportOnce(message, (exception as ConfigSourceException)?.GitOutput);
    }
 
    internal async Task<ConfigSource?> ReadBindingAsync(string configPath, CancellationToken cancellationToken)
    {
        return ReadBinding(await ReadProjectConfigurationAsync(configPath, cancellationToken));
    }
 
    private async Task<JsonObject?> ReadProjectConfigurationAsync(string configPath, CancellationToken cancellationToken)
    {
        using var configLock = await ConfigurationHelper.TryAcquireWorkspaceConfigLockAsync(
            executionContext, new FileInfo(configPath), logger, cancellationToken);
        return await ReadConfigurationFileAsync(configPath, cancellationToken);
    }
 
    private static async Task<JsonObject?> ReadConfigurationFileAsync(string path, CancellationToken cancellationToken)
    {
        return File.Exists(path)
            ? JsonNode.Parse(await File.ReadAllTextAsync(path, cancellationToken),
                documentOptions: ConfigurationHelper.ParseOptions) as JsonObject
                ?? throw new ConfigSourceException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfigurationFileMustBeJsonObject, path))
            : null;
    }
 
    private static ConfigSource? ReadBinding(JsonObject? config)
    {
        if (config is null)
        {
            return null;
        }
        var sourceProperty = config.FirstOrDefault(p => p.Key.Equals(ConfigSource.SectionName, StringComparison.OrdinalIgnoreCase));
        if (sourceProperty.Key is null)
        {
            return null;
        }
        try
        {
            var source = sourceProperty.Value?.Deserialize(JsonSourceGenerationContext.Default.ConfigSource)
                ?? throw new ConfigSourceException(ConfigSourceStrings.InvalidBinding);
            if (source.Repository is null || source.Path is null)
            {
                throw new ConfigSourceException(ConfigSourceStrings.InvalidBinding);
            }
            source.Validate();
            return source;
        }
        catch (JsonException)
        {
            throw new ConfigSourceException(ConfigSourceStrings.InvalidBinding);
        }
    }
 
    internal string GetCacheDirectory(ConfigSource source)
    {
        var key = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes($"{source.Repository}\n{source.Path}\n{source.Ref ?? ""}")).ToString("x16", CultureInfo.InvariantCulture);
        return Path.Combine(executionContext.AspireHomeDirectory.FullName, "configsources", key);
    }
 
    internal async Task<ConfigSourceSnapshot> AcquireAsync(ConfigSource source, bool force, CancellationToken cancellationToken)
    {
        source.Validate();
        var key = new ConfigSourceIdentity(source.Repository, source.Path, source.Ref);
        lock (_checksLock)
        {
            if (!force && _snapshots.TryGetValue(key, out var existing))
            {
                return existing;
            }
        }
 
        var cacheDirectory = GetCacheDirectory(source);
        DirectoryHelper.CreateWithOwnerOnlyPermissions(Path.GetDirectoryName(cacheDirectory)!);
        DirectoryHelper.CreateWithOwnerOnlyPermissions(cacheDirectory);
        using var cacheLock = await FileLock.AcquireAsync(Path.Combine(cacheDirectory, "cache.lock"), cancellationToken);
        var pointer = Path.Combine(cacheDirectory, "current");
        var snapshotName = Guid.NewGuid().ToString("N");
        var snapshotDirectory = Path.Combine(cacheDirectory, snapshotName);
        var temporaryPointer = Path.Combine(cacheDirectory, $"{snapshotName}.tmp");
        DirectoryHelper.CreateWithOwnerOnlyPermissions(snapshotDirectory);
        var published = false;
        try
        {
            var identity = new ConfigSource
            {
                Repository = source.Repository,
                Path = source.Path,
                Ref = source.Ref
            };
            var (commit, version, configuration) = await AcquireRepositoryAsync(identity, snapshotDirectory, cancellationToken);
            ConfigurationHelper.NormalizeSettings(configuration);
            var now = timeProvider.GetUtcNow();
            var snapshot = new ConfigSourceSnapshot(new(identity, commit, version, now), configuration);
            await File.WriteAllTextAsync(Path.Combine(snapshotDirectory, AspireConfigFile.FileName), configuration.ToJsonString(), cancellationToken);
            await File.WriteAllTextAsync(Path.Combine(snapshotDirectory, "metadata.json"),
                JsonSerializer.Serialize(snapshot.Metadata, JsonSourceGenerationContext.Default.ConfigSourceMetadata), cancellationToken);
            await File.WriteAllTextAsync(temporaryPointer, snapshotName, cancellationToken);
            await WriteCheckMetadataAsync(cacheDirectory,
                new(identity, commit, version, now, source.AppliedVersion is null ? [] : [source.AppliedVersion]), cancellationToken);
            cancellationToken.ThrowIfCancellationRequested();
            // The immutable snapshot contains both files. Only this pointer is replaced, so
            // failures or cancellation cannot publish half a configuration/metadata pair.
            File.Move(temporaryPointer, pointer, overwrite: true);
            published = true;
            lock (_checksLock)
            {
                _snapshots[key] = snapshot;
            }
            // Callers retain configuration in memory. After publication, older snapshots
            // can be removed while the cache lock excludes other acquisitions.
            try
            {
                foreach (var directory in Directory.GetDirectories(cacheDirectory))
                {
                    if (directory != snapshotDirectory && Guid.TryParseExact(Path.GetFileName(directory), "N", out _))
                    {
                        DeleteAcquisitionDirectory(directory);
                    }
                }
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogDebug("Configuration source snapshot cleanup failed ({FailureType}).", ex.GetType().Name);
            }
            return snapshot;
        }
        finally
        {
            File.Delete(temporaryPointer);
            if (!published)
            {
                DeleteAcquisitionDirectory(snapshotDirectory);
            }
        }
    }
 
    private async Task<ConfigSourceCheckMetadata> CheckVersionAsync(IReadOnlyList<ConfigSource> sources, CancellationToken cancellationToken)
    {
        var source = sources[0];
        var cacheDirectory = GetCacheDirectory(source);
        DirectoryHelper.CreateWithOwnerOnlyPermissions(Path.GetDirectoryName(cacheDirectory)!);
        DirectoryHelper.CreateWithOwnerOnlyPermissions(cacheDirectory);
        using var cacheLock = await FileLock.AcquireAsync(Path.Combine(cacheDirectory, "cache.lock"), cancellationToken);
        var path = Path.Combine(cacheDirectory, "check.json");
        if (File.Exists(path))
        {
            ConfigSourceCheckMetadata? previous;
            var json = await File.ReadAllTextAsync(path, cancellationToken);
            try
            {
                previous = JsonSerializer.Deserialize(json, JsonSourceGenerationContext.Default.ConfigSourceCheckMetadata);
            }
            catch (JsonException)
            {
                throw new ConfigSourceException(ConfigSourceStrings.InvalidCache);
            }
            if (previous is null || previous.Source is null || !ConfigSource.HasSameIdentity(previous.Source, source)
                || string.IsNullOrEmpty(previous.Commit) || string.IsNullOrEmpty(previous.Version))
            {
                throw new ConfigSourceException(ConfigSourceStrings.InvalidCache);
            }
            var age = timeProvider.GetUtcNow() - previous.CheckedAt;
            // A different app can have applied a version newer than this shared observation.
            // Reuse a mismatch only when that applied version was actually checked against it.
            // Older metadata without the version set is revalidated once.
            if (age >= TimeSpan.Zero && age < TimeSpan.FromHours(24)
                && sources.All(binding => previous.Version == binding.AppliedVersion
                    || previous.ComparedAppliedVersions?.Contains(binding.AppliedVersion, StringComparer.Ordinal) is true))
            {
                return previous;
            }
        }
        var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-config-source-check-");
        try
        {
            var identity = new ConfigSource
            {
                Repository = source.Repository,
                Path = source.Path,
                Ref = source.Ref
            };
            var (commit, version) = await CheckRepositoryAsync(identity, temporaryDirectory.FullName, cancellationToken);
            var comparedAppliedVersions = sources.Select(binding => binding.AppliedVersion).OfType<string>().Distinct(StringComparer.Ordinal).ToArray();
            var metadata = new ConfigSourceCheckMetadata(identity, commit, version, timeProvider.GetUtcNow(), comparedAppliedVersions);
            await WriteCheckMetadataAsync(cacheDirectory, metadata, cancellationToken);
            return metadata;
        }
        finally
        {
            DeleteAcquisitionDirectory(temporaryDirectory.FullName);
        }
    }
 
    private async Task<(string Commit, string Version, JsonObject Configuration)> AcquireRepositoryAsync(ConfigSource source, string directory, CancellationToken cancellationToken)
    {
        using var deadline = new CancellationTokenSource(s_gitTimeout, timeProvider);
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
        try
        {
            var (repositoryDirectory, commit, version) = await FetchVersionAsync(source, directory, allowInteractive: true, timeout.Token);
            var size = await git.GetObjectSizeAsync(repositoryDirectory, version, GitCommandMode.Interactive, timeout.Token);
            if (size is null or > 4 * 1024 * 1024)
            {
                throw new ConfigSourceException(ConfigSourceStrings.InvalidConfiguration);
            }
 
            var json = await git.ReadBlobAsync(repositoryDirectory, version, GitCommandMode.Interactive, timeout.Token);
            try
            {
                var configuration = JsonNode.Parse(json, documentOptions: ConfigurationHelper.ParseOptions) as JsonObject
                    ?? throw new ConfigSourceException(ConfigSourceStrings.InvalidConfiguration);
                // Validate the known settings as well as JSON syntax. Unknown content settings
                // are retained for future schemas, but a source cannot enable another source.
                if (configuration.Any(p => p.Key.Equals(ConfigSource.SectionName, StringComparison.OrdinalIgnoreCase)
                    || p.Key.StartsWith(ConfigSource.SectionName + ":", StringComparison.OrdinalIgnoreCase)
                    || p.Key.StartsWith(ConfigSource.SectionName + ".", StringComparison.OrdinalIgnoreCase)))
                {
                    throw new ConfigSourceException(ConfigSourceStrings.NestedSource);
                }
                // Use the same root-level flat-key compatibility as local settings before
                // validating duplicate paths and typed values such as "features:enabled".
                ConfigurationHelper.NormalizeSettings(configuration);
                using var parsed = new ConfigurationManager();
                using var stream = new MemoryStream(Encoding.UTF8.GetBytes(configuration.ToJsonString()));
                parsed.AddJsonStream(stream);
                _ = configuration.Deserialize(JsonSourceGenerationContext.Default.AspireConfigFile);
                return (commit, version, configuration);
            }
            catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException)
            {
                throw new ConfigSourceException(ConfigSourceStrings.InvalidConfiguration);
            }
        }
        catch (GitCommandException ex)
        {
            throw TranslateGitFailure(ex);
        }
        catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            throw new ConfigSourceException(ConfigSourceStrings.GitTimeout)
            {
                GitOutput = (ex as GitProcessCanceledException)?.Output
            };
        }
    }
 
    private async Task<(string Commit, string Version)> CheckRepositoryAsync(ConfigSource source, string directory, CancellationToken cancellationToken)
    {
        using var deadline = new CancellationTokenSource(s_gitTimeout, timeProvider);
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
        try
        {
            var (_, commit, version) = await FetchVersionAsync(source, directory, allowInteractive: false, timeout.Token);
            return (commit, version);
        }
        catch (GitCommandException ex)
        {
            throw TranslateGitFailure(ex);
        }
        catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            throw new ConfigSourceException(ConfigSourceStrings.GitTimeout)
            {
                GitOutput = (ex as GitProcessCanceledException)?.Output
            };
        }
    }
 
    private async Task<(GitRepositoryLocation Directory, string Commit, string Version)> FetchVersionAsync(
        ConfigSource source, string directory, bool allowInteractive, CancellationToken cancellationToken)
    {
        var workingDirectory = new DirectoryInfo(directory);
        var mode = allowInteractive ? GitCommandMode.Interactive : GitCommandMode.Unattended;
        string resolvedCommit;
        try
        {
            resolvedCommit = await git.ResolveReferenceAsync(source.Repository, source.GetGitRef(), workingDirectory, mode, cancellationToken);
        }
        catch (GitReferenceException)
        {
            throw new ConfigSourceException(ConfigSourceStrings.RepositoryUnavailable);
        }
        // Git cannot fetch between SHA-1 and SHA-256 repositories. Resolve the selector
        // before initialization so branch/default selectors also choose the source format.
        // https://git-scm.com/docs/git-init#Documentation/git-init.txt---object-formatltformatgt
        var repositoryDirectory = new GitRepositoryLocation(new DirectoryInfo(Path.Combine(directory, "repository.git")), IsBare: true);
        await git.InitializeAsync(workingDirectory, repositoryDirectory,
            resolvedCommit.Length == 64 ? "sha256" : "sha1", GitCommandMode.Interactive, cancellationToken);
        // Fetch accepts both a fully qualified branch ref and a full commit ID. Unlike
        // clone --branch, this also acquires commits outside the default branch's shallow history.
        // https://git-scm.com/docs/git-fetch
        await git.FetchAsync(repositoryDirectory, source.Repository, source.GetGitRef(),
            allowInteractive ? GitCommandMode.Interactive : GitCommandMode.Unattended, cancellationToken);
        var commit = await git.GetCommitAsync(repositoryDirectory, "FETCH_HEAD^{commit}", GitCommandMode.Interactive, cancellationToken);
        var entry = await git.GetFileAsync(repositoryDirectory, commit, source.Path, GitCommandMode.Interactive, cancellationToken);
        if (entry is not { Mode: GitTreeEntry.RegularFileMode or GitTreeEntry.ExecutableFileMode, Type: "blob" })
        {
            throw new ConfigSourceException(ConfigSourceStrings.MissingFile);
        }
 
        return (repositoryDirectory, commit, entry.ObjectId);
    }
 
    private static ConfigSourceException TranslateGitFailure(GitCommandException exception) =>
        new(exception.Failure switch
        {
            GitFailure.Unavailable => ConfigSourceStrings.GitNotFound,
            GitFailure.AuthenticationFailed => ConfigSourceStrings.AuthenticationFailed,
            _ => ConfigSourceStrings.RepositoryUnavailable
        });
 
    private static Task WriteCheckMetadataAsync(string directory, ConfigSourceCheckMetadata metadata, CancellationToken cancellationToken)
    {
        var json = JsonSerializer.SerializeToNode(metadata, JsonSourceGenerationContext.Default.ConfigSourceCheckMetadata)!.AsObject();
        return ConfigurationHelper.WriteSettingsFileAtomicallyAsync(Path.Combine(directory, "check.json"), json, cancellationToken);
    }
 
    private void ReportOnce(string message, string? gitOutput = null)
    {
        lock (_checksLock)
        {
            if (!_notifications.Contains((message, gitOutput)))
            {
                _notifications.Add((message, gitOutput));
            }
        }
    }
 
    private void EnsureSameProject(DirectoryInfo directory)
    {
        var path = Path.TrimEndingDirectorySeparator(directory.FullName);
        lock (_checksLock)
        {
            if (_projectDirectory is not null && !StringComparers.FileSystemPath.Equals(_projectDirectory, path))
            {
                throw new InvalidOperationException("A configuration source service instance cannot be used for multiple projects.");
            }
            _projectDirectory = path;
        }
    }
 
    private void DeleteAcquisitionDirectory(string directory)
    {
        try
        {
            // Git object files can be read-only on Windows, even after a failed fetch.
            foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
            {
                File.SetAttributes(file, File.GetAttributes(file) & ~FileAttributes.ReadOnly);
            }
            Directory.Delete(directory, recursive: true);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogDebug("Configuration source directory cleanup failed ({FailureType}).", ex.GetType().Name);
        }
    }
 
    private static string GetKey(JsonObject configuration, string key)
        => configuration.Select(p => p.Key).FirstOrDefault(p => p.Equals(key, StringComparison.OrdinalIgnoreCase)) ?? key;
 
    private static bool TryGetValue(JsonObject configuration, string[] path, out JsonNode? value)
    {
        if (FindPropertyParent(configuration, path) is { } parent)
        {
            value = parent[GetKey(parent, path[^1])];
            return true;
        }
 
        value = null;
        return false;
    }
 
    private static JsonObject? FindPropertyParent(JsonObject configuration, ReadOnlySpan<string> path)
    {
        if (path.Length == 1)
        {
            return configuration.ContainsKey(GetKey(configuration, path[0])) ? configuration : null;
        }
 
        // IConfiguration combines sections such as {"DOCS":{"llmsTxtUrl":"a"},"docs":{"api":{"sitemapUrl":"b"}}}.
        // Search every matching section so an earlier partial section cannot hide a later leaf.
        foreach (var (key, value) in configuration)
        {
            if (key.Equals(path[0], StringComparison.OrdinalIgnoreCase)
                && value is JsonObject section
                && FindPropertyParent(section, path[1..]) is { } parent)
            {
                return parent;
            }
        }
 
        return null;
    }
 
    private static void SetValue(JsonObject configuration, string[] path, JsonNode? value)
    {
        if (FindPropertyParent(configuration, path) is { } parent)
        {
            parent[GetKey(parent, path[^1])] = value?.DeepClone();
            return;
        }
 
        var current = configuration;
        foreach (var segment in path[..^1])
        {
            var key = GetKey(current, segment);
            if (current[key] is not JsonObject nested)
            {
                nested = new JsonObject();
                current[key] = nested;
            }
            current = nested;
        }
        current[GetKey(current, path[^1])] = value?.DeepClone();
    }
 
    private readonly record struct ConfigSourceIdentity(string Repository, string Path, string? Ref);
}