From 90803ceb9d1b08925e96c70c8f0312cac5b02df0 Mon Sep 17 00:00:00 2001 From: Lucas Manning Date: Thu, 11 Jun 2026 22:43:46 +0000 Subject: [PATCH] fuse: add host FD passthrough for external FUSE servers When a FUSE filesystem is mounted with an fd that is a host file descriptor (imported via fdimport) rather than an in-sandbox /dev/fuse DeviceFD, use a new host passthrough connection that reads and writes FUSE protocol messages directly to the host FD. This enables a FUSE server running outside the sandbox to serve filesystem requests for processes inside the sandbox. The sentry detects the host FD via a HostFD() interface check in GetFilesystem, creates a hostConnection that performs synchronous I/O over the FD, and routes all FUSE Call/CallAsync operations through it. Changes: - Add hostConnection type with synchronous Call/CallAsync/InitSend over a host FD (pkg/sentry/fsimpl/fuse/host_connection.go) - Add fuseCall/fuseCallAsync dispatch methods to filesystem that route to hostConn when set, falling back to the existing queue-based connection - Detect host FDs in GetFilesystem via a HostFD() interface and branch to getFilesystemHostFD which creates the host connection and performs FUSE_INIT synchronously - Add HostFD() method to host.fileDescription - Add unit tests using socketpair-based mock FUSE servers - Add integration tests with a full FUSE protocol server backed by a real host directory (INIT, LOOKUP, OPEN, READ, WRITE) - Add fuse_host test runner binary, --fuse-host runner flag, add_fuse_host defs.bzl support, and C++ syscall tests --- pkg/sentry/fsimpl/fuse/BUILD | 4 + pkg/sentry/fsimpl/fuse/connection.go | 51 +- pkg/sentry/fsimpl/fuse/file.go | 24 +- pkg/sentry/fsimpl/fuse/fusefs.go | 82 ++- pkg/sentry/fsimpl/fuse/host_connection.go | 130 +++++ .../fuse/host_connection_integration_test.go | 501 ++++++++++++++++++ .../fsimpl/fuse/host_connection_test.go | 288 ++++++++++ pkg/sentry/fsimpl/host/host.go | 10 + pkg/sentry/vfs/filesystem.go | 5 + test/runner/BUILD | 1 + test/runner/defs.bzl | 23 + test/runner/fuse_host/BUILD | 19 + test/runner/fuse_host/fuse_host.go | 347 ++++++++++++ test/runner/main.go | 74 +++ test/syscalls/BUILD | 5 + test/syscalls/linux/BUILD | 20 + test/syscalls/linux/fuse_host.cc | 126 +++++ 17 files changed, 1682 insertions(+), 28 deletions(-) create mode 100644 pkg/sentry/fsimpl/fuse/host_connection.go create mode 100644 pkg/sentry/fsimpl/fuse/host_connection_integration_test.go create mode 100644 pkg/sentry/fsimpl/fuse/host_connection_test.go create mode 100644 test/runner/fuse_host/BUILD create mode 100644 test/runner/fuse_host/fuse_host.go create mode 100644 test/syscalls/linux/fuse_host.cc diff --git a/pkg/sentry/fsimpl/fuse/BUILD b/pkg/sentry/fsimpl/fuse/BUILD index 19c0b15d87..a75a318f17 100644 --- a/pkg/sentry/fsimpl/fuse/BUILD +++ b/pkg/sentry/fsimpl/fuse/BUILD @@ -63,6 +63,7 @@ go_library( "directory.go", "file.go", "fusefs.go", + "host_connection.go", "inode.go", "inode_connection.go", "inode_refs.go", @@ -110,12 +111,15 @@ go_test( srcs = [ "connection_test.go", "dev_test.go", + "host_connection_integration_test.go", + "host_connection_test.go", "utils_test.go", ], library = ":fuse", deps = [ "//pkg/abi/linux", "//pkg/errors/linuxerr", + "//pkg/hostarch", "//pkg/marshal/primitive", "//pkg/sentry/fsimpl/testutil", "//pkg/sentry/kernel", diff --git a/pkg/sentry/fsimpl/fuse/connection.go b/pkg/sentry/fsimpl/fuse/connection.go index 8c79e0fa21..7cc181bede 100644 --- a/pkg/sentry/fsimpl/fuse/connection.go +++ b/pkg/sentry/fsimpl/fuse/connection.go @@ -43,6 +43,34 @@ const ( fuseDefaultMaxPagesPerReq = 32 ) +// fuseConn abstracts the FUSE request/response transport. The connection +// struct delegates call dispatch to its fuseConn implementation. +type fuseConn interface { + call(ctx context.Context, r *Request) (*Response, error) + release(ctx context.Context) +} + +// deviceConn implements fuseConn for the in-sandbox /dev/fuse path. +// It uses the queue-based mechanism where the FUSE daemon reads requests +// from and writes responses to the DeviceFD. +type deviceConn struct { + conn *connection +} + +func (dc *deviceConn) call(ctx context.Context, r *Request) (*Response, error) { + fut, err := dc.conn.callFuture(ctx, r) + if err != nil { + return nil, linuxError(err) + } + res, err := fut.resolve(ctx) + if err != nil { + return res, linuxError(err) + } + return res, nil +} + +func (dc *deviceConn) release(ctx context.Context) {} + // connection is the struct by which the sentry communicates with the FUSE server daemon. // // Lock order: @@ -54,6 +82,10 @@ const ( type connection struct { connectionRefs + // fuseConn is the transport implementation. For the DeviceFD path this + // is a *deviceConn; for host passthrough this is a *hostConnection. + fuseConn fuseConn `state:"nosave"` + // We target FUSE 7.23. // The following FUSE_INIT flags are currently unsupported by this implementation: // - FUSE_EXPORT_SUPPORT @@ -309,7 +341,12 @@ func newFUSEConnection(_ context.Context, fuseFD *DeviceFD, opts *filesystemOpti // synchronization and without checking if fuseFD has already been used to // mount another filesystem. - // Create the writeBuf for the header to be stored in. + return newFUSEConnectionOpts(opts) +} + +// newFUSEConnectionOpts creates a FUSE connection with the given options. +// This is used by both the DeviceFD path and the host FD passthrough path. +func newFUSEConnectionOpts(opts *filesystemOptions) (*connection, error) { conn := &connection{ completions: make(map[linux.FUSEOpID]*futureResponse), fullQueueCh: make(chan struct{}, opts.maxActiveRequests), @@ -321,6 +358,7 @@ func newFUSEConnection(_ context.Context, fuseFD *DeviceFD, opts *filesystemOpti initializedChan: make(chan struct{}), connected: true, } + conn.fuseConn = &deviceConn{conn: conn} conn.InitRefs() return conn, nil } @@ -379,16 +417,7 @@ func (conn *connection) Call(ctx context.Context, r *Request) (*Response, error) return nil, linuxerr.ECONNREFUSED } - fut, err := conn.callFuture(ctx, r) - if err != nil { - return nil, linuxError(err) - } - - res, err := fut.resolve(ctx) - if err != nil { - return res, linuxError(err) - } - return res, nil + return conn.fuseConn.call(ctx, r) } // callFuture makes a request to the server and returns a future response. diff --git a/pkg/sentry/fsimpl/fuse/file.go b/pkg/sentry/fsimpl/fuse/file.go index a0fa9c0c9e..d77a9cd517 100644 --- a/pkg/sentry/fsimpl/fuse/file.go +++ b/pkg/sentry/fsimpl/fuse/file.go @@ -69,8 +69,8 @@ func (fd *fileDescription) statusFlags() uint32 { // Release implements vfs.FileDescriptionImpl.Release. func (fd *fileDescription) Release(ctx context.Context) { // no need to release if FUSE server doesn't implement Open. - conn := fd.inode().fs.conn - if conn.noOpen { + fs := fd.inode().fs + if fs.conn.noOpen { return } @@ -89,19 +89,19 @@ func (fd *fileDescription) Release(ctx context.Context) { opcode = linux.FUSE_RELEASE } // Ignoring errors and FUSE server replies is analogous to Linux's behavior. - req := conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, opcode, &in) + req := fs.conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, opcode, &in) // The reply will be ignored since no callback is defined in asyncCallBack(). - conn.Call(ctx, req) + fs.conn.Call(ctx, req) } // OnClose implements vfs.FileDescriptionImpl.OnClose. func (fd *fileDescription) OnClose(ctx context.Context) error { inode := fd.inode() - conn := inode.fs.conn + fs := inode.fs inode.attrMu.Lock() defer inode.attrMu.Unlock() - if conn.noOpen { + if fs.conn.noOpen { return nil } if fd.OpenFlag&linux.FOPEN_NOFLUSH != 0 { @@ -112,8 +112,8 @@ func (fd *fileDescription) OnClose(ctx context.Context) error { Fh: fd.Fh, LockOwner: 0, // TODO(gvisor.dev/issue/3245): file lock } - req := conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, linux.FUSE_FLUSH, &in) - res, err := conn.Call(ctx, req) + req := fs.conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, linux.FUSE_FLUSH, &in) + res, err := fs.conn.Call(ctx, req) if err != nil { return err } @@ -170,9 +170,9 @@ func (fd *fileDescription) Sync(ctx context.Context) error { inode := fd.inode() inode.attrMu.Lock() defer inode.attrMu.Unlock() - conn := inode.fs.conn + fs := inode.fs // no need to proceed if FUSE server doesn't implement Open. - if conn.noOpen { + if fs.conn.noOpen { return linuxerr.EINVAL } @@ -181,8 +181,8 @@ func (fd *fileDescription) Sync(ctx context.Context) error { FsyncFlags: fd.statusFlags(), } // Ignoring errors and FUSE server replies is analogous to Linux's behavior. - req := conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, linux.FUSE_FSYNC, &in) + req := fs.conn.NewRequest(auth.CredentialsFromContext(ctx), pidFromContext(ctx), inode.nodeID, linux.FUSE_FSYNC, &in) // The reply will be ignored since no callback is defined in asyncCallBack(). - conn.CallAsync(ctx, req) + fs.conn.CallAsync(ctx, req) return nil } diff --git a/pkg/sentry/fsimpl/fuse/fusefs.go b/pkg/sentry/fsimpl/fuse/fusefs.go index 5264903c67..e4297e9f07 100644 --- a/pkg/sentry/fsimpl/fuse/fusefs.go +++ b/pkg/sentry/fsimpl/fuse/fusefs.go @@ -19,6 +19,7 @@ import ( "math" "strconv" + "golang.org/x/sys/unix" "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/context" "gvisor.dev/gvisor/pkg/errors/linuxerr" @@ -88,7 +89,8 @@ type filesystem struct { devMinor uint32 // conn is used for communication between the FUSE server - // daemon and the sentry fusefs. + // daemon and the sentry fusefs. It holds shared protocol state and + // delegates call dispatch to its internal fuseConn transport. conn *connection // opts is the options the fusefs is initialized with. @@ -130,14 +132,36 @@ func (fsType FilesystemType) GetFilesystem(ctx context.Context, vfsObj *vfs.Virt } defer fuseFDGeneric.DecRef(ctx) fuseFD, ok := fuseFDGeneric.Impl().(*DeviceFD) - if !ok { - log.Warningf("%s.GetFilesystem: device FD is %T, not a FUSE device", fsType.Name(), fuseFDGeneric) + if ok { + return fsType.getFilesystemDeviceFD(ctx, vfsObj, creds, kernelTask, fuseFD, devMinor, fsopts) + } + + // Check if this is a host FD. Try the file description first (for + // regular files, pipes), then the dentry inode (for sockets, which + // have a different file description type but the same host inode). + rawHostFD := -1 + if hfd, ok := fuseFDGeneric.Impl().(vfs.HostFDProvider); ok { + rawHostFD = hfd.HostFD() + } else if d := fuseFDGeneric.Dentry(); d != nil { + if kd, ok := d.Impl().(*kernfs.Dentry); ok { + if hfd, ok := kd.Inode().(vfs.HostFDProvider); ok { + rawHostFD = hfd.HostFD() + } + } + } + if rawHostFD == -1 { + log.Warningf("%s.GetFilesystem: fd is %T, not a FUSE device or host FD", fsType.Name(), fuseFDGeneric.Impl()) return nil, nil, linuxerr.EINVAL } + return fsType.getFilesystemHostFD(ctx, vfsObj, creds, kernelTask, int32(rawHostFD), devMinor, fsopts) +} + +// getFilesystemDeviceFD creates a FUSE filesystem backed by an in-sandbox +// /dev/fuse DeviceFD. +func (fsType FilesystemType) getFilesystemDeviceFD(ctx context.Context, vfsObj *vfs.VirtualFilesystem, creds *auth.Credentials, kernelTask *kernel.Task, fuseFD *DeviceFD, devMinor uint32, fsopts *filesystemOptions) (*vfs.Filesystem, *vfs.Dentry, error) { fuseFD.mu.Lock() connected := fuseFD.connected() - // Create a new FUSE filesystem. fs, err := newFUSEFilesystem(ctx, vfsObj, &fsType, fuseFD, devMinor, fsopts) if err != nil { log.Warningf("%s.NewFUSEFilesystem: failed with error: %v", fsType.Name(), err) @@ -155,9 +179,56 @@ func (fsType FilesystemType) GetFilesystem(ctx context.Context, vfsObj *vfs.Virt } } - // root is the fusefs root directory. root := fs.newRoot(ctx, creds, fsopts.rootMode) + return fs.VFSFilesystem(), root.VFSDentry(), nil +} + +// getFilesystemHostFD creates a FUSE filesystem that communicates with a FUSE +// server running on the host via a host file descriptor. +func (fsType FilesystemType) getFilesystemHostFD(ctx context.Context, vfsObj *vfs.VirtualFilesystem, creds *auth.Credentials, kernelTask *kernel.Task, hostFD int32, devMinor uint32, fsopts *filesystemOptions) (*vfs.Filesystem, *vfs.Dentry, error) { + // Dup the host FD so that the FUSE connection owns its own copy. + // The original may be shared with or closed by the host import path + // (e.g. socket endpoints take ownership of the FD). + dupFD, err := unix.Dup(int(hostFD)) + if err != nil { + log.Warningf("%s.getFilesystemHostFD: dup failed: %v", fsType.Name(), err) + return nil, nil, err + } + // The host import path sets the FD to non-blocking for epoll-based I/O. + // The FUSE passthrough connection uses synchronous blocking I/O, so + // clear the non-blocking flag. + if err := unix.SetNonblock(dupFD, false); err != nil { + unix.Close(dupFD) + log.Warningf("%s.getFilesystemHostFD: SetNonblock failed: %v", fsType.Name(), err) + return nil, nil, err + } + conn, err := newFUSEConnectionOpts(fsopts) + if err != nil { + unix.Close(dupFD) + log.Warningf("%s.getFilesystemHostFD: newFUSEConnection failed: %v", fsType.Name(), err) + return nil, nil, err + } + + hostConn := newHostConnection(conn, int32(dupFD)) + conn.fuseConn = hostConn + + fs := &filesystem{ + devMinor: devMinor, + opts: fsopts, + conn: conn, + clock: ktime.RealtimeClockFromContext(ctx), + } + fs.VFSFilesystem().Init(vfsObj, &fsType, fs) + + rootUserNs := kernel.KernelFromContext(ctx).RootUserNamespace() + hasSysAdmin := creds.HasCapabilityIn(linux.CAP_SYS_ADMIN, rootUserNs) + if err := hostConn.InitSend(creds, uint32(kernelTask.ThreadID()), hasSysAdmin); err != nil { + log.Warningf("%s.getFilesystemHostFD: InitSend failed: %v", fsType.Name(), err) + return nil, nil, err + } + + root := fs.newRoot(ctx, creds, fsopts.rootMode) return fs.VFSFilesystem(), root.VFSDentry(), nil } @@ -295,6 +366,7 @@ func newFUSEFilesystem(ctx context.Context, vfsObj *vfs.VirtualFilesystem, fsTyp // Release implements vfs.FilesystemImpl.Release. func (fs *filesystem) Release(ctx context.Context) { + fs.conn.fuseConn.release(ctx) fs.Filesystem.VFSFilesystem().VirtualFilesystem().PutAnonBlockDevMinor(fs.devMinor) fs.Filesystem.Release(ctx) } diff --git a/pkg/sentry/fsimpl/fuse/host_connection.go b/pkg/sentry/fsimpl/fuse/host_connection.go new file mode 100644 index 0000000000..67f5f0b115 --- /dev/null +++ b/pkg/sentry/fsimpl/fuse/host_connection.go @@ -0,0 +1,130 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fuse + +import ( + "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/context" + "gvisor.dev/gvisor/pkg/errors/linuxerr" + "gvisor.dev/gvisor/pkg/log" + "gvisor.dev/gvisor/pkg/sentry/kernel/auth" + "gvisor.dev/gvisor/pkg/sync" +) + +// hostConnection implements fuseConn for the host FD passthrough path. +// Instead of using the /dev/fuse device within the sandbox, it writes FUSE +// requests to and reads FUSE responses from a host FD. This allows a FUSE +// server running outside the sandbox to serve the filesystem. +// +// I/O is synchronous: each call() writes the full request and reads the full +// response while holding ioMu. Only one request can be in flight at a time. +type hostConnection struct { + // conn holds shared FUSE connection state (protocol version, limits, etc). + conn *connection + + // hostFD is the host file descriptor for /dev/fuse. + hostFD int32 + + // ioMu serializes read/write operations on hostFD. + ioMu sync.Mutex +} + +// newHostConnection creates a hostConnection that communicates over hostFD. +func newHostConnection(conn *connection, hostFD int32) *hostConnection { + return &hostConnection{ + conn: conn, + hostFD: hostFD, + } +} + +// call implements fuseConn.call by performing synchronous I/O on the host FD. +func (hc *hostConnection) call(ctx context.Context, r *Request) (*Response, error) { + return hc.doIO(r) +} + +// release implements fuseConn.release. +func (hc *hostConnection) release(ctx context.Context) { + hc.conn.DecRef(ctx) + unix.Close(int(hc.hostFD)) +} + +// doIO performs the actual write-then-read on the host FD under ioMu. +func (hc *hostConnection) doIO(r *Request) (*Response, error) { + hc.ioMu.Lock() + defer hc.ioMu.Unlock() + + // Write the full request. + data := r.data + for len(data) > 0 { + n, err := unix.Write(int(hc.hostFD), data) + if err != nil { + return nil, err + } + data = data[n:] + } + + if r.noReply { + return nil, nil + } + + // Read the response. The host kernel delivers one complete response per + // read(2) call on /dev/fuse. + respBuf := make([]byte, linux.FUSE_MIN_READ_BUFFER) + n, err := unix.Read(int(hc.hostFD), respBuf) + if err != nil { + return nil, err + } + if n < int(linux.SizeOfFUSEHeaderOut) { + log.Warningf("fuse host connection: short read %d bytes, need at least %d", n, linux.SizeOfFUSEHeaderOut) + return nil, linuxerr.EIO + } + respBuf = respBuf[:n] + + var hdr linux.FUSEHeaderOut + hdr.UnmarshalUnsafe(respBuf[:linux.SizeOfFUSEHeaderOut]) + + if hdr.Len > uint32(n) { + log.Warningf("fuse host connection: response says %d bytes but only read %d", hdr.Len, n) + return nil, linuxerr.EIO + } + + return &Response{ + opcode: r.hdr.Opcode, + hdr: hdr, + data: respBuf[:hdr.Len], + }, nil +} + +// InitSend performs the FUSE_INIT handshake synchronously over the host FD. +func (hc *hostConnection) InitSend(creds *auth.Credentials, pid uint32, hasSysAdminCap bool) error { + in := linux.FUSEInitIn{ + Major: linux.FUSE_KERNEL_VERSION, + Minor: linux.FUSE_KERNEL_MINOR_VERSION, + MaxReadahead: fuseDefaultMaxReadahead, + Flags: fuseDefaultInitFlags, + } + + req := hc.conn.NewRequest(creds, pid, 0, linux.FUSE_INIT, &in) + + res, err := hc.doIO(req) + if err != nil { + return err + } + + hc.conn.mu.Lock() + defer hc.conn.mu.Unlock() + return hc.conn.InitRecv(res, hasSysAdminCap) +} diff --git a/pkg/sentry/fsimpl/fuse/host_connection_integration_test.go b/pkg/sentry/fsimpl/fuse/host_connection_integration_test.go new file mode 100644 index 0000000000..9a9cea9f12 --- /dev/null +++ b/pkg/sentry/fsimpl/fuse/host_connection_integration_test.go @@ -0,0 +1,501 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fuse + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/hostarch" + "gvisor.dev/gvisor/pkg/sentry/kernel/auth" +) + +// testFUSEServer implements a minimal FUSE protocol server over a socketpair. +// It handles the operations needed to exercise basic filesystem I/O: +// INIT, GETATTR, LOOKUP, OPEN, READ, WRITE, FLUSH, RELEASE. +// +// The server is backed by a real directory on the host filesystem. +type testFUSEServer struct { + fd int + backDir string + nextFh uint64 + openFiles map[uint64]*os.File +} + +func newTestFUSEServer(fd int, backDir string) *testFUSEServer { + return &testFUSEServer{ + fd: fd, + backDir: backDir, + nextFh: 1, + openFiles: make(map[uint64]*os.File), + } +} + +func (s *testFUSEServer) serve(t *testing.T, done chan struct{}) { + t.Helper() + defer close(done) + for { + buf := make([]byte, 64*1024) + n, err := unix.Read(s.fd, buf) + if err != nil || n == 0 { + return + } + if n < int(linux.SizeOfFUSEHeaderIn) { + t.Errorf("fuse server: short request %d bytes", n) + return + } + buf = buf[:n] + + var hdr linux.FUSEHeaderIn + hdr.UnmarshalUnsafe(buf[:linux.SizeOfFUSEHeaderIn]) + payload := buf[linux.SizeOfFUSEHeaderIn:] + + resp := s.handleRequest(&hdr, payload) + if resp == nil { + continue + } + if _, err := unix.Write(s.fd, resp); err != nil { + return + } + } +} + +func (s *testFUSEServer) handleRequest(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + switch hdr.Opcode { + case linux.FUSE_INIT: + return s.handleInit(hdr) + case linux.FUSE_GETATTR: + return s.handleGetAttr(hdr) + case linux.FUSE_LOOKUP: + return s.handleLookup(hdr, payload) + case linux.FUSE_OPEN: + return s.handleOpen(hdr, payload) + case linux.FUSE_READ: + return s.handleRead(hdr, payload) + case linux.FUSE_WRITE: + return s.handleWrite(hdr, payload) + case linux.FUSE_FLUSH: + return s.replyOK(hdr) + case linux.FUSE_RELEASE: + return s.handleRelease(hdr, payload) + case linux.FUSE_ACCESS: + return s.replyOK(hdr) + default: + return s.replyError(hdr, -int32(unix.ENOSYS)) + } +} + +func (s *testFUSEServer) handleInit(hdr *linux.FUSEHeaderIn) []byte { + out := linux.FUSEInitOut{ + Major: linux.FUSE_KERNEL_VERSION, + Minor: linux.FUSE_KERNEL_MINOR_VERSION, + MaxWrite: 65536, + } + return s.marshalReply(hdr, &out) +} + +func (s *testFUSEServer) handleGetAttr(hdr *linux.FUSEHeaderIn) []byte { + path := s.backDir + if hdr.NodeID != linux.FUSE_ROOT_ID { + path = filepath.Join(s.backDir, "testfile") + } + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err != nil { + return s.replyError(hdr, -int32(unix.ENOENT)) + } + out := linux.FUSEAttrOut{ + AttrValid: 1, + Attr: statToFUSEAttr(stat, hdr.NodeID), + } + return s.marshalReply(hdr, &out) +} + +func (s *testFUSEServer) handleLookup(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + nameEnd := 0 + for nameEnd < len(payload) && payload[nameEnd] != 0 { + nameEnd++ + } + name := string(payload[:nameEnd]) + + path := filepath.Join(s.backDir, name) + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err != nil { + return s.replyError(hdr, -int32(unix.ENOENT)) + } + + const childNodeID uint64 = 2 + out := linux.FUSEEntryOut{ + NodeID: childNodeID, + Generation: 1, + EntryValid: 1, + AttrValid: 1, + Attr: statToFUSEAttr(stat, childNodeID), + } + return s.marshalReply(hdr, &out) +} + +func (s *testFUSEServer) handleOpen(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEOpenIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + path := filepath.Join(s.backDir, "testfile") + flags := int(in.Flags) & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) + f, err := os.OpenFile(path, flags, 0) + if err != nil { + return s.replyError(hdr, -int32(unix.EIO)) + } + + fh := s.nextFh + s.nextFh++ + s.openFiles[fh] = f + + out := linux.FUSEOpenOut{ + Fh: fh, + OpenFlag: linux.FOPEN_DIRECT_IO, + } + return s.marshalReply(hdr, &out) +} + +func (s *testFUSEServer) handleRead(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEReadIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + f, ok := s.openFiles[in.Fh] + if !ok { + return s.replyError(hdr, -int32(unix.EBADF)) + } + + data := make([]byte, in.Size) + n, err := f.ReadAt(data, int64(in.Offset)) + if err != nil && n == 0 { + return s.dataReply(hdr, nil) + } + return s.dataReply(hdr, data[:n]) +} + +func (s *testFUSEServer) handleWrite(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEWriteIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + f, ok := s.openFiles[in.Fh] + if !ok { + return s.replyError(hdr, -int32(unix.EBADF)) + } + + writeData := payload[in.SizeBytes():] + n, err := f.WriteAt(writeData, int64(in.Offset)) + if err != nil { + return s.replyError(hdr, -int32(unix.EIO)) + } + + out := linux.FUSEWriteOut{Size: uint32(n)} + return s.marshalReply(hdr, &out) +} + +func (s *testFUSEServer) handleRelease(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEReleaseIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + if f, ok := s.openFiles[in.Fh]; ok { + f.Close() + delete(s.openFiles, in.Fh) + } + return s.replyOK(hdr) +} + +type marshalUnsafer interface { + SizeBytes() int + MarshalUnsafe(dst []byte) []byte +} + +func (s *testFUSEServer) marshalReply(hdr *linux.FUSEHeaderIn, payload marshalUnsafer) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + payloadSize := payload.SizeBytes() + buf := make([]byte, hdrSize+payloadSize) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize + payloadSize), + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf[:hdrSize]) + payload.MarshalUnsafe(buf[hdrSize:]) + return buf +} + +func (s *testFUSEServer) dataReply(hdr *linux.FUSEHeaderIn, data []byte) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + buf := make([]byte, hdrSize+len(data)) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize + len(data)), + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf[:hdrSize]) + copy(buf[hdrSize:], data) + return buf +} + +func (s *testFUSEServer) replyOK(hdr *linux.FUSEHeaderIn) []byte { + return s.replyError(hdr, 0) +} + +func (s *testFUSEServer) replyError(hdr *linux.FUSEHeaderIn, errno int32) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + buf := make([]byte, hdrSize) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize), + Error: errno, + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf) + return buf +} + +func statToFUSEAttr(stat unix.Stat_t, ino uint64) linux.FUSEAttr { + return linux.FUSEAttr{ + Ino: ino, + Size: uint64(stat.Size), + Blocks: uint64(stat.Blocks), + Atime: uint64(stat.Atim.Sec), + Mtime: uint64(stat.Mtim.Sec), + Ctime: uint64(stat.Ctim.Sec), + AtimeNsec: uint32(stat.Atim.Nsec), + MtimeNsec: uint32(stat.Mtim.Nsec), + CtimeNsec: uint32(stat.Ctim.Nsec), + Mode: stat.Mode, + Nlink: uint32(stat.Nlink), + UID: stat.Uid, + GID: stat.Gid, + BlkSize: uint32(stat.Blksize), + } +} + +// newTestHostFUSEConnection creates a socketpair, starts a FUSE protocol +// server on one end backed by backDir, and returns a hostConnection using +// the other end. The connection is fully initialized via FUSE_INIT. +func newTestHostFUSEConnection(t *testing.T, backDir string) (*hostConnection, chan struct{}, func()) { + t.Helper() + + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET, 0) + if err != nil { + t.Fatalf("Socketpair: %v", err) + } + + server := newTestFUSEServer(fds[1], backDir) + serverDone := make(chan struct{}) + go server.serve(t, serverDone) + + fsopts := filesystemOptions{ + maxActiveRequests: maxActiveRequestsDefault, + maxRead: 65536, + } + conn, err := newFUSEConnectionOpts(&fsopts) + if err != nil { + unix.Close(fds[0]) + unix.Close(fds[1]) + t.Fatalf("newFUSEConnectionOpts: %v", err) + } + hc := newHostConnection(conn, int32(fds[0])) + + cleanup := func() { + unix.Close(fds[0]) + unix.Close(fds[1]) + <-serverDone + } + return hc, serverDone, cleanup +} + +// TestHostFUSEReadFile exercises a full FUSE read through the host passthrough +// path: INIT → LOOKUP → OPEN → READ → RELEASE, with the FUSE server backed +// by a real file on the host. +func TestHostFUSEReadFile(t *testing.T) { + s := setup(t) + defer s.Destroy() + + backDir := t.TempDir() + testData := "hello from the host FUSE server\n" + if err := os.WriteFile(filepath.Join(backDir, "testfile"), []byte(testData), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + hc, _, cleanup := newTestHostFUSEConnection(t, backDir) + defer cleanup() + + creds := auth.CredentialsFromContext(s.Ctx) + + // FUSE_INIT + if err := hc.InitSend(creds, 1, true); err != nil { + t.Fatalf("InitSend: %v", err) + } + if !hc.conn.isInitialized() { + t.Fatal("connection not initialized after InitSend") + } + + // FUSE_LOOKUP "testfile" + lookupIn := linux.FUSELookupIn{Name: linux.CString("testfile")} + lookupReq := hc.conn.NewRequest(creds, 1, linux.FUSE_ROOT_ID, linux.FUSE_LOOKUP, &lookupIn) + lookupResp, err := hc.Call(s.Ctx, lookupReq) + if err != nil { + t.Fatalf("LOOKUP Call: %v", err) + } + if lookupResp.Error() != nil { + t.Fatalf("LOOKUP error: %v", lookupResp.Error()) + } + var entryOut linux.FUSEEntryOut + if err := lookupResp.UnmarshalPayload(&entryOut); err != nil { + t.Fatalf("LOOKUP unmarshal: %v", err) + } + if entryOut.NodeID == 0 { + t.Fatal("LOOKUP returned nodeID 0") + } + + // FUSE_OPEN + openIn := linux.FUSEOpenIn{Flags: uint32(linux.O_RDONLY)} + openReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_OPEN, &openIn) + openResp, err := hc.Call(s.Ctx, openReq) + if err != nil { + t.Fatalf("OPEN Call: %v", err) + } + if openResp.Error() != nil { + t.Fatalf("OPEN error: %v", openResp.Error()) + } + var openOut linux.FUSEOpenOut + if err := openResp.UnmarshalPayload(&openOut); err != nil { + t.Fatalf("OPEN unmarshal: %v", err) + } + + // FUSE_READ + readIn := linux.FUSEReadIn{ + Fh: openOut.Fh, + Offset: 0, + Size: uint32(hostarch.PageSize), + } + readReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_READ, &readIn) + readResp, err := hc.Call(s.Ctx, readReq) + if err != nil { + t.Fatalf("READ Call: %v", err) + } + if readResp.Error() != nil { + t.Fatalf("READ error: %v", readResp.Error()) + } + readData := readResp.data[readResp.hdr.SizeBytes():] + if string(readData) != testData { + t.Fatalf("READ data: got %q, want %q", string(readData), testData) + } + + // FUSE_RELEASE + releaseIn := linux.FUSEReleaseIn{Fh: openOut.Fh} + releaseReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_RELEASE, &releaseIn) + releaseResp, err := hc.Call(s.Ctx, releaseReq) + if err != nil { + t.Fatalf("RELEASE Call: %v", err) + } + if releaseResp.Error() != nil { + t.Fatalf("RELEASE error: %v", releaseResp.Error()) + } +} + +// TestHostFUSEWriteFile exercises a full FUSE write through the host +// passthrough path: INIT → LOOKUP → OPEN → WRITE → RELEASE, then verifies +// the data was written to the backing file on the host. +func TestHostFUSEWriteFile(t *testing.T) { + s := setup(t) + defer s.Destroy() + + backDir := t.TempDir() + if err := os.WriteFile(filepath.Join(backDir, "testfile"), nil, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + hc, _, cleanup := newTestHostFUSEConnection(t, backDir) + defer cleanup() + + creds := auth.CredentialsFromContext(s.Ctx) + + // FUSE_INIT + if err := hc.InitSend(creds, 1, true); err != nil { + t.Fatalf("InitSend: %v", err) + } + + // FUSE_LOOKUP "testfile" + lookupIn := linux.FUSELookupIn{Name: linux.CString("testfile")} + lookupReq := hc.conn.NewRequest(creds, 1, linux.FUSE_ROOT_ID, linux.FUSE_LOOKUP, &lookupIn) + lookupResp, err := hc.Call(s.Ctx, lookupReq) + if err != nil { + t.Fatalf("LOOKUP Call: %v", err) + } + if lookupResp.Error() != nil { + t.Fatalf("LOOKUP error: %v", lookupResp.Error()) + } + var entryOut linux.FUSEEntryOut + if err := lookupResp.UnmarshalPayload(&entryOut); err != nil { + t.Fatalf("LOOKUP unmarshal: %v", err) + } + + // FUSE_OPEN for writing + openIn := linux.FUSEOpenIn{Flags: uint32(linux.O_WRONLY)} + openReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_OPEN, &openIn) + openResp, err := hc.Call(s.Ctx, openReq) + if err != nil { + t.Fatalf("OPEN Call: %v", err) + } + if openResp.Error() != nil { + t.Fatalf("OPEN error: %v", openResp.Error()) + } + var openOut linux.FUSEOpenOut + if err := openResp.UnmarshalPayload(&openOut); err != nil { + t.Fatalf("OPEN unmarshal: %v", err) + } + + // FUSE_WRITE + writeData := []byte("written via host FUSE passthrough\n") + writeIn := linux.FUSEWritePayloadIn{ + Header: linux.FUSEWriteIn{ + Fh: openOut.Fh, + Offset: 0, + Size: uint32(len(writeData)), + }, + Payload: writeData, + } + writeReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_WRITE, &writeIn) + writeResp, err := hc.Call(s.Ctx, writeReq) + if err != nil { + t.Fatalf("WRITE Call: %v", err) + } + if writeResp.Error() != nil { + t.Fatalf("WRITE error: %v", writeResp.Error()) + } + var writeOut linux.FUSEWriteOut + if err := writeResp.UnmarshalPayload(&writeOut); err != nil { + t.Fatalf("WRITE unmarshal: %v", err) + } + if writeOut.Size != uint32(len(writeData)) { + t.Fatalf("WRITE size: got %d, want %d", writeOut.Size, len(writeData)) + } + + // FUSE_RELEASE + releaseIn := linux.FUSEReleaseIn{Fh: openOut.Fh} + releaseReq := hc.conn.NewRequest(creds, 1, entryOut.NodeID, linux.FUSE_RELEASE, &releaseIn) + hc.Call(s.Ctx, releaseReq) + + // Verify the data reached the host filesystem. + got, err := os.ReadFile(filepath.Join(backDir, "testfile")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != string(writeData) { + t.Fatalf("backing file: got %q, want %q", string(got), string(writeData)) + } +} diff --git a/pkg/sentry/fsimpl/fuse/host_connection_test.go b/pkg/sentry/fsimpl/fuse/host_connection_test.go new file mode 100644 index 0000000000..12f1dad637 --- /dev/null +++ b/pkg/sentry/fsimpl/fuse/host_connection_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fuse + +import ( + "testing" + + "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/errors/linuxerr" + "gvisor.dev/gvisor/pkg/marshal/primitive" + "gvisor.dev/gvisor/pkg/sentry/kernel/auth" +) + +// newTestHostConnection creates a hostConnection backed by a socketpair. +// Returns the hostConnection, the server-side FD, and a cleanup function. +// The connection is pre-initialized for immediate use. +func newTestHostConnection(t *testing.T) (*hostConnection, int, func()) { + t.Helper() + + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET, 0) + if err != nil { + t.Fatalf("Socketpair: %v", err) + } + + fsopts := filesystemOptions{ + maxActiveRequests: maxActiveRequestsDefault, + maxRead: 4096, + } + conn, err := newFUSEConnectionOpts(&fsopts) + if err != nil { + unix.Close(fds[0]) + unix.Close(fds[1]) + t.Fatalf("newFUSEConnectionOpts: %v", err) + } + + conn.setInitialized() + conn.mu.Lock() + conn.connInitSuccess = true + conn.maxWrite = 4096 + conn.mu.Unlock() + + hc := newHostConnection(conn, int32(fds[0])) + + cleanup := func() { + unix.Close(fds[0]) + unix.Close(fds[1]) + } + return hc, fds[1], cleanup +} + +// echoServer reads one FUSE request from serverFD and echoes the payload +// back as a response. It signals completion on the done channel. +func echoServer(t *testing.T, serverFD int, done chan struct{}) { + t.Helper() + defer close(done) + + buf := make([]byte, linux.FUSE_MIN_READ_BUFFER) + n, err := unix.Read(serverFD, buf) + if err != nil { + t.Errorf("server Read: %v", err) + return + } + if n < int(linux.SizeOfFUSEHeaderIn) { + t.Errorf("server: short read %d bytes", n) + return + } + + var reqHdr linux.FUSEHeaderIn + reqHdr.UnmarshalUnsafe(buf[:linux.SizeOfFUSEHeaderIn]) + + payload := buf[linux.SizeOfFUSEHeaderIn:n] + respLen := linux.SizeOfFUSEHeaderOut + uint32(len(payload)) + respBuf := make([]byte, respLen) + + respHdr := linux.FUSEHeaderOut{ + Len: respLen, + Error: 0, + Unique: reqHdr.Unique, + } + respHdr.MarshalUnsafe(respBuf[:linux.SizeOfFUSEHeaderOut]) + copy(respBuf[linux.SizeOfFUSEHeaderOut:], payload) + + if _, err := unix.Write(serverFD, respBuf); err != nil { + t.Errorf("server Write: %v", err) + } +} + +func TestHostConnectionCall(t *testing.T) { + s := setup(t) + defer s.Destroy() + + hc, serverFD, cleanup := newTestHostConnection(t) + defer cleanup() + + done := make(chan struct{}) + go echoServer(t, serverFD, done) + + creds := auth.CredentialsFromContext(s.Ctx) + testObj := primitive.Uint32(42) + req := hc.conn.NewRequest(creds, 1, 1, echoTestOpcode, &testObj) + + resp, err := hc.Call(s.Ctx, req) + if err != nil { + t.Fatalf("Call: %v", err) + } + + <-done + + if resp.hdr.Error != 0 { + t.Fatalf("response error: %d", resp.hdr.Error) + } + if resp.hdr.Unique != req.hdr.Unique { + t.Fatalf("unique mismatch: got %d, want %d", resp.hdr.Unique, req.hdr.Unique) + } + + var got primitive.Uint32 + if err := resp.UnmarshalPayload(&got); err != nil { + t.Fatalf("UnmarshalPayload: %v", err) + } + if got != testObj { + t.Fatalf("payload: got %d, want %d", got, testObj) + } +} + +func TestHostConnectionInit(t *testing.T) { + s := setup(t) + defer s.Destroy() + + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET, 0) + if err != nil { + t.Fatalf("Socketpair: %v", err) + } + defer unix.Close(fds[0]) + defer unix.Close(fds[1]) + + fsopts := filesystemOptions{ + maxActiveRequests: maxActiveRequestsDefault, + maxRead: 4096, + } + conn, err := newFUSEConnectionOpts(&fsopts) + if err != nil { + t.Fatalf("newFUSEConnectionOpts: %v", err) + } + hc := newHostConnection(conn, int32(fds[0])) + + const testMaxWrite uint32 = 65536 + + done := make(chan struct{}) + go func() { + defer close(done) + + buf := make([]byte, linux.FUSE_MIN_READ_BUFFER) + n, err := unix.Read(fds[1], buf) + if err != nil { + t.Errorf("server Read: %v", err) + return + } + + var reqHdr linux.FUSEHeaderIn + reqHdr.UnmarshalUnsafe(buf[:linux.SizeOfFUSEHeaderIn]) + if reqHdr.Opcode != linux.FUSE_INIT { + t.Errorf("expected FUSE_INIT opcode, got %d", reqHdr.Opcode) + return + } + _ = n + + initOut := linux.FUSEInitOut{ + Major: linux.FUSE_KERNEL_VERSION, + Minor: linux.FUSE_KERNEL_MINOR_VERSION, + MaxWrite: testMaxWrite, + } + respLen := uint32(linux.SizeOfFUSEHeaderOut) + uint32(initOut.SizeBytes()) + respBuf := make([]byte, respLen) + + respHdr := linux.FUSEHeaderOut{ + Len: respLen, + Error: 0, + Unique: reqHdr.Unique, + } + respHdr.MarshalUnsafe(respBuf[:linux.SizeOfFUSEHeaderOut]) + initOut.MarshalUnsafe(respBuf[linux.SizeOfFUSEHeaderOut:]) + + if _, err := unix.Write(fds[1], respBuf); err != nil { + t.Errorf("server Write: %v", err) + } + }() + + creds := auth.CredentialsFromContext(s.Ctx) + if err := hc.InitSend(creds, 1, true); err != nil { + t.Fatalf("InitSend: %v", err) + } + + <-done + + if !conn.isInitialized() { + t.Fatal("connection not initialized after InitSend") + } + + conn.mu.Lock() + if !conn.connInitSuccess { + t.Error("connInitSuccess not set") + } + if conn.maxWrite < fuseMinMaxWrite { + t.Errorf("maxWrite = %d, want >= %d", conn.maxWrite, fuseMinMaxWrite) + } + conn.mu.Unlock() +} + +func TestHostConnectionCallAsync(t *testing.T) { + s := setup(t) + defer s.Destroy() + + hc, serverFD, cleanup := newTestHostConnection(t) + defer cleanup() + + // Server handles two requests: one async, one sync. + asyncDone := make(chan struct{}) + go echoServer(t, serverFD, asyncDone) + + creds := auth.CredentialsFromContext(s.Ctx) + asyncPayload := primitive.Uint32(99) + asyncReq := hc.conn.NewRequest(creds, 1, 1, echoTestOpcode, &asyncPayload) + + if err := hc.CallAsync(s.Ctx, asyncReq); err != nil { + t.Fatalf("CallAsync: %v", err) + } + <-asyncDone + + // Make a subsequent sync Call to verify no stale data in the FD. + syncDone := make(chan struct{}) + go echoServer(t, serverFD, syncDone) + + syncPayload := primitive.Uint32(123) + syncReq := hc.conn.NewRequest(creds, 2, 2, echoTestOpcode, &syncPayload) + + resp, err := hc.Call(s.Ctx, syncReq) + if err != nil { + t.Fatalf("Call after CallAsync: %v", err) + } + <-syncDone + + if resp.hdr.Unique != syncReq.hdr.Unique { + t.Fatalf("unique mismatch after async: got %d, want %d", resp.hdr.Unique, syncReq.hdr.Unique) + } + + var got primitive.Uint32 + if err := resp.UnmarshalPayload(&got); err != nil { + t.Fatalf("UnmarshalPayload: %v", err) + } + if got != syncPayload { + t.Fatalf("payload after async: got %d, want %d", got, syncPayload) + } +} + +func TestHostConnectionNotConnected(t *testing.T) { + s := setup(t) + defer s.Destroy() + + hc, _, cleanup := newTestHostConnection(t) + defer cleanup() + + // Disconnect the connection. + hc.conn.mu.Lock() + hc.conn.connected = false + hc.conn.mu.Unlock() + + creds := auth.CredentialsFromContext(s.Ctx) + testObj := primitive.Uint32(0) + req := hc.conn.NewRequest(creds, 1, 1, echoTestOpcode, &testObj) + + _, err := hc.Call(s.Ctx, req) + if !linuxerr.Equals(linuxerr.ENOTCONN, err) { + t.Fatalf("expected ENOTCONN, got %v", err) + } +} diff --git a/pkg/sentry/fsimpl/host/host.go b/pkg/sentry/fsimpl/host/host.go index ad681735ad..a7d374ae86 100644 --- a/pkg/sentry/fsimpl/host/host.go +++ b/pkg/sentry/fsimpl/host/host.go @@ -806,6 +806,16 @@ func (f *fileDescription) Release(context.Context) { // noop } +// HostFD returns the underlying host file descriptor number. +func (f *fileDescription) HostFD() int { + return f.inode.hostFD +} + +// HostFD returns the underlying host file descriptor number. +func (i *inode) HostFD() int { + return i.hostFD +} + // Allocate implements vfs.FileDescriptionImpl.Allocate. func (f *fileDescription) Allocate(ctx context.Context, mode, offset, length uint64) error { if f.inode.readonly { diff --git a/pkg/sentry/vfs/filesystem.go b/pkg/sentry/vfs/filesystem.go index 48608b1523..e4f61d741b 100644 --- a/pkg/sentry/vfs/filesystem.go +++ b/pkg/sentry/vfs/filesystem.go @@ -567,3 +567,8 @@ type PrependPathSyntheticError struct{} func (PrependPathSyntheticError) Error() string { return "vfs.FilesystemImpl.PrependPath() prepended synthetic name" } + +// HostFDProvider is implemented by VFS objects backed by a host FD. +type HostFDProvider interface { + HostFD() int +} diff --git a/test/runner/BUILD b/test/runner/BUILD index 733cca9405..e952e811e5 100644 --- a/test/runner/BUILD +++ b/test/runner/BUILD @@ -12,6 +12,7 @@ go_binary( data = [ "//runsc", "//test/runner/fuse", + "//test/runner/fuse_host", "//test/runner/setup_container", ], visibility = ["//:sandbox"], diff --git a/test/runner/defs.bzl b/test/runner/defs.bzl index 914885f7bb..ce06b3a9de 100644 --- a/test/runner/defs.bzl +++ b/test/runner/defs.bzl @@ -76,6 +76,7 @@ def _syscall_test( container = None, one_sandbox = True, fusefs = False, + fuse_host = False, directfs = False, leak_check = False, save = False, @@ -97,6 +98,8 @@ def _syscall_test( name += "_" + network + "net" if fusefs: name += "_fuse" + if fuse_host: + name += "_fuse_host" if directfs: name += "_directfs" if save: @@ -163,6 +166,7 @@ def _syscall_test( "--network=" + network, "--use-tmpfs=" + str(use_tmpfs), "--fusefs=" + str(fusefs), + "--fuse-host=" + str(fuse_host), "--file-access=" + file_access, "--overlay=" + str(overlay), "--add-host-uds=" + str(add_host_uds), @@ -227,6 +231,7 @@ def syscall_test_variants( overlay = False, netstack_sr = False, nftables = False, + add_fuse_host = False, kvm_use_cpu_nums = False, **kwargs): """Generates syscall tests for all variants. @@ -390,11 +395,28 @@ def syscall_test_variants( kvm_use_cpu_nums = kvm_use_cpu_nums, **kwargs ) + if add_fuse_host: + _syscall_test( + test = test, + platform = default_platform, + use_tmpfs = True, + fuse_host = True, + tags = platforms.get(default_platform, []) + tags, + debug = debug, + container = container, + one_sandbox = one_sandbox, + leak_check = leak_check, + size = size, + timeout = timeout, + kvm_use_cpu_nums = kvm_use_cpu_nums, + **kwargs + ) def syscall_test( test, use_tmpfs = False, add_fusefs = False, + add_fuse_host = False, add_overlay = False, add_host_uds = False, add_host_connector = False, @@ -501,6 +523,7 @@ def syscall_test( overlay = overlay, netstack_sr = False, nftables = nftables, + add_fuse_host = add_fuse_host, kvm_use_cpu_nums = kvm_use_cpu_nums, **kwargs ) diff --git a/test/runner/fuse_host/BUILD b/test/runner/fuse_host/BUILD new file mode 100644 index 0000000000..50b403d46b --- /dev/null +++ b/test/runner/fuse_host/BUILD @@ -0,0 +1,19 @@ +load("//tools:defs.bzl", "go_binary") + +package( + default_applicable_licenses = ["//:license"], + licenses = ["notice"], +) + +go_binary( + name = "fuse_host", + srcs = ["fuse_host.go"], + visibility = [ + "//visibility:public", + ], + deps = [ + "//pkg/abi/linux", + "//pkg/log", + "@org_golang_x_sys//unix:go_default_library", + ], +) diff --git a/test/runner/fuse_host/fuse_host.go b/test/runner/fuse_host/fuse_host.go new file mode 100644 index 0000000000..19f70383f6 --- /dev/null +++ b/test/runner/fuse_host/fuse_host.go @@ -0,0 +1,347 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Binary fuse_host implements a FUSE protocol server that runs on the host +// and communicates over a socketpair. One end of the socketpair is intended +// to be passed into a gVisor sandbox as a host FD, where it is used to mount +// a FUSE filesystem via the host passthrough path. +// +// The server implements a loopback filesystem: it forwards all operations +// to a backing directory on the host. +// +// Usage: +// +// fuse_host --back-dir=/path/to/backing --fd=N +// +// where N is the server-side FD of the socketpair (the caller creates the +// socketpair and passes the server end as an extra file). +package main + +import ( + "flag" + "os" + "path/filepath" + + "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/log" +) + +var ( + backDir = flag.String("back-dir", "", "The backing directory on the host for the loopback FUSE server.") + serverFD = flag.Int("fd", -1, "The server-side socketpair FD to read FUSE requests from.") +) + +func main() { + flag.Parse() + if *backDir == "" { + log.Warningf("fuse_host: --back-dir is required") + os.Exit(1) + } + if *serverFD < 0 { + log.Warningf("fuse_host: --fd is required") + os.Exit(1) + } + + s := &server{ + fd: *serverFD, + backDir: *backDir, + nextFh: 1, + openFiles: make(map[uint64]*os.File), + } + s.serve() +} + +type server struct { + fd int + backDir string + nextFh uint64 + openFiles map[uint64]*os.File +} + +func (s *server) serve() { + for { + buf := make([]byte, 64*1024) + n, err := unix.Read(s.fd, buf) + if err != nil || n == 0 { + return + } + if n < int(linux.SizeOfFUSEHeaderIn) { + log.Warningf("fuse_host: short request %d bytes", n) + return + } + buf = buf[:n] + + var hdr linux.FUSEHeaderIn + hdr.UnmarshalUnsafe(buf[:linux.SizeOfFUSEHeaderIn]) + payload := buf[linux.SizeOfFUSEHeaderIn:] + + resp := s.handleRequest(&hdr, payload) + if resp == nil { + continue + } + if _, err := unix.Write(s.fd, resp); err != nil { + log.Warningf("fuse_host: write error: %v", err) + return + } + } +} + +func (s *server) handleRequest(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + switch hdr.Opcode { + case linux.FUSE_INIT: + return s.handleInit(hdr) + case linux.FUSE_GETATTR: + return s.handleGetAttr(hdr, payload) + case linux.FUSE_LOOKUP: + return s.handleLookup(hdr, payload) + case linux.FUSE_OPEN: + return s.handleOpen(hdr, payload) + case linux.FUSE_READ: + return s.handleRead(hdr, payload) + case linux.FUSE_WRITE: + return s.handleWrite(hdr, payload) + case linux.FUSE_FLUSH: + return s.replyOK(hdr) + case linux.FUSE_RELEASE: + return s.handleRelease(hdr, payload) + case linux.FUSE_ACCESS: + return s.replyOK(hdr) + case linux.FUSE_STATFS: + return s.handleStatFS(hdr) + default: + return s.replyError(hdr, -int32(unix.ENOSYS)) + } +} + +func (s *server) handleInit(hdr *linux.FUSEHeaderIn) []byte { + out := linux.FUSEInitOut{ + Major: linux.FUSE_KERNEL_VERSION, + Minor: linux.FUSE_KERNEL_MINOR_VERSION, + MaxWrite: 65536, + Flags: linux.FUSE_BIG_WRITES, + } + return s.marshalReply(hdr, &out) +} + +func (s *server) handleGetAttr(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + path := s.nodeIDToPath(hdr.NodeID) + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err != nil { + return s.replyError(hdr, -int32(unix.ENOENT)) + } + out := linux.FUSEAttrOut{ + AttrValid: 1, + Attr: statToFUSEAttr(stat, hdr.NodeID), + } + return s.marshalReply(hdr, &out) +} + +func (s *server) handleLookup(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + nameEnd := 0 + for nameEnd < len(payload) && payload[nameEnd] != 0 { + nameEnd++ + } + name := string(payload[:nameEnd]) + + path := filepath.Join(s.nodeIDToPath(hdr.NodeID), name) + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err != nil { + return s.replyError(hdr, -int32(unix.ENOENT)) + } + + // Use inode number from stat as the node ID. + out := linux.FUSEEntryOut{ + NodeID: stat.Ino, + Generation: 1, + EntryValid: 1, + AttrValid: 1, + Attr: statToFUSEAttr(stat, stat.Ino), + } + return s.marshalReply(hdr, &out) +} + +func (s *server) handleOpen(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEOpenIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + path := s.nodeIDToPath(hdr.NodeID) + flags := int(in.Flags) & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC) + f, err := os.OpenFile(path, flags, 0) + if err != nil { + return s.replyError(hdr, -int32(unix.EIO)) + } + + fh := s.nextFh + s.nextFh++ + s.openFiles[fh] = f + + out := linux.FUSEOpenOut{ + Fh: fh, + OpenFlag: linux.FOPEN_DIRECT_IO, + } + return s.marshalReply(hdr, &out) +} + +func (s *server) handleRead(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEReadIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + f, ok := s.openFiles[in.Fh] + if !ok { + return s.replyError(hdr, -int32(unix.EBADF)) + } + + data := make([]byte, in.Size) + n, err := f.ReadAt(data, int64(in.Offset)) + if err != nil && n == 0 { + return s.dataReply(hdr, nil) + } + return s.dataReply(hdr, data[:n]) +} + +func (s *server) handleWrite(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEWriteIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + + f, ok := s.openFiles[in.Fh] + if !ok { + return s.replyError(hdr, -int32(unix.EBADF)) + } + + writeData := payload[in.SizeBytes():] + n, err := f.WriteAt(writeData, int64(in.Offset)) + if err != nil { + return s.replyError(hdr, -int32(unix.EIO)) + } + + out := linux.FUSEWriteOut{Size: uint32(n)} + return s.marshalReply(hdr, &out) +} + +func (s *server) handleRelease(hdr *linux.FUSEHeaderIn, payload []byte) []byte { + var in linux.FUSEReleaseIn + in.UnmarshalUnsafe(payload[:in.SizeBytes()]) + if f, ok := s.openFiles[in.Fh]; ok { + f.Close() + delete(s.openFiles, in.Fh) + } + return s.replyOK(hdr) +} + +func (s *server) handleStatFS(hdr *linux.FUSEHeaderIn) []byte { + var statfs unix.Statfs_t + if err := unix.Statfs(s.backDir, &statfs); err != nil { + return s.replyError(hdr, -int32(unix.EIO)) + } + out := linux.FUSEStatfsOut{ + Blocks: statfs.Blocks, + BlocksFree: statfs.Bfree, + BlocksAvailable: statfs.Bavail, + Files: statfs.Files, + FilesFree: statfs.Ffree, + BlockSize: uint32(statfs.Bsize), + NameLength: uint32(statfs.Namelen), + FragmentSize: uint32(statfs.Frsize), + } + return s.marshalReply(hdr, &out) +} + +// nodeIDToPath converts a FUSE node ID to a host filesystem path. +// Node ID 1 (FUSE_ROOT_ID) maps to the backing directory. +// Other node IDs are host inode numbers; we search the backing directory +// for a matching inode. For simplicity, this only supports a flat directory. +func (s *server) nodeIDToPath(nodeID uint64) string { + if nodeID == linux.FUSE_ROOT_ID { + return s.backDir + } + entries, err := os.ReadDir(s.backDir) + if err != nil { + return s.backDir + } + for _, e := range entries { + path := filepath.Join(s.backDir, e.Name()) + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err == nil && stat.Ino == nodeID { + return path + } + } + return s.backDir +} + +type marshalUnsafer interface { + SizeBytes() int + MarshalUnsafe(dst []byte) []byte +} + +func (s *server) marshalReply(hdr *linux.FUSEHeaderIn, payload marshalUnsafer) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + payloadSize := payload.SizeBytes() + buf := make([]byte, hdrSize+payloadSize) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize + payloadSize), + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf[:hdrSize]) + payload.MarshalUnsafe(buf[hdrSize:]) + return buf +} + +func (s *server) dataReply(hdr *linux.FUSEHeaderIn, data []byte) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + buf := make([]byte, hdrSize+len(data)) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize + len(data)), + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf[:hdrSize]) + copy(buf[hdrSize:], data) + return buf +} + +func (s *server) replyOK(hdr *linux.FUSEHeaderIn) []byte { + return s.replyError(hdr, 0) +} + +func (s *server) replyError(hdr *linux.FUSEHeaderIn, errno int32) []byte { + hdrSize := int(linux.SizeOfFUSEHeaderOut) + buf := make([]byte, hdrSize) + outHdr := linux.FUSEHeaderOut{ + Len: uint32(hdrSize), + Error: errno, + Unique: hdr.Unique, + } + outHdr.MarshalUnsafe(buf) + return buf +} + +func statToFUSEAttr(stat unix.Stat_t, ino uint64) linux.FUSEAttr { + return linux.FUSEAttr{ + Ino: ino, + Size: uint64(stat.Size), + Blocks: uint64(stat.Blocks), + Atime: uint64(stat.Atim.Sec), + Mtime: uint64(stat.Mtim.Sec), + Ctime: uint64(stat.Ctim.Sec), + AtimeNsec: uint32(stat.Atim.Nsec), + MtimeNsec: uint32(stat.Mtim.Nsec), + CtimeNsec: uint32(stat.Ctim.Nsec), + Mode: stat.Mode, + Nlink: uint32(stat.Nlink), + UID: stat.Uid, + GID: stat.Gid, + BlkSize: uint32(stat.Blksize), + } +} + diff --git a/test/runner/main.go b/test/runner/main.go index e81bcec538..c3b7d70db5 100644 --- a/test/runner/main.go +++ b/test/runner/main.go @@ -58,6 +58,7 @@ var ( network = flag.String("network", "none", "network stack to run on (sandbox, host, none)") useTmpfs = flag.Bool("use-tmpfs", false, "mounts tmpfs for /tmp") fusefs = flag.Bool("fusefs", false, "mounts a fusefs for /tmp") + fuseHost = flag.Bool("fuse-host", false, "mounts a fusefs backed by a host-side FUSE server communicating over a socketpair") fileAccess = flag.String("file-access", "exclusive", "mounts root in exclusive or shared mode") overlay = flag.Bool("overlay", false, "wrap filesystem mounts with writable tmpfs overlay") container = flag.Bool("container", false, "run tests in their own namespaces (user ns, network ns, etc), pretending to be root. Implicitly enabled if network=host, or if using network namespaces") @@ -93,6 +94,14 @@ const ( // FDs passed via ExtraFiles start at 3. hostPTYHostFD = 3 + // fuseHostGuestFD is the FD number inside the sandbox for the host FUSE + // socketpair end. + fuseHostGuestFD = 101 + + // fuseHostHostFD is the FD number on the host for the sandbox-side + // socketpair end. If hostPTY is also used, this shifts to 4. + fuseHostHostFDBase = 3 + // uniqueXMLSuffix is the suffix for individual per-testcase XML outputs. uniqueXMLSuffix = ".unique.xml" ) @@ -351,6 +360,59 @@ func runRunsc(tc *gtest.TestCase, spec *specs.Spec) error { spec.Process.Env = append(spec.Process.Env, fmt.Sprintf("TEST_HOST_PTY_FD=%d", hostPTYGuestFD)) } + var fuseHostFile *os.File + if *fuseHost { + fuseHostServerBin, err := testutil.FindFile("test/runner/fuse_host/fuse_host") + if err != nil { + return fmt.Errorf("cannot find fuse_host: %v", err) + } + + fuseHostBackDir, err := os.MkdirTemp(testutil.TmpDir(), "fuse-host-back") + if err != nil { + return fmt.Errorf("could not create fuse host backing dir: %v", err) + } + defer os.RemoveAll(fuseHostBackDir) + if err := os.Chmod(fuseHostBackDir, 0777); err != nil { + return fmt.Errorf("could not chmod fuse host backing dir: %v", err) + } + + fuseHostTestData := "hello from the host FUSE server\n" + if err := os.WriteFile(filepath.Join(fuseHostBackDir, "testfile"), []byte(fuseHostTestData), 0644); err != nil { + return fmt.Errorf("could not create fuse host test file: %v", err) + } + + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("socketpair for fuse_host: %v", err) + } + fuseHostFile = os.NewFile(uintptr(fds[0]), "fuse-host-sandbox") + serverFile := os.NewFile(uintptr(fds[1]), "fuse-host-server") + + fuseHostServerCmd := exec.Command(fuseHostServerBin, + fmt.Sprintf("--back-dir=%s", fuseHostBackDir), + "--fd=3", + ) + fuseHostServerCmd.ExtraFiles = []*os.File{serverFile} + fuseHostServerCmd.Stdout = os.Stdout + fuseHostServerCmd.Stderr = os.Stderr + if err := fuseHostServerCmd.Start(); err != nil { + fuseHostFile.Close() + serverFile.Close() + return fmt.Errorf("could not start fuse_host server: %v", err) + } + serverFile.Close() + defer func() { + fuseHostFile.Close() + fuseHostServerCmd.Process.Kill() + fuseHostServerCmd.Wait() + }() + + spec.Process.Env = append(spec.Process.Env, + fmt.Sprintf("GVISOR_FUSE_HOST_TEST=TRUE"), + fmt.Sprintf("GVISOR_FUSE_HOST_FD=%d", fuseHostGuestFD), + ) + } + bundleDir, cleanup, err := testutil.SetupBundleDir(spec) if err != nil { return fmt.Errorf("SetupBundleDir failed: %v", err) @@ -554,6 +616,14 @@ func runRunsc(tc *gtest.TestCase, spec *specs.Spec) error { if hostTTYFile != nil { cmdArgs = append(cmdArgs, fmt.Sprintf("--pass-fd=%d:%d", hostPTYHostFD, hostPTYGuestFD)) } + if fuseHostFile != nil { + // The host FD number is 3 + number of ExtraFiles already added. + fuseHostHostFD := fuseHostHostFDBase + if hostTTYFile != nil { + fuseHostHostFD++ + } + cmdArgs = append(cmdArgs, fmt.Sprintf("--pass-fd=%d:%d", fuseHostHostFD, fuseHostGuestFD)) + } cmdArgs = append(cmdArgs, "--bundle", bundleDir, id) } log.Infof("Executing: %v", append([]string{specutils.ExePath}, cmdArgs...)) @@ -561,6 +631,9 @@ func runRunsc(tc *gtest.TestCase, spec *specs.Spec) error { if hostTTYFile != nil && *waitForPid == 0 { cmd.ExtraFiles = append(cmd.ExtraFiles, hostTTYFile) } + if fuseHostFile != nil { + cmd.ExtraFiles = append(cmd.ExtraFiles, fuseHostFile) + } cmd.SysProcAttr = sysProcAttr if *container || *network == "host" || (cmd.SysProcAttr.Cloneflags&unix.CLONE_NEWNET != 0) { cmd.SysProcAttr.Cloneflags |= unix.CLONE_NEWNET @@ -977,6 +1050,7 @@ func runTestCaseRunsc(testBin string, tc *gtest.TestCase, args []string, t *test Type: "tmpfs", }) } + if *network == "host" && !testutil.TestEnvSupportsNetAdmin { log.Warningf("Testing with network=host but test environment does not support net admin or raw sockets. Dropping CAP_NET_ADMIN and CAP_NET_RAW.") specutils.DropCapability(spec.Process.Capabilities, "CAP_NET_ADMIN") diff --git a/test/syscalls/BUILD b/test/syscalls/BUILD index 0edfb4968e..e0734d26bc 100644 --- a/test/syscalls/BUILD +++ b/test/syscalls/BUILD @@ -279,6 +279,11 @@ syscall_test( test = "//test/syscalls/linux:fuse_test", ) +syscall_test( + add_fuse_host = True, + test = "//test/syscalls/linux:fuse_host_test", +) + syscall_test( test = "//test/syscalls/linux:getcpu_host_test", ) diff --git a/test/syscalls/linux/BUILD b/test/syscalls/linux/BUILD index fc78f53ac2..93b609c39f 100644 --- a/test/syscalls/linux/BUILD +++ b/test/syscalls/linux/BUILD @@ -4969,6 +4969,26 @@ cc_binary( ], ) +cc_binary( + name = "fuse_host_test", + testonly = 1, + srcs = ["fuse_host.cc"], + linkstatic = 1, + malloc = "//test/util:errno_safe_allocator", + deps = select_gtest() + [ + "//test/util:capability_util", + "//test/util:file_descriptor", + "//test/util:fs_util", + "//test/util:mount_util", + "//test/util:posix_error", + "//test/util:temp_path", + "//test/util:test_main", + "//test/util:test_util", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/strings:str_format", + ], +) + cc_binary( name = "process_vm_read_write_test", testonly = 1, diff --git a/test/syscalls/linux/fuse_host.cc b/test/syscalls/linux/fuse_host.cc new file mode 100644 index 0000000000..cfdaa438c7 --- /dev/null +++ b/test/syscalls/linux/fuse_host.cc @@ -0,0 +1,126 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Tests for FUSE host passthrough: a FUSE filesystem served by a host-side +// FUSE server communicating over a socketpair. +// +// These tests are run with the _fuse_host suffix. The test runner starts a +// host FUSE server, passes the socketpair FD into the sandbox, and sets +// GVISOR_FUSE_HOST_TEST=TRUE and GVISOR_FUSE_HOST_FD=. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "test/util/file_descriptor.h" +#include "test/util/fs_util.h" +#include "test/util/linux_capability_util.h" +#include "test/util/mount_util.h" +#include "test/util/posix_error.h" +#include "test/util/temp_path.h" +#include "test/util/test_util.h" + +namespace gvisor { +namespace testing { + +namespace { + +// Returns the FD number for the host FUSE socketpair end, or -1 if not set. +int GetFuseHostFD() { + const char* fd_str = getenv("GVISOR_FUSE_HOST_FD"); + if (fd_str == nullptr) return -1; + return atoi(fd_str); +} + +// FuseHostTest exercises the FUSE host passthrough path by mounting a FUSE +// filesystem using a host FD (socketpair end) and performing file operations. +class FuseHostTest : public ::testing::Test { + protected: + void SetUp() override { + SKIP_IF(absl::NullSafeStringView(getenv("GVISOR_FUSE_HOST_TEST")) != + "TRUE"); + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SYS_ADMIN))); + + fuse_fd_ = GetFuseHostFD(); + ASSERT_GE(fuse_fd_, 0) << "GVISOR_FUSE_HOST_FD not set"; + + mount_point_ = ASSERT_NO_ERRNO_AND_VALUE(TempPath::CreateDir()); + auto mount_opts = absl::StrFormat( + "fd=%d,user_id=0,group_id=0,rootmode=40000", fuse_fd_); + mount_ = ASSERT_NO_ERRNO_AND_VALUE( + Mount("fuse", mount_point_.path(), "fuse", MS_NODEV | MS_NOSUID, + mount_opts, 0)); + } + + int fuse_fd_ = -1; + TempPath mount_point_; + Cleanup mount_; +}; + +TEST_F(FuseHostTest, StatRoot) { + struct stat st; + ASSERT_THAT(stat(mount_point_.path().c_str(), &st), SyscallSucceeds()); + EXPECT_TRUE(S_ISDIR(st.st_mode)); +} + +TEST_F(FuseHostTest, ReadFile) { + const std::string expected = "hello from the host FUSE server\n"; + const std::string path = + JoinPath(mount_point_.path(), "testfile"); + + FileDescriptor fd = ASSERT_NO_ERRNO_AND_VALUE(Open(path, O_RDONLY)); + std::vector buf(expected.size()); + ASSERT_THAT(ReadFd(fd.get(), buf.data(), expected.size()), + SyscallSucceedsWithValue(expected.size())); + EXPECT_EQ(std::string(buf.data(), buf.size()), expected); +} + +TEST_F(FuseHostTest, WriteAndReadBack) { + const std::string path = + JoinPath(mount_point_.path(), "testfile"); + + // Write new data. + const std::string write_data = "overwritten by test\n"; + { + FileDescriptor fd = ASSERT_NO_ERRNO_AND_VALUE(Open(path, O_WRONLY)); + ASSERT_THAT(WriteFd(fd.get(), write_data.data(), write_data.size()), + SyscallSucceedsWithValue(write_data.size())); + } + + // Read it back. + { + FileDescriptor fd = ASSERT_NO_ERRNO_AND_VALUE(Open(path, O_RDONLY)); + std::vector buf(write_data.size()); + ASSERT_THAT(ReadFd(fd.get(), buf.data(), write_data.size()), + SyscallSucceedsWithValue(write_data.size())); + EXPECT_EQ(std::string(buf.data(), buf.size()), write_data); + } +} + +} // namespace +} // namespace testing +} // namespace gvisor