follow hard links (reparsepoints?) to files (windows terminal)?

1.2k views Asked by At

How do you follow a hard link (reparse point?) to a file? Piping to format-list doesn't show the target. At least in powershell 7, you get a little ascii arrow. That folder is in the $env:path. MicrosoftEdge.exe is linked in the same folder, if you don't have Windows Terminal. I'm in win10 20h2.

get-item $env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe

    Directory: C:\Users\admin\AppData\Local\Microsoft\WindowsApps

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
la---           3/31/2022  1:27 PM              0 wt.exe ->


get-item $env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe | % attributes

Archive, ReparsePoint

This just gives a lot of binary:

fsutil reparsepoint query $env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe

Reparse Tag Value : 0x8000001b
Tag value: Microsoft

Reparse Data Length: 0x192
Reparse Data:
0000:  03 00 00 00 4d 00 69 00  63 00 72 00 6f 00 73 00  ....M.i.c.r.o.s.
0010:  6f 00 66 00 74 00 2e 00  57 00 69 00 6e 00 64 00  o.f.t...W.i.n.d.
0020:  6f 00 77 00 73 00 54 00  65 00 72 00 6d 00 69 00  o.w.s.T.e.r.m.i.
0030:  6e 00 61 00 6c 00 50 00  72 00 65 00 76 00 69 00  n.a.l.P.r.e.v.i.
0040:  65 00 77 00 5f 00 38 00  77 00 65 00 6b 00 79 00  e.w._.8.w.e.k.y.
0050:  62 00 33 00 64 00 38 00  62 00 62 00 77 00 65 00  b.3.d.8.b.b.w.e.
0060:  00 00 4d 00 69 00 63 00  72 00 6f 00 73 00 6f 00  ..M.i.c.r.o.s.o.
0070:  66 00 74 00 2e 00 57 00  69 00 6e 00 64 00 6f 00  f.t...W.i.n.d.o.
0080:  77 00 73 00 54 00 65 00  72 00 6d 00 69 00 6e 00  w.s.T.e.r.m.i.n.
0090:  61 00 6c 00 50 00 72 00  65 00 76 00 69 00 65 00  a.l.P.r.e.v.i.e.
00a0:  77 00 5f 00 38 00 77 00  65 00 6b 00 79 00 62 00  w._.8.w.e.k.y.b.
00b0:  33 00 64 00 38 00 62 00  62 00 77 00 65 00 21 00  3.d.8.b.b.w.e.!.
00c0:  41 00 70 00 70 00 00 00  43 00 3a 00 5c 00 50 00  A.p.p...C.:.\.P.
00d0:  72 00 6f 00 67 00 72 00  61 00 6d 00 20 00 46 00  r.o.g.r.a.m. .F.
00e0:  69 00 6c 00 65 00 73 00  5c 00 57 00 69 00 6e 00  i.l.e.s.\.W.i.n.
00f0:  64 00 6f 00 77 00 73 00  41 00 70 00 70 00 73 00  d.o.w.s.A.p.p.s.
0100:  5c 00 4d 00 69 00 63 00  72 00 6f 00 73 00 6f 00  \.M.i.c.r.o.s.o.
0110:  66 00 74 00 2e 00 57 00  69 00 6e 00 64 00 6f 00  f.t...W.i.n.d.o.
0120:  77 00 73 00 54 00 65 00  72 00 6d 00 69 00 6e 00  w.s.T.e.r.m.i.n.
0130:  61 00 6c 00 50 00 72 00  65 00 76 00 69 00 65 00  a.l.P.r.e.v.i.e.
0140:  77 00 5f 00 31 00 2e 00  38 00 2e 00 31 00 30 00  w._.1...8...1.0.
0150:  39 00 32 00 2e 00 30 00  5f 00 78 00 36 00 34 00  9.2...0._.x.6.4.
0160:  5f 00 5f 00 38 00 77 00  65 00 6b 00 79 00 62 00  _._.8.w.e.k.y.b.
0170:  33 00 64 00 38 00 62 00  62 00 77 00 65 00 5c 00  3.d.8.b.b.w.e.\.
0180:  77 00 74 00 2e 00 65 00  78 00 65 00 00 00 30 00  w.t...e.x.e...0.
0190:  00 00                                             ..

Even Sysinternals Findlinks doesn't work:

findlinks $env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe

Findlinks v1.1 - Locate file hard links
Copyright (C) 2011-2016 Mark Russinovich
Sysinternals - www.sysinternals.com

