Skip to content

Synthesize /dev/bus/usb and the USB sysfs tree - #334

Merged
jserv merged 2 commits into
sysprog21:mainfrom
jotpalch:pr-b-usb-sysfs
Aug 31, 2026
Merged

Synthesize /dev/bus/usb and the USB sysfs tree#334
jserv merged 2 commits into
sysprog21:mainfrom
jotpalch:pr-b-usb-sysfs

Conversation

@jotpalch

@jotpalch jotpalch commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Note

Second piece of the general USB translation layer discussed in #319, sitting on top of the netlink piece merged as #325. With #325 alone a libusb guest reaches libusb_init = 0 but get_device_list still returns 0; this piece is what puts devices in that list. It is read-only enumeration, so it stands alone and does not depend on the pieces that follow.

Summary

libusb and nusb do not discover devices through a syscall. They read Linux sysfs directly: /sys/bus/usb/devices/<bus>-<port> for the attribute files, and /dev/bus/usb/BBB/DDD for the raw descriptor blob. A guest running on elfuse has neither tree, so enumeration came back empty no matter how far init got.

This change synthesizes both trees from an IOKit registry walk. No device is opened to build them: registry properties supply the 18-byte device descriptor, and GetConfigurationDescriptorPtr supplies the raw configuration descriptors without an open. An unmodified guest binary now enumerates the attached hardware.

What

New src/runtime/usb-sysfs.c materializes both trees as scratch directories of real host files, following the ensure_syscpu_dir pattern already in procemu.c:

/sys/bus/usb/devices/<bus>-<ports>          device attribute dirs
/sys/bus/usb/devices/<bus>-<ports>:<c>.<i>  interface attribute dirs
/dev/bus/usb/BBB/DDD                        char 189:(bus-1)*128+(dev-1)

Design decisions that are worth a reviewer's attention:

Bus numbering. busnum is the top byte of the IOKit locationID plus one. macOS controller indices start at 0 while Linux busnums start at 1, so a literal locationID >> 24 would produce a bus 0 that no Linux tool expects. The port path below it comes from the remaining locationID nibbles, six at most, which is the same <bus>-<port>.<port> spelling linux_usbfs.c parses.

devnum. Taken from the USB Address property. Devices without that property get a stable per-bus fallback, numbered 1..n in locationID order and skipping numbers already taken, so a device never silently collides with another and the numbering does not shuffle between reads within a run.

Root hubs are absent, on purpose. macOS publishes no root hub as a USB device, verified with ioreg: there is no IOUSBDevice entry to name. So the tree carries no usbN entry and no parent link from a device up to its bus. Enumeration is unaffected, because libusb derives topology from the <bus>-<port> names alone and nusb drops root hubs outright. The nports == 0 branch in the code is not a filter for root hubs, it is the guard for an entry with no port path to name it by, and the comment says so. The one visible consequence is lsusb -t, covered below.

One descriptor blob, two readers. The descriptors sysfs attribute and a read() of the matching /dev/bus/usb node are served from the same buffer, so the two views cannot drift. usb_sysfs_descriptors_dup exports it for the usbdevfs fd that comes next. The test asserts the two reads are byte-identical rather than merely both parseable.

One view of the active configuration. bNumInterfaces, bmAttributes, bMaxPower, configuration, and the emitted :c.i interface directories all read the current configuration through a single active_config() helper. An earlier revision read bNumInterfaces from the first configuration in the blob while the interface directories came from the active one, which disagreed on any device whose active configuration is not the first. That class of bug is now structurally impossible, and the test pins bNumInterfaces against the actual directory count.

All four attributes exist on every device, whatever the blob held. Linux keeps configuration, bNumInterfaces, bmAttributes and bMaxPower in dev_attr_grp (sysfs.c:782-786), an attribute group with no .is_visible hook (sysfs.c:815-817), so every USB device directory carries all four files. When udev->actconfig is NULL, usb_actconfig_show returns the 0 its lock handed it (sysfs.c:29-43) -- a zero-length read, not an error. Emitting an attribute only when its value was known would turn that into ENOENT, and the two are different answers to a reader probing for an optional attribute: absent means unsupported, empty means supported with nothing to say. libudev makes the distinction observable -- udev_device_get_sysattr_value() returns a pointer to "" for an empty attribute and NULL for an absent one. So all four now render through one pure leaf, usb_desc_actconfig_attrs, and the caller writes all four unconditionally; presence is structural rather than repeated per line. (Contrast manufacturer/product/serial, which are gated by dev_string_attrs_are_visible and which this tree correctly omits when absent.)

SYSFS_MAGIC. statfs and fstatfs report 0x62656572 for /sys paths and for descriptors fds. libudev checks this before it will trust the tree, so without it the whole tree reads as untrustworthy to a udev-backed consumer.

Path handling. . and .. inside a /sys path are resolved rather than refused, so /sys/bus/usb/devices/../devices resolves the way it does on Linux instead of reporting EACCES; a suffix that climbs above /sys is handed back to the normal path flow. The lexical fold decides only whether a name is this layer's at all. The name that is actually served is resolved component by component, so a .. behind a symlink pops the target's parent the way the kernel does: <dev>/subsystem/.. is /sys/bus, which makes <dev>/subsystem/../usb/devices resolve and <dev>/subsystem/../idVendor ENOENT -- the reverse of what folding the name first produces. st_ino is derived from the resolved spelling, so two ways of naming one directory agree on their identity and an (st_dev, st_ino) same-file test tells the truth. A node addressed as a directory, a bare trailing slash included, reports ENOTDIR once the node exists and ENOENT when it does not. Symlinks inside the tree are followed the way Linux follows them, so open() of a subsystem link reaches the target directory and libudev's and pyserial's USB-backed checks resolve. Every link in the tree is written by this code with a fixed target and the tree is read-only to the guest, so following is safe; containment is proved rather than assumed, in that the resolved path is canonicalized and tested against the canonical scratch root and anything landing outside reports ENOENT instead of being opened. open, stat, lstat and readlink all resolve through that one helper, so the entry points cannot disagree about what the tree holds -- readlink opens nothing, but a target string is still the guest learning about a host object it was never handed. That property did not hold on the first revision and the review caught it: O_NOFOLLOW split the final component off, contained the prefix, then reattached the leaf, so a .. leaf reattached to the canonical root named the host directory the tree sits in. open("<dev>/subsystem/../../..", O_NOFOLLOW) returned a descriptor on the host temp parent while stat, lstat and readlink of the same name refused it and logged the refusal. A . or .. leaf is no longer held back, since neither can be a symlink and O_NOFOLLOW has nothing to protect there, and containment is now factored into one usb_sys_contained() applied last on every path, including after the reattach. A probe that plants a file in the backing directory and asks 16 names through open, O_NOFOLLOW open, stat, lstat, readlink and openat reports 4 escapes against the first revision and 0 now, with all entry points agreeing on every name. O_NOFOLLOW on a symlink reports ELOOP before the access mode is consulted, which is the order Linux uses (open(subsystem, O_WRONLY|O_NOFOLLOW) is ELOOP, plain O_WRONLY is EISDIR, measured on 6.19), and O_PATH | O_NOFOLLOW yields an fd on the link itself.

Descriptor identity, and the errnos around it. A descriptor on a followed subsystem link names the directory it resolves to, not the spelling the guest opened, so openat(fd, "..") pops the target's parent. That is what Linux does (open("/sys/class/net/lo/subsystem", O_PATH) names /sys/class/net and .. off it names /sys/class, measured on 6.19) and the stamp now comes from F_GETPATH on the descriptor rather than from the request, which needs no special case because F_GETPATH already reports the target for a followed open and the link for an O_SYMLINK one. O_DIRECTORY|O_CREAT is EINVAL, decided while the open flags are built and therefore ahead of path resolution and of the dirfd check, with O_PATH excluded because the kernel masks the creation bit off there and O_PATH|O_DIRECTORY|O_CREAT opens the directory. Every sysfs attribute is poll-capable, not just the CPU subtree, because kernfs_fop_poll is installed by sysfs_file_operations on all of them, so epoll_ctl(ADD) on <dev>/uevent answers 0 rather than EPERM; udev-style readers arm uevent before they read it. statfs decides which filesystem answers from what the name resolves to, so /sys/../sys and a relative sys from a cwd of / both report SYSFS_MAGIC. st_rdev is composed in uint32_t before it becomes a dev_t: dev_t is a signed 32-bit int on macOS and shifting major 189 left by 24 lands in the sign bit, which UBSAN reports as undefined. The ENOENT rebase fallback that lets chase() step from a host directory into a synthetic subtree now requires the descriptor to be inside the sysroot, so a sysroot symlink pointing out of the tree cannot hand a host /dev dirfd to the /dev/bus/usb intercept.

Interface numbers are a whole byte. bInterfaceNumber is a __u8, so the selection is sized to all 256 values rather than 32; the earlier bound silently dropped interfaces numbered 32 and above and left bNumInterfaces disagreeing with the directories on disk. The walk itself is bounded by cfg_len, so the table size was never what kept it in bounds.

Read-only for now. O_RDONLY on a node serves the descriptor blob; O_RDWR and O_WRONLY report EACCES with a one-line warning. Write access to a directory is refused the way open(2) refuses it, with EISDIR rather than a readable descriptor or an EACCES: /dev/bus/usb, the per-bus directories and /sys/bus/usb/devices all answer alike, while a create under the read-only /sys tree still answers EACCES. The directory opener is shared with /proc, which had the same gap, so that spelling is corrected too. The typed usbdevfs fd and its ioctls are the next piece; this one deliberately carries no transfer path.

Layout deviations that are deliberate, and their cost. The /sys/bus/usb/devices entries are real directories rather than symlinks into /sys/devices/..., so realpath() of an entry canonicalizes to itself. libusb opens attributes relative to the entry and nusb canonicalizes the entry path, so both tolerate it. Two smaller ones are accepted rather than hidden, and both are recorded in docs/internals.md:

  • the /dev/bus/usb device nodes are placeholder files, so getdents64 reports them with d_type DT_REG where Linux reports DT_CHR. stat() is correct -- the intercept fills S_IFCHR with major 189 and the right minor, which is what libusb, nusb and lsusb consult -- so only a scanner that filters on d_type alone and never calls stat would skip them. No such consumer is known, and creating real character devices needs privileges elfuse does not ask for.
  • statfs on /dev/bus agrees with fstatfs but reports the scratch filesystem's magic instead of DEVTMPFS_MAGIC.
  • configuration is always empty, and this is the one deviation that is one-directional rather than cosmetic. The attribute holds the string named by the active configuration's iConfiguration, and a string descriptor is fetched over an ep0 control transfer on an open device -- which this piece deliberately does not do. IOKit offers no way around it, and that was checked against the live registry rather than assumed: an IOUSBHostDevice entry publishes iManufacturer, iProduct and iSerialNumber together with the three strings the family caches for them (USB Vendor Name, USB Product Name, kUSBSerialNumberString), but no iConfiguration index and no configuration string. An empty file is nevertheless the honest answer rather than a missing one, because it is the answer Linux gives from the same position: configuration_show emits nothing whenever actconfig->string is NULL (sysfs.c:83-84), and usb_cache_string is documented to return NULL "if the index is 0 or the string could not be read" (message.c:1074-1075). An empty configuration on Linux therefore means "no cached string", which is strictly weaker than "iConfiguration is 0" -- a device that NAKs its own string descriptor reads empty on real sysfs too, and every device here takes that second route. The residual gap: a configuration whose iConfiguration is non-zero and whose string is readable shows that string on Linux and shows nothing here, until an ep0 path exists to fetch it.

