From 04d1e2563a39654bd471a01d32bdbc486f354a74 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Mon, 18 May 2026 18:12:02 +0100 Subject: [PATCH] serve nfs: allow NFS clients to mount subpaths of the served remote Previously the Mount RPC ignored the path component of the mount request, so `server:/sub/dir` and `server:/` both landed at the root of the served remote. The Mount handler now cleans the requested path with path.Clean, looks it up in the VFS and serves a billy.Filesystem rooted at that directory, refusing the mount if the path does not exist or is not a plain directory. A pathRewriter cache wraps the inner handle cache so that the same file always produces the same NFS file handle regardless of which mount minted it (and stable across server restarts for the disk and symlink caches). This matches the traditional NFS expectation that a subpath mount behaves like `cd` into a subtree. nfsmount gains a --nfs-mount-path flag (default /) so clients can select a subpath at mount time. This replaces a latent misuse of --volname as the NFS mount path that was previously masked by the server ignoring it. Fixes #9442 --- cmd/nfsmount/nfsmount.go | 6 +- cmd/nfsmount/nfsmount_test.go | 55 +++++++++++++ cmd/serve/nfs/cache.go | 77 +++++++++++++++--- cmd/serve/nfs/cache_test.go | 51 +++++++++++- cmd/serve/nfs/filesystem.go | 48 ++++++++++-- cmd/serve/nfs/handler.go | 25 +++++- cmd/serve/nfs/handler_test.go | 144 ++++++++++++++++++++++++++++++++++ cmd/serve/nfs/nfs.go | 15 ++++ 8 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 cmd/serve/nfs/handler_test.go diff --git a/cmd/nfsmount/nfsmount.go b/cmd/nfsmount/nfsmount.go index 26ed96914..89821a82d 100644 --- a/cmd/nfsmount/nfsmount.go +++ b/cmd/nfsmount/nfsmount.go @@ -21,7 +21,8 @@ import ( ) var ( - sudo = false + sudo = false + mountPath = "/" ) func init() { @@ -32,6 +33,7 @@ func init() { mountlib.AddRc(name, mount) cmdFlags := cmd.Flags() flags.BoolVarP(cmdFlags, &sudo, "sudo", "", sudo, "Use sudo to run the mount/umount commands as root.", "") + flags.StringVarP(cmdFlags, &mountPath, "nfs-mount-path", "", mountPath, "Subpath of the remote to mount via NFS (must be an existing directory).", "") nfs.AddFlags(cmdFlags) } @@ -69,7 +71,7 @@ func mount(VFS *vfs.VFS, mountpoint string, opt *mountlib.Options) (asyncerrors } cmd = append(cmd, "mount") cmd = append(cmd, options...) - cmd = append(cmd, "localhost:"+opt.VolumeName, mountpoint) + cmd = append(cmd, "localhost:"+mountPath, mountpoint) fs.Debugf(nil, "Running mount command: %q", cmd) out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() diff --git a/cmd/nfsmount/nfsmount_test.go b/cmd/nfsmount/nfsmount_test.go index 746ba9f62..f55d1d827 100644 --- a/cmd/nfsmount/nfsmount_test.go +++ b/cmd/nfsmount/nfsmount_test.go @@ -7,14 +7,19 @@ import ( "errors" "os" "os/exec" + "path/filepath" "runtime" "testing" + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/cmd/mountlib" "github.com/rclone/rclone/cmd/serve/nfs" + "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/object" "github.com/rclone/rclone/vfs" "github.com/rclone/rclone/vfs/vfscommon" "github.com/rclone/rclone/vfs/vfstest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,3 +61,53 @@ func TestMount(t *testing.T) { }) } } + +// TestSubpathMount exercises --nfs-mount-path end-to-end: a local-backed +// source has a /sub subdirectory pre-populated, the NFS server exports +// the source, and the client mounts /sub. A file written into the source +// at /sub/hello.txt must be readable through the mountpoint as ./hello.txt. +func TestSubpathMount(t *testing.T) { + if runtime.GOOS != "darwin" { + if !commandOK("sudo", "-n", "mount", "--help") { + t.Skip("Can't run sudo mount without a password") + } + if !commandOK("sudo", "-n", "umount", "--help") { + t.Skip("Can't run sudo umount without a password") + } + sudo = true + } + + // Source filesystem on disk with the expected layout. + srcDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "sub", "hello.txt"), []byte("world"), 0644)) + + ctx := context.Background() + f, err := fs.NewFs(ctx, srcDir) + require.NoError(t, err) + + nfs.Opt.HandleCacheDir = t.TempDir() + require.NoError(t, nfs.Opt.HandleCache.Set("memory")) + + vfsOpt := vfscommon.Opt + vfsOpt.CacheMode = vfscommon.CacheModeOff + V := vfs.New(ctx, f, &vfsOpt) + defer V.Shutdown() + + prevPath := mountPath + mountPath = "/sub" + defer func() { mountPath = prevPath }() + + mountpoint := t.TempDir() + opt := mountlib.Options{} + opt.SetVolumeName("nfs-subpath-test") + _, unmount, _, err := mount(V, mountpoint, &opt) + require.NoError(t, err) + defer func() { + require.NoError(t, unmount()) + }() + + data, err := os.ReadFile(filepath.Join(mountpoint, "hello.txt")) + require.NoError(t, err, "hello.txt must be visible at the mount root when mounted on /sub") + assert.Equal(t, "world", string(data)) +} diff --git a/cmd/serve/nfs/cache.go b/cmd/serve/nfs/cache.go index 278d923dc..a9fae06cc 100644 --- a/cmd/serve/nfs/cache.go +++ b/cmd/serve/nfs/cache.go @@ -60,23 +60,78 @@ type Cache interface { // Set the cache of the handler to the type required by the user func (h *Handler) getCache() (c Cache, err error) { fs.Debugf("nfs", "Starting %v handle cache", h.opt.HandleCache) + var inner Cache switch h.opt.HandleCache { case cacheMemory: - return nfshelper.NewCachingHandler(h, h.opt.HandleLimit), nil + inner = nfshelper.NewCachingHandler(h, h.opt.HandleLimit) case cacheDisk: - return newDiskHandler(h) + inner, err = newDiskHandler(h) case cacheSymlink: - dh, err := newDiskHandler(h) - if err != nil { - return nil, err + var dh *diskHandler + dh, err = newDiskHandler(h) + if err == nil { + err = dh.makeSymlinkCache() } - err = dh.makeSymlinkCache() - if err != nil { - return nil, err - } - return dh, nil + inner = dh + default: + return nil, errors.New("unknown handle cache type") } - return nil, errors.New("unknown handle cache type") + if err != nil { + return nil, err + } + return &pathRewriter{inner: inner, rootFS: h.billyFS}, nil +} + +// pathRewriter wraps a Cache so that all handles refer to absolute VFS +// paths via the root FS, regardless of which subpath mount the request +// came in through. This guarantees the same file always gets the same +// handle, matching the traditional NFS expectation that a subpath mount +// is just a "cd" into a subtree. +type pathRewriter struct { + inner Cache + rootFS *FS +} + +// translate rewrites (f, splitPath) so calls into the inner cache always +// see (rootFS, absoluteSplitPath). For the root FS the inputs are +// returned unchanged, so root-only deployments keep the existing handle +// format (and on-disk hashes) byte-for-byte. +func (c *pathRewriter) translate(f billy.Filesystem, splitPath []string) (billy.Filesystem, []string) { + rfs, ok := f.(*FS) + if !ok || rfs.root == "" { + return f, splitPath + } + trimmed := strings.Trim(rfs.root, "/") + if trimmed == "" { + return c.rootFS, splitPath + } + prefix := strings.Split(trimmed, "/") + full := make([]string, 0, len(prefix)+len(splitPath)) + full = append(full, prefix...) + full = append(full, splitPath...) + return c.rootFS, full +} + +// ToHandle takes a file and represents it with an opaque handle to reference it. +func (c *pathRewriter) ToHandle(f billy.Filesystem, splitPath []string) []byte { + f, splitPath = c.translate(f, splitPath) + return c.inner.ToHandle(f, splitPath) +} + +// FromHandle converts from an opaque handle to the file it represents +func (c *pathRewriter) FromHandle(fh []byte) (billy.Filesystem, []string, error) { + return c.inner.FromHandle(fh) +} + +// InvalidateHandle invalidates the handle passed - used on rename and delete +func (c *pathRewriter) InvalidateHandle(f billy.Filesystem, handle []byte) error { + f, _ = c.translate(f, nil) + return c.inner.InvalidateHandle(f, handle) +} + +// HandleLimit exports how many file handles can be safely stored by this cache. +func (c *pathRewriter) HandleLimit() int { + return c.inner.HandleLimit() } // diskHandler implements an on disk NFS file handle cache diff --git a/cmd/serve/nfs/cache_test.go b/cmd/serve/nfs/cache_test.go index 7eb55b657..bbde50580 100644 --- a/cmd/serve/nfs/cache_test.go +++ b/cmd/serve/nfs/cache_test.go @@ -118,7 +118,7 @@ func TestCache(t *testing.T) { defer func() { ci.LogLevel = oldLogLevel }() - billyFS := &FS{nil} // place holder billyFS + billyFS := &FS{} // place holder billyFS for _, cacheType := range []handleCache{cacheMemory, cacheDisk, cacheSymlink} { t.Run(cacheType.String(), func(t *testing.T) { h := &Handler{ @@ -170,3 +170,52 @@ func TestCache(t *testing.T) { }) } } + +// Check that a file accessed via a root mount and via a subpath mount +// returns the same handle for every cache backend. +func TestPathRewriterHandleStability(t *testing.T) { + ci := fs.GetConfig(context.Background()) + oldLogLevel := ci.LogLevel + ci.LogLevel = fs.LogLevelEmergency + defer func() { + ci.LogLevel = oldLogLevel + }() + for _, cacheType := range []handleCache{cacheMemory, cacheDisk, cacheSymlink} { + t.Run(cacheType.String(), func(t *testing.T) { + rootFS := &FS{vfs: vfs.New(context.Background(), object.MemoryFs, nil)} + h := &Handler{ + vfs: rootFS.vfs, + billyFS: rootFS, + } + h.opt.HandleLimit = 1000 + h.opt.HandleCache = cacheType + h.opt.HandleCacheDir = t.TempDir() + c, err := h.getCache() + if err == ErrorSymlinkCacheNotSupported { + t.Skip(err.Error()) + } + if err == ErrorSymlinkCacheNoPermission { + t.Skip("Need more permissions to run symlink cache tests: " + testSymlinkCache) + } + require.NoError(t, err) + + subFS := rootFS.subFS("/foo") + + rootHandle := c.ToHandle(rootFS, []string{"foo", "bar", "file"}) + subHandle := c.ToHandle(subFS, []string{"bar", "file"}) + assert.Equal(t, rootHandle, subHandle, "same file via root mount and subpath mount must yield the same handle") + + // Both handles must resolve to the absolute path on the root FS. + gotFS, gotPath, err := c.FromHandle(rootHandle) + require.NoError(t, err) + assert.Equal(t, rootFS, gotFS) + assert.Equal(t, []string{"foo", "bar", "file"}, gotPath) + + // A handle minted under a subpath mount with an empty split (mount root) + // must equal the handle minted under the root mount for the subpath itself. + mountRootHandle := c.ToHandle(subFS, []string{}) + absHandle := c.ToHandle(rootFS, []string{"foo"}) + assert.Equal(t, mountRootHandle, absHandle, "subpath mount root handle must equal the root mount handle for that path") + }) + } +} diff --git a/cmd/serve/nfs/filesystem.go b/cmd/serve/nfs/filesystem.go index a87563171..c0989a660 100644 --- a/cmd/serve/nfs/filesystem.go +++ b/cmd/serve/nfs/filesystem.go @@ -44,13 +44,30 @@ func setSys(fi os.FileInfo) { // FS is our wrapper around the VFS to properly support billy.Filesystem interface type FS struct { - vfs *vfs.VFS + vfs *vfs.VFS + root string // absolute path within the VFS this FS is rooted at; empty means VFS root +} + +// fullPath returns the absolute path within the VFS for name, which is +// expressed relative to this FS's root. +func (f *FS) fullPath(name string) string { + if f.root == "" { + return name + } + return path.Join(f.root, name) +} + +// subFS returns a new *FS rooted at root within the VFS. root must already +// be a cleaned absolute path that the caller has validated as a directory. +func (f *FS) subFS(root string) *FS { + return &FS{vfs: f.vfs, root: root} } // ReadDir implements read dir -func (f *FS) ReadDir(path string) (dir []os.FileInfo, err error) { - defer log.Trace(path, "")("items=%d, err=%v", &dir, &err) - dir, err = f.vfs.ReadDir(path) +func (f *FS) ReadDir(p string) (dir []os.FileInfo, err error) { + p = f.fullPath(p) + defer log.Trace(p, "")("items=%d, err=%v", &dir, &err) + dir, err = f.vfs.ReadDir(p) if err != nil { return nil, err } @@ -62,24 +79,28 @@ func (f *FS) ReadDir(path string) (dir []os.FileInfo, err error) { // Create implements creating new files func (f *FS) Create(filename string) (node billy.File, err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "")("%v, err=%v", &node, &err) return f.vfs.Create(filename) } // Open opens a file func (f *FS) Open(filename string) (node billy.File, err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "")("%v, err=%v", &node, &err) return f.vfs.Open(filename) } // OpenFile opens a file func (f *FS) OpenFile(filename string, flag int, perm os.FileMode) (node billy.File, err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "flag=0x%X, perm=%v", flag, perm)("%v, err=%v", &node, &err) return f.vfs.OpenFile(filename, flag, perm) } // Stat gets the file stat func (f *FS) Stat(filename string) (fi os.FileInfo, err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "")("fi=%v, err=%v", &fi, &err) fi, err = f.vfs.Stat(filename) if err != nil { @@ -91,12 +112,15 @@ func (f *FS) Stat(filename string) (fi os.FileInfo, err error) { // Rename renames a file func (f *FS) Rename(oldpath, newpath string) (err error) { + oldpath = f.fullPath(oldpath) + newpath = f.fullPath(newpath) defer log.Trace(oldpath, "newpath=%q", newpath)("err=%v", &err) return f.vfs.Rename(oldpath, newpath) } // Remove deletes a file func (f *FS) Remove(filename string) (err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "")("err=%v", &err) return f.vfs.Remove(filename) } @@ -116,11 +140,12 @@ func (f *FS) TempFile(dir, prefix string) (node billy.File, err error) { // it does not redirect to VFS.MkDirAll because that one doesn't // honor the permissions func (f *FS) MkdirAll(filename string, perm os.FileMode) (err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "perm=%v", perm)("err=%v", &err) parts := strings.Split(filename, "/") for i := range parts { current := strings.Join(parts[:i+1], "/") - _, err := f.Stat(current) + _, err := f.vfs.Stat(current) if err == vfs.ENOENT { err = f.vfs.Mkdir(current, perm) if err != nil { @@ -133,6 +158,7 @@ func (f *FS) MkdirAll(filename string, perm os.FileMode) (err error) { // Lstat gets the stats for symlink func (f *FS) Lstat(filename string) (fi os.FileInfo, err error) { + filename = f.fullPath(filename) defer log.Trace(filename, "")("fi=%v, err=%v", &fi, &err) fi, err = f.vfs.Stat(filename) if err != nil { @@ -144,18 +170,21 @@ func (f *FS) Lstat(filename string) (fi os.FileInfo, err error) { // Symlink creates a link pointing to target func (f *FS) Symlink(target, link string) (err error) { + link = f.fullPath(link) defer log.Trace(target, "link=%q", link)("err=%v", &err) return f.vfs.Symlink(target, link) } // Readlink reads the contents of link func (f *FS) Readlink(link string) (result string, err error) { + link = f.fullPath(link) defer log.Trace(link, "")("result=%q, err=%v", &result, &err) return f.vfs.Readlink(link) } // Chmod changes the file modes func (f *FS) Chmod(name string, mode os.FileMode) (err error) { + name = f.fullPath(name) defer log.Trace(name, "mode=%v", mode)("err=%v", &err) file, err := f.vfs.Open(name) if err != nil { @@ -182,6 +211,7 @@ func (f *FS) Lchown(name string, uid, gid int) (err error) { // Chown changes owner of the file func (f *FS) Chown(name string, uid, gid int) (err error) { + name = f.fullPath(name) defer log.Trace(name, "uid=%d, gid=%d", uid, gid)("err=%v", &err) file, err := f.vfs.Open(name) if err != nil { @@ -197,6 +227,7 @@ func (f *FS) Chown(name string, uid, gid int) (err error) { // Chtimes changes the access time and modified time func (f *FS) Chtimes(name string, atime time.Time, mtime time.Time) (err error) { + name = f.fullPath(name) defer log.Trace(name, "atime=%v, mtime=%v", atime, mtime)("err=%v", &err) return f.vfs.Chtimes(name, atime, mtime) } @@ -207,10 +238,13 @@ func (f *FS) Chroot(path string) (FS billy.Filesystem, err error) { return nil, os.ErrInvalid } -// Root returns the root of a VFS +// Root returns the root of a VFS func (f *FS) Root() (root string) { defer log.Trace(nil, "")("root=%q", &root) - return f.vfs.Fs().Root() + if f.root == "" { + return f.vfs.Fs().Root() + } + return path.Join(f.vfs.Fs().Root(), f.root) } // Capabilities exports the filesystem capabilities diff --git a/cmd/serve/nfs/handler.go b/cmd/serve/nfs/handler.go index 894f573d7..e16f0f2b7 100644 --- a/cmd/serve/nfs/handler.go +++ b/cmd/serve/nfs/handler.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "net" + "os" + "path" "strings" "github.com/go-git/go-billy/v5" @@ -54,9 +56,30 @@ func NewHandler(ctx context.Context, vfs *vfs.VFS, opt *Options) (handler nfs.Ha } // Mount backs Mount RPC Requests, allowing for access control policies. +// +// The requested Dirpath is interpreted as an absolute path within the VFS. +// path.Clean is used to normalise it and to neutralise any ".." segments, +// so the result is always within the VFS root. If the cleaned path is the +// VFS root the shared root filesystem is returned. Otherwise the path is +// looked up and must be a plain directory (not a regular file, symlink or +// other special node). func (h *Handler) Mount(ctx context.Context, conn net.Conn, req nfs.MountRequest) (status nfs.MountStatus, hndl billy.Filesystem, auths []nfs.AuthFlavor) { auths = []nfs.AuthFlavor{nfs.AuthFlavorNull} - return nfs.MountStatusOk, h.billyFS, auths + cleaned := path.Clean("/" + string(req.Dirpath)) + if cleaned == "/" { + return nfs.MountStatusOk, h.billyFS, auths + } + node, err := h.vfs.Stat(cleaned) + if err != nil { + fs.Infof("nfs", "Mount of %q rejected: %v", cleaned, err) + return nfs.MountStatusErrNoEnt, h.billyFS, auths + } + if node.Mode().Type() != os.ModeDir { + fs.Infof("nfs", "Mount of %q rejected: not a plain directory (mode %v)", cleaned, node.Mode()) + return nfs.MountStatusErrNotDir, h.billyFS, auths + } + fs.Infof("nfs", "Mounting subpath %q", cleaned) + return nfs.MountStatusOk, h.billyFS.subFS(cleaned), auths } // Change provides an interface for updating file attributes. diff --git a/cmd/serve/nfs/handler_test.go b/cmd/serve/nfs/handler_test.go new file mode 100644 index 000000000..cde69281c --- /dev/null +++ b/cmd/serve/nfs/handler_test.go @@ -0,0 +1,144 @@ +//go:build unix + +package nfs + +import ( + "bytes" + "context" + "io" + "testing" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + nfs "github.com/willscott/go-nfs" +) + +// newTestHandler builds a Handler backed by a writable local-filesystem VFS +// rooted in a per-test temp directory with the layout the Mount tests need. +func newTestHandler(t *testing.T) *Handler { + t.Helper() + ctx := context.Background() + f, err := fs.NewFs(ctx, t.TempDir()) + require.NoError(t, err) + + vfsOpt := vfscommon.Opt + vfsOpt.CacheMode = vfscommon.CacheModeFull + V := vfs.New(ctx, f, &vfsOpt) + t.Cleanup(V.Shutdown) + + // Layout used across the tests: + // /sub/ (directory — valid subpath mount target) + // /sub/hello.txt (file — not a valid subpath mount target) + // /sub/nested/ (directory inside the subpath) + require.NoError(t, V.Mkdir("/sub", 0755)) + require.NoError(t, V.Mkdir("/sub/nested", 0755)) + hello, err := V.Create("/sub/hello.txt") + require.NoError(t, err) + _, err = io.Copy(hello, bytes.NewReader([]byte("world"))) + require.NoError(t, err) + require.NoError(t, hello.Close()) + + h := &Handler{vfs: V, billyFS: &FS{vfs: V}} + h.opt.HandleLimit = 1000 + h.opt.HandleCache = cacheMemory + cache, err := h.getCache() + require.NoError(t, err) + h.Cache = cache + return h +} + +func TestMountHandlerRoot(t *testing.T) { + h := newTestHandler(t) + status, fsh, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte("/")}) + assert.Equal(t, nfs.MountStatusOk, status) + rfs, ok := fsh.(*FS) + require.True(t, ok) + assert.Equal(t, "", rfs.root, "root mount must return the unrooted FS") + assert.Same(t, h.billyFS, rfs) +} + +func TestMountHandlerSubpath(t *testing.T) { + h := newTestHandler(t) + for _, raw := range []string{"/sub", "/sub/", "sub", "/./sub", "/foo/../sub"} { + status, fsh, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte(raw)}) + assert.Equal(t, nfs.MountStatusOk, status, "Dirpath %q should succeed", raw) + rfs, ok := fsh.(*FS) + require.True(t, ok, "Dirpath %q", raw) + assert.Equal(t, "/sub", rfs.root, "Dirpath %q should land at /sub", raw) + + // The subpath FS should expose hello.txt at its root. + entries, err := rfs.ReadDir("") + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + assert.ElementsMatch(t, []string{"hello.txt", "nested"}, names) + } +} + +func TestMountHandlerRejects(t *testing.T) { + h := newTestHandler(t) + cases := []struct { + name string + dirpath string + status nfs.MountStatus + }{ + {"missing", "/does-not-exist", nfs.MountStatusErrNoEnt}, + {"file", "/sub/hello.txt", nfs.MountStatusErrNotDir}, + {"deep-missing", "/sub/nope/deeper", nfs.MountStatusErrNoEnt}, + // path.Clean collapses ".." past the VFS root so this becomes /etc, + // which does not exist in the VFS — proving traversal cannot escape. + {"traversal", "/../../etc", nfs.MountStatusErrNoEnt}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, fsh, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte(tc.dirpath)}) + assert.Equal(t, tc.status, status) + // onMount calls ToHandle on the returned filesystem regardless of + // status, so the handler must return a non-nil billy.Filesystem + // even on rejection. We unconditionally hand back the root FS. + assert.NotNil(t, fsh) + }) + } +} + +// Subpath operations must end up at the right absolute VFS path. We +// exercise the round-trip by writing a file via the subpath FS and +// reading it back via the root FS. +func TestMountHandlerSubpathWrites(t *testing.T) { + h := newTestHandler(t) + _, fsh, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte("/sub")}) + subFS := fsh.(*FS) + + wf, err := subFS.Create("greeting.txt") + require.NoError(t, err) + _, err = io.Copy(wf, bytes.NewReader([]byte("howdy"))) + require.NoError(t, err) + require.NoError(t, wf.Close()) + + // The same file must be visible at /sub/greeting.txt via the root VFS. + node, err := h.vfs.Stat("/sub/greeting.txt") + require.NoError(t, err) + assert.False(t, node.IsDir()) + + // And invisible at the VFS root. + _, err = h.vfs.Stat("/greeting.txt") + assert.Equal(t, vfs.ENOENT, err) +} + +// Same file via root and subpath mounts must produce the same NFS handle. +func TestMountHandlerHandleStability(t *testing.T) { + h := newTestHandler(t) + _, rootFS, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte("/")}) + _, subFS, _ := h.Mount(context.Background(), nil, nfs.MountRequest{Dirpath: []byte("/sub")}) + + rootHandle := h.ToHandle(rootFS, []string{"sub", "hello.txt"}) + subHandle := h.ToHandle(subFS, []string{"hello.txt"}) + assert.Equal(t, rootHandle, subHandle, + "a file's NFS handle must not depend on which mount reached it") +} diff --git a/cmd/serve/nfs/nfs.go b/cmd/serve/nfs/nfs.go index ed4148253..a4ca663cc 100644 --- a/cmd/serve/nfs/nfs.go +++ b/cmd/serve/nfs/nfs.go @@ -194,6 +194,21 @@ Where |$PORT| is the same port number used in the |serve nfs| command and |$HOSTNAME| is the network address of the machine that |serve nfs| was run on. +NFS clients can also mount a subdirectory of the served remote by +including it in the mount path. For example to mount only the +|photos/2024| subdirectory: + +|||sh +mount -t nfs -o port=$PORT,mountport=$PORT,tcp $HOSTNAME:/photos/2024 path/to/mountpoint +||| + +The subpath is resolved within the served remote and must refer to an +existing directory (not a file or a symlink). Subpath mounts are a +convenience equivalent to mounting |/| and changing directory: they +share access to the same underlying VFS and the same file handles, so +they do not isolate the client from siblings or parents of the mounted +subdirectory. + If |--vfs-metadata-extension| is in use then for the |--nfs-cache-type disk| and |--nfs-cache-type cache| the metadata files will have the file handle of their parent file suffixed with |0x00, 0x00, 0x00, 0x01|.