Error opening c:\users\js2010\appdata\local\microsoft\windowsapps\wt.exe:
The file cannot be accessed by the system.
2

There are 2 answers

0
JosefZ On

Simply parse the fsutil.exe output (see the ParseFsutil function below). Tested under Microsoft Windows [Version 10.0.19043.1620]:

  • pwsh.exe (PSVersion 7.1.5),
  • powershell.exe/powershell_ise.exe (PSVersion 5.1.19041.1620),
  • user account type: administrator as well as standard user.

Please note ugly hard-coded index ($fsuColon) and width ($fsuWidth) in fsutil output, and non-optimized ParseFsutil function…

[CmdletBinding()]
[OutputType([System.Management.Automation.PSCustomObject],[System.Object[]])]
Param (
    [parameter(Mandatory = $false, ValueFromPipeline)]
    [string]$Path = "$env:LOCALAPPDATA\Microsoft\WindowsApps\*"
)

Function ParseFsutil {
    Param (
        [parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]$FullName
    )
    $fsusep = [char[]]@([char]0x20,[char]0xA0,"`t","`r","`n")
    $fsuopt = [System.StringSplitOptions]::RemoveEmptyEntries
    $fsuexe = fsutil.exe reparsepoint query $FullName
    $fsuColon = 4             #  ugly hard-coded index in `fsutil` output
    $fsuWidth = 50            #  ugly hard-coded width in `fsutil` output
    $fsuarr = $fsuexe | 
        Where-Object { $_.Length -gt $fsuColon -and $_[$fsuColon] -eq ':' } |
        ForEach-Object {
            $_.Substring($fsuColon+1, $fsuWidth).Trim().Split($fsusep, $fsuopt)
        }
    $fsuapp = [System.Text.UTF32Encoding]::Unicode.GetString(
        [byte[]]$fsuarr.ForEach({[System.Convert]::ToByte($_,16)}))
    $fsupes = [char[]](0..0x1F | ForEach-Object { [char]$_ })      # Controls
    $fsuapp.Split( $fsupes, $fsuopt )
}


Write-Verbose "PowerShell $($PSVersionTable.PSVersion.ToString())"
$RePAttr = [System.IO.FileAttributes]::ReparsePoint.value__
Get-ChildItem $Path -File -ErrorAction SilentlyContinue |
    ForEach-Object {
        $RePoint = [psCustomObject]@{
            Name   = $_.Name;
            Source = 'Target'
            Target = ''
        }
        if (($_.Attributes.value__ -band $RePAttr) -eq $RePAttr) {
            $RePoint.Target = $_ | Select-Object -ExpandProperty Target
            if ( $null -eq $RePoint.Target -or
                 $RePoint.Target.GetType().Name -ne 'String' -or
                 $RePoint.Target.Length -lt $RePoint.Name.Length )
            {
                Write-Verbose "Target from fsutil.exe for $($RePoint.Name)"
                $RePoint.Source = 'fsutil'
                $RePoint.Target = ($_ | ParseFsutil)[2]
            }
        } else {
            Write-Verbose "Item is not a reparse point: $($RePoint.Name)"
            $RePoint.Source = 'self'
            $RePoint.Target = $_.FullName
        }
        $RePoint
    }

Output (truncated): pwsh -nopro -file D:\PShell\SO\71697488.ps1 -Verbose

VERBOSE: PowerShell 7.1.5

Name              Source Target
----              ------ ------
MicrosoftEdge.exe Target C:\WINDOWS\system32\SystemUWPLauncher.exe
…
wt.exe            Target C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.12.10393.0_x64__8wekyb3d8bbwe\wt.exe

Output (truncated): powershell -nopro -file D:\PShell\SO\71697488.ps1 -Verbose

VERBOSE: PowerShell 5.1.19041.1620
VERBOSE: Target from fsutil.exe for MicrosoftEdge.exe
…
VERBOSE: Target from fsutil.exe for wt.exe
Name              Source Target
----              ------ ------
MicrosoftEdge.exe fsutil C:\WINDOWS\system32\SystemUWPLauncher.exe
…
wt.exe            fsutil C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.12.10393.0_x64__8wekyb3d8bbwe\wt.exe

Output: powershell -nopro -file D:\PShell\SO\71697488.ps1 -Path D:\PShell\SO\71697488.ps1 -Verbose

