Skip to content

Commit 28899ad

Browse files
beriberikixclaude
andcommitted
Refuse to bind devices something else already owns
bind allow-listed anything and only surfaced the problem when a transfer failed later, on a machine the user had stopped watching. It now reads ownership from the IORegistry first and refuses with an explanation naming the owner. The distinction between a kernel driver and a userspace holder is kept, because the remedies differ: quitting an app frees a device a process holds, while a kernel driver needs a DriverKit entitlement Apple has to grant. Reporting both as "kernel driver" would send users after an entitlement that cannot help them. Claimability is decided per interface. A first attempt walked the whole subtree below the device and swept up AppleUSBHostDeviceUserClient — a transient device-level handle that appears whenever any process opens the device, including this project's own probe — so a J-Link that opens perfectly well reported as taken. Two bugs found on the way. A `defer` inside the service loop captured the loop's `var`, which had already advanced to the next entry by the time the defer ran, so each iteration released the service the next iteration was about to read; only the first device ever returned properties. And BindCommand re-wrapped CommandHandlerError in CommandHandlerError, producing "Device binding failed: Device binding failed: ...". Verified against hardware: the J-Link binds and still moves bulk traffic end to end, the keyboard is refused. The bind success message no longer hedges about transfers possibly failing later, because they no longer can for this reason. An integration test asserted that whatever device happened to be first on the host would bind. It now asserts the outcome that matches that device's ownership, so it no longer depends on what is plugged in. 397 tests pass, lint clean, clean under CI's flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent 76fa7f1 commit 28899ad

8 files changed

Lines changed: 432 additions & 24 deletions

File tree

Documentation/development/driver-free-release.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ driver, which would send a reader after a DriverKit entitlement that could not h
7878

7979
## Work required
8080

81-
### 1. Detect and refuse driver-bound devices with a clear error
81+
### 1. Detect and refuse driver-bound devices with a clear error — DONE
82+
83+
`bind` now determines ownership up front and refuses with an explanation naming the
84+
owner, distinguishing a kernel driver from a userspace holder. Verified against
85+
hardware: a J-Link binds, a keyboard is refused with
86+
`macOS has bound a driver to it (AppleUserHIDDevice)`.
87+
88+
The original description follows.
8289

8390
Today `bind` allow-lists anything and only discovers the problem when a transfer fails.
8491
The daemon should determine ownership up front, using the logic already proven in
@@ -98,7 +105,12 @@ this device open" is actionable in a way "a kernel driver owns it" is not.
98105
allow-list with an explicit warning. Refusing is preferable: silently allow-listing a
99106
device that cannot be served is what the previous behaviour did.
100107

101-
### 2. README
108+
### 2. README — DONE
109+
110+
Replaced with the measured split: which classes work, which do not, that seizing was
111+
measured and does not work, and a pointer at the harness.
112+
113+
### 2b. README (original description)
102114

103115
Replace the blanket warning with the split. State plainly which classes work now, that
104116
the DriverKit entitlement gates the rest, and point at

