From c63c27d3c2c0286a58e799d859bc9bdaac011c26 Mon Sep 17 00:00:00 2001 From: criyle Date: Sun, 25 May 2025 20:24:59 -0400 Subject: [PATCH] test(pkg): add some unit tests --- pkg/memfd/memfd_linux.go | 12 +-- pkg/memfd/memfd_linux_test.go | 80 ++++++++++++++++ pkg/mount/builder_linux_test.go | 124 +++++++++++++++++++++++++ pkg/mount/mount_linux_test.go | 112 +++++++++++++++++++++++ pkg/pipe/buffer_test.go | 101 +++++++++++++++++++++ pkg/rlimit/rlimit_test.go | 136 ++++++++++++++++++++++++++++ pkg/unixsocket/socket_linux_test.go | 114 +++++++++++++++++++++++ 7 files changed, 673 insertions(+), 6 deletions(-) create mode 100644 pkg/memfd/memfd_linux_test.go create mode 100644 pkg/mount/builder_linux_test.go create mode 100644 pkg/mount/mount_linux_test.go create mode 100644 pkg/pipe/buffer_test.go create mode 100644 pkg/rlimit/rlimit_test.go diff --git a/pkg/memfd/memfd_linux.go b/pkg/memfd/memfd_linux.go index 3c4c07f..7eb6fb3 100644 --- a/pkg/memfd/memfd_linux.go +++ b/pkg/memfd/memfd_linux.go @@ -15,12 +15,12 @@ const roSeal = unix.F_SEAL_SEAL | unix.F_SEAL_SHRINK | unix.F_SEAL_GROW | unix.F func New(name string) (*os.File, error) { fd, err := unix.MemfdCreate(name, createFlag) if err != nil { - return nil, fmt.Errorf("memfd: memfd_create failed %v", err) + return nil, fmt.Errorf("memfd: memfd_create: %w", err) } file := os.NewFile(uintptr(fd), name) if file == nil { unix.Close(fd) - return nil, fmt.Errorf("memfd: NewFile failed for %v", name) + return nil, fmt.Errorf("memfd: new file failed for %q", name) } return file, nil } @@ -29,21 +29,21 @@ func New(name string) (*os.File, error) { func DupToMemfd(name string, reader io.Reader) (*os.File, error) { file, err := New(name) if err != nil { - return nil, fmt.Errorf("DupToMemfd: %v", err) + return nil, fmt.Errorf("memfd: dup: %w", err) } // linux syscall sendfile might be more efficient here if reader is a file if _, err = file.ReadFrom(reader); err != nil { file.Close() - return nil, fmt.Errorf("DupToMemfd: read from %v", err) + return nil, fmt.Errorf("memfd: read from: %w", err) } // make memfd readonly if _, err = unix.FcntlInt(file.Fd(), unix.F_ADD_SEALS, roSeal); err != nil { file.Close() - return nil, fmt.Errorf("DupToMemfd: memfd seal %v", err) + return nil, fmt.Errorf("memfd: seal: %w", err) } if _, err := file.Seek(0, 0); err != nil { file.Close() - return nil, fmt.Errorf("DupToMemfd: file seek %v", err) + return nil, fmt.Errorf("memfd: seek: %w", err) } return file, nil } diff --git a/pkg/memfd/memfd_linux_test.go b/pkg/memfd/memfd_linux_test.go new file mode 100644 index 0000000..307ddba --- /dev/null +++ b/pkg/memfd/memfd_linux_test.go @@ -0,0 +1,80 @@ +package memfd + +import ( + "bytes" + "io" + "os" + "testing" +) + +func TestNew(t *testing.T) { + f, err := New("test-memfd") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer f.Close() + + // Write and read to verify it's a valid file + data := []byte("hello world") + n, err := f.Write(data) + if err != nil { + t.Fatalf("Write error: %v", err) + } + if n != len(data) { + t.Errorf("Write n = %d, want %d", n, len(data)) + } + _, err = f.Seek(0, io.SeekStart) + if err != nil { + t.Fatalf("Seek error: %v", err) + } + read := make([]byte, len(data)) + n, err = f.Read(read) + if err != nil && err != io.EOF { + t.Fatalf("Read error: %v", err) + } + if string(read[:n]) != string(data) { + t.Errorf("Read = %q, want %q", string(read[:n]), string(data)) + } +} + +func TestDupToMemfd(t *testing.T) { + content := []byte("memfd content") + r := bytes.NewReader(content) + f, err := DupToMemfd("dup-memfd", r) + if err != nil { + t.Fatalf("DupToMemfd error: %v", err) + } + defer f.Close() + + // Should be sealed (readonly), so writing should fail + _, err = f.Write([]byte("fail")) + if err == nil { + t.Error("expected write to sealed memfd to fail, but it succeeded") + } + + // Should be able to read the content + _, err = f.Seek(0, io.SeekStart) + if err != nil { + t.Fatalf("Seek error: %v", err) + } + got, err := io.ReadAll(f) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if string(got) != string(content) { + t.Errorf("ReadAll = %q, want %q", string(got), string(content)) + } +} + +func TestDupToMemfd_ErrorPropagation(t *testing.T) { + // Pass a reader that always errors + r := errorReader{} + _, err := DupToMemfd("dup-memfd-err", r) + if err == nil { + t.Error("expected error from DupToMemfd, got nil") + } +} + +type errorReader struct{} + +func (errorReader) Read([]byte) (int, error) { return 0, os.ErrInvalid } diff --git a/pkg/mount/builder_linux_test.go b/pkg/mount/builder_linux_test.go new file mode 100644 index 0000000..6173959 --- /dev/null +++ b/pkg/mount/builder_linux_test.go @@ -0,0 +1,124 @@ +package mount + +import ( + "os" + "strings" + "testing" +) + +func TestBuilder_WithBind(t *testing.T) { + b := NewBuilder().WithBind("/src", "/dst", true) + if len(b.Mounts) != 1 { + t.Fatalf("expected 1 mount, got %d", len(b.Mounts)) + } + m := b.Mounts[0] + if m.Source != "/src" || m.Target != "/dst" { + t.Errorf("unexpected mount: %+v", m) + } + if !m.IsBindMount() { + t.Errorf("expected bind mount") + } + if !m.IsReadOnly() { + t.Errorf("expected readonly mount") + } +} + +func TestBuilder_WithTmpfs(t *testing.T) { + b := NewBuilder().WithTmpfs("/tmp", "size=64m") + if len(b.Mounts) != 1 { + t.Fatalf("expected 1 mount, got %d", len(b.Mounts)) + } + m := b.Mounts[0] + if !m.IsTmpFs() { + t.Errorf("expected tmpfs mount") + } + if m.Target != "/tmp" || m.Data != "size=64m" { + t.Errorf("unexpected mount: %+v", m) + } +} + +func TestBuilder_WithProc(t *testing.T) { + b := NewBuilder().WithProc() + if len(b.Mounts) != 1 { + t.Fatalf("expected 1 mount, got %d", len(b.Mounts)) + } + m := b.Mounts[0] + if m.FsType != "proc" { + t.Errorf("expected proc fsType") + } + if !m.IsReadOnly() { + t.Errorf("expected readonly proc mount") + } +} + +func TestBuilder_WithProcRW(t *testing.T) { + b := NewBuilder().WithProcRW(true) + if len(b.Mounts) != 1 { + t.Fatalf("expected 1 mount, got %d", len(b.Mounts)) + } + m := b.Mounts[0] + if m.FsType != "proc" { + t.Errorf("expected proc fsType") + } + if m.IsReadOnly() { + t.Errorf("expected read-write proc mount") + } +} + +func TestBuilder_WithMounts(t *testing.T) { + m1 := Mount{Source: "/a", Target: "/b"} + m2 := Mount{Source: "/c", Target: "/d"} + b := NewBuilder().WithMounts([]Mount{m1, m2}) + if len(b.Mounts) != 2 { + t.Fatalf("expected 2 mounts, got %d", len(b.Mounts)) + } +} + +func TestBuilder_WithMount(t *testing.T) { + m := Mount{Source: "/a", Target: "/b"} + b := NewBuilder().WithMount(m) + if len(b.Mounts) != 1 { + t.Fatalf("expected 1 mount, got %d", len(b.Mounts)) + } +} + +func TestBuilder_String(t *testing.T) { + b := NewBuilder(). + WithBind("/src", "/dst", false). + WithTmpfs("/tmp", "size=1m"). + WithProc() + s := b.String() + if !strings.HasPrefix(s, "Mounts: ") { + t.Errorf("unexpected prefix: %q", s) + } + if !strings.Contains(s, "bind[/src:/dst:rw]") { + t.Errorf("missing bind: %q", s) + } + if !strings.Contains(s, "tmpfs[/tmp]") { + t.Errorf("missing tmpfs: %q", s) + } + if !strings.Contains(s, "proc[ro]") { + t.Errorf("missing proc: %q", s) + } +} + +func TestBuilder_FilterNotExist(t *testing.T) { + tmpDir := t.TempDir() + tmpFilePath := tmpDir + "/mounttest" + f, err := os.Create(tmpFilePath) + if err != nil { + t.Fatal(err) + } + f.Close() + + b := NewBuilder(). + WithBind(f.Name(), "/dst1", false). + WithBind("/not/exist", "/dst2", false) + b.FilterNotExist() + if len(b.Mounts) != 1 { + t.Errorf("expected 1 mount after filter, got %d", len(b.Mounts)) + } + if b.Mounts[0].Source != f.Name() { + t.Errorf("unexpected mount: %+v", b.Mounts[0]) + } +} diff --git a/pkg/mount/mount_linux_test.go b/pkg/mount/mount_linux_test.go new file mode 100644 index 0000000..2a34cea --- /dev/null +++ b/pkg/mount/mount_linux_test.go @@ -0,0 +1,112 @@ +package mount + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestMount_IsBindMount(t *testing.T) { + m := Mount{Flags: syscall.MS_BIND} + if !m.IsBindMount() { + t.Errorf("expected IsBindMount true") + } + m.Flags = 0 + if m.IsBindMount() { + t.Errorf("expected IsBindMount false") + } +} + +func TestMount_IsReadOnly(t *testing.T) { + m := Mount{Flags: syscall.MS_RDONLY} + if !m.IsReadOnly() { + t.Errorf("expected IsReadOnly true") + } + m.Flags = 0 + if m.IsReadOnly() { + t.Errorf("expected IsReadOnly false") + } +} + +func TestMount_IsTmpFs(t *testing.T) { + m := Mount{FsType: "tmpfs"} + if !m.IsTmpFs() { + t.Errorf("expected IsTmpFs true") + } + m.FsType = "other" + if m.IsTmpFs() { + t.Errorf("expected IsTmpFs false") + } +} + +func TestMount_String(t *testing.T) { + tests := []struct { + m Mount + want string + }{ + { + m: Mount{Source: "/src", Target: "/dst", Flags: syscall.MS_BIND, FsType: "", Data: ""}, + want: "bind[/src:/dst:rw]", + }, + { + m: Mount{Source: "/src", Target: "/dst", Flags: syscall.MS_BIND | syscall.MS_RDONLY, FsType: "", Data: ""}, + want: "bind[/src:/dst:ro]", + }, + { + m: Mount{Source: "", Target: "/tmp", FsType: "tmpfs"}, + want: "tmpfs[/tmp]", + }, + { + m: Mount{Source: "", Target: "proc", FsType: "proc", Flags: syscall.MS_RDONLY}, + want: "proc[ro]", + }, + { + m: Mount{Source: "src", Target: "dst", FsType: "other", Flags: 0, Data: "data"}, + want: "mount[other,src:dst:0,data]", + }, + } + for _, tt := range tests { + got := tt.m.String() + if got != tt.want { + t.Errorf("Mount.String() = %q, want %q", got, tt.want) + } + } +} + +func TestEnsureMountTargetExists_Dir(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "foo/bar") + err := ensureMountTargetExists(tmpDir, target) + if err != nil { + t.Fatalf("ensureMountTargetExists error: %v", err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat error: %v", err) + } + if !info.IsDir() { + t.Errorf("expected directory at %s", target) + } +} + +func TestEnsureMountTargetExists_File(t *testing.T) { + tmpDir := t.TempDir() + srcFile := filepath.Join(tmpDir, "srcfile") + if err := os.WriteFile(srcFile, []byte("x"), 0644); err != nil { + t.Fatalf("write srcfile: %v", err) + } + target := filepath.Join(tmpDir, "targetfile") + err := ensureMountTargetExists(srcFile, target) + if err != nil { + t.Fatalf("ensureMountTargetExists error: %v", err) + } + // Should be a file or at least exist + info, err := os.Lstat(target) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + if info.IsDir() { + t.Errorf("expected file at %s, got directory", target) + } +} diff --git a/pkg/pipe/buffer_test.go b/pkg/pipe/buffer_test.go new file mode 100644 index 0000000..1684ccd --- /dev/null +++ b/pkg/pipe/buffer_test.go @@ -0,0 +1,101 @@ +package pipe + +import ( + "io" + "strings" + "testing" + "time" +) + +func TestNewBuffer_WriteAndRead(t *testing.T) { + const max = 10 + buf, err := NewBuffer(max) + if err != nil { + t.Fatalf("NewBuffer error: %v", err) + } + defer buf.W.Close() + + // Write less than max bytes + input := "hello" + n, err := buf.W.Write([]byte(input)) + if err != nil { + t.Fatalf("Write error: %v", err) + } + if n != len(input) { + t.Errorf("Write bytes = %d, want %d", n, len(input)) + } + buf.W.Close() + <-buf.Done + + got := buf.Buffer.String() + if got != input { + t.Errorf("Buffer content = %q, want %q", got, input) + } +} + +func TestNewBuffer_MaxBytes(t *testing.T) { + const max = 5 + buf, err := NewBuffer(max) + if err != nil { + t.Fatalf("NewBuffer error: %v", err) + } + defer buf.W.Close() + + // Write more than max bytes + input := "toolonginput" + _, err = io.Copy(buf.W, strings.NewReader(input)) + if err != nil { + t.Fatalf("Copy error: %v", err) + } + buf.W.Close() + <-buf.Done + + got := buf.Buffer.String() + if len(got) != int(max+1) { + t.Errorf("Buffer length = %d, want %d", len(got), max+1) + } + if got != input[:max+1] { + t.Errorf("Buffer content = %q, want %q", got, input[:max+1]) + } +} + +func TestBuffer_String(t *testing.T) { + const max = 8 + buf, err := NewBuffer(max) + if err != nil { + t.Fatalf("NewBuffer error: %v", err) + } + defer buf.W.Close() + + _, _ = buf.W.Write([]byte("abc")) + buf.W.Close() + <-buf.Done + + want := "Buffer[3/8]" + if buf.String() != want { + t.Errorf("String() = %q, want %q", buf.String(), want) + } +} + +func TestNewBuffer_DoneCloses(t *testing.T) { + const max = 4 + buf, err := NewBuffer(max) + if err != nil { + t.Fatalf("NewBuffer error: %v", err) + } + defer buf.W.Close() + + done := make(chan struct{}) + go func() { + _, _ = buf.W.Write([]byte("test")) + buf.W.Close() + close(done) + }() + + select { + case <-buf.Done: + // ok + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for Done channel") + } +} diff --git a/pkg/rlimit/rlimit_test.go b/pkg/rlimit/rlimit_test.go new file mode 100644 index 0000000..6bcca37 --- /dev/null +++ b/pkg/rlimit/rlimit_test.go @@ -0,0 +1,136 @@ +//go:build linux + +package rlimit + +import ( + "syscall" + "testing" +) + +func TestPrepareRLimit(t *testing.T) { + tests := []struct { + name string + rl RLimits + expect []int + }{ + { + name: "Empty", + rl: RLimits{}, + expect: []int{}, + }, + { + name: "CPU only", + rl: RLimits{CPU: 1}, + expect: []int{syscall.RLIMIT_CPU}, + }, + { + name: "Data only", + rl: RLimits{Data: 1024}, + expect: []int{syscall.RLIMIT_DATA}, + }, + { + name: "All fields", + rl: RLimits{CPU: 1, CPUHard: 2, Data: 1024, FileSize: 2048, Stack: 4096, AddressSpace: 8192, OpenFile: 16, DisableCore: true}, + expect: []int{syscall.RLIMIT_CPU, syscall.RLIMIT_DATA, syscall.RLIMIT_FSIZE, syscall.RLIMIT_STACK, syscall.RLIMIT_AS, syscall.RLIMIT_NOFILE, syscall.RLIMIT_CORE}, + }, + { + name: "DisableCore only", + rl: RLimits{DisableCore: true}, + expect: []int{syscall.RLIMIT_CORE}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rls := tt.rl.PrepareRLimit() + if len(rls) != len(tt.expect) { + t.Fatalf("expected %d rlimits, got %d", len(tt.expect), len(rls)) + } + for i, r := range rls { + if r.Res != tt.expect[i] { + t.Errorf("expected Res %d at %d, got %d", tt.expect[i], i, r.Res) + } + } + }) + } +} + +func TestRLimitString(t *testing.T) { + tests := []struct { + name string + rl RLimit + want string + }{ + { + name: "CPU", + rl: RLimit{Res: syscall.RLIMIT_CPU, Rlim: syscall.Rlimit{Cur: 1, Max: 2}}, + want: "CPU[1 s:2 s]", + }, + { + name: "NOFILE", + rl: RLimit{Res: syscall.RLIMIT_NOFILE, Rlim: syscall.Rlimit{Cur: 10, Max: 20}}, + want: "OpenFile[10:20]", + }, + { + name: "DATA", + rl: RLimit{Res: syscall.RLIMIT_DATA, Rlim: syscall.Rlimit{Cur: 1024, Max: 2048}}, + want: "Data[1.0 KiB:2.0 KiB]", + }, + { + name: "FSIZE", + rl: RLimit{Res: syscall.RLIMIT_FSIZE, Rlim: syscall.Rlimit{Cur: 100, Max: 200}}, + want: "File[100 B:200 B]", + }, + { + name: "STACK", + rl: RLimit{Res: syscall.RLIMIT_STACK, Rlim: syscall.Rlimit{Cur: 4096, Max: 8192}}, + want: "Stack[4.0 KiB:8.0 KiB]", + }, + { + name: "AS", + rl: RLimit{Res: syscall.RLIMIT_AS, Rlim: syscall.Rlimit{Cur: 123, Max: 456}}, + want: "AddressSpace[123 B:456 B]", + }, + { + name: "CORE", + rl: RLimit{Res: syscall.RLIMIT_CORE, Rlim: syscall.Rlimit{Cur: 0, Max: 0}}, + want: "Core[0 B:0 B]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rl.String() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestRLimitsString(t *testing.T) { + rl := RLimits{ + CPU: 1, + CPUHard: 2, + Data: 1024, + FileSize: 2048, + Stack: 4096, + AddressSpace: 8192, + OpenFile: 16, + DisableCore: true, + } + want := "RLimits[CPU[1 s:2 s],Data[1.0 KiB:1.0 KiB],File[2.0 KiB:2.0 KiB],Stack[4.0 KiB:4.0 KiB],AddressSpace[8.0 KiB:8.0 KiB],OpenFile[16:16],Core[0 B:0 B]]" + got := rl.String() + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestRLimitsString_Empty(t *testing.T) { + rl := RLimits{} + want := "RLimits[]" + got := rl.String() + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/pkg/unixsocket/socket_linux_test.go b/pkg/unixsocket/socket_linux_test.go index a2852f7..36bab9c 100644 --- a/pkg/unixsocket/socket_linux_test.go +++ b/pkg/unixsocket/socket_linux_test.go @@ -2,6 +2,8 @@ package unixsocket import ( "bytes" + "os" + "syscall" "testing" ) @@ -26,3 +28,115 @@ func TestBaseline(t *testing.T) { t.Fatal("not equal") } } + +func TestSendRecvMsg_Fds(t *testing.T) { + a, b, err := NewSocketPair() + if err != nil { + t.Fatal(err) + } + defer a.Close() + defer b.Close() + + // Create a file to send its fd + tmpfile, err := os.CreateTemp("", "unixsocket-fd") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + defer tmpfile.Close() + + msg := []byte("fdtest") + go func() { + a.SendMsg(msg, Msg{Fds: []int{int(tmpfile.Fd())}}) + }() + + buf := make([]byte, 64) + n, m, err := b.RecvMsg(buf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf[:n], msg) { + t.Errorf("RecvMsg got %q, want %q", buf[:n], msg) + } + if len(m.Fds) != 1 { + t.Errorf("expected 1 fd, got %d", len(m.Fds)) + } + if m.Fds != nil { + syscall.Close(m.Fds[0]) + } +} + +func TestSendRecvMsg_Cred(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("skipping credential test: requires root privileges") + return + } + a, b, err := NewSocketPair() + if err != nil { + t.Fatal(err) + } + defer a.Close() + defer b.Close() + + // Enable credential passing + if err := a.SetPassCred(1); err != nil { + t.Fatal(err) + } + if err := b.SetPassCred(1); err != nil { + t.Fatal(err) + } + + msg := []byte("credtest") + go func() { + a.SendMsg(msg, Msg{Cred: &syscall.Ucred{Pid: 123, Uid: 456, Gid: 789}}) + }() + + buf := make([]byte, 64) + n, m, err := b.RecvMsg(buf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf[:n], msg) { + t.Errorf("RecvMsg got %q, want %q", buf[:n], msg) + } + if m.Cred == nil { + t.Error("expected credential, got nil") + } +} + +func TestNewSocketPair_Close(t *testing.T) { + a, b, err := NewSocketPair() + if err != nil { + t.Fatal(err) + } + if err := a.Close(); err != nil { + t.Errorf("a.Close() error: %v", err) + } + if err := b.Close(); err != nil { + t.Errorf("b.Close() error: %v", err) + } +} + +func TestNewSocket_InvalidFd(t *testing.T) { + // Use an invalid fd + _, err := NewSocket(-1) + if err == nil { + t.Error("expected error for invalid fd, got nil") + } +} + +func TestSetPassCred_InvalidSocket(t *testing.T) { + a, b, err := NewSocketPair() + if err != nil { + t.Fatal(err) + } + defer a.Close() + defer b.Close() + + // Close the socket to make it invalid + a.Close() + err = a.SetPassCred(1) + if err == nil { + t.Error("expected error on SetPassCred for closed socket, got nil") + } +}