Skip to content

Commit 1eab5b6

Browse files
beriberikixclaude
andcommitted
Refuse to bind a device that exposes no interfaces
`bind` accepted a VIA Labs 2109:8887 and reported that no driver or process held it. Every transfer then failed with "Interface 0 not found", because the device has no interfaces to open: macOS had never configured it, so no interface nodes exist in the registry. Its only child there was the user client of a browser driving it over WebUSB. The classifier could not tell the two cases apart. "No drivers attached to any interface" and "no interfaces at all" both reached it as an empty list, and both came back unbound. It now distinguishes them, and a device with nothing to open is refused with an explanation naming the likely cause, rather than accepted and left to fail on first use. Found by ./Scripts/verify-hardware.sh, which picks the first bindable non-hub device and had been choosing this one — so the failure was the first thing the script reported on this machine, and it said nothing about the build it was meant to be validating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent 88da2fe commit 1eab5b6

3 files changed

Lines changed: 55 additions & 1 deletion

File tree

Sources/USBIPDCLI/Commands.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,17 @@ public class BindCommand: Command {
239239
case .unbound:
240240
break
241241

242+
case .noInterfaces:
243+
// Nothing to open, so nothing can be served. Accepting the bind here
244+
// produced a device that looked shared and failed every transfer with
245+
// "Interface 0 not found".
246+
logger.info("Refusing to bind a device with no interfaces", context: ["busid": busid])
247+
print("Cannot share \(busid): the device exposes no USB interfaces.")
248+
print("macOS has not configured it, which usually means another process is")
249+
print("driving it directly — a browser tab using WebUSB, for instance. Quit")
250+
print("that program and try again.")
251+
throw CommandLineError.invalidArguments("Device \(busid) exposes no USB interfaces")
252+
242253
case .partiallyClaimed(let free, let claimedBy):
243254
// Some interfaces are owned and some are not. A composite debug probe
244255
// looks like this: CMSIS-DAP free, the CDC serial port taken by macOS.

Sources/USBIPDCore/Device/DeviceOwnership.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ public enum DeviceOwnership: Equatable {
2020
/// alike — one is a dead end, the other is a thing the user can act on.
2121
case userspaceProcess(clients: [String])
2222

23+
/// The device exposes no interfaces at all, so there is nothing to open.
24+
///
25+
/// Usually this means macOS has not configured it — no configuration is selected,
26+
/// so no interface nodes exist — which is common for devices another process is
27+
/// driving directly, such as a WebUSB page in a browser. Reporting it as free was
28+
/// worse than useless: `bind` accepted the device, announced that nothing held it,
29+
/// and then every transfer failed with "Interface 0 not found".
30+
case noInterfaces
31+
2332
/// Some interfaces are free and others are owned. Common on debug probes, which
2433
/// pair a vendor-specific debug interface with a CDC serial port that macOS claims:
2534
/// a Raspberry Pi Debug Probe has CMSIS-DAP free on interface 0 while
@@ -33,7 +42,7 @@ public enum DeviceOwnership: Equatable {
3342
switch self {
3443
case .unbound, .partiallyClaimed:
3544
return true
36-
case .kernelDriver, .userspaceProcess:
45+
case .kernelDriver, .userspaceProcess, .noInterfaces:
3746
return false
3847
}
3948
}
@@ -182,6 +191,11 @@ public struct DeviceOwnershipInspector {
182191
}
183192

184193
private func classify(_ perInterface: [(drivers: [String], opens: Bool)]) -> DeviceOwnership {
194+
// Distinguish "no interfaces exist" from "interfaces exist and nothing has
195+
// claimed them". Both used to arrive here with no driver names and both were
196+
// called unbound, so a device with nothing to open was offered for sharing.
197+
guard !perInterface.isEmpty else { return .noInterfaces }
198+
185199
let all = perInterface.flatMap { $0.drivers }
186200
guard !all.isEmpty else { return .unbound }
187201

Tests/USBIPDCoreTests/Device/DeviceOwnershipTests.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,33 @@ extension DeviceOwnershipTests {
115115
XCTAssertEqual(ownership, .kernelDriver(drivers: ["AppleUserHIDDevice"]))
116116
XCTAssertFalse(ownership.isServable)
117117
}
118+
119+
/// A device with no interface nodes has nothing to open, so it cannot be served.
120+
///
121+
/// This reported as unbound, because "no drivers attached" and "no interfaces at
122+
/// all" both arrived at the classifier as an empty list. `bind` accepted a VIA Labs
123+
/// device that macOS had never configured — its only registry child was the user
124+
/// client of a browser driving it over WebUSB — announced that nothing held it, and
125+
/// then every transfer failed with "Interface 0 not found".
126+
func testDeviceWithNoInterfacesIsNotServable() {
127+
let ioKit = MockIOKitInterface()
128+
ioKit.mockDevices = [MockUSBDevice(vendorID: 0x2109, productID: 0x8887)]
129+
ioKit.classNamesByEntry[deviceEntry] = "IOUSBHostDevice"
130+
// No interface children at all.
131+
ioKit.childrenByEntry[deviceEntry] = []
132+
133+
let ownership = DeviceOwnershipInspector(ioKit: ioKit)
134+
.ownership(vendorID: 0x2109, productID: 0x8887)
135+
136+
XCTAssertEqual(ownership, .noInterfaces)
137+
XCTAssertFalse(ownership.isServable)
138+
}
139+
140+
/// The distinction that makes the check above meaningful: an interface that exists
141+
/// and has no driver is still the servable case.
142+
func testInterfaceWithNoDriverIsStillServable() {
143+
let ownership = inspector(interfaceDrivers: []).ownership(vendorID: 0x1366, productID: 0x0101)
144+
XCTAssertEqual(ownership, .unbound)
145+
XCTAssertTrue(ownership.isServable)
146+
}
118147
}

0 commit comments

Comments
 (0)