Windows 上利用 VBScript 取得 junction point 的真實路徑
Tags: vbscript, pyenv, powershell, scoop
Published: 2024-03-29 03:56:35

這幾天因為測試改用 [scoop](https://scoop.sh/#/) 當成軟體管理工具, 發現透過 scoop 安裝的 pyenv 無法幫我安裝 Python, 都會出現以下的錯誤訊息：

```
:: [Error] :: error installing "core" component MSI.
```

根據[善心人士的研究](https://github.com/pyenv-win/pyenv-win/issues/449), 發現 msi 格式的安裝套件需要以 `msiexe` 執行, 但是 `msiexe` 考量跳轉到其它資料夾的安全性, 所以如果遇到指定安裝的資料夾是 junction point 時, 並不會取得真正的路徑, 所以安裝就會失敗。scoop 為了讓軟體更新時不會動到執行軟體的路徑, 所以每個軟體都會有一個 current 的 junction point, 連結到實際版本的資料夾, pyenv 會把 Python 安裝到透過 current 連結的資料夾下, 因此遇到 msiexe 不取得真正路徑的問題。

pyenv 的 Windows 版本實際上使用了 VBScript 撰寫安裝 Python 的工作, 因此這就引起了我的好奇心, 要怎樣才能在 VBScript 中取得 junction point 的實際路徑, 這樣就可以再執行 msiexe 的時候, 改用真實路徑, 就可以避免前面提到的問題了。

## 取得 junction point 實際路徑的指令

VBScript 時實際上沒辦法取得 junction point 的真實路徑, 因此就要看看是不是有限成的指令可用, 再由 VBScript 去執行指令後取得輸出結果。

要在 Windows 上取得 junction point 的真實路徑, 有以下三種指令：

- 使用內建工具 [`fsutil`](https://learn.microsoft.com/zh-tw/windows-server/administration/windows-commands/fsutil)：

    ```
    # fsutil reparsepoint query "current"
    重新分析標記值 : 0xa0000003
    標記值: Microsoft
    標記值: Name Surrogate
    標記值: Mount Point
    取代名稱位移: 0
    取代名稱長度: 80
    列印名稱位移: 82
    列印名稱長度: 0
    取代名稱:       \??\C:\Users\meebo\scoop\apps\7zip\23.01

    重新分析資料長度：0x5c
    重新分析資料:
    0000:  00 00 50 00 52 00 00 00  5c 00 3f 00 3f 00 5c 00  ..P.R...\.?.?.\.
    0010:  43 00 3a 00 5c 00 55 00  73 00 65 00 72 00 73 00  C.:.\.U.s.e.r.s.
    0020:  5c 00 6d 00 65 00 65 00  62 00 6f 00 5c 00 73 00  \.m.e.e.b.o.\.s.
    0030:  63 00 6f 00 6f 00 70 00  5c 00 61 00 70 00 70 00  c.o.o.p.\.a.p.p.
    0040:  73 00 5c 00 37 00 7a 00  69 00 70 00 5c 00 32 00  s.\.7.z.i.p.\.2.
    0050:  33 00 2e 00 30 00 31 00  00 00 00 00              3...0.1.....
    ```
    
    『取代名稱』後面就是 current 這個 junction point 實際的路徑, 不過這個指令會依據系統選用的語系顯示對應語言的訊息, 像是剛剛看到的『取代名稱』在英文語系下就會是 "Substitute Name", 如果試想要透過程式從中取得實際路徑, 就要小心語系的問題。

- 使用需要額外下載的外部工具 [`junction`](https://learn.microsoft.com/en-us/sysinternals/downloads/junction)：

    ```
    # junction current

    Junction v1.07 - Creates and lists directory links
    Copyright (C) 2005-2016 Mark Russinovich
    Sysinternals - www.sysinternals.com

    C:\Users\meebo\scoop\apps\7zip\current: JUNCTION
       Substitute Name: C:\Users\meebo\scoop\apps\7zip\23.01
   ```
   
   它固定使用英文訊息, 所以不會有語系的問題。不過這個工具程式並沒有考慮到中文, 所以如果連結到中文資料夾的 junction point, 就會出現亂碼：
   
   ```
   # junction test

    Junction v1.07 - Creates and lists directory links
    Copyright (C) 2005-2016 Mark Russinovich
    Sysinternals - www.sysinternals.com

    C:\Users\meebo\code\test\test: JUNCTION
       Substitute Name: C:\Users\meebo\code\test\??
   ```
   
- 使用 PowerShell 的 [`get-item`](https://ss64.com/ps/get-item.html) 指令

    這應該是最理想的方案：
    
    ```
    # (get-item "current").Target
    C:\Users\meebo\scoop\apps\7zip\23.01
    ```
    
    即使是中文資料夾也沒問題：
    
    ```
    # (get-item "test").Target
    C:\Users\meebo\code\test\測試
    ```
    
## 使用 VBScript 取得 junction point 的實際路徑

VBScript 其實沒有真正取得 junction point 的方法, 所以必須執行剛剛介紹的工具間接取得：

```vb
foldername = "current"

Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("fsutil reparsepoint query """ & foldername & """")

Do While fsutil.Status = 0
 WScript.Sleep 100
Loop

If fsutil.ExitCode <> 0 Then
 WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
 WScript.Quit fsutil.ExitCode
End If

Set re = New RegExp
re.Pattern = "取代名稱:\s+(.*)"

For Each m In re.Execute(fsutil.StdOut.ReadAll)
 targetPath = m.SubMatches(0)
Next

WScript.Echo targetPath
```

要記得 VBScript 是 Big5 編碼, 存檔時要注意。執行結果如下：

```
# cscript //U //nologo c:\users\meebo\code\test\test2.vbs
\??\C:\Users\meebo\scoop\apps\7zip\23.01
```

請留意實際的路徑之前還會有垃圾資料, 也要處理掉。

如同之前所說, 因為 fsutil 的輸出會因為語系而採用不同語言, 程式碼中以規則樣式找尋真實路徑位置的地方也要跟著改。如果想要避免這個麻煩, 也可以改用 junction 外部工具：

```vb
foldername = "current"

Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("junction """ & foldername & """")

Do While fsutil.Status = 0
 WScript.Sleep 100
Loop

If fsutil.ExitCode <> 0 Then
 WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
 WScript.Quit fsutil.ExitCode
End If

Set re = New RegExp
re.Pattern = "Substitute Name:\s+(.*)"

For Each m In re.Execute(fsutil.StdOut.ReadAll)
 targetPath = m.SubMatches(0)
Next

WScript.Echo targetPath
```

執行結果如下：

```
# cscript //U //nologo c:\users\meebo\code\test\test.vbs 
C:\Users\meebo\scoop\apps\7zip\23.01
```

不過因為 fsutil 和 junction 各有缺點, 所以最理想的還是透過 PowerShell 指令：

```vb
foldername = "test"

Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("powershell -command  (get-item """ & foldername & """).target")

Do While fsutil.Status = 0
 WScript.Sleep 100
Loop

If fsutil.ExitCode <> 0 Then
 WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
 WScript.Quit fsutil.ExitCode
End If

targetPath = fsutil.StdOut.ReadAll
WScript.Echo targetPath
```

執行結果如下：

```
# cscript //U //nologo c:\users\meebo\code\test\test1.vbs
C:\Users\meebo\scoop\apps\7zip\23.01
```

即使是連結到中文資料夾也沒有問題：

```
# cscript //U //nologo c:\users\meebo\code\test\test1.vbs
C:\Users\meebo\code\test\測試
```