Merge pull request #5348 from zmanda/fix-gh-5234-prevent-mount-command-over-repository

mount: prevent mounting over repository
This commit is contained in:
Michael Eischer
2026-06-12 22:27:50 +02:00
committed by GitHub
4 changed files with 147 additions and 0 deletions
+12
View File
@@ -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
+72
View File
@@ -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))
}
+57
View File
@@ -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")
+6
View File
@@ -208,6 +208,12 @@ command needs to be in the ``PATH``. On macOS, you need `FUSE-T
<https://www.fuse-t.org/>`__ or `FUSE for macOS <https://osxfuse.github.io/>`__.
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