diff --git a/mountinfo/mounted_linux.go b/mountinfo/mounted_linux.go index 58f13c2..febdf69 100644 --- a/mountinfo/mounted_linux.go +++ b/mountinfo/mounted_linux.go @@ -1,6 +1,7 @@ package mountinfo import ( + "errors" "os" "path/filepath" @@ -18,15 +19,20 @@ import ( // be handled separately using e.g. errors.Is(err, fs.ErrNotExist). // // This function is only available on Linux. When available (since kernel -// v5.6), openat2(2) syscall is used to reliably detect all mounts. Otherwise, -// the implementation falls back to using stat(2), which can reliably detect -// normal (but not bind) mounts. +// v5.8), statx(2) syscall is used to reliably detect all mounts, followed +// by openat2(2) (since kernel v5.6). Otherwise, the implementation falls back +// to using stat(2), which can reliably detect normal (but not bind) mounts. func MountedFast(path string) (mounted, sure bool, err error) { // Root is always mounted. if path == string(os.PathSeparator) { return true, true, nil } + // Fastest path: statx, which does not need a normalized path. + if mounted, ok := tryMountedByStatx(path); ok { + return mounted, true, nil + } + path, err = normalizePath(path) if err != nil { return false, false, err @@ -35,6 +41,48 @@ func MountedFast(path string) (mounted, sure bool, err error) { return } +var errNoMountRoot = errors.New("statx: STATX_ATTR_MOUNT_ROOT not supported") + +// mountedByStatx is a method of detecting a mount that works for all kinds +// of mounts (incl. bind mounts), but requires a recent (v5.8+) linux kernel. +// Unlike other mountedBy* methods, it does not require path to be normalized +// (symlinks are resolved by the kernel), but it must be absolute and clean. +func mountedByStatx(path string) (bool, error) { + var st unix.Statx_t + + // Use AT_NO_AUTOMOUNT to not trigger an automount: we want to know + // whether path is a mount point now, without causing any side effects + // (this is also consistent with what other methods do). + err := unix.Statx(unix.AT_FDCWD, path, unix.AT_NO_AUTOMOUNT, 0, &st) + if err != nil { + return false, &os.PathError{Op: "statx", Path: path, Err: err} + } + if st.Attributes_mask&unix.STATX_ATTR_MOUNT_ROOT == 0 { + return false, errNoMountRoot + } + return st.Attributes&unix.STATX_ATTR_MOUNT_ROOT != 0, nil +} + +// tryMountedByStatx is a wrapper around mountedByStatx which accepts any path. +// If ok is false, other methods need to be used. In particular, no errors are +// returned: in case of an error (such as non-existent path), a fallback method +// should return the error to the caller. +func tryMountedByStatx(path string) (mounted, ok bool) { + // Do not use normalizePath here as it is relatively slow, and + // statx resolves symlinks anyway. Yet, make the path absolute and + // clean, so ".." is treated the same way as by normalizePath. + path, err := filepath.Abs(path) + if err != nil { + return false, false + } + // Root is always mounted (even if it is not a mount root, e.g. chroot). + if path == string(os.PathSeparator) { + return true, true + } + mounted, err = mountedByStatx(path) + return mounted, err == nil +} + // mountedByOpenat2 is a method of detecting a mount that works for all kinds // of mounts (incl. bind mounts), but requires a recent (v5.6+) linux kernel. func mountedByOpenat2(path string) (bool, error) { @@ -87,6 +135,11 @@ func mountedFast(path string) (mounted, sure bool, err error) { } func mounted(path string) (bool, error) { + // Fastest path: statx, which does not need a normalized path. + if mounted, ok := tryMountedByStatx(path); ok { + return mounted, nil + } + path, err := normalizePath(path) if err != nil { return false, err diff --git a/mountinfo/mounted_linux_test.go b/mountinfo/mounted_linux_test.go index 50667d8..ac7a5fa 100644 --- a/mountinfo/mounted_linux_test.go +++ b/mountinfo/mounted_linux_test.go @@ -267,6 +267,18 @@ func requireOpenat2(t *testing.T) { } } +func requireStatx(t *testing.T) { + t.Helper() + if err := tryStatx(); err != nil { + t.Skipf("statx: %v (old kernel? need Linux 5.8+)", err) + } +} + +func tryStatx() error { + _, err := mountedByStatx("/") + return err +} + func tryOpenat2() error { fd, err := unix.Openat2(unix.AT_FDCWD, ".", &unix.OpenHow{Flags: unix.O_RDONLY}) if err == nil { @@ -275,7 +287,7 @@ func tryOpenat2() error { return err } -func testMountedFast(t *testing.T, path string, tc *testMount, openat2Supported bool) { +func testMountedFast(t *testing.T, path string, tc *testMount, fastSupported bool) { mounted, sure, err := MountedFast(path) if err != nil { // Got an error; is it expected? @@ -295,7 +307,7 @@ func testMountedFast(t *testing.T, path string, tc *testMount, openat2Supported return } - if openat2Supported { + if fastSupported { if mounted != tc.isMount { t.Errorf("MountedFast: expected mounted to be %v, got %v", tc.isMount, mounted) } @@ -305,7 +317,7 @@ func testMountedFast(t *testing.T, path string, tc *testMount, openat2Supported } if tc.isBind { - // For bind mounts, in case openat2 is not supported, + // For bind mounts, in case neither statx nor openat2 is supported, // sure and mounted must be false. if sure { t.Error("MountedFast: expected sure to be false for a bind mount") @@ -328,14 +340,19 @@ func testMountedFast(t *testing.T, path string, tc *testMount, openat2Supported func TestMountedBy(t *testing.T) { checked := false - openat2Supported := false + // Whether MountedFast can reliably detect bind mounts. + fastSupported := false // List of individual implementations to check. toCheck := []func(string) (bool, error){mountedByMountinfo, mountedByStat} if tryOpenat2() == nil { - openat2Supported = true + fastSupported = true toCheck = append(toCheck, mountedByOpenat2) } + if tryStatx() == nil { + fastSupported = true + toCheck = append(toCheck, mountedByStatx) + } for _, tc := range testMounts { tc := tc @@ -360,7 +377,7 @@ func TestMountedBy(t *testing.T) { } // Check the public MountedFast() function as a whole. - testMountedFast(t, m, &tc, openat2Supported) + testMountedFast(t, m, &tc, fastSupported) // Check individual mountedBy* implementations. @@ -403,7 +420,15 @@ func TestMountedBy(t *testing.T) { func TestMountedByOpenat2VsMountinfo(t *testing.T) { requireOpenat2(t) + testMountedByVsMountinfo(t, "mountedByOpenat2", mountedByOpenat2) +} + +func TestMountedByStatxVsMountinfo(t *testing.T) { + requireStatx(t) + testMountedByVsMountinfo(t, "mountedByStatx", mountedByStatx) +} +func testMountedByVsMountinfo(t *testing.T, name string, fn func(string) (bool, error)) { mounts, err := GetMounts(nil) if err != nil { t.Fatalf("GetMounts error: %v", err) @@ -416,13 +441,13 @@ func TestMountedByOpenat2VsMountinfo(t *testing.T) { // (this special case is handled by Mounted). continue } - mounted, err := mountedByOpenat2(m) + mounted, err := fn(m) if err != nil { if !errors.Is(err, os.ErrPermission) { - t.Errorf("mountedByOpenat2(%q) error: %+v", m, err) + t.Errorf("%s(%q) error: %+v", name, m, err) } } else if !mounted { - t.Errorf("mountedByOpenat2(%q): expected true, got false", m) + t.Errorf("%s(%q): expected true, got false", name, m) } } }