powershellreplacepathenvironment-variablespath-manipulation

How can I find the potential source environment variable for a partial path in PowerShell?


I want to write a function that converts regular path to path that includes environment variables:

For example:

C:\Windows\SomePath

convert to:

%Windir%\SomePath

How would I do that and is this possible?

Here is what I'm trying to do, but problem is, I need to check the string for all possible variables, is there some more automatic way? such that -replace operator wont be needed

function Format-Path
{
    param (
        [parameter(Mandatory = $true)]
        [string] $FilePath
    )

    if (![System.String]::IsNullOrEmpty($FilePath))
    {
        # Strip away quotations and ending backslash
        $FilePath = $FilePath.Trim('"')
        $FilePath = $FilePath.TrimEnd('\\')
    }

$FilePath = $FilePath -replace "C:\\Windows", "%Windir%"
$FilePath = $FilePath -replace "C:\\ProgramFiles", "%ProgramFiles%"
$FilePath = $FilePath -replace "C:\\ProgramFiles (x86)", "%ProgramFiles (x86)%"
# ETC.. the list goes on..

return $FilePath
}

# test case
Format-Path '"C:\Windows\SomePath\"'

Output is:

%Windir%\SomePath

EDIT: Invalid input or bad code isn't really the problem because in the end the $Path can be easily checked with:

Test-Path -Path ([System.Environment]::ExpandEnvironmentVariables($FilePath))

