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
diff --git a/cmd/restic/cmd_mount.go b/cmd/restic/cmd_mount.go
index cf521d643..6e85901dc 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")
diff --git a/doc/050_restore.rst b/doc/050_restore.rst
index ec88d55c0..2ab37e5ba 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