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")