Nothing in scope reads either, and I would rather name them than have them found. A third one is gone rather than documented: stat() of a subsystem link now reports the directory it resolves to and lstat() reports the link, because the stat intercept takes the follow flag its callers already had.

Snapshot. The tree is built once per run, so a replug is not visible within a run until the uevent layer can invalidate it. Documented, not papered over.

No device ceiling. The device table grows on demand rather than stopping at a fixed 64. usbfs itself caps only at 127 devices per bus, and a chain of hubs can put more than any fixed guess on one machine; a truncated model would lose devices from both the /dev/bus/usb and the /sys view at once. Only a failed allocation stops the walk, and it says so.

O_PATH dirfds. An O_PATH descriptor on a non-directory answers ENOTDIR for a relative name resolved against it, as openat(2) does. The empty path is excluded from that rule, so fstat() of such a descriptor still reports the object it names.

Link and plumbing. The Makefile adds -framework IOKit -framework CoreFoundation and the new source. path.c gates claim /sys (minus the existing syscpu stub) and /dev/bus for the open and stat intercepts; openat/fstatat/readlinkat on synthetic paths resolve through path_rebase_hostdirfd and are re-offered to the intercepts, which is what keeps systemd chase()'s per-component walk on the synthetic tree. usb_lock is documented as a leaf.

Tests. New tests/test-usb-sysfs.c with its own make check lane, in two halves. The first needs no device at all (SYSFS_MAGIC from statfs and fstatfs alike, the read-only open contract, the .. fold, the ENOENT/EINVAL/ENOTDIR answers), so a machine with an empty bus still covers the path layer. The second walks whatever is attached and asserts the identities that need a device. It prints the device count, so a run that covered only the first half says so instead of reading as full cover. Writing it found two bugs before review did: the bare trailing slash returning a descriptors fd, and the unfolded st_ino. The descriptor blob is device-supplied, so the walks over it live in a pure leaf unit, src/runtime/usb-desc.c, with tests/test-usb-desc-host.c covering the malformed cases natively on the host: a bLength past the end of the buffer, a zero bLength, a truncated trailing descriptor, a wTotalLength longer than the blob, and a wTotalLength shorter than the configuration it names -- which without a header check lands the cursor on an interface descriptor and returns it as the active configuration, since an interface's byte 5 sits where bConfigurationValue does. The iterator compares in the remaining-bytes domain, so a bad length stops the walk in bounds instead of stepping the cursor off the end.

The same unit covers the active-configuration attribute set, which is where the interesting branches are hardware-inaccessible: no active configuration at all; a cfg_len below the header size; iConfiguration 0 with and without a string offered (one offered at index 0 is dropped, because usb_cache_string short-circuits before any transfer); iConfiguration non-zero with the string unreadable (NULL and "" alike); iConfiguration non-zero with the string readable, which is the branch no attached device can reach; a string longer than the kernel's MAX_USB_STRING_SIZE, which is truncated in its own tail and never in the trailing newline sysfs_emit always writes; and a two-configuration blob where selecting the wrong one shows the wrong string. That leaves only the emit-to-file step hardware-conditional, and test-usb-sysfs.c covers that against whatever is attached.

Three of the device-half assertions were rewritten because they could not fail. bmAttributes, bMaxPower and configuration are now checked against the device's own descriptors blob -- strtol's endptr against cfg[7], the power string against cfg[8] scaled by the speed's unit, and configuration empty whenever iConfiguration is 0 -- through a second, deliberately independent walk of the blob inside the test, so the test cannot pass by agreeing with the code under test. A mutant that emits the wrong bmAttributes byte, swaps the power unit and writes a non-empty configuration passes the old assertions 56/0 and fails the new ones in 5 places. A containment assertion was added for the same reason, and the review found that it too could not fail: the probe path dropped the tmp component the .. chain re-entered, so it never named the plant and passed on ENOENT while the resolver was leaking. It now asserts the chain with nothing appended, pins each depth by st_dev/st_ino against /sys/bus and /sys rather than by errno (refusing every depth would satisfy "not an escape" while breaking every relative walk through the link), takes the plant appearing in the opened directory's getdents listing as the oracle, and reads the plant back by its own name first so the refusals cannot both be reporting an absent file. Against the first revision it reports 100 passed, 33 failed, naming the escaped path and the planted file. The probe name comes from mkstemp, because the guest pid is always 1 and a pid suffix separates no lanes; eight concurrent lanes now pass 8/8 where the fixed name gave 3/8. The blob reader grows its buffer instead of capping: a fixed cap answered "the file was this long" and "the read stopped here" with one number, and the two-view compare read it as a length, so two blobs differing past the cap compared equal.

Evidence

Clean rebuild at this tip, ESP32-S3 (303a:1001) attached behind a Fresco Logic hub.

lsusb-lite, a static guest binary that walks sysfs exactly the way linux_usbfs.c does and then byte-compares the sysfs blob against the /dev/bus/usb node:

STEP statfs_/sys = 0 (0 ok) f_type=0x62656572 SYSFS_MAGIC
STEP opendir_/sys/bus/usb/devices = ok (0 ok)
DEVICE 2-1
  realpath = /sys/bus/usb/devices/2-1
  busnum = 2
  devnum = 1
  idVendor = 303a
  idProduct = 1001
  bcdDevice = 0101
  version =  2.00
  bDeviceClass = ef
  bDeviceSubClass = 02
  bDeviceProtocol = 01
  speed = 12
  bConfigurationValue = 1
  manufacturer = Espressif
  product = USB JTAG/serial debug unit
  serial = 34:85:18:42:6C:98
  descriptors = 116 bytes
  IFACE 2-1:1.1: bInterfaceNumber=01 bInterfaceClass=0a bInterfaceSubClass=02 bInterfaceProtocol=00
  IFACE 2-1:1.0: bInterfaceNumber=00 bInterfaceClass=02 bInterfaceSubClass=02 bInterfaceProtocol=00
  IFACE 2-1:1.2: bInterfaceNumber=02 bInterfaceClass=ff bInterfaceSubClass=ff bInterfaceProtocol=01
  node /dev/bus/usb/002/001 = 116 bytes -> MATCH
  PARSE OK
RESULT devices_ok=2 sysfs_ok=1

The hub enumerates too (1-1, Fresco Logic 1d5c:5801, MATCH), trimmed here for length.

The five attributes lsusb -t warns about, plus bMaxPower, read back on both devices:

== /sys/bus/usb/devices/1-1 ==        == /sys/bus/usb/devices/2-1 ==
  bmAttributes   = [e0]                 bmAttributes   = [c0]
  bMaxPower      = [0mA]                bMaxPower      = [500mA]
  configuration  = []                   configuration  = []
  maxchild       = [0]                  maxchild       = [0]
  rx_lanes       = [1]                  rx_lanes       = [1]
  tx_lanes       = [1]                  tx_lanes       = [1]

Those are not free-standing values; they are the configuration descriptor decoded. Bytes 18..26 of 2-1/descriptors are the configuration descriptor itself:

09 02 62 00 03 01 00 c0 fa
                  ^^ ^^ ^^
                  |  |  bMaxPower 0xfa = 250 units = 500mA
                  |  bmAttributes 0xc0 = bus-reserved | self-powered
                  iConfiguration 0, so no string is named and the
                  configuration file is empty -- as it is on Linux

bNumInterfaces is the 03 in that same descriptor, and three :1.N directories exist. configuration being empty is the Linux behavior for a configuration whose string was not cached, not a stub -- see the deviation note above for why the file is present and empty rather than omitted. maxchild is 0 for both, including the hub, because the hub class descriptor needs an open device; the code says so at the write site.

The one-blob invariant, and the node's identity:

$ cmp /sys/bus/usb/devices/2-1/descriptors /dev/bus/usb/002/001 && echo IDENTICAL
IDENTICAL
$ cat /sys/bus/usb/devices/2-1/dev
189:128

189:128 is (bus-1)*128 + (dev-1) for bus 2 device 1, and uevent agrees (MAJOR=189 MINOR=128 DEVNAME=bus/usb/002/001 PRODUCT=303a/1001/101 TYPE=239/2/1).

Test lane and full suite on a clean rebuild:

test-usb-sysfs: 138 passed, 0 failed - PASS
  devices examined: 2
test-usb-desc-host: all tests passed
test-sysroot-symlink-escape: 9 passed, 0 failed - PASS
make check BAREMETAL_CROSS=aarch64-elf- exit=0
  All 89 tests passed
  Results: 7 passed, 0 failed, 0 skipped (of 7)
  Results: 81 passed, 0 failed, 3 skipped (of 84)
  Results: 27 passed, 0 failed, 0 skipped (of 27)
  Results: 4 passed, 0 failed, 0 skipped (of 4)
make check-ubsan exit=0   0 runtime errors, test-usb-sysfs 138/0
make check-asan  exit=0   0 sanitizer reports, test-usb-sysfs 138/0

test-usb-sysfs moved onto the sanitizer lane through CHECK_SHARED_LANES rather than tests/manifest.txt: .ci/check-matrix-lists.sh rejects a manifest binary the matrix never runs, confirmed by adding it and watching the gate fail. It belongs there because this layer composes a dev_t by shifting a major number, which is exactly the arithmetic UBSAN is there to adjudicate, and nothing else on the lane builds the tree.

Run against the previous revision of this branch, the same test reports:

test-usb-sysfs: 100 passed, 33 failed - FAIL
escaped: /sys/bus/usb/devices/2-1/subsystem/../../.. lists the planted elfuse-usb-escape-probe-shftDx

(The three skips are environment-driven and identical on main: two musl fixtures absent, one busybox applet not in this build.)

On the self-hosted runner, which has four devices where this board has two, and at least one whose active configuration carries a non-zero iConfiguration:

  configuration is empty when iConfiguration is 0            OK   (x4)
  the four active-config attributes are all present          OK   (x4)
  an absent device attribute is ENOENT, not EACCES           OK   (x4)
  devices examined: 4
test-usb-sysfs: 126 passed, 0 failed - PASS

