You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In #310, discussing the serial ioctl work, the maintainer asked for something wider (comment):
I wonder if there is a way to avoid the VM entirely by translating the Linux USB interface into its macOS counterpart. In other words, rather than limiting the approach to USB serial devices, could we provide a more general USB compatibility/translation layer that exposes arbitrary Linux USB device interfaces to macOS applications?
The named scenario is embedded development: let probe-rs flash and debug real hardware from inside elfuse, skipping the VM plus VZUSBPassthroughDevice route entirely. Demand outside that niche is old and unmet: docker/for-mac#900 (serial/USB device access from Linux containers on macOS) has been open since November 2016 and carries 239 upvotes, because a VMM needs hypervisor-level USB forwarding that macOS never offered. elfuse is positioned differently: guest syscalls already execute in a host process that can call IOKit directly, so there is no VMM device model and no guest driver stack to feed.
This issue proposes the layer, shows what a working prototype does on real hardware, and asks for a decision on scope and PR slicing before anything is submitted.
What the hardware allows
Probed on an ESP32-S3 (VID 0x303a, PID 0x1001, a CDC + vendor-JTAG composite) from an unentitled, ad-hoc-signed, non-root process on macOS 15.6:
Interface
Bound macOS driver
USBInterfaceOpen result
0, CDC control (class 0x02)
AppleUSBACMControl
kIOReturnExclusiveAccess, always
1, CDC data (class 0x0a)
AppleUSBACMData + IOSerialBSDClient
succeeds while nobody holds /dev/cu.usbmodem*; ExclusiveAccess once someone does
2, vendor JTAG (class 0xff)
none
succeeds; both bulk pipes usable (0x02 OUT, 0x83 IN, mps 64)
USBDeviceOpen, GetConfigurationDescriptorPtr (no open needed) and ep0 GET_DESCRIPTOR control requests all succeed non-root. Driver detach via USBDeviceReEnumerate(kUSBReEnumerateCaptureDeviceMask) as non-root returns success and does nothing: the registry ID stays the same and the driver stays bound. libusb reaches the same verdict by refusing outright without root or the com.apple.vm.device-access entitlement (darwin_usb.c, darwin_detach_kernel_driver), and usbipd-darwin gates its capture on geteuid() == 0.
Conclusion: vendor/bulk devices are fully usable unprivileged. That covers the mainstream probe-rs transports (CMSIS-DAP v2, ST-Link, J-Link, WCH-Link, pure DFU devices, the ESP32 USB-JTAG interface). Class devices (CDC, HID, Mass Storage) sit behind macOS drivers; the escape hatches are root-mode whole-device capture, or the class interfaces macOS already exposes: CDC through the ttyACM/ttyUSB mapping (piece 3 of #310), HID through an IOHIDManager-backed /dev/hidraw* view. The layer and those side doors complement each other.
Minimum viable surface
Derived from reading what libusb (current git), nusb 0.2.7 (probe-rs' transport), hidapi and dfu-util actually call on Linux:
#
Surface
libusb
nusb
hidapi
dfu-util
1
/dev/bus/usb/BBB/DDD char node: open O_RDWR, read() returns device + all config descriptors, lseek
GETDRIVER: ENODATA when unbound, "usbfs" after claim
yes
yes
7
NETLINK_KOBJECT_UEVENT socket + SO_PASSCRED at init time
hard requirement
watch_devices only
via libusb
8
hidraw: /dev/hidrawN read/write/poll, HIDIOC* ioctls, /sys/class/hidraw tree
yes
Note
Row 7 is the sharpest edge: libusb_init() fails outright when it cannot create that netlink socket (linux_usbfs.c:416-430). elfuse today answers -EAFNOSUPPORT for anything except NETLINK_ROUTE (src/syscall/netlink.c:443-449 at ae46dbf), so every libusb program (dfu-util, pyusb, ...) currently dies inside elfuse before touching any device. Fixing this alone is a self-contained bug fix.
A new src/syscall/usbdev.c implements the usbdevfs ioctl set (semantics from drivers/usb/core/devio.c and include/uapi/linux/usbdevice_fs.h at v7.2) over IOUSBLib, following the mapping libusb's darwin backend already validates in production:
Linux operation
IOKit call
Gap to bridge
open(/dev/bus/usb/B/D)
plug-in for kIOUSBDeviceUserClientTypeID, then USBDeviceOpen
open can fail with ExclusiveAccess; libusb/nusb tolerate that and continue
read() descriptors
GetConfigurationDescriptorPtr (raw bytes, no open)
no raw device-descriptor getter; rebuild from registry properties
control URB / USBDEVFS_CONTROL
DeviceRequestTO / DeviceRequestAsyncTO
usable without open in practice
CLAIMINTERFACE
interface iterator, then USBInterfaceOpen
ExclusiveAccess when a macOS driver holds the interface; map to EBUSY, the errno Linux gives for a driver-bound interface
SETINTERFACE
SetAlternateInterface
pipe table must be rebuilt
SETCONFIGURATION
SetConfiguration
needs open; triggers driver matching
bulk URB
ReadPipeAsyncTO / WritePipeAsyncTO
endpoint address to pipeRef mapping per claimed interface
interrupt URB
ReadPipeAsync / WritePipeAsync
the TO variants reject interrupt pipes, so no host-side timeout
CLEAR_HALT
ClearPipeStallBothEnds
none
DISCARDURB
AbortPipe
aborts the whole pipe, no per-URB cancel; queue one URB per endpoint, report collateral kills as -ECONNRESET
RESET
USBDeviceReEnumerate(0)
tears down the user client; keep busnum/devnum stable and wait for re-attach (libusb allows 10 s)
DISCONNECT_CLAIM
USBDeviceReEnumerate(capture mask)
root only; non-root gets EACCES, and Mass Storage never detaches
Around that core: the /sys/bus/usb/devices and /dev/bus/usb trees are synthesized into the existing scratch-dir mechanism from IOKit registry properties, without opening any device, and statfs("/sys") reports SYSFS_MAGIC. URB data rides bounce buffers: SUBMITURB copies in, IOKit completes into host memory on a single CFRunLoop thread, REAPURB copies out on the vCPU thread, so no host thread ever touches guest memory. ppoll/pselect/epoll report a reapable URB as POLLOUT for this fd type, and disconnect as unmaskable POLLERR|POLLHUP. USB fds are process-local like FD_NETLINK today: after fork the child sees EBADF, the parent keeps working. Errno mapping from kIOReturn codes follows the darwin_usb.c table (kIOUSBPipeStalled to -EPIPE, kIOReturnAborted to -ENOENT after DISCARDURB, and so on).
The hardest semantic gaps and their treatment:
Cancel granularity. Linux kills one URB (usb_kill_urb); AbortPipe kills every URB on the pipe. Queueing one URB per endpoint inside elfuse trades throughput for correct DISCARDURB semantics, and libusb/nusb resubmit anything reported -ECONNRESET.
RESET. USBDeviceReEnumerate behaves like a replug. The synthetic tree keeps BUSNUM/DEVNUM stable, hides the registry ID change, and mimics libusb's wait for re-attach. A device whose descriptors changed (DFU after a firmware swap) gets -ENODEV, which matches the Linux kernel's usb_reset_device behavior.
Timeouts. usbfs URBs carry none (the guest issues DISCARDURB), so only the synchronous control/bulk paths use the TO variants and map to -ETIMEDOUT.
ZLP and URB splitting. Advertising NO_PACKET_SIZE_LIM plus SCATTER_GATHER keeps libusb from splitting into 16 KiB URBs, so BULK_CONTINUATION never triggers; ZERO_PACKET is emulated with a trailing zero-length write, as libusb's darwin backend does.
x86_64 guests under Rosetta share the arm64 dispatch: the usbdevfs structs are identical on both LP64 ABIs.
The two projects linked in #310 confirm the boundary from the other side. usb-macos-vm documents that VZUSBPassthroughDevice needs a profile-backed paid entitlement and captures whole devices into a VZ XHCI controller. usbipd-darwin implements root-mode capture with the same ReEnumerate mask the follow-up stage would use, and its capture code is a ready reference for it.
Working prototype
Stages 0 through 3 exist on the fork branch usb-layer, staged as reviewable pieces in jotpalch#4 through jotpalch#7; the upstream PRs will be cut from those. Verified against the ESP32-S3, output condensed:
$ build/elfuse --timeout 0 --sysroot sysroot-debian /usr/bin/lsusb
Bus 002 Device 001: ID 303a:1001 Espressif USB JTAG/serial debug unit
$ build/elfuse --timeout 0 ./usbfs-async 2 1
STEP CLAIMINTERFACE_2 = 0 (0 ok)
reaped urb=0x7ffec00 (submitted 0x7ffec00) status=0 actual_length=18
STEP DISCARDURB = 0 (0 ok)
reaped urb=0x7ffec38 (submitted 0x7ffec38) status=-2 (-ENOENT=-2) actual_length=0
second: CLAIMINTERFACE iface2 = -1 (errno 16 Device or resource busy)
Process 15833: 0 leaks for 0 total leaked bytes.
The first command is a stock Debian lsusb binary run unmodified through libusb; lsusb -v prints the full descriptor tree, and a raw-descriptor test confirms the synthetic sysfs descriptors file is byte-identical to what read() on the device node returns, an invariant the Linux kernel guarantees and the tools rely on. The async test claims the vendor interface, reaps an 18-byte control URB, and gets the Linux-correct -ENOENT status for a discarded bulk URB. The EBUSY line is a second elfuse process losing the claim race; the leaks line is leaks --atExit over an open/claim/URB/close loop. A fork test confirms the child gets EBADF while the parent's fd keeps working. make check, tests/driver.sh (77/77) and the elfuse-aarch64 lane of tests/test-matrix.sh (256 passed, 0 failed) are green on the branch.
Proposed PR series
PR
Content
Size
Acceptance
A
accept NETLINK_KOBJECT_UEVENT as a silent socket, SOL_SOCKET options on netlink fds
~350 lines
libusb_init() returns 0 (today -99)
B
synthetic /sys/bus/usb + /dev/bus/usb from the IOKit registry, SYSFS_MAGIC, links IOKit/CoreFoundation
follow-ups, each its own issue: hidraw over IOHID (CMSIS-DAP v1), root-mode capture, hotplug uevents, RESET re-enumeration, dup
per issue
Sizes are measured from the prototype. Each PR stands alone and is testable without the later ones, and the series will be submitted strictly serially: one PR at a time, the next opened only after the previous one merges, never as simultaneous stacked PRs. CI has no USB hardware and IOKit has no loopback, so tree shape, ioctl validation and errno semantics run in the normal test matrix, while transfer tests are gated on an env var naming the expected VID:PID and skip with a reason, following the silent-skip policy from #38.
Non-goals
Mass Storage raw access: macOS never detaches MSC interfaces, capture mask or not.
Isochronous URBs: a different frame-scheduling model, and no consumer in scope needs them.
USB/IP: the protocol terminates in a guest kernel's vhci_hcd, which elfuse does not have.
VZUSBPassthroughDevice (macOS 27): needs a paid, profile-backed entitlement, assignment is manual per device, and it feeds a Virtualization.framework XHCI controller, so an in-process translator cannot consume it.
Open questions
Non-root behavior for class interfaces: should CLAIMINTERFACE return EBUSY (matching Linux when a driver holds the interface), or should the synthetic tree omit interfaces macOS drivers fully own (clearer tool error messages)? I lean EBUSY.
Is root-mode capture in scope? It mutates host state (the device leaves macOS until release or replug), which sits oddly with elfuse's process-scoped, no-daemon design. If accepted, elfuse should auto-release on exit.
USB fds after fork: EBADF like FD_NETLINK/FD_INOTIFY today (the prototype does this), or a broker handoff? dfu-util and esptool wrappers fork before open, and probe-rs' gdb server does not share handles, so EBADF looks sufficient until a real tool disagrees.
Hotplug: start with no events plus a tree rescan on each libusb_get_device_list (libusb semantics allow it), or wire IOKit notifications into synthetic uevents from day one? I lean rescan first.
PR B adds -framework IOKit -framework CoreFoundation to the link line, which today carries only Hypervisor. Flagging early in case that needs discussion.
Moved from jotpalch/elfuse#8 at @jserv's request.
Motivation
In #310, discussing the serial ioctl work, the maintainer asked for something wider (comment):
The named scenario is embedded development: let probe-rs flash and debug real hardware from inside elfuse, skipping the VM plus
VZUSBPassthroughDeviceroute entirely. Demand outside that niche is old and unmet: docker/for-mac#900 (serial/USB device access from Linux containers on macOS) has been open since November 2016 and carries 239 upvotes, because a VMM needs hypervisor-level USB forwarding that macOS never offered. elfuse is positioned differently: guest syscalls already execute in a host process that can call IOKit directly, so there is no VMM device model and no guest driver stack to feed.This issue proposes the layer, shows what a working prototype does on real hardware, and asks for a decision on scope and PR slicing before anything is submitted.
What the hardware allows
Probed on an ESP32-S3 (VID 0x303a, PID 0x1001, a CDC + vendor-JTAG composite) from an unentitled, ad-hoc-signed, non-root process on macOS 15.6:
USBInterfaceOpenresultkIOReturnExclusiveAccess, always/dev/cu.usbmodem*;ExclusiveAccessonce someone doesUSBDeviceOpen,GetConfigurationDescriptorPtr(no open needed) and ep0GET_DESCRIPTORcontrol requests all succeed non-root. Driver detach viaUSBDeviceReEnumerate(kUSBReEnumerateCaptureDeviceMask)as non-root returns success and does nothing: the registry ID stays the same and the driver stays bound. libusb reaches the same verdict by refusing outright without root or thecom.apple.vm.device-accessentitlement (darwin_usb.c,darwin_detach_kernel_driver), and usbipd-darwin gates its capture ongeteuid() == 0.Conclusion: vendor/bulk devices are fully usable unprivileged. That covers the mainstream probe-rs transports (CMSIS-DAP v2, ST-Link, J-Link, WCH-Link, pure DFU devices, the ESP32 USB-JTAG interface). Class devices (CDC, HID, Mass Storage) sit behind macOS drivers; the escape hatches are root-mode whole-device capture, or the class interfaces macOS already exposes: CDC through the ttyACM/ttyUSB mapping (piece 3 of #310), HID through an IOHIDManager-backed
/dev/hidraw*view. The layer and those side doors complement each other.Minimum viable surface
Derived from reading what libusb (current git), nusb 0.2.7 (probe-rs' transport), hidapi and dfu-util actually call on Linux:
/dev/bus/usb/BBB/DDDchar node:openO_RDWR,read()returns device + all config descriptors,lseekpollon the fd: POLLOUT when a completed URB is reapable, POLLERR/HUP on disconnect/sys/bus/usb/devices/<bus>-<port>tree: busnum, devnum, idVendor, idProduct, bcdDevice, speed, string attrs, binarydescriptors, interface subdirs; entries must surviverealpath;statfs("/sys")returns SYSFS_MAGICNETLINK_KOBJECT_UEVENTsocket + SO_PASSCRED at init timewatch_devicesonly/dev/hidrawNread/write/poll, HIDIOC* ioctls,/sys/class/hidrawtreeNote
Row 7 is the sharpest edge:
libusb_init()fails outright when it cannot create that netlink socket (linux_usbfs.c:416-430). elfuse today answers-EAFNOSUPPORTfor anything except NETLINK_ROUTE (src/syscall/netlink.c:443-449at ae46dbf), so every libusb program (dfu-util, pyusb, ...) currently dies inside elfuse before touching any device. Fixing this alone is a self-contained bug fix.Deliberately deferred: usbfs
mmap, streams, isochronous URBs, CONNECTINFO, udev-format netlink group 2, writablebConfigurationValue.Design sketch
A new
src/syscall/usbdev.cimplements the usbdevfs ioctl set (semantics fromdrivers/usb/core/devio.candinclude/uapi/linux/usbdevice_fs.hat v7.2) over IOUSBLib, following the mapping libusb's darwin backend already validates in production:open(/dev/bus/usb/B/D)kIOUSBDeviceUserClientTypeID, thenUSBDeviceOpenread()descriptorsGetConfigurationDescriptorPtr(raw bytes, no open)DeviceRequestTO/DeviceRequestAsyncTOUSBInterfaceOpenSetAlternateInterfaceSetConfigurationReadPipeAsyncTO/WritePipeAsyncTOReadPipeAsync/WritePipeAsyncClearPipeStallBothEndsAbortPipeUSBDeviceReEnumerate(0)USBDeviceReEnumerate(capture mask)Around that core: the
/sys/bus/usb/devicesand/dev/bus/usbtrees are synthesized into the existing scratch-dir mechanism from IOKit registry properties, without opening any device, andstatfs("/sys")reports SYSFS_MAGIC. URB data rides bounce buffers: SUBMITURB copies in, IOKit completes into host memory on a single CFRunLoop thread, REAPURB copies out on the vCPU thread, so no host thread ever touches guest memory.ppoll/pselect/epollreport a reapable URB as POLLOUT for this fd type, and disconnect as unmaskable POLLERR|POLLHUP. USB fds are process-local like FD_NETLINK today: after fork the child sees EBADF, the parent keeps working. Errno mapping from kIOReturn codes follows the darwin_usb.c table (kIOUSBPipeStalledto -EPIPE,kIOReturnAbortedto -ENOENT after DISCARDURB, and so on).The hardest semantic gaps and their treatment:
usb_kill_urb);AbortPipekills every URB on the pipe. Queueing one URB per endpoint inside elfuse trades throughput for correct DISCARDURB semantics, and libusb/nusb resubmit anything reported -ECONNRESET.USBDeviceReEnumeratebehaves like a replug. The synthetic tree keeps BUSNUM/DEVNUM stable, hides the registry ID change, and mimics libusb's wait for re-attach. A device whose descriptors changed (DFU after a firmware swap) gets -ENODEV, which matches the Linux kernel'susb_reset_devicebehavior.x86_64 guests under Rosetta share the arm64 dispatch: the usbdevfs structs are identical on both LP64 ABIs.
The two projects linked in #310 confirm the boundary from the other side. usb-macos-vm documents that
VZUSBPassthroughDeviceneeds a profile-backed paid entitlement and captures whole devices into a VZ XHCI controller. usbipd-darwin implements root-mode capture with the same ReEnumerate mask the follow-up stage would use, and its capture code is a ready reference for it.Working prototype
Stages 0 through 3 exist on the fork branch
usb-layer, staged as reviewable pieces in jotpalch#4 through jotpalch#7; the upstream PRs will be cut from those. Verified against the ESP32-S3, output condensed:The first command is a stock Debian
lsusbbinary run unmodified through libusb;lsusb -vprints the full descriptor tree, and a raw-descriptor test confirms the synthetic sysfsdescriptorsfile is byte-identical to whatread()on the device node returns, an invariant the Linux kernel guarantees and the tools rely on. The async test claims the vendor interface, reaps an 18-byte control URB, and gets the Linux-correct -ENOENT status for a discarded bulk URB. The EBUSY line is a second elfuse process losing the claim race; the leaks line isleaks --atExitover an open/claim/URB/close loop. A fork test confirms the child gets EBADF while the parent's fd keeps working.make check,tests/driver.sh(77/77) and theelfuse-aarch64lane oftests/test-matrix.sh(256 passed, 0 failed) are green on the branch.Proposed PR series
NETLINK_KOBJECT_UEVENTas a silent socket, SOL_SOCKET options on netlink fdslibusb_init()returns 0 (today -99)/sys/bus/usb+/dev/bus/usbfrom the IOKit registry, SYSFS_MAGIC, links IOKit/CoreFoundationlsusblists attached deviceslsusb -vfull tree; concurrent claim gets EBUSYSizes are measured from the prototype. Each PR stands alone and is testable without the later ones, and the series will be submitted strictly serially: one PR at a time, the next opened only after the previous one merges, never as simultaneous stacked PRs. CI has no USB hardware and IOKit has no loopback, so tree shape, ioctl validation and errno semantics run in the normal test matrix, while transfer tests are gated on an env var naming the expected VID:PID and skip with a reason, following the silent-skip policy from #38.
Non-goals
vhci_hcd, which elfuse does not have.VZUSBPassthroughDevice(macOS 27): needs a paid, profile-backed entitlement, assignment is manual per device, and it feeds a Virtualization.framework XHCI controller, so an in-process translator cannot consume it.Open questions
libusb_get_device_list(libusb semantics allow it), or wire IOKit notifications into synthetic uevents from day one? I lean rescan first.-framework IOKit -framework CoreFoundationto the link line, which today carries only Hypervisor. Flagging early in case that needs discussion.References
usb-layer, staged PRs Accept uevent netlink sockets as silent jotpalch/elfuse#4-Harden signalfd read semantics #7