| File: Configuration\ConfigSource.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.Json.Nodes; using System.Text.Json.Serialization; using Aspire.Cli.Resources; namespace Aspire.Cli.Configuration; internal sealed class ConfigSource { public const string SectionName = "configSource"; // General configuration loading must tolerate incomplete advisory bindings. // Validate rejects missing values before a source operation uses them. [JsonPropertyName("repository")] public string Repository { get; init; } = string.Empty; [JsonPropertyName("path")] public string Path { get; init; } = string.Empty; [JsonPropertyName("ref")] public string? Ref { get; init; } [JsonPropertyName("appliedVersion")] public string? AppliedVersion { get; init; } internal string GetGitRef() { if (Ref is null) { return "HEAD"; } return IsCommit(Ref) || Ref.StartsWith("refs/heads/", StringComparison.Ordinal) ? Ref : $"refs/heads/{Ref}"; } public static bool HasSameIdentity(ConfigSource first, ConfigSource second) => first.Repository == second.Repository && first.Path == second.Path && first.Ref == second.Ref; public void Validate() { if (string.IsNullOrWhiteSpace(Repository) || string.IsNullOrWhiteSpace(Path)) { throw new ConfigSourceException(ConfigSourceStrings.InvalidBinding); } // Git accepts https://host/repo, ssh://git@host/repo, git@host:repo, and local paths. // Require fully qualified local paths so a saved binding cannot change repositories // when a later command runs in a different working directory. var isLocal = System.IO.Path.IsPathFullyQualified(Repository); var isUrl = Uri.TryCreate(Repository, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "ssh" && !string.IsNullOrEmpty(uri.Host) && string.IsNullOrEmpty(uri.Query) && string.IsNullOrEmpty(uri.Fragment) && (uri.Scheme == "ssh" ? !uri.UserInfo.Contains(':') : string.IsNullOrEmpty(uri.UserInfo)); var at = Repository.IndexOf('@'); var colon = Repository.IndexOf(':'); var isScp = at > 0 && colon > at + 1 && colon < Repository.Length - 1 && !Repository.Contains("://", StringComparison.Ordinal) && !Repository[..colon].Contains('/') && !Repository.Contains('\\') && Repository[0] != '-' && Repository[..at].All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-') && Uri.CheckHostName(Repository[(at + 1)..colon]) != UriHostNameType.Unknown; if (Repository.Any(char.IsControl) || (!isLocal && (Repository.Any(char.IsWhiteSpace) || Repository.Contains('?') || Repository.Contains('#') || Repository.StartsWith('-') || (!isUrl && !isScp)))) { throw new ConfigSourceException(ConfigSourceStrings.InvalidRepository); } if (Path.Contains('\\') || Path.Contains(':') || Path.Any(char.IsControl) || Path.Split('/').Any(p => p is "" or "." or "..")) { throw new ConfigSourceException(ConfigSourceStrings.InvalidPath); } if (Ref is not null && !IsCommit(Ref)) { var branch = Ref.StartsWith("refs/heads/", StringComparison.Ordinal) ? Ref["refs/heads/".Length..] : Ref; // Branch selectors follow git-check-ref-format rather than accepting revision // expressions, refspec operators, tags, or command-line options. // https://git-scm.com/docs/git-check-ref-format if (branch is "" or "@" or "HEAD" || branch.StartsWith('-') || branch.StartsWith("refs/", StringComparison.Ordinal) || branch.Contains("..", StringComparison.Ordinal) || branch.Contains("@{", StringComparison.Ordinal) || branch.Any(c => char.IsWhiteSpace(c) || char.IsControl(c) || c is '\\' or ':' or '?' or '*' or '[' or '~' or '^') || branch.Split('/').Any(part => part.Length == 0 || part.StartsWith('.') || part.EndsWith('.') || part.EndsWith(".lock", StringComparison.OrdinalIgnoreCase))) { throw new ConfigSourceException(ConfigSourceStrings.InvalidRef); } } } private static bool IsCommit(string value) => value.Length is 40 or 64 && value.All(char.IsAsciiHexDigit); } internal sealed record ConfigSourceMetadata(ConfigSource Source, string Commit, string Version, DateTimeOffset FetchedAt); internal sealed record ConfigSourceCheckMetadata( ConfigSource Source, string Commit, string Version, DateTimeOffset CheckedAt, string[]? ComparedAppliedVersions); internal sealed record ConfigSourceSnapshot(ConfigSourceMetadata Metadata, JsonObject Configuration); internal sealed class ConfigSourceException(string message) : Exception(message) { // Captured fetch diagnostics are for user display only, never logs or telemetry. public string? GitOutput { get; init; } }