Search code examples
powershellsymlinksubst

powershell: wrong symbolic link resolution


i think i found a bug in PS.

i create a new subst'ed drive letter:

C:\> subst k: c:\test
C:\> subst
K:\: => c:\test

but PS tells:

PS C:\> get-item 'K:\' | Format-list | Out-String

Directory:
Name           : K:\
Mode           : d-----
LinkType       :
Target         : {K:\test}

as you see, the drive letter in target is wrong. how it comes?

my windows version:

Windows 10 Enterprise
Version 1809
OS Build 17763.1457

my PS version:

PS C:\> $PSVersionTable
Name                           Value
----                           -----
PSVersion                      5.1.17763.1432
PSEdition                      Desktop
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.17763.1432
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

how to get the correct target with ps?

many thanks


Solution

  • I agree that it is a bug, but:

    • No symbolic link or other NTFS reparse point (such as a junction) is involved in your code.

    • As such, the .Target property - which reports a reparse point's target - should not even be filled in; that is the actual bug, which longer exists in PowerShell [Core] v6+.

    Thus, in order to weed out such false .Target values, you can filter files by their .LinkType property instead:

    Get-ChildItem | Where-Object LinkType -eq SymbolicLink # now, .Targets are valid
    

    Separately, if you're looking for a way to translate paths based on substituted drives to their underlying physical paths:

    Unfortunately, neither Convert-Path nor Get-PSDrive seem to be aware of substituted drives (created with subst.exe) - not even in PowerShell 7.0 - so you'll have to roll your own translation command:

    & {
      $fullName = Convert-Path -LiteralPath $args[0]
      $drive = Split-Path -Qualifier $fullName
      if ($drive.Length -eq 2 -and ($substDef = @(subst.exe) -match "^$drive")) {
        Join-Path ($substDef -split ' ', 3)[-1] $fullName.Substring($drive.Length)
      } else {
        $fullName
      }
    } 'K:\'
    

    The above should return C:\test\ in your case.

    Note: Due to use of Convert-Path, the above only works with existing paths; making it support nonexistent paths requires substantially more work (see below).
    Note that longstanding GitHub feature request #2993 asks for enhancing Convert-Path to also work with nonexistent paths.

    In the interim, here's advanced function Convert-PathEx to fill the gap.

    Once it is defined, you could do the following instead:

    PS> Convert-PathEx K:\
    C:\test\
    
    function Convert-PathEx {
      <#
    .SYNOPSIS
    Converts file-system paths to absolute, native paths.
    
    .DESCRIPTION
    An enhanced version of Convert-Path, which, however only supports *literal* paths.
    For wildcard expansion, pipe from Get-ChildItem or Get-Item.
    
    The enhancements are:
    
    * Support for non-existent paths.
    * On Windows, support for translating paths based on substituted drives
      (created with subst.exe) to physical paths.
    
    #>
      [CmdletBinding(PositionalBinding = $false)]
      param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('PSPath', 'LP')]
        [string[]] $LiteralPath
      )
    
      begin {
    
        $isWin = $env:OS -eq 'Windows_NT'
    
        # Helper function for ignoring .Substring() exceptions.
        function fromPos ($str, $ndx) {
          try { return $str.Substring($ndx) } catch { return '' }
        }
    
      }
    
      process {
    
        foreach ($path in $LiteralPath) {
    
          $path = $path -replace '^.+::' # strip any PS provider prefix, such as 'FileSystem::' or 'Microsoft.PowerShell.Core\FileSystem::'
    
          # Analyze drive information.
          $driveSpec = Split-Path -ErrorAction Ignore -Qualifier $path
          $driveObj = if ($driveSpec) { (Get-PSDrive -ErrorAction Ignore -PSProvider FileSystem -Name $driveSpec.Substring(0, $driveSpec.Length - 1)) | Select-Object -First 1 } # !! Get-PSDrive can report *case-sensitive variations* of the same drive, so we ensure we only get *one* object back.
          if ($driveSpec -and -not $driveObj) {
            Write-Error "Path has unknown file-system drive: $path" -Category InvalidArgument
            continue
          }
    
          $rest = if ($driveObj) { fromPos $path $driveSpec.Length } else { $path }
          $startsFromRoot = $rest -match '^[\\/]'
          if ($startsFromRoot) { $rest = fromPos $rest 1 } # Strip the initial separator, so that [IO.Path]::Combine() works correctly (with an initial "\" or "/", it ignores attempts to prepend a drive).
          $isAbsolute = $startsFromRoot -and ($driveObj -or -not $isWin) # /... paths on Unix are absolute paths.
    
          $fullName =
          if ($isAbsolute) {
            if ($driveObj) {
              # Prepend the path underlying the drive.
              [IO.Path]::Combine($driveObj.Root, $rest)
            } else {
              # Unix: Already a full, native path - pass it through.
              $path
            }
          } else {
            # Non-absolute path, which can have one three forms:
            #  relative: "foo", "./foo"
            #  drive-qualified relative (rare): "c:foo"
            #  Windows drive-qualified relative (rare): "c:foo"
            if ($startsFromRoot) {
              [IO.Path]::Combine($PWD.Drive.Root, $rest)
            } elseif ($driveObj) {
              # drive-qualified relative path: prepend the current dir *on the targeted drive*.
              # Note: .CurrentLocation is the location relative to the drive root, *wihtout* an initial "\" or "/"
              [IO.Path]::Combine($driveObj.Root, $driveObj.CurrentLocation, $rest)
            } else {
              # relative path, prepend the provider-native $PWD path.
              [IO.Path]::Combine($PWD.ProviderPath, $rest)
            }
          }
    
          # On Windows: Also check if the path is defined in terms of a
          #             *substituted* drive (created with `subst.exe`) and translate
          #             it to the underlying path.
          if ($isWin) {
            # Note: [IO.Path]::GetPathRoot() only works with single-letter drives, which is all we're interested in here.
            #       Also, it *includes a trailing separator*, so skipping the length of $diveSpec.Length works correctly with [IO.Path]::Combine().
            $driveSpec = [IO.Path]::GetPathRoot($fullName)
            if ($driveSpec -and ($substDef = @(subst.exe) -like "$driveSpec*")) {
              $fullName = [IO.Path]::Combine(($substDef -split ' ', 3)[-1], (fromPos $fullName $driveSpec.Length))
            }
          }
    
          # Finally, now that we have a native path, we can use [IO.Path]::GetFullPath() in order
          # to *normalize*  paths with components such as "./" and ".."
          [IO.Path]::GetFullPath($fullName)
    
        } # foreach
    
      }
    
    }