DSCResources/MSFT_xPackageResource/MSFT_xPackageResource.psm1
# Suppress Global Vars PSSA Error because $global:DSCMachineStatus must be allowed [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] param() data LocalizedData { # culture='en-US' # TODO: Support WhatIf ConvertFrom-StringData -StringData @' InvalidIdentifyingNumber = The specified IdentifyingNumber ({0}) is not a valid Guid InvalidPath = The specified Path ({0}) is not in a valid format. Valid formats are local paths, UNC, and HTTP InvalidNameOrId = The specified Name ({0}) and IdentifyingNumber ({1}) do not match Name ({2}) and IdentifyingNumber ({3}) in the MSI file NeedsMoreInfo = Either Name or ProductId is required InvalidBinaryType = The specified Path ({0}) does not appear to specify an EXE or MSI file and as such is not supported CouldNotOpenLog = The specified LogPath ({0}) could not be opened CouldNotStartProcess = The process {0} could not be started UnexpectedReturnCode = The return code {0} was not expected. Configuration is likely not correct PathDoesNotExist = The given Path ({0}) could not be found CouldNotOpenDestFile = Could not open the file {0} for writing CouldNotGetHttpStream = Could not get the {0} stream for file {1} ErrorCopyingDataToFile = Encountered error while writing the contents of {0} to {1} PackageConfigurationComplete = Package configuration finished PackageConfigurationStarting = Package configuration starting InstalledPackage = Installed package UninstalledPackage = Uninstalled package NoChangeRequired = Package found in desired state, no action required RemoveExistingLogFile = Remove existing log file CreateLogFile = Create log file MountSharePath = Mount share to get media DownloadHTTPFile = Download the media over HTTP or HTTPS StartingProcessMessage = Starting process {0} with arguments {1} RemoveDownloadedFile = Remove the downloaded file PackageInstalled = Package has been installed PackageUninstalled = Package has been uninstalled MachineRequiresReboot = The machine requires a reboot PackageDoesNotAppearInstalled = The package {0} is not installed PackageAppearsInstalled = The package {0} is installed PostValidationError = Package from {0} was installed, but the specified ProductId and/or Name does not match package details CheckingFileHash = Checking file '{0}' for expected {2} hash value of {1} InvalidFileHash = File '{0}' does not match expected {2} hash value of {1}. CheckingFileSignature = Checking file '{0}' for valid digital signature. FileHasValidSignature = File '{0}' contains a valid digital signature. Signer Thumbprint: {1}, Subject: {2} InvalidFileSignature = File '{0}' does not have a valid Authenticode signature. Status: {1} WrongSignerSubject = File '{0}' was not signed by expected signer subject '{1}' WrongSignerThumbprint = File '{0}' was not signed by expected signer certificate thumbprint '{1}' CreatingRegistryValue = Creating package registry value of {0}. RemovingRegistryValue = Removing package registry value of {0}. ValidateStandardArgumentsPathwasPath = Validate-StandardArguments, Path was {0} TheurischemewasuriScheme = The uri scheme was {0} ThepathextensionwaspathExt = The path extension was {0} ParsingProductIdasanidentifyingNumber = Parsing {0} as an identifyingNumber ParsedProductIdasidentifyingNumber = Parsed {0} as {1} EnsureisEnsure = Ensure is {0} productisproduct = product {0} found productasbooleanis = product as boolean is {0} Creatingcachelocation = Creating cache location NeedtodownloadfilefromschemedestinationwillbedestName = Need to download file from {0}, destination will be {1} Creatingthedestinationcachefile = Creating the destination cache file Creatingtheschemestream = Creating the {0} stream Settingdefaultcredential = Setting default credential Settingauthenticationlevel = Setting authentication level Ignoringbadcertificates = Ignoring bad certificates Gettingtheschemeresponsestream = Getting the {0} response stream ErrorOutString = Error: {0} Copyingtheschemestreambytestothediskcache = Copying the {0} stream bytes to the disk cache Redirectingpackagepathtocachefilelocation = Redirecting package path to cache file location ThebinaryisanEXE = The binary is an EXE Userhasrequestedloggingneedtoattacheventhandlerstotheprocess = User has requested logging, need to attach event handlers to the process StartingwithstartInfoFileNamestartInfoArguments = Starting {0} with {1} ProvideParameterForRegistryCheck = Please provide the {0} parameter in order to check for installation status from a registry key. ErrorSettingRegistryValue = An error occured while attempting to set the registry key {0} value {1} to {2} ErrorRemovingRegistryValue = An error occured while attempting to remove the registry key {0} value {1} ExeCouldNotBeUninstalled = The .exe file found at {0} could not be uninstalled. The uninstall functionality may not be implemented in this .exe file. '@ } # Commented-out until more languages are supported # Import-LocalizedData -BindingVariable 'LocalizedData' -FileName 'MSFT_xPackageResource.strings.psd1' Import-Module -Name "$PSScriptRoot\..\CommonResourceHelper.psm1" -Force $script:packageCacheLocation = "$env:programData\Microsoft\Windows\PowerShell\Configuration\BuiltinProvCache\MSFT_xPackageResource" $script:msiTools = $null <# .SYNOPSIS Asserts that the path extension is valid. .PARAMETER Path The path to validate the extension of. #> function Assert-PathExtensionValid { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path ) $pathExtension = [System.IO.Path]::GetExtension($Path) Write-Verbose -Message ($LocalizedData.ThePathExtensionWasPathExt -f $pathExtension) $validPathExtensions = @( '.msi', '.exe' ) if ($validPathExtensions -notcontains $pathExtension.ToLower()) { New-InvalidArgumentException -ArgumentName 'Path' -Message ($LocalizedData.InvalidBinaryType -f $Path) } } <# .SYNOPSIS Retrieves the product ID as an identifying number. .PARAMETER ProductId The product id to retrieve as an identifying number. #> function Convert-ProductIdToIdentifyingNumber { [OutputType([String])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $ProductId ) try { Write-Verbose -Message ($LocalizedData.ParsingProductIdAsAnIdentifyingNumber -f $ProductId) $identifyingNumber = "{{{0}}}" -f [Guid]::Parse($ProductId).ToString().ToUpper() Write-Verbose -Message ($LocalizedData.ParsedProductIdAsIdentifyingNumber -f $ProductId, $identifyingNumber) return $identifyingNumber } catch { New-InvalidArgumentException -ArgumentName 'ProductId' -Message ($LocalizedData.InvalidIdentifyingNumber -f $ProductId) } } <# .SYNOPSIS Converts the given path to a URI. Throws an exception if the path's scheme as a URI is not valid. .PARAMETER Path The path to retrieve as a URI. #> function Convert-PathToUri { [OutputType([Uri])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path ) try { $uri = [Uri] $Path } catch { New-InvalidArgumentException -ArgumentName 'Path' -Message ($LocalizedData.InvalidPath -f $Path) } $validUriSchemes = @( 'file', 'http', 'https' ) if ($validUriSchemes -notcontains $uri.Scheme) { Write-Verbose -Message ($Localized.TheUriSchemeWasUriScheme -f $uri.Scheme) New-InvalidArgumentException -ArgumentName 'Path' -Message ($LocalizedData.InvalidPath -f $Path) } return $uri } <# .SYNOPSIS Retrieves a value from a registry without throwing errors. .PARAMETER Key The key of the registry to get the value from. .PARAMETER Value The name of the value to retrieve. .PARAMETER RegistryHive The registry hive to retrieve the value from. .PARAMETER RegistyView The registry view to retrieve the value from. #> function Get-RegistryValueWithErrorsIgnored { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Key, [Parameter(Mandatory = $true)] [String] $Value, [Parameter(Mandatory = $true)] [Microsoft.Win32.RegistryHive] $RegistryHive, [Parameter(Mandatory = $true)] [Microsoft.Win32.RegistryView] $RegistryView ) $registryValue = $null try { $baseRegistryKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($RegistryHive, $RegistryView) $subRegistryKey = $baseRegistryKey.OpenSubKey($Key) if ($null -ne $subRegistryKey) { $registryValue = $subRegistryKey.GetValue($Value) } } catch { $exceptionText = ($_ | Out-String).Trim() Write-Verbose -Message "An exception occured while attempting to retrieve a registry value: $exceptionText" } return $registryValue } <# .SYNOPSIS Retrieves the product entry for the package with the given name and/or identifying number. .PARAMETER Name The name of the product entry to retrieve. .PARAMETER CreateCheckRegValue Indicates whether or not to retrieve the package installation status from a registry. .PARAMETER IdentifyingNumber The identifying number of the product entry to retrieve. .PARAMETER InstalledCheckRegHive The registry hive to check for package installation status. .PARAMETER InstalledCheckRegKey The registry key to open to check for package installation status. .PARAMETER InstalledCheckRegValueName The registry value name to check for package installation status. .PARAMETER InstalledCheckRegValueData The value to compare against the retrieved registry value to check for package installation. #> function Get-ProductEntry { [CmdletBinding()] param ( [String] $Name, [String] $IdentifyingNumber, [Switch] $CreateCheckRegValue, [ValidateSet('LocalMachine', 'CurrentUser')] [String] $InstalledCheckRegHive = 'LocalMachine', [String] $InstalledCheckRegKey, [String] $InstalledCheckRegValueName, [String] $InstalledCheckRegValueData ) $uninstallRegistryKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' $uninstallRegistryKeyWow64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' $productEntry = $null if (-not [String]::IsNullOrEmpty($IdentifyingNumber)) { $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKey -ChildPath $IdentifyingNumber $productEntry = Get-Item -Path $productEntryKeyLocation -ErrorAction 'SilentlyContinue' if ($null -eq $productEntry) { $productEntryKeyLocation = Join-Path -Path $uninstallRegistryKeyWow64 -ChildPath $IdentifyingNumber $productEntry = Get-Item $productEntryKeyLocation -ErrorAction 'SilentlyContinue' } } else { foreach ($registryKeyEntry in (Get-ChildItem -Path @( $uninstallRegistryKey, $uninstallRegistryKeyWow64) -ErrorAction 'Ignore' )) { if ($Name -eq (Get-LocalizedRegistryKeyValue -RegistryKey $registryKeyEntry -ValueName 'DisplayName')) { $productEntry = $registryKeyEntry break } } } if ($null -eq $productEntry) { if ($CreateCheckRegValue) { $installValue = $null $win32OperatingSystem = Get-CimInstance -ClassName 'Win32_OperatingSystem' -ErrorAction 'SilentlyContinue' # If 64-bit OS, check 64-bit registry view first if ($win32OperatingSystem.OSArchitecture -ieq '64-bit') { $installValue = Get-RegistryValueWithErrorsIgnored -Key $InstalledCheckRegKey -Value $InstalledCheckRegValueName -RegistryHive $InstalledCheckRegHive -RegistryView 'Registry64' } if ($null -eq $installValue) { $installValue = Get-RegistryValueWithErrorsIgnored -Key $InstalledCheckRegKey -Value $InstalledCheckRegValueName -RegistryHive $InstalledCheckRegHive -RegistryView 'Registry32' } if ($null -ne $installValue) { if ($InstalledCheckRegValueData -and $installValue -eq $InstalledCheckRegValueData) { $productEntry = @{ Installed = $true } } } } } return $productEntry } function Test-TargetResource { [OutputType([Boolean])] [CmdletBinding()] param ( [ValidateSet('Present', 'Absent')] [String] $Ensure = 'Present', [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $Name, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path, [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $ProductId, [String] $Arguments, [PSCredential] [System.Management.Automation.Credential()] $Credential, # Return codes 1641 and 3010 indicate success when a restart is requested per installation [ValidateNotNullOrEmpty()] [UInt32[]] $ReturnCode = @( 0, 1641, 3010 ), [String] $LogPath, [String] $FileHash, [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')] [String] $HashAlgorithm, [String] $SignerSubject, [String] $SignerThumbprint, [String] $ServerCertificateValidationCallback, [Boolean] $CreateCheckRegValue = $false, [ValidateSet('LocalMachine','CurrentUser')] [String] $InstalledCheckRegHive = 'LocalMachine', [String] $InstalledCheckRegKey, [String] $InstalledCheckRegValueName, [String] $InstalledCheckRegValueData ) Assert-PathExtensionValid -Path $Path $uri = Convert-PathToUri -Path $Path if (-not [String]::IsNullOrEmpty($ProductId)) { $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId } $getProductEntryParameters = @{ Name = $Name IdentifyingNumber = $identifyingNumber } $checkRegistryValueParameters = @{ CreateCheckRegValue = $CreateCheckRegValue InstalledCheckRegHive = $InstalledCheckRegHive InstalledCheckRegKey = $InstalledCheckRegKey InstalledCheckRegValueName = $InstalledCheckRegValueName InstalledCheckRegValueData = $InstalledCheckRegValueData } if ($CreateCheckRegValue) { Assert-RegistryParametersValid -InstalledCheckRegKey $InstalledCheckRegKey -InstalledCheckRegValueName $InstalledCheckRegValueName -InstalledCheckRegValueData $InstalledCheckRegValueData $getProductEntryParameters += $checkRegistryValueParameters } $productEntry = Get-ProductEntry @getProductEntryParameters Write-Verbose -Message ($LocalizedData.EnsureIsEnsure -f $Ensure) if ($null -eq $productEntry) { Write-Verbose -Message ($LocalizedData.ProductIsProduct -f $productEntry) } else { Write-Verbose -Message 'Product installation cannot be determined' } Write-Verbose -Message ($LocalizedData.ProductAsBooleanIs -f [Boolean]$productEntry) if ($null -ne $productEntry) { if ($CreateCheckRegValue) { Write-Verbose -Message ($LocalizedData.PackageAppearsInstalled -f $Name) } else { $displayName = Get-LocalizedRegistryKeyValue -RegistryKey $productEntry -ValueName 'DisplayName' Write-Verbose -Message ($LocalizedData.PackageAppearsInstalled -f $displayName) } } else { $displayName = $null if (-not [String]::IsNullOrEmpty($Name)) { $displayName = $Name } else { $displayName = $ProductId } Write-Verbose -Message ($LocalizedData.PackageDoesNotAppearInstalled -f $displayName) } return ($null -ne $productEntry -and $Ensure -eq 'Present') -or ($null -eq $productEntry -and $Ensure -eq 'Absent') } <# .SYNOPSIS Retrieves a localized registry key value. .PARAMETER RegistryKey The registry key to retrieve the value from. .PARAMETER ValueName The name of the value to retrieve. #> function Get-LocalizedRegistryKeyValue { [CmdletBinding()] param ( [Object] $RegistryKey, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $ValueName ) $localizedRegistryKeyValue = $RegistryKey.GetValue('{0}_Localized' -f $ValueName) if ($null -eq $localizedRegistryKeyValue) { $localizedRegistryKeyValue = $RegistryKey.GetValue($ValueName) } return $localizedRegistryKeyValue } function Get-TargetResource { [OutputType([Hashtable])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $Name, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path, [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $ProductId, [Boolean] $CreateCheckRegValue = $false, [ValidateSet('LocalMachine','CurrentUser')] [String] $InstalledCheckRegHive = 'LocalMachine', [String] $InstalledCheckRegKey, [String] $InstalledCheckRegValueName, [String] $InstalledCheckRegValueData ) Assert-PathExtensionValid -Path $Path $uri = Convert-PathToUri -Path $Path if (-not [String]::IsNullOrEmpty($ProductId)) { $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId } else { $identifyingNumber = $ProductId } $packageResourceResult = @{} $getProductEntryParameters = @{ Name = $Name IdentifyingNumber = $identifyingNumber } $checkRegistryValueParameters = @{ CreateCheckRegValue = $CreateCheckRegValue InstalledCheckRegHive = $InstalledCheckRegHive InstalledCheckRegKey = $InstalledCheckRegKey InstalledCheckRegValueName = $InstalledCheckRegValueName InstalledCheckRegValueData = $InstalledCheckRegValueData } if ($CreateCheckRegValue) { Assert-RegistryParametersValid -InstalledCheckRegKey $InstalledCheckRegKey -InstalledCheckRegValueName $InstalledCheckRegValueName -InstalledCheckRegValueData $InstalledCheckRegValueData $getProductEntryParameters += $checkRegistryValueParameters $packageResourceResult += $checkRegistryValueParameters } $productEntry = Get-ProductEntry @getProductEntryParameters if ($null -eq $productEntry) { $packageResourceResult += @{ Ensure = 'Absent' Name = $Name ProductId = $identifyingNumber Path = $Path Installed = $false } return $packageResourceResult } elseif ($CreateCheckRegValue) { $packageResourceResult += @{ Ensure = 'Present' Name = $Name ProductId = $identifyingNumber Path = $Path Installed = $true } return $packageResourceResult } <# Identifying number can still be null here (e.g. remote MSI with Name specified, local EXE). If the user gave a product ID just pass it through, otherwise get it from the product. #> if ($null -eq $identifyingNumber -and $null -ne $productEntry.Name) { $identifyingNumber = Split-Path -Path $productEntry.Name -Leaf } $installDate = $productEntry.GetValue('InstallDate') if ($null -ne $installDate) { try { $installDate = '{0:d}' -f [DateTime]::ParseExact($installDate, 'yyyyMMdd',[System.Globalization.CultureInfo]::CurrentCulture).Date } catch { $installDate = $null } } $publisher = Get-LocalizedRegistryKeyValue -RegistryKey $productEntry -ValueName 'Publisher' $estimatedSize = $productEntry.GetValue('EstimatedSize') if ($null -ne $estimatedSize) { $estimatedSize = $estimatedSize / 1024 } $displayVersion = $productEntry.GetValue('DisplayVersion') $comments = $productEntry.GetValue('Comments') $displayName = Get-LocalizedRegistryKeyValue -RegistryKey $productEntry -ValueName 'DisplayName' $packageResourceResult += @{ Ensure = 'Present' Name = $displayName Path = $Path InstalledOn = $installDate ProductId = $identifyingNumber Size = $estimatedSize Installed = $true Version = $displayVersion PackageDescription = $comments Publisher = $publisher } return $packageResourceResult } <# .SYNOPSIS Retrieves the MSI tools type. #> function Get-MsiTool { [OutputType([System.Type])] [CmdletBinding()] param () if ($null -ne $script:msiTools) { return $script:msiTools } $msiToolsCodeDefinition = @' [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] private static extern UInt32 MsiOpenPackageExW(string szPackagePath, int dwOptions, out IntPtr hProduct); [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] private static extern uint MsiCloseHandle(IntPtr hAny); [DllImport("msi.dll", CharSet = CharSet.Unicode, PreserveSig = true, SetLastError = true, ExactSpelling = true)] private static extern uint MsiGetPropertyW(IntPtr hAny, string name, StringBuilder buffer, ref int bufferLength); private static string GetPackageProperty(string msi, string property) { IntPtr MsiHandle = IntPtr.Zero; try { var res = MsiOpenPackageExW(msi, 1, out MsiHandle); if (res != 0) { return null; } int length = 256; var buffer = new StringBuilder(length); res = MsiGetPropertyW(MsiHandle, property, buffer, ref length); return buffer.ToString(); } finally { if (MsiHandle != IntPtr.Zero) { MsiCloseHandle(MsiHandle); } } } public static string GetProductCode(string msi) { return GetPackageProperty(msi, "ProductCode"); } public static string GetProductName(string msi) { return GetPackageProperty(msi, "ProductName"); } '@ if (([System.Management.Automation.PSTypeName]'Microsoft.Windows.DesiredStateConfiguration.xPackageResource.MsiTools').Type) { $script:msiTools = ([System.Management.Automation.PSTypeName]'Microsoft.Windows.DesiredStateConfiguration.xPackageResource.MsiTools').Type } else { $script:msiTools = Add-Type ` -Namespace 'Microsoft.Windows.DesiredStateConfiguration.xPackageResource' ` -Name 'MsiTools' ` -Using 'System.Text' ` -MemberDefinition $msiToolsCodeDefinition ` -PassThru } return $script:msiTools } <# .SYNOPSIS Retrieves the name of a product from an msi. .PARAMETER Path The path to the msi to retrieve the name from. #> function Get-MsiProductName { [OutputType([String])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path ) $msiTools = Get-MsiTool $productName = $msiTools::GetProductName($Path) return $productName } <# .SYNOPSIS Retrieves the code of a product from an msi. .PARAMETER Path The path to the msi to retrieve the code from. #> function Get-MsiProductCode { [OutputType([String])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path ) $msiTools = Get-MsiTool $productCode = $msiTools::GetProductCode($Path) return $productCode } <# .SYNOPSIS Asserts that the InstalledCheckRegKey, InstalledCheckRegValueName, and InstalledCheckRegValueData parameter required for retrieving package installation status from a registry are not null or empty. .PARAMETER InstalledCheckRegKey The InstalledCheckRegKey parameter to check. .PARAMETER InstalledCheckRegValueName The InstalledCheckRegValueName parameter to check. .PARAMETER InstalledCheckRegValueData The InstalledCheckRegValueData parameter to check. .NOTES This could be done with parameter validation. It is implemented this way to provide a clearer error message. #> function Assert-RegistryParametersValid { [CmdletBinding()] param ( [String] $InstalledCheckRegKey, [String] $InstalledCheckRegValueName, [String] $InstalledCheckRegValueData ) foreach ($parameter in $PSBoundParameters.Keys) { if ([String]::IsNullOrEmpty($PSBoundParameters[$parameter])) { New-InvalidArgumentException -ArgumentName $parameter -Message ($LocalizedData.ProvideParameterForRegistryCheck -f $parameter) } } } <# .SYNOPSIS Sets the value of a registry key to the specified data. .PARAMETER Key The registry key that contains the value to set. .PARAMETER Value The value name of the registry key value to set. .PARAMETER RegistryHive The registry hive that contains the registry key to set. .PARAMETER Data The data to set the registry key value to. #> function Set-RegistryValue { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Key, [Parameter(Mandatory = $true)] [String] $Value, [Parameter(Mandatory = $true)] [Microsoft.Win32.RegistryHive] $RegistryHive, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Data ) try { $baseRegistryKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($RegistryHive, [Microsoft.Win32.RegistryView]::Default) # Opens the subkey with write access $subRegistryKey = $baseRegistryKey.OpenSubKey($Key, $true) if ($null -eq $subRegistryKey) { Write-Verbose "Key: '$Key'" $subRegistryKey = $baseRegistryKey.CreateSubKey($Key) } $subRegistryKey.SetValue($Value, $Data) $subRegistryKey.Close() } catch { New-InvalidOperationException -Message ($LocalizedData.ErrorSettingRegistryValue -f $Key, $Value, $Data) -ErrorRecord $_ } } <# .SYNOPSIS Removes the specified value of a registry key. .PARAMETER Key The registry key that contains the value to remove. .PARAMETER Value The value name of the registry key value to remove. .PARAMETER RegistryHive The registry hive that contains the registry key to remove. #> function Remove-RegistryValue { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Key, [Parameter(Mandatory = $true)] [String] $Value, [Parameter(Mandatory = $true)] [Microsoft.Win32.RegistryHive] $RegistryHive ) try { $baseRegistryKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($RegistryHive, [Microsoft.Win32.RegistryView]::Default) $subRegistryKey = $baseRegistryKey.OpenSubKey($Key, $true) $subRegistryKey.DeleteValue($Value) $subRegistryKey.Close() } catch { New-InvalidOperationException -Message ($LocalizedData.ErrorRemovingRegistryValue -f $Key, $Value) -ErrorRecord $_ } } function Set-TargetResource { [CmdletBinding(SupportsShouldProcess = $true)] param ( [ValidateSet('Present', 'Absent')] [String] $Ensure = 'Present', [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $Name, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $Path, [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $ProductId, [String] $Arguments, [PSCredential] [System.Management.Automation.Credential()] $Credential, # Return codes 1641 and 3010 indicate success when a restart is requested per installation [ValidateNotNullOrEmpty()] [UInt32[]] $ReturnCode = @( 0, 1641, 3010 ), [String] $LogPath, [String] $FileHash, [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')] [String] $HashAlgorithm, [String] $SignerSubject, [String] $SignerThumbprint, [String] $ServerCertificateValidationCallback, [Boolean] $CreateCheckRegValue = $false, [ValidateSet('LocalMachine','CurrentUser')] [String] $InstalledCheckRegHive = 'LocalMachine', [String] $InstalledCheckRegKey, [String] $InstalledCheckRegValueName, [String] $InstalledCheckRegValueData ) $ErrorActionPreference = 'Stop' if (Test-TargetResource @PSBoundParameters) { return } Assert-PathExtensionValid -Path $Path $uri = Convert-PathToUri -Path $Path if (-not [String]::IsNullOrEmpty($ProductId)) { $identifyingNumber = Convert-ProductIdToIdentifyingNumber -ProductId $ProductId } else { $identifyingNumber = $ProductId } $productEntry = Get-ProductEntry -Name $Name -IdentifyingNumber $identifyingNumber <# Path gets overwritten in the download code path. Retain the user's original Path in case the install succeeded but the named package wasn't present on the system afterward so we can give a better error message. #> $originalPath = $Path Write-Verbose -Message $LocalizedData.PackageConfigurationStarting $logStream = $null $psDrive = $null $downloadedFileName = $null try { $fileExtension = [System.IO.Path]::GetExtension($Path).ToLower() if (-not [String]::IsNullOrEmpty($LogPath)) { try { if ($fileExtension -eq '.msi') { <# We want to pre-verify the log path exists and is writable ahead of time even in the MSI case, as detecting WHY the MSI log path doesn't exist would be rather problematic for the user. #> if ((Test-Path -Path $LogPath) -and $PSCmdlet.ShouldProcess($LocalizedData.RemoveExistingLogFile, $null, $null)) { Remove-Item -Path $LogPath } if ($PSCmdlet.ShouldProcess($LocalizedData.CreateLogFile, $null, $null)) { New-Item -Path $LogPath -Type 'File' | Out-Null } } elseif ($PSCmdlet.ShouldProcess($LocalizedData.CreateLogFile, $null, $null)) { $logStream = New-Object -TypeName 'System.IO.StreamWriter' -ArgumentList @( $LogPath, $false ) } } catch { New-InvalidOperationException -Message ($LocalizedData.CouldNotOpenLog -f $LogPath) -ErrorRecord $_ } } # Download or mount file as necessary if (-not ($fileExtension -eq '.msi' -and $Ensure -eq 'Absent')) { if ($uri.IsUnc -and $PSCmdlet.ShouldProcess($LocalizedData.MountSharePath, $null, $null)) { $psDriveArgs = @{ Name = [Guid]::NewGuid() PSProvider = 'FileSystem' Root = Split-Path -Path $uri.LocalPath } # If we pass a null for Credential, a dialog will pop up. if ($null -ne $Credential) { $psDriveArgs['Credential'] = $Credential } $psDrive = New-PSDrive @psDriveArgs $Path = Join-Path -Path $psDrive.Root -ChildPath (Split-Path -Path $uri.LocalPath -Leaf) } elseif (@( 'http', 'https' ) -contains $uri.Scheme -and $Ensure -eq 'Present' -and $PSCmdlet.ShouldProcess($LocalizedData.DownloadHTTPFile, $null, $null)) { $uriScheme = $uri.Scheme $outStream = $null $responseStream = $null try { Write-Verbose -Message ($LocalizedData.CreatingCacheLocation) if (-not (Test-Path -Path $script:packageCacheLocation -PathType 'Container')) { New-Item -Path $script:packageCacheLocation -ItemType 'Directory' | Out-Null } $destinationPath = Join-Path -Path $script:packageCacheLocation -ChildPath (Split-Path -Path $uri.LocalPath -Leaf) Write-Verbose -Message ($LocalizedData.NeedtodownloadfilefromschemedestinationwillbedestName -f $uriScheme, $destinationPath) try { Write-Verbose -Message ($LocalizedData.CreatingTheDestinationCacheFile) $outStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList @( $destinationPath, 'Create' ) } catch { # Should never happen since we own the cache directory New-InvalidOperationException -Message ($LocalizedData.CouldNotOpenDestFile -f $destinationPath) -ErrorRecord $_ } try { Write-Verbose -Message ($LocalizedData.CreatingTheSchemeStream -f $uriScheme) $webRequest = [System.Net.WebRequest]::Create($uri) Write-Verbose -Message ($LocalizedData.SettingDefaultCredential) $webRequest.Credentials = [System.Net.CredentialCache]::DefaultCredentials if ($uriScheme -eq 'http') { # Default value is MutualAuthRequested, which applies to the https scheme Write-Verbose -Message ($LocalizedData.SettingAuthenticationLevel) $webRequest.AuthenticationLevel = [System.Net.Security.AuthenticationLevel]::None } elseif ($uriScheme -eq 'https' -and -not [String]::IsNullOrEmpty($ServerCertificateValidationCallback)) { Write-Verbose -Message 'Assigning user-specified certificate verification callback' $serverCertificateValidationScriptBlock = [ScriptBlock]::Create($ServerCertificateValidationCallback) $webRequest.ServerCertificateValidationCallBack = $serverCertificateValidationScriptBlock } Write-Verbose -Message ($LocalizedData.Gettingtheschemeresponsestream -f $uriScheme) $responseStream = (([System.Net.HttpWebRequest]$webRequest).GetResponse()).GetResponseStream() } catch { Write-Verbose -Message ($LocalizedData.ErrorOutString -f ($_ | Out-String)) New-InvalidOperationException -Message ($LocalizedData.CouldNotGetHttpStream -f $uriScheme, $Path) -ErrorRecord $_ } try { Write-Verbose -Message ($LocalizedData.CopyingTheSchemeStreamBytesToTheDiskCache -f $uriScheme) $responseStream.CopyTo($outStream) $responseStream.Flush() $outStream.Flush() } catch { New-InvalidOperationException -Message ($LocalizedData.ErrorCopyingDataToFile -f $Path, $destinationPath) -ErrorRecord $_ } } finally { if ($null -ne $outStream) { $outStream.Close() } if ($null -ne $responseStream) { $responseStream.Close() } } Write-Verbose -Message ($LocalizedData.RedirectingPackagePathToCacheFileLocation) $Path = $destinationPath $downloadedFileName = $destinationPath } # At this point the Path ought to be valid unless it's a MSI uninstall case if (-not (Test-Path -Path $Path -PathType 'Leaf')) { New-InvalidOperationException -Message ($LocalizedData.PathDoesNotExist -f $Path) } Assert-FileValid -Path $Path -HashAlgorithm $HashAlgorithm -FileHash $FileHash -SignerSubject $SignerSubject -SignerThumbprint $SignerThumbprint } $startInfo = New-Object -TypeName 'System.Diagnostics.ProcessStartInfo' # Necessary for I/O redirection and just generally a good idea $startInfo.UseShellExecute = $false $process = New-Object -TypeName 'System.Diagnostics.Process' $process.StartInfo = $startInfo # Concept only, will never touch disk $errorLogPath = $LogPath + ".err" if ($fileExtension -eq '.msi') { $startInfo.FileName = "$env:winDir\system32\msiexec.exe" if ($Ensure -eq 'Present') { # Check if the MSI package specifies the ProductName and Code $productName = Get-MsiProductName -Path $Path $productCode = Get-MsiProductCode -Path $Path if ((-not [String]::IsNullOrEmpty($Name)) -and ($productName -ne $Name)) { New-InvalidArgumentException -ArgumentName 'Name' -Message ($LocalizedData.InvalidNameOrId -f $Name, $identifyingNumber, $productName, $productCode) } if ((-not [String]::IsNullOrEmpty($identifyingNumber)) -and ($identifyingNumber -ne $productCode)) { New-InvalidArgumentException -ArgumentName 'ProductId' -Message ($LocalizedData.InvalidNameOrId -f $Name, $identifyingNumber, $productName, $productCode) } $startInfo.Arguments = '/i "{0}"' -f $Path } else { $productEntry = Get-ProductEntry -Name $Name -IdentifyingNumber $identifyingNumber # We may have used the Name earlier, now we need the actual ID $id = Split-Path -Path $productEntry.Name -Leaf $startInfo.Arguments = '/x{0}' -f $id } if ($LogPath) { $startInfo.Arguments += ' /log "{0}"' -f $LogPath } $startInfo.Arguments += " /quiet" if ($Arguments) { $startInfo.Arguments += "$Arguments" } } else { # EXE Write-Verbose -Message $LocalizedData.TheBinaryIsAnExe if ($Ensure -eq 'Present') { $startInfo.FileName = $Path $startInfo.Arguments = $Arguments if ($LogPath) { Write-Verbose -Message ($LocalizedData.UserHasRequestedLoggingNeedToAttachEventHandlersToTheProcess) $startInfo.RedirectStandardError = $true $startInfo.RedirectStandardOutput = $true Register-ObjectEvent -InputObject $process -EventName 'OutputDataReceived' -SourceIdentifier $LogPath Register-ObjectEvent -InputObject $process -EventName 'ErrorDataReceived' -SourceIdentifier $errorLogPath } } else { # Absent case $startInfo.FileName = "$env:winDir\system32\msiexec.exe" # We may have used the Name earlier, now we need the actual ID if ($null -eq $productEntry.Name) { $id = $Path } else { $id = Split-Path -Path $productEntry.Name -Leaf } $startInfo.Arguments = "/x $id /quiet /norestart" if ($LogPath) { $startInfo.Arguments += ' /log "{0}"' -f $LogPath } if ($Arguments) { $startInfo.Arguments += "$Arguments" } } } Write-Verbose -Message ($LocalizedData.StartingWithStartInfoFileNameStartInfoArguments -f $startInfo.FileName, $startInfo.Arguments) if ($PSCmdlet.ShouldProcess(($LocalizedData.StartingProcessMessage -f $startInfo.FileName, $startInfo.Arguments), $null, $null)) { try { $exitCode = 0 $process.Start() | Out-Null # Identical to $fileExtension -eq '.exe' -and $logPath if ($logStream) { $process.BeginOutputReadLine() $process.BeginErrorReadLine() } $process.WaitForExit() if ($process) { $exitCode = $process.ExitCode } } catch { New-InvalidOperationException -Message ($LocalizedData.CouldNotStartProcess -f $Path) -ErrorRecord $_ } if ($logStream) { #We have to re-mux these since they appear to us as different streams #The underlying Win32 APIs prevent this problem, as would constructing a script #on the fly and executing it, but the former is highly problematic from PowerShell #and the latter doesn't let us get the return code for UI-based EXEs $outputEvents = Get-Event -SourceIdentifier $LogPath $errorEvents = Get-Event -SourceIdentifier $errLogPath $masterEvents = @() + $outputEvents + $errorEvents $masterEvents = $masterEvents | Sort-Object -Property TimeGenerated foreach($event in $masterEvents) { $logStream.Write($event.SourceEventArgs.Data); } Remove-Event -SourceIdentifier $LogPath Remove-Event -SourceIdentifier $errLogPath } if (-not ($ReturnCode -contains $exitCode)) { # Some .exe files do not support uninstall if ($Ensure -eq 'Absent' -and $fileExtension -eq '.exe' -and $exitCode -eq '1620') { Write-Warning -Message ($LocalizedData.ExeCouldNotBeUninstalled -f $Path) } else { New-InvalidOperationException ($LocalizedData.UnexpectedReturnCode -f $exitCode.ToString()) } } } } finally { if ($psDrive) { Remove-PSDrive -Name $psDrive -Force } if ($logStream) { $logStream.Dispose() } } if ($downloadedFileName -and $PSCmdlet.ShouldProcess($LocalizedData.RemoveDownloadedFile, $null, $null)) { <# This is deliberately not in the finally block because we want to leave the downloaded file on disk if an error occurred as a debugging aid for the user. #> Remove-Item -Path $downloadedFileName } $operationMessageString = $LocalizedData.PackageUninstalled if ($Ensure -eq 'Present') { $operationMessageString = $LocalizedData.PackageInstalled } if ($CreateCheckRegValue) { $registryValueString = '{0}\{1}\{2}' -f $InstalledCheckRegHive, $InstalledCheckRegKey, $InstalledCheckRegValueName if ($Ensure -eq 'Present') { Write-Verbose -Message ($LocalizedData.CreatingRegistryValue -f $registryValueString) Set-RegistryValue -RegistryHive $InstalledCheckRegHive -Key $InstalledCheckRegKey -Value $InstalledCheckRegValueName -Data $InstalledCheckRegValueData } else { Write-Verbose ($LocalizedData.RemovingRegistryValue -f $registryValueString) Remove-RegistryValue -RegistryHive $InstalledCheckRegHive -Key $InstalledCheckRegKey -Value $InstalledCheckRegValueName } } <# Check if a reboot is required, if so notify CA. The MSFT_ServerManagerTasks provider is missing on some client SKUs (worked on both Server and Client Skus in Windows 10). #> $serverFeatureData = Invoke-CimMethod -Name 'GetServerFeature' -Namespace 'root\microsoft\windows\servermanager' -Class 'MSFT_ServerManagerTasks' -Arguments @{ BatchSize = 256 } -ErrorAction 'Ignore' $registryData = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction 'Ignore' if (($serverFeatureData -and $serverFeatureData.RequiresReboot) -or $registryData -or $exitcode -eq 3010 -or $exitcode -eq 1641) { Write-Verbose $LocalizedData.MachineRequiresReboot $global:DSCMachineStatus = 1 } if ($Ensure -eq 'Present') { $getProductEntryParameters = @{ Name = $Name IdentifyingNumber = $identifyingNumber } $checkRegistryValueParameters = @{ CreateCheckRegValue = $CreateCheckRegValue InstalledCheckRegHive = $InstalledCheckRegHive InstalledCheckRegKey = $InstalledCheckRegKey InstalledCheckRegValueName = $InstalledCheckRegValueName InstalledCheckRegValueData = $InstalledCheckRegValueData } if ($CreateCheckRegValue) { $getProductEntryParameters += $checkRegistryValueParameters } $productEntry = Get-ProductEntry @getProductEntryParameters if ($null -eq $productEntry) { New-InvalidOperationException -Message ($LocalizedData.PostValidationError -f $originalPath) } } Write-Verbose -Message $operationMessageString Write-Verbose -Message $LocalizedData.PackageConfigurationComplete } <# .SYNOPSIS Asserts that the file at the given path is valid. .PARAMETER Path The path to the file to check. .PARAMETER FileHash The hash that should match the hash of the file. .PARAMETER HashAlgorithm The algorithm to use to retrieve the file hash. .PARAMETER SignerThumbprint The certificate thumbprint that should match the file's signer certificate. .PARAMETER SignerSubject The certificate subject that should match the file's signer certificate. #> function Assert-FileValid { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Path, [String] $FileHash, [String] $HashAlgorithm, [String] $SignerThumbprint, [String] $SignerSubject ) if (-not [String]::IsNullOrEmpty($FileHash)) { Assert-FileHashValid -Path $Path -Hash $FileHash -Algorithm $HashAlgorithm } if (-not [String]::IsNullOrEmpty($SignerThumbprint) -or -not [String]::IsNullOrEmpty($SignerSubject)) { Assert-FileSignatureValid -Path $Path -Thumbprint $SignerThumbprint -Subject $SignerSubject } } <# .SYNOPSIS Asserts that the hash of the file at the given path matches the given hash. .PARAMETER Path The path to the file to check the hash of. .PARAMETER Hash The hash to check against. .PARAMETER Algorithm The algorithm to use to retrieve the file's hash. #> function Assert-FileHashValid { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Path, [Parameter(Mandatory)] [String] $Hash, [String] $Algorithm = 'SHA256' ) if ([String]::IsNullOrEmpty($Algorithm)) { $Algorithm = 'SHA256' } Write-Verbose -Message ($LocalizedData.CheckingFileHash -f $Path, $Hash, $Algorithm) $fileHash = Get-FileHash -LiteralPath $Path -Algorithm $Algorithm -ErrorAction 'Stop' if ($fileHash.Hash -ne $Hash) { throw ($LocalizedData.InvalidFileHash -f $Path, $Hash, $Algorithm) } } <# .SYNOPSIS Asserts that the signature of the file at the given path is valid. .PARAMETER Path The path to the file to check the signature of .PARAMETER Thumbprint The certificate thumbprint that should match the file's signer certificate. .PARAMETER Subject The certificate subject that should match the file's signer certificate. #> function Assert-FileSignatureValid { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Path, [String] $Thumbprint, [String] $Subject ) Write-Verbose -Message ($LocalizedData.CheckingFileSignature -f $Path) $signature = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction 'Stop' if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { throw ($LocalizedData.InvalidFileSignature -f $Path, $signature.Status) } else { Write-Verbose -Message ($LocalizedData.FileHasValidSignature -f $Path, $signature.SignerCertificate.Thumbprint, $signature.SignerCertificate.Subject) } if ($null -ne $Subject -and ($signature.SignerCertificate.Subject -notlike $Subject)) { throw ($LocalizedData.WrongSignerSubject -f $Path, $Subject) } if ($null -ne $Thumbprint -and ($signature.SignerCertificate.Thumbprint -ne $Thumbprint)) { throw ($LocalizedData.WrongSignerThumbprint -f $Path, $Thumbprint) } } Export-ModuleMember -Function *-TargetResource |