How does PowerShell treat “.” in paths?

As js2010’s helpful answer states, it is the use of a .NET method that introduces the problem:
.NET’s single, process-wide current directory typically and by design[1] differs from PowerShell’s runspace-specific one.

This has the following implications:

  • Since PowerShell itself does reliably interpret . as the current location (which is PowerShell’s generalization of the concept of a current directory that can refer to other types of locations as well, on drives exposed by other PowerShell drive providers, such as the registry provider), you can avoid the problem by using PowerShell commands, if available.

  • When you do call .NET methods, be sure to resolve any relative paths to absolute, file-system-native[2] ones beforehand or, where supported, additionally supply the current PowerShell filesystem location as a reference directory – this avoids the problem of the mismatched current directories.

  • (Another, but suboptimal option is to first set [Environment]::CurrentDirectory = $PWD.ProviderPath every time a relative path is passed, but that is clumsy and shouldn’t be used if there’s a chance that multiple PowerShell runspaces exist in the same process.)

The next section shows how to safely pass relative PowerShell paths to .NET methods, whereas the bottom section solves the specific problem in your question: how to resolve an arbitrary, given PowerShell path to an absolute, native filesystem path.

Caveat:

  • For resolving relative paths, the solutions below assume that PowerShell’s current location (as output by Get-Location / reflected in $PWD) is a file-system location, i.e. a directory – as is typical.

  • If this assumption cannot be made (in the unlikely event that the current location is a registry location, for instance), an explicit reference to the FileSystem provider‘s location is required, using (Get-Location -PSProvider FileSystem).ProviderPath in lieu of $PWD.ProviderPath below. Notably, this precludes the Convert-Path and New-Item approaches below.


Passing a known relative file-system path safely to a .NET method:

As stated, the discrepancy in current directories requires that an absolute path be passed to .NET methods, arrived at based on PowerShell‘s current directory.

The examples assume relative path someFile.txt to be passed to .NET method [IO.File]::ReadAllText() and [IO.File]::WriteAllText()

Note that simple string interpolation is used, with / (which can be used interchangeably with \) used to join the path components; if the current directory happens to be the root directory, you’ll end up with 2 path separators, but that doesn’t affect functionality. If you still need to avoid that, however, use the Join-Path cmdlet instead.

If the path is known to exist:

Use Convert-Path:

[IO.File]::ReadAllText((Convert-Path -LiteralPath someFile.txt))

That Convert-Path and Resolve-Path only work with existing paths (as of PowerShell (Core) 7.2) is unfortunate; providing an opt- in for nonexistent path has been proposed in GitHub issue #2993.

Similarly, it would be helpful if Convert-Path and Resolve-Path supported a -PSProvider parameter to allow specifying the target provider explicitly, as Get-Location already supports – see GitHub issue #10494.

If the path is one to be created:

The simplest and robust approach is to use New-Item to let PowerShell create the item beforehand, which returns a file-system information object whose .FullName property reflects the full, native path:

# For a *directory* path, use -Type Directory
[IO.File]::WriteAllText(
  (New-Item -Type File ./someFile.txt).FullName,
  "sample content"
)

If you don’t want to create the file/directory in PowerShell beforehand, there are several approaches:

  • Simplest, but not fully robust, via$PWD (fails if the current directory is based on a PowerShell-specific drive created with New-PsDrive or if the current location is not a filesystem location):
[IO.File]::WriteAllText("$PWD/someFile.txt", "sample content")
  • More robust: via $PWD.ProviderPath (resolves a PowerShell drive-based path to the underlying native filesystem path, but can still fail if the current location is not a filesystem location):
[IO.File]::WriteAllText("$($PWD.ProviderPath)/someFile.txt", "sample content")
  • Fully robust: via (Get-Location -PSProvider FileSystem).ProviderPath
[IO.File]::WriteAllText(
  "$((Get-Location -PSProvider FileSystem).ProviderPath)/someFile.txt"),
  "sample content"
)

Resolving any given file-system path to a full, native one:

That is, you may have to resolve to a full, file-system native a path that is given to you, which may or may not be relative, and may or may not be based on a PowerShell-specific drive (which .NET knows nothing about).

If the path exists, use Convert-Path to resolve any PowerShell filesystem path to an absolute, filesystem-native one:

$dir = "./temp"
Convert-Path -LiteralPath $dir

The related Resolve-Path cmdlet provides similar functionality, but it doesn’t resolve paths based on PowerShell-specific drives (created with New-PsDrive) to their underlying native filesystem paths.

If the path doesn’t exist (yet):

In PowerShell (Core) v6+, which builds on .NET Core / .NET 5+, you can use the new [IO.Path]::GetFullPath() overload that accepts a reference directory for the specified relative path:

$dir = "./temp"
[IO.Path]::GetFullPath($dir, $PWD.ProviderPath)

Note how the current location’s native filesystem path, $PWD.ProviderPath, is passed as the reference directory.

In Windows PowerShell, you can use [IO.Path]::Combine(), but that requires extra work:

  • On Windows, [IO.Path]::Combine() considers a (non-UNC) path that starts with \ a full one, despite being rooted only relative to the current drive. Therefore, such paths must be prefixed with the drive spec. of the native file-system directory underlying PowerShell’s current location. Tip of the hat to Theo.

  • In order to normalize the resulting full path – i.e. in order to ensure that path components such as . and .. are removed / resolved – you need to combine it with [IO.Path]::GetFullPath().

$dir = "./temp"
[IO.Path]::GetFullPath($(
  if ($env:OS -eq 'Windows_NT' -and $dir -match '^([\\/])(?!\1)') {
    [IO.Path]::GetPathRoot($PWD.ProviderPath) + $dir.Substring(1)
  } else {
    [IO.Path]::Combine($PWD.ProviderPath, $dir)
  }
))

[1] While a given process typically has only one PowerShell runspace (session), the potential for multiple ones to coexist in a process means that it is conceptually impossible for all of them to sync their individual working directories with the one and only process-wide .NET working directory. For a more in-depth explanation, see this GitHub issue.

[2] That is, paths based on PowerShell-specific drives (see New-PSDrive) must be translated to paths based on drives known to all processes.

Leave a Comment