From 66aecec961d6da1d2222befb54ef0c31da45a49f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:19:43 +0000 Subject: [PATCH 1/2] Fix V3 packageContent URL selection to compare parsed versions (issue 1657) Co-authored-by: et1975 <623703+et1975@users.noreply.github.com> --- src/code/PSResourceInfo.cs | 12 ++ src/code/V3ServerAPICalls.cs | 119 ++++++++++++++---- ...ResourceV3ServerVersionSelection.Tests.ps1 | 58 +++++++++ 3 files changed, 167 insertions(+), 22 deletions(-) create mode 100644 test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 5faf27f8f..7732e3475 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -2023,6 +2023,18 @@ public static void WritePSGetResourceInfo( throw new PSArgumentException("psObjectGetInfo argument is not a PSGetResourceInfo type."); } + + public static string SelectV3PackageContentUrl( + string[] versionedResponses, + string version) + { + if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) + { + throw new PSArgumentException($"Version {version} is not a valid NuGet version."); + } + + return Cmdlets.V3ServerAPICalls.GetPackageContentUrlForVersion(versionedResponses, requiredVersion); + } } #endregion diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index a66b35e9d..a2b52d172 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -913,17 +913,7 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error } else { - // loop through responses to find one containing required version - foreach (string response in versionedResponses) - { - // Response will be "packageContent" element value that looks like: "{packageBaseAddress}/{packageName}/{normalizedVersion}/{packageName}.{normalizedVersion}.nupkg" - // Ex: https://api.nuget.org/v3-flatcontainer/test_module/1.0.0/test_module.1.0.0.nupkg - if (response.Contains(version.ToNormalizedString())) - { - pkgContentUrl = response; - break; - } - } + pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version); } if (String.IsNullOrEmpty(pkgContentUrl)) @@ -997,17 +987,7 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v } else { - // loop through responses to find one containing required version - foreach (string response in versionedResponses) - { - // Response will be "packageContent" element value that looks like: "{packageBaseAddress}/{packageName}/{normalizedVersion}/{packageName}.{normalizedVersion}.nupkg" - // Ex: https://api.nuget.org/v3-flatcontainer/test_module/1.0.0/test_module.1.0.0.nupkg - if (response.Contains(version.ToNormalizedString())) - { - pkgContentUrl = response; - break; - } - } + pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version); } if (String.IsNullOrEmpty(pkgContentUrl)) @@ -1039,6 +1019,101 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v return pkgStream; } + /// + /// Selects the "packageContent" entry (i.e the .nupkg download URL) matching the required version. + /// The version encoded in the entry is parsed and compared as a NuGetVersion, instead of searching for the version + /// text anywhere within the entry, as a substring search matches version prefixes too + /// (i.e requesting version '1.2.3' would match the entry for version '1.2.30'). + /// + internal static string GetPackageContentUrlForVersion(string[] versionedResponses, NuGetVersion requiredVersion) + { + if (versionedResponses == null || requiredVersion == null) + { + return String.Empty; + } + + foreach (string response in versionedResponses) + { + if (String.IsNullOrWhiteSpace(response)) + { + continue; + } + + // Response will be "packageContent" element value that looks like: "{packageBaseAddress}/{packageName}/{normalizedVersion}/{packageName}.{normalizedVersion}.nupkg" + // Ex: https://api.nuget.org/v3-flatcontainer/test_module/1.0.0/test_module.1.0.0.nupkg + if (PackageContentUrlMatchesVersion(response, requiredVersion)) + { + return response; + } + } + + return String.Empty; + } + + /// + /// Determines whether the given "packageContent" entry refers to the required version. + /// + private static bool PackageContentUrlMatchesVersion(string packageContentUrl, NuGetVersion requiredVersion) + { + string path = packageContentUrl; + string query = String.Empty; + int queryIndex = path.IndexOfAny(new char[] { '?', '#' }); + if (queryIndex >= 0) + { + query = path.Substring(queryIndex + 1); + path = path.Substring(0, queryIndex); + } + + string[] pathSegments = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + string normalizedVersion = requiredVersion.ToNormalizedString(); + for (int i = 0; i < pathSegments.Length; i++) + { + string segment = UnescapeUrlPart(pathSegments[i]); + + // Path segment containing just the version, ex: ".../test_module/1.0.0/..." + if (NuGetVersion.TryParse(segment, out NuGetVersion segmentVersion) && segmentVersion == requiredVersion) + { + return true; + } + + // Last path segment is the file name, ex: "test_module.1.0.0.nupkg" + if (segment.EndsWith($".{normalizedVersion}.nupkg", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // Some repositories pass the version as a query parameter, ex: "...?packageVersion=1.0.0" + foreach (string queryParameter in query.Split(new char[] { '&', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + int separatorIndex = queryParameter.IndexOf('='); + if (separatorIndex < 0) + { + continue; + } + + string queryValue = UnescapeUrlPart(queryParameter.Substring(separatorIndex + 1)); + if (NuGetVersion.TryParse(queryValue, out NuGetVersion queryVersion) && queryVersion == requiredVersion) + { + return true; + } + } + + return false; + } + + private static string UnescapeUrlPart(string urlPart) + { + try + { + return Uri.UnescapeDataString(urlPart); + } + catch (UriFormatException) + { + return urlPart; + } + } + /// /// Gets the versioned package entries from the RegistrationsBaseUrl resource /// i.e when the package Name being searched for does not contain wildcard diff --git a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 new file mode 100644 index 000000000..735fee785 --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Import-Module "$psscriptroot/../PSGetTestUtils.psm1" -Force + +Describe 'Test V3 packageContent url selection for a required version' -tags 'CI' { + + BeforeAll { + $packageBaseAddress = 'https://api.nuget.org/v3-flatcontainer/test_module' + # Responses are returned in descending version order, ie the entry for 1.2.30 precedes the entry for 1.2.3 + $versionedResponses = @( + "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg", + "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" + ) + } + + It 'Should select the url for the exact version requested' { + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.3') + $url | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" + } + + It 'Should not select the url of a version which the requested version is a prefix of' { + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.30') + $url | Should -BeExactly "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg" + } + + It 'Should select the url for a version with four version parts' { + $responses = @( + "$packageBaseAddress/2024.5.20.12/test_module.2024.5.20.12.nupkg", + "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg" + ) + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2024.5.20.1') + $url | Should -BeExactly "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg" + } + + It 'Should select the url for a prerelease version' { + $responses = @( + "$packageBaseAddress/2.5.0-beta10/test_module.2.5.0-beta10.nupkg", + "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg" + ) + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2.5.0-beta1') + $url | Should -BeExactly "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg" + } + + It 'Should select the url when the version is passed as a query parameter' { + $responses = @( + "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.30", + "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + ) + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3') + $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + } + + It 'Should not select any url when the requested version is not present' { + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.4') + $url | Should -BeNullOrEmpty + } +} From 4da50cd913198bf321eb8060e0706390eb27c199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:45:27 +0000 Subject: [PATCH 2/2] Address PR #2019 review feedback on V3 packageContent URL selection Co-authored-by: et1975 <623703+et1975@users.noreply.github.com> --- src/code/PSResourceInfo.cs | 2 +- src/code/V3ServerAPICalls.cs | 10 ++++++++++ ...nstallPSResourceV3ServerVersionSelection.Tests.ps1 | 11 ++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 7732e3475..af12bf236 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -2030,7 +2030,7 @@ public static string SelectV3PackageContentUrl( { if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) { - throw new PSArgumentException($"Version {version} is not a valid NuGet version."); + throw new PSArgumentException($"Version '{version}' is not a valid NuGet version."); } return Cmdlets.V3ServerAPICalls.GetPackageContentUrlForVersion(versionedResponses, requiredVersion); diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index a2b52d172..b8b512461 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -1092,6 +1092,12 @@ private static bool PackageContentUrlMatchesVersion(string packageContentUrl, Nu continue; } + string queryKey = UnescapeUrlPart(queryParameter.Substring(0, separatorIndex)).Trim(); + if (!queryKey.EndsWith("version", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + string queryValue = UnescapeUrlPart(queryParameter.Substring(separatorIndex + 1)); if (NuGetVersion.TryParse(queryValue, out NuGetVersion queryVersion) && queryVersion == requiredVersion) { @@ -1112,6 +1118,10 @@ private static string UnescapeUrlPart(string urlPart) { return urlPart; } + catch (ArgumentException) + { + return urlPart; + } } /// diff --git a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 index 735fee785..98b74bd7a 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -19,7 +19,7 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI $url | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" } - It 'Should not select the url of a version which the requested version is a prefix of' { + It 'Should select the url for a version which another version is a prefix of' { $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.30') $url | Should -BeExactly "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg" } @@ -51,6 +51,15 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" } + It 'Should not select a url where a non-version query parameter matches the version' { + $responses = @( + "https://www.myget.org/api/download?packageId=1.2.3&packageVersion=1.2.30", + "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + ) + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3') + $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + } + It 'Should not select any url when the requested version is not present' { $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.4') $url | Should -BeNullOrEmpty