diff --git a/dev-proxy-abstractions/BaseLoader.cs b/dev-proxy-abstractions/BaseLoader.cs index 8d07d0e9..f16ead4a 100644 --- a/dev-proxy-abstractions/BaseLoader.cs +++ b/dev-proxy-abstractions/BaseLoader.cs @@ -24,13 +24,16 @@ private async Task ValidateFileContents(string fileContents) using var document = JsonDocument.Parse(fileContents, ProxyUtils.JsonDocumentOptions); var root = document.RootElement; - if (!root.TryGetProperty("$schema", out var schemaUrl)) + if (!root.TryGetProperty("$schema", out var schemaUrlElement)) { _logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath); return true; } - var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJson(fileContents, schemaUrl.GetString(), _logger); + var schemaUrl = schemaUrlElement.GetString() ?? ""; + ProxyUtils.ValidateSchemaVersion(schemaUrl, _logger); + var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJson(fileContents, schemaUrl, _logger); + if (!IsValid) { _logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors)); diff --git a/dev-proxy-abstractions/BaseProxyPlugin.cs b/dev-proxy-abstractions/BaseProxyPlugin.cs index 6190ab4a..064254af 100644 --- a/dev-proxy-abstractions/BaseProxyPlugin.cs +++ b/dev-proxy-abstractions/BaseProxyPlugin.cs @@ -83,6 +83,7 @@ public virtual async Task RegisterAsync() return (false, [string.Format(CultureInfo.InvariantCulture, "Configuration section {0} not found in configuration file", configSectionName)]); } + ProxyUtils.ValidateSchemaVersion(schemaUrl, Logger); return await ProxyUtils.ValidateJson(configSection.GetRawText(), schemaUrl, Logger); } catch (Exception ex) diff --git a/dev-proxy-abstractions/ProxyUtils.cs b/dev-proxy-abstractions/ProxyUtils.cs index 111ea129..177b78cb 100644 --- a/dev-proxy-abstractions/ProxyUtils.cs +++ b/dev-proxy-abstractions/ProxyUtils.cs @@ -320,7 +320,6 @@ public static string ProductVersion } } - return _productVersion; } } @@ -355,7 +354,7 @@ public static void MergeHeaders(IList allHeaders, IList jsonSerializerOptions; - + public static JsonDocumentOptions JsonDocumentOptions { get; } = new() { AllowTrailingCommas = true, @@ -425,7 +424,8 @@ public static List GetWildcardPatterns(List urls) } // For multiple URLs, find the common prefix - var paths = group.Select(url => { + var paths = group.Select(url => + { if (url.Contains('*')) { return url; @@ -518,10 +518,126 @@ public static string ReplaceVariables(string s, Dictionary varia return s1; } - public static string GetVersionString(string productVersion) + public static void ValidateSchemaVersion(string schemaUrl, ILogger logger) + { + if (string.IsNullOrWhiteSpace(schemaUrl)) + { + logger.LogDebug("Schema is empty, skipping schema version validation."); + return; + } + + try + { + var uri = new Uri(schemaUrl); + if (uri.Segments.Length > 2) + { + var schemaVersion = uri.Segments[^2] + .TrimStart('v') + .TrimEnd('/'); + var currentVersion = NormalizeVersion(ProductVersion); + if (CompareSemVer(currentVersion, schemaVersion) != 0) + { + var currentSchemaUrl = uri.ToString().Replace($"/v{schemaVersion}/", $"/v{currentVersion}/"); + logger.LogWarning("The version of schema does not match the installed Dev Proxy version, the expected schema is {schema}", currentSchemaUrl); + } + } + else + { + logger.LogDebug("Invalid schema {schemaUrl}, skipping schema version validation.", schemaUrl); + } + } + catch (Exception ex) + { + logger.LogWarning("Invalid schema {schemaUrl}, skipping schema version validation. Error: {error}", schemaUrl, ex.Message); + } + } + + /// + /// Compares two semantic versions strings. + /// + /// ver1 + /// ver2 + /// + /// Returns 0 if the versions are equal, -1 if a is less than b, and 1 if a is greater than b. + /// An invalid argument is "rounded" to a minimal version. + /// + public static int CompareSemVer(string? a, string? b) + { + if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) + { + return 0; + } + else if (string.IsNullOrWhiteSpace(a)) + { + return -1; + } + else if (string.IsNullOrWhiteSpace(b)) + { + return 1; + } + + a = a.TrimStart('v'); + b = b.TrimStart('v'); + + var aParts = a.Split('-'); + var bParts = b.Split('-'); + + var aParsed = Version.TryParse(aParts[0], out var aVersion); + var bParsed = Version.TryParse(bParts[0], out var bVersion); + if (!aParsed && !bParsed) + { + return 0; + } + else if (!aParsed) + { + return -1; + } + else if (!bParsed) + { + return 1; + } + + var compare = aVersion!.CompareTo(bVersion); + if (compare != 0) + { + // if the versions are different, return the comparison result + return compare; + } + + // if the versions are the same, compare the prerelease tags + if (aParts.Length == 1 && bParts.Length == 1) + { + // if both versions are stable, they are equal + return 0; + } + else if (aParts.Length == 1) + { + // if a is stable and b is not, a is greater + return 1; + } + else if (bParts.Length == 1) + { + // if b is stable and a is not, b is greater + return -1; + } + else if (aParts[1] == bParts[1]) + { + // if both versions are prerelease and the tags are the same, they are equal + return 0; + } + else + { + // if both versions are prerelease, b is greater + return -1; + } + } + + /// + /// Produces major.minor.patch version dropping a pre-release suffix. + /// + /// A version looks like "0.28.1", "0.28.1-alpha", "0.28.10-beta.1", "0.28.10-rc.1", or "0.28.0-preview-1", etc. + public static string NormalizeVersion(string version) { - return productVersion.Contains("-beta") - ? productVersion.Split("-")[0] - : productVersion; + return version.Split('-', StringSplitOptions.None)[0]; } } diff --git a/dev-proxy/CommandHandlers/ConfigNewCommandHandler.cs b/dev-proxy/CommandHandlers/ConfigNewCommandHandler.cs index fe24cf14..1b633a9b 100644 --- a/dev-proxy/CommandHandlers/ConfigNewCommandHandler.cs +++ b/dev-proxy/CommandHandlers/ConfigNewCommandHandler.cs @@ -17,7 +17,7 @@ public class VisualStudioCodeSnippet public static class ConfigNewCommandHandler { - private static readonly string snippetsFileUrl = $"https://aka.ms/devproxy/snippets/v{ProxyUtils.GetVersionString(ProxyUtils.ProductVersion)}"; + private static readonly string snippetsFileUrl = $"https://aka.ms/devproxy/snippets/v{ProxyUtils.NormalizeVersion(ProxyUtils.ProductVersion)}"; private static readonly string configFileSnippetName = "ConfigFile"; public static async Task CreateConfigFileAsync(string name, ILogger logger) diff --git a/dev-proxy/PluginLoader.cs b/dev-proxy/PluginLoader.cs index 75c545e9..6f608192 100644 --- a/dev-proxy/PluginLoader.cs +++ b/dev-proxy/PluginLoader.cs @@ -134,6 +134,16 @@ private PluginConfig PluginConfig { if (_pluginConfig == null) { + var schemaUrl = Configuration.GetValue("$schema"); + if (string.IsNullOrWhiteSpace(schemaUrl)) + { + _logger.LogDebug("No schema URL found in configuration file, skipping schema version validation"); + } + else + { + ProxyUtils.ValidateSchemaVersion(schemaUrl, _logger); + } + _pluginConfig = new PluginConfig(); if (isDiscover) diff --git a/dev-proxy/UpdateNotification.cs b/dev-proxy/UpdateNotification.cs index 71dcd074..b647bc78 100644 --- a/dev-proxy/UpdateNotification.cs +++ b/dev-proxy/UpdateNotification.cs @@ -45,7 +45,7 @@ internal static class UpdateNotification // -1 = latest release is greater // 0 = versions are equal // 1 = current version is greater - if (CompareSemVer(currentVersion, latestReleaseVersion) < 0) + if (ProxyUtils.CompareSemVer(currentVersion, latestReleaseVersion) < 0) { return latestRelease; } @@ -60,76 +60,6 @@ internal static class UpdateNotification } } - /// - /// Compares two semantic versions strings. - /// - /// ver1 - /// ver2 - /// Returns 0 if the versions are equal, -1 if a is less than b, and 1 if a is greater than b. - private static int CompareSemVer(string? a, string? b) - { - if (a == null && b == null) - { - return 0; - } - else if (a == null) - { - return -1; - } - else if (b == null) - { - return 1; - } - - if (a.StartsWith('v')) - { - a = a[1..]; - } - if (b.StartsWith('v')) - { - b = b[1..]; - } - - var aParts = a.Split('-'); - var bParts = b.Split('-'); - - var aVersion = new Version(aParts[0]); - var bVersion = new Version(bParts[0]); - - var compare = aVersion.CompareTo(bVersion); - if (compare != 0) - { - // if the versions are different, return the comparison result - return compare; - } - - // if the versions are the same, compare the prerelease tags - if (aParts.Length == 1 && bParts.Length == 1) - { - // if both versions are stable, they are equal - return 0; - } - else if (aParts.Length == 1) - { - // if a is stable and b is not, a is greater - return 1; - } - else if (bParts.Length == 1) - { - // if b is stable and a is not, b is greater - return -1; - } - else if (aParts[1] == bParts[1]) - { - // if both versions are prerelease and the tags are the same, they are equal - return 0; - } - else { - // if both versions are prerelease, b is greater - return -1; - } - } - private static async Task GetLatestReleaseAsync(ReleaseType releaseType) { var http = new HttpClient();