diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs index d32b2300f..d9175c0b0 100644 --- a/src/code/ContainerRegistryServerAPICalls.cs +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -649,9 +649,9 @@ internal Hashtable GetContainerRegistryMetadata(string packageName, string exact pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; } } - else if (rootDom.TryGetProperty("Version", out pkgVersionElement)) + else if (rootDom.TryGetProperty("Version", out pkgVersionElement) || rootDom.TryGetProperty("version", out pkgVersionElement)) { - // script metadata will have "Version" property + // script metadata will have "Version" property, but nupkg only based .nuspec will have lowercase "version" property and JsonElement.TryGetProperty() is case sensitive pkgVersionString = pkgVersionElement.ToString(); } else @@ -1115,12 +1115,11 @@ private static Collection> GetDefaultHeaders(string #endregion #region Publish Methods - /// /// Helper method that publishes a package to the container registry. /// This gets called from Publish-PSResource. /// - internal bool PushNupkgContainerRegistry(string psd1OrPs1File, + internal bool PushNupkgContainerRegistry( string outputNupkgDir, string packageName, string modulePrefix, @@ -1128,10 +1127,14 @@ internal bool PushNupkgContainerRegistry(string psd1OrPs1File, ResourceType resourceType, Hashtable parsedMetadataHash, Hashtable dependencies, + bool isNupkgPathSpecified, + string originalNupkgPath, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); - string fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); + + // if isNupkgPathSpecified, then we need to publish the original .nupkg file, as it may be signed + string fullNupkgFile = isNupkgPathSpecified ? originalNupkgPath : System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; string packageNameLowercase = pkgNameForUpload.ToLower(); diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index db88bfa8a..409665edc 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -836,7 +836,8 @@ public static bool TryConvertFromContainerRegistryJson( // Version // For scripts (i.e with "Version" property) the version can contain prerelease label - if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement)) + // For nupkg only based packages the .nuspec's metadata attributes will be lowercase + if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement) || rootDom.TryGetProperty("version", out scriptVersionElement)) { versionValue = scriptVersionElement.ToString(); pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); @@ -883,25 +884,25 @@ public static bool TryConvertFromContainerRegistryJson( metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); // License Url - if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement)) + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) { metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; } // Project Url - if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement)) + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) { metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; } // Icon Url - if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement)) + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) { metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; } // Tags - if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement)) + if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement) || rootDom.TryGetProperty("tags", out tagsElement)) { string[] pkgTags = Utils.EmptyStrArray; if (tagsElement.ValueKind == JsonValueKind.Array) @@ -937,7 +938,7 @@ public static bool TryConvertFromContainerRegistryJson( } // Author - if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement)) + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement)) { metadata["Authors"] = authorsElement.ToString(); @@ -948,19 +949,19 @@ public static bool TryConvertFromContainerRegistryJson( } // Copyright - if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement)) + if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement) || rootDom.TryGetProperty("copyright", out copyrightElement)) { metadata["Copyright"] = copyrightElement.ToString(); } // Description - if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement)) + if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement) || rootDom.TryGetProperty("description", out descriptiontElement)) { metadata["Description"] = descriptiontElement.ToString(); } // ReleaseNotes - if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement)) + if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement) || rootDom.TryGetProperty("releaseNotes", out releaseNotesElement)) { metadata["ReleaseNotes"] = releaseNotesElement.ToString(); } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 0eec8e0d9..66daea84e 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Management.Automation; using System.Net; using System.Net.Http; @@ -440,12 +441,28 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); - var pkgMetadataFile = (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish; + if (_isNupkgPathSpecified) + { + // copy the .nupkg to a temp path (outputNupkgDir field) as we don't want to tamper with the original, possibly signed, .nupkg file + string copiedNupkgFilePath = CopyNupkgFileToTempPath(nupkgFilePath: Path, errRecord: out ErrorRecord copyErrRecord); + if (copyErrRecord != null) + { + _cmdletPassedIn.WriteError(copyErrRecord); + return; + } + + // get package info (name, version, metadata hashtable) from the copied .nupkg package and then populate appropriate fields (_pkgName, _pkgVersion, parsedMetadata) + GetPackageInfoFromNupkg(nupkgFilePath: copiedNupkgFilePath, errRecord: out ErrorRecord pkgInfoErrRecord); + if (pkgInfoErrRecord != null) + { + _cmdletPassedIn.WriteError(pkgInfoErrRecord); + return; + } + } - if (!containerRegistryServer.PushNupkgContainerRegistry(pkgMetadataFile, outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, out ErrorRecord pushNupkgContainerRegistryError)) + if (!containerRegistryServer.PushNupkgContainerRegistry(outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, _isNupkgPathSpecified, Path, out ErrorRecord pushNupkgContainerRegistryError)) { _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); - // exit out of processing return; } } @@ -455,6 +472,7 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe { outputNupkgDir = pathToNupkgToPublish; } + // This call does not throw any exceptions, but it will write unsuccessful responses to the console if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) { @@ -474,7 +492,8 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe } finally { - if (!_isNupkgPathSpecified) + // For scenarios such as Publish-PSResource -NupkgPath -Repository , the outputNupkgDir will be set to NupkgPath path, and a temp outputDir folder will not have been created and thus doesn't need to attempt to be deleted + if (Directory.Exists(outputDir)) { _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); Utils.DeleteDirectory(outputDir); @@ -1243,6 +1262,191 @@ private bool CheckDependenciesExist(Hashtable dependencies, string repositoryNam return true; } + /// + /// This method is called by Publish-PSResource when the -NupkgPath parameter is specified + /// The method copies the .nupkg file to a temp path (populated at outputNupkgDir field) as we dont' want to extract and read original .nupkg file + /// + private string CopyNupkgFileToTempPath(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + string destinationFilePath = String.Empty; + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + try + { + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + if (!Directory.Exists(outputNupkgDir)) + { + Directory.CreateDirectory(outputNupkgDir); + } + } + + destinationFilePath = System.IO.Path.Combine(outputNupkgDir, packageFullName); + File.Copy(Path, destinationFilePath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error moving .nupkg at -NupkgPath to temp nupkg dir path '{outputNupkgDir}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return destinationFilePath; + } + + return destinationFilePath; + } + + /// + /// Get package info from the .nupkg file provided, inluding package name (_pkgName), package version (_pkgVersion), and metadata parsed into a hashtable (parsedMetadata) + /// + private void GetPackageInfoFromNupkg(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + Regex rx = new Regex(@"\.\d+\.", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) + { + return; + } + + Match match = matches[0]; + + GroupCollection groups = match.Groups; + if (groups.Count == 0) + { + return; + } + + Capture group = groups[0]; + + string pkgFoundName = packageFullName.Substring(0, group.Index); + + string version = packageFullName.Substring(group.Index + 1, packageFullName.LastIndexOf('.') - group.Index - 1); + _cmdletPassedIn.WriteDebug($"Found package '{pkgFoundName}', version '{version}', from packageFullName '{packageFullName}' at path '{Path}'"); + + if (!NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing version '{version}' into NuGetVersion instance."), + "ErrorParsingNuGetVersion", + ErrorCategory.NotSpecified, + this); + + return; + } + + _pkgName = pkgFoundName; + _pkgVersion = nugetVersion; + parsedMetadata = GetMetadataFromNupkg(nupkgFilePath, _pkgName, out errRecord); + } + + /// + /// Extract copied .nupkg, find metadata file (either .ps1, .psd1, or .nuspec) and read metadata into a hashtable + /// + internal Hashtable GetMetadataFromNupkg(string copiedNupkgPath, string packageName, out ErrorRecord errRecord) + { + Hashtable pkgMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + errRecord = null; + + // in temp directory create an "extract" folder to which we'll copy .nupkg to, extract contents, etc. + string nupkgDirPath = Directory.GetParent(copiedNupkgPath).FullName; //someGuid/nupkg/myPkg.nupkg -> /someGuid/nupkg + string tempPath = Directory.GetParent(nupkgDirPath).FullName; // someGuid + var extractPath = System.IO.Path.Combine(tempPath, "extract"); // someGuid/extract + + try + { + var dir = Directory.CreateDirectory(extractPath); + dir.Attributes &= ~FileAttributes.ReadOnly; + + // change extension to .zip + string zipFilePath = System.IO.Path.ChangeExtension(copiedNupkgPath, ".zip"); + File.Move(copiedNupkgPath, zipFilePath); + + // extract from .zip + _cmdletPassedIn.WriteDebug($"Extracting '{zipFilePath}' to '{extractPath}'"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, extractPath); + + string psd1FilePath = String.Empty; + string ps1FilePath = String.Empty; + string nuspecFilePath = String.Empty; + Utils.GetMetadataFilesFromPath(extractPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); + + List pkgTags = new List(); + + if (File.Exists(psd1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read module manifest file '{psd1FilePath}'"); + if (!Utils.TryReadManifestFile(psd1FilePath, out pkgMetadata, out Exception readManifestError)) + { + errRecord = new ErrorRecord( + readManifestError, + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + } + else if (File.Exists(ps1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read script file '{ps1FilePath}'"); + if (!PSScriptFileInfo.TryTestPSScriptFileInfo(ps1FilePath, out PSScriptFileInfo parsedScript, out ErrorRecord[] errors, out string[] verboseMsgs)) + { + errRecord = new ErrorRecord( + new InvalidDataException($"PSScriptFile could not be read properly"), + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + + pkgMetadata = parsedScript.ToHashtable(); + } + else if (File.Exists(nuspecFilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read nuspec file '{nuspecFilePath}'"); + pkgMetadata = Utils.GetMetadataFromNuspec(nuspecFilePath, _cmdletPassedIn, out errRecord); + if (errRecord != null) + { + return pkgMetadata; + } + } + else + { + errRecord = new ErrorRecord( + new InvalidDataException($".nupkg package must contain either .psd1, .ps1, or .nuspec file and none were found"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidData, + this); + + return pkgMetadata; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new InvalidOperationException($"Temporary folder for installation could not be created or set due to: {e.Message}"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidOperation, + this); + } + finally + { + if (Directory.Exists(extractPath)) + { + Utils.DeleteDirectory(extractPath); + } + } + + return pkgMetadata; + } + #endregion } } diff --git a/src/code/Utils.cs b/src/code/Utils.cs index da80d3f42..d51ba0fdb 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -23,6 +23,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -1583,6 +1584,11 @@ public static void DeleteDirectoryWithRestore(string dirPath) /// public static void DeleteDirectory(string dirPath) { + if (!Directory.Exists(dirPath)) + { + throw new Exception($"Path '{dirPath}' that was attempting to be deleted does not exist."); + } + // Remove read only file attributes first foreach (var dirFilePath in Directory.GetFiles(dirPath,"*",SearchOption.AllDirectories)) { @@ -1830,6 +1836,73 @@ public static void CreateFile(string filePath) #endregion + #region Nuspec file parsing methods + + public static Hashtable GetMetadataFromNuspec(string nuspecFilePath, PSCmdlet cmdletPassedIn, out ErrorRecord errorRecord) + { + Hashtable nuspecHashtable = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + + XmlDocument nuspecXmlDocument = LoadXmlDocument(nuspecFilePath, cmdletPassedIn, out errorRecord); + if (errorRecord != null) + { + return nuspecHashtable; + } + + try + { + XmlNodeList elemList = nuspecXmlDocument.GetElementsByTagName("metadata"); + for(int i = 0; i < elemList.Count; i++) + { + XmlNode metadataInnerXml = elemList[i]; + + for(int j= 0; j + /// Method that loads file content into XMLDocument. Used when reading .nuspec file. + /// + public static XmlDocument LoadXmlDocument(string filePath, PSCmdlet cmdletPassedIn, out ErrorRecord errRecord) + { + errRecord = null; + XmlDocument doc = new XmlDocument(); + doc.PreserveWhitespace = true; + try { doc.Load(filePath); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "LoadXmlDocumentFailure", + ErrorCategory.ReadError, + cmdletPassedIn); + } + + return doc; + } + + #endregion + } #endregion diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 index 1426efe11..af57385a1 100644 --- a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -97,6 +97,9 @@ Describe "Test Publish-PSResource" -tags 'CI' { # Path to specifically to that invalid test scripts folder $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Path to specifically to that invalid test nupkgs folder + $script:testNupkgsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testNupkgs" } AfterEach { if(!(Test-Path $script:PublishModuleBase)) @@ -511,6 +514,42 @@ Describe "Test Publish-PSResource" -tags 'CI' { $results[0].Name | Should -Be $script:PublishModuleName $results[0].Version | Should -Be $version } + + It "Publish a package given NupkgPath to a package with .psd1" { + $packageName = "temp-testmodule-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .ps1" { + $packageName = "temp-testscript-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .nuspec" { + $packageName = "temp-testnupkg-nupkgpath" + $version = "1.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } } Describe 'Test Publish-PSResource for MAR Repository' -tags 'CI' { diff --git a/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..a0f7d11d8 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg differ diff --git a/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..370ba0068 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg differ diff --git a/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..e3adbf814 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg differ