4

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))
Lance U. Matthews
  • 15,725
  • 6
  • 48
  • 68
metablaster
  • 1,958
  • 12
  • 26
  • Hmmm. It would be helpful if you said what problem you were trying to solve with this, but in any case, are you *for sure* intending to replace hardcoded paths with the `%vars%`, or do you want to do the replacement only when the actual envvar matches? If (for instance) `%ProgramFiles%` on the given machine happens to be on drive `D:`, do you still want to replace it with a hardcoded path with `C:` ? – Steve Friedl Feb 06 '20 at 04:37
  • @SteveFriedl I need this because these paths will be passed to windows firewall, this will help sorting rules in Windows Firewall GUI based on paths to file. thank you for mentioning C drive, that can be solved with `%SystemDrive%` basically I'm fine with hardcoded values. – metablaster Feb 06 '20 at 04:45
  • 1
    This is going to be a hard one as there are multiple env variables that can do the same thing. You will need to split the input and then make multiple joins then checks until no more joins can be made. You can get an array of env variables that contain a certain value by doing `@((gci Env: | where {$_.value -eq "C:\windows"}).Name)` – Drew Feb 06 '20 at 04:48
  • @Drew this is very interesting piece, I'll see what to do with it. thanks – metablaster Feb 06 '20 at 04:55
  • 1
    Your lines substituting `"%ProgramFiles%"` and `"%ProgramFiles(x86)%"` (the latter variable having an _extra_ space) are missing a space between `Program` and `Files` in the pattern. With that fixed, be aware that by checking for `%ProgramFiles%` first if `$FilePath` is `'C:\Program Files (x86)\name.ext'` it gets changed to `'%ProgramFiles% (x86)\name.ext'`; you might need to test the paths from longest to shortest, or some other workaround. Is it a fixed list of environment variables you're checking against, or dynamically querying and using any one that looks like a(n absolute) path? – Lance U. Matthews Feb 06 '20 at 05:10
  • @BACON I appreciate your valuable input! I missed that, yes fixed amount of paths, only those paths where programs can be installed, excluding user profile which is not valid for firewall if it contains env. variable. I'm not yet sure what list I may need. Just trying to make a generic function. – metablaster Feb 06 '20 at 05:16

3 Answers3

2

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...

  • Test for equality with the original path C:\Program Files (x86)\Test. No variables match.
  • Test for equality with the parent path C:\Program Files (x86). %ProgramFiles(x86)% matches. No further ancestor paths (i.e. C:) are tested.
  • %ProgramFiles% will never match because the partial path C:\Program Files is not tested.

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\
...                                   ...
Lance U. Matthews
  • 15,725
  • 6
  • 48
  • 68
1

Here is what i ended up at to try and automate it a bit:

function Format-Path {
    param (
        [string]$path
    )

    ## Environmental variable list
    $EV_Variables = ( gci env:* | Where-Object {$_.Name -notin "SystemDrive", "HomeDrive" -and $_.Value -notmatch ";"} | sort-object name  )

    ## Compare string
    while ($EV_Variables.Value -like $path) {
        foreach ($EC in $EV_Variables) {
            if ( $path -like "*$($EC.value)*") {
                $path = $path.Replace($EC.Value, "%$($EC.Name)%")
            }
        }
    }

    $path
}

Format-Path "C:\Program Files (x86)\Nmap\nmap.exe"
Format-Path "C:\Windows"
Format-Path "C:\Program Files"
Format-Path "C:\Program Files (x86)"

Which returns:

%nmap%
%SystemRoot%
%ProgramFiles%
%ProgramFiles% (x%NUMBER_OF_PROCESSORS%%PROCESSOR_LEVEL%)

I tested this on a few environmental variables and it worked for most, with a few issues around certain environmental variables like %NUMBER_OF_PROCESSORS% shown above, but im sure you could just exclude them from the variable list.

Owain Esau
  • 1,876
  • 2
  • 21
  • 34
1

Thank you everyone for suggestions, I see Owain Esau already posted the question while I was also working on a solution.

It would probably be a mistake if I wouldn't post what I come out with:

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

    $SearchString = Split-Path -Path $FilePath -Parent
    $Variables = @(Get-ChildItem Env:) | Sort-Object -Descending { $_.Value.length }

    foreach ($Value in $Variables.Value)
    {
        if ($Value -like "*$SearchString")
        {
            $Replacement = "%" + @(($Variables | Where-Object { $_.Value -eq $Value} ).Name)[0] + "%"

            return $FilePath.Replace($SearchString, $Replacement)
        }
    }
}

Format-Path "C:\Windows\SomePath"
Format-Path "C:\Program Files (x86)\SomePath"
Format-Path "C:\Program Files\SomePath"
Format-Path "C:\ProgramData\SomePath"

# does not work for %SystemDrive%
Format-Path "C:\SomePath"

Output:

%SystemRoot%\SomePath
%ProgramFiles(x86)%\SomePath
%ProgramFiles%\SomePath
%ProgramData%\SomePath

This works except for %SystemDrive% which need additional improvements. I'll leave this question open for improvements.

metablaster
  • 1,958
  • 12
  • 26
  • A few things I found with this: passing `C:\Windows\system32\cmd.exe` yields nothing when it should yield `%ComSpec%`, yet passing `C:\Windows\system32\cmd.exe\invalid` yields `%ComSpec%\invalid`. You can see the same thing passing `C:\Windows` and `C:\Windows\system32` with `%WinDir%`. I believe unconditionally calling `Split-Path -Path $FilePath -Parent` means it can never match when `$FilePath` and a variable have the exact same path. Also, this returns nothing at all if the path, say `X:\Foo`, is not matched by any variable; adding `return $FilePath` after the loop should fix that. – Lance U. Matthews Feb 06 '20 at 11:11