README.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
> [!WARNING]
2-
> Sharing a USB device that macOS has already bound to an in-kernel driver requires the
3-
> DriverKit USB transport entitlements (`com.apple.developer.driverkit`,
4-
> `com.apple.developer.driverkit.transport.usb`). Those are managed capabilities that
5-
> Apple must approve; our request has been pending since August 23rd, 2025.
1+
> [!IMPORTANT]
2+
> **Which devices work:** those macOS has not bound a driver to. Debug probes
3+
> (J-Link, ST-Link, CMSIS-DAP), boards in DFU or bootloader mode, and vendor-specific
4+
> interfaces generally. These need no entitlement and no System Extension — a J-Link
5+
> has been driven end to end from a Linux client with probe-rs.
66
>
7-
> This is *not* the App Sandbox entitlement `com.apple.security.device.usb`, which is
8-
> freely usable and does not grant this capability — see
7+
> **Which do not:** anything macOS claims — USB-serial adapters, HID, mass storage,
8+
> audio, cameras. `bind` refuses these with an explanation rather than failing later.
9+
> Releasing them needs the DriverKit USB transport entitlements
10+
> (`com.apple.developer.driverkit`, `com.apple.developer.driverkit.transport.usb`),
11+
> which Apple must approve; our request has been pending since August 23rd, 2025.
12+
>
13+
> Seizing the interface was measured and does **not** work, and neither unmounting nor
14+
> ejecting a device releases it. There is no workaround short of that entitlement.
15+
>
16+
> Note this is *not* the App Sandbox entitlement `com.apple.security.device.usb`, which
17+
> is freely usable and grants nothing here — see
918
> [Documentation/development/entitlement-validation.md](Documentation/development/entitlement-validation.md).
10-
> Run `./Scripts/validate-usb-entitlements.sh` to measure which of your devices are
11-
> blocked and which are already usable.
19+
> Run `./Scripts/validate-usb-entitlements.sh` to check your own hardware.
1220
1321
# usbipd-mac
1422

