Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,33 @@ public static bool IsValidChannelFormat(string channel)
}

// The only two forms that include a '-' are:
// * "<partial-version>-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.
// * "<partial-version>-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);
Copy link
Copy Markdown
Member

@nagilson nagilson Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this doing validation on baseaPart or should we also be checking that? I think that IsValidPartialVersion no longer executes on anything besides the band part, so it will pass as long as there is a - and valid version afterward.

Suggested change
return IsValidPartialVersion(bandPart);
return IsValidPartialVersion(bandPart) && isValidPartialVersion(getMajorMinor(basePart));

Suggestion 2:
add a unit test for input such as invalid.invalid.1xx-daily

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also recognize this function appears to be a 'best-effort' implementation

}

return IsValidPartialVersion(basePart);
}

var dashIndex = channel.IndexOf('-', StringComparison.Ordinal);
if (dashIndex >= 0)
{
var versionPart = channel.Substring(0, dashIndex);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we validate the version component after the dash, so it might try to install a version string such as '10.0.1xx-RANDOM` - but don't think this is new. It likely gets caught elsewhere in the code? Could be worth improving or adding a test for now.

return IsValidNumericVersion(versionPart);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,43 @@ public DailyChannelResolver(ReleaseManifest? releaseManifest = null, HttpClient?
}

// "<M>-daily" → use "<M>.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 ("<band>-preview.5-daily"):
// 1. Translate the label to aka.ms's dotless form ("preview5") so the URL has
// the shape aka.ms expects: ".../<band>-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);
}

/// <summary>
/// Ensures a partial version has a feature-band component. <c>"11.0"</c> →
/// <c>"11.0.1xx"</c>; <c>"11.0.2xx"</c> passes through unchanged. Used when the
/// aka.ms path requires a feature band (prerelease-qualified daily channels) but
/// the user supplied only major.minor.
/// </summary>
private static string EnsureFeatureBand(string partialVersion)
{
var parts = partialVersion.Split('.');
return parts.Length == 2 ? $"{partialVersion}.1xx" : partialVersion;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right assumption - e.g., : https://aka.ms/dotnet/10.0.4xx/daily this is a valid outcome and if I ask for 10.0-daily I'd expect to get 4xx. It might help to demonstrate the type of version input to this function - is it only 11.0-preview.5-daily? If this is true, it could be renamed to AddFeatureBandToPreviewVersion so it isn't misused.

}

/// <summary>
/// Converts a channel's partial version into the form expected by aka.ms paths:
/// bare-major <c>10</c> becomes <c>10.0</c>; major.minor and feature-band
Expand Down
128 changes: 122 additions & 6 deletions src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,105 @@ public static string StripDailySuffix(string channelName)
? channelName.Substring(0, channelName.Length - DailySuffix.Length)
: channelName;

/// <summary>
/// Tries to split a daily-scope string into a partial-version prefix and a
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does daily-scope string mean a string that is a not fully specified version, or a fully specified version, but one without the daily suffix?

/// prerelease label. Accepts both <c>preview5</c> and <c>preview.5</c>
/// spellings of the label (e.g. <c>"11.0.1xx-preview.5"</c> →
/// <c>("11.0.1xx", "preview.5")</c>; <c>"11.0.1xx-preview5"</c> →
/// <c>("11.0.1xx", "preview.5")</c>). The returned <paramref name="prereleaseLabel"/>
/// is always normalized to <c>label.N</c> form (i.e. with the dot) so it can
/// be compared directly against a <see cref="ReleaseVersion.Prerelease"/>.
/// </summary>
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;
}

/// <summary>
/// Normalizes a prerelease label to <c>name.N</c> form (e.g.
/// <c>"preview5"</c> and <c>"preview.5"</c> both produce <c>"preview.5"</c>).
/// Returns <c>false</c> if the input doesn't match a <c>{letters}{digits}</c>
/// or <c>{letters}.{digits}</c> shape.
/// </summary>
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;
}

/// <summary>
/// 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").
Expand Down Expand Up @@ -112,19 +211,36 @@ 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))
{
return false;
}

return new UpdateChannel(StripDailySuffix(Name)).Matches(version);
string scope = StripDailySuffix(Name);

// Prerelease-qualified daily channels ("<band>-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);
Expand Down
10 changes: 10 additions & 0 deletions test/dotnetup.Tests/ChannelVersionResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Comment thread
dsplaisted marked this conversation as resolved.
[InlineData("11.0-preview.5-daily")]
public void GetLatestVersionForChannel_Daily_ResolvesAgainstLiveAkaMs(string channelName)
{
var resolver = new ChannelVersionResolver();
Expand Down Expand Up @@ -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)
{
Expand All @@ -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));
Expand Down
53 changes: 53 additions & 0 deletions test/dotnetup.Tests/DailyChannelResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
["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<string, string>
{
["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()
{
Expand Down
18 changes: 18 additions & 0 deletions test/dotnetup.Tests/UpdateChannelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)]
Expand Down
Loading