System.IO.FileInfo and Relative Paths
Asked Answered
A

4

10

I was wondering if someone could help me understand why does System.IO.FileInfo behaves differently on Windows than on Linux when handling relative paths.

Example

  • On Linux
PS /home/user/Documents> ([System.IO.FileInfo]'./test.txt').FullName
/home/user/Documents/test.txt
  • On Windows
PS C:\Users\User\Documents> ([System.IO.FileInfo]'.\test.txt').FullName
C:\Users\User\test.txt

EDIT

To clarify on the above, there is no difference on how System.IO.FileInfo handles relative paths on Windows or Linux. The issue is related to [Environment]::CurrentDirectory not being updated by Push-Location and Set-Location, see Mark Bereza's answer for more details on this.

PS /home/user> [Environment]::CurrentDirectory
/home/user
PS /home/user> cd ./Documents/
PS /home/user/Documents> [Environment]::CurrentDirectory
/home/user

And assuming this is a expected behavior, what would be an optimal way to approach our param(...) blocks on scripts and functions to accept both cases (absolute and relative). I used to type constraint the path parameter to System.IO.FileInfo but now I can see it is clearly wrong.

This is what I came across, but I'm wondering if there is a better way.
I believe Split-Path -IsAbsolute will also bring problems if working with Network Paths, please correct me if I'm wrong.

param(
    [ValidateScript({ 
        if (Test-Path $_ -PathType Leaf) {
            return $true
        }
        throw 'Invalid File Path'
    })]
    [string] $Path
)

if (-not (Split-Path $Path -IsAbsolute)) {
    [string] $Path = Resolve-Path $Path
}
Assyrian answered 7/12, 2021 at 2:1 Comment(3)
You could try [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($pwd, '.\test.txt'))Joannjoanna
@Joannjoanna that's excellent and works fine on both OS. Would you ask [IO.Path]::IsRooted(..) before or just do it no matter what? I guess I wont find an answer on the behavior of FileInfo on Windows vs Linux so you can propose this as an answer and if by the end of the day nobody can answer the other question I'll go ahead an accept it. Thanks Theo.Assyrian
This has been discussed before. For example: #11246568Ridglea
T
3

The underlying problem is that System.IO .NET methods, including the [System.IO.FileInfo]::new() constructor you're (implicitly) invoking, use [System.IO.Directory]::GetCurrentDirectory() to resolve relative paths. For reasons outlined here, this location isn't updated when you change your working directory within PowerShell.

If you want to use .NET methods on a relative path within a PowerShell session, your best bet is to first resolve it to an absolute path using either a PowerShell cmdlet (which will implicitly resolve it relative to the current working directory), or by combining it with the $PWD automatic variable.

Using cmdlets like Resolve-Path, Convert-Path, and Get-Item is the most straight-forward way to accomplish this, the primary difference between them (for this purpose) being their return type: PathInfo for ResolvePath, string for Convert-Path, and a type corresponding to the item retrieved for Get-Item. These have their limitations, however:

  1. They only work if the path provided actually exists, meaning they won't work for validating something like an "OutputPath," which may point to an item that doesn't yet exist.
  2. They are designed to work with any PowerShell provider, not just the filesystem provider. This means that they'll accept things like registry paths, which may not be what you want. Note that this also applies to Test-Path, so Test-Path $_ -PathType Leaf returning $true is not sufficient to determine that the input is a valid file.

If you want to get around these limitations, you'll have to be a bit creative. The currently accepted answer mostly accomplishes this, but fails for paths that are rooted, but not fully qualified (as an example, C:\Users would be both rooted and fully qualified, while \Users would be rooted but not fully qualified). To illustrate this:

PS D:\> Set-Location -Path 'C:\'
PS C:\> [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($PWD, '\Users'))
D:\Users

If you're using PowerShell 6+, you can instead do:

$Path = [System.IO.Path]::GetFullPath($Path, $PWD)
  • If $Path isn't fully qualified, it will be resolved relative to $PWD and normalized.
  • If $Path is already fully qualified, $PWD will be ignored and $Path will be unchanged (besides normalization).
  • This will work even if $Path doesn't exist.
  • This will always treat $Path as a path to a file or directory (and not a registry key, for example).

If you're using a PowerShell version older than 6, Santiago's answer is probably your best bet.

Now that $Path has been resolved to a fully qualified path, System.IO .NET methods will behave as you'd expect, allowing you to perform checks like:

[System.IO.File]::Exists($Path)

which, unlike Test-Path -Path $Path -PathType Leaf, will ensure the path exists and is specifically a file.

Traherne answered 19/4 at 2:34 Comment(0)
J
2

Feels a bit duplicate, but since you asked..

I'm sorry I don't know about Linux, but in Windows:

