🦔

filepath.EvalSymlinks のバグを修正した

2023/01/21に公開

macOS 環境で filepath.EvalSymlinks が想定外のエラーを返すケースがあったので、これについて調べてみた。

% go version
go version go1.19.5 darwin/amd64
% pwd
/Users/matsuyoshi/tmp
% ls -l dst
lrwxr-xr-x  1 matsuyoshi  staff  8  1 19 00:28 dst@ -> /tmp/src
% cat main.go
package main

import (
        "fmt"
        "path/filepath"
)

func main() {
  v, err := filepath.EvalSymlinks("dst")
  fmt.Println(v)
  fmt.Println(err)
}
% go run main.go

lstat private: no such file or directory

処理を追う

GoDoc のコメントからコピペ。

EvalSymlinks returns the path name after the evaluation of any symbolic links. If path is relative the result will be relative to the current directory, unless one of the components is an absolute symbolic link. EvalSymlinks calls Clean on the result.

EvalSymlinks は、引数に受け取ったシンボリックリンクを評価したあとのパスを返す。引数 path が相対パスの場合、コンポーネントの一つが絶対パスへのシンボリックリンクでなければ、結果も現在のディレクトリからみた相対的なものになる。 EvalSymlinks は結果に対して Clean を呼び出す。リンクを辿っていくときに絶対パスのものがあれば結果は絶対パスになる。

実際の処理は walkSymlinks が行っている。処理の概観は以下の通り。

  • 引数の path をパスセパレータを区切りとして先頭から評価(評価対象 dest)
    • /a/b/c/d の場合は /a を評価→ /a/b を評価→…
  • dest について os.Lstat を呼び出して FileInfo を取得
  • dest がリンクでなくてディレクトリの場合は次のイテレーションに進む(ディレクトリを掘っていく)
  • dest がリンクの場合は os.Readlink でリンク先を取得
    • リンク先が絶対パスの場合、次のイテレーションで評価する dest を絶対パスとして構築する
    • リンク先が相対パスの場合、評価した dest と取得したリンクを元に次のイテレーションで評価する dest を準備する(dest の最後のパスセパレータ以降の部分を相対パスのリンクを使って置き換える)

想定外の no such file or directory

ルートディレクトリ直下に相対パスのリンクがある場合、評価が失敗する。たとえば以下のケース。

  • ルートディレクトリ直下にはリンク a とディレクトリ b が存在する
  • /ab/aa へのリンク(相対パス)になっている

このとき、path に /a/c へのリンクである d を指定して filepath.EvalSymlinks を呼び出すと、以下の順序で処理される。

  1. d の評価
    • dest は d
    • os.Lstat, os.Readlink により link = /a/c を取得して path = /a/c に更新
    • リンクが絶対パスのため、dest を / に更新して次イテレーションに進む
  2. /a/c の評価
    • dest は /a
    • os.Lstat, os.Readlink により link = b/aa を取得して path = b/aa に更新
    • リンクが相対パス
      • for r = len(dest) - 1; r >= volLen; r-- のループ
      • dest は /a なので r は 0
      • r も volLen も 0 なので r < volLen は false
      • dest が dest[:0] つまり空文字に更新されて次イテレーションに進む
  3. b/aa の評価
    • dest は b
    • b というファイルパスが存在せず os.Lstat でエラー lstat b: no such file or directory になる

macOS では etc, tmp および var は、ルート直下の private ディレクトリの相対パスのシンボリックリンクになっている。

lrwxr-xr-x@  1 root  wheel    11 10 13 15:06 etc@ -> private/etc
drwxr-xr-x   6 root  wheel   192 11 12 01:15 private/
lrwxr-xr-x@  1 root  wheel    11 10 13 15:06 tmp@ -> private/tmp
lrwxr-xr-x@  1 root  wheel    11 10 13 15:06 var@ -> private/var

上記であげた例と対応させると、 a(aa)etc, tmp, varbprivate になる。

記事冒頭や issue に記載の通り、 /tmp 以下のシンボリックリンクを相対パス形式で filepath.EvalSymlinks に渡すと失敗する。(dst => d / src => c)

% pwd
/Users/matsuyoshi/tmp
% ls -l dst
lrwxr-xr-x  1 matsuyoshi  staff  8  1 19 00:28 dst@ -> /tmp/src
% cat main.go
package main

import (
        "fmt"
        "path/filepath"
)

func main() {
  v, err := filepath.EvalSymlinks("dst")
  fmt.Println(v)
  fmt.Println(err)
}
% go run main.go

lstat private: no such file or directory

原因

walkSymlinks では、リンクが絶対パスを指していたら、次のイテレーションからは dest を絶対パスで構築して処理している。このときに vol と volLen が更新されていないため、以降のイテレーションでルート直下で相対パスを指しているリンクが出てきたとき、dest が正しく構築されずにエラーになる。

つまり、リンク先が絶対パスのものが登場したタイミングで vol と volLen を更新すればよい。このコミットで修正した(レビューによって原因が明確になった)。

その他

ちなみになんでこれに気付いたかというと、mattn/go-zglob を git clone して go test したら失敗していて、なんでなんだろうなと思って調べたから。当たり前だけど、OSS を自分の環境に持ってきてテストするだけで言語のバグ修正に貢献できるチャンスがある。

zglob って **/* みたいなやつという認識だけど、ググっても関節の画像ばっかで **/* だよという説明はみつからなかった。由来はなんだろう?

Discussion