VERBOSE: PowerShell 5.1.19041.1620
VERBOSE: Item is not a reparse point: 71697488.ps1

Name         Source Target
----         ------ ------
71697488.ps1 self   D:\PShell\SO\71697488.ps1

Raw output from the ParseFsutil function:

Get-Item "$env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe" | ParseFsutil
Microsoft.WindowsTerminal_8wekyb3d8bbwe
Microsoft.WindowsTerminal_8wekyb3d8bbwe!App
C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.12.10393.0_x64__8wekyb3d8bbwe\wt.exe
0
Get-Item "$env:LOCALAPPDATA\Microsoft\WindowsApps\MicrosoftEdge.exe" |
    ParseFsutil
Microsoft.MicrosoftEdge_8wekyb3d8bbwe
Microsoft.MicrosoftEdge_8wekyb3d8bbwe!MicrosoftEdge
C:\WINDOWS\system32\SystemUWPLauncher.exe
1
0
mklement0 On

tl;dr

  • $env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe is an AppX reparse point aka app execution alias aka (internally) AppExecLink, a Microsoft-specific NTFS reparse point, used with UWP / Microsoft Store applications as entry points, i.e. the executables that launch them.

    • Hard links are unrelated, both conceptually and technically.
  • While such a reparse point's data does point to another file - similar to a symbolic link - the data is considered an undocumented implementation detail by Microsoft, subject to change.[1]

    • Therefore, while you technically can parse that data, you shouldn't, or at least you shouldn't rely on it in production code, as there is no guarantee of long-term stability.

    • Such reparse points can be executed like any other executable, which is usually sufficient.

    • While you may want to know which different executable is ultimately launched behind the scenes, static analysis of the reparse data isn't guaranteed to give you that information, as in some cases a generic launcher executable is used, which opaquely launches the ultimate target executable (see below for details). In other words: the target executable stored in the reparse data isn't necessarily the real target executable.

    • Resolving an AppX reparse point to its target executable is deeply embedded in the system, namely in the CreateProcess family of WinAPI functions. You need runtime analysis to see what executable a given launched AppX reparse point ends up invoking; a simple way to is to invoke interactively and look for the resulting process in Task Manager; this comment on GitHub shows a complex programmatic approach based on the QueryFullProcessImageName WinAPI function


How do you follow a hard link (reparse point?) to a file?

A hard link is not a reparse point.

Hard links, in fact, do not point to other file paths, the way that symbolic links do, for instance.

Instead, a group of related hard links directly point to the same file data, without any relationship between their paths.

However, unlike on Unix-like platforms, the system APIs on Windows allow you to directly discover all paths pointing to the same file data, given one of those paths.

In Windows PowerShell, this array of related paths is exposed via the .Target property that System.IO.FileInfo instances emitted by Get-ChildItem / Get-Item are decorated with.

Unfortunately, this was removed in PowerShell (Core) v6+ - see this answer for details.


By contrast, other forms of NTFS links - notably symbolic links (symlinks), but also the older junctions and volume mount point types - are reparse points. I'll refer to them collectively as symlinks below.

Reparse points are essentially just metadata attached to a file-system entry, and their use is open-ended; for instance, reparse points are also used to manage on-demand downloads of cloud-hosted OneDrive files.

In short: Symlinks are just one type of reparse point, and not every reparse point is a link.

The subclass of reparse points that publicly point to other file-system paths are known as name surrogates.


$env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe is an example of yet another kind of reparse point: an AppX reparse point aka app execution alias aka (internally) AppExecLink.

These are used by UWP applications, which notably includes all applications downloaded from the Microsoft Store.

While they are conceptually similar to symlinks, there are important differences:

  • The link target isn't necessarily the true target executable; it may just be the generic UWP launcher, C:\WINDOWS\system32\SystemUWPLauncher.exe, such as in the case of Microsoft Edge.

  • More importantly, though, Microsoft considers the reparse data associated with app execution aliases (AppX reparse points) to be an undocumented implementation detail, subject to change.[1] In other words:

    • You should treat an app execution alias just like any other (regular, self-contained) executable and not as a link to another executable.

    • You shouldn't try to parse the reparse data yourself.

Of course, you can try to parse it yourself, but you cannot rely on the data format not to change.

  • Note: Even when parsed correctly, the data doesn't tell you the full story, such as in the case of Microsoft Edge: the path of the executable that is ultimately used - C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe, presumably launched by C:\WINDOWS\system32\SystemUWPLauncher.exe - cannot be gleaned from the reparse data (alone).[2]

