Skip to content

Instantly share code, notes, and snippets.

@Jaykul
Last active January 25, 2021 08:09
Show Gist options
  • Save Jaykul/79e7e86b24b3af68aef59bd98c9279fc to your computer and use it in GitHub Desktop.
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
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
}
}
}
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
}
}
}
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