[Go]*os.Rootベースのファイルシステム抽象化とライブラリ非依存ヘルパーの提案
*os.Rootベースのファイルシステム抽象化とライブラリ非依存ヘルパーの提案
こんにちは。
go1.25rc1がリリースされましたね。
DRAFT RELEASE NOTEは以下となります。
今回はGo 1.24で追加され、Go 1.25で残りのメソッドが実装されることになる*os.Rootを基盤としたfilesystem abstraction libraryと、どのライブラリでも使用可能な汎用ヘルパー関数の設計について提案します。
概要
この記事では、Go 1.25で完全実装される*os.Rootを基盤とした新しいfilesystem abstraction libraryの提案と、既存ライブラリの課題を解決するアプローチについて説明します。
背景
GoにはGo 1.16で追加されたfs.FSがありますが、これは読み込み専用のファイルシステムインターフェースです。書き込み機能を持つファイルシステム抽象化の標準化は、プラットフォーム間の挙動の違いを統一する複雑さに対して、標準ライブラリに含める十分な動機が見つからないという理由で見送られています。
そのため、コミュニティでは独自の書き込み可能ファイルシステム抽象化ライブラリが数多く開発されています:
- afero - 最も広く使われている(imported by: 7,666)
- go-billy - go-gitプロジェクトの一部として活発に開発
- hackpadfs - fs.FSをベースとした比較的新しいライブラリ
セキュリティの課題
GoはDockerやPodmanなどのコンテナ基盤で広く使用されており、ファイルシステム操作のセキュリティは重要な課題です。特に、path traversal攻撃(../../../etc/passwdのような相対パスを使った不正なファイルアクセス)を防ぐ仕組みが必要とされています。
過去にsecure-joinというAPIの提案がありましたが、実装の複雑さから採用されませんでした。しかし、github.com/cyphar/filepath-securejoinがk8s.ioの各種パッケージで使用されていることからも、この機能への需要は確実に存在します。
*os.Rootの登場と意義
Go 1.24で一部、Go 1.25で完全実装される*os.Rootは、secure-joinと似たような課題に対して取り組むために提案されました。
*os.Rootの特徴:
- 特定のサブディレクトリ以下のみにアクセスを制限
- path traversal攻撃とsymlink escapeの両方を防止
本記事の提案
この*os.Rootの登場により、既存のfilesystem abstraction libraryが直面する互換性や統一性の課題を解決する新しいアプローチが可能になります:
-
vroot: *os.Rootのメソッドセットを基盤としたfilesystem abstraction library
-
osパッケージで定義されるファイル操作のすべてを網羅 - 新たなAPIの基調
- rootおよびsub-rootからsymlink escapeさせない
- 絶対パスを受け付けない
-
-
fsutil: Genericsを活用した、filesystem-abstraction-library-agnostic helpers
- ヘルパーが特定のfilesystem abstraction libraryにくっつかないようにgenericsでもとから剥がしとこうよという提案
あとの内容は目次を見てください。
環境
$ go version
go version go1.25rc1 linux/amd64
GOTOOLCHAIN環境変数を設定すれば問答無用でgo1.25rc1のsdkを落としてそれで実行できるようになります。
export GOTOOLCHAIN=go1.25rc1
issue
先に述べておきますが、*os.Rootにはまだバグがあります。
-
#73868
-
OpenRoot->*os.Root.OpenRoot->*os.Root.OpenRootで開いた子root、孫rootで開いたファイルに対してReaddir系のメソッドを呼び出すとENOENTが返ってくるというもの。 - 書いてありますが
*os.Root.OpenRootが新しい*os.Rootを開くときにnameを適切に渡しそこなっているのが原因です。 -
Goはstdを編集してコンパイルしなおすと普通に変更が反映されるのでsdkを直接修正すれば次回以降のgo testなどはうまく動作するようになります。 - 実はReaddirによって
[]fs.FileInfoを取得される際にはos.Lstatが用いられます。 -
lstatが呼ばれるときのprefixがちゃんとわたっていないことが問題です。lstatatが存在していればこんなバグも起こらなかったんでしょうが、どうもPOSIX APIには存在しないようです。 - これを機に
Readdirもfstatat(3p)を使おうみたいな話の流れになるんですかね? - と思ってstdを読み直すと
os.Lstatはすでにfstatatを使用していますのでもしかしたら使えない理由があってやっていないのかも・・・ - windowsでは起きません。
-
-
#69509
-
wasip1でパスの取り扱いがおかしいというもの。
-
以下はバグではなくenhancementですがこれは#67002の中で述べられていた、各プラットフォーム向けの最適なAPIを使用することで最適な実装を行おうというものです。
-
#73076
- 各プラットフォーム向けに最適な実装をしようというもの
- 多分、ファイルに対するIO操作のほうがよほど時間がかかるのでこの最適化がされなくても十分な実行速度を持てると思いますが、std libraryはあらゆるものから使われるわけですからメンテナンス性を確保できている限り、速ければ速いほどいいですよね。
とりあえずrc2を待ちましょう。
はじめに
これはread-only filesystemで、/で区切られたパスによってファイルを開いて読めるだけ・・・というものです。
type FS interface {
Open(name string) (File, error)
}
type File interface {
Read(p []byte) (n int, err error)
Stat() (FileInfo, error)
Close() error
}
特定のディレクトリの下に特定の構造があり、それを期待して読み込んだり書き込んだりするようなプログラムを書くことは、筆者としてはたびたびあります。
ディレクトリ自体は設定ファイルなりなんなりで自由に変えることができるため、どのディレクトリに読み込んでいるのかはプログラムの関心から外したいという欲求が筆者にはよくありますし、実際にfs.FSが実装されたのはそういった欲求は広く存在するからだと思います。
fs.FSは単にinterfaceであるため、それさえ満たせばdata sourceは何でもよいことになります。
当然、どこかのディレクトリ以下でもいいし、smb/nfsなどのネットワークファイルシステム, tar/zipなどのアーカイブファイル、なんならin-memoryの構造でもかまいません。
同様に書き込みに関しても似たように、書き込み先は何でもよいということがあります。
fs.FSはread-onlyですので、書き込みは行えません。
fs.FSに書き込めるinterfaceも実装しようというproposalは上がりましたが、プラットフォーム間の挙動を埋めるためのコードを書き、それをstdに取り込むことはできるがそうする強い動機は見つからないということでcloseされています。コミュニティーの中でいろいろな形が模索されたのち、数年後にまた検討しようとのことです。
stdには取り込まれませんが、コミュニティーの中でいくつもwritableなfilesystem abstraction libaryが開発されています。
筆者が知ってる限りの例で有名なものを挙げると
この中ではaferoが一番有名で現在imported by: 7,666で最多となります。これはあくまでgo proxyに記録されているaferoをimportしているgo moduleの数なので実際にはもっとたくさんのgo moduleが利用していると思われます。
筆者はaferoを使用しており、大変便利ですが、それぞれに若干のつらさがあります。
既存ライブラリの辛さ
それぞれのライブラリにはそれぞれつらみがあります。
-
afero:
- symlinkの取り扱いがSymlinkIfPossible(oldname, newname string) errorというinterfaceになっている
- hardlinkのサポートがない
-
OsFsが単なる
os.Createなどへのショートハンドでしかないため、BasePathFsとの組み合わせが前提となっている。- これがあるため絶対パスを禁止することができなくなっています。
-
MemMapFsの不備
- pathのnormalizeに漏れがあり、
/path/to/fileと./path/to/fileで扱いが別になってしまうため、fs.Walkでwalkするとき、rootを"."とするとパスが見つからないことがある。- このせいでテストでだいぶ驚くことになる
- fileの
ReadAtメソッドがReaderAtはconcurrentに呼び出されてもよいというconstraintを守っていない
- pathのnormalizeに漏れがあり、
- 実装が全般的に
/path/to/fileを受け付けてしまいます。絶対パス風ならはじいてほしいと思っています。 - あまり活動が活発ではない。
-
go-billy:
-
go-gitプロジェクトの一環として開発されており、大変元気です。 - interfaceがcomposableになるように細かく分けられている
-
FilesyteminterfaceにはTempFileというやや専門的すぎなメソッドが含まれていたり、 -
FileにはLock/Unlockが含まれています。-
Fileはcomposableになっていないため、この専門的なメソッドの実装は必須です
-
- osfsのOpenFile系が勝手に親フォルダを作成してしまう挙動はかなり意見が強いです。
- major versionが多すぎる
-
v5が最新で、v6のリリースを示唆する書き込みもissue中にあります(next major versionへの言及) - Go v1のリリース日は2012-03-28、Go1.16のリリースが2021-02-16であることを考えると、多すぎる。いくらたいていはinterface的な互換があるとはいえ多すぎるmajor versionの更新は相互運用性にかかわってきます。
-
-
-
hackpadfs:
- 筆者はお試し以上に使ったことがないため特に深いことは言えないですが、
-
go-billy同様にinterfaceがcomposableになるように細かく分けられています -
go-billy以上にfs.FSに寄せてあって、ベースとなるFSはfs.FSです- つまりwrite operationは常にfs.Fileを
type-assertionで書き込み可能なinterfaceに「広げる」必要があります。
- つまりwrite operationは常にfs.Fileを
-
memfsがkey-value storeベースの実装になっており、こうなってしまうと効率的にsubfsへの分割ができなくなってしまいます。
根本的辛さ: interfaceとしてのかみ合わなさ
ただし、これらのライブラリは基本的にinterfaceを定めるものなので、最終的に実装に文句があるなら自前でそろえてしまえばいいということになります。
なので、根本的に回避不能な辛さはinterfaceがいいか悪いかのみで判断すべきになります。
- afero -> symlink周りの取り扱いが遠回り
-
go-billy -> major versionの多さによる不安定さ、
Fileに存在するLock/Unlockメソッド - hackpadfs -> fs.Fileをwritableになるようにtype-assertしなければならない
が、根本的に回避できない辛さとなります。
*os.Rootの登場
secure-join
若干余談ですがコンテクストとしてsecure-joinの存在を知っていたほうが*os.Rootの立ち位置が明らかになるかも知れないので触れておきます。
GoはDocker, podmanなど、コンテナ基盤で盛んに使われています。
コンテナは実装によりますが、基本的にはpivot_root(2)、unsahre(2)その他もろもろで隔離された名前空間のなかで動作するプロセスやらroot fsやらのことをさします。
#20126でかつてsecure-joinというpath traversalを防ぎながらjoinを行うAPIの追加がproposeされましたが、完全な実装の難しさからcloseされています。しかし、github.com/cyphar/filepath-securejoinのdependenciesを見れば、k8s.ioの各種パッケージからインポートされていることがわかります。こちらは/procの下などをコンテナの名前空間を見せたりするのに使う安全策を組み込んでいるような記述があります。
*os.Rootはこれとは違って/procでのカーネル空間で起きるsymlink resolutionなどは考慮に加えません。
*os.Root
#67002で*os.Rootが提案され、Go 1.24で一部のメソッド、Go 1.25で残りすべてのメソッドが追加されます。
*os.Rootは特定のサブディレクトリの下のみを操作できるosのメソッドを提供するものです。
path traversalに加えて、symlinkによって特定のサブディレクトリの外に出るのを防ぐことができます。
*os.Rootのmethod set
masterで確認すればわかりますが、*os.Rootには以下のようなメソッドが追加されています:
type Root struct {
// unexported fields
}
func (r *os.Root) Chmod(name string, mode os.FileMode) error
func (r *os.Root) Chown(name string, uid int, gid int) error
func (r *os.Root) Chtimes(name string, atime time.Time, mtime time.Time) error
func (r *os.Root) Close() error
func (r *os.Root) Create(name string) (*os.File, error)
func (r *os.Root) FS() fs.FS
func (r *os.Root) Lchown(name string, uid int, gid int) error
func (r *os.Root) Link(oldname string, newname string) error
func (r *os.Root) Lstat(name string) (os.FileInfo, error)
func (r *os.Root) Mkdir(name string, perm os.FileMode) error
func (r *os.Root) MkdirAll(name string, perm os.FileMode) error
func (r *os.Root) Name() string
func (r *os.Root) Open(name string) (*os.File, error)
func (r *os.Root) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
func (r *os.Root) OpenRoot(name string) (*os.Root, error)
func (r *os.Root) ReadFile(name string) ([]byte, error)
func (r *os.Root) Readlink(name string) (string, error)
func (r *os.Root) Remove(name string) error
func (r *os.Root) RemoveAll(name string) error
func (r *os.Root) Rename(oldname string, newname string) error
func (r *os.Root) Stat(name string) (os.FileInfo, error)
func (r *os.Root) Symlink(oldname string, newname string) error
func (r *os.Root) WriteFile(name string, data []byte, perm os.FileMode) error
Truncateを除いたsymlinkやhardlink作成機能も含まれており、ファイルシステム操作に必要なすべての機能が揃っています。
*os.Rootの仕組み
*os.Rootはopenat(2)などの、fdからの相対パス開きができるAPIに依存しています。
*os.Rootの各methodにパスが渡されるとパスセパレータ(/か\)でパスコンポーネントに分割し、OBJ_DONT_REPARSE(windows)/O_NOFOLLOW(unix)付きでNtCreateFile/openatを呼び出し、ディレクトリを1つずつ開いていきます。
symlinkが見つかった場合にはreadlinkatを使って読み取りますが、この場合には読み込まれたリンクでパスコンポーネントを置き換え(a/b/cでb -> ../dだった場合a/../d/cで)、rootからパスをたどりなおします。これはopenat(dirFd, "..")をしてしまうと、dirFdが開いているファイルがrenameなどで移動された際のTOCTOU(Time Of Check, Time Of Use) raceによって間違ったパスをたどってしまうため、そうならないようにするための対策のようです。
vroot: *os.Root-based filesystem abstraction
*os.Rootが標準を示したことでfilesystem abstraction libraryの持つべきベーシックなinterfaceが定まりました。
・・・っていってもosパッケージ内での基本的なファイル操作APIはGo 1から特に追加も変更もなかったためずっと前から定まっていたんですが、
特定のサブディレクトリから脱出しないとか、絶対パスは使わせないというAPI constraintのベースラインがさらに追加されました。
*os.Rootがstdに入っちゃったらこれとうまくやれないfilesystem abstraction libraryはつらい思いをするのは目に見えています。
現状aferoは/から始まるパスでも動作してしまうためこのsubtleな違いが実装を入れ替えたときに微妙なエラーを引き起こすことが考えられます。(そもそも前述通り筆者はaferoのMemMapFsのsubtletiesでテストが動かなかったことがあるわけですが)
どうせなら作ってしまえということで、*os.Rootを中心にとらえたfilesystem abstraction libraryを作ってみます。
まだめちゃくちゃWIPですがここにホストしてあります。
- major versionは基本的に上がることはないはず:
- 前述通り、Go 1から特にファイル操作APIは増えたり変わったりしていないため、このinterfaceは安定しているとみなすことができます。
- intefaceのcomposabilityは一切捨てます。
- ファイルシステムは書き込み先の事情でいきなりいろいろ変わるのでinterface上のmethodのある/なしで何かを判断し分ける必要はそもそもないと思っています。
- 例えば、残り容量の足りなくなってきたfilesystemがremountされてread-onlyに突然なったりです。
-
sftp,nfs,smbなどのネットワークストレージは相手サーバーの設定変更でできることが変わってきます。
- もしかしたら
Capabilityextension interfaceを通じてcapabilityのチェックができるようにするかもしれませんが現状では何も考えていません。- これはstatvfs(3)によるmount flagのチェックと対応するためそこまでおかしく感じないんじゃないかと思います。
- ファイルシステムは書き込み先の事情でいきなりいろいろ変わるのでinterface上のmethodのある/なしで何かを判断し分ける必要はそもそもないと思っています。
Fs
*os.Rootのmethod setを直訳してinterfaceを作ります。
// Fs represents capablities [*os.Root] has as an interface.
//
// Methods are encouraged to return [*os.LinkError] wrapping an appropriate error for Rename, Link and Symlink,
// [*fs.PathError] for others.
type Fs interface {
Chmod(name string, mode fs.FileMode) error
Chown(name string, uid int, gid int) error
Chtimes(name string, atime time.Time, mtime time.Time) error
// Close closes Fs.
// Callers should not use Fs after return of this method but
// it is still possible that the method is just a no-op.
Close() error
Create(name string) (File, error)
Lchown(name string, uid int, gid int) error
Link(oldname string, newname string) error
Lstat(name string) (fs.FileInfo, error)
Mkdir(name string, perm fs.FileMode) error
MkdirAll(name string, perm fs.FileMode) error
// Name returns name for the Fs.
// For osfs, it reutnrs the name of the directory presented to OpenRoot.
Name() string
Open(name string) (File, error)
OpenFile(name string, flag int, perm fs.FileMode) (File, error)
OpenRoot(name string) (Rooted, error)
ReadLink(name string) (string, error)
Remove(name string) error
RemoveAll(name string) error
Rename(oldname string, newname string) error
Stat(name string) (fs.FileInfo, error)
Symlink(oldname string, newname string) error
}
- 1点だけ*os.Rootと違うところ:
ReadlinkではなくReadLinkと名前が変えてあります。- これは
Go 1.25で追加されるfs.ReadLinkFSのinterfaceと合わせるためにこうなっています。
- これは
File
*os.Fileを直訳してFile interfaceを定義します。
// File is basically same as [*os.File]
// but some system dependent methods are removed.
type File interface {
// Chdir() error
Chmod(mode fs.FileMode) error
Chown(uid int, gid int) error
Close() error
// Fd returns internal detail of file handle.
// Only os-backed File should reutrn this value.
// Otherwise, return ^(uintptr(0)) to indicate this is invalid value.
Fd() uintptr
Name() string
Read(b []byte) (n int, err error)
ReadAt(b []byte, off int64) (n int, err error)
ReadDir(n int) ([]fs.DirEntry, error)
// File might implement ReaderFrom but is not necessary.
// ReadFrom(r io.Reader) (n int64, err error)
Readdir(n int) ([]fs.FileInfo, error)
Readdirnames(n int) (names []string, err error)
Seek(offset int64, whence int) (ret int64, err error)
// SetDeadline(t time.Time) error
// SetReadDeadline(t time.Time) error
// SetWriteDeadline(t time.Time) error
Stat() (fs.FileInfo, error)
Sync() error
// SyscallConn() (syscall.RawConn, error)
Truncate(size int64) error
Write(b []byte) (n int, err error)
WriteAt(b []byte, off int64) (n int, err error)
WriteString(s string) (n int, err error)
// File might implement WriterTo but is not necessary.
// WriteTo(w io.Writer) (n int64, err error)
}
- 実際のファイルとは限らないので
Chdirは消します。 -
ReadFrom,WriteToはio.Copy向けの最適な実装を提供するextension interfaceなので強制ではなくします。 -
SetDeadline,SetReadDeadline,SetWriteDeadlineはソケットなど一部ファイル向けなので強制ではなくします。 -
SyscallConnも同様に消します。 -
Fdは大抵のケースで不要に思いますが、-
file lockの実装に必要です。 - 後述の
WalkDirのために必須としてあります。- filesystemをwalkするときはたいてい、bind mountによるループが起きていないかのチェックが必要です。
- unix系のplatformではstat(2)などを通じてstruct statを得ることで、inodeとdev numberの組み合わせでファイル固有の値を得ることができますが、
- windowsプラットフォームではGetFileInformationByHandleを用います。
- これには
fd・・・というかFileHandleの値が必要です。
- これには
-
RootedとUnrooted
vrootは二つの中心的interfaceが存在します。
-
Rooted: *os.Rootと同じく、path traversalとsymlink escapeを防ぐ -
Unrooted: path traversalは防ぐが、symlink escapeは許す。
// Unrooted is like [Rooted] but allow escaping root by sysmlink.
// Path traversals are still not allowed.
type Unrooted interface {
Fs
Unrooted()
OpenUnrooted(name string) (Unrooted, error)
}
// Rooted indicates the implementation is rooted,
// which means escaping root by path traversal or symlink
// is not allowed.
type Rooted interface {
Fs
Rooted()
}
*os.Rootとの滑らかな相互運用は目指していますが実際にはroot外に向かっているsymlinkを解決したい場面はたくさんあると思います。
思いつく限りだと
- それこそ
/etc/passwdへのsymlinkが必要な場面 -
/etc/smb.confが別のマウントポイントへのsymlinkになっている -
Volta(についてくるnpm)やpnpmのようなpackage managerがsymlinkによって依存ファイルを管理している
などなどでしょうか?
そういったケースにおいてsymlinkを解決してroot外へのアクセスをさせたいことは普通にあると思うのでUnrootedも同時に定義しておきます。
UnrootedはTOCTOUにも弱いつくりになっていることが想定されます(これはaferoのBasePathFsと同じです)。
そのためUnrootedからRootedを開くことは(もちろん不安全だが)できるようになっていますが、その逆はできません。
前述のとおり、*os.Rootはfdからの相対的なパス操作によってpath escapeを防ぎます。このfdが指し示すディレクトリは開いた後にrenameなどによって移動されていることは十分にあり得ます。fdからパスへの正確な変換方法は筆者の知り及ぶ限りありませんし、それがTOCTOU raceに強いとも思えません。そのためRootedからUnrootedへの変換は定義上作ることができない、ということになります。
実装
osfs
とりあえず*os.RootからRootedへ変換できるようにします。
osパッケージがerrPathEscapesをエクスポートしないため
文字列を見てエラーを差し替える部分を作っておきます。
文字列比較はやらないでいいならやりたくないですが、こうしないとerrors.Is(err, ErrPathEscapes)でテストをかけないので仕方なくやっています。
aferoのBasePathFsとほぼ同じものとしてosfsのUnrootedを作ります。
WalkDir
fs.WalkDirと互換なものとしてvroot.WalkDirを定義しておきます。
interfaceがsymlinkの存在をもとから考慮に入れているのでfs.WalkDirと違ってsymlinkをresolveしてたどってもよいことにしてあります。
fs.WalkDirFuncとは違い、vroot.WalkDirFuncはsymlinkを解決した後にrealPathも受け取るようになっています。ただしこれはReadLinkとLstatを組み合わせてパスをレキシカルに解決するだけのとても単純な仕組みであるため、TOCTOU raceには弱いです。
(現状まったくdoc commentが書けていませんが)root外のrealPathの取得はRootedはもちろんUnrootedでもできない(root外のパスに対してReadLinkを呼ぶ必要があるため)ので、その場合はrealPathには""が渡されることになります。
なので基本的にrealPathに""が来てなおかつディレクトリの場合は、SkipDirを返してstepするのをやめたほうが良いですね。
また、WalkDirがbind mountによるfilesystem loopによって無限ループに陥らないようにするために、可能であればファイルからユニークな値を取り出します。
unix系ではstatから
(plan9)
windowsではGetFileInformationByHandleから
それぞれユニークな値を取得します。
とりあえずlinux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64では見た限り正しく固有な値をとれているようです。
plan9やwasip1などではとりあえずコンパイルできますが、実装的に正しいのか検証はできていません。
to/from fs.FS
fs.FSとvrootの相互変換を定義します。
ReadOnly
Rooted/Unrootedをread-onlyになるようにラップする仕組みも欲しいため作っておきます。
これは間違って書かないようにするための安全策としてあったほうが良いですね
overlayfs
これが一番欲しかったものかもしれない。
overlay filesystemです。複数のvroot.Rootedを重ね合わせて一つのfsに見せかけます。
- 複数の
vroot.Rootedを重ねて一つに見せます。 - ディレクトリの内容は統合され、
- ファイル(ディレクトリ以外)の場合は最も「上側」のレイヤーのものが選ばれます。
- Copy-On-Writeの挙動があります。
-
chmodなどでファイルのメタデータを変更したときか、 - write modeでファイルを開くとコピーが起きます。
- 本当はファイルに対して初めて
Writeが呼ばれたときにコピーが起きるようにしたかったのですが、ロックの取り方やrace conditionの懸念から単純なこの挙動になっています。 - 実用上それで困らないと思ってます。
- 本当はファイルに対して初めて
-
- 書き込みはすべて
top layerにのみ起きます。 - 下層にあるファイルを消えたように見せるために、white out listという形で消えたパスを管理します。
- これはこのissueを参考にしています: https://github.com/opencontainers/image-spec/issues/24
ユースケースはいくつかあって
- 複数のディレクトリの内容を仮想的に重ねて見せたい
- ビルド成果物を共有フォルダなどに格納するとき、オペレーションでミスりたくないから
WORM(Write Once Read Many)にしておいてばらばらのディレクトリに格納しておくが実際には1つのディレクトリのように見せたい。
- ビルド成果物を共有フォルダなどに格納するとき、オペレーションでミスりたくないから
- code genratorなどの成果物をoverlayに書き出し、top layerのコンテンツをpackages.ConfigのOverlayに渡すことで書き出す前に型チェックをかける。
- cache
-
copy-on-writeがread-onlyで開いたときにも起きるようにすればcacheとして使用できます。
-
layerの重ね合わせはsymlinkも考慮に加えます。
あるlayerにあるsymlinkのlink targetは別のlayerをさしていてもよく、あればそちらに向けて解決されます。
つまり、下記exampleのように動作します。
package overlayfs_test
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"github.com/ngicks/go-fsys-helper/vroot"
"github.com/ngicks/go-fsys-helper/vroot/osfs"
"github.com/ngicks/go-fsys-helper/vroot/overlayfs"
)
func must1(err error) {
// ...省略...
}
func must2[V any](v V, err error) V {
// ...省略...
}
func tree(fsys vroot.Fs) error {
// ...省略...
}
func Example_overlay_symlink() {
tempDir := must2(os.MkdirTemp("", ""))
for i := range 4 {
layer := filepath.Join(tempDir, "layer"+strconv.FormatInt(int64(i), 10))
must1(os.MkdirAll(filepath.Join(layer, "meta"), fs.ModePerm))
must1(os.MkdirAll(filepath.Join(layer, "data"), fs.ModePerm))
}
// create leyred file system like this.
//
// +-------+
// LAYER3: | link2 |<-----+
// +-------+ |
// | |
// +-------+ | +-------+
// LAYER2: | link3 |<---+ | link1 |
// +-------+ +-------+
// |
// | +------+
// LAYER1: +----->| file |
// +------+
must1(os.MkdirAll(filepath.Join(tempDir, "layer3", "data", filepath.FromSlash("a/b/")), fs.ModePerm))
must1(os.Symlink("../link3", filepath.Join(tempDir, "layer3", "data", filepath.FromSlash("a/b/link2"))))
must1(os.MkdirAll(filepath.Join(tempDir, "layer2", "data", filepath.FromSlash("a/b/c")), fs.ModePerm))
must1(os.Symlink("../link2", filepath.Join(tempDir, "layer2", "data", filepath.FromSlash("a/b/c/link1"))))
must1(os.Symlink("./b/file", filepath.Join(tempDir, "layer2", "data", filepath.FromSlash("a/link3"))))
must1(os.MkdirAll(filepath.Join(tempDir, "layer1", "data", filepath.FromSlash("a/b/")), fs.ModePerm))
must1(os.WriteFile(filepath.Join(tempDir, "layer1", "data", filepath.FromSlash("a/b/file")), []byte("foobar"), fs.ModePerm))
var closer []func() error
defer func() {
for _, c := range closer {
err := c()
if err != nil {
fmt.Printf("meta fsys close error = %v\n", err)
}
}
}()
composeLayer := func(i int) overlayfs.Layer {
metaFsys := must2(
osfs.NewRooted(filepath.Join(tempDir, "layer"+strconv.FormatInt(int64(i), 10), "meta")),
)
closer = append(closer, metaFsys.Close)
meta := overlayfs.NewMetadataStoreSimpleText(metaFsys)
data := must2(
osfs.NewRooted(filepath.Join(tempDir, "layer"+strconv.FormatInt(int64(i), 10), "data")),
)
return overlayfs.NewLayer(meta, data)
}
fsys := overlayfs.New(
composeLayer(0),
[]overlayfs.Layer{composeLayer(1), composeLayer(2), composeLayer(3)},
nil,
)
must1(tree(vroot.FromIoFsRooted(os.DirFS(tempDir).(fs.ReadLinkFS), tempDir)))
fmt.Println()
bin, err := vroot.ReadFile(fsys, filepath.FromSlash("a/b/c/link1"))
if err != nil {
fmt.Printf("err = %v\n", err)
} else {
fmt.Printf("%q: %s\n", "a/b/c/link1", string(bin))
}
// Output:
// layer1/data/a/b/file
// layer2/data/a/b/c/link1 -> ../link2
// layer2/data/a/link3 -> ./b/file
// layer3/data/a/b/link2 -> ../link3
//
// "a/b/c/link1": foobar
}
これ実装中にwindowsではERROR_PATH_NOT_FOUND, ERROR_FILE_NOT_FOUNDはほぼ同じもののように扱われ、POSIXのENOTDIRのような概念はないらしいということを知りました。やっぱりマルチプラットフォームなライブラリづくりは面倒ですね。
synthfs(=in-memory fs)
以下の記事で書いたsynthetic filesystemのvroot版です。
完全にin-memoryなfilesystem+任意のバッキングストレージという組み合わせで成り立つfilesystemで、
file pathによるtrieを構築し、各ノードには適当なバッキングストレージのファイルを追加できるようにします。
バッキングストレージは、ほかのvroot.Fs(fs.FSから変換してもよい)、memoryなど、何でもよいです。メモリーからのみallocateするようにすると、これがaferoでいうところのMemMapFsになります。
作った動機は上記の記事内で説明していますが、
- ほかのfs.FSの内容から
sha256sumなどをとったメタデータをデータと同じディレクトリに追加し、(一旦書き出しを経ずに)CopyFSとかAddFSに直接渡す。 - in-memory fsでパスを何かしらの木構造で持つものが欲しかった:
- 権限のみを差し替えるようなラッパーが欲しかった
memfs
in-memory filesystemです。上記のsynthfsのショートハンドです。
tarfs
vrootとは直接は関係ないですが、以下の記事で作ったtarfsにsymlinkとhardlinkの取り扱いを加え、さらに、vrootと組み合わせるのを意識して「symlink解決時にsub-rootより上の階層に移動するか」の設定を追加しました。
symlinkを無視するのがデフォルト挙動ですが、デフォルトでハンドルしたほうがいい気もするので(破壊的に変更になりますが)そのように変えるかもしれません。
symlinkありのtarでも普通に動いているので使えそうな感じですが、世にどんなエッジケースがあるのかよくわからないのでしばらくタグをつけずに様子見します。
ちなみにfstest.TestFSがsymlinkを考慮するのはgo1.25以降であるようなのでsymlink/hardlinkありのアーカイブを使ったテストは//go:build go1.25でGo 1.25以降でないと実行されないように制限してあります。リリースされたらCIが常にgo1.25を使うように変更しようかなと。
fsutil: filesystem-abstraction-library-agnostic helpers
多分どのfilesystem abstraction libraryでも動作するようなヘルパーを書いてるけど、interfaceがそれぞれ違うせいでどれかでしか使えないっていうことがあるともったいないですよね?
ということで、逆の発想としてどのライブラリでも使えるようにヘルパーを作る仕組みを考えてみます。
「考えてみます」といっても特段難しいことはなく、Fileの部分をtype parameterにしたFs interfaceを再定義し、これを引数となるようなジェネリック関数を作ればよいだけです。
さらに、必要最低限な機能にのみ依存するようにするために、Fs, Fileのinterfaceはmethod単位で分割します。
interfaceの分割
つまり、Fs interfaceを以下のように分割します。
// Fs files
type ChmodFs interface {
Chmod(name string, mode fs.FileMode) error
}
type ChownFs interface {
Chown(name string, uid int, gid int) error
}
type OpenFileFs[File any] interface {
OpenFile(name string, flag int, perm fs.FileMode) (File, error)
}
// ...
同様にFileもmethodごとに切り分けます。
// File interfaces
type ChmodFile interface {
Chmod(mode fs.FileMode) error
}
type NameFile interface {
Name() string
}
type ReadAtFile interface {
ReadAt(b []byte, off int64) (n int, err error)
}
type ReadDirFile interface {
ReadDir(n int) ([]fs.DirEntry, error)
}
// ...
Example1: OpenFileRandom
関数は必要なinterfaceだけに依存するように前述したinterfaceを適当に組み合わせます。
例えば、os.CreateTempと似たような機能をもつヘルパーを定義するとすると、
type OpenFileFs[File any] interface {
OpenFile(name string, flag int, perm fs.FileMode) (File, error)
}
var (
ErrBadPattern = errors.New("bad pattern")
ErrMaxRetry = errors.New("max retry")
)
func OpenFileRandom[FS OpenFileFs[File], File any](fsys FS, dir string, pattern string, perm fs.FileMode) (File, error) {
return openRandom(
fsys,
dir,
pattern,
perm,
func(fsys FS, name string, perm fs.FileMode) (File, error) {
return fsys.OpenFile(filepath.FromSlash(name), os.O_RDWR|os.O_CREATE|os.O_EXCL, perm|0o200) // at least writable
},
)
}
func MkdirRandom[FS interface {
OpenFileFs[File]
MkdirFs
}, File any](fsys FS, dir string, pattern string, perm fs.FileMode) (File, error) {
return openRandom(
fsys,
dir,
pattern,
perm,
func(fsys FS, name string, perm fs.FileMode) (File, error) {
err := fsys.Mkdir(name, perm)
if err != nil {
return *new(File), err
}
return fsys.OpenFile(name, os.O_RDONLY, 0)
},
)
}
func openRandom[FS, File any](
fsys FS,
dir string,
pattern string,
perm fs.FileMode,
open func(fsys FS, name string, perm fs.FileMode) (File, error),
) (File, error) {
if dir == "" {
dir = "." + string(filepath.Separator)
}
if strings.Contains(pattern, string(filepath.Separator)) {
return *new(File), fmt.Errorf("%w: %q contains path separators", ErrBadPattern, pattern)
}
var prefix, suffix string
if i := strings.LastIndex(pattern, "*"); i < 0 {
prefix = pattern
} else {
prefix, suffix = pattern[:i], pattern[i+1:]
}
attempt := 0
for {
random := randomUint32Padded()
name := filepath.Join(dir, prefix+random+suffix)
f, err := open(fsys, name, perm.Perm())
if err == nil {
return f, nil
}
if errors.Is(err, fs.ErrExist) {
attempt++
if attempt < 10000 {
continue
} else {
return *new(File), fmt.Errorf(
"%w: opening %s",
ErrMaxRetry, path.Join(dir, prefix+"*"+suffix),
)
}
} else {
return *new(File), err
}
}
}
// randomUint32Padded return math/rand/v2.Uint32 as left-0-padded string.
// The returned string always satisfies len(s) == 10 and '0' <= s[i] <= '9'.
func randomUint32Padded() string {
// os.MkdiTemp does this thing. Just shadowing the behavior.
// But there's no strong opinion about this;
// It can be longer, or even shorter. We can expand this to
// 9999999999 instead of 4294967295.
s := strconv.FormatUint(uint64(rand.Uint32()), 10)
var builder strings.Builder
builder.Grow(len("4294967295"))
r := len("4294967295") - len(s)
for range r {
builder.WriteByte('0')
}
builder.WriteString(s)
return builder.String()
}
という感じになります。
os.O_RDWR|os.O_CREATE|os.O_EXCLでファイルを開ければよく、File自体には触りませんから、そこはanyよい、という感じです。
Fileがtype paramになった都合上、nilを直接返せなくなってしまいますが、*new(T)でzero valueを作成して返せばそれでよいです。
Example2: SafeWrite
逆にFileが書けるのを期待するようなときについて考えてみます。例えば上記のOpenFileRandomでファイルを開いてからそこに内容を書き込み、最後にRenameすることで最終的な名前にすることで、中途半端な状態が見えないようにするSafeWriteがあったとすると、以下のようになります。
type safeWriteFile interface {
WriteFile
CloseFile
NameFile
SyncFile
}
type safeWriteFsys[File safeWriteFile] interface {
OpenFileFs[File]
RenameFs
RemoveFs
}
func SafeWrite[File safeWriteFile](fsys safeWriteFsys[File], name string, r io.Reader, perm fs.FileMode) error {
dir := filepath.Dir(name)
randomFile, err := OpenFileRandom(fsys, dir, "*.tmp", perm.Perm())
if err != nil {
return err
}
randomFileName := filepath.Join(dir, filepath.Base(randomFile.Name()))
defer func() {
_ = randomFile.Close()
if err != nil {
fsys.Remove(randomFileName)
}
}()
bufP := bufpool.GetBytes()
defer bufpool.PutBytes(bufP)
buf := *bufP
_, err = io.CopyBuffer(randomFile, r, buf)
if err != nil {
return err
}
err = randomFile.Sync()
if err != nil {
return err
}
err = fsys.Rename(randomFileName, filepath.Clean(name))
if err != nil {
return err
}
return nil
}
実際に複数ライブラリ相手に使ってみる
実際に複数のfilesystem abstraction libraryに対して動かしてみます。
(このsnippetはvrootがgo1.25rc1指定なのでGo Playgroundでは動きません!Go dev branchモードはgo 1.25扱いになるからです!)
package main
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
billyosfs "github.com/go-git/go-billy/v5/osfs"
"github.com/ngicks/go-fsys-helper/fsutil"
vrootosfs "github.com/ngicks/go-fsys-helper/vroot/osfs"
"github.com/spf13/afero"
)
func main() {
tempDir, err := os.MkdirTemp("", "")
if err != nil {
panic(err)
}
defer func() {
os.RemoveAll(tempDir)
}()
aferoBase := filepath.Join(tempDir, "afero")
err = os.Mkdir(aferoBase, fs.ModePerm)
if err != nil {
panic(err)
}
aferoFsys := afero.NewBasePathFs(afero.NewOsFs(), aferoBase)
{
f, err := fsutil.OpenFileRandom(aferoFsys, "", "*.tmp", fs.ModePerm)
if err != nil {
panic(err)
}
fmt.Printf("afero file name = %q\n", f.Name())
_ = f.Close()
}
goBillyBase := filepath.Join(tempDir, "go-billy")
err = os.Mkdir(goBillyBase, fs.ModePerm)
if err != nil {
panic(err)
}
billyFsys := billyosfs.New(goBillyBase, billyosfs.WithBoundOS())
{
f, err := fsutil.OpenFileRandom(billyFsys, "", "*.tmp", fs.ModePerm)
if err != nil {
panic(err)
}
fmt.Printf("billy file name = %q\n", f.Name())
_ = f.Close()
}
vrootBase := filepath.Join(tempDir, "vroot")
err = os.Mkdir(vrootBase, fs.ModePerm)
if err != nil {
panic(err)
}
vrootFsys, err := vrootosfs.NewRooted(vrootBase)
if err != nil {
panic(err)
}
defer vrootFsys.Close()
{
f, err := fsutil.OpenFileRandom(vrootFsys, "", "*.tmp", fs.ModePerm)
if err != nil {
panic(err)
}
fmt.Printf("vroot file name = %q\n", f.Name())
_ = f.Close()
}
var seen []string
fs.WalkDir(os.DirFS(tempDir), ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || path == "." || d.IsDir() {
return err
}
if d.Type().IsRegular() {
seen = append(seen, path)
}
return nil
})
bin, _ := json.MarshalIndent(seen, "", " ")
fmt.Printf("seen path = %s\n", string(bin))
}
実行してみるとこんな感じ。動いてますね。
$ go run .
afero file name = "/0121404083.tmp"
billy file name = "/tmp/1301612191/go-billy/0632349466.tmp"
vroot file name = "/tmp/1301612191/vroot/3571826966.tmp"
seen path = [
"afero/0121404083.tmp",
"go-billy/0632349466.tmp",
"vroot/3571826966.tmp"
]
こうしてみてみるとaferoのBasePathFsの挙動はだいぶいただけないです。/-prefixも削除されていたら文句なかったんですが。
Interoperabilityへのアドバイス
思いつく限りのInteroperabilityへのアドバイスをリストしていきます。見つけ次第追記していくかも。なんか思い当たるものがあったら教えてください。
全プラットフォームでgo vetしよう
下記のbashscriptでGOOS=android, GOOS=iosを除いた全OS/ARCHの組み合わせに対してgo vet ./...がかけられます。
#!/bin/bash
supported_list=$(go tool dist list)
IFS=$'\n'
for os_arch in $supported_list; do
IFS='/' read -r os arch <<< $os_arch
if [[ $os == "android" ]] || [[ $os == "ios" ]]; then
continue
fi
echo ${os_arch}:
GOOS=${os} GOARCH=${arch} go vet ./...
echo
done
エラーが表示されなければとりあえず全プラットフォームでコンパイルまではする、というのがわかります。
エラーは再定義するしかない
syscall.ELOOPなど、大部分のエラーがplan9にはありません。そもそもplan9にsymlinkはありません。
全プラットフォームで(GOOS=ios, GOOS=androidを除く)でコンパイルできる程度にするためには、この辺のエラーを再定義する必要があります。
plan9側では適当にエラーを定義します。
FileのNameメソッドは信用しない
-
*os.FileにおけるNameの挙動はos.Openに渡されたパスを返すことですが、ライブラリ、例えばaferoなどではOpenに渡したのとは別のものが返ってくることがあります。 - ライブラリによってこの挙動に差があります:
- これらはinterfaceですので、どのような実装になっているかは定かではありません。
- 実装によっては下層の実装は
osfsなんだけどパスの変換などを行っているかもしれません
- 実装によっては下層の実装は
- ですのでInteroperabilityを重視するならばbase name以外を信用するのは危険です。
親ディレクトリの存在チェックをする
- 親ディレクトリの有無で挙動差が生まれることがよくあるようです:
- windowsでは「親ディレクトリがない」と「パスにファイルが含まれる」が一緒
- POSIX APIにおける
ENOTDIRの概念がないようです。 - 試しに適当な
go moduleでos.Open(".\\go.mod\\foo)とか呼び出してみるとわかりますが、ERROR_PATH_NOT_FOUNDが返ってきます。
- POSIX APIにおける
- 親ディレクトリがない時エラーになってほしいなら存在チェックをしたほうがよいです。
- パスコンポーネント上にファイルがあるときに特別な処理を行いたいときは存在チェックをしたほうが良いです。
Renameで上書きは常に通じるわけではない
-
sftpでマウントしている環境だとマウント設定によってはrenameで上書きしようとするとエラーでした。
なんかfilesystem abstraction libraryの話ではなくなってしまってますが・・・
雑感: Claude Code使ってました
このライブラリの実装にClaude Codeを大いに活用したため雑感を記します。
- 型とdoc comment、テストケースを細かく分けて何をするかをdoc commentで書いておいて、あとよろしく!で完成するのでかなり便利です。
- といいつつ中身を見てると無駄なことをすることがあるので手動で若干直す必要があります。
-
strings.Splitを使うコードを出してくるところをstrings.SplitSeqを使うように指示すると、一旦全部sliceに受けるようなコードにしてきたりして手で直さざるを得ないところがありました。- 新しめなAPIは学習されてないらしく、使われ方のパターンを把握していない感じがありますね。
-
*overlayfs.Fsのコードはだいぶダメだったのでほぼ人が書いてます。- 何をどうするかをコメントで自己説明すればもっといい感じに出力してくれたかもしれませんが、それほぼ英語の自然言語でコーディングしてるだけなのでコードを手で書けばいいやって思って人間が書きました。
- 逆に
*synthfs.Fsはtarfsを参考にしてっていうとほぼokなものが出ました。- ただしロック周りのロジックは結構手で直しました。
- ぱっとみよさそうなんですが、もしかしたらあとで人が書き直すかもしれないです
- 似たようなコードを若干変えて特別な考慮を加える、みたいなタスクは得意そうですね。
-
ghコマンド経由でGitHub Actionsの結果をみて修正をさせようと試みましたが急に見当違いなことを言い出しました。こういう使い方は厳しいようです。- というのも、claudeは
print debugや、実験コードを一旦生成して仮説検証したり、実際に動く環境があることを活かすので、実行環境がないとその方法が通じなく、あてずっぽうなことを言うしかなくなるのかなと思います。 - なんとなくですが、エラーの文章が発生する環境=自分が今動いている環境と思うようなバイアスがあるような感じがします。
- というのも、claudeは
- バグを見つけてくるのはすごい得意です。ここうまく動かないけどなんでかな?と聞いたら、人がするような、debugを仕込んで実行して状態を観測したら原因を推測して・・・というループを行って発見してきます。
- このループを回すのがものすごい高速なので人がデバッグする速度じゃ追いつけません
- 複数のモジュールにまたがって起きるバグはこの方法が通用しませんが、
Goのコンパイルオプションにはoverlayというモジュールの一部を差し替えるものがありますから、何かしらのmcpツールで元のソースを編集してoverlayに渡してコンパイルさせられるように環境を整えたらこのデバッグ方法を行ってもらえるかもしれません。そうなってくると人間のデバッグ力じゃもうすでに追いつけないものになりますね。 - REST APIやgRPCをまたぐとそれでも通用しないです。
- 前述した#73868はclaudeが見つけて指摘してきました。誰も報告してなければ報告しようかと思いましたがすでにされていましたね。貢献失敗!
- コードベース読んで説明するのも同様に得意そうです。
- ちなみにこの記事は一部AIに書かせましたが、丁寧でざっくりしすぎてしまい、筆者の文書ににじみ出る雑味が消えてしまったので、ほとんどの変更をrevertして人力で書き直しています。
おわりに
- コンセプトとして*os.Root準拠のfilesystem abstractionを考えてみました。
- 逆に、filesystem-abstraction-library-agnosticなutilityについても提案しました。
今後は
-
rc2を待ちます(rc1のバグによってGitHub Actions上のテストが通過しないため) - AIが書いたテストやコードをレビューしてない部分があるのでちゃんと読んでリファクタするなりをします。
- 自作ライブラリ内で使ってたたきにたたきます。
-
vroot-adapterという別の名前のモジュールを作成し、afero, go-billyと相互に変換がかけられるようにします。- ただしaferoに関してはベストエフォートになります。
-
vroot-adapter下にいろんなアダプターをおいておきたいと思っています。例えばsftpsmbnfss3- etc, etc.
-
vroot over stream,stream over gRPCで、gRPC経由で相互にファイルシステムを公開しあえないかなと思っています。- 同一マシン内でIPCするときに適切にファイルシステムを共有する方法をずっと探っていたので、それに対する答えとしてこれを考えています。
-
tarを送り付けあってもいいんですがそれだとあまりにオーバーヘッドが大きいので。 - もしかしたら
NFS over gRPCにしたほうが最適な実装は得られるかもしれないです
-
github.com/natefinch/lumberjackやgithub.com/cavaliergopher/grabのfsys interfaceに書き出す版が欲しいとずっと思っていたので、そのへんを
fsutil下に実装するかも。
Discussion