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
This commit is contained in:
Nick Craig-Wood
2026-05-24 18:09:03 +01:00
parent 761157714b
commit 04d1e2563a
8 changed files with 399 additions and 22 deletions
+4 -2
View File
@@ -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()
+55
View File
@@ -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))
}
+66 -11
View File
@@ -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
+50 -1
View File
@@ -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")
})
}
}
+41 -7
View File
@@ -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
+24 -1
View File
@@ -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.
+144
View File
@@ -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")
}
+15
View File
@@ -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|.