In fact, PowerShell (Core) briefly implemented its own parsing, but removed it again later after internal feedback.

The since-removed code can be found here, which involves P/Invoke operations and is therefore not directly available in PowerShell (though you could try ad hoc-compilation of embedded C# code via Add-Type).

As JosefZ's answer shows, you can also glean the raw byte data from the output from fsutil reparsepoint query, but that is both less robust and slow.

Here's an alternative implementation - slightly more robust, but still slow; once defined, invoke it as follows:

Get-ChildItem -File $env:LOCALAPPDATA\Microsoft\WindowsApps | Resolve-AppXExePath
function Resolve-AppXExePath {
<#
.SYNOPSIS
  Resolves AppX execution aliases to their app IDs and target paths.
.EXAMPLE
  Get-ChildItem -File $env:LOCALAPPDATA\Microsoft\WindowsApps | Resolve-AppXExePath
 .NOTES
  This command is slow, because a call to fsutil.exe is made for each input path.
  #>
  param(
    [Parameter(ValueFromPipeline)]
    [Alias('PSPath')]
    [string] $LiteralPath
  )
  
  process {
    $fullName = Convert-Path -LiteralPath $LiteralPath
    if (-not $?) { return }
  
    # Get a hex-dump representation of the reparse-point data via fsutil reparsepoint query $fullName
    $hexDump = fsutil reparsepoint query $fullName 2>&1
    if ($LASTEXITCODE) { Write-Error $hexDump; return }
  
    # Extract the raw bytes that make up the reparse-point data.
    [byte[]] $bytes = -split (-join ($hexDump -match '^[a-f0-9]+:' -replace '^[a-f0-9]+:\s+(((?:[a-f0-9]{2}) +){1,16}).+$', '$1')) -replace '^', '0x'
    
    # Convert the data to a UTF-16 string and split into fields by NUL bytes.
    $props = [System.Text.Encoding]::Unicode.GetString($bytes) -split "`0"
    
    # Output a custom object with the App ID (Package ID + entry-point name)
    # and the target path (which may just be the universal UWP launcher)
    [PSCustomObject] @{
      AppId = $props[2]
      Target = $props[3]
    }
  
  }
}

Sample output:

AppId                                                               Target
-----                                                               ------
Microsoft.XboxGamingOverlay_8wekyb3d8bbwe!App                       C:\Program Files\WindowsApps\Microsoft.XboxGamingOverlay_5.721.12013.0_x64__8wekyb3d8bbwe\GameBarElevatedFT.exe
Microsoft.MicrosoftEdge_8wekyb3d8bbwe!MicrosoftEdge                 C:\WINDOWS\system32\SystemUWPLauncher.exe
Microsoft.DesktopAppInstaller_8wekyb3d8bbwe!PythonRedirector        C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.17.10271.0_x64__8wekyb3d8bbwe\AppInstallerPythonRedirector.exe
Microsoft.DesktopAppInstaller_8wekyb3d8bbwe!PythonRedirector        C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.17.10271.0_x64__8wekyb3d8bbwe\AppInstallerPythonRedirector.exe
CanonicalGroupLimited.UbuntuonWindows_79rhkp1fndgsc!ubuntuonwindows C:\Program Files\WindowsApps\CanonicalGroupLimited.UbuntuonWindows_2004.2022.1.0_x64__79rhkp1fndgsc\ubuntu.exe
CanonicalGroupLimited.Ubuntu18.04onWindows_79rhkp1fndgsc!ubuntu1804 C:\Program Files\WindowsApps\CanonicalGroupLimited.Ubuntu18.04onWindows_1804.2020.824.0_x64__79rhkp1fndgsc\ubuntu1804.exe
Microsoft.DesktopAppInstaller_8wekyb3d8bbwe!winget                  C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.17.10271.0_x64__8wekyb3d8bbwe\winget.exe
Microsoft.WindowsTerminal_8wekyb3d8bbwe!App                         C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.11.3471.0_x64__8wekyb3d8bbwe\wt.exe

[1] See this comment by the .NET team on GitHub.

[2] Whether the generic SystemUWPLauncher.exe launcher is used may be related to whether a given application is a bona fide UWP application vs. a repackaged desktop application, via the Desktop Bridge. It is unclear to me how, when SystemUWPLauncher.exe is used, the ultimate target executable is determined.