diff --git a/pkg/unikontainers/mount.go b/pkg/unikontainers/mount.go index a4be7cd5..40914042 100644 --- a/pkg/unikontainers/mount.go +++ b/pkg/unikontainers/mount.go @@ -33,6 +33,19 @@ import ( var ErrCopyDir = errors.New("can not copy a directory") +var ( + mountSyscall = unix.Mount + mknodSyscall = unix.Mknod + statSyscall = unix.Stat + chmodSyscall = unix.Chmod + openSyscall = unix.Open + closeSyscall = unix.Close + chownSyscall = os.Chown + runningInUserNSHook = userns.RunningInUserNS + osChmodHook = os.Chmod + mkdirAllHook = os.MkdirAll +) + type mountFlagStruct struct { clear bool flag int @@ -47,25 +60,25 @@ func createTmpfs(monRootfs string, path string, flags uint64, mode string, size mountType := "tmpfs" data := "mode=" + mode + ",size=" + size - err := os.MkdirAll(dstPath, 0755) + err := mkdirAllHook(dstPath, 0755) if err != nil { return fmt.Errorf("failed to create %s dir: %w", path, err) } - err = unix.Mount(mountType, dstPath, mountType, uintptr(flags), data) + err = mountSyscall(mountType, dstPath, mountType, uintptr(flags), data) if err != nil { return fmt.Errorf("failed to mount %s tmpfs: %w", path, err) } // Remove propagation - err = unix.Mount("", dstPath, "", unix.MS_PRIVATE, "") + err = mountSyscall("", dstPath, "", unix.MS_PRIVATE, "") if err != nil { return fmt.Errorf("failed to create %s tmpfs: %w", path, err) } if mode == "1777" { // sonarcloud:go:S2612 -- This is a tmpfs mount point, sticky bit 1777 is required (like /tmp), controlled path, safe by design - err := os.Chmod(dstPath, 01777) // NOSONAR + err := osChmodHook(dstPath, 01777) // NOSONAR if err != nil { return fmt.Errorf("failed to chmod %s: %w", path, err) } @@ -83,13 +96,13 @@ func createTmpfs(monRootfs string, path string, flags uint64, mode string, size func setupDev(monRootfs string, devPath string) error { // In a user namespace, always bind-mount the existing host device node. // Only MS_BIND is used here (no extra flags) to mirror runc's device handling. - if userns.RunningInUserNS() { + if runningInUserNSHook() { return fileFromHost(monRootfs, devPath, "", unix.MS_BIND, false) } // Get info of the original file var devStat unix.Stat_t - err := unix.Stat(devPath, &devStat) + err := statSyscall(devPath, &devStat) if err != nil { return fmt.Errorf("failed to stat dev %s: %w", devPath, err) } @@ -116,14 +129,14 @@ func setupDev(monRootfs string, devPath string) error { // the necessary directories if filepath.Dir(devPath) != "/dev" { dstDir := filepath.Dir(dstPath) - err = os.MkdirAll(dstDir, 0755) + err = mkdirAllHook(dstDir, 0755) if err != nil { return fmt.Errorf("failed to create directory %s: %w", dstDir, err) } } // Create the new device node - err = unix.Mknod(dstPath, devStat.Mode, int(newDev)) //nolint: gosec + err = mknodSyscall(dstPath, devStat.Mode, int(newDev)) //nolint: gosec if err != nil { return fmt.Errorf("failed to make device node %s: %w", dstPath, err) } @@ -133,13 +146,13 @@ func setupDev(monRootfs string, devPath string) error { // removes the burdain of getting kvm/block group id permBits := devStat.Mode & 0o777 permBits |= 0o006 - err = unix.Chmod(dstPath, permBits) + err = chmodSyscall(dstPath, permBits) if err != nil { return fmt.Errorf("failed to chmod %s: %w", dstPath, err) } // Set the owner as in the original file - err = os.Chown(dstPath, int(devStat.Uid), int(devStat.Gid)) + err = chownSyscall(dstPath, int(devStat.Uid), int(devStat.Gid)) if err != nil { return fmt.Errorf("failed to chown %s: %w", dstPath, err) } @@ -159,7 +172,7 @@ func setupDev(monRootfs string, devPath string) error { func fileFromHost(monRootfs string, hostPath string, target string, mFlags int, withCopy bool) error { // Get the info of the original file var fileInfo unix.Stat_t - err := unix.Stat(hostPath, &fileInfo) + err := statSyscall(hostPath, &fileInfo) if err != nil { return err } @@ -200,12 +213,12 @@ func fileFromHost(monRootfs string, hostPath string, target string, mFlags int, // If a copy is created, set up the permissions and ownership to match the original file. // For bind mounts the host inode attributes remain unchanged. if withCopy { - err = unix.Chmod(dstPath, fileInfo.Mode) + err = chmodSyscall(dstPath, fileInfo.Mode) if err != nil { return fmt.Errorf("failed to chmod %s: %w", dstPath, err) } - err = os.Chown(dstPath, int(fileInfo.Uid), int(fileInfo.Gid)) + err = chownSyscall(dstPath, int(fileInfo.Uid), int(fileInfo.Gid)) if err != nil { return fmt.Errorf("failed to chown %s: %w", dstPath, err) } @@ -218,7 +231,7 @@ func fileFromHost(monRootfs string, hostPath string, target string, mFlags int, // mount flags. if mFlags & ^(unix.MS_BIND|unix.MS_REC|unix.MS_REMOUNT) != 0 { flags := mFlags | unix.MS_BIND | unix.MS_REMOUNT - err = unix.Mount(dstPath, dstPath, "", uintptr(flags), "") + err = mountSyscall(dstPath, dstPath, "", uintptr(flags), "") if err != nil { return fmt.Errorf("failed to set mount flags for %s: %w", dstPath, err) } @@ -230,23 +243,23 @@ func fileFromHost(monRootfs string, hostPath string, target string, mFlags int, // bindMountFile bind mounts a file/directory to a new path func bindMountFile(hostPath string, dstDir string, dstPath string, perm uint32, mFlags int, isDir bool) error { var mountTarget string - err := os.MkdirAll(dstDir, 0755) + err := mkdirAllHook(dstDir, 0755) if err != nil { return fmt.Errorf("failed to create directory %s: %w", dstDir, err) } if !isDir { - dstFile, err1 := unix.Open(dstPath, unix.O_CREAT, perm) + dstFile, err1 := openSyscall(dstPath, unix.O_CREAT, perm) if err1 != nil { return fmt.Errorf("failed to create file %s: %w", dstPath, err1) } - unix.Close(dstFile) + closeSyscall(dstFile) mountTarget = dstPath } else { mountTarget = dstDir } - err = unix.Mount(hostPath, mountTarget, "", uintptr(mFlags), "") + err = mountSyscall(hostPath, mountTarget, "", uintptr(mFlags), "") if err != nil { return fmt.Errorf("failed to bind mount %s: %w", mountTarget, err) } @@ -288,7 +301,7 @@ func rootfsParentMountPrivate(path string) error { // and EINVAL means this is not a mount point, so traverse up until we // find one. for { - err = unix.Mount("", path, "", unix.MS_PRIVATE, "") + err = mountSyscall("", path, "", unix.MS_PRIVATE, "") if err == nil { return nil } @@ -314,7 +327,7 @@ func prepareRoot(path string, rootfsPropagation string) error { } } - err := unix.Mount("", "/", "", uintptr(flag), "") + err := mountSyscall("", "/", "", uintptr(flag), "") if err != nil { return err } @@ -324,7 +337,7 @@ func prepareRoot(path string, rootfsPropagation string) error { return err } - return unix.Mount(path, path, "bind", unix.MS_BIND|unix.MS_REC, "") + return mountSyscall(path, path, "bind", unix.MS_BIND|unix.MS_REC, "") } func mountVolumes(rootfsPath string, mounts []specs.Mount) error { @@ -366,7 +379,7 @@ func mountVolumes(rootfsPath string, mounts []specs.Mount) error { dstPath := filepath.Join(rootfsPath, m.Destination) for _, pFlag := range propFlag { - err = unix.Mount(dstPath, dstPath, "", uintptr(pFlag), "") + err = mountSyscall(dstPath, dstPath, "", uintptr(pFlag), "") if err != nil { return fmt.Errorf("failed to set propagation flag for %s: %w", m.Source, err) } diff --git a/pkg/unikontainers/mount_test.go b/pkg/unikontainers/mount_test.go new file mode 100644 index 00000000..46f786a6 --- /dev/null +++ b/pkg/unikontainers/mount_test.go @@ -0,0 +1,567 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// 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 unikontainers + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" + "golang.org/x/sys/unix" +) + +type mountCall struct { + source string + target string + fstype string + flags uintptr + data string +} + +func TestCreateTmpfs(t *testing.T) { + origMount := mountSyscall + origMkdirAll := mkdirAllHook + origOsChmod := osChmodHook + t.Cleanup(func() { + mountSyscall = origMount + mkdirAllHook = origMkdirAll + osChmodHook = origOsChmod + }) + + tmpDir := "/tmp/mock-root" + + t.Run("successful mount with mode 1777", func(t *testing.T) { + var calls []mountCall + var chmodCalled bool + var chmodMode os.FileMode + + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + calls = append(calls, mountCall{source, target, fstype, flags, data}) + return nil + } + + osChmodHook = func(name string, mode os.FileMode) error { + chmodCalled = true + chmodMode = mode + return nil + } + + err := createTmpfs(tmpDir, "tmp", unix.MS_NOSUID, "1777", "64m") + assert.NoError(t, err) + assert.Len(t, calls, 2) + + // Check first mount (mount tmpfs) + assert.Equal(t, "tmpfs", calls[0].source) + assert.Equal(t, filepath.Join(tmpDir, "tmp"), calls[0].target) + assert.Equal(t, uintptr(unix.MS_NOSUID), calls[0].flags) + assert.Equal(t, "mode=1777,size=64m", calls[0].data) + + // Check second mount (remount private) + assert.Equal(t, "", calls[1].source) + assert.Equal(t, uintptr(unix.MS_PRIVATE), calls[1].flags) + + // Check os.Chmod was called + assert.True(t, chmodCalled) + assert.Equal(t, os.FileMode(01777), chmodMode) + }) + + t.Run("successful mount without mode 1777", func(t *testing.T) { + var chmodCalled bool + + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + return nil + } + + osChmodHook = func(name string, mode os.FileMode) error { + chmodCalled = true + return nil + } + + err := createTmpfs(tmpDir, "tmp", unix.MS_NOSUID, "0755", "64m") + assert.NoError(t, err) + assert.False(t, chmodCalled) + }) + + t.Run("mkdirAll failure", func(t *testing.T) { + mkdirAllHook = func(path string, perm os.FileMode) error { + return errors.New("mkdir failed") + } + + err := createTmpfs(tmpDir, "tmp", unix.MS_NOSUID, "1777", "64m") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mkdir failed") + }) + + t.Run("mount failure", func(t *testing.T) { + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + return errors.New("mount failed") + } + + err := createTmpfs(tmpDir, "tmp", unix.MS_NOSUID, "1777", "64m") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mount failed") + }) +} + +func TestSetupDev(t *testing.T) { + origMknod := mknodSyscall + origStat := statSyscall + origChmod := chmodSyscall + origChown := chownSyscall + origMkdirAll := mkdirAllHook + origUserNS := runningInUserNSHook + t.Cleanup(func() { + mknodSyscall = origMknod + statSyscall = origStat + chmodSyscall = origChmod + chownSyscall = origChown + mkdirAllHook = origMkdirAll + runningInUserNSHook = origUserNS + }) + + tmpDir := "/tmp/mock-root" + + t.Run("user-namespace branch", func(t *testing.T) { + runningInUserNSHook = func() bool { + return true + } + + var fileFromHostCalled bool + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG | 0o644 + return nil + } + + // Mock fileFromHost via statSyscall and mountSyscall + origMount := mountSyscall + defer func() { mountSyscall = origMount }() + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + fileFromHostCalled = true + assert.Equal(t, uintptr(unix.MS_BIND), flags) + return nil + } + + err := setupDev(tmpDir, "/dev/null") + assert.NoError(t, err) + assert.True(t, fileFromHostCalled) + }) + + t.Run("non-user-namespace happy path", func(t *testing.T) { + runningInUserNSHook = func() bool { + return false + } + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFCHR | 0o600 + stat.Rdev = unix.Mkdev(1, 3) + return nil + } + + mknodSyscall = func(path string, mode uint32, dev int) error { + assert.Equal(t, filepath.Join(tmpDir, "dev/null"), path) + assert.Equal(t, uint32(unix.S_IFCHR|0o600), mode) + return nil + } + + chmodSyscall = func(path string, mode uint32) error { + assert.Equal(t, uint32(0o606), mode) // 0o600 | 0o006 + return nil + } + + chownSyscall = func(path string, uid int, gid int) error { + return nil + } + + err := setupDev(tmpDir, "/dev/null") + assert.NoError(t, err) + }) + + t.Run("statSyscall failure", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + return errors.New("stat failed") + } + + err := setupDev(tmpDir, "/dev/null") + assert.Error(t, err) + assert.Contains(t, err.Error(), "stat failed") + }) + + t.Run("not a device node error", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG | 0o644 + return nil + } + + err := setupDev(tmpDir, "/dev/regular") + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not a device node") + }) + + t.Run("mkdirAll failure for deep device path", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFCHR | 0o600 + return nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { + return errors.New("mkdirall failed") + } + + err := setupDev(tmpDir, "/dev/net/tun") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mkdirall failed") + }) + + t.Run("mknodSyscall failure", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFCHR | 0o600 + return nil + } + mknodSyscall = func(path string, mode uint32, dev int) error { + return errors.New("mknod failed") + } + + err := setupDev(tmpDir, "/dev/null") + assert.Error(t, err) + assert.Contains(t, err.Error(), "mknod failed") + }) + + t.Run("chmodSyscall failure", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFCHR | 0o600 + return nil + } + mknodSyscall = func(path string, mode uint32, dev int) error { + return nil + } + chmodSyscall = func(path string, mode uint32) error { + return errors.New("chmod failed") + } + + err := setupDev(tmpDir, "/dev/null") + assert.Error(t, err) + assert.Contains(t, err.Error(), "chmod failed") + }) + + t.Run("chownSyscall failure", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFCHR | 0o600 + return nil + } + mknodSyscall = func(path string, mode uint32, dev int) error { + return nil + } + chmodSyscall = func(path string, mode uint32) error { + return nil + } + chownSyscall = func(path string, uid, gid int) error { + return errors.New("chown failed") + } + + err := setupDev(tmpDir, "/dev/null") + assert.Error(t, err) + assert.Contains(t, err.Error(), "chown failed") + }) +} + +func TestFileFromHost(t *testing.T) { + origStat := statSyscall + origMount := mountSyscall + origChmod := chmodSyscall + origOpen := openSyscall + origClose := closeSyscall + origChown := chownSyscall + t.Cleanup(func() { + statSyscall = origStat + mountSyscall = origMount + chmodSyscall = origChmod + openSyscall = origOpen + closeSyscall = origClose + chownSyscall = origChown + }) + + tmpDir := t.TempDir() + + t.Run("bind mount directory", func(t *testing.T) { + var mountSource, mountTarget string + var mountFlags uintptr + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountSource = source + mountTarget = target + mountFlags = flags + return nil + } + + err := fileFromHost(tmpDir, "/etc/config", "", unix.MS_BIND, false) + assert.NoError(t, err) + assert.Equal(t, "/etc/config", mountSource) + assert.Equal(t, filepath.Join(tmpDir, "etc/config"), mountTarget) + assert.Equal(t, uintptr(unix.MS_BIND), mountFlags) + }) + + t.Run("withCopy is true", func(t *testing.T) { + srcFile, err := os.CreateTemp(tmpDir, "src") + assert.NoError(t, err) + defer srcFile.Close() + + _, err = srcFile.WriteString("hello copy") + assert.NoError(t, err) + + var chmodCalled, chownCalled bool + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG | 0o644 + return nil + } + chmodSyscall = func(path string, mode uint32) error { + chmodCalled = true + return nil + } + chownSyscall = func(path string, uid, gid int) error { + chownCalled = true + return nil + } + + dstPath := filepath.Join(tmpDir, "dst") + err = fileFromHost(tmpDir, srcFile.Name(), "dst", 0, true) + assert.NoError(t, err) + + // Assert file actually copied + content, err := os.ReadFile(dstPath) + assert.NoError(t, err) + assert.Equal(t, "hello copy", string(content)) + + assert.True(t, chmodCalled) + assert.True(t, chownCalled) + }) + + t.Run("remount with flags (e.g. MS_NOSUID)", func(t *testing.T) { + var mountCalls []mountCall + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + + // MS_BIND | MS_NOSUID + err := fileFromHost(tmpDir, "/etc/config", "", unix.MS_BIND|unix.MS_NOSUID, false) + assert.NoError(t, err) + + // Should make two mount calls: first MS_BIND, second MS_BIND | MS_NOSUID | MS_REMOUNT + assert.Len(t, mountCalls, 2) + assert.Equal(t, uintptr(unix.MS_BIND|unix.MS_NOSUID), mountCalls[0].flags) + assert.Equal(t, uintptr(unix.MS_BIND|unix.MS_NOSUID|unix.MS_REMOUNT), mountCalls[1].flags) + }) +} + +func TestMapMountFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagStr string + expectedFlag int + expectedClear bool + expectedExist bool + }{ + {"ro", "ro", unix.MS_RDONLY, false, true}, + {"rw", "rw", unix.MS_RDONLY, true, true}, + {"defaults", "defaults", 0, false, true}, + {"noexec", "noexec", unix.MS_NOEXEC, false, true}, + {"exec", "exec", unix.MS_NOEXEC, true, true}, + {"invalid flag", "invalid-flag", 0, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, exist := mapMountFlag(tt.flagStr) + assert.Equal(t, tt.expectedExist, exist) + if exist { + assert.Equal(t, tt.expectedFlag, f.flag) + assert.Equal(t, tt.expectedClear, f.clear) + } + }) + } +} + +func TestMapRootfsPropagationFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val string + expected int + expectedErr bool + }{ + {"shared", "shared", unix.MS_SHARED, false}, + {"private", "private", unix.MS_PRIVATE, false}, + {"rslave", "rslave", unix.MS_SLAVE | unix.MS_REC, false}, + {"invalid", "invalid", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := mapRootfsPropagationFlag(tt.val) + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, f) + } + }) + } +} + +func TestPrepareRoot(t *testing.T) { + origMount := mountSyscall + t.Cleanup(func() { mountSyscall = origMount }) + + t.Run("success", func(t *testing.T) { + var calls []mountCall + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + calls = append(calls, mountCall{source, target, fstype, flags, data}) + if target == "/run" { + return nil + } + return nil + } + + err := prepareRoot("/run/container/rootfs", "shared") + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(calls), 3) + + // First: remount / with mapped propagation + assert.Equal(t, "/", calls[0].target) + assert.Equal(t, uintptr(unix.MS_SHARED), calls[0].flags) + + // Last: bind mount target to itself + lastCall := calls[len(calls)-1] + assert.Equal(t, "/run/container/rootfs", lastCall.source) + assert.Equal(t, "/run/container/rootfs", lastCall.target) + assert.Equal(t, "bind", lastCall.fstype) + assert.Equal(t, uintptr(unix.MS_BIND|unix.MS_REC), lastCall.flags) + }) +} + +func TestMountVolumes(t *testing.T) { + origMount := mountSyscall + origStat := statSyscall + t.Cleanup(func() { + mountSyscall = origMount + statSyscall = origStat + }) + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + t.Run("flag accumulation and clearing", func(t *testing.T) { + var mountCalls []mountCall + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + + mounts := []specs.Mount{ + { + Type: "bind", + Source: "/host/data", + Destination: "/container/data", + Options: []string{"ro", "bind", "nodev", "rw"}, // rw should clear ro/RDONLY + }, + } + + err := mountVolumes("/tmp/rootfs", mounts) + assert.NoError(t, err) + + // Verification of exact accumulation: + // Options should result in: MS_BIND | MS_NODEV (accumulated bind + nodev, and ro cleared by rw) + // Since MS_NODEV is outside basic bind flags, fileFromHost does: + // 1. bind mount with mFlags (MS_BIND | MS_NODEV) + // 2. remount to apply options (MS_BIND | MS_NODEV | MS_REMOUNT) + assert.Len(t, mountCalls, 2) + assert.Equal(t, "/host/data", mountCalls[0].source) + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[0].target) + assert.Equal(t, uintptr(unix.MS_BIND|unix.MS_NODEV), mountCalls[0].flags) + + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[1].source) + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[1].target) + assert.Equal(t, uintptr(unix.MS_BIND|unix.MS_NODEV|unix.MS_REMOUNT), mountCalls[1].flags) + }) + + t.Run("propagation flag handling and unknown-flag skipping", func(t *testing.T) { + var mountCalls []mountCall + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + + mounts := []specs.Mount{ + { + Type: "bind", + Source: "/host/data", + Destination: "/container/data", + Options: []string{"rshared", "unknown-flag"}, // unknown flag is ignored, rshared sets propagation mount + }, + } + + err := mountVolumes("/tmp/rootfs", mounts) + assert.NoError(t, err) + + // Expected mount calls: + // 1. fileFromHost on s.Destination (flags = 0 since only "rshared" and "unknown-flag" are present, neither map to standard mount flags) + // 2. propagation remount (flags = MS_SHARED | MS_REC) + assert.Len(t, mountCalls, 2) + assert.Equal(t, "/host/data", mountCalls[0].source) + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[0].target) + assert.Equal(t, uintptr(0), mountCalls[0].flags) + + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[1].source) + assert.Equal(t, "/tmp/rootfs/container/data", mountCalls[1].target) + assert.Equal(t, uintptr(unix.MS_SHARED|unix.MS_REC), mountCalls[1].flags) + }) +} diff --git a/pkg/unikontainers/shared_fs.go b/pkg/unikontainers/shared_fs.go index 9ac86e8b..2a6aeb42 100644 --- a/pkg/unikontainers/shared_fs.go +++ b/pkg/unikontainers/shared_fs.go @@ -29,6 +29,8 @@ import ( // TODO: Find and set the correct size for the tmpfs in the host const tmpfsSizeFor9pfsRootfs = "65536k" +var spawnProcessHook = spawnProcess + type sharedfsRootfs struct { mounts []specs.Mount vfsdConfig types.ExtraBinConfig @@ -101,7 +103,7 @@ func (s sharedfsRootfs) preStart() error { args = append(args, strings.Fields(s.vfsdConfig.Options)...) } - err := spawnProcess(s.vfsdConfig.Path, args) + err := spawnProcessHook(s.vfsdConfig.Path, args) if err != nil { err = fmt.Errorf("failed to start virtiofsd: %w", err) } diff --git a/pkg/unikontainers/shared_fs_test.go b/pkg/unikontainers/shared_fs_test.go new file mode 100644 index 00000000..ccaf7603 --- /dev/null +++ b/pkg/unikontainers/shared_fs_test.go @@ -0,0 +1,413 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// 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 unikontainers + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" + "golang.org/x/sys/unix" +) + +func TestChooseTmpfsSize(t *testing.T) { + t.Parallel() + + // BytesToStringMB divides by decimal 1_000_000 (standard decimal MB). + // To disambiguate and ensure we test both binary bounds and round-number decimal cases: + tests := []struct { + name string + sfsType string + memory uint64 + expected string + }{ + { + name: "9pfs uses default constant size", + sfsType: "9pfs", + memory: 1024 * 1024 * 1024, + expected: "65536k", + }, + { + name: "virtiofs with zero memory falls back to DefaultMemory (256MB) which yields 1m as 1MiB overhead is added", + sfsType: "virtiofs", + memory: 0, + expected: "1m", + }, + { + name: "virtiofs with 100MB (100_000_000 bytes) + 1MiB (1_048_576 bytes) rounds to 101m", + sfsType: "virtiofs", + memory: 100 * 1000 * 1000, + expected: "101m", + }, + { + name: "virtiofs with 256 MiB (268_435_456 bytes) + 1MiB (1_048_576) rounds to 269m", + sfsType: "virtiofs", + memory: 256 * 1024 * 1024, + expected: "269m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := chooseTmpfsSize(tt.sfsType, tt.memory) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestAdjustPathsForSharedfs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + expected string + }{ + { + name: "empty path returns empty", + path: "", + expected: "", + }, + { + name: "non-empty path prepended with rootfs path dynamically using the actual Go constant", + path: "usr/lib", + expected: filepath.Join(containerRootfsMountPath, "usr/lib"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := adjustPathsForSharedfs(tt.path) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestSharedfsRootfsGetSharedDirs(t *testing.T) { + t.Parallel() + + s := sharedfsRootfs{ + sfsType: "virtiofs", + } + + params, err := s.getSharedDirs() + assert.NoError(t, err) + assert.Equal(t, containerRootfsMountPath, params.Path) + assert.Equal(t, "virtiofs", params.Type) +} + +func TestSharedfsRootfsPreStart(t *testing.T) { + // Hook spawnProcess + origSpawn := spawnProcessHook + t.Cleanup(func() { spawnProcessHook = origSpawn }) + + t.Run("9pfs returns nil directly", func(t *testing.T) { + s := sharedfsRootfs{ + sfsType: "9pfs", + } + err := s.preStart() + assert.NoError(t, err) + }) + + t.Run("virtiofs with empty options", func(t *testing.T) { + var spawnBinary string + var spawnArgs []string + + spawnProcessHook = func(binaryPath string, args []string) error { + spawnBinary = binaryPath + spawnArgs = args + return nil + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + sharedPath: "/tmp/shared", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + Options: "", // empty options + }, + } + + err := s.preStart() + assert.NoError(t, err) + assert.Equal(t, "/usr/bin/virtiofsd", spawnBinary) + // Should only contain socket-path and shared-dir + assert.Equal(t, []string{"--socket-path=/tmp/vhostqemu", "--shared-dir", "/tmp/shared"}, spawnArgs) + }) + + t.Run("virtiofs with custom options", func(t *testing.T) { + var spawnArgs []string + + spawnProcessHook = func(binaryPath string, args []string) error { + spawnArgs = args + return nil + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + sharedPath: "/tmp/shared", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + Options: "--sandbox chroot --cache=none", + }, + } + + err := s.preStart() + assert.NoError(t, err) + assert.Equal(t, []string{ + "--socket-path=/tmp/vhostqemu", + "--shared-dir", + "/tmp/shared", + "--sandbox", + "chroot", + "--cache=none", + }, spawnArgs) + }) +} + +func TestSharedfsRootfsPostSetup(t *testing.T) { + origMount := mountSyscall + origStat := statSyscall + origMkdirAll := mkdirAllHook + origChmod := osChmodHook + origOpen := openSyscall + origClose := closeSyscall + t.Cleanup(func() { + mountSyscall = origMount + statSyscall = origStat + mkdirAllHook = origMkdirAll + osChmodHook = origChmod + openSyscall = origOpen + closeSyscall = origClose + }) + + t.Run("happy path virtiofs", func(t *testing.T) { + var mountCalls []mountCall + var chmodCalled bool + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + + osChmodHook = func(name string, mode os.FileMode) error { + chmodCalled = true + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + memory: 256 * 1000 * 1000, + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + }, + } + + err := s.postSetup() + assert.NoError(t, err) + + // Expected mount calls: + // 1. fileFromHost on s.mountedPath (bind mount) -> 2 calls (bind, remount private) + // 2. fileFromHost on s.vfsdConfig.Path (bind mount) -> 2 calls (bind, remount private) + // 3. createTmpfs (/tmp mount tmpfs) -> 2 calls (mount tmpfs, remount private) + assert.Len(t, mountCalls, 6) + assert.Equal(t, "/tmp/mounted", mountCalls[0].source) + assert.Equal(t, "/usr/bin/virtiofsd", mountCalls[2].source) + assert.Equal(t, "tmpfs", mountCalls[4].source) + assert.True(t, chmodCalled) + }) + + t.Run("happy path 9pfs", func(t *testing.T) { + var mountCalls []mountCall + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + + osChmodHook = func(name string, mode os.FileMode) error { + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + + s := sharedfsRootfs{ + sfsType: "9pfs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + memory: 256 * 1000 * 1000, + } + + err := s.postSetup() + assert.NoError(t, err) + + // Expected mount calls: + // 1. fileFromHost on s.mountedPath (bind mount) -> 2 calls (bind, remount private) + // (virtiofsd mount skipped because sfsType is 9pfs) + // 2. createTmpfs (/tmp mount tmpfs) -> 2 calls (mount tmpfs, remount private) + assert.Len(t, mountCalls, 4) + assert.Equal(t, "/tmp/mounted", mountCalls[0].source) + assert.Equal(t, "tmpfs", mountCalls[2].source) + }) + + t.Run("failure on container rootfs mount", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + return errors.New("stat failed") + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + } + + err := s.postSetup() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to mount container's rootfs") + }) + + t.Run("failure on volume mounts", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + // First call (container rootfs) succeeds + if path == "/tmp/mounted" { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + // Second call (volumes stat) fails + return errors.New("vol stat failed") + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + return nil + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + mounts: []specs.Mount{ + { + Type: "bind", + Source: "/vol/src", + Destination: "/vol/dst", + }, + }, + } + + err := s.postSetup() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to mount volumes") + }) + + t.Run("failure on virtiofsd bind-mount", func(t *testing.T) { + var mountCalls []mountCall + + statSyscall = func(path string, stat *unix.Stat_t) error { + if path == "/tmp/mounted" { + stat.Mode = unix.S_IFDIR | 0o755 + } else { + stat.Mode = unix.S_IFREG | 0o755 + } + return nil + } + + mkdirAllHook = func(path string, perm os.FileMode) error { + return nil + } + + openSyscall = func(path string, mode int, perm uint32) (int, error) { + return 100, nil + } + + closeSyscall = func(fd int) error { + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + // First two mount calls (for /tmp/mounted bind/remount) should succeed. + if len(mountCalls) < 2 { + mountCalls = append(mountCalls, mountCall{source, target, fstype, flags, data}) + return nil + } + // Next call (for virtiofsd) fails + return errors.New("virtiofsd mount failed") + } + + s := sharedfsRootfs{ + sfsType: "virtiofs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + }, + } + + err := s.postSetup() + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not bind mount") + }) + + t.Run("failure on tmpfs creation", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0o755 + return nil + } + + mkdirAllHook = func(path string, perm os.FileMode) error { + if path == "/tmp/mon/tmp" { + return errors.New("mkdir tmp failed") + } + return nil + } + + mountSyscall = func(source string, target string, fstype string, flags uintptr, data string) error { + return nil + } + + s := sharedfsRootfs{ + sfsType: "9pfs", + monRootfs: "/tmp/mon", + mountedPath: "/tmp/mounted", + } + + err := s.postSetup() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create tmpfs") + }) +}