diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-31 19:09:16 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-31 19:09:16 +0300 |
| commit | 8a1bf6236f6a525881c647fd881093b393436411 (patch) | |
| tree | 0fb537b847b6e5c72d2d3d98d7ce8fbc15330102 | |
| parent | c3177bd82c16429c1bb246d19af76012479f0c01 (diff) | |
listxattrat: READ-classify return for xattr-list family consistency
listxattrat(2) (Linux 6.13+) returns the size in bytes of the list of
extended attribute names, exactly like listxattr/llistxattr/flistxattr,
but its exit was classified UNCLASSIFIED, so its read bytes were dropped
from I/O totals. Classify it as ReadClassified and regenerate the BPF
handler (ret_type now READ_CLASSIFIED). This mirrors the getxattrat fix
(task ku, commit c3177bd) and completes xattr-family consistency:
get-family and list-family are READ_CLASSIFIED while set-family and
remove-family stay UNCLASSIFIED (they return 0/-1).
Update the docs ReadClassified list and the retclassify expectation, and
add an ioworkload scenario plus integration test: the workload sets a
user xattr then lists names via the raw listxattrat(2) syscall with
AT_FDCWD, and the test asserts enter_listxattrat captures the file path
and accounts the returned name-list size as read bytes.
Task: r20
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rw-r--r-- | cmd/ioworkload/scenario_xattr.go | 76 | ||||
| -rw-r--r-- | cmd/ioworkload/scenarios.go | 1 | ||||
| -rw-r--r-- | docs/syscall-tracing-plan.md | 2 | ||||
| -rw-r--r-- | integrationtests/xattr_test.go | 27 | ||||
| -rw-r--r-- | internal/c/generated_tracepoints.c | 4 | ||||
| -rw-r--r-- | internal/c/generated_tracepoints_result.txt | 2 | ||||
| -rw-r--r-- | internal/generate/classify.go | 4 | ||||
| -rw-r--r-- | internal/generate/retclassify_test.go | 6 |
8 files changed, 117 insertions, 5 deletions
diff --git a/cmd/ioworkload/scenario_xattr.go b/cmd/ioworkload/scenario_xattr.go index 42c679f..f974c01 100644 --- a/cmd/ioworkload/scenario_xattr.go +++ b/cmd/ioworkload/scenario_xattr.go @@ -21,6 +21,19 @@ import ( // or -1 on error. const sysGetxattrat = 464 +// listxattrat is syscall number 465 on amd64 (added in Linux 6.13, right after +// getxattrat). Go's syscall package does not export SYS_LISTXATTRAT, so we +// invoke it by its raw number. Its signature is: +// +// listxattrat(int dfd, const char *pathname, unsigned int at_flags, +// char *list, size_t size) +// +// The filesystem PATH is at args[1] (after the dirfd); args[3] is the userspace +// buffer that receives the NUL-separated list of xattr names. The syscall +// returns the size in bytes of the name list (a read byte-count), or -1 on +// error — exactly like listxattr/llistxattr/flistxattr. +const sysListxattrat = 465 + // xattrArgs mirrors struct xattr_args from <linux/xattr.h> (Linux 6.13+): // a userspace value buffer pointer plus its size and flags. type xattrArgs struct { @@ -105,3 +118,66 @@ func callGetxattrat(path, name string, wantSize int) error { } return nil } + +// xattrListxattrat creates a file on tmpfs (/tmp), sets a user xattr on it, then +// lists that file's xattr names via the raw listxattrat(2) syscall with +// AT_FDCWD. This exercises ior's listxattrat tracing end-to-end and confirms: +// - the real filesystem path (args[1]) is captured, NOT the dirfd; +// - the syscall exit is READ-classified so the returned name-list size is +// accounted as read bytes, consistent with listxattr/llistxattr/flistxattr. +func xattrListxattrat() error { + dir, cleanup, err := makeTempDir("xattr-listxattrat") + 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) + + const xattrName = "user.ior" + if err := syscall.Setxattr(path, xattrName, []byte("listxattrat-value"), 0); err != nil { + return fmt.Errorf("setxattr: %w", err) + } + + // The returned list is the NUL-terminated xattr name, e.g. "user.ior\0". + return callListxattrat(path, len(xattrName)+1) +} + +// callListxattrat performs the raw listxattrat(AT_FDCWD, path, 0, list, +// sizeof(list)) call and verifies it returns at least the expected list size. +// The kernel may report additional system xattr names (e.g. "security.*"), so +// we assert the returned size is at least wantMinSize rather than exact. +func callListxattrat(path string, wantMinSize int) error { + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + + list := make([]byte, 256) + // 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( + sysListxattrat, + uintptr(dirfd), + uintptr(unsafe.Pointer(pathBytes)), + 0, // at_flags + uintptr(unsafe.Pointer(&list[0])), + uintptr(len(list)), + 0, + ) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(&list[0]) + if errno != 0 { + return fmt.Errorf("listxattrat: %w", errno) + } + if int(ret) < wantMinSize { + return fmt.Errorf("listxattrat returned %d, want at least %d", int(ret), wantMinSize) + } + return nil +} diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go index b7ea44a..6503db7 100644 --- a/cmd/ioworkload/scenarios.go +++ b/cmd/ioworkload/scenarios.go @@ -99,6 +99,7 @@ var scenarios = map[string]func() error{ "stat-access-enoent": statAccessEnoent, "stat-fstat-ebadf": statFstatEbadf, "xattr-getxattrat": xattrGetxattrat, + "xattr-listxattrat": xattrListxattrat, "utime-basic": utimeBasic, "utime-utimes": utimeUtimes, "utime-enoent": utimeEnoent, diff --git a/docs/syscall-tracing-plan.md b/docs/syscall-tracing-plan.md index 8cca6fd..71890ae 100644 --- a/docs/syscall-tracing-plan.md +++ b/docs/syscall-tracing-plan.md @@ -92,7 +92,7 @@ sudo ./ior -trace-syscalls openat,recvmsg,nanosleep -no-trace-kinds null Payload bytes classified by return value: -- ReadClassified: `fgetxattr`, `flistxattr`, `getdents`, `getdents64`, `getrandom`, `getxattr`, `getxattrat`, `lgetxattr`, `listxattr`, `llistxattr`, `mq_timedreceive`, `msgrcv`, `pread64`, `preadv`, `preadv2`, `process_vm_readv`, `read`, `readlink`, `readlinkat`, `readv`, `recvfrom`, `recvmsg`, `syslog` +- ReadClassified: `fgetxattr`, `flistxattr`, `getdents`, `getdents64`, `getrandom`, `getxattr`, `getxattrat`, `lgetxattr`, `listxattr`, `listxattrat`, `llistxattr`, `mq_timedreceive`, `msgrcv`, `pread64`, `preadv`, `preadv2`, `process_vm_readv`, `read`, `readlink`, `readlinkat`, `readv`, `recvfrom`, `recvmsg`, `syslog` - TransferClassified: `copy_file_range`, `sendfile64`, `splice`, `tee`, `vmsplice` - WriteClassified: `mq_timedsend`, `msgsnd`, `process_vm_writev`, `pwrite64`, `pwritev`, `pwritev2`, `sendmsg`, `sendto`, `write`, `writev` diff --git a/integrationtests/xattr_test.go b/integrationtests/xattr_test.go index d36043c..2ba30ac 100644 --- a/integrationtests/xattr_test.go +++ b/integrationtests/xattr_test.go @@ -6,6 +6,10 @@ import "testing" // not perturbed by unrelated xattr/open traffic. var xattrTraceArgs = []string{"-trace-syscalls", "getxattrat,setxattr,openat"} +// xattrListTraceArgs restricts tracing to the listxattrat tracepoints so the +// test is not perturbed by unrelated xattr/open traffic. +var xattrListTraceArgs = []string{"-trace-syscalls", "listxattrat,setxattr,openat"} + // TestXattrGetxattrat verifies ior traces getxattrat(2) (Linux 6.13+) // end-to-end. getxattrat takes a dirfd plus a real filesystem path at args[1] // (NOT args[0]=dfd) and an xattr NAME at args[3]; only the path must be @@ -35,3 +39,26 @@ func TestXattrGetxattrat(t *testing.T) { assertEventBytesAtLeast(t, result, exp, uint64(len("getxattrat-value"))) assertEventDurationPositive(t, result, exp) } + +// TestXattrListxattrat verifies ior traces listxattrat(2) (Linux 6.13+) +// end-to-end. listxattrat takes a dirfd plus a real filesystem path at args[1] +// (NOT args[0]=dfd); only the path must be captured. The path is read on +// syscall entry, so enter_listxattrat must carry the file path "xattrfile.txt". +func TestXattrListxattrat(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-listxattrat", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_listxattrat", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrListTraceArgs) + + // listxattrat returns the size in bytes of the xattr name list; ior + // READ-classifies the exit, so the recorded byte count must reflect at least + // the NUL-terminated "user.ior" name set by the workload (consistent with + // listxattr/llistxattr/flistxattr). + exp := ExpectedEvent{Tracepoint: "enter_listxattrat", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, exp, uint64(len("user.ior")+1)) + assertEventDurationPositive(t, result, exp) +} diff --git a/internal/c/generated_tracepoints.c b/internal/c/generated_tracepoints.c index 8c76e7a..64d40db 100644 --- a/internal/c/generated_tracepoints.c +++ b/internal/c/generated_tracepoints.c @@ -6579,7 +6579,7 @@ int handle_sys_enter_listxattrat(struct syscall_trace_enter *ctx) { return 0; } -/// sys_exit_listxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret) +/// sys_exit_listxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret) SEC("tracepoint/syscalls/sys_exit_listxattrat") int handle_sys_exit_listxattrat(struct syscall_trace_exit *ctx) { __u32 pid, tid; @@ -6599,7 +6599,7 @@ int handle_sys_exit_listxattrat(struct syscall_trace_exit *ctx) { ev->tid = tid; ev->time = bpf_ktime_get_boot_ns(); ev->ret = ctx->ret; - ev->ret_type = UNCLASSIFIED; + ev->ret_type = READ_CLASSIFIED; bpf_ringbuf_submit(ev, 0); return 0; diff --git a/internal/c/generated_tracepoints_result.txt b/internal/c/generated_tracepoints_result.txt index 0842bda..e6cb4d3 100644 --- a/internal/c/generated_tracepoints_result.txt +++ b/internal/c/generated_tracepoints_result.txt @@ -510,7 +510,7 @@ sys_exit_listen is a struct ret_event (UNCLASSIFIED) (kind=ret) sys_exit_listmount is a struct ret_event (UNCLASSIFIED) (kind=ret) sys_exit_listns is a struct ret_event (UNCLASSIFIED) (kind=ret) sys_exit_listxattr is a struct ret_event (READ_CLASSIFIED) (kind=ret) -sys_exit_listxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret) +sys_exit_listxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret) sys_exit_llistxattr is a struct ret_event (READ_CLASSIFIED) (kind=ret) sys_exit_lremovexattr is a struct ret_event (UNCLASSIFIED) (kind=ret) sys_exit_lseek is a struct ret_event (UNCLASSIFIED) (kind=ret) diff --git a/internal/generate/classify.go b/internal/generate/classify.go index 3746bd9..0735291 100644 --- a/internal/generate/classify.go +++ b/internal/generate/classify.go @@ -593,6 +593,10 @@ var retClassifications = map[string]RetClassification{ "getxattrat": ReadClassified, "lgetxattr": ReadClassified, "listxattr": ReadClassified, + // listxattrat (Linux 6.13+) returns the size in bytes of the list of + // extended attribute names, exactly like listxattr/llistxattr/flistxattr, + // so it is a read byte-count. + "listxattrat": ReadClassified, "llistxattr": ReadClassified, "pread64": ReadClassified, "preadv": ReadClassified, diff --git a/internal/generate/retclassify_test.go b/internal/generate/retclassify_test.go index acd019b..4fc7501 100644 --- a/internal/generate/retclassify_test.go +++ b/internal/generate/retclassify_test.go @@ -8,7 +8,11 @@ func TestClassifyRetRead(t *testing.T) { // getxattrat (Linux 6.13+) returns the xattr value size in bytes, the // same read byte-count as getxattr/lgetxattr/fgetxattr. "getxattrat", - "lgetxattr", "listxattr", "llistxattr", "pread64", "preadv", + "lgetxattr", "listxattr", + // listxattrat (Linux 6.13+) returns the size in bytes of the xattr + // name list, the same read byte-count as listxattr/llistxattr/flistxattr. + "listxattrat", + "llistxattr", "pread64", "preadv", "preadv2", "process_vm_readv", "read", "readlink", "readlinkat", "readv", "recvmsg", "recvfrom", "syslog", "mq_timedreceive", "getrandom", "msgrcv", } |
