From e3b6f847af5a193c82926a0b78d7662f9fb43626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 13 Mar 2026 16:32:39 +0100 Subject: [PATCH] Detect reserved PSGallery module versions --- PowerForge.Tests/ModuleVersionStepperTests.cs | 76 +++++++++++++++++++ PowerForge/Services/ModuleVersionStepper.cs | 75 +++++++++++++++--- .../PowerShellGalleryVersionFeedClient.cs | 39 ++++++++++ 3 files changed, 178 insertions(+), 12 deletions(-) diff --git a/PowerForge.Tests/ModuleVersionStepperTests.cs b/PowerForge.Tests/ModuleVersionStepperTests.cs index de9bdd3c..22b91eb6 100644 --- a/PowerForge.Tests/ModuleVersionStepperTests.cs +++ b/PowerForge.Tests/ModuleVersionStepperTests.cs @@ -22,6 +22,22 @@ public void Step_UsesUnlistedPowerShellGalleryVersionWhenStepping() Assert.Equal("3.0.0", result.CurrentVersion); } + [Fact] + public void Step_UsesReservedPowerShellGalleryVersionWhenFeedOmitsIt() + { + using var client = new HttpClient(new FakeReservedPowerShellGalleryHandler()); + var stepper = new ModuleVersionStepper( + new NullLogger(), + new StubPowerShellRunner(new PowerShellRunResult(0, VisibleRepositoryItem("PSPublishModule", "2.0.27"), string.Empty, "pwsh.exe")), + client); + + var result = stepper.Step("3.0.X", moduleName: "PSPublishModule", localPsd1Path: null, repository: "PSGallery"); + + Assert.Equal("3.0.1", result.Version); + Assert.Equal(ModuleVersionSource.Repository, result.CurrentVersionSource); + Assert.Equal("3.0.0", result.CurrentVersion); + } + private static string VisibleRepositoryItem(string name, string version) => string.Join("::", new[] { @@ -58,6 +74,11 @@ private sealed class FakePowerShellGalleryFeedHandler : HttpMessageHandler protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var uri = request.RequestUri?.ToString() ?? string.Empty; + if (!uri.Contains("FindPackagesById", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + var body = uri.Contains("$skip=100", StringComparison.OrdinalIgnoreCase) ? BuildSecondPage() : BuildFirstPage(); @@ -103,4 +124,59 @@ private static string BuildSecondPage() """; } + + private sealed class FakeReservedPowerShellGalleryHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri?.ToString() ?? string.Empty; + + if (uri.Contains("FindPackagesById", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + + + + + + 2.0.27 + false + 2026-03-10T10:00:00 + + + + """, Encoding.UTF8, "application/atom+xml") + }); + } + + if (uri.Contains("Packages(", StringComparison.OrdinalIgnoreCase) && + uri.Contains("PSPublishModule", StringComparison.OrdinalIgnoreCase) && + uri.Contains("3.0.0", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + + + + + 3.0.0 + false + 1900-01-01T00:00:00 + + + """, Encoding.UTF8, "application/atom+xml") + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } } diff --git a/PowerForge/Services/ModuleVersionStepper.cs b/PowerForge/Services/ModuleVersionStepper.cs index e71d70d8..77327841 100644 --- a/PowerForge/Services/ModuleVersionStepper.cs +++ b/PowerForge/Services/ModuleVersionStepper.cs @@ -47,11 +47,11 @@ internal ModuleVersionStepper(ILogger logger, IPowerShellRunner runner, HttpClie /// Optional local PSD1 path to resolve current version from. /// Repository name used with PSResourceGet (default: PSGallery). /// Whether to include prerelease versions when resolving current version. - public ModuleVersionStepResult Step( - string expectedVersion, - string? moduleName = null, - string? localPsd1Path = null, - string repository = "PSGallery", + public ModuleVersionStepResult Step( + string expectedVersion, + string? moduleName = null, + string? localPsd1Path = null, + string repository = "PSGallery", bool prerelease = false) { if (string.IsNullOrWhiteSpace(expectedVersion)) @@ -68,8 +68,8 @@ public ModuleVersionStepResult Step( usedAutoVersioning: false); } - var (current, source) = ResolveCurrentVersion(moduleName, localPsd1Path, repository, prerelease); - var proposed = ComputeNextVersion(expectedVersion, current); + var (current, source) = ResolveCurrentVersion(expectedVersion, moduleName, localPsd1Path, repository, prerelease); + var proposed = ComputeNextVersion(expectedVersion, current); return new ModuleVersionStepResult( expectedVersion: expectedVersion, @@ -79,11 +79,12 @@ public ModuleVersionStepResult Step( usedAutoVersioning: true); } - private (Version? Version, ModuleVersionSource Source) ResolveCurrentVersion( - string? moduleName, - string? localPsd1Path, - string repository, - bool prerelease) + private (Version? Version, ModuleVersionSource Source) ResolveCurrentVersion( + string expectedVersion, + string? moduleName, + string? localPsd1Path, + string repository, + bool prerelease) { if (string.IsNullOrWhiteSpace(moduleName)) return (null, ModuleVersionSource.None); @@ -121,6 +122,13 @@ public ModuleVersionStepResult Step( try { var galleryVersion = TryResolveCurrentVersionFromPowerShellGalleryFeed(moduleName!, prerelease); + var reservedVersion = TryResolveReservedPowerShellGalleryVersion(expectedVersion, moduleName!, galleryVersion, prerelease); + if (reservedVersion is not null && (galleryVersion is null || reservedVersion.CompareTo(galleryVersion) > 0)) + { + _logger.Verbose($"PowerShell Gallery reserved version for '{moduleName}' was resolved from the exact package metadata endpoint ({reservedVersion})."); + return (reservedVersion, ModuleVersionSource.Repository); + } + if (galleryVersion is not null) return (galleryVersion, ModuleVersionSource.Repository); } @@ -155,6 +163,49 @@ public ModuleVersionStepResult Step( } } + private Version? TryResolveReservedPowerShellGalleryVersion( + string expectedVersion, + string moduleName, + Version? currentVersion, + bool prerelease) + { + if (prerelease) + return null; + + Version? latestReserved = null; + Version? cursor = currentVersion; + var missCount = 0; + var hitCount = 0; + const int maxProbeCount = 12; + const int maxConsecutiveMissesAfterHit = 3; + const int maxConsecutiveMissesBeforeHit = 4; + + for (var index = 0; index < maxProbeCount; index++) + { + var candidateText = ComputeNextVersion(expectedVersion, cursor); + if (!TryParseRepositoryVersion(candidateText, out var candidateVersion)) + break; + + if (_powerShellGalleryFeed.VersionExists(moduleName, candidateText, timeout: TimeSpan.FromSeconds(20))) + { + latestReserved = candidateVersion; + cursor = candidateVersion; + hitCount++; + missCount = 0; + continue; + } + + cursor = candidateVersion; + missCount++; + if (hitCount > 0 && missCount >= maxConsecutiveMissesAfterHit) + break; + if (hitCount == 0 && missCount >= maxConsecutiveMissesBeforeHit) + break; + } + + return latestReserved; + } + private Version? TryResolveCurrentVersionFromPowerShellGalleryFeed(string moduleName, bool prerelease) { var versions = _powerShellGalleryFeed.GetVersions(moduleName, prerelease, timeout: TimeSpan.FromMinutes(2)); diff --git a/PowerForge/Services/PowerShellGalleryVersionFeedClient.cs b/PowerForge/Services/PowerShellGalleryVersionFeedClient.cs index 5682ec6a..cf5e6b73 100644 --- a/PowerForge/Services/PowerShellGalleryVersionFeedClient.cs +++ b/PowerForge/Services/PowerShellGalleryVersionFeedClient.cs @@ -1,5 +1,6 @@ using System.Net.Http; using System.Xml.Linq; +using System.Globalization; namespace PowerForge; @@ -9,6 +10,7 @@ namespace PowerForge; public sealed class PowerShellGalleryVersionFeedClient { private const string FindPackagesByIdTemplate = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='{0}'"; + private const string ExactPackageTemplate = "https://www.powershellgallery.com/api/v2/Packages(Id='{0}',Version='{1}')"; private const string UnlistedPublishedMarker = "1900-01-01T00:00:00"; private static readonly HttpClient SharedClient = CreateSharedClient(); private readonly HttpClient _client; @@ -70,6 +72,43 @@ public IReadOnlyList GetVersions( return versions; } + /// + /// Checks whether the exact package/version exists in the gallery metadata endpoint, + /// including versions that are unlisted or removed from the public feed listing. + /// + public bool VersionExists( + string packageId, + string version, + TimeSpan? timeout = null) + { + if (string.IsNullOrWhiteSpace(packageId)) + throw new ArgumentException("PackageId is required.", nameof(packageId)); + if (string.IsNullOrWhiteSpace(version)) + throw new ArgumentException("Version is required.", nameof(version)); + + using var cts = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : null; + var token = cts?.Token ?? CancellationToken.None; + var requestUri = string.Format( + CultureInfo.InvariantCulture, + ExactPackageTemplate, + Uri.EscapeDataString(packageId.Trim()), + Uri.EscapeDataString(version.Trim())); + + try + { + using var response = _client.GetAsync(requestUri, token).GetAwaiter().GetResult(); + return response.IsSuccessStatusCode; + } + catch (TaskCanceledException) + { + return false; + } + catch (HttpRequestException) + { + return false; + } + } + private static IEnumerable ParseEntries(XDocument document, bool includePrerelease) { XNamespace atom = "http://www.w3.org/2005/Atom";