What does not work yet, stated plainly. Real Debian lsusb (usbutils 018 over libusb-1.0.28, the udev-backed build) still does not reach this tree:

$ elfuse --sysroot ./sysroot-libusb /usr/bin/lsusb
WARN unimplemented syscall 264 (...)
unable to initialize libusb: -99

Syscall 264 is name_to_handle_at, which libudev uses to decide whether /sys is a genuine sysfs mount. That is a separate change and not in this PR, so the evidence above comes from a static guest binary that walks the same sysfs paths libusb walks, not from lsusb itself. Transfers are also not here: this piece is enumeration only, and reading or writing an endpoint needs the usbdevfs fd that follows.

Known boundary: lsusb -t

lsusb -t lists devices without the bus rows above them. The README feature line now says so, and no longer claims the tree is enough for lsusb at all: a udev-backed lsusb needs name_to_handle_at as well, which is not implemented yet, so the bullet promises libusb-style enumeration and names both gaps. lsusb -t no longer prints the per-device warnings either: the five missing-attribute warnings per device are gone, and it exits 0 silently. That is the improvement this PR makes to tree mode, and it is the whole of it.

The empty tree itself is structural and recorded in #319 (comment). Tree mode does not enumerate through libusb; it walks /sys/bus/usb/devices itself and roots the tree at the usbN root-hub entries, which macOS gives us nothing to build from. Making the tree render is a coherent chunk of work rather than a missing attribute: usbN entries occupy Device 001 on each bus on Linux, so real devices would renumber to 002 and up, moving their /dev/bus/usb paths, and the per-interface driver symlinks would then have to agree with what GETDRIVER reports. No consumer in scope reads any of it, so it is recorded as a known boundary rather than planned work. If a real workflow needs tree mode, it can become its own issue then.

cubic-dev-ai[bot]

This comment was marked as resolved.

@jotpalch
jotpalch force-pushed the pr-b-usb-sysfs branch 3 times, most recently from 32814d3 to 4447cb1 Compare August 26, 2026 11:18

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 18 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/runtime/usb-sysfs.c">

<violation number="1" location="src/runtime/usb-sysfs.c:305">
P1: On hosts that publish only the modern `IOUSBHostDevice` class, this match returns no devices and the synthetic USB tree is empty. Match the host-family device class and use a descriptor API compatible with that service.</violation>

<violation number="2" location="src/runtime/usb-sysfs.c:387">
P2: When IOKit reports current configuration 0, the fallback treats the device as configured with its first descriptor and exposes interfaces that are not active. Track whether the property is absent separately from its value, and preserve zero for an unconfigured device.</violation>
</file>

<file name="src/runtime/procemu.c">

<violation number="1" location="src/runtime/procemu.c:1241">
P2: When a guest opens a synthetic directory with `O_DIRECTORY|O_CREAT`, this guard returns `EISDIR`, but Linux returns `EINVAL` for that combination. Handle `O_DIRECTORY|O_CREAT` separately before the general directory-write check.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/runtime/usb-sysfs.c
{
model_clear();

CFMutableDictionaryRef match = IOServiceMatching("IOUSBDevice");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: On hosts that publish only the modern IOUSBHostDevice class, this match returns no devices and the synthetic USB tree is empty. Match the host-family device class and use a descriptor API compatible with that service.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/usb-sysfs.c, line 305:

<comment>On hosts that publish only the modern `IOUSBHostDevice` class, this match returns no devices and the synthetic USB tree is empty. Match the host-family device class and use a descriptor API compatible with that service.</comment>

<file context>
@@ -0,0 +1,1696 @@
+{
+    model_clear();
+
+    CFMutableDictionaryRef match = IOServiceMatching("IOUSBDevice");
+    if (!match)
+        return;
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is wrong, and I can measure it on the machine the premise describes.

This host publishes only IOUSBHostDevice. There is no IOUSBDevice instance in the registry at all. IOServiceMatching("IOUSBDevice") still returns every one of them, because IOUSBHostDevice metaCasts to IOUSBDevice, and IOKit matching is by conformance rather than by exact class name:

=== IOServiceMatching("IOUSBDevice") ===
  [1] class=IOUSBHostDevice  name=USB2.0 Hub                 conforms: IOUSBDevice=1 IOUSBHostDevice=1
  [2] class=IOUSBHostDevice  name=USB JTAG/serial debug unit conforms: IOUSBDevice=1 IOUSBHostDevice=1
  total: 2

=== IOServiceMatching("IOUSBHostDevice") ===
  [1] class=IOUSBHostDevice  name=USB2.0 Hub                 conforms: IOUSBDevice=1 IOUSBHostDevice=1
  [2] class=IOUSBHostDevice  name=USB JTAG/serial debug unit conforms: IOUSBDevice=1 IOUSBHostDevice=1
  total: 2

The two matches return the identical set, and IOObjectConformsTo(svc, "IOUSBDevice") is 1 on both entries. So the tree is not empty on a host that publishes only the modern class, and this is that host (macOS 15.6.1, ESP32-S3 behind a Fresco hub).

That agrees with what the two backends this layer has to look like do. libusb's darwin backend sets darwin_device_class = "IOUSBDevice" and nusb matches kIOUSBDeviceClassName, which is the same string. Neither has a second class name, and neither is broken on modern macOS. Adding IOUSBHostDevice as an alternative match would only ever be redundant with the one already there, so nothing changed here.

The descriptor half of the suggestion does not apply either: this layer reads descriptors out of the registry properties, not through a device interface bound to a particular service class.

Comment thread src/syscall/fs-stat.c Outdated
Comment thread src/syscall/fs-stat.c Outdated
Comment thread src/syscall/fs.c
Comment thread src/runtime/usb-sysfs.c
Comment thread src/runtime/usb-sysfs.c Outdated
Comment thread src/runtime/procemu.c
Comment on lines +1241 to +1246
if (!(linux_flags & LINUX_O_PATH) &&
((linux_flags & LINUX_O_ACCMODE) != LINUX_O_RDONLY ||
(linux_flags & (LINUX_O_CREAT | LINUX_O_TRUNC)))) {
errno = EISDIR;
return -1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a guest opens a synthetic directory with O_DIRECTORY|O_CREAT, this guard returns EISDIR, but Linux returns EINVAL for that combination. Handle O_DIRECTORY|O_CREAT separately before the general directory-write check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/procemu.c, line 1241:

<comment>When a guest opens a synthetic directory with `O_DIRECTORY|O_CREAT`, this guard returns `EISDIR`, but Linux returns `EINVAL` for that combination. Handle `O_DIRECTORY|O_CREAT` separately before the general directory-write check.</comment>

<file context>
@@ -1227,6 +1228,23 @@ static int proc_parse_int_write(const void *buf, size_t count, int *out)
+     * O_PATH is the documented exception: it opens the object for name
+     * operations only, and the access mode is ignored rather than checked.
+     */
+    if (!(linux_flags & LINUX_O_PATH) &&
+        ((linux_flags & LINUX_O_ACCMODE) != LINUX_O_RDONLY ||
+         (linux_flags & (LINUX_O_CREAT | LINUX_O_TRUNC)))) {
</file context>
Suggested change
if (!(linux_flags & LINUX_O_PATH) &&
((linux_flags & LINUX_O_ACCMODE) != LINUX_O_RDONLY ||
(linux_flags & (LINUX_O_CREAT | LINUX_O_TRUNC)))) {
errno = EISDIR;
return -1;
}
if (!(linux_flags & LINUX_O_PATH) &&
(linux_flags & LINUX_O_DIRECTORY) &&
(linux_flags & LINUX_O_CREAT)) {
errno = EINVAL;
return -1;
}
if (!(linux_flags & LINUX_O_PATH) &&
((linux_flags & LINUX_O_ACCMODE) != LINUX_O_RDONLY ||
(linux_flags & (LINUX_O_CREAT | LINUX_O_TRUNC)))) {
errno = EISDIR;
return -1;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and the rule reaches further than the sysfs guard. Measured on 6.19:

open("/tmp",            O_DIRECTORY|O_CREAT|O_RDONLY) -> -1 EINVAL
open("/etc/hostname",   O_DIRECTORY|O_CREAT|O_RDONLY) -> -1 EINVAL
open("/tmp/absent-xyz", O_DIRECTORY|O_CREAT|O_RDONLY) -> -1 EINVAL
open("/proc/self",      O_DIRECTORY|O_CREAT|O_RDONLY) -> -1 EINVAL
openat(-7, "x",         O_DIRECTORY|O_CREAT)          -> -1 EINVAL
openat2("/tmp", O_DIRECTORY|O_CREAT)                  -> -1 EINVAL

An existing directory, a regular file, an absent name and a bad dirfd all answer the same, so the decision is made while the open flags are built, before the path is resolved and before the descriptor is validated. EISDIR described a lookup the kernel never performed. Since macOS open(2) agrees, host-backed names already answered EINVAL and only the intercepts did not, so the rule now sits at the top of sys_openat_path rather than inside each of them.

There is one exception, and I got it wrong on the first pass, so it is worth recording. Under O_PATH the kernel masks the flags down to O_DIRECTORY|O_NOFOLLOW|O_PATH before that pair is looked at, so the creation bit is already gone:

open("/tmp",       O_PATH|O_DIRECTORY|O_CREAT) -> ok        (identical to O_PATH|O_DIRECTORY)
open("/sys",       O_PATH|O_DIRECTORY|O_CREAT) -> ok
open(absent,       O_PATH|O_DIRECTORY|O_CREAT) -> -1 ENOENT
open("/etc/hosts", O_PATH|O_DIRECTORY|O_CREAT) -> -1 ENOTDIR

Refusing the pair unconditionally turned three working opens into EINVAL, including a descriptor systemd's chase() takes on every directory it walks. The bit is now cleared under O_PATH rather than the pair merely skipped, because left set it still reads as write intent and the read-only sysfs gate answered EISDIR for a descriptor the kernel hands out. The eight-row probe above now matches Linux row for row, and the test asserts both forms over /sys, /sys/bus/usb/devices, an absent name under it, /dev/bus/usb and /proc/self.

Comment thread src/syscall/fs-stat.c Outdated
Comment thread src/runtime/usb-sysfs.c
d->i_product = (v = ioreg_num(svc, "iProduct")) > 0 ? (unsigned) v : 0;
d->i_serial =
(v = ioreg_num(svc, "iSerialNumber")) > 0 ? (unsigned) v : 0;
d->cfg_value = (v = ioreg_num(svc, "kUSBCurrentConfiguration")) > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When IOKit reports current configuration 0, the fallback treats the device as configured with its first descriptor and exposes interfaces that are not active. Track whether the property is absent separately from its value, and preserve zero for an unconfigured device.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/usb-sysfs.c, line 387:

<comment>When IOKit reports current configuration 0, the fallback treats the device as configured with its first descriptor and exposes interfaces that are not active. Track whether the property is absent separately from its value, and preserve zero for an unconfigured device.</comment>

<file context>
@@ -0,0 +1,1696 @@
+        d->i_product = (v = ioreg_num(svc, "iProduct")) > 0 ? (unsigned) v : 0;
+        d->i_serial =
+            (v = ioreg_num(svc, "iSerialNumber")) > 0 ? (unsigned) v : 0;
+        d->cfg_value = (v = ioreg_num(svc, "kUSBCurrentConfiguration")) > 0
+                           ? (unsigned) v
+                           : 0;
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism is right and nothing changed here. I want to say why rather than let it look overlooked.

You are correct that cfg_value == 0 carries two meanings at once. kUSBCurrentConfiguration absent and kUSBCurrentConfiguration == 0 both fall through the same branch to the first configuration's bConfigurationValue, and an unconfigured device would then show that configuration's interfaces. Linux shows nothing for those attributes when actconfig is NULL, and no interface directories at all.

What stopped me is that I cannot produce the trigger and would be shipping an untested contract. Measured on this host, both attached devices publish the key:

device USB2.0 Hub                  kUSBCurrentConfiguration = 1   bNumConfigurations = 1
device USB JTAG/serial debug unit  kUSBCurrentConfiguration = 1   bNumConfigurations = 1

Board access here is read-only, so I cannot put a device into the unconfigured state to see what IOKit publishes for it, and I have no evidence for whether the key goes absent, goes to zero, or the service disappears. A real fix is not a one-line separation either: bConfigurationValue, bNumInterfaces, configuration and bmAttributes/bMaxPower all have to take the unconfigured spelling together, and emit_interface_dirs has to emit nothing, which is four attributes plus the directory set changing on a path no test could exercise.

I would rather leave the conflation documented and visible than replace it with an unconfigured-device contract that has never been run. If you would prefer it landed on the strength of the kernel side alone, say so and I will do it, but it will ship unverified against IOKit and I want that on the record before it does.

Comment thread tests/test-usb-sysfs.c Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 18 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/path.c">

<violation number="1" location="src/syscall/path.c:1213">
P1: When an `O_PATH` open follows a USB `subsystem` symlink, relative `openat()` calls resolve from the symlink spelling rather than the target directory. Resolve or canonicalize the followed target before storing the `FD_PATH` stamp, or make this rebasing symlink-aware.</violation>
</file>

<file name="tests/test-usb-sysfs.c">

<violation number="1" location="tests/test-usb-sysfs.c:322">
P2: The containment test never reaches the planted `/tmp` symlink, so it passes with ENOENT even if the resolver permits an escape. Keep the `tmp` component when constructing the probe path.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/syscall/path.c
* where the descriptor names itself rather than a child, and fstatat() on
* an O_PATH fd of a regular file has to keep working.
*/
if (snap.type == FD_PATH && path[0] != '\0' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an O_PATH open follows a USB subsystem symlink, relative openat() calls resolve from the symlink spelling rather than the target directory. Resolve or canonicalize the followed target before storing the FD_PATH stamp, or make this rebasing symlink-aware.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 1213:

<comment>When an `O_PATH` open follows a USB `subsystem` symlink, relative `openat()` calls resolve from the symlink spelling rather than the target directory. Resolve or canonicalize the followed target before storing the `FD_PATH` stamp, or make this rebasing symlink-aware.</comment>

<file context>
@@ -1155,11 +1189,33 @@ int resolve_proc_dirfd_path(guest_fd_t dirfd,
+     * where the descriptor names itself rather than a child, and fstatat() on
+     * an O_PATH fd of a regular file has to keep working.
+     */
+    if (snap.type == FD_PATH && path[0] != '\0' &&
+        !proc_path_fd_is_dir(&snap)) {
+        errno = ENOTDIR;
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. I measured the rule on 6.19 first, since it is not the obvious one:

open("/sys/class/net/lo/subsystem", O_PATH)             fd names /sys/class/net
openat(that fd, "..", O_PATH|O_DIRECTORY)               fd names /sys/class
open("/sys/class/net/lo/subsystem", O_PATH|O_NOFOLLOW)  fd names /sys/devices/virtual/net/lo/subsystem

A followed open names the target and pops the target's parent; only the O_NOFOLLOW open names the link. Here both named the link's own spelling, so every relative walk off such a descriptor restarted from the link's directory.

The fix asks the descriptor rather than the request. usb_sysfs_guest_path_for_fd() reads F_GETPATH and translates the host path back to its /sys spelling, and sys_openat_path stamps that. F_GETPATH needed no special case: it already reports the target for an open that followed and the link for an O_SYMLINK open, which is the same split Linux makes, so O_PATH|O_NOFOLLOW falls out correctly.

Before and after, on both the device and the interface directories:

fd on <dev>/subsystem names  /sys/bus/usb/devices/1-1/subsystem  ->  /sys/bus/usb
openat(fd, "..") lands on    /sys/bus/usb/devices/1-1            ->  /sys/bus

with O_PATH|O_NOFOLLOW still naming the link.

One gap this exposed rather than introduced, which I would rather state than leave to be found. A dirfd-relative name whose tail crosses the link is still folded lexically before the sysfs resolver sees it, in proc_apply_components(), which predates this PR:

open("/sys/bus/usb/devices/1-1/subsystem/..")  names /sys/bus                    (matches Linux)
openat(devices_fd, "1-1/subsystem/..")         names /sys/bus/usb/devices/1-1
stat() and fstatat() on that name report different st_ino

On 6.19 all four agree (/sys/class, same inode). Before this change both spellings were lexical and therefore agreed with each other and not with Linux; now the path form is right and the dirfd form is not. Lexical folding clamps at the guest root, so it cannot leave the tree, and the escape probe confirms the openat form never reached the plant: this is a fidelity gap, not a containment one. Fixing it means changing the fold order for every dirfd-relative walk, /proc and /dev/pts included, which I do not want to do inside this PR.

Comment thread src/runtime/usb-sysfs.c
Comment thread src/syscall/path.c
Comment thread src/runtime/usb-sysfs.c Outdated
Comment thread tests/test-usb-sysfs.c Outdated
Comment thread tests/test-usb-sysfs.c
/* probe + 4 drops the leading "/tmp", which the '..' chain re-enters. */
char path[700];
int n =
snprintf(path, sizeof(path), "%s/subsystem/../../..%s", dir, probe + 4);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The containment test never reaches the planted /tmp symlink, so it passes with ENOENT even if the resolver permits an escape. Keep the tmp component when constructing the probe path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-usb-sysfs.c, line 322:

<comment>The containment test never reaches the planted `/tmp` symlink, so it passes with ENOENT even if the resolver permits an escape. Keep the `tmp` component when constructing the probe path.</comment>

<file context>
@@ -0,0 +1,691 @@
+    /* probe + 4 drops the leading "/tmp", which the '..' chain re-enters. */
+    char path[700];
+    int n =
+        snprintf(path, sizeof(path), "%s/subsystem/../../..%s", dir, probe + 4);
+    if (n < 0 || (size_t) n >= sizeof(path)) {
+        unlink(probe);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this is the more serious half of the pair, because the test was passing for the wrong reason.

The probe path was built so that the appended component dropped the tmp the .. chain had just re-entered, so the name never named the plant. It answered ENOENT and the assertion read that as containment. The rebuilt test against 96b2dd2, the tree exactly as posted:

test-usb-sysfs: 100 passed, 33 failed - FAIL
escaped: /sys/bus/usb/devices/2-1/subsystem/../../.. lists the planted elfuse-usb-escape-probe-shftDx

So the resolver fix in the sibling thread is now pinned by a test that fails without it.

Four changes:

  • The chain is asserted with nothing appended, since appending is what made the name miss.
  • Each depth is pinned by identity, st_dev/st_ino against stat("/sys/bus") and stat("/sys"), not by errno. A resolver that simply refused all three depths would satisfy "not an escape" while breaking every relative walk through the link, and only the identity assertion separates the two.
  • The oracle is the plant appearing in the opened directory's listing, read through getdents, rather than an errno. If it opens something, the test says which directory it opened.
  • The plant is read back by its own name first, so the refusals below it cannot both be reporting an absent file.

@jotpalch

Copy link
Copy Markdown
Contributor Author

Round summary. Head is now 96b2dd2; all GitHub Actions checks on it pass.

The 4-device runner caught an assertion that was wrong about Linux. On Runtime (Release) for 32814d3 the self-hosted runner had four USB devices attached and reported:

  configuration is empty exactly when iConfiguration is 0 FAIL: configuration against iConfiguration (errno=13)
  devices examined: 4
test-usb-sysfs: 117 passed, 1 failed - FAIL

The test was wrong, not the code. configuration_show emits nothing whenever actconfig->string is NULL (sysfs.c:83-84), and usb_cache_string returns NULL "if the index is 0 or the string could not be read" (message.c:1074-1075). Empty therefore means "no cached string", which is strictly weaker than "iConfiguration is 0": a device whose string could not be read reads empty on real sysfs too. Our board has two devices and neither took that route, so the assertion held here and only failed where a device with a non-zero iConfiguration existed. The assertion was rewritten to the rule the kernel actually implements.

It exposed a sibling bug in the presence rule. The four active-configuration attributes were emitted only when their value was known, which turned an empty read into ENOENT. Linux keeps bNumInterfaces, bmAttributes, bMaxPower and configuration in dev_attr_grp (sysfs.c:782-786) with no .is_visible hook (sysfs.c:815-817), so they exist on every USB device directory, and usb_actconfig_show returns the 0 its lock gave it when actconfig is NULL (sysfs.c:29-43), a zero-length read. Absent and empty are different answers to a reader probing for an optional attribute. usb_desc_actconfig_attrs() now renders all four into one struct and clears them before any early return, so the presence rule is structural rather than repeated per line.

Fuzzing the descriptor walk found a newline truncation. An earlier draft of the attribute render wrote the configuration string with a bare %s, so a string longer than the buffer had its truncation eat the trailing newline that sysfs_emit always writes. The render is now precision-bounded ("%.*s\n"), so a too-long string loses its own tail and never the newline. The same fuzz run over usb_desc_active_config() and the iterator (random and semi-structured blobs, exact-size allocations under ASan) is what pinned the header validation the reviews asked for.

We introduced a case-exactness regression and fixed it. check-name-caseexact went 46/0 to 45/1 between the parent commit and 49a6139. The bisected symptom named ENOTDIR-for-ENOENT, and that was a mirage: EXPECT_ERRNO prints the global errno without clearing it, the stat() had actually succeeded, and errno=20 was left by the assertion above it. The real defect is in src/syscall/proc-state.c, untouched by this PR and unchanged since d410047: the two early returns inside the component loop of resolve_byte_exact_through_links() never wrote the guest spelling their caller reads on every verdict, and the caller's buffer was uninitialized. Stack bytes that happened to spell / rebased resolution onto the root, which exists, so an absent path was answered from the root's host path. The read predates this branch but was benign there; the USB sysfs work changed the frame below and made it live, which is why git bisect lands here. 96b2dd2 publishes the spelling at the top of each walk iteration and seeds the buffer empty in both callers, so the contract holds structurally. 11 assertions were added, each clearing errno first so the same regression prints errno=0 rather than a stale ENOTDIR; against pristine 49a6139 the file reports 53 passed, 4 failed, and with the fix 57 passed, 0 failed.

check-commit-log.sh was being invoked wrongly, so earlier passes were vacuous. It reads commit ids on standard input, not as arguments. Called with no stdin it loops zero times and exits 0. The correct invocation is git rev-list --no-merges origin/main..HEAD | bash scripts/check-commit-log.sh, and it was re-run that way with the two commit ids on stdin. Every other gate was re-checked for the same failure mode by planting a violation in proc-state.c and confirming the gate names the file: check-atomics (proc-state.c:927: atomic_load), check-eintr-contract (rebase_after_link can report EINTR), check-format and clang-format, check-newline. Also green: commentflow (429 files), cppcheck (60 files), make lint, make check with BAREMETAL_CROSS=aarch64-elf-, and an ASan plus UBSan build over the resolver lanes with no diagnostics.

One known limitation is recorded rather than fixed. getdents64() on a usbfs bus directory reports DT_REG for the device nodes while stat() of the same entry reports S_IFCHR, because the nodes are placeholder files that only the open and stat paths divert. A scanner filtering on d_type skips them. Fixing it needs directory enumeration intercepted for those directories, which is larger than the rest of this PR.

PR-C and later are held. They now rebase onto 96b2dd2, but check-atomics fails on them with nine shared-memory accesses in src/syscall/usbdev.c that state no memory order. That is not a mechanical _explicit rewrite: each site needs the order it actually requires decided, so those branches are held pending a deliberate memory-ordering pass rather than pushed forward with a default sequential consistency that says nothing about the invariant.

@jserv
jserv requested a review from maxliu0 August 26, 2026 20:32
Comment thread docs/internals.md
Comment on lines +1008 to +1013
One boundary is worth stating plainly: there are no `usbN` root-hub
entries. macOS publishes no root hub as a USB device -- the registry match
returns downstream devices only -- so the tree carries no bus entry and no
parent link from a device up to it. Enumeration is unaffected (libusb
leaves the parent NULL, nusb skips port-less names), but topology is: a
`lsusb -t` listing shows the devices without the bus rows above them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a crucial limitation. What other factors do you think might prevent this approach from being generalized to map the Linux USB subsystem to its IOKit-based macOS counterpart?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five, roughly in order of how hard they are to get around. The first three are macOS policy, so no amount of translation work moves them. The last two are engineering, but they cost something permanent.

Driver arbitration decides which devices are reachable at all. Once a class driver matches an interface, USBInterfaceOpen returns kIOReturnExclusiveAccess and there is no unprivileged way past it. I probed the ESP32-S3 here: interface 0 (CDC control) is held by AppleUSBACMControl and always refuses, interface 1 (CDC data) refuses whenever anything holds /dev/cu.usbmodem*, and interface 2, the vendor JTAG class with no matching driver, opens fine. The escape is USBDeviceReEnumerate(kUSBReEnumerateCaptureDeviceMask), which needs root or com.apple.vm.device-access; as an unentitled non-root process it returns success and does nothing, the registry ID and the driver binding both unchanged. Mass storage is excluded from capture outright. So the layer serves vendor and bulk devices completely, which happens to cover the probe-rs transports, while CDC and HID are reachable only through the class interfaces macOS already exposes, /dev/cu.* and IOHIDManager. That is why the ttyACM mapping is a companion to this work rather than a workaround for it.

Cancellation granularity is wrong in a way that costs throughput. USBDEVFS_DISCARDURB kills one URB. IOKit offers AbortPipe, which kills everything outstanding on the pipe. Keeping the Linux semantics means queueing inside elfuse and handing IOKit one URB per endpoint at a time, so the pipelining that a bulk-heavy tool relies on is gone. Reporting collateral kills as -ECONNRESET and letting the guest resubmit is the alternative, and libusb and nusb do resubmit, but then a cancel on one transfer perturbs unrelated ones. Neither choice is free, and no future macOS API is likely to change that.

Reset is an unplug. ResetDevice has done nothing since 10.11, so the real reset is USBDeviceReEnumerate, which tears down the user client and re-runs matching. Linux USBDEVFS_RESET keeps the fd valid and the device numbering stable. Emulating it means holding busnum and devnum steady across a registry identity that changed underneath, waiting for the re-attach, and comparing descriptors afterwards. DFU is exactly the workflow that leans on reset, and it is also the one where the descriptors legitimately change, so the emulation has to distinguish "came back different" from "never came back".

Identity is topological, not device-based. locationID encodes the port path, so unplugging A and plugging B into the same port produces the same ID. This piece resolves that by cross-checking idVendor, idProduct and the serial string against the cached model and answering -ENODEV on a mismatch, which is safe but not complete: the model is a one-shot snapshot with no hotplug observer, so the new device is unusable until the process restarts. Real hotplug needs IOKit notifications rewritten as uevents on a netlink socket, which is a fair amount of machinery for something libusb tolerates the absence of.

Bus topology has no macOS source. This is the one you flagged. Root hubs are not published as USB devices, so there is no usbN, no parent link, and lsusb -t loses its bus rows. Synthesizing a plausible root hub is possible but it would have to invent a device that no registry entry backs, and everything downstream, maxchild, the port numbering, /sys/bus/usb/devices/usb1, would be invention too. I would rather the tree stay short than carry a fabricated node.

Two smaller ones for completeness. Isochronous transfers need explicit frame scheduling through GetBusFrameNumber, where usbfs lets URB_ISO_ASAP hand the decision to the controller, so audio and video capture do not map cleanly. And the permission models do not line up at all: Linux gates usbfs with udev rules and file permissions, macOS gates IOKit with driver matching and entitlements, and there is no spelling of "let this program have this device" that means the same thing on both sides.

Where that leaves the design: the layer is complete for devices macOS does not claim, honest but limited for devices it does, and structurally unable to present bus topology. I would rather write those three sentences in the documentation than let a reader discover them from a tool that half works.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where that leaves the design: the layer is complete for devices macOS does not claim, honest but limited for devices it does, and structurally unable to present bus topology. I would rather write those three sentences in the documentation than let a reader discover them from a tool that half works.

Good. Document the above properly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eight factors, in the order I would rank them, with what I measured on this host (macOS 15.6.1, ESP32-S3 behind a Fresco hub).

1. There is no root hub because the host controller is not a USB device. ioreg -p IOUSB shows two AppleT8103USBXHCI roots with the devices hanging directly off them:

+-o AppleT8103USBXHCI@01000000   "locationID" = 16777216
| +-o USB JTAG/serial debug unit@01100000  <IOUSBHostDevice>  "locationID" = 17825792
+-o AppleT8103USBXHCI@00000000   "locationID" = 0
+-o USB2.0 Hub@00100000          <IOUSBHostDevice>  "locationID" = 1048576

Nothing above a device conforms to IOUSBDevice, so there is no service to render as usb1, and no parent handle for the .. link Linux gives every device. Fabricating one means inventing a device descriptor for hardware that does not exist as a device.

2. Bus and device numbers are not readable, only derivable. Both attached devices report USB Address = 1, because the address is per-controller and the controller identity appears only inside locationID. busnum and devnum are therefore synthesized from locationID and will not agree with the numbers a Linux host would assign to the same hardware.

3. The registry stops at the device descriptor. A full property dump of an IOUSBHostDevice carries idVendor, idProduct, bcdDevice, bDeviceClass, bMaxPacketSize0, bNumConfigurations and so on, and nothing from the configuration descriptor: no bConfigurationValue, no bNumInterfaces, no bmAttributes, no bMaxPower, no iConfiguration. Everything below the device descriptor has to come from a different API. The string half is worse: exactly three cached strings exist (kUSBVendorString, kUSBProductString, kUSBSerialNumberString), matching the three indices the family caches, and a string named by any other index needs an ep0 transfer on an open device.

4. Opening the device is not always permitted. The hub here carries UsbExclusiveOwner = "AppleUSB20Hub". A kernel class driver already holds it. Linux answers that situation with USBDEVFS_DISCONNECT and driver rebinding through sysfs; IOKit has no counterpart short of a codeless kext or a DriverKit replacement. So every attribute needing a live control transfer is not merely unimplemented, it is unreachable for any device a macOS class driver has claimed, which is most of them.

5. States Linux models are not separable here. The unconfigured device is the example already in this review: kUSBCurrentConfiguration is a plain number, so "not configured" and "key not published" cannot be told apart from outside the family.

6. sysfs is live and this is a snapshot. Linux drives uevent notification and poll on kernfs nodes. This tree is built once into a scratch directory, so hotplug, uevent wakeups and the netlink uevent socket all need a different mechanism (IOKit notification ports), and until that exists a replugged device is not seen within a run.

7. Identity is synthetic. Real sysfs nodes are kernfs inodes on one superblock. These are ordinary files under /tmp with inode numbers this layer makes up. statfs answers SYSFS_MAGIC deliberately, but a consumer keying on st_dev consistency or inode stability across the tree is being told a story rather than shown one.

8. The writable half of sysfs is absent by construction. bConfigurationValue, authorized and the reset and quirk attributes are writable on Linux and drive real device operations. Nothing here is writable, and for most of them the equivalent needs the exclusive open that point 4 rules out.

Which of these are boundaries and which are unfinished work matters more than the count. 1 and 4 are boundaries: one asks for a device that does not exist, the other is an OS policy decision. 6 is work rather than a boundary, and it is the one I would do first, because sd-device and udev-style readers arm uevent before they read anything. 2, 3, 5 and 7 are fidelity gaps a determined implementation could narrow, at the cost of an ep0 path that runs into 4 for anything a class driver owns.

So the honest scope is closer to "enumerate and describe devices well enough for libusb and nusb" than "map the subsystem". The parts consumers actually read are reachable; the parts that make sysfs a control surface mostly are not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added as its own section, "Limits Of The IOKit Mapping", right after the synthetic-tree description in docs/internals.md. It states the whole boundary rather than the one line the thread started on: driver arbitration deciding which devices are reachable at all, the cancellation-granularity and reset-semantics costs, identity being topological rather than device-based, the missing bus topology, and the two narrower ones (isochronous frame scheduling and the mismatched permission models). Each claim is the measured behavior, not a guess, and it lands in the "Synthesize" commit alongside the tree it describes rather than in the proc-state fix.

@jotpalch

Copy link
Copy Markdown
Contributor Author

Round summary. Head is now 83e741c; every thread on the previous head has a reply with the measurement behind it.

One of the sixteen was a live containment escape, and our own test was the reason it survived. usb_sys_resolve_suffix split the final component off an O_NOFOLLOW request, contained the prefix, then reattached the leaf, so a .. leaf reattached to the canonical root named the host directory the tree is built in. Reproduced against 96b2dd2 with a file planted in that directory and the same name asked through six entry points:

subsystem/../../..  open=-1 ENOENT  open|O_NOFOLLOW=ok(fd)  stat=-1 ENOENT  lstat=ok  readlink=-1 EINVAL
    the returned fd listed the planted poc-escape-oracle-A7UFiy

Four spellings escaped in all, two at a device directory and the same two at an interface directory. What hid it is that the entry points disagreed: stat, lstat and readlink refused the exact name O_NOFOLLOW open served, and even logged resolves outside the tree; refusing while the open went through.

The containment test that was supposed to catch this could not fail. Its probe path dropped the tmp component the .. chain had just re-entered, so it never named the plant, answered ENOENT, and the assertion read that as containment. Rebuilt so it asserts the chain with nothing appended, pins each depth by inode identity against /sys/bus and /sys rather than by errno, and takes the plant showing up in the opened directory's listing as the oracle, it reports against the previous head:

test-usb-sysfs: 100 passed, 33 failed - FAIL
escaped: /sys/bus/usb/devices/2-1/subsystem/../../.. lists the planted elfuse-usb-escape-probe-shftDx

Containment is now one usb_sys_contained() applied last on every path, including after the reattach, and ./.. leaves are no longer held back at all, since neither can be a symlink. The probe reports 0 escapes and all six entry points agreeing on all 16 names.

Thirteen fixed, two refused, one refuted. The fixed set: the escape, the vacuous test, the parallel-lane race, the fd stamp on a followed subsystem link, statfs on folded and relative sysfs names, the sysroot-crossing rebase fallback, ELOOP before EISDIR under O_NOFOLLOW, the dev_t shift into the sign bit, O_DIRECTORY|O_CREAT, poll capability across all of /sys, interface numbers at or above 32, and the capped test reader.

IOUSBDevice matching (3862278585) is refuted, with the measurement in the thread: this host publishes only IOUSBHostDevice, and IOServiceMatching("IOUSBDevice") returns the identical set, because matching is by conformance. The unconfigured-device conflation (3862278651) is real and unfixed; I cannot produce the state on read-only board access and did not want to ship an unconfigured-device contract across four attributes and the interface directories that has never been run. Both threads say so in full.

Two things I found while verifying, that nobody asked for. My first O_DIRECTORY|O_CREAT fix over-corrected: under O_PATH the kernel masks the creation bit off, so O_PATH|O_DIRECTORY|O_CREAT opens the directory, and refusing the pair unconditionally turned three working opens into EINVAL. Caught by a matrix against 6.19 before pushing, fixed, and asserted. Separately, a dirfd-relative name whose tail crosses the subsystem link is still folded lexically before the sysfs resolver sees it, so openat(devices_fd, "1-1/subsystem/..") and open(".../1-1/subsystem/..") name different directories and report different inodes where Linux has them agree. That fold lives in proc_apply_components() and predates this PR; lexical folding clamps at the guest root so it cannot leave the tree, and the escape probe confirms the openat form never reached the plant. It is a fidelity gap rather than a containment one and I did not want to change the fold order for every dirfd-relative walk inside this PR.

Verification at this head: make check BAREMETAL_CROSS=aarch64-elf- exit 0 with 0 failures, test-usb-sysfs 138/0, make check-ubsan and make check-asan both exit 0 with no sanitizer reports, and commit-log, commentflow, clang-format, cppcheck, newline, security and matrix-list gates clean. Each gate was also proved non-vacuous by injecting a violation into src/runtime/usb-sysfs.c and watching it fail.

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enumeration works as advertised, and the descriptor walks in usb-desc.c hold up: every cursor step is expressed in the remaining-bytes domain, the interface table covers the whole __u8 range, and the configuration truncation math lands exactly on the buffer. Containment in usb_sys_resolve_suffix also holds for the cases I could construct: /sys/bus/usb/devices/2-1/subsystem/../../../../etc/passwd resolves out of the tree and is refused.

Two of the problems below are reachable today, measured against a build of this branch and a build of main with the same sysroot.

One note on the test lane: check_devices() returning 0 leaves the whole device half unexecuted while the run still exits 0, so a CI machine with an empty bus is green against mutations in descriptor copying, node identity and the subsystem-link behavior. The printed count makes it visible to a reader, but nothing fails. A fixture-backed device would keep those assertions honest.

Comment thread src/runtime/usb-sysfs.c Outdated
Comment thread src/syscall/fs.c
* so sys_fstatfs can answer synthetically instead of leaking the /tmp
* filesystem.
*/
if (path_prefix_match(path, "/sys", 4) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stamping the guest spelling on every /sys descriptor without teaching sys_fchdir and resolve_proc_cwd_path about it opens a hole. proc_virtual_dir_path names only /proc directories, and the /dev/pts special case at fs.c:2181 does not cover /sys, so fchdir() on a /sys descriptor lands the real host cwd in the scratch tree and publishes it. Measured on this tip:

getcwd after fchdir = /private/tmp/elfuse-usbsys-YWiRrb/bus/usb/devices
create relative 'intruder' -> SUCCEEDED

Two things follow. The scratch path leaks into getcwd, and the read-only guarantee is gone: relative names resolved against that cwd bypass the intercepts entirely, so O_CREAT writes into the tree. fstatfs on such a descriptor also reports the /tmp filesystem instead of SYSFS_MAGIC, since nothing stamps it. Give /sys and /dev/bus the treatment /dev/pts already has in both sys_fchdir and resolve_proc_cwd_path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and reproduced. Pre-fix, getcwd after fchdir onto the /sys descriptor returned the /private/tmp/elfuse-usbsys-*/bus/usb/devices scratch path, a relative O_CREAT 'intruder' succeeded, and fstatfs reported the /tmp filesystem, not SYSFS_MAGIC. test-usb-sysfs-sysroot shows those three assertions failing before the fix and passing after.

Owning this plainly: it was our own regression too. Stamping the guest spelling on every /sys descriptor was our fix for cubic 3862484145 (an O_PATH openat off a subsystem fd), and it left fchdir uncovered. The fix gives /sys and /dev/bus the /dev/pts treatment in both places you named: sys_fchdir (fs.c:2195) publishes the stamped guest path as the virtual cwd, and resolve_proc_cwd_path (path.c:1259) re-offers relative names to the intercepts. Now getcwd reads /sys/bus/usb/devices, the relative create is refused EACCES, fstatfs(fd) reports SYSFS_MAGIC, and I checked that statfs(path) agrees with fstatfs(fd) on the owned directory. The cubic behavior survives: openat("..") off the O_PATH subsystem fd still pops the target's parent (existing suite 138/0).

What prevents recurrence: the fchdir/getcwd/relative-create/fstatfs quartet is now asserted in test-usb-sysfs-sysroot, so a future descriptor-stamp that forgets the cwd side fails CI.

Comment thread src/runtime/usb-sysfs.c
again = 1;
}
}
usb_devs[best].devnum = devnum;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback numbering is unbounded while usb_minor() assumes a devnum below 128. Registry-supplied addresses are already clamped (addr > 0 && addr < 128), but this path is not, so 128 or more devices on one bus without a USB Address property assign devnum 128 and up, and (busnum - 1) * 128 + (devnum - 1) walks into the next bus's minor range: bus 1 devnum 129 and bus 2 devnum 1 both produce minor 128. Unlikely to be hit, one line to prevent: stop at 127 and log the entries dropped, matching the usbfs cap the header already cites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. An overflow fixture (129 address-less devices on bus 1 plus one on bus 2, all routed through the fallback devnum path) reproduces it: bus 1 devnum 129 and bus 2 devnum 1 both map to minor 128. The fallback now caps at 127 (usb-sysfs.c:564), logs each dropped entry with its locationID, and a compaction pass removes the tombstones so no later pass, emit, or lookup sees a devnum below 1. New lane test-usb-sysfs-overflow: 3 failing assertions before the cap, 5 passed 0 failed after (bus 1 keeps exactly 127, bus 2 keeps its single device, every minor unique). Matches the usbfs cap the header already cites.

Comment thread src/runtime/usb-sysfs.c Outdated
@jotpalch

Copy link
Copy Markdown
Contributor Author

Pushed a revision addressing all four review findings, with a note on where they came from.

The two reachable ones (the /sys shadow and the fchdir escape) were self-inflicted: both trace to earlier cubic rounds where I over-widened the layer. The poll gate grew from /sys/devices/system/cpu to all of /sys so a sysfs attribute could join epoll, and every /sys descriptor got stamped with the guest spelling to fix an O_PATH openat. Each was correct for its own case but claimed more of /sys than the layer actually models. The meta-fix is to intercept only what we synthesize: usb_sys_suffix_owned() owns just /sys/bus/usb, and anything else under /sys that resolves to nothing now returns PROC_NOT_INTERCEPTED so the sysroot backing answers; fchdir onto a synthetic /sys or /dev/bus directory now gets the same virtual-cwd treatment /dev/pts already had.

The device half used to go unexecuted on a machine with no USB device attached, which left descriptor copying, node identity and the subsystem links untested while the run still exited 0. ELFUSE_USB_FIXTURE now injects a canned device model through the real emit path, so descriptor byte-identity, dev/rdev/minor, bNumInterfaces against the interface-directory count, and the O_PATH subsystem-link behavior all run without hardware. ELFUSE_USB_FIXTURE=overflow drives the devnum-cap regression the same way.

New lanes: test-usb-sysfs-sysroot (the /sys fall-through, the access/open agreement, the fchdir containment, and the epoll cubic, all under a populated sysroot) and test-usb-sysfs-overflow (the per-bus 127 cap). Full make check is clean; the USB lanes are 138/13/5.

One residual I did not close: readdir(/sys) and readdir(/sys/bus) still enumerate only the synthetic subtree, since those directories resolve in-tree and are served whole. Named lookups fall through correctly; a getdents union is a larger change I kept out of this fix.

@jotpalch
jotpalch force-pushed the pr-b-usb-sysfs branch 2 times, most recently from a589ff2 to 8d882f4 Compare August 30, 2026 20:01

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase latest main branch and resolve conflicts.

New src/runtime/usb-sysfs.c builds two scratch-dir trees from an IOKit
enumeration of IOUSBDevice registry entries, opening no device (raw
config descriptors come from GetConfigurationDescriptorPtr, which needs
no open; the 18-byte device descriptor is synthesized from registry
properties):

  /sys/bus/usb/devices/<bus>-<ports>[:<c>.<i>]  attrs, subsystem links
  /dev/bus/usb/BBB/DDD                 char 189:(bus-1)*128+dev-1

busnum is locationID>>24 plus one (macOS controllers start at 0, Linux
busnums at 1), devnum is the 'USB Address' property with a stable
per-bus fallback, and the port path comes from the locationID nibbles,
all following the Linux usb sysfs layout that libusb's linux_usbfs.c
enumerates. The descriptors blob is shared between the sysfs attribute
and the /dev node so the two reads are byte-identical, and
usb_sysfs_descriptors_dup exports it for the upcoming usbdevfs fd
constructor. Node opens serve the blob read-only for now; O_RDWR is
EACCES until the usbdevfs fd lands.

Device directories carry the current configuration's attributes --
bNumInterfaces, bmAttributes, bMaxPower and configuration -- plus
maxchild and rx_lanes/tx_lanes, which is the set lsusb -t warns about
per device when it is missing. All of them, and the emitted interface
directories, read the active configuration through one helper so an
attribute file and a :c.i directory cannot describe two different
configurations.

Those four are written on every device, whatever the descriptor blob
turned out to hold. Linux keeps them in dev_attr_grp (sysfs.c:782-786),
an attribute group with no .is_visible hook (sysfs.c:815-817), so they
exist on every USB device directory; usb_actconfig_show returns the 0
its lock gave it when actconfig is NULL (sysfs.c:29-43), which is a
zero-length read. Emitting an attribute only when its value was known
turned that into ENOENT, and the two are different answers to a reader
probing for an optional attribute -- absent means unsupported, empty
means supported with nothing to say. usb_desc_actconfig_attrs renders
all four into one struct so the presence rule is structural rather than
repeated per line.

configuration is emitted empty, and the reason is not that the
configuration has no string. iConfiguration names a string descriptor,
which is fetched over an ep0 control transfer on an open device; this
stage opens nothing. IOKit offers no way around it, and that was checked
against the live registry rather than assumed: an IOUSBHostDevice entry
publishes iManufacturer, iProduct and iSerialNumber together with the
three strings the family caches for them ("USB Vendor Name", "USB
Product Name", kUSBSerialNumberString), but no iConfiguration index and
no configuration string, and IOUSBDeviceInterface has no cached-string
call to stand in for the transfer the way GetConfigurationDescriptorPtr
stands in for fetching a configuration descriptor.

An empty file is nevertheless the honest answer rather than a missing
one, because it is the same answer Linux gives from the same position.
configuration_show emits nothing whenever actconfig->string is NULL
(sysfs.c:83-84), and usb_cache_string is documented to return NULL "if
the index is 0 or the string could not be read" (message.c:1074-1075).
An empty configuration on Linux therefore means "no cached string",
which is strictly weaker than "iConfiguration is 0" -- a device that
NAKs its own string descriptor reads empty on real sysfs too. Every
device here takes that second route. Omitting the file instead would
have invented a deviation Linux does not have, since dev_attr_grp keeps
the attribute unconditionally. The gap that remains is narrow and
one-directional and is recorded in internals.md: a configuration whose
iConfiguration is non-zero and whose string is readable shows that
string on Linux and shows nothing here, until an ep0 path exists to
fetch it.

The rendering is bounded for the caller that will close that gap. The
string goes in through a precision-bounded conversion rather than a bare
"%s", so what a too-long string loses is its own tail and never the
trailing newline: usb_string() stops at MAX_USB_STRING_SIZE counting the
terminator, so every string Linux could have cached still round-trips
whole, and sysfs_emit ends every attribute it writes with the newline
whatever the string did.

That helper treats the blob as the untrusted device input it is. A
configuration is matched only after bLength and bDescriptorType have
been checked, and the walk stops at the first record that is not a
configuration header: stepping by wTotalLength alone lets a device that
under-reports it land the cursor on its own interface descriptor, whose
byte 5 is bInterfaceProtocol rather than bConfigurationValue, and be
handed back as the active configuration. The same two fields are checked
before the registry-less bConfigurationValue fallback reads byte 5 out
of the first descriptor.

Plumbing to make real consumers enumerate:

- path.c gates claim /sys (minus the syscpu stub) and /dev/bus for the
  open/stat intercepts; procemu dispatchers forward to usb-sysfs
- statfs/fstatfs answer SYSFS_MAGIC for /sys paths and for descriptors
  fds, which libudev checks before trusting the tree
- openat/fstatat/readlinkat on the synthetic paths are resolved via
  path_rebase_hostdirfd and re-offered to the intercepts, which is what
  keeps systemd chase()'s per-component walk on the synthetic tree

'.' and '..' inside a /sys path are folded rather than refused, so
/sys/bus/usb/devices/../devices resolves the way it does on Linux
instead of reporting EACCES; a suffix that climbs above /sys is handed
back to the normal path flow. That lexical fold decides only whether a
name is ours: the served path is resolved component by component, so a
'..' after a symlink pops the target's parent the way the kernel does.
<dev>/subsystem/.. is therefore /sys/bus, which makes
subsystem/../usb/devices resolve and subsystem/../idVendor ENOENT -- the
reverse of what folding the name first produced. st_ino is derived from
the resolved spelling, so two ways of naming one directory agree on its
identity. A node addressed as a directory -- a trailing slash included
-- reports ENOTDIR once the node exists and ENOENT when it does not.

stat() of a subsystem link reports the directory it resolves to and
lstat() reports the link, so the intercept takes the follow flag its
callers already have; a stat that could not say which of the two it was
performing had to answer S_IFLNK for both. An O_PATH descriptor on a
non-directory answers ENOTDIR for a relative name resolved against it,
as openat(2) does. The empty path is excluded from that rule, so fstat()
of such a descriptor still reports the object it names.

Write access to a synthetic directory is refused the way open(2) refuses
it, with EISDIR rather than a readable descriptor or an EACCES:
/dev/bus/usb, the per-bus directories and /sys/bus/usb/devices all
answer alike, and a create under the read-only /sys tree still answers
EACCES. The directory opener is shared with /proc, which had the same
gap.

The device table grows on demand instead of stopping at a fixed 64:
usbfs caps at 127 devices per bus and a chain of hubs can exceed any
fixed guess, and a truncated model loses devices from both views at
once.

Makefile links IOKit/CoreFoundation and adds the new sources; usb_lock
is documented as a leaf.

The scratch-file write loops (usb_write_file, usb_blob_fd) retry EINTR
like their syscpu_write_file template: a stray signal must not fail the
one-shot tree build or a node open.

Two boundaries are documented rather than papered over. macOS publishes
no root hub as a USB device, so the tree carries no usbN entry under
/sys/bus/usb/devices and no parent link from a device up to its bus:
enumeration is unaffected, but a lsusb -t listing shows devices without
the bus rows above them. And the tree is a boot-time snapshot, so a
replug is not seen within a run until the uevent layer can invalidate
it.

tests/test-usb-sysfs.c asserts the contract in two halves. The first
needs no device -- SYSFS_MAGIC from statfs and fstatfs alike, the
read-only open contract, the '..' fold, and the ENOENT/EINVAL/ENOTDIR
answers -- so a machine with an empty bus still covers the path layer.
The second walks the attached devices and asserts the identities that
need one, above all that the descriptors attribute and a read() of the
matching node return the same bytes, and that a `subsystem` link is
reachable every way a path walker reaches one: followed by open, by
openat with a dirfd and by O_PATH, ELOOP only when the caller asked for
O_NOFOLLOW, and readable as a target throughout. It prints the device
count, so a run that covered only the first half says so rather than
reading as full cover.

tests/test-usb-desc-host.c covers the descriptor walk on the host, with
no hardware: a bLength longer than the bytes that remain, a zero-length
descriptor, a record truncated mid-header, and a wTotalLength past the
end of the blob. It asserts the cursor bound directly on every step
rather than inferring it from the result, because a walk that steps out
of the buffer and only then re-tests its guard still reports the right
interfaces.

Configuration blobs are device-supplied, so bLength and wTotalLength are
numbers the peripheral chose. The walks over them live in a leaf unit
(runtime/usb-desc.c) that steps in the remaining-bytes domain: a
descriptor is accepted only when its bLength fits what the buffer still
holds, so the cursor never leaves the object even for a blob whose
lengths are nonsense. Advancing by a device-chosen length first and
re-testing the bound afterwards yields the same interface list while
having formed an out-of-bounds pointer on the way, which is why the
order matters. A malformed record stops the walk and keeps the
interfaces read before it -- a device with a trailing junk descriptor
must not lose its whole configuration -- and the same rule clamps the
wTotalLength step that locates the active configuration.

The tree's own symlinks are followed on open, the way Linux follows
them. `subsystem` is what libudev and pyserial's list_ports_linux walk
to prove a node is USB-backed, so forcing O_NOFOLLOW on every open under
/sys would make that walk ELOOP for exactly the consumers this layer
exists for. The syscpu rule it would inherit -- do not follow symlinks
the guest created inside the scratch dir -- has nothing to bite on here:
these links are emitted by this file with fixed targets, and the guest
cannot create anything in a tree whose every mutating open is refused. A
blanket refusal is therefore replaced by a positive check. realpath()
performs the whole walk -- symlinks in the leaf and in every
intermediate component, and '..' in kernel order -- and the fully
canonical result is tested against the canonical scratch root before the
open. That containment test is the sole escape authority, which is what
lets the unfolded suffix be handed to it: nothing above it is trusted to
have kept the path inside. A no-follow open resolves everything up to
the final component the same way and then appends the leaf unfollowed,
so a symlink leaf stays a symlink without the walk losing its
containment. A path that resolves outside is reported absent instead of
opened, so an open still cannot escape the tree. O_NOFOLLOW asked for by
the guest is honored and answers ELOOP, as on Linux.

stat and readlink resolve through the same helper, so all three entry
points agree on what the tree contains and none of them can be walked
out of it. readlink opens nothing, which is what makes it easy to leave
behind, but a target string is still the guest learning about a host
object it was never handed; the suite plants a symlink where such an
escape would land so the assertion cannot pass by accident.

Document the tree in internals.md and the README feature list, which
names libusb-style enumeration rather than lsusb: the udev-backed lsusb
needs name_to_handle_at as well, and that is not implemented yet.

The sysfs attributes copied out of the active configuration --
bmAttributes, bMaxPower, configuration -- are asserted against the
device's own descriptors blob rather than for being present and
parseable, since a shape-only check passes for a wrong value as readily
as for a right one.

configuration is asserted in the one direction Linux guarantees. An
earlier draft asserted the biconditional -- empty exactly when
iConfiguration is 0 -- and that is not a property real sysfs has, for
the usb_cache_string reason above; it would fail against Linux itself on
a device whose string descriptor cannot be read. What holds both ways is
the index-0 half, because index 0 short-circuits usb_cache_string before
any transfer, so a non-empty configuration there is a value invented by
this layer. That half stays, together with a presence check over all
four attributes. The other branch has no device on this platform to
produce it and no code path either, so asserting it against hardware
would only ever re-assert the empty case; it is covered host-side
instead.

tests/test-usb-desc-host.c therefore also drives
usb_desc_actconfig_attrs over synthesized descriptors, which runs every
branch on any host with no bus involved: no active configuration at all,
iConfiguration 0 with and without a string offered, a non-zero index
whose string could not be read, a non-zero index whose string was read,
a string longer than the kernel's own MAX_USB_STRING_SIZE cache bound,
and the two-configuration case where picking the wrong one would show
the wrong string.

The test file's FAIL macro prints the global errno, which says nothing
about an assertion that compared two values it already held. A
configuration mismatch was reported as "(errno=13)" -- the EACCES left
behind by the writable-open check several assertions earlier -- which
reads as an attribute that could not be opened rather than one holding
the wrong bytes, and sent the first reading of the failure down the
wrong path entirely. Value comparisons now clear errno first and report
the value they saw. Measured while there: the read path already answers
ENOENT for an attribute the tree does not carry, which is what sysfs
answers, so the EACCES was never on it; that is now asserted rather than
left to be re-derived, since the tree's EACCES is the read-only refusal
of a mutating open and a reader probing for an optional attribute must
not see the two confused.

Name resolution under /sys is one walk with one containment test at the
end of it, and the test is applied to the spelling the caller is handed
rather than to a prefix it was derived from. O_NOFOLLOW and lstat need
the final component left unresolved, so it is split off, the rest is
resolved through realpath and contained, and the leaf is reattached --
which is the one step that moves the name after the test has run, so the
test runs again on the result. A '.' or '..' leaf is not split off at
all: neither can be a symlink, so withholding one protects nothing, and
a '..' reattached to the canonical root names the host directory the
scratch root sits in. open("<dev>/subsystem/../../..", O_RDONLY |
O_NOFOLLOW) returned a descriptor on it, and readdir listed the host's
temp directory; lstat and openat agreed. Resolved as a whole the same
name is ENOENT, because the object it reaches is "/", which this tree
does not carry -- one '..' after the link is /sys/bus and two is /sys,
both still served.

A descriptor's stamped identity now comes from the descriptor, not from
the request. Opening <dev>/subsystem without O_NOFOLLOW yields the
directory the link resolves to, and Linux's fd reports that directory:
/proc/self/fd/N names it and openat(fd, "..") pops its parent, which is
how systemd's chase() and libudev step from a device up into /sys/bus.
Stamping the guest's spelling restarted every such walk from the link's
own directory. F_GETPATH answers for both cases with no special-casing:
it reports the target for an open that followed the link and the link
itself for the O_PATH|O_NOFOLLOW open that asked for the link.

Three errno answers now match what Linux was measured to give (6.19,
docker gcc:14). O_NOFOLLOW on a symlink is ELOOP before the access mode
is consulted, so a writable O_NOFOLLOW open of `subsystem` is ELOOP and
not the EISDIR its target would earn -- confirmed against a real
/sys/class/net/lo/subsystem. O_DIRECTORY|O_CREAT is EINVAL, decided in
build_open_flags() before the path is resolved at all, for an existing
directory, an absent name and a synthetic one alike; macOS open(2)
agrees, so host-backed names already answered it and only the intercepts
did not, which is why the rule now sits at the top of sys_openat_path
instead of inside each of them. O_PATH is the exception the same code
makes: it masks the flags down to O_DIRECTORY|O_NOFOLLOW|O_PATH
first, so O_PATH|O_DIRECTORY|O_CREAT opens the directory and behaves
exactly like O_PATH|O_DIRECTORY. The bit is dropped there rather than
the pair merely skipped, because left set it still reads as write
intent and the read-only sysfs gate answered EISDIR for a descriptor
the kernel hands out. And statfs decides which filesystem
answers from what the name resolves to: "/sys/../sys" is sysfs, and so
is "sys" from a cwd of "/". Both were ENOENT here, the first because the
folded name was classified and the raw one then probed for existence,
the second because a leading '/' was required before the fold ran.

The ENOENT rebase fallback -- which exists so systemd's chase() can step
from a host-backed directory into a synthetic subtree -- now requires
the descriptor it rebases from to be inside the sysroot. path_host_to_
guest passes a host path that is not under the sysroot through
unchanged, so a dirfd on the host's /dev, reachable through any sysroot
symlink pointing out of the tree, rebased to the guest-absolute "/dev"
and handed the walk to the /dev/bus/usb and /dev/shm intercepts: a name
resolved outside the guest namespace came back answered from inside it.
Without a sysroot the guest root is the host root and every host path
does have a guest spelling, so the gate is on the sysroot, not on the
fallback.

Pollability is a property of kernfs, not of one subtree. sysfs_file_
operations installs .poll on every attribute, and a real /sys answers
epoll_ctl(ADD) with 0 for net/lo/mtu and bus/usb/devices/1-1/idVendor
alike; naming the CPU subtree alone made every attribute this layer adds
report EPERM, which is the answer for a file that can never be polled --
and udev-style readers arm `uevent` before they read it.

Two fixed-size tables are gone. bInterfaceNumber is a __u8, so a
32-entry seen[] silently dropped every interface numbered at or above
32 while the walk itself stayed bounded and terminated; the selection
moved into usb_desc_interfaces(), sized to the whole byte range and unit
tested there, which also gives the emit path one place to answer "which
interfaces get a directory". The test file's reader had the same shape:
a fixed 64 KiB cap made "the file was this long" and "I stopped here"
the same number, so the two-view compare read a truncation as a length
and passed on a prefix. It grows instead, since a descriptors blob is
bounded by the device -- wTotalLength is 16-bit, bNumConfigurations a
byte -- and there is no honest constant to pick.

The synthetic node's dev_t is composed in uint32_t. dev_t is a signed
32-bit int on this platform, so shifting major 189 left by 24 lands in
the sign bit; UBSAN says so in as many words. The SDK's own makedev()
has the same defect, which is why the encoding is spelled out rather
than borrowed. The sanitizer lane could not have caught it, because
nothing on it built this tree: test-usb-sysfs moves into
CHECK_SHARED_LANES, which check-sanitizer runs, rather than into
tests/manifest.txt, which .ci/check-matrix-lists.sh rightly rejects for
a binary the reference kernel never adjudicates.

The containment assertions are rebuilt around the shape that escaped.
The old ones appended a component after the '..' chain, so the prefix
was resolved and refused before the planted symlink was ever consulted:
readlink answered ENOENT byte-identically with the plant present and
with it removed, and the suite reported 68 of 68 while the escape above
was live. The chain is now asserted with nothing appended, pinned by
identity to the object each depth resolves to, and a descriptor that
does open is read: the plant appearing in its listing is what names the
host directory it reached. The plant's own name is read back first, so a
plant that never got created cannot be mistaken for a refusal, and the
name comes from mkstemp -- a fixed one failed three of eight concurrent
runs on "File exists" rather than on the contract, and a pid suffix does
not separate lanes here because every guest starts at pid 1.

Verified against the attached ESP32-S3 (303a:1001): tests/test-usb-sysfs
reports 133 passed, 0 failed over 2 devices, and lsusb-lite parses
both with the sysfs and node descriptor blobs MATCHing. The same file
reports 100 passed, 33 failed against the tree before these fixes.

Real Debian lsusb 018 over libusb-1.0.28 does not get as far as the tree
yet: libusb_init fails -99 with an unimplemented name_to_handle_at
(syscall 264) in the log, which libudev uses to decide whether /sys is a
genuine sysfs mount. Answering that is a separate change.
An absolute guest path whose intermediate component does not exist could
resolve to a path the guest never named. On a case-sensitive sysroot,
stat("/name-relative/absent-dir/below") succeeded and described the
sysroot root, where Linux owes ENOENT.

resolve_byte_exact_through_links() reports two things: a verdict, and
the guest spelling resolution ended at, which the caller reads on every
verdict (rebase_after_link) to decide whether a link moved the lookup
off the path it was handed. Only the fall-through return published that
spelling. The two returns inside the component loop -- taken when lstat
of a prefix fails, which is exactly the absent and the ENOTDIR case --
exited without writing it, leaving the caller's stack buffer
uninitialized and the rebase decision reading whatever was there.

The ENOTDIR arm never reached the decision, because the caller returns
on that errno first. The absent arm did: garbage that happened to spell
"/" rebased the lookup onto the root, which exists, so the host fallback
handed the caller the root's host path and the stat succeeded.

Publish the spelling at the top of each iteration, before the walk that
may exit early, so every verdict carries one. Callers seed the buffer
empty and rebase_after_link reads an empty spelling as "the walk
reported none", which makes the contract hold structurally rather than
by each return path remembering; one byte, not a 4 KiB zero-fill, since
these resolvers run on every absolute path a guest names.

The uninitialized read predates this branch -- it is unchanged since
d410047 -- but it was benign there and is not here: whether the stale
bytes spell an existing path is decided by what the frame below happened
to leave, and the USB sysfs work changed that. check-name-caseexact went
46/0 to 45/1 at 49a6139 with no change to this file.

That failure also read as the wrong errno rather than as no failure at
all, because EXPECT_ERRNO prints the global errno and a call that
returns 0 leaves the previous assertion's behind: the succeeding stat
reported "errno=20", the ENOTDIR of the assertion above it, and the
bisected symptom named ENOTDIR-for-ENOENT. The new assertions clear
errno first, so the same regression prints errno=0. The macro itself is
left alone -- several call sites pass a return value captured earlier
and depend on the errno standing at the point of the check.

tests/test-sysroot-name-relative.c pins both halves of the split, which
are decided by different findings and so cannot be derived from each
other: ENOTDIR from a component that exists and is not a directory,
ENOENT from one that does not exist. Absent is asserted named on its
own, one and two components deep, under a trailing slash (which flips
the answer to ENOTDIR only when the leaf exists), and through open as
well as stat. The dirfd spelling is asserted beside it, since an O_PATH
descriptor on a regular file is refused before the name is looked up
while one on a real directory still owes ENOENT for a name it does not
hold, and AT_EMPTY_PATH must not be caught by the first rule.

Every case was measured on Linux 6.19 (docker gcc:14) rather than
derived from path_resolution(7): stat and lstat of <missing>/child
ENOENT, of <regular file>/child ENOTDIR, both unchanged at two
components deep and under a trailing slash; open likewise; openat and
fstatat against an O_PATH regular file ENOTDIR, against an O_PATH
directory ENOENT for an absent name, and fstatat with AT_EMPTY_PATH on
the O_PATH regular file 0.

Against 49a6139 the file reports 53 passed, 4 failed; with the fix, 57
passed, 0 failed, and check-name-caseexact is green again.
@jserv
jserv merged commit fad2cc1 into sysprog21:main Aug 31, 2026
14 checks passed
@jserv

jserv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Thank @jotpalch for contributing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants