📂

[Go]*os.Rootベースのファイルシステム抽象化とライブラリ非依存ヘルパーの提案

に公開

*os.Rootベースのファイルシステム抽象化とライブラリ非依存ヘルパーの提案

こんにちは。

go1.25rc1がリリースされましたね。

https://x.com/golang/status/1932876844849594525

DRAFT RELEASE NOTEは以下となります。

https://tip.golang.org/doc/go1.25

今回は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はDockerPodmanなどのコンテナ基盤で広く使用されており、ファイルシステム操作のセキュリティは重要な課題です。特に、path traversal攻撃(../../../etc/passwdのような相対パスを使った不正なファイルアクセス)を防ぐ仕組みが必要とされています。

過去にsecure-joinというAPIの提案がありましたが、実装の複雑さから採用されませんでした。しかし、github.com/cyphar/filepath-securejoink8s.ioの各種パッケージで使用されていることからも、この機能への需要は確実に存在します。

*os.Rootの登場と意義

Go 1.24で一部、Go 1.25で完全実装される*os.Rootは、secure-joinと似たような課題に対して取り組むために提案されました。

*os.Rootの特徴:

  • 特定のサブディレクトリ以下のみにアクセスを制限
  • path traversal攻撃とsymlink escapeの両方を防止

本記事の提案

この*os.Rootの登場により、既存のfilesystem abstraction libraryが直面する互換性や統一性の課題を解決する新しいアプローチが可能になります:

  1. vroot: *os.Rootのメソッドセットを基盤としたfilesystem abstraction library

    • osパッケージで定義されるファイル操作のすべてを網羅
    • 新たなAPIの基調
      • rootおよびsub-rootからsymlink escapeさせない
      • 絶対パスを受け付けない
  2. fsutil: Genericsを活用した、filesystem-abstraction-library-agnostic helpers

    • ヘルパーが特定のfilesystem abstraction libraryにくっつかないようにgenericsでもとから剥がしとこうよという提案

あとの内容は目次を見てください。

環境

$ go version
go version go1.25rc1 linux/amd64

GOTOOLCHAIN環境変数を設定すれば問答無用でgo1.25rc1sdkを落としてそれで実行できるようになります。

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には存在しないようです。
    • これを機にReaddirfstatat(3p)を使おうみたいな話の流れになるんですかね?
    • と思ってstdを読み直すとos.Lstatはすでにfstatatを使用していますのでもしかしたら使えない理由があってやっていないのかも・・・
    • windowsでは起きません。
  • #69509
    • wasip1でパスの取り扱いがおかしいというもの。

以下はバグではなくenhancementですがこれは#67002の中で述べられていた、各プラットフォーム向けの最適なAPIを使用することで最適な実装を行おうというものです。

  • #73076
    • 各プラットフォーム向けに最適な実装をしようというもの
    • 多分、ファイルに対するIO操作のほうがよほど時間がかかるのでこの最適化がされなくても十分な実行速度を持てると思いますが、std libraryはあらゆるものから使われるわけですからメンテナンス性を確保できている限り、速ければ速いほどいいですよね。

とりあえずrc2を待ちましょう。

はじめに

GoにはGo 1.16で追加されたfs.FSがあります。

これは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を使用しており、大変便利ですが、それぞれに若干のつらさがあります。

既存ライブラリの辛さ

それぞれのライブラリにはそれぞれつらみがあります。

根本的辛さ: 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の立ち位置が明らかになるかも知れないので触れておきます。

GoDocker, podmanなど、コンテナ基盤で盛んに使われています。
コンテナは実装によりますが、基本的にはpivot_root(2)unsahre(2)その他もろもろで隔離された名前空間のなかで動作するプロセスやらroot fsやらのことをさします。

