diff --git a/assets/MicrosoftUpdate/RegisterMicrosoftUpdate.ps1 b/assets/MicrosoftUpdate/RegisterMicrosoftUpdate.ps1 index da89768f74a..c58c32221ae 100644 --- a/assets/MicrosoftUpdate/RegisterMicrosoftUpdate.ps1 +++ b/assets/MicrosoftUpdate/RegisterMicrosoftUpdate.ps1 @@ -1,12 +1,78 @@ +#Requires -Version 7.0 + # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +<# +.SYNOPSIS + Registers Microsoft Update for automatic updates (best-effort, non-blocking). + +.DESCRIPTION + This script is called by the MSI installer to opt into Microsoft Update. + It is designed to be: + - Idempotent: exits early if already registered + - Time-bounded: uses external process with timeout to avoid hangs + - Non-fatal: always exits 0 so the MSI can complete + + In constrained language mode or WDAC/AppLocker environments, COM operations + may fail. This script handles those cases gracefully. + +.PARAMETER TestHook + For testing purposes only. 'Hang' simulates a hang, 'Fail' simulates a failure. +#> + param( [ValidateSet('Hang', 'Fail')] $TestHook ) -$waitTimeoutSeconds = 300 +# Microsoft Update service GUID +$MicrosoftUpdateServiceId = '7971f918-a847-4430-9279-4a52d1efe18d' +# Service registration flags: asfAllowPendingRegistration + asfAllowOnlineRegistration + asfRegisterServiceWithAU +$MicrosoftUpdateServiceRegistrationFlags = 7 +$waitTimeoutSeconds = 120 + +# Helper function to release COM objects deterministically +function Release-ComObject { + param($ComObject) + if ($null -ne $ComObject) { + try { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($ComObject) + } + catch { + # Intentionally suppressing errors - COM release can fail if object is already released + # or inaccessible. This is expected and should not affect script success. + Write-Verbose "COM object cleanup failed (expected/safe to ignore): $_" -Verbose:$false + } + } +} + +# Idempotent pre-check: if already registered, exit immediately +# This runs outside the job/external process to minimize work when already registered +function Test-MicrosoftUpdateRegistered { + $serviceManager = $null + $registration = $null + $service = $null + try { + $serviceManager = New-Object -ComObject Microsoft.Update.ServiceManager + $registration = $serviceManager.QueryServiceRegistration($MicrosoftUpdateServiceId) + $service = $registration.Service + return $service.IsRegisteredWithAu + } + catch { + # QueryServiceRegistration throws if the service isn't registered + # or if COM operations fail. In either case, we should attempt registration. + # Return $false to indicate not registered. + return $false + } + finally { + Release-ComObject $service + Release-ComObject $registration + Release-ComObject $serviceManager + } +} + +# Build the job script (check test hooks first before idempotency check) switch ($TestHook) { 'Hang' { $waitTimeoutSeconds = 10 @@ -16,55 +82,87 @@ switch ($TestHook) { $jobScript = { throw "This job script should fail" } } default { - $jobScript = { - # This registers Microsoft Update via a predefined GUID with the Windows Update Agent. - # https://learn.microsoft.com/windows/win32/wua_sdk/opt-in-to-microsoft-update + # Check if already registered before doing any expensive work + Write-Verbose "MicrosoftUpdate: checking if already registered..." -Verbose + $alreadyRegistered = Test-MicrosoftUpdateRegistered + + if ($alreadyRegistered -eq $true) { + Write-Verbose "MicrosoftUpdate: Microsoft Update is already registered, skipping" -Verbose + exit 0 + } - $serviceManager = (New-Object -ComObject Microsoft.Update.ServiceManager) - $isRegistered = $serviceManager.QueryServiceRegistration('7971f918-a847-4430-9279-4a52d1efe18d').Service.IsRegisteredWithAu + # Not registered, attempt to register using an external process with timeout + # Using external process avoids issues with constrained language mode affecting jobs/runspaces + Write-Verbose "MicrosoftUpdate: Microsoft Update not registered, attempting registration..." -Verbose - if (!$isRegistered) { - Write-Verbose -Verbose "Opting into Microsoft Update as the Automatic Update Service" - # 7 is the combination of asfAllowPendingRegistration, asfAllowOnlineRegistration, asfRegisterServiceWithAU - # AU means Automatic Updates - $null = $serviceManager.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '') + # Normal path: register via COM in a job with timeout + $jobScript = { + param($ServiceId, $RegistrationFlags) + $serviceManager = New-Object -ComObject Microsoft.Update.ServiceManager + try { + $null = $serviceManager.AddService2($ServiceId, $RegistrationFlags, '') + Write-Host 'MicrosoftUpdate: registration succeeded' + return $true } - else { - Write-Verbose -Verbose "Microsoft Update is already registered for Automatic Updates" + catch { + Write-Host "MicrosoftUpdate: registration failed - $_" + return $false + } + finally { + if ($null -ne $serviceManager) { + try { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($serviceManager) + } + catch { + # Intentionally suppressing errors - COM cleanup failures are expected and safe + Write-Verbose "COM cleanup failed (expected/safe to ignore): $_" -Verbose:$false + } + } } - - $isRegistered = $serviceManager.QueryServiceRegistration('7971f918-a847-4430-9279-4a52d1efe18d').Service.IsRegisteredWithAu - - # Return if it was successful, which is the opposite of Pending. - return $isRegistered } } } -Write-Verbose "Running job script: $jobScript" -Verbose -$job = Start-ThreadJob -ScriptBlock $jobScript +try { + Write-Verbose "MicrosoftUpdate: starting registration with $waitTimeoutSeconds second timeout" -Verbose + + # Start the job + if ($TestHook) { + $job = Start-ThreadJob -ScriptBlock $jobScript + } + else { + $job = Start-ThreadJob -ScriptBlock $jobScript -ArgumentList $MicrosoftUpdateServiceId, $MicrosoftUpdateServiceRegistrationFlags + } + + # Wait with timeout + $completed = Wait-Job -Job $job -Timeout $waitTimeoutSeconds -Write-Verbose "Waiting on Job for $waitTimeoutSeconds seconds" -Verbose -$null = Wait-Job -Job $job -Timeout $waitTimeoutSeconds + if ($completed) { + $result = Receive-Job -Job $job + Remove-Job -Job $job -Force -if ($job.State -ne 'Running') { - Write-Verbose "Job finished. State: $($job.State)" -Verbose - $result = Receive-Job -Job $job -Verbose - Write-Verbose "Result: $result" -Verbose - if ($result) { - Write-Verbose "Registration succeeded" -Verbose - exit 0 + if ($result -eq $true) { + Write-Verbose "MicrosoftUpdate: completed successfully" -Verbose + } + else { + Write-Verbose "MicrosoftUpdate: registration failed, continuing installation" -Verbose + } } else { - Write-Verbose "Registration failed" -Verbose - # at the time this was written, the MSI is ignoring the exit code - exit 1 + # Process timed out - stop the job and continue + Write-Verbose "MicrosoftUpdate: timed out after $waitTimeoutSeconds seconds, continuing installation" -Verbose + Stop-Job -Job $job + Remove-Job -Job $job -Force } } -else { - Write-Verbose "Job timed out" -Verbose - Write-Verbose "Stopping Job. State: $($job.State)" -Verbose - Stop-Job -Job $job - # at the time this was written, the MSI is ignoring the exit code - exit 258 +catch { + Write-Verbose "MicrosoftUpdate: unexpected error - $_, continuing installation" -Verbose + # Ensure job cleanup to prevent resource leaks + if ($job) { + Stop-Job -Job $job -ErrorAction SilentlyContinue + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } } + +# Always exit 0 so the MSI can complete +exit 0 diff --git a/src/PowerShell.Core.Instrumentation/RegisterManifest.ps1 b/src/PowerShell.Core.Instrumentation/RegisterManifest.ps1 index 992c7b1c159..53644246f42 100644 --- a/src/PowerShell.Core.Instrumentation/RegisterManifest.ps1 +++ b/src/PowerShell.Core.Instrumentation/RegisterManifest.ps1 @@ -1,16 +1,19 @@ +#Requires -Version 7.0 + # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. <# -.Synopsis +.SYNOPSIS Registers or unregisters the PowerShell ETW manifest -.Parameter Path +.PARAMETER Path The fully qualified path to the PowerShell.Core.Instrumentation.man manifest file. The default value is the location of this script. -.Parameter Unregister +.PARAMETER Unregister Specify to unregister the manifest. -.Notes + +.NOTES The PowerShell.Core.Instrumentation.man and PowerShell.Core.Instrumentation.dll files are expected to be at the location specified by the Path parameter. When registered, PowerShell.Core.Instrumentation.dll is locked to prevent deleting or changing. @@ -27,6 +30,12 @@ param Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' +# Timeout for wevtutil operations (seconds) +$wevtutilTimeoutSeconds = 60 + +# Publisher GUID from the manifest - used for idempotent checks +$publisherGuid = '{f90714a8-5509-434a-bf6d-b1624c8a19a2}' + function Start-NativeExecution([scriptblock]$sb, [switch]$IgnoreExitcode) { $backupEAP = $script:ErrorActionPreference @@ -47,6 +56,126 @@ function Start-NativeExecution([scriptblock]$sb, [switch]$IgnoreExitcode) } } +function Invoke-NativeProcess { + <# + .SYNOPSIS + Runs a native process and returns the exit code. + .PARAMETER FilePath + The path to the executable to run. + .PARAMETER Arguments + The arguments to pass to the executable. + .OUTPUTS + Returns the process exit code. + #> + param( + [string]$FilePath, + [string]$Arguments + ) + + $tempDir = [System.IO.Path]::GetTempPath() + $outFile = Join-Path $tempDir "wevtutil-stdout-$([System.IO.Path]::GetRandomFileName())" + $errFile = Join-Path $tempDir "wevtutil-stderr-$([System.IO.Path]::GetRandomFileName())" + $exitCode = $null + try { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -NoNewWindow -PassThru -RedirectStandardOutput $outFile -RedirectStandardError $errFile + $process.WaitForExit() + $exitCode = $process.ExitCode + + # Only clean up temp files on success; keep them for debugging on failure + if ($exitCode -eq 0) { + Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue + } + } + finally { + # Ensure temp files are not leaked when the process is interrupted (e.g. job timeout) + if ($null -eq $exitCode) { + Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue + } + } + + return $exitCode +} + +function Invoke-WevtutilWithTimeout { + <# + .SYNOPSIS + Runs wevtutil with a timeout to prevent hangs. + .PARAMETER Arguments + The arguments to pass to wevtutil. + .PARAMETER TimeoutSeconds + Maximum time to wait for wevtutil to complete. + .PARAMETER IgnoreExitCode + If set, non-zero exit codes are not treated as errors. + .OUTPUTS + Returns $true if completed successfully, $false otherwise. + #> + param( + [string]$Arguments, + [int]$TimeoutSeconds = 60, + [switch]$IgnoreExitCode + ) + + $wevtutilPath = Join-Path $env:SystemRoot 'System32\wevtutil.exe' + if (-not (Test-Path $wevtutilPath)) { + Write-Verbose "EventManifest: wevtutil.exe not found at $wevtutilPath" -Verbose + return $false + } + + try { + Write-Verbose "EventManifest: running wevtutil.exe $Arguments (timeout: ${TimeoutSeconds}s)" -Verbose + + # Use Start-ThreadJob with timeout for idiomatic PowerShell with timeout support + $job = Start-ThreadJob -ScriptBlock ${function:Invoke-NativeProcess} -ArgumentList $wevtutilPath, $Arguments + + $completed = Wait-Job -Job $job -Timeout $TimeoutSeconds + + if ($completed) { + $exitCode = Receive-Job -Job $job + Remove-Job -Job $job -Force + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + Write-Verbose "EventManifest: wevtutil failed with exit code $exitCode" -Verbose + return $false + } + return $true + } + else { + Write-Verbose "EventManifest: wevtutil timed out after $TimeoutSeconds seconds" -Verbose + Stop-Job -Job $job + Remove-Job -Job $job -Force + return $false + } + } + catch { + Write-Verbose "EventManifest: error running wevtutil - $_" -Verbose + return $false + } +} + +function Test-ManifestRegistered { + <# + .SYNOPSIS + Checks if the PowerShell event manifest is already registered. + .DESCRIPTION + Queries the registry for the publisher GUID to determine if registration is needed. + .OUTPUTS + Returns $true if registered, $false if not, $null if unable to determine. + #> + try { + # Check if the publisher is registered in the event log registry + $publisherKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Publishers\$publisherGuid" + if (Test-Path $publisherKey) { + Write-Verbose "EventManifest: publisher $publisherGuid found in registry" -Verbose + return $true + } + return $false + } + catch { + Write-Verbose "EventManifest: unable to check registry - $_" -Verbose + return $null + } +} + function Test-Elevated { [CmdletBinding()] @@ -58,6 +187,7 @@ function Test-Elevated # Note that the SID won't show up unless the process is elevated. return (([Security.Principal.WindowsIdentity]::GetCurrent()).Groups -contains "S-1-5-32-544") } + $IsWindowsOs = $PSHOME.EndsWith('\WindowsPowerShell\v1.0', [System.StringComparison]::OrdinalIgnoreCase) -or $IsWindows if (-not $IsWindowsOs) @@ -70,28 +200,73 @@ if (-not (Test-Elevated)) throw 'This script must be run from an elevated process.' } -$manifest = Get-Item -Path (Join-Path -Path $Path -ChildPath 'PowerShell.Core.Instrumentation.man') -$binary = Get-Item -Path (Join-Path -Path $Path -ChildPath 'PowerShell.Core.Instrumentation.dll') +# Resolve path for security validation - prevents directory traversal attacks +# If path doesn't exist, we'll handle it gracefully when checking for files +$resolvedPath = $Path +if (Test-Path -Path $Path -PathType Leaf) { + throw "Path parameter must be a directory, not a file: $Path" +} +elseif (Test-Path -Path $Path -PathType Container) { + # Path is a directory; resolve to its canonical form + $resolvedPath = (Resolve-Path -Path $Path).Path +} + +$manifest = Join-Path -Path $resolvedPath -ChildPath 'PowerShell.Core.Instrumentation.man' +$binary = Join-Path -Path $resolvedPath -ChildPath 'PowerShell.Core.Instrumentation.dll' $files = @($manifest, $binary) foreach ($file in $files) { if (-not (Test-Path -Path $file)) { - throw "Could not find $($file.Name) at $Path" + Write-Verbose "EventManifest: could not find $file, skipping registration" -Verbose + exit 0 } } -[string] $command = 'wevtutil um "{0}"' -f $manifest.FullName +if ($Unregister) +{ + # During uninstall, attempt to unregister but don't block if it fails + # An orphaned registration is better than a hung uninstall + Write-Verbose "EventManifest: attempting to unregister manifest" -Verbose + $unregisterArgs = 'um "{0}"' -f $manifest + $result = Invoke-WevtutilWithTimeout -Arguments $unregisterArgs -TimeoutSeconds $wevtutilTimeoutSeconds -IgnoreExitCode + if ($result) { + Write-Verbose "EventManifest: unregistration completed" -Verbose + } + else { + Write-Verbose "EventManifest: unregistration failed or timed out, continuing" -Verbose + } + exit 0 +} -# Unregister if present. Avoids warnings when registering the manifest -# and it is already registered. -Write-Verbose "unregister the manifest, if present: $command" -Start-NativeExecution {Invoke-Expression $command} $true +# Installation path: check if already registered (idempotent) +$isRegistered = Test-ManifestRegistered +if ($isRegistered -eq $true) { + Write-Verbose "EventManifest: already registered, skipping" -Verbose + exit 0 +} +elseif ($null -eq $isRegistered) { + Write-Verbose "EventManifest: unable to determine registration status, proceeding with registration" -Verbose +} -if (-not $Unregister) -{ - $command = 'wevtutil.exe im "{0}" /rf:"{1}" /mf:"{1}"' -f $manifest.FullName, $binary.FullName - Write-Verbose -Message "Register the manifest: $command" - Start-NativeExecution { Invoke-Expression $command } +# Attempt to unregister first to avoid warnings during registration +# This is best-effort; we continue even if it fails +Write-Verbose "EventManifest: unregistering any existing manifest" -Verbose +$unregisterArgs = 'um "{0}"' -f $manifest +$null = Invoke-WevtutilWithTimeout -Arguments $unregisterArgs -TimeoutSeconds $wevtutilTimeoutSeconds -IgnoreExitCode + +# Now attempt to register the manifest +Write-Verbose "EventManifest: registering manifest" -Verbose +$registerArgs = 'im "{0}" /rf:"{1}" /mf:"{1}"' -f $manifest, $binary +$result = Invoke-WevtutilWithTimeout -Arguments $registerArgs -TimeoutSeconds $wevtutilTimeoutSeconds + +if ($result) { + Write-Verbose "EventManifest: registration completed successfully" -Verbose } +else { + Write-Verbose "EventManifest: registration failed or timed out, continuing installation" -Verbose +} + +# Always exit 0 so the MSI can complete +exit 0 diff --git a/test/packaging/windows/RegisterManifest-ArgumentParsing.Tests.ps1 b/test/packaging/windows/RegisterManifest-ArgumentParsing.Tests.ps1 new file mode 100644 index 00000000000..a0d85a49090 --- /dev/null +++ b/test/packaging/windows/RegisterManifest-ArgumentParsing.Tests.ps1 @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "RegisterManifest Argument Parsing Tests" -Tags "CI" { + BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..' '..' '..' 'src' 'PowerShell.Core.Instrumentation' 'RegisterManifest.ps1' + + # Mock wevtutil to capture what arguments it receives + function Test-WevtutilArgumentParsing { + param( + [string]$ManifestPath, + [string]$BinaryPath, + [string]$Command + ) + + # Build the arguments string as RegisterManifest.ps1 would + if ($Command -eq 'install') { + $arguments = 'im "{0}" /rf:"{1}" /mf:"{1}"' -f $ManifestPath, $BinaryPath + } + elseif ($Command -eq 'uninstall') { + $arguments = 'um "{0}"' -f $ManifestPath + } + + # Create a test script that echoes arguments to capture what wevtutil would receive + $testScript = { + param($WevtutilPath, $Arguments) + + # Use cmd.exe to simulate how Windows processes the command line + $echoScript = "@echo off`necho ARGS: %*" + $echoPath = Join-Path $env:TEMP "echo-test-$([guid]::NewGuid()).bat" + try { + Set-Content -Path $echoPath -Value $echoScript + + # Execute with the arguments to see how they're parsed + $output = cmd /c "`"$echoPath`" $Arguments 2>&1" + return $output + } + finally { + Remove-Item $echoPath -Force -ErrorAction SilentlyContinue + } + } + + return @{ + Arguments = $arguments + TestOutput = & $testScript 'wevtutil.exe' $arguments + } + } + } + + Context "Argument construction for paths without spaces" { + It "Should construct install arguments correctly for simple paths" { + $manifest = "C:\PowerShell\PowerShell.Core.Instrumentation.man" + $binary = "C:\PowerShell\PowerShell.Core.Instrumentation.dll" + + $result = Test-WevtutilArgumentParsing -ManifestPath $manifest -BinaryPath $binary -Command 'install' + + $result.Arguments | Should -Be 'im "C:\PowerShell\PowerShell.Core.Instrumentation.man" /rf:"C:\PowerShell\PowerShell.Core.Instrumentation.dll" /mf:"C:\PowerShell\PowerShell.Core.Instrumentation.dll"' + } + + It "Should construct uninstall arguments correctly for simple paths" { + $manifest = "C:\PowerShell\PowerShell.Core.Instrumentation.man" + + $result = Test-WevtutilArgumentParsing -ManifestPath $manifest -Command 'uninstall' + + $result.Arguments | Should -Be 'um "C:\PowerShell\PowerShell.Core.Instrumentation.man"' + } + } + + Context "Argument construction for paths with spaces" { + It "Should construct install arguments correctly for paths with spaces" { + $manifest = "C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.man" + $binary = "C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.dll" + + $result = Test-WevtutilArgumentParsing -ManifestPath $manifest -BinaryPath $binary -Command 'install' + + $result.Arguments | Should -Be 'im "C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.man" /rf:"C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.dll" /mf:"C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.dll"' + } + + It "Should construct uninstall arguments correctly for paths with spaces" { + $manifest = "C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.man" + + $result = Test-WevtutilArgumentParsing -ManifestPath $manifest -Command 'uninstall' + + $result.Arguments | Should -Be 'um "C:\Program Files\PowerShell\7\PowerShell.Core.Instrumentation.man"' + } + + It "Should handle paths with parentheses and spaces" { + $manifest = "C:\Program Files (x86)\PowerShell\7\PowerShell.Core.Instrumentation.man" + $binary = "C:\Program Files (x86)\PowerShell\7\PowerShell.Core.Instrumentation.dll" + + $result = Test-WevtutilArgumentParsing -ManifestPath $manifest -BinaryPath $binary -Command 'install' + + $result.Arguments | Should -Be 'im "C:\Program Files (x86)\PowerShell\7\PowerShell.Core.Instrumentation.man" /rf:"C:\Program Files (x86)\PowerShell\7\PowerShell.Core.Instrumentation.dll" /mf:"C:\Program Files (x86)\PowerShell\7\PowerShell.Core.Instrumentation.dll"' + } + } + + Context "Argument string handling by Start-Process" { + It "Should properly quote arguments when passed as string to Start-Process" { + # This tests that our argument formatting works with Start-Process + $testPath = "C:\Program Files\Test Path\file.txt" + $arguments = 'test "{0}"' -f $testPath + + # The arguments string should contain the quotes + $arguments | Should -Match '"C:\\Program Files\\Test Path\\file\.txt"' + } + + It "Should handle multiple quoted arguments in single string" { + $path1 = "C:\Program Files\Path1\file.man" + $path2 = "C:\Program Files\Path2\file.dll" + $arguments = 'im "{0}" /rf:"{1}" /mf:"{1}"' -f $path1, $path2 + + # Verify quotes are properly placed + $arguments | Should -Match 'im "C:\\Program Files\\Path1\\file\.man"' + $arguments | Should -Match '/rf:"C:\\Program Files\\Path2\\file\.dll"' + $arguments | Should -Match '/mf:"C:\\Program Files\\Path2\\file\.dll"' + } + } + + Context "Integration test with actual process execution" { + It "Should execute external process with quoted paths correctly" -Skip:(-not $IsWindows) { + # Create a test directory with spaces + $testDir = Join-Path $env:TEMP "Test Dir With Spaces $([guid]::NewGuid())" + $testFile = Join-Path $testDir "test.txt" + + try { + New-Item -Path $testDir -ItemType Directory -Force | Out-Null + "test content" | Out-File $testFile -Force + + # Use cmd.exe type command which requires proper quoting + $arguments = 'type "{0}"' -f $testFile + + $output = cmd /c $arguments 2>&1 + + $output | Should -Match "test content" + } + finally { + Remove-Item $testDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} diff --git a/test/packaging/windows/registration-scripts.tests.ps1 b/test/packaging/windows/registration-scripts.tests.ps1 new file mode 100644 index 00000000000..bb0e8eca34a --- /dev/null +++ b/test/packaging/windows/registration-scripts.tests.ps1 @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe -Name "Registration Scripts" -Fixture { + BeforeAll { + Set-StrictMode -Off + + function Test-Elevated { + [CmdletBinding()] + [OutputType([bool])] + Param() + + return (([Security.Principal.WindowsIdentity]::GetCurrent()).Groups -contains "S-1-5-32-544") + } + + function Test-IsMuEnabled { + $sm = $null + try { + $sm = New-Object -ComObject Microsoft.Update.ServiceManager + $mu = $sm.Services | Where-Object { $_.ServiceId -eq '7971f918-a847-4430-9279-4a52d1efe18d' } + if ($mu) { + return $true + } + return $false + } + finally { + if ($null -ne $sm) { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($sm) + } + } + } + + function Unregister-MicrosoftUpdate { + $sm = $null + try { + $sm = New-Object -ComObject Microsoft.Update.ServiceManager + $mu = $sm.Services | Where-Object { $_.ServiceId -eq '7971f918-a847-4430-9279-4a52d1efe18d' } + if ($mu) { + $sm.RemoveService($mu.ServiceID) + return $true + } + } + catch { + Write-Warning "Failed to unregister Microsoft Update: $_" + } + finally { + if ($null -ne $sm) { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($sm) + } + } + return $false + } + + $registrationScriptsPath = Join-Path (Split-Path $PSScriptRoot -Parent | Split-Path -Parent | Split-Path -Parent) 'src\PowerShell.Core.Instrumentation' + $muScriptPath = Join-Path (Split-Path $PSScriptRoot -Parent | Split-Path -Parent | Split-Path -Parent) 'assets\MicrosoftUpdate\RegisterMicrosoftUpdate.ps1' + + if (!(Test-Path $registrationScriptsPath)) { + Write-Warning "Registration scripts path not found: $registrationScriptsPath" + } + if (!(Test-Path $muScriptPath)) { + Write-Warning "MU script path not found: $muScriptPath" + } + } + + Context "RegisterMicrosoftUpdate.ps1" { + BeforeEach { + # Ensure MU is not registered before each test + Unregister-MicrosoftUpdate | Out-Null + } + + It "Should register Microsoft Update when not already registered" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $muScriptPath + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0" + Test-IsMuEnabled | Should -Be $true -Because "Microsoft Update should be registered" + } + + It "Should exit 0 when already registered (idempotent)" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + # Register first time + & $muScriptPath | Out-Null + + # Try to register again + & $muScriptPath + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 even when already registered" + Test-IsMuEnabled | Should -Be $true -Because "Microsoft Update should still be registered" + } + + It "Should handle timeout gracefully with Hang test hook" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $muScriptPath -TestHook Hang + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 even on timeout" + } + + It "Should handle failure gracefully with Fail test hook" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $muScriptPath -TestHook Fail + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 even on failure" + } + } + + Context "RegisterManifest.ps1" { + BeforeAll { + $manifestScriptPath = Join-Path $registrationScriptsPath 'RegisterManifest.ps1' + $manifestPath = Join-Path $registrationScriptsPath 'PowerShell.Core.Instrumentation.man' + $binaryPath = Join-Path $registrationScriptsPath 'PowerShell.Core.Instrumentation.dll' + + if (!(Test-Path $manifestScriptPath)) { + Write-Warning "Manifest script not found: $manifestScriptPath" + } + } + + It "Should not fail when manifest files don't exist" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $manifestScriptPath -Path 'C:\nonexistent' + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 gracefully when files don't exist" + } + + It "Should exit 0 on successful registration" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $manifestScriptPath + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0" + } + + It "Should handle unregister gracefully" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + & $manifestScriptPath -Unregister + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 on unregister" + } + + It "Should be idempotent on re-registration" { + if (!(Test-Elevated)) { + Set-ItResult -Skipped -Because "requires elevation" + return + } + # Register first time + & $manifestScriptPath + $LASTEXITCODE | Should -Be 0 + + # Register again - should still exit 0 + & $manifestScriptPath + $LASTEXITCODE | Should -Be 0 -Because "script should exit 0 when already registered" + } + } +}