Synthesize /dev/bus/usb and the USB sysfs tree - #334
Conversation
32814d3 to
4447cb1
Compare
There was a problem hiding this comment.
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
| { | ||
| model_clear(); | ||
|
|
||
| CFMutableDictionaryRef match = IOServiceMatching("IOUSBDevice"); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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>
| 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; | |
| } |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
4447cb1 to
49a6139
Compare
There was a problem hiding this comment.
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
| * 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' && |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| /* 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); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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_inoagainststat("/sys/bus")andstat("/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.
|
Round summary. Head is now The 4-device runner caught an assertion that was wrong about Linux. On Runtime (Release) for The test was wrong, not the code. 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 Fuzzing the descriptor walk found a newline truncation. An earlier draft of the attribute render wrote the configuration string with a bare We introduced a case-exactness regression and fixed it.
One known limitation is recorded rather than fixed. PR-C and later are held. They now rebase onto |
| 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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
96b2dd2 to
83e741c
Compare
|
Round summary. Head is now One of the sixteen was a live containment escape, and our own test was the reason it survived. 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: The containment test that was supposed to catch this could not fail. Its probe path dropped the Containment is now one Thirteen fixed, two refused, one refuted. The fixed set: the escape, the vacuous test, the parallel-lane race, the fd stamp on a followed
Two things I found while verifying, that nobody asked for. My first Verification at this head: |
83e741c to
09198d7
Compare
jserv
left a comment
There was a problem hiding this comment.
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.
| * so sys_fstatfs can answer synthetically instead of leaking the /tmp | ||
| * filesystem. | ||
| */ | ||
| if (path_prefix_match(path, "/sys", 4) || |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| again = 1; | ||
| } | ||
| } | ||
| usb_devs[best].devnum = devnum; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
09198d7 to
9cd1460
Compare
|
Pushed a revision addressing all four review findings, with a note on where they came from. The two reachable ones (the 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. New lanes: One residual I did not close: |
a589ff2 to
8d882f4
Compare
jserv
left a comment
There was a problem hiding this comment.
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.
8d882f4 to
b6c0365
Compare
|
Thank @jotpalch for contributing! |
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 = 0butget_device_liststill 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/DDDfor 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
GetConfigurationDescriptorPtrsupplies the raw configuration descriptors without an open. An unmodified guest binary now enumerates the attached hardware.What
New
src/runtime/usb-sysfs.cmaterializes both trees as scratch directories of real host files, following theensure_syscpu_dirpattern already inprocemu.c:Design decisions that are worth a reviewer's attention:
Bus numbering.
busnumis the top byte of the IOKitlocationIDplus one. macOS controller indices start at 0 while Linux busnums start at 1, so a literallocationID >> 24would produce a bus 0 that no Linux tool expects. The port path below it comes from the remaininglocationIDnibbles, six at most, which is the same<bus>-<port>.<port>spellinglinux_usbfs.cparses.devnum. Taken from the
USB Addressproperty. Devices without that property get a stable per-bus fallback, numbered 1..n inlocationIDorder 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 noIOUSBDeviceentry to name. So the tree carries nousbNentry 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. Thenports == 0branch 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 islsusb -t, covered below.One descriptor blob, two readers. The
descriptorssysfs attribute and aread()of the matching/dev/bus/usbnode are served from the same buffer, so the two views cannot drift.usb_sysfs_descriptors_dupexports 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.iinterface directories all read the current configuration through a singleactive_config()helper. An earlier revision readbNumInterfacesfrom 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 pinsbNumInterfacesagainst the actual directory count.All four attributes exist on every device, whatever the blob held. Linux keeps
configuration,bNumInterfaces,bmAttributesandbMaxPowerindev_attr_grp(sysfs.c:782-786), an attribute group with no.is_visiblehook (sysfs.c:815-817), so every USB device directory carries all four files. Whenudev->actconfigis NULL,usb_actconfig_showreturns the0its 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 intoENOENT, 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 andNULLfor 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. (Contrastmanufacturer/product/serial, which are gated bydev_string_attrs_are_visibleand which this tree correctly omits when absent.)SYSFS_MAGIC.statfsandfstatfsreport0x62656572for/syspaths and fordescriptorsfds. 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/syspath are resolved rather than refused, so/sys/bus/usb/devices/../devicesresolves the way it does on Linux instead of reportingEACCES; a suffix that climbs above/sysis 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/devicesresolve and<dev>/subsystem/../idVendorENOENT-- the reverse of what folding the name first produces.st_inois 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, reportsENOTDIRonce the node exists andENOENTwhen it does not. Symlinks inside the tree are followed the way Linux follows them, soopen()of asubsystemlink 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 reportsENOENTinstead of being opened.open,stat,lstatandreadlinkall resolve through that one helper, so the entry points cannot disagree about what the tree holds --readlinkopens 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_NOFOLLOWsplit 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 whilestat,lstatandreadlinkof 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 oneusb_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_NOFOLLOWon a symlink reportsELOOPbefore the access mode is consulted, which is the order Linux uses (open(subsystem, O_WRONLY|O_NOFOLLOW)is ELOOP, plainO_WRONLYis EISDIR, measured on 6.19), andO_PATH | O_NOFOLLOWyields an fd on the link itself.Descriptor identity, and the errnos around it. A descriptor on a followed
subsystemlink names the directory it resolves to, not the spelling the guest opened, soopenat(fd, "..")pops the target's parent. That is what Linux does (open("/sys/class/net/lo/subsystem", O_PATH)names/sys/class/netand..off it names/sys/class, measured on 6.19) and the stamp now comes fromF_GETPATHon 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 anO_SYMLINKone.O_DIRECTORY|O_CREATisEINVAL, 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 andO_PATH|O_DIRECTORY|O_CREATopens the directory. Every sysfs attribute is poll-capable, not just the CPU subtree, becausekernfs_fop_pollis installed bysysfs_file_operationson all of them, soepoll_ctl(ADD)on<dev>/ueventanswers 0 rather thanEPERM; udev-style readers armueventbefore they read it.statfsdecides which filesystem answers from what the name resolves to, so/sys/../sysand a relativesysfrom a cwd of/both reportSYSFS_MAGIC.st_rdevis composed inuint32_tbefore it becomes adev_t:dev_tis 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 letschase()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/devdirfd to the/dev/bus/usbintercept.Interface numbers are a whole byte.
bInterfaceNumberis 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 leftbNumInterfacesdisagreeing with the directories on disk. The walk itself is bounded bycfg_len, so the table size was never what kept it in bounds.Read-only for now.
O_RDONLYon a node serves the descriptor blob;O_RDWRandO_WRONLYreportEACCESwith a one-line warning. Write access to a directory is refused the wayopen(2)refuses it, withEISDIRrather than a readable descriptor or anEACCES:/dev/bus/usb, the per-bus directories and/sys/bus/usb/devicesall answer alike, while a create under the read-only/systree still answersEACCES. 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/devicesentries are real directories rather than symlinks into/sys/devices/..., sorealpath()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 indocs/internals.md:/dev/bus/usbdevice nodes are placeholder files, sogetdents64reports them withd_typeDT_REGwhere Linux reportsDT_CHR.stat()is correct -- the intercept fillsS_IFCHRwith major 189 and the right minor, which is what libusb, nusb andlsusbconsult -- so only a scanner that filters ond_typealone and never callsstatwould skip them. No such consumer is known, and creating real character devices needs privileges elfuse does not ask for.statfson/dev/busagrees withfstatfsbut reports the scratch filesystem's magic instead ofDEVTMPFS_MAGIC.configurationis 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'siConfiguration, 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: anIOUSBHostDeviceentry publishesiManufacturer,iProductandiSerialNumbertogether with the three strings the family caches for them (USB Vendor Name,USB Product Name,kUSBSerialNumberString), but noiConfigurationindex 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_showemits nothing wheneveractconfig->stringis NULL (sysfs.c:83-84), andusb_cache_stringis documented to return NULL "if the index is 0 or the string could not be read" (message.c:1074-1075). An emptyconfigurationon Linux therefore means "no cached string", which is strictly weaker than "iConfigurationis 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 whoseiConfigurationis 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 asubsystemlink now reports the directory it resolves to andlstat()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/usband the/sysview at once. Only a failed allocation stops the walk, and it says so.O_PATHdirfds. AnO_PATHdescriptor on a non-directory answersENOTDIRfor a relative name resolved against it, asopenat(2)does. The empty path is excluded from that rule, sofstat()of such a descriptor still reports the object it names.Link and plumbing. The Makefile adds
-framework IOKit -framework CoreFoundationand the new source.path.cgates claim/sys(minus the existing syscpu stub) and/dev/busfor the open and stat intercepts;openat/fstatat/readlinkaton synthetic paths resolve throughpath_rebase_hostdirfdand are re-offered to the intercepts, which is what keeps systemdchase()'s per-component walk on the synthetic tree.usb_lockis documented as a leaf.Tests. New
tests/test-usb-sysfs.cwith its ownmake checklane, in two halves. The first needs no device at all (SYSFS_MAGICfromstatfsandfstatfsalike, the read-only open contract, the..fold, theENOENT/EINVAL/ENOTDIRanswers), 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 unfoldedst_ino. The descriptor blob is device-supplied, so the walks over it live in a pure leaf unit,src/runtime/usb-desc.c, withtests/test-usb-desc-host.ccovering the malformed cases natively on the host: abLengthpast the end of the buffer, a zerobLength, a truncated trailing descriptor, awTotalLengthlonger than the blob, and awTotalLengthshorter 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 wherebConfigurationValuedoes. 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_lenbelow the header size;iConfiguration0 with and without a string offered (one offered at index 0 is dropped, becauseusb_cache_stringshort-circuits before any transfer);iConfigurationnon-zero with the string unreadable (NULL and""alike);iConfigurationnon-zero with the string readable, which is the branch no attached device can reach; a string longer than the kernel'sMAX_USB_STRING_SIZE, which is truncated in its own tail and never in the trailing newlinesysfs_emitalways 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, andtest-usb-sysfs.ccovers that against whatever is attached.Three of the device-half assertions were rewritten because they could not fail.
bmAttributes,bMaxPowerandconfigurationare now checked against the device's owndescriptorsblob --strtol's endptr againstcfg[7], the power string againstcfg[8]scaled by the speed's unit, andconfigurationempty wheneveriConfigurationis 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 wrongbmAttributesbyte, swaps the power unit and writes a non-emptyconfigurationpasses 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 thetmpcomponent 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 byst_dev/st_inoagainst/sys/busand/sysrather 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'sgetdentslisting 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 frommkstemp, 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 waylinux_usbfs.cdoes and then byte-compares the sysfs blob against the/dev/bus/usbnode:The hub enumerates too (
1-1, Fresco Logic 1d5c:5801,MATCH), trimmed here for length.The five attributes
lsusb -twarns about, plusbMaxPower, read back on both devices:Those are not free-standing values; they are the configuration descriptor decoded. Bytes 18..26 of
2-1/descriptorsare the configuration descriptor itself:bNumInterfacesis the03in that same descriptor, and three:1.Ndirectories exist.configurationbeing 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.maxchildis 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:
189:128 is
(bus-1)*128 + (dev-1)for bus 2 device 1, andueventagrees (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-sysfsmoved onto the sanitizer lane throughCHECK_SHARED_LANESrather thantests/manifest.txt:.ci/check-matrix-lists.shrejects a manifest binary the matrix never runs, confirmed by adding it and watching the gate fail. It belongs there because this layer composes adev_tby 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:
(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: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:Syscall 264 is
name_to_handle_at, which libudev uses to decide whether/sysis 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 fromlsusbitself. 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 -tlsusb -tlists devices without the bus rows above them. The README feature line now says so, and no longer claims the tree is enough forlsusbat all: a udev-backedlsusbneedsname_to_handle_atas well, which is not implemented yet, so the bullet promises libusb-style enumeration and names both gaps.lsusb -tno 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/devicesitself and roots the tree at theusbNroot-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:usbNentries occupyDevice 001on each bus on Linux, so real devices would renumber to 002 and up, moving their/dev/bus/usbpaths, and the per-interfacedriversymlinks would then have to agree with whatGETDRIVERreports. 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.