From 494c277bd4937c0c8a4fce4f53401053036925b1 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Jun 2026 09:39:09 +0300 Subject: test(xattr): cover get/list/lset/setxattrat/removexattr + fd-based xattr variants Extend the xattr scenario + integration test to exercise end-to-end the xattr syscall variants that previously had no coverage (only setxattr and the -at get/list/remove variants were tested). Classifications were already verified correct by inspection; this is pure coverage hardening. READ-classified variants (assert enter path/fd + bytes>=1): - getxattr(path,name,...) READ, KindPathname@arg0 (ej0) - lgetxattr(path,name,...) READ, KindPathname@arg0 (oj0) - listxattr(path,...) READ, KindPathname@arg0 (rj0) - llistxattr(path,...) READ, KindPathname@arg0 (rj0) - fgetxattr(fd,...) READ, KindFd@arg0 (8i0) - flistxattr(fd,...) READ, KindFd@arg0 (8i0) UNCLASSIFIED variants (assert enter path/fd + bytes==0): - lsetxattr(path,...) KindPathname@arg0 (cl0) - setxattrat(dirfd,path,...) KindPathname@arg1 (vj0) - removexattr(path,name) KindPathname@arg0 (kj0) - lremovexattr(path,name) KindPathname@arg0 (kj0) - fsetxattr(fd,...) KindFd@arg0 (8i0) - fremovexattr(fd,...) KindFd@arg0 (8i0) The l* GET/LIST/SET/REMOVE variants target a regular file (not a bare symlink) so they fire deterministically: user.* xattrs on a symlink itself are kernel-restricted (EPERM). setxattrat uses the raw syscall (nr 463, Linux 6.13+) since Go does not export it; this kernel (7.0.9) supports it. New scenarios use golang.org/x/sys/unix (raw syscalls, no glibc redirect) so the exact tracepoints fire. New tests scope -trace-syscalls to exactly the variant under test to avoid substring-match cross-contamination. All 13 TestXattr* integration tests PASS under root (mage testWithName). Co-Authored-By: Claude Opus 4.8 --- cmd/ioworkload/scenario_xattr.go | 313 +++++++++++++++++++++++++++++++++++++++ cmd/ioworkload/scenarios.go | 9 ++ 2 files changed, 322 insertions(+) (limited to 'cmd') diff --git a/cmd/ioworkload/scenario_xattr.go b/cmd/ioworkload/scenario_xattr.go index ef2e377..aa33d60 100644 --- a/cmd/ioworkload/scenario_xattr.go +++ b/cmd/ioworkload/scenario_xattr.go @@ -6,6 +6,8 @@ import ( "runtime" "syscall" "unsafe" + + "golang.org/x/sys/unix" ) // getxattrat is syscall number 464 on amd64 (added in Linux 6.13). Go's @@ -48,6 +50,21 @@ const sysListxattrat = 465 // UNCLASSIFIED, exactly like removexattr/lremovexattr/fremovexattr. const sysRemovexattrat = 466 +// setxattrat is syscall number 463 on amd64 (added in Linux 6.13, the first of +// the xattr -at quartet). Go's syscall package does not export SYS_SETXATTRAT, +// so we invoke it by its raw number. Its signature is: +// +// setxattrat(int dfd, const char *pathname, unsigned int at_flags, +// const char *name, const struct xattr_args *uargs, size_t usize) +// +// The filesystem PATH is at args[1] (after the dirfd); args[3] is the xattr +// NAME and must NOT be captured as a path. Unlike getxattrat/listxattrat, +// setxattrat SETS an attribute and returns 0 on success / -1 on error (a +// status, NOT a read byte-count — the value size is an INPUT field of +// xattr_args) — so its exit is UNCLASSIFIED, exactly like +// setxattr/lsetxattr/fsetxattr. +const sysSetxattrat = 463 + // xattrArgs mirrors struct xattr_args from (Linux 6.13+): // a userspace value buffer pointer plus its size and flags. type xattrArgs struct { @@ -261,3 +278,299 @@ func callRemovexattrat(path, name string) error { } return nil } + +// xattrName is the user-namespace attribute name used by every xattr scenario. +// User xattrs must live under the "user." prefix and are only permitted on +// regular files/dirs on a supporting filesystem (tmpfs satisfies this). +const xattrName = "user.ior" + +// makeXattrFile creates an empty regular file under a fresh temp dir and sets a +// user.* xattr on it so subsequent get/list/remove syscalls have something to +// operate on. It returns the file path, the value that was set (so callers can +// assert the READ byte count), and the dir cleanup func. +func makeXattrFile(prefix string, value []byte) (string, func(), error) { + dir, cleanup, err := makeTempDir(prefix) + if err != nil { + return "", nil, err + } + path := filepath.Join(dir, "xattrfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + if err := syscall.Setxattr(path, xattrName, value, 0); err != nil { + cleanup() + return "", nil, fmt.Errorf("setxattr: %w", err) + } + return path, cleanup, nil +} + +// xattrGetxattr creates a file, sets a user xattr on it, then reads that xattr +// back via the path-based getxattr(2) syscall (which FOLLOWS symlinks; here the +// path is a regular file). golang.org/x/sys/unix.Getxattr issues the raw +// syscall directly (no glibc redirection), so the enter_getxattr tracepoint +// fires with the file path at args[0] and the exit is READ-classified with the +// returned value size as the byte count, consistent with +// lgetxattr/fgetxattr/getxattrat. +func xattrGetxattr() error { + value := []byte("getxattr-value") + path, cleanup, err := makeXattrFile("xattr-getxattr", value) + if err != nil { + return err + } + defer cleanup() + + buf := make([]byte, 256) + n, err := unix.Getxattr(path, xattrName, buf) + if err != nil { + return fmt.Errorf("getxattr: %w", err) + } + if n != len(value) { + return fmt.Errorf("getxattr returned %d, want %d", n, len(value)) + } + return nil +} + +// xattrLgetxattr is the no-follow counterpart of xattrGetxattr. lgetxattr(2) +// does NOT follow symlinks, but since the target path is a regular file (not a +// symlink) it behaves identically to getxattr and returns the value size — this +// is the deterministic way to fire the tracepoint, because user.* xattrs on a +// symlink itself are kernel-restricted (EPERM). The enter_lgetxattr tracepoint +// captures the file path at args[0]; the exit is READ-classified. +func xattrLgetxattr() error { + value := []byte("lgetxattr-value") + path, cleanup, err := makeXattrFile("xattr-lgetxattr", value) + if err != nil { + return err + } + defer cleanup() + + buf := make([]byte, 256) + n, err := unix.Lgetxattr(path, xattrName, buf) + if err != nil { + return fmt.Errorf("lgetxattr: %w", err) + } + if n != len(value) { + return fmt.Errorf("lgetxattr returned %d, want %d", n, len(value)) + } + return nil +} + +// xattrListxattr creates a file, sets a user xattr on it, then lists the file's +// xattr names via the path-based listxattr(2) syscall. enter_listxattr captures +// the file path at args[0]; the exit is READ-classified with the NUL-separated +// name-list byte count (at least len("user.ior")+1), consistent with +// llistxattr/flistxattr/listxattrat. +func xattrListxattr() error { + path, cleanup, err := makeXattrFile("xattr-listxattr", []byte("listxattr-value")) + if err != nil { + return err + } + defer cleanup() + + buf := make([]byte, 256) + n, err := unix.Listxattr(path, buf) + if err != nil { + return fmt.Errorf("listxattr: %w", err) + } + if n < len(xattrName)+1 { + return fmt.Errorf("listxattr returned %d, want at least %d", n, len(xattrName)+1) + } + return nil +} + +// xattrLlistxattr is the no-follow counterpart of xattrListxattr. As with +// lgetxattr, the target is a regular file so llistxattr(2) returns the name-list +// size deterministically (user.* on a bare symlink is restricted). +// enter_llistxattr captures the file path; the exit is READ-classified. +func xattrLlistxattr() error { + path, cleanup, err := makeXattrFile("xattr-llistxattr", []byte("llistxattr-value")) + if err != nil { + return err + } + defer cleanup() + + buf := make([]byte, 256) + n, err := unix.Llistxattr(path, buf) + if err != nil { + return fmt.Errorf("llistxattr: %w", err) + } + if n < len(xattrName)+1 { + return fmt.Errorf("llistxattr returned %d, want at least %d", n, len(xattrName)+1) + } + return nil +} + +// xattrLsetxattr creates a file, sets an initial xattr, then updates it via the +// no-follow path-based lsetxattr(2) syscall. As with the other l* variants the +// target is a regular file, so lsetxattr succeeds (user.* on a bare symlink is +// restricted). enter_lsetxattr captures the file path at args[0]; the exit is +// UNCLASSIFIED — lsetxattr returns 0/-1, never a byte count (its `size` arg is +// the INPUT value length), exactly like setxattr/setxattrat/fsetxattr. +func xattrLsetxattr() error { + path, cleanup, err := makeXattrFile("xattr-lsetxattr", []byte("lsetxattr-initial")) + if err != nil { + return err + } + defer cleanup() + + if err := unix.Lsetxattr(path, xattrName, []byte("lsetxattr-updated"), 0); err != nil { + return fmt.Errorf("lsetxattr: %w", err) + } + return nil +} + +// xattrSetxattrat creates a file then sets a user xattr on it via the raw +// setxattrat(2) syscall with AT_FDCWD. This exercises ior's setxattrat tracing +// end-to-end and confirms the real filesystem path (args[1]) is captured (NOT +// the dirfd or the xattr name at args[3]) and that the exit is UNCLASSIFIED +// (0/-1 status, never a byte count), matching setxattr/lsetxattr/fsetxattr. +// setxattrat is Linux 6.13+; if the kernel lacks it (ENOSYS) the scenario +// reports the error so the caller can treat it as best-effort. +func xattrSetxattrat() error { + dir, cleanup, err := makeTempDir("xattr-setxattrat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "xattrfile.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) + + return callSetxattrat(path, xattrName, []byte("setxattrat-value")) +} + +// callSetxattrat performs the raw setxattrat(AT_FDCWD, path, 0, name, args, +// sizeof(args)) call. The value to write is passed via struct xattr_args (its +// `size` field is the value length); the syscall returns 0 on success / -1 on +// error — never a byte count. +func callSetxattrat(path, name string, value []byte) error { + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + nameBytes, err := syscall.BytePtrFromString(name) + if err != nil { + return fmt.Errorf("name bytes: %w", err) + } + + args := xattrArgs{ + value: uint64(uintptr(unsafe.Pointer(&value[0]))), + size: uint32(len(value)), + flags: 0, + } + + // Use a runtime int variable so the negative AT_FDCWD survives the uintptr + // conversion: converting the negative constant directly overflows uintptr. + dirfd := _AT_FDCWD + ret, _, errno := syscall.Syscall6( + sysSetxattrat, + uintptr(dirfd), + uintptr(unsafe.Pointer(pathBytes)), + 0, // at_flags + uintptr(unsafe.Pointer(nameBytes)), + uintptr(unsafe.Pointer(&args)), + unsafe.Sizeof(args), + ) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(nameBytes) + runtime.KeepAlive(&value[0]) + runtime.KeepAlive(&args) + if errno != 0 { + return fmt.Errorf("setxattrat: %w", errno) + } + if int(ret) != 0 { + return fmt.Errorf("setxattrat returned %d, want 0", int(ret)) + } + return nil +} + +// xattrRemovexattr creates a file, sets a user xattr on it, then removes it via +// the path-based removexattr(2) syscall. enter_removexattr captures the file +// path at args[0] (NOT the xattr name at args[1]); the exit is UNCLASSIFIED — +// removexattr returns 0/-1, never a byte count, matching +// lremovexattr/fremovexattr/removexattrat. +func xattrRemovexattr() error { + path, cleanup, err := makeXattrFile("xattr-removexattr", []byte("removexattr-value")) + if err != nil { + return err + } + defer cleanup() + + if err := unix.Removexattr(path, xattrName); err != nil { + return fmt.Errorf("removexattr: %w", err) + } + return nil +} + +// xattrLremovexattr is the no-follow counterpart of xattrRemovexattr. The target +// is a regular file, so lremovexattr(2) removes the user.* attr deterministically +// (user.* on a bare symlink is restricted). enter_lremovexattr captures the file +// path at args[0]; the exit is UNCLASSIFIED. +func xattrLremovexattr() error { + path, cleanup, err := makeXattrFile("xattr-lremovexattr", []byte("lremovexattr-value")) + if err != nil { + return err + } + defer cleanup() + + if err := unix.Lremovexattr(path, xattrName); err != nil { + return fmt.Errorf("lremovexattr: %w", err) + } + return nil +} + +// xattrFd exercises the entire fd-based xattr family on an open file descriptor: +// fsetxattr (set), fgetxattr (READ), flistxattr (READ), then fremovexattr +// (remove). golang.org/x/sys/unix issues the raw syscalls directly, so each +// tracepoint fires with the FD at args[0] (kind=fd, no path). fgetxattr and +// flistxattr are READ-classified (return value/name-list sizes); fsetxattr and +// fremovexattr are UNCLASSIFIED (return 0/-1 status). +func xattrFd() error { + dir, cleanup, err := makeTempDir("xattr-fd") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "xattrfile.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) + + value := []byte("fxattr-value") + if err := unix.Fsetxattr(fd, xattrName, value, 0); err != nil { + return fmt.Errorf("fsetxattr: %w", err) + } + + buf := make([]byte, 256) + n, err := unix.Fgetxattr(fd, xattrName, buf) + if err != nil { + return fmt.Errorf("fgetxattr: %w", err) + } + if n != len(value) { + return fmt.Errorf("fgetxattr returned %d, want %d", n, len(value)) + } + + list := make([]byte, 256) + ln, err := unix.Flistxattr(fd, list) + if err != nil { + return fmt.Errorf("flistxattr: %w", err) + } + if ln < len(xattrName)+1 { + return fmt.Errorf("flistxattr returned %d, want at least %d", ln, len(xattrName)+1) + } + + if err := unix.Fremovexattr(fd, xattrName); err != nil { + return fmt.Errorf("fremovexattr: %w", err) + } + return nil +} diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go index 7e03b3d..b1f8cf6 100644 --- a/cmd/ioworkload/scenarios.go +++ b/cmd/ioworkload/scenarios.go @@ -110,6 +110,15 @@ var scenarios = map[string]func() error{ "xattr-getxattrat": xattrGetxattrat, "xattr-listxattrat": xattrListxattrat, "xattr-removexattrat": xattrRemovexattrat, + "xattr-getxattr": xattrGetxattr, + "xattr-lgetxattr": xattrLgetxattr, + "xattr-listxattr": xattrListxattr, + "xattr-llistxattr": xattrLlistxattr, + "xattr-lsetxattr": xattrLsetxattr, + "xattr-setxattrat": xattrSetxattrat, + "xattr-removexattr": xattrRemovexattr, + "xattr-lremovexattr": xattrLremovexattr, + "xattr-fd": xattrFd, "chmod-basic": chmodBasic, "chown-basic": chownBasic, "utime-basic": utimeBasic, -- cgit v1.2.3