diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34645be..992b652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,43 @@ on: - cron: "30 10 * * 0" jobs: - test-windows: + test-build: strategy: fail-fast: false matrix: + os: + - windows-latest + - ubuntu-latest + - macos-latest go-version: + - "1.18" + - "1.19" + - "1.20" - "1.21" - "1.22" + - "1.23" - "^1" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: go build check + run: go build ./... + - name: go test build check + run: go test -run none ./... + + test-windows: + strategy: + fail-fast: false + matrix: + go-version: + - "1.18" + - "1.20" + - "1.21" + - "oldstable" + - "stable" runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -26,6 +55,8 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: mkdir gocoverdir + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} run: | # mktemp --tmpdir -d gocoverdir.XXXXXXXX function New-TemporaryDirectory { @@ -42,8 +73,15 @@ jobs: $GOCOVERDIR = (New-TemporaryDirectory -Prefix "gocoverdir") echo "GOCOVERDIR=$GOCOVERDIR" >>"$env:GITHUB_ENV" - name: unit tests - run: go test -v -cover '-test.gocoverdir' "$env:GOCOVERDIR" ./... + run: | + if (Test-Path 'env:GOCOVERDIR') { + go test -v -cover '-test.gocoverdir' "$env:GOCOVERDIR" ./... + } else { + go test -v ./... + } - name: upload coverage + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} uses: actions/upload-artifact@v4 with: name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} @@ -57,9 +95,11 @@ jobs: - ubuntu-latest - macos-latest go-version: + - "1.18" + - "1.20" - "1.21" - - "1.22" - - "^1" + - "oldstable" + - "stable" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -67,14 +107,18 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: mkdir gocoverdir + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} run: | GOCOVERDIR="$(mktemp --tmpdir -d gocoverdir.XXXXXXXX)" echo "GOCOVERDIR=$GOCOVERDIR" >>"$GITHUB_ENV" - name: go test - run: go test -v -cover -timeout=30m -test.gocoverdir="$GOCOVERDIR" ./... + run: go test -v -timeout=30m ${GOCOVERDIR:+-cover -test.gocoverdir="$GOCOVERDIR"} ./... - name: sudo go test - run: sudo go test -v -cover -timeout=30m -test.gocoverdir="$GOCOVERDIR" ./... + run: sudo go test -v -timeout=30m ${GOCOVERDIR:+-cover -test.gocoverdir="$GOCOVERDIR"} ./... - name: upload coverage + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} uses: actions/upload-artifact@v4 with: name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} @@ -89,7 +133,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "^1" + go-version: "stable" - name: download all coverage uses: actions/download-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0565724..b776049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ## +### Compatibility ### +- The minimum Go version requirement for `filepath-securejoin` is now Go 1.18 + (we use generics internally). + + For reference, `filepath-securejoin@v0.3.0` somewhat-arbitrarily bumped the + Go version requirement to 1.21. + + While we did make some use of Go 1.21 stdlib features (and in principle Go + versions <= 1.21 are no longer even supported by upstream anymore), some + downstreams have complained that the version bump has meant that they have to + do workarounds when backporting fixes that use the new `filepath-securejoin` + API onto old branches. This is not an ideal situation, but since using this + library is probably better for most downstreams than a hand-rolled + workaround, we now have compatibility shims that allow us to build on older + Go versions. +- Lower minimum version requirement for `golang.org/x/sys` to `v0.18.0` (we + need the wrappers for `fsconfig(2)`), which should also make backporting + patches to older branches easier. + ## [0.3.5] - 2024-12-06 ## + ### Fixed ### - `MkdirAll` will now no longer return an `EEXIST` error if two racing processes are creating the same directory. We will still verify that the path diff --git a/go.mod b/go.mod index 5003746..648cea6 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/cyphar/filepath-securejoin -go 1.21 +go 1.18 require ( github.com/stretchr/testify v1.9.0 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.18.0 ) require ( diff --git a/go.sum b/go.sum index 020992f..2387471 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/gocompat_errors_go120.go b/gocompat_errors_go120.go new file mode 100644 index 0000000..42452bb --- /dev/null +++ b/gocompat_errors_go120.go @@ -0,0 +1,18 @@ +//go:build linux && go1.20 + +// 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 ( + "fmt" +) + +// 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 { + return fmt.Errorf("%w: %w", extraErr, baseErr) +} diff --git a/gocompat_errors_test.go b/gocompat_errors_test.go new file mode 100644 index 0000000..849abab --- /dev/null +++ b/gocompat_errors_test.go @@ -0,0 +1,26 @@ +//go:build linux + +// 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 ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGoCompatErrorWrap(t *testing.T) { + baseErr := errors.New("base error") + extraErr := errors.New("extra error") + + err := wrapBaseError(baseErr, extraErr) + + require.Error(t, err) + assert.ErrorIs(t, err, baseErr, "wrapped error should contain base error") + assert.ErrorIs(t, err, extraErr, "wrapped error should contain extra error") +} diff --git a/gocompat_errors_unsupported.go b/gocompat_errors_unsupported.go new file mode 100644 index 0000000..e7adca3 --- /dev/null +++ b/gocompat_errors_unsupported.go @@ -0,0 +1,38 @@ +//go:build linux && !go1.20 + +// 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 ( + "fmt" +) + +type wrappedError struct { + inner error + isError error +} + +func (err wrappedError) Is(target error) bool { + return err.isError == target +} + +func (err wrappedError) Unwrap() error { + return err.inner +} + +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 +// 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 { + return wrappedError{ + inner: baseErr, + isError: extraErr, + } +} diff --git a/gocompat_generics_go121.go b/gocompat_generics_go121.go new file mode 100644 index 0000000..ddd6fa9 --- /dev/null +++ b/gocompat_generics_go121.go @@ -0,0 +1,32 @@ +//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 { + return slices.DeleteFunc(slice, delFn) +} + +func slices_Contains[S ~[]E, E comparable](slice S, val E) bool { + return slices.Contains(slice, val) +} + +func slices_Clone[S ~[]E, E any](slice S) S { + return slices.Clone(slice) +} + +func sync_OnceValue[T any](f func() T) func() T { + return sync.OnceValue(f) +} + +func sync_OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + return sync.OnceValues(f) +} diff --git a/gocompat_generics_unsupported.go b/gocompat_generics_unsupported.go new file mode 100644 index 0000000..f1e6fe7 --- /dev/null +++ b/gocompat_generics_unsupported.go @@ -0,0 +1,124 @@ +//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 ( + "sync" +) + +// These are very minimal implementations of functions that appear in Go 1.21's +// stdlib, included so that we can build on older Go versions. Most are +// 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. +// Copied from the Go 1.24 stdlib implementation. +func clearSlice[S ~[]E, E any](slice S) { + var zero E + for i := range slice { + slice[i] = zero + } +} + +// Copied from the Go 1.24 stdlib implementation. +func slices_IndexFunc[S ~[]E, E any](s S, f func(E) bool) int { + for i := range s { + if f(s[i]) { + return i + } + } + return -1 +} + +// Copied from the Go 1.24 stdlib implementation. +func slices_DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S { + i := slices_IndexFunc(s, del) + if i == -1 { + return s + } + // Don't start copying elements until we find one to delete. + for j := i + 1; j < len(s); j++ { + if v := s[j]; !del(v) { + s[i] = v + i++ + } + } + clearSlice(s[i:]) // zero/nil out the obsolete elements, for GC + return s[:i] +} + +// 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 { + return slices_IndexFunc(s, func(e E) bool { return e == v }) >= 0 +} + +// Copied from the Go 1.24 stdlib implementation. +func slices_Clone[S ~[]E, E any](s S) S { + // Preserve nil in case it matters. + if s == nil { + return nil + } + return append(S([]E{}), s...) +} + +// Copied from the Go 1.24 stdlib implementation. +func sync_OnceValue[T any](f func() T) func() T { + var ( + 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 + } + return func() T { + once.Do(g) + if !valid { + panic(p) + } + return result + } +} + +// Copied from the Go 1.24 stdlib implementation. +func sync_OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + var ( + once sync.Once + valid bool + p any + r1 T1 + r2 T2 + ) + g := func() { + defer func() { + p = recover() + if !valid { + panic(p) + } + }() + r1, r2 = f() + f = nil + valid = true + } + return func() (T1, T2) { + once.Do(g) + if !valid { + panic(p) + } + return r1, r2 + } +} diff --git a/lookup_linux.go b/lookup_linux.go index 290befa..be81e49 100644 --- a/lookup_linux.go +++ b/lookup_linux.go @@ -12,7 +12,6 @@ import ( "os" "path" "path/filepath" - "slices" "strings" "golang.org/x/sys/unix" @@ -113,7 +112,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 := slices_DeleteFunc( strings.Split(linkTarget, "/"), func(part string) bool { return part == "" || part == "." }) diff --git a/lookup_linux_test.go b/lookup_linux_test.go index eac547c..883eec4 100644 --- a/lookup_linux_test.go +++ b/lookup_linux_test.go @@ -11,7 +11,6 @@ import ( "fmt" "os" "path/filepath" - "slices" "strings" "testing" @@ -473,36 +472,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, 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)}, // 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), + slices_Clone(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), + slices_Clone(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}, 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 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, 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 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), + slices_Clone(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 @@ -514,7 +513,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), + slices_Clone(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 6dfe8c4..5e559bb 100644 --- a/mkdir_linux.go +++ b/mkdir_linux.go @@ -11,7 +11,6 @@ import ( "fmt" "os" "path/filepath" - "slices" "strings" "golang.org/x/sys/unix" @@ -93,7 +92,7 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err } remainingParts := strings.Split(remainingPath, string(filepath.Separator)) - if slices.Contains(remainingParts, "..") { + if slices_Contains(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 @@ -127,8 +126,12 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err if err := unix.Mkdirat(int(currentDir.Fd()), part, uint32(mode)); err != nil && !errors.Is(err, unix.EEXIST) { err = &os.PathError{Op: "mkdirat", Path: currentDir.Name() + "/" + part, Err: err} // Make the error a bit nicer if the directory is dead. - if err2 := isDeadInode(currentDir); err2 != nil { - err = fmt.Errorf("%w (%w)", err, err2) + if deadErr := isDeadInode(currentDir); deadErr != nil { + // TODO: Once we bump the minimum Go version to 1.20, we can use + // 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) } return nil, err } diff --git a/openat2_linux.go b/openat2_linux.go index ae3b381..f7a13e6 100644 --- a/openat2_linux.go +++ b/openat2_linux.go @@ -12,12 +12,11 @@ import ( "os" "path/filepath" "strings" - "sync" "golang.org/x/sys/unix" ) -var hasOpenat2 = sync.OnceValue(func() bool { +var hasOpenat2 = sync_OnceValue(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 8cc827d..809a579 100644 --- a/procfs_linux.go +++ b/procfs_linux.go @@ -12,7 +12,6 @@ import ( "os" "runtime" "strconv" - "sync" "golang.org/x/sys/unix" ) @@ -54,7 +53,7 @@ func verifyProcRoot(procRoot *os.File) error { return nil } -var hasNewMountApi = sync.OnceValue(func() bool { +var hasNewMountApi = sync_OnceValue(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 @@ -192,11 +191,11 @@ func doGetProcRoot() (*os.File, error) { return procRoot, err } -var getProcRoot = sync.OnceValues(func() (*os.File, error) { +var getProcRoot = sync_OnceValues(func() (*os.File, error) { return doGetProcRoot() }) -var hasProcThreadSelf = sync.OnceValue(func() bool { +var hasProcThreadSelf = sync_OnceValue(func() bool { return unix.Access("/proc/thread-self/", unix.F_OK) == nil }) @@ -265,12 +264,20 @@ func procThreadSelf(procRoot *os.File, subpath string) (_ *os.File, _ procThread Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_XDEV | unix.RESOLVE_NO_MAGICLINKS, }) if err != nil { - return nil, nil, fmt.Errorf("%w: %w", errUnsafeProcfs, err) + // TODO: Once we bump the minimum Go version to 1.20, we can use + // 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, nil, wrapBaseError(err, errUnsafeProcfs) } } else { handle, err = openatFile(procRoot, threadSelf+subpath, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { - return nil, nil, fmt.Errorf("%w: %w", errUnsafeProcfs, err) + // TODO: Once we bump the minimum Go version to 1.20, we can use + // 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, nil, wrapBaseError(err, errUnsafeProcfs) } defer func() { if Err != nil { @@ -289,12 +296,17 @@ func procThreadSelf(procRoot *os.File, subpath string) (_ *os.File, _ procThread return handle, runtime.UnlockOSThread, nil } -var hasStatxMountId = sync.OnceValue(func() bool { +// STATX_MNT_ID_UNIQUE is provided in golang.org/x/sys@v0.20.0, but in order to +// avoid bumping the requirement for a single constant we can just define it +// ourselves. +const STATX_MNT_ID_UNIQUE = 0x4000 + +var hasStatxMountId = sync_OnceValue(func() bool { var ( stx unix.Statx_t // We don't care which mount ID we get. The kernel will give us the // unique one if it is supported. - wantStxMask uint32 = unix.STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID + wantStxMask uint32 = STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID ) err := unix.Statx(-int(unix.EBADF), "/", 0, int(wantStxMask), &stx) return err == nil && stx.Mask&wantStxMask != 0 @@ -310,7 +322,7 @@ func getMountId(dir *os.File, path string) (uint64, error) { stx unix.Statx_t // We don't care which mount ID we get. The kernel will give us the // unique one if it is supported. - wantStxMask uint32 = unix.STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID + wantStxMask uint32 = STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID ) err := unix.Statx(int(dir.Fd()), path, unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW, int(wantStxMask), &stx) diff --git a/testing_mocks_linux_test.go b/testing_mocks_linux_test.go index d7a2bf5..c08ba4f 100644 --- a/testing_mocks_linux_test.go +++ b/testing_mocks_linux_test.go @@ -8,7 +8,6 @@ package securejoin import ( "os" - "testing" ) type forceGetProcRootLevel int @@ -33,17 +32,17 @@ func testingCheckClose(check bool, f *os.File) bool { } func testingForcePrivateProcRootOpenTree(f *os.File) bool { - return testing.Testing() && testingForceGetProcRoot != nil && + return testingForceGetProcRoot != nil && testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTree, f) } func testingForcePrivateProcRootOpenTreeAtRecursive(f *os.File) bool { - return testing.Testing() && testingForceGetProcRoot != nil && + return testingForceGetProcRoot != nil && testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTreeAtRecursive, f) } func testingForceGetProcRootUnsafe() bool { - return testing.Testing() && testingForceGetProcRoot != nil && + return testingForceGetProcRoot != nil && *testingForceGetProcRoot >= forceGetProcRootUnsafe } @@ -58,12 +57,12 @@ const ( var testingForceProcThreadSelf *forceProcThreadSelfLevel func testingForceProcSelfTask() bool { - return testing.Testing() && testingForceProcThreadSelf != nil && + return testingForceProcThreadSelf != nil && *testingForceProcThreadSelf >= forceProcSelfTask } func testingForceProcSelf() bool { - return testing.Testing() && testingForceProcThreadSelf != nil && + return testingForceProcThreadSelf != nil && *testingForceProcThreadSelf >= forceProcSelf }