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 --- integrationtests/xattr_test.go | 240 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) (limited to 'integrationtests') diff --git a/integrationtests/xattr_test.go b/integrationtests/xattr_test.go index f99629d..73e74c7 100644 --- a/integrationtests/xattr_test.go +++ b/integrationtests/xattr_test.go @@ -14,6 +14,25 @@ var xattrListTraceArgs = []string{"-trace-syscalls", "listxattrat,setxattr,opena // the test is not perturbed by unrelated xattr/open traffic. var xattrRemoveTraceArgs = []string{"-trace-syscalls", "removexattrat,setxattr,openat"} +// The trace selectors below scope tracing to exactly the path/fd-based xattr +// variant under test (plus setxattr/openat used to prime the attribute), so the +// substring-based assertion matcher cannot pick up sibling tracepoints. The +// syscall filter is exact-match, so e.g. tracing "getxattr" does NOT enable +// lgetxattr/fgetxattr/getxattrat. +var ( + xattrGetTraceArgs = []string{"-trace-syscalls", "getxattr,setxattr,openat"} + xattrLgetTraceArgs = []string{"-trace-syscalls", "lgetxattr,setxattr,openat"} + xattrListPathTraceArgs = []string{"-trace-syscalls", "listxattr,setxattr,openat"} + xattrLlistTraceArgs = []string{"-trace-syscalls", "llistxattr,setxattr,openat"} + xattrLsetTraceArgs = []string{"-trace-syscalls", "lsetxattr,setxattr,openat"} + xattrSetatTraceArgs = []string{"-trace-syscalls", "setxattrat,setxattr,openat"} + xattrRemovePathTraceArgs = []string{"-trace-syscalls", "removexattr,setxattr,openat"} + xattrLremoveTraceArgs = []string{"-trace-syscalls", "lremovexattr,setxattr,openat"} + // The fd family runs all four fd-based syscalls in one scenario, so trace + // them together. + xattrFdTraceArgs = []string{"-trace-syscalls", "fsetxattr,fgetxattr,flistxattr,fremovexattr,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 @@ -137,3 +156,224 @@ func TestXattrRemovexattrat(t *testing.T) { assertEventBytesEqual(t, result, exp, 0) assertEventDurationPositive(t, result, exp) } + +// assertNoXattrNameAsPath fails if any record for the given enter tracepoint +// captured the xattr NAME ("user.ior") as its path instead of the file path. +func assertNoXattrNameAsPath(t *testing.T, result TestResult, tracepoint string) { + t.Helper() + for _, rec := range result.Records { + if rec.TraceID.String() == tracepoint && rec.Path == "user.ior" { + t.Errorf("%s captured xattr name %q as path instead of file path", tracepoint, rec.Path) + } + } +} + +// TestXattrGetxattr verifies ior traces the PATH-based getxattr(2) end-to-end. +// getxattr(const char *path, const char *name, void *value, size_t size) takes +// the filesystem PATH at args[0] and the xattr NAME at args[1]; only the path +// must be captured on entry. getxattr returns the xattr value size in bytes, so +// ior READ-classifies the exit and the recorded byte count must reflect at +// least the value written by the workload (consistent with +// lgetxattr/fgetxattr/getxattrat). +func TestXattrGetxattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-getxattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_getxattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrGetTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_getxattr") + + exp := ExpectedEvent{Tracepoint: "enter_getxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, exp, 1) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrLgetxattr verifies ior traces the no-follow PATH-based lgetxattr(2) +// end-to-end. lgetxattr has getxattr's signature but does NOT follow symlinks; +// the workload targets a regular file, so it returns the value size (user.* +// xattrs on a bare symlink are kernel-restricted). The PATH is at args[0]; the +// exit is READ-classified. +func TestXattrLgetxattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-lgetxattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_lgetxattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrLgetTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_lgetxattr") + + exp := ExpectedEvent{Tracepoint: "enter_lgetxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, exp, 1) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrListxattr verifies ior traces the PATH-based listxattr(2) end-to-end. +// listxattr(const char *path, char *list, size_t size) takes the PATH at +// args[0]; it returns the size in bytes of the NUL-separated xattr name list, +// so ior READ-classifies the exit (consistent with llistxattr/flistxattr/ +// listxattrat). +func TestXattrListxattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-listxattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_listxattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrListPathTraceArgs) + + exp := ExpectedEvent{Tracepoint: "enter_listxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, exp, 1) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrLlistxattr verifies ior traces the no-follow PATH-based llistxattr(2) +// end-to-end. As with lgetxattr the target is a regular file, so it returns the +// name-list size. The PATH is at args[0]; the exit is READ-classified. +func TestXattrLlistxattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-llistxattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_llistxattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrLlistTraceArgs) + + exp := ExpectedEvent{Tracepoint: "enter_llistxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, exp, 1) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrLsetxattr verifies ior traces the no-follow PATH-based lsetxattr(2) +// end-to-end. lsetxattr(const char *path, const char *name, const void *value, +// size_t size, int flags) takes the PATH at args[0] and the xattr NAME at +// args[1]; only the path must be captured. lsetxattr returns 0/-1 (its `size` +// arg is the INPUT value length), so the exit is UNCLASSIFIED — the accounted +// bytes must be exactly zero (contrast getxattr/lgetxattr, which ARE READ- +// classified), matching setxattr/setxattrat/fsetxattr. +func TestXattrLsetxattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-lsetxattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_lsetxattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrLsetTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_lsetxattr") + + exp := ExpectedEvent{Tracepoint: "enter_lsetxattr", Comm: "ioworkload"} + assertEventBytesEqual(t, result, exp, 0) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrSetxattrat verifies ior traces the -at variant setxattrat(2) (Linux +// 6.13+) end-to-end. setxattrat takes a dirfd plus a real filesystem PATH at +// args[1] (NOT args[0]=dfd) and the xattr NAME at args[3]; only the path must be +// captured. setxattrat SETS an attribute and returns 0/-1 (the value size is an +// INPUT field of struct xattr_args), so the exit is UNCLASSIFIED — the accounted +// bytes must be exactly zero (guarding against accidental READ-classification +// like its getxattrat/listxattrat siblings), matching setxattr/lsetxattr/ +// fsetxattr. +func TestXattrSetxattrat(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-setxattrat", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_setxattrat", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrSetatTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_setxattrat") + + exp := ExpectedEvent{Tracepoint: "enter_setxattrat", Comm: "ioworkload"} + assertEventBytesEqual(t, result, exp, 0) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrRemovexattr verifies ior traces the PATH-based removexattr(2) +// end-to-end. removexattr(const char *path, const char *name) takes the PATH at +// args[0] and the xattr NAME at args[1]; only the path must be captured. +// removexattr returns 0/-1, never a byte count, so the exit is UNCLASSIFIED — +// the accounted bytes must be exactly zero, matching lremovexattr/fremovexattr/ +// removexattrat. +func TestXattrRemovexattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-removexattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_removexattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrRemovePathTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_removexattr") + + exp := ExpectedEvent{Tracepoint: "enter_removexattr", Comm: "ioworkload"} + assertEventBytesEqual(t, result, exp, 0) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrLremovexattr verifies ior traces the no-follow PATH-based +// lremovexattr(2) end-to-end. As with the other l* variants the target is a +// regular file, so it removes the user.* attr. The PATH is at args[0]; the exit +// is UNCLASSIFIED. +func TestXattrLremovexattr(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-lremovexattr", []ExpectedEvent{ + { + PathContains: "xattrfile.txt", + Tracepoint: "enter_lremovexattr", + Comm: "ioworkload", + MinCount: 1, + }, + }, xattrLremoveTraceArgs) + + assertNoXattrNameAsPath(t, result, "enter_lremovexattr") + + exp := ExpectedEvent{Tracepoint: "enter_lremovexattr", Comm: "ioworkload"} + assertEventBytesEqual(t, result, exp, 0) + assertEventDurationPositive(t, result, exp) +} + +// TestXattrFd verifies ior traces the entire fd-based xattr family end-to-end: +// fsetxattr/fgetxattr/flistxattr/fremovexattr. Each takes the FD at args[0] +// (kind=fd, no path) rather than a pathname. fgetxattr and flistxattr return +// value/name-list sizes and are READ-classified (bytes>0); fsetxattr and +// fremovexattr return 0/-1 status and are UNCLASSIFIED (bytes==0). All four +// fire on the single open fd in the xattr-fd scenario. +func TestXattrFd(t *testing.T) { + result, _ := runScenarioResultWithIorArgs(t, "xattr-fd", []ExpectedEvent{ + {Tracepoint: "enter_fsetxattr", Comm: "ioworkload", MinCount: 1}, + {Tracepoint: "enter_fgetxattr", Comm: "ioworkload", MinCount: 1}, + {Tracepoint: "enter_flistxattr", Comm: "ioworkload", MinCount: 1}, + {Tracepoint: "enter_fremovexattr", Comm: "ioworkload", MinCount: 1}, + }, xattrFdTraceArgs) + + // READ-classified fd reads: returned value/name-list sizes are accounted. + fget := ExpectedEvent{Tracepoint: "enter_fgetxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, fget, 1) + assertEventDurationPositive(t, result, fget) + + flist := ExpectedEvent{Tracepoint: "enter_flistxattr", Comm: "ioworkload"} + assertEventBytesAtLeast(t, result, flist, 1) + assertEventDurationPositive(t, result, flist) + + // UNCLASSIFIED fd ops: 0/-1 status returns, never a byte count. + fset := ExpectedEvent{Tracepoint: "enter_fsetxattr", Comm: "ioworkload"} + assertEventBytesEqual(t, result, fset, 0) + assertEventDurationPositive(t, result, fset) + + fremove := ExpectedEvent{Tracepoint: "enter_fremovexattr", Comm: "ioworkload"} + assertEventBytesEqual(t, result, fremove, 0) + assertEventDurationPositive(t, result, fremove) +} -- cgit v1.2.3