From a0d7745e8ba97a8598e10064373504941f00db34 Mon Sep 17 00:00:00 2001 From: Srigovind Nayak <5201843+konidev20@users.noreply.github.com> Date: Sat, 16 May 2026 18:03:49 +0530 Subject: [PATCH] mount: refuse mountpoints that overlap the local repository Mounting a local repository onto its own directory caused the FUSE server to read its own backend files through the mount it had just created, deadlocking the kernel. `umount` then reported "Device or resource busy" and recovery required a reboot that took several minutes. The same shape occurs when the mountpoint is nested inside the repository directory, or when the repository directory is nested inside the mountpoint. The mount command now resolves both paths via filepath.Abs and filepath.EvalSymlinks and refuses with a fatal error if either path equals or contains the other. The check runs before the repository lock is acquired so an overlap fails fast. Only the local backend is checked; remote backends cannot shadow the mountpoint directory. --- cmd/restic/cmd_mount.go | 72 ++++++++++++++++++++++++ cmd/restic/cmd_mount_integration_test.go | 57 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/cmd/restic/cmd_mount.go b/cmd/restic/cmd_mount.go index 96f217880..b74dff9f7 100644 --- a/cmd/restic/cmd_mount.go +++ b/cmd/restic/cmd_mount.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -13,6 +14,8 @@ import ( "github.com/spf13/pflag" "golang.org/x/sys/unix" + "github.com/restic/restic/internal/backend/local" + "github.com/restic/restic/internal/backend/location" "github.com/restic/restic/internal/data" "github.com/restic/restic/internal/debug" "github.com/restic/restic/internal/errors" @@ -149,6 +152,19 @@ func runMount(ctx context.Context, opts MountOptions, gopts global.Options, args return errors.Fatal("inaccessible mountpoint") } + // Refuse to mount onto (or under, or over) the local repository directory. + // Doing so makes the FUSE server read its own backend files through the + // mount it just created, deadlocking the kernel (GH #5234). + loc, err := location.Parse(gopts.Backends, gopts.Repo) + if err != nil { + return err + } + if loc.Scheme == "local" { + if err := checkMountpointOverlap(loc.Config.(*local.Config).Path, mountpoint); err != nil { + return err + } + } + debug.Log("start mount") defer debug.Log("finish mount") @@ -230,3 +246,59 @@ func runMount(ctx context.Context, opts MountOptions, gopts global.Options, args return err } + +// checkMountpointOverlap returns an error.Fatal if the local repository at +// repoPath and the mountpoint overlap: equal paths, mountpoint nested inside +// the repo, or the repo nested inside the mountpoint. Any overlap deadlocks +// the FUSE server (GH #5234). +func checkMountpointOverlap(repoPath, mountpoint string) error { + rp, err := resolvePath(repoPath) + if err != nil { + return err + } + mp, err := resolvePath(mountpoint) + if err != nil { + return err + } + + const tail = "; refusing to mount to avoid deadlocking the FUSE server" + switch { + case rp == mp: + return errors.Fatal(fmt.Sprintf("mountpoint %s is the local repository directory%s", mp, tail)) + case isInside(rp, mp): + return errors.Fatal(fmt.Sprintf("mountpoint %s is inside the local repository directory %s%s", mp, rp, tail)) + case isInside(mp, rp): + return errors.Fatal(fmt.Sprintf("local repository directory %s is inside the mountpoint %s%s", rp, mp, tail)) + } + return nil +} + +// resolvePath returns p as an absolute, symlink-resolved path. If EvalSymlinks +// fails (e.g. the path does not fully exist), it falls back to the absolute +// form: overlap detection is best-effort and we'd rather refuse a clear +// overlap than abort on an unrelated stat error. +func resolvePath(p string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return abs, nil + } + return resolved, nil +} + +// isInside reports whether child is strictly nested inside parent. Both paths +// must already be cleaned and absolute. Equal paths return false; the caller +// handles equality separately so it can produce a distinct error message. +func isInside(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + if rel == "." || rel == ".." { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/cmd/restic/cmd_mount_integration_test.go b/cmd/restic/cmd_mount_integration_test.go index a5f4a7aef..6f5557778 100644 --- a/cmd/restic/cmd_mount_integration_test.go +++ b/cmd/restic/cmd_mount_integration_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -212,6 +213,62 @@ func TestMount(t *testing.T) { checkSnapshots(t, env.gopts, env.mountpoint, snapshotIDs, 4) } +func TestCheckMountpointOverlap(t *testing.T) { + tempdir := t.TempDir() + repo := filepath.Join(tempdir, "repo") + repoSub := filepath.Join(repo, "sub") + sibling := filepath.Join(tempdir, "mnt") + for _, d := range []string{repo, repoSub, sibling} { + rtest.OK(t, os.MkdirAll(d, 0700)) + } + + cases := []struct { + name string + repo string + mount string + wantSub string // substring of expected error; empty means expect nil + }{ + {"equal", repo, repo, "is the local repository directory"}, + {"mount inside repo", repo, repoSub, "is inside the local repository directory"}, + {"repo inside mount", repoSub, repo, "is inside the mountpoint"}, + {"disjoint", repo, sibling, ""}, + {"prefix-not-subpath", repo, repo + "-other", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rtest.OK(t, os.MkdirAll(tc.mount, 0700)) + err := checkMountpointOverlap(tc.repo, tc.mount) + if tc.wantSub == "" { + rtest.OK(t, err) + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSub) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} + +func TestCheckMountpointOverlapSymlink(t *testing.T) { + tempdir := t.TempDir() + repo := filepath.Join(tempdir, "repo") + rtest.OK(t, os.MkdirAll(repo, 0700)) + link := filepath.Join(tempdir, "link-to-repo") + rtest.OK(t, os.Symlink(repo, link)) + + err := checkMountpointOverlap(repo, link) + if err == nil { + t.Fatal("expected overlap error when mountpoint is a symlink to repo, got nil") + } + if !strings.Contains(err.Error(), "is the local repository directory") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestMountSameTimestamps(t *testing.T) { if !rtest.RunFuseTest { t.Skip("Skipping fuse tests")