#20126でかつてsecure-joinというpath traversalを防ぎながらjoinを行うAPIの追加がproposeされましたが、完全な実装の難しさからcloseされています。しかし、github.com/cyphar/filepath-securejoindependenciesを見れば、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.Rootopenat(2)などの、fdからの相対パス開きができるAPIに依存しています。
*os.Rootの各methodにパスが渡されるとパスセパレータ(/\)でパスコンポーネントに分割し、OBJ_DONT_REPARSE(windows)/O_NOFOLLOW(unix)付きでNtCreateFile/openatを呼び出し、ディレクトリを1つずつ開いていきます。
symlinkが見つかった場合にはreadlinkatを使って読み取りますが、この場合には読み込まれたリンクでパスコンポーネントを置き換え(a/b/cb -> ../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な違いが実装を入れ替えたときに微妙なエラーを引き起こすことが考えられます。(そもそも前述通り筆者はaferoMemMapFsのsubtletiesでテストが動かなかったことがあるわけですが)

どうせなら作ってしまえということで、*os.Rootを中心にとらえたfilesystem abstraction libraryを作ってみます。

https://github.com/ngicks/go-fsys-helper/tree/main/vroot

まだめちゃくちゃWIPですがここにホストしてあります。

  • major versionは基本的に上がることはないはず:
    • 前述通り、Go 1から特にファイル操作APIは増えたり変わったりしていないため、このinterfaceは安定しているとみなすことができます。
  • intefaceのcomposabilityは一切捨てます。
    • ファイルシステムは書き込み先の事情でいきなりいろいろ変わるのでinterface上のmethodのある/なしで何かを判断し分ける必要はそもそもないと思っています。
      • 例えば、残り容量の足りなくなってきたfilesystemがremountされてread-onlyに突然なったりです。
      • sftp, nfs, smbなどのネットワークストレージは相手サーバーの設定変更でできることが変わってきます。
    • もしかしたらCapability extension interfaceを通じてcapabilityのチェックができるようにするかもしれませんが現状では何も考えていません。
      • これはstatvfs(3)によるmount flagのチェックと対応するためそこまでおかしく感じないんじゃないかと思います。

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, WriteToio.Copy向けの最適な実装を提供するextension interfaceなので強制ではなくします。
  • SetDeadline, SetReadDeadline, SetWriteDeadlineはソケットなど一部ファイル向けなので強制ではなくします。
  • SyscallConnも同様に消します。
  • Fdは大抵のケースで不要に思いますが、
    • file lockの実装に必要です。
      • Fdがvalidな値(=^(uintptr(0))以外)のとき、fcntl(2)(unix)/LockFile(windows)などを用いてロックし、そうでないときはFs固有の方法でロックすればよいでしょう。
      • record lockingをvroot上で再実装するのは骨が折れそうなので、こういう形で余白を残しつつ放置する作戦です。
    • 後述の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にも弱いつくりになっていることが想定されます(これはaferoBasePathFsと同じです)。
そのためUnrootedからRootedを開くことは(もちろん不安全だが)できるようになっていますが、その逆はできません。
前述のとおり、*os.Rootfdからの相対的なパス操作によってpath escapeを防ぎます。このfdが指し示すディレクトリは開いた後にrenameなどによって移動されていることは十分にあり得ます。fdからパスへの正確な変換方法は筆者の知り及ぶ限りありませんし、それがTOCTOU raceに強いとも思えません。そのためRootedからUnrootedへの変換は定義上作ることができない、ということになります。

実装

osfs

とりあえず*os.RootからRootedへ変換できるようにします。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/osfs/rooted.go#L16-L21

osパッケージがerrPathEscapesをエクスポートしないため

https://github.com/golang/go/blob/go1.25rc1/src/os/file.go#L421

文字列を見てエラーを差し替える部分を作っておきます。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/osfs/rooted.go#L45-L58

文字列比較はやらないでいいならやりたくないですが、こうしないとerrors.Is(err, ErrPathEscapes)でテストをかけないので仕方なくやっています。

aferoBasePathFsとほぼ同じものとしてosfsUnrootedを作ります。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/osfs/unrooted.go#L19-L26

WalkDir

fs.WalkDirと互換なものとしてvroot.WalkDirを定義しておきます。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/walk.go#L12-L25

interfaceがsymlinkの存在をもとから考慮に入れているのでfs.WalkDirと違ってsymlinkをresolveしてたどってもよいことにしてあります。

fs.WalkDirFuncとは違い、vroot.WalkDirFuncはsymlinkを解決した後にrealPathも受け取るようになっています。ただしこれはReadLinkLstatを組み合わせてパスをレキシカルに解決するだけのとても単純な仕組みであるため、TOCTOU raceには弱いです。
(現状まったくdoc commentが書けていませんが)root外のrealPathの取得はRootedはもちろんUnrootedでもできない(root外のパスに対してReadLinkを呼ぶ必要があるため)ので、その場合はrealPathには""が渡されることになります。
なので基本的にrealPathに""が来てなおかつディレクトリの場合は、SkipDirを返してstepするのをやめたほうが良いですね。

また、WalkDirがbind mountによるfilesystem loopによって無限ループに陥らないようにするために、可能であればファイルからユニークな値を取り出します。

unix系ではstatから

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/walk_unix.go#L1-L22

(plan9)

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/walk_plan9.go#L1-L22

windowsではGetFileInformationByHandleから

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/walk_windows.go#L1-L37

それぞれユニークな値を取得します。
とりあえずlinux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64では見た限り正しく固有な値をとれているようです。
plan9wasip1などではとりあえずコンパイルできますが、実装的に正しいのか検証はできていません。

to/from fs.FS

fs.FSvrootの相互変換を定義します。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/iofs_from.go#L32-L53

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/iofs_from.go#L192-L209

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/iofs_to.go#L18-L31

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/iofs_to.go#L69-L82

ReadOnly

Rooted/Unrootedread-onlyになるようにラップする仕組みも欲しいため作っておきます。
これは間違って書かないようにするための安全策としてあったほうが良いですね

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/readonly.go#L13-L21

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/readonly.go#L113-L121

overlayfs

これが一番欲しかったものかもしれない。

overlay filesystemです。複数のvroot.Rootedを重ね合わせて一つのfsに見せかけます。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/overlayfs/overlay.go#L35-L82

  • 複数のvroot.Rootedを重ねて一つに見せます。
  • ディレクトリの内容は統合され、
  • ファイル(ディレクトリ以外)の場合は最も「上側」のレイヤーのものが選ばれます。
  • Copy-On-Writeの挙動があります。
    • chmodなどでファイルのメタデータを変更したときか、
    • write modeでファイルを開くとコピーが起きます。
      • 本当はファイルに対して初めてWriteが呼ばれたときにコピーが起きるようにしたかったのですが、ロックの取り方やrace conditionの懸念から単純なこの挙動になっています。
      • 実用上それで困らないと思ってます。
  • 書き込みはすべてtop layerにのみ起きます。
  • 下層にあるファイルを消えたように見せるために、white out listという形で消えたパスを管理します。

ユースケースはいくつかあって

  • 複数のディレクトリの内容を仮想的に重ねて見せたい
    • ビルド成果物を共有フォルダなどに格納するとき、オペレーションでミスりたくないから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のように動作します。

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版です。

https://zenn.dev/ngicks/articles/go-virtual-mesh-fs-for-os-copyfs

完全にin-memoryなfilesystem+任意のバッキングストレージという組み合わせで成り立つfilesystemで、
file pathによるtrieを構築し、各ノードには適当なバッキングストレージのファイルを追加できるようにします。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/synthfs/fs.go#L22-L56

バッキングストレージは、ほかのvroot.Fs(fs.FSから変換してもよい)、memoryなど、何でもよいです。メモリーからのみallocateするようにすると、これがaferoでいうところのMemMapFsになります。

作った動機は上記の記事内で説明していますが、

  • ほかのfs.FSの内容からsha256sumなどをとったメタデータをデータと同じディレクトリに追加し、(一旦書き出しを経ずに)CopyFSとかAddFSに直接渡す。
  • in-memory fsでパスを何かしらの木構造で持つものが欲しかった:
    • in-memory fsはmap[string]dataをベースに実装されること多いようである。
    • aferoMemMapFsでおきたパスの取り扱いが間違っている問題が起こらないようにする。
    • sub-fsysを取得するとき、全体ロックを取得しなくていいようにする。
  • 権限のみを差し替えるようなラッパーが欲しかった
    • 例えばembed.FSは内容物のpermission bitが必ず0o444(ファイル)と0o555(ディレクトリ)になります([1],[2],[3])。
    • そのため、AddFSがpermissionを広げない場合に狭いファイルが書かれることがあります。

memfs

in-memory filesystemです。上記のsynthfsのショートハンドです。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/vroot/memfs/mem.go#L1-L27

tarfs

vrootとは直接は関係ないですが、以下の記事で作ったtarfsにsymlinkとhardlinkの取り扱いを加え、さらに、vrootと組み合わせるのを意識して「symlink解決時にsub-rootより上の階層に移動するか」の設定を追加しました。

https://zenn.dev/ngicks/articles/go-tar-reader-implement-reader-at

https://github.com/ngicks/go-fsys-helper/tree/2adb17618ef755813afd6fa49910134eb94d3ceb/tarfs

symlinkを無視するのがデフォルト挙動ですが、デフォルトでハンドルしたほうがいい気もするので(破壊的に変更になりますが)そのように変えるかもしれません。
symlinkありのtarでも普通に動いているので使えそうな感じですが、世にどんなエッジケースがあるのかよくわからないのでしばらくタグをつけずに様子見します。

ちなみにfstest.TestFSがsymlinkを考慮するのはgo1.25以降であるようなのでsymlink/hardlinkありのアーカイブを使ったテストは//go:build go1.25Go 1.25以降でないと実行されないように制限してあります。リリースされたらCIが常にgo1.25を使うように変更しようかなと。

fsutil: filesystem-abstraction-library-agnostic helpers

https://github.com/ngicks/go-fsys-helper/tree/main/fsutil

多分どの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はvrootgo1.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"
]

