diff --git a/pkg/unikontainers/mount.go b/pkg/unikontainers/mount.go index 6170db0d..8ea78977 100644 --- a/pkg/unikontainers/mount.go +++ b/pkg/unikontainers/mount.go @@ -37,6 +37,25 @@ import ( var ErrCopyDir = errors.New("can not copy a directory") +var ( + mkdirAllHook = os.MkdirAll + osChmodHook = os.Chmod + runningInUserNSHook = userns.RunningInUserNS + statSyscall = unix.Stat + statfsSyscall = unix.Statfs + mknodSyscall = unix.Mknod + chmodSyscall = unix.Chmod + chownSyscall = os.Chown + mountSyscall = unix.Mount + openSyscall = unix.Open + closeSyscall = unix.Close + secureJoinHook = securejoin.SecureJoin + copyFileHook = copyFile + containerdMountHook = func(m *mount.Mount, target string) error { + return m.Mount(target) + } +) + // setupDevices creates every device from the list inside monitor's rootfs func setupDevices(monRootfs string, devices []specs.LinuxDevice, needsTAP bool) error { for _, dev := range devices { @@ -61,7 +80,7 @@ func setupDevices(monRootfs string, devices []specs.LinuxDevice, needsTAP bool) func setupDev(monRootfs string, dev specs.LinuxDevice) 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 applyMount(monRootfs, bindMount(dev.Path, dev.Path, false)) } @@ -85,7 +104,7 @@ func setupDev(monRootfs string, dev specs.LinuxDevice) error { // the necessary directories if filepath.Dir(dev.Path) != "/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) } @@ -104,7 +123,7 @@ func setupDev(monRootfs string, dev specs.LinuxDevice) error { if newDev > uint64(math.MaxInt) { return fmt.Errorf("device number for %s too large: %d", dstPath, newDev) } - err = unix.Mknod(dstPath, devType|permBits, int(newDev)) //#nosec G115 -- device numbers validated previously + err = mknodSyscall(dstPath, devType|permBits, int(newDev)) //#nosec G115 -- device numbers validated previously if err != nil { return fmt.Errorf("failed to make device node %s: %w", dstPath, err) } @@ -113,7 +132,7 @@ func setupDev(monRootfs string, dev specs.LinuxDevice) error { // read/write them. This is helpful for non-root monitor execution and // removes the burdain of getting kvm/block group id permBits |= 0o006 - err = unix.Chmod(dstPath, permBits) + err = chmodSyscall(dstPath, permBits) if err != nil { return fmt.Errorf("failed to chmod %s: %w", dstPath, err) } @@ -125,7 +144,7 @@ func setupDev(monRootfs string, dev specs.LinuxDevice) error { return fmt.Errorf("reference of %s GID is nil", dev.Path) } // Set the owner as in the original file - err = os.Chown(dstPath, int(*dev.UID), int(*dev.GID)) + err = chownSyscall(dstPath, int(*dev.UID), int(*dev.GID)) if err != nil { return fmt.Errorf("failed to chown %s: %w", dstPath, err) } @@ -143,7 +162,7 @@ func setupDev(monRootfs string, dev specs.LinuxDevice) error { func fileFromHost(monRootfs string, hostPath string, target string) 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 } @@ -159,23 +178,23 @@ func fileFromHost(monRootfs string, hostPath string, target string) error { return fmt.Errorf("failed to get relative path of %s to /: %w", hostPath, err) } } - dstPath, err := securejoin.SecureJoin(monRootfs, target) + dstPath, err := secureJoinHook(monRootfs, target) if err != nil { return fmt.Errorf("failed to resolve target %s: %w", target, err) } - err = copyFile(hostPath, dstPath) + err = copyFileHook(hostPath, dstPath) if err != nil { return fmt.Errorf("failed to copy file %s: %w", hostPath, err) } // Set up the permissions and ownership to match the original file. - 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) } @@ -263,7 +282,7 @@ func mapVFSFlag(value string) (flag uintptr, clear bool, err error) { // but returns the uintptr to use in mount later. func getUnprivilegedMountFlags(path string) (uintptr, error) { var st unix.Statfs_t - err := unix.Statfs(path, &st) + err := statfsSyscall(path, &st) if err != nil { return 0, err } @@ -313,7 +332,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 } @@ -339,7 +358,7 @@ func prepareRoot(path string, rootfsPropagation string) error { } } - err := unix.Mount("", "/", "", uintptr(flag), "") + err := mountSyscall("", "/", "", uintptr(flag), "") if err != nil { return err } @@ -349,7 +368,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, "") } // applyMounts sets up every mount specified in the mounts argument inside the @@ -376,7 +395,7 @@ func applyMounts(rootfsPath string, mounts []specs.Mount) error { // applyMount mounts a single entry under rootfsPath. func applyMount(rootfsPath string, m specs.Mount) error { - target, err := securejoin.SecureJoin(rootfsPath, m.Destination) + target, err := secureJoinHook(rootfsPath, m.Destination) if err != nil { return fmt.Errorf("failed to resolve mount target %s: %w", m.Destination, err) } @@ -396,7 +415,7 @@ func applyMount(rootfsPath string, m specs.Mount) error { Source: m.Source, Options: containerdOpts, } - err = cm.Mount(target) + err = containerdMountHook(&cm, target) if err != nil { return fmt.Errorf("failed to mount %s at %s: %w", cm.Source, target, err) } @@ -407,7 +426,7 @@ func applyMount(rootfsPath string, m specs.Mount) error { // EPERM (this is what containerd's own bind remount does for us elsewhere). if m.Type == "bind" && vfsFlags != 0 { remount := vfsFlags | unix.MS_BIND | unix.MS_REMOUNT - if userns.RunningInUserNS() { + if runningInUserNSHook() { locked, err := getUnprivilegedMountFlags(m.Source) if err != nil { return fmt.Errorf("failed to get locked mount flags of %s: %w", m.Source, err) @@ -418,21 +437,21 @@ func applyMount(rootfsPath string, m specs.Mount) error { // runc instead errors out in this case. remount |= locked } - err = unix.Mount("", target, "", remount, "") + err = mountSyscall("", target, "", remount, "") if err != nil { return fmt.Errorf("failed to apply mount flags for %s: %w", target, err) } } for _, pFlag := range propagation { - err = unix.Mount("", target, "", uintptr(pFlag), "") + err = mountSyscall("", target, "", uintptr(pFlag), "") if err != nil { return fmt.Errorf("failed to set propagation flag for %s: %w", m.Destination, err) } } if m.Type == "tmpfs" && slices.Contains(m.Options, "mode=1777") { - err = os.Chmod(target, 0o777|os.ModeSticky) + err = osChmodHook(target, 0o777|os.ModeSticky) if err != nil { return fmt.Errorf("failed to chmod %s: %w", target, err) } @@ -445,21 +464,21 @@ func applyMount(rootfsPath string, m specs.Mount) error { func createMountPoint(target string, m specs.Mount) error { if m.Type == "bind" { var st unix.Stat_t - err := unix.Stat(m.Source, &st) + err := statSyscall(m.Source, &st) if err != nil { return fmt.Errorf("failed to stat mount source %s: %w", m.Source, err) } if st.Mode&unix.S_IFMT != unix.S_IFDIR { dstDir := filepath.Dir(target) - err := os.MkdirAll(dstDir, 0755) + err := mkdirAllHook(dstDir, 0755) if err != nil { return fmt.Errorf("failed to create directory %s: %w", dstDir, err) } - fd, err := unix.Open(target, unix.O_CREAT, 0644) + fd, err := openSyscall(target, unix.O_CREAT, 0644) if err != nil { return fmt.Errorf("failed to create file %s: %w", target, err) } - err = unix.Close(fd) + err = closeSyscall(fd) if err != nil { return fmt.Errorf("failed to close file %s: %w", target, err) } @@ -468,7 +487,7 @@ func createMountPoint(target string, m specs.Mount) error { } } - err := os.MkdirAll(target, 0755) + err := mkdirAllHook(target, 0755) if err != nil { return fmt.Errorf("failed to create directory %s: %w", target, err) } diff --git a/pkg/unikontainers/mount_test.go b/pkg/unikontainers/mount_test.go new file mode 100644 index 00000000..c4526fb6 --- /dev/null +++ b/pkg/unikontainers/mount_test.go @@ -0,0 +1,527 @@ +// 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/containerd/containerd/mount" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" + "golang.org/x/sys/unix" +) + +// WARNING: These tests mutate global package-level hook variables (mkdirAllHook, statSyscall, etc.). +// Therefore, they MUST NOT run in parallel with other tests. Do NOT add t.Parallel() to these tests. + +type mountCall struct { + source string + target string + fstype string + flags uintptr + data string +} + +func TestSetupDev(t *testing.T) { + origRunningInUserNS := runningInUserNSHook + origSecureJoin := secureJoinHook + origContainerdMount := containerdMountHook + origStat := statSyscall + origMkdirAll := mkdirAllHook + origMknod := mknodSyscall + origChmod := chmodSyscall + origChown := chownSyscall + t.Cleanup(func() { + runningInUserNSHook = origRunningInUserNS + secureJoinHook = origSecureJoin + containerdMountHook = origContainerdMount + statSyscall = origStat + mkdirAllHook = origMkdirAll + mknodSyscall = origMknod + chmodSyscall = origChmod + chownSyscall = origChown + }) + + t.Run("running in user namespace", func(t *testing.T) { + runningInUserNSHook = func() bool { return true } + var mounts []mountCall + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + containerdMountHook = func(m *mount.Mount, target string) error { + mounts = append(mounts, mountCall{m.Source, target, m.Type, 0, ""}) + return nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG // treat as file, not directory + return nil + } + openSyscall = func(path string, flags int, mode uint32) (int, error) { + return 10, nil + } + closeSyscall = func(fd int) error { + return nil + } + + dev := specs.LinuxDevice{Path: "/dev/kvm"} + err := setupDev("/tmp/mon", dev) + assert.NoError(t, err) + assert.Len(t, mounts, 1) + assert.Equal(t, "/dev/kvm", mounts[0].source) + assert.Equal(t, "/tmp/mon/dev/kvm", mounts[0].target) + }) + + t.Run("regular mknod path", func(t *testing.T) { + runningInUserNSHook = func() bool { return false } + mkdirAllCalled := false + mkdirAllHook = func(path string, perm os.FileMode) error { + mkdirAllCalled = true + return nil + } + mknodCalled := false + mknodSyscall = func(path string, mode uint32, dev int) error { + mknodCalled = true + return nil + } + chmodCalled := false + chmodSyscall = func(path string, mode uint32) error { + chmodCalled = true + return nil + } + chownCalled := false + chownSyscall = func(path string, uid, gid int) error { + chownCalled = true + return nil + } + + mode := os.FileMode(0600) + dev := specs.LinuxDevice{ + Path: "/dev/subfolder/kvm", + Type: "c", + FileMode: &mode, + UID: new(uint32), + GID: new(uint32), + } + + err := setupDev("/tmp/mon", dev) + assert.NoError(t, err) + assert.True(t, mkdirAllCalled) + assert.True(t, mknodCalled) + assert.True(t, chmodCalled) + assert.True(t, chownCalled) + }) + + t.Run("invalid device type", func(t *testing.T) { + dev := specs.LinuxDevice{ + Path: "/dev/invalid", + Type: "invalid", + } + err := setupDev("/tmp/mon", dev) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not a device node") + }) +} + +func TestFileFromHost(t *testing.T) { + origStat := statSyscall + origSecureJoin := secureJoinHook + origCopyFile := copyFileHook + origChmod := chmodSyscall + origChown := chownSyscall + origMount := mountSyscall + t.Cleanup(func() { + statSyscall = origStat + secureJoinHook = origSecureJoin + copyFileHook = origCopyFile + chmodSyscall = origChmod + chownSyscall = origChown + mountSyscall = origMount + }) + + t.Run("error statting host path", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + return errors.New("stat error") + } + err := fileFromHost("/tmp/mon", "/nonexistent", "") + assert.Error(t, err) + }) + + t.Run("cannot copy directory", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR + return nil + } + err := fileFromHost("/tmp/mon", "/tmp/dir", "") + assert.ErrorIs(t, err, ErrCopyDir) + }) + + t.Run("happy path file copy", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG | 0644 + return nil + } + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + copyCalled := false + copyFileHook = func(source, target string) error { + copyCalled = true + return nil + } + chmodCalled := false + chmodSyscall = func(path string, mode uint32) error { + chmodCalled = true + return nil + } + chownCalled := false + chownSyscall = func(path string, uid, gid int) error { + chownCalled = true + return nil + } + + err := fileFromHost("/tmp/mon", "/etc/passwd", "etc/passwd") + assert.NoError(t, err) + assert.True(t, copyCalled) + assert.True(t, chmodCalled) + assert.True(t, chownCalled) + }) +} + +func TestGetUnprivilegedMountFlags(t *testing.T) { + origStatfs := statfsSyscall + t.Cleanup(func() { statfsSyscall = origStatfs }) + + t.Run("statfs fails", func(t *testing.T) { + statfsSyscall = func(path string, stat *unix.Statfs_t) error { + return errors.New("statfs error") + } + _, err := getUnprivilegedMountFlags("/tmp/path") + assert.Error(t, err) + }) + + t.Run("extract flags correctly", func(t *testing.T) { + statfsSyscall = func(path string, stat *unix.Statfs_t) error { + stat.Flags = int64(unix.ST_RDONLY | unix.ST_NOSUID | unix.ST_RELATIME) + return nil + } + flags, err := getUnprivilegedMountFlags("/tmp/path") + assert.NoError(t, err) + assert.Equal(t, uintptr(unix.MS_RDONLY|unix.MS_NOSUID|unix.MS_RELATIME), flags) + }) + + t.Run("fallback to strictatime", func(t *testing.T) { + statfsSyscall = func(path string, stat *unix.Statfs_t) error { + stat.Flags = 0 // no noatime, no relatime + return nil + } + flags, err := getUnprivilegedMountFlags("/tmp/path") + assert.NoError(t, err) + assert.Equal(t, uintptr(unix.MS_STRICTATIME), flags) + }) +} + +func TestRootfsParentMountPrivate(t *testing.T) { + origMount := mountSyscall + t.Cleanup(func() { mountSyscall = origMount }) + + t.Run("already private", func(t *testing.T) { + mountCalls := 0 + mountSyscall = func(source, target, fstype string, flags uintptr, data string) error { + mountCalls++ + return nil + } + err := rootfsParentMountPrivate("/tmp/mon/rootfs") + assert.NoError(t, err) + assert.Equal(t, 1, mountCalls) + }) + + t.Run("traverse up to find mount point", func(t *testing.T) { + mountCalls := 0 + mountSyscall = func(source, target, fstype string, flags uintptr, data string) error { + mountCalls++ + if target == "/tmp/mon/rootfs" || target == "/tmp/mon" { + return unix.EINVAL // not a mount point + } + return nil + } + err := rootfsParentMountPrivate("/tmp/mon/rootfs") + assert.NoError(t, err) + // Should try /tmp/mon/rootfs, then /tmp/mon, then /tmp, and succeed + assert.Equal(t, 3, mountCalls) + }) +} + +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, target, fstype string, flags uintptr, data string) error { + calls = append(calls, mountCall{source, target, fstype, flags, data}) + return nil + } + + err := prepareRoot("/tmp/mon/rootfs", "shared") + assert.NoError(t, err) + assert.Len(t, calls, 3) + assert.Equal(t, "/", calls[0].target) + assert.Equal(t, uintptr(unix.MS_SHARED), calls[0].flags) + assert.Equal(t, "/tmp/mon/rootfs", calls[2].source) + assert.Equal(t, "/tmp/mon/rootfs", calls[2].target) + assert.Equal(t, "bind", calls[2].fstype) + }) +} + +func TestApplyMounts(t *testing.T) { + origSecureJoin := secureJoinHook + origMkdirAll := mkdirAllHook + origContainerdMount := containerdMountHook + t.Cleanup(func() { + secureJoinHook = origSecureJoin + mkdirAllHook = origMkdirAll + containerdMountHook = origContainerdMount + }) + + t.Run("empty mounts returns nil", func(t *testing.T) { + err := applyMounts("/tmp/mon", nil) + assert.NoError(t, err) + }) + + t.Run("fail applying mount", func(t *testing.T) { + secureJoinHook = func(root, path string) (string, error) { + return "", errors.New("join error") + } + mounts := []specs.Mount{{Source: "/src", Destination: "/dst"}} + err := applyMounts("/tmp/mon", mounts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply mount") + }) +} + +func TestApplyMount(t *testing.T) { + origSecureJoin := secureJoinHook + origMkdirAll := mkdirAllHook + origContainerdMount := containerdMountHook + origRunningInUserNS := runningInUserNSHook + origStatfs := statfsSyscall + origMount := mountSyscall + origChmod := osChmodHook + origStat := statSyscall + t.Cleanup(func() { + secureJoinHook = origSecureJoin + mkdirAllHook = origMkdirAll + containerdMountHook = origContainerdMount + runningInUserNSHook = origRunningInUserNS + statfsSyscall = origStatfs + mountSyscall = origMount + osChmodHook = origChmod + statSyscall = origStat + }) + + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR | 0755 // default to directory + return nil + } + + t.Run("basic mount success", func(t *testing.T) { + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + mountCalled := false + containerdMountHook = func(m *mount.Mount, target string) error { + mountCalled = true + assert.Equal(t, "tmpfs", m.Type) + assert.Equal(t, "/tmp/mon/tmp", target) + return nil + } + + m := specs.Mount{Type: "tmpfs", Destination: "/tmp"} + err := applyMount("/tmp/mon", m) + assert.NoError(t, err) + assert.True(t, mountCalled) + }) + + t.Run("bind mount remount flags", func(t *testing.T) { + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + containerdMountHook = func(m *mount.Mount, target string) error { return nil } + runningInUserNSHook = func() bool { return false } + var calls []mountCall + mountSyscall = func(source, target, fstype string, flags uintptr, data string) error { + calls = append(calls, mountCall{source, target, fstype, flags, data}) + return nil + } + + m := specs.Mount{ + Type: "bind", + Source: "/src", + Destination: "/dst", + Options: []string{"ro", "nodev"}, + } + err := applyMount("/tmp/mon", m) + assert.NoError(t, err) + assert.Len(t, calls, 1) + assert.Equal(t, "/tmp/mon/dst", calls[0].target) + assert.Equal(t, uintptr(unix.MS_RDONLY|unix.MS_NODEV|unix.MS_BIND|unix.MS_REMOUNT), calls[0].flags) + }) + + t.Run("user ns unprivileged flag merging", func(t *testing.T) { + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + containerdMountHook = func(m *mount.Mount, target string) error { return nil } + runningInUserNSHook = func() bool { return true } + statfsSyscall = func(path string, stat *unix.Statfs_t) error { + stat.Flags = int64(unix.ST_NOSUID) + return nil + } + var calls []mountCall + mountSyscall = func(source, target, fstype string, flags uintptr, data string) error { + calls = append(calls, mountCall{source, target, fstype, flags, data}) + return nil + } + + m := specs.Mount{ + Type: "bind", + Source: "/src", + Destination: "/dst", + Options: []string{"ro"}, + } + err := applyMount("/tmp/mon", m) + assert.NoError(t, err) + assert.Len(t, calls, 1) + // Should have MS_RDONLY (from spec options) and MS_NOSUID (from Statfs) + assert.Equal(t, uintptr(unix.MS_RDONLY|unix.MS_NOSUID|unix.MS_BIND|unix.MS_REMOUNT|unix.MS_STRICTATIME), calls[0].flags) + }) + + t.Run("tmpfs sticky bit chmod", func(t *testing.T) { + secureJoinHook = func(root, path string) (string, error) { + return filepath.Join(root, path), nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + containerdMountHook = func(m *mount.Mount, target string) error { return nil } + chmodCalled := false + osChmodHook = func(name string, mode os.FileMode) error { + chmodCalled = true + assert.Equal(t, os.FileMode(0777)|os.ModeSticky, mode) + return nil + } + + m := specs.Mount{ + Type: "tmpfs", + Destination: "/tmp", + Options: []string{"mode=1777"}, + } + err := applyMount("/tmp/mon", m) + assert.NoError(t, err) + assert.True(t, chmodCalled) + }) +} + +func TestCreateMountPoint(t *testing.T) { + origStat := statSyscall + origMkdirAll := mkdirAllHook + origOpen := openSyscall + origClose := closeSyscall + t.Cleanup(func() { + statSyscall = origStat + mkdirAllHook = origMkdirAll + openSyscall = origOpen + closeSyscall = origClose + }) + + t.Run("bind mount directory", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFDIR + return nil + } + mkdirAllCalled := false + mkdirAllHook = func(path string, perm os.FileMode) error { + mkdirAllCalled = true + assert.Equal(t, "/tmp/mon/dst", path) + return nil + } + + m := specs.Mount{Type: "bind", Source: "/src"} + err := createMountPoint("/tmp/mon/dst", m) + assert.NoError(t, err) + assert.True(t, mkdirAllCalled) + }) + + t.Run("bind mount file", func(t *testing.T) { + statSyscall = func(path string, stat *unix.Stat_t) error { + stat.Mode = unix.S_IFREG + return nil + } + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + openCalled := false + openSyscall = func(path string, flags int, mode uint32) (int, error) { + openCalled = true + assert.Equal(t, "/tmp/mon/dst", path) + return 10, nil + } + closeCalled := false + closeSyscall = func(fd int) error { + closeCalled = true + assert.Equal(t, 10, fd) + return nil + } + + m := specs.Mount{Type: "bind", Source: "/src"} + err := createMountPoint("/tmp/mon/dst", m) + assert.NoError(t, err) + assert.True(t, openCalled) + assert.True(t, closeCalled) + }) + + t.Run("non-bind mount mkdir target", func(t *testing.T) { + mkdirAllCalled := false + mkdirAllHook = func(path string, perm os.FileMode) error { + mkdirAllCalled = true + assert.Equal(t, "/tmp/mon/dst", path) + return nil + } + + m := specs.Mount{Type: "tmpfs"} + err := createMountPoint("/tmp/mon/dst", m) + assert.NoError(t, err) + assert.True(t, mkdirAllCalled) + }) +} + +func TestSplitMountOptions(t *testing.T) { + t.Run("separate options correctly", func(t *testing.T) { + opts, prop, vfs := splitMountOptions([]string{"ro", "rshared", "nodev", "sync"}, true) + assert.Empty(t, opts) // for bind mounts, VFS options "ro", "nodev", "sync" are withheld + assert.Equal(t, []int{unix.MS_SHARED | unix.MS_REC}, prop) + assert.Equal(t, uintptr(unix.MS_RDONLY|unix.MS_NODEV|unix.MS_SYNCHRONOUS), vfs) + }) + + t.Run("non-bind keeps VFS flags", func(t *testing.T) { + opts, prop, vfs := splitMountOptions([]string{"ro", "rshared", "nodev", "sync"}, false) + assert.Equal(t, []string{"ro", "nodev", "sync"}, opts) + assert.Equal(t, []int{unix.MS_SHARED | unix.MS_REC}, prop) + assert.Equal(t, uintptr(unix.MS_RDONLY|unix.MS_NODEV|unix.MS_SYNCHRONOUS), vfs) + }) +} diff --git a/pkg/unikontainers/shared_fs.go b/pkg/unikontainers/shared_fs.go index 9441f39a..f4c7099a 100644 --- a/pkg/unikontainers/shared_fs.go +++ b/pkg/unikontainers/shared_fs.go @@ -27,6 +27,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 @@ -88,7 +90,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..bd4655cd --- /dev/null +++ b/pkg/unikontainers/shared_fs_test.go @@ -0,0 +1,178 @@ +// 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" + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +// WARNING: These tests mutate global package-level hook variables (spawnProcessHook). +// Therefore, they MUST NOT run in parallel with other tests. Do NOT add t.Parallel() to these tests. + +func TestChooseTmpfsSize(t *testing.T) { + tests := []struct { + name string + sfsType string + mem uint64 + expected string + }{ + {"9pfs size", "9pfs", 1024 * 1024, tmpfsSizeFor9pfsRootfs}, + {"virtiofs 0 mem", "virtiofs", 0, "1m"}, + {"virtiofs 1024MB", "virtiofs", 1024 * 1024 * 1024, "1074m"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, chooseTmpfsSize(tt.sfsType, tt.mem)) + }) + } +} + +func TestAdjustPathsForSharedfs(t *testing.T) { + assert.Equal(t, "", adjustPathsForSharedfs("")) + assert.Equal(t, containerRootfsMountPath+"/foo", adjustPathsForSharedfs("foo")) + assert.Equal(t, containerRootfsMountPath+"/foo/bar", adjustPathsForSharedfs("/foo/bar")) +} + +func TestSharedfsPreStart(t *testing.T) { + origSpawn := spawnProcessHook + t.Cleanup(func() { spawnProcessHook = origSpawn }) + + t.Run("9pfs does nothing", func(t *testing.T) { + spawnCalled := false + spawnProcessHook = func(bin string, args []string) error { + spawnCalled = true + return nil + } + s := sharedfsRootfs{sfsType: "9pfs"} + err := s.preStart() + assert.NoError(t, err) + assert.False(t, spawnCalled) + }) + + t.Run("virtiofs launches daemon", func(t *testing.T) { + var spawnBin string + var spawnArgs []string + spawnProcessHook = func(bin string, args []string) error { + spawnBin = bin + spawnArgs = args + return nil + } + s := sharedfsRootfs{ + sfsType: "virtiofs", + sharedPath: "/shared/dir", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + Options: "--sandbox chroot --syslog", + }, + } + + err := s.preStart() + assert.NoError(t, err) + assert.Equal(t, "/usr/bin/virtiofsd", spawnBin) + assert.Contains(t, spawnArgs, "--socket-path=/tmp/vhostqemu") + assert.Contains(t, spawnArgs, "--shared-dir") + assert.Contains(t, spawnArgs, "/shared/dir") + assert.Contains(t, spawnArgs, "--sandbox") + assert.Contains(t, spawnArgs, "chroot") + assert.Contains(t, spawnArgs, "--syslog") + }) + + t.Run("virtiofs fails daemon launch", func(t *testing.T) { + spawnProcessHook = func(bin string, args []string) error { + return errors.New("exec error") + } + s := sharedfsRootfs{ + sfsType: "virtiofs", + sharedPath: "/shared/dir", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + }, + } + + err := s.preStart() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to start virtiofsd") + }) +} + +func TestSharedfsGetMounts(t *testing.T) { + t.Run("virtiofs mounts", func(t *testing.T) { + s := sharedfsRootfs{ + sfsType: "virtiofs", + mountedPath: "/mnt/shared", + vfsdConfig: types.ExtraBinConfig{ + Path: "/usr/bin/virtiofsd", + }, + memory: 512 * 1024 * 1024, + mounts: []specs.Mount{ + {Type: "bind", Source: "/host/a", Destination: "/container/a"}, + {Type: "proc", Source: "proc", Destination: "/proc"}, // should be filtered out + }, + } + + mounts, err := s.getMounts() + assert.NoError(t, err) + // Expected mounts: + // 1. Rootfs bind mount: /mnt/shared -> containerRootfsMountPath + // 2. Virtiofsd binary bind mount: /usr/bin/virtiofsd -> /usr/bin/virtiofsd + // 3. /tmp tmpfs mount: /tmp (size=513m) + // 4. Filtered bind mount: /host/a -> containerRootfsMountPath/container/a + assert.Len(t, mounts, 4) + + assert.Equal(t, "bind", mounts[0].Type) + assert.Equal(t, "/mnt/shared", mounts[0].Source) + assert.Equal(t, containerRootfsMountPath, mounts[0].Destination) + + assert.Equal(t, "bind", mounts[1].Type) + assert.Equal(t, "/usr/bin/virtiofsd", mounts[1].Source) + assert.Equal(t, "/usr/bin/virtiofsd", mounts[1].Destination) + + assert.Equal(t, "tmpfs", mounts[2].Type) + assert.Equal(t, "/tmp", mounts[2].Destination) + assert.Contains(t, mounts[2].Options, "size=537m") + + assert.Equal(t, "bind", mounts[3].Type) + assert.Equal(t, "/host/a", mounts[3].Source) + assert.Equal(t, containerRootfsMountPath+"/container/a", mounts[3].Destination) + }) + + t.Run("9pfs mounts", func(t *testing.T) { + s := sharedfsRootfs{ + sfsType: "9pfs", + mountedPath: "/mnt/shared", + memory: 512 * 1024 * 1024, + } + + mounts, err := s.getMounts() + assert.NoError(t, err) + // Expected mounts: + // 1. Rootfs bind mount: /mnt/shared -> containerRootfsMountPath + // 2. /tmp tmpfs mount: /tmp (size=65536k) + assert.Len(t, mounts, 2) + assert.Equal(t, "bind", mounts[0].Type) + assert.Equal(t, "/mnt/shared", mounts[0].Source) + assert.Equal(t, containerRootfsMountPath, mounts[0].Destination) + + assert.Equal(t, "tmpfs", mounts[1].Type) + assert.Equal(t, "/tmp", mounts[1].Destination) + assert.Contains(t, mounts[1].Options, "size=65536k") + }) +}