summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-06 09:19:02 +0300
committerPaul Buetow <paul@buetow.org>2026-06-06 09:19:02 +0300
commit322496c2863d5bc14b0a5e4af16690bf19073cae (patch)
treeede84dee5e86f732c7016e2893c726ae8289abd7 /cmd
parente44475525894cb5a184e7fecd449c258f204f189 (diff)
test(chown): add end-to-end coverage for chown/fchown/lchown/fchownat family
chown/lchown/fchownat/fchown previously had no integration coverage: no scenario in cmd/ioworkload/ and no test in integrationtests/. All four are correctly classified (chown/lchown KindPathname args[0]; fchownat KindPathname args[1]; fchown KindFd args[0]; all FamilyFS, all UNCLASSIFIED ret) but nothing exercised them end-to-end. Add a chown-basic scenario that, on a temp file and a symlink the caller owns, issues raw chown(path,-1,-1), lchown(symlink,-1,-1), fchownat(AT_FDCWD,path, -1,-1,0) and fchown(fd,-1,-1). Owner/group -1/-1 means "leave both ids unchanged", which the kernel accepts without CAP_CHOWN, so the scenario is fully UNPRIVILEGED and nothing is actually modified. Raw syscalls are used so each distinct tracepoint fires rather than glibc redirecting chown to fchownat. TestChownBasic asserts enter_chown and enter_fchownat capture the regular file path (chownfile.txt), enter_lchown captures the symlink (chownlink), and enter_fchown fires (KindFd, no path). Mirrors the chmod coverage (scenario_chmod.go / chmod_test.go). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/ioworkload/scenario_chown.go150
-rw-r--r--cmd/ioworkload/scenarios.go1
2 files changed, 151 insertions, 0 deletions
diff --git a/cmd/ioworkload/scenario_chown.go b/cmd/ioworkload/scenario_chown.go
new file mode 100644
index 0000000..e171284
--- /dev/null
+++ b/cmd/ioworkload/scenario_chown.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "syscall"
+ "unsafe"
+)
+
+// chownBasic drives the chown ownership-change family end-to-end on a file and
+// a symlink the caller owns. Every call passes owner/group -1/-1 ("don't
+// change either id"), which the kernel accepts without CAP_CHOWN, so the whole
+// scenario is UNPRIVILEGED and nothing is actually modified. It exercises, in
+// order:
+//
+// - chown(path, -1, -1) — path at args[0], KindPathname, FamilyFS
+// - lchown(symlink, -1, -1) — path at args[0], KindPathname, FamilyFS
+// - fchownat(AT_FDCWD, path, -1,-1,0) — path at args[1], KindPathname, FamilyFS
+// - fchown(fd, -1, -1) — fd at args[0], KindFd, FamilyFS
+//
+// We use raw syscalls (rather than os.Chown / syscall.Chown etc.) so each
+// distinct tracepoint actually fires; glibc/Go wrappers can redirect chown to
+// fchownat and hide the syscall under test. The temp file and symlink are
+// cleaned up afterwards.
+func chownBasic() error {
+ dir, cleanup, err := makeTempDir("chown-basic")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "chownfile.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)
+
+ symlink := filepath.Join(dir, "chownlink")
+ if err := syscall.Symlink(path, symlink); err != nil {
+ return fmt.Errorf("symlink: %w", err)
+ }
+
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ symlinkBytes, err := syscall.BytePtrFromString(symlink)
+ if err != nil {
+ return fmt.Errorf("symlink bytes: %w", err)
+ }
+
+ if err := callChown(pathBytes); err != nil {
+ return err
+ }
+ if err := callLchown(symlinkBytes); err != nil {
+ return err
+ }
+ if err := callFchownat(pathBytes); err != nil {
+ return err
+ }
+ if err := callFchown(fd); err != nil {
+ return err
+ }
+ return nil
+}
+
+// callChown issues raw chown(path, -1, -1). chown takes the filesystem path at
+// args[0], so ior captures it as a KindPathname event under enter_chown. Owner
+// and group -1 mean "leave both unchanged", so no CAP_CHOWN is required.
+func callChown(pathBytes *byte) error {
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_CHOWN,
+ uintptr(unsafe.Pointer(pathBytes)),
+ uintptr(_UID_NOCHANGE),
+ uintptr(_GID_NOCHANGE),
+ )
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("chown: %w", errno)
+ }
+ return nil
+}
+
+// callLchown issues raw lchown(symlink, -1, -1). Like chown the path is at
+// args[0] (KindPathname), but lchown acts on the symlink itself rather than its
+// target. The -1/-1 owner/group keep it unprivileged.
+func callLchown(symlinkBytes *byte) error {
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_LCHOWN,
+ uintptr(unsafe.Pointer(symlinkBytes)),
+ uintptr(_UID_NOCHANGE),
+ uintptr(_GID_NOCHANGE),
+ )
+ runtime.KeepAlive(symlinkBytes)
+ if errno != 0 {
+ return fmt.Errorf("lchown: %w", errno)
+ }
+ return nil
+}
+
+// callFchownat issues raw fchownat(AT_FDCWD, path, -1, -1, 0). The path is at
+// args[1] (after the dirfd), so ior captures it as a KindPathname event under
+// enter_fchownat. A runtime int holds AT_FDCWD so the negative value survives
+// the uintptr conversion instead of overflowing.
+func callFchownat(pathBytes *byte) error {
+ dirfd := _AT_FDCWD
+ _, _, errno := syscall.Syscall6(
+ syscall.SYS_FCHOWNAT,
+ uintptr(dirfd),
+ uintptr(unsafe.Pointer(pathBytes)),
+ uintptr(_UID_NOCHANGE),
+ uintptr(_GID_NOCHANGE),
+ 0, // flags
+ 0,
+ )
+ runtime.KeepAlive(pathBytes)
+ if errno != 0 {
+ return fmt.Errorf("fchownat: %w", errno)
+ }
+ return nil
+}
+
+// callFchown issues raw fchown(fd, -1, -1). fchown operates on an open fd at
+// args[0] (KindFd) and carries no path, so ior records enter_fchown keyed by
+// the descriptor rather than a filename.
+func callFchown(fd int) error {
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_FCHOWN,
+ uintptr(fd),
+ uintptr(_UID_NOCHANGE),
+ uintptr(_GID_NOCHANGE),
+ )
+ if errno != 0 {
+ return fmt.Errorf("fchown: %w", errno)
+ }
+ return nil
+}
+
+// _UID_NOCHANGE and _GID_NOCHANGE are the (uid_t)-1 / (gid_t)-1 sentinels that
+// tell the chown family "leave this id unchanged". Using them for both owner
+// and group means the call performs no ownership change and therefore needs no
+// CAP_CHOWN, keeping the scenario fully unprivileged. They are -1 cast to the
+// unsigned 32-bit id types, i.e. 0xFFFFFFFF, which we form directly so the
+// value passed through uintptr is the kernel's expected (uid_t)-1.
+const (
+ _UID_NOCHANGE uint32 = 0xFFFFFFFF
+ _GID_NOCHANGE uint32 = 0xFFFFFFFF
+)
diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go
index c234f78..131e5f0 100644
--- a/cmd/ioworkload/scenarios.go
+++ b/cmd/ioworkload/scenarios.go
@@ -111,6 +111,7 @@ var scenarios = map[string]func() error{
"xattr-listxattrat": xattrListxattrat,
"xattr-removexattrat": xattrRemovexattrat,
"chmod-basic": chmodBasic,
+ "chown-basic": chownBasic,
"utime-basic": utimeBasic,
"utime-utimes": utimeUtimes,
"utime-enoent": utimeEnoent,