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 1/3] 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") From a37010a825ceda8bc473400796bc8bf7a7872a0b Mon Sep 17 00:00:00 2001 From: Srigovind Nayak <5201843+konidev20@users.noreply.github.com> Date: Sat, 16 May 2026 18:06:14 +0530 Subject: [PATCH 2/3] chore: update changelog for issue 5234 --- changelog/unreleased/issue-5234 | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog/unreleased/issue-5234 diff --git a/changelog/unreleased/issue-5234 b/changelog/unreleased/issue-5234 new file mode 100644 index 000000000..1374efdfe --- /dev/null +++ b/changelog/unreleased/issue-5234 @@ -0,0 +1,12 @@ +Bugfix: Prevent mounting over the repository directory + +Using a local repository directory as the `mount` target — or a path +that contains it, or that it contains — caused the FUSE server to +read its own backend files through the new mount, deadlocking the +kernel and requiring a long reboot to recover. + +Restic now resolves both paths and refuses any such overlap with a +clear error before mounting. + +https://github.com/restic/restic/issues/5234 +https://github.com/restic/restic/pull/5348 From e1e36ed848e9d2a5df82ff4b8a3433592cc61e4d Mon Sep 17 00:00:00 2001 From: Srigovind Nayak <5201843+konidev20@users.noreply.github.com> Date: Sat, 16 May 2026 18:07:42 +0530 Subject: [PATCH 3/3] doc: note that mountpoint must not overlap the repository Document the new restriction added so users encountering the error message have a reference, and so the constraint is visible before they hit it. --- doc/050_restore.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/050_restore.rst b/doc/050_restore.rst index 3c92fcc98..d388ab16c 100644 --- a/doc/050_restore.rst +++ b/doc/050_restore.rst @@ -208,6 +208,12 @@ command needs to be in the ``PATH``. On macOS, you need `FUSE-T `__ or `FUSE for macOS `__. On FreeBSD, you may need to install FUSE and load the kernel module (``kldload fuse``). +.. note:: The mountpoint must not overlap the local repository directory. + Using the repository directory itself, a subdirectory of it, or a parent + of it as the mountpoint causes the FUSE server to read its own backend + files through the new mount and deadlock the kernel. ``restic mount`` + detects this and refuses such mountpoints. + Restic supports storage and preservation of hard links. However, since hard links exist in the scope of a filesystem by definition, restoring hard links from a FUSE mount should be done by a program that preserves