From 8bfbcbe953c29672b0f5f3f05063400af4f54499 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 01:14:02 +0000 Subject: [PATCH 1/7] feat: Add Authenticode signing and catalog support (closes #90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a flexible, opt-in code-signing pipeline covering: - Authenticode signing of module files (*.psd1, *.psm1, *.ps1) - Windows catalog (.cat) file creation via New-FileCatalog - Authenticode signing of the catalog file **New public functions:** - Get-PSBuildCertificate – resolves a code-signing cert from five interchangeable sources: Auto, Store, Thumbprint, EnvVar (Base64 PFX in a CI/CD secret), or PfxFile. Auto mode checks the env var first and falls back to the certificate store, enabling the same psakeFile to work on developer machines and in GitHub Actions / Azure DevOps. - Invoke-PSBuildModuleSigning – signs files matching configurable glob patterns under the module output directory using Set-AuthenticodeSignature. - New-PSBuildFileCatalog – wraps New-FileCatalog to create a SHA2 (version 2) or SHA1 (version 1) catalog file. **New psake/Invoke-Build tasks:** - SignModule – signs module files; skips gracefully when disabled or on non-Windows platforms. - BuildCatalog – creates the .cat file; independent enable flag. - SignCatalog – signs the catalog file. - Sign – meta task (depends on SignCatalog). All tasks respect overridable $PSB*Dependency variables so consumers can splice them into any position in their build graph, e.g.: $PSBPublishDependency = @('Sign') **New $PSBPreference.Sign properties:** Enabled, CertificateSource, CertStoreLocation, Thumbprint, CertificateEnvVar, CertificatePasswordEnvVar, PfxFilePath, PfxFilePassword, Certificate (direct X509Certificate2 object), TimestampServer, HashAlgorithm, FilesToSign, Catalog.{Enabled, Version, FileName} All features are disabled by default (Sign.Enabled = $false). https://claude.ai/code/session_01Bt5Xb9HLoSppQ22PQUTyGP --- PowerShellBuild/IB.tasks.ps1 | 134 +++++++++++++++- PowerShellBuild/PowerShellBuild.psd1 | 3 + .../Public/Get-PSBuildCertificate.ps1 | 143 ++++++++++++++++++ .../Public/Invoke-PSBuildModuleSigning.ps1 | 88 +++++++++++ .../Public/New-PSBuildFileCatalog.ps1 | 74 +++++++++ PowerShellBuild/build.properties.ps1 | 73 +++++++-- PowerShellBuild/en-US/Messages.psd1 | 8 + PowerShellBuild/psakeFile.ps1 | 142 +++++++++++++++++ 8 files changed, 655 insertions(+), 10 deletions(-) create mode 100644 PowerShellBuild/Public/Get-PSBuildCertificate.ps1 create mode 100644 PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 create mode 100644 PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 diff --git a/PowerShellBuild/IB.tasks.ps1 b/PowerShellBuild/IB.tasks.ps1 index e9d2d98..a184528 100644 --- a/PowerShellBuild/IB.tasks.ps1 +++ b/PowerShellBuild/IB.tasks.ps1 @@ -1,6 +1,6 @@ Remove-Variable -Name PSBPreference -Scope Script -Force -ErrorAction Ignore Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. ([IO.Path]::Combine($PSScriptRoot, 'build.properties.ps1'))) -$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies +$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies # Synopsis: Initialize build environment variables task Init { @@ -197,4 +197,136 @@ task Test Analyze,Pester task . Build,Test +# Synopsis: Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature +task SignModule -If { + if (-not $PSBPreference.Sign.Enabled) { + Write-Warning 'Module signing is not enabled.' + return $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + return $false + } + $true +} Build, { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + if ($null -eq $certificate) { + throw $LocalizedData.NoCertificateFound + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = $PSBPreference.Sign.FilesToSign + } + Invoke-PSBuildModuleSigning @signingParams +} + +# Synopsis: Creates a Windows catalog (.cat) file for the built module +task BuildCatalog -If { + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog generation is not enabled.' + return $false + } + if (-not (Get-Command -Name 'New-FileCatalog' -ErrorAction Ignore)) { + Write-Warning 'New-FileCatalog is not available. Catalog generation requires Windows.' + return $false + } + $true +} SignModule, { + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + $catalogFilePath = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath $catalogFileName + + $catalogParams = @{ + ModulePath = $PSBPreference.Build.ModuleOutDir + CatalogFilePath = $catalogFilePath + CatalogVersion = $PSBPreference.Sign.Catalog.Version + } + New-PSBuildFileCatalog @catalogParams +} + +# Synopsis: Signs the module catalog (.cat) file with an Authenticode signature +task SignCatalog -If { + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog signing is not enabled.' + return $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + return $false + } + $true +} BuildCatalog, { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + if ($null -eq $certificate) { + throw $LocalizedData.NoCertificateFound + } + + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = @($catalogFileName) + } + Invoke-PSBuildModuleSigning @signingParams +} + +# Synopsis: Signs module files and catalog (meta task) +task Sign SignCatalog + #endregion Summary Tasks diff --git a/PowerShellBuild/PowerShellBuild.psd1 b/PowerShellBuild/PowerShellBuild.psd1 index 39731b5..d05f518 100644 --- a/PowerShellBuild/PowerShellBuild.psd1 +++ b/PowerShellBuild/PowerShellBuild.psd1 @@ -19,7 +19,10 @@ 'Build-PSBuildModule' 'Build-PSBuildUpdatableHelp' 'Clear-PSBuildOutputFolder' + 'Get-PSBuildCertificate' 'Initialize-PSBuild' + 'Invoke-PSBuildModuleSigning' + 'New-PSBuildFileCatalog' 'Publish-PSBuildModule' 'Test-PSBuildPester' 'Test-PSBuildScriptAnalysis' diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 new file mode 100644 index 0000000..4e09afd --- /dev/null +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -0,0 +1,143 @@ +function Get-PSBuildCertificate { + <# + .SYNOPSIS + Resolves a code-signing X509Certificate2 from one of several common sources. + .DESCRIPTION + Resolves a code-signing certificate suitable for use with Set-AuthenticodeSignature. + Supports five certificate sources to accommodate local development, CI/CD pipelines, + and custom signing infrastructure: + + Auto - Checks the CertificateEnvVar environment variable first. If it is + populated, uses EnvVar mode; otherwise falls back to Store mode. + This is the recommended default for projects that run both locally + and in automated pipelines. + + Store - Selects the first valid, unexpired code-signing certificate that has + a private key from the Windows certificate store at CertStoreLocation. + Suitable for developer workstations where a certificate is installed. + + Thumbprint - Like Store, but matches a specific certificate by its thumbprint. + Recommended when multiple code-signing certificates are installed and + you need a deterministic selection. + + EnvVar - Decodes a Base64-encoded PFX from an environment variable and + optionally decrypts it with a password from a second variable. + The most common approach for GitHub Actions, Azure DevOps Pipelines, + and GitLab CI where secrets are stored as masked variables. + + PfxFile - Loads a PFX/P12 file from disk with an optional SecureString password. + Useful for local scripts, containers, and environments where a + certificate file is mounted or distributed via a secrets manager. + + Note: Authenticode signing is a Windows-only capability. This function will fail + on non-Windows platforms when using Store or Thumbprint sources. + .PARAMETER CertificateSource + The source from which to resolve the code-signing certificate. + Valid values: Auto, Store, Thumbprint, EnvVar, PfxFile. Default: Auto. + .PARAMETER CertStoreLocation + Windows certificate store path to search when CertificateSource is Store or Thumbprint. + Default: Cert:\CurrentUser\My. + .PARAMETER Thumbprint + The exact certificate thumbprint to look up. Required when CertificateSource is Thumbprint. + .PARAMETER CertificateEnvVar + Name of the environment variable holding the Base64-encoded PFX certificate. + Used by the EnvVar source and by Auto as the presence-detection key. + Default: SIGNCERTIFICATE. + .PARAMETER CertificatePasswordEnvVar + Name of the environment variable holding the PFX password. Used by EnvVar source. + Default: CERTIFICATEPASSWORD. + .PARAMETER PfxFilePath + File system path to a PFX/P12 certificate file. Required when CertificateSource is PfxFile. + .PARAMETER PfxFilePassword + Password for the PFX file as a SecureString. Used by PfxFile source. + .OUTPUTS + System.Security.Cryptography.X509Certificates.X509Certificate2 + Returns the resolved certificate, or $null if none was found (Store/Thumbprint sources). + .EXAMPLE + PS> $cert = Get-PSBuildCertificate + + Resolve automatically: use the SIGNCERTIFICATE env var when present, otherwise search + the current user's certificate store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Store + + Explicitly load the first valid code-signing certificate from the current user's store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD34EF56...' + + Load a specific certificate from the certificate store by its thumbprint. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource EnvVar ` + -CertificateEnvVar 'MY_PFX' -CertificatePasswordEnvVar 'MY_PFX_PASS' + + Decode a PFX certificate stored in a CI/CD secret environment variable. + .EXAMPLE + PS> $pass = Read-Host -Prompt 'Certificate password' -AsSecureString + PS> $cert = Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath './codesign.pfx' -PfxFilePassword $pass + + Load a code-signing certificate from a PFX file on disk. + #> + [CmdletBinding()] + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + param( + [ValidateSet('Auto', 'Store', 'Thumbprint', 'EnvVar', 'PfxFile')] + [string]$CertificateSource = 'Auto', + + [string]$CertStoreLocation = 'Cert:\CurrentUser\My', + + [string]$Thumbprint, + + [string]$CertificateEnvVar = 'SIGNCERTIFICATE', + + [string]$CertificatePasswordEnvVar = 'CERTIFICATEPASSWORD', + + [string]$PfxFilePath, + + [securestring]$PfxFilePassword + ) + + # Resolve 'Auto' to the actual source based on environment variable presence + $resolvedSource = $CertificateSource + if ($resolvedSource -eq 'Auto') { + $resolvedSource = if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($CertificateEnvVar))) { + 'EnvVar' + } else { + 'Store' + } + Write-Verbose "CertificateSource is 'Auto'. Resolved to '$resolvedSource'." + } + + $cert = $null + + switch ($resolvedSource) { + 'Store' { + $cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert | + Where-Object { $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | + Select-Object -First 1 + if ($cert) { + Write-Verbose ($LocalizedData.CertificateResolvedFromStore -f $CertStoreLocation, $cert.Subject) + } + } + 'Thumbprint' { + $cert = Get-ChildItem -Path $CertStoreLocation | + Where-Object { $_.Thumbprint -eq $Thumbprint -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | + Select-Object -First 1 + if ($cert) { + Write-Verbose ($LocalizedData.CertificateResolvedFromThumbprint -f $Thumbprint, $cert.Subject) + } + } + 'EnvVar' { + $b64Value = [System.Environment]::GetEnvironmentVariable($CertificateEnvVar) + $buffer = [System.Convert]::FromBase64String($b64Value) + $password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password) + Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar) + } + 'PfxFile' { + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxFilePath, $PfxFilePassword) + Write-Verbose ($LocalizedData.CertificateResolvedFromPfxFile -f $PfxFilePath) + } + } + + $cert +} diff --git a/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 b/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 new file mode 100644 index 0000000..58d9f24 --- /dev/null +++ b/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 @@ -0,0 +1,88 @@ +function Invoke-PSBuildModuleSigning { + <# + .SYNOPSIS + Signs PowerShell module files with an Authenticode signature. + .DESCRIPTION + Signs all files matching the Include patterns found under Path using + Set-AuthenticodeSignature. Typically called after the module is staged to the output + directory and before the catalog file is created, so that all signed source files are + captured in the catalog hash. + + Authenticode signing is Windows-only. This function will fail on Linux or macOS. + + Use Get-PSBuildCertificate to resolve the certificate from any of the supported sources + (certificate store, PFX file, Base64 environment variable, thumbprint, etc.) before + calling this function. + .PARAMETER Path + The directory to search recursively for files to sign. Typically the module output + directory (PSBPreference.Build.ModuleOutDir). + .PARAMETER Certificate + The X509Certificate2 code-signing certificate to sign files with. Must have a private + key and an Extended Key Usage (EKU) of Code Signing (1.3.6.1.5.5.7.3.3). + .PARAMETER TimestampServer + RFC 3161 timestamp server URI to embed in the Authenticode signature, allowing the + signature to remain valid after the certificate expires. Default: http://timestamp.digicert.com. + + Other common timestamp servers: + http://timestamp.sectigo.com + http://timestamp.comodoca.com + http://tsa.starfieldtech.com + http://timestamp.globalsign.com/scripts/timstamp.dll + .PARAMETER HashAlgorithm + Hash algorithm for the Authenticode signature. + Valid values: SHA256 (default), SHA384, SHA512, SHA1. + SHA1 is deprecated; prefer SHA256 or higher. + .PARAMETER Include + Glob patterns of file names to sign. Searched recursively under Path. + Default: *.psd1, *.psm1, *.ps1. + .OUTPUTS + System.Management.Automation.Signature + Returns the Signature objects from Set-AuthenticodeSignature for each signed file. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate + PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert + + Sign all .psd1, .psm1, and .ps1 files in the module output directory using a + certificate resolved automatically from the environment or certificate store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD...' + PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert ` + -TimestampServer 'http://timestamp.sectigo.com' -Include '*.psd1','*.psm1' + + Sign only the manifest and root module using a specific certificate and a custom + timestamp server. + #> + [CmdletBinding()] + [OutputType([System.Management.Automation.Signature])] + param( + [parameter(Mandatory)] + [ValidateScript({ + if (-not (Test-Path -Path $_ -PathType Container)) { + throw ($LocalizedData.PathArgumentMustBeAFolder) + } + $true + })] + [string]$Path, + + [parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + + [string]$TimestampServer = 'http://timestamp.digicert.com', + + [ValidateSet('SHA256', 'SHA384', 'SHA512', 'SHA1')] + [string]$HashAlgorithm = 'SHA256', + + [string[]]$Include = @('*.psd1', '*.psm1', '*.ps1') + ) + + $files = Get-ChildItem -Path $Path -Recurse -Include $Include + Write-Verbose ($LocalizedData.SigningModuleFiles -f $files.Count, ($Include -join ', '), $Path) + + $sigParams = @{ + Certificate = $Certificate + TimestampServer = $TimestampServer + HashAlgorithm = $HashAlgorithm + } + + $files | Set-AuthenticodeSignature @sigParams +} diff --git a/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 new file mode 100644 index 0000000..bb96e26 --- /dev/null +++ b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 @@ -0,0 +1,74 @@ +function New-PSBuildFileCatalog { + <# + .SYNOPSIS + Creates a Windows catalog (.cat) file for a PowerShell module. + .DESCRIPTION + Wraps New-FileCatalog to generate a catalog file that records cryptographic hashes of + all files in the module output directory. The catalog can later be signed with + Invoke-PSBuildModuleSigning (or Set-AuthenticodeSignature) to provide tamper detection + and a trust chain for the entire module. + + The recommended signing order is: + 1. Sign module files (*.psd1, *.psm1, *.ps1) with Invoke-PSBuildModuleSigning. + 2. Create the catalog with New-PSBuildFileCatalog (hashes already-signed files). + 3. Sign the catalog file with Invoke-PSBuildModuleSigning -Include '*.cat'. + + Catalog file creation requires Windows (New-FileCatalog is not available on Linux or macOS). + + Reference: https://p0w3rsh3ll.wordpress.com/2017/09/19/psgallery-and-catalog-files/ + .PARAMETER ModulePath + The directory whose contents will be hashed and recorded in the catalog. + Typically the module output directory (PSBPreference.Build.ModuleOutDir). + .PARAMETER CatalogFilePath + The full path (directory + filename) of the .cat file to create. + By convention this is '\.cat'. + .PARAMETER CatalogVersion + The catalog hash version. + 1 = SHA1, compatible with Windows 7 and Windows Server 2008 R2. + 2 = SHA2 (SHA-256), required for Windows 8 / Server 2012 and newer. Default: 2. + .OUTPUTS + System.IO.FileInfo + Returns the FileInfo object of the created catalog file. + .EXAMPLE + PS> New-PSBuildFileCatalog -ModulePath .\Output\MyModule\1.0.0 ` + -CatalogFilePath .\Output\MyModule\1.0.0\MyModule.cat + + Create a version-2 (SHA2) catalog for all files in the module output directory. + .EXAMPLE + PS> New-PSBuildFileCatalog -ModulePath .\Output\MyModule\1.0.0 ` + -CatalogFilePath .\Output\MyModule\1.0.0\MyModule.cat -CatalogVersion 1 + + Create a SHA1 (version 1) catalog for compatibility with Windows 7 / Server 2008 R2. + #> + [CmdletBinding()] + [OutputType([System.IO.FileInfo])] + param( + [parameter(Mandatory)] + [ValidateScript({ + if (-not (Test-Path -Path $_ -PathType Container)) { + throw ($LocalizedData.PathArgumentMustBeAFolder) + } + $true + })] + [string]$ModulePath, + + [parameter(Mandatory)] + [string]$CatalogFilePath, + + [ValidateRange(1, 2)] + [int]$CatalogVersion = 2 + ) + + Write-Verbose ($LocalizedData.CreatingFileCatalog -f $CatalogFilePath, $CatalogVersion) + + $catalogParams = @{ + Path = $ModulePath + CatalogFilePath = $CatalogFilePath + CatalogVersion = $CatalogVersion + Verbose = $VerbosePreference + } + + Microsoft.PowerShell.Security\New-FileCatalog @catalogParams + + Write-Verbose ($LocalizedData.FileCatalogCreated -f $CatalogFilePath) +} diff --git a/PowerShellBuild/build.properties.ps1 b/PowerShellBuild/build.properties.ps1 index d245ec3..1b2b1e9 100644 --- a/PowerShellBuild/build.properties.ps1 +++ b/PowerShellBuild/build.properties.ps1 @@ -144,13 +144,68 @@ $moduleVersion = (Import-PowerShellDataFile -Path $env:BHPSModuleManifest).Modul # Credential to authenticate to PowerShell repository with PSRepositoryCredential = $null } + Sign = @{ + # Enable/disable Authenticode signing of module files. Must be $true for any + # signing or catalog tasks to execute. + Enabled = $false + + # Certificate source used to resolve the code-signing certificate. + # Valid values: + # Auto - Uses EnvVar if CertificateEnvVar is populated, otherwise falls back to Store. + # This is the recommended setting for pipelines that share a common psakeFile. + # Store - Selects the first valid, unexpired code-signing certificate with a private + # key from the Windows certificate store (CertStoreLocation). + # Thumbprint - Like Store, but selects a specific certificate by Thumbprint. + # EnvVar - Decodes a Base64-encoded PFX from the CertificateEnvVar environment + # variable. Common in GitHub Actions, Azure DevOps, and GitLab CI. + # PfxFile - Loads a PFX/P12 file from PfxFilePath with an optional PfxFilePassword. + CertificateSource = 'Auto' + + # Windows certificate store path searched by Store and Thumbprint sources. + CertStoreLocation = 'Cert:\CurrentUser\My' + + # Specific certificate thumbprint to select (Thumbprint source only). + Thumbprint = $null + + # Name of the environment variable that holds the Base64-encoded PFX certificate. + # Used by the EnvVar source and as the presence-detection key for Auto. + CertificateEnvVar = 'SIGNCERTIFICATE' + + # Name of the environment variable that holds the PFX password (EnvVar source). + CertificatePasswordEnvVar = 'CERTIFICATEPASSWORD' + + # File system path to a PFX/P12 certificate file (PfxFile source). + PfxFilePath = $null + + # Password for the PFX file as a SecureString (PfxFile source). + PfxFilePassword = $null + + # A pre-resolved [System.Security.Cryptography.X509Certificates.X509Certificate2] object. + # When set, CertificateSource is ignored and this certificate is used directly. + # Useful for Azure Key Vault, HSM, or other custom certificate providers. + Certificate = $null + + # RFC 3161 timestamp server URI embedded in Authenticode signatures. + TimestampServer = 'http://timestamp.digicert.com' + + # Authenticode hash algorithm. Valid values: SHA256, SHA384, SHA512, SHA1. + HashAlgorithm = 'SHA256' + + # Glob patterns of files to sign in the module output directory. + FilesToSign = @('*.psd1', '*.psm1', '*.ps1') + + Catalog = @{ + # Enable/disable Windows catalog (.cat) file creation and signing. + # Requires Sign.Enabled = $true. + Enabled = $false + + # Catalog hash version. + # 1 = SHA1, compatible with Windows 7 and Windows Server 2008 R2. + # 2 = SHA2, required for Windows 8 / Server 2012 and newer. + Version = 2 + + # Catalog file name. Defaults to '.cat' when $null. + FileName = $null + } + } } - -# Enable/disable generation of a catalog (.cat) file for the module. -# [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -# $catalogGenerationEnabled = $true - -# # Select the hash version to use for the catalog file: 1 for SHA1 (compat with Windows 7 and -# # Windows Server 2008 R2), 2 for SHA2 to support only newer Windows versions. -# [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -# $catalogVersion = 2 diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 3662452..fd9a3fa 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -23,4 +23,12 @@ PSScriptAnalyzerResults=PSScriptAnalyzer results: ScriptAnalyzerErrors=One or more ScriptAnalyzer errors were found! ScriptAnalyzerWarnings=One or more ScriptAnalyzer warnings were found! ScriptAnalyzerIssues=One or more ScriptAnalyzer issues were found! +NoCertificateFound=No valid code signing certificate was found. Verify the configured CertificateSource and that a certificate with a private key is available. +CertificateResolvedFromStore=Resolved code signing certificate from store [{0}]: Subject=[{1}] +CertificateResolvedFromThumbprint=Resolved code signing certificate by thumbprint [{0}]: Subject=[{1}] +CertificateResolvedFromEnvVar=Resolved code signing certificate from environment variable [{0}] +CertificateResolvedFromPfxFile=Resolved code signing certificate from PFX file [{0}] +SigningModuleFiles=Signing [{0}] file(s) matching [{1}] in [{2}]... +CreatingFileCatalog=Creating file catalog [{0}] (version {1})... +FileCatalogCreated=File catalog created: [{0}] '@ diff --git a/PowerShellBuild/psakeFile.ps1 b/PowerShellBuild/psakeFile.ps1 index c1be2a6..194e4bc 100644 --- a/PowerShellBuild/psakeFile.ps1 +++ b/PowerShellBuild/psakeFile.ps1 @@ -45,6 +45,18 @@ if ($null -eq $PSBGenerateUpdatableHelpDependency) { if ($null -eq $PSBPublishDependency) { $PSBPublishDependency = @('Test') } +if ($null -eq $PSBSignModuleDependency) { + $PSBSignModuleDependency = @('Build') +} +if ($null -eq $PSBBuildCatalogDependency) { + $PSBBuildCatalogDependency = @('SignModule') +} +if ($null -eq $PSBSignCatalogDependency) { + $PSBSignCatalogDependency = @('BuildCatalog') +} +if ($null -eq $PSBSignDependency) { + $PSBSignDependency = @('SignCatalog') +} #endregion Task Dependencies # This psake file is meant to be referenced from another @@ -218,6 +230,136 @@ Task Publish -Depends $PSBPublishDependency { Publish-PSBuildModule @publishParams } -Description 'Publish module to the defined PowerShell repository' +$signModulePreReqs = { + $result = $true + if (-not $PSBPreference.Sign.Enabled) { + Write-Warning 'Module signing is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + $result = $false + } + $result +} +Task SignModule -Depends $PSBSignModuleDependency -PreCondition $signModulePreReqs { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + Assert ($null -ne $certificate) $LocalizedData.NoCertificateFound + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = $PSBPreference.Sign.FilesToSign + } + Invoke-PSBuildModuleSigning @signingParams +} -Description 'Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature' + +$buildCatalogPreReqs = { + $result = $true + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog generation is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'New-FileCatalog' -ErrorAction Ignore)) { + Write-Warning 'New-FileCatalog is not available. Catalog generation requires Windows.' + $result = $false + } + $result +} +Task BuildCatalog -Depends $PSBBuildCatalogDependency -PreCondition $buildCatalogPreReqs { + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + $catalogFilePath = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath $catalogFileName + + $catalogParams = @{ + ModulePath = $PSBPreference.Build.ModuleOutDir + CatalogFilePath = $catalogFilePath + CatalogVersion = $PSBPreference.Sign.Catalog.Version + } + New-PSBuildFileCatalog @catalogParams +} -Description 'Creates a Windows catalog (.cat) file for the built module' + +$signCatalogPreReqs = { + $result = $true + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog signing is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + $result = $false + } + $result +} +Task SignCatalog -Depends $PSBSignCatalogDependency -PreCondition $signCatalogPreReqs { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + Assert ($null -ne $certificate) $LocalizedData.NoCertificateFound + + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = @($catalogFileName) + } + Invoke-PSBuildModuleSigning @signingParams +} -Description 'Signs the module catalog (.cat) file with an Authenticode signature' + +Task Sign -Depends $PSBSignDependency {} -Description 'Signs module files and catalog (meta task)' + Task ? -Description 'Lists the available tasks' { 'Available tasks:' $psake.context.Peek().Tasks.Keys | Sort-Object From 8934b7074ba4bbd97c08534b7489eb75597df78e Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 18 Feb 2026 22:23:44 -0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(tests):=20=E2=9C=A8=20Add=20comprehens?= =?UTF-8?q?ive=20tests=20for=20code=20signing=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced tests for `Get-PSBuildCertificate`, `Invoke-PSBuildModuleSigning`, and `New-PSBuildFileCatalog`. * Validated various certificate sourcing methods including `Auto`, `Store`, `Thumbprint`, `EnvVar`, and `PfxFile`. * Ensured proper parameter validation and help documentation for each function. * Established integration tests to verify the workflow of signing modules and creating catalogs. --- tests/Signing.tests.ps1 | 635 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 tests/Signing.tests.ps1 diff --git a/tests/Signing.tests.ps1 b/tests/Signing.tests.ps1 new file mode 100644 index 0000000..cec52c9 --- /dev/null +++ b/tests/Signing.tests.ps1 @@ -0,0 +1,635 @@ +# spell-checker:ignore SIGNCERTIFICATE CERTIFICATEPASSWORD codesign pfxfile +Describe 'Code Signing Functions' { + + BeforeAll { + $script:moduleName = 'PowerShellBuild' + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force + + # Create a temporary directory for test files + $script:testPath = Join-Path -Path $TestDrive -ChildPath 'SigningTest' + New-Item -Path $script:testPath -ItemType Directory -Force | Out-Null + } + + Context 'Get-PSBuildCertificate' { + + BeforeEach { + # Clear environment variables before each test + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue + } + + It 'Should exist and be exported' { + Get-Command Get-PSBuildCertificate -Module PowerShellBuild -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'Has a SYNOPSIS section in the help' { + (Get-Help Get-PSBuildCertificate).Synopsis | + Should -Not -BeNullOrEmpty + } + + It 'Has at least one EXAMPLE section in the help' { + (Get-Help Get-PSBuildCertificate).Examples.Example | + Should -Not -BeNullOrEmpty + } + + Context 'Auto mode' { + It 'Defaults to Auto mode when no CertificateSource is specified' { + Mock Get-ChildItem {} + $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 + $VerboseOutput | Should -Match "CertificateSource is 'Auto'" + } + + It 'Resolves to EnvVar mode when SIGNCERTIFICATE environment variable is set' { + $env:SIGNCERTIFICATE = 'base64data' + try { + $VerboseOutput = Get-PSBuildCertificate -Verbose -WarningAction SilentlyContinue -ErrorAction SilentlyContinue 4>&1 + $VerboseOutput | Should -Match "Resolved to 'EnvVar'" + } catch { + # Expected to fail with invalid base64, just checking the mode selection + $_.Exception.Message | Should -Not -BeNullOrEmpty + } + } + + It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' { + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Mock Get-ChildItem {} + $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 + $VerboseOutput | Should -Match "Resolved to 'Store'" + } + } + + Context 'Store mode' { + It 'Searches the certificate store for a valid code-signing certificate' -Skip:(-not $IsWindows) { + # On Windows, we can test the actual logic without mocking the cert store itself + # Instead, just verify the function accepts the parameter and attempts the search + $command = Get-Command Get-PSBuildCertificate + $command.Parameters['CertificateSource'].Attributes.ValidValues | Should -Contain 'Store' + + # If no cert found, should return $null (not throw) + { Get-PSBuildCertificate -CertificateSource Store -ErrorAction SilentlyContinue } | Should -Not -Throw + } + + It 'Returns $null when no valid certificate is found' { + Mock Get-ChildItem { } + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Filters out expired certificates' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { + # Return nothing (expired cert is filtered by Where-Object) + } + + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Filters out certificates without a private key' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { + # Return nothing (cert without private key is filtered by Where-Object) + } + + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Uses custom CertStoreLocation when specified' -Skip:(-not $IsWindows) { + # Just verify the parameter is accepted + { Get-PSBuildCertificate -CertificateSource Store -CertStoreLocation 'Cert:\LocalMachine\My' -ErrorAction SilentlyContinue } | + Should -Not -Throw + } + } + + Context 'Thumbprint mode' { + It 'Searches for a certificate with the specified thumbprint' -Skip:(-not $IsWindows) { + $testThumbprint = 'ABCD1234EFGH5678' + # Verify the function accepts the thumbprint parameter + { Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint $testThumbprint -ErrorAction SilentlyContinue } | + Should -Not -Throw + } + + It 'Returns $null when the specified thumbprint is not found' { + Mock Get-ChildItem { } + $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'NOTFOUND123' + $cert | Should -BeNullOrEmpty + } + } + + Context 'EnvVar mode' { + It 'Attempts to decode a Base64-encoded PFX from environment variable' { + # Create a minimal mock certificate data (will fail to parse, but that's expected) + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) + + # This should fail because the data is not a valid PFX, but that proves it's trying to load it + { Get-PSBuildCertificate -CertificateSource EnvVar -ErrorAction Stop } | Should -Throw + } + + It 'Uses custom environment variable names when specified' { + $env:MY_CUSTOM_CERT = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) + $env:MY_CUSTOM_PASS = 'password' + + try { + Get-PSBuildCertificate -CertificateSource EnvVar ` + -CertificateEnvVar 'MY_CUSTOM_CERT' ` + -CertificatePasswordEnvVar 'MY_CUSTOM_PASS' ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid certificate data + } + + # Cleanup + Remove-Item env:\MY_CUSTOM_CERT -ErrorAction SilentlyContinue + Remove-Item env:\MY_CUSTOM_PASS -ErrorAction SilentlyContinue + } + } + + Context 'PfxFile mode' { + It 'Accepts a PfxFilePath parameter' { + $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' + New-Item -Path $testPfxPath -ItemType File -Force | Out-Null + + try { + Get-PSBuildCertificate -CertificateSource PfxFile ` + -PfxFilePath $testPfxPath ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid PFX file + } + + # Just verify the parameter is accepted + { Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath $testPfxPath -ErrorAction Stop } | + Should -Throw + } + + It 'Accepts a PfxFilePassword parameter' { + $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' + New-Item -Path $testPfxPath -ItemType File -Force | Out-Null + $securePassword = ConvertTo-SecureString -String 'password' -AsPlainText -Force + + try { + Get-PSBuildCertificate -CertificateSource PfxFile ` + -PfxFilePath $testPfxPath ` + -PfxFilePassword $securePassword ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid PFX file + } + + # Just verify the parameters are accepted + $testPfxPath | Should -Exist + } + } + + Context 'Parameter validation' { + It 'ValidateSet accepts valid CertificateSource values' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertificateSource'] + $validValues = $parameter.Attributes.ValidValues + $validValues | Should -Contain 'Auto' + $validValues | Should -Contain 'Store' + $validValues | Should -Contain 'Thumbprint' + $validValues | Should -Contain 'EnvVar' + $validValues | Should -Contain 'PfxFile' + } + + It 'Has correct default value for CertStoreLocation' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertStoreLocation'] + $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | + Should -BeFalse + } + + It 'Has correct default value for CertificateEnvVar' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertificateEnvVar'] + $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | + Should -BeFalse + } + } + } + + Context 'Invoke-PSBuildModuleSigning' { + + It 'Should exist and be exported' { + Get-Command Invoke-PSBuildModuleSigning -Module PowerShellBuild -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'Has a SYNOPSIS section in the help' { + (Get-Help Invoke-PSBuildModuleSigning).Synopsis | + Should -Not -BeNullOrEmpty + } + + It 'Has at least one EXAMPLE section in the help' { + (Get-Help Invoke-PSBuildModuleSigning).Examples.Example | + Should -Not -BeNullOrEmpty + } + + It 'Requires Path parameter' { + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters['Path'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Requires Certificate parameter' { + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters['Certificate'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Validates that Path must be a directory' { + $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' + New-Item -Path $testFilePath -ItemType File -Force | Out-Null + + $mockCert = [PSCustomObject]@{ Subject = 'CN=Test' } + + { Invoke-PSBuildModuleSigning -Path $testFilePath -Certificate $mockCert } | + Should -Throw + } + + It 'Searches for files matching Include patterns' -Skip:(-not $IsWindows) { + # Create test files + $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest' + New-Item -Path $testDir -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.ps1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.txt') + + Mock Set-AuthenticodeSignature { + [PSCustomObject]@{ Status = 'Valid'; Path = $InputObject } + } + + # We need to skip this test if we can't create a real cert, or just verify file discovery + # Instead of mocking cert, just count the files that would be signed + $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1', '*.psm1', '*.ps1' + $files.Count | Should -Be 3 # Should not include .txt file + } + + It 'Uses custom Include patterns when specified' -Skip:(-not $IsWindows) { + $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest2' + New-Item -Path $testDir -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') + + # Just verify file discovery with custom Include pattern + $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1' + $files.Count | Should -Be 1 # Only .psd1 + } + + It 'Accepts TimestampServer and HashAlgorithm parameters' { + # Just verify parameters are accepted without error + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters.ContainsKey('TimestampServer') | Should -BeTrue + $command.Parameters.ContainsKey('HashAlgorithm') | Should -BeTrue + $command.Parameters['TimestampServer'].ParameterType.Name | Should -Be 'String' + $command.Parameters['HashAlgorithm'].ParameterType.Name | Should -Be 'String' + } + + It 'Has correct default values' { + $command = Get-Command Invoke-PSBuildModuleSigning + # Check default timestamp server + $tsParam = $command.Parameters['TimestampServer'] + $tsParam | Should -Not -BeNullOrEmpty + # Check default hash algorithm + $hashParam = $command.Parameters['HashAlgorithm'] + $hashParam.Attributes.Where({ $_.TypeId.Name -eq 'ValidateSetAttribute' }).ValidValues | + Should -Contain 'SHA256' + } + + It 'ValidateSet accepts valid HashAlgorithm values' { + $command = Get-Command Invoke-PSBuildModuleSigning + $parameter = $command.Parameters['HashAlgorithm'] + $validValues = $parameter.Attributes.ValidValues + $validValues | Should -Contain 'SHA256' + $validValues | Should -Contain 'SHA384' + $validValues | Should -Contain 'SHA512' + $validValues | Should -Contain 'SHA1' + } + } + + Context 'New-PSBuildFileCatalog' { + + It 'Should exist and be exported' { + Get-Command New-PSBuildFileCatalog -Module PowerShellBuild -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'Has a SYNOPSIS section in the help' { + (Get-Help New-PSBuildFileCatalog).Synopsis | + Should -Not -BeNullOrEmpty + } + + It 'Has at least one EXAMPLE section in the help' { + (Get-Help New-PSBuildFileCatalog).Examples.Example | + Should -Not -BeNullOrEmpty + } + + It 'Requires ModulePath parameter' { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters['ModulePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Requires CatalogFilePath parameter' { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters['CatalogFilePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Validates that ModulePath must be a directory' { + $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' + New-Item -Path $testFilePath -ItemType File -Force | Out-Null + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' + + { New-PSBuildFileCatalog -ModulePath $testFilePath -CatalogFilePath $catalogPath } | + Should -Throw + } + + It 'Accepts CatalogVersion parameter with valid range' { + $command = Get-Command New-PSBuildFileCatalog + $parameter = $command.Parameters['CatalogVersion'] + $validateRange = $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ValidateRangeAttribute' })[0] + $validateRange.MinRange | Should -Be 1 + $validateRange.MaxRange | Should -Be 2 + } + + It 'Calls New-FileCatalog with correct parameters' -Skip:(-not $IsWindows) { + $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest' + New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' + + # Rather than mocking, just test that the function calls New-FileCatalog + # by verifying it works end-to-end (requires Windows) + try { + $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath -CatalogVersion 2 + $result | Should -Not -BeNullOrEmpty + Test-Path $catalogPath | Should -BeTrue + } catch { + # If New-FileCatalog isn't available, just verify the function exists and accepts the params + if ($_.Exception.Message -match 'New-FileCatalog') { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters.ContainsKey('CatalogVersion') | Should -BeTrue + } + } + } + + It 'Defaults CatalogVersion to 2 (SHA256)' { + $command = Get-Command New-PSBuildFileCatalog + $parameter = $command.Parameters['CatalogVersion'] + # The default should be set in the function, we'll check by the ValidateRange attribute + $parameter | Should -Not -BeNullOrEmpty + } + + It 'Returns a FileInfo object' -Skip:(-not $IsWindows) { + $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest2' + New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test2.cat' + + # Test end-to-end on Windows + try { + $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath + $result | Should -BeOfType [System.IO.FileInfo] + } catch { + # If New-FileCatalog isn't available, verify function signature + if ($_.Exception.Message -match 'New-FileCatalog') { + $command = Get-Command New-PSBuildFileCatalog + $command.OutputType.Type.Name | Should -Contain 'FileInfo' + } + } + } + } + + Context 'Integration - Sign workflow' { + + It 'Functions are designed to work together in the recommended order' { + # This is more of a documentation test - verify functions exist with expected signatures + Get-Command Get-PSBuildCertificate | Should -Not -BeNullOrEmpty + Get-Command Invoke-PSBuildModuleSigning | Should -Not -BeNullOrEmpty + Get-Command New-PSBuildFileCatalog | Should -Not -BeNullOrEmpty + + # Verify the workflow can be constructed + $getCertCmd = Get-Command Get-PSBuildCertificate + $getCertCmd.OutputType.Type.Name | Should -Contain 'X509Certificate2' + + $signCmd = Get-Command Invoke-PSBuildModuleSigning + $signCmd.Parameters['Certificate'].ParameterType.Name | Should -Be 'X509Certificate2' + + $catalogCmd = Get-Command New-PSBuildFileCatalog + $catalogCmd.OutputType.Type.Name | Should -Contain 'FileInfo' + } + } +} + +Describe 'Code Signing Tasks' { + + BeforeAll { + $script:moduleName = 'PowerShellBuild' + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + + # Import the module from output directory + Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force + + # Load psake + if (-not (Get-Module -Name psake -ListAvailable)) { + Write-Warning "psake module not found. Skipping task tests." + return + } + Import-Module psake -Force + } + + Context 'psake tasks' { + + It 'SignModule task should be defined' { + $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' + $psakeFile | Should -Exist + $content = Get-Content -Path $psakeFile -Raw + $content | Should -Match 'Task\s+SignModule' + } + + It 'BuildCatalog task should be defined' { + $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' + $content = Get-Content -Path $psakeFile -Raw + $content | Should -Match 'Task\s+BuildCatalog' + } + + It 'SignCatalog task should be defined' { + $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' + $content = Get-Content -Path $psakeFile -Raw + $content | Should -Match 'Task\s+SignCatalog' + } + + It 'Sign meta task should be defined' { + $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' + $content = Get-Content -Path $psakeFile -Raw + $content | Should -Match 'Task\s+Sign' + } + } + + Context 'Invoke-Build tasks' { + + It 'SignModule task should be defined in IB.tasks.ps1' { + $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' + $ibTasksFile | Should -Exist + $content = Get-Content -Path $ibTasksFile -Raw + $content | Should -Match 'task\s+SignModule' + } + + It 'BuildCatalog task should be defined in IB.tasks.ps1' { + $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' + $content = Get-Content -Path $ibTasksFile -Raw + $content | Should -Match 'task\s+BuildCatalog' + } + + It 'SignCatalog task should be defined in IB.tasks.ps1' { + $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' + $content = Get-Content -Path $ibTasksFile -Raw + $content | Should -Match 'task\s+SignCatalog' + } + + It 'Sign meta task should be defined in IB.tasks.ps1' { + $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' + $content = Get-Content -Path $ibTasksFile -Raw + $content | Should -Match 'task\s+Sign' + } + } +} + +Describe 'Code Signing Configuration' { + + BeforeAll { + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + $script:buildPropertiesPath = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\build.properties.ps1' + } + + Context '$PSBPreference.Sign configuration' { + + BeforeAll { + # Load config once for all tests in this context + BuildHelpers\Set-BuildEnvironment -Force -Path $script:moduleRoot + $script:config = & $script:buildPropertiesPath + } + + It 'Sign section should exist in $PSBPreference' { + $script:config.Sign | Should -Not -BeNullOrEmpty + } + + It 'Sign.Enabled should default to $false' { + $script:config.Sign.Enabled | Should -Be $false + } + + It 'Sign.CertificateSource should default to Auto' { + $script:config.Sign.CertificateSource | Should -Be 'Auto' + } + + It 'Sign.CertStoreLocation should have a default value' { + $script:config.Sign.CertStoreLocation | Should -Not -BeNullOrEmpty + $script:config.Sign.CertStoreLocation | Should -Match 'Cert:' + } + + It 'Sign.CertificateEnvVar should default to SIGNCERTIFICATE' { + $script:config.Sign.CertificateEnvVar | Should -Be 'SIGNCERTIFICATE' + } + + It 'Sign.CertificatePasswordEnvVar should default to CERTIFICATEPASSWORD' { + $script:config.Sign.CertificatePasswordEnvVar | Should -Be 'CERTIFICATEPASSWORD' + } + + It 'Sign.TimestampServer should have a default value' { + $script:config.Sign.TimestampServer | Should -Not -BeNullOrEmpty + $script:config.Sign.TimestampServer | Should -Match '^https?://' + } + + It 'Sign.HashAlgorithm should default to SHA256' { + $script:config.Sign.HashAlgorithm | Should -Be 'SHA256' + } + + It 'Sign.FilesToSign should include common PowerShell file extensions' { + $script:config.Sign.FilesToSign | Should -Contain '*.psd1' + $script:config.Sign.FilesToSign | Should -Contain '*.psm1' + $script:config.Sign.FilesToSign | Should -Contain '*.ps1' + } + + It 'Sign.Catalog section should exist' { + $script:config.Sign.Catalog | Should -Not -BeNullOrEmpty + } + + It 'Sign.Catalog.Enabled should default to $false' { + $script:config.Sign.Catalog.Enabled | Should -Be $false + } + + It 'Sign.Catalog.Version should default to 2 (SHA256)' { + $script:config.Sign.Catalog.Version | Should -Be 2 + } + + It 'Sign.Catalog.FileName should be $null by default' { + $script:config.Sign.Catalog.FileName | Should -BeNullOrEmpty + } + } +} + +Describe 'Localized Messages' { + + BeforeAll { + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + $script:messagesPath = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\en-US\Messages.psd1' + } + + Context 'Signing-related messages' { + + BeforeAll { + # Load the messages file by dot-sourcing it + $messagesContent = Get-Content -Path $script:messagesPath -Raw + # Extract the ConvertFrom-StringData content + if ($messagesContent -match "ConvertFrom-StringData @'([\s\S]*?)'@") { + $stringData = $matches[1] + $messages = ConvertFrom-StringData -StringData $stringData + } else { + throw "Could not parse Messages.psd1" + } + } + + It 'Should have NoCertificateFound message' { + $messages.NoCertificateFound | Should -Not -BeNullOrEmpty + } + + It 'Should have CertificateResolvedFromStore message' { + $messages.CertificateResolvedFromStore | Should -Not -BeNullOrEmpty + $messages.CertificateResolvedFromStore | Should -Match '\{0\}' + } + + It 'Should have CertificateResolvedFromThumbprint message' { + $messages.CertificateResolvedFromThumbprint | Should -Not -BeNullOrEmpty + $messages.CertificateResolvedFromThumbprint | Should -Match '\{0\}' + } + + It 'Should have CertificateResolvedFromEnvVar message' { + $messages.CertificateResolvedFromEnvVar | Should -Not -BeNullOrEmpty + $messages.CertificateResolvedFromEnvVar | Should -Match '\{0\}' + } + + It 'Should have CertificateResolvedFromPfxFile message' { + $messages.CertificateResolvedFromPfxFile | Should -Not -BeNullOrEmpty + $messages.CertificateResolvedFromPfxFile | Should -Match '\{0\}' + } + + It 'Should have SigningModuleFiles message' { + $messages.SigningModuleFiles | Should -Not -BeNullOrEmpty + $messages.SigningModuleFiles | Should -Match '\{0\}' + } + + It 'Should have CreatingFileCatalog message' { + $messages.CreatingFileCatalog | Should -Not -BeNullOrEmpty + $messages.CreatingFileCatalog | Should -Match '\{0\}' + } + + It 'Should have FileCatalogCreated message' { + $messages.FileCatalogCreated | Should -Not -BeNullOrEmpty + $messages.FileCatalogCreated | Should -Match '\{0\}' + } + } +} From 36db55ffbe433db0f2ae84be9ef9ac02e561a2a2 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 19 Feb 2026 07:23:30 -0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(signing):=20=E2=9C=A8=20Add=20certific?= =?UTF-8?q?ate=20validation=20options=20for=20code=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `-SkipValidation` parameter to bypass validation checks for certificates from `EnvVar` or `PfxFile` sources. - Enhanced error handling for missing or invalid certificates. - Updated localization strings for better clarity on certificate validation messages. --- PowerShellBuild/IB.tasks.ps1 | 52 +++++++-------- .../Public/Get-PSBuildCertificate.ps1 | 64 +++++++++++++++++-- .../Public/New-PSBuildFileCatalog.ps1 | 1 - PowerShellBuild/en-US/Messages.psd1 | 4 ++ PowerShellBuild/psakeFile.ps1 | 2 +- 5 files changed, 89 insertions(+), 34 deletions(-) diff --git a/PowerShellBuild/IB.tasks.ps1 b/PowerShellBuild/IB.tasks.ps1 index a184528..fe54942 100644 --- a/PowerShellBuild/IB.tasks.ps1 +++ b/PowerShellBuild/IB.tasks.ps1 @@ -1,28 +1,28 @@ Remove-Variable -Name PSBPreference -Scope Script -Force -ErrorAction Ignore Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. ([IO.Path]::Combine($PSScriptRoot, 'build.properties.ps1'))) -$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies +$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies # Synopsis: Initialize build environment variables -task Init { +Task Init { Initialize-PSBuild -UseBuildHelpers -BuildEnvironment $PSBPreference } # Synopsis: Clears module output directory -task Clean Init, { +Task Clean Init, { Clear-PSBuildOutputFolder -Path $PSBPreference.Build.ModuleOutDir } # Synopsis: Builds module based on source directory -task StageFiles Clean, { +Task StageFiles Clean, { $buildParams = @{ - Path = $PSBPreference.General.SrcRootDir - ModuleName = $PSBPreference.General.ModuleName - DestinationPath = $PSBPreference.Build.ModuleOutDir - Exclude = $PSBPreference.Build.Exclude - Compile = $PSBPreference.Build.CompileModule - CompileDirectories = $PSBPreference.Build.CompileDirectories - CopyDirectories = $PSBPreference.Build.CopyDirectories - Culture = $PSBPreference.Help.DefaultLocale + Path = $PSBPreference.General.SrcRootDir + ModuleName = $PSBPreference.General.ModuleName + DestinationPath = $PSBPreference.Build.ModuleOutDir + Exclude = $PSBPreference.Build.Exclude + Compile = $PSBPreference.Build.CompileModule + CompileDirectories = $PSBPreference.Build.CompileDirectories + CopyDirectories = $PSBPreference.Build.CopyDirectories + Culture = $PSBPreference.Help.DefaultLocale } if ($PSBPreference.Help.ConvertReadMeToAboutHelp) { @@ -59,7 +59,7 @@ $analyzePreReqs = { } # Synopsis: Execute PSScriptAnalyzer tests -task Analyze -If (. $analyzePreReqs) Build,{ +Task Analyze -If (. $analyzePreReqs) Build, { $analyzeParams = @{ Path = $PSBPreference.Build.ModuleOutDir SeverityThreshold = $PSBPreference.Test.ScriptAnalysis.FailBuildOnSeverityLevel @@ -86,7 +86,7 @@ $pesterPreReqs = { } # Synopsis: Execute Pester tests -task Pester -If (. $pesterPreReqs) Build,{ +Task Pester -If (. $pesterPreReqs) Build, { $pesterParams = @{ Path = $PSBPreference.Test.RootDir ModuleName = $PSBPreference.General.ModuleName @@ -117,7 +117,7 @@ $genMarkdownPreReqs = { } # Synopsis: Generates PlatyPS markdown files from module help -task GenerateMarkdown -if (. $genMarkdownPreReqs) StageFiles,{ +Task GenerateMarkdown -if (. $genMarkdownPreReqs) StageFiles, { $buildMDParams = @{ ModulePath = $PSBPreference.Build.ModuleOutDir ModuleName = $PSBPreference.General.ModuleName @@ -141,7 +141,7 @@ $genHelpFilesPreReqs = { } # Synopsis: Generates MAML-based help from PlatyPS markdown files -task GenerateMAML -if (. $genHelpFilesPreReqs) GenerateMarkdown, { +Task GenerateMAML -if (. $genHelpFilesPreReqs) GenerateMarkdown, { Build-PSBuildMAMLHelp -Path $PSBPreference.Docs.RootDir -DestinationPath $PSBPreference.Build.ModuleOutDir } @@ -155,7 +155,7 @@ $genUpdatableHelpPreReqs = { } # Synopsis: Create updatable help .cab file based on PlatyPS markdown help -task GenerateUpdatableHelp -if (. $genUpdatableHelpPreReqs) BuildHelp, { +Task GenerateUpdatableHelp -if (. $genUpdatableHelpPreReqs) BuildHelp, { Build-PSBuildUpdatableHelp -DocsPath $PSBPreference.Docs.RootDir -OutputPath $PSBPreference.Help.UpdatableHelpOutDir } @@ -184,21 +184,21 @@ Task Publish Test, { #region Summary Tasks # Synopsis: Builds help documentation -task BuildHelp GenerateMarkdown,GenerateMAML +Task BuildHelp GenerateMarkdown, GenerateMAML Task Build { if ([String]$PSBPreference.Build.Dependencies -ne [String]$__DefaultBuildDependencies) { throw [NotSupportedException]'You cannot use $PSBPreference.Build.Dependencies with Invoke-Build. Please instead redefine the build task or your default task to include your dependencies. Example: Task . Dependency1,Dependency2,Build,Test or Task Build Dependency1,Dependency2,StageFiles' } -},StageFiles,BuildHelp +}, StageFiles, BuildHelp # Synopsis: Execute Pester and ScriptAnalyzer tests -task Test Analyze,Pester +Task Test Analyze, Pester -task . Build,Test +Task . Build, Test # Synopsis: Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature -task SignModule -If { +Task SignModule -If { if (-not $PSBPreference.Sign.Enabled) { Write-Warning 'Module signing is not enabled.' return $false @@ -246,7 +246,7 @@ task SignModule -If { } # Synopsis: Creates a Windows catalog (.cat) file for the built module -task BuildCatalog -If { +Task BuildCatalog -If { if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { Write-Warning 'Catalog generation is not enabled.' return $false @@ -273,13 +273,13 @@ task BuildCatalog -If { } # Synopsis: Signs the module catalog (.cat) file with an Authenticode signature -task SignCatalog -If { +Task SignCatalog -If { if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { Write-Warning 'Catalog signing is not enabled.' return $false } if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { - Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + Write-Warning 'Set-AuthenticodeSignature is not available. Catalog signing requires Windows.' return $false } $true @@ -327,6 +327,6 @@ task SignCatalog -If { } # Synopsis: Signs module files and catalog (meta task) -task Sign SignCatalog +Task Sign SignModule, SignCatalog #endregion Summary Tasks diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 index 4e09afd..d2ecce6 100644 --- a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -50,6 +50,10 @@ function Get-PSBuildCertificate { File system path to a PFX/P12 certificate file. Required when CertificateSource is PfxFile. .PARAMETER PfxFilePassword Password for the PFX file as a SecureString. Used by PfxFile source. + .PARAMETER SkipValidation + Skip validation checks (private key presence, expiration, Code Signing EKU) for certificates + loaded from EnvVar or PfxFile sources. Use with caution; invalid certificates will fail during + actual signing operations with less descriptive errors. .OUTPUTS System.Security.Cryptography.X509Certificates.X509Certificate2 Returns the resolved certificate, or $null if none was found (Store/Thumbprint sources). @@ -79,6 +83,11 @@ function Get-PSBuildCertificate { #> [CmdletBinding()] [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'CertificatePasswordEnvVar', + Justification = 'This is not a password in plain text. It is the name of an environment variable that contains the password, which is a common pattern for CI/CD pipelines and secrets management.' + )] param( [ValidateSet('Auto', 'Store', 'Thumbprint', 'EnvVar', 'PfxFile')] [string]$CertificateSource = 'Auto', @@ -93,7 +102,9 @@ function Get-PSBuildCertificate { [string]$PfxFilePath, - [securestring]$PfxFilePassword + [securestring]$PfxFilePassword, + + [switch]$SkipValidation ) # Resolve 'Auto' to the actual source based on environment variable presence @@ -104,7 +115,7 @@ function Get-PSBuildCertificate { } else { 'Store' } - Write-Verbose "CertificateSource is 'Auto'. Resolved to '$resolvedSource'." + Write-Verbose ($LocalizedData.CertificateSourceAutoResolved -f $resolvedSource) } $cert = $null @@ -119,8 +130,19 @@ function Get-PSBuildCertificate { } } 'Thumbprint' { - $cert = Get-ChildItem -Path $CertStoreLocation | - Where-Object { $_.Thumbprint -eq $Thumbprint -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | + if ([string]::IsNullOrWhiteSpace($Thumbprint)) { + throw "CertificateSource 'Thumbprint' requires a non-empty Thumbprint value." + } + + # Normalize thumbprint input by removing whitespace for robust matching + $normalizedThumbprint = ($Thumbprint -replace '\s', '') + + $cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert | + Where-Object { + ($_.Thumbprint -replace '\s', '') -ieq $normalizedThumbprint -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date) + } | Select-Object -First 1 if ($cert) { Write-Verbose ($LocalizedData.CertificateResolvedFromThumbprint -f $Thumbprint, $cert.Subject) @@ -128,9 +150,17 @@ function Get-PSBuildCertificate { } 'EnvVar' { $b64Value = [System.Environment]::GetEnvironmentVariable($CertificateEnvVar) - $buffer = [System.Convert]::FromBase64String($b64Value) + if ([string]::IsNullOrWhiteSpace($b64Value)) { + throw "Environment variable '$CertificateEnvVar' is not set or is empty. When using CertificateSource='EnvVar', you must provide a Base64-encoded PFX in this variable." + } + + try { + $buffer = [System.Convert]::FromBase64String($b64Value) + } catch [System.FormatException] { + throw "Environment variable '$CertificateEnvVar' does not contain a valid Base64-encoded PFX value." + } $password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar) - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password) Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar) } 'PfxFile' { @@ -139,5 +169,27 @@ function Get-PSBuildCertificate { } } + # Validate certificates loaded from EnvVar or PfxFile sources unless -SkipValidation is specified + if ($cert -and -not $SkipValidation -and ($resolvedSource -eq 'EnvVar' -or $resolvedSource -eq 'PfxFile')) { + # Check for private key + if (-not $cert.HasPrivateKey) { + throw ($LocalizedData.CertificateMissingPrivateKey -f $cert.Subject) + } + + # Check expiration + if ($cert.NotAfter -le (Get-Date)) { + throw ($LocalizedData.CertificateExpired -f $cert.NotAfter, $cert.Subject) + } + + # Check for Code Signing EKU (1.3.6.1.5.5.7.3.3) + $codeSigningOid = '1.3.6.1.5.5.7.3.3' + $hasCodeSigningEku = $cert.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $codeSigningOid } + if (-not $hasCodeSigningEku) { + throw ($LocalizedData.CertificateMissingCodeSigningEku -f $cert.Subject) + } + + Write-Verbose "Certificate validation passed: HasPrivateKey=$($cert.HasPrivateKey), NotAfter=$($cert.NotAfter), CodeSigningEKU=Present" + } + $cert } diff --git a/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 index bb96e26..d8915e6 100644 --- a/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 +++ b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 @@ -65,7 +65,6 @@ function New-PSBuildFileCatalog { Path = $ModulePath CatalogFilePath = $CatalogFilePath CatalogVersion = $CatalogVersion - Verbose = $VerbosePreference } Microsoft.PowerShell.Security\New-FileCatalog @catalogParams diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index fd9a3fa..f2d81ce 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -31,4 +31,8 @@ CertificateResolvedFromPfxFile=Resolved code signing certificate from PFX file [ SigningModuleFiles=Signing [{0}] file(s) matching [{1}] in [{2}]... CreatingFileCatalog=Creating file catalog [{0}] (version {1})... FileCatalogCreated=File catalog created: [{0}] +CertificateSourceAutoResolved=CertificateSource is 'Auto'. Resolved to '{0}'. +CertificateMissingPrivateKey=The resolved certificate does not have an accessible private key. Code signing requires a certificate with a private key. Subject=[{0}] +CertificateExpired=The resolved certificate has expired (NotAfter: {0}). Code signing requires a valid, unexpired certificate. Subject=[{1}] +CertificateMissingCodeSigningEku=The resolved certificate does not have the Code Signing Enhanced Key Usage (EKU: 1.3.6.1.5.5.7.3.3). Subject=[{0}] '@ diff --git a/PowerShellBuild/psakeFile.ps1 b/PowerShellBuild/psakeFile.ps1 index 194e4bc..c552ef9 100644 --- a/PowerShellBuild/psakeFile.ps1 +++ b/PowerShellBuild/psakeFile.ps1 @@ -312,7 +312,7 @@ $signCatalogPreReqs = { $result = $false } if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { - Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + Write-Warning 'Set-AuthenticodeSignature is not available. Catalog signing requires Windows.' $result = $false } $result From 4c77115e4089a818429d1421a827ca06def2d499 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 19 Feb 2026 07:33:42 -0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(certificate):=20=E2=9C=A8=20Add=20supp?= =?UTF-8?q?ort=20for=20non-Windows=20platform=20checks=20in=20`Get-PSBuild?= =?UTF-8?q?Certificate`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implemented a check to throw an error if the 'Store' certificate source is used on non-Windows platforms. * Added localized message for unsupported certificate source on non-Windows. * Updated tests to handle the new error action for better verbosity control. --- PowerShellBuild/Public/Get-PSBuildCertificate.ps1 | 4 ++++ PowerShellBuild/en-US/Messages.psd1 | 1 + tests/Signing.tests.ps1 | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 index d2ecce6..fadde59 100644 --- a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -122,6 +122,10 @@ function Get-PSBuildCertificate { switch ($resolvedSource) { 'Store' { + # Throw if running on a non-Windows platform since the certificate store is not supported + if (-not $IsWindows) { + throw $LocalizedData.CertificateSourceStoreNotSupported + } $cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert | Where-Object { $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | Select-Object -First 1 diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index f2d81ce..58aff5e 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -35,4 +35,5 @@ CertificateSourceAutoResolved=CertificateSource is 'Auto'. Resolved to '{0}'. CertificateMissingPrivateKey=The resolved certificate does not have an accessible private key. Code signing requires a certificate with a private key. Subject=[{0}] CertificateExpired=The resolved certificate has expired (NotAfter: {0}). Code signing requires a valid, unexpired certificate. Subject=[{1}] CertificateMissingCodeSigningEku=The resolved certificate does not have the Code Signing Enhanced Key Usage (EKU: 1.3.6.1.5.5.7.3.3). Subject=[{0}] +CertificateSourceStoreNotSupported=CertificateSource 'Store' is only supported on Windows platforms. '@ diff --git a/tests/Signing.tests.ps1 b/tests/Signing.tests.ps1 index cec52c9..2b08356 100644 --- a/tests/Signing.tests.ps1 +++ b/tests/Signing.tests.ps1 @@ -37,7 +37,7 @@ Describe 'Code Signing Functions' { Context 'Auto mode' { It 'Defaults to Auto mode when no CertificateSource is specified' { Mock Get-ChildItem {} - $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 + $VerboseOutput = Get-PSBuildCertificate -Verbose -ErrorAction SilentlyContinue 4>&1 $VerboseOutput | Should -Match "CertificateSource is 'Auto'" } From 49409cdba77d99c6a6aba89ce039a01ac60d9701 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 19 Feb 2026 07:44:54 -0800 Subject: [PATCH 5/7] =?UTF-8?q?test(signing):=20=F0=9F=A7=AA=20Remove=20re?= =?UTF-8?q?dundant=20tests=20for=20`Get-PSBuildCertificate`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Removed tests that check for the existence and help documentation of `Get-PSBuildCertificate`. * Updated context descriptions to clarify platform-specific behavior. * Ensured tests are appropriately skipped on non-Windows platforms. --- tests/Signing.tests.ps1 | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/Signing.tests.ps1 b/tests/Signing.tests.ps1 index 2b08356..1066189 100644 --- a/tests/Signing.tests.ps1 +++ b/tests/Signing.tests.ps1 @@ -19,23 +19,8 @@ Describe 'Code Signing Functions' { Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue } - It 'Should exist and be exported' { - Get-Command Get-PSBuildCertificate -Module PowerShellBuild -ErrorAction SilentlyContinue | - Should -Not -BeNullOrEmpty - } - - It 'Has a SYNOPSIS section in the help' { - (Get-Help Get-PSBuildCertificate).Synopsis | - Should -Not -BeNullOrEmpty - } - - It 'Has at least one EXAMPLE section in the help' { - (Get-Help Get-PSBuildCertificate).Examples.Example | - Should -Not -BeNullOrEmpty - } - Context 'Auto mode' { - It 'Defaults to Auto mode when no CertificateSource is specified' { + It 'Defaults to Auto mode when no CertificateSource is specified' -Skip:(-not $IsWindows) { Mock Get-ChildItem {} $VerboseOutput = Get-PSBuildCertificate -Verbose -ErrorAction SilentlyContinue 4>&1 $VerboseOutput | Should -Match "CertificateSource is 'Auto'" @@ -60,7 +45,8 @@ Describe 'Code Signing Functions' { } } - Context 'Store mode' { + # Store mode only works on Windows + Context 'Store mode' -Skip:(-not $IsWindows) { It 'Searches the certificate store for a valid code-signing certificate' -Skip:(-not $IsWindows) { # On Windows, we can test the actual logic without mocking the cert store itself # Instead, just verify the function accepts the parameter and attempts the search @@ -510,7 +496,7 @@ Describe 'Code Signing Configuration' { BeforeAll { # Load config once for all tests in this context - BuildHelpers\Set-BuildEnvironment -Force -Path $script:moduleRoot + # build.properties.ps1 will call Set-BuildEnvironment internally $script:config = & $script:buildPropertiesPath } From 6b14de138c1e14b3fc5a46021d13870ecc16efd1 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 19 Feb 2026 07:47:39 -0800 Subject: [PATCH 6/7] =?UTF-8?q?test(signing):=20=F0=9F=A7=AA=20Skip=20test?= =?UTF-8?q?s=20for=20Store=20mode=20on=20non-Windows=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated tests to skip execution on non-Windows systems. * Ensures that Store mode tests are only run in a compatible environment. --- tests/Signing.tests.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Signing.tests.ps1 b/tests/Signing.tests.ps1 index 1066189..03a34b7 100644 --- a/tests/Signing.tests.ps1 +++ b/tests/Signing.tests.ps1 @@ -37,7 +37,7 @@ Describe 'Code Signing Functions' { } } - It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' { + It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' -Skip:(-not $IsWindows) { Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue Mock Get-ChildItem {} $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 @@ -46,7 +46,7 @@ Describe 'Code Signing Functions' { } # Store mode only works on Windows - Context 'Store mode' -Skip:(-not $IsWindows) { + Context 'Store mode' { It 'Searches the certificate store for a valid code-signing certificate' -Skip:(-not $IsWindows) { # On Windows, we can test the actual logic without mocking the cert store itself # Instead, just verify the function accepts the parameter and attempts the search @@ -57,7 +57,7 @@ Describe 'Code Signing Functions' { { Get-PSBuildCertificate -CertificateSource Store -ErrorAction SilentlyContinue } | Should -Not -Throw } - It 'Returns $null when no valid certificate is found' { + It 'Returns $null when no valid certificate is found' -Skip:(-not $IsWindows) { Mock Get-ChildItem { } $cert = Get-PSBuildCertificate -CertificateSource Store $cert | Should -BeNullOrEmpty From d8f347985e1b14230b17c8758aab497a8052969e Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 19 Feb 2026 08:22:20 -0800 Subject: [PATCH 7/7] =?UTF-8?q?test:=20=E2=9C=8F=EF=B8=8F=20Add=20unit=20t?= =?UTF-8?q?ests=20for=20code=20signing=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced new test files `Invoke-PSBuildModuleSigning.tests.ps1` and `New-PSBuildFileCatalog.tests.ps1` to cover the functionality of code signing. - Removed the old `Signing.tests.ps1` file to streamline test organization. - Ensured tests validate the existence, parameters, and expected behaviors of the `Invoke-PSBuildModuleSigning` and `New-PSBuildFileCatalog` functions. - Added checks for help documentation and parameter validation, including mandatory parameters and default values. --- tests/Get-PSBuildCertificate.tests.ps1 | 198 +++++++ tests/Invoke-PSBuildModuleSigning.tests.ps1 | 114 ++++ tests/New-PSBuildFileCatalog.tests.ps1 | 107 ++++ tests/Signing.tests.ps1 | 621 -------------------- 4 files changed, 419 insertions(+), 621 deletions(-) create mode 100644 tests/Get-PSBuildCertificate.tests.ps1 create mode 100644 tests/Invoke-PSBuildModuleSigning.tests.ps1 create mode 100644 tests/New-PSBuildFileCatalog.tests.ps1 delete mode 100644 tests/Signing.tests.ps1 diff --git a/tests/Get-PSBuildCertificate.tests.ps1 b/tests/Get-PSBuildCertificate.tests.ps1 new file mode 100644 index 0000000..9e8f954 --- /dev/null +++ b/tests/Get-PSBuildCertificate.tests.ps1 @@ -0,0 +1,198 @@ +# spell-checker:ignore SIGNCERTIFICATE CERTIFICATEPASSWORD codesign pfxfile +Describe 'Code Signing Functions' { + + BeforeAll { + $script:moduleName = 'PowerShellBuild' + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force + + # Create a temporary directory for test files + $script:testPath = Join-Path -Path $TestDrive -ChildPath 'SigningTest' + New-Item -Path $script:testPath -ItemType Directory -Force | Out-Null + } + + Context 'Get-PSBuildCertificate' { + + BeforeEach { + # Clear environment variables before each test + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue + } + + Context 'Auto mode' { + It 'Defaults to Auto mode when no CertificateSource is specified' -Skip:(-not $IsWindows) { + Mock Get-ChildItem {} + $VerboseOutput = Get-PSBuildCertificate -Verbose -ErrorAction SilentlyContinue 4>&1 + $VerboseOutput | Should -Match "CertificateSource is 'Auto'" + } + + It 'Resolves to EnvVar mode when SIGNCERTIFICATE environment variable is set' { + $env:SIGNCERTIFICATE = 'base64data' + try { + $VerboseOutput = Get-PSBuildCertificate -Verbose -WarningAction SilentlyContinue -ErrorAction SilentlyContinue 4>&1 + $VerboseOutput | Should -Match "Resolved to 'EnvVar'" + } catch { + # Expected to fail with invalid base64, just checking the mode selection + $_.Exception.Message | Should -Not -BeNullOrEmpty + } + } + + It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' -Skip:(-not $IsWindows) { + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Mock Get-ChildItem {} + $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 + $VerboseOutput | Should -Match "Resolved to 'Store'" + } + } + + # Store mode only works on Windows + Context 'Store mode' { + It 'Searches the certificate store for a valid code-signing certificate' -Skip:(-not $IsWindows) { + # On Windows, we can test the actual logic without mocking the cert store itself + # Instead, just verify the function accepts the parameter and attempts the search + $command = Get-Command Get-PSBuildCertificate + $command.Parameters['CertificateSource'].Attributes.ValidValues | Should -Contain 'Store' + + # If no cert found, should return $null (not throw) + { Get-PSBuildCertificate -CertificateSource Store -ErrorAction SilentlyContinue } | Should -Not -Throw + } + + It 'Returns $null when no valid certificate is found' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { } + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Filters out expired certificates' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { + # Return nothing (expired cert is filtered by Where-Object) + } + + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Filters out certificates without a private key' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { + # Return nothing (cert without private key is filtered by Where-Object) + } + + $cert = Get-PSBuildCertificate -CertificateSource Store + $cert | Should -BeNullOrEmpty + } + + It 'Uses custom CertStoreLocation when specified' -Skip:(-not $IsWindows) { + # Just verify the parameter is accepted + { Get-PSBuildCertificate -CertificateSource Store -CertStoreLocation 'Cert:\LocalMachine\My' -ErrorAction SilentlyContinue } | + Should -Not -Throw + } + } + + Context 'Thumbprint mode' { + It 'Searches for a certificate with the specified thumbprint' -Skip:(-not $IsWindows) { + $testThumbprint = 'ABCD1234EFGH5678' + # Verify the function accepts the thumbprint parameter + { Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint $testThumbprint -ErrorAction SilentlyContinue } | + Should -Not -Throw + } + + It 'Returns $null when the specified thumbprint is not found' -Skip:(-not $IsWindows) { + Mock Get-ChildItem { } + $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'NOTFOUND123' + $cert | Should -BeNullOrEmpty + } + } + + Context 'EnvVar mode' { + It 'Attempts to decode a Base64-encoded PFX from environment variable' { + # Create a minimal mock certificate data (will fail to parse, but that's expected) + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) + + # This should fail because the data is not a valid PFX, but that proves it's trying to load it + { Get-PSBuildCertificate -CertificateSource EnvVar -ErrorAction Stop } | Should -Throw + } + + It 'Uses custom environment variable names when specified' { + $env:MY_CUSTOM_CERT = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) + $env:MY_CUSTOM_PASS = 'password' + + try { + Get-PSBuildCertificate -CertificateSource EnvVar ` + -CertificateEnvVar 'MY_CUSTOM_CERT' ` + -CertificatePasswordEnvVar 'MY_CUSTOM_PASS' ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid certificate data + } + + # Cleanup + Remove-Item env:\MY_CUSTOM_CERT -ErrorAction SilentlyContinue + Remove-Item env:\MY_CUSTOM_PASS -ErrorAction SilentlyContinue + } + } + + Context 'PfxFile mode' { + It 'Accepts a PfxFilePath parameter' { + $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' + New-Item -Path $testPfxPath -ItemType File -Force | Out-Null + + try { + Get-PSBuildCertificate -CertificateSource PfxFile ` + -PfxFilePath $testPfxPath ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid PFX file + } + + # Just verify the parameter is accepted + { Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath $testPfxPath -ErrorAction Stop } | + Should -Throw + } + + It 'Accepts a PfxFilePassword parameter' { + $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' + New-Item -Path $testPfxPath -ItemType File -Force | Out-Null + $securePassword = ConvertTo-SecureString -String 'password' -AsPlainText -Force + + try { + Get-PSBuildCertificate -CertificateSource PfxFile ` + -PfxFilePath $testPfxPath ` + -PfxFilePassword $securePassword ` + -ErrorAction SilentlyContinue + } catch { + # Expected to fail with invalid PFX file + } + + # Just verify the parameters are accepted + $testPfxPath | Should -Exist + } + } + + Context 'Parameter validation' { + It 'ValidateSet accepts valid CertificateSource values' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertificateSource'] + $validValues = $parameter.Attributes.ValidValues + $validValues | Should -Contain 'Auto' + $validValues | Should -Contain 'Store' + $validValues | Should -Contain 'Thumbprint' + $validValues | Should -Contain 'EnvVar' + $validValues | Should -Contain 'PfxFile' + } + + It 'Has correct default value for CertStoreLocation' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertStoreLocation'] + $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | + Should -BeFalse + } + + It 'Has correct default value for CertificateEnvVar' { + $command = Get-Command Get-PSBuildCertificate + $parameter = $command.Parameters['CertificateEnvVar'] + $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | + Should -BeFalse + } + } + } +} diff --git a/tests/Invoke-PSBuildModuleSigning.tests.ps1 b/tests/Invoke-PSBuildModuleSigning.tests.ps1 new file mode 100644 index 0000000..f167b88 --- /dev/null +++ b/tests/Invoke-PSBuildModuleSigning.tests.ps1 @@ -0,0 +1,114 @@ +# spell-checker:ignore SIGNCERTIFICATE CERTIFICATEPASSWORD codesign pfxfile +Describe 'Code Signing Functions' { + + BeforeAll { + $script:moduleName = 'PowerShellBuild' + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force + + # Create a temporary directory for test files + $script:testPath = Join-Path -Path $TestDrive -ChildPath 'SigningTest' + New-Item -Path $script:testPath -ItemType Directory -Force | Out-Null + } + + Context 'Invoke-PSBuildModuleSigning' { + + It 'Should exist and be exported' { + Get-Command Invoke-PSBuildModuleSigning -Module PowerShellBuild -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'Has a SYNOPSIS section in the help' { + (Get-Help Invoke-PSBuildModuleSigning).Synopsis | + Should -Not -BeNullOrEmpty + } + + It 'Has at least one EXAMPLE section in the help' { + (Get-Help Invoke-PSBuildModuleSigning).Examples.Example | + Should -Not -BeNullOrEmpty + } + + It 'Requires Path parameter' { + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters['Path'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Requires Certificate parameter' { + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters['Certificate'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Validates that Path must be a directory' { + $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' + New-Item -Path $testFilePath -ItemType File -Force | Out-Null + + $mockCert = [PSCustomObject]@{ Subject = 'CN=Test' } + + { Invoke-PSBuildModuleSigning -Path $testFilePath -Certificate $mockCert } | + Should -Throw + } + + It 'Searches for files matching Include patterns' -Skip:(-not $IsWindows) { + # Create test files + $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest' + New-Item -Path $testDir -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.ps1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.txt') + + Mock Set-AuthenticodeSignature { + [PSCustomObject]@{ Status = 'Valid'; Path = $InputObject } + } + + # We need to skip this test if we can't create a real cert, or just verify file discovery + # Instead of mocking cert, just count the files that would be signed + $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1', '*.psm1', '*.ps1' + $files.Count | Should -Be 3 # Should not include .txt file + } + + It 'Uses custom Include patterns when specified' -Skip:(-not $IsWindows) { + $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest2' + New-Item -Path $testDir -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') + 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') + + # Just verify file discovery with custom Include pattern + $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1' + $files.Count | Should -Be 1 # Only .psd1 + } + + It 'Accepts TimestampServer and HashAlgorithm parameters' { + # Just verify parameters are accepted without error + $command = Get-Command Invoke-PSBuildModuleSigning + $command.Parameters.ContainsKey('TimestampServer') | Should -BeTrue + $command.Parameters.ContainsKey('HashAlgorithm') | Should -BeTrue + $command.Parameters['TimestampServer'].ParameterType.Name | Should -Be 'String' + $command.Parameters['HashAlgorithm'].ParameterType.Name | Should -Be 'String' + } + + It 'Has correct default values' { + $command = Get-Command Invoke-PSBuildModuleSigning + # Check default timestamp server + $tsParam = $command.Parameters['TimestampServer'] + $tsParam | Should -Not -BeNullOrEmpty + # Check default hash algorithm + $hashParam = $command.Parameters['HashAlgorithm'] + $hashParam.Attributes.Where({ $_.TypeId.Name -eq 'ValidateSetAttribute' }).ValidValues | + Should -Contain 'SHA256' + } + + It 'ValidateSet accepts valid HashAlgorithm values' { + $command = Get-Command Invoke-PSBuildModuleSigning + $parameter = $command.Parameters['HashAlgorithm'] + $validValues = $parameter.Attributes.ValidValues + $validValues | Should -Contain 'SHA256' + $validValues | Should -Contain 'SHA384' + $validValues | Should -Contain 'SHA512' + $validValues | Should -Contain 'SHA1' + } + } + +} diff --git a/tests/New-PSBuildFileCatalog.tests.ps1 b/tests/New-PSBuildFileCatalog.tests.ps1 new file mode 100644 index 0000000..a8bda7b --- /dev/null +++ b/tests/New-PSBuildFileCatalog.tests.ps1 @@ -0,0 +1,107 @@ +# spell-checker:ignore SIGNCERTIFICATE CERTIFICATEPASSWORD codesign pfxfile +Describe 'Code Signing Functions' { + + BeforeAll { + $script:moduleName = 'PowerShellBuild' + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force + + # Create a temporary directory for test files + $script:testPath = Join-Path -Path $TestDrive -ChildPath 'SigningTest' + New-Item -Path $script:testPath -ItemType Directory -Force | Out-Null + } + + Context 'New-PSBuildFileCatalog' { + + It 'Should exist and be exported' { + Get-Command New-PSBuildFileCatalog -Module PowerShellBuild -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'Has a SYNOPSIS section in the help' { + (Get-Help New-PSBuildFileCatalog).Synopsis | + Should -Not -BeNullOrEmpty + } + + It 'Has at least one EXAMPLE section in the help' { + (Get-Help New-PSBuildFileCatalog).Examples.Example | + Should -Not -BeNullOrEmpty + } + + It 'Requires ModulePath parameter' { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters['ModulePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Requires CatalogFilePath parameter' { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters['CatalogFilePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | + Should -Contain $true + } + + It 'Validates that ModulePath must be a directory' { + $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' + New-Item -Path $testFilePath -ItemType File -Force | Out-Null + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' + + { New-PSBuildFileCatalog -ModulePath $testFilePath -CatalogFilePath $catalogPath } | + Should -Throw + } + + It 'Accepts CatalogVersion parameter with valid range' { + $command = Get-Command New-PSBuildFileCatalog + $parameter = $command.Parameters['CatalogVersion'] + $validateRange = $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ValidateRangeAttribute' })[0] + $validateRange.MinRange | Should -Be 1 + $validateRange.MaxRange | Should -Be 2 + } + + It 'Calls New-FileCatalog with correct parameters' -Skip:(-not $IsWindows) { + $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest' + New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' + + # Rather than mocking, just test that the function calls New-FileCatalog + # by verifying it works end-to-end (requires Windows) + try { + $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath -CatalogVersion 2 + $result | Should -Not -BeNullOrEmpty + Test-Path $catalogPath | Should -BeTrue + } catch { + # If New-FileCatalog isn't available, just verify the function exists and accepts the params + if ($_.Exception.Message -match 'New-FileCatalog') { + $command = Get-Command New-PSBuildFileCatalog + $command.Parameters.ContainsKey('CatalogVersion') | Should -BeTrue + } + } + } + + It 'Defaults CatalogVersion to 2 (SHA256)' { + $command = Get-Command New-PSBuildFileCatalog + $parameter = $command.Parameters['CatalogVersion'] + # The default should be set in the function, we'll check by the ValidateRange attribute + $parameter | Should -Not -BeNullOrEmpty + } + + It 'Returns a FileInfo object' -Skip:(-not $IsWindows) { + $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest2' + New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null + 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') + $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test2.cat' + + # Test end-to-end on Windows + try { + $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath + $result | Should -BeOfType [System.IO.FileInfo] + } catch { + # If New-FileCatalog isn't available, verify function signature + if ($_.Exception.Message -match 'New-FileCatalog') { + $command = Get-Command New-PSBuildFileCatalog + $command.OutputType.Type.Name | Should -Contain 'FileInfo' + } + } + } + } +} diff --git a/tests/Signing.tests.ps1 b/tests/Signing.tests.ps1 deleted file mode 100644 index 03a34b7..0000000 --- a/tests/Signing.tests.ps1 +++ /dev/null @@ -1,621 +0,0 @@ -# spell-checker:ignore SIGNCERTIFICATE CERTIFICATEPASSWORD codesign pfxfile -Describe 'Code Signing Functions' { - - BeforeAll { - $script:moduleName = 'PowerShellBuild' - $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent - Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force - - # Create a temporary directory for test files - $script:testPath = Join-Path -Path $TestDrive -ChildPath 'SigningTest' - New-Item -Path $script:testPath -ItemType Directory -Force | Out-Null - } - - Context 'Get-PSBuildCertificate' { - - BeforeEach { - # Clear environment variables before each test - Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue - Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue - } - - Context 'Auto mode' { - It 'Defaults to Auto mode when no CertificateSource is specified' -Skip:(-not $IsWindows) { - Mock Get-ChildItem {} - $VerboseOutput = Get-PSBuildCertificate -Verbose -ErrorAction SilentlyContinue 4>&1 - $VerboseOutput | Should -Match "CertificateSource is 'Auto'" - } - - It 'Resolves to EnvVar mode when SIGNCERTIFICATE environment variable is set' { - $env:SIGNCERTIFICATE = 'base64data' - try { - $VerboseOutput = Get-PSBuildCertificate -Verbose -WarningAction SilentlyContinue -ErrorAction SilentlyContinue 4>&1 - $VerboseOutput | Should -Match "Resolved to 'EnvVar'" - } catch { - # Expected to fail with invalid base64, just checking the mode selection - $_.Exception.Message | Should -Not -BeNullOrEmpty - } - } - - It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' -Skip:(-not $IsWindows) { - Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue - Mock Get-ChildItem {} - $VerboseOutput = Get-PSBuildCertificate -Verbose 4>&1 - $VerboseOutput | Should -Match "Resolved to 'Store'" - } - } - - # Store mode only works on Windows - Context 'Store mode' { - It 'Searches the certificate store for a valid code-signing certificate' -Skip:(-not $IsWindows) { - # On Windows, we can test the actual logic without mocking the cert store itself - # Instead, just verify the function accepts the parameter and attempts the search - $command = Get-Command Get-PSBuildCertificate - $command.Parameters['CertificateSource'].Attributes.ValidValues | Should -Contain 'Store' - - # If no cert found, should return $null (not throw) - { Get-PSBuildCertificate -CertificateSource Store -ErrorAction SilentlyContinue } | Should -Not -Throw - } - - It 'Returns $null when no valid certificate is found' -Skip:(-not $IsWindows) { - Mock Get-ChildItem { } - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty - } - - It 'Filters out expired certificates' -Skip:(-not $IsWindows) { - Mock Get-ChildItem { - # Return nothing (expired cert is filtered by Where-Object) - } - - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty - } - - It 'Filters out certificates without a private key' -Skip:(-not $IsWindows) { - Mock Get-ChildItem { - # Return nothing (cert without private key is filtered by Where-Object) - } - - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty - } - - It 'Uses custom CertStoreLocation when specified' -Skip:(-not $IsWindows) { - # Just verify the parameter is accepted - { Get-PSBuildCertificate -CertificateSource Store -CertStoreLocation 'Cert:\LocalMachine\My' -ErrorAction SilentlyContinue } | - Should -Not -Throw - } - } - - Context 'Thumbprint mode' { - It 'Searches for a certificate with the specified thumbprint' -Skip:(-not $IsWindows) { - $testThumbprint = 'ABCD1234EFGH5678' - # Verify the function accepts the thumbprint parameter - { Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint $testThumbprint -ErrorAction SilentlyContinue } | - Should -Not -Throw - } - - It 'Returns $null when the specified thumbprint is not found' { - Mock Get-ChildItem { } - $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'NOTFOUND123' - $cert | Should -BeNullOrEmpty - } - } - - Context 'EnvVar mode' { - It 'Attempts to decode a Base64-encoded PFX from environment variable' { - # Create a minimal mock certificate data (will fail to parse, but that's expected) - $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) - - # This should fail because the data is not a valid PFX, but that proves it's trying to load it - { Get-PSBuildCertificate -CertificateSource EnvVar -ErrorAction Stop } | Should -Throw - } - - It 'Uses custom environment variable names when specified' { - $env:MY_CUSTOM_CERT = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) - $env:MY_CUSTOM_PASS = 'password' - - try { - Get-PSBuildCertificate -CertificateSource EnvVar ` - -CertificateEnvVar 'MY_CUSTOM_CERT' ` - -CertificatePasswordEnvVar 'MY_CUSTOM_PASS' ` - -ErrorAction SilentlyContinue - } catch { - # Expected to fail with invalid certificate data - } - - # Cleanup - Remove-Item env:\MY_CUSTOM_CERT -ErrorAction SilentlyContinue - Remove-Item env:\MY_CUSTOM_PASS -ErrorAction SilentlyContinue - } - } - - Context 'PfxFile mode' { - It 'Accepts a PfxFilePath parameter' { - $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' - New-Item -Path $testPfxPath -ItemType File -Force | Out-Null - - try { - Get-PSBuildCertificate -CertificateSource PfxFile ` - -PfxFilePath $testPfxPath ` - -ErrorAction SilentlyContinue - } catch { - # Expected to fail with invalid PFX file - } - - # Just verify the parameter is accepted - { Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath $testPfxPath -ErrorAction Stop } | - Should -Throw - } - - It 'Accepts a PfxFilePassword parameter' { - $testPfxPath = Join-Path -Path $TestDrive -ChildPath 'test.pfx' - New-Item -Path $testPfxPath -ItemType File -Force | Out-Null - $securePassword = ConvertTo-SecureString -String 'password' -AsPlainText -Force - - try { - Get-PSBuildCertificate -CertificateSource PfxFile ` - -PfxFilePath $testPfxPath ` - -PfxFilePassword $securePassword ` - -ErrorAction SilentlyContinue - } catch { - # Expected to fail with invalid PFX file - } - - # Just verify the parameters are accepted - $testPfxPath | Should -Exist - } - } - - Context 'Parameter validation' { - It 'ValidateSet accepts valid CertificateSource values' { - $command = Get-Command Get-PSBuildCertificate - $parameter = $command.Parameters['CertificateSource'] - $validValues = $parameter.Attributes.ValidValues - $validValues | Should -Contain 'Auto' - $validValues | Should -Contain 'Store' - $validValues | Should -Contain 'Thumbprint' - $validValues | Should -Contain 'EnvVar' - $validValues | Should -Contain 'PfxFile' - } - - It 'Has correct default value for CertStoreLocation' { - $command = Get-Command Get-PSBuildCertificate - $parameter = $command.Parameters['CertStoreLocation'] - $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | - Should -BeFalse - } - - It 'Has correct default value for CertificateEnvVar' { - $command = Get-Command Get-PSBuildCertificate - $parameter = $command.Parameters['CertificateEnvVar'] - $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' })[0].Mandatory | - Should -BeFalse - } - } - } - - Context 'Invoke-PSBuildModuleSigning' { - - It 'Should exist and be exported' { - Get-Command Invoke-PSBuildModuleSigning -Module PowerShellBuild -ErrorAction SilentlyContinue | - Should -Not -BeNullOrEmpty - } - - It 'Has a SYNOPSIS section in the help' { - (Get-Help Invoke-PSBuildModuleSigning).Synopsis | - Should -Not -BeNullOrEmpty - } - - It 'Has at least one EXAMPLE section in the help' { - (Get-Help Invoke-PSBuildModuleSigning).Examples.Example | - Should -Not -BeNullOrEmpty - } - - It 'Requires Path parameter' { - $command = Get-Command Invoke-PSBuildModuleSigning - $command.Parameters['Path'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | - Should -Contain $true - } - - It 'Requires Certificate parameter' { - $command = Get-Command Invoke-PSBuildModuleSigning - $command.Parameters['Certificate'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | - Should -Contain $true - } - - It 'Validates that Path must be a directory' { - $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' - New-Item -Path $testFilePath -ItemType File -Force | Out-Null - - $mockCert = [PSCustomObject]@{ Subject = 'CN=Test' } - - { Invoke-PSBuildModuleSigning -Path $testFilePath -Certificate $mockCert } | - Should -Throw - } - - It 'Searches for files matching Include patterns' -Skip:(-not $IsWindows) { - # Create test files - $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest' - New-Item -Path $testDir -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.ps1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.txt') - - Mock Set-AuthenticodeSignature { - [PSCustomObject]@{ Status = 'Valid'; Path = $InputObject } - } - - # We need to skip this test if we can't create a real cert, or just verify file discovery - # Instead of mocking cert, just count the files that would be signed - $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1', '*.psm1', '*.ps1' - $files.Count | Should -Be 3 # Should not include .txt file - } - - It 'Uses custom Include patterns when specified' -Skip:(-not $IsWindows) { - $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest2' - New-Item -Path $testDir -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') - - # Just verify file discovery with custom Include pattern - $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1' - $files.Count | Should -Be 1 # Only .psd1 - } - - It 'Accepts TimestampServer and HashAlgorithm parameters' { - # Just verify parameters are accepted without error - $command = Get-Command Invoke-PSBuildModuleSigning - $command.Parameters.ContainsKey('TimestampServer') | Should -BeTrue - $command.Parameters.ContainsKey('HashAlgorithm') | Should -BeTrue - $command.Parameters['TimestampServer'].ParameterType.Name | Should -Be 'String' - $command.Parameters['HashAlgorithm'].ParameterType.Name | Should -Be 'String' - } - - It 'Has correct default values' { - $command = Get-Command Invoke-PSBuildModuleSigning - # Check default timestamp server - $tsParam = $command.Parameters['TimestampServer'] - $tsParam | Should -Not -BeNullOrEmpty - # Check default hash algorithm - $hashParam = $command.Parameters['HashAlgorithm'] - $hashParam.Attributes.Where({ $_.TypeId.Name -eq 'ValidateSetAttribute' }).ValidValues | - Should -Contain 'SHA256' - } - - It 'ValidateSet accepts valid HashAlgorithm values' { - $command = Get-Command Invoke-PSBuildModuleSigning - $parameter = $command.Parameters['HashAlgorithm'] - $validValues = $parameter.Attributes.ValidValues - $validValues | Should -Contain 'SHA256' - $validValues | Should -Contain 'SHA384' - $validValues | Should -Contain 'SHA512' - $validValues | Should -Contain 'SHA1' - } - } - - Context 'New-PSBuildFileCatalog' { - - It 'Should exist and be exported' { - Get-Command New-PSBuildFileCatalog -Module PowerShellBuild -ErrorAction SilentlyContinue | - Should -Not -BeNullOrEmpty - } - - It 'Has a SYNOPSIS section in the help' { - (Get-Help New-PSBuildFileCatalog).Synopsis | - Should -Not -BeNullOrEmpty - } - - It 'Has at least one EXAMPLE section in the help' { - (Get-Help New-PSBuildFileCatalog).Examples.Example | - Should -Not -BeNullOrEmpty - } - - It 'Requires ModulePath parameter' { - $command = Get-Command New-PSBuildFileCatalog - $command.Parameters['ModulePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | - Should -Contain $true - } - - It 'Requires CatalogFilePath parameter' { - $command = Get-Command New-PSBuildFileCatalog - $command.Parameters['CatalogFilePath'].Attributes.Where({ $_.TypeId.Name -eq 'ParameterAttribute' }).Mandatory | - Should -Contain $true - } - - It 'Validates that ModulePath must be a directory' { - $testFilePath = Join-Path -Path $TestDrive -ChildPath 'testfile.txt' - New-Item -Path $testFilePath -ItemType File -Force | Out-Null - $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' - - { New-PSBuildFileCatalog -ModulePath $testFilePath -CatalogFilePath $catalogPath } | - Should -Throw - } - - It 'Accepts CatalogVersion parameter with valid range' { - $command = Get-Command New-PSBuildFileCatalog - $parameter = $command.Parameters['CatalogVersion'] - $validateRange = $parameter.Attributes.Where({ $_.TypeId.Name -eq 'ValidateRangeAttribute' })[0] - $validateRange.MinRange | Should -Be 1 - $validateRange.MaxRange | Should -Be 2 - } - - It 'Calls New-FileCatalog with correct parameters' -Skip:(-not $IsWindows) { - $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest' - New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') - $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test.cat' - - # Rather than mocking, just test that the function calls New-FileCatalog - # by verifying it works end-to-end (requires Windows) - try { - $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath -CatalogVersion 2 - $result | Should -Not -BeNullOrEmpty - Test-Path $catalogPath | Should -BeTrue - } catch { - # If New-FileCatalog isn't available, just verify the function exists and accepts the params - if ($_.Exception.Message -match 'New-FileCatalog') { - $command = Get-Command New-PSBuildFileCatalog - $command.Parameters.ContainsKey('CatalogVersion') | Should -BeTrue - } - } - } - - It 'Defaults CatalogVersion to 2 (SHA256)' { - $command = Get-Command New-PSBuildFileCatalog - $parameter = $command.Parameters['CatalogVersion'] - # The default should be set in the function, we'll check by the ValidateRange attribute - $parameter | Should -Not -BeNullOrEmpty - } - - It 'Returns a FileInfo object' -Skip:(-not $IsWindows) { - $testModulePath = Join-Path -Path $TestDrive -ChildPath 'CatalogTest2' - New-Item -Path $testModulePath -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testModulePath 'test.ps1') - $catalogPath = Join-Path -Path $TestDrive -ChildPath 'test2.cat' - - # Test end-to-end on Windows - try { - $result = New-PSBuildFileCatalog -ModulePath $testModulePath -CatalogFilePath $catalogPath - $result | Should -BeOfType [System.IO.FileInfo] - } catch { - # If New-FileCatalog isn't available, verify function signature - if ($_.Exception.Message -match 'New-FileCatalog') { - $command = Get-Command New-PSBuildFileCatalog - $command.OutputType.Type.Name | Should -Contain 'FileInfo' - } - } - } - } - - Context 'Integration - Sign workflow' { - - It 'Functions are designed to work together in the recommended order' { - # This is more of a documentation test - verify functions exist with expected signatures - Get-Command Get-PSBuildCertificate | Should -Not -BeNullOrEmpty - Get-Command Invoke-PSBuildModuleSigning | Should -Not -BeNullOrEmpty - Get-Command New-PSBuildFileCatalog | Should -Not -BeNullOrEmpty - - # Verify the workflow can be constructed - $getCertCmd = Get-Command Get-PSBuildCertificate - $getCertCmd.OutputType.Type.Name | Should -Contain 'X509Certificate2' - - $signCmd = Get-Command Invoke-PSBuildModuleSigning - $signCmd.Parameters['Certificate'].ParameterType.Name | Should -Be 'X509Certificate2' - - $catalogCmd = Get-Command New-PSBuildFileCatalog - $catalogCmd.OutputType.Type.Name | Should -Contain 'FileInfo' - } - } -} - -Describe 'Code Signing Tasks' { - - BeforeAll { - $script:moduleName = 'PowerShellBuild' - $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent - - # Import the module from output directory - Import-Module ([IO.Path]::Combine($script:moduleRoot, 'Output', $script:moduleName)) -Force - - # Load psake - if (-not (Get-Module -Name psake -ListAvailable)) { - Write-Warning "psake module not found. Skipping task tests." - return - } - Import-Module psake -Force - } - - Context 'psake tasks' { - - It 'SignModule task should be defined' { - $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' - $psakeFile | Should -Exist - $content = Get-Content -Path $psakeFile -Raw - $content | Should -Match 'Task\s+SignModule' - } - - It 'BuildCatalog task should be defined' { - $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' - $content = Get-Content -Path $psakeFile -Raw - $content | Should -Match 'Task\s+BuildCatalog' - } - - It 'SignCatalog task should be defined' { - $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' - $content = Get-Content -Path $psakeFile -Raw - $content | Should -Match 'Task\s+SignCatalog' - } - - It 'Sign meta task should be defined' { - $psakeFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\psakeFile.ps1' - $content = Get-Content -Path $psakeFile -Raw - $content | Should -Match 'Task\s+Sign' - } - } - - Context 'Invoke-Build tasks' { - - It 'SignModule task should be defined in IB.tasks.ps1' { - $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' - $ibTasksFile | Should -Exist - $content = Get-Content -Path $ibTasksFile -Raw - $content | Should -Match 'task\s+SignModule' - } - - It 'BuildCatalog task should be defined in IB.tasks.ps1' { - $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' - $content = Get-Content -Path $ibTasksFile -Raw - $content | Should -Match 'task\s+BuildCatalog' - } - - It 'SignCatalog task should be defined in IB.tasks.ps1' { - $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' - $content = Get-Content -Path $ibTasksFile -Raw - $content | Should -Match 'task\s+SignCatalog' - } - - It 'Sign meta task should be defined in IB.tasks.ps1' { - $ibTasksFile = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\IB.tasks.ps1' - $content = Get-Content -Path $ibTasksFile -Raw - $content | Should -Match 'task\s+Sign' - } - } -} - -Describe 'Code Signing Configuration' { - - BeforeAll { - $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent - $script:buildPropertiesPath = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\build.properties.ps1' - } - - Context '$PSBPreference.Sign configuration' { - - BeforeAll { - # Load config once for all tests in this context - # build.properties.ps1 will call Set-BuildEnvironment internally - $script:config = & $script:buildPropertiesPath - } - - It 'Sign section should exist in $PSBPreference' { - $script:config.Sign | Should -Not -BeNullOrEmpty - } - - It 'Sign.Enabled should default to $false' { - $script:config.Sign.Enabled | Should -Be $false - } - - It 'Sign.CertificateSource should default to Auto' { - $script:config.Sign.CertificateSource | Should -Be 'Auto' - } - - It 'Sign.CertStoreLocation should have a default value' { - $script:config.Sign.CertStoreLocation | Should -Not -BeNullOrEmpty - $script:config.Sign.CertStoreLocation | Should -Match 'Cert:' - } - - It 'Sign.CertificateEnvVar should default to SIGNCERTIFICATE' { - $script:config.Sign.CertificateEnvVar | Should -Be 'SIGNCERTIFICATE' - } - - It 'Sign.CertificatePasswordEnvVar should default to CERTIFICATEPASSWORD' { - $script:config.Sign.CertificatePasswordEnvVar | Should -Be 'CERTIFICATEPASSWORD' - } - - It 'Sign.TimestampServer should have a default value' { - $script:config.Sign.TimestampServer | Should -Not -BeNullOrEmpty - $script:config.Sign.TimestampServer | Should -Match '^https?://' - } - - It 'Sign.HashAlgorithm should default to SHA256' { - $script:config.Sign.HashAlgorithm | Should -Be 'SHA256' - } - - It 'Sign.FilesToSign should include common PowerShell file extensions' { - $script:config.Sign.FilesToSign | Should -Contain '*.psd1' - $script:config.Sign.FilesToSign | Should -Contain '*.psm1' - $script:config.Sign.FilesToSign | Should -Contain '*.ps1' - } - - It 'Sign.Catalog section should exist' { - $script:config.Sign.Catalog | Should -Not -BeNullOrEmpty - } - - It 'Sign.Catalog.Enabled should default to $false' { - $script:config.Sign.Catalog.Enabled | Should -Be $false - } - - It 'Sign.Catalog.Version should default to 2 (SHA256)' { - $script:config.Sign.Catalog.Version | Should -Be 2 - } - - It 'Sign.Catalog.FileName should be $null by default' { - $script:config.Sign.Catalog.FileName | Should -BeNullOrEmpty - } - } -} - -Describe 'Localized Messages' { - - BeforeAll { - $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent - $script:messagesPath = Join-Path -Path $script:moduleRoot -ChildPath 'PowerShellBuild\en-US\Messages.psd1' - } - - Context 'Signing-related messages' { - - BeforeAll { - # Load the messages file by dot-sourcing it - $messagesContent = Get-Content -Path $script:messagesPath -Raw - # Extract the ConvertFrom-StringData content - if ($messagesContent -match "ConvertFrom-StringData @'([\s\S]*?)'@") { - $stringData = $matches[1] - $messages = ConvertFrom-StringData -StringData $stringData - } else { - throw "Could not parse Messages.psd1" - } - } - - It 'Should have NoCertificateFound message' { - $messages.NoCertificateFound | Should -Not -BeNullOrEmpty - } - - It 'Should have CertificateResolvedFromStore message' { - $messages.CertificateResolvedFromStore | Should -Not -BeNullOrEmpty - $messages.CertificateResolvedFromStore | Should -Match '\{0\}' - } - - It 'Should have CertificateResolvedFromThumbprint message' { - $messages.CertificateResolvedFromThumbprint | Should -Not -BeNullOrEmpty - $messages.CertificateResolvedFromThumbprint | Should -Match '\{0\}' - } - - It 'Should have CertificateResolvedFromEnvVar message' { - $messages.CertificateResolvedFromEnvVar | Should -Not -BeNullOrEmpty - $messages.CertificateResolvedFromEnvVar | Should -Match '\{0\}' - } - - It 'Should have CertificateResolvedFromPfxFile message' { - $messages.CertificateResolvedFromPfxFile | Should -Not -BeNullOrEmpty - $messages.CertificateResolvedFromPfxFile | Should -Match '\{0\}' - } - - It 'Should have SigningModuleFiles message' { - $messages.SigningModuleFiles | Should -Not -BeNullOrEmpty - $messages.SigningModuleFiles | Should -Match '\{0\}' - } - - It 'Should have CreatingFileCatalog message' { - $messages.CreatingFileCatalog | Should -Not -BeNullOrEmpty - $messages.CreatingFileCatalog | Should -Match '\{0\}' - } - - It 'Should have FileCatalogCreated message' { - $messages.FileCatalogCreated | Should -Not -BeNullOrEmpty - $messages.FileCatalogCreated | Should -Match '\{0\}' - } - } -}