From e63ce255ff0690ba4056f4eb3de8b58d4c3005f6 Mon Sep 17 00:00:00 2001 From: Initzero Date: Mon, 5 Oct 2026 08:20:01 +0200 Subject: [PATCH 1/2] Restore filesystem naming and registry value helpers --- CHANGELOG.md | 11 ++ IT-ToolBox.psd1 | 2 + IT-ToolBox.psm1 | 2 + Public/Remove-SpecialCharacters.ps1 | 111 +++++++++++++++++ Public/Test-RegistryValue.ps1 | 37 ++++++ README.md | 50 +++++++- Staging/v3/README.md | 3 +- Staging/v3/Remove-SpecialCharacters.ps1 | 100 --------------- Staging/v3/Test-RegistryValue.ps1 | 26 ---- Tests/FilesystemRegistry.Tests.ps1 | 156 ++++++++++++++++++++++++ Tests/Module.Tests.ps1 | 2 +- 11 files changed, 367 insertions(+), 133 deletions(-) create mode 100644 Public/Remove-SpecialCharacters.ps1 create mode 100644 Public/Test-RegistryValue.ps1 delete mode 100644 Staging/v3/Remove-SpecialCharacters.ps1 delete mode 100644 Staging/v3/Test-RegistryValue.ps1 create mode 100644 Tests/FilesystemRegistry.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1c555..43fa79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Filesystem naming and registry restoration (unreleased) + +- Restore Remove-SpecialCharacters and Test-RegistryValue; export twenty-four commands. +- Preserve the historical punctuation policy; return a rename preview by default. +- Honor WhatIf/Confirm, preflight collisions and rename deepest descendants first. +- Use literal paths, include hidden entries and skip symbolic links/junctions. +- Preserve LogActivites with a corrected alias and configurable portable log path. +- Check Windows registry value names regardless of data; support unnamed values. +- Propagate operational failures and document platform and non-transactional behavior. +- Add real filesystem tests and Windows-only temporary HKCU integration tests. + ## Timestamp and uptime restoration (unreleased) - Restore Convert-LogonTimestamp and Get-OsUpTime; export twenty-two supported commands. diff --git a/IT-ToolBox.psd1 b/IT-ToolBox.psd1 index 6757fb5..493511f 100644 --- a/IT-ToolBox.psd1 +++ b/IT-ToolBox.psd1 @@ -22,6 +22,8 @@ 'Test-IsUrl' 'Convert-LogonTimestamp' 'Get-OsUpTime' + 'Remove-SpecialCharacters' + 'Test-RegistryValue' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/IT-ToolBox.psm1 b/IT-ToolBox.psm1 index 0b55c55..06114f1 100644 --- a/IT-ToolBox.psm1 +++ b/IT-ToolBox.psm1 @@ -22,6 +22,8 @@ Export-ModuleMember -Function @( 'Test-IsUrl' 'Convert-LogonTimestamp' 'Get-OsUpTime' + 'Remove-SpecialCharacters' + 'Test-RegistryValue' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/Public/Remove-SpecialCharacters.ps1 b/Public/Remove-SpecialCharacters.ps1 new file mode 100644 index 0000000..9a6c6a2 --- /dev/null +++ b/Public/Remove-SpecialCharacters.ps1 @@ -0,0 +1,111 @@ +function Remove-SpecialCharacters { + <# + .SYNOPSIS + Previews or renames descendant files and directories using the historical character policy. + .DESCRIPTION + Replaces !@#$%^&*(){}[]":;,<>/|\+=`~ and apostrophe with a hyphen. Spaces, + Unicode and other characters are preserved. This is a naming policy, not + universal filename validation. The root itself and symbolic links/junctions + are not renamed; links are never traversed. Hidden items are included. + Without -AutoFix, returns preview records. AutoFix preflights collisions before + any renames, then processes children before parents and honors WhatIf/Confirm. + This is not a transaction: an I/O error can leave earlier renames completed. + Paths in records describe each individual rename, before ancestor renames. + .PARAMETER LogActivites + Historical spelling; LogActivities is an alias. Logs affected-item records. + .PARAMETER LogFilePath + Log path; defaults to Remove-SpecialCharacters-yyyyMMdd.log inside ItemsPath. + .EXAMPLE + Remove-SpecialCharacters -ItemsPath ./files + .EXAMPLE + Remove-SpecialCharacters -ItemsPath ./files -AutoFix -WhatIf + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$ItemsPath, + [switch]$AutoFix, + [Alias('LogActivities')] + [switch]$LogActivites, + [ValidateNotNullOrEmpty()] + [string]$LogFilePath + ) + + $root = Get-Item -LiteralPath $ItemsPath -Force -ErrorAction Stop + if ($root.PSProvider.Name -ne 'FileSystem' -or -not $root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'ItemsPath must be a filesystem directory, not a link or junction.' + } + if ($LogActivites -and -not $LogFilePath) { + $LogFilePath = Join-Path $root.FullName ('Remove-SpecialCharacters-{0}.log' -f [datetime]::Now.ToString('yyyyMMdd')) + } + if ($LogActivites) { $LogFilePath = Resolve-NewLogEntryPath -Path $LogFilePath } + $invalidChars = '!@#$%^&*(){}[]":;,<>/|\+=`~''' + $pattern = ($invalidChars.ToCharArray() | ForEach-Object { [regex]::Escape([string]$_) }) -join '|' + $comparer = if ($IsWindows -or $IsMacOS) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal } + $existing = [System.Collections.Generic.HashSet[string]]::new($comparer) + $destinations = [System.Collections.Generic.Dictionary[string,object]]::new($comparer) + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push(@{ Path = $root.FullName; Depth = 0 }) + $plan = [System.Collections.Generic.List[object]]::new() + while ($pending.Count -gt 0) { + $directory = $pending.Pop() + foreach ($item in Get-ChildItem -LiteralPath $directory.Path -Force -ErrorAction Stop) { + [void]$existing.Add($item.FullName) + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + $depth = $directory.Depth + 1 + if ($item.PSIsContainer) { $pending.Push(@{ Path = $item.FullName; Depth = $depth }) } + if ($item.Name -notmatch $pattern) { continue } + $newName = $item.Name -replace $pattern, '-' + $destination = Join-Path (Split-Path $item.FullName -Parent) $newName + $record = [pscustomobject]@{ + Path = $item.FullName + NewName = $newName + DestinationPath = $destination + ItemType = if ($item.PSIsContainer) { 'Directory' } else { 'File' } + Depth = $depth + HasCollision = $false + Status = 'Preview' + } + if ($destinations.ContainsKey($destination)) { + $record.HasCollision = $true + $destinations[$destination].HasCollision = $true + } + else { $destinations.Add($destination, $record) } + $plan.Add($record) + } + } + foreach ($record in $plan) { + if ($existing.Contains($record.DestinationPath)) { $record.HasCollision = $true } + } + if ($LogActivites) { + $comparison = if ($IsWindows -or $IsMacOS) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + foreach ($record in $plan) { + $sourcePrefix = $record.Path + [IO.Path]::DirectorySeparatorChar + $destinationPrefix = $record.DestinationPath + [IO.Path]::DirectorySeparatorChar + if ($comparer.Equals($LogFilePath, $record.Path) -or $comparer.Equals($LogFilePath, $record.DestinationPath) -or + ($record.ItemType -eq 'Directory' -and ($LogFilePath.StartsWith($sourcePrefix, $comparison) -or $LogFilePath.StartsWith($destinationPrefix, $comparison)))) { + throw 'The activity log path must not participate in the rename plan or lie inside a renamed directory.' + } + } + } + if ($AutoFix -and @($plan | Where-Object HasCollision).Count -gt 0) { + throw 'Rename collisions detected. Run without -AutoFix to inspect HasCollision; no items were renamed.' + } + foreach ($record in ($plan | Sort-Object @{ Expression = 'Depth'; Descending = $true }, Path)) { + if ($AutoFix) { + $record.Status = 'Skipped' + if ($PSCmdlet.ShouldProcess($record.Path, ('Rename to {0}' -f $record.NewName))) { + Rename-Item -LiteralPath $record.Path -NewName $record.NewName -Force -ErrorAction Stop + $record.Status = 'Renamed' + } + } + # WhatIf suppresses optional logging as well as renames. + if ($LogActivites -and $PSCmdlet.ShouldProcess($LogFilePath, 'Append activity log')) { + New-LogEntry -LogFilePath $LogFilePath -LogMessage ('{0}: {1} -> {2}; collision={3}' -f $record.Status, $record.Path, $record.DestinationPath, $record.HasCollision) -NoConsole + } + $record + } +} diff --git a/Public/Test-RegistryValue.ps1 b/Public/Test-RegistryValue.ps1 new file mode 100644 index 0000000..a9460b3 --- /dev/null +++ b/Public/Test-RegistryValue.ps1 @@ -0,0 +1,37 @@ +function Test-RegistryValue { + <# + .SYNOPSIS + Tests whether a named registry value exists on Windows. + .DESCRIPTION + Uses a literal Registry-provider path and checks value names, not their data. + Empty strings, zero and unexpanded environment-variable data still count as + existing values. Missing keys/values return false; permissions and other + operational errors terminate. Non-Windows hosts throw PlatformNotSupportedException. + .PARAMETER Value + Value name; an empty string selects the unnamed/default registry value. + .EXAMPLE + Test-RegistryValue -Path 'HKCU:\Software\Example' -Value 'Enabled' + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$Path, + [Parameter(Mandatory = $true, Position = 1)] + [AllowEmptyString()] + [ValidateNotNull()] + [string]$Value + ) + if (-not $IsWindows) { + throw [PlatformNotSupportedException]::new('Test-RegistryValue requires the Windows Registry provider.') + } + [System.Management.Automation.ProviderInfo]$provider = $null + [System.Management.Automation.PSDriveInfo]$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 { $key = Get-Item -LiteralPath $Path -ErrorAction Stop } + catch [System.Management.Automation.ItemNotFoundException] { return $false } + try { return ($key.GetValueNames() -contains $Value) } + finally { $key.Dispose() } +} diff --git a/README.md b/README.md index 7c31fbb..8289e05 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The foundation imports without WinSCP, GnuPG, Active Directory or Exchange depen | Test-IsUrl | Absolute HTTP/HTTPS/FTP/FTPS URL syntax | | Convert-LogonTimestamp | AD FILETIME conversion with local/UTC output | | Get-OsUpTime | Local OS uptime and remote Windows CIM queries | +| Remove-SpecialCharacters | Preview or apply a recursive filesystem naming policy | +| Test-RegistryValue | Windows registry value-name existence check | | New-StringEncryption | Passphrase-based AES-256-GCM string encryption | | New-StringDecryption | Authenticate and decrypt the versioned string format | | New-RandomString | Secure random selection from the historical alphabet | @@ -62,10 +64,9 @@ Redaction is opt-in and does not guarantee detection of every secret. - SCP and GnuPG wrappers and bundled WinSCP binaries are removed. Separate modules will own file transfer and OpenPGP; no replacement is bundled here. - `Legacy/` retains string encryption, Exchange and script-context helpers for reference. -- `Staging/v3/` retains 5 candidate commands pending tests and compatibility fixes. - These cover report chains, character removal, distinguished names, - user principal names and registry values. They are not currently exported. -- Only the twenty-two listed commands are exported. Private helpers, variables and aliases +- `Staging/v3/` retains 3 candidate commands pending tests and compatibility fixes. + These cover report chains, distinguished names and user principal names. They are not currently exported. +- Only the twenty-four listed commands are exported. Private helpers, variables and aliases are not exported. Existing calls to other v2 commands require the v2 release until those commands return to the supported API. - The module GUID and Git history are preserved. @@ -282,3 +283,44 @@ is not changed. Tests cover actual local uptime and mocked remote query/session behavior. Remote Windows connectivity and authentication have not been integration-tested. + +## Filesystem naming policy and registry values + +`Remove-SpecialCharacters` scans descendant files and directories, including hidden +items, and replaces the original punctuation list with a hyphen. Spaces and Unicode +are preserved. Despite its name, it operates on filesystem names, not input strings, +and its policy is not universal filename validation. The root is never renamed. + +```powershell +# Preview first; records include Path, NewName, DestinationPath, HasCollision and Status. +Remove-SpecialCharacters -ItemsPath './files' +Remove-SpecialCharacters -ItemsPath './files' -AutoFix -WhatIf +Remove-SpecialCharacters -ItemsPath './files' -AutoFix +``` + +Preview is the default and creates no files unless logging is requested. AutoFix +preflights existing destinations and duplicate proposed names before any rename, +then processes deepest items first. Collision checks are conservatively insensitive +to case on Windows and macOS, and case-sensitive on Linux. Links and junctions are +not renamed or followed. Literal paths prevent wildcard expansion. Rename errors +terminate; this is not a transaction and an I/O failure can leave earlier renames +completed. Reported paths describe individual operations before ancestor renames. +Avoid changing the tree concurrently with this command. + +The historical `-LogActivites` spelling remains supported, with `-LogActivities` as +an alias. An optional `-LogFilePath` overrides the default activity log inside the +root directory. Log paths that participate in the rename plan or lie inside a +directory being renamed are rejected before writes. Only affected-item records +are logged; WhatIf suppresses logging. +This replaces the staged implementation's silent default output, fixed C:\Temp +logging and swallowed errors. + +`Test-RegistryValue -Path 'HKCU:\Software\Example' -Value 'Enabled'` is Windows-only. +It checks literal value names, case-insensitively, and returns true for existing +empty strings or zero data. Use `-Value ''` for an unnamed/default value. Missing +keys or values return false; permission failures and other operational errors +terminate. Non-Registry provider paths are rejected. This does not inspect remote +registries or select an alternate registry view. + +Filesystem tests use real temporary trees. Windows CI additionally exercises real +temporary HKCU keys; those registry integration tests are skipped on Linux/macOS. diff --git a/Staging/v3/README.md b/Staging/v3/README.md index 466b6fb..81c0bc5 100644 --- a/Staging/v3/README.md +++ b/Staging/v3/README.md @@ -2,6 +2,5 @@ These existing utilities are retained unchanged but are not loaded or exported. They return to Public only after behavioral tests and necessary PowerShell 7 fixes. -Remaining candidates cover report chains, character removal, distinguished names, -user principal names and registry values. +Remaining candidates cover report chains, distinguished names and user principal names. This folder is not a supported API and must not be dot-sourced as part of normal module import. diff --git a/Staging/v3/Remove-SpecialCharacters.ps1 b/Staging/v3/Remove-SpecialCharacters.ps1 deleted file mode 100644 index ce760e1..0000000 --- a/Staging/v3/Remove-SpecialCharacters.ps1 +++ /dev/null @@ -1,100 +0,0 @@ -function Remove-SpecialCharacters -{ -<# - .SYNOPSIS - Removes invalid characters from files/folders names - - .DESCRIPTION - A detailed description of the Remove-SpecialCharacters function. - - .PARAMETER ItemsPath - Specifies the ItemsPath to scan for files/folders - - .PARAMETER AutoFix - If specified function will automatically replace characters in the file/directory name - - .PARAMETER LogActivites - If used all activies will be logged to C:\Temp - - .EXAMPLE - PS C:\> Remove-SpecialCharacters -ItemsPath $value1 - - .NOTES - Additional information about the function. -#> - - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true, - ValueFromPipeline = $false, - ValueFromPipelineByPropertyName = $false, - ValueFromRemainingArguments = $false, - HelpMessage = 'Specifies the path to scan for files/folders')] - [string] - $ItemsPath, - [Parameter(Mandatory = $false, - ValueFromPipeline = $false, - ValueFromPipelineByPropertyName = $false, - ValueFromRemainingArguments = $false, - HelpMessage = 'If specified function will automatically replace characters in the file/directory name')] - [switch] - $AutoFix, - [Parameter(Mandatory = $false, - ValueFromPipeline = $false, - ValueFromPipelineByPropertyName = $false, - ValueFromRemainingArguments = $false, - HelpMessage = 'If used all activies will be logged to C:\Temp')] - [switch] - $LogActivites - ) - - # Define invalid characters to replace - #[char[]]$invalidChars = '!@#$%^&*(){}[]":;,<>/|\+=`~ ''' # Match spaces - [char[]]$invalidChars = '!@#$%^&*(){}[]":;,<>/|\+=`~''' - - # Build up a regex to characters - [string]$charsRegex = ($invalidChars | ForEach-Object { [regex]::Escape($_) }) -join '|' - - # Define a static path for activities log file - [string]$logFilePath = 'C:\Temp\' + $env:COMPUTERNAME + '-' + $(Get-Date -Format "yyyy-MM-dd") + ".log" - - # Get all items in the specified path - try - { - $itemsToProcess = Get-ChildItem -Path $ItemsPath -Recurse | Sort-Object FullName -Descending - - foreach ($item in $itemsToProcess) - { - if (($item.Name -match $charsRegex) -and ($AutoFix)) - { - # Rename the offending item - Rename-Item -Path $item.FullName -NewName $($item.Name -replace $charsRegex, '-') - - #$itemsToProcess = Get-ChildItem -Path $ItemsPath -Recurse - - # Check if we should log the task - if ($LogActivites) - { - New-LogEntry -LogFilePath $logFilePath -LogMessage "Item $item contains invalid chars!" - New-LogEntry -LogFilePath $logFilePath -LogMessage "Invalid characters will be replaced with a '-'" - } - } - elseif (($item.Name -match $charsRegex) -and ($LogActivites)) - { - New-LogEntry -LogFilePath $logFilePath -LogMessage "File/Directory $item contains invalid characters!" - } - else - { - if ($LogActivites) - { - New-LogEntry -LogFilePath $logFilePath -LogMessage "File/Directory $item - Does not contain invalid characters. Nothing to do" - } - } - } - } - catch - { - New-LogEntry -LogFilePath $logFilePath -LogMessage "Path not found or permissions issue!" -IsErrorMessage - } -} \ No newline at end of file diff --git a/Staging/v3/Test-RegistryValue.ps1 b/Staging/v3/Test-RegistryValue.ps1 deleted file mode 100644 index ffae137..0000000 --- a/Staging/v3/Test-RegistryValue.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -function Test-RegistryValue -{ - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $Path, - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $Value - ) - - try - { - Get-ItemProperty -Path $Path | Select-Object -ExpandProperty $Value -ErrorAction Stop | Out-Null - - return $true - } - - catch - { - return $false - } -} \ No newline at end of file diff --git a/Tests/FilesystemRegistry.Tests.ps1 b/Tests/FilesystemRegistry.Tests.ps1 new file mode 100644 index 0000000..338c556 --- /dev/null +++ b/Tests/FilesystemRegistry.Tests.ps1 @@ -0,0 +1,156 @@ +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../IT-ToolBox.psd1') -Force -ErrorAction Stop +} + +Describe 'Filesystem rename policy' { + BeforeEach { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $root | Out-Null + } + It 'previews affected names without changing files or their content' { + $path = Join-Path $root 'a!b.txt' + Set-Content -LiteralPath $path -Value 'original content' + Set-Content -LiteralPath (Join-Path $root 'unchanged résumé.txt') -Value 'keep' + $records = @(Remove-SpecialCharacters $root) + $records.Count | Should -Be 1 + $records[0].NewName | Should -Be 'a-b.txt' + $records[0].Status | Should -Be 'Preview' + $records[0].HasCollision | Should -BeFalse + Get-Content -LiteralPath $path | Should -Be 'original content' + } + It 'renames files before their containing directories' { + $folder = New-Item -ItemType Directory -Path (Join-Path $root 'folder!') + Set-Content -LiteralPath (Join-Path $folder.FullName 'report@.txt') -Value 'content' + $records = @(Remove-SpecialCharacters $root -AutoFix -Confirm:$false) + $records.Count | Should -Be 2 + $records[0].ItemType | Should -Be 'File' + ($records.Status | Select-Object -Unique) | Should -Be 'Renamed' + Get-Content -LiteralPath (Join-Path $root 'folder-/report-.txt') | Should -Be 'content' + } + It 'uses literal paths for wildcard-shaped names' { + $folder = Join-Path $root '[folder]' + [void][IO.Directory]::CreateDirectory($folder) + Set-Content -LiteralPath (Join-Path $folder 'a[b].txt') -Value 'literal' + Remove-SpecialCharacters $folder -AutoFix -Confirm:$false | Out-Null + Get-Content -LiteralPath (Join-Path $folder 'a-b-.txt') | Should -Be 'literal' + Test-Path -LiteralPath $folder | Should -BeTrue + } + It 'honors WhatIf for both renames and activity logs' { + $path = Join-Path $root 'name!.txt' + Set-Content -LiteralPath $path -Value 'keep' + $records = @(Remove-SpecialCharacters $root -AutoFix -LogActivities -WhatIf) + $records[0].Status | Should -Be 'Skipped' + Test-Path -LiteralPath $path | Should -BeTrue + @(Get-ChildItem $root -Filter '*.log').Count | Should -Be 0 + } + It 'reports an existing destination and blocks the complete rename plan' { + Set-Content -LiteralPath (Join-Path $root 'a!.txt') -Value 'source' + Set-Content -LiteralPath (Join-Path $root 'a-.txt') -Value 'destination' + Set-Content -LiteralPath (Join-Path $root 'other!.txt') -Value 'other' + $records = @(Remove-SpecialCharacters $root) + ($records | Where-Object HasCollision).Count | Should -Be 1 + { Remove-SpecialCharacters $root -AutoFix -Confirm:$false } | Should -Throw '*collisions*' + Get-Content -LiteralPath (Join-Path $root 'a!.txt') | Should -Be 'source' + Get-Content -LiteralPath (Join-Path $root 'a-.txt') | Should -Be 'destination' + Test-Path -LiteralPath (Join-Path $root 'other!.txt') | Should -BeTrue + } + It 'reports both entries when two sources map to the same destination' { + Set-Content -LiteralPath (Join-Path $root 'a!.txt') -Value 'one' + Set-Content -LiteralPath (Join-Path $root 'a@.txt') -Value 'two' + $records = @(Remove-SpecialCharacters $root) + @($records | Where-Object HasCollision).Count | Should -Be 2 + { Remove-SpecialCharacters $root -AutoFix -Confirm:$false } | Should -Throw '*collisions*' + @(Get-ChildItem $root).Count | Should -Be 2 + } + It 'does not follow or rename directory links and junctions' { + $target = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $target | Out-Null + Set-Content -LiteralPath (Join-Path $target 'outside!.txt') -Value 'outside' + $kind = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } + New-Item -ItemType $kind -Path (Join-Path $root 'link!') -Target $target | Out-Null + @(Remove-SpecialCharacters $root -AutoFix -Confirm:$false).Count | Should -Be 0 + Test-Path -LiteralPath (Join-Path $target 'outside!.txt') | Should -BeTrue + { Remove-SpecialCharacters (Join-Path $root 'link!') } | Should -Throw '*link or junction*' + } + It 'includes hidden entries' { + $path = Join-Path $root '.hidden!.txt' + Set-Content -LiteralPath $path -Value 'hidden' + if ($IsWindows) { (Get-Item -LiteralPath $path).Attributes = [IO.FileAttributes]::Hidden } + @(Remove-SpecialCharacters $root).Count | Should -Be 1 + Remove-SpecialCharacters $root -AutoFix -Confirm:$false | Out-Null + Test-Path -LiteralPath (Join-Path $root '.hidden-.txt') | Should -BeTrue + } + It 'supports the historical logging spelling and corrected alias' { + Set-Content -LiteralPath (Join-Path $root 'name!.txt') -Value 'keep' + $log = Join-Path $TestDrive ([guid]::NewGuid().ToString() + '.log') + Remove-SpecialCharacters $root -LogActivites -LogFilePath $log -Confirm:$false | Out-Null + Remove-SpecialCharacters $root -LogActivities -LogFilePath $log -Confirm:$false | Out-Null + @(Get-Content -LiteralPath $log).Count | Should -Be 2 + Get-Content -LiteralPath $log | Should -Match 'Preview:' + } + It 'rejects logging paths that would move during renaming before any writes' { + $folder = New-Item -ItemType Directory -Path (Join-Path $root 'folder!') + Set-Content -LiteralPath (Join-Path $folder.FullName 'file!.txt') -Value 'keep' + $log = Join-Path $folder.FullName 'activities.log' + { Remove-SpecialCharacters $root -AutoFix -LogActivities -LogFilePath $log -Confirm:$false } | Should -Throw '*activity log path*' + Test-Path -LiteralPath $log | Should -BeFalse + Test-Path -LiteralPath $folder.FullName | Should -BeTrue + } + It 'returns no records for an empty directory' { + @(Remove-SpecialCharacters $root).Count | Should -Be 0 + } + It 'reports missing roots and rejects non-directory or non-filesystem roots' { + { Remove-SpecialCharacters (Join-Path $root 'missing') } | Should -Throw + $file = Join-Path $root 'file.txt' + Set-Content -LiteralPath $file -Value 'file' + { Remove-SpecialCharacters $file } | Should -Throw '*filesystem directory*' + { Remove-SpecialCharacters 'Env:' } | Should -Throw '*filesystem directory*' + } + It 'propagates rename errors without fabricating success' { + Set-Content -LiteralPath (Join-Path $root 'name!.txt') -Value 'keep' + Mock Rename-Item -ModuleName IT-ToolBox { throw 'rename failed' } + { Remove-SpecialCharacters $root -AutoFix -Confirm:$false } | Should -Throw '*rename failed*' + } +} + +Describe 'Registry platform boundary' { + It 'imports and reports unsupported registry access on non-Windows hosts' -Skip:$IsWindows { + { Test-RegistryValue -Path 'HKCU:\Software\Example' -Value 'Name' } | Should -Throw '*Windows Registry provider*' + } +} + +Describe 'Windows registry integration' -Skip:(-not $IsWindows) { + BeforeAll { + $registryPath = 'HKCU:\Software\ITToolBoxTests-' + [guid]::NewGuid().ToString() + New-Item -Path $registryPath | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name Empty -Value '' -PropertyType String | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name Zero -Value 0 -PropertyType DWord | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name 'A*B' -Value 'literal' -PropertyType String | Out-Null + $key = Get-Item -LiteralPath $registryPath + $key.SetValue('', 'default') + $key.Dispose() + } + AfterAll { + Remove-Item -LiteralPath $registryPath -Recurse -Force + } + It 'detects empty and zero-valued entries case-insensitively' { + Test-RegistryValue $registryPath Empty | Should -BeTrue + Test-RegistryValue $registryPath zero | Should -BeTrue + } + It 'detects the unnamed value and literal wildcard-shaped value names' { + Test-RegistryValue $registryPath '' | Should -BeTrue + Test-RegistryValue $registryPath 'A*B' | Should -BeTrue + Test-RegistryValue $registryPath 'A*' | Should -BeFalse + } + It 'returns false for missing keys and values' { + Test-RegistryValue $registryPath Missing | Should -BeFalse + Test-RegistryValue ($registryPath + '\Missing') Name | Should -BeFalse + } + It 'rejects paths from other providers' { + { Test-RegistryValue $TestDrive Name } | Should -Throw '*Registry provider*' + } + It 'propagates access-denied errors' { + Mock Get-Item -ModuleName IT-ToolBox { throw [UnauthorizedAccessException]::new('access denied') } + { Test-RegistryValue $registryPath Name } | Should -Throw '*access denied*' + } +} diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index 1ad2b07..9fe4d96 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -10,7 +10,7 @@ Describe 'IT-ToolBox module boundary' { } It 'exports exactly the supported commands' { - $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object + $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-RegistryValue', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object $actual = @($module.ExportedFunctions.Keys | Sort-Object) ($actual -join ',') | Should -Be ($expected -join ',') $module.ExportedVariables.Count | Should -Be 0 From 90c52e4c1a26d0770e26aabbd59378e8ff48b7e9 Mon Sep 17 00:00:00 2001 From: Initzero Date: Mon, 5 Oct 2026 08:27:11 +0200 Subject: [PATCH 2/2] Fix Windows registry integration test setup --- Tests/FilesystemRegistry.Tests.ps1 | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Tests/FilesystemRegistry.Tests.ps1 b/Tests/FilesystemRegistry.Tests.ps1 index 338c556..83adcef 100644 --- a/Tests/FilesystemRegistry.Tests.ps1 +++ b/Tests/FilesystemRegistry.Tests.ps1 @@ -122,13 +122,12 @@ Describe 'Registry platform boundary' { Describe 'Windows registry integration' -Skip:(-not $IsWindows) { BeforeAll { $registryPath = 'HKCU:\Software\ITToolBoxTests-' + [guid]::NewGuid().ToString() - New-Item -Path $registryPath | Out-Null - New-ItemProperty -LiteralPath $registryPath -Name Empty -Value '' -PropertyType String | Out-Null - New-ItemProperty -LiteralPath $registryPath -Name Zero -Value 0 -PropertyType DWord | Out-Null - New-ItemProperty -LiteralPath $registryPath -Name 'A*B' -Value 'literal' -PropertyType String | Out-Null - $key = Get-Item -LiteralPath $registryPath - $key.SetValue('', 'default') - $key.Dispose() + New-Item -Path $registryPath -ErrorAction Stop | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name Empty -Value '' -PropertyType String -ErrorAction Stop | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name Zero -Value 0 -PropertyType DWord -ErrorAction Stop | Out-Null + New-ItemProperty -LiteralPath $registryPath -Name 'A*B' -Value 'literal' -PropertyType String -ErrorAction Stop | Out-Null + # Get-Item returns a read-only key; use the provider to set its default value. + Set-Item -LiteralPath $registryPath -Value 'default' -ErrorAction Stop } AfterAll { Remove-Item -LiteralPath $registryPath -Recurse -Force