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); diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index 703894f87..9ee5a905f 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -2162,51 +2162,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 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) - { - 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."); - } - } - - #endregion } diff --git a/src/code/TestHooks.cs b/src/code/TestHooks.cs new file mode 100644 index 000000000..3f93c9149 --- /dev/null +++ b/src/code/TestHooks.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using NuGet.Versioning; +using System.Management.Automation; +using System.Text.Json; + +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 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) + { + 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 a66b35e9d..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,28 +901,10 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error return null; } - string pkgContentUrl = String.Empty; - if (getLatestVersion) - { - pkgContentUrl = versionedResponses[0]; - } - 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; - } - } - } - + 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", @@ -966,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) @@ -990,28 +962,10 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v return null; } - string pkgContentUrl = String.Empty; - if (getLatestVersion) - { - pkgContentUrl = versionedResponses[0]; - } - 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; - } - } - } - + 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", @@ -1039,6 +993,87 @@ private async Task InstallHelperAsync(string packageName, NuGetVersion v return pkgStream; } + /// + /// 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 GetPackageContentUrl(string[] registrationEntries, NuGetVersion requiredVersion) + { + string pkgContentUrl = String.Empty; + if (registrationEntries == null) + { + return pkgContentUrl; + } + + NuGetVersion latestVersion = null; + foreach (string registrationEntry in registrationEntries) + { + if (!TryGetPackageContentEntry(registrationEntry, out NuGetVersion entryVersion, out string entryPkgContentUrl)) + { + continue; + } + + if (requiredVersion != null) + { + if (entryVersion == requiredVersion) + { + return entryPkgContentUrl; + } + } + else if (latestVersion == null || entryVersion > latestVersion) + { + latestVersion = entryVersion; + pkgContentUrl = entryPkgContentUrl; + } + } + + return pkgContentUrl; + } + + /// + /// 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 TryGetPackageContentEntry(string registrationEntry, out NuGetVersion version, out string pkgContentUrl) + { + version = null; + pkgContentUrl = String.Empty; + if (String.IsNullOrWhiteSpace(registrationEntry)) + { + return false; + } + + try + { + using (JsonDocument registrationEntryJson = JsonDocument.Parse(registrationEntry)) + { + 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; + } + + pkgContentUrl = pkgContentElement.GetString(); + } + } + catch (JsonException) + { + version = null; + return false; + } + + return !String.IsNullOrWhiteSpace(pkgContentUrl); + } + /// /// Gets the versioned package entries from the RegistrationsBaseUrl resource /// i.e when the package Name being searched for does not contain wildcard @@ -1451,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) { @@ -1494,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) @@ -1526,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)) { - if (!IsLatestVersionFirstForSearch(versionedResponseArr, out errRecord, errorMsgs, debugMsgs, verboseMsgs)) - { - Array.Reverse(versionedResponseArr); - } - } - else - { - if (!IsLatestVersionFirstForInstall(versionedResponseArr, upperVersion, out errRecord, errorMsgs, debugMsgs, verboseMsgs)) - { - Array.Reverse(versionedResponseArr); - } + Array.Reverse(versionedResponseArr); } return versionedResponseArr; @@ -1649,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 new file mode 100644 index 000000000..2aeb7a98e --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceV3ServerVersionSelection.Tests.ps1 @@ -0,0 +1,101 @@ +# 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' + + # 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' { + 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' { + 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' { + $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") + ) + Get-PackageContentUrl $fourPartEntries '2024.5.20.1' | Should -BeExactly "$packageBaseAddress/2024.5.20.1/test_module.2024.5.20.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") + ) + Get-PackageContentUrl $prereleaseEntries '2.5.0-beta1' | Should -BeExactly "$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') + ) + Get-PackageContentUrl $opaqueEntries '1.2.3' | Should -BeExactly 'https://api.nuget.org/v3-flatcontainer/download/d4e5f6' + } + + 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") + ) + Get-PackageContentUrl $incompleteEntries '1.2.3' | Should -BeExactly "$packageBaseAddress/1.2.3/test_module.1.2.3.nupkg" + } + + 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") + ) + 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' { + Get-PackageContentUrl $entries '1.2.4' | Should -BeNullOrEmpty + } +}