こうしてみてみるとaferoBasePathFsの挙動はだいぶいただけないです。/-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を除く)でコンパイルできる程度にするためには、この辺のエラーを再定義する必要があります。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/fsutil/errdef/err.go#L1-L12

plan9側では適当にエラーを定義します。

https://github.com/ngicks/go-fsys-helper/blob/2adb17618ef755813afd6fa49910134eb94d3ceb/fsutil/errdef/err_plan9.go#L1-L30

FileのNameメソッドは信用しない

  • *os.FileにおけるNameの挙動はos.Openに渡されたパスを返すことですが、ライブラリ、例えばaferoなどではOpenに渡したのとは別のものが返ってくることがあります。
  • ライブラリによってこの挙動に差があります:
    • aferoOsFs+BasePathFsでは、BasePathFsの挙動としてsubpathとして指定されたpath prefixをNameから削除する挙動があります。
    • go-billyosfs(BoundOS)は素直に*os.FileNameの返り値を返すのでフルパスが返ります。
    • vrootosfsgo-billyと同様にフルパスが返ります。(単なる*os.Rootのラッパーなので、それの挙動が透けています。)
  • これらはinterfaceですので、どのような実装になっているかは定かではありません。
    • 実装によっては下層の実装はosfsなんだけどパスの変換などを行っているかもしれません
  • ですのでInteroperabilityを重視するならばbase name以外を信用するのは危険です。