You can add a test first to see if the path is relative and if so, convert it to absolute like:

$Path = '.\test.txt'
if (![System.IO.Path]::IsPathRooted($Path) -or $Path -match '^\\[^\\]+') {
    $Path =  [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($pwd, $Path))
}

I added $Path -match '^\\[^\\]+' to also convert relative paths starting with a backslash like \ReadWays.ps1 meaning the path starts at the root directory. UNC paths that start with two backslashes are regarded as absolute.


Apparently (I really have no idea why..) the above does not work on Linux, because there, when using a UNC path, the part ![System.IO.Path]::IsPathRooted('\\server\folder') yields True.

It seems then you need to check the OS first and do the check differently on Linux.

$Path = '\\server\share'

if ($IsWindows) {  # $IsWindows exists in version 7.x. Older versions do `$env:OS -match 'Windows'`
    if (![System.IO.Path]::IsPathRooted($Path) -or $Path -match '^\\[^\\]+') {
        $Path =  [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($pwd, $Path))
    }
}
else {
    if ($Path -notlike '\\*\*') {  # exclude UNC paths as they are not relative
        if (![System.IO.Path]::IsPathRooted($Path) -or $Path -match '^\\[^\\]+') {
            $Path =  [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($pwd, $Path))
        }
    }
}
Joannjoanna answered 7/12, 2021 at 14:52 Comment(0)
A
2

The easiest alternative would be to use Convert-Path to:

  • Handle UNC, Relative, Absolute and Rooted Paths.
  • Be compatible with Windows and Linux
  • Be efficient

Another neat option if we are using [CmdletBinding()] is to use $PSCmdlet.GetUnresolvedProviderPathFromPSPath(..) method:

function Test-ResolvePath {
    [CmdletBinding()]
    param($path)
    $PSCmdlet.GetUnresolvedProviderPathFromPSPath($path)
}

Test-ResolvePath \\server01\test         # => \\server01\test
Test-ResolvePath C:\Users\user\Documents # => C:\Users\user\Documents
Test-ResolvePath C:Documents             # => C:\Documents
(Test-ResolvePath .) -eq $PWD.Path       # => True
(Test-ResolvePath ~) -eq $HOME           # => True

If we're looking to emulate the behavior of most Microsoft.PowerShell.Management cmdlets, having a -LiteralPath and -Path the code would be a bit more complex and the APIs being called are also different, PathIntrinsics.GetUnresolvedProviderPathFromPSPath method for literal path validation and PSCmdlet.GetResolvedProviderPathFromPSPath method for wildcard path validation. Both methods ensure that the target exists.

function Test-ResolvePath {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param(
        [Parameter(
            ParameterSetName = 'LiteralPath',
            ValueFromPipelineByPropertyName,
            Mandatory)]
        [Alias('PSPath')]
        [string] $LiteralPath,

        [Parameter(
            ParameterSetName = 'Path',
            Mandatory,
            ValueFromPipeline,
            Position = 0)]
        [SupportsWildcards()]
        [string] $Path
    )

    process {
        $provider = $null

        try {
            if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
                $resolvedPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
                    $LiteralPath,
                    [ref] $provider,
                    [ref] $null)
            }
            else {
                $resolvedPath = $PSCmdlet.GetResolvedProviderPathFromPSPath(
                    $Path,
                    [ref] $provider)
            }

            foreach ($path in $resolvedPath) {
                [pscustomobject]@{
                    Path             = $path
                    Provider         = $provider
                    IsFileSystemPath = $provider.ImplementingType -eq [Microsoft.PowerShell.Commands.FileSystemProvider]
                }
            }
        }
        catch {
            $PSCmdlet.WriteError($_)
        }
    }
}

Test-ResolvePath $pwd\*
Test-ResolvePath HKLM:
Test-ResolvePath -LiteralPath Cert:
Test-ResolvePath doesnotexist
Assyrian answered 29/3, 2022 at 15:21 Comment(2)
hi @BitcoinMurderousManiac thanks for the upvote, I'm not sure about a post but I have 2 modules that outputs trees: github.com/santisq/PSTree & github.com/santisq/PSADTreeAssyrian
Thank you so much, that's exactly what it was Get-PSTree!! Keep up the awesome work and all the awesome answers!!Bogosian
I
2

Another option:

As you wanted the result of a cast to [System.IO.FileInfo], you can instead use Get-Item, which will also return a [System.IO.FileInfo] object, but with resolved relative paths as expected. It will also incorporate some error detection (invalid characters or non-existent path etc.).

Example:

PS C:\Users\User\Documents> (Get-Item -LiteralPath '.\test.txt').FullName
C:\Users\User\Documents\test.txt
Impassive answered 19/7, 2022 at 7:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.