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