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|.