Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions IT-ToolBox.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
'Test-IsUrl'
'Convert-LogonTimestamp'
'Get-OsUpTime'
'Remove-SpecialCharacters'
'Test-RegistryValue'
'New-StringEncryption'
'New-StringDecryption'
'New-RandomString'
Expand Down
2 changes: 2 additions & 0 deletions IT-ToolBox.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Export-ModuleMember -Function @(
'Test-IsUrl'
'Convert-LogonTimestamp'
'Get-OsUpTime'
'Remove-SpecialCharacters'
'Test-RegistryValue'
'New-StringEncryption'
'New-StringDecryption'
'New-RandomString'
Expand Down
111 changes: 111 additions & 0 deletions Public/Remove-SpecialCharacters.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
37 changes: 37 additions & 0 deletions Public/Test-RegistryValue.ps1
Original file line number Diff line number Diff line change
@@ -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() }
}
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
3 changes: 1 addition & 2 deletions Staging/v3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
100 changes: 0 additions & 100 deletions Staging/v3/Remove-SpecialCharacters.ps1

This file was deleted.

26 changes: 0 additions & 26 deletions Staging/v3/Test-RegistryValue.ps1

This file was deleted.

Loading
Loading