Last active
January 25, 2021 08:09
-
-
Save Jaykul/79e7e86b24b3af68aef59bd98c9279fc to your computer and use it in GitHub Desktop.
The Path commands for Convert and Resolve don't have -Force and don't fix case
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Convert-Path { | |
<# | |
.SYNOPSIS | |
A replacement for convert-path that returns a normalized form of paths, even on Windows file system. | |
.DESCRIPTION | |
This replacement for Convert-Path ensures it's returning the actual case for PowerShell paths, and normalizes them so that folders always include a trailing slash, allowing comparisons of paths to do so correctly. | |
When working with environment variables and paths, we want to remove duplicates, | |
but uniqueness tests are case-sensitive, | |
while the Windows FileSystem (and thus, the built-in Convert-Path) is not. | |
By ensuring we're returning case-correct paths, we ensure that Select-Object -Unique and Get-Unique work. | |
.EXAMPLE | |
New-PSDrive PS FileSystem C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL | |
Set-Location PS:\ | |
Convert-Path .\v*\modules\activedirectory | |
The built-in Convert-Path would return: | |
"C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL\v1.0\modules\ActiveDirectory" | |
This implementation would return the case-sensitive correct path: | |
"C:\Windows\System32\WindowsPowerShell\v1.0\Modules\ActiveDirectory" | |
#> | |
[CmdletBinding()] | |
param( | |
# Specifies the Windows PowerShell paths to be converted. | |
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] | |
[ValidateNotNullOrEmpty()] | |
[Alias("PSPath")] | |
[string[]]$Path | |
) | |
process { | |
try { | |
$EAP, $ErrorActionPreference = $ErrorActionPreference, "Stop" | |
# First, resolve any relative paths or wildcards in the argument | |
# Use Get-Item -Force to make sure it doesn't miss "hidden" items | |
$LiteralPath = @(Get-Item $Path -Force | ForEach-Object { $_.FullName.TrimEnd("/\") }) | |
Write-Verbose "Resolved '$Path' to '$($LiteralPath -join ', ')'" | |
# Then, wildcard in EACH path segment forces OS to look up the actual case of the path | |
# Special cases should NOT get wild cards: | |
# Either slash in the double \\ in a UNC path | |
# Drive letters, like C: or C$ | |
# The server or share name in a UNC path: \\Server\share\ | |
$Wildcarded = $LiteralPath -replace '(?<!(?:^|\\|/|:|\\\\[^\\]*|\\\\[^\\]*\\[^\\]*))(\\|/|$)', '*$1' | |
Write-Verbose "Wildcarded: '$($Wildcarded -join ', ')'" | |
$CaseCorrected = Get-Item $Wildcarded -Force | Microsoft.PowerShell.Management\Convert-Path | |
Write-Verbose "Case correct options: '$($CaseCorrected -join ', ')'" | |
# Finally, a case-insensitive compare returns only the original paths | |
$CaseCorrected | Where-Object { $LiteralPath -iContains "$_" } | | |
# And always adds a trailing slash to folder paths, for clarity | |
ForEach-Object { $_.TrimEnd("/\") + $(if (Test-Path $_ -PathType Container) { | |
[IO.Path]::DirectorySeparatorChar | |
}) } | |
} catch { | |
if ($EAP -in "Ignore", "SilentlyContinue") { | |
return | |
} | |
throw | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Describe "Convert-Path" { | |
Context "The wildcard feature correctly injects wildcards to paths" { | |
# This gets the -replace line from the script: | |
$Replacer = (Get-Command Convert-Path).Definition -split "`n" -match "-replace" -split ' = ' | |
# This turns it into a testable -replace line | |
$Replacer = [ScriptBlock]::Create($Replacer[-1]) | |
It "Converts <LiteralPath> into <Expected>" { | |
param($LiteralPath, $Expected) | |
& $Replacer | Should Be $Expected | |
} -TestCases @( | |
@{LiteralPath = "C:\"; Expected = "C:\" } | |
@{LiteralPath = "C:\Folder"; Expected = "C:\Folder*" } | |
@{LiteralPath = "C:\Folder\"; Expected = "C:\Folder*\" } | |
@{LiteralPath = "C:\Folder\Folder"; Expected = "C:\Folder*\Folder*" } | |
@{LiteralPath = "C:\Folder\Folder\"; Expected = "C:\Folder*\Folder*\" } | |
# Neither slash in the UNC path, nor the server or share name should get wildcards | |
@{LiteralPath = "\\Server\Share"; Expected = "\\Server\Share" } | |
@{LiteralPath = "\\Server\Share\"; Expected = "\\Server\Share\" } | |
@{LiteralPath = "\\Server\Share\Folder"; Expected = "\\Server\Share\Folder*" } | |
@{LiteralPath = "\\Server\Share\Folder\"; Expected = "\\Server\Share\Folder*\" } | |
# Admin shares should work the same way | |
@{LiteralPath = '\\Server\C$\Folder'; Expected = '\\Server\C$\Folder*' } | |
@{LiteralPath = '\\Server\C$\Folder\'; Expected = '\\Server\C$\Folder*\' } | |
@{LiteralPath = '\\Server\C$\Folder\Folder'; Expected = '\\Server\C$\Folder*\Folder*' } | |
@{LiteralPath = '\\Server\C$\Folder\Folder\'; Expected = '\\Server\C$\Folder*\Folder*\' } | |
@{LiteralPath = '\\Server\C$\'; Expected = '\\Server\C$\' } | |
# It should work with linux style paths | |
@{LiteralPath = "/var"; Expected = "/var*" } | |
@{LiteralPath = "/var/"; Expected = "/var*/" } | |
@{LiteralPath = "/var/ftp"; Expected = "/var*/ftp*" } | |
@{LiteralPath = "/var/ftp/pub"; Expected = "/var*/ftp*/pub*" } | |
) | |
} | |
Context "Correctly get case of paths" { | |
It "Converts case of literal paths" { | |
Convert-Path "C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL\" | Should -BeExactly "C:\Windows\System32\WindowsPowerShell\" | |
} | |
It "Resolves (and converts case of) wildcard paths" { | |
Convert-Path "C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL\v*\mod*\activedirectory\" | Should -BeExactly "C:\Windows\System32\WindowsPowerShell\v1.0\Modules\ActiveDirectory\" | |
} | |
It "Normalizes folder paths with trailing slashes" { | |
Convert-Path "C:\WINDOWS\SYSTEM32\" | Should -BeExactly "C:\Windows\System32\" | |
Convert-Path "C:\WINDOWS\SYSTEM32" | Should -BeExactly "C:\Windows\System32\" | |
} | |
It "Works for paths without trailing slashes" { | |
Convert-Path "C:\WINDOWS\SYSTEM.ini" | Should -BeExactly "C:\Windows\system.ini" | |
} | |
It "Works for pipeline paths" { | |
"C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL", "C:\WINDOWS\SYSTEM.ini", "C:\WINDOWS\SYSTEM32" | Convert-Path | Should -BeExactly "C:\Windows\System32\WindowsPowerShell\", "C:\Windows\system.ini", "C:\Windows\System32\" | |
} | |
It "Works for arrays of paths" { | |
Convert-Path "C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL", "C:\WINDOWS\SYSTEM32\" | Should -BeExactly "C:\Windows\System32\WindowsPowerShell\", "C:\Windows\System32\" | |
} | |
It "Converts case of PSDrive root" { | |
New-PSDrive PS FileSystem C:\WINDOWS\SYSTEM32\WINDOWSPOWERSHELL | |
Push-Location PS:\ | |
try { | |
Convert-Path .\v*\modules\activedirectory | Should -BeExactly "C:\Windows\System32\WindowsPowerShell\v1.0\Modules\ActiveDirectory\" | |
} finally { | |
Pop-Location | |
Remove-PSDrive PS | |
} | |
} | |
} | |
Context "Errors on non-existent paths just like Convert-Path" { | |
It "Throws when the path doesn't exist" { | |
try { | |
Convert-Path "C:\WINDOWS32" | |
throw "Expected an ItemNotFoundException" | |
} catch [System.Management.Automation.ItemNotFoundException] { | |
} | |
} | |
It "Suppresses errors with -ErrorAction" { | |
Convert-Path "C:\WINDOWS32" -ErrorAction SilentlyContinue | Should -BeNullOrEmpty | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Repair-Path { | |
<# | |
.SYNOPSIS | |
Repair path variables by removing duplicates optionally normalizing and removing non-existent paths. | |
#> | |
[CmdletBinding(SupportsShouldProcess, ConfirmImpact="High")] | |
param( | |
[ValidateNotNullOrEmpty()] | |
[string]$PathVariable = "Path", | |
# The scopes to fix. Defaults to all scopes/ | |
[System.EnvironmentVariableTarget[]]$Scope = @("Machine", "User"), #"Process", | |
# If set, normalizes paths and removes non-existent paths | |
[switch]$Normalize, | |
[switch]$Shorten | |
) | |
foreach ($target in $Scope) { | |
$OriginalValue = [Environment]::GetEnvironmentVariable($PathVariable, $target) | |
$NormalPath = $ExistingPath = @(($OriginalValue -Split [IO.Path]::PathSeparator).Where{$_}) | |
if($Normalize) { | |
$NormalPath = $ExistingPath | Convert-Path -ErrorAction Ignore -Verbose:$False | |
} | |
# Remove duplicates | |
$NormalPath = $NormalPath | Select-Object -Unique | |
# For user scope, remove duplicates of machine scope | |
if ($target -eq "User") { | |
$MachinePath = ([Environment]::GetEnvironmentVariable($PathVariable, "Machine") -Split [IO.Path]::PathSeparator).Where{$_} | Convert-Path -ErrorAction Ignore -Verbose:$False | Sort-Object | |
$NormalPath = @( | |
foreach($item in $NormalPath) { | |
if([Array]::BinarySearch($MachinePath, $item) -lt 0) { | |
$item | |
} else { | |
Write-Warning "Removing '$item' from User scope (already in Machine scope)" | |
} | |
} | |
) | |
} | |
# Set the value back | |
if ($NewValue = $NormalPath -join [IO.Path]::PathSeparator) { | |
if ($NewValue -ne $OriginalValue) { | |
$existing = $replacing = 0 | |
$New = $Old = "" | |
Write-Verbose "Originally $($ExistingPath.Length) items, now $($NormalPath.Length)" | |
for ($i = 0; $i -lt ([Math]::Max($ExistingPath.Length, $NormalPath.Length)); $i++) { | |
# Identical: output both as white | |
if($ExistingPath[$existing] -ceq $NormalPath[$replacing]) { | |
Write-Verbose "$i identical: $($ExistingPath[$existing])" | |
$Old += $ExistingPath[$existing] + "`n" | |
$New += $NormalPath[$replacing] + "`n" | |
$existing++ | |
$replacing++ | |
# Non-existing folders in red | |
} elseif(!(Convert-Path $ExistingPath[$existing] -Verbose:$false)) { | |
Write-Verbose "$i non-existent: $($ExistingPath[$existing])" | |
$Old += "$fg:darkred$($ExistingPath[$existing])$fg:clear" | |
$existing++ | |
# Mostly the same: output old as gray, new as white | |
} elseif((Convert-Path $ExistingPath[$existing] -Verbose:$false) -eq $NormalPath[$replacing]) { | |
Write-Verbose "$i similar: $($ExistingPath[$existing])" | |
$Old += "$fg:gray($ExistingPath[$existing])$fg:clear`n" | |
$New += $NormalPath[$replacing] + "`n" | |
$existing++ | |
$replacing++ | |
# Dupes? | |
} elseif ((Convert-Path $ExistingPath[$existing] -Verbose:$false) -in $NormalPath) { | |
Write-Verbose "$i dupe: $($ExistingPath[$existing])" | |
$Old += "$fg:red$($ExistingPath[$existing])$fg:clear`n" | |
$existing++ | |
} else { | |
Write-Verbose "$i different: $($ExistingPath[$existing]) != $($NormalPath[$replacing])" | |
$New += "$fg:green$($NormalPath[$replacing])$fg:clear" | |
$replacing++ | |
} | |
} | |
if ($PSCmdlet.ShouldProcess( | |
"Set $Scope environment variable $PathVariable = $NewValue", | |
"Old Values: $Old`nNew Value: $New", | |
"Update $PathVariable at $target scope")) { | |
[System.Environment]::SetEnvironmentVariable($PathVariable, $NewValue, $target) | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment