Stop the USB layer shadowing the sysroot

Merged
JO
2mo ago
+5128-211

NOTE: these are regressions #334 introduced. I found them in a post-merge audit of my own diff, not in a report, and every line below is a measurement against a build of 377c134 (main as it stood before #334) and a build of current main, both driven with --sysroot over a rootfs whose /sys carries class/net/eth0/address, kernel/mm/transparent_hugepage/enabled, devices/system/node/online, and whose /dev/bus carries other/f. They reproduce with no USB device attached and no fixture, so every sysroot user is affected.

Summary

#334 taught the USB layer to stand aside for names it does not synthesize, but wired that decision into only the two follow resolve-failure paths. Every other entry point kept answering from the synthetic tree alone, so the layer shadowed the sysroot it was supposed to defer to.

probebefore #334current mainthis branchLinux
lstat("/sys/class")OKENOENTOKOK
open("/sys/class", O_NOFOLLOW)OKENOENTOKOK
readlink("/sys/class")EINVALENOENTEINVALEINVAL
readdir("/sys")class fs kernel devicesbusbus class fs kernel devices(both)
readdir("/dev/bus")otherusbusb otherother
open("/dev/bus/other/f")OKENOENT while access said OKOKOK
fstatfs on /sys/class0x1a0x1a while statfs said SYSFS_MAGICSYSFS_MAGICSYSFS_MAGIC

What

The decision of whether a /sys or /dev/bus name belongs to this layer is made once, on the resolved name, and every entry point routes through it: open, openat, stat, lstat, fstatat, statx, access and faccessat both ways, readlink, readlinkat, getdents64, statfs, fstatfs, fstat, chdir, fchdir, epoll_ctl.

getdents64 unions the synthetic entries with the backing directory's own rather than replacing them, deduplicated by name with ours winning, and only for the three directories that exist on both sides. Under /sys/bus/usb and /dev/bus/usb absence stays authoritative.

The union holds the backing's names, not its descriptor. A second host descriptor pinned for the lifetime of a directory handle would break the budget #342 had just established: measured at ulimit -n 128, a guest could hold 896 union directory handles open but only 637 once each was read to the end, against 896 either way on main. The drain reads the backing's listing and closes it before returning, so the extra descriptor never spans a return to the guest, and tests/test-dir-fd-budget-union measures that difference so it cannot come back.

A listing that cannot be completed is reported rather than truncated. getdents64 follows Linux's shape here: a call that already wrote entries returns the count and delivers the error on the next call, a call that wrote nothing returns the error. Two silent truncations turned up while pinning this down and are fixed with it: a readdir failure mid-drain ended the listing as though it were the end of the directory, and a first entry too large for the guest buffer returned 0, which a guest reads as end of directory where Linux returns EINVAL.

Along the way the same class of defect surfaced in three more places and is fixed here: chdir never consulted the intercepts at all, epoll_ctl accepted a synthetic /sys directory while refusing the sysroot-backed one beside it, and fstat on a descriptor opened on a /dev/bus/usb node reported a regular file where stat on the same name reported the character device, which libusb reads.

Tests

tests/test-usb-sysfs-matrix is 20 entry points against 11 path classes. The expected values are recorded from a real kernel rather than written by hand: MATRIX_RECORD=1 on Linux emits the table, and the recorder self-checks 206 of 206 there. Against current main the lane reports 44 failures; here it passes 226. Each fix was reverted in isolation to confirm the rows it owns fail without it.

Nineteen cells are recorded XFAIL rather than deleted: a .. chain that walks through /sys/bus/usb and back out resolves on Linux and does not here, because the host walk would have to traverse a directory that exists only inside this layer. That predates #334, reproduces identically before it, and wants a path-layer change of its own.

Evidence

make check BAREMETAL_CROSS=aarch64-elf-   All 98 tests passed, exit 0
test-usb-sysfs-matrix        226 passed, 0 failed   (44 failed on main)
test-dir-fd-budget-union       3 passed, 0 failed   (fails without the drain fix)
test-dir-backing-drain-error   5/5, 8/8, 8/8
test-getdents64-small-buf      4 passed, 0 failed   (3 of 4 fail without it)
test-dir-fd-budget             OK                   (#342's own lane)
test-epoll, test-fcntl-flags   OK

Summary by cubic

Stops synthetic USB views from shadowing sysroot-backed names under /sys and /dev/bus: names outside /sys/bus/usb and /dev/bus/usb now fall through to the backing instead of returning ENOENT, while missing names in owned subtrees remain authoritative. Lookup, enumeration, metadata, and capability checks now use the same ownership decision.

Bug Fixes

  • Shared directories merge synthetic and backing entries with synthetic names taking precedence.
  • Backing entries are drained and the backing descriptor is closed before returning, preserving the host descriptor budget.
  • dup, dup2, F_DUPFD, F_DUPFD_CLOEXEC, and fork share directory position and union state instead of re-listing backing names.
  • Directory read failures and buffers too small for one entry now report errors instead of silently returning end-of-directory.
  • fstat and fstatfs match path-based USB identity, while chdir, fchdir, and epoll_ctl use the same path handling.
  • Traversal through synthetic USB directories with .., and a fork child whose parent closes the descriptor before backing drain, remain recorded XFAILs.

Tests

  • Adds a recorded matrix covering supported entry points and path classes, plus targeted coverage for descriptor budgets, aliases, fd reuse, drain failures, primary read failures, and small buffers.