mirror of
https://github.com/restic/restic.git
synced 2026-06-17 22:24:17 +00:00
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.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user