diff --git a/oci/utils_unix.go b/oci/utils_unix.go index 7063184ea..f43d894c3 100644 --- a/oci/utils_unix.go +++ b/oci/utils_unix.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" + "github.com/containerd/containerd/pkg/userns" specs "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" "golang.org/x/sys/unix" @@ -31,6 +32,13 @@ import ( // ErrNotADevice denotes that a file is not a valid linux device. var ErrNotADevice = errors.New("not a device node") +// Testing dependencies +var ( + osReadDir = os.ReadDir + usernsRunningInUserNS = userns.RunningInUserNS + overrideDeviceFromPath func(path string) error +) + // HostDevices returns all devices that can be found under /dev directory. func HostDevices() ([]specs.LinuxDevice, error) { return getDevices("/dev", "") @@ -53,7 +61,7 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) { return []specs.LinuxDevice{*dev}, nil } - files, err := os.ReadDir(path) + files, err := osReadDir(path) if err != nil { return nil, err } @@ -73,6 +81,12 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) { } sub, err := getDevices(filepath.Join(path, f.Name()), cp) if err != nil { + if errors.Is(err, os.ErrPermission) && usernsRunningInUserNS() { + // ignore the "permission denied" error if running in userns. + // This allows rootless containers to use devices that are + // accessible, ignoring devices / subdirectories that are not. + continue + } return nil, err } @@ -90,6 +104,12 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) { if os.IsNotExist(err) { continue } + if errors.Is(err, os.ErrPermission) && usernsRunningInUserNS() { + // ignore the "permission denied" error if running in userns. + // This allows rootless containers to use devices that are + // accessible, ignoring devices that are not. + continue + } return nil, err } if device.Type == fifoDevice { @@ -115,6 +135,12 @@ const ( // DeviceFromPath takes the path to a device to look up the information about a // linux device and returns that information as a LinuxDevice struct. func DeviceFromPath(path string) (*specs.LinuxDevice, error) { + if overrideDeviceFromPath != nil { + if err := overrideDeviceFromPath(path); err != nil { + return nil, err + } + } + var stat unix.Stat_t if err := unix.Lstat(path, &stat); err != nil { return nil, err diff --git a/oci/utils_unix_go116_test.go b/oci/utils_unix_go116_test.go new file mode 100644 index 000000000..b1fa01b19 --- /dev/null +++ b/oci/utils_unix_go116_test.go @@ -0,0 +1,55 @@ +//go:build !go1.17 && !windows && !darwin +// +build !go1.17,!windows,!darwin + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package oci + +import "io/fs" + +// The following code is adapted from go1.17.1/src/io/fs/readdir.go +// to compensate for the lack of fs.FileInfoToDirEntry in Go 1.16. + +// dirInfo is a DirEntry based on a FileInfo. +type dirInfo struct { + fileInfo fs.FileInfo +} + +func (di dirInfo) IsDir() bool { + return di.fileInfo.IsDir() +} + +func (di dirInfo) Type() fs.FileMode { + return di.fileInfo.Mode().Type() +} + +func (di dirInfo) Info() (fs.FileInfo, error) { + return di.fileInfo, nil +} + +func (di dirInfo) Name() string { + return di.fileInfo.Name() +} + +// fileInfoToDirEntry returns a DirEntry that returns information from info. +// If info is nil, FileInfoToDirEntry returns nil. +func fileInfoToDirEntry(info fs.FileInfo) fs.DirEntry { + if info == nil { + return nil + } + return dirInfo{fileInfo: info} +} diff --git a/oci/utils_unix_go117_test.go b/oci/utils_unix_go117_test.go new file mode 100644 index 000000000..20ef980ce --- /dev/null +++ b/oci/utils_unix_go117_test.go @@ -0,0 +1,24 @@ +//go:build go1.17 && !windows && !darwin +// +build go1.17,!windows,!darwin + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package oci + +import "io/fs" + +var fileInfoToDirEntry = fs.FileInfoToDirEntry diff --git a/oci/utils_unix_test.go b/oci/utils_unix_test.go index 7ec324562..9f2c8d05e 100644 --- a/oci/utils_unix_test.go +++ b/oci/utils_unix_test.go @@ -19,7 +19,128 @@ package oci -import "testing" +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/containerd/containerd/pkg/userns" +) + +func cleanupTest() { + overrideDeviceFromPath = nil + osReadDir = os.ReadDir + usernsRunningInUserNS = userns.RunningInUserNS +} + +// Based on test from runc: +// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L34-L47 +func TestHostDevicesOSReadDirFailure(t *testing.T) { + testError := fmt.Errorf("test error: %w", os.ErrPermission) + + // Override os.ReadDir to inject error. + osReadDir = func(dirname string) ([]os.DirEntry, error) { + return nil, testError + } + + // Override userns.RunningInUserNS to ensure not running in user namespace. + usernsRunningInUserNS = func() bool { + return false + } + defer cleanupTest() + + _, err := HostDevices() + if !errors.Is(err, testError) { + t.Fatalf("Unexpected error %v, expected %v", err, testError) + } +} + +// Based on test from runc: +// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L34-L47 +func TestHostDevicesOSReadDirFailureInUserNS(t *testing.T) { + testError := fmt.Errorf("test error: %w", os.ErrPermission) + + // Override os.ReadDir to inject error. + osReadDir = func(dirname string) ([]os.DirEntry, error) { + if dirname == "/dev" { + fi, err := os.Lstat("/dev/null") + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + return []os.DirEntry{fileInfoToDirEntry(fi)}, nil + } + return nil, testError + } + // Override userns.RunningInUserNS to ensure running in user namespace. + usernsRunningInUserNS = func() bool { + return true + } + defer cleanupTest() + + _, err := HostDevices() + if !errors.Is(err, nil) { + t.Fatalf("Unexpected error %v, expected %v", err, nil) + } +} + +// Based on test from runc: +// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L49-L74 +func TestHostDevicesDeviceFromPathFailure(t *testing.T) { + testError := fmt.Errorf("test error: %w", os.ErrPermission) + + // Override DeviceFromPath to produce an os.ErrPermission on /dev/null. + overrideDeviceFromPath = func(path string) error { + if path == "/dev/null" { + return testError + } + return nil + } + + // Override userns.RunningInUserNS to ensure not running in user namespace. + usernsRunningInUserNS = func() bool { + return false + } + defer cleanupTest() + + d, err := HostDevices() + if !errors.Is(err, testError) { + t.Fatalf("Unexpected error %v, expected %v", err, testError) + } + + assert.Equal(t, 0, len(d)) +} + +// Based on test from runc: +// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L49-L74 +func TestHostDevicesDeviceFromPathFailureInUserNS(t *testing.T) { + testError := fmt.Errorf("test error: %w", os.ErrPermission) + + // Override DeviceFromPath to produce an os.ErrPermission on all devices, + // except for /dev/null. + overrideDeviceFromPath = func(path string) error { + if path == "/dev/null" { + return nil + } + return testError + } + + // Override userns.RunningInUserNS to ensure running in user namespace. + usernsRunningInUserNS = func() bool { + return true + } + defer cleanupTest() + + d, err := HostDevices() + if !errors.Is(err, nil) { + t.Fatalf("Unexpected error %v, expected %v", err, nil) + } + assert.Equal(t, 1, len(d)) + assert.Equal(t, d[0].Path, "/dev/null") +} func TestHostDevicesAllValid(t *testing.T) { devices, err := HostDevices()