親ディレクトリの存在チェックをする

  • 親ディレクトリの有無で挙動差が生まれることがよくあるようです:
    • go-billy: 勝手に作る
      • 前述通り、親ディレクトリがなかったらとりあえず作ってしまいます。
    • afero: 実装による
      • MemMapFsでは親ディレクトリがなくてもファイルが作れます
      • OsFsでは勝手に作られません。
    • vroot: 親ディレクトリは勝手に作られません
      • 明確に書かれていませんがinterface規約として親ディレクトリがないのにファイル作成が成功してはいけないというものがあります。
        • acceptancetestによって親ディレクトリがない時のファイル作成がエラーする挙動がテストされています。
  • windowsでは「親ディレクトリがない」と「パスにファイルが含まれる」が一緒
    • POSIX APIにおけるENOTDIRの概念がないようです。
    • 試しに適当なgo moduleos.Open(".\\go.mod\\foo)とか呼び出してみるとわかりますが、ERROR_PATH_NOT_FOUNDが返ってきます。
  • 親ディレクトリがない時エラーになってほしいなら存在チェックをしたほうがよいです。
  • パスコンポーネント上にファイルがあるときに特別な処理を行いたいときは存在チェックをしたほうが良いです。

Renameで上書きは常に通じるわけではない

  • sftpでマウントしている環境だとマウント設定によってはrenameで上書きしようとするとエラーでした。

なんかfilesystem abstraction libraryの話ではなくなってしまってますが・・・

雑感: Claude Code使ってました

このライブラリの実装にClaude Codeを大いに活用したため雑感を記します。

  • 型とdoc comment、テストケースを細かく分けて何をするかをdoc commentで書いておいて、あとよろしく!で完成するのでかなり便利です。
  • といいつつ中身を見てると無駄なことをすることがあるので手動で若干直す必要があります。
  • strings.Splitを使うコードを出してくるところをstrings.SplitSeqを使うように指示すると、一旦全部sliceに受けるようなコードにしてきたりして手で直さざるを得ないところがありました。
    • 新しめなAPIは学習されてないらしく、使われ方のパターンを把握していない感じがありますね。
  • *overlayfs.Fsのコードはだいぶダメだったのでほぼ人が書いてます。
    • 何をどうするかをコメントで自己説明すればもっといい感じに出力してくれたかもしれませんが、それほぼ英語の自然言語でコーディングしてるだけなのでコードを手で書けばいいやって思って人間が書きました。
  • 逆に*synthfs.Fstarfsを参考にしてっていうとほぼokなものが出ました。
    • ただしロック周りのロジックは結構手で直しました。
    • ぱっとみよさそうなんですが、もしかしたらあとで人が書き直すかもしれないです
  • 似たようなコードを若干変えて特別な考慮を加える、みたいなタスクは得意そうですね。
  • ghコマンド経由でGitHub Actionsの結果をみて修正をさせようと試みましたが急に見当違いなことを言い出しました。こういう使い方は厳しいようです。
    • というのも、claudeはprint debugや、実験コードを一旦生成して仮説検証したり、実際に動く環境があることを活かすので、実行環境がないとその方法が通じなく、あてずっぽうなことを言うしかなくなるのかなと思います。
    • なんとなくですが、エラーの文章が発生する環境=自分が今動いている環境と思うようなバイアスがあるような感じがします。
  • バグを見つけてくるのはすごい得意です。ここうまく動かないけどなんでかな?と聞いたら、人がするような、debugを仕込んで実行して状態を観測したら原因を推測して・・・というループを行って発見してきます。
    • このループを回すのがものすごい高速なので人がデバッグする速度じゃ追いつけません
    • 複数のモジュールにまたがって起きるバグはこの方法が通用しませんが、Goのコンパイルオプションにはoverlayというモジュールの一部を差し替えるものがありますから、何かしらのmcpツールで元のソースを編集してoverlayに渡してコンパイルさせられるように環境を整えたらこのデバッグ方法を行ってもらえるかもしれません。そうなってくると人間のデバッグ力じゃもうすでに追いつけないものになりますね。
    • REST APIやgRPCをまたぐとそれでも通用しないです。
    • 前述した#73868はclaudeが見つけて指摘してきました。誰も報告してなければ報告しようかと思いましたがすでにされていましたね。貢献失敗!
  • コードベース読んで説明するのも同様に得意そうです。
  • ちなみにこの記事は一部AIに書かせましたが、丁寧でざっくりしすぎてしまい、筆者の文書ににじみ出る雑味が消えてしまったので、ほとんどの変更をrevertして人力で書き直しています。

おわりに

  • コンセプトとして*os.Root準拠のfilesystem abstractionを考えてみました。
    • これはafero, go-billy, hackpadfsなどでのinterfaceのつらみを解消しつつ、*os.Root的なroot外へのsymlinkへの脱出をエラー扱いするものです。
  • 逆に、filesystem-abstraction-library-agnosticなutilityについても提案しました。

今後は

  • rc2を待ちます(rc1のバグによってGitHub Actions上のテストが通過しないため)
  • AIが書いたテストやコードをレビューしてない部分があるのでちゃんと読んでリファクタするなりをします。
  • 自作ライブラリ内で使ってたたきにたたきます。
    • この記事や、この記事で触れている、code generatorのファイル書き込み部分にoverlayfsを使用し、トップレイヤをsynthfsのin-memory filesystemにしておき、packages.ConfigのOverlayにメモリコンテンツを渡すことで書き出し前に型チェックをかけることをひそかに構想しています。だからgo1.25をずっと待っていたんです。
  • vroot-adapterという別の名前のモジュールを作成し、afero, go-billyと相互に変換がかけられるようにします。
    • ただしaferoに関してはベストエフォートになります。
  • vroot-adapter下にいろんなアダプターをおいておきたいと思っています。例えば
    • sftp
    • smb
    • nfs
    • s3
    • etc, etc.
  • vroot over stream, stream over gRPCで、gRPC経由で相互にファイルシステムを公開しあえないかなと思っています。
    • 同一マシン内でIPCするときに適切にファイルシステムを共有する方法をずっと探っていたので、それに対する答えとしてこれを考えています。
    • tarを送り付けあってもいいんですがそれだとあまりにオーバーヘッドが大きいので。
    • もしかしたらNFS over gRPCにしたほうが最適な実装は得られるかもしれないです
  • github.com/natefinch/lumberjackgithub.com/cavaliergopher/grabのfsys interfaceに書き出す版が欲しいとずっと思っていたので、そのへんをfsutil下に実装するかも。

GitHubで編集を提案

Discussion