diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index 2ba1eccec910..19f128d79850 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -111,22 +111,33 @@ public static bool IsValidChannelFormat(string channel) } // The only two forms that include a '-' are: - // * "-daily" (e.g. "10.0-daily", "10.0.1xx-daily"). - // Daily only applies to partial versions; "10.0.103-daily" is rejected - // because a specific patch is already specific. + // * "-daily" (e.g. "10.0-daily", "10.0.1xx-daily"), + // optionally with a prerelease-label qualifier ("11.0.1xx-preview.5-daily" + // or "11.0.1xx-preview5-daily"). Daily only applies to scopes; a + // specific patch like "10.0.103-daily" is already specific and is + // rejected. // * a fully-qualified version with a prerelease tag (e.g. "10.0.100-preview.1.32640"). // The prerelease tag is opaque; we only validate the numeric prefix. - var dashIndex = channel.IndexOf('-', StringComparison.Ordinal); - if (dashIndex >= 0) + if (channel.EndsWith(DailySuffix, StringComparison.OrdinalIgnoreCase)) { - var versionPart = channel.Substring(0, dashIndex); - var suffix = channel.Substring(dashIndex); + var basePart = channel.Substring(0, channel.Length - DailySuffix.Length); + if (string.IsNullOrEmpty(basePart)) + { + return false; + } - if (suffix.Equals(DailySuffix, StringComparison.OrdinalIgnoreCase)) + if (UpdateChannel.TrySplitPartialVersionAndPrereleaseLabel(basePart, out var bandPart, out _)) { - return !string.IsNullOrEmpty(versionPart) && IsValidPartialVersion(versionPart); + return IsValidPartialVersion(bandPart); } + return IsValidPartialVersion(basePart); + } + + var dashIndex = channel.IndexOf('-', StringComparison.Ordinal); + if (dashIndex >= 0) + { + var versionPart = channel.Substring(0, dashIndex); return IsValidNumericVersion(versionPart); } diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DailyChannelResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DailyChannelResolver.cs index c4deead3d74f..0938496a2221 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DailyChannelResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DailyChannelResolver.cs @@ -87,10 +87,43 @@ public DailyChannelResolver(ReleaseManifest? releaseManifest = null, HttpClient? } // "-daily" → use ".0" as the aka.ms partial version (aka.ms paths use major.minor). - string partialVersion = NormalizePartialVersion(UpdateChannel.StripDailySuffix(channel.Name)); + // For prerelease-qualified daily channels ("-preview.5-daily"): + // 1. Translate the label to aka.ms's dotless form ("preview5") so the URL has + // the shape aka.ms expects: ".../-preview5/daily/...". + // 2. If the band is a bare major.minor (e.g. "11.0-preview.5-daily"), inject + // the default ".1xx" feature band. aka.ms only publishes prerelease-qualified + // daily shortlinks under a feature-band path, so "11.0-preview5" has no + // target; "11.0.1xx-preview5" is the canonical form aka.ms serves and it + // returns the right artifact for any component (SDK, runtime, aspnetcore, + // windowsdesktop). + string scope = UpdateChannel.StripDailySuffix(channel.Name); + string partialVersion; + if (UpdateChannel.TrySplitPartialVersionAndPrereleaseLabel(scope, out var bandPart, out var prereleaseLabel)) + { + string akaMsLabel = prereleaseLabel.Replace(".", string.Empty, StringComparison.Ordinal); + string normalizedBand = EnsureFeatureBand(NormalizePartialVersion(bandPart)); + partialVersion = $"{normalizedBand}-{akaMsLabel}"; + } + else + { + partialVersion = NormalizePartialVersion(scope); + } + return TryResolvePartialVersion(partialVersion, archivePrefix, rid, extension); } + /// + /// Ensures a partial version has a feature-band component. "11.0" → + /// "11.0.1xx"; "11.0.2xx" passes through unchanged. Used when the + /// aka.ms path requires a feature band (prerelease-qualified daily channels) but + /// the user supplied only major.minor. + /// + private static string EnsureFeatureBand(string partialVersion) + { + var parts = partialVersion.Split('.'); + return parts.Length == 2 ? $"{partialVersion}.1xx" : partialVersion; + } + /// /// Converts a channel's partial version into the form expected by aka.ms paths: /// bare-major 10 becomes 10.0; major.minor and feature-band diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs index 295e09041ddc..ffbc2aa68c36 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs @@ -43,6 +43,105 @@ public static string StripDailySuffix(string channelName) ? channelName.Substring(0, channelName.Length - DailySuffix.Length) : channelName; + /// + /// Tries to split a daily-scope string into a partial-version prefix and a + /// prerelease label. Accepts both preview5 and preview.5 + /// spellings of the label (e.g. "11.0.1xx-preview.5" → + /// ("11.0.1xx", "preview.5"); "11.0.1xx-preview5" → + /// ("11.0.1xx", "preview.5")). The returned + /// is always normalized to label.N form (i.e. with the dot) so it can + /// be compared directly against a . + /// + internal static bool TrySplitPartialVersionAndPrereleaseLabel( + string scope, + out string partialVersion, + out string prereleaseLabel) + { + partialVersion = string.Empty; + prereleaseLabel = string.Empty; + + int dashIndex = scope.IndexOf('-', StringComparison.Ordinal); + if (dashIndex <= 0 || dashIndex >= scope.Length - 1) + { + return false; + } + + string left = scope.Substring(0, dashIndex); + string right = scope.Substring(dashIndex + 1); + + if (!TryNormalizePrereleaseLabel(right, out string normalized)) + { + return false; + } + + partialVersion = left; + prereleaseLabel = normalized; + return true; + } + + /// + /// Normalizes a prerelease label to name.N form (e.g. + /// "preview5" and "preview.5" both produce "preview.5"). + /// Returns false if the input doesn't match a {letters}{digits} + /// or {letters}.{digits} shape. + /// + internal static bool TryNormalizePrereleaseLabel(string label, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrEmpty(label)) + { + return false; + } + + string name; + string number; + int dotIndex = label.IndexOf('.', StringComparison.Ordinal); + if (dotIndex >= 0) + { + name = label.Substring(0, dotIndex); + number = label.Substring(dotIndex + 1); + } + else + { + // No dot: find the boundary between the alpha name and the digit run. + int boundary = 0; + while (boundary < label.Length && char.IsLetter(label[boundary])) + { + boundary++; + } + if (boundary == 0 || boundary == label.Length) + { + return false; + } + name = label.Substring(0, boundary); + number = label.Substring(boundary); + } + + if (name.Length == 0 || number.Length == 0) + { + return false; + } + + foreach (char c in name) + { + if (!char.IsLetter(c)) + { + return false; + } + } + + foreach (char c in number) + { + if (!char.IsDigit(c)) + { + return false; + } + } + + normalized = $"{name.ToLowerInvariant()}.{number}"; + return true; + } + /// /// Checks if the channel string looks like an SDK version or feature band pattern rather than a runtime version. /// SDK versions have a third component >= 100 (e.g., "9.0.103", "9.0.304") or use "xx" patterns (e.g., "9.0.1xx"). @@ -112,11 +211,12 @@ public bool Matches(ReleaseVersion version) return version.Major == major; } - // Scoped daily channels (e.g. "10.0-daily", "10.0.1xx-daily") match the - // same versions their base scope would, but restricted to prerelease - // versions. A stable release is not a daily build, even if its version - // falls inside the base scope; rejecting it here keeps the channel's - // "what satisfies me?" answer coherent for GC and reporting. + // Scoped daily channels (e.g. "10.0-daily", "10.0.1xx-daily", + // "11.0.1xx-preview.5-daily") match the same versions their base scope + // would, but restricted to prerelease versions. A stable release is not + // a daily build, even if its version falls inside the base scope; + // rejecting it here keeps the channel's "what satisfies me?" answer + // coherent for GC and reporting. if (IsDaily) { if (IsStableRelease(version)) @@ -124,7 +224,23 @@ public bool Matches(ReleaseVersion version) return false; } - return new UpdateChannel(StripDailySuffix(Name)).Matches(version); + string scope = StripDailySuffix(Name); + + // Prerelease-qualified daily channels ("-preview.5-daily") match only + // versions whose prerelease starts with the requested label, in addition to the + // base scope matching. + if (TrySplitPartialVersionAndPrereleaseLabel(scope, out string partialVersion, out string prereleaseLabel)) + { + if (!new UpdateChannel(partialVersion).Matches(version)) + { + return false; + } + + return version.Prerelease.Equals(prereleaseLabel, StringComparison.OrdinalIgnoreCase) + || version.Prerelease.StartsWith(prereleaseLabel + ".", StringComparison.OrdinalIgnoreCase); + } + + return new UpdateChannel(scope).Matches(version); } return MatchesMajorMinorOrFeatureBand(version); diff --git a/test/dotnetup.Tests/ChannelVersionResolverTests.cs b/test/dotnetup.Tests/ChannelVersionResolverTests.cs index 455156043756..f9d4ab8702bd 100644 --- a/test/dotnetup.Tests/ChannelVersionResolverTests.cs +++ b/test/dotnetup.Tests/ChannelVersionResolverTests.cs @@ -111,6 +111,9 @@ public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() [InlineData("daily")] [InlineData("10.0-daily")] [InlineData("11.0-daily")] + [InlineData("11.0.1xx-preview.5-daily")] + [InlineData("11.0.1xx-preview5-daily")] + [InlineData("11.0-preview.5-daily")] public void GetLatestVersionForChannel_Daily_ResolvesAgainstLiveAkaMs(string channelName) { var resolver = new ChannelVersionResolver(); @@ -151,6 +154,9 @@ public void GetLatestVersionForChannel_FullyQualifiedPrereleaseVersion_ReturnsEx [InlineData("10-daily", true)] [InlineData("10.0-daily", true)] [InlineData("10.0.1xx-daily", true)] + [InlineData("11.0.1xx-preview.5-daily", true)] + [InlineData("11.0.1xx-preview5-daily", true)] + [InlineData("10.0.1xx-rc.1-daily", true)] [InlineData("10.0-DAILY", true)] public void IsValidChannelFormat_ValidInputs_ReturnsTrue(string channel, bool expected) { @@ -173,6 +179,10 @@ public void IsValidChannelFormat_ValidInputs_ReturnsTrue(string channel, bool ex [InlineData("-daily", false)] // Empty scope before -daily [InlineData("preview-daily", false)] // Named channels can't take -daily [InlineData("100-daily", false)] // Major outside reasonable range + [InlineData("11.0.1xx--daily", false)] // Empty phase label + [InlineData("11.0.1xx-preview-daily", false)] // Phase label missing number + [InlineData("11.0.1xx-5-daily", false)] // Phase label missing letters + [InlineData("11.0.103-preview.5-daily", false)] // Specific patch + phase still rejected public void IsValidChannelFormat_InvalidInputs_ReturnsFalse(string channel, bool expected) { Assert.Equal(expected, ChannelVersionResolver.IsValidChannelFormat(channel)); diff --git a/test/dotnetup.Tests/DailyChannelResolverTests.cs b/test/dotnetup.Tests/DailyChannelResolverTests.cs index 778291828554..a623e52b2f21 100644 --- a/test/dotnetup.Tests/DailyChannelResolverTests.cs +++ b/test/dotnetup.Tests/DailyChannelResolverTests.cs @@ -115,6 +115,59 @@ public void Resolve_FeatureBandDaily_PassesScopeThrough() version.Should().NotBeNull(); } + [Theory] + [InlineData("11.0.1xx-preview.5-daily")] + [InlineData("11.0.1xx-preview5-daily")] + public void Resolve_PhaseQualifiedDaily_UsesDotlessAkaMsPath(string channelName) + { + // Both "preview.5" and "preview5" forms of the channel must resolve to the + // dotless aka.ms path segment ".../11.0.1xx-preview5/daily/..." that the + // service actually serves. + const string archiveUrl = + "https://ci.dot.net/public/Sdk/11.0.100-preview.5.26302.115/dotnet-sdk-11.0.100-preview.5.26302.115-win-x64.zip"; + using var handler = new RedirectHandler(new Dictionary + { + ["https://aka.ms/dotnet/11.0.1xx-preview5/daily/dotnet-sdk-"] = archiveUrl, + }); + using var httpClient = new HttpClient(handler); + using var resolver = new DailyChannelResolver(new ReleaseManifest(), httpClient); + + var version = resolver.Resolve(new UpdateChannel(channelName), InstallArchitecture.x64); + + version.Should().NotBeNull(); + version!.ToString().Should().Be("11.0.100-preview.5.26302.115"); + } + + [Theory] + [InlineData("11.0-preview.5-daily", InstallComponent.SDK)] + [InlineData("11.0-preview.5-daily", InstallComponent.Runtime)] + [InlineData("11.0-preview.5-daily", InstallComponent.ASPNETCore)] + public void Resolve_MajorMinorPrereleaseDaily_InjectsDefaultFeatureBand(string channelName, InstallComponent component) + { + // aka.ms only publishes prerelease-qualified daily shortlinks under the SDK + // feature-band path (".../11.0.1xx-preview5/daily/...") — even for non-SDK + // components. When the user types the more natural runtime form + // "11.0-preview.5-daily", DailyChannelResolver must inject the default ".1xx" + // band so the URL it queries actually has a target. + var archiveUrls = new Dictionary + { + ["https://aka.ms/dotnet/11.0.1xx-preview5/daily/dotnet-sdk-"] + = "https://ci.dot.net/public/Sdk/11.0.100-preview.5.26302.115/dotnet-sdk-11.0.100-preview.5.26302.115-win-x64.zip", + ["https://aka.ms/dotnet/11.0.1xx-preview5/daily/dotnet-runtime-"] + = "https://ci.dot.net/public/Runtime/11.0.0-preview.5.26302.115/dotnet-runtime-11.0.0-preview.5.26302.115-win-x64.zip", + ["https://aka.ms/dotnet/11.0.1xx-preview5/daily/aspnetcore-runtime-"] + = "https://ci.dot.net/public/aspnetcore/Runtime/11.0.0-preview.5.26302.115/aspnetcore-runtime-11.0.0-preview.5.26302.115-win-x64.zip", + }; + using var handler = new RedirectHandler(archiveUrls); + using var httpClient = new HttpClient(handler); + using var resolver = new DailyChannelResolver(new ReleaseManifest(), httpClient); + + var version = resolver.Resolve(new UpdateChannel(channelName), InstallArchitecture.x64, component); + + version.Should().NotBeNull(); + version!.Prerelease.Should().StartWith("preview.5"); + } + [Fact] public void Resolve_AkaMsReturnsNotFound_ReturnsNull() { diff --git a/test/dotnetup.Tests/UpdateChannelTests.cs b/test/dotnetup.Tests/UpdateChannelTests.cs index f4f824e8c588..56be948326ed 100644 --- a/test/dotnetup.Tests/UpdateChannelTests.cs +++ b/test/dotnetup.Tests/UpdateChannelTests.cs @@ -39,6 +39,21 @@ public class UpdateChannelTests [InlineData("10.0.1xx-daily", "10.0.103-preview.1", true)] [InlineData("10.0.1xx-daily", "10.0.103", false)] [InlineData("10.0.1xx-daily", "10.0.204-preview.1", false)] + // Phase-qualified daily channels: the version's prerelease label must also match. + [InlineData("11.0.1xx-preview.5-daily", "11.0.100-preview.5.26302.115", true)] + [InlineData("11.0.1xx-preview5-daily", "11.0.100-preview.5.26302.115", true)] // dotless form accepted + [InlineData("11.0.1xx-preview.5-daily", "11.0.100-preview.6.26302.118", false)] // wrong phase + [InlineData("11.0.1xx-preview.5-daily", "11.0.100", false)] // stable + [InlineData("11.0.1xx-preview.5-daily", "11.0.204-preview.5.26302.115", false)] // wrong feature band + [InlineData("10.0.1xx-rc.1-daily", "10.0.100-rc.1.25451.107", true)] + [InlineData("10.0.1xx-rc1-daily", "10.0.100-rc.1.25451.107", true)] + // Runtime-form prerelease-qualified daily channels: users type major.minor + // without an SDK feature band; the channel must still match the version's + // major.minor + prerelease, since aka.ms maps these to the SDK band internally. + [InlineData("11.0-preview.5-daily", "11.0.0-preview.5.26302.115", true)] // runtime + [InlineData("11.0-preview.5-daily", "11.0.100-preview.5.26302.115", true)] // SDK in same band + [InlineData("11.0-preview.5-daily", "11.0.0-preview.6.26302.118", false)] // wrong label + [InlineData("11.0-preview.5-daily", "11.0.0", false)] // stable public void Matches_ReturnsExpectedResult(string channel, string versionString, bool expected) { var updateChannel = new UpdateChannel(channel); @@ -54,6 +69,9 @@ public void Matches_ReturnsExpectedResult(string channel, string versionString, [InlineData("10.0-daily", true)] [InlineData("10.0.1xx-daily", true)] [InlineData("10.0-DAILY", true)] + [InlineData("11.0.1xx-preview.5-daily", true)] + [InlineData("11.0.1xx-preview5-daily", true)] + [InlineData("10.0.1xx-rc1-daily", true)] [InlineData("10.0", false)] [InlineData("preview", false)] [InlineData("10.0.100-preview.1", false)]