summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-21 19:28:23 +0200
committerPaul Buetow <paul@buetow.org>2026-02-21 19:28:23 +0200
commita5b711c5f221704209706b79fbf310a18e079391 (patch)
tree84615902f79a901aa9d98e3423c4756477b7cf4b
parent2c2cbe07f5e10fdb996e2a039cde84be44866f18 (diff)
more on integration tests
-rw-r--r--.gitignore1
-rw-r--r--Magefile.go46
-rw-r--r--integrationtests/close_test.go25
-rw-r--r--integrationtests/cmd/ioworkload/scenarios.go1122
-rw-r--r--integrationtests/dir_test.go47
-rw-r--r--integrationtests/dup_test.go36
-rw-r--r--integrationtests/fcntl_test.go36
-rw-r--r--integrationtests/harness.go179
-rw-r--r--integrationtests/helpers_test.go50
-rw-r--r--integrationtests/link_test.go59
-rw-r--r--integrationtests/open_test.go36
-rw-r--r--integrationtests/readwrite_test.go64
-rw-r--r--integrationtests/rename_test.go36
-rw-r--r--integrationtests/stat_test.go80
-rw-r--r--integrationtests/unlink_test.go36
15 files changed, 1829 insertions, 24 deletions
diff --git a/.gitignore b/.gitignore
index 0ef2f72..27fd9f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/ioriotng
+/ioworkload
/ioriotng.bpf.c
*.o
vmlinux.h
diff --git a/Magefile.go b/Magefile.go
index 28f9616..00d2be7 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"strings"
"github.com/magefile/mage/mg"
@@ -22,10 +23,12 @@ import (
const (
binaryName = "ior"
+ workloadBinaryName = "ioworkload"
defaultLibbpfgoPath = "../libbpfgo"
bpfSourcePath = "internal/c/ior.bpf.c"
bpfObjectPath = "internal/c/ior.bpf.o"
bpfOutputPath = "ior.bpf.o"
+ workloadSourcePath = "./integrationtests/cmd/ioworkload"
tracepointsCPath = "internal/c/generated_tracepoints.c"
tracepointsResult = "internal/c/generated_tracepoints_result.txt"
tracepointsResultNew = "internal/c/generated_tracepoints_result.txt.new"
@@ -191,6 +194,9 @@ func Clean() error {
if err := removeFilesByName(binaryName); err != nil {
return err
}
+ if err := removeFilesByPath(workloadBinaryName); err != nil {
+ return err
+ }
if err := removeFilesByPath(bpfOutputPath); err != nil {
return err
}
@@ -234,6 +240,19 @@ func World() error {
return nil
}
+// IntegrationTest builds everything and runs integration tests with sudo.
+func IntegrationTest() error {
+ mg.SerialDeps(All)
+ fmt.Println("Building ioworkload binary...")
+ if err := sh.RunV("go", "build", "-o", workloadBinaryName, workloadSourcePath); err != nil {
+ return fmt.Errorf("build ioworkload: %w", err)
+ }
+ fmt.Println("Running integration tests (requires root)...")
+ env := goEnv()
+ forwardEnv(env, "HOME", "GOPATH", "GOMODCACHE")
+ return sudoRunWithEnv(env, "go", "test", "./integrationtests/...", "-v", "-failfast", "-count=1")
+}
+
// Prof generates CPU and memory profiling PDFs.
func Prof() error {
if err := runShellCommand("go tool pprof -pdf ./ior ior.cpuprofile > cpuprofile.pdf"); err != nil {
@@ -410,6 +429,33 @@ func sudoOutput(cmd string, args ...string) (string, error) {
return sh.Output("sudo", append([]string{cmd}, args...)...)
}
+func sudoRunWithEnv(env map[string]string, cmd string, args ...string) error {
+ if os.Geteuid() == 0 {
+ return sh.RunWithV(env, cmd, args...)
+ }
+ keys := make([]string, 0, len(env))
+ for k := range env {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ sudoArgs := make([]string, 0, 1+len(keys)+1+len(args))
+ sudoArgs = append(sudoArgs, "env")
+ for _, k := range keys {
+ sudoArgs = append(sudoArgs, k+"="+env[k])
+ }
+ sudoArgs = append(sudoArgs, cmd)
+ sudoArgs = append(sudoArgs, args...)
+ return sh.RunV("sudo", sudoArgs...)
+}
+
+func forwardEnv(env map[string]string, keys ...string) {
+ for _, k := range keys {
+ if v := os.Getenv(k); v != "" {
+ env[k] = v
+ }
+ }
+}
+
func writeTracepointsResult(output string, strict bool) error {
result := extractTracepointReasons(output)
if err := os.WriteFile(tracepointsResultNew, []byte(result), 0o644); err != nil {
diff --git a/integrationtests/close_test.go b/integrationtests/close_test.go
new file mode 100644
index 0000000..d91a37d
--- /dev/null
+++ b/integrationtests/close_test.go
@@ -0,0 +1,25 @@
+package integrationtests
+
+import "testing"
+
+func TestCloseBasic(t *testing.T) {
+ runScenario(t, "close-basic", []ExpectedEvent{
+ {
+ PathContains: "closefile-",
+ Tracepoint: "enter_close",
+ Comm: "ioworkload",
+ MinCount: 3,
+ },
+ })
+}
+
+func TestCloseRange(t *testing.T) {
+ runScenario(t, "close-range", []ExpectedEvent{
+ {
+ PathContains: "closerangefile-",
+ Tracepoint: "enter_close_range",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/cmd/ioworkload/scenarios.go b/integrationtests/cmd/ioworkload/scenarios.go
index 41563d8..bf1561c 100644
--- a/integrationtests/cmd/ioworkload/scenarios.go
+++ b/integrationtests/cmd/ioworkload/scenarios.go
@@ -4,22 +4,50 @@ import (
"fmt"
"os"
"path/filepath"
+ "runtime"
"syscall"
+ "unsafe"
)
// scenarios maps scenario names to their execution functions.
var scenarios = map[string]func() error{
- "open-basic": openBasic,
- "open-creat": openCreat,
- "readwrite-basic": readwriteBasic,
+ "open-basic": openBasic,
+ "open-creat": openCreat,
+ "open-by-handle-at": openByHandleAt,
+ "readwrite-basic": readwriteBasic,
+ "readwrite-pread": readwritePread,
+ "readwrite-pwrite": readwritePwrite,
+ "readwrite-readv": readwriteReadv,
+ "readwrite-writev": readwriteWritev,
"close-basic": closeBasic,
+ "close-range": closeRange,
"dup-basic": dupBasic,
- "fcntl-dupfd": fcntlDupfd,
- "rename-basic": renameBasic,
+ "dup-dup2": dupDup2,
+ "dup-dup3": dupDup3,
+ "fcntl-dupfd": fcntlDupfd,
+ "fcntl-setfl": fcntlSetfl,
+ "fcntl-dupfd-cloexec": fcntlDupfdCloexec,
+ "rename-basic": renameBasic,
+ "rename-renameat": renameRenameat,
+ "rename-renameat2": renameRenameat2,
"link-basic": linkBasic,
+ "link-linkat": linkLinkat,
+ "link-symlinkat": linkSymlinkat,
+ "link-readlinkat": linkReadlinkat,
"unlink-basic": unlinkBasic,
+ "unlink-unlinkat": unlinkUnlinkat,
+ "unlink-rmdir": unlinkRmdir,
"dir-basic": dirBasic,
+ "dir-mkdirat": dirMkdirat,
+ "dir-chdir": dirChdir,
+ "dir-getdents": dirGetdents,
"stat-basic": statBasic,
+ "stat-fstat": statFstat,
+ "stat-lstat": statLstat,
+ "stat-newfstatat": statNewfstatat,
+ "stat-statx": statStatx,
+ "stat-access": statAccess,
+ "stat-faccessat": statFaccessat,
"sync-basic": syncBasic,
"truncate-basic": truncateBasic,
}
@@ -95,6 +123,122 @@ func readwriteBasic() error {
return nil
}
+// readwritePread opens a file, writes data, then reads it back via pread64.
+func readwritePread() error {
+ dir, cleanup, err := makeTempDir("readwrite-pread")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "preadfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ data := []byte("pread test data")
+ if _, err := syscall.Write(fd, data); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+
+ buf := make([]byte, len(data))
+ if _, err := syscall.Pread(fd, buf, 0); err != nil {
+ return fmt.Errorf("pread: %w", err)
+ }
+ return nil
+}
+
+// readwritePwrite opens a file and writes data via pwrite64.
+func readwritePwrite() error {
+ dir, cleanup, err := makeTempDir("readwrite-pwrite")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "pwritefile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, err := syscall.Pwrite(fd, []byte("pwrite test data"), 0); err != nil {
+ return fmt.Errorf("pwrite: %w", err)
+ }
+ return nil
+}
+
+// readwriteReadv opens a file, writes data, then reads it back via readv.
+func readwriteReadv() error {
+ dir, cleanup, err := makeTempDir("readwrite-readv")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "readvfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ data := []byte("readv test data here")
+ if _, err := syscall.Write(fd, data); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ if _, err := syscall.Seek(fd, 0, 0); err != nil {
+ return fmt.Errorf("seek: %w", err)
+ }
+
+ buf1 := make([]byte, 5)
+ buf2 := make([]byte, 15)
+ iovs := []syscall.Iovec{
+ {Base: &buf1[0], Len: uint64(len(buf1))},
+ {Base: &buf2[0], Len: uint64(len(buf2))},
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_READV, uintptr(fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs)))
+ runtime.KeepAlive(buf1)
+ runtime.KeepAlive(buf2)
+ if errno != 0 {
+ return fmt.Errorf("readv: %w", errno)
+ }
+ return nil
+}
+
+// readwriteWritev opens a file and writes data via writev.
+func readwriteWritev() error {
+ dir, cleanup, err := makeTempDir("readwrite-writev")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "writevfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ buf1 := []byte("writev ")
+ buf2 := []byte("test data")
+ iovs := []syscall.Iovec{
+ {Base: &buf1[0], Len: uint64(len(buf1))},
+ {Base: &buf2[0], Len: uint64(len(buf2))},
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_WRITEV, uintptr(fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs)))
+ runtime.KeepAlive(buf1)
+ runtime.KeepAlive(buf2)
+ if errno != 0 {
+ return fmt.Errorf("writev: %w", errno)
+ }
+ return nil
+}
+
// closeBasic opens multiple files and closes them.
func closeBasic() error {
dir, cleanup, err := makeTempDir("close-basic")
@@ -120,6 +264,39 @@ func closeBasic() error {
return nil
}
+// closeRange opens multiple files and closes a range of them via close_range(2).
+func closeRange() error {
+ dir, cleanup, err := makeTempDir("close-range")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ var fds []int
+ for i := range 3 {
+ path := filepath.Join(dir, fmt.Sprintf("closerangefile-%d.txt", i))
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open %d: %w", i, err)
+ }
+ fds = append(fds, fd)
+ }
+
+ if fds[2]-fds[0] != 2 {
+ return fmt.Errorf("fds not contiguous: %v", fds)
+ }
+
+ first := uintptr(fds[0])
+ last := uintptr(fds[len(fds)-1])
+ _, _, errno := syscall.Syscall(sysCloseRange, first, last, 0)
+ if errno != 0 {
+ return fmt.Errorf("close_range: %w", errno)
+ }
+ return nil
+}
+
+const sysCloseRange = 436
+
// dupBasic opens a file, dups the fd, writes via the dup, closes both.
func dupBasic() error {
dir, cleanup, err := makeTempDir("dup-basic")
@@ -147,6 +324,63 @@ func dupBasic() error {
return nil
}
+// dupDup2 opens a file and duplicates the fd onto a specific target fd via dup2.
+func dupDup2() error {
+ dir, cleanup, err := makeTempDir("dup-dup2")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "dup2file.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ // Use a high fd number to avoid collisions.
+ targetFd := 500
+ if err := syscall.Dup2(fd, targetFd); err != nil {
+ return fmt.Errorf("dup2: %w", err)
+ }
+ defer syscall.Close(targetFd)
+
+ if _, err := syscall.Write(targetFd, []byte("via dup2")); err != nil {
+ return fmt.Errorf("write via dup2: %w", err)
+ }
+ return nil
+}
+
+// dupDup3 opens a file and duplicates the fd onto a specific target fd via dup3
+// with O_CLOEXEC flag.
+func dupDup3() error {
+ dir, cleanup, err := makeTempDir("dup-dup3")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "dup3file.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ // Use a high fd number to avoid collisions.
+ targetFd := 501
+ if err := syscall.Dup3(fd, targetFd, syscall.O_CLOEXEC); err != nil {
+ return fmt.Errorf("dup3: %w", err)
+ }
+ defer syscall.Close(targetFd)
+
+ if _, err := syscall.Write(targetFd, []byte("via dup3")); err != nil {
+ return fmt.Errorf("write via dup3: %w", err)
+ }
+ return nil
+}
+
// fcntlDupfd uses fcntl F_DUPFD to duplicate a file descriptor.
func fcntlDupfd() error {
dir, cleanup, err := makeTempDir("fcntl-dupfd")
@@ -174,7 +408,67 @@ func fcntlDupfd() error {
return nil
}
-// renameBasic creates a file and renames it.
+// fcntlSetfl uses fcntl F_GETFL/F_SETFL to read and modify file status flags.
+func fcntlSetfl() error {
+ dir, cleanup, err := makeTempDir("fcntl-setfl")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "fcntlsetflfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFL, 0)
+ if errno != 0 {
+ return fmt.Errorf("fcntl F_GETFL: %w", errno)
+ }
+
+ _, _, errno = syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_SETFL, flags|syscall.O_APPEND)
+ if errno != 0 {
+ return fmt.Errorf("fcntl F_SETFL: %w", errno)
+ }
+
+ if _, err := syscall.Write(fd, []byte("appended via fcntl setfl")); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ return nil
+}
+
+// fcntlDupfdCloexec uses fcntl F_DUPFD_CLOEXEC to duplicate a file descriptor
+// with the close-on-exec flag set.
+func fcntlDupfdCloexec() error {
+ dir, cleanup, err := makeTempDir("fcntl-dupfd-cloexec")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "fcntlcloexecfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ newFd, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_DUPFD_CLOEXEC, 0)
+ if errno != 0 {
+ return fmt.Errorf("fcntl F_DUPFD_CLOEXEC: %w", errno)
+ }
+ defer syscall.Close(int(newFd))
+
+ if _, err := syscall.Write(int(newFd), []byte("via fcntl dupfd cloexec")); err != nil {
+ return fmt.Errorf("write via fcntl dup cloexec: %w", err)
+ }
+ return nil
+}
+
+// renameBasic creates a file and renames it via rename(2).
+// Uses raw SYS_RENAME because Go's syscall.Rename wraps renameat on amd64.
func renameBasic() error {
dir, cleanup, err := makeTempDir("rename-basic")
if err != nil {
@@ -193,10 +487,138 @@ func renameBasic() error {
return fmt.Errorf("close: %w", err)
}
- return syscall.Rename(oldPath, newPath)
+ oldBytes, err := syscall.BytePtrFromString(oldPath)
+ if err != nil {
+ return fmt.Errorf("old path bytes: %w", err)
+ }
+ newBytes, err := syscall.BytePtrFromString(newPath)
+ if err != nil {
+ return fmt.Errorf("new path bytes: %w", err)
+ }
+
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_RENAME,
+ uintptr(unsafe.Pointer(oldBytes)),
+ uintptr(unsafe.Pointer(newBytes)),
+ 0,
+ )
+ runtime.KeepAlive(oldBytes)
+ runtime.KeepAlive(newBytes)
+ if errno != 0 {
+ return fmt.Errorf("rename: %w", errno)
+ }
+ return nil
+}
+
+// renameRenameat creates a file and renames it via renameat(2).
+func renameRenameat() error {
+ dir, cleanup, err := makeTempDir("rename-renameat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ oldName := "renameat-old.txt"
+ newName := "renameat-new.txt"
+ path := filepath.Join(dir, oldName)
+
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ oldBytes, err := syscall.BytePtrFromString(oldName)
+ if err != nil {
+ return fmt.Errorf("old name bytes: %w", err)
+ }
+ newBytes, err := syscall.BytePtrFromString(newName)
+ if err != nil {
+ return fmt.Errorf("new name bytes: %w", err)
+ }
+
+ _, _, errno := syscall.Syscall6(
+ syscall.SYS_RENAMEAT,
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(oldBytes)),
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(newBytes)),
+ 0, 0,
+ )
+ runtime.KeepAlive(oldBytes)
+ runtime.KeepAlive(newBytes)
+ if errno != 0 {
+ return fmt.Errorf("renameat: %w", errno)
+ }
+ return nil
+}
+
+const sysRenameat2 = 316 // SYS_RENAMEAT2 on amd64
+
+// renameRenameat2 creates a file and renames it via renameat2(2) with no flags.
+func renameRenameat2() error {
+ dir, cleanup, err := makeTempDir("rename-renameat2")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ oldName := "renameat2-old.txt"
+ newName := "renameat2-new.txt"
+ path := filepath.Join(dir, oldName)
+
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ oldBytes, err := syscall.BytePtrFromString(oldName)
+ if err != nil {
+ return fmt.Errorf("old name bytes: %w", err)
+ }
+ newBytes, err := syscall.BytePtrFromString(newName)
+ if err != nil {
+ return fmt.Errorf("new name bytes: %w", err)
+ }
+
+ _, _, errno := syscall.Syscall6(
+ sysRenameat2,
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(oldBytes)),
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(newBytes)),
+ 0, // flags=0: plain rename
+ 0,
+ )
+ runtime.KeepAlive(oldBytes)
+ runtime.KeepAlive(newBytes)
+ if errno != 0 {
+ return fmt.Errorf("renameat2: %w", errno)
+ }
+ return nil
}
-// linkBasic creates a file, hard links it, and symlinks it.
+// linkBasic creates a file, hard links it via link(2), symlinks it via
+// symlink(2), and reads the symlink via readlink(2).
+// Uses raw SYS_LINK, SYS_SYMLINK, SYS_READLINK because Go's syscall wrappers
+// delegate to linkat/symlinkat/readlinkat on amd64.
func linkBasic() error {
dir, cleanup, err := makeTempDir("link-basic")
if err != nil {
@@ -213,24 +635,238 @@ func linkBasic() error {
return fmt.Errorf("close: %w", err)
}
+ // Hard link via raw SYS_LINK.
hardPath := filepath.Join(dir, "hardlink.txt")
- if err := syscall.Link(origPath, hardPath); err != nil {
- return fmt.Errorf("link: %w", err)
+ origBytes, err := syscall.BytePtrFromString(origPath)
+ if err != nil {
+ return fmt.Errorf("orig path bytes: %w", err)
+ }
+ hardBytes, err := syscall.BytePtrFromString(hardPath)
+ if err != nil {
+ return fmt.Errorf("hard path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_LINK,
+ uintptr(unsafe.Pointer(origBytes)),
+ uintptr(unsafe.Pointer(hardBytes)),
+ 0,
+ )
+ runtime.KeepAlive(origBytes)
+ runtime.KeepAlive(hardBytes)
+ if errno != 0 {
+ return fmt.Errorf("link: %w", errno)
}
+ // Symlink via raw SYS_SYMLINK.
symPath := filepath.Join(dir, "symlink.txt")
- if err := syscall.Symlink(origPath, symPath); err != nil {
- return fmt.Errorf("symlink: %w", err)
+ targetBytes, err := syscall.BytePtrFromString(origPath)
+ if err != nil {
+ return fmt.Errorf("target path bytes: %w", err)
+ }
+ symBytes, err := syscall.BytePtrFromString(symPath)
+ if err != nil {
+ return fmt.Errorf("sym path bytes: %w", err)
+ }
+ _, _, errno = syscall.Syscall(
+ syscall.SYS_SYMLINK,
+ uintptr(unsafe.Pointer(targetBytes)),
+ uintptr(unsafe.Pointer(symBytes)),
+ 0,
+ )
+ runtime.KeepAlive(targetBytes)
+ runtime.KeepAlive(symBytes)
+ if errno != 0 {
+ return fmt.Errorf("symlink: %w", errno)
+ }
+
+ // Readlink via raw SYS_READLINK.
+ symBytes2, err := syscall.BytePtrFromString(symPath)
+ if err != nil {
+ return fmt.Errorf("sym path bytes: %w", err)
+ }
+ buf := make([]byte, 256)
+ _, _, errno = syscall.Syscall(
+ syscall.SYS_READLINK,
+ uintptr(unsafe.Pointer(symBytes2)),
+ uintptr(unsafe.Pointer(&buf[0])),
+ uintptr(len(buf)),
+ )
+ runtime.KeepAlive(symBytes2)
+ runtime.KeepAlive(buf)
+ if errno != 0 {
+ return fmt.Errorf("readlink: %w", errno)
+ }
+ return nil
+}
+
+// linkLinkat creates a file and hard links it via linkat(2).
+func linkLinkat() error {
+ dir, cleanup, err := makeTempDir("link-linkat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ origName := "linkat-original.txt"
+ origPath := filepath.Join(dir, origName)
+ fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ hardName := "linkat-hard.txt"
+ oldBytes, err := syscall.BytePtrFromString(origName)
+ if err != nil {
+ return fmt.Errorf("old name bytes: %w", err)
+ }
+ newBytes, err := syscall.BytePtrFromString(hardName)
+ if err != nil {
+ return fmt.Errorf("new name bytes: %w", err)
+ }
+
+ _, _, errno := syscall.Syscall6(
+ syscall.SYS_LINKAT,
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(oldBytes)),
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(newBytes)),
+ 0, // flags
+ 0,
+ )
+ runtime.KeepAlive(oldBytes)
+ runtime.KeepAlive(newBytes)
+ if errno != 0 {
+ return fmt.Errorf("linkat: %w", errno)
+ }
+ return nil
+}
+
+// linkSymlinkat creates a symlink via symlinkat(2).
+func linkSymlinkat() error {
+ dir, cleanup, err := makeTempDir("link-symlinkat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ origPath := filepath.Join(dir, "symlinkat-original.txt")
+ fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ targetBytes, err := syscall.BytePtrFromString(origPath)
+ if err != nil {
+ return fmt.Errorf("target bytes: %w", err)
+ }
+ linkName := "symlinkat-link.txt"
+ linkBytes, err := syscall.BytePtrFromString(linkName)
+ if err != nil {
+ return fmt.Errorf("link name bytes: %w", err)
+ }
+
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_SYMLINKAT,
+ uintptr(unsafe.Pointer(targetBytes)),
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(linkBytes)),
+ )
+ runtime.KeepAlive(targetBytes)
+ runtime.KeepAlive(linkBytes)
+ if errno != 0 {
+ return fmt.Errorf("symlinkat: %w", errno)
+ }
+ return nil
+}
+
+// linkReadlinkat creates a symlink, then reads it via readlinkat(2).
+func linkReadlinkat() error {
+ dir, cleanup, err := makeTempDir("link-readlinkat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ origPath := filepath.Join(dir, "readlinkat-original.txt")
+ fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ // Create symlink using raw SYS_SYMLINK so we don't mix tracepoints.
+ linkPath := filepath.Join(dir, "readlinkat-link.txt")
+ targetBytes, err := syscall.BytePtrFromString(origPath)
+ if err != nil {
+ return fmt.Errorf("target bytes: %w", err)
+ }
+ linkPathBytes, err := syscall.BytePtrFromString(linkPath)
+ if err != nil {
+ return fmt.Errorf("link path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_SYMLINK,
+ uintptr(unsafe.Pointer(targetBytes)),
+ uintptr(unsafe.Pointer(linkPathBytes)),
+ 0,
+ )
+ runtime.KeepAlive(targetBytes)
+ runtime.KeepAlive(linkPathBytes)
+ if errno != 0 {
+ return fmt.Errorf("symlink: %w", errno)
}
+ // Read via readlinkat(2).
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ linkName := "readlinkat-link.txt"
+ nameBytes, err := syscall.BytePtrFromString(linkName)
+ if err != nil {
+ return fmt.Errorf("link name bytes: %w", err)
+ }
buf := make([]byte, 256)
- if _, err := syscall.Readlink(symPath, buf); err != nil {
- return fmt.Errorf("readlink: %w", err)
+ _, _, errno = syscall.Syscall6(
+ syscall.SYS_READLINKAT,
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(nameBytes)),
+ uintptr(unsafe.Pointer(&buf[0])),
+ uintptr(len(buf)),
+ 0, 0,
+ )
+ runtime.KeepAlive(nameBytes)
+ runtime.KeepAlive(buf)
+ if errno != 0 {
+ return fmt.Errorf("readlinkat: %w", errno)
}
return nil
}
-// unlinkBasic creates a file and unlinks it.
+// unlinkBasic creates a file and unlinks it via raw SYS_UNLINK.
+// We use the raw syscall because Go's syscall.Unlink wraps unlinkat on amd64.
func unlinkBasic() error {
dir, cleanup, err := makeTempDir("unlink-basic")
if err != nil {
@@ -247,10 +883,82 @@ func unlinkBasic() error {
return fmt.Errorf("close: %w", err)
}
- return syscall.Unlink(path)
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_UNLINK, uintptr(unsafe.Pointer(pathBytes)), 0, 0)
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("unlink: %w", errno)
+ }
+ return nil
}
-// dirBasic creates a directory, checks access, then removes it.
+// unlinkUnlinkat creates a file and unlinks it via unlinkat(2).
+func unlinkUnlinkat() error {
+ dir, cleanup, err := makeTempDir("unlink-unlinkat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "unlinkat-file.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ nameBytes, err := syscall.BytePtrFromString("unlinkat-file.txt")
+ if err != nil {
+ return fmt.Errorf("name bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(dirFD), uintptr(unsafe.Pointer(nameBytes)), 0)
+ runtime.KeepAlive(nameBytes)
+ if errno != 0 {
+ return fmt.Errorf("unlinkat: %w", errno)
+ }
+ return nil
+}
+
+// unlinkRmdir creates a directory and removes it via raw SYS_RMDIR.
+// We use the raw syscall because Go's syscall.Rmdir wraps unlinkat on amd64.
+func unlinkRmdir() error {
+ dir, cleanup, err := makeTempDir("unlink-rmdir")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ subDir := filepath.Join(dir, "rmdir-me")
+ if err := syscall.Mkdir(subDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
+
+ pathBytes, err := syscall.BytePtrFromString(subDir)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_RMDIR, uintptr(unsafe.Pointer(pathBytes)), 0, 0)
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("rmdir: %w", errno)
+ }
+ return nil
+}
+
+// dirBasic creates a directory via raw SYS_MKDIR, checks access, then removes it
+// via raw SYS_RMDIR. We use raw syscalls because Go's syscall.Mkdir wraps mkdirat
+// and syscall.Rmdir wraps unlinkat on amd64.
func dirBasic() error {
dir, cleanup, err := makeTempDir("dir-basic")
if err != nil {
@@ -259,16 +967,102 @@ func dirBasic() error {
defer cleanup()
subDir := filepath.Join(dir, "subdir")
- if err := syscall.Mkdir(subDir, 0o755); err != nil {
- return fmt.Errorf("mkdir: %w", err)
+ pathBytes, err := syscall.BytePtrFromString(subDir)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
}
+ _, _, errno := syscall.Syscall(syscall.SYS_MKDIR, uintptr(unsafe.Pointer(pathBytes)), 0o755, 0)
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("mkdir: %w", errno)
+ }
+
if err := syscall.Access(subDir, syscall.F_OK); err != nil {
return fmt.Errorf("access: %w", err)
}
- return syscall.Rmdir(subDir)
+
+ pathBytes2, err := syscall.BytePtrFromString(subDir)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno = syscall.Syscall(syscall.SYS_RMDIR, uintptr(unsafe.Pointer(pathBytes2)), 0, 0)
+ runtime.KeepAlive(pathBytes2)
+ if errno != 0 {
+ return fmt.Errorf("rmdir: %w", errno)
+ }
+ return nil
}
-// statBasic creates a file and stats it.
+// dirMkdirat creates a directory via mkdirat(2) using Go's syscall.Mkdir
+// which wraps mkdirat with AT_FDCWD on amd64.
+func dirMkdirat() error {
+ dir, cleanup, err := makeTempDir("dir-mkdirat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ subDir := filepath.Join(dir, "mkdirat-subdir")
+ if err := syscall.Mkdir(subDir, 0o755); err != nil {
+ return fmt.Errorf("mkdirat: %w", err)
+ }
+ return nil
+}
+
+// dirChdir creates a temp directory, then changes to it via chdir(2).
+// Restores the original working directory afterward.
+func dirChdir() error {
+ origDir, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("getwd: %w", err)
+ }
+
+ dir, cleanup, err := makeTempDir("dir-chdir")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ defer syscall.Chdir(origDir)
+
+ if err := syscall.Chdir(dir); err != nil {
+ return fmt.Errorf("chdir: %w", err)
+ }
+ return nil
+}
+
+// dirGetdents opens a directory and reads its entries via getdents64(2).
+func dirGetdents() error {
+ dir, cleanup, err := makeTempDir("dir-getdents")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ // Create a file so getdents has something to return.
+ filePath := filepath.Join(dir, "getdents-file.txt")
+ fd, err := syscall.Open(filePath, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open file: %w", err)
+ }
+ syscall.Close(fd)
+
+ dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return fmt.Errorf("open dir: %w", err)
+ }
+ defer syscall.Close(dirFD)
+
+ buf := make([]byte, 4096)
+ _, _, errno := syscall.Syscall(syscall.SYS_GETDENTS64, uintptr(dirFD), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
+ runtime.KeepAlive(buf)
+ if errno != 0 {
+ return fmt.Errorf("getdents64: %w", errno)
+ }
+ return nil
+}
+
+// statBasic creates a file and stats it via raw SYS_STAT (newstat).
+// We use the raw syscall because Go's syscall.Stat wraps newfstatat on amd64.
func statBasic() error {
dir, cleanup, err := makeTempDir("stat-basic")
if err != nil {
@@ -281,14 +1075,191 @@ func statBasic() error {
if err != nil {
return fmt.Errorf("open: %w", err)
}
+ syscall.Close(fd)
+
+ var stat syscall.Stat_t
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_STAT, uintptr(unsafe.Pointer(pathBytes)), uintptr(unsafe.Pointer(&stat)), 0)
+ runtime.KeepAlive(pathBytes)
+ runtime.KeepAlive(&stat)
+ if errno != 0 {
+ return fmt.Errorf("stat: %w", errno)
+ }
+ return nil
+}
+
+// statFstat creates a file and stats it via raw SYS_FSTAT (newfstat).
+// This is an fd_event, so ior resolves the path via its fd lookup table.
+func statFstat() error {
+ dir, cleanup, err := makeTempDir("stat-fstat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "fstatfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
defer syscall.Close(fd)
var stat syscall.Stat_t
- if err := syscall.Fstat(fd, &stat); err != nil {
- return fmt.Errorf("fstat: %w", err)
+ _, _, errno := syscall.Syscall(syscall.SYS_FSTAT, uintptr(fd), uintptr(unsafe.Pointer(&stat)), 0)
+ runtime.KeepAlive(&stat)
+ if errno != 0 {
+ return fmt.Errorf("fstat: %w", errno)
+ }
+ return nil
+}
+
+// statLstat creates a file and stats it via raw SYS_LSTAT (newlstat).
+// We use the raw syscall because Go's syscall.Lstat wraps newfstatat on amd64.
+func statLstat() error {
+ dir, cleanup, err := makeTempDir("stat-lstat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "lstatfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ var stat syscall.Stat_t
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_LSTAT, uintptr(unsafe.Pointer(pathBytes)), uintptr(unsafe.Pointer(&stat)), 0)
+ runtime.KeepAlive(pathBytes)
+ runtime.KeepAlive(&stat)
+ if errno != 0 {
+ return fmt.Errorf("lstat: %w", errno)
}
+ return nil
+}
+
+// statNewfstatat creates a file and stats it via Go's syscall.Stat, which
+// wraps SYS_NEWFSTATAT (fstatat with AT_FDCWD) on amd64.
+func statNewfstatat() error {
+ dir, cleanup, err := makeTempDir("stat-newfstatat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "fstatatfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ var stat syscall.Stat_t
if err := syscall.Stat(path, &stat); err != nil {
- return fmt.Errorf("stat: %w", err)
+ return fmt.Errorf("newfstatat: %w", err)
+ }
+ return nil
+}
+
+const (
+ sysStatx = 332
+ rOK = 0x4 // R_OK
+ statxBasicMask = 0x07ff // STATX_BASIC_STATS
+)
+
+const atFDCwd = -100 // AT_FDCWD
+
+// statStatx creates a file and stats it via raw statx(2) syscall.
+func statStatx() error {
+ dir, cleanup, err := makeTempDir("stat-statx")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "statxfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ var buf [256]byte // statx struct is ~256 bytes
+ _, _, errno := syscall.Syscall6(
+ sysStatx,
+ ^uintptr(99), // AT_FDCWD (-100)
+ uintptr(unsafe.Pointer(pathBytes)),
+ 0,
+ statxBasicMask,
+ uintptr(unsafe.Pointer(&buf[0])),
+ 0,
+ )
+ runtime.KeepAlive(pathBytes)
+ runtime.KeepAlive(buf)
+ if errno != 0 {
+ return fmt.Errorf("statx: %w", errno)
+ }
+ return nil
+}
+
+// statAccess creates a file and checks access via raw SYS_ACCESS.
+// We use the raw syscall because Go's syscall.Access wraps faccessat on amd64.
+func statAccess() error {
+ dir, cleanup, err := makeTempDir("stat-access")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "accessfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_ACCESS, uintptr(unsafe.Pointer(pathBytes)), rOK, 0)
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("access: %w", errno)
+ }
+ return nil
+}
+
+// statFaccessat creates a file and checks access via faccessat(2).
+// Go's syscall.Faccessat wraps SYS_FACCESSAT.
+func statFaccessat() error {
+ dir, cleanup, err := makeTempDir("stat-faccessat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "faccessatfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ if err := syscall.Faccessat(atFDCwd, path, uint32(rOK), 0); err != nil {
+ return fmt.Errorf("faccessat: %w", err)
}
return nil
}
@@ -334,3 +1305,106 @@ func truncateBasic() error {
}
return syscall.Ftruncate(fd, 5)
}
+
+// openByHandleAt creates a file, resolves its handle via name_to_handle_at,
+// then opens it via open_by_handle_at. Requires root (CAP_DAC_READ_SEARCH).
+// LockOSThread prevents goroutine migration between the two syscalls so that
+// ior sees the same TID for both and can correlate the path.
+func openByHandleAt() error {
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ dir, cleanup, err := makeTempDir("open-by-handle-at")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "handlefile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ if err := syscall.Close(fd); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+
+ handle, mountFD, err := nameToHandleAt(dir, "handlefile.txt")
+ if err != nil {
+ return fmt.Errorf("name_to_handle_at: %w", err)
+ }
+ defer syscall.Close(mountFD)
+
+ fd2, err := openByHandleAtSyscall(mountFD, handle, syscall.O_RDONLY)
+ if err != nil {
+ return fmt.Errorf("open_by_handle_at: %w", err)
+ }
+ return syscall.Close(fd2)
+}
+
+// fileHandle matches the kernel's struct file_handle layout.
+type fileHandle struct {
+ Size uint32
+ Type int32
+ // Handle bytes follow immediately after.
+}
+
+const (
+ sysNameToHandleAt = 303
+ sysOpenByHandleAt = 304
+)
+
+// nameToHandleAt calls name_to_handle_at(2) and returns the file handle
+// and the directory fd. The caller can pass this dirFD as the mount_fd
+// argument to open_by_handle_at since any fd on the same filesystem works.
+func nameToHandleAt(dirPath, name string) ([]byte, int, error) {
+ dirFD, err := syscall.Open(dirPath, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
+ if err != nil {
+ return nil, 0, fmt.Errorf("open dir: %w", err)
+ }
+
+ nameBytes, err := syscall.BytePtrFromString(name)
+ if err != nil {
+ syscall.Close(dirFD)
+ return nil, 0, fmt.Errorf("name bytes: %w", err)
+ }
+
+ // Start with a buffer large enough for most handles.
+ buf := make([]byte, unsafe.Sizeof(fileHandle{})+128)
+ fh := (*fileHandle)(unsafe.Pointer(&buf[0]))
+ fh.Size = uint32(len(buf) - int(unsafe.Sizeof(fileHandle{})))
+
+ var mountID int32
+ _, _, errno := syscall.Syscall6(
+ sysNameToHandleAt,
+ uintptr(dirFD),
+ uintptr(unsafe.Pointer(nameBytes)),
+ uintptr(unsafe.Pointer(fh)),
+ uintptr(unsafe.Pointer(&mountID)),
+ 0, 0,
+ )
+ if errno != 0 {
+ syscall.Close(dirFD)
+ return nil, 0, fmt.Errorf("syscall: %w", errno)
+ }
+
+ handleLen := int(unsafe.Sizeof(fileHandle{})) + int(fh.Size)
+ handle := make([]byte, handleLen)
+ copy(handle, buf[:handleLen])
+
+ return handle, dirFD, nil
+}
+
+// openByHandleAtSyscall calls open_by_handle_at(2).
+func openByHandleAtSyscall(mountFD int, handle []byte, flags int) (int, error) {
+ fd, _, errno := syscall.Syscall(
+ sysOpenByHandleAt,
+ uintptr(mountFD),
+ uintptr(unsafe.Pointer(&handle[0])),
+ uintptr(flags),
+ )
+ if errno != 0 {
+ return 0, fmt.Errorf("syscall: %w", errno)
+ }
+ return int(fd), nil
+}
diff --git a/integrationtests/dir_test.go b/integrationtests/dir_test.go
new file mode 100644
index 0000000..67bbe93
--- /dev/null
+++ b/integrationtests/dir_test.go
@@ -0,0 +1,47 @@
+package integrationtests
+
+import "testing"
+
+func TestDirBasic(t *testing.T) {
+ runScenario(t, "dir-basic", []ExpectedEvent{
+ {
+ PathContains: "subdir",
+ Tracepoint: "enter_mkdir",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestDirMkdirat(t *testing.T) {
+ runScenario(t, "dir-mkdirat", []ExpectedEvent{
+ {
+ PathContains: "mkdirat-subdir",
+ Tracepoint: "enter_mkdirat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestDirChdir(t *testing.T) {
+ runScenario(t, "dir-chdir", []ExpectedEvent{
+ {
+ PathContains: "dir-chdir",
+ Tracepoint: "enter_chdir",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestDirGetdents(t *testing.T) {
+ runScenario(t, "dir-getdents", []ExpectedEvent{
+ {
+ PathContains: "dir-getdents",
+ Tracepoint: "enter_getdents64",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/dup_test.go b/integrationtests/dup_test.go
new file mode 100644
index 0000000..6289465
--- /dev/null
+++ b/integrationtests/dup_test.go
@@ -0,0 +1,36 @@
+package integrationtests
+
+import "testing"
+
+func TestDupBasic(t *testing.T) {
+ runScenario(t, "dup-basic", []ExpectedEvent{
+ {
+ PathContains: "dupfile.txt",
+ Tracepoint: "enter_dup",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestDupDup2(t *testing.T) {
+ runScenario(t, "dup-dup2", []ExpectedEvent{
+ {
+ PathContains: "dup2file.txt",
+ Tracepoint: "enter_dup2",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestDupDup3(t *testing.T) {
+ runScenario(t, "dup-dup3", []ExpectedEvent{
+ {
+ PathContains: "dup3file.txt",
+ Tracepoint: "enter_dup3",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/fcntl_test.go b/integrationtests/fcntl_test.go
new file mode 100644
index 0000000..f9c95bd
--- /dev/null
+++ b/integrationtests/fcntl_test.go
@@ -0,0 +1,36 @@
+package integrationtests
+
+import "testing"
+
+func TestFcntlDupfd(t *testing.T) {
+ runScenario(t, "fcntl-dupfd", []ExpectedEvent{
+ {
+ PathContains: "fcntlfile.txt",
+ Tracepoint: "enter_fcntl",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestFcntlSetfl(t *testing.T) {
+ runScenario(t, "fcntl-setfl", []ExpectedEvent{
+ {
+ PathContains: "fcntlsetflfile.txt",
+ Tracepoint: "enter_fcntl",
+ Comm: "ioworkload",
+ MinCount: 2,
+ },
+ })
+}
+
+func TestFcntlDupfdCloexec(t *testing.T) {
+ runScenario(t, "fcntl-dupfd-cloexec", []ExpectedEvent{
+ {
+ PathContains: "fcntlcloexecfile.txt",
+ Tracepoint: "enter_fcntl",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/harness.go b/integrationtests/harness.go
new file mode 100644
index 0000000..315fec4
--- /dev/null
+++ b/integrationtests/harness.go
@@ -0,0 +1,179 @@
+package integrationtests
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ workloadStartupTimeout = 5 * time.Second
+ iorShutdownGrace = 3 * time.Second
+)
+
+// TestHarness orchestrates integration tests by starting an ior trace
+// against a known ioworkload process and collecting the .ior.zst output.
+type TestHarness struct {
+ IorBinary string // path to built ior binary
+ WorkloadBinary string // path to built ioworkload binary
+ BpfObject string // path to ior.bpf.o
+ OutputDir string // temp dir for .ior.zst output
+}
+
+// Run executes a single integration test scenario. It starts the ioworkload
+// binary, reads its PID from stdout, launches ior with a PID filter, waits
+// for both to finish, and parses the resulting .ior.zst file.
+func (h *TestHarness) Run(scenario string, duration int) (TestResult, int, error) {
+ workloadCmd, workloadPID, err := h.startWorkload(scenario)
+ if err != nil {
+ return TestResult{}, 0, err
+ }
+
+ iorCmd, err := h.startIor(workloadPID, scenario, duration)
+ if err != nil {
+ workloadCmd.Process.Kill()
+ workloadCmd.Wait()
+ return TestResult{}, workloadPID, err
+ }
+
+ workloadErr, iorErr := waitBoth(workloadCmd, iorCmd, duration)
+
+ if iorErr != nil {
+ return TestResult{}, workloadPID, fmt.Errorf("ior: %w", iorErr)
+ }
+ if workloadErr != nil {
+ return TestResult{}, workloadPID, fmt.Errorf("workload: %w", workloadErr)
+ }
+
+ iorFile, err := findIorZstFile(h.OutputDir, scenario)
+ if err != nil {
+ return TestResult{}, workloadPID, fmt.Errorf("find .ior.zst: %w", err)
+ }
+
+ result, err := LoadTestResult(iorFile)
+ if err != nil {
+ return TestResult{}, workloadPID, fmt.Errorf("parse result: %w", err)
+ }
+
+ return result, workloadPID, nil
+}
+
+func (h *TestHarness) startWorkload(scenario string) (*exec.Cmd, int, error) {
+ cmd := exec.Command(h.WorkloadBinary, "--scenario="+scenario)
+ cmd.Stderr = os.Stderr
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, 0, fmt.Errorf("workload stdout pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return nil, 0, fmt.Errorf("start workload: %w", err)
+ }
+
+ pidCh := make(chan int, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ scanner := bufio.NewScanner(stdout)
+ if scanner.Scan() {
+ pid, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
+ if err != nil {
+ errCh <- fmt.Errorf("parse workload PID: %w", err)
+ return
+ }
+ pidCh <- pid
+ } else if err := scanner.Err(); err != nil {
+ errCh <- fmt.Errorf("reading workload stdout: %w", err)
+ } else {
+ errCh <- fmt.Errorf("workload produced no output")
+ }
+ }()
+
+ select {
+ case pid := <-pidCh:
+ return cmd, pid, nil
+ case err := <-errCh:
+ cmd.Process.Kill()
+ cmd.Wait()
+ return nil, 0, err
+ case <-time.After(workloadStartupTimeout):
+ cmd.Process.Kill()
+ cmd.Wait()
+ return nil, 0, fmt.Errorf("timeout waiting for workload PID")
+ }
+}
+
+func (h *TestHarness) startIor(pid int, scenario string, duration int) (*exec.Cmd, error) {
+ cmd := exec.Command(h.IorBinary,
+ "-pid", strconv.Itoa(pid),
+ "-flamegraph",
+ "-name", scenario,
+ "-duration", strconv.Itoa(duration),
+ )
+ cmd.Dir = h.OutputDir
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start ior: %w", err)
+ }
+ return cmd, nil
+}
+
+// waitBoth waits for both the workload and ior commands concurrently.
+// If ior does not finish within duration + grace period, it is killed.
+func waitBoth(workloadCmd, iorCmd *exec.Cmd, duration int) (workloadErr, iorErr error) {
+ workloadDone := make(chan error, 1)
+ iorDone := make(chan error, 1)
+
+ go func() { workloadDone <- workloadCmd.Wait() }()
+ go func() { iorDone <- iorCmd.Wait() }()
+
+ timeout := time.After(time.Duration(duration)*time.Second + iorShutdownGrace)
+
+ for workloadDone != nil || iorDone != nil {
+ select {
+ case err := <-workloadDone:
+ workloadErr = err
+ workloadDone = nil
+ case err := <-iorDone:
+ iorErr = err
+ iorDone = nil
+ case <-timeout:
+ if iorDone != nil {
+ iorCmd.Process.Kill()
+ iorErr = fmt.Errorf("ior timed out")
+ iorDone = nil
+ }
+ if workloadDone != nil {
+ workloadCmd.Process.Kill()
+ workloadErr = fmt.Errorf("workload timed out")
+ workloadDone = nil
+ }
+ return
+ }
+ }
+ return
+}
+
+// findIorZstFile locates the .ior.zst file matching the scenario name in the output directory.
+func findIorZstFile(dir, scenario string) (string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", fmt.Errorf("read output dir: %w", err)
+ }
+
+ for _, e := range entries {
+ name := e.Name()
+ if strings.Contains(name, scenario) && strings.HasSuffix(name, ".ior.zst") {
+ return filepath.Join(dir, name), nil
+ }
+ }
+
+ return "", fmt.Errorf("no .ior.zst file found for scenario %q in %s", scenario, dir)
+}
diff --git a/integrationtests/helpers_test.go b/integrationtests/helpers_test.go
new file mode 100644
index 0000000..edf57b9
--- /dev/null
+++ b/integrationtests/helpers_test.go
@@ -0,0 +1,50 @@
+package integrationtests
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+const (
+ iorBinaryDefault = "../ior"
+ workloadBinaryDefault = "../ioworkload"
+ bpfObjectDefault = "../ior.bpf.o"
+ defaultDuration = 10
+)
+
+func newTestHarness(t *testing.T) TestHarness {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Skip("requires root for BPF")
+ }
+
+ return TestHarness{
+ IorBinary: absPath(t, iorBinaryDefault),
+ WorkloadBinary: absPath(t, workloadBinaryDefault),
+ BpfObject: absPath(t, bpfObjectDefault),
+ OutputDir: t.TempDir(),
+ }
+}
+
+func absPath(t *testing.T, rel string) string {
+ t.Helper()
+ p, err := filepath.Abs(rel)
+ if err != nil {
+ t.Fatalf("resolve path %s: %v", rel, err)
+ }
+ return p
+}
+
+func runScenario(t *testing.T, scenario string, expected []ExpectedEvent) {
+ t.Helper()
+ h := newTestHarness(t)
+ result, pid, err := h.Run(scenario, defaultDuration)
+ if err != nil {
+ t.Fatalf("run scenario %s: %v", scenario, err)
+ }
+
+ AssertNoUnexpectedPID(t, result, pid)
+ AssertNoUnexpectedComm(t, result, "ioworkload")
+ AssertEventsPresent(t, result, expected)
+}
diff --git a/integrationtests/link_test.go b/integrationtests/link_test.go
new file mode 100644
index 0000000..fcebffb
--- /dev/null
+++ b/integrationtests/link_test.go
@@ -0,0 +1,59 @@
+package integrationtests
+
+import "testing"
+
+func TestLinkBasic(t *testing.T) {
+ runScenario(t, "link-basic", []ExpectedEvent{
+ {
+ PathContains: "hardlink.txt",
+ Tracepoint: "enter_link",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ {
+ PathContains: "symlink.txt",
+ Tracepoint: "enter_symlink",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ {
+ PathContains: "symlink.txt",
+ Tracepoint: "enter_readlink",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestLinkLinkat(t *testing.T) {
+ runScenario(t, "link-linkat", []ExpectedEvent{
+ {
+ PathContains: "linkat-hard.txt",
+ Tracepoint: "enter_linkat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestLinkSymlinkat(t *testing.T) {
+ runScenario(t, "link-symlinkat", []ExpectedEvent{
+ {
+ PathContains: "symlinkat-link.txt",
+ Tracepoint: "enter_symlinkat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestLinkReadlinkat(t *testing.T) {
+ runScenario(t, "link-readlinkat", []ExpectedEvent{
+ {
+ PathContains: "readlinkat-link.txt",
+ Tracepoint: "enter_readlinkat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/open_test.go b/integrationtests/open_test.go
new file mode 100644
index 0000000..3f07ae3
--- /dev/null
+++ b/integrationtests/open_test.go
@@ -0,0 +1,36 @@
+package integrationtests
+
+import "testing"
+
+func TestOpenBasic(t *testing.T) {
+ runScenario(t, "open-basic", []ExpectedEvent{
+ {
+ PathContains: "testfile.txt",
+ Tracepoint: "enter_openat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestOpenCreat(t *testing.T) {
+ runScenario(t, "open-creat", []ExpectedEvent{
+ {
+ PathContains: "creatfile.txt",
+ Tracepoint: "enter_openat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestOpenByHandleAt(t *testing.T) {
+ runScenario(t, "open-by-handle-at", []ExpectedEvent{
+ {
+ PathContains: "handlefile.txt",
+ Tracepoint: "enter_open_by_handle_at",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/readwrite_test.go b/integrationtests/readwrite_test.go
new file mode 100644
index 0000000..3770ae1
--- /dev/null
+++ b/integrationtests/readwrite_test.go
@@ -0,0 +1,64 @@
+package integrationtests
+
+import "testing"
+
+func TestReadwriteBasic(t *testing.T) {
+ runScenario(t, "readwrite-basic", []ExpectedEvent{
+ {
+ PathContains: "rwfile.txt",
+ Tracepoint: "enter_write",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ {
+ PathContains: "rwfile.txt",
+ Tracepoint: "enter_read",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestReadwritePread(t *testing.T) {
+ runScenario(t, "readwrite-pread", []ExpectedEvent{
+ {
+ PathContains: "preadfile.txt",
+ Tracepoint: "enter_pread64",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestReadwritePwrite(t *testing.T) {
+ runScenario(t, "readwrite-pwrite", []ExpectedEvent{
+ {
+ PathContains: "pwritefile.txt",
+ Tracepoint: "enter_pwrite64",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestReadwriteReadv(t *testing.T) {
+ runScenario(t, "readwrite-readv", []ExpectedEvent{
+ {
+ PathContains: "readvfile.txt",
+ Tracepoint: "enter_readv",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestReadwriteWritev(t *testing.T) {
+ runScenario(t, "readwrite-writev", []ExpectedEvent{
+ {
+ PathContains: "writevfile.txt",
+ Tracepoint: "enter_writev",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/rename_test.go b/integrationtests/rename_test.go
new file mode 100644
index 0000000..8e3238d
--- /dev/null
+++ b/integrationtests/rename_test.go
@@ -0,0 +1,36 @@
+package integrationtests
+
+import "testing"
+
+func TestRenameBasic(t *testing.T) {
+ runScenario(t, "rename-basic", []ExpectedEvent{
+ {
+ PathContains: "oldname.txt",
+ Tracepoint: "enter_rename",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestRenameRenameat(t *testing.T) {
+ runScenario(t, "rename-renameat", []ExpectedEvent{
+ {
+ PathContains: "renameat-old.txt",
+ Tracepoint: "enter_renameat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestRenameRenameat2(t *testing.T) {
+ runScenario(t, "rename-renameat2", []ExpectedEvent{
+ {
+ PathContains: "renameat2-old.txt",
+ Tracepoint: "enter_renameat2",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/stat_test.go b/integrationtests/stat_test.go
new file mode 100644
index 0000000..e38171a
--- /dev/null
+++ b/integrationtests/stat_test.go
@@ -0,0 +1,80 @@
+package integrationtests
+
+import "testing"
+
+func TestStatBasic(t *testing.T) {
+ runScenario(t, "stat-basic", []ExpectedEvent{
+ {
+ PathContains: "statfile.txt",
+ Tracepoint: "enter_newstat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatFstat(t *testing.T) {
+ runScenario(t, "stat-fstat", []ExpectedEvent{
+ {
+ PathContains: "fstatfile.txt",
+ Tracepoint: "enter_newfstat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatLstat(t *testing.T) {
+ runScenario(t, "stat-lstat", []ExpectedEvent{
+ {
+ PathContains: "lstatfile.txt",
+ Tracepoint: "enter_newlstat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatNewfstatat(t *testing.T) {
+ runScenario(t, "stat-newfstatat", []ExpectedEvent{
+ {
+ PathContains: "fstatatfile.txt",
+ Tracepoint: "enter_newfstatat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatStatx(t *testing.T) {
+ runScenario(t, "stat-statx", []ExpectedEvent{
+ {
+ PathContains: "statxfile.txt",
+ Tracepoint: "enter_statx",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatAccess(t *testing.T) {
+ runScenario(t, "stat-access", []ExpectedEvent{
+ {
+ PathContains: "accessfile.txt",
+ Tracepoint: "enter_access",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestStatFaccessat(t *testing.T) {
+ runScenario(t, "stat-faccessat", []ExpectedEvent{
+ {
+ PathContains: "faccessatfile.txt",
+ Tracepoint: "enter_faccessat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
diff --git a/integrationtests/unlink_test.go b/integrationtests/unlink_test.go
new file mode 100644
index 0000000..942f43d
--- /dev/null
+++ b/integrationtests/unlink_test.go
@@ -0,0 +1,36 @@
+package integrationtests
+
+import "testing"
+
+func TestUnlinkBasic(t *testing.T) {
+ runScenario(t, "unlink-basic", []ExpectedEvent{
+ {
+ PathContains: "unlinkme.txt",
+ Tracepoint: "enter_unlink",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestUnlinkUnlinkat(t *testing.T) {
+ runScenario(t, "unlink-unlinkat", []ExpectedEvent{
+ {
+ PathContains: "unlinkat-file.txt",
+ Tracepoint: "enter_unlinkat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}
+
+func TestUnlinkRmdir(t *testing.T) {
+ runScenario(t, "unlink-rmdir", []ExpectedEvent{
+ {
+ PathContains: "rmdir-me",
+ Tracepoint: "enter_rmdir",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ })
+}