Solution

  • The code below is my take on this. There's some peculiarities with the path and backslash manipulations, so I tried to explain everything in comments.

    There is one key take away and that is that unbounded string searches such as those performed by -replace, -like, .Contains(), etc. and can produce undesirable results when the value of one variable's path is a substring of either another variable's path or a directory's path. For example, given %ProgramFiles% (C:\Program Files) and %ProgramFiles(x86)% (C:\Program Files (x86)), the path C:\Program Files (x86)\Test could be transformed into %ProgramFiles% (x86)\Test instead of %ProgramFiles(x86)%\Test if %ProgramFiles% happens to be tested before %ProgramFiles(x86)%.

    The solution is to only compare a variable's path to a complete path segment. That is, in the case of the path C:\Program Files (x86)\Test, the comparisons would go like this...

    By only testing against complete path segments it does not matter in what order the variables are compared to the candidate path.

    New-Variable -Name 'VariablesToSubstitute' -Option Constant -Value @(
        # Hard-code system variables that contain machine-wide paths
        'CommonProgramFiles',
        'CommonProgramFiles(x86)',
        'ComSpec',
        'ProgramData',            # Alternatively: ALLUSERSPROFILE
        'ProgramFiles',
        'ProgramFiles(x86)',
        'SystemDrive'
        'SystemRoot'              # Alternatively: WinDir
    
        'MyDirectoryWithoutSlash' # Defined below
        'MyDirectoryWithSlash'    # Defined below
    );
    
    function Format-Path
    {
        param (
            [parameter(Mandatory = $true)]
            [string] $FilePath
        )
    
        if (![System.String]::IsNullOrEmpty($FilePath))
        {
            # Strip away quotations
            $FilePath = $FilePath.Trim('"')
            # Leave trailing slashes intact so variables with a trailing slash will match
            #$FilePath = $FilePath.TrimEnd('\')
        }
    
        # Initialize this once, but only after the test code has started
        if ($null -eq $script:pathVariables)
        {
            $script:pathVariables = $VariablesToSubstitute | ForEach-Object -Process {
                $path = [Environment]::GetEnvironmentVariable($_)
                if ($null -eq $path)
                {
                    Write-Warning -Message "The environment variable ""$_"" is not defined."
                }
                else
                {
                    return [PSCustomObject] @{
                        Name = $_
                        Path = $path
                    }
                }
            }
        }
    
        # Test against $FilePath and its ancestors until a match is found or the path is empty.
        # Only comparing with complete path segments prevents performing partial substitutions
        # (e.g. a path starting with %ProgramFiles(x86)% being substituted with %ProgramFiles%, 
        #       or "C:\Windows.old" being transformed to "%SystemRoot%.old")
        for ($filePathAncestorOrSelf = $FilePath;
            -not [String]::IsNullOrEmpty($filePathAncestorOrSelf);
            # Split-Path -Parent removes the trailing backslash on the result *unless* the result
            # is a drive root.  It'd be easier to normalize all paths without the backslash, but
            # Split-Path throws an error if the input path is a drive letter with no slash, so
            # normalize everything *with* the backslash and strip it off later.
            $filePathAncestorOrSelf = EnsureTrailingBackslash (
                # Protect against the case where $FilePath is a drive letter with no backslash
                # We have to do this here because we want our initial path above to be
                # exactly $FilePath, not (EnsureTrailingBackslash $FilePath).
                Split-Path -Path (EnsureTrailingBackslash $filePathAncestorOrSelf) -Parent
            )
        )
        {
            # Test against $filePathAncestorOrSelf with and without a trailing backslash
            foreach ($candidatePath in $filePathAncestorOrSelf, $filePathAncestorOrSelf.TrimEnd('\'))
            {
                foreach ($variable in $pathVariables)
                {
                    if ($candidatePath -ieq $variable.Path)
                    {
                        $variableBasePath = "%$($variable.Name)%"
                        # The rest of the path after the variable's path
                        $pathRelativeToVariable = $FilePath.Substring($variable.Path.Length)
    
                        # Join-Path appends a trailing backslash if the child path is empty - we don't want that
                        if ([String]::IsNullOrEmpty($pathRelativeToVariable))
                        {
                            return $variableBasePath
                        }
                        # Join-Path will join the base and relative path with a slash,
                        # which we don't want if the variable path already ends with a slash
                        elseif ($variable.Path -like '*\')
                        {
                            return $variableBasePath + $pathRelativeToVariable
                        }
                        else
                        {
                            return Join-Path -Path $variableBasePath -ChildPath $pathRelativeToVariable
                        }
                    }
                }
            }
        }
    
        return $FilePath
    }
    
    function EnsureTrailingBackslash([String] $path)
    {
        return $(
            # Keep an empty path unchanged so the for loop will terminate properly
            if ([String]::IsNullOrEmpty($path) -or $path.EndsWith('\')) {
                $path
            } else {
                "$path\"
            }
        )
    }
    

    Using this test code...

    $Env:MyDirectoryWithoutSlash = 'C:\My Directory'
    $Env:MyDirectoryWithSlash    = 'C:\My Directory\'
    
    @'
    X:
    X:\Windows
    X:\Windows\system32
    X:\Windows\system32\cmd.exe
    X:\Windows.old
    X:\Windows.old\system32
    X:\Windows.old\system32\cmd.exe
    X:\Program Files\Test
    X:\Program Files (x86)\Test
    X:\Program Files (it's a trap!)\Test
    X:\My Directory
    X:\My Directory\Test
    '@ -split "`r`n?" `
        | ForEach-Object -Process {
            # Test the path with the system drive letter
            $_ -replace 'X:', $Env:SystemDrive
    
            # Test the path with the non-system drive letter
            $_
        } | ForEach-Object -Process {
            $path = $_.TrimEnd('\')
    
            # Test the path without a trailing slash
            $path
    
            # If the path is a directory (determined by the
            # absence of an extension in the last segment)...
            if ([String]::IsNullOrEmpty([System.IO.Path]::GetExtension($path)))
            {
                # Test the path with a trailing slash
                "$path\"
            }
        } | ForEach-Object -Process {
            [PSCustomObject] @{
                InputPath  = $_
                OutputPath = Format-Path $_
            }
        }
    

    ...I get this result...

    InputPath                             OutputPath
    ---------                             ----------
    C:                                    %SystemDrive%
    C:\                                   %SystemDrive%\
    X:                                    X:
    X:\                                   X:\
    C:\Windows                            %SystemRoot%
    C:\Windows\                           %SystemRoot%\
    X:\Windows                            X:\Windows
    X:\Windows\                           X:\Windows\
    C:\Windows\system32                   %SystemRoot%\system32
    C:\Windows\system32\                  %SystemRoot%\system32\
    X:\Windows\system32                   X:\Windows\system32
    X:\Windows\system32\                  X:\Windows\system32\
    C:\Windows\system32\cmd.exe           %ComSpec%
    X:\Windows\system32\cmd.exe           X:\Windows\system32\cmd.exe
    C:\Windows.old                        %SystemDrive%\Windows.old
    X:\Windows.old                        X:\Windows.old
    C:\Windows.old\system32               %SystemDrive%\Windows.old\system32
    C:\Windows.old\system32\              %SystemDrive%\Windows.old\system32\
    X:\Windows.old\system32               X:\Windows.old\system32
    X:\Windows.old\system32\              X:\Windows.old\system32\
    C:\Windows.old\system32\cmd.exe       %SystemDrive%\Windows.old\system32\cmd.exe
    X:\Windows.old\system32\cmd.exe       X:\Windows.old\system32\cmd.exe
    C:\Program Files\Test                 %ProgramFiles%\Test
    C:\Program Files\Test\                %ProgramFiles%\Test\
    X:\Program Files\Test                 X:\Program Files\Test
    X:\Program Files\Test\                X:\Program Files\Test\
    C:\Program Files (x86)\Test           %ProgramFiles(x86)%\Test
    C:\Program Files (x86)\Test\          %ProgramFiles(x86)%\Test\
    X:\Program Files (x86)\Test           X:\Program Files (x86)\Test
    X:\Program Files (x86)\Test\          X:\Program Files (x86)\Test\
    C:\Program Files (it's a trap!)\Test  %SystemDrive%\Program Files (it's a trap!)\Test
    C:\Program Files (it's a trap!)\Test\ %SystemDrive%\Program Files (it's a trap!)\Test\
    X:\Program Files (it's a trap!)\Test  X:\Program Files (it's a trap!)\Test
    X:\Program Files (it's a trap!)\Test\ X:\Program Files (it's a trap!)\Test\
    C:\My Directory                       %MyDirectoryWithoutSlash%
    C:\My Directory\                      %MyDirectoryWithSlash%
    X:\My Directory                       X:\My Directory
    X:\My Directory\                      X:\My Directory\
    C:\My Directory\Test                  %MyDirectoryWithSlash%Test
    C:\My Directory\Test\                 %MyDirectoryWithSlash%Test\
    X:\My Directory\Test                  X:\My Directory\Test
    X:\My Directory\Test\                 X:\My Directory\Test\
    

    Note that candidate ancestor paths are always searched first with a trailing slash and then without. This means that in the unlikely event there are two variable paths that differ only by the presence or absence of a trailing slash, the variable with the trailing slash will be matched. Thus, as seen above, C:\My Directory\Test will become %MyDirectoryWithSlash%Test, which looks a little strange. By reversing the order of the first foreach loop in the function from...

    foreach ($candidatePath in $filePathAncestorOrSelf, $filePathAncestorOrSelf.TrimEnd('\'))
    

    ...to...

    foreach ($candidatePath in $filePathAncestorOrSelf.TrimEnd('\'), $filePathAncestorOrSelf)
    

    ...the relevant output changes to this...

    InputPath                             OutputPath
    ---------                             ----------
    ...                                   ...
    C:\My Directory\                      %MyDirectoryWithoutSlash%\
    ...                                   ...
    C:\My Directory\Test                  %MyDirectoryWithoutSlash%\Test
    C:\My Directory\Test\                 %MyDirectoryWithoutSlash%\Test\
    ...                                   ...