diff --git a/src/PSGet.Format.ps1xml b/src/PSGet.Format.ps1xml index 7b47f3878..8baea53f1 100644 --- a/src/PSGet.Format.ps1xml +++ b/src/PSGet.Format.ps1xml @@ -1,19 +1,19 @@ - + PSResourceInfo Microsoft.PowerShell.PowerShellGet.UtilClasses.PSResourceInfo - + - - - - - - - + + + + + + + @@ -23,42 +23,66 @@ Repository Description - + PSIncludedResourceInfoTable - Microsoft.PowerShell.PowerShellGet.UtilClasses.PSIncludedResourceInfo + Microsoft.PowerShell.PowerShellGet.UtilClasses.PSIncludedResourceInfo - - - - - - - - - - - - - - - - - - - Name - $_.ParentResource.Version - $_.ParentResource.PrereleaseLabel - $_.ParentResource.Name - $_.ParentResource.Repository - - - + + + + + + + + + + + + + + + + + + + Name + $_.ParentResource.Version + $_.ParentResource.PrereleaseLabel + $_.ParentResource.Name + $_.ParentResource.Repository + + + + + + + PSRepositoryInfo + + Microsoft.PowerShell.PowerShellGet.UtilClasses.PSRepositoryInfo + + + + + + + + + + + + Name + Url + Trusted + Priority + + + diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index d126df242..e7a3d7726 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -9,12 +9,14 @@ using NuGet.Protocol.Core.Types; using NuGet.Versioning; using System; +using System.Collections; using System.Collections.Generic; using System.Data; using System.Linq; using System.Management.Automation; using System.Net; using System.Net.Http; +using System.Security; using System.Threading; using Dbg = System.Diagnostics.Debug; @@ -139,7 +141,7 @@ public IEnumerable FindByResourceName( // detect if Script repository needs to be added and/or Module repository needs to be skipped Uri psGalleryScriptsUrl = new Uri("http://www.powershellgallery.com/api/v2/items/psscript/"); - PSRepositoryInfo psGalleryScripts = new PSRepositoryInfo(_psGalleryScriptsRepoName, psGalleryScriptsUrl, repositoriesToSearch[i].Priority, false); + PSRepositoryInfo psGalleryScripts = new PSRepositoryInfo(_psGalleryScriptsRepoName, psGalleryScriptsUrl, repositoriesToSearch[i].Priority, trusted: false, credentialInfo: null); if (_type == ResourceType.None) { _cmdletPassedIn.WriteVerbose("Null Type provided, so add PSGalleryScripts repository"); @@ -159,7 +161,7 @@ public IEnumerable FindByResourceName( // detect if Script repository needs to be added and/or Module repository needs to be skipped Uri poshTestGalleryScriptsUrl = new Uri("https://www.poshtestgallery.com/api/v2/items/psscript/"); - PSRepositoryInfo poshTestGalleryScripts = new PSRepositoryInfo(_poshTestGalleryScriptsRepoName, poshTestGalleryScriptsUrl, repositoriesToSearch[i].Priority, false); + PSRepositoryInfo poshTestGalleryScripts = new PSRepositoryInfo(_poshTestGalleryScriptsRepoName, poshTestGalleryScriptsUrl, repositoriesToSearch[i].Priority, trusted: false, credentialInfo: null); if (_type == ResourceType.None) { _cmdletPassedIn.WriteVerbose("Null Type provided, so add PoshTestGalleryScripts repository"); @@ -180,7 +182,8 @@ public IEnumerable FindByResourceName( _cmdletPassedIn.WriteVerbose(string.Format("Searching in repository {0}", repositoriesToSearch[i].Name)); foreach (var pkg in SearchFromRepository( repositoryName: repositoriesToSearch[i].Name, - repositoryUrl: repositoriesToSearch[i].Url)) + repositoryUrl: repositoriesToSearch[i].Url, + repositoryCredentialInfo: repositoriesToSearch[i].CredentialInfo)) { yield return pkg; } @@ -193,7 +196,8 @@ public IEnumerable FindByResourceName( private IEnumerable SearchFromRepository( string repositoryName, - Uri repositoryUrl) + Uri repositoryUrl, + PSCredentialInfo repositoryCredentialInfo) { PackageSearchResource resourceSearch; PackageMetadataResource resourceMetadata; @@ -229,12 +233,25 @@ private IEnumerable SearchFromRepository( // HTTP, HTTPS, FTP Uri schemes (only other Uri schemes allowed by RepositorySettings.Read() API) PackageSource source = new PackageSource(repositoryUrl.ToString()); + + // Explicitly passed in Credential takes precedence over repository CredentialInfo if (_credential != null) { string password = new NetworkCredential(string.Empty, _credential.Password).Password; source.Credentials = PackageSourceCredential.FromUserInput(repositoryUrl.ToString(), _credential.UserName, password, true, null); _cmdletPassedIn.WriteVerbose("credential successfully set for repository: " + repositoryName); } + else if (repositoryCredentialInfo != null) + { + PSCredential repoCredential = Utils.GetRepositoryCredentialFromSecretManagement( + repositoryName, + repositoryCredentialInfo, + _cmdletPassedIn); + + string password = new NetworkCredential(string.Empty, repoCredential.Password).Password; + source.Credentials = PackageSourceCredential.FromUserInput(repositoryUrl.ToString(), repoCredential.UserName, password, true, null); + _cmdletPassedIn.WriteVerbose("credential successfully read from vault and set for repository: " + repositoryName); + } // GetCoreV3() API is able to handle V2 and V3 repository endpoints var provider = FactoryExtensionsV3.GetCoreV3(NuGet.Protocol.Core.Types.Repository.Provider); diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 64c42dddd..5094b1f02 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -226,7 +226,9 @@ private List ProcessRepositories( List pkgsInstalled = InstallPackage( pkgsFromRepoToInstall, + repoName, repo.Url.AbsoluteUri, + repo.CredentialInfo, credential, isLocalRepo); @@ -303,7 +305,9 @@ private IEnumerable FilterByInstalledPkgs(IEnumerable InstallPackage( IEnumerable pkgsToInstall, // those found to be required to be installed (includes Dependency packages as well) + string repoName, string repoUrl, + PSCredentialInfo repoCredentialInfo, PSCredential credential, bool isLocalRepo) { @@ -398,11 +402,23 @@ private List InstallPackage( /* Download from a non-local repository */ // Set up NuGet API resource for download PackageSource source = new PackageSource(repoUrl); + + // Explicitly passed in Credential takes precedence over repository CredentialInfo if (credential != null) { string password = new NetworkCredential(string.Empty, credential.Password).Password; source.Credentials = PackageSourceCredential.FromUserInput(repoUrl, credential.UserName, password, true, null); } + else if (repoCredentialInfo != null) + { + PSCredential repoCredential = Utils.GetRepositoryCredentialFromSecretManagement( + repoName, + repoCredentialInfo, + _cmdletPassedIn); + + string password = new NetworkCredential(string.Empty, repoCredential.Password).Password; + source.Credentials = PackageSourceCredential.FromUserInput(repoUrl, repoCredential.UserName, password, true, null); + } var provider = FactoryExtensionsV3.GetCoreV3(NuGet.Protocol.Core.Types.Repository.Provider); SourceRepository repository = new SourceRepository(source, provider); diff --git a/src/code/PSCredentialInfo.cs b/src/code/PSCredentialInfo.cs new file mode 100644 index 000000000..11ec0f198 --- /dev/null +++ b/src/code/PSCredentialInfo.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; + +namespace Microsoft.PowerShell.PowerShellGet.UtilClasses +{ + /// + /// This class contains information for a repository's authentication credential. + /// + public sealed class PSCredentialInfo + { + #region Constructor + + /// + /// Initializes a new instance of the PSCredentialInfo class with + /// vaultName and secretName of type string, and + /// (optionally) credential of type PSCredential. + /// + /// + /// + /// + public PSCredentialInfo(string vaultName, string secretName, PSCredential credential = null) + { + VaultName = vaultName; + SecretName = secretName; + Credential = credential; + } + + /// + /// Initializes a new instance of the PSCredentialInfo class with + /// vaultName and secretName of type string, and + /// (optionally) credential of type PSCredential from a PSObject. + /// + /// + public PSCredentialInfo(PSObject psObject) + { + if (psObject == null) + { + throw new ArgumentNullException(nameof(psObject)); + } + + VaultName = (string) psObject.Properties[PSCredentialInfo.VaultNameAttribute]?.Value; + SecretName = (string) psObject.Properties[PSCredentialInfo.SecretNameAttribute]?.Value; + Credential = (PSCredential) psObject.Properties[PSCredentialInfo.CredentialAttribute]?.Value; + } + + #endregion + + #region Members + + private string _vaultName; + /// + /// the Name of the SecretManagement Vault + /// + public string VaultName { + get + { + return _vaultName; + } + + private set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"Invalid CredentialInfo, {PSCredentialInfo.VaultNameAttribute} must be a non-empty string"); + } + + _vaultName = value; + } + } + + private string _secretName; + /// + /// the Name of the Secret + /// + public string SecretName { + get + { + return _secretName; + } + + private set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"Invalid CredentialInfo, {PSCredentialInfo.SecretNameAttribute} must be a non-empty string"); + } + + _secretName = value; + } + } + + /// + /// optional Credential object to save in a SecretManagement Vault + /// for authenticating to repositories + /// + public PSCredential Credential { get; private set; } + + internal static readonly string VaultNameAttribute = nameof(VaultName); + internal static readonly string SecretNameAttribute = nameof(SecretName); + internal static readonly string CredentialAttribute = nameof(Credential); + + #endregion + } +} diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index b1d99eb08..294559c72 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -13,12 +13,13 @@ public sealed class PSRepositoryInfo { #region Constructor - public PSRepositoryInfo(string name, Uri url, int priority, bool trusted) + public PSRepositoryInfo(string name, Uri url, int priority, bool trusted, PSCredentialInfo credentialInfo) { Name = name; Url = url; Priority = priority; Trusted = trusted; + CredentialInfo = credentialInfo; } #endregion @@ -37,6 +38,7 @@ public PSRepositoryInfo(string name, Uri url, int priority, bool trusted) /// /// whether the repository is trusted + /// public bool Trusted { get; } /// @@ -45,6 +47,11 @@ public PSRepositoryInfo(string name, Uri url, int priority, bool trusted) [ValidateRange(0, 50)] public int Priority { get; } + /// + /// the credential information for repository authentication + /// + public PSCredentialInfo CredentialInfo { get; } + #endregion } } diff --git a/src/code/RegisterPSResourceRepository.cs b/src/code/RegisterPSResourceRepository.cs index 821f05688..4ac0e9861 100644 --- a/src/code/RegisterPSResourceRepository.cs +++ b/src/code/RegisterPSResourceRepository.cs @@ -84,6 +84,12 @@ class RegisterPSResourceRepository : PSCmdlet [ValidateRange(0, 50)] public int Priority { get; set; } = defaultPriority; + /// + /// Specifies vault and secret names as PSCredentialInfo for the repository. + /// + [Parameter(ParameterSetName = NameParameterSet)] + public PSCredentialInfo CredentialInfo { get; set; } + /// /// Specifies a proxy server for the request, rather than a direct connection to the internet resource. /// @@ -137,7 +143,7 @@ protected override void ProcessRecord() try { - items.Add(NameParameterSetHelper(Name, _url, Priority, Trusted)); + items.Add(NameParameterSetHelper(Name, _url, Priority, Trusted, CredentialInfo)); } catch (Exception e) { @@ -194,7 +200,7 @@ protected override void ProcessRecord() } } - private PSRepositoryInfo AddToRepositoryStoreHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted) + private PSRepositoryInfo AddToRepositoryStoreHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted, PSCredentialInfo repoCredentialInfo) { // remove trailing and leading whitespaces, and if Name is just whitespace Name should become null now and be caught by following condition repoName = repoName.Trim(' '); @@ -208,16 +214,42 @@ private PSRepositoryInfo AddToRepositoryStoreHelper(string repoName, Uri repoUrl throw new ArgumentException("Invalid url, must be one of the following Uri schemes: HTTPS, HTTP, FTP, File Based"); } + if (repoCredentialInfo != null) + { + bool isSecretManagementModuleAvailable = Utils.IsSecretManagementModuleAvailable(repoName, this); + + if (repoCredentialInfo.Credential != null) + { + if (!isSecretManagementModuleAvailable) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException($"Microsoft.PowerShell.SecretManagement module is not found, but is required for saving PSResourceRepository {repoName}'s Credential in a vault."), + "RepositoryCredentialSecretManagementUnavailableModule", + ErrorCategory.ResourceUnavailable, + this)); + } + else + { + Utils.SaveRepositoryCredentialToSecretManagementVault(repoName, repoCredentialInfo, this); + } + } + + if (!isSecretManagementModuleAvailable) + { + WriteWarning($"Microsoft.PowerShell.SecretManagement module cannot be found. Make sure it is installed before performing PSResource operations in order to successfully authenticate to PSResourceRepository \"{repoName}\" with its CredentialInfo."); + } + } + WriteVerbose("All required values to add to repository provided, calling internal Add() API now"); if (!ShouldProcess(repoName, "Register repository to repository store")) { return null; } - return RepositorySettings.Add(repoName, repoUrl, repoPriority, repoTrusted); + return RepositorySettings.Add(repoName, repoUrl, repoPriority, repoTrusted, repoCredentialInfo); } - private PSRepositoryInfo NameParameterSetHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted) + private PSRepositoryInfo NameParameterSetHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted, PSCredentialInfo repoCredentialInfo) { if (repoName.Equals("PSGallery", StringComparison.OrdinalIgnoreCase)) { @@ -225,14 +257,14 @@ private PSRepositoryInfo NameParameterSetHelper(string repoName, Uri repoUrl, in throw new ArgumentException("Cannot register PSGallery with -Name parameter. Try: Register-PSResourceRepository -PSGallery"); } - return AddToRepositoryStoreHelper(repoName, repoUrl, repoPriority, repoTrusted); + return AddToRepositoryStoreHelper(repoName, repoUrl, repoPriority, repoTrusted, repoCredentialInfo); } private PSRepositoryInfo PSGalleryParameterSetHelper(int repoPriority, bool repoTrusted) { Uri psGalleryUri = new Uri(PSGalleryRepoURL); WriteVerbose("(PSGallerySet) internal name and uri values for Add() API are hardcoded and validated, priority and trusted values, if passed in, also validated"); - return AddToRepositoryStoreHelper(PSGalleryRepoName, psGalleryUri, repoPriority, repoTrusted); + return AddToRepositoryStoreHelper(PSGalleryRepoName, psGalleryUri, repoPriority, repoTrusted, repoCredentialInfo: null); } private List RepositoriesParameterSetHelper() @@ -242,11 +274,11 @@ private List RepositoriesParameterSetHelper() { if (repo.ContainsKey(PSGalleryRepoName)) { - if (repo.ContainsKey("Name") || repo.ContainsKey("Url")) + if (repo.ContainsKey("Name") || repo.ContainsKey("Url") || repo.ContainsKey("CredentialInfo")) { WriteError(new ErrorRecord( - new PSInvalidOperationException("Repository hashtable cannot contain PSGallery key with -Name and/or -URL key value pairs"), - "NotProvideNameUrlForPSGalleryRepositoriesParameterSetRegistration", + new PSInvalidOperationException("Repository hashtable cannot contain PSGallery key with -Name, -URL and/or -CredentialInfo key value pairs"), + "NotProvideNameUrlCredentialInfoForPSGalleryRepositoriesParameterSetRegistration", ErrorCategory.InvalidArgument, this)); continue; @@ -322,13 +354,25 @@ private PSRepositoryInfo RepoValidationHelper(Hashtable repo) return null; } + PSCredentialInfo repoCredentialInfo = null; + if (repo.ContainsKey("CredentialInfo") && + !Utils.TryCreateValidPSCredentialInfo(credentialInfoCandidate: (PSObject) repo["CredentialInfo"], + cmdletPassedIn: this, + repoCredentialInfo: out repoCredentialInfo, + errorRecord: out ErrorRecord errorRecord1)) + { + WriteError(errorRecord1); + return null; + } + try { WriteVerbose(String.Format("(RepositoriesParameterSet): on repo: {0}. Registers Name based repository", repo["Name"])); return NameParameterSetHelper(repo["Name"].ToString(), repoURL, repo.ContainsKey("Priority") ? Convert.ToInt32(repo["Priority"].ToString()) : defaultPriority, - repo.ContainsKey("Trusted") ? Convert.ToBoolean(repo["Trusted"].ToString()) : defaultTrusted); + repo.ContainsKey("Trusted") ? Convert.ToBoolean(repo["Trusted"].ToString()) : defaultTrusted, + repoCredentialInfo); } catch (Exception e) { diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index a38f45856..5ca3f6e64 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -60,7 +60,7 @@ public static void CheckRepositoryStore() // Add PSGallery to the newly created store Uri psGalleryUri = new Uri(PSGalleryRepoURL); - Add(PSGalleryRepoName, psGalleryUri, defaultPriority, defaultTrusted); + Add(PSGalleryRepoName, psGalleryUri, defaultPriority, defaultTrusted, repoCredentialInfo: null); } // Open file (which should exist now), if cannot/is corrupted then throw error @@ -79,7 +79,7 @@ public static void CheckRepositoryStore() /// Returns: PSRepositoryInfo containing information about the repository just added to the repository store /// /// - public static PSRepositoryInfo Add(string repoName, Uri repoURL, int repoPriority, bool repoTrusted) + public static PSRepositoryInfo Add(string repoName, Uri repoURL, int repoPriority, bool repoTrusted, PSCredentialInfo repoCredentialInfo) { try { @@ -103,6 +103,12 @@ public static PSRepositoryInfo Add(string repoName, Uri repoURL, int repoPriorit new XAttribute("Trusted", repoTrusted) ); + if (repoCredentialInfo != null) + { + newElement.Add(new XAttribute(PSCredentialInfo.VaultNameAttribute, repoCredentialInfo.VaultName)); + newElement.Add(new XAttribute(PSCredentialInfo.SecretNameAttribute, repoCredentialInfo.SecretName)); + } + root.Add(newElement); // Close the file @@ -113,14 +119,14 @@ public static PSRepositoryInfo Add(string repoName, Uri repoURL, int repoPriorit throw new PSInvalidOperationException(String.Format("Adding to repository store failed: {0}", e.Message)); } - return new PSRepositoryInfo(repoName, repoURL, repoPriority, repoTrusted); + return new PSRepositoryInfo(repoName, repoURL, repoPriority, repoTrusted, repoCredentialInfo); } /// - /// Updates a repository name, URL, priority, or installation policy + /// Updates a repository name, URL, priority, installation policy, or credential information /// Returns: void /// - public static PSRepositoryInfo Update(string repoName, Uri repoURL, int repoPriority, bool? repoTrusted) + public static PSRepositoryInfo Update(string repoName, Uri repoURL, int repoPriority, bool? repoTrusted, PSCredentialInfo repoCredentialInfo) { PSRepositoryInfo updatedRepo; try @@ -158,16 +164,50 @@ public static PSRepositoryInfo Update(string repoName, Uri repoURL, int repoPrio node.Attribute("Trusted").Value = repoTrusted.ToString(); } + // A null CredentialInfo value passed in signifies that CredentialInfo was not attempted to be set. + // Set VaultName and SecretName attributes if non-null value passed in for repoCredentialInfo + if (repoCredentialInfo != null) + { + if (node.Attribute(PSCredentialInfo.VaultNameAttribute) == null) + { + node.Add(new XAttribute(PSCredentialInfo.VaultNameAttribute, repoCredentialInfo.VaultName)); + } + else + { + node.Attribute(PSCredentialInfo.VaultNameAttribute).Value = repoCredentialInfo.VaultName; + } + + if (node.Attribute(PSCredentialInfo.SecretNameAttribute) == null) + { + node.Add(new XAttribute(PSCredentialInfo.SecretNameAttribute, repoCredentialInfo.SecretName)); + } + else + { + node.Attribute(PSCredentialInfo.SecretNameAttribute).Value = repoCredentialInfo.SecretName; + } + } + // Create Uri from node Url attribute to create PSRepositoryInfo item to return. if (!Uri.TryCreate(node.Attribute("Url").Value, UriKind.Absolute, out Uri thisUrl)) { throw new PSInvalidOperationException(String.Format("Unable to read incorrectly formatted URL for repo {0}", repoName)); } + // Create CredentialInfo based on new values or whether it was empty to begin with + PSCredentialInfo thisCredentialInfo = null; + if (node.Attribute(PSCredentialInfo.VaultNameAttribute)?.Value != null && + node.Attribute(PSCredentialInfo.SecretNameAttribute)?.Value != null) + { + thisCredentialInfo = new PSCredentialInfo( + node.Attribute(PSCredentialInfo.VaultNameAttribute).Value, + node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); + } + updatedRepo = new PSRepositoryInfo(repoName, thisUrl, Int32.Parse(node.Attribute("Priority").Value), - Boolean.Parse(node.Attribute("Trusted").Value)); + Boolean.Parse(node.Attribute("Trusted").Value), + thisCredentialInfo); // Close the file root.Save(FullRepositoryPath); @@ -212,11 +252,17 @@ public static List Remove(string[] repoNames, out string[] err continue; } + PSCredentialInfo repoCredentialInfo = null; + if (node.Attribute("VaultName") != null & node.Attribute("SecretName") != null) + { + repoCredentialInfo = new PSCredentialInfo(node.Attribute("VaultName").Value, node.Attribute("SecretName").Value); + } removedRepos.Add( new PSRepositoryInfo(repo, new Uri(node.Attribute("Url").Value), Int32.Parse(node.Attribute("Priority").Value), - Boolean.Parse(node.Attribute("Trusted").Value))); + Boolean.Parse(node.Attribute("Trusted").Value), + repoCredentialInfo)); // Remove item from file node.Remove(); } @@ -256,15 +302,47 @@ public static List Read(string[] repoNames, out string[] error continue; } + PSCredentialInfo thisCredentialInfo; + string credentialInfoErrorMessage = $"Repository {repo.Attribute("Name").Value} has invalid CredentialInfo. {PSCredentialInfo.VaultNameAttribute} and {PSCredentialInfo.SecretNameAttribute} should both be present and non-empty"; + // both keys are present + if (repo.Attribute(PSCredentialInfo.VaultNameAttribute) != null && repo.Attribute(PSCredentialInfo.SecretNameAttribute) != null) + { + try + { + // both values are non-empty + // = valid credentialInfo + thisCredentialInfo = new PSCredentialInfo(repo.Attribute(PSCredentialInfo.VaultNameAttribute).Value, repo.Attribute(PSCredentialInfo.SecretNameAttribute).Value); + } + catch (Exception) + { + thisCredentialInfo = null; + tempErrorList.Add(credentialInfoErrorMessage); + continue; + } + } + // both keys are missing + else if (repo.Attribute(PSCredentialInfo.VaultNameAttribute) == null && repo.Attribute(PSCredentialInfo.SecretNameAttribute) == null) + { + // = valid credentialInfo + thisCredentialInfo = null; + } + // one of the keys is missing + else + { + thisCredentialInfo = null; + tempErrorList.Add(credentialInfoErrorMessage); + continue; + } + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(repo.Attribute("Name").Value, thisUrl, Int32.Parse(repo.Attribute("Priority").Value), - Boolean.Parse(repo.Attribute("Trusted").Value)); + Boolean.Parse(repo.Attribute("Trusted").Value), + thisCredentialInfo); foundRepos.Add(currentRepoItem); } } - else { foreach (string repo in repoNames) @@ -282,10 +360,43 @@ public static List Read(string[] repoNames, out string[] error continue; } + PSCredentialInfo thisCredentialInfo; + string credentialInfoErrorMessage = $"Repository {node.Attribute("Name").Value} has invalid CredentialInfo. {PSCredentialInfo.VaultNameAttribute} and {PSCredentialInfo.SecretNameAttribute} should both be present and non-empty"; + // both keys are present + if (node.Attribute(PSCredentialInfo.VaultNameAttribute) != null && node.Attribute(PSCredentialInfo.SecretNameAttribute) != null) + { + try + { + // both values are non-empty + // = valid credentialInfo + thisCredentialInfo = new PSCredentialInfo(node.Attribute(PSCredentialInfo.VaultNameAttribute).Value, node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); + } + catch (Exception) + { + thisCredentialInfo = null; + tempErrorList.Add(credentialInfoErrorMessage); + continue; + } + } + // both keys are missing + else if (node.Attribute(PSCredentialInfo.VaultNameAttribute) == null && node.Attribute(PSCredentialInfo.SecretNameAttribute) == null) + { + // = valid credentialInfo + thisCredentialInfo = null; + } + // one of the keys is missing + else + { + thisCredentialInfo = null; + tempErrorList.Add(credentialInfoErrorMessage); + continue; + } + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(node.Attribute("Name").Value, thisUrl, Int32.Parse(node.Attribute("Priority").Value), - Boolean.Parse(node.Attribute("Trusted").Value)); + Boolean.Parse(node.Attribute("Trusted").Value), + thisCredentialInfo); foundRepos.Add(currentRepoItem); } diff --git a/src/code/SetPSResourceRepository.cs b/src/code/SetPSResourceRepository.cs index a2144e3e1..2ee10e5ae 100644 --- a/src/code/SetPSResourceRepository.cs +++ b/src/code/SetPSResourceRepository.cs @@ -26,6 +26,7 @@ public sealed class SetPSResourceRepository : PSCmdlet private const string RepositoriesParameterSet = "RepositoriesParameterSet"; private const int DefaultPriority = -1; private Uri _url; + #endregion #region Parameters @@ -82,7 +83,13 @@ public SwitchParameter Trusted public int Priority { get; set; } = DefaultPriority; /// - /// When specified, displays the succcessfully registered repository and its information + /// Specifies vault and secret names as PSCredentialInfo for the repository. + /// + [Parameter(ParameterSetName = NameParameterSet)] + public PSCredentialInfo CredentialInfo { get; set; } + + /// + /// When specified, displays the successfully registered repository and its information /// [Parameter] public SwitchParameter PassThru { get; set; } @@ -114,7 +121,7 @@ protected override void ProcessRecord() case NameParameterSet: try { - items.Add(UpdateRepositoryStoreHelper(Name, _url, Priority, Trusted)); + items.Add(UpdateRepositoryStoreHelper(Name, _url, Priority, Trusted, CredentialInfo)); } catch (Exception e) { @@ -155,7 +162,7 @@ protected override void ProcessRecord() } } - private PSRepositoryInfo UpdateRepositoryStoreHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted) + private PSRepositoryInfo UpdateRepositoryStoreHelper(string repoName, Uri repoUrl, int repoPriority, bool repoTrusted, PSCredentialInfo repoCredentialInfo) { if (repoUrl != null && !(repoUrl.Scheme == Uri.UriSchemeHttp || repoUrl.Scheme == Uri.UriSchemeHttps || repoUrl.Scheme == Uri.UriSchemeFtp || repoUrl.Scheme == Uri.UriSchemeFile)) { @@ -173,17 +180,49 @@ private PSRepositoryInfo UpdateRepositoryStoreHelper(string repoName, Uri repoUr // check PSGallery URL is not trying to be set if (repoName.Equals("PSGallery", StringComparison.OrdinalIgnoreCase) && repoUrl != null) { - throw new ArgumentException("The PSGallery repository has a pre-defined URL. Setting the -URL parmeter for this repository is not allowed, instead try running 'Register-PSResourceRepository -PSGallery'."); + throw new ArgumentException("The PSGallery repository has a pre-defined URL. Setting the -URL parameter for this repository is not allowed, instead try running 'Register-PSResourceRepository -PSGallery'."); + } + + // check PSGallery CredentialInfo is not trying to be set + if (repoName.Equals("PSGallery", StringComparison.OrdinalIgnoreCase) && repoCredentialInfo != null) + { + throw new ArgumentException("The PSGallery repository does not require authentication. Setting the -CredentialInfo parameter for this repository is not allowed, instead try running 'Register-PSResourceRepository -PSGallery'."); } // determine trusted value to pass in (true/false if set, null otherwise, hence the nullable bool variable) bool? _trustedNullable = isSet ? new bool?(repoTrusted) : new bool?(); - // determine if either 1 of 3 values are attempting to be set: URL, Priority, Trusted. + if (repoCredentialInfo != null) + { + bool isSecretManagementModuleAvailable = Utils.IsSecretManagementModuleAvailable(repoName, this); + + if (repoCredentialInfo.Credential != null) + { + if (!isSecretManagementModuleAvailable) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException($"Microsoft.PowerShell.SecretManagement module is not found, but is required for saving PSResourceRepository {repoName}'s Credential in a vault."), + "RepositoryCredentialSecretManagementUnavailableModule", + ErrorCategory.ResourceUnavailable, + this)); + } + else + { + Utils.SaveRepositoryCredentialToSecretManagementVault(repoName, repoCredentialInfo, this); + } + } + + if (!isSecretManagementModuleAvailable) + { + WriteWarning($"Microsoft.PowerShell.SecretManagement module cannot be found. Make sure it is installed before performing PSResource operations in order to successfully authenticate to PSResourceRepository \"{repoName}\" with its CredentialInfo."); + } + } + + // determine if either 1 of 4 values are attempting to be set: URL, Priority, Trusted, CredentialInfo. // if none are (i.e only Name parameter was provided, write error) - if(repoUrl == null && repoPriority == DefaultPriority && _trustedNullable == null) + if (repoUrl == null && repoPriority == DefaultPriority && _trustedNullable == null && repoCredentialInfo == null) { - throw new ArgumentException("Either URL, Priority or Trusted parameters must be requested to be set"); + throw new ArgumentException("Either URL, Priority, Trusted or CredentialInfo parameters must be requested to be set"); } WriteVerbose("All required values to set repository provided, calling internal Update() API now"); @@ -191,8 +230,7 @@ private PSRepositoryInfo UpdateRepositoryStoreHelper(string repoName, Uri repoUr { return null; } - - return RepositorySettings.Update(repoName, repoUrl, repoPriority, _trustedNullable); + return RepositorySettings.Update(repoName, repoUrl, repoPriority, _trustedNullable, repoCredentialInfo); } private List RepositoriesParameterSetHelper() @@ -248,17 +286,30 @@ private PSRepositoryInfo RepoValidationHelper(Hashtable repo) bool repoTrusted = false; isSet = false; - if(repo.ContainsKey("Trusted")) + if (repo.ContainsKey("Trusted")) { repoTrusted = (bool) repo["Trusted"]; isSet = true; } + + PSCredentialInfo repoCredentialInfo = null; + if (repo.ContainsKey("CredentialInfo") && + !Utils.TryCreateValidPSCredentialInfo(credentialInfoCandidate: (PSObject) repo["CredentialInfo"], + cmdletPassedIn: this, + repoCredentialInfo: out repoCredentialInfo, + errorRecord: out ErrorRecord errorRecord1)) + { + WriteError(errorRecord1); + return null; + } + try { return UpdateRepositoryStoreHelper(repo["Name"].ToString(), repoURL, repo.ContainsKey("Priority") ? Convert.ToInt32(repo["Priority"].ToString()) : DefaultPriority, - repoTrusted); + repoTrusted, + repoCredentialInfo); } catch (Exception e) { diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 2b5e5b9aa..30e8765c2 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1,665 +1,1072 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using NuGet.Versioning; -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Language; -using System.Runtime.InteropServices; - -namespace Microsoft.PowerShell.PowerShellGet.UtilClasses -{ - internal static class Utils - { - #region String fields - - public static readonly string[] EmptyStrArray = Array.Empty(); - - #endregion - - #region String methods - - public static string TrimQuotes(string name) - { - return name.Trim('\'', '"'); - } - - public static string QuoteName(string name) - { - bool quotesNeeded = false; - foreach (var c in name) - { - if (Char.IsWhiteSpace(c)) - { - quotesNeeded = true; - break; - } - } - - if (!quotesNeeded) - { - return name; - } - - return "'" + CodeGeneration.EscapeSingleQuotedStringContent(name) + "'"; - } - - /// - /// Converts an ArrayList of object types to a string array. - /// - public static string[] GetStringArray(ArrayList list) - { - if (list == null) { return null; } - - var strArray = new string[list.Count]; - for (int i=0; i < list.Count; i++) - { - strArray[i] = list[i] as string; - } - - return strArray; - } - - public static string[] ProcessNameWildcards( - string[] pkgNames, - out string[] errorMsgs, - out bool isContainWildcard) - { - List namesWithSupportedWildcards = new List(); - List errorMsgsList = new List(); - - if (pkgNames == null) - { - isContainWildcard = true; - errorMsgs = errorMsgsList.ToArray(); - return new string[] {"*"}; - } - - isContainWildcard = false; - foreach (string name in pkgNames) - { - if (WildcardPattern.ContainsWildcardCharacters(name)) - { - if (String.Equals(name, "*", StringComparison.InvariantCultureIgnoreCase)) - { - isContainWildcard = true; - errorMsgs = new string[] {}; - return new string[] {"*"}; - } - - if (name.Contains("?") || name.Contains("[")) - { - errorMsgsList.Add(String.Format("-Name with wildcards '?' and '[' are not supported for this cmdlet so Name entry: {0} will be discarded.", name)); - continue; - } - - isContainWildcard = true; - namesWithSupportedWildcards.Add(name); - } - else - { - namesWithSupportedWildcards.Add(name); - } - } - - errorMsgs = errorMsgsList.ToArray(); - return namesWithSupportedWildcards.ToArray(); - } - - #endregion - - #region Version methods - - public static string GetNormalizedVersionString( - string versionString, - string prerelease) - { - // versionString may be like 1.2.0.0 or 1.2.0 - // prerelease may be null or "alpha1" - // possible passed in examples: - // versionString: "1.2.0" prerelease: "alpha1" - // versionString: "1.2.0" prerelease: "" <- doubtful though - // versionString: "1.2.0.0" prerelease: "alpha1" - // versionString: "1.2.0.0" prerelease: "" - - if (String.IsNullOrEmpty(prerelease)) - { - return versionString; - } - - int numVersionDigits = versionString.Split('.').Count(); - - if (numVersionDigits == 3) - { - // versionString: "1.2.0" prerelease: "alpha1" - return versionString + "-" + prerelease; - } - else if (numVersionDigits == 4) - { - // versionString: "1.2.0.0" prerelease: "alpha1" - return versionString.Substring(0, versionString.LastIndexOf('.')) + "-" + prerelease; - } - - return versionString; - } - - public static bool TryParseVersionOrVersionRange( - string version, - out VersionRange versionRange) - { - versionRange = null; - if (version == null) { return false; } - - if (version.Trim().Equals("*")) - { - versionRange = VersionRange.All; - return true; - } - - // parse as NuGetVersion - if (NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) - { - versionRange = new VersionRange( - minVersion: nugetVersion, - includeMinVersion: true, - maxVersion: nugetVersion, - includeMaxVersion: true, - floatRange: null, - originalString: version); - return true; - } - - // parse as Version range - return VersionRange.TryParse(version, out versionRange); - } - - public static bool GetVersionForInstallPath( - string installedPkgPath, - bool isModule, - PSCmdlet cmdletPassedIn, - out NuGetVersion pkgNuGetVersion) - { - // this method returns false if the PSGetModuleInfo.xml or {pkgName}_InstalledScriptInfo.xml file - // could not be parsed properly, or the version from it could not be parsed into a NuGetVersion. - // In this case the caller method (i.e GetHelper.FilterPkgPathsByVersion()) should skip the current - // installed package path or reassign NuGetVersion variable passed in to a non-null value as it sees fit. - - // for Modules, installedPkgPath will look like this: - // ./PowerShell/Modules/test_module/3.0.0 - // for Scripts, installedPkgPath will look like this: - // ./PowerShell/Scripts/test_script.ps1 - string pkgName = isModule ? String.Empty : Utils.GetInstalledPackageName(installedPkgPath); - - string packageInfoXMLFilePath = isModule ? Path.Combine(installedPkgPath, "PSGetModuleInfo.xml") : Path.Combine((new DirectoryInfo(installedPkgPath).Parent).FullName, "InstalledScriptInfos", $"{pkgName}_InstalledScriptInfo.xml"); - if (!PSResourceInfo.TryRead(packageInfoXMLFilePath, out PSResourceInfo psGetInfo, out string errorMsg)) - { - cmdletPassedIn.WriteVerbose(String.Format( - "The {0} file found at location: {1} cannot be parsed due to {2}", - isModule ? "PSGetModuleInfo.xml" : $"{pkgName}_InstalledScriptInfo.xml", - packageInfoXMLFilePath, - errorMsg)); - pkgNuGetVersion = null; - return false; - } - - string version = psGetInfo.Version.ToString(); - string prereleaseLabel = psGetInfo.PrereleaseLabel; - - if (!NuGetVersion.TryParse( - value: String.IsNullOrEmpty(prereleaseLabel) ? version : GetNormalizedVersionString(version, prereleaseLabel), - version: out pkgNuGetVersion)) - { - cmdletPassedIn.WriteVerbose(String.Format("Leaf directory in path '{0}' cannot be parsed into a version.", installedPkgPath)); - return false; - } - - return true; - } - - #endregion - - #region Url methods - - public static bool TryCreateValidUrl( - string uriString, - PSCmdlet cmdletPassedIn, - out Uri uriResult, - out ErrorRecord errorRecord) - { - errorRecord = null; - if (Uri.TryCreate(uriString, UriKind.Absolute, out uriResult)) - { - return true; - } - - Exception ex; - try - { - // This is needed for a relative path urlstring. Does not throw error for an absolute path. - var filePath = cmdletPassedIn.SessionState.Path.GetResolvedPSPathFromPSPath(uriString)[0].Path; - if (Uri.TryCreate(filePath, UriKind.Absolute, out uriResult)) - { - return true; - } - - ex = new PSArgumentException($"Invalid Uri file path: {uriString}"); - } - catch (Exception e) - { - ex = e; - } - - errorRecord = new ErrorRecord( - new PSArgumentException( - $"The provided Uri is not valid: {uriString}. It must be of Uri Scheme: HTTP, HTTPS, FTP or a file path", - ex), - "InvalidUri", - ErrorCategory.InvalidArgument, - cmdletPassedIn); - - return false; - } - - #endregion - - #region Path methods - - public static string[] GetSubDirectories(string dirPath) - { - try - { - return Directory.GetDirectories(dirPath); - } - catch - { - return EmptyStrArray; - } - } - - public static string[] GetDirectoryFiles(string dirPath) - { - try - { - return Directory.GetFiles(dirPath); - } - catch - { - return EmptyStrArray; - } - } - - public static string GetInstalledPackageName(string pkgPath) - { - if (string.IsNullOrEmpty(pkgPath)) - { - return string.Empty; - } - - if (File.Exists(pkgPath)) - { - // ex: ./PowerShell/Scripts/TestScript.ps1 - return Path.GetFileNameWithoutExtension(pkgPath); - } - - // expecting the full version module path - // ex: ./PowerShell/Modules/TestModule/1.0.0 - return new DirectoryInfo(pkgPath).Parent.Name; - } - - public static List GetAllResourcePaths( - PSCmdlet psCmdlet, - ScopeType? scope = null) - { - GetStandardPlatformPaths( - psCmdlet, - out string myDocumentsPath, - out string programFilesPath); - - List resourcePaths = new List(); - - // Path search order is PSModulePath paths first, then default paths. - if (scope is null) - { - string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); - resourcePaths.AddRange(psModulePath.Split(Path.PathSeparator).ToList()); - } - - if (scope is null || scope.Value is ScopeType.CurrentUser) - { - resourcePaths.Add(Path.Combine(myDocumentsPath, "Modules")); - resourcePaths.Add(Path.Combine(myDocumentsPath, "Scripts")); - } - - if (scope is null || scope.Value is ScopeType.AllUsers) - { - resourcePaths.Add(Path.Combine(programFilesPath, "Modules")); - resourcePaths.Add(Path.Combine(programFilesPath, "Scripts")); - } - - // resourcePaths should now contain, eg: - // ./PowerShell/Scripts - // ./PowerShell/Modules - // add all module directories or script files - List pathsToSearch = new List(); - foreach (string path in resourcePaths) - { - psCmdlet.WriteVerbose(string.Format("Retrieving directories in the path '{0}'", path)); - - if (path.EndsWith("Scripts")) - { - try - { - pathsToSearch.AddRange(GetDirectoryFiles(path)); - } - catch (Exception e) - { - psCmdlet.WriteVerbose(string.Format("Error retrieving files from '{0}': '{1}'", path, e.Message)); - } - } - else - { - try - { - pathsToSearch.AddRange(GetSubDirectories(path)); - } - catch (Exception e) - { - psCmdlet.WriteVerbose(string.Format("Error retrieving directories from '{0}': '{1}'", path, e.Message)); - } - } - } - - // resourcePaths should now contain eg: - // ./PowerShell/Scripts/Test-Script.ps1 - // ./PowerShell/Modules/TestModule - // need to use .ToList() to cast the IEnumerable to type List - pathsToSearch = pathsToSearch.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); - pathsToSearch.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); - - return pathsToSearch; - } - - // Find all potential installation paths given a scope - public static List GetAllInstallationPaths( - PSCmdlet psCmdlet, - ScopeType scope) - { - GetStandardPlatformPaths( - psCmdlet, - out string myDocumentsPath, - out string programFilesPath); - - // The default user scope is CurrentUser - var installationPaths = new List(); - if (scope == ScopeType.AllUsers) - { - installationPaths.Add(Path.Combine(programFilesPath, "Modules")); - installationPaths.Add(Path.Combine(programFilesPath, "Scripts")); - } - else - { - installationPaths.Add(Path.Combine(myDocumentsPath, "Modules")); - installationPaths.Add(Path.Combine(myDocumentsPath, "Scripts")); - } - - installationPaths = installationPaths.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); - installationPaths.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); - - return installationPaths; - } - - private readonly static Version PSVersion6 = new Version(6, 0); - private static void GetStandardPlatformPaths( - PSCmdlet psCmdlet, - out string myDocumentsPath, - out string programFilesPath) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - myDocumentsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); - programFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); - } - else - { - // paths are the same for both Linux and macOS - myDocumentsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "powershell"); - programFilesPath = System.IO.Path.Combine("/usr", "local", "share", "powershell"); - } - } - - #endregion - - #region Manifest methods - - public static bool TryParseModuleManifest( - string moduleFileInfo, - PSCmdlet cmdletPassedIn, - out Hashtable parsedMetadataHashtable) - { - parsedMetadataHashtable = new Hashtable(); - bool successfullyParsed = false; - - // A script will already have the metadata parsed into the parsedMetadatahash, - // a module will still need the module manifest to be parsed. - if (moduleFileInfo.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) - { - // Parse the module manifest - var ast = Parser.ParseFile( - moduleFileInfo, - out Token[] tokens, - out ParseError[] errors); - - if (errors.Length > 0) - { - var message = String.Format("Could not parse '{0}' as a PowerShell data file.", moduleFileInfo); - var ex = new ArgumentException(message); - var psdataParseError = new ErrorRecord(ex, "psdataParseError", ErrorCategory.ParserError, null); - cmdletPassedIn.WriteError(psdataParseError); - return successfullyParsed; - } - else - { - var data = ast.Find(a => a is HashtableAst, false); - if (data != null) - { - parsedMetadataHashtable = (Hashtable)data.SafeGetValue(); - successfullyParsed = true; - } - else - { - var message = String.Format("Could not parse as PowerShell data file-- no hashtable root for file '{0}'", moduleFileInfo); - var ex = new ArgumentException(message); - var psdataParseError = new ErrorRecord(ex, "psdataParseError", ErrorCategory.ParserError, null); - cmdletPassedIn.WriteError(psdataParseError); - } - } - } - - return successfullyParsed; - } - - #endregion - - #region Misc methods - - public static void WriteVerboseOnCmdlet( - PSCmdlet cmdlet, - string message) - { - try - { - cmdlet.InvokeCommand.InvokeScript( - script: $"param ([string] $message) Write-Verbose -Verbose -Message $message", - useNewScope: true, - writeToPipeline: System.Management.Automation.Runspaces.PipelineResultTypes.None, - input: null, - args: new object[] { message }); - } - catch { } - } - - #endregion - - #region Directory and File - - /// - /// Deletes a directory and its contents. - /// Attempts to restore the directory and contents if deletion fails. - /// - public static void DeleteDirectoryWithRestore(string dirPath) - { - string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - - try - { - // Create temporary directory for restore operation if needed. - CopyDirContents(dirPath, tempDirPath, overwrite: true); - - try - { - DeleteDirectory(dirPath); - } - catch (Exception ex) - { - // Delete failed. Attempt to restore the saved directory content. - try - { - RestoreDirContents(tempDirPath, dirPath); - } - catch (Exception exx) - { - throw new PSInvalidOperationException( - $"Cannot remove package path {dirPath}. An attempt to restore the old package has failed with error: {exx.Message}", - ex); - } - - throw new PSInvalidOperationException( - $"Cannot remove package path {dirPath}. The previous package contents have been restored.", - ex); - } - } - finally - { - if (Directory.Exists(tempDirPath)) - { - DeleteDirectory(tempDirPath); - } - } - } - - /// - /// Deletes a directory and its contents - /// This is a workaround for .NET Directory.Delete(), which can fail with WindowsPowerShell - /// on OneDrive with 'access denied' error. - /// Later versions of .NET, with PowerShellCore, do not have this bug. - /// - public static void DeleteDirectory(string dirPath) - { - foreach (var dirFilePath in Directory.GetFiles(dirPath)) - { - if (File.GetAttributes(dirFilePath).HasFlag(FileAttributes.ReadOnly)) - { - File.SetAttributes(dirFilePath, (File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly)); - } - - File.Delete(dirFilePath); - } - - foreach (var dirSubPath in Directory.GetDirectories(dirPath)) - { - DeleteDirectory(dirSubPath); - } - - Directory.Delete(dirPath); - } - - /// - /// Moves files from source to destination locations. - /// This is a workaround for .NET File.Move(), which fails over different file volumes. - /// - public static void MoveFiles( - string sourceFilePath, - string destFilePath, - bool overwrite = true) - { - File.Copy(sourceFilePath, destFilePath, overwrite); - File.Delete(sourceFilePath); - } - - /// - /// Moves the directory, including contents, from source to destination locations. - /// This is a workaround for .NET Directory.Move(), which fails over different file volumes. - /// - public static void MoveDirectory( - string sourceDirPath, - string destDirPath, - bool overwrite = true) - { - CopyDirContents(sourceDirPath, destDirPath, overwrite); - DeleteDirectory(sourceDirPath); - } - - private static void CopyDirContents( - string sourceDirPath, - string destDirPath, - bool overwrite) - { - if (Directory.Exists(destDirPath)) - { - if (!overwrite) - { - throw new PSInvalidOperationException( - $"Cannot move directory because destination directory already exists: '{destDirPath}'"); - } - - DeleteDirectory(destDirPath); - } - - Directory.CreateDirectory(destDirPath); - - foreach (var filePath in Directory.GetFiles(sourceDirPath)) - { - var destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); - File.Copy(filePath, destFilePath); - } - - foreach (var srcSubDirPath in Directory.GetDirectories(sourceDirPath)) - { - var destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(srcSubDirPath)); - CopyDirContents(srcSubDirPath, destSubDirPath, overwrite); - } - } - - private static void RestoreDirContents( - string sourceDirPath, - string destDirPath) - { - if (!Directory.Exists(destDirPath)) - { - Directory.CreateDirectory(destDirPath); - } - - foreach (string filePath in Directory.GetFiles(sourceDirPath)) - { - string destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); - if (!File.Exists(destFilePath)) - { - File.Copy(filePath, destFilePath); - } - } - - foreach (string srcSubDirPath in Directory.GetDirectories(sourceDirPath)) - { - string destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(srcSubDirPath)); - RestoreDirContents(srcSubDirPath, destSubDirPath); - } - } - - #endregion - } -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using NuGet.Versioning; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; +using System.Runtime.InteropServices; +using System.Security; + +namespace Microsoft.PowerShell.PowerShellGet.UtilClasses +{ + #region Utils + + internal static class Utils + { + #region String fields + + public static readonly string[] EmptyStrArray = Array.Empty(); + + #endregion + + #region String methods + + public static string TrimQuotes(string name) + { + return name.Trim('\'', '"'); + } + + public static string QuoteName(string name) + { + bool quotesNeeded = false; + foreach (var c in name) + { + if (Char.IsWhiteSpace(c)) + { + quotesNeeded = true; + break; + } + } + + if (!quotesNeeded) + { + return name; + } + + return "'" + CodeGeneration.EscapeSingleQuotedStringContent(name) + "'"; + } + + /// + /// Converts an ArrayList of object types to a string array. + /// + public static string[] GetStringArray(ArrayList list) + { + if (list == null) { return null; } + + var strArray = new string[list.Count]; + for (int i=0; i < list.Count; i++) + { + strArray[i] = list[i] as string; + } + + return strArray; + } + + public static string[] ProcessNameWildcards( + string[] pkgNames, + out string[] errorMsgs, + out bool isContainWildcard) + { + List namesWithSupportedWildcards = new List(); + List errorMsgsList = new List(); + + if (pkgNames == null) + { + isContainWildcard = true; + errorMsgs = errorMsgsList.ToArray(); + return new string[] {"*"}; + } + + isContainWildcard = false; + foreach (string name in pkgNames) + { + if (WildcardPattern.ContainsWildcardCharacters(name)) + { + if (String.Equals(name, "*", StringComparison.InvariantCultureIgnoreCase)) + { + isContainWildcard = true; + errorMsgs = new string[] {}; + return new string[] {"*"}; + } + + if (name.Contains("?") || name.Contains("[")) + { + errorMsgsList.Add(String.Format("-Name with wildcards '?' and '[' are not supported for this cmdlet so Name entry: {0} will be discarded.", name)); + continue; + } + + isContainWildcard = true; + namesWithSupportedWildcards.Add(name); + } + else + { + namesWithSupportedWildcards.Add(name); + } + } + + errorMsgs = errorMsgsList.ToArray(); + return namesWithSupportedWildcards.ToArray(); + } + + #endregion + + #region Version methods + + public static string GetNormalizedVersionString( + string versionString, + string prerelease) + { + // versionString may be like 1.2.0.0 or 1.2.0 + // prerelease may be null or "alpha1" + // possible passed in examples: + // versionString: "1.2.0" prerelease: "alpha1" + // versionString: "1.2.0" prerelease: "" <- doubtful though + // versionString: "1.2.0.0" prerelease: "alpha1" + // versionString: "1.2.0.0" prerelease: "" + + if (String.IsNullOrEmpty(prerelease)) + { + return versionString; + } + + int numVersionDigits = versionString.Split('.').Count(); + + if (numVersionDigits == 3) + { + // versionString: "1.2.0" prerelease: "alpha1" + return versionString + "-" + prerelease; + } + else if (numVersionDigits == 4) + { + // versionString: "1.2.0.0" prerelease: "alpha1" + return versionString.Substring(0, versionString.LastIndexOf('.')) + "-" + prerelease; + } + + return versionString; + } + + public static bool TryParseVersionOrVersionRange( + string version, + out VersionRange versionRange) + { + versionRange = null; + if (version == null) { return false; } + + if (version.Trim().Equals("*")) + { + versionRange = VersionRange.All; + return true; + } + + // parse as NuGetVersion + if (NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) + { + versionRange = new VersionRange( + minVersion: nugetVersion, + includeMinVersion: true, + maxVersion: nugetVersion, + includeMaxVersion: true, + floatRange: null, + originalString: version); + return true; + } + + // parse as Version range + return VersionRange.TryParse(version, out versionRange); + } + + public static bool GetVersionForInstallPath( + string installedPkgPath, + bool isModule, + PSCmdlet cmdletPassedIn, + out NuGetVersion pkgNuGetVersion) + { + // this method returns false if the PSGetModuleInfo.xml or {pkgName}_InstalledScriptInfo.xml file + // could not be parsed properly, or the version from it could not be parsed into a NuGetVersion. + // In this case the caller method (i.e GetHelper.FilterPkgPathsByVersion()) should skip the current + // installed package path or reassign NuGetVersion variable passed in to a non-null value as it sees fit. + + // for Modules, installedPkgPath will look like this: + // ./PowerShell/Modules/test_module/3.0.0 + // for Scripts, installedPkgPath will look like this: + // ./PowerShell/Scripts/test_script.ps1 + string pkgName = isModule ? String.Empty : Utils.GetInstalledPackageName(installedPkgPath); + + string packageInfoXMLFilePath = isModule ? Path.Combine(installedPkgPath, "PSGetModuleInfo.xml") : Path.Combine((new DirectoryInfo(installedPkgPath).Parent).FullName, "InstalledScriptInfos", $"{pkgName}_InstalledScriptInfo.xml"); + if (!PSResourceInfo.TryRead(packageInfoXMLFilePath, out PSResourceInfo psGetInfo, out string errorMsg)) + { + cmdletPassedIn.WriteVerbose(String.Format( + "The {0} file found at location: {1} cannot be parsed due to {2}", + isModule ? "PSGetModuleInfo.xml" : $"{pkgName}_InstalledScriptInfo.xml", + packageInfoXMLFilePath, + errorMsg)); + pkgNuGetVersion = null; + return false; + } + + string version = psGetInfo.Version.ToString(); + string prereleaseLabel = psGetInfo.PrereleaseLabel; + + if (!NuGetVersion.TryParse( + value: String.IsNullOrEmpty(prereleaseLabel) ? version : GetNormalizedVersionString(version, prereleaseLabel), + version: out pkgNuGetVersion)) + { + cmdletPassedIn.WriteVerbose(String.Format("Leaf directory in path '{0}' cannot be parsed into a version.", installedPkgPath)); + return false; + } + + return true; + } + + #endregion + + #region Url methods + + public static bool TryCreateValidUrl( + string uriString, + PSCmdlet cmdletPassedIn, + out Uri uriResult, + out ErrorRecord errorRecord) + { + errorRecord = null; + if (Uri.TryCreate(uriString, UriKind.Absolute, out uriResult)) + { + return true; + } + + Exception ex; + try + { + // This is needed for a relative path urlstring. Does not throw error for an absolute path. + var filePath = cmdletPassedIn.SessionState.Path.GetResolvedPSPathFromPSPath(uriString)[0].Path; + if (Uri.TryCreate(filePath, UriKind.Absolute, out uriResult)) + { + return true; + } + + ex = new PSArgumentException($"Invalid Uri file path: {uriString}"); + } + catch (Exception e) + { + ex = e; + } + + errorRecord = new ErrorRecord( + new PSArgumentException( + $"The provided Uri is not valid: {uriString}. It must be of Uri Scheme: HTTP, HTTPS, FTP or a file path", + ex), + "InvalidUri", + ErrorCategory.InvalidArgument, + cmdletPassedIn); + + return false; + } + + #endregion + + #region PSCredentialInfo methods + + public static bool TryCreateValidPSCredentialInfo( + PSObject credentialInfoCandidate, + PSCmdlet cmdletPassedIn, + out PSCredentialInfo repoCredentialInfo, + out ErrorRecord errorRecord) + { + repoCredentialInfo = null; + errorRecord = null; + + try + { + if (!string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute]?.Value) + && !string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute]?.Value)) + { + PSCredential credential = null; + if (credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute] != null) + { + try + { + credential = (PSCredential) credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute].Value; + } + catch (Exception e) + { + errorRecord = new ErrorRecord( + new PSArgumentException($"Invalid CredentialInfo {PSCredentialInfo.CredentialAttribute}", e), + "InvalidCredentialInfo", + ErrorCategory.InvalidArgument, + cmdletPassedIn); + + return false; + } + } + + repoCredentialInfo = new PSCredentialInfo( + (string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute].Value, + (string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute].Value, + credential + ); + + return true; + } + else + { + errorRecord = new ErrorRecord( + new PSArgumentException($"Invalid CredentialInfo, must include non-empty {PSCredentialInfo.VaultNameAttribute} and {PSCredentialInfo.SecretNameAttribute}, and optionally a {PSCredentialInfo.CredentialAttribute}"), + "InvalidCredentialInfo", + ErrorCategory.InvalidArgument, + cmdletPassedIn); + + return false; + } + } + catch (Exception e) + { + errorRecord = new ErrorRecord( + new PSArgumentException("Invalid CredentialInfo values", e), + "InvalidCredentialInfo", + ErrorCategory.InvalidArgument, + cmdletPassedIn); + + return false; + } + } + + public static PSCredential GetRepositoryCredentialFromSecretManagement( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), + "RepositoryCredentialSecretManagementInaccessibleVault", + ErrorCategory.ResourceUnavailable, + cmdletPassedIn)); + return null; + } + + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName, + [string] $SecretName + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + & $module ""Get-Secret"" -Name $SecretName -Vault $VaultName + ", + args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName }, + out Exception terminatingError); + + var secretValue = (results.Count == 1) ? results[0] : null; + if (secretValue == null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "RepositoryCredentialCannotGetSecretFromVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + if (secretValue is PSCredential secretCredential) + { + return secretCredential; + } + + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), + "RepositoryCredentialInvalidSecretType", + ErrorCategory.InvalidType, + cmdletPassedIn)); + + return null; + } + + public static void SaveRepositoryCredentialToSecretManagementVault( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), + "RepositoryCredentialSecretManagementInaccessibleVault", + ErrorCategory.ResourceUnavailable, + cmdletPassedIn)); + return; + } + + PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName, + [string] $SecretName, + [object] $SecretValue + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + & $module ""Set-Secret"" -Name $SecretName -Vault $VaultName -Secret $SecretValue + ", + args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName, repositoryCredentialInfo.Credential }, + out Exception terminatingError); + + if (terminatingError != null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Set-Secret encountered an error while adding secret \"{repositoryCredentialInfo.SecretName}\" to vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "RepositoryCredentialCannotAddSecretToVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + } + + public static bool IsSecretManagementModuleAvailable( + string repositoryName, + PSCmdlet cmdletPassedIn) + { + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + $module = Microsoft.PowerShell.Core\Get-Module -Name Microsoft.PowerShell.SecretManagement -ErrorAction Ignore + if ($null -eq $module) { + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru -ErrorAction Ignore + } + if ($null -eq $module) { + return 1 + } + return 0 + ", + args: new object[] {}, + out Exception terminatingError); + + if (terminatingError != null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Cannot validate Microsoft.PowerShell.SecretManagement module setup for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "RepositoryCredentialSecretManagementInvalidModule", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + int result = (results.Count > 0) ? results[0] : 1; + return result == 0; + } + + public static bool IsSecretManagementVaultAccessible( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + & $module ""Test-SecretVault"" -Name $VaultName + ", + args: new object[] { repositoryCredentialInfo.VaultName }, + out Exception terminatingError); + + if (terminatingError != null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Test-SecretVault encountered an error while validating the vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "RepositoryCredentialSecretManagementInvalidVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + bool result = (results.Count > 0) ? results[0] : false; + return result; + } + + #endregion + + #region Path methods + + public static string[] GetSubDirectories(string dirPath) + { + try + { + return Directory.GetDirectories(dirPath); + } + catch + { + return EmptyStrArray; + } + } + + public static string[] GetDirectoryFiles(string dirPath) + { + try + { + return Directory.GetFiles(dirPath); + } + catch + { + return EmptyStrArray; + } + } + + public static string GetInstalledPackageName(string pkgPath) + { + if (string.IsNullOrEmpty(pkgPath)) + { + return string.Empty; + } + + if (File.Exists(pkgPath)) + { + // ex: ./PowerShell/Scripts/TestScript.ps1 + return Path.GetFileNameWithoutExtension(pkgPath); + } + + // expecting the full version module path + // ex: ./PowerShell/Modules/TestModule/1.0.0 + return new DirectoryInfo(pkgPath).Parent.Name; + } + + public static List GetAllResourcePaths( + PSCmdlet psCmdlet, + ScopeType? scope = null) + { + GetStandardPlatformPaths( + psCmdlet, + out string myDocumentsPath, + out string programFilesPath); + + List resourcePaths = new List(); + + // Path search order is PSModulePath paths first, then default paths. + if (scope is null) + { + string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); + resourcePaths.AddRange(psModulePath.Split(Path.PathSeparator).ToList()); + } + + if (scope is null || scope.Value is ScopeType.CurrentUser) + { + resourcePaths.Add(Path.Combine(myDocumentsPath, "Modules")); + resourcePaths.Add(Path.Combine(myDocumentsPath, "Scripts")); + } + + if (scope is null || scope.Value is ScopeType.AllUsers) + { + resourcePaths.Add(Path.Combine(programFilesPath, "Modules")); + resourcePaths.Add(Path.Combine(programFilesPath, "Scripts")); + } + + // resourcePaths should now contain, eg: + // ./PowerShell/Scripts + // ./PowerShell/Modules + // add all module directories or script files + List pathsToSearch = new List(); + foreach (string path in resourcePaths) + { + psCmdlet.WriteVerbose(string.Format("Retrieving directories in the path '{0}'", path)); + + if (path.EndsWith("Scripts")) + { + try + { + pathsToSearch.AddRange(GetDirectoryFiles(path)); + } + catch (Exception e) + { + psCmdlet.WriteVerbose(string.Format("Error retrieving files from '{0}': '{1}'", path, e.Message)); + } + } + else + { + try + { + pathsToSearch.AddRange(GetSubDirectories(path)); + } + catch (Exception e) + { + psCmdlet.WriteVerbose(string.Format("Error retrieving directories from '{0}': '{1}'", path, e.Message)); + } + } + } + + // resourcePaths should now contain eg: + // ./PowerShell/Scripts/Test-Script.ps1 + // ./PowerShell/Modules/TestModule + // need to use .ToList() to cast the IEnumerable to type List + pathsToSearch = pathsToSearch.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); + pathsToSearch.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); + + return pathsToSearch; + } + + // Find all potential installation paths given a scope + public static List GetAllInstallationPaths( + PSCmdlet psCmdlet, + ScopeType scope) + { + GetStandardPlatformPaths( + psCmdlet, + out string myDocumentsPath, + out string programFilesPath); + + // The default user scope is CurrentUser + var installationPaths = new List(); + if (scope == ScopeType.AllUsers) + { + installationPaths.Add(Path.Combine(programFilesPath, "Modules")); + installationPaths.Add(Path.Combine(programFilesPath, "Scripts")); + } + else + { + installationPaths.Add(Path.Combine(myDocumentsPath, "Modules")); + installationPaths.Add(Path.Combine(myDocumentsPath, "Scripts")); + } + + installationPaths = installationPaths.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); + installationPaths.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); + + return installationPaths; + } + + private readonly static Version PSVersion6 = new Version(6, 0); + private static void GetStandardPlatformPaths( + PSCmdlet psCmdlet, + out string myDocumentsPath, + out string programFilesPath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; + myDocumentsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + programFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); + } + else + { + // paths are the same for both Linux and macOS + myDocumentsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "powershell"); + programFilesPath = System.IO.Path.Combine("/usr", "local", "share", "powershell"); + } + } + + #endregion + + #region Manifest methods + + public static bool TryParseModuleManifest( + string moduleFileInfo, + PSCmdlet cmdletPassedIn, + out Hashtable parsedMetadataHashtable) + { + parsedMetadataHashtable = new Hashtable(); + bool successfullyParsed = false; + + // A script will already have the metadata parsed into the parsedMetadatahash, + // a module will still need the module manifest to be parsed. + if (moduleFileInfo.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) + { + // Parse the module manifest + var ast = Parser.ParseFile( + moduleFileInfo, + out Token[] tokens, + out ParseError[] errors); + + if (errors.Length > 0) + { + var message = String.Format("Could not parse '{0}' as a PowerShell data file.", moduleFileInfo); + var ex = new ArgumentException(message); + var psdataParseError = new ErrorRecord(ex, "psdataParseError", ErrorCategory.ParserError, null); + cmdletPassedIn.WriteError(psdataParseError); + return successfullyParsed; + } + else + { + var data = ast.Find(a => a is HashtableAst, false); + if (data != null) + { + parsedMetadataHashtable = (Hashtable)data.SafeGetValue(); + successfullyParsed = true; + } + else + { + var message = String.Format("Could not parse as PowerShell data file-- no hashtable root for file '{0}'", moduleFileInfo); + var ex = new ArgumentException(message); + var psdataParseError = new ErrorRecord(ex, "psdataParseError", ErrorCategory.ParserError, null); + cmdletPassedIn.WriteError(psdataParseError); + } + } + } + + return successfullyParsed; + } + + #endregion + + #region Misc methods + + public static void WriteVerboseOnCmdlet( + PSCmdlet cmdlet, + string message) + { + try + { + cmdlet.InvokeCommand.InvokeScript( + script: $"param ([string] $message) Write-Verbose -Verbose -Message $message", + useNewScope: true, + writeToPipeline: System.Management.Automation.Runspaces.PipelineResultTypes.None, + input: null, + args: new object[] { message }); + } + catch { } + } + + #endregion + + #region Directory and File + + /// + /// Deletes a directory and its contents. + /// Attempts to restore the directory and contents if deletion fails. + /// + public static void DeleteDirectoryWithRestore(string dirPath) + { + string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + try + { + // Create temporary directory for restore operation if needed. + CopyDirContents(dirPath, tempDirPath, overwrite: true); + + try + { + DeleteDirectory(dirPath); + } + catch (Exception ex) + { + // Delete failed. Attempt to restore the saved directory content. + try + { + RestoreDirContents(tempDirPath, dirPath); + } + catch (Exception exx) + { + throw new PSInvalidOperationException( + $"Cannot remove package path {dirPath}. An attempt to restore the old package has failed with error: {exx.Message}", + ex); + } + + throw new PSInvalidOperationException( + $"Cannot remove package path {dirPath}. The previous package contents have been restored.", + ex); + } + } + finally + { + if (Directory.Exists(tempDirPath)) + { + DeleteDirectory(tempDirPath); + } + } + } + + /// + /// Deletes a directory and its contents + /// This is a workaround for .NET Directory.Delete(), which can fail with WindowsPowerShell + /// on OneDrive with 'access denied' error. + /// Later versions of .NET, with PowerShellCore, do not have this bug. + /// + public static void DeleteDirectory(string dirPath) + { + foreach (var dirFilePath in Directory.GetFiles(dirPath)) + { + if (File.GetAttributes(dirFilePath).HasFlag(FileAttributes.ReadOnly)) + { + File.SetAttributes(dirFilePath, (File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly)); + } + + File.Delete(dirFilePath); + } + + foreach (var dirSubPath in Directory.GetDirectories(dirPath)) + { + DeleteDirectory(dirSubPath); + } + + Directory.Delete(dirPath); + } + + /// + /// Moves files from source to destination locations. + /// This is a workaround for .NET File.Move(), which fails over different file volumes. + /// + public static void MoveFiles( + string sourceFilePath, + string destFilePath, + bool overwrite = true) + { + File.Copy(sourceFilePath, destFilePath, overwrite); + File.Delete(sourceFilePath); + } + + /// + /// Moves the directory, including contents, from source to destination locations. + /// This is a workaround for .NET Directory.Move(), which fails over different file volumes. + /// + public static void MoveDirectory( + string sourceDirPath, + string destDirPath, + bool overwrite = true) + { + CopyDirContents(sourceDirPath, destDirPath, overwrite); + DeleteDirectory(sourceDirPath); + } + + private static void CopyDirContents( + string sourceDirPath, + string destDirPath, + bool overwrite) + { + if (Directory.Exists(destDirPath)) + { + if (!overwrite) + { + throw new PSInvalidOperationException( + $"Cannot move directory because destination directory already exists: '{destDirPath}'"); + } + + DeleteDirectory(destDirPath); + } + + Directory.CreateDirectory(destDirPath); + + foreach (var filePath in Directory.GetFiles(sourceDirPath)) + { + var destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); + File.Copy(filePath, destFilePath); + } + + foreach (var srcSubDirPath in Directory.GetDirectories(sourceDirPath)) + { + var destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(srcSubDirPath)); + CopyDirContents(srcSubDirPath, destSubDirPath, overwrite); + } + } + + private static void RestoreDirContents( + string sourceDirPath, + string destDirPath) + { + if (!Directory.Exists(destDirPath)) + { + Directory.CreateDirectory(destDirPath); + } + + foreach (string filePath in Directory.GetFiles(sourceDirPath)) + { + string destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); + if (!File.Exists(destFilePath)) + { + File.Copy(filePath, destFilePath); + } + } + + foreach (string srcSubDirPath in Directory.GetDirectories(sourceDirPath)) + { + string destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(srcSubDirPath)); + RestoreDirContents(srcSubDirPath, destSubDirPath); + } + } + + #endregion + } + + #endregion + + #region PowerShellInvoker + + internal static class PowerShellInvoker + { + #region Members + + private static bool _isHostDefault = false; + private const string DefaultHost = "Default Host"; + + private static Runspace _runspace; + + #endregion Members + + #region Methods + + public static Collection InvokeScriptWithHost( + PSCmdlet cmdlet, + string script, + object[] args, + out Exception terminatingError) + { + return InvokeScriptWithHost( + cmdlet, + script, + args, + out terminatingError); + } + + public static Collection InvokeScriptWithHost( + PSCmdlet cmdlet, + string script, + object[] args, + out Exception terminatingError) + { + Collection returnCollection = new Collection(); + terminatingError = null; + + // Create the runspace if it + // doesn't exist + // is not in a workable state + // has a default host (no UI) when a non-default host is available + if (_runspace == null || + _runspace.RunspaceStateInfo.State != RunspaceState.Opened || + _isHostDefault && !cmdlet.Host.Name.Equals(DefaultHost, StringComparison.InvariantCultureIgnoreCase)) + { + if (_runspace != null) + { + _runspace.Dispose(); + } + + _isHostDefault = cmdlet.Host.Name.Equals(DefaultHost, StringComparison.InvariantCultureIgnoreCase); + + var iss = InitialSessionState.CreateDefault2(); + // We are running trusted script. + iss.LanguageMode = PSLanguageMode.FullLanguage; + // Import the current PowerShellGet module. + var modPathObjects = cmdlet.InvokeCommand.InvokeScript( + script: "(Get-Module -Name PowerShellGet).Path"); + string modPath = (modPathObjects.Count > 0 && + modPathObjects[0].BaseObject is string modPathStr) + ? modPathStr : string.Empty; + if (!string.IsNullOrEmpty(modPath)) + { + iss.ImportPSModule(new string[] { modPath }); + } + + try + { + _runspace = RunspaceFactory.CreateRunspace(cmdlet.Host, iss); + _runspace.Open(); + } + catch (Exception ex) + { + terminatingError = ex; + return returnCollection; + } + } + + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.Runspace = _runspace; + + var cmd = new Command( + command: script, + isScript: true, + useLocalScope: true); + cmd.MergeMyResults( + myResult: PipelineResultTypes.Error | PipelineResultTypes.Warning | PipelineResultTypes.Verbose | PipelineResultTypes.Debug | PipelineResultTypes.Information, + toResult: PipelineResultTypes.Output); + ps.Commands.AddCommand(cmd); + foreach (var arg in args) + { + ps.Commands.AddArgument(arg); + } + + try + { + // Invoke the script. + var results = ps.Invoke(); + + // Extract expected output types from results pipeline. + foreach (var psItem in results) + { + if (psItem == null || psItem.BaseObject == null) { continue; } + + switch (psItem.BaseObject) + { + case ErrorRecord error: + cmdlet.WriteError(error); + break; + + case WarningRecord warning: + cmdlet.WriteWarning(warning.Message); + break; + + case VerboseRecord verbose: + cmdlet.WriteVerbose(verbose.Message); + break; + + case DebugRecord debug: + cmdlet.WriteDebug(debug.Message); + break; + + case InformationRecord info: + cmdlet.WriteInformation(info); + break; + + case T result: + returnCollection.Add(result); + break; + + case T[] resultArray: + foreach (var item in resultArray) + { + returnCollection.Add(item); + } + break; + } + } + } + catch (Exception ex) + { + terminatingError = ex; + } + } + + return returnCollection; + } + + #endregion Methods + } + + #endregion +} diff --git a/test/GetPSResourceRepository.Tests.ps1 b/test/GetPSResourceRepository.Tests.ps1 index 6d5e2e8f9..0e8777323 100644 --- a/test/GetPSResourceRepository.Tests.ps1 +++ b/test/GetPSResourceRepository.Tests.ps1 @@ -3,7 +3,7 @@ Import-Module "$psscriptroot\PSGetTestUtils.psm1" -Force -Describe "Test Register-PSResourceRepository" { +Describe "Test Get-PSResourceRepository" { BeforeEach { $TestRepoName1 = "testRepository" $TestRepoName2 = "testRepository2" @@ -86,6 +86,19 @@ Describe "Test Register-PSResourceRepository" { } } + It "given invalid and valid CredentialInfo, get valid ones and write error for non valid ones" { + Get-NewPSResourceRepositoryFileWithCredentialInfo + + $res = Get-PSResourceRepository -Name "localtestrepo*" -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -Not -Be 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ErrorGettingSpecifiedRepo,Microsoft.PowerShell.PowerShellGet.Cmdlets.GetPSResourceRepository" + + # should have successfully got the other valid/registered repositories with no error + foreach ($entry in $res) { + $entry.Name | Should -BeIn "localtestrepo1","localtestrepo2" + } + } + It "throw error and get no repositories when provided null Name" { # $errorMsg = "Cannot validate argument on parameter 'Name'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again." {Get-PSResourceRepository -Name $null -ErrorAction Stop} | Should -Throw -ErrorId "ParameterArgumentValidationError,Microsoft.PowerShell.PowerShellGet.Cmdlets.GetPSResourceRepository" diff --git a/test/PSCredentialInfo.Tests.ps1 b/test/PSCredentialInfo.Tests.ps1 new file mode 100644 index 000000000..18775ef03 --- /dev/null +++ b/test/PSCredentialInfo.Tests.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Import-Module "$psscriptroot\PSGetTestUtils.psm1" -Force + +Describe "Create PSCredentialInfo with VaultName and SecretName" -tags 'CI' { + + It "Verifies VaultName is not empty" { + { New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("", "testsecret") } | Should -Throw -ErrorId "ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjectCommand" + } + + It "Verifies SecretName is not empty" { + { New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "") } | Should -Throw -ErrorId "ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjectCommand" + } + + It "Creates PSCredentialInfo successfully if VaultName and SecretName are non-empty" { + $credentialInfo = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret") + $credentialInfo.VaultName | Should -Be "testvault" + $credentialInfo.SecretName | Should -Be "testsecret" + } +} + +Describe "Create PSCredentialInfo with VaultName, SecretName, and Credential" -tags 'CI' { + + It "Creates PSCredentialInfo successfully if Credential is null" { + $credentialInfo = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret", $null) + + $credentialInfo.VaultName | Should -Be "testvault" + $credentialInfo.SecretName | Should -Be "testsecret" + } + + It "Creates PSCredentialInfo successfully if Credential is non-null and of type PSCredential" { + $credential = New-Object System.Management.Automation.PSCredential ("username", (ConvertTo-SecureString "password" -AsPlainText -Force)) + $credentialInfo = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret", $credential) + + $credentialInfo.VaultName | Should -Be "testvault" + $credentialInfo.SecretName | Should -Be "testsecret" + } +} + +Describe "Create PSCredentialInfo from a PSObject" -tags 'CI' { + + It "Throws if VaultName is null" { + $customObject = New-Object PSObject + { New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo $customObject } | Should -Throw -ErrorId "ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjectCommand" + } + + It "Throws if SecretName is null" { + $customObject = New-Object PSObject + $customObject | Add-Member -Name "VaultName" -Value "testvault" -MemberType NoteProperty + { New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo $customObject } | Should -Throw -ErrorId "ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjectCommand" + } + + It "Creates PSCredentialInfo successfully from PSObject with VaultName and SecretName" { + $properties = [PSCustomObject]@{ + VaultName = "testvault" + SecretName = "testsecret" + } + + $credentialInfo = [Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo] $properties + + $credentialInfo.VaultName | Should -Be "testvault" + $credentialInfo.SecretName | Should -Be "testsecret" + } + + It "Creates PSCredentialInfo successfully from PSObject with VaultName, SecretName and Credential" { + $credential = New-Object System.Management.Automation.PSCredential ("username", (ConvertTo-SecureString "password" -AsPlainText -Force)) + $properties = [PSCustomObject]@{ + VaultName = "testvault" + SecretName = "testsecret" + Credential = [PSCredential] $credential + } + + $credentialInfo = [Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo] $properties + + $credentialInfo.VaultName | Should -Be "testvault" + $credentialInfo.SecretName | Should -Be "testsecret" + $credentialInfo.Credential.UserName | Should -Be "username" + $credentialInfo.Credential.GetNetworkCredential().Password | Should -Be "password" + + } +} diff --git a/test/PSGetTestUtils.psm1 b/test/PSGetTestUtils.psm1 index 6b83bd5d4..b0620b4d9 100644 --- a/test/PSGetTestUtils.psm1 +++ b/test/PSGetTestUtils.psm1 @@ -214,6 +214,25 @@ function Get-RevertPSResourceRepositoryFile { } } +function Get-NewPSResourceRepositoryFileWithCredentialInfo { + # register our own repositories with desired priority + $powerShellGetPath = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath "PowerShellGet" + $originalXmlFilePath = Join-Path -Path $powerShellGetPath -ChildPath "PSResourceRepository.xml" + $tempXmlFilePath = Join-Path -Path $powerShellGetPath -ChildPath "temp.xml" + + if (Test-Path -Path $originalXmlFilePath) { + Copy-Item -Path $originalXmlFilePath -Destination $tempXmlFilePath + Remove-Item -Path $originalXmlFilePath -Force -ErrorAction Ignore + } + + if (! (Test-Path -Path $powerShellGetPath)) { + $null = New-Item -Path $powerShellGetPath -ItemType Directory -Verbose + } + + $fileToCopy = Join-Path -Path $PSScriptRoot -ChildPath "testRepositoriesWithCredentialInfo.xml" + Copy-Item -Path $fileToCopy -Destination $originalXmlFilePath -Force -Verbose +} + function Register-LocalRepos { $repoURLAddress = Join-Path -Path $TestDrive -ChildPath "testdir" $null = New-Item $repoURLAddress -ItemType Directory -Force diff --git a/test/PublishPSResource.Tests.ps1 b/test/PublishPSResource.Tests.ps1 index d5a96b45b..695f540ea 100644 --- a/test/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResource.Tests.ps1 @@ -11,14 +11,14 @@ Describe "Test Publish-PSResource" { $tmpRepoPath = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath" New-Item $tmpRepoPath -Itemtype directory -Force $testRepository = "testRepository" - Register-PSResourceRepository -Name $testRepository -URL $tmpRepoPath -Priority 1 -ErrorAction SilentlyContinue - $script:repositoryPath = (get-psresourcerepository "testRepository").Url.AbsolutePath + Register-PSResourceRepository -Name $testRepository -URL $tmpRepoPath -Priority 1 -ErrorAction SilentlyContinue + $script:repositoryPath = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository").Url.AbsolutePath) $tmpRepoPath2 = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath2" New-Item $tmpRepoPath2 -Itemtype directory -Force $testRepository2 = "testRepository2" Register-PSResourceRepository -Name $testRepository2 -URL $tmpRepoPath2 -ErrorAction SilentlyContinue - $script:repositoryPath2 = (get-psresourcerepository "testRepository2").Url.AbsolutePath + $script:repositoryPath2 = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository2").Url.AbsolutePath) # Create module $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" @@ -38,7 +38,7 @@ Describe "Test Publish-PSResource" { } # Create temp destination path - $script:destinationPath = Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath" + $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) New-Item $script:destinationPath -ItemType directory -Force } AfterAll { @@ -112,7 +112,7 @@ Describe "Test Publish-PSResource" { Publish-PSResource -Path $script:PublishModuleBase -SkipDependenciesCheck $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" - (Get-ChildItem $script:repositoryPath).FullName | select-object -Last 1 | Should -Be $expectedPath + (Get-ChildItem $script:repositoryPath).FullName | select-object -Last 1 | Should -Be $expectedPath } <# The following tests are related to passing in parameters to customize a nuspec. @@ -266,17 +266,14 @@ Describe "Test Publish-PSResource" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" - $tmpPath = Join-Path -Path $TestDrive -ChildPath "testtmppath" - New-Item $tmpPath -Itemtype directory -Force - - Publish-PSResource -Path $script:PublishModuleBase -Repository $testRepository2 -DestinationPath $tmpPath + Publish-PSResource -Path $script:PublishModuleBase -Repository $testRepository2 -DestinationPath $script:destinationPath $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" (Get-ChildItem $script:repositoryPath2).FullName | Should -Be $expectedPath - $expectedPath = Join-Path -Path $tmpPath -ChildPath "$script:PublishModuleName.$version.nupkg" - (Get-ChildItem $tmpPath).FullName | Should -Be $expectedPath + $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:destinationPath).FullName | Should -Be $expectedPath } It "Publish a module and clean up properly when file in module is readonly" { diff --git a/test/RegisterPSResourceRepository.Tests.ps1 b/test/RegisterPSResourceRepository.Tests.ps1 index 712204ec9..7fd83f084 100644 --- a/test/RegisterPSResourceRepository.Tests.ps1 +++ b/test/RegisterPSResourceRepository.Tests.ps1 @@ -10,20 +10,30 @@ Describe "Test Register-PSResourceRepository" { $TestRepoName1 = "testRepository" $TestRepoName2 = "testRepository2" $TestRepoName3 = "testRepository3" + $TestRepoName4 = "testRepository4" $relativeCurrentPath = Get-Location Get-NewPSResourceRepositoryFile $tmpDir1Path = Join-Path -Path $TestDrive -ChildPath "tmpDir1" $tmpDir2Path = Join-Path -Path $TestDrive -ChildPath "tmpDir2" $tmpDir3Path = Join-Path -Path $TestDrive -ChildPath "tmpDir3" - $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path) + $tmpDir4Path = Join-Path -Path $TestDrive -ChildPath "tmpDir4" + $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path, $tmpDir4Path) Get-NewTestDirs($tmpDirPaths) + + $relativeCurrentPath = Get-Location + + $credentialInfo1 = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret") + $secureString = ConvertTo-SecureString "testpassword" -AsPlainText -Force + $credential = New-Object pscredential ("testusername", $secureString) + $credentialInfo2 = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret", $credential) } AfterEach { Get-RevertPSResourceRepositoryFile $tmpDir1Path = Join-Path -Path $TestDrive -ChildPath "tmpDir1" $tmpDir2Path = Join-Path -Path $TestDrive -ChildPath "tmpDir2" $tmpDir3Path = Join-Path -Path $TestDrive -ChildPath "tmpDir3" - $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path) + $tmpDir4Path = Join-Path -Path $TestDrive -ChildPath "tmpDir4" + $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path, $tmpDir4Path) Get-RemoveTestDirs($tmpDirPaths) } @@ -51,6 +61,16 @@ Describe "Test Register-PSResourceRepository" { $res.Priority | Should -Be 20 } + It "register repository given Name, URL, Trusted, Priority, CredentialInfo (NameParameterSet)" { + $res = Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path -Trusted -Priority 20 -CredentialInfo $credentialInfo1 -PassThru + $res.Name | Should -Be $TestRepoName1 + $res.URL.LocalPath | Should -Contain $tmpDir1Path + $res.Trusted | Should -Be True + $res.Priority | Should -Be 20 + $res.CredentialInfo.VaultName | Should -Be "testvault" + $res.CredentialInfo.SecretName | Should -Be "testsecret" + } + It "register repository with PSGallery parameter (PSGalleryParameterSet)" { Unregister-PSResourceRepository -Name $PSGalleryName $res = Register-PSResourceRepository -PSGallery -PassThru @@ -82,7 +102,8 @@ Describe "Test Register-PSResourceRepository" { $hashtable1 = @{Name = $TestRepoName1; URL = $tmpDir1Path} $hashtable2 = @{Name = $TestRepoName2; URL = $tmpDir2Path; Trusted = $True} $hashtable3 = @{Name = $TestRepoName3; URL = $tmpDir3Path; Trusted = $True; Priority = 20} - $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3 + $hashtable4 = @{Name = $TestRepoName4; URL = $tmpDir4Path; Trusted = $True; Priority = 30; CredentialInfo = (New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret"))} + $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3, $hashtable4 Register-PSResourceRepository -Repositories $arrayOfHashtables $res = Get-PSResourceRepository -Name $TestRepoName1 @@ -99,6 +120,14 @@ Describe "Test Register-PSResourceRepository" { $res3.URL.LocalPath | Should -Contain $tmpDir3Path $res3.Trusted | Should -Be True $res3.Priority | Should -Be 20 + + $res4 = Get-PSResourceRepository -Name $TestRepoName4 + $res4.URL.LocalPath | Should -Contain $tmpDir4Path + $res4.Trusted | Should -Be True + $res4.Priority | Should -Be 30 + $res4.CredentialInfo.VaultName | Should -Be "testvault" + $res4.CredentialInfo.SecretName | Should -Be "testsecret" + $res4.CredentialInfo.Credential | Should -BeNullOrEmpty } It "register repositories with Repositories parameter, psgallery style repository (RepositoriesParameterSet)" { @@ -117,7 +146,8 @@ Describe "Test Register-PSResourceRepository" { $hashtable2 = @{Name = $TestRepoName1; URL = $tmpDir1Path} $hashtable3 = @{Name = $TestRepoName2; URL = $tmpDir2Path; Trusted = $True} $hashtable4 = @{Name = $TestRepoName3; URL = $tmpDir3Path; Trusted = $True; Priority = 20} - $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3, $hashtable4 + $hashtable5 = @{Name = $TestRepoName4; URL = $tmpDir4Path; Trusted = $True; Priority = 30; CredentialInfo = (New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret"))} + $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3, $hashtable4, $hashtable5 Register-PSResourceRepository -Repositories $arrayOfHashtables @@ -140,6 +170,14 @@ Describe "Test Register-PSResourceRepository" { $res4.URL.LocalPath | Should -Contain $tmpDir3Path $res4.Trusted | Should -Be True $res4.Priority | Should -Be 20 + + $res5 = Get-PSResourceRepository -Name $TestRepoName4 + $res5.URL.LocalPath | Should -Contain $tmpDir4Path + $res5.Trusted | Should -Be True + $res5.Priority | Should -Be 30 + $res5.CredentialInfo.VaultName | Should -Be "testvault" + $res5.CredentialInfo.SecretName | Should -Be "testsecret" + $res5.CredentialInfo.Credential | Should -BeNullOrEmpty } It "not register repository when Name is provided but URL is not" { @@ -150,7 +188,7 @@ Describe "Test Register-PSResourceRepository" { {Register-PSResourceRepository -Name "" -URL $tmpDir1Path -ErrorAction Stop} | Should -Throw -ErrorId "ParameterArgumentValidationError,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" } - It "not register rpeository when Name is null but URL is provided" { + It "not register repository when Name is null but URL is provided" { {Register-PSResourceRepository -Name $null -URL $tmpDir1Path -ErrorAction Stop} | Should -Throw -ErrorId "ParameterArgumentValidationError,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" } @@ -163,13 +201,15 @@ Describe "Test Register-PSResourceRepository" { } # this error message comes from the parameter cmdlet tags (earliest point of detection) - It "not register PSGallery when PSGallery parameter provided with Name or URL" { + It "not register PSGallery when PSGallery parameter provided with Name, URL or CredentialInfo" { {Register-PSResourceRepository -PSGallery -Name $PSGalleryName -ErrorAction Stop} | Should -Throw -ErrorId "AmbiguousParameterSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" {Register-PSResourceRepository -PSGallery -URL $PSGalleryURL -ErrorAction Stop} | Should -Throw -ErrorId "AmbiguousParameterSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" + {Register-PSResourceRepository -PSGallery -CredentialInfo $credentialInfo1 -ErrorAction Stop} | Should -Throw -ErrorId "AmbiguousParameterSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" } $testCases = @{Type = "Name key specified with PSGallery key"; IncorrectHashTable = @{PSGallery = $True; Name=$PSGalleryName}}, - @{Type = "URL key specified with PSGallery key"; IncorrectHashTable = @{PSGallery = $True; URL=$PSGalleryURL}} + @{Type = "URL key specified with PSGallery key"; IncorrectHashTable = @{PSGallery = $True; URL=$PSGalleryURL}}, + @{Type = "CredentialInfo key specified with PSGallery key"; IncorrectHashTable = @{PSGallery = $True; CredentialInfo = $credentialInfo1}} It "not register incorrectly formatted PSGallery type repo among correct ones when incorrect type is " -TestCases $testCases { param($Type, $IncorrectHashTable) @@ -182,7 +222,7 @@ Describe "Test Register-PSResourceRepository" { Unregister-PSResourceRepository -Name $PSGalleryName Register-PSResourceRepository -Repositories $arrayOfHashtables -ErrorVariable err -ErrorAction SilentlyContinue $err.Count | Should -Not -Be 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "NotProvideNameUrlForPSGalleryRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" + $err[0].FullyQualifiedErrorId | Should -BeExactly "NotProvideNameUrlCredentialInfoForPSGalleryRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository" $res = Get-PSResourceRepository -Name $TestRepoName1 $res.Name | Should -Be $TestRepoName1 @@ -194,10 +234,10 @@ Describe "Test Register-PSResourceRepository" { $res3.Name | Should -Be $TestRepoName3 } - $testCases2 = @{Type = "-Name is not specified"; IncorrectHashTable = @{URL = $tmpDir1Path}; ErrorId = "NullNameForRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, - @{Type = "-Name is PSGallery"; IncorrectHashTable = @{Name = "PSGallery"; URL = $tmpDir1Path}; ErrorId = "PSGalleryProvidedAsNameRepoPSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, - @{Type = "-URL not specified"; IncorrectHashTable = @{Name = "testRepository"}; ErrorId = "NullURLForRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, - @{Type = "-URL is not valid scheme"; IncorrectHashTable = @{Name = "testRepository"; URL="www.google.com"}; ErrorId = "InvalidUri,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"} + $testCases2 = @{Type = "-Name is not specified"; IncorrectHashTable = @{URL = $tmpDir1Path}; ErrorId = "NullNameForRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, + @{Type = "-Name is PSGallery"; IncorrectHashTable = @{Name = $PSGalleryName; URL = $tmpDir1Path}; ErrorId = "PSGalleryProvidedAsNameRepoPSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, + @{Type = "-URL not specified"; IncorrectHashTable = @{Name = $TestRepoName1}; ErrorId = "NullURLForRepositoriesParameterSetRegistration,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"}, + @{Type = "-URL is not valid scheme"; IncorrectHashTable = @{Name = $TestRepoName1; URL="www.google.com"}; ErrorId = "InvalidUri,Microsoft.PowerShell.PowerShellGet.Cmdlets.RegisterPSResourceRepository"} It "not register incorrectly formatted Name type repo among correct ones when incorrect type is " -TestCases $testCases2 { param($Type, $IncorrectHashTable, $ErrorId) @@ -240,4 +280,19 @@ Describe "Test Register-PSResourceRepository" { $res.Name | Should -Be "localFileShareTestRepo" $res.URL.LocalPath | Should -Contain "\\hcgg.rest.of.domain.name\test\ITxx\team\NuGet\" } + + It "prints a warning if CredentialInfo is passed in without SecretManagement module setup" { + $output = Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path -Trusted -Priority 20 -CredentialInfo $credentialInfo1 3>&1 + $output | Should -Match "Microsoft.PowerShell.SecretManagement module cannot be found" + + $res = Get-PSResourceRepository -Name $TestRepoName1 + $res | Should -Not -BeNullOrEmpty + } + + It "throws error if CredentialInfo is passed in with Credential property without SecretManagement module setup" { + { Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path -Trusted -Priority 20 -CredentialInfo $credentialInfo2 } | Should -Throw -ErrorId "RepositoryCredentialSecretManagementUnavailableModule" + + $res = Get-PSResourceRepository -Name $TestRepoName1 -ErrorAction Ignore + $res | Should -BeNullOrEmpty + } } diff --git a/test/SetPSResourceRepository.Tests.ps1 b/test/SetPSResourceRepository.Tests.ps1 index 74f107ecc..ba38d53f7 100644 --- a/test/SetPSResourceRepository.Tests.ps1 +++ b/test/SetPSResourceRepository.Tests.ps1 @@ -9,20 +9,30 @@ Describe "Test Set-PSResourceRepository" { $PSGalleryURL = Get-PSGalleryLocation $TestRepoName1 = "testRepository" $TestRepoName2 = "testRepository2" + $TestRepoName3 = "testRepository3" $relativeCurrentPath = Get-Location Get-NewPSResourceRepositoryFile $tmpDir1Path = Join-Path -Path $TestDrive -ChildPath "tmpDir1" $tmpDir2Path = Join-Path -Path $TestDrive -ChildPath "tmpDir2" $tmpDir3Path = Join-Path -Path $TestDrive -ChildPath "tmpDir3" - $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path) + $tmpDir4Path = Join-Path -Path $TestDrive -ChildPath "tmpDir4" + $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path, $tmpDir4Path) Get-NewTestDirs($tmpDirPaths) + + $relativeCurrentPath = Get-Location + + $credentialInfo1 = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret") + $secureString = ConvertTo-SecureString "testpassword" -AsPlainText -Force + $credential = New-Object pscredential ("testusername", $secureString) + $credentialInfo2 = New-Object Microsoft.PowerShell.PowerShellGet.UtilClasses.PSCredentialInfo ("testvault", "testsecret", $credential) } AfterEach { Get-RevertPSResourceRepositoryFile $tmpDir1Path = Join-Path -Path $TestDrive -ChildPath "tmpDir1" $tmpDir2Path = Join-Path -Path $TestDrive -ChildPath "tmpDir2" $tmpDir3Path = Join-Path -Path $TestDrive -ChildPath "tmpDir3" - $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path) + $tmpDir4Path = Join-Path -Path $TestDrive -ChildPath "tmpDir4" + $tmpDirPaths = @($tmpDir1Path, $tmpDir2Path, $tmpDir3Path, $tmpDir4Path) Get-RemoveTestDirs($tmpDirPaths) } @@ -34,6 +44,7 @@ Describe "Test Set-PSResourceRepository" { $res.URL.LocalPath | Should -Contain $tmpDir2Path $res.Priority | Should -Be 50 $res.Trusted | Should -Be False + $res.CredentialInfo | Should -BeNullOrEmpty } It "set repository given Name and Priority parameters" { @@ -44,6 +55,7 @@ Describe "Test Set-PSResourceRepository" { $res.URL.LocalPath | Should -Contain $tmpDir1Path $res.Priority | Should -Be 25 $res.Trusted | Should -Be False + $res.CredentialInfo | Should -BeNullOrEmpty } It "set repository given Name and Trusted parameters" { @@ -54,6 +66,20 @@ Describe "Test Set-PSResourceRepository" { $res.URL.LocalPath | Should -Contain $tmpDir1Path $res.Priority | Should -Be 50 $res.Trusted | Should -Be True + $res.CredentialInfo | Should -BeNullOrEmpty + } + + It "set repository given Name and CredentialInfo parameters" { + Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path + Set-PSResourceRepository -Name $TestRepoName1 -CredentialInfo $credentialInfo1 + $res = Get-PSResourceRepository -Name $TestRepoName1 + $res.Name | Should -Be $TestRepoName1 + $res.URL.LocalPath | Should -Contain $tmpDir1Path + $res.Priority | Should -Be 50 + $res.Trusted | Should -Be False + $res.CredentialInfo.VaultName | Should -Be "testvault" + $res.CredentialInfo.SecretName | Should -Be "testsecret" + $res.CredentialInfo.Credential | Should -BeNullOrEmpty } It "not set repository and write error given just Name parameter" { @@ -103,12 +129,14 @@ Describe "Test Set-PSResourceRepository" { Unregister-PSResourceRepository -Name $PSGalleryName Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path Register-PSResourceRepository -Name $TestRepoName2 -URL $tmpDir2Path + Register-PSResourceRepository -Name $TestRepoName3 -URL $tmpDir3Path Register-PSResourceRepository -PSGallery $hashtable1 = @{Name = $TestRepoName1; URL = $tmpDir2Path}; $hashtable2 = @{Name = $TestRepoName2; Priority = 25}; - $hashtable3 = @{Name = $PSGalleryName; Trusted = $True}; - $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3 + $hashtable3 = @{Name = $TestRepoName3; CredentialInfo = [PSCustomObject] @{ VaultName = "testvault"; SecretName = "testsecret" }}; + $hashtable4 = @{Name = $PSGalleryName; Trusted = $True}; + $arrayOfHashtables = $hashtable1, $hashtable2, $hashtable3, $hashtable4 Set-PSResourceRepository -Repositories $arrayOfHashtables $res = Get-PSResourceRepository -Name $TestRepoName1 @@ -116,18 +144,30 @@ Describe "Test Set-PSResourceRepository" { $res.URL.LocalPath | Should -Contain $tmpDir2Path $res.Priority | Should -Be 50 $res.Trusted | Should -Be False + $res.CredentialInfo | Should -BeNullOrEmpty $res2 = Get-PSResourceRepository -Name $TestRepoName2 $res2.Name | Should -Be $TestRepoName2 $res2.URL.LocalPath | Should -Contain $tmpDir2Path $res2.Priority | Should -Be 25 $res2.Trusted | Should -Be False + $res2.CredentialInfo | Should -BeNullOrEmpty - $res3 = Get-PSResourceRepository -Name $PSGalleryName - $res3.Name | Should -Be $PSGalleryName - $res3.URL | Should -Contain $PSGalleryURL + $res3 = Get-PSResourceRepository -Name $TestRepoName3 + $res3.Name | Should -Be $TestRepoName3 + $res3.URL.LocalPath | Should -Contain $tmpDir3Path $res3.Priority | Should -Be 50 - $res3.Trusted | Should -Be True + $res3.Trusted | Should -Be False + $res3.CredentialInfo.VaultName | Should -Be "testvault" + $res3.CredentialInfo.SecretName | Should -Be "testsecret" + $res3.CredentialInfo.Credential | Should -BeNullOrEmpty + + $res4 = Get-PSResourceRepository -Name $PSGalleryName + $res4.Name | Should -Be $PSGalleryName + $res4.URL | Should -Contain $PSGalleryURL + $res4.Priority | Should -Be 50 + $res4.Trusted | Should -Be True + $res4.CredentialInfo | Should -BeNullOrEmpty } It "not set and throw error for trying to set PSGallery URL (NameParameterSet)" { @@ -136,6 +176,12 @@ Describe "Test Set-PSResourceRepository" { {Set-PSResourceRepository -Name $PSGalleryName -URL $tmpDir1Path -ErrorAction Stop} | Should -Throw -ErrorId "ErrorInNameParameterSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.SetPSResourceRepository" } + It "not set and throw error for trying to set PSGallery CredentialInfo (NameParameterSet)" { + Unregister-PSResourceRepository -Name $PSGalleryName + Register-PSResourceRepository -PSGallery + {Set-PSResourceRepository -Name $PSGalleryName -CredentialInfo $credentialInfo1 -ErrorAction Stop} | Should -Throw -ErrorId "ErrorInNameParameterSet,Microsoft.PowerShell.PowerShellGet.Cmdlets.SetPSResourceRepository" + } + It "not set repository and throw error for trying to set PSGallery URL (RepositoriesParameterSet)" { Unregister-PSResourceRepository -Name $PSGalleryName Register-PSResourceRepository -PSGallery @@ -166,6 +212,27 @@ Describe "Test Set-PSResourceRepository" { $res.Priority | Should -Be 50 } + It "not set repository and throw error for trying to set PSGallery CredentialInfo (RepositoriesParameterSet)" { + Unregister-PSResourceRepository -Name $PSGalleryName + Register-PSResourceRepository -PSGallery + + Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path + + $hashtable1 = @{Name = $PSGalleryName; CredentialInfo = $credentialInfo1} + $hashtable2 = @{Name = $TestRepoName1; Priority = 25} + $arrayOfHashtables = $hashtable1, $hashtable2 + + Set-PSResourceRepository -Repositories $arrayOfHashtables -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -Not -Be 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ErrorSettingIndividualRepoFromRepositories,Microsoft.PowerShell.PowerShellGet.Cmdlets.SetPSResourceRepository" + + $res = Get-PSResourceRepository -Name $TestRepoName1 + $res.URL.LocalPath | Should -Contain $tmpDir1Path + $res.Priority | Should -Be 25 + $res.Trusted | Should -Be False + $res.CredentialInfo | Should -BeNullOrEmpty + } + It "should set repository with local file share NuGet based Uri" { Register-PSResourceRepository -Name "localFileShareTestRepo" -URL $tmpDir1Path Set-PSResourceRepository -Name "localFileShareTestRepo" -URL "\\hcgg.rest.of.domain.name\test\ITxx\team\NuGet\" @@ -182,4 +249,23 @@ Describe "Test Set-PSResourceRepository" { $res.Priority | Should -Be 50 $res.Trusted | Should -Be False } + + It "prints a warning if CredentialInfo is passed in without SecretManagement module setup" { + Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path + $output = Set-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path -CredentialInfo $credentialInfo1 3>&1 + $output | Should -Match "Microsoft.PowerShell.SecretManagement module cannot be found" + + $res = Get-PSResourceRepository -Name $TestRepoName1 + $res | Should -Not -BeNullOrEmpty + } + + It "throws error if CredentialInfo is passed in with Credential property without SecretManagement module setup" { + { + Register-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path + Set-PSResourceRepository -Name $TestRepoName1 -URL $tmpDir1Path -CredentialInfo $credentialInfo2 + } | Should -Throw -ErrorId "RepositoryCredentialSecretManagementUnavailableModule" + + $res = Get-PSResourceRepository -Name $TestRepoName1 -ErrorAction Ignore + $res.CredentialInfo | Should -BeNullOrEmpty + } } diff --git a/test/UnregisterPSResourceRepository.Tests.ps1 b/test/UnregisterPSResourceRepository.Tests.ps1 index 5b9cc9fd0..870da4717 100644 --- a/test/UnregisterPSResourceRepository.Tests.ps1 +++ b/test/UnregisterPSResourceRepository.Tests.ps1 @@ -3,7 +3,7 @@ Import-Module "$psscriptroot\PSGetTestUtils.psm1" -Force -Describe "Test Register-PSResourceRepository" { +Describe "Test Unregister-PSResourceRepository" { BeforeEach { $TestGalleryName = Get-PoshTestGalleryName $TestGalleryUrl = Get-PoshTestGalleryLocation diff --git a/test/testRepositoriesWithCredentialInfo.xml b/test/testRepositoriesWithCredentialInfo.xml new file mode 100644 index 000000000..2797e7bf4 --- /dev/null +++ b/test/testRepositoriesWithCredentialInfo.xml @@ -0,0 +1,7 @@ + + + + + + +