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/6] 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/6] 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 From c90cd6e134d69a8a0e42b21b57f4953ad00087dc Mon Sep 17 00:00:00 2001 From: Eugene Tolmachev Date: Wed, 23 Sep 2026 17:56:10 -0400 Subject: [PATCH 3/6] Fix V3 package URL filename matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434a152d-b80d-4a4d-946e-3c9c45e2ba78 --- src/code/PSResourceInfo.cs | 5 ++-- src/code/V3ServerAPICalls.cs | 18 ++++++++++----- ...ResourceV3ServerVersionSelection.Tests.ps1 | 23 +++++++++++++------ 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index af12bf236..1c1e99f7e 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -2026,14 +2026,15 @@ public static void WritePSGetResourceInfo( public static string SelectV3PackageContentUrl( string[] versionedResponses, - string version) + string version, + string packageName) { if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) { throw new PSArgumentException($"Version '{version}' is not a valid NuGet version."); } - return Cmdlets.V3ServerAPICalls.GetPackageContentUrlForVersion(versionedResponses, requiredVersion); + return Cmdlets.V3ServerAPICalls.GetPackageContentUrlForVersion(versionedResponses, requiredVersion, packageName); } } diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index b8b512461..55836f118 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -913,7 +913,7 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error } else { - pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version); + pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version, packageName); } if (String.IsNullOrEmpty(pkgContentUrl)) @@ -987,7 +987,7 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v } else { - pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version); + pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version, packageName); } if (String.IsNullOrEmpty(pkgContentUrl)) @@ -1025,7 +1025,10 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v /// 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) + internal static string GetPackageContentUrlForVersion( + string[] versionedResponses, + NuGetVersion requiredVersion, + string packageName) { if (versionedResponses == null || requiredVersion == null) { @@ -1041,7 +1044,7 @@ internal static string GetPackageContentUrlForVersion(string[] versionedResponse // 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)) + if (PackageContentUrlMatchesVersion(response, requiredVersion, packageName)) { return response; } @@ -1053,7 +1056,10 @@ internal static string GetPackageContentUrlForVersion(string[] versionedResponse /// /// Determines whether the given "packageContent" entry refers to the required version. /// - private static bool PackageContentUrlMatchesVersion(string packageContentUrl, NuGetVersion requiredVersion) + private static bool PackageContentUrlMatchesVersion( + string packageContentUrl, + NuGetVersion requiredVersion, + string packageName) { string path = packageContentUrl; string query = String.Empty; @@ -1077,7 +1083,7 @@ private static bool PackageContentUrlMatchesVersion(string packageContentUrl, Nu } // Last path segment is the file name, ex: "test_module.1.0.0.nupkg" - if (segment.EndsWith($".{normalizedVersion}.nupkg", StringComparison.OrdinalIgnoreCase)) + if (segment.Equals($"{packageName}.{normalizedVersion}.nupkg", StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 index 98b74bd7a..4ec01b27d 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -15,12 +15,12 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI } It 'Should select the url for the exact version requested' { - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.3') + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.3', 'test_module') $url | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" } 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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.30', 'test_module') $url | Should -BeExactly "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg" } @@ -29,16 +29,25 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI "$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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2024.5.20.1', 'test_module') $url | Should -BeExactly "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg" } + It 'Should not select a url whose file name has the requested version as part of another version' { + $responses = @( + "$packageBaseAddress/3.2.5.1/test_module.3.2.5.1.nupkg", + "$packageBaseAddress/2.5.1/test_module.2.5.1.nupkg" + ) + $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2.5.1', 'test_module') + $url | Should -BeExactly "$packageBaseAddress/2.5.1/test_module.2.5.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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2.5.0-beta1', 'test_module') $url | Should -BeExactly "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg" } @@ -47,7 +56,7 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI "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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3', 'test_module') $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" } @@ -56,12 +65,12 @@ Describe 'Test V3 packageContent url selection for a required version' -tags 'CI "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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3', 'test_module') $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 = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.4', 'test_module') $url | Should -BeNullOrEmpty } } From eef282e4f64c09472383e7d518c5ba8f0c22a8bf Mon Sep 17 00:00:00 2001 From: alerickson Date: Tue, 6 Oct 2026 11:13:11 -0700 Subject: [PATCH 4/6] Select the V3 package to install by its catalogEntry version instead of matching the version text in the packageContent URL --- src/code/PSResourceInfo.cs | 47 ---- src/code/TestHooks.cs | 58 +++++ src/code/V3ServerAPICalls.cs | 212 +++++------------- ...ResourceV3ServerVersionSelection.Tests.ps1 | 103 +++++---- 4 files changed, 183 insertions(+), 237 deletions(-) create mode 100644 src/code/TestHooks.cs diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 1c1e99f7e..aecb7adec 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -1992,51 +1992,4 @@ private static Dependency[] GetDependenciesForPsd1(Hashtable[] requiredModules) } #endregion - - #region Test Hooks - - public static class TestHooks - { - public static PSObject ReadPSGetResourceInfo(string filePath) - { - if (PSResourceInfo.TryRead(filePath, out PSResourceInfo psGetInfo, out string errorMsg)) - { - return PSObject.AsPSObject(psGetInfo); - } - - throw new PSInvalidOperationException(errorMsg); - } - - public static void WritePSGetResourceInfo( - string filePath, - PSObject psObjectGetInfo) - { - if (psObjectGetInfo.BaseObject is PSResourceInfo psGetInfo) - { - if (!psGetInfo.TryWrite(filePath, out string errorMsg)) - { - throw new PSInvalidOperationException(errorMsg); - } - - return; - } - - throw new PSArgumentException("psObjectGetInfo argument is not a PSGetResourceInfo type."); - } - - public static string SelectV3PackageContentUrl( - string[] versionedResponses, - string version, - string packageName) - { - if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) - { - throw new PSArgumentException($"Version '{version}' is not a valid NuGet version."); - } - - return Cmdlets.V3ServerAPICalls.GetPackageContentUrlForVersion(versionedResponses, requiredVersion, packageName); - } - } - - #endregion } diff --git a/src/code/TestHooks.cs b/src/code/TestHooks.cs new file mode 100644 index 000000000..e80cbc2d2 --- /dev/null +++ b/src/code/TestHooks.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using NuGet.Versioning; +using System.Management.Automation; + +namespace Microsoft.PowerShell.PSResourceGet.UtilClasses +{ + /// + /// Entry points that expose internal functionality to the Pester tests. + /// + public static class TestHooks + { + public static PSObject ReadPSGetResourceInfo(string filePath) + { + if (PSResourceInfo.TryRead(filePath, out PSResourceInfo psGetInfo, out string errorMsg)) + { + return PSObject.AsPSObject(psGetInfo); + } + + throw new PSInvalidOperationException(errorMsg); + } + + public static void WritePSGetResourceInfo( + string filePath, + PSObject psObjectGetInfo) + { + if (psObjectGetInfo.BaseObject is PSResourceInfo psGetInfo) + { + if (!psGetInfo.TryWrite(filePath, out string errorMsg)) + { + throw new PSInvalidOperationException(errorMsg); + } + + return; + } + + throw new PSArgumentException("psObjectGetInfo argument is not a PSGetResourceInfo type."); + } + + /// + /// Selects the "packageContent" URL from V3 registration entries for the given version, or for the latest version if no version is given. + /// + public static string SelectV3PackageContentUrl( + string[] registrationEntries, + string version) + { + NuGetVersion requiredVersion = null; + if (!string.IsNullOrEmpty(version) && !NuGetVersion.TryParse(version, out requiredVersion)) + { + throw new PSArgumentException($"Version '{version}' is not a valid NuGet version."); + } + + return V3ServerAPICalls.GetPackageContentUrl(registrationEntries, requiredVersion); + } + } +} diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index 55836f118..4d12ba98d 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -883,11 +883,6 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error { debugMsgs.Enqueue("In V3ServerAPICalls::InstallHelper()"); Stream pkgStream = null; - bool getLatestVersion = true; - if (version != null) - { - getLatestVersion = false; - } string[] versionedResponses = GetVersionedPackageEntriesFromRegistrationsResource(packageName, packageContentProperty, isSearch: false, out errRecord, errorMsgs, debugMsgs, verboseMsgs); if (errRecord != null) @@ -906,18 +901,10 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error return null; } - string pkgContentUrl = String.Empty; - if (getLatestVersion) - { - pkgContentUrl = versionedResponses[0]; - } - else - { - pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version, packageName); - } - + string pkgContentUrl = GetPackageContentUrl(versionedResponses, version); if (String.IsNullOrEmpty(pkgContentUrl)) { + debugMsgs.Enqueue($"No registration entry for package '{packageName}' had a 'catalogEntry' version matching '{version}' and a 'packageContent' URL."); errRecord = new ErrorRecord( new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), "InstallFailure", @@ -956,11 +943,6 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v { debugMsgs.Enqueue("In V3ServerAPICalls::InstallHelperAsync()"); Stream pkgStream = null; - bool getLatestVersion = true; - if (version != null) - { - getLatestVersion = false; - } string[] versionedResponses = GetVersionedPackageEntriesFromRegistrationsResource(packageName, packageContentProperty, isSearch: false, out ErrorRecord errRecord, errorMsgs, debugMsgs, verboseMsgs); if (errRecord != null) @@ -980,18 +962,10 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v return null; } - string pkgContentUrl = String.Empty; - if (getLatestVersion) - { - pkgContentUrl = versionedResponses[0]; - } - else - { - pkgContentUrl = GetPackageContentUrlForVersion(versionedResponses, version, packageName); - } - + string pkgContentUrl = GetPackageContentUrl(versionedResponses, version); if (String.IsNullOrEmpty(pkgContentUrl)) { + debugMsgs.Enqueue($"No registration entry for package '{packageName}' had a 'catalogEntry' version matching '{version}' and a 'packageContent' URL."); errorMsgs.Enqueue(new ErrorRecord( new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), "InstallFailure", @@ -1020,114 +994,84 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v } /// - /// 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'). + /// Selects the "packageContent" URL (i.e the .nupkg download URL) of the registration entry for the required version, + /// or of the latest version if the required version is null. + /// The version is taken from the entry's "catalogEntry" > "version" property and compared as a NuGetVersion, + /// rather than being inferred from the URL text (a substring search for '1.2.3' would match the URL for '1.2.30'). /// - internal static string GetPackageContentUrlForVersion( - string[] versionedResponses, - NuGetVersion requiredVersion, - string packageName) + internal static string GetPackageContentUrl(string[] registrationEntries, NuGetVersion requiredVersion) { - if (versionedResponses == null || requiredVersion == null) + string pkgContentUrl = String.Empty; + if (registrationEntries == null) { - return String.Empty; + return pkgContentUrl; } - foreach (string response in versionedResponses) + NuGetVersion latestVersion = null; + foreach (string registrationEntry in registrationEntries) { - if (String.IsNullOrWhiteSpace(response)) + if (!TryGetPackageContentEntry(registrationEntry, out NuGetVersion entryVersion, out string entryPkgContentUrl)) { 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, packageName)) + if (requiredVersion != null) { - return response; + if (entryVersion == requiredVersion) + { + return entryPkgContentUrl; + } + } + else if (latestVersion == null || entryVersion > latestVersion) + { + latestVersion = entryVersion; + pkgContentUrl = entryPkgContentUrl; } } - return String.Empty; + return pkgContentUrl; } /// - /// Determines whether the given "packageContent" entry refers to the required version. + /// Gets the version and "packageContent" URL from a registration entry, which looks like: + /// { "catalogEntry": { "version": "1.0.0", ... }, "packageContent": "https://api.nuget.org/v3-flatcontainer/test_module/1.0.0/test_module.1.0.0.nupkg", ... } /// - private static bool PackageContentUrlMatchesVersion( - string packageContentUrl, - NuGetVersion requiredVersion, - string packageName) + private static bool TryGetPackageContentEntry(string registrationEntry, out NuGetVersion version, out string pkgContentUrl) { - string path = packageContentUrl; - string query = String.Empty; - int queryIndex = path.IndexOfAny(new char[] { '?', '#' }); - if (queryIndex >= 0) + version = null; + pkgContentUrl = String.Empty; + if (String.IsNullOrWhiteSpace(registrationEntry)) { - query = path.Substring(queryIndex + 1); - path = path.Substring(0, queryIndex); + return false; } - string[] pathSegments = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - string normalizedVersion = requiredVersion.ToNormalizedString(); - for (int i = 0; i < pathSegments.Length; i++) + try { - 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) + using (JsonDocument registrationEntryJson = JsonDocument.Parse(registrationEntry)) { - return true; - } + JsonElement rootDom = registrationEntryJson.RootElement; + if (rootDom.ValueKind != JsonValueKind.Object || + !rootDom.TryGetProperty(packageContentProperty, out JsonElement pkgContentElement) || + pkgContentElement.ValueKind != JsonValueKind.String || + !rootDom.TryGetProperty(catalogEntryProperty, out JsonElement catalogEntryElement) || + catalogEntryElement.ValueKind != JsonValueKind.Object || + !catalogEntryElement.TryGetProperty(versionName, out JsonElement versionElement) || + !NuGetVersion.TryParse(versionElement.ToString(), out version)) + { + version = null; + return false; + } - // Last path segment is the file name, ex: "test_module.1.0.0.nupkg" - if (segment.Equals($"{packageName}.{normalizedVersion}.nupkg", StringComparison.OrdinalIgnoreCase)) - { - return true; + pkgContentUrl = pkgContentElement.GetString(); } } - - // Some repositories pass the version as a query parameter, ex: "...?packageVersion=1.0.0" - foreach (string queryParameter in query.Split(new char[] { '&', ';' }, StringSplitOptions.RemoveEmptyEntries)) + catch (JsonException) { - int separatorIndex = queryParameter.IndexOf('='); - if (separatorIndex < 0) - { - 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) - { - return true; - } + version = null; + return false; } - return false; - } - - private static string UnescapeUrlPart(string urlPart) - { - try - { - return Uri.UnescapeDataString(urlPart); - } - catch (UriFormatException) - { - return urlPart; - } - catch (ArgumentException) - { - return urlPart; - } + return !String.IsNullOrWhiteSpace(pkgContentUrl); } /// @@ -1542,8 +1486,9 @@ private string[] GetMetadataElementsFromResponse(string response, string propert if (metadataElement.ValueKind == JsonValueKind.String) { - // This is when property is "packageContent" - versionedPkgResponses.Add(metadataElement.ToString()); + // This is when property is "packageContent". + // The whole entry is returned so the version can be read from its "catalogEntry" rather than inferred from the URL. + versionedPkgResponses.Add(item.ToString()); } else if(metadataElement.ValueKind == JsonValueKind.Object) { @@ -1585,7 +1530,8 @@ private string[] GetMetadataElementsFromResponse(string response, string propert /// This contains an inner items element (containing the package metadata) and the packageContent element (containing URI through which the .nupkg can be downloaded) /// This can be the "catalogEntry" or "packageContent" property. /// The "catalogEntry" property is used for search, and the value is package metadata. - /// The "packageContent" property is used for download, and the value is a URI for the .nupkg file. + /// The "packageContent" property is used for download, and the value is the whole registration entry, + /// containing both the "catalogEntry" (with the package version) and the "packageContent" URI for the .nupkg file. /// /// private string[] GetVersionedResponsesFromRegistrationsResource(string registrationsBaseUrl, string packageName, string property, bool isSearch, out ErrorRecord errRecord, ConcurrentQueue errorMsgs, ConcurrentQueue debugMsgs, ConcurrentQueue verboseMsgs) @@ -1617,25 +1563,16 @@ private string[] GetVersionedResponsesFromRegistrationsResource(string registrat } // Reverse array of versioned responses, if needed, so that version entries are in descending order. - if (String.IsNullOrEmpty(upperVersion)) + // Install selects its entry by comparing versions, so it does not depend on the order. + if (String.IsNullOrEmpty(upperVersion) || !isSearch) { // add write Debug and use these results return versionedResponseArr; } - if (isSearch) - { - if (!IsLatestVersionFirstForSearch(versionedResponseArr, out errRecord, errorMsgs, debugMsgs, verboseMsgs)) - { - Array.Reverse(versionedResponseArr); - } - } - else + if (!IsLatestVersionFirstForSearch(versionedResponseArr, out errRecord, errorMsgs, debugMsgs, verboseMsgs)) { - if (!IsLatestVersionFirstForInstall(versionedResponseArr, upperVersion, out errRecord, errorMsgs, debugMsgs, verboseMsgs)) - { - Array.Reverse(versionedResponseArr); - } + Array.Reverse(versionedResponseArr); } return versionedResponseArr; @@ -1740,33 +1677,6 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro return latestVersionFirst; } - /// - /// Returns true if the nupkg URI entries for each package version are arranged in descending order with respect to the package's version. - /// ADO feeds usually return version entries in descending order, but Nuget.org repository returns them in ascending order. - /// Entries do not reflect prerelease preference so all versions (including prerelease) are being considered here, so upper version (including prerelease) can be used for comparison. - /// - private bool IsLatestVersionFirstForInstall(string[] versionedResponses, string upperVersion, out ErrorRecord errRecord, ConcurrentQueue errorMsgs, ConcurrentQueue debugMsgs, ConcurrentQueue verboseMsgs) - { - debugMsgs.Enqueue("In V3ServerAPICalls::IsLatestVersionFirstForInstall()"); - errRecord = null; - bool latestVersionFirst = true; - - // We don't need to perform this check if no responses, or single response - if (versionedResponses.Length < 2) - { - return latestVersionFirst; - } - - string firstResponse = versionedResponses[0]; - // for Install, response will be a URI value for the package .nupkg, not JSON - if (!firstResponse.Contains(upperVersion)) - { - latestVersionFirst = false; - } - - return latestVersionFirst; - } - /// /// Helper method that determines if specified tags are present in package's tags. /// diff --git a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 index 4ec01b27d..2aeb7a98e 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -7,70 +7,95 @@ 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" + + # Registration entries returned by the V3 RegistrationsBaseUrl resource, containing the version in "catalogEntry" and the .nupkg url in "packageContent" + function New-RegistrationEntry([string] $Version, [string] $PackageContent) { + @{ + catalogEntry = @{ id = 'test_module'; version = $Version } + packageContent = $PackageContent + } | ConvertTo-Json -Compress + } + + function Get-PackageContentUrl([string[]] $Entries, [string] $Version) { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($Entries, $Version) + } + + # Entries are in descending version order, ie the entry for 1.2.30 precedes the entry for 1.2.3 + $entries = @( + (New-RegistrationEntry '1.2.30' "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg"), + (New-RegistrationEntry '1.2.3' "$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', 'test_module') - $url | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" + Get-PackageContentUrl $entries '1.2.3' | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" } 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', 'test_module') - $url | Should -BeExactly "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg" + Get-PackageContentUrl $entries '1.2.30' | 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" + $fourPartEntries = @( + (New-RegistrationEntry '2024.5.20.12' "$packageBaseAddress/2024.5.20.12/test_module.2024.5.20.12.nupkg"), + (New-RegistrationEntry '2024.5.20.1' "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg") ) - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2024.5.20.1', 'test_module') - $url | Should -BeExactly "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg" + Get-PackageContentUrl $fourPartEntries '2024.5.20.1' | Should -BeExactly "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.1.nupkg" } - It 'Should not select a url whose file name has the requested version as part of another version' { - $responses = @( - "$packageBaseAddress/3.2.5.1/test_module.3.2.5.1.nupkg", - "$packageBaseAddress/2.5.1/test_module.2.5.1.nupkg" + It 'Should select the url for a prerelease version' { + $prereleaseEntries = @( + (New-RegistrationEntry '2.5.0-beta10' "$packageBaseAddress/2.5.0-beta10/test_module.2.5.0-beta10.nupkg"), + (New-RegistrationEntry '2.5.0-beta1' "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg") ) - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2.5.1', 'test_module') - $url | Should -BeExactly "$packageBaseAddress/2.5.1/test_module.2.5.1.nupkg" + Get-PackageContentUrl $prereleaseEntries '2.5.0-beta1' | Should -BeExactly "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.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" + It 'Should compare versions rather than version text' { + $nonNormalizedEntries = @( + (New-RegistrationEntry '2.5.0-Beta1+build.5' "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg"), + (New-RegistrationEntry '1.2.3.0' "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg") + ) + Get-PackageContentUrl $nonNormalizedEntries '2.5.0-beta1' | Should -BeExactly "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg" + Get-PackageContentUrl $nonNormalizedEntries '1.2.3' | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" + } + + It 'Should not select a url which contains the requested version text but is for another version' { + $misleadingEntries = @( + (New-RegistrationEntry '1.2.30' "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg?ref=1.2.3"), + (New-RegistrationEntry '1.2.3' "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg?ref=1.2.3") + ) + Get-PackageContentUrl $misleadingEntries '1.2.3' | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg?ref=1.2.3" + } + + It 'Should select the url when it does not contain the version' { + $opaqueEntries = @( + (New-RegistrationEntry '1.2.30' 'https://api.nuget.org/v3-flatcontainer/download/a1b2c3'), + (New-RegistrationEntry '1.2.3' 'https://api.nuget.org/v3-flatcontainer/download/d4e5f6') ) - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '2.5.0-beta1', 'test_module') - $url | Should -BeExactly "$packageBaseAddress/2.5.0-beta1/test_module.2.5.0-beta1.nupkg" + Get-PackageContentUrl $opaqueEntries '1.2.3' | Should -BeExactly 'https://api.nuget.org/v3-flatcontainer/download/d4e5f6' } - 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" + It 'Should skip entries without a version or url' { + $incompleteEntries = @( + '{"catalogEntry":{"id":"test_module"},"packageContent":"https://api.nuget.org/v3-flatcontainer/test_module/1.2.3/a.nupkg"}', + '{"catalogEntry":{"id":"test_module","version":"1.2.3"}}', + 'not json', + (New-RegistrationEntry '1.2.3' "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg") ) - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3', 'test_module') - $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + Get-PackageContentUrl $incompleteEntries '1.2.3' | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" } - 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" + It 'Should select the url for the latest version when no version is requested, regardless of entry order' { + $ascendingEntries = @( + (New-RegistrationEntry '1.2.3' "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg"), + (New-RegistrationEntry '1.2.30' "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg"), + (New-RegistrationEntry '1.2.4' "$packageBaseAddress/1.2.4/test_module.1.2.4.nupkg") ) - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($responses, '1.2.3', 'test_module') - $url | Should -BeExactly "https://www.myget.org/api/download?packageId=test_module&packageVersion=1.2.3" + Get-PackageContentUrl $ascendingEntries $null | Should -BeExactly "$packageBaseAddress/1.2.30/test_module.1.2.30.nupkg" } It 'Should not select any url when the requested version is not present' { - $url = [Microsoft.PowerShell.PSResourceGet.UtilClasses.TestHooks]::SelectV3PackageContentUrl($versionedResponses, '1.2.4', 'test_module') - $url | Should -BeNullOrEmpty + Get-PackageContentUrl $entries '1.2.4' | Should -BeNullOrEmpty } } From 09d292d06ee2d4594e5e24d047cd68acc79e161a Mon Sep 17 00:00:00 2001 From: alerickson Date: Tue, 6 Oct 2026 12:00:33 -0700 Subject: [PATCH 5/6] Add missing ConvertFromJson method in test hook --- src/code/TestHooks.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/code/TestHooks.cs b/src/code/TestHooks.cs index e80cbc2d2..3f93c9149 100644 --- a/src/code/TestHooks.cs +++ b/src/code/TestHooks.cs @@ -4,6 +4,7 @@ using Microsoft.PowerShell.PSResourceGet.Cmdlets; using NuGet.Versioning; using System.Management.Automation; +using System.Text.Json; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -22,6 +23,19 @@ public static PSObject ReadPSGetResourceInfo(string filePath) throw new PSInvalidOperationException(errorMsg); } + public static PSObject ConvertFromJson(string json, PSRepositoryInfo repository) + { + using (JsonDocument pkgJson = JsonDocument.Parse(json)) + { + if (PSResourceInfo.TryConvertFromJson(pkgJson, out PSResourceInfo psGetInfo, repository, out string errorMsg)) + { + return PSObject.AsPSObject(psGetInfo); + } + + throw new PSInvalidOperationException(errorMsg); + } + } + public static void WritePSGetResourceInfo( string filePath, PSObject psObjectGetInfo) From 38070f730228a47e8a9d0cb2e687e7d20da005e3 Mon Sep 17 00:00:00 2001 From: alerickson Date: Tue, 6 Oct 2026 12:39:42 -0700 Subject: [PATCH 6/6] Include the prerelease label in the version passed to InstallPackageAsync when installing packages in parallel --- src/code/InstallHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 928bd6c1a..4d2f72c02 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -913,7 +913,7 @@ private ConcurrentDictionary InstallParentAndDependencyPackag Parallel.ForEach(parentAndDeps, new ParallelOptions { MaxDegreeOfParallelism = maxDegreeOfParallelism }, depPkg => { var depPkgName = depPkg.Name; - var depPkgVersion = depPkg.Version.ToString(); + var depPkgVersion = Utils.GetFullVersionString(depPkg.Version.ToString(), depPkg.Prerelease); verboseMsgs.Enqueue($"Installing package '{depPkgName}' version '{depPkgVersion}'"); //Stream responseStream = currentServer.InstallPackage(depPkgName, depPkgVersion, true, out ErrorRecord installNameErrRecord);