From c959381ed2e1d6661cc2635cff3ba5abac20cca1 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Sat, 26 Jul 2025 01:52:36 +0900 Subject: [PATCH] container: access test case by index in helper This is more elegant and allows for much easier extension of the tests. Mountinfo is still serialised however due to libPaths nondeterminism. Signed-off-by: Ophestra --- container/container_test.go | 311 ++++++++++++++++++++---------------- container/init.go | 1 + container/init_test.go | 43 +++++ 3 files changed, 214 insertions(+), 141 deletions(-) create mode 100644 container/init_test.go diff --git a/container/container_test.go b/container/container_test.go index 8024319..81b7049 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/gob" + "errors" + "fmt" "log" "os" + "strconv" "strings" "syscall" "testing" "time" + "hakurei.app/command" "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/container/vfs" @@ -23,11 +27,60 @@ import ( const ( ignore = "\x00" ignoreV = -1 + + pathWantMnt = "/etc/hakurei/want-mnt" ) -func TestMain(m *testing.M) { - container.TryArgv0(hlog.Output{}, hlog.Prepare, internal.InstallOutput) - os.Exit(m.Run()) +var containerTestCases = []struct { + name string + filter bool + session bool + net bool + ops *container.Ops + + mnt []*vfs.MountInfoEntry + uid int + gid int + + rules []seccomp.NativeRule + flags seccomp.ExportFlag + presets seccomp.FilterPreset +}{ + {"minimal", true, false, false, + new(container.Ops), nil, + 1000, 100, nil, 0, seccomp.PresetStrict}, + {"allow", true, true, true, + new(container.Ops), nil, + 1000, 100, nil, 0, seccomp.PresetExt | seccomp.PresetDenyDevel}, + {"no filter", false, true, true, + new(container.Ops), nil, + 1000, 100, nil, 0, seccomp.PresetExt}, + {"custom rules", true, true, true, + new(container.Ops), nil, + 1, 31, []seccomp.NativeRule{{seccomp.ScmpSyscall(syscall.SYS_SETUID), seccomp.ScmpErrno(syscall.EPERM), nil}}, 0, seccomp.PresetExt}, + {"tmpfs", true, false, false, + new(container.Ops). + Tmpfs(hst.Tmp, 0, 0755), + []*vfs.MountInfoEntry{ + ent("/", hst.Tmp, "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore), + }, + 9, 9, nil, 0, seccomp.PresetStrict}, + {"dev", true, true /* go test output is not a tty */, false, + new(container.Ops). + Dev("/dev"). + Mqueue("/dev/mqueue"), + []*vfs.MountInfoEntry{ + ent("/", "/dev", "rw,nosuid,nodev,relatime", "tmpfs", "devtmpfs", ignore), + ent("/null", "/dev/null", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/zero", "/dev/zero", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/full", "/dev/full", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/random", "/dev/random", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/urandom", "/dev/urandom", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/tty", "/dev/tty", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), + ent("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), + ent("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + }, + 1971, 100, nil, 0, seccomp.PresetStrict}, } func TestContainer(t *testing.T) { @@ -39,89 +92,30 @@ func TestContainer(t *testing.T) { t.Cleanup(func() { container.SetOutput(oldOutput) }) } - testCases := []struct { - name string - filter bool - session bool - net bool - ops *container.Ops - mnt []*vfs.MountInfoEntry - host string - rules []seccomp.NativeRule - flags seccomp.ExportFlag - presets seccomp.FilterPreset - }{ - {"minimal", true, false, false, - new(container.Ops), nil, "test-minimal", - nil, 0, seccomp.PresetStrict}, - {"allow", true, true, true, - new(container.Ops), nil, "test-minimal", - nil, 0, seccomp.PresetExt | seccomp.PresetDenyDevel}, - {"no filter", false, true, true, - new(container.Ops), nil, "test-no-filter", - nil, 0, seccomp.PresetExt}, - {"custom rules", true, true, true, - new(container.Ops), nil, "test-no-filter", - []seccomp.NativeRule{ - {seccomp.ScmpSyscall(syscall.SYS_SETUID), seccomp.ScmpErrno(syscall.EPERM), nil}, - }, 0, seccomp.PresetExt}, - {"tmpfs", true, false, false, - new(container.Ops). - Tmpfs(hst.Tmp, 0, 0755), - []*vfs.MountInfoEntry{ - e("/", hst.Tmp, "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore), - }, "test-tmpfs", - nil, 0, seccomp.PresetStrict}, - {"dev", true, true /* go test output is not a tty */, false, - new(container.Ops). - Dev("/dev"). - Mqueue("/dev/mqueue"), - []*vfs.MountInfoEntry{ - e("/", "/dev", "rw,nosuid,nodev,relatime", "tmpfs", "devtmpfs", ignore), - e("/null", "/dev/null", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/zero", "/dev/zero", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/full", "/dev/full", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/random", "/dev/random", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/urandom", "/dev/urandom", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/tty", "/dev/tty", "rw,nosuid", "devtmpfs", "devtmpfs", ignore), - e("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), - e("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), - }, "", - nil, 0, seccomp.PresetStrict}, - } - - for _, tc := range testCases { + for i, tc := range containerTestCases { t.Run(tc.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - c := container.New(ctx, "/usr/bin/sandbox.test", "-test.v", - "-test.run=TestHelperCheckContainer", "--", "check", tc.host) - c.Uid = 1000 - c.Gid = 100 - c.Hostname = tc.host + hostname := hostnameFromTestCase(tc.name) + c := container.New(ctx, os.Args[0], "container", strconv.Itoa(i)) + prepareHelper(c) + c.Uid = tc.uid + c.Gid = tc.gid + c.Hostname = hostname c.Stdout, c.Stderr = os.Stdout, os.Stderr - c.Ops = tc.ops + *c.Ops = append(*c.Ops, *tc.ops...) c.SeccompRules = tc.rules c.SeccompFlags = tc.flags | seccomp.AllowMultiarch c.SeccompPresets = tc.presets c.SeccompDisable = !tc.filter c.RetainSession = tc.session c.HostNet = tc.net - if c.Args[5] == "" { - if name, err := os.Hostname(); err != nil { - t.Fatalf("cannot get hostname: %v", err) - } else { - c.Args[5] = name - } - } c. Tmpfs("/tmp", 0, 0755). Bind(os.Args[0], os.Args[0], 0). - Mkdir("/usr/bin", 0755). - Link(os.Args[0], "/usr/bin/sandbox.test"). - Place("/etc/hostname", []byte(c.Args[5])) + Place("/etc/hostname", []byte(hostname)) // in case test has cgo enabled var libPaths []string if entries, err := ldd.Exec(ctx, os.Args[0]); err != nil { @@ -135,23 +129,33 @@ func TestContainer(t *testing.T) { // needs /proc to check mountinfo c.Proc("/proc") + // mountinfo cannot be resolved directly by helper due to libPaths nondeterminism mnt := make([]*vfs.MountInfoEntry, 0, 3+len(libPaths)) - mnt = append(mnt, e("/sysroot", "/", "rw,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore)) + mnt = append(mnt, ent("/sysroot", "/", "rw,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore)) mnt = append(mnt, tc.mnt...) mnt = append(mnt, - e("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore), - e(ignore, os.Args[0], "ro,nosuid,nodev,relatime", ignore, ignore, ignore), - e(ignore, "/etc/hostname", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore), + // Tmpfs("/tmp", 0, 0755) + ent("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore), + // Bind(os.Args[0], os.Args[0], 0) + ent(ignore, os.Args[0], "ro,nosuid,nodev,relatime", ignore, ignore, ignore), + // Place("/etc/hostname", []byte(hostname)) + ent(ignore, "/etc/hostname", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore), ) for _, name := range libPaths { - mnt = append(mnt, e(ignore, name, "ro,nosuid,nodev,relatime", ignore, ignore, ignore)) + // Bind(name, name, 0) + mnt = append(mnt, ent(ignore, name, "ro,nosuid,nodev,relatime", ignore, ignore, ignore)) } - mnt = append(mnt, e("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw")) + mnt = append(mnt, + // Proc("/proc") + ent("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + // Place(pathWantMnt, want.Bytes()) + ent(ignore, pathWantMnt, "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore), + ) want := new(bytes.Buffer) if err := gob.NewEncoder(want).Encode(mnt); err != nil { t.Fatalf("cannot serialise expected mount points: %v", err) } - c.Stdin = want + c.Place(pathWantMnt, want.Bytes()) if err := c.Start(); err != nil { hlog.PrintBaseError(err, "start:") @@ -168,7 +172,7 @@ func TestContainer(t *testing.T) { } } -func e(root, target, vfsOptstr, fsType, source, fsOptstr string) *vfs.MountInfoEntry { +func ent(root, target, vfsOptstr, fsType, source, fsOptstr string) *vfs.MountInfoEntry { return &vfs.MountInfoEntry{ ID: ignoreV, Parent: ignoreV, @@ -183,6 +187,10 @@ func e(root, target, vfsOptstr, fsType, source, fsOptstr string) *vfs.MountInfoE } } +func hostnameFromTestCase(name string) string { + return "test-" + strings.Join(strings.Fields(name), "-") +} + func TestContainerString(t *testing.T) { c := container.New(t.Context(), "ldd", "/usr/bin/env") c.SeccompFlags |= seccomp.AllowMultiarch @@ -196,72 +204,93 @@ func TestContainerString(t *testing.T) { } } -func TestHelperCheckContainer(t *testing.T) { - if len(os.Args) != 6 || os.Args[4] != "check" { - return - } - - t.Run("user", func(t *testing.T) { - if uid := syscall.Getuid(); uid != 1000 { - t.Errorf("Getuid: %d, want 1000", uid) - } - if gid := syscall.Getgid(); gid != 100 { - t.Errorf("Getgid: %d, want 100", gid) - } - }) - t.Run("hostname", func(t *testing.T) { - if name, err := os.Hostname(); err != nil { - t.Fatalf("cannot get hostname: %v", err) - } else if name != os.Args[5] { - t.Errorf("Hostname: %q, want %q", name, os.Args[5]) - } - - if p, err := os.ReadFile("/etc/hostname"); err != nil { - t.Fatalf("%v", err) - } else if string(p) != os.Args[5] { - t.Errorf("/etc/hostname: %q, want %q", string(p), os.Args[5]) - } - }) - t.Run("mount", func(t *testing.T) { - var mnt []*vfs.MountInfoEntry - if err := gob.NewDecoder(os.Stdin).Decode(&mnt); err != nil { - t.Fatalf("cannot receive expected mount points: %v", err) - } - - var d *vfs.MountInfoDecoder - if f, err := os.Open("/proc/self/mountinfo"); err != nil { - t.Fatalf("cannot open mountinfo: %v", err) - } else { - d = vfs.NewMountInfoDecoder(f) - } - - i := 0 - for cur := range d.Entries() { - if i == len(mnt) { - t.Errorf("got more than %d entries", len(mnt)) - break +func init() { + helperCommands = append(helperCommands, func(c command.Command) { + c.Command("container", command.UsageInternal, func(args []string) error { + if len(args) != 1 { + return syscall.EINVAL } - - // ugly hack but should be reliable and is less likely to false negative than comparing by parsed flags - cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",relatime") - cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",noatime") - mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",relatime") - mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",noatime") - - if !cur.EqualWithIgnore(mnt[i], "\x00") { - t.Errorf("[FAIL] %s", cur) + tc := containerTestCases[0] + if i, err := strconv.Atoi(args[0]); err != nil { + return fmt.Errorf("cannot parse test case index: %v", err) } else { - t.Logf("[ OK ] %s", cur) + tc = containerTestCases[i] } - i++ - } - if err := d.Err(); err != nil { - t.Errorf("cannot parse mountinfo: %v", err) - } + if uid := syscall.Getuid(); uid != tc.uid { + return fmt.Errorf("uid: %d, want %d", uid, tc.uid) + } + if gid := syscall.Getgid(); gid != tc.gid { + return fmt.Errorf("gid: %d, want %d", gid, tc.gid) + } - if i != len(mnt) { - t.Errorf("got %d entries, want %d", i, len(mnt)) - } + wantHost := hostnameFromTestCase(tc.name) + if host, err := os.Hostname(); err != nil { + return fmt.Errorf("cannot get hostname: %v", err) + } else if host != wantHost { + return fmt.Errorf("hostname: %q, want %q", host, wantHost) + } + + if p, err := os.ReadFile("/etc/hostname"); err != nil { + return fmt.Errorf("cannot read /etc/hostname: %v", err) + } else if string(p) != wantHost { + return fmt.Errorf("/etc/hostname: %q, want %q", string(p), wantHost) + } + + { + var fail bool + + var mnt []*vfs.MountInfoEntry + if f, err := os.Open(pathWantMnt); err != nil { + return fmt.Errorf("cannot open expected mount points: %v", err) + } else if err = gob.NewDecoder(f).Decode(&mnt); err != nil { + return fmt.Errorf("cannot parse expected mount points: %v", err) + } else if err = f.Close(); err != nil { + return fmt.Errorf("cannot close expected mount points: %v", err) + } + + var d *vfs.MountInfoDecoder + if f, err := os.Open("/proc/self/mountinfo"); err != nil { + return fmt.Errorf("cannot open mountinfo: %v", err) + } else { + d = vfs.NewMountInfoDecoder(f) + } + + i := 0 + for cur := range d.Entries() { + if i == len(mnt) { + return fmt.Errorf("got more than %d entries", len(mnt)) + } + + // ugly hack but should be reliable and is less likely to false negative than comparing by parsed flags + cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",relatime") + cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",noatime") + mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",relatime") + mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",noatime") + + if !cur.EqualWithIgnore(mnt[i], "\x00") { + fail = true + log.Printf("[FAIL] %s", cur) + } else { + log.Printf("[ OK ] %s", cur) + } + + i++ + } + if err := d.Err(); err != nil { + return fmt.Errorf("cannot parse mountinfo: %v", err) + } + + if i != len(mnt) { + return fmt.Errorf("got %d entries, want %d", i, len(mnt)) + } + + if fail { + return errors.New("one or more mountinfo entries do not match") + } + } + + return nil + }) }) } diff --git a/container/init.go b/container/init.go index 04867ba..8853bd8 100644 --- a/container/init.go +++ b/container/init.go @@ -270,6 +270,7 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) { cmd.ExtraFiles = extraFiles cmd.Dir = params.Dir + msg.Verbosef("starting initial program %s", params.Path) if err := cmd.Start(); err != nil { log.Fatalf("%v", err) } diff --git a/container/init_test.go b/container/init_test.go new file mode 100644 index 0000000..ad35b4c --- /dev/null +++ b/container/init_test.go @@ -0,0 +1,43 @@ +package container_test + +import ( + "log" + "os" + "testing" + + "hakurei.app/command" + "hakurei.app/container" + "hakurei.app/internal" + "hakurei.app/internal/hlog" +) + +const ( + envDoCheck = "HAKUREI_TEST_DO_CHECK" +) + +var helperCommands []func(c command.Command) + +func TestMain(m *testing.M) { + container.TryArgv0(hlog.Output{}, hlog.Prepare, internal.InstallOutput) + + if os.Getenv(envDoCheck) == "1" { + c := command.New(os.Stderr, log.Printf, "helper", func(args []string) error { + log.SetFlags(0) + log.SetPrefix("helper: ") + return nil + }) + for _, f := range helperCommands { + f(c) + } + c.MustParse(os.Args[1:], func(err error) { + if err != nil { + log.Fatal(err.Error()) + } + }) + return + } + + os.Exit(m.Run()) +} + +func prepareHelper(c *container.Container) { c.Env = append(c.Env, envDoCheck+"=1") }