diff --git a/all.bash b/all.bash index 4cbd37464..4479bf772 100755 --- a/all.bash +++ b/all.bash @@ -20,8 +20,8 @@ GOOS=freebsd go build ./fs/... ./example/loopback/... GO_TEST="go test -timeout 5m -p 1 -count 1" # Run all tests as current user $GO_TEST ./... -# Direct-mount tests need to run as root -sudo env PATH=$PATH $GO_TEST -run 'Test(DirectMount|Passthrough)' ./fs ./fuse +# The following tests need to run as root +sudo env PATH=$PATH $GO_TEST -run 'Test(DirectMount|Passthrough|IDMappedMount)' ./fs ./fuse make -C benchmark go test ./benchmark -test.bench '.*' -test.cpu 1,2 diff --git a/example/loopback/main.go b/example/loopback/main.go index 46c0cd9f4..612110a08 100644 --- a/example/loopback/main.go +++ b/example/loopback/main.go @@ -46,6 +46,7 @@ func main() { // Scans the arg list and sets up flags debug := flag.Bool("debug", false, "print debugging messages.") other := flag.Bool("allow-other", false, "mount with -o allowother.") + idmap := flag.Bool("idmapped", false, "enable id-mapped mount") quiet := flag.Bool("q", false, "quiet") ro := flag.Bool("ro", false, "mount read-only") directmount := flag.Bool("directmount", false, "try to call the mount syscall instead of executing fusermount") @@ -105,6 +106,7 @@ func main() { Debug: *debug, DirectMount: *directmount, DirectMountStrict: *directmountstrict, + IDMappedMount: *idmap, FsName: orig, // First column in "df -T": original dir Name: "loopback", // Second column in "df -T" will be shown as "fuse." + Name }, diff --git a/fs/idmapped_mount_test.go b/fs/idmapped_mount_test.go new file mode 100644 index 000000000..b6b45b56d --- /dev/null +++ b/fs/idmapped_mount_test.go @@ -0,0 +1,115 @@ +// Copyright 2025 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fs + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/hanwen/go-fuse/v2/fuse" + "golang.org/x/sys/unix" +) + +func TestIDMappedMount(t *testing.T) { + // sys_open_tree requires CAP_SYS_ADMIN + if os.Geteuid() != 0 { + t.Skip("id-mapped mount requires CAP_SYS_ADMIN") + } + + tc := newTestCase(t, &testOptions{idMappedMount: true}) + tc.writeOrig("file", "hello", 0644) + + fi, err := os.Lstat(filepath.Join(tc.origDir, "file")) + if err != nil { + t.Fatalf("stat for path %s failed: %v", filepath.Join(tc.origDir, "file"), err) + } + st := fuse.ToStatT(fi) + + if tc.server.KernelSettings().Flags64()&fuse.CAP_ALLOW_IDMAP == 0 { + t.Skip("Kernel does not support id-mapped mount") + } + + const offset = 10000 + fd, err := usernsFD(offset) + if err != nil { + t.Fatalf("failed to get user namespace FD: %v", err) + } + defer fd.Close() + + idDir := t.TempDir() + if err = idMapMount(tc.mntDir, idDir, int(fd.Fd())); err != nil { + t.Fatalf("id-mapped mount failed: %v", err) + } + defer unix.Unmount(idDir, 0) + + mfi, err := os.Lstat(filepath.Join(idDir, "file")) + if err != nil { + t.Fatalf("stat for path %s failed: %v", filepath.Join(idDir, "file"), err) + } + mst := fuse.ToStatT(mfi) + + if st.Uid+offset != mst.Uid { + t.Errorf("Uid %v + offset %v != mapped Uid %v", st.Uid, offset, mst.Uid) + } + if st.Gid+offset != mst.Gid { + t.Errorf("Gid %v + offset %v != mapped Gid %v", st.Gid, offset, mst.Gid) + } +} + +func idMapMount(source, target string, fd int) (err error) { + const ignored = 0 + dFd, err := unix.OpenTree(ignored, source, uint(unix.OPEN_TREE_CLONE|unix.OPEN_TREE_CLOEXEC|unix.AT_EMPTY_PATH)) + if err != nil { + return fmt.Errorf("open tree failed %s: %w", source, err) + } + defer unix.Close(dFd) + if err = unix.MountSetattr(dFd, "", unix.AT_EMPTY_PATH, &unix.MountAttr{Attr_set: unix.MOUNT_ATTR_IDMAP, Userns_fd: uint64(fd)}); err != nil { + return fmt.Errorf("set attr for %s failed: %w", source, err) + } + if err = unix.MoveMount(dFd, "", ignored, target, unix.MOVE_MOUNT_F_EMPTY_PATH); err != nil { + return fmt.Errorf("move mount to %s failed: %w", target, err) + } + return nil +} + +func usernsFD(offset int) (*os.File, error) { + var err error + args := []string{"sleep", "1h"} + if args[0], err = exec.LookPath("sleep"); err != nil { + return nil, fmt.Errorf("failed to find sleep binary: %w", err) + } + p, err := os.StartProcess(args[0], args, &os.ProcAttr{ + Sys: &syscall.SysProcAttr{ + Cloneflags: unix.CLONE_NEWUSER, + UidMappings: []syscall.SysProcIDMap{ + { + ContainerID: 0, + HostID: offset, + Size: offset, + }, + }, + GidMappings: []syscall.SysProcIDMap{ + { + ContainerID: 0, + HostID: offset, + Size: offset, + }, + }, + Pdeathsig: syscall.SIGKILL, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to start process: %w", err) + } + defer func() { + p.Kill() + p.Wait() + }() + return os.Open(fmt.Sprintf("/proc/%d/ns/user", p.Pid)) +} diff --git a/fs/simple_test.go b/fs/simple_test.go index e0f89b917..65e08d6b1 100644 --- a/fs/simple_test.go +++ b/fs/simple_test.go @@ -63,6 +63,7 @@ type testOptions struct { directMount bool // sets MountOptions.DirectMount directMountStrict bool // sets MountOptions.DirectMountStrict disableSplice bool // sets MountOptions.DisableSplice + idMappedMount bool // sets MountOptions.IDMappedMount } // newTestCase creates the directories `orig` and `mnt` inside a temporary @@ -114,6 +115,7 @@ func newTestCase(t *testing.T, opts *testOptions) *testCase { DirectMountStrict: opts.directMountStrict, EnableLocks: opts.enableLocks, DisableSplice: opts.disableSplice, + IDMappedMount: opts.idMappedMount, } if !opts.suppressDebug { mOpts.Debug = testutil.VerboseTest() @@ -121,6 +123,9 @@ func newTestCase(t *testing.T, opts *testOptions) *testCase { if opts.ro { mOpts.Options = append(mOpts.Options, "ro") } + if opts.idMappedMount { + mOpts.Options = append(mOpts.Options, "default_permissions") + } tc.server, err = fuse.NewServer(tc.rawFS, tc.mntDir, mOpts) if err != nil { t.Fatal(err) diff --git a/fuse/api.go b/fuse/api.go index d335435ea..3d3a77a88 100644 --- a/fuse/api.go +++ b/fuse/api.go @@ -335,6 +335,18 @@ type MountOptions struct { // Maximum stacking depth for passthrough files. Defaults to 1. MaxStackDepth int + + // Enable ID-mapped mount if the Kernel supports it. + // ID-mapped mount allows the device to be mounted on the system + // with the IDs remapped (via mount_setattr, move_mount syscalls) to + // those of the user on the local system. + // + // Enabling this flag automatically sets the "default_permissions" + // mount option. This is required by FUSE to delegate the UID/GID-based + // permission checks to the kernel. For requests that create new inodes, + // FUSE will send the mapped UID/GIDs. For all other requests, FUSE + // will send "-1". + IDMappedMount bool } // RawFileSystem is an interface close to the FUSE wire protocol. diff --git a/fuse/mount_linux.go b/fuse/mount_linux.go index 96111d3b7..a113f482d 100644 --- a/fuse/mount_linux.go +++ b/fuse/mount_linux.go @@ -65,6 +65,9 @@ func mountDirect(mountPoint string, opts *MountOptions, ready chan<- error) (fd if opts.AllowOther { r = append(r, "allow_other") } + if opts.IDMappedMount && !opts.containsOption("default_permissions") { + r = append(r, "default_permissions") + } if opts.Debug { opts.Logger.Printf("mountDirect: calling syscall.Mount(%q, %q, %q, %#x, %q)", diff --git a/fuse/opcode.go b/fuse/opcode.go index c7a66b8cf..eda362b9a 100644 --- a/fuse/opcode.go +++ b/fuse/opcode.go @@ -100,7 +100,7 @@ func doInit(server *protocolServer, req *request) { kernelFlags := input.Flags64() server.kernelSettings = *input kernelFlags &= (CAP_ASYNC_READ | CAP_BIG_WRITES | CAP_FILE_OPS | - CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH) + CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH | CAP_ALLOW_IDMAP) if server.opts.EnableLocks { kernelFlags |= CAP_FLOCK_LOCKS | CAP_POSIX_LOCKS @@ -119,6 +119,10 @@ func doInit(server *protocolServer, req *request) { // Clear CAP_READDIRPLUS kernelFlags &= ^uint64(CAP_READDIRPLUS) } + if !server.opts.IDMappedMount { + // Clear CAP_ALLOW_IDMAP + kernelFlags &= ^uint64(CAP_ALLOW_IDMAP) + } dataCacheMode := kernelFlags & CAP_AUTO_INVAL_DATA if server.opts.ExplicitDataCacheControl { diff --git a/fuse/server.go b/fuse/server.go index 08987f227..aa38b3278 100644 --- a/fuse/server.go +++ b/fuse/server.go @@ -281,6 +281,9 @@ func (o *MountOptions) optionsStrings() []string { if runtime.GOOS == "darwin" { r = append(r, "daemon_timeout=0") } + if o.IDMappedMount && !o.containsOption("default_permissions") { + r = append(r, "default_permissions") + } // Commas and backslashs in an option need to be escaped, because // options are separated by a comma and backslashs are used to @@ -293,6 +296,15 @@ func (o *MountOptions) optionsStrings() []string { return rEscaped } +func (o *MountOptions) containsOption(opt string) bool { + for _, o := range o.Options { + if o == opt { + return true + } + } + return false +} + // DebugData returns internal status information for debugging // purposes. func (ms *Server) DebugData() string {