Public/New-HDTRegistryService.ps1
|
function New-HDTRegistryService { <# .SYNOPSIS Creates the real IRegistryService adapter over the registry provider. .DESCRIPTION The one place in HDT that reads the registry. PROJECT constraint 4 forbids engine logic from touching it directly, so Get-HDTMachineFact receives this object and can be swapped for New-HDTFakeRegistryService in a test. Six methods. TestPath and GetValue are the read subset the fact gatherer needs, which today is the SecureBoot state key. NewKey, SetValue, RemoveValue and RemoveKey are the write half the autologon lifecycle runs on. REMOVING SOMETHING THAT IS NOT THERE IS NOT AN ERROR. The teardown runs on machines in unknown states - an image that already carried a DefaultPassword, a run that died between two writes - and a teardown that throws on the first absent value is a teardown that does not finish, leaving a machine armed with six of nine artifacts cleared. SetValue creates the key first because New-ItemProperty fails on a key that does not exist. RemoveKey goes through Get-Item so that an absent key is a no-op while a key with children and no Recurse still throws - the same two behaviours the fake has, expressed as a pipeline rather than as a branch. GetValue returns $null for a missing key and for a missing value name, and never throws. That is the contract, not a branch on data: on a BIOS machine HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State genuinely does not exist, and a gatherer that had to catch would swallow real errors too. Paths are PowerShell provider paths (HKLM:\...). The long hive names - HKEY_LOCAL_MACHINE\ and friends - are accepted as synonyms, because that is how a runbook or a rules file tends to write them. Every call is recorded in $Operations, before it can throw, exactly as the fakes record (tests/helpers/README.md section 4), so provenance and query-order assertions work against either implementation. It is a [pscustomobject] carrying ScriptMethod members rather than a PowerShell class: classes dot-sourced into the module are the known flaky path across -Force re-imports (see 01-03). .PARAMETER Journal The shared cross-service operation journal. When supplied, every recorded call is appended to it in addition to $Operations, numbered globally across services. .OUTPUTS System.Management.Automation.PSCustomObject with the six IRegistryService ScriptMethods. Note that Get-Member -MemberType Method does NOT list a ScriptMethod - use -MemberType Method, ScriptMethod. .EXAMPLE $registry = New-HDTRegistryService $registry.GetValue('HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State', 'UEFISecureBootEnabled') Returns 1 on a Secure Boot machine and $null on a BIOS machine, which is what HDTSecureBootEnabled is derived from. .EXAMPLE $registry = New-HDTRegistryService $registry.SetValue('HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon', 'AutoLogonCount', 3, 'DWord') Three more autologons, as the reboot ceremony arms them. A lab test observed the count reading 2, 1, 0 across those three legs. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a stateless service adapter object; it changes no state.')] [CmdletBinding()] [OutputType([object])] param( [Parameter()] [AllowNull()] [System.Collections.ArrayList] $Journal ) $service = [pscustomobject] @{ Operations = [System.Collections.ArrayList]::new() Journal = $Journal ServiceName = 'RegistryService' } $service | Add-Member -MemberType ScriptMethod -Name Record -Value { param([string] $Operation, [object[]] $Argument) [void] $this.Operations.Add([pscustomobject] @{ Sequence = $this.Operations.Count + 1 Operation = $Operation Arguments = $Argument }) if ($null -ne $this.Journal) { [void] $this.Journal.Add([pscustomobject] @{ Sequence = $this.Journal.Count + 1 Service = $this.ServiceName Operation = $Operation Arguments = $Argument }) } } $service | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value { return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation })) } $service | Add-Member -MemberType ScriptMethod -Name NormalizePath -Value { param([string] $Path) $normalized = $Path $normalized = $normalized -replace '^HKEY_LOCAL_MACHINE\\', 'HKLM:\' $normalized = $normalized -replace '^HKEY_CURRENT_USER\\', 'HKCU:\' $normalized = $normalized -replace '^HKEY_CLASSES_ROOT\\', 'HKCR:\' $normalized = $normalized -replace '^HKEY_USERS\\', 'HKU:\' return $normalized.TrimEnd('\') } $service | Add-Member -MemberType ScriptMethod -Name TestPath -Value { param([string] $Path) $this.Record('TestPath', @($Path)) return [bool] (Test-Path -LiteralPath $this.NormalizePath($Path)) } $service | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param([string] $Path, [string] $Name) $this.Record('GetValue', @($Path, $Name)) $item = Get-ItemProperty -LiteralPath $this.NormalizePath($Path) -Name $Name -ErrorAction SilentlyContinue if ($null -eq $item) { return $null } $property = $item.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } $service | Add-Member -MemberType ScriptMethod -Name NewKey -Value { param([string] $Path) $this.Record('NewKey', @($Path)) # -Force is what makes this idempotent and what creates intermediate # keys, so there is no "does it exist" branch to get wrong. New-Item -Path $this.NormalizePath($Path) -Force -ErrorAction Stop | Out-Null } $service | Add-Member -MemberType ScriptMethod -Name SetValue -Value { param([string] $Path, [string] $Name, [object] $Value, [string] $Type) $this.Record('SetValue', @($Path, $Name, $Value, $Type)) $full = $this.NormalizePath($Path) New-Item -Path $full -Force -ErrorAction Stop | Out-Null New-ItemProperty -LiteralPath $full -Name $Name -Value $Value -PropertyType $Type -Force -ErrorAction Stop | Out-Null } $service | Add-Member -MemberType ScriptMethod -Name RemoveValue -Value { param([string] $Path, [string] $Name) $this.Record('RemoveValue', @($Path, $Name)) # Absent is not an error: teardown runs on machines in unknown states. Remove-ItemProperty -LiteralPath $this.NormalizePath($Path) -Name $Name -Force -ErrorAction SilentlyContinue } $service | Add-Member -MemberType ScriptMethod -Name RemoveKey -Value { param([string] $Path, [bool] $Recurse) $this.Record('RemoveKey', @($Path, $Recurse)) # Get-Item yields nothing for an absent key, so the pipeline is a no-op # there; a key with children and no Recurse still reaches Remove-Item and # still throws. One pipeline, both behaviours, no branch on data. Get-Item -LiteralPath $this.NormalizePath($Path) -ErrorAction SilentlyContinue | Remove-Item -Recurse:$Recurse -Force -Confirm:$false -ErrorAction Stop } return $service } |