fuse-hello-world実験
FUSEが「非rootユーザーでもファイルシステムをマウントできる」「システムコール1回ごとにカーネル⇔ユーザー空間を往復する」という仕組みを手を動かして体感するための実験記録。libfuse自体には依存せず、/dev/fuseを直接叩く純Rust実装のfuserクレート(低レベルAPI相当)で、hello.txtという1ファイルだけを返す最小のファイルシステムを実装した。
実験は2段階やった。
- 1回目: 素朴にマウントして、非rootのままマウント・読み取り・アンマウントができることを確認(成功)
- 2回目: マウントの内部動作をstraceで覗いたところ、ptraceの副作用でsetuidが無効化され、今度はマウント自体が失敗した(これはこれで仕組みの理解につながった)
使ったもの
- fuser 0.18.0 — FUSEの低レベルAPIをRustで実装したクレート。
libfuse3-devのヘッダ類は一切インストールせずに使えた。 - 公式リポジトリの
examples/hello.rsをベースに、マルチスレッド統計などの余分な機能を削った最小版を書いた。
実装
Filesystemトレイトのlookup/getattr/read/readdirの4つだけを実装する。ルート(inode 1)配下にhello.txt(inode 2)が1つあるだけの固定内容のファイルシステム。
impl Filesystem for HelloFS {
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
if u64::from(parent) == 1 && name.to_str() == Some("hello.txt") {
reply.entry(&TTL, &HELLO_TXT_ATTR, Generation(0));
} else {
reply.error(Errno::ENOENT);
}
}
// getattr, read, readdir も同様に ino で分岐するだけ
}
fn main() {
let mountpoint = env::args().nth(1).expect("usage: fuse-hello <mountpoint>");
let mut cfg = Config::default();
cfg.mount_options.extend([MountOption::RO, MountOption::FSName("hello".to_string())]);
fuser::mount(HelloFS, &mountpoint, &cfg).unwrap();
}
躓いた点
fuser 0.18系は最近のバージョンで型付けが厳格化されており、ネット上に多い旧バージョン(0.1x系)ベースの解説コードそのままでは動かなかった。
mount2()が廃止されmount(fs, mountpoint, &Config)に変わっていた。Configはmount_options: Vec<MountOption>を持つ構造体で、Config::default()に.extend()する。- inode番号が生の
u64ではなくINodeNoというnewtypeでラップされるようになっていた(FileHandle・LockOwner・OpenFlags・Errnoも同様)。u64::from(ino)で取り出す。 Filesystemトレイトの各メソッドのレシーバが&mut selfではなく&selfになっていた(内部でAtomicU64等を使う前提の設計)。MountOption::AutoUnmountを付けるとAllowOtherかAllowRootも同時に必要という制約があり、付けたままだとマウントが失敗した(今回はそのまま外し、fusermount3 -uで手動アンマウントする方針にした)。
公式リポジトリにexamples/hello.rsが残っていたので、これを読んで型を合わせた。
1回目: 素朴にマウントして動作確認
sudoを一切使わず、一般ユーザーのままビルド・マウントできた。
$ whoami
tokuhirom
$ ./target/debug/fuse-hello mnt &
$ mount | grep hello
hello on .../mnt type fuse (ro,nosuid,nodev,relatime,user_id=1000,group_id=1000)
$ ls -la mnt
-rw-r--r-- 1 tokuhirom tokuhirom 34 Jan 1 1970 hello.txt
$ cat mnt/hello.txt
Hello, FUSE from a non-root user!
$ echo test > mnt/hello.txt
read-only file system: mnt/hello.txt
$ fusermount3 -u mnt # これも非rootのまま
コールバック呼び出しの観察
stderrにコールバック呼び出しをログ出力するようにしていたところ、ls一発で以下のような順番で呼ばれるのが見えた。
lookup(parent=1, name=".Trash") # デスクトップ環境のゴミ箱自動検出が先に触りにきた
lookup(parent=1, name=".Trash-1000")
getattr(ino=1)
readdir(ino=1, offset=0)
lookup(parent=1, name="hello.txt")
readdir(ino=1, offset=3)
read(ino=2, offset=0)
FUSEノートに書いた「アプリのシステムコール→カーネルのpendingキュー→ユーザー空間デーモンが処理→結果を書き戻す」という往復が、lsやcatのような日常的なコマンド1つでも複数回のコールバック呼び出しとして実際に発生していることが確認できた。.Trashへの問い合わせは自分では意図しておらず、GNOME(GVFS)側がマウントされたファイルシステムを自動でゴミ箱対応チェックしにきているものと見られる。
書き込みを弾けた理由
書き込み(echo test > mnt/hello.txt)をread-only file systemエラーで弾けたのは、writeコールバックを実装せずMountOption::ROを渡しただけで済んだため。FUSEでは実装していない操作はカーネル側/ライブラリ側がデフォルトでENOSYS相当を返す設計になっており、最小限のコールバックだけ実装すれば安全に「読み取り専用ファイルシステム」を作れることが分かる。
2回目: マウントの内部動作をstraceで確認
非rootでマウントできる仕組みを確かめるため、fuserクレートのソース(src/mnt/fuse_pure.rs)を読むと、マウント時は以下の順で処理していることが分かる。
/dev/fuseをopenしてfdを取得- 自プロセスのまま
mount("hello", mnt, "fuse", ...)を直接呼んでみる(非特権なので通常は失敗する想定) EPERMが返ったら、socketpair(AF_UNIX)を作り、setuid-rootのfusermount3をfork・execする。子プロセスとの通信用fdは環境変数_FUSE_COMMFDで渡すfusermount3が(実効uid=0で)実際にmount(2)を呼んで成功させ、マウントに使った/dev/fuseのfdをSCM_RIGHTSでUNIXソケット越しに親プロセスへ送り返す- 親プロセスが
recvmsg()でそのfdを受け取り、以後はそのfdでFUSEカーネルモジュールと直接通信する
strace -f -e trace=mount,execve,socketpair,recvmsg ./target/debug/fuse-hello mntで実際にこの通りの流れが見えた。
openat(AT_FDCWD, "/dev/fuse", O_RDWR|O_CLOEXEC) = 3
mount("hello", ".../mnt", "fuse", MS_RDONLY|MS_NOSUID|MS_NODEV, "fd=3,rootmode=40775,user_id=1000"...) = -1 EPERM
socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, [3, 4]) = 0
execve("/usr/bin/fusermount3", ["fusermount3", "-o", "ro,fsname=hello", "--", ".../mnt"], ...) = 0
recvmsg(4, ...) = 0 # fusermount3から送られてきたfdを受信
ptraceの副作用でマウント自体が失敗した
ただしこの回のトレースではfusermount3側のmount(2)自体もEPERMになり、fuse-helloがpanicして失敗した。strace -fでfusermount3のexecve後もトレースを継続していたため、ptraceされているとsetuidによる昇格が無効化されるという制約が働き、setuid-rootのはずのfusermount3が非特権のままmountを試みて失敗したと考えられる。同じコマンドをstraceなしで実行した1回目の実験では問題なく成功していることから、原因はstraceによるトレースそのものだと分かる。
マウント失敗時のmntディレクトリの挙動
このmount(2)失敗パターンの場合、実際には一度も本物のFUSEマウントが成立していないため、mntはただの空ディレクトリのままだった。
ls mnt— 空(hello.txtは存在しない。マウントされていないのでファイルシステムの中身が差し替わっていない)cat mnt/hello.txt—No such file or directory(ローカルの空ディレクトリの中を見ているだけなので当然存在しない)fusermount3 -u mnt—entry for .../mnt not found in /etc/mtabというメッセージを出すだけで、エラー終了はしない。/etc/mtab(実体は/proc/self/mounts)にそもそもエントリが無い=マウントされていないことを正しく検出し、何もせず終わる
つまりマウントに失敗してもmntディレクトリ自体が壊れたり「取り憑かれた」ような状態(stale mount)にはならず、後片付けなしでそのまま次の試行に使えた。stale mountになるのはあくまで「一度正常にマウントされたファイルシステムのデーモンプロセスだけが異常終了した」場合であり、マウント確立前の失敗ではそのリスクがないことが確認できた。
ソース
このリポジトリのnotes/code/fuse-hello/に、上記のソース一式を置いてある(cargo buildするだけでビルドできる)。
出典
- GitHub - cberner/fuser
- fuser 0.18.0 examples/hello.rs
man strace(BUGSセクション)