From 2dfdfc9da405c84570007562f36893dbae63d839 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Sat, 30 Aug 2025 00:19:12 +1000 Subject: [PATCH 1/4] gocompat: move compatibility shims to internal subpackage This also lets us rename them and remove the nolint:revive lines. Signed-off-by: Aleksa Sarai --- gocompat_generics_go121.go | 30 ---------------- internal/gocompat/README.md | 10 ++++++ internal/gocompat/doc.go | 13 +++++++ .../gocompat/gocompat_errors_go120.go | 6 ++-- .../gocompat/gocompat_errors_test.go | 4 +-- .../gocompat/gocompat_errors_unsupported.go | 6 ++-- internal/gocompat/gocompat_generics_go121.go | 34 +++++++++++++++++++ .../gocompat/gocompat_generics_unsupported.go | 25 ++++++++------ lookup_linux.go | 4 ++- lookup_linux_test.go | 30 ++++++++-------- mkdir_linux.go | 6 ++-- openat2_linux.go | 4 ++- procfs_linux.go | 14 ++++---- procfs_lookup_linux.go | 4 ++- 14 files changed, 117 insertions(+), 73 deletions(-) delete mode 100644 gocompat_generics_go121.go create mode 100644 internal/gocompat/README.md create mode 100644 internal/gocompat/doc.go rename gocompat_errors_go120.go => internal/gocompat/gocompat_errors_go120.go (76%) rename gocompat_errors_test.go => internal/gocompat/gocompat_errors_test.go (92%) rename gocompat_errors_unsupported.go => internal/gocompat/gocompat_errors_unsupported.go (84%) create mode 100644 internal/gocompat/gocompat_generics_go121.go rename gocompat_generics_unsupported.go => internal/gocompat/gocompat_generics_unsupported.go (68%) diff --git a/gocompat_generics_go121.go b/gocompat_generics_go121.go deleted file mode 100644 index b0e5d36..0000000 --- a/gocompat_generics_go121.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && go1.21 - -// Copyright (C) 2024 SUSE LLC. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package securejoin - -import ( - "slices" - "sync" -) - -func slices_DeleteFunc[S ~[]E, E any](slice S, delFn func(E) bool) S { //nolint:revive // name is meant to mirror stdlib - return slices.DeleteFunc(slice, delFn) -} - -func slices_Contains[S ~[]E, E comparable](slice S, val E) bool { //nolint:revive // name is meant to mirror stdlib - return slices.Contains(slice, val) -} - -func slices_Clone[S ~[]E, E any](slice S) S { //nolint:revive // name is meant to mirror stdlib - return slices.Clone(slice) -} - -func sync_OnceValue[T any](f func() T) func() T { //nolint:revive // name is meant to mirror stdlib - return sync.OnceValue(f) -} diff --git a/internal/gocompat/README.md b/internal/gocompat/README.md new file mode 100644 index 0000000..5dcb6ae --- /dev/null +++ b/internal/gocompat/README.md @@ -0,0 +1,10 @@ +## gocompat ## + +This directory contains backports of stdlib functions from later Go versions so +the filepath-securejoin can continue to be used by projects that are stuck with +Go 1.18 support. Note that often filepath-securejoin is added in security +patches for old releases, so avoiding the need to bump Go compiler requirements +is a huge plus to downstreams. + +The source code is licensed under the same license as the Go stdlib. See the +source files for the precise license information. diff --git a/internal/gocompat/doc.go b/internal/gocompat/doc.go new file mode 100644 index 0000000..4b1803f --- /dev/null +++ b/internal/gocompat/doc.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build linux && go1.20 + +// Copyright (C) 2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package gocompat includes compatibility shims (backported from future Go +// stdlib versions) to permit filepath-securejoin to be used with older Go +// versions (often filepath-securejoin is added in security patches for old +// releases, so avoiding the need to bump Go compiler requirements is a huge +// plus to downstreams). +package gocompat diff --git a/gocompat_errors_go120.go b/internal/gocompat/gocompat_errors_go120.go similarity index 76% rename from gocompat_errors_go120.go rename to internal/gocompat/gocompat_errors_go120.go index c6b1829..4a114bd 100644 --- a/gocompat_errors_go120.go +++ b/internal/gocompat/gocompat_errors_go120.go @@ -5,15 +5,15 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package securejoin +package gocompat import ( "fmt" ) -// wrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except +// WrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except // that on pre-1.20 Go versions only errors.Is() works properly (errors.Unwrap) // is only guaranteed to give you baseErr. -func wrapBaseError(baseErr, extraErr error) error { +func WrapBaseError(baseErr, extraErr error) error { return fmt.Errorf("%w: %w", extraErr, baseErr) } diff --git a/gocompat_errors_test.go b/internal/gocompat/gocompat_errors_test.go similarity index 92% rename from gocompat_errors_test.go rename to internal/gocompat/gocompat_errors_test.go index ee4114e..a5b1ec3 100644 --- a/gocompat_errors_test.go +++ b/internal/gocompat/gocompat_errors_test.go @@ -6,7 +6,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package securejoin +package gocompat import ( "errors" @@ -20,7 +20,7 @@ func TestGoCompatErrorWrap(t *testing.T) { baseErr := errors.New("base error") extraErr := errors.New("extra error") - err := wrapBaseError(baseErr, extraErr) + err := WrapBaseError(baseErr, extraErr) require.Error(t, err) assert.ErrorIs(t, err, baseErr, "wrapped error should contain base error") //nolint:testifylint // we are testing error behaviour directly diff --git a/gocompat_errors_unsupported.go b/internal/gocompat/gocompat_errors_unsupported.go similarity index 84% rename from gocompat_errors_unsupported.go rename to internal/gocompat/gocompat_errors_unsupported.go index c4c8eb3..3061016 100644 --- a/gocompat_errors_unsupported.go +++ b/internal/gocompat/gocompat_errors_unsupported.go @@ -6,7 +6,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package securejoin +package gocompat import ( "fmt" @@ -29,10 +29,10 @@ func (err wrappedError) Error() string { return fmt.Sprintf("%v: %v", err.isError, err.inner) } -// wrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except +// WrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except // that on pre-1.20 Go versions only errors.Is() works properly (errors.Unwrap) // is only guaranteed to give you baseErr. -func wrapBaseError(baseErr, extraErr error) error { +func WrapBaseError(baseErr, extraErr error) error { return wrappedError{ inner: baseErr, isError: extraErr, diff --git a/internal/gocompat/gocompat_generics_go121.go b/internal/gocompat/gocompat_generics_go121.go new file mode 100644 index 0000000..6587e2c --- /dev/null +++ b/internal/gocompat/gocompat_generics_go121.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && go1.21 + +// Copyright (C) 2024-2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gocompat + +import ( + "slices" + "sync" +) + +// SlicesDeleteFunc is equivalent to Go 1.21's slices.DeleteFunc. +func SlicesDeleteFunc[S ~[]E, E any](slice S, delFn func(E) bool) S { + return slices.DeleteFunc(slice, delFn) +} + +// SlicesContains is equivalent to Go 1.21's slices.Contains. +func SlicesContains[S ~[]E, E comparable](slice S, val E) bool { + return slices.Contains(slice, val) +} + +// SlicesClone is equivalent to Go 1.21's slices.Clone. +func SlicesClone[S ~[]E, E any](slice S) S { + return slices.Clone(slice) +} + +// SyncOnceValue is equivalent to Go 1.21's sync.OnceValue. +func SyncOnceValue[T any](f func() T) func() T { + return sync.OnceValue(f) +} diff --git a/gocompat_generics_unsupported.go b/internal/gocompat/gocompat_generics_unsupported.go similarity index 68% rename from gocompat_generics_unsupported.go rename to internal/gocompat/gocompat_generics_unsupported.go index dd02cd1..2ef03a0 100644 --- a/gocompat_generics_unsupported.go +++ b/internal/gocompat/gocompat_generics_unsupported.go @@ -3,11 +3,11 @@ //go:build linux && !go1.21 // Copyright (C) 2021, 2022 The Go Authors. All rights reserved. -// Copyright (C) 2024 SUSE LLC. All rights reserved. +// Copyright (C) 2024-2025 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package securejoin +package gocompat import ( "sync" @@ -18,7 +18,7 @@ import ( // borrowed directly from the stdlib, and a few are modified to be "obviously // correct" without needing to copy too many other helpers. -// clearSlice is equivalent to the builtin clear from Go 1.21. +// clearSlice is equivalent to Go 1.21's builtin clear. // Copied from the Go 1.24 stdlib implementation. func clearSlice[S ~[]E, E any](slice S) { var zero E @@ -27,8 +27,9 @@ func clearSlice[S ~[]E, E any](slice S) { } } +// slicesIndexFunc is equivalent to Go 1.21's slices.IndexFunc. // Copied from the Go 1.24 stdlib implementation. -func slices_IndexFunc[S ~[]E, E any](s S, f func(E) bool) int { //nolint:revive // name is meant to mirror stdlib +func slicesIndexFunc[S ~[]E, E any](s S, f func(E) bool) int { for i := range s { if f(s[i]) { return i @@ -37,9 +38,10 @@ func slices_IndexFunc[S ~[]E, E any](s S, f func(E) bool) int { //nolint:revive return -1 } +// SlicesDeleteFunc is equivalent to Go 1.21's slices.DeleteFunc. // Copied from the Go 1.24 stdlib implementation. -func slices_DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S { //nolint:revive // name is meant to mirror stdlib - i := slices_IndexFunc(s, del) +func SlicesDeleteFunc[S ~[]E, E any](s S, del func(E) bool) S { + i := slicesIndexFunc(s, del) if i == -1 { return s } @@ -54,14 +56,16 @@ func slices_DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S { //nolint:revive return s[:i] } +// SlicesContains is equivalent to Go 1.21's slices.Contains. // Similar to the stdlib slices.Contains, except that we don't have // slices.Index so we need to use slices.IndexFunc for this non-Func helper. -func slices_Contains[S ~[]E, E comparable](s S, v E) bool { //nolint:revive // name is meant to mirror stdlib - return slices_IndexFunc(s, func(e E) bool { return e == v }) >= 0 +func SlicesContains[S ~[]E, E comparable](s S, v E) bool { + return slicesIndexFunc(s, func(e E) bool { return e == v }) >= 0 } +// SlicesClone is equivalent to Go 1.21's slices.Clone. // Copied from the Go 1.24 stdlib implementation. -func slices_Clone[S ~[]E, E any](s S) S { //nolint:revive // name is meant to mirror stdlib +func SlicesClone[S ~[]E, E any](s S) S { // Preserve nil in case it matters. if s == nil { return nil @@ -69,8 +73,9 @@ func slices_Clone[S ~[]E, E any](s S) S { //nolint:revive // name is meant to mi return append(S([]E{}), s...) } +// SyncOnceValue is equivalent to Go 1.21's sync.OnceValue. // Copied from the Go 1.24 stdlib implementation. -func sync_OnceValue[T any](f func() T) func() T { //nolint:revive // name is meant to mirror stdlib +func SyncOnceValue[T any](f func() T) func() T { var ( once sync.Once valid bool diff --git a/lookup_linux.go b/lookup_linux.go index 31419b4..c53b0e2 100644 --- a/lookup_linux.go +++ b/lookup_linux.go @@ -20,6 +20,8 @@ import ( "strings" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) type symlinkStackEntry struct { @@ -117,7 +119,7 @@ func (s *symlinkStack) push(dir *os.File, remainingPath, linkTarget string) erro return nil } // Split the link target and clean up any "" parts. - linkTargetParts := slices_DeleteFunc( + linkTargetParts := gocompat.SlicesDeleteFunc( strings.Split(linkTarget, "/"), func(part string) bool { return part == "" || part == "." }) diff --git a/lookup_linux_test.go b/lookup_linux_test.go index 0afc342..ff7b609 100644 --- a/lookup_linux_test.go +++ b/lookup_linux_test.go @@ -22,6 +22,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) type partialLookupFunc func(root *os.File, unsafePath string) (*os.File, string, error) @@ -475,36 +477,36 @@ func TestPartialLookup_RacingRename(t *testing.T) { allowedResults []lookupResult }{ // Swap a symlink in and out. - "swap-dir-link1-basic": {"a/b", "b-link", "a/b/c/d/e", nil, slices_Clone(defaultExpected)}, - "swap-dir-link2-basic": {"a/b/c", "c-link", "a/b/c/d/e", nil, slices_Clone(defaultExpected)}, - "swap-dir-link1-dotdot1": {"a/b", "b-link", "a/b/../b/../b/../b/../b/../b/../b/c/d/../d/../d/../d/../d/../d/e", nil, slices_Clone(defaultExpected)}, - "swap-dir-link1-dotdot2": {"a/b", "b-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, slices_Clone(defaultExpected)}, - "swap-dir-link2-dotdot": {"a/b/c", "c-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, slices_Clone(defaultExpected)}, + "swap-dir-link1-basic": {"a/b", "b-link", "a/b/c/d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-link2-basic": {"a/b/c", "c-link", "a/b/c/d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-link1-dotdot1": {"a/b", "b-link", "a/b/../b/../b/../b/../b/../b/../b/c/d/../d/../d/../d/../d/../d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-link1-dotdot2": {"a/b", "b-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-link2-dotdot": {"a/b/c", "c-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, gocompat.SlicesClone(defaultExpected)}, // TODO: Swap a directory. // Swap a non-directory. "swap-dir-file-basic": {"a/b", "file", "a/b/c/d/e", []error{unix.ENOTDIR, unix.ENOENT}, append( // We could hit one of the final paths. - slices_Clone(defaultExpected), + gocompat.SlicesClone(defaultExpected), // We could hit the file and stop resolving. lookupResult{handlePath: "/file", remainingPath: "c/d/e", fileType: unix.S_IFREG}, )}, "swap-dir-file-dotdot": {"a/b", "file", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", []error{unix.ENOTDIR, unix.ENOENT}, append( // We could hit one of the final paths. - slices_Clone(defaultExpected), + gocompat.SlicesClone(defaultExpected), // We could hit the file and stop resolving. lookupResult{handlePath: "/file", remainingPath: "c/d/e", fileType: unix.S_IFREG}, )}, // Swap a dangling symlink. - "swap-dir-danglinglink-basic": {"a/b", "bad-link", "a/b/c/d/e", []error{unix.ENOENT}, slices_Clone(defaultExpected)}, - "swap-dir-danglinglink-dotdot": {"a/b", "bad-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", []error{unix.ENOENT}, slices_Clone(defaultExpected)}, + "swap-dir-danglinglink-basic": {"a/b", "bad-link", "a/b/c/d/e", []error{unix.ENOENT}, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-danglinglink-dotdot": {"a/b", "bad-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", []error{unix.ENOENT}, gocompat.SlicesClone(defaultExpected)}, // Swap the root. - "swap-root-basic": {".", "../outsideroot", "a/b/c/d/e", nil, slices_Clone(defaultExpected)}, - "swap-root-dotdot": {".", "../outsideroot", "a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/c/d/e", nil, slices_Clone(defaultExpected)}, - "swap-root-dotdot-extra": {".", "../outsideroot", "a/" + strings.Repeat("b/c/d/../../../", 10) + "b/c/d/e", nil, slices_Clone(defaultExpected)}, + "swap-root-basic": {".", "../outsideroot", "a/b/c/d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-root-dotdot": {".", "../outsideroot", "a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/c/d/e", nil, gocompat.SlicesClone(defaultExpected)}, + "swap-root-dotdot-extra": {".", "../outsideroot", "a/" + strings.Repeat("b/c/d/../../../", 10) + "b/c/d/e", nil, gocompat.SlicesClone(defaultExpected)}, // Swap one of our walking paths outside the root. "swap-dir-outsideroot-basic": {"a/b", "../outsideroot", "a/b/c/d/e", nil, append( // We could hit the expected path. - slices_Clone(defaultExpected), + gocompat.SlicesClone(defaultExpected), // We could also land in the "outsideroot" path. This is okay // because there was a moment when this directory was inside // the root, and the attacker moved it outside the root. If we @@ -516,7 +518,7 @@ func TestPartialLookup_RacingRename(t *testing.T) { )}, "swap-dir-outsideroot-dotdot": {"a/b", "../outsideroot", "a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/c/d/e", nil, append( // We could hit the expected path. - slices_Clone(defaultExpected), + gocompat.SlicesClone(defaultExpected), // We could also land in the "outsideroot" path. This is okay // because there was a moment when this directory was inside // the root, and the attacker moved it outside the root. diff --git a/mkdir_linux.go b/mkdir_linux.go index 0ca1311..5b9b1c6 100644 --- a/mkdir_linux.go +++ b/mkdir_linux.go @@ -19,6 +19,8 @@ import ( "strings" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) var ( @@ -124,7 +126,7 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (_ *os.F } remainingParts := strings.Split(remainingPath, string(filepath.Separator)) - if slices_Contains(remainingParts, "..") { + if gocompat.SlicesContains(remainingParts, "..") { // The path contained ".." components after the end of the "real" // components. We could try to safely resolve ".." here but that would // add a bunch of extra logic for something that it's not clear even @@ -160,7 +162,7 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (_ *os.F // multiple %w verbs for this wrapping. For now we need to use a // compatibility shim for older Go versions. // err = fmt.Errorf("%w (%w)", err, deadErr) - err = wrapBaseError(err, deadErr) + err = gocompat.WrapBaseError(err, deadErr) } return nil, err } diff --git a/openat2_linux.go b/openat2_linux.go index 4b668a1..998d02e 100644 --- a/openat2_linux.go +++ b/openat2_linux.go @@ -20,9 +20,11 @@ import ( "strings" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) -var hasOpenat2 = sync_OnceValue(func() bool { +var hasOpenat2 = gocompat.SyncOnceValue(func() bool { fd, err := unix.Openat2(unix.AT_FDCWD, ".", &unix.OpenHow{ Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_IN_ROOT, diff --git a/procfs_linux.go b/procfs_linux.go index 45e65e1..ae492b5 100644 --- a/procfs_linux.go +++ b/procfs_linux.go @@ -19,6 +19,8 @@ import ( "strconv" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) func fstat(f *os.File) (unix.Stat_t, error) { @@ -71,7 +73,7 @@ func verifyProcRoot(procRoot *os.File) error { return nil } -var hasNewMountAPI = sync_OnceValue(func() bool { +var hasNewMountAPI = gocompat.SyncOnceValue(func() bool { // All of the pieces of the new mount API we use (fsopen, fsconfig, // fsmount, open_tree) were added together in Linux 5.1[1,2], so we can // just check for one of the syscalls and the others should also be @@ -127,7 +129,7 @@ type procfsFeatures struct { hasSubsetPid bool } -var getProcfsFeatures = sync_OnceValue(func() procfsFeatures { +var getProcfsFeatures = gocompat.SyncOnceValue(func() procfsFeatures { if !hasNewMountAPI() { return procfsFeatures{} } @@ -242,7 +244,7 @@ func getProcRoot(subset bool) (*os.File, error) { return procRoot, err } -var hasProcThreadSelf = sync_OnceValue(func() bool { +var hasProcThreadSelf = gocompat.SyncOnceValue(func() bool { return unix.Access("/proc/thread-self/", unix.F_OK) == nil }) @@ -269,7 +271,7 @@ func procOpen(procRoot *os.File, subpath string) (*os.File, error) { // multiple %w verbs for this wrapping. For now we need to use a // compatibility shim for older Go versions. // err = fmt.Errorf("%w: %w", errUnsafeProcfs, err) - return nil, wrapBaseError(err, errUnsafeProcfs) + return nil, gocompat.WrapBaseError(err, errUnsafeProcfs) } return handle, nil } @@ -396,7 +398,7 @@ const ( wantStatxMntMask = _STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID ) -var hasStatxMountID = sync_OnceValue(func() bool { +var hasStatxMountID = gocompat.SyncOnceValue(func() bool { var stx unix.Statx_t err := unix.Statx(-int(unix.EBADF), "/", 0, wantStatxMntMask, &stx) return err == nil && stx.Mask&wantStatxMntMask != 0 @@ -420,7 +422,7 @@ func getMountID(dir *os.File, path string) (uint64, error) { // multiple %w verbs for this wrapping. For now we need to use a // compatibility shim for older Go versions. // err = fmt.Errorf("%w: could not get mount id: %w", errUnsafeProcfs, err) - err = wrapBaseError(fmt.Errorf("could not get mount id: %w", err), errUnsafeProcfs) + err = gocompat.WrapBaseError(fmt.Errorf("could not get mount id: %w", err), errUnsafeProcfs) } if err != nil { return 0, &os.PathError{Op: "statx(STATX_MNT_ID_...)", Path: fullPath, Err: err} diff --git a/procfs_lookup_linux.go b/procfs_lookup_linux.go index 4011171..a26b9aa 100644 --- a/procfs_lookup_linux.go +++ b/procfs_lookup_linux.go @@ -23,6 +23,8 @@ import ( "strings" "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" ) // procfsLookupInRoot is a stripped down version of completeLookupInRoot, @@ -88,7 +90,7 @@ func procfsLookupInRoot(procRoot *os.File, unsafePath string) (Handle *os.File, // multiple %w verbs for this wrapping. For now we need to use a // compatibility shim for older Go versions. // err = fmt.Errorf("%w: %w", errUnsafeProcfs, err) - return nil, wrapBaseError(err, errUnsafeProcfs) + return nil, gocompat.WrapBaseError(err, errUnsafeProcfs) } return handle, nil } From 7618c019e56d6665dc2bafd0f10c1b6182add8e9 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Sat, 30 Aug 2025 00:42:08 +1000 Subject: [PATCH 2/4] gocompat: import Go 1.25's sync.OnceValue{,s} We need sync.OnceValues again, but while we're at it lets get the slightly performance optimised versions from Go 1.25. Signed-off-by: Aleksa Sarai --- internal/gocompat/gocompat_generics_go121.go | 5 ++ .../gocompat/gocompat_generics_unsupported.go | 70 ++++++++++++++----- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/internal/gocompat/gocompat_generics_go121.go b/internal/gocompat/gocompat_generics_go121.go index 6587e2c..43e927e 100644 --- a/internal/gocompat/gocompat_generics_go121.go +++ b/internal/gocompat/gocompat_generics_go121.go @@ -32,3 +32,8 @@ func SlicesClone[S ~[]E, E any](slice S) S { func SyncOnceValue[T any](f func() T) func() T { return sync.OnceValue(f) } + +// SyncOnceValues is equivalent to Go 1.21's sync.OnceValues. +func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + return sync.OnceValues(f) +} diff --git a/internal/gocompat/gocompat_generics_unsupported.go b/internal/gocompat/gocompat_generics_unsupported.go index 2ef03a0..d543899 100644 --- a/internal/gocompat/gocompat_generics_unsupported.go +++ b/internal/gocompat/gocompat_generics_unsupported.go @@ -74,30 +74,66 @@ func SlicesClone[S ~[]E, E any](s S) S { } // SyncOnceValue is equivalent to Go 1.21's sync.OnceValue. -// Copied from the Go 1.24 stdlib implementation. +// Copied from the Go 1.25 stdlib implementation. func SyncOnceValue[T any](f func() T) func() T { - var ( + // Use a struct so that there's a single heap allocation. + d := struct { + f func() T once sync.Once valid bool p any result T - ) - g := func() { - defer func() { - p = recover() - if !valid { - panic(p) - } - }() - result = f() - f = nil - valid = true + }{ + f: f, } return func() T { - once.Do(g) - if !valid { - panic(p) + d.once.Do(func() { + defer func() { + d.f = nil + d.p = recover() + if !d.valid { + panic(d.p) + } + }() + d.result = d.f() + d.valid = true + }) + if !d.valid { + panic(d.p) + } + return d.result + } +} + +// SyncOnceValues is equivalent to Go 1.21's sync.OnceValues. +// Copied from the Go 1.25 stdlib implementation. +func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + // Use a struct so that there's a single heap allocation. + d := struct { + f func() (T1, T2) + once sync.Once + valid bool + p any + r1 T1 + r2 T2 + }{ + f: f, + } + return func() (T1, T2) { + d.once.Do(func() { + defer func() { + d.f = nil + d.p = recover() + if !d.valid { + panic(d.p) + } + }() + d.r1, d.r2 = d.f() + d.valid = true + }) + if !d.valid { + panic(d.p) } - return result + return d.r1, d.r2 } } From 069d5ffe0638668d8d524e41acbbc5db6f888edc Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Fri, 29 Aug 2025 17:53:26 +1000 Subject: [PATCH 3/4] internal: add kernelversion package This was originally intended to be a simple copy of runc's internal kernelversion package (which was derived from containerd, in turn derived from Docker). However, the runc one is quite over-complicated and uses quite confusing terminology and so I went ahead and wrote a more generic but less complicated version. It is very loosely based on the Go stdlib's kernelversion but completely rewritten and extended to support more than 2-digit kernel versions (for 2.6.x support) and with quite a few added tests. Signed-off-by: Aleksa Sarai --- internal/gocompat/gocompat_generics_go121.go | 14 ++ .../gocompat/gocompat_generics_unsupported.go | 50 ++++++- internal/kernelversion/kernel_linux.go | 123 ++++++++++++++++++ internal/kernelversion/kernel_linux_test.go | 101 ++++++++++++++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 internal/kernelversion/kernel_linux.go create mode 100644 internal/kernelversion/kernel_linux_test.go diff --git a/internal/gocompat/gocompat_generics_go121.go b/internal/gocompat/gocompat_generics_go121.go index 43e927e..d4a9381 100644 --- a/internal/gocompat/gocompat_generics_go121.go +++ b/internal/gocompat/gocompat_generics_go121.go @@ -9,6 +9,7 @@ package gocompat import ( + "cmp" "slices" "sync" ) @@ -37,3 +38,16 @@ func SyncOnceValue[T any](f func() T) func() T { func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { return sync.OnceValues(f) } + +// CmpOrdered is equivalent to Go 1.21's cmp.Ordered generic type definition. +type CmpOrdered = cmp.Ordered + +// CmpCompare is equivalent to Go 1.21's cmp.Compare. +func CmpCompare[T CmpOrdered](x, y T) int { + return cmp.Compare(x, y) +} + +// Max2 is equivalent to Go 1.21's max builtin (but only for two parameters). +func Max2[T CmpOrdered](x, y T) T { + return max(x, y) +} diff --git a/internal/gocompat/gocompat_generics_unsupported.go b/internal/gocompat/gocompat_generics_unsupported.go index d543899..0ea6218 100644 --- a/internal/gocompat/gocompat_generics_unsupported.go +++ b/internal/gocompat/gocompat_generics_unsupported.go @@ -5,7 +5,7 @@ // Copyright (C) 2021, 2022 The Go Authors. All rights reserved. // Copyright (C) 2024-2025 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. +// license that can be found in the LICENSE.BSD file. package gocompat @@ -137,3 +137,51 @@ func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { return d.r1, d.r2 } } + +// CmpOrdered is equivalent to Go 1.21's cmp.Ordered generic type definition. +// Copied from the Go 1.25 stdlib implementation. +type CmpOrdered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | + ~string +} + +// isNaN reports whether x is a NaN without requiring the math package. +// This will always return false if T is not floating-point. +// Copied from the Go 1.25 stdlib implementation. +func isNaN[T CmpOrdered](x T) bool { + return x != x +} + +// CmpCompare is equivalent to Go 1.21's cmp.Compare. +// Copied from the Go 1.25 stdlib implementation. +func CmpCompare[T CmpOrdered](x, y T) int { + xNaN := isNaN(x) + yNaN := isNaN(y) + if xNaN { + if yNaN { + return 0 + } + return -1 + } + if yNaN { + return +1 + } + if x < y { + return -1 + } + if x > y { + return +1 + } + return 0 +} + +// Max2 is equivalent to Go 1.21's max builtin for two parameters. +func Max2[T CmpOrdered](x, y T) T { + m := x + if y > m { + m = y + } + return m +} diff --git a/internal/kernelversion/kernel_linux.go b/internal/kernelversion/kernel_linux.go new file mode 100644 index 0000000..6756bf1 --- /dev/null +++ b/internal/kernelversion/kernel_linux.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2022 The Go Authors. All rights reserved. +// Copyright (C) 2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE.BSD file. + +// The parsing logic is very loosely based on the Go stdlib's +// src/internal/syscall/unix/kernel_version_linux.go but with an API that looks +// a bit like runc's libcontainer/system/kernelversion. +// +// TODO(cyphar): This API has been copied around to a lot of different projects +// (Docker, containerd, runc, and now filepath-securejoin) -- maybe we should +// put it in a separate project? + +// Package kernelversion provides a simple mechanism for checking whether the +// running kernel is at least as new as some baseline kernel version. This is +// often useful when checking for features that would be too complicated to +// test support for (or in cases where we know that some kernel features in +// backport-heavy kernels are broken and need to be avoided). +package kernelversion + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/gocompat" +) + +// KernelVersion is a numeric representation of the key numerical elements of a +// kernel version (for instance, "4.1.2-default-1" would be represented as +// KernelVersion{4, 1, 2}). +type KernelVersion []uint64 + +func (kver KernelVersion) String() string { + var str strings.Builder + for idx, elem := range kver { + if idx != 0 { + _, _ = str.WriteRune('.') + } + _, _ = str.WriteString(strconv.FormatUint(elem, 10)) + } + return str.String() +} + +var errInvalidKernelVersion = errors.New("invalid kernel version") + +// parseKernelVersion parses a string and creates a KernelVersion based on it. +func parseKernelVersion(kverStr string) (KernelVersion, error) { + kver := make(KernelVersion, 1, 3) + for idx, ch := range kverStr { + if '0' <= ch && ch <= '9' { + v := &kver[len(kver)-1] + *v = (*v * 10) + uint64(ch-'0') + } else { + if idx == 0 || kverStr[idx-1] < '0' || '9' < kverStr[idx-1] { + // "." must be preceded by a digit while in version section + return nil, fmt.Errorf("%w %q: kernel version has dot(s) followed by non-digit in version section", errInvalidKernelVersion, kverStr) + } + if ch != '.' { + break + } + kver = append(kver, 0) + } + } + if len(kver) < 2 { + return nil, fmt.Errorf("%w %q: kernel versions must contain at least two components", errInvalidKernelVersion, kverStr) + } + return kver, nil +} + +// getKernelVersion gets the current kernel version. +var getKernelVersion = gocompat.SyncOnceValues(func() (KernelVersion, error) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return nil, err + } + // Remove the \x00 from the release. + release := uts.Release[:] + return parseKernelVersion(string(release[:bytes.IndexByte(release, 0)])) +}) + +// GreaterEqualThan returns true if the the host kernel version is greater than +// or equal to the provided [KernelVersion]. When doing this comparison, any +// non-numerical suffixes of the host kernel version are ignored. +// +// If the number of components provided is not equal to the number of numerical +// components of the host kernel version, any missing components are treated as +// 0. This means that GreaterEqualThan(KernelVersion{4}) will be treated the +// same as GreaterEqualThan(KernelVersion{4, 0, 0, ..., 0, 0}), and that if the +// host kernel version is "4" then GreaterEqualThan(KernelVersion{4, 1}) will +// return false (because the host version will be treated as "4.0"). +func GreaterEqualThan(wantKver KernelVersion) (bool, error) { + hostKver, err := getKernelVersion() + if err != nil { + return false, err + } + + // Pad out the kernel version lengths to match one another. + cmpLen := gocompat.Max2(len(hostKver), len(wantKver)) + hostKver = append(hostKver, make(KernelVersion, cmpLen-len(hostKver))...) + wantKver = append(wantKver, make(KernelVersion, cmpLen-len(wantKver))...) + + for i := 0; i < cmpLen; i++ { + switch gocompat.CmpCompare(hostKver[i], wantKver[i]) { + case -1: + // host < want + return false, nil + case +1: + // host > want + return true, nil + case 0: + continue + } + } + // equal version values + return true, nil +} diff --git a/internal/kernelversion/kernel_linux_test.go b/internal/kernelversion/kernel_linux_test.go new file mode 100644 index 0000000..694e2e7 --- /dev/null +++ b/internal/kernelversion/kernel_linux_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE.BSD file. + +package kernelversion + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetKernelVersion(t *testing.T) { + version, err := getKernelVersion() + require.NoError(t, err) + assert.GreaterOrEqualf(t, len(version), 2, "KernelVersion %#v must have at least 2 elements", version) +} + +func TestParseKernelVersion(t *testing.T) { + for _, test := range []struct { + kverStr string + expected KernelVersion + expectedErr error + }{ + // <2 components + {"", nil, errInvalidKernelVersion}, + {"dummy", nil, errInvalidKernelVersion}, + {"1", nil, errInvalidKernelVersion}, + {"420", nil, errInvalidKernelVersion}, + // >=2 components + {"3.7", KernelVersion{3, 7}, nil}, + {"3.8", KernelVersion{3, 8}, nil}, + {"3.8.0", KernelVersion{3, 8, 0}, nil}, + {"3.8.12", KernelVersion{3, 8, 12}, nil}, + {"3.8.12.10.0.2", KernelVersion{3, 8, 12, 10, 0, 2}, nil}, + {"42.12.1000", KernelVersion{42, 12, 1000}, nil}, + // suffix + {"2.6.16foobar", KernelVersion{2, 6, 16}, nil}, + {"2.6.16f00b4r", KernelVersion{2, 6, 16}, nil}, + {"3.8.16-generic", KernelVersion{3, 8, 16}, nil}, + {"6.12.0-1-default", KernelVersion{6, 12, 0}, nil}, + {"4.9.27-default-foo.12.23", KernelVersion{4, 9, 27}, nil}, + // invalid version section + {"-1.2", nil, errInvalidKernelVersion}, + {"3a", nil, errInvalidKernelVersion}, + {"3.a", nil, errInvalidKernelVersion}, + {"3.4.a", nil, errInvalidKernelVersion}, + {"a", nil, errInvalidKernelVersion}, + {"aa", nil, errInvalidKernelVersion}, + {"a.a", nil, errInvalidKernelVersion}, + {"a.a.a", nil, errInvalidKernelVersion}, + {"-3.1", nil, errInvalidKernelVersion}, + {"-3.", nil, errInvalidKernelVersion}, + {"1.-foo", nil, errInvalidKernelVersion}, + {".1", nil, errInvalidKernelVersion}, + {".1.2", nil, errInvalidKernelVersion}, + } { + test := test // copy iterator + t.Run(test.kverStr, func(t *testing.T) { + kver, err := parseKernelVersion(test.kverStr) + if test.expectedErr != nil { + require.Errorf(t, err, "parseKernelVersion(%q)", test.kverStr) + require.ErrorIsf(t, err, test.expectedErr, "parseKernelVersion(%q)", test.kverStr) + assert.Nilf(t, kver, "parseKernelVersion(%q) returned kver", test.kverStr) + } else { + require.NoErrorf(t, err, "parseKernelVersion(%q)", test.kverStr) + assert.Equal(t, test.expected, kver, "parseKernelVersion(%q) return kver", test.kverStr) + } + }) + } +} + +func TestGreaterEqualThan(t *testing.T) { + hostKver, err := getKernelVersion() + require.NoError(t, err) + + for _, test := range []struct { + name string + wantKver KernelVersion + expected bool + }{ + {"HostVersion", hostKver[:], true}, + {"OlderMajor", KernelVersion{hostKver[0] - 1, hostKver[1]}, true}, + {"OlderMinor", KernelVersion{hostKver[0], hostKver[1] - 1}, true}, + {"NewerMajor", KernelVersion{hostKver[0] + 1, hostKver[1]}, false}, + {"NewerMinor", KernelVersion{hostKver[0], hostKver[1] + 1}, false}, + {"ExtraDot", append(hostKver, 1), false}, + {"ExtraZeros", append(hostKver, make(KernelVersion, 10)...), true}, + } { + test := test // copy iterator + t.Run(fmt.Sprintf("%s:%s", test.name, test.wantKver), func(t *testing.T) { + got, err := GreaterEqualThan(test.wantKver) + require.NoErrorf(t, err, "GreaterEqualThan(%s)", test.wantKver) + assert.Equalf(t, test.expected, got, "GreaterEqualThan(%s)", test.wantKver) + }) + } +} From a13178f46b3435892e821353c053c6097cc31a78 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Sat, 30 Aug 2025 00:29:44 +1000 Subject: [PATCH 4/4] procfs: do not use RHEL 8 backported fsopen(2) There appears to be an insanely difficult to debug performance pathology with their backport (which I've struggled to debug for many weeks now). It's time to just cut our losses and not bother trying to use this feature on kernels that tried to backport it. Signed-off-by: Aleksa Sarai --- procfs_linux.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/procfs_linux.go b/procfs_linux.go index ae492b5..326f640 100644 --- a/procfs_linux.go +++ b/procfs_linux.go @@ -21,6 +21,7 @@ import ( "golang.org/x/sys/unix" "github.com/cyphar/filepath-securejoin/internal/gocompat" + "github.com/cyphar/filepath-securejoin/internal/kernelversion" ) func fstat(f *os.File) (unix.Stat_t, error) { @@ -75,7 +76,7 @@ func verifyProcRoot(procRoot *os.File) error { var hasNewMountAPI = gocompat.SyncOnceValue(func() bool { // All of the pieces of the new mount API we use (fsopen, fsconfig, - // fsmount, open_tree) were added together in Linux 5.1[1,2], so we can + // fsmount, open_tree) were added together in Linux 5.2[1,2], so we can // just check for one of the syscalls and the others should also be // available. // @@ -91,7 +92,12 @@ var hasNewMountAPI = gocompat.SyncOnceValue(func() bool { return false } _ = unix.Close(fd) - return true + + // RHEL 8 has a backport of fsopen(2) that appears to have some very + // difficult to debug performance pathology. As such, it seems prudent to + // simply reject pre-5.2 kernels. + isNotBackport, _ := kernelversion.GreaterEqualThan(kernelversion.KernelVersion{5, 2}) + return isNotBackport }) func fsopen(fsName string, flags int) (*os.File, error) {