Sources/Common/IOKitInterface.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ public protocol IOKitInterface {
1818

1919
// Property access
2020
func registryEntryCreateCFProperty(_ entry: io_registry_entry_t, _ key: CFString, _ allocator: CFAllocator?, _ options: IOOptionBits) -> Unmanaged<CFTypeRef>?
21+
22+
/// Children of a registry entry in the given plane. Needed to see which drivers
23+
/// have matched against a device, which is how device ownership is determined.
24+
func registryEntryGetChildIterator(_ entry: io_registry_entry_t, _ plane: String, _ iterator: UnsafeMutablePointer<io_iterator_t>) -> kern_return_t
25+
26+
/// The IOKit class name of a service, e.g. "IOUSBMassStorageDriver".
27+
func objectCopyClass(_ object: io_object_t) -> String?
2128

2229
// Notification system
2330
func notificationPortCreate(_ mainPort: mach_port_t) -> IONotificationPortRef?
@@ -49,6 +56,15 @@ public class RealIOKitInterface: IOKitInterface {
4956
return IOObjectRelease(object)
5057
}
5158

59+
public func registryEntryGetChildIterator(_ entry: io_registry_entry_t, _ plane: String, _ iterator: UnsafeMutablePointer<io_iterator_t>) -> kern_return_t {
60+
return IORegistryEntryGetChildIterator(entry, plane, iterator)
61+
}
62+
63+
public func objectCopyClass(_ object: io_object_t) -> String? {
64+
guard let name = IOObjectCopyClass(object) else { return nil }
65+
return name.takeRetainedValue() as String
66+
}
67+
5268
public func registryEntryCreateCFProperty(_ entry: io_registry_entry_t, _ key: CFString, _ allocator: CFAllocator?, _ options: IOOptionBits) -> Unmanaged<CFTypeRef>? {
5369
return IORegistryEntryCreateCFProperty(entry, key, allocator, options)
5470
}

Sources/USBIPDCLI/Commands.swift

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,16 @@ public class BindCommand: Command {
165165
private let deviceDiscovery: DeviceDiscovery
166166
private let serverConfig: ServerConfig
167167
private let systemExtensionManager: SystemExtensionManager?
168-
169-
public init(deviceDiscovery: DeviceDiscovery, serverConfig: ServerConfig, systemExtensionManager: SystemExtensionManager? = nil) {
168+
private let ownershipInspector: DeviceOwnershipInspector
169+
170+
public init(deviceDiscovery: DeviceDiscovery,
171+
serverConfig: ServerConfig,
172+
systemExtensionManager: SystemExtensionManager? = nil,
173+
ownershipInspector: DeviceOwnershipInspector = DeviceOwnershipInspector()) {
170174
self.deviceDiscovery = deviceDiscovery
171175
self.serverConfig = serverConfig
172176
self.systemExtensionManager = systemExtensionManager
177+
self.ownershipInspector = ownershipInspector
173178
}
174179

175180
public func execute(with arguments: [String]) throws {
@@ -224,7 +229,51 @@ public class BindCommand: Command {
224229
])
225230

226231
let deviceIdentifier = "\(device.busID)-\(device.deviceID)"
227-
232+
233+
// Refuse devices something else already owns, before promising to share
234+
// them. This used to allow-list anything and only surface the problem when
235+
// a transfer failed, on a machine the user was no longer looking at.
236+
switch ownershipInspector.ownership(vendorID: device.vendorID, productID: device.productID) {
237+
case .unbound:
238+
break
239+
240+
case .kernelDriver(let drivers):
241+
let names = drivers.joined(separator: ", ")
242+
logger.error("Refusing to bind a driver-bound device", context: [
243+
"busid": busid,
244+
"drivers": names
245+
])
246+
// Printed rather than folded into the thrown error: the error string is
247+
// also logged as context, and a multi-line message there is unreadable.
248+
print("""
249+
Cannot share \(busid): macOS has bound a driver to it (\(names)).
250+
251+
Releasing it needs a DriverKit entitlement Apple has to grant.
252+
Seizing the interface was measured and does not work, and neither
253+
unmounting nor ejecting releases it.
254+
255+
Devices macOS does not claim — debug probes, boards in DFU mode,
256+
vendor-specific interfaces — work today. Check a specific device
257+
with ./Scripts/validate-usb-entitlements.sh
258+
""")
259+
throw CommandHandlerError.deviceBindingFailed("\(busid) is owned by \(names)")
260+
261+
case .userspaceProcess(let clients):
262+
let names = clients.joined(separator: ", ")
263+
logger.error("Refusing to bind a device held by another process", context: [
264+
"busid": busid,
265+
"clients": names
266+
])
267+
print("""
268+
Cannot share \(busid): another process has it open (\(names)).
269+
270+
Quit whatever is using the device — a camera or audio app, a
271+
browser tab holding it over WebUSB — and bind again. Unlike a
272+
kernel driver, this needs no entitlement.
273+
""")
274+
throw CommandHandlerError.deviceBindingFailed("\(busid) is open in another process")
275+
}
276+
228277
// Step 1: Validate System Extension is available and ready
229278
print("Checking System Extension status...")
230279
if let extensionManager = systemExtensionManager {
@@ -301,13 +350,18 @@ public class BindCommand: Command {
301350
// is unbound and the "claim" succeeds without claiming. Saying the
302351
// device is under exclusive control would be reporting an outcome that
303352
// was never verified.
304-
print("Registered for USB/IP sharing. Exclusive claiming is not verified:")
305-
print(" devices with no kernel driver bound are servable regardless;")
306-
print(" devices macOS has bound to a driver may still fail at transfer time.")
353+
// bind now refuses driver-bound and process-held devices up front, so
354+
// reaching this point means nothing else owns the interface. The old
355+
// hedge about transfers possibly failing later no longer applies.
356+
print("Registered for USB/IP sharing. No driver or process holds this device.")
307357
} else {
308358
print("Allow-listed device \(identity)")
309359
print("Not claimed: the System Extension is not active. See the warning above.")
310360
}
361+
} catch let handlerError as CommandHandlerError {
362+
// Already carries its own message. Re-wrapping produced the doubled
363+
// "Device binding failed: Device binding failed: ..." prefix.
364+
throw handlerError
311365
} catch let deviceError as DeviceDiscoveryError {
312366
logger.error("Device discovery error during bind", context: ["error": deviceError.localizedDescription])
313367
throw CommandHandlerError.deviceBindingFailed(deviceError.localizedDescription)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// DeviceOwnership.swift
2+
// Determines whether anything already owns a USB device, before we promise to share it.
3+
4+
import Foundation
5+
import IOKit
6+
import Common
7+
8+
/// Who currently holds a device's interfaces.
9+
public enum DeviceOwnership: Equatable {
10+
/// Nothing has matched against the interfaces. This is the servable case.
11+
case unbound
12+
13+
/// A kernel driver owns at least one interface. Releasing it needs DriverKit
14+
/// rebinding, which requires an entitlement Apple has to grant — measured on
15+
/// 2026-08-06, `USBInterfaceOpenSeize` does not help.
16+
case kernelDriver(drivers: [String])
17+
18+
/// A userspace process holds the interfaces open. Blocks a claim just as firmly,
19+
/// but quitting that process frees the device, so the two must not be reported
20+
/// alike — one is a dead end, the other is a thing the user can act on.
21+
case userspaceProcess(clients: [String])
22+
23+
public var isServable: Bool {
24+
if case .unbound = self { return true }
25+
return false
26+
}
27+
}
28+
29+
/// Structural USB nodes. Their presence says nothing about whether a function driver
30+
/// claimed anything — every device has them.
31+
private let structuralClasses: Set<String> = [
32+
"IOUSBHostDevice",
33+
"IOUSBDevice",
34+
"IOUSBHostInterface",
35+
"IOUSBInterface",
36+
"IOUSBHostLegacyClient",
37+
"AppleUSBHostLegacyClient",
38+
"IOUSBHostLegacyDevice",
39+
"IOUSBHostLegacyInterface",
40+
// Creates the interface nodes; structural, not a claim.
41+
"AppleUSBHostCompositeDevice",
42+
// A transient device-level handle that appears whenever any process opens the
43+
// device — including this project's own probe. It says nothing about whether an
44+
// interface can be claimed, and treating it as an owner made a device that opens
45+
// perfectly well report as taken.
46+
"AppleUSBHostDeviceUserClient",
47+
"IOUSBHostDeviceUserClient"
48+
]
49+
50+
/// Userspace client connections — libusb, WebUSB, a framework daemon. Not kernel
51+
/// drivers. Counting them as such made a fully usable device look kernel-owned during
52+
/// harness development, and pointed at DriverKit when the fix was to quit an app.
53+
private let userspaceClientClasses: Set<String> = [
54+
"AppleUSBHostFrameworkInterfaceClient",
55+
"IOUSBHostInterfaceUserClient",
56+
"AppleUSBHostFrameworkDeviceClient"
57+
]
58+
59+
/// Reads device ownership out of the IORegistry.
60+
public struct DeviceOwnershipInspector {
61+
private let ioKit: IOKitInterface
62+
63+
public init(ioKit: IOKitInterface = RealIOKitInterface()) {
64+
self.ioKit = ioKit
65+
}
66+
67+
/// Determine who owns the device with this vendor and product ID.
68+
///
69+
/// Returns `.unbound` when the device cannot be found: absent evidence of an
70+
/// owner, refusing to bind would be worse than letting the attempt proceed and
71+
/// fail with a real error.
72+
public func ownership(vendorID: UInt16, productID: UInt16) -> DeviceOwnership {
73+
guard let matching = ioKit.serviceMatching("IOUSBHostDevice") else {
74+
return .unbound
75+
}
76+
77+
var iterator: io_iterator_t = 0
78+
guard ioKit.serviceGetMatchingServices(kIOMasterPortDefault, matching, &iterator) == KERN_SUCCESS else {
79+
return .unbound
80+
}
81+
defer { _ = ioKit.objectRelease(iterator) }
82+
83+
// Bind the current service to a `let` before the defer. A `defer` capturing the
84+
// loop's `var` releases whatever it holds when the iteration ends — by which
85+
// point it has already been reassigned to the next service, so each pass freed
86+
// the entry the next pass was about to read.
87+
while true {
88+
let service = ioKit.iteratorNext(iterator)
89+
guard service != 0 else { break }
90+
defer { _ = ioKit.objectRelease(service) }
91+
92+
if intProperty(service, "idVendor") == Int(vendorID),
93+
intProperty(service, "idProduct") == Int(productID) {
94+
return classify(collectClaimants(of: service))
95+
}
96+
}
97+
98+
return .unbound
99+
}
100+
101+
/// Drivers matched against the device's interfaces.
102+
///
103+
/// Claimability is decided per interface, not for the device as a whole: a device
104+
/// can be opened while its interface is held by someone else, and it is the
105+
/// interface that a transfer needs. Collecting every class in the subtree instead
106+
/// swept up unrelated nodes and reported servable hardware as owned.
107+
private func collectClaimants(of deviceEntry: io_registry_entry_t) -> [String] {
108+
var claimants: [String] = []
109+
for interfaceEntry in interfaceNodes(under: deviceEntry) {
110+
defer { _ = ioKit.objectRelease(interfaceEntry) }
111+
claimants.append(contentsOf: immediateDrivers(of: interfaceEntry))
112+
}
113+
return claimants
114+
}
115+
116+
/// Interface nodes below a device, wherever the composite driver put them.
117+
private func interfaceNodes(under entry: io_registry_entry_t, depth: Int = 0) -> [io_registry_entry_t] {
118+
guard depth < 4 else { return [] }
119+
120+
var iterator: io_iterator_t = 0
121+
guard ioKit.registryEntryGetChildIterator(entry, kIOServicePlane, &iterator) == KERN_SUCCESS else {
122+
return []
123+
}
124+
defer { _ = ioKit.objectRelease(iterator) }
125+
126+
var interfaces: [io_registry_entry_t] = []
127+
while true {
128+
let child = ioKit.iteratorNext(iterator)
129+
guard child != 0 else { break }
130+
131+
let className = ioKit.objectCopyClass(child) ?? ""
132+
if className == "IOUSBHostInterface" || className == "IOUSBInterface" {
133+
interfaces.append(child) // released by the caller
134+
} else {
135+
interfaces.append(contentsOf: interfaceNodes(under: child, depth: depth + 1))
136+
_ = ioKit.objectRelease(child)
137+
}
138+
}
139+
return interfaces
140+
}
141+
142+
/// Immediate driver children of one interface — what actually holds it.
143+
private func immediateDrivers(of interfaceEntry: io_registry_entry_t) -> [String] {
144+
var iterator: io_iterator_t = 0
145+
guard ioKit.registryEntryGetChildIterator(interfaceEntry, kIOServicePlane, &iterator) == KERN_SUCCESS else {
146+
return []
147+
}
148+
defer { _ = ioKit.objectRelease(iterator) }
149+
150+
var drivers: [String] = []
151+
while true {
152+
let child = ioKit.iteratorNext(iterator)
153+
guard child != 0 else { break }
154+
defer { _ = ioKit.objectRelease(child) }
155+
156+
if let className = ioKit.objectCopyClass(child), !structuralClasses.contains(className) {
157+
drivers.append(className)
158+
}
159+
}
160+
return drivers
161+
}
162+
163+
private func classify(_ claimants: [String]) -> DeviceOwnership {
164+
guard !claimants.isEmpty else { return .unbound }
165+
166+
let unique = Array(Set(claimants)).sorted()
167+
let kernelDrivers = unique.filter { !userspaceClientClasses.contains($0) }
168+
169+
// A device can have both — a webcam's audio control interface is kernel-owned
170+
// while its video interfaces are held by the camera framework. A kernel driver
171+
// is the harder blocker, so it decides the verdict.
172+
if kernelDrivers.isEmpty {
173+
return .userspaceProcess(clients: unique)
174+
}
175+
return .kernelDriver(drivers: kernelDrivers)
176+
}
177+
178+
private func intProperty(_ service: io_service_t, _ key: String) -> Int? {
179+
guard let ref = ioKit.registryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0) else {
180+
return nil
181+
}
182+
return (ref.takeRetainedValue() as? NSNumber)?.intValue
183+
}
184+
}

0 commit comments

Comments
 (0)