diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-06 09:19:02 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-06 09:19:02 +0300 |
| commit | 322496c2863d5bc14b0a5e4af16690bf19073cae (patch) | |
| tree | ede84dee5e86f732c7016e2893c726ae8289abd7 /cmd/ioworkload | |
| parent | e44475525894cb5a184e7fecd449c258f204f189 (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/ioworkload')
| -rw-r--r-- | cmd/ioworkload/scenario_chown.go | 150 | ||||
| -rw-r--r-- | cmd/ioworkload/scenarios.go | 1 |
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, |
