From 84b15c666947d1ffb31f6d00920891721ac98484 Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 00:29:28 +0200 Subject: [PATCH 1/2] Define and test accepted GUID formats --- .github/workflows/guid.yml | 38 +++++++++++++++++++++ Test-IsGuid.ps1 | 67 ++++++++++++++++++-------------------- Tests/Guid.Tests.ps1 | 28 ++++++++++++++++ docs/GUID.md | 9 +++++ 4 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/guid.yml create mode 100644 Tests/Guid.Tests.ps1 create mode 100644 docs/GUID.md diff --git a/.github/workflows/guid.yml b/.github/workflows/guid.yml new file mode 100644 index 0000000..e15a539 --- /dev/null +++ b/.github/workflows/guid.yml @@ -0,0 +1,38 @@ +name: GUID validation + +on: + push: + branches: [master] + paths: ['Test-IsGuid.ps1', 'Tests/Guid.Tests.ps1', '.github/workflows/guid.yml'] + pull_request: + paths: ['Test-IsGuid.ps1', 'Tests/Guid.Tests.ps1', '.github/workflows/guid.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = './Tests' + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/Test-IsGuid.ps1 b/Test-IsGuid.ps1 index c6ab6e2..1d7c268 100644 --- a/Test-IsGuid.ps1 +++ b/Test-IsGuid.ps1 @@ -1,44 +1,39 @@ +#Requires -Version 7.4 + function Test-IsGuid { <# - .SYNOPSIS - Cmdlet will check if input string is a valid GUID. - - .DESCRIPTION - Cmdlet will check if input string is a valid GUID. - - .PARAMETER ObjectGuid - A string representing the GUID to be tested. - - .EXAMPLE - PS C:\> Test-IsGuid -ObjectGuid 'value1' - - # Output - $False - - .EXAMPLE - PS C:\> Test-IsGuid -ObjectGuid '7761bf39-9a9f-42c8-869f-7c6e2689811a' - - # Output - $True - - .OUTPUTS - System.Boolean - - .NOTES - Additional information about the function. + .SYNOPSIS + Tests a complete GUID string using defined .NET formats. + .DESCRIPTION + Accepts N (32 digits), D (dashed), B (matched braces) and P (matched + parentheses). Rejects surrounding whitespace, extra lines, mixed optional + delimiters and the X initializer format. Null and empty input return false. + The all-zero GUID is syntactically valid; this does not test object existence. #> + [CmdletBinding()] [OutputType([bool])] - param - ( - [Parameter(Mandatory = $true)] - [string] - $ObjectGuid + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowNull()] + [AllowEmptyString()] + [string]$ObjectGuid ) - # Define verification regex - [regex]$guidRegex = '(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$' - - # Check guid against regex - return $ObjectGuid -match $guidRegex + process + { + if ([string]::IsNullOrWhiteSpace($ObjectGuid) -or $ObjectGuid -match '\s') + { + return $false + } + $parsed = [guid]::Empty + foreach ($format in @('N', 'D', 'B', 'P')) + { + if ([guid]::TryParseExact($ObjectGuid, $format, [ref]$parsed)) + { + return $true + } + } + return $false + } } diff --git a/Tests/Guid.Tests.ps1 b/Tests/Guid.Tests.ps1 new file mode 100644 index 0000000..aa287d5 --- /dev/null +++ b/Tests/Guid.Tests.ps1 @@ -0,0 +1,28 @@ +BeforeAll { . (Join-Path $PSScriptRoot '../Test-IsGuid.ps1') } +Describe 'GUID syntax contract' { + It 'accepts ' -ForEach @( + @{Value='7761bf39-9a9f-42c8-869f-7c6e2689811a'} + @{Value='7761BF39-9A9F-42C8-869F-7C6E2689811A'} + @{Value='7761bf399a9f42c8869f7c6e2689811a'} + @{Value='{7761bf39-9a9f-42c8-869f-7c6e2689811a}'} + @{Value='(7761bf39-9a9f-42c8-869f-7c6e2689811a)'} + @{Value='00000000-0000-0000-0000-000000000000'} + ) { Test-IsGuid $Value | Should -BeTrue } + It 'rejects malformed or unsupported input: ' -ForEach @( + @{Value=$null};@{Value=''};@{Value=' '};@{Value='not-a-guid'} + @{Value='7761bf39-9a9f-42c8-869f-7c6e2689811a}'} + @{Value='{7761bf39-9a9f-42c8-869f-7c6e2689811a)'} + @{Value='{7761bf399a9f42c8869f7c6e2689811a}'} + @{Value='7761bf39-9a9f42c8-869f-7c6e2689811a'} + @{Value="junk`n7761bf39-9a9f-42c8-869f-7c6e2689811a`njunk"} + @{Value="7761bf39-9a9f-42c8-869f-7c6e2689811a`n"} + @{Value=' 7761bf39-9a9f-42c8-869f-7c6e2689811a'} + @{Value='{0x7761bf39,0x9a9f,0x42c8,{0x86,0x9f,0x7c,0x6e,0x26,0x89,0x81,0x1a}}'} + ) { Test-IsGuid -ObjectGuid $Value | Should -BeFalse } + It 'returns one Boolean per pipeline record' { + $r=@('bad','','00000000-0000-0000-0000-000000000000') | Test-IsGuid + $r.Count | Should -Be 3 + ($r -join ',') | Should -BeExactly 'False,False,True' + foreach($v in $r){$v | Should -BeOfType ([bool])} + } +} diff --git a/docs/GUID.md b/docs/GUID.md new file mode 100644 index 0000000..254be9a --- /dev/null +++ b/docs/GUID.md @@ -0,0 +1,9 @@ +# Test-IsGuid: accepted formats + +PowerShell 7.4+. Dot-source Test-IsGuid.ps1 as before. This is an independent standalone correction: IT-ToolBox does not export an equivalent function and is not changed. + +Guid.TryParseExact accepts only N (32 hex digits), D (standard dashed), B (dashed inside matched braces), and P (dashed inside matched parentheses). Case is ignored. All-zero GUIDs are valid syntax. Surrounding whitespace, multiple lines, partial delimiter pairs, mixed hyphen layouts, undashed values inside braces and the X initializer representation are rejected. This intentionally removes false positives from the old optional-delimiter/multiline regex. It does not prove directory/object existence or require a specific UUID version. + +Null/empty input returns false, including in pipelines. The function returns one Boolean per pipeline input. Existing ObjectGuid parameter name remains; positional invocation remains supported. + +Run Invoke-Pester ./Tests/Guid.Tests.ps1 with Pester 5.7.1. CI runs the root test suites on Windows, Linux and macOS. From b275e47c3cd7b6ffedcf9dcb4579a5903a524d18 Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 00:29:29 +0200 Subject: [PATCH 2/2] Correct literal registry reporting and export behavior --- .github/workflows/registry.yml | 38 +++++ Export-Registry.ps1 | 219 +++++++------------------ Private/Open-StandaloneRegistryKey.ps1 | 25 +++ Test-IsRegistryKey.ps1 | 42 ++--- Tests/Registry.Tests.ps1 | 120 ++++++++++++++ docs/Registry.md | 28 ++++ 6 files changed, 288 insertions(+), 184 deletions(-) create mode 100644 .github/workflows/registry.yml create mode 100644 Private/Open-StandaloneRegistryKey.ps1 create mode 100644 Tests/Registry.Tests.ps1 create mode 100644 docs/Registry.md diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml new file mode 100644 index 0000000..91be334 --- /dev/null +++ b/.github/workflows/registry.yml @@ -0,0 +1,38 @@ +name: Registry reporting + +on: + push: + branches: [master] + paths: ['Export-Registry.ps1', 'Test-IsRegistryKey.ps1', 'Private/Open-StandaloneRegistryKey.ps1', 'Tests/Registry.Tests.ps1', '.github/workflows/registry.yml'] + pull_request: + paths: ['Export-Registry.ps1', 'Test-IsRegistryKey.ps1', 'Private/Open-StandaloneRegistryKey.ps1', 'Tests/Registry.Tests.ps1', '.github/workflows/registry.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = './Tests' + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/Export-Registry.ps1 b/Export-Registry.ps1 index c9952f1..c6f3c26 100644 --- a/Export-Registry.ps1 +++ b/Export-Registry.ps1 @@ -1,182 +1,87 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/Open-StandaloneRegistryKey.ps1') + function Export-Registry { <# - .SYNOPSIS - Export registry item properties. - - .DESCRIPTION - Export item properties for a given registry key. - - By default results will be written to the pipeline unless the -ExportFormat parameter is used. - - .PARAMETER KeyPath - A string representing the Key(s) to export in the PsDrive format IE: HKCU:\SOFTWARE\TestSoftware - - .PARAMETER ExportFormat - A string representing the format to use for the export. - - Possible values are: - - - CSV - - XML - - PArameter is used in conjunction with the ExportPath paramter. - - .PARAMETER ExportPath - A string representing the path where keys should be exported. - - .PARAMETER NoBinaryData - When parameter is specified any binary data present in the registry key is removed. - - .EXAMPLE - PS C:\> Export-RegistryNew -KeyPath 'HKCU:\SOFTWARE\TestSoftware' - - .NOTES - Additional information about the function. + .SYNOPSIS + Reports values from literal Windows Registry keys to objects, CSV or CLIXML. + .DESCRIPTION + Reads values on the supplied keys only, not descendant keys. Missing keys + warn and continue. Empty keys yield no rows. The unnamed value has Name ''. + Path is a stable native key-name string. XML preserves typed values; CSV is + a text report and does not preserve array/binary types. Neither is a .reg + backup. Existing export files are overwritten only after ShouldProcess. #> - - [CmdletBinding(DefaultParameterSetName = 'PrintOnly')] - param - ( - [Parameter(ParameterSetName = 'PrintOnly', - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true, - Position = 0, - HelpMessage = 'Enter a registry path using the PSDrive format (IE: HKCU:\SOFTWARE\TestSoftware')] - [Parameter(ParameterSetName = 'Export', - Mandatory = $true)] - [ValidateNotNullOrEmpty()] + [CmdletBinding(DefaultParameterSetName = 'PrintOnly', SupportsShouldProcess)] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('PSPath')] - [string[]] - $KeyPath, - [Parameter(ParameterSetName = 'Export', - Mandatory = $true)] - [ValidateSet('xml', 'csv', 'reg', IgnoreCase = $true)] [ValidateNotNullOrEmpty()] - [string] - $ExportFormat, - [Parameter(ParameterSetName = 'Export', - Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $ExportPath, - [Parameter(ParameterSetName = 'Export')] - [switch] - $NoBinaryData - ) + [string[]]$KeyPath, - begin - { - # Initialize results array - [System.Collections.ArrayList]$returnData = @() - } + [Parameter(ParameterSetName = 'Export', Mandatory)] + [ValidateSet('xml', 'csv')] + [string]$ExportFormat, + [Parameter(ParameterSetName = 'Export', Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ExportPath, + + [switch]$NoBinaryData + ) + begin { $rows = [System.Collections.Generic.List[object]]::new() } process { - # Go through all paths foreach ($path in $KeyPath) { - if ((Test-IsRegistryKey -KeyPath $path) -eq $true) + $key = Open-StandaloneRegistryKey -Path $path + if ($null -eq $key) { - Write-Verbose "Getting properties for key: $path" - - # Get registry item - $paramGetItem = @{ - Path = $path - ErrorAction = 'Stop' - } - - [Microsoft.Win32.RegistryKey]$regItem = Get-Item @paramGetItem - - # Get key properties - [array]$regItemProperties = $regItem.'Property' - - if ($regItemProperties.Count -gt 0) - { - # Enumerate properties - foreach ($property in $regItemProperties) - { - Write-Verbose "Exporting $property" - - # Append data to return array - [void]($returnData.Add([pscustomobject]@{ - 'Path' = $regItem - 'Name' = $property - 'Value' = $regItem.GetValue($property, $null, 'DoNotExpandEnvironmentNames') - 'Type' = $regItem.GetValueKind($property) - 'Computername' = $env:computername - })) - } - } - else - { - # Return default object - [void]($returnData.Add([pscustomobject]@{ - 'Path' = $regItem - 'Name' = '(Default)' - 'Value' = $null - 'Type' = 'String' - 'Computername' = $env:computername - })) - } + Write-Warning "Key $path does not exist." + continue } - else + try { - Write-Warning -Message "Key $path does not exist" - - continue + foreach ($name in $key.GetValueNames()) + { + $kind = $key.GetValueKind($name) + if ($NoBinaryData -and $kind -eq [Microsoft.Win32.RegistryValueKind]::Binary) { continue } + $rows.Add([pscustomobject]@{ + Path = [string]$key.Name + Name = [string]$name + Value = $key.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + Type = [string]$kind + Computername = [Environment]::MachineName + }) + } } + finally { $key.Dispose() } } } - end { - # Check we have results - if ($null -ne $returnData) + if ($PSCmdlet.ParameterSetName -eq 'PrintOnly') { - switch ($PSCmdlet.ParameterSetName) - { - 'Export' - { - # Remove binary data - if ($PSBoundParameters.ContainsKey('NoBinaryData')) - { - Write-Verbose -Message 'Removing binary data from return values' - - # Remove binary data - $returnData = $returnData | Where-Object { $_.Type -ne 'Binary' } - } - - switch ($ExportFormat) - { - 'csv' - { - # Export to CSV - $returnData | Export-Csv -Path $ExportPath -NoTypeInformation -Force - } - 'xml' - { - # Export to XML and overwrite - $returnData | Export-Clixml -Path $ExportPath -Force - } - } - - Write-Verbose -Message "Data written to $ExportPath" - } - - default - { - Write-Verbose -Message 'No data will be exported' - - # Print on screen only - $returnData - } - } + $rows.ToArray() } - else + elseif ($rows.Count -eq 0) { - Write-Warning -Message 'No found - No export will be created' + Write-Warning 'No values matched; no export file was created or changed.' + } + elseif ($PSCmdlet.ShouldProcess($ExportPath, "Overwrite $ExportFormat registry report")) + { + if ($ExportFormat -eq 'csv') + { + $rows.ToArray() | Export-Csv -LiteralPath $ExportPath -NoTypeInformation -Force -ErrorAction Stop + } + else + { + $rows.ToArray() | Export-Clixml -LiteralPath $ExportPath -Force -ErrorAction Stop + } + Write-Verbose "Data written to $ExportPath" } } -} \ No newline at end of file +} diff --git a/Private/Open-StandaloneRegistryKey.ps1 b/Private/Open-StandaloneRegistryKey.ps1 new file mode 100644 index 0000000..84a5f77 --- /dev/null +++ b/Private/Open-StandaloneRegistryKey.ps1 @@ -0,0 +1,25 @@ +function Open-StandaloneRegistryKey +{ + [CmdletBinding()] + param ([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path) + + if (-not $IsWindows) + { + throw [PlatformNotSupportedException]::new('Registry functions require the Windows Registry provider.') + } + $provider = $null + $drive = $null + [void]$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path, [ref]$provider, [ref]$drive) + if ($provider.Name -ne 'Registry') + { + throw 'Path must use the Registry provider.' + } + try + { + return Get-Item -LiteralPath $Path -ErrorAction Stop + } + catch [System.Management.Automation.ItemNotFoundException] + { + return $null + } +} diff --git a/Test-IsRegistryKey.ps1 b/Test-IsRegistryKey.ps1 index c2e8a63..e9265f3 100644 --- a/Test-IsRegistryKey.ps1 +++ b/Test-IsRegistryKey.ps1 @@ -1,34 +1,22 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/Open-StandaloneRegistryKey.ps1') + function Test-IsRegistryKey { <# - .SYNOPSIS - Cmdlet will check if the specified registry key is valid. - - .DESCRIPTION - Cmdlet will check if the specified registry path is valid. - - .PARAMETER KeyPath - A string representing the registry path to check in the PSDrive format IE HKLM:\SOFTWARE - - .EXAMPLE - PS C:\> Test-IsRegistryKey -KeyPath 'value1' + .SYNOPSIS + Tests whether a literal Windows Registry key exists. + .DESCRIPTION + Missing keys return false. Provider, permission and operational errors + terminate. Non-Windows systems are unsupported. This tests keys, not values. #> - + [CmdletBinding()] [OutputType([bool])] - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $KeyPath - ) + param ([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$KeyPath) - if (Test-Path -Path $KeyPath) - { - return (Get-Item -Path $KeyPath).PsProvider.Name -match 'Registry' - } - else - { - return $false - } + $key = Open-StandaloneRegistryKey -Path $KeyPath + if ($null -eq $key) { return $false } + try { return $true } + finally { $key.Dispose() } } diff --git a/Tests/Registry.Tests.ps1 b/Tests/Registry.Tests.ps1 new file mode 100644 index 0000000..abfa002 --- /dev/null +++ b/Tests/Registry.Tests.ps1 @@ -0,0 +1,120 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '../Export-Registry.ps1') + . (Join-Path $PSScriptRoot '../Test-IsRegistryKey.ps1') + function New-FakeRegistryKey { + param([switch]$Empty) + $key=[pscustomobject]@{Name='HKEY_CURRENT_USER\Example';Disposed=$false;Empty=$Empty.IsPresent} + $key | Add-Member ScriptMethod GetValueNames { if(-not $this.Empty){ @('Text','Bytes','') } } + $key | Add-Member ScriptMethod GetValueKind {param($Name) if($Name-eq'Bytes'){[Microsoft.Win32.RegistryValueKind]::Binary}else{[Microsoft.Win32.RegistryValueKind]::String} } + $key | Add-Member ScriptMethod GetValue {param($Name,$Default,$Options) if($Name-eq'Bytes'){return ,([byte[]]@(1,2))} elseif($Name-eq''){return 'default'}else{return '%TEMP%'} } + $key | Add-Member ScriptMethod Dispose { $this.Disposed=$true } + return $key + } +} +Describe 'Registry reporting with offline key doubles' { + BeforeEach { + $script:key=New-FakeRegistryKey + Mock Open-StandaloneRegistryKey {$script:key} + } + It 'returns stable path strings and preserves unexpanded data' { + $rows=@(Export-Registry -KeyPath 'HKCU:\Example') + $rows.Count | Should -Be 3 + $rows[0].Path | Should -BeExactly 'HKEY_CURRENT_USER\Example' + $rows[0].Path | Should -BeOfType ([string]) + $rows[0].Value | Should -BeExactly '%TEMP%' + $rows[2].Name | Should -BeExactly '' + $script:key.Disposed | Should -BeTrue + } + It 'preserves binary rows with NoBinaryData false' { + $rows=@(Export-Registry 'HKCU:\Example' -NoBinaryData:$false) + @($rows | Where-Object Type -eq Binary).Count | Should -Be 1 + } + It 'omits binary data only when enabled' { + @(Export-Registry 'HKCU:\Example' -NoBinaryData).Count | Should -Be 2 + } + It 'handles pipeline keys and literal metacharacters' { + $null=@('HKCU:\A[1]','HKCU:\B*') | Export-Registry + Should -Invoke Open-StandaloneRegistryKey -Times 1 -Exactly -ParameterFilter {$Path -ceq 'HKCU:\A[1]'} + Should -Invoke Open-StandaloneRegistryKey -Times 1 -Exactly -ParameterFilter {$Path -ceq 'HKCU:\B*'} + } + It 'does not invent a default value for an empty key' { + $script:key=New-FakeRegistryKey -Empty + @(Export-Registry 'HKCU:\Empty').Count | Should -Be 0 + $script:key.Disposed | Should -BeTrue + } + It 'warns and returns no rows for a missing key' { + Mock Open-StandaloneRegistryKey {$null} + @(Export-Registry 'HKCU:\Missing' -WarningAction SilentlyContinue).Count | Should -Be 0 + Test-IsRegistryKey 'HKCU:\Missing' | Should -BeFalse + } + It 'propagates permission errors' { + Mock Open-StandaloneRegistryKey {throw [UnauthorizedAccessException]::new('Access denied')} + {Export-Registry 'HKCU:\Denied'} | Should -Throw '*Access denied*' + {Test-IsRegistryKey 'HKCU:\Denied'} | Should -Throw '*Access denied*' + } + It 'closes the key even when reading fails' { + $script:key | Add-Member ScriptMethod GetValueNames {throw 'Read failed'} -Force + {Export-Registry 'HKCU:\Example'} | Should -Throw '*Read failed*' + $script:key.Disposed | Should -BeTrue + } + It 'tests key existence and closes the handle' { + Test-IsRegistryKey 'HKCU:\Example' | Should -BeTrue + $script:key.Disposed | Should -BeTrue + } + It 'exports CSV and CLIXML with literal output paths' { + $csv=Join-Path $TestDrive 'report[1].csv' + Export-Registry 'HKCU:\Example' -ExportFormat csv -ExportPath $csv + @(Import-Csv -LiteralPath $csv).Count | Should -Be 3 + $xml=Join-Path $TestDrive 'report[1].xml' + Export-Registry 'HKCU:\Example' -ExportFormat xml -ExportPath $xml + $rows=@(Import-Clixml -LiteralPath $xml) + (@($rows | Where-Object Type -eq Binary)[0].Value -is [byte[]]) | Should -BeTrue + } + It 'honors WhatIf without changing a report' { + $path=Join-Path $TestDrive 'keep.csv';Set-Content $path 'keep' + Export-Registry 'HKCU:\Example' -ExportFormat csv -ExportPath $path -WhatIf + (Get-Content $path) | Should -BeExactly 'keep' + } + It 'does not truncate existing reports when no values match' { + $script:key=New-FakeRegistryKey -Empty + $path=Join-Path $TestDrive 'keep.csv';Set-Content $path 'keep' + Export-Registry 'HKCU:\Empty' -ExportFormat csv -ExportPath $path -WarningAction SilentlyContinue + (Get-Content $path) | Should -BeExactly 'keep' + } + It 'rejects unsupported reg exports at binding' { + {Export-Registry 'HKCU:\Example' -ExportFormat reg -ExportPath (Join-Path $TestDrive 'no.reg')} | Should -Throw + Should -Invoke Open-StandaloneRegistryKey -Times 0 -Exactly + } + It 'propagates filesystem errors' { + {Export-Registry 'HKCU:\Example' -ExportFormat csv -ExportPath $TestDrive} | Should -Throw + } +} +Describe 'Real registry platform boundary' { + It 'rejects non-Windows registry calls' -Skip:$IsWindows { + {Test-IsRegistryKey 'HKCU:\Software'} | Should -Throw '*Windows Registry provider*' + {Export-Registry 'HKCU:\Software'} | Should -Throw '*Windows Registry provider*' + } + It 'uses literal HKCU keys and actual value types on Windows' -Skip:(-not $IsWindows) { + $parent='HKCU:\Software\RegistryFunctionTests-'+[guid]::NewGuid().ToString('N') + $path=$parent+'\Literal[1]' + try { + $null=New-Item -Path $parent -Force + $key=Get-Item -LiteralPath $parent + try {$child=$key.CreateSubKey('Literal[1]')}finally{$key.Dispose()} + try { + $child.SetValue('Bytes',[byte[]]@(1,2),[Microsoft.Win32.RegistryValueKind]::Binary) + $child.SetValue('Text','%TEMP%',[Microsoft.Win32.RegistryValueKind]::ExpandString) + $child.SetValue('', 'unnamed',[Microsoft.Win32.RegistryValueKind]::String) + } finally {$child.Dispose()} + Test-IsRegistryKey $path | Should -BeTrue + $rows=@(Export-Registry $path -NoBinaryData:$false) + $rows.Count | Should -Be 3 + ($rows | Where-Object Name -eq Text).Value | Should -BeExactly '%TEMP%' + @(Export-Registry $path -NoBinaryData).Count | Should -Be 2 + Test-IsRegistryKey ($parent+'\Missing') | Should -BeFalse + {Test-IsRegistryKey $TestDrive} | Should -Throw '*Registry provider*' + } finally { + if(Test-Path -LiteralPath $parent){Remove-Item -LiteralPath $parent -Recurse -Force} + } + } +} diff --git a/docs/Registry.md b/docs/Registry.md new file mode 100644 index 0000000..51da8fa --- /dev/null +++ b/docs/Registry.md @@ -0,0 +1,28 @@ +# Registry reporting corrections + +PowerShell 7.4 on Windows is required for real registry operations. Test-IsRegistryKey and Export-Registry retain their root filenames and load Private/Open-StandaloneRegistryKey.ps1. Keep that helper when copying scripts. IT-ToolBox's Test-RegistryValue is not a substitute: it checks named values, while Test-IsRegistryKey checks key existence. IT-ToolBox is unchanged. + +## Contract changes + +- Keys and output filenames are literal paths. Wildcard metacharacters are not expanded. +- Missing keys return false from Test-IsRegistryKey. Export-Registry warns and continues; permission/provider/operational errors terminate. +- Registry handles are disposed after existence checks or value enumeration, including error paths. +- ExportFormat accepts csv and xml. The previously advertised reg option had no implementation and now fails parameter binding. For a restorable registry backup use an appropriate registry-native backup/export tool; this function creates reports. +- Output Path is a native registry-name string, not a live RegistryKey. Name is the actual value name; an existing unnamed/default value has Name ''. Empty keys yield no rows rather than a fabricated default value. +- NoBinaryData is available for object output and file output. Explicit NoBinaryData:$false preserves binary rows. +- XML uses CLIXML and preserves supported value types. CSV is a text report: arrays/binary data are not losslessly serialized by this format and cannot be treated as a backup. ExpandString values are read without expanding environment variables. +- No matched values means no export file is created or overwritten, even if filtering removed every row. Existing output files otherwise retain the historical overwrite behavior. +- WhatIf/Confirm guard file creation/overwrite, not the read-only registry queries. File errors terminate; success is reported only after the write succeeds. +- Reads values on the named keys only; it does not recursively enumerate descendants. + +## Examples + +```powershell +. ./Test-IsRegistryKey.ps1 +. ./Export-Registry.ps1 +Test-IsRegistryKey -KeyPath 'HKCU:\Software\Example' +Export-Registry -KeyPath 'HKCU:\Software\Example' -NoBinaryData +Export-Registry -KeyPath 'HKCU:\Software\Example' -ExportFormat xml -ExportPath './registry-report.xml' -WhatIf +``` + +The tests have offline key doubles for behavior/serialization/error handling, a non-Windows platform rejection check, and a Windows-only integration test that creates and removes a uniquely named temporary key under HKCU\Software. That test does not touch application keys and needs no HKLM writes. Run Invoke-Pester ./Tests/Registry.Tests.ps1 with Pester 5.7.1. The Windows CI result is required before production use; an offline passing run is not a Windows integration result.