diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 5faf27f8f..af12bf236 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..b8b512461 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,111 @@ 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 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; + } + } + + return false; + } + + private static string UnescapeUrlPart(string urlPart) + { + try + { + return Uri.UnescapeDataString(urlPart); + } + catch (UriFormatException) + { + return urlPart; + } + catch (ArgumentException) + { + 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..98b74bd7a --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -0,0 +1,67 @@ +# 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 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" + } + + 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 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 + } +}